Captures the 14 product-spec decisions locked today with Sami:
- Time-based pricing: 30/90/180/365 day tiers at $20/$50/$70/$99
- Free = up to 3 users, Pro = unlimited
- Free = local-only; Pro adds Tailscale-mediated share + public share links
- Stripe Checkout, USD-only, optional dashcaddy.net account
- LIFETIME keys are creator-only (no public exposure)
- Use existing license-keygen, no new auth system
- Invitees MUST use email magic link (email = identity for non-host users)
BACKLOG gets 5 new build tickets:
- DC-052: License-tier enforcement (cap Free at 3 users, gate share on Pro)
- DC-053: Public + Tailscale-mediated share routes (the killer Pro feature)
- DC-054: Stripe webhook bridge for auto-issuing license keys
- DC-055: dashcaddy.net/pricing page + Stripe Checkout
- DC-056: ToS + Privacy Policy pages (GDPR-aware)
No code changes — pure planning artifacts. Code work begins next.
When only TOTP is enabled (today's production state for everyone),
auth-gate.js was falling through to the legacy TOTP overlay with no
visible path to the email provider. The email method was unreachable
from the UI even when configured. Fixed: append a small 'Or sign in
with email instead ->' link to the bottom of the TOTP card. Clicking
swaps the body to the email challenge form.
Why this matters even for the single-totp path: email is the
phone-friendly, no-app-required recovery path. Operator forgets their
TOTP secret at 2am, they can request a link without touching the
authenticator app. The link just wasn't reachable before.
Renders the link only when the methods response includes both totp
and email — preserves the truly-single-provider case unchanged.
New module status/js/auth-gate.js owns the Caddy ?auth=required flow.
On load it queries GET /api/v1/auth/login/methods to discover which
AuthProviders are configured. Three branches:
* 0 providers → legacy TOTP overlay (delegates to window._showTotpOverlay)
* 1 provider (totp only) → legacy TOTP overlay (delegates, no UI change)
* 2+ providers → provider selector with 'Sign in with …' buttons
Email provider challenge is a single email input + 'Send sign-in link'
button. POST to /api/v1/auth/login/email/initiate. On success the UI
shows 'check the server logs' message if deliveredVia == 'dev-console'
(production hosts without SMTP fall back gracefully) or 'check your
inbox' when SMTP is configured.
TOTP button just calls window.location.reload() — simplest path because
totp-auth.js wires the 6-digit input handlers at module-load time, and
a reload re-runs all IIFEs with the original markup. Same behavior as
the legacy single-provider path.
Coordination with totp-auth.js: auth-gate.js sets window.__dc_049_handled
= true at IIFE entry. totp-auth.js's top-level ?auth=required check
reads that flag and skips its own UI when set — eliminates the flicker
in multi-provider installs. Single-provider installs still work because
the legacy code path is unchanged (auth-gate delegates to it).
Bundle order in build.js: auth-gate.js BEFORE totp-auth.js so the flag
is set in time.
Webpack-style bundle markers verified offline: __dc_049_handled,
auth-gate-email-input, provider-btn, _showAuthGate, totp_redirect all
present in dist/core.js (now 20 files, 248KB raw / 153KB min). New SW
cache hash dashcaddy-shell-680e230383 (was 743f9c17b0).
After every rebuild the freshly-baked dashboard bundle lives in
/opt/dashcaddy/status/dist/ + sw.js. DNS2 also serves files from
/var/www/dashcaddy-status/dist/ + sw.js (the original Windows-installer
mirror path). Without an explicit copy step between build and start.sh,
the served bundle stays on whatever hash was there before, while the API
responds with new code. That mismatch is what shows up in the dashboard
as "version unavailable" + "no data" widgets — saw it in the deploy
that followed DC-046/047 (fixed by a manual cp this time, never again).
Sync block runs before docker run:
cp /opt/dashcaddy/status/dist/*.js /var/www/dashcaddy-status/dist/
cp /opt/dashcaddy/status/sw.js /var/www/dashcaddy-status/
cp /opt/dashcaddy/status/index.html /var/www/dashcaddy-status/
All guarded so set -e doesn't kill the container start on a
single per-file failure (e.g. read-only mount, missing dir). Missing
source dir is a WARN + no-op rather than a fatal — fresh installs
without status/dist/ don't get a stale-bundle problem, just a log line.
Test: scripts/test-start-sh-sync.sh — 7 assertions across 4 cases
(fresh-copy, missing-source, idempotent-re-sync, set-e-survives-permission-
denied). All pass.
Pluggable AuthProvider framework for any future auth method (OIDC, SAML,
passkeys) to plug in without touching the auth path again. Two
implementations ship:
* TOTP — refactored from routes/auth/totp.js into src/auth/providers/totp.js
as one AuthProvider impl. Legacy /api/v1/totp/* routes stay mounted for
back-compat; new /api/v1/auth/login/totp/* routes use the new shape.
* EmailMagicLink — src/auth/providers/email.js. Initiate issues a 32-byte
base64url token, stores its SHA-256 hash in data/email-tokens.json
(atomic lockfile-based mutation, automatic TTL cleanup), and delivers via
nodemailer if providers.email.{host,port,username,password} is set OR
falls back to log.info('auth', 'email magic link issued', ...) for dev.
Verify accepts the token, marks it used, creates the same DashCaddy
session cookie that TOTP uses (single global cookie model).
createAuthProviderRegistry() composes both implementations and exposes
them via /api/v1/auth/login/{methods, :provider/initiate, :provider/verify,
recovery-info} and /api/v1/auth/disable/:provider. PUBLIC_ROUTES + CSRF
exemptions updated to use :provider placeholder (parameterized for future
providers).
Test fix: src/utilities/middleware.js PUBLIC_ROUTES and csrf-protection.js
both switched to the :provider form because the prior literal 'totp'
wouldn't match the parameterized mount path Express 4.22 produces.
Test fix: __tests__/public-routes-drift.test.js extractMountPath() was
broken for Express 4.22's new ^/path/?(?=/|$) source format (no \?\?
terminator in the literal-mount case). Rewrote the parser to normalize
escaped slashes + trailing lookaheads instead of relying on regex
matching against the raw source.
New: __tests__/auth-provider-registry.test.js — 9 tests covering registry
composition, getProvider round-trip, listEnabled no-secrets-leak guarantee,
enabled-flag respect, listAll vs listEnabled distinction, email provider
dev-console fallback (token written to JSON store + log.info with
deliveredVia: 'dev-console' + response masked), verify rejects unknown
tokens via AuthenticationError.
Tests: 1241/1241 passing across 46 suites (was 1232; +9 new).
DNS2 deploy: this commit + bump VERSION to 1.16.0 + publish tarball to
get.dashcaddy.net + docker build + bash start.sh. The image-layer migration
from DC-050 also runs on first container recreate post-merge.
Three-part fix for the silent data-loss failure mode that survives DC-039:
If SERVICES_FILE env was unset, platformPaths.dataDir resolved to /etc/dashcaddy
(image-layer path), and audit/license/error logs would silently land there and
vanish on every container recreate.
1. platform-paths.assertSafe({mode:'production'}) — throws FATAL on forbidden
zones (/app/src,routes,scripts,utils,managers,security + /etc/* + /usr + /var).
Bypassed with SKIP_DATA_DIR_GUARD=1.
2. server.js calls assertSafe() before any runtime work.
3. start.sh one-time migration: scans 6 known image-layer zombie paths,
copies non-empty content to bind mount with 'migrated-' prefix,
gated by sentinel file. Survives set -e per-file failures.
19/19 platform-paths tests + 5/5 shell migration tests.
Suite: 1066/1067 (1 pre-existing public-routes-drift failure from in-flight
auth refactor, untouched by this commit).
Verified live on DNS2: live audit log at /app/data/audit-log.json (315KB,
active) is unaffected; vestigial 2-byte /app/src/security/audit-log.json +
140KB /app/src/utils/error.log (pre-DC-039 era) will be recovered on next
container recreate.
Symptoms:
- Open 4-5 service tabs (Plex, Torrent, Radarr, etc.) + dashboard polling
- Each page-load fires Caddy forward_auth on every asset (HTML, JS, CSS, XHR)
- /api/v1/auth/gate/<service> counted each call against the 20/15min STRICT budget
- Within a minute or two of normal browsing, every gated service flips to 'down'
with statusCode 429, because Caddy bounces the 429 to a 'auth required' redirect
to status.sami
Fix:
- Split /auth/gate into its own limiter: 600/15min (40/min average) — comfortably
accommodates ~6 service tabs each polling every 15s
- Keep /auth/keys, /auth/jwt, /auth/app-token on the original 20/15min STRICT
(those actually mint credentials — gate just hands Caddy pre-existing auth)
- Same skip clause preserved: req.auth.type in {session, jwt, apikey} bypasses
the limit, so a properly-logged-in user never hits either limit
This is the same class of bug as the DC-044 / P21 health-check probe false
negative (probe chatter exhausting the auth budget). Adding to BACKLOG.
Previously, /api/v1/updates/available silently skipped any image on a
non-Docker-Hub registry (line 154 routing: 'ghcr.io/seerr-team/seerr'
has 3 slash-delimited segments → 'Custom registry not yet supported').
Symptoms: 4 of 6 production containers (seerr, albyhub, phoenixd,
velxio) all on ghcr.io. UpdateManager would log 'Custom registry not
yet supported: ghcr.io/...' and return null. Updates invisible in the
Updates modal, even when newer images existed.
Fix:
- Rewrite image parsing to detect the tag-vs-registry-host colon
correctly (lastColon > lastSlash guard, handles ghcr.io:443/path).
- Add getGhcrDigest() mirroring the DockerHub pattern, against
ghcr.io's OCI distribution endpoint. Same bearer-token auth flow,
the existing parseAuthHeader + authenticateAndGetDigest already
handle the WWW-Authenticate format ghcr.io returns.
- Multiple Accept headers for the response — Docker Hub used
manifest.v2 only; GHCR serves manifest.list.v2 for multi-arch tags
like ':latest', and the response is the multi-arch manifest itself
with the platform-specific digest in the Child header chain. We
use the digest from the 'docker-content-digest' response header,
which the GHCR endpoint sets even for manifest lists.
Verified: UpdateManager log now shows 'Found 6 updates available' on
DNS2 (previously 0-2, all from non-Docker-Hub images). /api/v1/updates/available
returns entries for seerr, albyhub, etc.
Per-tile Update button (core.js:811) + Updates modal Update/Update All
(features.js:1508 + L()) are already wired and now functional for all
registries.
Sami confirmed: the user's email IS their identity. No separate username
field at any point. One field, one identifier, no display-name collection
on first login.
Updated DC-047 ticket to lock this in.
Tickets added per Sami's request: email-only auth as an option alongside
TOTP, not a replacement. Architecture: AuthProvider interface so future
methods (OIDC, SAML, passkeys) plug in without further refactors.
Reuses existing nodemailer integration (no new dependency) — SMTP creds
live in the same notification config that already supports email alerts.
TDC-046 — refactor TOTP into one of N providers (foundation, ~1hr)
- DC-047 — EmailMagicLinkProvider via nodemailer (~3hrs)
- DC-048 — Multi-user bootstrap + admin invites (~2hrs)
- DC-049 — Login UI showing all enabled providers (~1hr)
Sami mentioned he wants to use the SMTP server his website (sami-ahmed.net)
runs — host will be configurable in the existing email provider config.
Documented as done in BACKLOG. Live-verified on dc-contabo-de test server:
workflow engine now starts, 90s post-restart shows zero error spam.
Combined with DC-044, workflows now actually execute end-to-end.
The bundled-workflows.js:310 call site used a non-existent .getState()
method AND forgot to await. The Promise short-circuited via '|| []' to an
empty array, so every health-check-on-interval workflow ran every 5 min
reporting 'Action health-check failed: servicesStateManager.getState is
not a function' while silently iterating over zero services. Visible on
both DNS2 (production) and dc-contabo-de (test server) — same code, same
bug, same log spam.
Fix: 'await servicesStateManager.read().catch(() => []) || []' — uses the
actual async method, returns empty array on read() failure (corrupt or
missing state file shouldn't break the workflow), preserves the original
short-circuit guard.
New regression test __tests__/bundled-workflows-health-check.test.js with
5 cases:
1. uses .read() not the non-existent .getState() — does not throw
2. returns checked/healthy counts from read() output
3. gracefully degrades if read() throws — empty services list, no crash
4. servicesStateManager absent on ctx → no crash, empty result
5. single service (non-template serviceId) path still works
Tests: 1219/1219 pass (1214 baseline + 5 new). ESLint: clean for the new
file. Test fixture note: had to clearInterval the constructor's
scheduledJobs so Jest could exit cleanly — scheduled workflows are not
under test here.
Empirically measured against all 4 release versions + origin/main: every
patch in the old script is a no-op against every current release. v1.14.4
(the version that originally needed patches) doesn't even ship src/ in the
tarball — the old script silently no-op'd on it because it couldn't find
files to patch, then the build crashed with MODULE_NOT_FOUND in production.
Repurposed as a verifier: 5 hard checks (server.js requires, license-manager
path, src/ tree presence, license-keygen.js at root, generic src/ require
path scan) + informational warnings. Exits 1 on ANY failure with a clear
'Build should be ABORTED' message naming the v1.14.4-class bug if relevant.
Old behaviour was 'patch and continue' (silently hid regressions); new
behaviour is 'fail loud' (every regression now produces a build abort).
Files changed:
- scripts/dashcaddy-post-deploy-patches.sh — rewritten as verifier (222→274
lines, header explains the empirical evidence + behaviour change)
- dashcaddy-api/scripts/test-dashcaddy-post-deploy-verifier.sh — new
regression test, 17 assertions across 10 scenarios (clean tree, missing
files, broken requires, empty src/, missing app.js, absolute path, etc.)
Empirical measurements documented:
- origin/main: 5/5 checks pass
- v1.14.9 (latest): 5/5 checks pass (0 patches applied under old script)
- v1.14.8: 5/5 checks pass (0 patches applied under old script)
- v1.14.4: 2/5 checks FAIL under new verifier (src/ missing, license-manager
in wrong location) — old script silently no-op'd on the same input
Tests: 1214/1214 Jest + 31 shell assertions. Lint: 150 warnings, all
pre-existing in untouched files (zero new warnings introduced).
The host-side updater only backed up code + data/, leaving trigger.json and
result.json unarchived. After a failed update, operators had to reconstruct
'what was being attempted' by joining timestamps across files. Now the
backup captures both files into a 'update-state/' subdir alongside code +
data backups, keyed by from-version.
- New `backup_update_state()` function in dashcaddy-update.sh: idempotent,
tolerates absent files (cleans up empty subdir), tolerates chattr +i
(unlock/copy/relock).
- Wired into main() right after `backup_data_dir`, before `cleanup_old_backups`.
- Deliberately does NOT auto-restore trigger.json on rollback — the rollback
handler reads a fresh trigger.json written by the operator/container;
restoring the previous attempt's trigger would clobber the active rollback
request. Backups are read-only forensic evidence.
- New `dashcaddy-api/scripts/test-dashcaddy-update-backup.sh` (14 assertions,
5 test groups): both-files-present, partial-present, no-files-present,
idempotency, main() flow ordering. All 14 pass.
- Synced the duplicate at `dashcaddy-api/scripts/dashcaddy-update.sh`
(md5-identical to scripts/dashcaddy-update.sh).
Tests: 1214/1214 pass (zero change). Lint: 150 warnings, all pre-existing
in untouched files (zero new warnings introduced).
Multiple modules derived file paths from __dirname, which is unstable in two
ways: (1) it moves whenever the file is reorganized under src/, and (2) it
points to the in-container source dir /app/src/<x> in production, which is
not bind-mounted, so writes would silently land in the image layer.
Affected modules (10 files): backup-manager, resource-monitor, update-manager,
docker-security, audit-logger, bundled-workflows, port-lock-manager, logging,
error-handler, license-keygen, plus crypto-utils and credential-manager which
already had multi-candidate resolvers but no centralised fallback.
Introduced platformPaths.dataDir (derived from SERVICES_FILE/CONFIG_FILE/
DNS_CREDENTIALS_FILE env vars when set, else path.dirname(servicesFile)) so
every module resolves the same canonical data directory. Each module now
fans the runtime files into the data dir while preserving per-file env-var
overrides for custom deployments.
Why a single resolver:
- one place to swap the default path scheme in v2.x without chasing
hardcoded __dirname joins
- a single source-of-truth for tests, backup tools, and the soon-to-be
added single-volume migration script
- prevents the class of DC-033 (self-updater 0.0.0) bugs where __dirname
drift in a subdirectory silently loses runtime state
Also fixed:
- audit-logger: AUDIT_LOG_FILE default was /app/src/security/audit-log.json
(writable in dev, image-layer in production). Now /app/data/audit-log.json
via platformPaths.dataDir, matching logging.js's same file. Same physical
path, no behavior change for callers that already set AUDIT_LOG_FILE.
- logging.js: LOG_DIR was __dirname (src/utils/) — error.log and
audit-log.json were being written into the source tree. Now
platformPaths.dataDir, matching every other persistent file.
- error-handler.js: ERROR_LOG_FILE hard-coded to __dirname/error.log
(src/utilities/error.log), redundant with logging.js's own default.
Now platformPaths.dataDir/error.log.
- host-registry / event-store / event-workers: simplified the
'platformPaths.dataDir || path.join(__dirname, ../../data)' pattern
to just platformPaths.dataDir (the legacy fallback is no longer
reachable — services.json lives at dataDir/services.json now).
- public-routes-drift.test.js: added 'routes/security.js' to the
direct-mount list so the /api/v1/security/events/ingest and
/api/v1/security/events/batch entries in PUBLIC_ROUTES are
recognized as mounted (was missing — fixed DC-044's drift-detection
test gap).
Tests: 1214/1214 pass (0 new failures, 1 new test for the corrected route
mount detection path). ESLint: 146 warnings + 4 errors — same baseline as
HEAD (no new warnings or errors introduced; one pre-existing require-await
on readline was removed as a drive-by in event-workers.js since the module
uses line-level fs reads, not readline). Container config files like
audit-log.json, container-stats.json, and workflow-history.json still
exist on the running container's image layer — Docker will pick up the
new defaults on the next recreate (the update path already moves
services.json+config.json+credentials.json via the data bind mount).
Introduces a unified security event store and HTTP API that ingests events
from any of the configured sources (API audit, Caddy access log, fail2ban,
shared_bans, future remote agents) and surfaces them in the dashboard.
New files:
src/security/event-store.js JSONL-backed store + in-memory query index
src/security/host-registry.js Registered hosts with per-host API keys
src/security/event-workers.js Tail-followers for Caddy/fail2ban/shared_bans logs
routes/security.js Events, hosts, ingest, SSE stream endpoints
status/js/security-center.js Dashboard modal with Overview/Events/Hosts tabs
SECURITY-FEATURE.md Full feature documentation
DEAD-CODE.md, DUP-CODE.md, HARDENING.md Prior audits
Modified:
src/app.js Mount /api/v1/security/*
src/utilities/middleware.js Add ingest endpoints to PUBLIC_ROUTES
src/security/audit-logger.js Mirror audit events into security store
server.js Start security workers on boot
status/build.js Bundle security-center.js
status/index.html Add Security button to nav
Generated from static analysis of router.*() registrations across
47 route files. Covers 285 routes grouped into 29 feature areas.
Each entry includes method, full path, auth classification
(public/protected per PUBLIC_ROUTES allowlist), rate-limit bucket
(GENERAL/STRICT/TOTP), and source file:line.
Also cross-checks against openapi.yaml: 142 routes undocumented,
18 stale paths in spec. This is a real gap that should be fixed
before v1.0 public release.
First draft of the product spec covering pricing tiers, billing,
auth model, distribution, support, hosting, and compliance posture.
10 questions with proposed defaults per phase 2 of the sellable
DashCaddy roadmap. Awaiting Sami's review.
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).
Adds 6 tests that catch the exact bug DC-033 fixed. Verified to actually
fail (4/6) against the pre-fix code (git show 20d280f^:self-updater.js),
proving it's a real regression test and not a placebo. Full suite: 40/40
suites, 1081/1081 tests.