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

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