193 lines
10 KiB
Markdown
193 lines
10 KiB
Markdown
# 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).
|