jkunz d050df679b
Release / build-and-release (push) Successful in 21m45s
v32.6.3
2026-09-29 05:07:41 +00:00
2026-09-29 05:07:41 +00:00
2026-09-29 05:07:41 +00:00
2026-09-29 05:07:41 +00:00
2025-05-19 17:34:48 +00:00
2024-02-15 20:30:38 +01:00
2024-02-15 20:30:38 +01:00
2024-02-15 20:30:38 +01:00
2026-09-29 05:07:41 +00:00
2026-09-29 05:07:41 +00:00
2026-09-29 04:47:35 +00:00

@serve.zone/dcrouter

dcrouter is the serve.zone datacenter gateway runtime: a TypeScript control plane that brings HTTP/HTTPS/TCP routing, email ingress, authoritative DNS, RADIUS, VPN access control, remote ingress tunnels, certificate operations, metrics, and an Ops dashboard into one process.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Why It Exists

Modern infrastructure often has too many tiny edge tools: a proxy here, a DNS daemon there, a separate cert worker, another dashboard, and a tunnel process bolted on later. dcrouter is designed as a cohesive gateway layer for operators who want one audited place to define public routes, domains, edge tunnels, access policy, and operational state.

Highlights:

  • 🌐 SmartProxy-backed HTTP, HTTPS, TCP, TLS/SNI, source-policy rate limits/challenges, managed Special Forwards, and optional HTTP/3 route handling
  • 📬 SmartMTA-backed SMTP ingress, email-domain operations, and managed WorkApp SMTP credentials for gateway clients
  • 🧭 SmartDNS-backed authoritative DNS plus generated DNS-over-HTTPS routes
  • 🔐 ACME with managed-domain DNS-01 support, certificate state, API tokens, users, source profiles, and target profiles
  • 🛡️ RADIUS, VLAN assignment, VPN-protected routes, IP intelligence, block rules, and remote ingress firewall snapshots
  • 🖥️ Browser Ops dashboard and TypedRequest API served by the built-in OpsServer

Runtime Areas

Area What dcrouter manages
Proxying SmartProxy routes for HTTP, HTTPS, TCP, SNI, TLS termination, passthrough, backend forwarding, source policies, rate limits, and browser challenges
Route ownership Constructor routes, generated email/DNS routes, and API-created routes with explicit origins
DNS Delegation-verified authoritative zones, generated NS records, static DNS records, provider-backed domains, and DoH endpoints
Email UnifiedEmailServer startup, email-domain management, route-backed delivery actions, received mail operations, managed app address bindings, and outbound SMTP submission identities
Certificates ACME config, managed-domain DNS-01 challenges, isolated testing certificates, durable issuance recovery, provisioning backoff, and certificate status reporting
Edge access Remote ingress hub, edge registrations, derived edge ports, pushed firewall rules, VPN-only route access
Network auth RADIUS clients, MAC Authentication Bypass, VLAN mapping, and accounting sessions
Security policy DB-backed block rules, public-IP intelligence, compiled SmartProxy deny policy, RemoteIngress firewall snapshots, and audit events
Operations Dashboard views, TypedRequest handlers, metrics, logs, health, API tokens, users, and configuration views

Operational Metrics

The Ops Overview exposes a rolling 24-hour Email Traffic chart with one UTC-minute point for Sent, Received, and Failed. Sent and Failed represent terminal SmartMTA queue outcomes. Received represents successfully accepted inbound messages and is counted once per accepted SMTP envelope, including store-only delivery; rejected, aborted, outbound-authenticated, and failed-persistence paths are excluded.

With database persistence enabled, email minute buckets survive restarts and are retained for the rolling 24-hour window. The Security → Authentication view records admin login outcomes separately from the general security-event stream and reports authoritative success/failure totals for the last 24 hours. Authentication events retain only the normalized username, optional user ID, requested/resolved source, outcome, timestamp, and a generic failure classification; credentials, tokens, raw errors, and client IPs are not stored. Durable authentication history is retained for 90 days.

When the database is disabled, both views continue to operate from bounded in-process state, but their history resets on restart.

Install

Install the CLI/runtime on a Linux gateway host with the released self-extracting binary:

curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash

The installer downloads dcrouter-linux-x64 or dcrouter-linux-arm64 from the latest Gitea release, installs it under /opt/dcrouter, and links /usr/local/bin/dcrouter. Use --version vX.Y.Z to pin a release, --install-dir /path to change the target directory, or --source to clone the tag and build the NodeNext package locally.

curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source

Use the package as a TypeScript library:

pnpm add @serve.zone/dcrouter

Quick Start

This starts the gateway on unprivileged ports and stores data under the default ~/.serve.zone/dcrouter base directory.

import { DcRouter } from '@serve.zone/dcrouter';

const router = new DcRouter({
  coreTrafficConfig: {
    routes: [
      {
        name: 'local-app',
        match: {
          domains: ['localhost'],
          ports: [18080],
        },
        action: {
          type: 'forward',
          targets: [{ host: '127.0.0.1', port: 3001 }],
        },
      },
    ],
  },
  dbConfig: {
    enabled: true,
  },
  opsServerPort: 3000,
});

await router.start();

After startup:

  • open the dashboard at http://localhost:3000
  • complete the first-admin bootstrap flow if no persisted admin account exists yet
  • send proxied traffic to http://localhost:18080
  • stop gracefully with await router.stop()

Initial Admin Bootstrap

When DB-backed persistence is enabled and no persisted admin exists, dcrouter does not auto-create an admin account. The Ops dashboard exposes a non-cancelable first-admin bootstrap flow that must be completed explicitly.

Bootstrap behavior:

  • getAdminBootstrapStatus reports whether persistence is ready and whether a first admin is required.
  • The temporary env/config admin identity is only used to authorize bootstrap access while no persisted admin exists.
  • Set DCROUTER_ADMIN_PASSWORD to choose that temporary password yourself; nothing is then written to disk. Without it dcrouter generates one per start and writes it to <baseDir>/.bootstrap-admin-password (default ~/.serve.zone/dcrouter/) with mode 0600. Only the path is logged — the password itself is never written to the log, because every start would otherwise add a credential to a file that is kept, shipped and read casually.
  • createInitialAdminUser creates the first persisted admin with normalized email and local password authentication.
  • Optional idp.global authentication can be enabled for that local account. SDK 17 uses the hosted https://app.idp.global endpoint by default; adminAuth.idpGlobalUrl or DCROUTER_IDP_GLOBAL_URL can override it, and the local dcrouter role remains authoritative. The IdP listener must use TypedSocket 8 and TypedRequest 8 (TypedServer 11); coordinate this client upgrade with the provider rollout because earlier listener transport majors cannot complete its WebSocket handshake.
  • After a persisted admin exists, temporary bootstrap admin login is rejected and normal persisted-account authentication is used.

Configuration Model

DcRouter is configured with IDcRouterOptions from @serve.zone/dcrouter.

Option Purpose
baseDir Root directory for dcrouter runtime data. Defaults to ~/.serve.zone/dcrouter.
coreTrafficConfig Main CoreTraffic route configuration for HTTP/HTTPS/TCP/SNI traffic. smartProxyConfig remains accepted as a legacy alias.
emailConfig UnifiedEmailServer configuration: hostname, ports, domains, and mail routes.
emailOutboundMode Outbound SMTP mode. Defaults to remoteIngress; explicit direct mode lets unmanaged/static senders dial from the hub.
emailPortConfig External-to-internal email port mapping and received-email storage path.
tls Legacy/static TLS and ACME contact settings used to seed certificate config.
dnsNsDomains Nameserver hostnames used for generated NS records and DoH routes. A zone becomes authoritative by having its public NS records observed naming one of these.
dnsRecords Constructor-defined DNS records.
publicIp / proxyIps IPs used for generated A records and proxy-aware DNS exposure.
dbConfig Document persistence (@lossless.org/client/nosqldb) via embedded LocalSmartDb or external MongoDB.
radiusConfig RADIUS authentication, accounting, and VLAN assignment.
remoteIngressConfig Remote ingress hub configuration for edge tunnel nodes.
vpnConfig VPN server/client definitions and VPN-only routing behavior.
http3 HTTP/3 augmentation settings for qualifying HTTPS routes.
opsServerPort Port for the Ops dashboard and /typedrequest API. Defaults to 3000.

Important runtime behavior:

  • dbConfig.enabled defaults to enabled. Without mongoDbUrl, dcrouter uses embedded LocalSmartDb.
  • If the DB is disabled, constructor-defined proxy traffic can still run, but persistent API routes, tokens, managed domains, and stored certificate state are unavailable. The embedded DNS server is also skipped entirely, because DNS authority is delegation-verified database state — the DoH routes are still generated, but nothing answers behind them.
  • Qualifying HTTPS forward routes on port 443 are HTTP/3-augmented unless http3.enabled === false or the route opts out.
  • One TLS-terminating DNS-over-HTTPS socket route is generated on the first dnsNsDomains entry. SmartDNS accepts RFC 8484 requests on /dns-query and the compatibility /resolve alias after TLS termination.
  • Email listener ports can be remapped internally, for example public 25, 587, and 465 to unprivileged internal ports.
  • emailOutboundMode: 'remoteIngress' requires an enabled RemoteIngress hub and an eligible connected QUIC edge with SMTP egress enabled. If no eligible edge is available, outbound delivery fails or defers instead of silently falling back to direct SMTP from the hub.

Approved Testing Domains

The testing API lets an enrolled developer obtain certificates and manage A, AAAA, and CNAME records for an operator-approved exact hostname. It does not create a proxy route. Production route certificates keep their separate legacy identity; testing keys are isolated by grant, exact identifiers, ACME account, and issuer.

Requirements: database and DNS management ready, an existing managed DomainDoc with verified authority, an explicitly enabled testing-zone policy, and enabled, ready ACME configuration. Run one active dcrouter writer for these operations; the shared DNS mutation boundary is process-local. Multiple active writers need durable fencing before enabling testing mutations.

Use DcRouterApiClient.testing from @serve.zone/dcrouter/apiclient. Every method returns { ok: true, value } or { ok: false, error }. Transport failures throw TestingTransportError with a safe code. The client has no automatic retries, polling, response cache, or credential persistence. It requires an HTTPS origin (HTTP is accepted only for loopback tests), exactly one credential source, and supports per-call abortSignal and timeoutMs (15 seconds by default).

Enrollment and Approval

An actual admin identity or admin-role token with testing:manage provisions a registration and configures testing zones. Each machine credential carries only testing:read and testing:write; it cannot administer routes, generic DNS, certificates, other tokens, or registrations. JWTs cannot substitute for machine credentials on machine-only methods.

import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
import type { data } from '@serve.zone/dcrouter/interfaces';

function value<T>(result: data.TTestingResult<T>): T {
  if (!result.ok) throw new Error(result.error.code);
  return result.value;
}

const admin = new DcRouterApiClient({
  baseUrl: process.env.GITZONE_TESTING_BASE_URL!,
  apiToken: process.env.GITZONE_TESTING_ADMIN_TOKEN!,
});
const zone = value(await admin.testing.putZone({
  policy: {
    domainId: 'existing-domain-id', enabled: true,
    permittedRecordTypes: ['A', 'AAAA', 'CNAME'],
    minTtl: 60, maxTtl: 3600, maxGrants: 100,
  },
  expectedRevision: null, // Creation only; updates use the current revision.
}));
const enrollment = value(await admin.testing.createRegistration({
  displayName: 'Developer laptop',
  credentialExpiresAt: Date.now() + 30 * 86400000,
  idempotencyKey: 'operator-chosen-enrollment-key',
}));
// Deliver enrollment.token through a secret channel. Never log enrollment.
const machine = new DcRouterApiClient({
  baseUrl: process.env.GITZONE_TESTING_BASE_URL!,
  apiToken: enrollment.token,
});
const pending = value(await machine.testing.requestGrant({
  zoneId: zone.id, hostname: 'alice.deep.dev.example.com',
  idempotencyKey: 'developer-chosen-grant-key',
}));
const approved = value(await admin.testing.reviewGrant({
  grantId: pending.id, expectedRevision: pending.revision,
  action: 'approve', reason: 'Approved development hostname',
}));

Names use Node's UTS #46 IDNA conversion, lowercase ASCII labels, and removal of one trailing dot. The exact hostname must be strictly below the configured zone. An approval never grants a wildcard, apex, parent, or descendant hostname. IP literals, malformed labels, and challenge owner names are rejected. One registration reserves each hostname; automatic reassignment is excluded.

Use listRegistrations, listZones, listGrants, and getGrant to inspect metadata. Lists default to 25 items, accept at most 100, and return nextAfterId for the next request's afterId. Machines see their own registration/grants.

Certificates and DNS

const job = value(await machine.testing.requestCertificate({
  grantId: approved.id, idempotencyKey: 'developer-chosen-certificate-key',
}));
// Poll with a deadline and cancellation, waiting at least pollAfterMs between calls.
const status = value(await machine.testing.getCertificateJob({ jobId: job.id }));
if (status.state === 'ready') {
  const material = value(await machine.testing.downloadCertificate({ jobId: job.id }));
  // material contains certificatePem/privateKeyPem plus exact identifiers,
  // fingerprintSha256, validFrom, validUntil, and renewAfter. Do not log it.
}

const mutation = value(await machine.testing.upsertDnsRecord({
  grantId: approved.id, clientRecordKey: 'web-address', expectedRevision: null,
  type: 'A', value: '192.0.2.10', ttl: 300,
  idempotencyKey: 'developer-chosen-dns-key',
}));
const dnsStatus = value(await machine.testing.getDnsMutation({ mutationId: mutation.id }));
// Wait for succeeded, then use listDnsRecords to obtain the current revision.
// Pass that revision to upsertDnsRecord or deleteDnsRecord; never guess it.
await machine.stop();
await admin.stop();

A certificate request ensures usable material: it coalesces with unfinished work or reuses valid material until renewal is due. Renewal is on demand, follows actual certificate validity and the ACME renewal threshold, which SmartAcme fixes into each certificate's renewAfter at issuance, and cannot be forced by a testing credential. Status responses contain metadata only. Download rechecks live authorization and the stored certificate's identity, fingerprint, and key pair. Disabled/unready ACME returns acme_unavailable. Account, issuer and ACME directory changes are refused while certificate jobs or ACME DNS cleanup remain unresolved; after a permitted change, issuance waits until the SmartAcme instance has been rebuilt with the new configuration (normally at restart).

DNS writes affect only the exact granted hostname and the policy's narrower record-type and TTL bounds. TXT, NS, SOA, MX, CAA, wildcard and child-owner writes are excluded. CNAME exclusivity includes foreign records. Each stable clientRecordKey owns one record; update/delete require its current revision, and delete/recreate never reuses an earlier revision. Manual, gateway, mail, and ACME records cannot be adopted or overwritten. Success means the managed write completed, not that every resolver cache has refreshed.

Rotation, Revocation, and Recovery

rotateCredential takes the registration ID, expectedGeneration, and new expiration. It preserves grants and immediately invalidates the old generation. Creation/rotation returns the raw credential once. A lost creation response can be located through listRegistrations; repeating the same creation returns credential_unavailable, so recover by explicit rotation. No raw credential is retained for retrieval.

revokeRegistration and grant reviewGrant with action: 'revoke' stop new work and downloads, cancel undispatched jobs, and schedule owned DNS cleanup. An in-flight CA result cannot become newly downloadable after revocation. Revocation does not erase already delivered keys or revoke a CA certificate. Grant cleanupState reports pending, indeterminate, or completed. Only an admin can reopen a denied/revoked grant as pending for another approval.

Admins use listRecoveries, getRecovery, and recover. Always use the returned operation ID, revision, and supportedActions. DNS cleanup recovery uses kind dns and the grant ID; ordinary mutations use their mutation ID. recheck is read-only at the provider/CA and can repair a locally missing result only after positive remote evidence. retry-proven-unapplied is offered only when the owning service proves it safe and current authorization permits execution. Certificate recovery is queued, so poll for its result. An acknowledged pending order established by a recheck needs a fresh authenticated certificate request with a new idempotency key to resume. Each recovery records operator, reason, action, and time.

abandon preserves abandoned_unverified evidence and the conflicting resource reservation. It does not assert remote rollback or permit a replacement write. For certificates dcrouter keeps that guarantee itself: SmartAcme would replace an abandoned attempt with a new order on a request an hour later, but the reservation keeps new jobs out, and a job that finds its attempt abandoned never requests it again. Unknown provider creates/deletes are never blindly retried. Cloudflare ownership comments identify the intended record; equal name/type/value alone is not proof of ownership. ACME TXT resources likewise have an opaque per-order owner and value-specific cleanup, preserving simultaneous and unrelated TXT values.

Capacity and Retention

There are two worker slots, including maintenance, with renewable 120-second leases and one shared wake timer. Shutdown stops admission and claims, drains ACME and workers, then tears down DNS and persistence. Lease expiry permits reconciliation after restart; it does not prove an external effect was absent.

Limits are 1,000 registrations, 100 zones, 25 grants per registration, ten DNS records per grant, eight active certificate jobs per registration, 32 unresolved operations, 1,000 retained operations, and 1,000 request receipts per registration. At most 128 access requests enter/wait for the local admission boundary. ACME has a separate global cap of 256 unresolved TXT resources. Revocation cleanup retains one captured record operation per grant so a full client quota cannot prevent cleanup. Maintenance uses bounded batches of 25.

Resolved jobs and request receipts are retained for 30 days, then explicitly removed with their capacity counters. Known expired references return expired; physically purged IDs return not_found. Start a new authorized request to use still-valid cached material. Unresolved and abandoned effects have no TTL and continue occupying quota and reservations. A changed payload cannot reuse an idempotency key. Retrying the same payload/key never intentionally creates a second operation.

Schema migration testing-access-and-exact-certificate-identities (18.8.0 to 19.1.0) installs the indexes and backfills legacy certificate identities without rewriting PEM data. Rollout and staging-CA validation are separate operational steps; isolated tests do not issue public certificates.

Web Push Provider

Web Push is disabled by default and does not read its secret configuration while disabled. It requires DB-backed persistence and the web-push-provider-schema migration. Enable it only after all three secret values are present:

Environment variable Value
DCROUTER_WEB_PUSH_ENABLED true, 1, on, or yes starts the provider and worker. false, 0, off, no, unset, or empty disables it. Any other value fails startup.
DCROUTER_WEB_PUSH_MASTER_KEY_RING JSON or a base64Object: JSON value containing AES-256 keys used for encrypted VAPID and queued-delivery envelopes.
DCROUTER_WEB_PUSH_HMAC_KEY_RING The same key-ring shape, with different key material, used for credentials and opaque request/endpoint digests.
DCROUTER_WEB_PUSH_VAPID_SUBJECT An HTTPS URL or mailto: contact passed to push services.

Each key ring has the form {"currentKeyId":"key-2026-07","keys":{"key-2026-07":"<32-byte-unpadded-base64url>"}}, supports at most eight distinct keys, and must name a configured current key. Encryption and HMAC rings must never reuse key material.

All four values are resolved through @push.rocks/qenv from the process environment, a .nogit/env.json beside the working directory, or a Docker secret, in that order. dcrouter ships no qenv.yml and declares no required: list, because none of these names is mandatory while the feature is off: with the flag on, WebPushManager.start() refuses and names the missing key material, and the webpush-encryption-context migration refuses when sealed documents exist without DCROUTER_WEB_PUSH_MASTER_KEY_RING. A qenv.yml left in the working directory is not enforced at start; it only logs the names it finds missing.

Operational behavior:

  • Control-plane callers need gateway-client readWebPush or manageWebPush capability. App calls use the one-time binding credential returned by syncWebPushBinding; the active credential record stores only its HMAC verifier. To recover a lost rotation response, dcrouter may return the same issuance again during its 15-minute overlap window; that recovery secret is AES-GCM encrypted at rest and is removed when the window expires.
  • Every sync or delete carries a durable controller id, stable epoch, monotonically increasing generation, operation id, and intent. Sync accepts only enabled; delete accepts disabled or deleted, an exact owner, and an optional binding id. Exact same-generation retries resume cleanup and replay the durable outcome, while lower generations, changed controller epochs, or conflicting same-generation requests fail closed. An owner-reserving delete tombstone is created even when a timed-out create has not returned a binding id, so the late create cannot reactivate the provider.
  • A binding admits 120 new requests per minute and 5,000 per hour, atomically across workers and restarts. Matching idempotent replays do not consume admission. The nonterminal queue is capped at 10,000 items per binding.
  • Payloads are limited to 3,500 UTF-8 bytes, TTL must be from 1 through 86,400 seconds, delivery makes at most eight attempts, and each retry sends the remaining TTL. Terminal rows retain only non-secret delivery metadata for seven days.
  • Subscription endpoints must be public HTTPS hostnames on port 443. DNS is bounded and pinned for the request; IP literals, credentials, fragments, and any answer containing a non-global address are rejected.
  • App credential rotation has a 15-minute overlap. VAPID rotation retains retiring keys for 90 days and, if necessary, until old queued items have terminally drained, with four VAPID keys maximum. Rotate before reaching that bound.
  • Startup asserts the index topology of web_push_binding, web_push_admission and web_push_spool against the declared model and requires an exact match: a declared index that is missing is re-created on bind, while a renamed or altered index, or an index nobody declared, fails startup closed. Remedy for a hand-added index: drop it (db.<collection>.dropIndex('<name>')) or add it to the web-push-provider-schema migration and the model declaration.
  • Upgrading to 32.0.0 reseals every stored Web Push envelope under new associated data, so DCROUTER_WEB_PUSH_MASTER_KEY_RING must hold the key each existing envelope names before the first 32.0.0 start; otherwise the webpush-encryption-context migration refuses by name and dcrouter does not start. The push payload also loses its schemaVersion member with @serve.zone/interfaces 32, so a service worker that reads it must stop expecting the field.
  • For a key-ring rotation, add new unique key material, set currentKeyId, restart, and keep old encryption keys until every VAPID, delivery, and credential-recovery envelope using them has been rotated or purged. Keep each old HMAC key only until no current or overlap credential references its key id; terminal and idempotency retention does not require the old HMAC key. Remove old keys only after those conditions are true.
  • deleteWebPushBinding is a destructive tombstone operation: it revokes and removes all credential hashes, prior credentials, credential-recovery envelopes, and encrypted VAPID private keys; cancels and scrubs queued work; and clears admission state. Queued work is terminalized immediately, but an already sending request remains sending until the remote response wins or its lease expires. An expired lease after cancellation is recorded as a failed delivery with an unknown remote outcome, never as a confirmed cancellation. Delete/disable reports success only after that lifecycle drains; a bounded drain timeout returns a retryable failure, and the same controller operation resumes cleanup. Re-enabling the same owner creates a new lifecycle, credential, and VAPID key. Clients must recreate browser push subscriptions and must not reuse deleted credentials.
  • The worker performs bounded maintenance every cycle and at startup, repeatedly removing expired credential overlap/recovery state, drained retiring or retired VAPID private keys, stale admission state, and incomplete disabled/deleted lifecycle cleanup without materializing an unbounded collection.

For a staged rollout, deploy with DCROUTER_WEB_PUSH_ENABLED=false, confirm the migration and health of the rest of dcrouter, install the three secret values, enable one gateway-client binding, and exercise status/enqueue/delivery/cancellation before expanding. To roll back worker activity, set the feature flag to false and restart; durable unexpired items remain encrypted and resume after re-enable. Do not delete bindings as a rollback mechanism because deletion is intentionally irreversible.

Route Ownership

dcrouter keeps generated and operator-created routes separate so automation can reconcile safely.

Origin Source Mutability
config Constructor coreTrafficConfig.routes and seed data Toggle only
email Email listener and email-domain generated routes Toggle only
dns Generated DNS-over-HTTPS and DNS-related routes Toggle only
api Ops UI, specialized managed-route workflows, and gateway clients Operator routes use generic CRUD; managed routes use their specialized API; toggle remains available

System routes are persisted with stable systemKey values. Ordinary operator-created API routes are editable through generic route CRUD. Routes carrying managed ownership metadata, including Special Forwards and gateway-client routes, reject generic structural updates and deletion so their owning workflow keeps canonical match, priority, source-policy, and ownership fields intact.

A gateway-client route is keyed by metadata.externalKey, spelled <gatewayClientType>:<gatewayClientId>:<appId>:<routeKey>. The route key is a bare hostname, or route:<routeRef> when the client owns a route reference without a hostname, or host-route:<base64url([hostname, routeRef])> when it owns one hostname under a specific route reference. dcrouter composes and matches the key itself: a gateway client sends ownership fields and never an externalKey. Upgrading to 32.0.1 rewrites stored keys from the released v2:host-route: spelling through the host-route-key migration step; a key that still spells the old prefix afterwards is treated as malformed and listed with its route hostname only.

Gateway-Client Hostnames

A gateway client can hold an exact hostname without a gateway route: one A or AAAA record at an address it chooses, and one certificate for exactly that name. Cloudly uses this for its cluster relay names, whose record carries the relay node's own address and whose certificate the relay serves itself. The contracts are @serve.zone/interfaces 32.21.0's syncGatewayClientDnsRecord, getGatewayClientCertificate and releaseGatewayClientCertificate, and getGatewayCapabilities advertises them as dns.clientRecords and certificates.exactIssuance.

Operation Token capabilities Effect
syncGatewayClientDnsRecord syncDnsRecords Publishes, edits or withdraws the ownership's address record. Never proxied.
getGatewayClientCertificate requestCertificates, readCertificates Answers ready with the PEM chain and key, pending while SmartAcme issues it, or refused.
releaseGatewayClientCertificate requestCertificates Retires the ownership's certificate material and claim; the record stays.
  • Only a gateway-client credential can call them. dcrouter takes the client from the credential, and the ownership names just { appId, hostname }. The hostname must match the client's hostnamePatterns.
  • A record's value must lie inside the client's allowedDnsRecordAddresses, a list of canonical CIDR prefixes. Set it with provisionGatewayClientCredential, the admin createGatewayClient and updateGatewayClient requests, or in the Ops UI under Access > Gateway Clients (the create dialog and the row action Edit DNS Addresses), which judges the list with the same shared validator. An absent or empty list allows no record, and it stays out of the client's policy digest, so credentials of clients without one keep matching. Changing it through an admin update is a policy change: it raises the client's policy generation, as a change to its hostname patterns does, so the client's current credentials must be provisioned again.
  • A hostname has one holder. A testing reservation, an enabled gateway-client route, another client or app, or an address record dcrouter does not own makes it a conflict (record-conflict, hostname-conflict). dcrouter never overwrites or adopts such a record. In the other direction, the route DNS reconciler answers record-conflict for a hostname that an ownership holds, and a testing grant for it is refused on request and on approval with ownership_conflict, also when the ownership holds only a certificate.
  • Records are stored as DNS records managed by gateway-client-hostname and written under the same DNS exclusion as the route reconciler. Provider zones are synchronized first, as for routes.
  • A certificate needs a zone with verified authority: a provider zone or a delegation-verified zone (zone-unmanaged otherwise). SmartAcme issues it by DNS-01 for exactly the one hostname, in a namespace per client and app (dcrouter-gateway-client:<digest>), so no other owner shares its key. A request for a hostname nobody holds yet first synchronizes a provider zone and checks it for address records dcrouter does not own; a failed synchronization answers issuance-unavailable, which is retryable. Requests for a hostname the ownership already holds, such as polls while an issuance runs, synchronize nothing. A request that is not answered within two seconds is pending and the issuance continues. An attempt SmartAcme can no longer complete is refused with issuance-failed and the retryAfter at which SmartAcme places a new order. Asking again after renewAfter renews the certificate.
  • Every minute, and at start, dcrouter removes what no live ownership holds: the records, certificates and claims of a deleted client or of a hostname its patterns no longer allow, records whose address left the allowance, and certificate material a release left behind. A disabled client keeps its holdings.

DNS Authority

Which zones the embedded DNS server may answer for authoritatively is database state, not deployment configuration. There is no dnsScopes option.

A zone enters the authority set exactly one way: its public delegation must name one of dnsNsDomains, observed through a single DNS-over-HTTPS lookup against a public resolver. The system resolver is never used for this — on a dcrouter host it may be dcrouter itself, which would answer with the very NS records dcrouter generated and make the proof self-confirming. An ops-API caller cannot repoint somebody else's delegation, so writing the record is not the same as manufacturing the proof.

Operation Scope Effect
getDnsAuthority dns-authority:read The authority set, its evidence, and whether it was readable.
probeDnsAuthorityZone dns-authority:read Read-only delegation probe. Never mutates.
verifyDnsAuthorityZone dns-authority:write Claims a zone, only against a delegated verdict.
revokeDnsAuthorityZone dns-authority:write Drops a zone. Every zone is revocable.
getDnsAuthorityDrift dns-authority:read Advisory comparison of claimed authority against reality.

Writes accept an admin identity or an API token carrying dns-authority:write; a non-admin identity is refused. Probes return three verdicts, never two: delegated, not-delegated, and undeterminable. A timeout is not evidence that a zone is not ours, so a mutation refuses on undeterminable rather than assuming either way.

Claiming or revoking a zone takes effect in-process, with no restart: it moves the running DNS server's authoritative zone set, its generated apex NS records, route certificate warnings, and the private-route overlay together.

Cold start. A database with no authority document is a legitimate state, and it means dcrouter claims no configured zone and REFUSES ordinary unhandled queries. RFC 6761 localhost answers and the explicitly non-authoritative private-route overlay remain deliberate exceptions. The DNS server still starts — so DoH keeps serving and a zone verified a moment later takes effect immediately — and the condition is logged at error alongside a startup drift audit listing every dcrouter-hosted zone delegated to us that is not being served. An authority document that cannot be read is treated differently: the set is unknown rather than empty, so the DNS services fail and retry instead of quietly revoking every zone. dcrouter itself still comes up — both services are optional — so the rest of the router keeps running while DNS stays deliberately down.

Route Source Bindings

API-created route records pass ordered metadata.sourceBindings[] alongside the SmartProxy route config to express source and path policy variants without duplicating whole routes by hand. Each binding points at a source profile id through sourceProfileRef. Dashboard presets resolve seeded profile names to ids before saving.

Source profiles store reusable defaults only. A source profile is not enforced globally by itself; dcrouter compiles it into SmartProxy routes only when a route references it through metadata.sourceBindings[].

Runtime behavior:

  • Source matching uses the referenced SourceProfile.security.ipAllowList.
  • Bindings are evaluated in order and the first matching source profile wins.
  • A matched binding that exceeds its configured rate or connection limit is terminal; dcrouter does not fall through to later bindings. Rate limits normally return 429, or trigger a browser challenge when rateLimit.onExceeded.type === 'challenge'.
  • Source-binding rate limits are always keyed by source IP; dcrouter ignores path and header keying on source-binding and path-policy overrides.
  • Source-binding and path-policy rateLimit and challenge fields use tri-state semantics: omitted means inherit, null means explicitly clear inherited protection, and an object means custom protection.
  • Effective protection precedence is path policy, then route binding, then source profile, then parent source profile, then no protection.
  • When sourceBindings[] are present, dcrouter compiles source-policy variants from source profile, binding, and path policy protection. Base route rateLimit and challenge values are not part of that inheritance chain; routes without source bindings use normal SmartProxy route security.
  • Direct challenge protection applies to matching HTTP-visible requests. rateLimit.onExceeded.type === 'challenge' configures a browser challenge for rate-limit exceed events.
  • Binding-level and path-policy onExceeded is only for 429 message handling. Browser challenge-on-exceeded behavior belongs on rateLimit.onExceeded.
  • Private-only binding lists are valid. dcrouter adds a same-match terminal deny fallback so unmatched sources fail closed.
  • A public or wildcard binding is optional. When present, it must be last and must use *, or both 0.0.0.0/0 and ::/0, in security.ipAllowList.
  • Create/update paths reject source bindings with missing source profiles, source profiles without source matches, or any all-source binding that shadows later bindings; persisted invalid bindings fail closed at compile time.
  • Server-side caps bound policy expansion to 16 source bindings, 12 path policies per binding, 64 path patterns per path policy, 256 characters and 8 wildcards per custom path pattern, 512 compiled SmartProxy route-port variants per stored route, and enough priority headroom above the stored route priority for generated source-binding variants.

Path policies let a source binding override rate limits, browser challenges, or connection limits for specific path classes. dcrouter currently ships Gitea-oriented classes: git-smart-http, static, normal-html, expensive-html, raw, and archive. Path-specific variants win over the same binding's fallback; if every path policy is path-specific, dcrouter adds a source-level fallback route for unmatched paths so normal browsing cannot fall through to a later source binding. The Gitea preset keeps git-smart-http high-limit and separate from HTML crawling paths so normal git clone, git fetch, git push, and Git LFS traffic are not subject to the lower HTML crawler limits.

const trustedProfileId = 'source-profile-id-trusted';
const publicProfileId = 'source-profile-id-public';

const createRoutePayload = {
  route: {
    name: 'public-gitea',
    match: { domains: ['code.example.com'], ports: [443] },
    action: {
      type: 'forward',
      targets: [{ host: '10.10.0.20', port: 3000 }],
      tls: { mode: 'terminate', certificate: 'auto' },
    },
  },
  metadata: {
    sourceBindings: [
      {
        sourceProfileRef: trustedProfileId,
        rateLimit: null,
        maxConnections: 5000,
        onExceeded: { type: '429' },
      },
      {
        sourceProfileRef: publicProfileId,
        onExceeded: { type: '429' },
        pathPolicies: [
          {
            pathClass: 'git-smart-http',
            rateLimit: { enabled: true, maxRequests: 1200, window: 60, keyBy: 'ip' },
          },
          {
            pathClass: 'static',
            rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
          },
          {
            pathClass: 'raw',
            rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
          },
          {
            pathClass: 'archive',
            rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
          },
          {
            pathClass: 'expensive-html',
            rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
          },
          {
            pathClass: 'normal-html',
            rateLimit: {
              enabled: true,
              maxRequests: 120,
              window: 60,
              keyBy: 'ip',
              onExceeded: {
                type: 'challenge',
                challenge: {
                  providerId: 'smartchallenge',
                  challengeType: 'wait',
                  applyTo: { methods: ['GET'], browserNavigationsOnly: true },
                  clearance: { ttlSeconds: 300, bindToHost: true, bindToRoute: true },
                },
                clearanceEffect: 'bypass-rate-limit',
              },
            },
          },
        ],
      },
    ],
  },
};

Source Profiles

Source profiles are reusable source-side security defaults. They can carry ipAllowList, ipBlockList, maxConnections, rateLimit, authentication fields, VPN fields, and challenge. Profiles can extend parent profiles; IP allow/block lists are unioned and scalar/object protection fields such as maxConnections, rateLimit, and challenge are overridden by the more specific profile.

When dbConfig.seedOnEmpty seeds an empty database, dcrouter's built-in profile set includes:

Profile Default purpose
TRUSTED NETWORKS Private networks, localhost, and high connection allowance
AI CRAWLERS Placeholder crawler profile with low per-IP request limits until verified crawler CIDRs are added
PUBLIC Public fallback profile with per-IP request limiting
STANDARD Standard private-network access profile

The source profile dashboard supports protection modes for rate limits and browser challenges: inherit/unset, none/clear inherited, or custom. Custom challenge settings expose the provider id, challenge type, and clearance TTL used by source-policy route compilation.

Challenge Support

dcrouter registers the smartchallenge provider with the wait challenge type when SmartProxy starts. Source policies can apply challenges in two ways:

  • challenge on a source profile, route binding, or path policy protects matching HTTP-visible traffic directly.
  • rateLimit.onExceeded.type: 'challenge' configures the rate-limit exceeded path to issue the configured challenge.

Challenge-aware source-policy variants and HTTP/3-augmented challenged routes force HTTP protocol matching where needed so SmartProxy can process browser challenges correctly.

ACME And Certificate Challenges

DB-backed ACME configuration drives SmartProxy certificate provisioning. When ACME is enabled and dcrouter has managed domains, DnsManager builds the DNS-01 provider used by SmartAcme. That provider creates and removes challenge TXT records through the same DNS record path used for dcrouter-hosted zones and provider-managed domains.

The renewal threshold in the ACME settings (default 30 days, a positive number) governs both SmartProxy's daily renewal sweep and SmartAcme: a certificate is renewed once at most that many days remain, or a third of its lifetime if that is less. A failed renewal leaves the current certificate stored and served. SmartProxy and SmartAcme receive the threshold when dcrouter builds them, so a changed threshold takes effect at the next restart or SmartProxy rebuild. A stored threshold that is not a positive number, written by an earlier build or directly into the database, leaves ACME unavailable: dcrouter starts SmartProxy without ACME, logs an AcmePermanentFailureError (invalid-renewal-threshold), and keeps the OpsServer running, so the threshold can be corrected in Domains > Certificates > Settings or with updateAcmeConfig.

By default SmartAcme orders from Let's Encrypt, production or staging per the Use Let's Encrypt production setting. The optional ACME directory (directoryUrl in updateAcmeConfig and getAcmeConfig) points it at another CA's RFC 8555 directory instead, such as a private CA or SmartAcme's own server.AcmeServer on a disposable test host; the production setting is then ignored. dcrouter accepts only what SmartAcme accepts: an https: URL, or an http: URL on localhost, 127.0.0.1 or [::1], without credentials or fragment, and updateAcmeConfig names the rule a refused URL breaks. directoryUrl: null returns to Let's Encrypt, and omitting the field keeps the stored value. A directory change is treated like an account change: it is refused while certificate jobs or ACME DNS cleanup are unfinished, and issuance waits until dcrouter has rebuilt SmartAcme on the new directory at the next restart or SmartProxy rebuild. Account keys and exact-issuance records are kept per directory, so material from one CA is never used with another. The certificate settings and the configuration view show the directory in use. Issuance still uses dcrouter's DNS-01 provider, and SmartAcme checks the challenge itself before it asks the CA, even a CA that skips validation: for a route certificate it polls the host's system resolver for the _acme-challenge TXT record (up to 100 times, 5 s apart) and then waits a fixed 60 s; for an exact certificate it polls up to 100 times, 1 s apart, and then waits its DNS propagation delay, 60 s by default, which dcrouter does not change. Each issuance therefore takes at least 60 s, and the host's resolver, on a disposable rehearsal hub too, must see dcrouter's zone.

Certificate provisioning is DNS-01 only: SmartAcme's only challenge handler is the DnsManager provider, and SmartProxy's own ACME and its HTTP-01 fallback stay off while dcrouter's provision callback is wired. While SmartAcme is starting, retrying account setup or out of its startup budget, the callback defers each domain instead of failing it, so SmartProxy starts no 30-minute provisioning cooldown for it. Once SmartAcme is ready, including after a corrected ACME configuration re-arms it, dcrouter runs a provisioning sweep that issues the deferred certificates. A failed attempt does put the domain on that cooldown; an operator reprovision (reprovisionCertificateDomain) lifts it before re-applying the routes, so the domain is provisioned again at once. Issued certificates are stored through the proxy certificate store, and certificate status is tracked from both newly issued and store-loaded certificate events.

Operators can also create a managed Special Forward for an external Let's Encrypt HTTP-01 responder. The workflow accepts exact domains plus either an inline backend or a reusable Network Target. dcrouter compiles that intent into a public port 80 route matching only /.well-known/acme-challenge/*, assigns priority 1000, binds the PUBLIC source profile, and can optionally expose the route through RemoteIngress. The high-priority path route coexists with the normal HTTP-to-HTTPS redirect for the same domain.

Use the Routes view's Type multitoggle and select Special, or use the raw TypedRequest methods createLetsEncryptHttp01Forward, updateLetsEncryptHttp01Forward, and deleteLetsEncryptHttp01Forward. Generic route update/delete methods intentionally reject these managed routes.

Security Policy And IP Intelligence

When DB-backed persistence is enabled, SecurityPolicyManager maintains global deny policy from block rules and observed public-IP intelligence. Rule types are ip, cidr, asn, and organization; organization rules support exact or contains matching against enriched ASN and registrant organization data.

The compiled policy contains blockedIps and blockedCidrs. dcrouter merges it into SmartProxy's security policy and also compiles an IPv4 firewall snapshot for RemoteIngress edge synchronization. Security policy changes are audited when block rules are created, updated, or deleted.

The OpsServer exposes these security policy methods through TypedRequest: listSecurityBlockRules, createSecurityBlockRule, updateSecurityBlockRule, deleteSecurityBlockRule, listIpIntelligence, refreshIpIntelligence, getCompiledSecurityPolicy, and listSecurityPolicyAudit.

Production-Flavored Example

import { DcRouter } from '@serve.zone/dcrouter';

const router = new DcRouter({
  baseDir: '/var/lib/dcrouter',
  coreTrafficConfig: {
    routes: [
      {
        name: 'web-app',
        match: { domains: ['app.example.com'], ports: [443] },
        action: {
          type: 'forward',
          targets: [{ host: '10.10.0.21', port: 8080 }],
          tls: { mode: 'terminate', certificate: 'auto' },
        },
      },
      {
        name: 'internal-admin',
        match: { domains: ['admin.example.com'], ports: [443] },
        action: {
          type: 'forward',
          targets: [{ host: '10.10.0.30', port: 9000 }],
          tls: { mode: 'terminate', certificate: 'auto' },
        },
        vpnOnly: true,
      },
    ],
  },
  emailConfig: {
    hostname: 'mail.example.com',
    ports: [25, 587, 465],
    domains: [{ domain: 'example.com', dnsMode: 'internal-dns' }],
    routes: [
      {
        name: 'inbound-example',
        match: { recipients: '*@example.com' },
        action: {
          type: 'forward',
          forward: { host: 'mail-backend.example.com', port: 25 },
        },
      },
    ],
  },
  emailOutboundMode: 'remoteIngress',
  dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
  // Which zones the embedded DNS server answers for is not configured here.
  // A zone earns authority by its public delegation naming dnsNsDomains, and
  // that proof lives in the database — see the "DNS Authority" section above.
  publicIp: '203.0.113.10',
  remoteIngressConfig: {
    enabled: true,
    tunnelPort: 8443,
    hubDomain: 'ingress.example.com',
  },
  vpnConfig: {
    enabled: true,
    serverEndpoint: 'vpn.example.com',
    clients: [{ clientId: 'ops-laptop', description: 'Operations laptop' }],
  },
  opsServerPort: 3000,
});

await router.start();

VPN Target Profiles

Target profiles define what a VPN client can reach through domains, direct targets, and routeRefs. Set allowRoutesByClientSourceIp: true on a target profile when a VPN client should also be granted to routes whose source policy is meant to evaluate the client's real connecting IP.

dcrouter maps target profiles to SmartProxy VPN client grants. SmartVPN forwards both the real client source IP and authenticated VPN metadata through trusted PROXY v2 headers, so SmartProxy checks source policy and VPN client authorization separately for each connection. Route security.ipAllowList and security.ipBlockList stay the source of truth for real source-IP policy; vpnOnly adds the requirement for authenticated VPN metadata and a matching VPN client grant.

const targetProfile = {
  name: 'ops laptop source access',
  allowRoutesByClientSourceIp: true,
};

Automation API

The OpsServer exposes TypedRequest handlers at /typedrequest. You can use raw contracts or the object-oriented API client.

Socket endpoints use TypedSocket 8, including realtime subscriptions, log streams, and platform mail/web-push connections. Older TypedSocket clients require an upgrade before connecting. Coordinate server and client deployments when moving from dcrouter 18; HTTP gateway reconciliation remains compatible. The API client documentation covers log-stream consumption, cancellation, and connection cleanup.

It also exposes an admin-JWT authenticated read-only MCP endpoint at /mcp. The MCP tools return safe summaries for runtime status, routes, source and target profiles, network targets, DNS, email domains, RemoteIngress edges, and VPN clients without API tokens, logs, certificates, private keys, or provider credentials.

pnpm add @serve.zone/dcrouter-apiclient
import { DcRouterApiClient } from '@serve.zone/dcrouter-apiclient';

const client = new DcRouterApiClient({
  baseUrl: 'https://dcrouter.example.com',
});

await client.login('admin@example.com', 'strong-password');

const route = await client.routes.build()
  .setName('api-gateway')
  .setMatch({ ports: 443, domains: ['api.example.com'] })
  .setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8081 }] })
  .save();

await route.toggle(true);

Use @serve.zone/dcrouter-apiclient/interfaces (or @serve.zone/dcrouter/interfaces from the runtime package) when you want dcrouter-local raw TypedRequest contracts instead of resource managers. Use @serve.zone/interfaces for the canonical machine-facing gateway client route, DNS, domain, and mail contracts shared with Onebox and Cloudly.

dcrouter 32.0.0 builds against @serve.zone/interfaces 32.0.0: the installed interfaces release is the contract version, there is no version field on the wire and no per-shape version suffix. dcrouter serves no session that carries the 32 protocol handshake and opens none — its gateway-client, mail, Web Push and CoreMail gateway contracts are unchanged in 32.0.0 and carry no protocol offer — so no caller has to send one.

Gateway-client mail contracts let Cloudly or Onebox claim exact app addresses, attach inbound smtpForward targets, enable managed outbound SMTP credentials, rotate those credentials, enqueue service mail, and query delivery status through TypedRequest. getGatewayClientMailDomainCount requires an authenticated, enabled gateway-client credential with the gateway-clients:read scope and readMail capability. It returns only that credential owner's distinct domain count; it does not accept a caller-selected owner and does not load bindings, DNS details, or recent mail. Managed SMTP users can only send as their exact claimed address; mismatched envelope or header From values are rejected before relay. Typed submissions may set replyTo to one bare printable-ASCII mailbox address. Dcrouter validates it authoritatively and renders one Reply-To header; custom headers remain unable to override Reply-To or any other protected MIME field. Delivery status queries return the dcrouter spool item state, including deferred SmartMTA errors and next retry timestamps when outbound delivery is temporarily delayed.

Email Operations Views

The Email Log keeps its search controls sticky and renders the traffic chart above the message table. With no query, the chart covers the latest 24 hours. Searches expand the chart window to the smallest supported period that contains matching retained messages (7, 14, or 30 days). Dragging across the chart selects an epoch-millisecond time range for the table while the chart continues to show the complete search context; clearing the selection restores the full result list.

Inbound messages persist a separate security disposition: accepted, flagged, or rejected. SPF, DKIM, or DMARC findings therefore remain visible in Email Security without overwriting the transport/delivery status. The security table shows time, sender, recipients, subject, and the specific authentication failure. Sent-message detail exposes the asynchronous outbound authentication self-check based on the exact post-DKIM bytes and the actual RemoteIngress source address. It is diagnostic evidence, not a receipt from the destination server.

RemoteIngress Hub and Edge Versions

dcrouter runs the RemoteIngress hub in process, so the hub's @serve.zone/remoteingress version is the one dcrouter is built with; edges run their own install of the package or binary. Run the hub and every edge on remoteingress 32.0.3 or later (dcrouter 32.6.2 or later for the hub). Each side reads the QUIC control stream with its own reader, and releases before 32.0.3 could lose a control frame that arrived split across packets and tear the whole tunnel down with every port listener of that edge; an upgraded hub stops losing frames from edges and writes each control message as one buffer, but QUIC can still deliver a message in parts, so only an upgraded edge is safe against a frame that arrives split.

Hubs and edges must also share the 32 major: 32.x edges must not pair with 5.2.x hubs (dcrouter releases before 32.1.0) or the reverse, because the capability names and the heartbeat PONG payload are part of the wire contract and changed with the major. Upgrade the hub and all of its edges in one attended slot, hub first, then each edge.

RemoteIngress SMTP Egress

The email settings API and Ops UI expose outboundMode. Keep it at direct to let unmanaged/static senders dial destination MX hosts from the dcrouter hub; managed sender domains are always rejected in direct mode. Set it to remoteIngress to make SmartMTA request a one-shot RemoteIngress TCP egress proxy for each outbound SMTP connection while keeping the logical MX host and port for SMTP/TLS identity. Direct mode is explicit operator policy, never an automatic fallback.

RemoteIngress edge create/update APIs accept an egress policy. Egress is default-disabled and is propagated to the hub only when egress.enabled === true.

Supported egress policy fields:

Field Purpose
enabled Enables edge-originating outbound TCP egress for that edge.
allowedPorts Destination ports allowed by dcrouter. Currently limited to SMTP port 25.
allowedHostPatterns Optional MX host patterns evaluated by the edge before dialing.
allowPrivateRanges Allows private destination ranges when explicitly enabled. Loopback, metadata, multicast, unspecified, documentation, shared, and other reserved ranges remain blocked by the RemoteIngress edge.
deniedCidrs CIDRs denied after edge-side DNS resolution.
maxConcurrentStreams Optional concurrent outbound SMTP stream cap for the edge.

RemoteIngress status responses include capabilities, egressEnabled, and source-bound egress identity. A mail edge is eligible only when it is enabled, carries the mail tag, permits destination port 25, is connected with a fresh heartbeat, uses native QUIC without TCP fallback, advertises both egressTcp and egressIdentity, and reports a fresh non-stale localSocketBind identity. Every configured address family must have exactly one matching fresh proof: configuring both publicIp and publicIpV6 requires valid IPv4 and IPv6 identities. Each identity must match the edge's declared address, the edge must have a mailHostname, its PTR must resolve exactly to that hostname, and the hostname's A/AAAA set must contain the source address. Domain edge filters narrow this pool and remain fail-closed when they match nothing.

Edges run the same @serve.zone/remoteingress major as the hub (32); an edge on an older major advertises the pre-32 capability spellings, is not recognized, and is refused both egress and mail eligibility. Edge runtimes must configure egressSourceIpv4 and/or egressSourceIpv6 with concrete addresses assigned to the edge host. The equivalent CLI flags are --egress-source-ipv4 and --egress-source-ipv6; the environment variables are REMOTEINGRESS_EGRESS_SOURCE_IPV4 and REMOTEINGRESS_EGRESS_SOURCE_IPV6. These addresses must match dcrouter's declared edge IPs. Every outbound socket is bound to the matching family, and a missing or unbindable family fails closed. There is no OS-selected address or direct-hub fallback in remoteIngress mode.

Managed email domains use the durable lifecycle pending -> active | failed -> deleting; the document is removed only after deletion cleanup completes. Creation must obtain a usable DKIM key before persistence, then automatically reconciles and validates MX, SPF, DKIM, DMARC, and direct per-edge A/AAAA records. Inbound acceptance is independent and remains available while outbound SMTP credentials and sending are gated until the domain has a validated activeRevision and both selected MX edges are live.

Managed domains require two eligible edges with distinct hostnames. The reconciler selects stable priority 10 and priority 20 targets and uses that same two-edge set for direct A/AAAA records, public validation, the active revision, and outbound egress. Zero or one eligible edge produces no partial desired MX/SPF set and never falls back to the hub. SPF uses -all; steady state authorizes the selected pair, while a make-before-break transition temporarily authorizes both the active and pending pairs until activation triggers immediate contraction. Activation requires the complete MX RRset, all record intents, and exact PTR plus forward-confirmed A/AAAA validation. Topology replacement validates the desired state before activation, then removes stale owned records; an incomplete pinned topology withdraws the previous owned MX/SPF state.

The reconciler is globally serialized and coalesces concurrent triggers. It runs at startup, schedules desired-state checks no more often than every five minutes, and reacts to relevant domain, DNS, DKIM, topology, or egress-identity changes. Failed domains honor persisted retryAt state with exponential backoff up to one hour. Provider state is refreshed on each pass; only records marked managedBy: 'mail-dns-reconciler' are mutated or deleted. Unmanaged SPF, DMARC, DKIM, or MX conflicts fail visibly instead of being overwritten. Each domain persists desired and active revisions, per-record intent status, provider/validation errors, attempt and validation timestamps, capability state, and retryAt. Provider/API failures propagate through the email-domain API and never become false provisioning success. Provider deletion retains local ownership until the provider returns an explicit not-found response or a complete provider-ID listing confirms absence; DNS domain migration/deletion and forced provider deletion are refused while managed email references or reconciler-owned mail records remain.

DKIM generation failure is atomic. Automatic rotation defaults to 90 days with a 30-day retiring-selector overlap; rotationIntervalDays must be a positive integer. dcrouter publishes and validates a staged selector, requires SmartMTA's selector-correct signing capability, promotes it for signing only after the DNS revision validates, retains the previous selector for the overlap, and schedules retirement at retireAfter. Retiring metadata is pruned only after the owned DNS record has been removed successfully.

The additive public contracts include IEmailDomainActionResult, IEmailDomain.reconciliation, lifecycle/error/capability/retry fields, active/pending/retiring DKIM material, DNS intents and revisions, and edge identities. For mutation requests, success: true means the operation was accepted and durably recorded; lifecycleStatus: 'pending' or 'deleting' still means activation or cleanup has not completed.

SMTP Submission Accounts

Operators manage authenticated SMTP submission accounts in the Ops UI under Email → SMTP Accounts, or through the typed API (getSmtpAccounts, createSmtpAccount, updateSmtpAccount, toggleSmtpAccount, rotateSmtpAccountPassword, deleteSmtpAccount; API-token scopes smtp-accounts:read / smtp-accounts:write, writes require an admin identity).

Credentials are hashed at rest: passwords are machine-generated, shown exactly once at create/rotate time, and only an RFC 5802 SCRAM-SHA-256 verifier is persisted. Accounts are loaded into memory at startup so SMTP authentication never touches the database. Disabled accounts always fail authentication and still shadow same-named legacy static users (SmartMTA account-over-user precedence).

Each account carries a sender scope ("send AS who": exact addresses plus whole domains), a recipient scope ("send TO who": any or a restricted address/domain list), and a mail policy (DKIM signing on/off, delivery queue). Scope enforcement happens inside SmartMTA at MAIL FROM/RCPT/DATA time; dcrouter additionally generates one relay route per (account × sender domain) at priority 850 — below WorkApp per-address routes (900), above operator-configured routes (so a scoped account's generated relay route deliberately takes precedence over lower-priority configured routes for that account's mail) — bound to the exact username via authenticatedUser, with allowRelay and process.dkim/process.queue from the account's policy. SmartMTA resolves the active DKIM selector per domain from its registry, so selector rotation stays owned by the email-domain reconciler and is never snapshotted into routes. An account with an explicit sender scope may not use the SMTP null sender (<>); accounts with unrestricted senders (including recipient-restricted ones) still may.

Accounts without a sender scope authenticate unrestricted but generate no relay routes — their relay permission remains whatever operator-configured routes grant. Sender domains that are not managed email domains are allowed with a warning (no managed SPF/DKIM/DMARC alignment); enabling DKIM signing is stricter and requires every sender-scope domain to be a managed, outbound-ready email domain with active DKIM material — this fails at configuration time, never at delivery time.

Submission accounts can only authenticate over an encrypted transport (see below). A message an authenticated account sends to a domain dcrouter hosts is delivered into that local mailbox rather than relayed — authentication grants permission to relay, it does not make a locally addressed message outbound.

On upgrade, a migration converts legacy plaintext emailConfig.auth.users entries into hashed account documents and strips the plaintext from the persisted settings. Migrated accounts are unrestricted and generate no relay routes, so authentication and relay behave byte-identically to the legacy users — relay authorization is provably not widened (or narrowed) by the migration. Scoping a migrated account is an explicit post-migration operator action through the SMTP accounts UI/API. The legacy routes themselves stay in place until an operator retires them explicitly with removeLegacyEmailRoute { routeName }, which edits the persisted settings through the standard restart path, refuses unknown names, and refuses generated managed route names.

SMTP TLS Material And AUTH Transport

SMTP AUTH is only ever offered on an encrypted transport. A submission port that has no TLS material and no upstream TLS terminator advertises neither STARTTLS nor AUTH, and refuses an AUTH command with 538 5.7.11 — credentials never cross a cleartext channel.

emailConfig.tls therefore has to actually reach the SMTP listener. SmartMTA consumes PEM content, so dcrouter resolves it in this order:

  1. emailConfig.tls.certPem / keyPem — inline PEM.
  2. emailConfig.tls.certPath / keyPath — file paths, read eagerly at startup. Both are required together. A path that cannot be read, or that holds an empty file, fails startup rather than silently leaving the listener without TLS.
  3. The stored ACME certificate for emailConfig.hostname in the proxy certificate store. Renewals are pushed to the running listener automatically (unless explicit paths are configured, which are never superseded by an ACME renewal for the same name).

CoreTraffic terminates public TLS for port 465 and forwards the decrypted stream to the internal SMTP leg, so that leg is plaintext even though the client's channel was encrypted. dcrouter derives those ports from the generated email routes and declares them to SmartMTA as smtp.tlsTerminatedPorts, gated on the PROXY-protocol trust dcrouter already requires from 127.0.0.1/::1 — so implicit-TLS submission keeps authenticating while the plain ports require STARTTLS first.

At startup dcrouter logs the transport posture of every listener port — which have TLS material, which are edge-terminated, and which are AUTH-capable — and logs an error naming the affected ports when AUTH is configured but a port can only offer cleartext.

CoreMail Mail Gateway

@serve.zone/coremail holds workload mailboxes and connects to dcrouter as an authenticated TypedSocket client. dcrouter is its transport: it relays CoreMail's outbound mail to the internet and hands CoreMail the inbound mail for the addresses it owns. Message bytes never travel as RPC payloads — they move over one-time, digest-pinned HTTP transfers.

Provisioning a peer. Cloudly or Onebox creates the peer through the gateway-client API with syncCoreMailGatewayPeer (and reads or removes it with listCoreMailGatewayPeers / deleteCoreMailGatewayPeer). The request needs a gateway-clients:write credential with the manageMail capability, and the peer is owner-scoped: a token may only ever provision its own ownership, and one coreMailServiceId cannot be claimed by a second owner.

The peer carries verifier material only:

{
  "coreMailServiceId": "coremail-prod",
  "transferOrigin": "https://coremail.example.com",
  "credentials": [
    {
      "credentialId": "coremail-gateway",
      "version": 2,
      "state": "current",
      "format": "argon2id",
      "verificationHash": "$argon2id$v=19$m=65536,t=3,p=1$<salt>$<digest>"
    }
  ]
}

The plaintext credential is delivered only to CoreMail as runtime secret material; dcrouter never sees, stores or logs it. Exactly one credential is current, it must hold the greatest version, and a rotation keeps the previous version as retiring with an acceptUntil deadline. The argon2 package emits its PHC parameters as m,p,t — the contract requires the canonical m,t,p order, so the producer canonicalises the string before sending it.

Endpoints. Two URLs are involved and their canonical forms differ:

Setting Owner Canonical form
gateway.endpointUrl (in CoreMail's desired state) producer wss://<dcrouter-ops-host>/ — with the trailing slash
transferOrigin (in the peer above) producer https://<coremail-host> — origin only, no trailing slash

endpointUrl points at dcrouter's ops server port (opsServerPort, default 3000) — the same TypedSocket surface platformclient's MAIL_TYPED_URL reaches. transferOrigin is CoreMail's own HTTPS origin, from which dcrouter fetches and to which it uploads message bytes; dcrouter returns it verbatim during authentication and CoreMail refuses the session unless it matches its own desired state exactly.

Routing mail to a mailbox. Give the address an inbound target of type coreMail:

{
  "address": "support@example.com",
  "inboundTarget": { "type": "coreMail", "coreMail": { "coreMailServiceId": "coremail-prod" } }
}

Such an address accepts inbound without any outbound SMTP credential, because CoreMail pulls its mail over its own authenticated session. At RCPT time dcrouter asks that address's own CoreMail service whether to accept; CoreMail may reject or defer with its own SMTP code, and an unhandled answer means CoreMail disclaims the recipient and dcrouter's existing decision stands unchanged.

An accepted or refused CoreMail decision is authoritative for that recipient and short-circuits a caller-supplied emailConfig.hooks.onRcptTo — the configured hook still runs for every recipient CoreMail does not own or disclaims. Only unhandled falls through to it.

Operational notes. A CoreMail-bound message is accepted into the normal inbound spool, so it appears in the Email Log and follows the spool's retry schedule. Routing handles expire five minutes after RCPT; a handoff that arrives later — including after a dcrouter restart that lost the RCPT-time state — is re-resolved against CoreMail under a fresh delivery identity and delivered, since the message already carries a 250. If CoreMail no longer claims a recipient at that point the message fails terminally as email-spool-failed-terminal with an error log stating that no bounce will follow, rather than being requeued until it is destroyed. Outbound delivery status is durable per message and terminal for an unknown id, so a workload is never left polling a message dcrouter has lost.

OCI / Container Bootstrap

runCli() supports an environment-driven container mode when DCROUTER_MODE=OCI_CONTAINER.

import { runCli } from '@serve.zone/dcrouter';

await runCli();

Supported environment overrides include:

Variable Purpose
DCROUTER_CONFIG_PATH JSON file loaded as the base IDcRouterOptions object.
DCROUTER_BASE_DIR Runtime data root.
DCROUTER_TLS_EMAIL / DCROUTER_TLS_DOMAIN TLS/ACME seed settings.
DCROUTER_PUBLIC_IP / DCROUTER_PROXY_IPS Public/proxy IP exposure settings.
DCROUTER_DNS_NS_DOMAINS Nameserver hostnames. DCROUTER_DNS_SCOPES is no longer honored — DNS authority is delegation-verified database state, and a container still setting it is warned at startup.
DCROUTER_EMAIL_HOSTNAME / DCROUTER_EMAIL_PORTS Email server seed settings.
DCROUTER_CACHE_ENABLED Enables or disables DB-backed persistence.
DCROUTER_MAX_CONNECTIONS, DCROUTER_MAX_CONNECTIONS_PER_IP, DCROUTER_CONNECTION_RATE_LIMIT SmartProxy capacity and rate-limit overrides.

Docker Image

Release builds publish a multi-arch OCI image for linux/amd64 and linux/arm64. The image sets DCROUTER_MODE=OCI_CONTAINER and starts node ./cli.js.

From 32.0.0 on the image is built from Dockerfile_##version##, so tsdocker publishes it under the release version tag only — code.foss.global/serve.zone/dcrouter:32.0.0 for this release — and dcrouter publishes no latest tag again. The existing dcrouter:latest stays frozen at the 20.2.0 build for consumers that still pull it by that name, including Onebox's managed dcrouter, so pin dcrouter by version.

docker run --rm --name dcrouter \
  --network host \
  -v dcrouter-data:/data \
  -e DCROUTER_BASE_DIR=/data \
  -e DCROUTER_TLS_EMAIL=ops@example.com \
  code.foss.global/serve.zone/dcrouter:32.0.0

Host networking is the simplest container mode for a gateway that owns HTTP/S, SMTP, DNS, RADIUS, remote ingress, and dynamic proxy ports. For narrower deployments, publish only the ports you enable in IDcRouterOptions or via the DCROUTER_* environment overrides.

Published Modules

This repository defines two npm packages from one codebase.

Package Entry point Purpose Platform Docs
@serve.zone/dcrouter . Runtime and orchestrator Linux x64/arm64 ./readme.md
@serve.zone/dcrouter ./interfaces Shared contracts as a subpath of the runtime Linux x64/arm64 ./ts_interfaces/readme.md
@serve.zone/dcrouter ./apiclient API client as a subpath of the runtime Linux x64/arm64 ./ts_apiclient/readme.md
@serve.zone/dcrouter-apiclient . API client without the server Linux x64/arm64 ./ts_apiclient/readme.md
@serve.zone/dcrouter-apiclient ./interfaces Shared contracts without the server Linux x64/arm64 ./ts_interfaces/readme.md

Install @serve.zone/dcrouter-apiclient when you consume the API rather than operate the router: it declares 8 dependencies against the runtime package's 48 and resolves a production closure of 184 packages instead of 579, dropping the native argon2 build, mongodb, @lossless.org/client, the dashboard component library, @push.rocks/smartdata and @push.rocks/smartbucket. @serve.zone/dcrouter keeps both subpath exports, so existing consumers migrate whenever it suits them. @serve.zone/dcrouter-apiclient has no release for 32.2.0 and earlier; a consumer pinned to one of those runtime versions imports the same client from @serve.zone/dcrouter/apiclient.

Both packages install on Linux x64 and arm64 only. Contract members are typed against @push.rocks/smartmta, @push.rocks/smartproxy and @push.rocks/smartnetwork, so the client package depends on them even though those imports are type-only, and @push.rocks/smartmta declares os: ["linux"] with cpu: ["x64", "arm64"]; installing elsewhere fails with ERR_PNPM_UNSUPPORTED_PLATFORM. The constraint is upstream: it goes away once @push.rocks/smartproxy, @push.rocks/smartmta and @push.rocks/smartnetwork move their Rust payloads into per-platform optional packages.

ts_migrations and ts_web are internal module boundaries of the runtime package and are not published separately (./ts_migrations/readme.md, ./ts_web/readme.md).

One gitzone release (gitzone 7 and later) publishes both packages and the Docker images at the same version. It qualifies the images first, publishes @serve.zone/dcrouter-apiclient before @serve.zone/dcrouter, and promotes the images only once both packages are verified on every registry, so every runtime release from then on has a client release of the same version.

Development

pnpm run build
pnpm test
pnpm run watch

Useful source entry points:

  • ts/index.ts exports DcRouter, runCli(), and public module surfaces.
  • ts/classes.dcrouter.ts owns service startup, dependency ordering, and IDcRouterOptions.
  • ts/opsserver/classes.opsserver.ts wires the dashboard server and TypedRequest handlers.
  • ts/remoteingress/ integrates @serve.zone/remoteingress with stored edge registrations.
  • ts_migrations/index.ts contains all DB schema migration steps.
  • Dockerfile_##version## builds the service image; the file name is the published image tag.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
Datacenter gateway for HTTP/S, TCP, DNS, mail, RADIUS, VPN, certificates, and remote ingress.
Readme
35 MiB
2026-09-29 05:07:41 +00:00
Languages
TypeScript 99.6%
Shell 0.3%