# DashCaddy Private Network — Product Spec (v1) > Source: feature brief draft v4 (Sami, 2026-10-03) + technical review (same thread). > Status: **approved for build** — "Do it and then build the non-tailscale version as well." > Tickets: DC-140 … DC-152 (see BACKLOG.md "P7 — Private Network lane"). > Judge gate: every commit in this lane is graded via codex-as-judge, min B. ## 1. What we're building DashCaddy turns a tailnet (or a self-hosted WireGuard hub) into a **managed private network**: people, devices, per-app access, private DNS names, and a personal certificate authority — all from one panel. Two network modes, one product: | | Tailscale mode (v1 default) | WireGuard mode (self-hosted) | |---|---|---| | Data plane | Tailscale (direct, NAT traversal) | WireGuard hub run by DashCaddy on the host | | Control plane | api.tailscale.com (OAuth client preferred; API key fallback) | DashCaddy itself (host wg-quick + key store) | | Invites | Tailscale user-invites API, link-first (no email required) | DashCaddy invite link + QR config | | Block/remove | API device expire/delete + ACL deny | Remove peer from hub + firewall rule | | Unblock | Re-allow at ACL level; expired device keys must re-authenticate (see §5) | Re-add peer from stored record (immediate) | | Rules | Tailnet policy ACLs compiled by DashCaddy | Hub firewall (nftables set-based) compiled by DashCaddy | | Outside dependency | Yes | None | **Everything above the provider is shared**: people, groups, resources, the access map, forward_auth enforcement, Technitium names, the CA, the UI. This is enforced by the provider abstraction (DC-140) — no feature module may call a provider directly. ## 2. Core concepts and stores - **Person** — a human. `people.json` (atomic JSON store, same pattern as invite-store/user-store). Fields: `id`, `displayName`, `providerUserId` (Tailscale user id or WG pubkey identity), `groups[]`, `status: active|blocked|removed`, `devices[]`, `createdAt`, `protectedAt` (admin guard). - **Device** — one machine. Belongs to a person. `keyId` (TS device id / WG peer pubkey), `name`, `ip` (tailnet IP), `online`, `lastSeen`, `os`. - **Group** — named bundle of people, DashCaddy-side only (UI concept). Free Tailscale plans cap ACL groups at 3 — therefore **DashCaddy groups never map to Tailscale groups**; compiled ACLs reference users directly. - **Resource** — an app or device reachable by name. `plex.internal`, `nas.internal`. Derived from the existing services registry + manual entries. - **Access map** — groups/people × resources grid. Single source of truth. `network-access.json`: `{ grants: [{ principal: {type:'person'|'group', id}, resource: 'plex', allowed: bool }] }`. This file, not Tailscale state, is authoritative. ## 3. Provider abstraction (DC-140) `src/network/` package: ``` src/network/provider.js — NetworkProvider interface (documented contract) src/network/registry.js — active provider resolution + capability flags src/network/errors.js — ProviderError { code, retryable } src/network/providers/tailscale.js src/network/providers/wireguard.js ``` Contract (all async, all return plain JSON, all validated): | Method | Tailscale impl | WireGuard impl | |---|---|---| | `id()`, `capabilities()` | `'tailscale'` | `'wireguard'` | | `getStatus()` | tailscale status --json (cached, existing manager) | wg show dump + iface up/down | | `listDevices()` | API + local status merge | peer store read | | `listPeople()` | API users → people projection | people.json (self-owned) | | `createInvite({role, email?})` | POST user-invites (email optional → returns inviteUrl) | mint one-time invite token in store | | `revokeInvite(id)` | DELETE user-invite | delete token | | `blockPerson(userId)` | expire all device keys + ACL deny | drop peer + firewall deny set | | `unblockPerson(userId)` | re-allow at ACL level; expired device keys stay expired (re-auth required, §5) | re-add stored peer (immediate) | | `removePerson(userId)` | DELETE devices, revoke invites | remove peer + person record | | `revokeDevice(deviceId)` | expire/delete device key | remove single peer | | `syncAccess(map, resources)` | compile ACLs → dry-run → push (hash-guarded) | compile nftables → apply → verify | | `whois(ip)` | local tailscale whois | peer table lookup | | `configureDns({zone, server})` | split-DNS API | not supported (Technitium direct) | Capabilities: `{ invites, splitDnsApi, perPeerFirewall, selfHosted }`. Feature modules check capabilities and degrade cleanly (e.g. WireGuard mode skips the split-DNS API step and writes Technitium directly). **Existing routes untouched.** `routes/tailscale.js` / `tailscale-admin.js` keep working as-is; the new `routes/network.js` + `src/network/` are additive. ## 4. Enforcement — two layers **Layer 1: network.** The access map compiles to provider rules. - Tailscale: HuJSON policy with per-user src entries (never groups), `dst` as `host:port` per resource. DashCaddy host always keeps an explicit allow for the admin. **Lockout guard** (hard rule, tested): refuse to push any policy where (a) the last active admin person would lose access to the DashCaddy host resource, or (b) the DashCaddy host device would lose egress. Use the ACL preview/dry-run endpoint before every push; keep the last-known-good policy body for one-click rollback. Store the policy hash after each push and compare before the next — outside edits ⇒ drift banner, not silent overwrite. - WireGuard: hub-and-spoke means everyone can reach the hub; per-resource enforcement is a hub nftables matrix. One nft set per resource (`net_dc_res_plex`), peer IPs added per grant. **Apply is transactional**: build the full ruleset to a temp file, `nft -c` (check), swap atomically via `nft -f`, verify with `nft list set`, roll back file kept. **Layer 2: app (forward_auth).** Shared across providers. - Whois path: forward_auth asks DashCaddy which person owns the connecting device (provider.whois), then checks the access map. Tailnet users get in without a separate login. **Identity follows the device** — a shared device grants the owner's access; documented in the UI. - Step-up: resources flagged `sensitive` additionally require the existing DashCaddy login + TOTP (reuse sso-gate). ## 5. People lifecycle (DC-141, DC-144) - Invite = link-first (DC-085 pattern): inviteUrl is the artifact, email is an opt-in checkbox, never logged raw to console. - Block: network-layer deny (ACL / firewall) + expire Tailscale device keys + kill DashCaddy sessions + mark person blocked. **Unblock honestly:** the deny, the status, and the session policy reverse — but expired Tailscale device keys are NOT un-expired; those devices must re-authenticate with Tailscale on next connect. The UI says "blocked devices will need to sign in again" rather than pretending instant reversal. WireGuard peers restore immediately on re-add (peer config is re-generated server-side from the stored pubkey). - Remove: block semantics + delete provider devices + delete person record + purge from access map. The last active admin can never be blocked/removed (same guard as the ACL compiler — one shared `assertNotLastAdmin()`). ## 6. Names — Technitium (DC-146) - Per-resource + per-device A/AAAA records → tailnet IPs, written via Technitium HTTP API (credential in credential-manager, existing pattern). - Split DNS: Tailscale mode uses the API when available; check + repair wizard step. WireGuard mode: devices get Technitium's hub IP as their DNS entry in the generated client config. - Record writes are idempotent (upsert by name+type) and verified by resolve. ## 7. Certificate authority (DC-147) Builds on the existing DashCA pipeline (routes/ca.js, DC-076). Changes: - Root CN = operator-chosen name ("Ahmed Family Certificate Authority"), captured at first setup, baked in; rename ⇒ guided re-trust flow (warn at naming time, not after). - Root gets a **critical nameConstraints extension** (permittedSubtrees DNS:). Mark critical so non-understanding clients fail closed. - Leaves ≤ 398 days, SANs always set, serverAuth EKU (iOS 13+ requirements) — the existing openssl leaf path already complies; add regression tests that parse the issued cert and assert all three. - Branded install page at ca.: per-OS steps including **iOS's second step** (enable trust in Settings → General → About → Certificate Trust Settings), SHA-256 fingerprint for verification, and an Android note that native apps ignore user CAs (browsers fine). - Root key never leaves the host except through an explicit wizard step: encrypted backup download ( passphrase-wrapped ) + checksum. ## 8. WireGuard provider (DC-149–151) ### 8.1 Addressing and routing (exact prefixes) - Hub interface `dcnet0`: 10.53.0.1/16, listen port chosen at setup (default 51820, UDP). Endpoint = host's reachable address (setup captures it; wizard warns if it's behind NAT without a forwarded port). - Client address pool: 10.53.0.0/16, one /32 per device (10.53.x.y/32 assigned on device add, stable, stored in people.json devices[]). - **AllowedIPs = 10.53.0.0/16 only** (a CIDR, never a DNS zone). Split tunnel: client's other traffic is untouched. Resource names resolve via Technitium (8.3) to IPs inside 10.53.0.0/16, which routes through the tunnel. - Hub forwarding: `net.ipv4.ip_forward=1` is required for hub-and-spoke peer→peer traffic; peer→hub-resource traffic is local. No NAT/masquerade on dcnet0 — all resource access is to hub-local services on 10.53.0.1 or to other peers' /32s, so addresses stay routable and firewall rules match peers. - Firewall baseline — three distinct zones, never conflated: (a) **External input**: the WireGuard UDP listen port arrives on the EXTERNAL interface (e.g. eth0), not dcnet0 — its accept rule is scoped to the external interface name (captured at setup). (b) **dcnet0 local input** (iifname dcnet0): ICMP from 10.53.0.0/16, admin ports (API/SSH on 10.53.0.1) from admin peers only, per-resource ports to 10.53.0.1; drop other new inbound to 10.53.0.1. (c) **dcnet0 forwarding** (ip_forward=1): peer→resource gated by per-resource sets; peer→peer default DROP (both iifname and oifname dcnet0 tails) unless a grant implies it. ### 8.2 Key lifecycle (no server-side private-key storage) - **Client keypairs are generated in the invitee's browser** using WebCrypto **X25519** — WireGuard requires Curve25519 key material; there is NO fallback algorithm (a browser lacking X25519 support gets an explicit "browser not supported" error, not a degraded key). Serialization (browser-export reality): `exportKey('raw', publicKey)` yields the raw 32-byte public key → base64. `exportKey('pkcs8', privateKey)` yields a PKCS#8 wrapper — the page strips the fixed 16-byte PKCS#8 X25519 prefix (`302e020100300506032b656e0422 0420`) to extract the raw 32-byte private key → base64 (`wg genkey`-format; the extraction is pure byte-slicing of a fixed-prefix wrapper, verified by length/constant-prefix assertions before use). The server receives the base64 public key (44 chars) and validates length/charset before use. - The redemption page posts ONLY the public key to the server; the server registers it as a WireGuard peer (10.53.x.y/32) and returns the client config (Address, DNS, PublicKey of hub, AllowedIPs = 10.53.0.0/16). - The private key NEVER transits or rests on the server: it lives in the browser page for the session, rendered into the config + QR client-side. - Server stores: peer public keys, assigned /32s, names — nothing else. - Revocation = remove peer (pubkey) from the hub + firewall-set update; the client's private key becomes useless without hub acceptance. ### 8.3 DNS - Technitium serves the private zone on the hub IP (10.53.0.1). Client config DNS = 10.53.0.1. Per-resource/per-device records resolve to 10.53.x.y /32s (per DC-146). In Tailscale mode, the same records hold tailnet IPs instead. ### 8.4 Hub management - `wg-quick` interface `dcnet0` managed by DashCaddy (systemd unit `dcnet-hub.service` if absent), keygen for the HUB keypair on the host (hub key is host-local and backed up with it). ### 8.5 Invites - Invite redemption: one-time token (7-day TTL, single-use) → redemption page (browser keygen per 8.2) → config + QR rendered client-side, page auto-expires after redemption. - Firewall: nftables matrix (section 4). Server resources listen on the hub IP; per-resource sets gate peer access. The host's own admin ports are NOT exposed on dcnet0 except to admins. WireGuard mode dependency note: "no third party" still depends on host networking — a reachable UDP endpoint, wireguard-tools, nftables, and Technitium on the DashCaddy host. ## 9. Setup wizard (DC-152) Single flow, mode pick first: 1. Mode: Tailscale (paste OAuth client secret or API key — surface expiry warnings for API keys, ≤90-day; recommend OAuth client `tskey-client-`) or WireGuard (port, endpoint hostname/IP). 2. Private domain pick (default `.internal`, `home.arpa` offered, custom with "could become real later" warning). 3. Provider connect + health check + first sync. 4. Technitium zone + split-DNS check. 5. CA naming (locked-in warning) + root generation + install page link. 6. First person (the admin — protected). ## 10. Non-goals (explicitly out of scope for this lane) - Headscale support. Subnet routers / LAN access. Time-limited access. - Multi-hub HA. Client auto-updaters. Migration between modes with data carry-over (documented manual path only — switching re-runs the wizard). ## 11. Test strategy - Every new module: Jest unit tests with the same mock rigor as tailscale-manager.test.js (no live network in tests). - ACL compiler: golden-policy tests (map JSON → expected HuJSON), lockout-guard adversarial cases (last-admin removal, host egress cut, drift detection). - WG provider: execFile-mocked `wg`/`nft` binaries; ruleset built to temp and compared as text; transactional-apply failure injection. - forward_auth: route tests against the running supertest app (existing __tests__/routes pattern), including shared-device semantics. - After merge + deploy to the dev rig: live-curl every new route (the DC-048 discipline — hermetic green is not production proof).