From 420fa2ba2a1a86cb0cd638c95b67506e1a491214 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 21:06:44 -0700 Subject: [PATCH] P7 Private Network lane: product spec (Tailscale v1 + WireGuard self-hosted) + backlog tickets DC-140..DC-152 --- BACKLOG.md | 121 +++++++++++++++++++++ specs/PRIVATE-NETWORK-SPEC.md | 192 ++++++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 specs/PRIVATE-NETWORK-SPEC.md diff --git a/BACKLOG.md b/BACKLOG.md index b63d36f..5df898e 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -433,3 +433,124 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami - **prerequisite:** None. `caddy-upstream-watcher.js` already provides equivalent monitoring. - **result:** Shipped GLM-pending (Codex quota dead). Before/after on DNS2 (`journalctl -u caddy --since "5 minutes ago" | grep health_checker.active | wc -l`): **before = ~30 entries / 5min** (active probe every 10s, all failing); **after = 0 entries / 5min**. Live-verified: `caddy validate` succeeded (after removing `.bak` file that caused `ambiguous site definition`), Caddy reloaded via `caddy-apply`, route `arch.sami → 100.120.159.34:5000` still active in admin API (verified via `curl http://localhost:2019/config/apps/http/servers/srv0/routes` — `health_uri: None, health_interval: None` confirms the block is gone). Container `dashcaddy-api Up About an hour (healthy)` (no restart needed — only Caddyfile changed, not container). Live HTTP smoke all green: `https://status.sami=200`, `https://dashcaddy.net=200`, `https://ca.sami=200`, `https://status.sami/api/health=401` (auth-gated, expected). Watcher state for `100.120.159.34:5000`: `consecutiveFailures: 1905`, `lastError: "probe timeout"`, `status: down`, `dead: true` — correctly tracked in `/opt/dashcaddy/dashcaddy-api/data/caddy-upstreams.json`. Backup deleted (would have caused site-definition ambiguity on next Caddy reload). Git: change lives only in DNS2's `/etc/caddy/sites/arch.sami` (the `/etc/caddy` git repo `.gitignore` excludes `sites/` per design — only the main `Caddyfile` is tracked). The dashcaddy source repo (`/root/dashcaddy`) carries only this BACKLOG.md documentation update on branch `dc/DC-084-arch-sami-caddy-healthcheck-removal`. - **Tests:** No source code change; existing `__tests__/caddy-upstream-watcher.test.js` 26/26 pass (baseline preserved). 2465/2465 repo tests pass (4 pre-existing billing test suites fail with `Cannot find module pdfkit` — unrelated to this change). +# P7 — Private Network lane (DC-140 … DC-152) + +> Spec: `specs/PRIVATE-NETWORK-SPEC.md`. Parent feature: DashCaddy Private Network +> (Tailscale mode v1 + WireGuard self-hosted mode). Ordered by dependency, not by +> value. All commits judge-gated (min B). + +### DC-140: Network provider abstraction (`src/network/`) +- **status:** todo +- **owner:** (fleet) +- **details:** `provider.js` (contract + JSDoc), `registry.js` (active provider + + capabilities), `errors.js` (ProviderError with code/retryable). No feature code + may import a provider directly — registry only. Provider contract methods: + getStatus, listDevices, listPeople, createInvite, revokeInvite, blockPerson, + unblockPerson, removePerson, revokeDevice, syncAccess, whois, configureDns. + Tailscale provider wraps existing tailscale-coord.js + tailscale-manager.js. + Registry defaults to inactive until the wizard connects one. Tests: registry + resolution, capability flags, error mapping. Files: `src/network/*.js`, + `__tests__/network-registry.test.js`, `__tests__/network-provider-tailscale.test.js`. + +### DC-141: People store + routes (`routes/network-people.js`) +- **status:** todo +- **details:** people.json atomic store (invite-store pattern), CRUD routes under + `/api/v1/network/people`, admin-protection (`protectedAt`), last-admin guard + `assertNotLastAdmin()` exported for reuse. Sync endpoint merging provider users + into people. Tests: store CRUD, route contract, guard (cannot block/remove + last admin), sync merge idempotency. + +### DC-142: Tailscale invites (link-first) via provider +- **status:** todo +- **details:** POST/GET/DELETE `/api/v1/network/invites` calling provider + createInvite/revokeInvite. Tailscale: user-invites API, email optional — + inviteUrl is the artifact (share-text pre-formed sentence, copy button, + navigator.share, opt-in email checkbox; never log raw invite URLs to console). + Reuse DC-085 share pattern. Tests: route contract with mocked provider, + no-raw-token-in-logs assertion. + +### DC-143: Block / unblock / remove person + revoke device +- **status:** todo +- **details:** POST `/network/people/:id/block|unblock|remove`, + `/network/devices/:id/revoke`. Orchestrates: provider calls, session kill + (existing auth store), access-map purge/restore, audit log entries. Block must + be fully reversible (snapshots pre-block grants). Remove deletes devices + + person record. Tests: orchestration order (mocked provider), reversibility, + last-admin guard integration, audit entries written. + +### DC-143 cleanup note: access map module itself is DC-145. + +### DC-144: Groups (DashCaddy-side) + resources +- **status:** todo +- **details:** groups in people-store (array on person), group CRUD routes, + resource model (services registry projection + manual entries), per-resource + `sensitive` flag. No Tailscale-group mapping — ever (free plan caps 3; compiled + rules are per-user). Tests: group CRUD, resource projection, sensitive flag + validation. + +### DC-145: Access map store + grid API +- **status:** todo +- **details:** `network-access.json` grants store, bulk GET/PUT + `/network/access` (whole-grid save, atomic), effective-access computation + (group grants + person overrides), change events for compiler subscription. + This file is the single source of truth. Tests: store, effective access + resolution (group + override precedence), atomic whole-grid save. + +### DC-146: Technitium integration +- **status:** todo +- **details:** Technitium HTTP API client (credential via credential-manager), + per-resource/per-device record upserts (idempotent), split-DNS configure when + provider capability present, verify-by-resolve after write, wizard check step. + Tests: API client with mocked HTTP, idempotent upsert, failure paths. + +### DC-147: CA naming + name constraint + install page hardening +- **status:** todo +- issued from existing DashCA (routes/ca.js): operator-named root (setup-time, + rename = guided re-trust), critical nameConstraints (permitted DNS subtree = + private TLD), leaf regression tests (SAN + serverAuth EKU + ≤398d), branded + install page with per-OS steps incl. iOS enable-trust second step + fingerprint + + Android native-app caveat, encrypted root-key backup step (passphrase + + checksum). Tests: parse issued cert and assert constraint/SAN/EKU/validity; + install-page content checks. + +### DC-148: Tailscale ACL compiler + lockout guard +- **status:** todo +- **details:** access map → per-user HuJSON policy compiler. Hard rules: + last-active-admin must keep access to the DashCaddy host resource; host device + keeps egress; dry-run (ACL preview/test) before every push; last-known-good + policy retained for rollback; post-push hash stored, pre-push hash compare → + drift banner on mismatch. Provider.syncAccess(tailscale) uses this. Tests: + golden-policy cases, lockout adversarial cases (each rule), drift detection, + preview-failure refusal to push. + +### DC-149: WireGuard provider — hub management +- **status:** todo +- **details:** `providers/wireguard.js`: wg-quick iface `dcnet0`, address pool + 10.d.c.0.0/16 stable assignment, keygen on hub, peer store, systemd unit + if missing. All execFile-mocked in tests. Tests: pool assignment stability, + peer add/remove, unit generation, error paths (missing wg binary). + +### DC-150: WireGuard invites (QR) + client config +- **status:** todo +- **details:** one-time token (7d TTL, single-use) → redemption page rendering + client config + QR (qrencode -t ansiutf8 → pre? no: PNG served) with Address, + DNS=Technitium hub IP, AllowedIPs = private zone only (split tunnel). Private + key shown once, never stored server-side. Tests: token lifecycle, config + template correctness (allowed-ips, DNS), single-use enforcement, expiry. + +### DC-151: WireGuard firewall matrix (nftables) +- **status:** todo +- **details:** per-resource nft sets (net_dc_res_), peer IPs per grant, + transactional apply (build temp ruleset → `nft -c` → `nft -f` → verify → + rollback file), host admin ports restricted to admin peers. Tests: ruleset + golden text, transactional failure injection (nft -c fails → no apply), + verify-step failure, admin-port isolation. + +### DC-152: Setup wizard + status page +- **status:** todo +- **details:** mode pick → credentials/endpoint → private domain pick + (.internal default, home.arpa, custom+warning) → provider connect + sync → + Technitium check → CA naming (locked-in warning) → first admin person. + Status page: who's online, recent connections (provider status + DashCaddy + connection log). Reuse monitoring widget patterns. Tests: wizard step + contracts, status aggregation with mocked providers. diff --git a/specs/PRIVATE-NETWORK-SPEC.md b/specs/PRIVATE-NETWORK-SPEC.md new file mode 100644 index 0000000..c92189c --- /dev/null +++ b/specs/PRIVATE-NETWORK-SPEC.md @@ -0,0 +1,192 @@ +# 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).