# @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/](https://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/](https://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, HTTP-01 fallback, stored certificate metadata, 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:

```bash
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.

```bash
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:

```bash
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.

```typescript
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.
- `createInitialAdminUser` creates the first persisted admin with normalized email and local password authentication.
- Optional `idp.global` authentication can be enabled for that local account. The hosted `https://idp.global` endpoint is used by default, `adminAuth.idpGlobalUrl` or `DCROUTER_IDP_GLOBAL_URL` only override it, and the local dcrouter role remains authoritative.
- 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` | Smartdata persistence 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.

## 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 `17.8.0` 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.

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.
- 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.

## 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.

```typescript
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.

SmartAcme is configured with DNS-01 priority for managed domains. If SmartAcme is still starting or retrying account setup, the certificate provision callback falls back to HTTP-01 for that request. 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

```typescript
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.

```typescript
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.

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.

```bash
pnpm add @serve.zone/dcrouter-apiclient
```

```typescript
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/interfaces` or `@serve.zone/dcrouter-interfaces` 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.

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 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 `egressTcpV1` and `egressIdentityV1`, 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.

RemoteIngress v5 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.

## OCI / Container Bootstrap

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

```typescript
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 at `code.foss.global/serve.zone/dcrouter:latest` for `linux/amd64` and `linux/arm64`. The image sets `DCROUTER_MODE=OCI_CONTAINER` and starts `node ./cli.js`.

```bash
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:latest
```

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 intentionally publishes multiple module boundaries from one codebase.

| Module | Purpose | Docs |
| --- | --- | --- |
| `@serve.zone/dcrouter` | Main runtime and orchestrator | `./readme.md` |
| `@serve.zone/dcrouter/interfaces` | Shared contracts as a subpath export | `./ts_interfaces/readme.md` |
| `@serve.zone/dcrouter/apiclient` | API client as a subpath export | `./ts_apiclient/readme.md` |
| `@serve.zone/dcrouter-interfaces` | Standalone contracts package | `./ts_interfaces/readme.md` |
| `@serve.zone/dcrouter-apiclient` | Standalone OO API client package | `./ts_apiclient/readme.md` |
| `@serve.zone/dcrouter-migrations` | Standalone migration runner package | `./ts_migrations/readme.md` |
| `@serve.zone/dcrouter-web` | Dashboard frontend module boundary | `./ts_web/readme.md` |

## Development

```bash
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.

## 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](./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.
