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.
[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).
1. /totp/recovery-info: was PUBLIC, leaking TOTP configuration status
to unauthenticated attackers. Now requires valid session (401 otherwise).
2. /totp/check-session: had an unconditional bypass that returned
authenticated:true whenever totpConfig.enabled was false. This let
anyone reach authenticated endpoints without credentials.
Now throws AuthenticationError instead.
3. /totp/setup: was unmetered despite generating secrets. Added 3/hour
per-IP rate limit in addition to the existing global 10/15min limiter.
All changes verified live via https://status.sami:
- recovery-info unauth → 401 [DC-110] (was 200)
- check-session no cookie → 401 TOTP protection required (was 200)
- 4th setup attempt → 429 [DC-429]
Covers the full BACKLOG DC-006 acceptance criteria:
- GET /api/totp/config — read current config
- POST /api/totp/setup — generate / import Base32 secret
- POST /api/totp/verify-setup — activate TOTP after setup
- POST /api/totp/verify — login with TOTP code → session + CSRF
- GET /api/totp/check-session — auth gate (200 / 401)
- POST /api/totp/disable — disable TOTP (requires valid code)
- POST /api/totp/config — update session duration
25 tests, all passing. Uses real otplib for code generation
(so we exercise actual TOTP math) but mocks credentialManager,
session, totpConfig, saveTotpConfig — those own their own state
machines (disk, cookies, file) that don't belong in a routes
test.
Also fixed a latent DC-005 bug: routes/auth/totp.js had wrong
require-path depth after the refactor (../../../src/... went 3
levels up instead of 2, breaking route load). Changed to
../../src/... for the 2-level depth. NOTE: the same depth bug
exists in many other depth-2 route files (auth/keys.js,
auth/sso-gate.js, auth/session-handlers.js, recipes/*, apps/*,
arr/*, config/*) — see BACKLOG.md DC-005 follow-up note. Tests
didn't catch this because no test previously imported the auth
routes; this new test exercises that import path.
Result: 904/904 Jest tests pass (879 baseline + 25 new).
ESLint: this file clean. Pre-existing 134 src/ warnings are
unrelated (DC-005 refactor moved files without re-applying
DC-004 lint cleanup — separate follow-up).
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
Convert ~160 raw res.json()/res.status().json() calls across 32+ files
to use centralized helpers from src/utils/responses.js (ok, errorResponse,
successMessage, notFound, validationError, forbidden, unauthorized, conflict).
No behavior changes — response shapes are identical. Future schema changes
(e.g., requestId envelope) only need to update one module.
Fix error vs errorResponse signature mismatch in routes/health.js CA cert
endpoint where error(res, message, statusCode) was being called with
errorResponse(res, statusCode, message, extras) argument order.
Files changed: middleware.js, csrf-protection.js, error-handler.js,
license-manager.js, src/app.js, and 27 route files.
Test suite: 755 pass / 4 pre-existing failures (services credential tests).
Three small cleanups for v1.14.0:
1. /caddy/cas now uses standard success envelope
Was: { status: 'success', data: { cas: caList } }
Now: { success: true, cas: caList }
Updated frontend service-infrastructure.js to match.
2. /api/health/ca now uses standard envelope + meaningful HTTP codes
Was: { status, message, daysUntilExpiration } with 200 on every error
Now: { success, caStatus, message|error, daysUntilExpiration }
with 200 / 404 / 500 as appropriate
caStatus field preserves the original 'healthy'/'warning'/'critical'/'error'
semantic so any future consumer of the CA-health state still has it.
Tests updated to match.
3. Dead timeout: keys in fetchT opts are now a warning, not a silent strip
src/utils/http.js:41 used to do without telling
anyone. Callers that wrote fetchT(url, { timeout: 5000 }) got the default
5s timeout with no indication that their explicit value was ignored.
Now it logs a warning naming the call site, then strips the key.
Fixed 4 call sites that had stale timeout: keys:
- src/context/caddy.js
- src/context/dns.js
- src/context/provider-dns.js
- routes/dns.js (2 places)
Two cleanups in one pass for the v1.14.0 'works on any platform' theme:
1. Response helpers — merged src/utils/responses.js and the root-level
response-helpers.js into a single module at src/utils/responses.js.
The old module had a richer set (created, noContent, validationError,
unauthorized, forbidden, notFound, conflict) and is now re-exported
from the new location. Updated 15 routes to import from
src/utils/responses and deleted the root response-helpers.js.
2. Error logger — error-handler.js now uses the unified
src/utils/logging.js#logError (same one src/app.js uses), so all errors
go to one log file with one rotation policy. Removed the dead
asyncHandler export (the real one is in src/utils/async-handler.js
and is used everywhere). Deleted the legacy error-logger.js.
Both are invisible to users — same HTTP response shapes, same log file
path, same error format. Internal-only refactor.