@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 |
| 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:
getAdminBootstrapStatusreports 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_PASSWORDto 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 mode0600. 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. createInitialAdminUsercreates the first persisted admin with normalized email and local password authentication.- Optional
idp.globalauthentication can be enabled for that local account. SDK 17 uses the hostedhttps://app.idp.globalendpoint by default;adminAuth.idpGlobalUrlorDCROUTER_IDP_GLOBAL_URLcan 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.enableddefaults to enabled. WithoutmongoDbUrl, 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
443are HTTP/3-augmented unlesshttp3.enabled === falseor the route opts out. - One TLS-terminating DNS-over-HTTPS socket route is generated on the first
dnsNsDomainsentry. SmartDNS accepts RFC 8484 requests on/dns-queryand the compatibility/resolvealias after TLS termination. - Email listener ports can be remapped internally, for example public
25,587, and465to 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
readWebPushormanageWebPushcapability. App calls use the one-time binding credential returned bysyncWebPushBinding; 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 acceptsdisabledordeleted, 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_admissionandweb_push_spoolagainst 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 theweb-push-provider-schemamigration 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_RINGmust hold the key each existing envelope names before the first 32.0.0 start; otherwise thewebpush-encryption-contextmigration refuses by name and dcrouter does not start. The push payload also loses itsschemaVersionmember with@serve.zone/interfaces32, 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. deleteWebPushBindingis 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 remainssendinguntil 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'shostnamePatterns. - A record's value must lie inside the client's
allowedDnsRecordAddresses, a list of canonical CIDR prefixes. Set it withprovisionGatewayClientCredential, the admincreateGatewayClientandupdateGatewayClientrequests, 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 answersrecord-conflictfor a hostname that an ownership holds, and a testing grant for it is refused on request and on approval withownership_conflict, also when the ownership holds only a certificate. - Records are stored as DNS records managed by
gateway-client-hostnameand 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-unmanagedotherwise). 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 answersissuance-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 ispendingand the issuance continues. An attempt SmartAcme can no longer complete isrefusedwithissuance-failedand theretryAfterat which SmartAcme places a new order. Asking again afterrenewAfterrenews 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 whenrateLimit.onExceeded.type === 'challenge'. - Source-binding rate limits are always keyed by source IP; dcrouter ignores
pathandheaderkeying on source-binding and path-policy overrides. - Source-binding and path-policy
rateLimitandchallengefields use tri-state semantics: omitted means inherit,nullmeans 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 routerateLimitandchallengevalues are not part of that inheritance chain; routes without source bindings use normal SmartProxy route security. - Direct
challengeprotection applies to matching HTTP-visible requests.rateLimit.onExceeded.type === 'challenge'configures a browser challenge for rate-limit exceed events. - Binding-level and path-policy
onExceededis only for429message handling. Browser challenge-on-exceeded behavior belongs onrateLimit.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 both0.0.0.0/0and::/0, insecurity.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:
challengeon 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:
emailConfig.tls.certPem/keyPem— inline PEM.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.- The stored ACME certificate for
emailConfig.hostnamein 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.tsexportsDcRouter,runCli(), and public module surfaces.ts/classes.dcrouter.tsowns service startup, dependency ordering, andIDcRouterOptions.ts/opsserver/classes.opsserver.tswires the dashboard server and TypedRequest handlers.ts/remoteingress/integrates@serve.zone/remoteingresswith stored edge registrations.ts_migrations/index.tscontains all DB schema migration steps.Dockerfile_##version##builds the service image; the file name is the published image tag.
License and Legal Information
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.