P7 Private Network lane: product spec (Tailscale v1 + WireGuard self-hosted) + backlog tickets DC-140..DC-152
This commit is contained in:
2 files changed
+313
No files matched your search
@@ -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).
|
||||
Reference in new issue
Block a user