Commit Graph
397 Commits
Author SHA1 Message Date
Hermes a800f0d74e DC-047: clarify email-only is the identity (no username field)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-13 16:42:47 -07:00
Hermes fb42663ff2 DC-046..049: backlog — pluggable auth + email magic link
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-13 16:40:18 -07:00
Hermes 92eb04ada8 DC-045: fix WorkflowEngine init — new (require(...))() precedence bug
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-13 15:47:07 -07:00
Hermes b492e1cd4f DC-044: fix WorkflowEngine healthCheckService — servicesStateManager.getState bug
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-13 15:38:11 -07:00
Hermes 49e6c9cc11 DC-041: claim for Hermes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-13 15:12:52 -07:00
Hermes cbe0c912fc DC-040: repurpose post-deploy-patches.sh as a verifier (fail-loud, not patch-and-continue)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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).
2026-07-13 15:09:08 -07:00
Hermes b13960fa9a DC-040: claim for Hermes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-13 15:03:49 -07:00
Hermes 5f30fbf1ca DC-038: mark done in BACKLOG
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-13 13:27:47 -07:00
Hermes 1cc112f1e4 DC-038: backup trigger.json + result.json in dashcaddy-update.sh
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).
2026-07-13 13:27:38 -07:00
Hermes 2f583e176e DC-038: claim for Hermes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-13 13:22:57 -07:00
Hermes fdfe37fcc4 DC-039: mark done + record result
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-13 09:00:06 -07:00
Hermes f750d01ed0 DC-039: route all module file defaults through platformPaths.dataDir
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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).
2026-07-13 08:59:38 -07:00
hermes c9d067c2f0 Add Security Center — multi-source event pipeline with dashboard UI
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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
2026-07-13 02:28:56 -07:00
Hermes Agent f405186eb8 Add API-SURFACE.md — full route inventory with auth + rate-limit classification
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-13 00:59:22 -07:00
Hermes Agent 58f737a173 Add PRODUCT-SPEC.md draft — sellable subscription model
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-13 00:29:32 -07:00
Krystie 81f6049ded DC-044 Add X-DashCaddy-HealthCheck marker to batch probe endpoint
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-09 02:02:45 -07:00
Krystie 0f04bb3638 DC-044: mount Sami CA + fix ca.sami /etc/hosts in DashCaddy container
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-08 22:00:25 -07:00
Krystie 2169ec9853 DC-044: fix slice(13) -> slice(12) for /api/v1/auth/totp/check-session drift
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Slice(13) was off by one — it dropped the leading '/' before 'totp/'
producing /api/v1totp/check-session. Should be slice(12) so the '/'
stays.
2026-07-08 21:43:12 -07:00
Krystie 1baef432c4 DC-044: also handle /api/v1/auth/* drift variant in back-compat shim
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.
2026-07-08 21:42:03 -07:00
Krystie cf8909740f DC-044: fix legacy /api/auth/totp/check-session shim path (drop /auth)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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}
2026-07-08 21:33:07 -07:00
Krystie a7b0714643 DC-044: fix plex.sami auto-login JS 404 (add check-session to legacy shim)
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.
2026-07-08 21:33:06 -07:00
Krystie d539ee3b08 DC-044: fix /health/ready caddy probe false negative
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.
2026-07-08 21:33:06 -07:00
Hermes e036bfe452 DC-039: claim for Hermes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-07 08:13:23 -07:00
Krystie 6fb4f9b169 DC-043: tailscale coordination API client + admin/settings routes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-06 21:28:03 -07:00
Krystie d04238621f DC-042: implement real Tailscale manager — replace null stub
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-06 18:55:16 -07:00
Krystie ca705fe59f DC-025: sync DC-025 hardening (channel gate, locked-file deploy_tree, deploy_mode, post-deploy patches, dns-providers handling) into canonical dashcaddy-api/scripts/dashcaddy-update.sh
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).
2026-07-06 15:26:10 -07:00
Krystie a6201b47cd BACKLOG: claim DC-037 (move symlink creation into install script) 2026-07-06 15:10:55 -07:00
Krystie 369827c43f DC-031: fix /api/v1/network/ips ReferenceError + add regression tests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- 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).
2026-07-06 15:06:28 -07:00
Krystie 71fd7cd58f DC-035: add regression test for SelfUpdater.getLocalVersion() (DC-033 class)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-05 22:49:06 -07:00
Krystie 36c4528c7c BACKLOG: claim DC-035 (regression test for getLocalVersion) 2026-07-05 22:45:32 -07:00
Krystie 7ec428f34f DC-036: delete dead dashcaddy-api/self-updater.js (0 callers); sync VERSION to 42376e2
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-05 22:43:55 -07:00
Krystie ec532c8af7 BACKLOG: claim DC-036 (delete dead root self-updater.js) 2026-07-05 22:34:53 -07:00
Krystie 41302c12ec BACKLOG: mark DC-034 done — v1.14.9 published to get.dashcaddy.net
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-05 22:34:26 -07:00
Krystie 42376e2186 chore(release): bump to 1.14.9 (DC-033 fix baked in for auto-update)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-05 22:02:46 -07:00
Krystie be25c598f1 BACKLOG: claim DC-034 (regenerate release tarball as 1.14.9) 2026-07-05 22:01:32 -07:00
Krystie 47970cfd14 BACKLOG: document DC-033 (done) + add DC-034..041 from v1.14.8/0.0.0 incident
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Captures the work done in this session (DC-033) and surfaces 9 follow-up
items that came out of the cross-check investigation:

P1: DC-034 (regenerate release tarball as 1.14.9), DC-035 (regression test
for getLocalVersion), DC-036 (delete dead root self-updater.js), DC-037
(move symlink creation into install script so fresh hosts don't repeat
the v1.14.4 failure mode).

P2: DC-038 (backup trigger.json/result.json), DC-039 (audit for other
__dirname antipatterns), DC-040 (audit whether post-deploy-patches.sh is
still needed), DC-041 (integration test for the auto-update pipeline).

Each ticket cites the specific files, commit SHAs, and evidence from
this session so future agents can pick up where this left off.
2026-07-05 21:59:37 -07:00
Krystie 77536f4486 DC-033: bump VERSION to 20d280f (DC-033 commit SHA)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-05 21:50:46 -07:00
Krystie 20d280f1dd DC-033: fix getLocalVersion __dirname resolution
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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).
2026-07-05 21:49:39 -07:00
Krystie ba23cdff02 DC-032: fix health checker authLimiter feedback loop + ca.sami DNS
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-05 12:58:13 -07:00
Hermes ac0a4f56d5 DC-031: claim for Hermes — /api/v1/network/ips ReferenceError
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-03 07:52:21 -07:00
Krystie 95f558c49f DC-030: bake /etc/hosts overrides into start.sh (fix git.sami resolution in container)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The container's health-checker runs against Caddy via /etc/hosts resolution.
The node:20-alpine base image has no entries for *.sami, so without explicit
--add-host flags every *.sami probe resolves via the configured DNS server
(100.121.150.22 Technitium or 8.8.8.8) — both of which DO resolve *.sami but
return the WAN/Tailscale IP. That works for most services because Caddy on
DNS2:443 handles them.

BUT: a previous container run passed --add-host=git.sami:100.81.59.99
(DNS3's Tailscale IP). DNS3 does NOT serve HTTPS on 443 — Gitea listens on
:3030 only. So git.sami health checks inside the container hit DNS3:443,
get ECONNREFUSED, and the dashboard shows git.sami as down even though Caddy
on DNS2:443 correctly routes git.sami → 100.81.59.99:3030.

Fix: inject the correct --add-host flags from start.sh (the source of truth
for container setup) so future recreates get consistent resolution. git.sami
is intentionally left OUT — Caddy on DNS2:443 is the only correct ingress
for git.sami traffic.

Also documents the rationale so the next person doesn't reintroduce the
git.sami override by accident.

Live verified:
- container /etc/hosts has all needed entries except git.sami
- curl https://git.sami/ from inside container → 200 (via Caddy on :443)
- curl https://sync.sami/ from inside container → 302 (upstream redirect)
- curl https://router.sami/ from inside container → 302 (upstream redirect)
2026-07-03 00:08:55 -07:00
Krystie a92eeceae5 DC-029: skip authLimiter for already-authenticated requests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-02 18:27:03 -07:00
Krystie 57de3cb8e3 chore(release): bump to 1.14.7
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-01 12:19:03 -07:00
Hermes a2e7d9dbaf DC-020: mark done — fixed last broken require in server.js
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-01 07:38:17 -07:00
Hermes f94b164190 DC-020: fix last broken require in server.js (./state-manager -> ./src/managers/state-manager)
The DC-020 require-path sweep fixed every '../src/...' -> './src/...' in
server.js, but missed one: line 73 still had .
From the production entry point (/app/server.js) this resolves to
/app/state-manager.js — a file that does NOT exist (the module lives at
src/managers/state-manager.js). Unlike the optional modules below it,
this require is bare (not wrapped in try/catch), so a MODULE_NOT_FOUND
here throws out of the top-level startup IIFE and crash-loops the
container — the exact same failure mode as the deleted license-keygen.js.

Fix: ./state-manager -> ./src/managers/state-manager (matches line 146).

Also hardens the DC-020 regression guard (app-startup-smoke.test.js):
adds a static check that EVERY relative require() in server.js resolves
to a real file on disk. server.js cannot be require()'d at test time
(its IIFE binds port 3001 + starts interval modules, leaking workers),
so the static scan is what catches this class of entry-point path bug.
This test would have failed on the original ./state-manager line.

1067/1067 tests pass (was 1066 baseline + 1 new). Zero new ESLint warnings.
2026-07-01 07:37:45 -07:00
Krystie fef7e07b49 DC-026/027/028: close 3 more auth security holes + rate limit /auth/* + audit credential exposures
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
[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).
2026-07-01 04:20:57 -07:00
Krystie bfa4ba570e DC-025: harden updater — channel gate + safe locked-file replacement
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The host-side updater has been silently broken in two ways:

1. Empty staging directories would cause rm -rf of live routes/src with no
   replacement, leaving the host tree gutted while the container kept serving
   from its own image. Now deploy_tree() refuses to delete unless the staging
   source has actual files.

2. chattr +i on critical files (used to protect security-hotfixed routes from
   being clobbered by upstream tarballs) caused rm -rf to partially execute
   then fail under set -e, leaving the host in a half-deleted state. Now
   deploy_tree() scans for immutable files, unlocks them before replace,
   and re-locks them after — so security-locked files survive every update.

Also adds:
- Channel gate: trigger.json channel=prerelease/beta/rc/alpha is rejected
  unless ALLOW_PRERELEASE=true is set in /opt/dashcaddy/updates/channel.conf.
  Default is 'stable only', safe for production. Staging hosts opt in.
- channel.conf.example documenting the new opt-in mechanism.

Verified end-to-end: manual trigger.json → path unit fired → routes (53 files)
+ src (62 files) deployed → container rebuilt → health check passed. totp.js
remained locked with security edits intact.
2026-07-01 04:02:30 -07:00
Krystie b7624cc507 DC-024: Bump installer version to 1.14.6 and sync VERSION to current commit
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- dashcaddy-installer/install.sh: 1.1.0 → 1.14.6 (matches current release)
- dashcaddy-api/VERSION: 10f72afa5f51e4 (current HEAD with TOTP security fixes)

The host source tree was rebuilt from the published v1.14.6 tarball to fix a
deletion gap where /opt/dashcaddy/dashcaddy-api/{routes,src}/ were gutted by an
interrupted prior update cycle. Container was unaffected (built from image).
2026-07-01 03:50:00 -07:00
Krystie a5f51e4a0c DC-023: operational fixes — DNS, rate limiter, version sync
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- 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)
2026-07-01 03:10:53 -07:00
Krystie e73bfbb0a1 DC-021: build pipeline now ships src/ + hygiene for generated artifacts
The release tarball previously omitted dashcaddy-api/src/, which meant the
in-container self-updater had to apply post-deploy patches (dashcaddy-post-
deploy-patches.sh) to work around missing files. That script generates 37
flat copies of src/ files at the dashcaddy-api/ root level to satisfy
broken require() paths. With proper src/ shipping, those files become
obsolete, but they were still being shown as untracked in git.

Changes:
- BUILD-PIPELINE-FIX.md documents the build pipeline fix (in /opt/dashcaddy-release/
  build-release.sh — sibling repo, not tracked here)
- .gitignore now ignores the 37 generated post-deploy artifacts plus the
  backups/ and updates/ runtime directories, so 'git status' stays clean
- scripts/dashcaddy-post-deploy-patches.sh is now tracked so it's preserved
  across rebuilds (still useful as a safety net for transitional installs)
2026-07-01 03:09:59 -07:00