P7 Private Network lane: product spec (Tailscale v1 + WireGuard self-hosted) + backlog tickets DC-140..DC-152

This commit is contained in:
Hermes committed 2026-10-03 21:06:44 -07:00
1 parent 7557b49b5b
commit 420fa2ba2a
2 files changed
+313

No files matched your search

+121
View File
@@ -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_<name>), 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.
+192
View File
@@ -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:<private-tld>). 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.<tld>: 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).