# 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 | | 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-enable + ACL restore | re-add peer | | `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 (reversible): expire device keys + deny at network layer + kill DashCaddy sessions + mark person blocked. Unblock reverses all four. - 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) - Hub: `wg-quick` interface `dcnet0` managed by DashCaddy ( systemd unit `dcnet-hub.service` if absent ), 10.D.C.0.0/16 address pool with stable per-device assignment stored in people.json. - Keygen on the hub; peer private keys exist only in the one-time invite redemption (client config + QR). Server stores pubkeys only. - Client config template: Address, DNS = Technitium hub IP, AllowedIPs = private zone subnet only (split tunnel — normal internet traffic never touches the hub). - Invite redemption: one-time token → render config + QR (qrencode), page auto-expires; token single-use, 7-day TTL. - Firewall: nftables matrix (above). 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. ## 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).