The original DC-044 fix (b492e1c) repaired servicesStateManager.getState() but
missed two latent bugs at the same code path that were still spamming DNS2
every 15 minutes:
1. notify-on-failure fired unconditionally. The comment said 'Only send if
previous action failed' but executeAction never checked. Every
health-check-on-interval cycle ran notify regardless of outcome.
2. {{serviceId}} template never interpolated. healthCheckService returned
{ checked, healthy, results } with no serviceId in scope, so the
production alert 'Health check failed for {{serviceId}}' stayed literal
in every notification.
3. checkContainerHealth compared info.State.Health (an object) to the string
'unhealthy' — always true, so any container with an explicit HEALTHCHECK
was always reported healthy.
Fix:
- Extract _runActions(actions, triggerData) from executeWorkflow so the
per-action result threading and failingServices context surface are
testable in isolation.
- Gate notify-on-failure on previousResult.success === false. Returns
{ skipped: true, reason: 'no previous failure' } when no preceding failure.
- healthCheckService throws an Error with .failingServices attached when
any service is unhealthy, surfacing IDs into the next action's context.
- checkContainerHealth now reads info.State.Health.Status: 'healthy' or
'starting' → healthy, 'unhealthy' or no health check + stopped → unhealthy.
- Update bundled health-check-on-interval template from {{serviceId}} to
{{failingServices}} (the variable now in scope).
Tests (12 new, 16 total in file):
- 5 _runActions tests (gate, interpolation, multi-service batch, first-action
no-op, plain notify regression guard)
- 1 end-to-end executeWorkflow test against bundled health-check-on-interval
asserting no literal {{...}} tokens reach notification.send
- 3 checkContainerHealth tests (running-but-unhealthy, no-healthcheck, stopped)
- 1 healthCheckService throw test with failingServices attached
- 2 updates to existing assertions for new return shape
Full suite: 1461/1463 (2 pre-existing license-keygen failures in DC-054
territory, unrelated to this commit).
Co-graded: Codex B urn:ump:b2nzzoulodwsullt3rhz4mtzou7fqgwiuoyrzxho67gdpwx3uvaa
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.
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.
First pricing enforcement. Per PRODUCT-SPEC-DECISIONS.md:
- Free = up to 3 users, no share features
- Pro = unlimited users + Tailscale-mediated share + public share links
- LIFETIME keys are creator-only (Sami runs --lifetime on his dev
machine; production API rejects LIFETIME codes at activate())
- Free has NO trial; Pro is a deliberate paid choice
Changes:
- src/managers/license-manager.js:
- isPro() shorthand (active + non-expired = true; LIFETIME counts)
- allowsLifetimeLicense() reads ALLOW_LIFETIME_LICENSE env var
- activate() rejects LIFETIME codes with a clear error unless the
env flag is set (so Stripe webhook can't accidentally issue one)
- src/security/user-store.js: countUsers() helper for the tier gate
- src/utilities/errors.js: PaymentRequiredError (HTTP 402, code DC-402)
- routes/auth/admin.js:
- _requireProIfUserLimitReached middleware on POST /admin/users
and POST /admin/invites (throws 402 at count >= 3 + Free)
- /invites/:token/accept also gated — burns the invite at cap so
it can't be replayed later
- routes/auth/index.js: bridge licenseManager + userStore onto
req.app.locals so the gate middleware can find them; pass
licenseManager into the provider registry for future use
Tests: 19 new (license-tier-enforcement.test.js) covering isPro(),
allowsLifetimeLicense(), LIFETIME accept/reject paths, countUsers(),
PaymentRequiredError shape, and end-to-end admin-route gating.
Full suite: 1317/1317 passing across 50 suites.
Defer to DC-053 (share routes), DC-054 (Stripe bridge), DC-055
(pricing page), DC-056 (ToS).
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.
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.
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.
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
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.
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.
- 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).
The SelfUpdater's getLocalVersion() used __dirname to find package.json
and VERSION, but server.js loads the module via './src/docker/self-updater'
so __dirname resolves to /app/src/docker inside the container — which
has no package.json. Result: /api/v1/system/version silently returned
{version: '0.0.0', commit: null} and checkForUpdate() always thought we
were outdated.
Walk a candidate list of paths (api root first, __dirname second) so the
function works regardless of where the module is required from. Log to
stderr on total failure instead of swallowing silently.
Verified on DNS2: /api/v1/system/version now returns
{"name":"DashCaddy","version":"1.14.8","commit":"fef7e07"}
(v1.14.8 with the security fixes DC-020..032).
Three coordinated changes to stop every gated *.sami service flipping
red after ~20 probes:
1. health-checker.js _doRequest() now sends X-DashCaddy-HealthCheck: 1
on every outgoing probe. Caddy uses this header (combined with a
trusted source IP via the new @healthcheckProbe matcher in the
dashcaddy_auth snippet) to bypass forward_auth for local container
probes. Without the bypass, forward_auth 401's every probe, and the
authLimiter (20 req / 15 min, DC-027) caps us out within minutes.
2. evaluateHealth() default expectedStatusCodes now includes 401, 403,
and 429. Defense in depth — if a future Caddy reload drops the
bypass, 429 from the rate-limited gate no longer marks the service
as down (it just means the gate answered, which proves the service
is reachable through Caddy).
3. (start.sh — already shipped on the running container, will land
with the next release build) ca.sami now maps to 100.121.150.22
(DNS2) instead of 127.0.0.1, which is the container's own loopback
where nothing serves :443. The CA web UI lives on DNS2's Caddy.
Tests:
- evaluateHealth: 401, 403, 429 accepted by default
- _doRequest: X-DashCaddy-HealthCheck: 1 always present
- _doRequest: user-supplied headers preserved alongside marker
Bump 1.14.7 → 1.14.8.
The DC-027 rate limiter on /api/v1/auth/* shipped with skip: () => isTest,
which counted every request — including those from a logged-in TOTP session.
Caddy's forward_auth fires /auth/gate/* on every page-load asset (HTML, JS,
CSS, XHR), so a normal browser session exhausted the 20-req/15-min budget
within ~3 page loads and started getting 429 'Too many auth requests' even
with a valid session cookie.
Fix: extend skip to also return true when req.auth.type is 'session',
'jwt', or 'apikey' (set by jwtApiKeyAuthMiddleware, which runs upstream
of the limiter). The unauthenticated path is still rate-limited — DC-027's
credential-scraping defense is preserved.
Also closes the uncommitted working-tree changes for:
- DC-026: routes/auth/sso-gate.js — pre-auth check in buildLoginPage,
redirected error fallbacks to status.sami?auth=required&return=...
- DC-022: dashcaddy-api/VERSION bumped to fef7e07
- status/index.html + status/js/tailscale-devices.js — Tailscale device card
4 new regression tests pin the fix:
- skips when req.auth.type === 'session'
- skips when req.auth.type === 'jwt'
- skips when req.auth.type === 'apikey'
- still counts UNAUTHENTICATED requests (defense preserved)
Live verified: 50/50 authenticated /auth/gate/plex calls passed (was
20/30 before fix). plex.sami/dashcaddy-login returns 200 with no redirect
loop. Plex auto-login token round-trips end-to-end.
[DC-026] routes/auth/sso-gate.js — fix sessionDuration='never' bypass
Both /auth/gate/:serviceId and /auth/app-token/:serviceId had a session
check gated on `sessionDuration !== 'never'`. An admin setting TOTP to
never-expire accidentally created an authentication-free path to credential
injection (Basic Auth, X-Api-Key, Plex/Prowlarr tokens). Patched: session
required whenever TOTP is enabled, period. Added 8 regression tests.
[DC-027] src/utilities/middleware.js — rate limit /auth/*
New authLimiter (20 req / 15 min) on /auth/keys, /auth/jwt, /auth/gate,
/auth/app-token. These endpoints expose credentials and were unmetered.
Without this, an attacker with a guessed session cookie could burn through
every credential-touching endpoint. Added 5 tests.
[DC-028] src/security/audit-logger.js — log credential exposures
/auth/gate and /auth/app-token were in SKIP_PATHS, silently dropping
every credential-exposure event from the audit log. Combined with the
GET-skip rule, NONE of these events were being recorded. Now logged
with named actions: auth.credential-injection, auth.app-token-issue,
auth.api-key-generate, auth.api-key-revoke, auth.jwt-mint. Added 9 tests.
[start.sh] Disable in-container self-updater
DASHCADDY_UPDATE_ENABLED=false. Without this, the container kept writing
trigger.json every 30 min and clobbered my in-progress host edits. The
path unit on the host is still active for manual triggers, but the
container won't auto-update itself — only when an admin clicks the
update button or a new release is manually published.
[package.json] Bump to 1.14.7
Test results: 1066/1066 passing across 39 suites (added 22 new tests).
- VERSION: bump from 1.14.4 to 1.14.6 to match package.json (HEAD had stale value)
- middleware.js: apply existing totpLimiter (10/15min) to /totp/setup endpoint
(was previously unmetered, allowing secret enumeration)
- dashcaddy-update.sh: hook post-deploy-patches.sh into the update flow
so the container can survive transitions between broken → fixed tarballs
- start.sh: add --add-host flags for get.dashcaddy.net and get2.dashcaddy.net
so the container can resolve the release server (was failing with ENOTFOUND)
v1.14.4 (commit d2a48b1) shipped with broken relative paths and missing
license-keygen.js module. This commit:
- server.js: 26 '../src/...' requires rewritten to './src/...' (server is
at API root, must use ./src for files in src/)
- src/managers/license-manager.js: './license-keygen' rewritten to
'../../license-keygen' (license-keygen.js lives at API root)
- src/docker/self-updater.js: './platform-paths' rewritten to
'../../platform-paths' (platform-paths.js lives at API root)
- license-keygen.js: restored to root (was missing from v1.14.4 tarball)
- VERSION: bumped to d2a48b1-patched (matches upstream commit but with
our fixes baked in)
Makes the v1.14.4 source buildable and runnable without external patches.
Companion to scripts/dashcaddy-post-deploy-patches.sh which applies these
fixes automatically during the host-side update flow.
The refactor(desloppify) commit a2e6566 deleted license-keygen.js and added it
to .gitignore, believing it was stale dev-root noise. It is actually a required
production module: src/managers/license-manager.js does require('./license-keygen')
and imports verifyCode/parseCode/VALID_DURATIONS. The deletion put the production
dashcaddy-api container in a crash-restart loop (MODULE_NOT_FOUND from
/app/src/app.js -> /app/server.js). The 1036-test suite passed because no test
ever executed require() on the real app module.
Fixes:
- Restore license-keygen.js from git history (a2e6566^) to src/managers/, the
path the post-DC-005 require resolves to. CLI main() is require.main-guarded,
so only the library exports are used at runtime.
- Remove the license-keygen.js line from .gitignore so the restored module is
tracked (otherwise the fix would not survive a container rebuild).
- Fix a second masked broken require: src/docker/self-updater.js required
'./platform-paths' (resolves to src/docker/, doesn't exist) -> corrected to
'../../platform-paths' (repo root, where all 9 other callers point).
- Add .encryption-key to .gitignore (runtime AES secret that the require graph
regenerates; was untracked + un-ignored -> latent leak on git add -A).
- Add __tests__/app-startup-smoke.test.js: executes require() on the real app
module and asserts the full require graph resolves. This regression guard
would have caught both broken requires.
Verified: app module now loads clean; 1038/1038 tests pass (+2 new); the smoke
test fails if either required module is missing.
The 489f700 fix accidentally stripped the subdirectory name from all bare
requires (e.g. managers/state-manager → .//state-manager instead of
./managers/state-manager). Fixed all 39 occurrences with correct subdir
prefixes (managers/, security/, monitoring/, docker/, utilities/, recipes/).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DC-005 refactored everything into src/ subdirs but left bare require()
paths in app.js (managers/, security/, monitoring/, docker/, recipes/,
utilities/, context/, dns/) which resolve fine in tests (jest mocks) but
fail in the container where NODE_PATH=/app/src is not set. Fixed ~25
requires with relative paths.
- sso-gate.js: add GET /api/v1/auth/login-page?service= route; auto-login
HTML for chat/plex/jellyfin/emby now served from code instead of inline
Caddyfile respond blobs. Fix merge() try-block syntax error (was missing
closing } before catch, breaking Jellyfin/Emby localStorage merge).
- middleware.js: add /api/v1/auth/login-page to PUBLIC_ROUTES.
- CLAUDE.md: complete rewrite — was describing the old Windows-local
C:/caddy/ layout; now accurately describes DNS2 production (paths,
container, caddy-apply workflow, SSO architecture, common mistakes).
- .gitignore: cover runtime JSON/log/cert files that were sitting untracked
in dev root (audit-log, backup-history, credentials, health-history, etc.),
plus generated-certs/, pki/, assets/.
- Remove tracked dev-root noise: comprehensive-test.js, license-keygen.js,
test-security-fixes.js (scripts that don't belong at repo root).
- Remove stale routes/openclaw.js (leftover from old monolithic structure).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Logger.error() called this._log('error',...) but dropped the return value.
_log returns the writeErrorLog(...) promise for error level, so every
await logError(...)/await log.error(...) caller was awaiting undefined —
the error.log disk write was fire-and-forget. This caused:
1. __tests__/logging.test.js 'captures request context' to flake in the
full suite (test read error.log before the un-awaited appendFile
completed; passed in isolation).
2. In production, 6 route handlers + the global boundAsyncHandler error
catcher all await logError(...) expecting the write to flush — error
entries could be lost on fast process exit/restart.
Fix: add 'return' so the promise propagates. Verified: logging test
passes 10/10 full-suite runs (was ~1/6 failure rate). No behavior change
for debug/info/warn (they never wrote to disk).
Fresh users copy-pasting healthcheck blocks from k8s/Docker docs need
the standard short aliases. Without /healthz and /readyz they get
connection refused. This commit:
1. Adds /healthz + /readyz as root-level aliases for /health/live +
/health/ready in src/app.js. Handler bodies DRYed into named
functions (livenessHandler, readinessHandler) so a probe semantics
change updates all five paths at once.
2. Removes the dead /api/v1/health*, /api/v1/health/live, /api/v1/health/ready
registrations from PUBLIC_ROUTES and CSRF exclusion list — those
routes were never actually mounted on the apiRouter (only root
paths existed). Anyone probing /api/v1/health now gets a clean 404
instead of being routed through to a duplicate root handler.
3. Adds bypass for the 5 probe paths in three places where it matters:
- PUBLIC_ROUTES (no auth)
- csrf-protection.js excludedPaths (no CSRF check)
- middleware.js request-logging exclusion (k8s polling every 10s
doesn't flood the audit log)
- middleware.js Tailscale auth bypass (probes don't carry Tailscale
identity headers)
4. Adds __tests__/health-probe-aliases.test.js (19 tests):
- Alias equivalence (/healthz == /health/live, /readyz == /health/ready)
- Back-compat (/health == /health/live)
- Path consolidation (all 3 /api/v1/health* return 404)
- Source-of-truth PUBLIC_ROUTES allowlist sync check
- Source-of-truth src/app.js mount list sync check (catches drift
between handler mount and middleware allowlist)
5. Documents probes in README (copy-paste docker-compose.yml +
Kubernetes blocks) and user-guide (Health Probes section + System
API table updated).
Post-fix: 941/941 tests pass (+19 new). Zero new ESLint warnings
introduced. The pre-existing warnings/errors in src/app.js line 906
('os' is not defined) and the empty blocks in logging.test.js are
not regressions from this commit.
After DC-005 refactor moved health-checker.js into src/monitoring/, the
require path was never updated. Tests in __tests__/health-checker.test.js
failed with 'Cannot find module' → 59 cascading test failures in the
health-checker suite.
Path: src/monitoring/health-checker.js → 'require(./platform-paths)'
Fix: 'require(../../platform-paths)'
Verified: 921/922 tests passing (one known async-timing flake in
logging.test.js 'writes entry to ERROR_LOG_FILE with context').
Routes covered in this batch:
- routes/events.js (1 call: GET /status)
- routes/workflows.js (6 calls: GET/POST/PUT/DELETE /workflows, POST /test, POST /:id/toggle)
- routes/openclaw.js (4 calls: GET /:hostname, DELETE /:hostname, POST /connect, GET /status)
- routes/dns.js (1 call: POST /credentials per-server results envelope)
Wire format unchanged — each handler now produces the same {success, ...} shape via success(). Net result: every {success, ...} envelope in routes/ now flows through the response helper, leaving only the intentional raw-array calls (services.js) and error-path envelopes for separate cleanup.
Routes converted: updates.js (17), notifications.js (8), tailscale.js (12).
All 3 routes now receive ok() through the factory destructure; wired in app.js.
notifications.js: kept 2 res.json() calls for genuine partial-failure semantics
- POST /test with ?provider=X: success reflects actual delivery
- POST /send: success reflects per-provider results
ok() hardcodes success:true and would lose that semantic; documented why.
tailscale.js: dropped unused 'fs' and unused 'NotFoundError' top-level imports
(NotFoundError is still required() lazily inside the protect-service handler).
Net change: 12 calls cleaned up, 2 lint warnings fixed.
750/750 tests still pass.
Routes converted: browse.js, logs.js, sites.js. Each factory dep now receives
the ok() response helper from src/utils/responses.js. Wired through the route
factory destructuring in src/app.js so the helper is available wherever the
route needs to send a success response.
Also touched (incidental cleanup landed in the same patch because the cron
session was exploring how ok/errorResponse are composed):
- src/config/site.js: 28 lines net — response shape consistency
- src/context/caddy.js, dns.js: 34 lines net — minor refactors
- src/utils/http.js, logging.js: 46 lines net — ESLint hygiene and helper plumbing
750/750 tests pass, 0 new ESLint warnings.
Cherry-picks the unified logger design from the 171c1ad WIP (which Hermes
signed off on as 'Ship this') and applies all Hermes review fixes
(krystie-wip/logger-refactor, 2026-06-15).
src/utils/logging.js is now the single entry point for:
- log.info / log.warn / log.error / log.debug (with level filtering,
color-coded dev output, JSON prod output)
- log.audit() / log.auditMiddleware() (audit-log.json + SKIP_PATHS
+ sensitive-key redaction)
- logError(ctx, err, extra) (writes error.log with
rotation, request context extraction)
- safeErrorMessage(err) (DC-200 port collision,
No-such-container, ECONNREFUSED, etc.)
Existing src/security/audit-logger.js kept untouched — routes/errorlogs.js
still uses auditLogger.query/clear, no callers migrated.
Hermes' must-fixes (all addressed):
[1] Syntax error on logger.js:401 — old logger.js at repo root is gone;
refactored src/utils/logging.js is the new home, no Chinese IME bug.
[2] /health/live and /health/ready endpoints — untouched in src/app.js.
[3] Tests — added __tests__/logging.test.js (18 tests, all pass) covering
module loads, level filtering, sanitize/audit/auditMiddleware,
safeErrorMessage, and logError. Full suite: 897/897 pass across 31
suites (was 879 + 18 new).
Hermes' should-fixes:
[4] asyncHandler signature — KEPT 3-arg (logError, fn, context). 49 route
files still call it this way; src/app.js's boundAsyncHandler unchanged.
[5] platformPaths.pkiRootCert — UNTOUCHED, still used in src/app.js.
[6] Five managers (Dependency, AutoRestart, ConfigDrift, SSL, DNS) — ALL
FIVE still initialized at server boot (verified via test).
[7] ok(res, ...) helper — UNTOUCHED, all routes still use it.
[8] Network-intel helpers (isPrivateLan, isTailscaleIP) — UNTOUCHED in
src/app.js, no duplicate inline logic added.
- setLevel() now updates both GLOBAL_LEVEL and the singleton log._level,
so level-filter tests don't pollute later tests.
- Logger.audit() and Logger.error() now return promises so await works.
- Logger._log() awaits writeErrorLog so callers using await can rely on
the error.log being flushed.
- safeErrorMessage() handles null/undefined explicitly (regression fix —
String(null) returned 'null' before, now returns 'An internal error
occurred').
- src/app.js boundLogError() simplified to 3-arg form matching the
unified logError(ctx, err, extra) signature.
- createLogger(level) alias exported so existing src/app.js callers work.
- logError, safeErrorMessage, LOG_LEVELS still exported.
- asyncHandler still imported from ./utils/async-handler, not from logging.
- No changes to routes/* (audit-logger.js still consumed unchanged).
- jest: 897/897 tests pass across 31 suites
- node -e "require('./src/app.js')" loads cleanly
- node server.js boots through full init (all 5 managers start)
- Color-coded logger output visible in dev mode (no NODE_ENV)
- JSON output in production mode (NODE_ENV=production)
Three logical changes grouped:
1. Widget bundle rebuild + sami-files logo (from previous session)
- status/dist/{init,core,features,onboarding}.js rebuilt from latest source
- status/sw.js cache bumped to dashcaddy-shell-594ec75648 to force SW refresh
- status/assets/sami-files.png added (Sami Files service card logo)
2. status/build.js: include monitoring-widgets.js in bundle
- The original build.js was missing monitoring-widgets.js from its JS()
bundle list — that's why the System Overview widget never showed up
in the live init.js until we ran the live /var/www/dashcaddy-status/
build.js. Now consistent.
3. dashcaddy-api/scripts/dashcaddy-update.sh restart_container(): preserve
TOTP secret across container recreates
- Was only setting SERVICES_FILE; container fell back to image-local
/app/credentials.json + /app/.encryption-key (auto-generated fresh
every recreate), which broke TOTP for the bind-mounted secret at
/app/data/credentials.json
- Added CREDENTIALS_FILE + ENCRYPTION_KEY_FILE env vars pointing at
/app/data/ so the container reads from the bind-mounted host data dir
- See skill: software-development/dashcaddy/references/totp-and-system-overview-pitfalls.md §9
4. Auto-updater integration (pulled from upstream release):
- dashcaddy-api/VERSION: dev → c64bbe2
- dashcaddy-api/health-checker.js, middleware.js, package.json,
routes/backups.js, src/app.js: new release code (bundled workflows,
/api/auth/ → /api/v1/ back-compat rewrite, backup storage limits)
- Recreate status/js/monitoring-widgets.js with robust services count
(reads from window.APPS, #cards DOM, then live fetch as fallback)
- Add sami-files service to data/services.json (Sami Files card)
- Add sami-files template to app-templates.js under 'Files' category
with full systemd deployment docs and Caddy snippet
- Bundle monitoring-widgets.js into init.js
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)