Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39602afe5 | ||
|
|
a4f961d609 | ||
|
|
5f47e12f18 | ||
|
|
2e6d535242 | ||
|
|
420fa2ba2a | ||
|
|
7557b49b5b | ||
|
|
65a447e27e | ||
|
|
121caef488 |
No files matched your search
+134
@@ -433,3 +433,137 @@ 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
|
||||
snapshots pre-block grants; unblock restores the AUTHORIZATION state (grants,
|
||||
status, sessions) — Tailscale device keys, once expired, are irreversible and
|
||||
require re-authentication (reauthRequired=true + user note); WireGuard peers
|
||||
restore immediately. Remove deletes devices + person record. Tests: orchestration order (mocked provider), honest unblock
|
||||
semantics (Tailscale: reauthRequired true + note; WireGuard: immediate
|
||||
restore), last-admin guard integration, audit entries written.
|
||||
|
||||
**Note (not a ticket):** the 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` at 10.53.0.1/16
|
||||
(client pool 10.53.0.0/16, one /32 per device, stable assignment), HUB keypair
|
||||
generated host-side, peer store (pubkeys only), systemd unit if missing,
|
||||
net.ipv4.ip_forward=1 for hub-and-spoke, no NAT on dcnet0. 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. Client
|
||||
keypair generated IN THE BROWSER via WebCrypto X25519 ONLY — NO fallback
|
||||
algorithm; browsers lacking X25519 get an explicit unsupported-browser
|
||||
failure. Server receives the public key only, registers the peer /32,
|
||||
returns config
|
||||
(Address, DNS=10.53.0.1, hub PublicKey, AllowedIPs=10.53.0.0/16 — a CIDR,
|
||||
never a DNS zone). Config + QR rendered client-side; private key never
|
||||
transits or rests on the server. Tests: token lifecycle, single-use
|
||||
enforcement, expiry, server-rejects-private-key-posts, config template
|
||||
correctness.
|
||||
|
||||
### 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), three-zone baseline: external-interface UDP listen-port accept
|
||||
(NOT on dcnet0); dcnet0 local input = ICMP + admin ports from admin set only +
|
||||
resource ports; dcnet0 forwarding = per-resource sets, peer-to-peer default
|
||||
drop. 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.
|
||||
@@ -0,0 +1,201 @@
|
||||
# Evidence file for DC-PRODUCTION-GRADE-BACKLOG.md P6 section (rev14)
|
||||
|
||||
Verbatim excerpts captured 2026-09-14 from the production tree `/opt/dashcaddy`
|
||||
and the live DNS2 deployment, so every live-state claim in the backlog can be
|
||||
checked against this file.
|
||||
|
||||
## E1. Self-updater contract — dashcaddy-api/src/docker/self-updater.js (production tree /opt/dashcaddy)
|
||||
|
||||
Verbatim grep output (`grep -n "UPDATE_URL\|MIRROR_URL\|CHANNEL\|checkInterval\|INTERVAL" ...`):
|
||||
```
|
||||
24: CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
|
||||
25: UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
|
||||
26: MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
|
||||
35: CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
|
||||
46: checkInterval: parseInt(options.checkInterval || DEFAULTS.CHECK_INTERVAL, 10),
|
||||
47: updateUrl: options.updateUrl || DEFAULTS.UPDATE_URL,
|
||||
48: mirrorUrl: options.mirrorUrl || DEFAULTS.MIRROR_URL,
|
||||
64: channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
|
||||
```
|
||||
Revoked kill switch, verbatim (same file, lines 531/535):
|
||||
```js
|
||||
if (remote?.revoked === true) {
|
||||
```
|
||||
```js
|
||||
reason: 'release revoked',
|
||||
```
|
||||
Proves: 30-min poll interval, update + mirror feed URLs, channel selection,
|
||||
and the `revoked: true` kill switch — all in `self-updater.js` of the
|
||||
production tree.
|
||||
|
||||
## E2. Shipdeck v0 DoD — shipdeck/docs/SPEC.md, verbatim
|
||||
|
||||
```
|
||||
## Definition of done (v0) — MET 2026-09-14
|
||||
- [x] `shipdeck deploy` lands a real Go hello-world on samihost end-to-end:
|
||||
build → systemd active → caddy gate (tailnet 200 / public 403) → DNS on
|
||||
DNS2+DNS1 → HTTP health 200 → journal row (epochs 1789360016, …0128,
|
||||
…0610, …0672; rollback …0128→…0016 verified with content diff)
|
||||
- [x] `shipdeck status` all green; `shipdeck rollback` swaps + re-verifies green
|
||||
```
|
||||
|
||||
## E3. Version endpoint — production tree /opt/dashcaddy, verbatim grep output
|
||||
|
||||
PUBLIC_ROUTES membership (`src/utilities/middleware.js`):
|
||||
```
|
||||
488- { path: '/api/v1/monitoring/stats', exact: true, method: 'GET', monitoring: true },
|
||||
489- { path: '/api/v1/health-checks/status', exact: true, method: 'GET', monitoring: true },
|
||||
490: { path: '/api/v1/version', exact: true, method: 'GET' },
|
||||
```
|
||||
Route registration at startup (`src/app.js`, verbatim `sed -n '519,523p'`):
|
||||
```js
|
||||
appName = versionRoute.getName();
|
||||
// Pre-build the version router once at startup and reuse it.
|
||||
const versionRouter = versionRoute.buildRouter();
|
||||
apiRouter.use(versionRouter);
|
||||
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
|
||||
```
|
||||
(The version HANDLER source lives in the route module wired by
|
||||
`versionRoute.buildRouter()` — `/api/v1/version` returns
|
||||
`{ name, version, node, platform, arch, uptime, instanceId }` from
|
||||
`package.json` read at startup, per the E5 live response below.)
|
||||
|
||||
## E4. Installer build targets — dashcaddy-installer/BUILD_GUIDE.md, verbatim
|
||||
|
||||
```
|
||||
# Windows (creates portable .exe and installer)
|
||||
# macOS zip (from any host; electron-builder's native target)
|
||||
npm run build:mac
|
||||
# macOS real drag-install .dmg — built ON LINUX, no Mac needed
|
||||
# (one-time toolchain: see scripts/build-dmg-linux.sh header)
|
||||
# Linux (creates AppImage and .deb)
|
||||
npm run build:linux
|
||||
```
|
||||
Output artifacts (same file): portable + NSIS .exe (Windows), mac .zip +
|
||||
Linux-built .dmg (macOS, unsigned), AppImage + .deb (Linux).
|
||||
|
||||
## E5. Live deployment state on DNS2 — complete captured output (rev14), capture start 2026-09-14T11:50:10Z, all three exit codes 0
|
||||
|
||||
The block below is the COMPLETE, unedited terminal capture of the three
|
||||
commands (per-command timestamp + exit status embedded). The first command's
|
||||
stdout is the full version.json — no ellipses; the long changelog string is
|
||||
part of the real response.
|
||||
|
||||
```
|
||||
=== E5 CAPTURE 2026-09-14T11:50:10Z — per-command timestamps below ===
|
||||
$ curl -sk --resolve get.dashcaddy.net:443:127.0.0.1 https://get.dashcaddy.net/release/version.json
|
||||
{
|
||||
"version": "1.16.0",
|
||||
"commit": "70e252c",
|
||||
"channel": "stable",
|
||||
"url": "https://get.dashcaddy.net/release/latest.tar.gz",
|
||||
"sha256": "f4dd3a6efe70a99c64b2b2093af13b79943846f83546c76fba9f99b24e489357",
|
||||
"publishedAt": "2026-09-13T12:45:26Z",
|
||||
"changelog": "DashCaddy v1.16.0 \u2014 self-updater hardening + auto-update enabled + docker disk discipline\n\nFIXES\n- self-updater: same-version releases are NEVER \"newer\" again (DC-122). Commit\n labels are opaque build stamps; only a semver bump counts. This bug made\n same-version installs with any commit-string difference report\n \"update available\" forever and would have re-applied stale tarballs in a\n loop once auto-update was enabled.\n- self-updater: _autoCheckAndApply skips re-applying an identical\n version@sha256 within a process lifetime (defense-in-depth against apply\n loops).\n- dashcaddy-update.sh: data-dir cp fallback copied the directory INTO the\n destination (nested data/data), so rollback restored nothing. Now copies\n contents (\"dir/.\") \u2014 backup AND restore paths fixed.\n- dashcaddy-update.sh: frontend is now snapshotted before deploy and restored\n on build-failure and health-check-failure rollbacks. Previously a failed\n update left the NEW frontend paired with the ROLLED BACK API.\n- start.sh: bundle sync is newer-source-only. The old unconditional copy\n reverted self-updater-deployed frontends on every container restart.\n- dashcaddy-update.sh: docker prune now runs on the success path, both\n failure paths, and after rollbacks (shared prune_docker helper).\n\nCHANGES\n- start.sh: DASHCADDY_UPDATE_ENABLED=true \u2014 auto-update is ON (Sami 2026-09-13).\n- start.sh + fallback docker run: json-file log caps (10MB x 3) so container\n logs can never grow unbounded again.\n\nTESTS\n- __tests__/self-updater-isnewer.test.js: 8 regression cases covering the\n same-version commit-mismatch bug, semver ordering, and edge inputs."
|
||||
}curl_exit=0 at 2026-09-14T11:50:10Z
|
||||
|
||||
$ curl -s -m 6 http://127.0.0.1:3001/api/v1/version
|
||||
{"success":true,"name":"dashcaddy-api","version":"1.16.0","node":"v20.11.1","platform":"linux","arch":"x64","uptime":1768.092256197,"instanceId":null}curl_exit=0 at 2026-09-14T11:50:10Z
|
||||
|
||||
$ docker inspect dashcaddy-api --format '{{range .Config.Env}}{{println .}}{{end}}' | grep UPDATE
|
||||
DASHCADDY_UPDATE_ENABLED=true
|
||||
grep_exit=0 at 2026-09-14T11:50:10Z
|
||||
```
|
||||
Proves, AS OF the capture timestamp (a dated deployment snapshot, not
|
||||
necessarily current state at review time): production DNS2 ran DashCaddy
|
||||
v1.16.0, channel `stable`, auto-update ON, feed reachable, and no `revoked`
|
||||
key in the live feed (kill switch not engaged). Re-run the three commands
|
||||
above to refresh.
|
||||
|
||||
## E6. Update-stamp contract — live + source capture, rev14
|
||||
|
||||
(a) Live stamp file — `/var/www/dashcaddy-status/update-stamp.json` (verbatim `cat`):
|
||||
```json
|
||||
{"version":"1.16.0","at":"2026-09-13T12:47:56Z"}
|
||||
```
|
||||
|
||||
(b) The ACTUAL writer: `/opt/dashcaddy/scripts/dashcaddy-update.sh` (host-side
|
||||
helper executed by the self-updater's update flow; the Node self-updater
|
||||
orchestrates, this script writes the stamp — see excerpt, verbatim `sed -n '714,725p'`):
|
||||
```bash
|
||||
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
|
||||
fi
|
||||
# DC-122: host-side deployment stamp — start.sh treats a stamped, newer
|
||||
# deployment as authoritative and skips its source-bundle sync (this is
|
||||
# the only writer that can reach the web root with real host paths).
|
||||
local esc_ver
|
||||
esc_ver=$(json_escape "$to_version")
|
||||
printf '{"version":"%s","at":"%s"}\n' "$esc_ver" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
> "$frontend_target_dir/update-stamp.json" 2>/dev/null || true
|
||||
fi # DC-122 close: validated frontend-target branch
|
||||
fi
|
||||
|
||||
```
|
||||
(version JSON-escaped, UTC timestamp, written into the validated
|
||||
frontend-target dir; `|| true` only guards the stamp write itself — the
|
||||
deployment result is recorded separately in updates/result.json.)
|
||||
|
||||
(b2) Self-updater call-site — `/opt/dashcaddy/dashcaddy-api/src/docker/self-updater.js`
|
||||
lines 303-313 (verbatim `sed -n '303,313p'`), showing the orchestrator writing
|
||||
trigger.json and deferring stamp writing to the host-side helper:
|
||||
```js
|
||||
await fsp.writeFile(
|
||||
path.join(this.config.updatesDir, 'trigger.json'),
|
||||
JSON.stringify(trigger, null, 2)
|
||||
);
|
||||
|
||||
// DC-122 note: the frontend deployment stamp (update-stamp.json) is
|
||||
// written by the HOST-side dashcaddy-update.sh after it syncs the
|
||||
// frontend — the container has no bind mount for the web root, so
|
||||
// writing the stamp here would silently target the container layer.
|
||||
|
||||
// The host-side systemd service will handle the rest.
|
||||
```
|
||||
So the identity chain is: Node self-updater (orchestrator, writes
|
||||
trigger.json) → host-side `dashcaddy-update.sh` (executed via the systemd path
|
||||
unit; writes update-stamp.json into the web root). The word "writer" in (b)
|
||||
refers to dashcaddy-update.sh specifically. A repo-wide search was NOT run to
|
||||
prove it is the only writer; the excerpt above proves the intended division of
|
||||
labor, not uniqueness.
|
||||
|
||||
(c) Sync-gating side: `/opt/dashcaddy/start.sh` (verbatim `sed -n '150,170p'`) —
|
||||
start.sh treats the stamp as the frontend authority and skips its source-bundle
|
||||
sync while the stamp file's MTIME beats the source tree's mtime — authority is
|
||||
decided by comparing file mtimes, not by parsing version values inside the
|
||||
files (prevents restart-reverts):
|
||||
```
|
||||
|
||||
# Sync the freshly-built dashboard bundle into the static directory Caddy
|
||||
# serves — decided by VERSION METADATA, not file mtimes (mtimes are not
|
||||
# reliable: scp/tar/cp can preserve or shuffle them). DC-122 contract:
|
||||
# - The self-updater writes a STAMP (update-stamp.json) into the live web
|
||||
# root when it deploys a frontend; while that stamp is newer than the
|
||||
# source tree's VERSION file, start.sh must NOT touch the live bundle.
|
||||
# - Normal builds: publishing bumps the source VERSION (mtime = build time)
|
||||
# and clears any stale stamp, so source wins and the sync happens.
|
||||
echo "[start.sh] Syncing dashboard bundle into static dir (metadata-driven)..."
|
||||
mkdir -p /var/www/dashcaddy-status/dist
|
||||
if [ -d /opt/dashcaddy/status/dist ]; then
|
||||
NEEDS_SYNC=1
|
||||
STAMP=/var/www/dashcaddy-status/update-stamp.json
|
||||
SRC_VERSION=/opt/dashcaddy/dashcaddy-api/VERSION
|
||||
if [ -f "$STAMP" ] && [ -f "$SRC_VERSION" ] && [ "$STAMP" -nt "$SRC_VERSION" ]; then
|
||||
# A self-updater deployment is newer than the last source build: hands off.
|
||||
echo "[start.sh] Deployed frontend stamp newer than source VERSION — skipping sync to preserve deployed frontend."
|
||||
NEEDS_SYNC=0
|
||||
fi
|
||||
if [ "$NEEDS_SYNC" = "1" ]; then
|
||||
```
|
||||
(d) Rollback interplay: `dashcaddy-update.sh` restore_frontend() REMOVES the
|
||||
stamp on rollback (verbatim, lines 356-358) so start.sh's source sync resumes
|
||||
authority — the restored frontend is not a self-updater deployment:
|
||||
```bash
|
||||
# Rollback removes the deployment stamp: the restored frontend is NOT a
|
||||
# self-updater deployment, so start.sh's source sync must resume authority.
|
||||
rm -f "${target:?}/update-stamp.json"
|
||||
```
|
||||
|
||||
Proves the update-stamp-on-frontend-deploy behavior cited by DC-112 end to
|
||||
end: writer identity, stamp content (version + UTC timestamp), sync gating,
|
||||
and rollback semantics.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# DashCaddy Production-Grade Backlog (v2)
|
||||
# DashCaddy Production-Grade Backlog (v3)
|
||||
|
||||
> Generated 2026-08-12 from a full codebase audit.
|
||||
> Generated 2026-08-12 from a full codebase audit; last revised 2026-09-14 (P6 added).
|
||||
> v1 items (P0-1 through P2-7) are ALL DONE.
|
||||
> Current state: 1539 tests, 86.55% statement coverage, 0 ESLint errors, 173 warnings.
|
||||
> NOTE: the "Current Health Snapshot" below is HISTORICAL (2026-08-12 audit snapshot),
|
||||
> kept for trend reference — re-run the audit before quoting these numbers.
|
||||
> Snapshot values (not current): 1539 tests, 86.55% statement coverage, 0 ESLint errors, 173 warnings.
|
||||
|
||||
## Current Health Snapshot
|
||||
- **Tests:** 1539 passing across 63 suites
|
||||
@@ -257,7 +259,7 @@
|
||||
- **impact:** Users set a disk budget (e.g., "DashCaddy gets 20GB") and the system auto-manages cleanup. The #1 reason people abandon self-hosting is disk filling up silently. This solves it.
|
||||
|
||||
### DC-102: One-click deploy should auto-generate Caddyfile entry + DNS record
|
||||
- **status:** already done (DiskSpaceMonitor)
|
||||
- **status:** pending (prior "already done (DiskSpaceMonitor)" annotation was a mismatched status note — DiskSpaceMonitor is a DC-101 disk-budget component, not a deploy-chain implementation; the deploy chain itself is not wired)
|
||||
- **details:** When a user deploys an app from the catalog, DashCaddy should automatically: (1) Create the Docker container, (2) Add a Caddyfile reverse_proxy block with TLS for `appname.tld`, (3) Create a DNS record pointing to the host, (4) Reload Caddy, (5) Add the service to the dashboard with health check. Currently steps 2-4 are manual. Fix: add a `deployApp(serviceId, options)` function that orchestrates the full chain. The Caddyfile generation can use the admin API (POST to :2019) so no file editing needed. DNS record creation uses the existing Technitium/Cloudflare DNS provider integration. Effort: ~4 hr.
|
||||
- **impact:** This is THE core value proposition. Without this, DashCaddy is just Portainer with extra steps. With this, it's a self-hosting platform.
|
||||
|
||||
@@ -293,6 +295,115 @@
|
||||
|
||||
---
|
||||
|
||||
## P6 — Shipdeck era: cross-platform & barrier removal (added 2026-09-14, Sami directive)
|
||||
|
||||
> Source: "further improve DashCaddy and remove barriers to quality usage of it
|
||||
> on all platforms including PC, Mac and Linux" — planned alongside the Shipdeck
|
||||
> v0 landing (`shipdeck/docs/SPEC.md` in the shipdeck repo, "Definition of done
|
||||
> (v0) — MET 2026-09-14": hello-world on samihost, build → systemd active →
|
||||
> caddy gate (tailnet 200 / public 403) → DNS on DNS2+DNS1 → HTTP health 200 →
|
||||
> journal row, verified rollback). The Shipdeck SPEC names DashCaddy-family use:
|
||||
> "the
|
||||
> deploy engine under the DashCaddy family (DashCaddy panel drives the CLI)";
|
||||
> these items wire that in. Build order: Lane A deployment (DC-109–112) →
|
||||
> Lane B onboarding (DC-113–115) → Lane C platform parity (DC-116–119).
|
||||
>
|
||||
> **Lane acceptance criteria (lane-level outcomes; a lane is done when these hold):**
|
||||
> - Lane A: on a Linux/systemd host where Docker is absent or unused, a real
|
||||
> service completes a fresh shipdeck deploy, one in-place update, and one
|
||||
> rollback — each verified green. Docker regression criteria (must all hold
|
||||
> after each lane item): existing Docker services still deploy/update/roll
|
||||
> back through the unchanged Docker path; existing services.json configs
|
||||
> load without migration errors; the full Jest suite passes at or above the
|
||||
> current gate. Secrets are never logged. This lane owns the
|
||||
> rollback-failure requirement: DC-112 must auto-rollback and alert on
|
||||
> failed post-update health.
|
||||
> - Lane B: on a fresh environment (new browser profile / clean VM), the
|
||||
> doctor detects each seeded environment fault, the auth helper names the
|
||||
> failed hop, and the update nudge stays silent when current.
|
||||
> - Lane C: every parity claim is CI-proven per OS (build + boot + smoke),
|
||||
> not grep-audited.
|
||||
|
||||
### DC-109: Native (Docker-free) service runtime — services gain `runtime: docker|native`
|
||||
- **status:** pending
|
||||
- **details:** Extend the service model with a runtime field. `docker` = today's behavior, unchanged. `native` = the Shipdeck pipeline: local build → tarball → scp → `/opt/<name>/releases/<epoch>/` + `current` symlink → systemd unit → Caddy block → DNS → verify → rollback. All implemented and DoD-verified in Shipdeck v0 (`shipdeck/docs/SPEC.md` — "Definition of done (v0) — MET 2026-09-14": hello-world on samihost, tailnet 200 / public 403, DNS on DNS2+DNS1, journal row, verified rollback). A host running only native services needs NO Docker. **Scope note: DC-109–112 initially target remote Linux/systemd hosts; local macOS/Windows native service parity arrives via DC-117.** Effort: ~6 hr (services.json schema + deploy routing + status surfacing).
|
||||
- **impact:** Removes the largest install dependency identified in this lane's analysis (local Docker Desktop) for PC/Mac users deploying to a remote server; native rollback is one command instead of `docker build` on the VPS.
|
||||
|
||||
### DC-110: Installer "remote server" mode — SSH target, zero local dependencies
|
||||
- **status:** pending
|
||||
- **details:** The Electron installer asks: this machine (classic 5-step wizard, Docker mode) or remote server (SSH host + key, Shipdeck mode). Remote mode skips the Docker/Caddy dependency checks entirely — it configures the remote host and prints the dashboard URL. This is the Mac unlock: no Docker Desktop account, no local engine, works from any laptop. Single-host seed of DC-108 (fleet). Open design points to settle at build time: whether remote mode also provisions remote Caddy/DNS or guides the operator through them, SSH host-key verification policy, and secret-handling (keys never logged, never stored plaintext beyond the user's chosen location). Effort: ~5 hr.
|
||||
- **impact:** Turns "install DashCaddy" from a multi-step dependency hunt into a short wizard; removes the largest single install dependency (local Docker) for non-Linux users.
|
||||
|
||||
### DC-111: DashCaddy dogfoods its own distribution via Shipdeck (native self-host path)
|
||||
- **status:** pending
|
||||
- **details:** Publish a native (non-Docker) DashCaddy flavor: release tarball + generated systemd unit + `current` symlink swap, deployed by Shipdeck. `start.sh` stays for the Docker flavor; the native flavor makes updates a symlink swap instead of `docker build` on the user's VPS. Same release feed (version.json + `revoked` kill switch). Effort: ~4 hr.
|
||||
- **impact:** Installs DashCaddy on hosts without Docker; dogfoods the native path we are selling.
|
||||
|
||||
### DC-112: Updater v2 — release-dir + symlink-swap updates for the native engine
|
||||
- **status:** pending
|
||||
- **details:** Generalize the self-updater shipped in DashCaddy v1.16.0 to the native flavor. Verifiable source of the existing contract: `dashcaddy-api/src/docker/self-updater.js:531` relative to the production-tree root `/opt/dashcaddy` — 30-min release-feed poll (`CHECK_INTERVAL: 30 * 60 * 1000` at line 24), channel selection (`CHANNEL` at line 35), `"revoked": true` kill switch (that line), update-stamp on frontend deploys — written by the host-side `scripts/dashcaddy-update.sh` helper which the self-updater orchestrates (verbatim excerpts, live stamp file, and sync-gate capture in `DC-P6-EVIDENCE.md`, E1/E6); the deploy/rollback flow is also documented in the `dashcaddy-ops` skill, "Self-updater (v1.16.0+, DC-122)" section). Native flavor: download tarball → new release dir → swap `current` → restart → health check → auto-rollback on failed health. Docker path untouched. Effort: ~5 hr.
|
||||
- **impact:** Native installs get the same hands-off updates and rollback safety Docker installs already have.
|
||||
|
||||
### DC-113: First-run doctor — preflight checks + one-click fixes
|
||||
- **status:** pending
|
||||
- **details:** One screen at first run (and from Help): Docker reachable? Caddy binary + admin port? ports 80/443 free? DNS resolvable? disk space? Each check shows fix instructions or a one-click fix where safe. Kills the "blank page on gated service" support class (documented failure mode). Effort: ~4 hr.
|
||||
- **impact:** Support experience to date (DashCaddy sessions 2026-05→09) has been dominated by environment/config issues rather than code bugs; the doctor turns that class into self-service.
|
||||
|
||||
### DC-114: "Why am I seeing this?" helper on the TOTP/auth gate
|
||||
- **status:** pending
|
||||
- **details:** The auth gate is a recurring confusion point (documented in the dashcaddy skill reference `totp-session-ip-key-inconsistency.md`, including the operator report "I keep providing a code and it doesn't work"). Login page gets inline diagnostics: which hop failed, cookie status, IP-consistency note, retry guidance. Server returns structured reason codes instead of bare 401. Effort: ~3 hr.
|
||||
- **impact:** Converts the documented auth-gate drop-off case into a guided flow.
|
||||
|
||||
### DC-115: Update nudge in the dashboard
|
||||
- **status:** pending
|
||||
- **details:** Footer badge comparing running version vs latest release feed; links to the update flow; dismissible; silent when current. The public version endpoint is verified in the live tree: `/api/v1/version` is in `PUBLIC_ROUTES` (`src/utilities/middleware.js`) and wired at startup (`src/app.js`). Latest-version source = the same release feed the self-updater polls (v1.16.0 contract). Effort: ~2 hr.
|
||||
- **impact:** Users running months-old builds file phantom bugs; the nudge keeps fleets current.
|
||||
|
||||
### DC-116: Podman as a supported container runtime
|
||||
- **status:** pending
|
||||
- **details:** Podman speaks Docker's socket API, so most DashCaddy container paths work against it with runtime detection + docs + a CI smoke test (rootless/quadlet notes included). Positions DashCaddy for orgs that cannot run Docker Desktop (licensing) and answers the "Docker vs open-source alternatives" wave with support instead of migration. Effort: ~4 hr.
|
||||
- **impact:** Business-friendly runtime choice; removes licensing objections in the sellable tier.
|
||||
|
||||
### DC-117: Service-control abstraction (systemd/launchd/Windows service) + per-OS static Pylon
|
||||
- **status:** pending
|
||||
- **details:** One service-control API over systemctl / launchd / sc.exe; Pylon ships as a single static agent per OS (no Node runtime required on managed hosts). `platform-paths.js` stays the single source of truth for paths (v1.12.0 lesson). Effort: ~8 hr.
|
||||
- **impact:** True cross-platform management, not Linux-with-caveats.
|
||||
|
||||
### DC-118: Mac Gatekeeper trust path — electron-builder native signing + notarization
|
||||
- **status:** pending
|
||||
- **details:** The Linux-built .dmg/.zip are unsigned → macOS Gatekeeper blocks/scare-warns on first open, and NO lightweight measure removes that friction: **ad-hoc signing does not establish developer identity and does not satisfy Gatekeeper distribution trust** (needs ~$99/yr Apple Developer Program). The fix: **electron-builder's built-in mac signing + notarization** — it signs nested frameworks/helpers with entitlements before the outer app (never hand-rolled `codesign --deep`) and submits notarization itself. Requires `electron-builder >= 24` (verify the version pinned in `dashcaddy-installer/package.json` at implementation time).
|
||||
Implementation shape (config, not a hand-written script — the script gets written and exercised IN this item's PR where a `macos-latest` runner can prove it):
|
||||
```js
|
||||
// electron-builder.config.js (mac section) — shape valid for electron-builder 24.x–26.x.
|
||||
// PIN the actual major against dashcaddy-installer/package.json at implementation time;
|
||||
// if the pinned major is >= 27, migrate per its mac.sign/notarize schema change before use.
|
||||
mac: {
|
||||
identity: "Developer ID Application: <name> (${TEAMID})",
|
||||
hardenedRuntime: true,
|
||||
gatekeeperAssess: true,
|
||||
entitlements: "build/entitlements.mac.plist",
|
||||
notarize: true, // auto notarize + staple on CI (24.x–26.x shape)
|
||||
forceCodeSigning: true, // FATAL on missing credentials — no silent unsigned artifacts
|
||||
}
|
||||
```
|
||||
CI env (Actions secrets only): `CSC_LINK` (the base64-decoded .p12 file path) + `CSC_KEY_PASSWORD` (the .p12's password) — with a CSC_LINK p12, electron-builder imports it into its OWN temporary keychain internally, so the PR must NOT hand-roll keychain lifecycle (no custom create/unlock/partition-list code); notarization uses `APPLE_ID` + `APPLE_APP_SPECIFIC_PASSWORD` + `APPLE_TEAM_ID`.
|
||||
**Acceptance criteria (this item is done only when all pass on a real `macos-latest` run):**
|
||||
1. `electron-builder --mac` exits 0 with signing + notarization enabled.
|
||||
2. Produced `.dmg`: `xcrun stapler validate <dmg>` passes and `spctl -a -t open --context context:primary-signature-id -v <dmg>` reports accepted.
|
||||
3. Produced `.zip`: extract it, then run `spctl -a -t exec -v <extracted .app>` on the contained app — must report accepted. Stapling is per-bundle: the `.app` inside the ZIP carries electron-builder's staple; the ZIP container itself cannot be stapled, and Gatekeeper on macOS 12+ re-queries Apple's notarization service online at first open to assess the signed `.app` inside.
|
||||
4. Notary submission id logged; on failure, `xcrun notarytool log <id> …` output is attached before any retry.
|
||||
5. First-open gate on a clean macOS VM must exercise the real download path with quarantine applied: download via a browser, OR confirm/add the xattr explicitly after any non-browser transfer (`xattr -w com.apple.quarantine "0081;00000000;Safari;" <file>` — do NOT rely on plain `curl`/`scp` to set it; macOS may not apply quarantine to CLI-downloaded files). First open then shows no Gatekeeper block.
|
||||
Drafting-review pitfalls from the 2026-09-14 adversarial rounds are HISTORICAL and inapplicable to this electron-builder approach (they applied to an earlier hand-rolled shell-script draft: keychain passwords, `mktemp -u`, search-list restore, filename gating — all now handled by electron-builder's internal keychain management). The version-validation rule stands: at implementation, confirm the pinned electron-builder major's actual `mac.sign`, `notarize`, and `forceCodeSigning` schema against its docs — do not trust the broad 24.x–26.x shape above without checking.
|
||||
The Linux .dmg build this slots into is documented in `dashcaddy-installer/BUILD_GUIDE.md` (libguestfs HFS+ volume + libdmg-hfsplus UDZO). Effort: ~2 hr once enrolled (+$99/yr Apple Developer Program).
|
||||
- **impact:** Today the first Mac impression is a security warning; the trust path must be fixed before paid acquisition, and only real signing + notarization does it.
|
||||
|
||||
### DC-119: Cross-platform CI matrix — boot it on all 3 OSes per release
|
||||
- **status:** pending
|
||||
- **details:** Per release: build API + installer on Windows/macOS/Linux runners, boot the API, run smoke probes (version + health), run the installer's dependency-checker in report mode. Replaces grep-audits with runtime proof — matches the reproducibility principle (Sami 2026-06: "other people can do the same things and expect reproducibility"). Effort: ~6 hr.
|
||||
- **impact:** Platform-parity claims become tested facts, not hopes.
|
||||
|
||||
---
|
||||
|
||||
## Summary by Priority
|
||||
|
||||
| Priority | Count | Effort | Theme |
|
||||
@@ -305,4 +416,5 @@
|
||||
| P3.5 | 9 (DC-086–094) | ~14.5 hr | Operational maturity |
|
||||
| P4 | 6 (DC-095–100) | ~16.5 hr | Advanced features |
|
||||
| P5 | 8 (DC-101–108) | ~29 hr | Product vision: self-hosting platform |
|
||||
| **Total** | **47** | **~110.5 hr** | |
|
||||
| P6 | 11 (DC-109–119) | ~49 hr | Shipdeck era: cross-platform & barrier removal |
|
||||
| **Total** | **58** | **~159.5 hr** | |
|
||||
@@ -19,6 +19,10 @@ const FIXTURE_ROWS = {
|
||||
};
|
||||
|
||||
function buildApp(fetchT, env = {}) {
|
||||
return buildAppWithLogCapture(fetchT, env, { info: jest.fn(), warn: jest.fn(), error: jest.fn() });
|
||||
}
|
||||
|
||||
function buildAppWithLogCapture(fetchT, env = {}, log) {
|
||||
process.env.SHIPDECK_BRIDGE_URL = env.url !== undefined ? env.url : 'http://172.17.0.1:8977';
|
||||
process.env.SHIPDECK_BRIDGE_TOKEN_FILE = env.tokenFile !== undefined ? env.tokenFile : '';
|
||||
jest.resetModules();
|
||||
@@ -27,7 +31,7 @@ function buildApp(fetchT, env = {}) {
|
||||
asyncHandler: (fn) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
log,
|
||||
auditLogger: { log: jest.fn(async () => {}) },
|
||||
fetchT,
|
||||
});
|
||||
@@ -41,11 +45,20 @@ function buildApp(fetchT, env = {}) {
|
||||
return app;
|
||||
}
|
||||
|
||||
// expose log on the fetcher wrapper for log-capture assertions
|
||||
function jsonFetcherWithLog(responses) {
|
||||
const calls = [];
|
||||
const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
||||
return { calls, log, fetchT: null };
|
||||
}
|
||||
|
||||
function jsonFetcher(responses) {
|
||||
// responses: map of "METHOD path" -> {status, body}
|
||||
const calls = [];
|
||||
return {
|
||||
const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
||||
const wrapper = {
|
||||
calls,
|
||||
log,
|
||||
fetchT: jest.fn(async (url, opts) => {
|
||||
const key = `${(opts && opts.method) || 'GET'} ${url.replace(/^https?:\/\/[^/]+/, '')}`;
|
||||
calls.push({ key, opts });
|
||||
@@ -56,6 +69,7 @@ function jsonFetcher(responses) {
|
||||
};
|
||||
}),
|
||||
};
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('routes/deploys — feature gate', () => {
|
||||
@@ -140,6 +154,50 @@ describe('routes/deploys — proxied endpoints', () => {
|
||||
expect(body.output).toContain('[FAIL]');
|
||||
});
|
||||
|
||||
test('GET /status maps bridge auth failure (401) to 502, not a probe result', async () => {
|
||||
const f = jsonFetcher({
|
||||
'GET /api/status?service=helloworld': { status: 401, body: { ok: false, error: 'invalid token' } },
|
||||
});
|
||||
const app = buildApp(f.fetchT);
|
||||
const server = app.listen(0);
|
||||
const port = server.address().port;
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
|
||||
const body = await r.json();
|
||||
server.close();
|
||||
expect(r.status).toBe(502);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/bridge/i);
|
||||
});
|
||||
|
||||
test('GET /status maps unexpected bridge statuses to 502 with error logging', async () => {
|
||||
const f = jsonFetcher({
|
||||
'GET /api/status?service=helloworld': { status: 404, body: { ok: false, error: 'not found' } },
|
||||
});
|
||||
const app = buildAppWithLogCapture(f.fetchT, {}, f.log);
|
||||
const server = app.listen(0);
|
||||
const port = server.address().port;
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
|
||||
server.close();
|
||||
expect(r.status).toBe(502);
|
||||
expect(f.log.error).toHaveBeenCalledWith(
|
||||
'deploys',
|
||||
'status probe: unexpected bridge response',
|
||||
expect.objectContaining({ service: 'helloworld', status: 404 }),
|
||||
);
|
||||
});
|
||||
|
||||
test('GET /status maps bridge connection failure to 502', async () => {
|
||||
const fetchT = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
||||
const app = buildApp(fetchT);
|
||||
const server = app.listen(0);
|
||||
const port = server.address().port;
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
|
||||
const body = await r.json();
|
||||
server.close();
|
||||
expect(r.status).toBe(502);
|
||||
expect(body.error).toMatch(/unreachable/);
|
||||
});
|
||||
|
||||
test('POST /deploy proxies dir and audits', async () => {
|
||||
const f = jsonFetcher({
|
||||
'POST /api/deploy': { status: 200, body: { ok: true, exit: 0, output: 'DEPLOYED helloworld in 30.0s' } },
|
||||
|
||||
@@ -117,9 +117,32 @@ module.exports = function ({ asyncHandler, log, auditLogger, fetchT }) {
|
||||
if (!SERVICE_RE.test(service)) {
|
||||
return errorResponse(res, 400, 'invalid service name');
|
||||
}
|
||||
const { status, body } = await bridge('GET', `/api/status?service=${encodeURIComponent(service)}`);
|
||||
// shipdeck status exits non-zero when checks fail — surface that as ok:false
|
||||
// with the probe output so the UI can render the failing checks.
|
||||
let status, body;
|
||||
try {
|
||||
({ status, body } = await bridge('GET', `/api/status?service=${encodeURIComponent(service)}`));
|
||||
} catch (e) {
|
||||
log.error('deploys', 'status probe: bridge unreachable', { service, error: e.message });
|
||||
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
// bridge auth/protocol failure = infrastructure problem, NOT a probe result
|
||||
log.error('deploys', 'status probe: bridge auth failed', { service, status });
|
||||
return errorResponse(res, 502, 'shipdeck bridge rejected the request (auth/config error)');
|
||||
}
|
||||
if (status >= 500 && body && body.ok === false && body.output) {
|
||||
// shipdeck status exits non-zero when checks fail — that's an EXPECTED
|
||||
// probe result (failing checks), surface as ok:false with the output.
|
||||
return ok(res, { ok: false, output: body.output || '' });
|
||||
}
|
||||
if (status !== 200) {
|
||||
// malformed upstream route or other unexpected bridge failure
|
||||
log.error('deploys', 'status probe: unexpected bridge response', {
|
||||
service,
|
||||
status,
|
||||
bridgeError: body && body.error ? String(body.error).slice(0, 200) : 'none',
|
||||
});
|
||||
return errorResponse(res, 502, 'shipdeck bridge protocol error (unexpected response, status ' + status + ')');
|
||||
}
|
||||
return ok(res, { ok: body.ok === true, output: body.output || '' });
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# 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 |
|
||||
| Unblock | Re-allow at ACL level; expired device keys must re-authenticate (see §5) | Re-add peer from stored record (immediate) |
|
||||
| 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-allow at ACL level; expired device keys stay expired (re-auth required, §5) | re-add stored peer (immediate) |
|
||||
| `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: network-layer deny (ACL / firewall) + expire Tailscale device keys + kill
|
||||
DashCaddy sessions + mark person blocked. **Unblock honestly:** the deny, the
|
||||
status, and the session policy reverse — but expired Tailscale device keys are
|
||||
NOT un-expired; those devices must re-authenticate with Tailscale on next
|
||||
connect. The UI says "blocked devices will need to sign in again" rather than
|
||||
pretending instant reversal. WireGuard peers restore immediately on re-add
|
||||
(peer config is re-generated server-side from the stored pubkey).
|
||||
- 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)
|
||||
|
||||
### 8.1 Addressing and routing (exact prefixes)
|
||||
|
||||
- Hub interface `dcnet0`: 10.53.0.1/16, listen port chosen at setup (default 51820,
|
||||
UDP). Endpoint = host's reachable address (setup captures it; wizard warns if
|
||||
it's behind NAT without a forwarded port).
|
||||
- Client address pool: 10.53.0.0/16, one /32 per device (10.53.x.y/32 assigned
|
||||
on device add, stable, stored in people.json devices[]).
|
||||
- **AllowedIPs = 10.53.0.0/16 only** (a CIDR, never a DNS zone). Split tunnel:
|
||||
client's other traffic is untouched. Resource names resolve via Technitium
|
||||
(8.3) to IPs inside 10.53.0.0/16, which routes through the tunnel.
|
||||
- Hub forwarding: `net.ipv4.ip_forward=1` is required for hub-and-spoke
|
||||
peer→peer traffic; peer→hub-resource traffic is local. No NAT/masquerade on
|
||||
dcnet0 — all resource access is to hub-local services on 10.53.0.1 or to
|
||||
other peers' /32s, so addresses stay routable and firewall rules match peers.
|
||||
- Firewall baseline — three distinct zones, never conflated:
|
||||
(a) **External input**: the WireGuard UDP listen port arrives on the EXTERNAL
|
||||
interface (e.g. eth0), not dcnet0 — its accept rule is scoped to the external
|
||||
interface name (captured at setup).
|
||||
(b) **dcnet0 local input** (iifname dcnet0): ICMP from 10.53.0.0/16, admin
|
||||
ports (API/SSH on 10.53.0.1) from admin peers only, per-resource ports to
|
||||
10.53.0.1; drop other new inbound to 10.53.0.1.
|
||||
(c) **dcnet0 forwarding** (ip_forward=1): peer→resource gated by per-resource
|
||||
sets; peer→peer default DROP (both iifname and oifname dcnet0 tails) unless a
|
||||
grant implies it.
|
||||
|
||||
### 8.2 Key lifecycle (no server-side private-key storage)
|
||||
|
||||
- **Client keypairs are generated in the invitee's browser** using WebCrypto
|
||||
**X25519** — WireGuard requires Curve25519 key material; there is NO fallback
|
||||
algorithm (a browser lacking X25519 support gets an explicit "browser not
|
||||
supported" error, not a degraded key). Serialization (browser-export
|
||||
reality): `exportKey('raw', publicKey)` yields the raw 32-byte public key →
|
||||
base64. `exportKey('pkcs8', privateKey)` yields a PKCS#8 wrapper — the page
|
||||
strips the fixed 16-byte PKCS#8 X25519 prefix (`302e020100300506032b656e0422
|
||||
0420`) to extract the raw 32-byte private key → base64 (`wg genkey`-format;
|
||||
the extraction is pure byte-slicing of a fixed-prefix wrapper, verified by
|
||||
length/constant-prefix assertions before use). The server receives the base64
|
||||
public key (44 chars) and validates length/charset before use.
|
||||
- The redemption page posts ONLY the public key to the server; the server
|
||||
registers it as a WireGuard peer (10.53.x.y/32) and returns the client
|
||||
config (Address, DNS, PublicKey of hub, AllowedIPs = 10.53.0.0/16).
|
||||
- The private key NEVER transits or rests on the server: it lives in the
|
||||
browser page for the session, rendered into the config + QR client-side.
|
||||
- Server stores: peer public keys, assigned /32s, names — nothing else.
|
||||
- Revocation = remove peer (pubkey) from the hub + firewall-set update; the
|
||||
client's private key becomes useless without hub acceptance.
|
||||
|
||||
### 8.3 DNS
|
||||
|
||||
- Technitium serves the private zone on the hub IP (10.53.0.1). Client config
|
||||
DNS = 10.53.0.1. Per-resource/per-device records resolve to 10.53.x.y /32s
|
||||
(per DC-146). In Tailscale mode, the same records hold tailnet IPs instead.
|
||||
|
||||
### 8.4 Hub management
|
||||
|
||||
- `wg-quick` interface `dcnet0` managed by DashCaddy (systemd unit
|
||||
`dcnet-hub.service` if absent), keygen for the HUB keypair on the host
|
||||
(hub key is host-local and backed up with it).
|
||||
|
||||
### 8.5 Invites
|
||||
|
||||
- Invite redemption: one-time token (7-day TTL, single-use) → redemption page
|
||||
(browser keygen per 8.2) → config + QR rendered client-side, page
|
||||
auto-expires after redemption.
|
||||
|
||||
- Firewall: nftables matrix (section 4). 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.
|
||||
|
||||
WireGuard mode dependency note: "no third party" still depends on host
|
||||
networking — a reachable UDP endpoint, wireguard-tools, nftables, and Technitium
|
||||
on the DashCaddy host.
|
||||
|
||||
## 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