Pre-fix: /api/v1/ws checked cookies.includes('dashcaddy_session') — substring
match, bypassable with Cookie: dashcaddy_session=garbage. Production also
accepted any 11+ char ?token= query string. Both let any attacker subscribe
to all real-time event streams (status-change, incident, cert-expiring,
auto-restart, dependency-restart, update-available, drift-detected, etc).
Fix (3 files, +404/-81):
(1) server.js:80-99 wires ctx.session.isValid (HMAC-verifying isSessionValid
from middleware.js:265-279) into deps.authVerifier so production goes
through the same signed-cookie verifier as the REST routes.
(2) dashboard-ws.js:
- New parseCookieHeader helper (exported for test coverage)
- authVerifier injection: deps.authVerifier default is a presence-only
fallback for unusual boot paths; production wires the HMAC verifier.
- Upgrade handler replaces substring check with authVerifier(request).
401 includes Connection: close so browsers don't retry. Logs WS upgrade
rejections at WARN with ip + path.
- Removes ?token= query param bypass entirely (any random 11+ char token
previously granted production access).
- 16 KB message size cap defense-in-depth in the message handler.
(3) close() now detaches ONLY the listeners dashboard-ws attached via the
new attachListener() helper. The previous code called
resourceMonitor.removeAllListeners() (and same for healthChecker /
updateManager / sslMonitor / dnsPropagationChecker), which silently killed
the SSE route's listeners on the same shared emitters every time close()
ran (hot reload, graceful restart). The new test proves the SSE listener
survives dashboard-ws.close() and the resourceMonitor still emits to it.
Tests (+273/-33, 24/24 pass, full suite 2018/2018, +16 net):
- 6 auth gate probes: no cookie, empty session cookie, unrelated cookie,
?token= bypass rejected, token+empty-cookie combo rejected, valid
cookie grants 101
- 2 listener-isolation: close() detaches only OUR listeners; close() is
idempotent
- 8 parseCookieHeader unit tests (undefined, empty, single, multi,
whitespace, HMAC-shaped value preservation, malformed pair, empty name)
- Existing DC-076 tests updated to send Cookie header
Refs: codex-as-judge SKILL.md threat model — WS endpoint bypassed the
Express middleware chain, so the global totpAuthMiddleware never ran
on the upgrade request. Auth must be re-asserted at the upgrade handler.
DC-053 follow-ups (queue item 2b). Three small fixes to the caddy-upstream-watcher:
1. verifiedViaBridge flag: a loopback upstream whose PRIOR probe succeeded via
host-gateway proves the bridge CAN reach the host. A later failed probe
is then near-conclusive evidence the upstream itself went dead. The
DC-053 code unconditionally marked loopback failures as unverifiable,
throwing away this signal. Now: track verifiedViaBridge per-upstream and
treat verified-then-failed as down (count failures, open incident after
DEAD_AFTER_MS=5min).
2. IN_CONTAINER=false kill-switch test (B-grade polish, folded into same
commit per conjoint-commit anti-pattern).
3. git.sami intermittent ENOTFOUND (~2/h in ssl-monitor TLS handshake):
pin git.sami -> 100.121.150.22 (DNS2 Tailscale) in container /etc/hosts
via --add-host in start.sh. Existing comment explicitly forbids pinning
to DNS3/100.81.59.99 (no HTTPS listener there); DNS2/100.121.150.22 is
correct (Caddy serves git.sami on DNS2:443 and routes to DNS3:3030
internally).
GLM-5.3 judge round 1 (208s, 6 tool calls, on-disk verified): grade B,
all 25 tests green, no blocking issues, 3 LOW polish suggestions.
Folded two actionable LOWs (persistence + JSDoc) into this commit:
- verifiedViaBridge now persisted in _saveState/_restoreUpstreamStates so
a known-good loopback upstream stays labeled across container restarts
(1-tick blip becomes 0-tick).
- snapshot() gained JSDoc describing the verifiedViaBridge semantic for
dashboard consumers.
Third LOW (long-term: prefer host-side liveness signal from Caddy) is a
roadmap note, not actionable now.
Tests: 1921/1921 (was 1910; +11 net: 6 new for items 2b-a/2b-b/persistence +
5 previously-skipped baseline). Full suite 86/86 green.
Full language display names + RTL set (ar/fa/ur) on /i18n/languages;
card.internet/auth/tailscale/dashca, status pills, filter bar and
batch-operation strings added to every language dictionary. Frontend:
English now loads the server dictionary too (keys are semantic ids,
not fallback copy), failed loads keep existing DOM text instead of
exposing raw keys, isLoaded() gate for pre-load renders. Rebuilt
status/dist. Tests: i18n-cards 9/9, full suite 1837/1837.
Companion to ff92706 (drift test fix). The inline handler moves to a
module exporting { buildRouter, getVersion, getName }, pre-built once
at startup and mounted bare on apiRouter — the exact shape the drift
test walker now recognizes. Dockerfile builder stage switches to
npm ci --omit=dev for deterministic builds. Tests: 12/12 across the
three new/updated suites; full suite 1837/1837.
The direct-mounts walker silently skipped route modules exporting
{ buildRouter } objects instead of function factories, causing a false
stale-entry failure for /api/v1/version. Normalize object exports with
a buildRouter method to the factory before the typeof-function check.
Only version.js uses this shape (verified across routes/). Full suite
1837/1837. Adversarial review: A-, no blocking issues; follow-up: warn
on unrecognized export shapes.
AI Intent Router:
- Wired /api/v1/ai/intent and /api/v1/ai/capabilities into app.js
- Pattern matching works offline, no API key needed
- Handles: deploy, recommend, diagnose, backup, health, list
- AI chat floating button on dashboard (🤖)
- Suggestion chips: Deploy Plex, Stream movies, Block ads, System health
- Deploy buttons in chat launch the app selector
TOTP Fix:
- secureFetch() was missing credentials: same-origin
- Session cookie was not being sent on API calls
- Added credentials: same-origin to all fetch calls
- Users no longer prompted for TOTP on every action
Nesting Guard:
- Fixed logging module path (../utils/logging not ./logging)
- Switched to console.log to avoid module export mismatch
MCP Server:
- 551-line JSON-RPC server ready at src/mcp/mcp-server.js
- Configurable via DASHCADDY_URL + DASHCADDY_API_KEY env vars
i18n:
- Expanded from 6 to 31 languages (no Hebrew per policy)
- Added: pt, ru, ja, ko, hi, tr, it, nl, pl, sv, id, uk, th, vi, fa, cs, ms, ro, el, bn, hu, fi, da, no, ur
- RTL support for ar, fa, ur
- Language selector dropdown wired into dashboard navbar
Disk Safety:
- New backend route /api/v1/disk-settings (GET/POST/cleanup)
- Frontend modal with sliders for health interval, max entries, retention days
- Clean Up Now button triggers immediate cleanup
- Wired into dashboard navbar
Desktop Auto-Updater (from timed-out subagent):
- electron-updater installed and configured
- Checks get.dashcaddy.net/release/ for updates
- Publish config added to package.json
VM Uninstall:
- Wizard calls vmDestroy before regular uninstall
- Cleans up VM/disk sandbox on uninstall
Cleanup:
- Recursive data nesting guard (nesting-guard.js)
- Removed 242MB of data/data/data/ duplicates
- Cleaned 242MB of recursive data/data/data/ nesting
- Added nesting-guard.js: auto-detects and removes recursive duplicates at startup
- Wired VM sandbox cleanup into uninstall wizard (calls vmDestroy before regular uninstall)
- Container stats, health data, and VM disk all cleaned on uninstall
- Add VM provisioning module (vm-provisioner.js) with 3 platform strategies:
* Windows: WSL2 distro with fixed VHDX
* macOS: Lima VM with fixed disk
* Linux: loopback ext4 image
- Add IPC handlers (vm-ipc.js) for Electron wizard integration
- Add disk budget wizard step (disk-budget-step.js) with presets
- Wire VM handlers into main process (index.js)
- Add preload bridges for VM operations
- Update install.sh with --disk-size flag and sandbox functions
- Add disk safety env vars to docker-compose template
- Add memory limits to prevent OOM during startup
Users can now pick a disk budget (10GB/30GB/100GB/custom) and DashCaddy
creates a sandboxed VM that physically cannot exceed that limit.
Uninstall cleanly removes the entire VM/disk with zero leakage.
ARCHITECTURE:
- Windows: dedicated WSL2 distro with fixed VHDX, Docker inside
- macOS: Lima VM with fixed disk, Docker inside
- Linux: sparse ext4 loopback image, Docker data-root inside
NEW FILES:
- vm-provisioner.js: core provisioning engine (create/start/destroy/export)
- Disk presets: Minimal(10GB), Balanced(30GB), Power(100GB), Custom
- Sparse images that grow on demand (start at ~0 bytes)
- Full lifecycle: provision → deploy DashCaddy → destroy (clean removal)
- Data export before uninstall for users who want to migrate
- vm-ipc.js: Electron IPC handlers connecting wizard to provisioner
- vm:provision, vm:destroy, vm:get-status, vm:export-data, vm:get-presets
- disk-budget-step.js: wizard UI step with preset cards + custom slider
- Real-time free space check against selected disk size
- Plain English description of what each tier handles
UPDATED:
- caddyfile-generator.js: docker-compose now includes disk safety env vars
(health retention, stats caps, memory limits) as defense-in-depth
even inside the VM sandbox
GUARANTEE: DashCaddy physically cannot exceed the storage budget.
The OS enforces the limit at the disk/image level, not our code.
- New route /api/v1/log-insights: analyzes audit logs + security events
- Shows top IPs with request counts, failures, and top actions
- Plain English insights (heavy users, auth failures, security alerts)
- Summary stats: total requests, unique IPs, failed actions
- Storage info showing log file sizes and entry counts
- New route POST /api/v1/log-insights/dispose: preview-then-confirm cleanup
- First call shows what would be deleted (preview mode)
- Second call with confirm:true actually deletes
- Configurable retention period (default 30 days)
- Frontend panel with modal UI showing insights as cards
- Period selector (1h, 6h, 24h, 7d)
- Top visitors table with IP, requests, failures, actions, last seen
- Storage info footer
- Clean Old Logs button with preview confirmation dialog
- Wired into app.js and dashboard navbar (🔍 Insights button)
- Addresses QA issue: users need to see who is accessing before cleanup
Caddy handle_path /dashcaddy-api/* only strips the /dashcaddy-api prefix, so
the login-page fetch to /dashcaddy-api/api/auth/sso-exchange arrived at the
app as /api/auth/sso-exchange - one path segment short of the canonical
/api/v1/auth/sso-exchange mount, so it 404d (masked by isPublicRoute never
even being reached). Add it to the same narrow gate/app-token rewrite case.
Caught by an end-to-end curl replay of the actual handoff flow before
asking for another live retest.
Domain=.sami cookies are silently rejected by real browsers - .sami is an
unregistered custom TLD, so browsers treat sami itself as the effective
public suffix and refuse to set a cookie scoped to it (the same rule that
stops a site from setting a supercookie for all of .com). Confirmed via
curl verbose (cookie dropped, domain must not set cookies for sami) and
via the Firefox console on the actual device (Cookie rejected for invalid
domain) for the same cookie. The session cookie set on status.sami after
TOTP verify could never reach plex.sami/jellyfin.sami/emby.sami/chat.sami
no matter how the cookie itself was built - prior fixes tonight left this
mechanism untouched, which is why the loop persisted.
Fix: /totp/verify mints a short-lived (60s) single-use opaque token. The
status.sami frontend appends it to the redirect URL when bouncing the
user back to a gated service. That services login page exchanges the
token via the new public GET /api/v1/auth/sso-exchange for a host-only
session cookie (no Domain attribute - always accepted). isSessionValid
only checks the cookies HMAC signature, never its Domain, so the host-only
cookie validates identically to the cross-domain one on every existing
check with zero changes to that logic.
The never-trap fallback commit (fb638f6) left an extra closing brace after
the fail() call in the plex/jellyfin/emby page bodies (JSON.stringify(j))}
followed by another }).catch(...) on the next line - one brace too many,
since unlike the chat body these have no try/catch needing the extra scope).
This threw a SyntaxError parsing the inline <script>, which silently killed
the ENTIRE script - including the 15s failsafe redirect - leaving the page
stuck on "Signing in to ..." forever with zero console output explaining
why. Confirmed via node --check on the actual generated <script> contents
for all four services.
Sami reported plex.sami/dashcaddy-login hangs at 'Signing in to Plex...'
indefinitely. Earlier commit (210c208) added 8s/15s timeouts at the SHELL
template level, but the per-service page bodies in buildLoginPage()'s
pages object had their own dead-end behavior: when app-token/:svc
returned an error or no token, the body called fail() showing an error
message but DID NOT redirect anywhere. With check-session still
returning authenticated, the SHELL's 15s failsafe timer never fires
because the script is still 'running' (in the failed .then chain).
Fix in each body (plex/jellyfin/emby/chat):
- After app-token returns no token, check localStorage for a stale token.
If present, redirect to /web/?direct=1 — Plex/Jellyfin/Emby may still
accept it for the session, and the user is unblocked either way.
- If no stale token, the fail() message now includes a manual link to
/web/?direct=1 (not just status.sami re-auth), so the user always has
an exit. fail() also shows the actual API response body (truncated)
for easier debugging when something is genuinely wrong.
- catch() handlers get the same manual-link treatment.
- Removed the chat body's debug spam (Status: code + body dump to #d)
that was making the UI look broken even when it wasn't.
Verified: 133/133 auth/sso/csrf/session tests pass; served page on
plex.sami/jellyfin.sami/emby.sami/chat.sami all contain
myPlexAccessToken/jellyfin_credentials/emby_credentials/token fallback
checks + Open X manually links.
buildLoginPage() in routes/auth/sso-gate.js shipped with bare fetches
(no signal). When app-token/:serviceId hung in the browser (slow upstream,
no response after 30s+, etc.), the page sat on 'Signing in to Plex...'
indefinitely. Verified on DNS2 2026-07-22: user reported 'still doing the
same thing' even after cookie + XFF fixes were verified working end-to-end.
Hardening:
- check-session fetch: 5s AbortSignal timeout
- app-token/:svc fetch (via ft()): 8s AbortSignal timeout
- 15s hard overall timer: if nothing succeeds, force-redirect to
status.sami?auth=required so the user can re-auth
- try/catch around fail() to prevent DOM exception from breaking flow
Verified live: 133/133 auth/sso/csrf/session tests pass; container
healthy; served page contains 'withTimeout' + 'overallTimer' + '15000'.
Auto-login can no longer hang the page.
isSessionValid previously checked verifyIPSession() first, falling back to
verifySessionCookie() only if IP miss. Under Caddy --network host forward_auth,
req.ip arrived as 100.121.150.22 (DNS2 tailnet) instead of the user's real IP,
causing every cross-subdomain auto-login (plex/jellyfin/emby/chat) to 401 even
with a valid cookie. Now cookie-only; the IP cache write-back is kept as a
no-op for telemetry compat.
Verification on DNS2: /dashcaddy-login renders in 184ms (was 7s).
app-token/plex with the TOTP-issued cookie returns 200 with a real Plex token.
The dashboard polls /api/v1/services/status (not /probe/:id) for its
refresh loop. routes/services.js's requestStatusCode() didn't set the
X-DashCaddy-HealthCheck: 1 marker, so the batch endpoint hit the
forward_auth gate, got rate-limited by authLimiter (429), and reported
7 services (router, chat, sync, torrent, sonarr, radarr, prowlarr,
requests) as down.
Same fix in src/app.js /probe/:id (the single-service endpoint) for
consistency.
Without the marker, every probe from the container IP trips
authLimiter within 20 requests and the rest of the batch fails.
health-checker.js background poll was already setting the marker
correctly, which is why the cached health view showed 15/15 while
the live dashboard showed 8/15.
Two related fixes from the dashboard 11/15 false-negatives:
1. The Sami Home Network CA cert (/etc/ssl/sami-ca/root.crt) was not
mounted into the container, so the health-checker's HTTPS probe to
*.sami hosts failed with "certificate verify failed". Added a bind
mount + CA_CERT_PATH env var so the app's httpsAgent picks it up
(verified at startup: "HTTPS agent configured with CA certificate").
2. The --add-host=ca.sami:127.0.0.1 line pinned ca.sami to the
container's loopback, but nothing listens on 443 inside the
container. Probe failed with ECONNREFUSED 127.0.0.1:443. Removed
the override so ca.sami resolves via DNS to 100.121.150.22 (Caddy
on DNS2) and the probe reaches the real service.
After both fixes: 15/15 services healthy, 0 429s on the health checker,
caddy.ok=true on /health/ready.
The user's browser cached an older version of the auto-login page that
called /dashcaddy-api/api/v1/auth/totp/check-session (with both v1 and
auth prefixes) instead of the current /dashcaddy-api/api/auth/totp/check-session
(legacy, no v1). The shim only handled the legacy path, so the stale
JS 404'd and the page hung at 'Signing in to Plex...' even after the
fix was deployed.
Add /api/v1/auth/{gate,app-token,totp/check-session} to the shim so
stale browser caches keep working. Also add /api/v1/auth/gate and
/api/v1/auth/app-token for the same drift reason.
The shim added in the previous commit rewrote /api/auth/totp/check-session
to /api/v1/auth/totp/check-session, but the canonical route is mounted at
/totp/check-session (no /auth prefix). The 404 returned to the auto-login
JS path was Route GET /v1/auth/totp/check-session — Express's /api/v1
mount stripped the /api/v1 prefix, leaving /auth/totp/check-session, which
doesn't match /totp/check-session.
Drop both /api and /auth (9 chars) so the legacy path maps to the
canonical /api/v1/totp/check-session.
Verified after deploy:
GET /api/auth/totp/check-session -> {"authenticated":true}
GET /api/v1/totp/check-session -> {"authenticated":true}
The Plex/Jellyfin/Emby/chat auto-login page JS (sso-gate.js
buildLoginPage) calls /api/auth/totp/check-session — the pre-1.5.0
legacy prefix. The back-compat shim in app.js only handled
/api/auth/gate/ and /api/auth/app-token/, so check-session 404'd and
the page hung at "Signing in to Plex..." forever (user reported
2026-07-09, confirmed: request returns "Route GET /v1/auth/totp/
check-session not found").
Add /api/auth/totp/check-session to the legacy path rewrite so the JS
gets the canonical /api/v1/totp/check-session endpoint.
Verified: plex.sami/dashcaddy-login now returns the auto-login page
and the JS check-session fetch resolves to {"authenticated":true} for
active TOTP sessions.
The caddy.ok check in /health/ready probed /config/ (51KB) and timed out
at 3s with "This operation was aborted" while Caddy admin was actually
healthy. Two underlying issues:
1. Native undici fetch() rejects connections to :2019 (Caddy admin). Use
fetchT() which falls back to raw http.request for the admin port.
2. /config/ is heavy and head-of-line blocks when /load is in flight.
Switch to /config/apps/http/servers/srv0/listen (9 bytes) and bump
timeout to 10s.
Verified on DNS2 2026-07-09: direct Caddy admin curl 200 in 3ms,
/health/ready was aborting at 3s. After fix: /health/ready caddy.ok
true in <100ms.
Caddyfile change (/etc/caddy/Caddyfile) added /dashcaddy-login to the
@needsAuth not path exclude so direct hits to the auto-login landing
page render the page instead of getting gate-redirected to a blank
302 — applied and reloaded via POST /load earlier this session.
Companion to DC-042 (tailscale-manager). Adds the write-side of Tailscale
integration — REST client for api.tailscale.com that lets DashCaddy
manage its own tailnet (devices, pre-auth keys, users, ACL).
src/managers/tailscale-coord.js — new module:
* Ping, list/get/delete devices, create/list/delete pre-auth keys,
list users, get/update ACL
* 5min read cache, 60s device-list cache, 1hr ping cache
* Cache invalidation on writes
* Graceful {configured:false} when no token
* TailscaleCoordError class with code mapping (unauthorized, not_found,
rate_limited, server_error)
* fetchImpl injection point for tests; native https in production
* 45 unit tests covering all paths
src/context/index.js — new tailscaleCoord namespace:
* getClient() lazy-builds a fresh client each call (token re-read from
credentialManager so settings changes take effect without restart)
* loadMetadata/saveMetadata for tailscale-config.json
* setApiToken/hasApiToken wrappers around credentialManager
routes/tailscale-admin.js — new routes:
* GET /api/v1/tailscale/settings — config status, never the token
* PUT /api/v1/tailscale/settings — validate + store encrypted
* DELETE /api/v1/tailscale/settings — wipe token + metadata
* POST /api/v1/tailscale/settings/test — ping without saving
* GET /api/v1/tailscale/admin/devices — full device list
* DELETE /api/v1/tailscale/admin/devices/:id — revoke device
* GET /api/v1/tailscale/admin/users — tailnet users
* GET /api/v1/tailscale/admin/keys — pre-auth key metadata
* POST /api/v1/tailscale/admin/keys — create pre-auth key (returns secret ONCE)
* DELETE /api/v1/tailscale/admin/keys/:id — revoke pre-auth key
* 29 route integration tests with supertest
src/app.js — wired the new router into the /api/v1/tailscale mount.
BACKLOG.md — DC-042 marked done, DC-043 added with full design notes.
Note: deliberately no token auto-rotation — Tailscale API keys
don't auto-renew, and silently re-issuing admin credentials
would erode the audit-trail checkpoint that token expiry
provides.
Tests: 1167 -> 1212 (+45), all green. Lint clean.
The previous getTailscaleStatus() in src/app.js was a hard-coded
`return null` stub with a TODO saying it would be populated by context.
The context had a tailscale.* namespace declared with null function
stubs (routes/context.js:71), but nothing ever set them to real
functions. routes/tailscale.js has been calling ctx.tailscale.getStatus()
/ getLocalIP() / isTailscaleIP() and getting undefined back, silently
returning empty device lists. The tailscaleAuthMiddleware's allowedTailnet
check (DC-121, device-not-in-tailnet 403) was dead code for the same reason.
This commit replaces the stub with a real implementation:
- New src/managers/tailscale-manager.js shells out to the host's
`tailscale status --json` (cached 5 minutes), parses the result, and
exposes getStatus / getLocalIP / getSummary / getDevices / isTailscaleIP /
invalidateCache / getAccessToken (stub) / startSyncTimer / stopSyncTimer
/ syncAPI (stub). All failure modes (CLI missing, tailscaled down,
malformed JSON, EACCES) are handled gracefully — return null with no
cache poisoning.
- src/context/index.js now wires the manager into ctx.tailscale.* so
routes/tailscale.js and middleware.js's allowedTailnet gate get the
real functions.
- src/app.js:189 getTailscaleStatus() now delegates to the manager
instead of returning null.
- The duplicate isTailscaleIP() in src/app.js:179 (no malformed-input
guards) is removed in favor of the canonical version in
src/utilities/network-detector.js (DC-031) which the manager also uses.
- start.sh now bind-mounts /usr/bin/tailscale (statically linked Go binary
— works under Alpine libc) and /var/run/tailscale/ into the container,
read-only. Lets the container invoke the CLI without needing its own
tailscale install.
- 41 new unit tests in __tests__/tailscale-manager.test.js cover: CLI
success/missing/daemon-down/malformed-JSON paths, 5-min cache hit/miss,
1-hour installed-cache hit/miss, getLocalIP IPv4/IPv6/missing-choices,
getSummary shape, getDevices shape with full + minimal peer fields,
startSyncTimer/stopSyncTimer interval + idempotency, TAILSCALE_BIN env
override.
Total: 1138 tests pass (was 1097, +41 new), 0 new ESLint warnings.
What this unlocks:
- /api/v1/tailscale/status → real installed/connected/hostname/ip/
peerCount/onlinePeerCount summary instead of empty
- /api/v1/tailscale/devices → real device list (was returning [])
- /api/v1/tailscale/check-connection → works (uses real isTailscaleIP)
- tailscaleAuthMiddleware allowedTailnet check (DC-121) is no longer
dead code — a request from a Tailscale IP not in the allowed tailnet
now actually gets 403 instead of being silently allowed.
The host-side /opt/dashcaddy/scripts/dashcaddy-update.sh was hardened in
DC-025 (commit bfa4ba5, 2026-07-05), but the canonical script at
dashcaddy-api/scripts/dashcaddy-update.sh was never updated. This created
a drift hazard: anyone running release.sh and rebuilding the install
tarball would propagate the pre-hardening version, undoing DC-025 on
fresh hosts.
This commit syncs the hardening from the host-side script to the canonical,
so the next release builds and ships the hardened version. Specifically
adds:
- channel_allowed() gate (refuse prereleases unless ALLOW_PRERELEASE=true)
- deploy_mode() dispatch (compose / start.sh / bare docker run)
- build_image() helper
- deploy_tree() with chattr +i preservation and empty-staging-dir guard
- Post-deploy patches invocation (dashcaddy-post-deploy-patches.sh)
- dns-providers directory backup/restore
Verified: bash -n passes on both scripts; canonical and host-side are now
byte-identical (md5 a72e1dc37fb3487edc00e81ea37ac60b).
Discovered while investigating a WIP on DNS2 that had silently reverted
these features. That WIP was discarded (the BACKLOG entry it claimed to
satisfy described an implementation that didn't exist in the diff).
- Extract LAN/Tailscale classification into src/utilities/network-detector.js
(detectInterfaceIps, isTailscaleIP, isPrivateLanIP). The route handler in
src/app.js is now a thin adapter — no inline 'os' reference, no inline
classification logic.
- Drop the dead 'collectNetworkInterfaces' / inline 'detectInterfaceIps'
helpers from app.js (the original ReferenceError shape).
- Add __tests__/network-ips-route.test.js (16 tests):
- Detector unit tests for Tailscale CGNAT (100.64/10) and RFC 1918 LAN
ranges with malformed-input guards.
- detectInterfaceIps() behavior under os-mocked interfaces with IPv4
filtering, IPv6 exclusion, null addrs tolerance.
- Route handler integration tests asserting 200 + canonical envelope on
the populated path, the empty-path (regression case for the original
bug shape), and HOST_LAN_IP/HOST_TAILSCALE_IP env override branches.
- Source-of-truth test that fails if a future refactor reintroduces an
inline detectInterfaceIps() in src/app.js or references 'os' without
a prior require('os') line.
- Fix latent ESLint Error in backup-manager.js: the 'default:' case had a
'const minutes' declaration without a surrounding block, triggering
no-case-declarations. Added the block braces.
Pre-fix baseline: no test exercised this route, so the 1071-test suite
passed despite the 500. Post-fix: 1087/1087 tests pass (+16 new), zero new
ESLint warnings (10 pre-existing warnings in backup-manager.js unrelated
to this commit).