Commit Graph
306 Commits
Author SHA1 Message Date
Hermes 923ce8c300 DC-049 auth gate UI: pluggable provider selector + email challenge
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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).
2026-07-20 02:17:12 -07:00
Hermes Agent 56f1a001f2 start.sh auto-sync dashboard bundle into /var/www/dashcaddy-status/
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-20 02:07:25 -07:00
Hermes Agent c619d3a36b DC-046 DC-047 pluggable auth providers + email magic link
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-20 01:40:33 -07:00
Hermes Agent 894e091335 DC-050 harden dataDir + add image-layer migration
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-20 00:53:05 -07:00
Hermes 09efce2891 DC-047: claim for Hermes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-17 09:16:55 -07:00
Hermes 9689592086 DC-046: claim for Hermes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-15 09:04:30 -07:00
Krystie 3cf5980083 fix(middleware): split /auth/gate from authLimiter — 20/15min was burning budget on per-asset forward_auth chatter
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-14 04:22:32 -07:00
Krystie de3215f704 fix(update-manager): add ghcr.io registry support
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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.
2026-07-14 04:10:35 -07:00
Krystie fb8942a3fa chore(release): bump to 1.15.0 — DC-042/043 Tailscale admin + DC-044 health-check marker
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-14 02:13:29 -07:00
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