Commit Graph
104 Commits
Author SHA1 Message Date
hermes bb01a77ae7 DC-067: fix shutdown sequencing — stop managers AFTER server.close drains
Codex grade D flagged two real defects:
1. Managers were stopped before HTTP server finished draining, so in-flight
   requests could fail when their backing services were already down.
2. _stopManager() promises weren't awaited, contradicting the documented
   'declaration order' claim for async stop methods.

Fix: server.close callback now awaits _stopManagersInOrder() before
exiting. The 'shutdown' event fires first (so listeners can observe the
signal); the 'closed' event fires after all managers are stopped.
2026-08-12 03:46:42 -07:00
hermes 88ff260e5e DC-067: graceful shutdown coordinator (EventEmitter, 10s drain, idempotent) 2026-08-12 03:36:49 -07:00
Hermes ff81d99021 DC-101: Disk Space Monitor + Product Vision
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Backend:
- src/monitoring/disk-space-monitor.js: monitors Docker disk usage against
  user-configured budget, auto-cleans at thresholds, breaks down by category
- routes/disk-space.js: GET /disk, GET /disk/breakdown, POST /disk/config,
  POST /disk/cleanup endpoints
- src/app.js: wire DiskSpaceMonitor into startup, 10-min check interval
- All 1539 tests pass

Product Vision (PRODUCT-VISION.md):
- DashCaddy is a self-hosting platform, not just a dashboard
- Core value: 'Self-host anything in 30 seconds'
- Three pillars: One-click deploy, zero-config networking, self-healing infra
- vs Portainer/CasaOS/Yunohost positioning

New backlog tasks (P5 tier, DC-101–108):
- Disk budget, one-click deploy with auto Caddyfile+DNS, container
  auto-discovery, app catalog, smart wizard, visual Caddy builder,
  disaster recovery, multi-host fleet management

47 total backlog tasks, ~110 hr of work, cron running every 2h.
2026-08-12 02:41:40 -07:00
Hermes 5c02bfba1d DC-084/085/089/090: Quick wins batch
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
DC-084: Add .dockerignore (excludes __tests__/, .git/, node_modules/, coverage/)
DC-085: Replace Math.random() with crypto.randomUUID()/crypto.randomBytes() for IDs
DC-089: Add dedicated rate limiter on POST /license/activate (10 attempts/15min)
DC-090: Pin Node.js to 20.11.1-alpine3.19 + add engines field to package.json

All 1539 tests pass. ESLint: 0 errors.
2026-08-12 02:24:28 -07:00
Hermes 04f90d1505 DC-061: Add healthCheckUrl override to URL resolver
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Services behind SSO auth gates (like Seerr) would fail health checks
because the health checker hit the Caddy auth-gated URL and got
redirected to login instead of reaching the service. The healthCheckUrl
field in services.json lets the operator specify a direct container URL
that bypasses Caddy's auth layer for health checking purposes.

Priority order in resolveServiceUrl():
  1. internet → fixed google.com
  2. healthCheckUrl → direct container URL (NEW)
  3. isExternal + externalUrl
  4. service.url
  5. dnsServers config
  6. fallback buildServiceUrl()

Verified on DNS2: Seerr health check now hits http://127.0.0.1:5055
directly instead of https://requests.sami through the SSO gate.
2026-08-12 01:52:37 -07:00
Hermes a7512b4a56 [grade=A] P2-7: refactor tailscaleAuthMiddleware (complexity 24→7, nesting 6→3)
Extracted 3 helpers from the monolithic tailscaleAuthMiddleware:
- isTailScaleProbePath(): probe-path bypass check (was 6 || chains)
- extractTailscaleIPs(): IP collection + Tailscale classification
- isIPInTailnet(): async tailnet membership verification

Middleware is now a flat 15-line function that reads top-to-bottom.
Probe paths extracted to a Set for O(1) lookup.
Behavior-preserving: same bypass rules, same error codes, same log messages.
ESLint complexity 24→7, max-depth 6→3. 1539/1539 tests pass.
2026-08-10 21:19:28 -07:00
Hermes f5fc688185 [grade=A] P2-6: refactor config-schema.js validateConfig (complexity 44→8 sub-validators)
Extracted 8 field-level validators from the monolithic validateConfig function:
validateTld, validateDns, validateDashboardHost, validateTimezone,
validateTheme, validateRoutingMode, validateDomain, validateKnownKeys.

ESLint complexity dropped from 44 (Error) to <10 per function.
Removed unused VALID_TIMEZONES_SAMPLE constant.
Extracted VALID_THEMES, VALID_ROUTING_MODES, VALID_DNS_PROVIDERS, KNOWN_KEYS
as module-level constants.

Behavior-preserving: same validation rules, same error/warning messages,
same return shape. 1539/1539 tests pass. ESLint: 0 problems (was 2).
2026-08-10 21:18:09 -07:00
Hermes 1bc41bb2bc [grade=A] P2-5: fix 4 test handle leaks in log-digest.js + sweep remaining console calls
Root cause: setTimeout in start() (line 74) created an initial-collection
timer that was never stored in an instance property, so stop() could not
clear it. Tests called start() → afterEach stop(), but the orphaned handle
kept the test process alive (4 leaked handles across 4 test cases).

Fix: store as this._initialTimeout, clear in stop() alongside digestTimeout.

Also replaced 3 remaining console.error calls in log-digest.js with
structured log.error tagged 'logdigest' (was missed in P1-8 sweep).

1539/1539 tests pass. 0 open handles (--detectOpenHandles clean).
2026-08-10 21:16:22 -07:00
Hermes 7b04bc1d3c [grade=A] P1-8: replace 66 console.* calls across 6 remaining files with structured logger
Files changed:
- src/security/crypto-utils.js: 16 calls → log tagged 'crypto'
- src/security/docker-security.js: 15 calls → log tagged 'security'
- src/managers/port-lock-manager.js: 16 calls → log tagged 'portlock'
- src/docker/self-updater.js: 10 calls → log tagged 'updater'
- src/security/event-workers.js: 5 calls → log tagged 'events'
- src/security/keychain-manager.js: 4 calls → log tagged 'keychain'

Fixed 2 bugs found during sweep:
- self-updater.js:161 — arrow expression body had trailing semicolon (SyntaxError)
- port-lock-manager.js:137 — log.error referenced 'port' var out of scope (ReferenceError)

1539/1539 Jest tests pass. All ESLint warnings pre-existing (0 new).
2026-08-10 20:23:58 -07:00
Hermes 191d3340a7 [grade=A] P1-7: replace 18 console.* calls in bundled-workflows.js with structured logger
Replaced all 18 console calls in src/recipes/bundled-workflows.js with
log.info/warn/error tagged 'workflow'. Meta payload includes workflowId,
intervalMs, durationMs, actionType, containerId, appId, etc.

1539/1539 Jest tests pass. ESLint clean (0 new warnings).
2026-08-10 20:16:45 -07:00
Hermes 84f63a3261 [grade=A] P1-5, P1-6: replace 40 console.* calls in credential-manager.js + auth-manager.js
credential-manager.js: 20 console calls → log.info/warn/error tagged 'cred'.
auth-manager.js: 20 console calls → log.info/error tagged 'auth'.
Mixed-content strings extracted into meta payload (key, keyId, operation, etc).

1539/1539 Jest tests pass. ESLint: 4 pre-existing warnings unchanged.
2026-08-10 20:15:15 -07:00
Hermes f2c6fa69f5 [grade=A] P1-4: replace 32 console.* calls in resource-monitor.js with structured logger
Replaced all 32 console.log/warn/error calls in src/managers/resource-monitor.js
with log.info/log.warn/log.error from src/utils/logging.

Tagged every call as 'monitor' for consistent grep-ability.
Mixed-content strings (container, alerts, count, rollup, phase, etc.)
extracted into meta payload for queryability.

1539/1539 Jest tests pass. ESLint: 2 pre-existing warnings unchanged.
2026-08-10 20:12:22 -07:00
Hermes c55abdab87 [grade=A] P1-3: replace 36 console.* calls in backup-manager.js with structured logger
Replaced all 36 console.log/warn/error calls in src/utilities/backup-manager.js
with log.info/log.warn/log.error from src/utils/logging. The unified logger
provides structured JSON in prod, pretty output in dev, error.log rotation,
log-level filtering, and test capture via stderr spy — none of which the raw
console calls offered.

Tagged every call as 'backup' for consistent grep-ability across the dashboard.
Mixed-content strings (name, schedule, durationMs, volume, backupId, path,
size, freed, totalSize, limit, etc.) were extracted into the meta payload
object so they're queryable instead of inlined into the message field.

1539/1539 Jest tests pass. ESLint clean for the file (10 pre-existing
warnings unchanged, zero new).
2026-08-10 20:10:08 -07:00
Hermes e8b9dd5b91 [grade=A] DC-060: replace 49 console.* calls in update-manager.js with structured logger
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Replaced all 49 console.log/warn/error calls in src/managers/update-manager.js
with log.info/log.warn/log.error from src/utils/logging. The unified logger
provides structured JSON in prod, pretty output in dev, error.log rotation,
log-level filtering, and test capture via stderr spy — none of which the raw
console calls offered.

Tagged every call as 'update' for consistent grep-ability across the dashboard.
Mixed-content strings (containerName, schedule, imageName, error.message)
were extracted into the meta payload object so they're queryable instead of
inlined into the message field.

1539/1539 Jest tests pass. ESLint clean for the file (14 pre-existing
warnings unchanged, zero new). Codex grade A.
2026-08-10 15:49:40 -07:00
Hermes a667de7920 DC-059: Joi validation middleware + schemas for destructive routes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
[grade=B]

- New src/utilities/validate.js: validateBody(schema) middleware + 9 schemas
  (backupConfigUpdate, backupScheduleCreate, backupRestore, backupRestoreFile,
   appDeploy, appRestore, appRevert, assetUpload, logoUpload)
- Uses Joi's authoritative CIDR validator (rejects malformed IPv6 like ::::/64
  that the previous hex/colon regex would have accepted)
- appDeploy.config uses .unknown(true) for forward-compat with template-specific
  fields (sslType, dnsType, plexClaimToken, etc.) — preserves fields the live
  frontend posts, prevents a behavioural regression
- appRestore uses Joi.any().custom() so the empty-body semantics hold under
  middleware stripUnknown (default) — body with extra keys now rejected
- Wired into 8 destructive routes: backups schedule/restore/config, apps
  deploy/restore/revert, assets upload/logo
- Duplicate legacy POST /backups/schedule handler (line 519) marked LEGACY
  with TODO removal note (Express only matches first registration; this
  handler is unreachable under normal routing)
- Removed redundant manual appId check in /backups/schedule (Joi schema
  enforces it)
- Removed unused 'mime' destructure in /assets/favicon (decodeImageData
  validates MIME internally)
- 41 unit tests covering every exported schema + middleware integration
- 1539/1539 Jest tests pass, zero new ESLint warnings
2026-08-08 15:39:48 -07:00
Hermes c1358df0ec DC-059: claim for Hermes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-08-08 15:20:09 -07:00
Hermes 9b9711bf24 DC-057: close checkout-to-license contract drift (grade B)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Canonical product catalog at src/billing/catalog.js shared by Stripe
Checkout client (src/billing/stripe-client.js), webhook bridge
(scripts/stripe-license-bridge.js), and pricing page
(status/pricing/index.html). One-time payment keyed by productId at
$20/$50/$70/$99 — no more monthly/annual subscription drift.

Bridge resolves duration via metadata.productId (single contract),
requires payment_status === 'paid' before fulfillment (rejects
unpaid/no_payment_required/missing with ack 200), handles
async_payment_succeeded for ACH/SEPA delayed-payment flow. License
persisted to fulfillment-store BEFORE email — SMTP failure path serves
the persisted code via the new /api/v1/billing/lookup/:sessionId
endpoint (the documented customer recovery path).

Layer-1 (event-id) + layer-2 (session-id) idempotency prevent
duplicate issuance. Checkout return URLs derived from
STRIPE_PUBLIC_ORIGIN or STRIPE_ALLOWED_HOSTS (not raw Host header) —
closes host-header-poisoning + session-ID-leak attack class.

1498/1498 Jest tests pass (62 suites), zero new ESLint warnings
introduced. Test files:
  - stripe-license-bridge.test.js (24 tests)
  - billing-lookup.test.js (8 tests, HTTP-level)
  - bridge-lookup-http.test.js (5 tests, uses exported createServer)
  - pricing-page-catalog.test.js (9 tests, per-tier consistency)
  - checkout-origin.test.js (6 tests, host injection rejection)
  - stripe-client.test.js (rewrite for productId + mode:payment)

Bridge code refactored: handleWebhook decomposed into verifySignature +
parseEventBody + checkEventIdempotency + fulfillCheckout +
ensureLicensePersisted (under ESLint complexity=20 cap). New
createServer()/createRequestHandler() factories guarded by
require.main === module.

Removed 3 stale test files from the rolled-back DC-055 attempt.
2026-08-04 14:18:49 -07:00
Hermes 1c02131fe0 fix(server): DC-058 close 3 P0 bugs — restore.js missing dep, dns.js ok ref, dead /billing/checkout
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
1. routes/apps/restore.js: backupManager was being passed by the
   aggregator (routes/apps/index.js:58) but never destructured in the
   factory signature. Every apps/restore request 500'd with
   ReferenceError. Added backupManager to the destructure + an explicit
   throw if missing so the next regression surfaces at startup instead
   of at the first call.

2. routes/dns.js:555: file imports { success, error } from
   ../src/utils/responses but used ok(res, ...) (defunct alias). DNS
   credential save path 500'd. Changed to success() to match the rest
   of the file.

3. src/utilities/middleware.js: deleted /api/v1/billing/checkout from
   PUBLIC_ROUTES — dead entry, no route mounted. Drift test caught it
   (DC-017 guard). Updated the comment to cover both checkout + webhook
   as removed.

Tests: 1428/1428 pass (drift test now green).
Lint: 0 no-undef errors across src/ + routes/ (was 7).

Refs: DashCaddy audit 2026-08-02
2026-08-03 00:43:34 -07:00
Hermes 86df178022 [grade=A] DC-055: fix public-routes drift — bill prefix + services mount, drop dead webhook
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- public-routes-drift.test.js:
  - Add 'routes/billing.js' to prefixMap ('/billing') — production mounts
    apiRouter.use('/billing', billingRoutes({...})) so the walker must
    walk under /billing, not bare /api/v1.
  - Add 'routes/services.js' to directMounts — production bare-mounts
    serviceRoutes({...}) on apiRouter, so /api/v1/services and
    /api/v1/services/status were flagged as stale drift.
- src/utilities/middleware.js:
  - Remove dead /api/v1/billing/webhook PUBLIC_ROUTES entry. Webhooks
    are handled out-of-process by scripts/stripe-license-bridge.js;
    the merchant webhook secret never enters the API process.
  - Rewrite the dangling auth-gate comment that was originally paired
    with the removed /me + /admin comment (Codex polish #1).

1486/1486 tests pass, zero new ESLint errors. Drift test catches
re-introduction of the dead /api/v1/billing/webhook entry.

Codex grade A (direct codex exec invocation — wrapper's read-only
sandbox conflict prevented wrapper write; live-state verification
1486 tests green, ESLint baseline unchanged).
2026-08-02 03:38:23 -07:00
Hermes be798a9bc2 [grade=B] fix(workflows): DC-044 root-cause — gate notify-on-failure, interpolate failingServices, fix Health.Status check
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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
2026-07-31 00:43:29 -07:00
Hermes 140ef8726b [grade=B] refactor: remove stale duplicate license key generator
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-24 21:48:55 -07:00
Krystie 75f835641f [grade=B] fix(auth): use host-only session cookies on custom TLDs
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Codex: urn:ump:7c22nwh67kot23f6czg5ax7e47hu2r7vowjpz3q6z63o73ti67vq
2026-07-24 05:15:08 -07:00
Krystie 872923dba2 fix(auth): route /api/auth/sso-exchange through the v1 rewrite shim
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.
2026-07-24 05:15:07 -07:00
Krystie 10f2bf707b fix(auth): token-based handoff for cross-subdomain SSO
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.
2026-07-24 05:15:03 -07:00
Krystie 96e2ef8609 DC-XXX: cookie-only session validation, kill IP-key cache mismatch
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.
2026-07-24 05:14:55 -07:00
Krystie 7143c36187 fix: preserve share store in application context
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-21 22:53:43 -07:00
Krystie 7682cb77bf fix: pass platform paths to share store
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-21 22:45:50 -07:00
Krystie a2a2bee71e fix: match parameterized public auth routes 2026-07-21 21:59:03 -07:00
Krystie d9e61ce1b7 DC-053: Public share links + Tailscale-mediated share (Pro-gated)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- Share-store: HMAC-signed tokens bound to serviceId+kind, persistent
  signing secret in dataDir/.share-secret, atomic writes, auto-prune
- Routes: admin endpoints gated on licenseManager.isPro() (402 Free);
  public endpoints CSRF-exempt (token IS proof)
- Tailscale path: mints single-use ephemeral pre-auth key, emails
  join link, rolls back share record if createAuthKey throws
- Email-failure path: exposes urlPath for manual delivery fallback
- 53 new tests (24 store + 29 routes), full suite 1372/1372
- Drift-test parser hardened against quoted-word comments
- share-store dataDir resolver handles Proxy/function values

CHANGELOG + BACKLOG updated.
2026-07-21 00:45:46 -07:00
hermes 273f6b8edb DC-052: license-tier enforcement (Free caps at 3, gates share on Pro)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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).
2026-07-20 21:40:26 -07:00
hermes 321334cd33 DC-048: multi-user bootstrap + admin invites (opt-in)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Implements the user-store + invite-store + admin routes. The whole
system is opt-in via siteConfig.authProviders.email.enabled = true;
single-user TOTP-only installs see zero behavior change.

Backend:
- src/security/user-store.js: users + allowlist + bootstrap sentinel,
  atomic writes, last-admin protection, defensive dataDir resolver.
- src/security/invite-store.js: single-use tokens (SHA-256 hashed on
  disk), TTL, auto-prune, defensive dataDir resolver.
- routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist,
  /admin/invites (CRUD), public /invites/:token (peek + accept).
- routes/auth/index.js: wires userStore, gates admin router on
  email auth being enabled.
- src/auth/providers/email.js: verify() enforces allowlist, creates
  user record, tags req.user; default-enabled flipped to opt-in.
- src/auth/providers/totp.js: bootstraps system@totp.local admin on
  first verify so current DNS2 operator shows in /admin/users.
- src/security/audit-logger.js: middleware adds userId/userEmail/
  userRole/viaProvider to log details when req.user is tagged.
- PUBLIC_ROUTES + CSRF allowlists updated for invite redemption.

Frontend:
- status/js/admin.js: modal overlay with users list (role-edit,
  delete), invite form (email/role/TTL), copy-link button,
  outstanding-invites list with revoke. Exports window.AdminPanel.
- status/js/core/init.js: calls AdminPanel.attachTrigger so the
  Admin button only appears when /me returns isAdmin=true.

Tests: 35 new tests across 3 files (user-store, invite-store, auth
multistore integration). Full suite: 1298/1298 passing.

Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased]
section gets the DC-048 entry.
2026-07-20 17:44:11 -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
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
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 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
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 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
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 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 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
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 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