- routes/ca.js: declare format before pfx/pem/crt dispatch (was ReferenceError
on every request that passed validation); add CA_CERT_FORMATS single source
of truth + hardened format extraction (string-coerce, whitelist)
- routes/caddycode.js: fix upstream-validation regexes (control-char classes)
- routes/logs.js: SSE/validation lint fixes
- routes/openclaw.js: remove useless regex escape in ALLOWED_PATH_RE
- fleet-validation.js + http-caddy-admin-origin test: eslint-disable for
intentional control-regex security sentinels
- ca-dc076.routes.test.js: regression test for declared format + behavioral
coverage of format validation (now pre-PKI)
Judge: Qwen lane (qwen3.8-max) grade B, 0 blocking, verdict
/tmp/judge-batch1-verdict.json. API suite 2859/2859 green.
Alpine/musl getaddrinfo (used by Node dns.lookup/tls.connect) queries all
resolv.conf nameservers in parallel and takes the first reply. 8.8.8.8
NXDOMAINs the internal .sami TLD and won that race 18/400 measured inside
the live container — the source of ssl-monitor 'Failed to check cert'
ENOTFOUND warn noise (and the original reason for the git.sami hosts pin).
DNS1 Technitium secondary (100.71.97.12) serves *.sami AND recurses
externally, verified from inside the container, so both race winners are
correct. Shell-only change; no JS/test context touched.
[glm-grade=B] (Codex cold-read, 0 blocking; polish items folded: live-state
claims now carry measured provenance, musl-vs-c-ares attribution verified
by discriminating test A/B/C)
The deploy host showed phantom dist drift on every git pull: committed
dist bundles could not be reproduced on DNS2. Root cause (verified with
esbuild 0.25.12 probes): the production transform uses sourcemap:'both',
whose inline map base64-embeds RAW source bytes as sourcesContent — a CRLF
working copy (Windows dev, core.autocrlf=true) vs an LF checkout produces
different dist bytes and a different sw.js cache tag.
- build.js: normalizeSource (\r\n -> \n) on every source read (bundle
inputs + sw.js read); exported + require.main guard so tests can import
it without triggering a build
- .gitattributes: * text=auto eol=lf (git-layer kill of the CRLF vector)
+ binary exclusions; 9 CRLF-in-index asset files renormalized
- tests/build-determinism.test.js: 3-case pin (byte-identity after
normalization, divergence pre-normalization, CR-strip contract) importing
the ACTUAL normalizeSource from build.js
- package.json: declare jsdom devDependency — 2 committed test files
require('jsdom') but it was never declared, so fresh-checkout
npm test failed (it only passed where a stray ancestor node_modules
happened to contain it)
- dist/features.js + sw.js: canonical deterministic rebuild
(cache tag 3354f5fd96 -> d39ab69dd4)
Verified: status 54/54, API 2858/2858 (128 suites); CRLF-sim tree and LF
tree of same HEAD produce byte-identical dist artifacts (sha256-equal).
Judge: glm-5.3 cold round 1 = A, round 2 (post-fold) = A clean, 0 blocking.
The host-side uptime watchdog and on-host cron jobs curl Caddy with a stock
curl/8.5.0 UA from loopback and the host tailscale IP — ~300 GET /api/health
401 warn-events/day burying real perimeter signal (census: 339+20 of 5000
access.log lines). Dropping on UA alone would blind the store to external
curl scanners, so generic tool UAs (curl/) are now dropped ONLY when the
source remote_ip is one of this host's own addresses (DASHCADDY_SELF_IPS,
default loopback; start.sh derives 127.0.0.1 + tailscale ip -4, empty-safe).
remote_ip (TCP peer) is used, never the spoofable client_ip. DashCaddy-*
probe UAs stay unconditionally dropped. 9 regression pins cover the full
conjunction matrix incl. external-IP+curl KEPT and spoofed-XFF KEPT.
2858/2858 green (128 suites).
Judge: glm-4.6@zai-coding-paas cold-read round 1 = A clean (0 blocking),
both polish notes folded. URN urn:ump:s2sgitfepze65crtp57dpdw4gk4w7upsoi4tqcvfwahcsicyepba
- query().total: full-scan true count (was capped at offset+limit by an
early break — dashboard 24h stat read ≤1000 vs real ~46k)
- trim trigger/curer mismatch: byte trigger + line curer never converged
when avg line > ~524B (live avg 355B); now keeps maxDisk lines OR
≤80% byte budget, whichever retains fewer (TRIM_TARGET_FACTOR);
budget env-overridable via SECURITY_EVENT_TRIM_BYTES / opts.trimSizeLimit
- trim/append race: trim renamed over the file mid-append losing events
to the unlinked inode; now single-flight, queue-empty gated, holds the
write lock, error paths always release + lazily re-kick (no hot loop)
Judge: glm-4.6@zai-coding-paas adversarial cold-read, grade B clean
(0 blocking, 4 polish — 2 folded, 1 already-satisfied, 1 deferred as
judge-endorsed safer). 127 suites / 2849 tests green.
The caddy tail worker (DC-112) was 100% dead in prod: no /var/log/caddy
mount, no CADDY_ACCESS_LOG env, no global access log in the Caddyfile.
Store census 45,912 events, 100% source_type 'api', ZERO 'caddy'.
- createTail: firstStartMaxBytes (5 MiB) bounds first-ever-start replay
against a long-lived access.log; multi-chunk partial-line discard on
the jump; normal restarts resume at exact persisted offset (judge r1
fix-first fold)
- worker: self-noise filter drops our own probe UAs
(DashCaddy-Probe/1.0, DashCaddy-HealthCheck/1.0) from the derived
store — raw log keeps everything; ~50-100 events/min of probe noise
would otherwise bury perimeter signal in the 100k-cap store
- worker: metadata.host reads request.host (real caddy JSON nests it;
verified against live /var/log/caddy/seeds.log — the DC-112 read was
always null on live lines); top-level fallback kept
- worker: onAppear recovery log (DC-112 judge polish fold)
- start.sh: -v /var/log/caddy:/var/log/caddy:ro + CADDY_ACCESS_LOG env
- README: dead caddy-api/ dir refs -> dashcaddy-api/ (queue item e)
- tests: +12 DC-113 pins (hermetic, real worker + store); DC-112
fixtures corrected to the real nested request.host shape
126 suites / 2841 tests green. Judge: GLM-5.3 round-1 B, round-2 A
(SHIP). Verdict urn:ump:mccln523fptotuvrmddlqpf4zpkrxf273tg4kyytuyy3776rj3pq
Queue item (g) — same defect class as DC-111 defect 1, different writer:
the caddy access-log tail named every event http.<status>, so forward_auth
gate hits were unanswerable in the unified security event store.
- resolveCaddyAction() mirrors audit-logger ACTION_MAP vocabulary for BOTH
URI shapes (legacy /api/auth/gate/<id> from Caddyfile line 87, canonical
/api/v1/... from dashboard JS): auth.credential-injection,
auth.app-token-issue, auth.sso-exchange (exact-path match, boundary-tested)
- severity escalation now covers the legacy /api/auth/ prefix too
- metadata fidelity: caddy logs headers as ARRAYS — old single-value read
always produced user_agent:null; added metadata.host (vhost) and
duration_seconds (caddy logs seconds; duration_ms kept, no consumers)
- dead-path visibility: once-per-process warn when the access log is
missing. Live discovery: prod store has 45,912 events, 100%
source_type 'api', ZERO 'caddy' — the container has no /var/log/caddy
mount, so the worker silently no-ops. Infra wiring queued separately.
- new __tests__/caddy-worker-naming-dc112.test.js: 22 pins (real tail +
real store, hermetic sinks, both URI shapes, boundary rows, offset
persistence, once-warn). Full suite 125/2831 green.
Judge: GLM-5.3 cold read round-1 A (deleg_65498358, 3 polish items folded)
+ round-2 A (deleg_eed9dcbf, judge re-ran suite itself).
URN urn:ump:mjbwfcw6z7budx45s2rutovw5fatpp4jyzzspo7tq6nzajayulmq
1. audit-logger middleware computed action/resource from req.path INSIDE
the res.json override — after the /api/v1 router rebased req.url to the
router-relative path. resolveAction fell through ACTION_MAP for every
HTTP request, producing 45,899 'unknown.get' entries since 2026-07-14.
Fix: snapshot req.path/req.method at app-level (post-shim, pre-router).
2. DC-044 shim double-prefixed ALREADY-canonical /api/v1/auth/gate|x
into /api/v1/v1/... → 401 for canonical-URI clients. Fix: rewrite only
legacy /api/auth/* shapes; canonical pass through untouched.
3. event-store VALID_OUTCOMES lacked 'failure' → every failed API action's
security event was rejected+dropped from security-events.jsonl. Fix: add
'failure' to the vocabulary set.
8 regression pins over a faithful shim→audit→router mount mirror.
Suite: 124 suites / 2809 tests green.
Judge: GLM-5.3 cold read round-1 A/ship, URN urn:ump:ywv6rpmx55tlxbgc7ciyxqfcmx2v6q756cjbmif2d66k4bwwn6ya
audit-logger.js (StateManager write path into audit-log.json) starred only
6 sensitive keys at the middleware layer; email-bearing resource paths
(/invites/<email>/accept), DC-048 details.userEmail attribution, and emails
in non-sensitive body keys landed RAW — while the parallel unified-logger
path has masked at every sink since DC-095. DC-110 closes the parity gap
using the SAME canonical primitives (sa****@example.com): log() masks
resource (maskEmailsInString) and deep-masks details (maskEmails) at the
single write-point, covering middleware AND direct route calls. The
security-event mirror now uses the masked entry.resource for target/message
(judge round-1 fix-first: the raw parameter leaked emails into
security-events.jsonl). logging.js exports maskEmails (export-only).
maskEmails clones — caller details objects are never mutated.
Judge: GLM-5.3 cold read (standing Sami authorization 2026-08-17; Codex
quota dead until 2026-08-29). Round 1 (deleg_ebb83285) C fix-first —
caught the event-store mirror leak. Round 2 (deleg_923f9076) after
in-commit fix + mirror test: grade A, ship. Verdict URN
urn:ump:mwtxoj6dbdq7bfeba3l2am2rgx34zjimcjde5f6sxz6tozsjbskq (GET
readback verified: grade A, topic codex-judge-verdict). Deferred
(judge-accepted): one-time scrub of historical raw-email lines in the
live 16MB security-events.jsonl — queued follow-up.
Tests: 123 suites / 2801 green (+6 DC-110 pins: resource+details mask,
non-mutation, middleware e2e with *** survival, idempotence, no-email
regression, masked mirror target/message).
DC-095 masks emails at every live log sink; the rotated archive was the
remaining belt-and-braces gap — any future sink that forgets masking would
persist raw PII in error.log.1 for a full rotation cycle. appendErrorLog
now scrubs the freshly rotated archive with the SAME canonical mask
(sa****@example.com) via atomic rewrite (sibling .redact-<pid> temp, wx
open preserving mode else 0600, fsync, rename). Scrub failure is caught
and logged; the new error line is still appended. Stale crash-leftover
.redact-<pid> temps are swept best-effort on every rotation. Also
documents scripts/redact-log-pii.js usage in README (queue item e).
Judge: GLM-5.3 cold read (deleg_e7cd7f8c), round-1 grade B ship — both
nits addressed in-commit: stale-temp sweep (new 5th test pins it),
quoted-local-part mask edge deferred as pre-existing DC-095 primitive.
Tests: 122 suites / 2790 green (+5 DC-108 pins; was 2785 post-DC-107).
If the atomicWriteJSON of rotated credentials failed after rotateKey() had
already persisted+cached the new key, the on-disk key could no longer decrypt
the on-disk credentials.json (permanent loss on restart). Catch-path now
restores the old key via new cryptoUtils.restoreKey() (canonical atomic-write,
0600, hex-validated) while still holding the proper-lockfile. Double-failure
(rollback throws) is contained and the lock is still released. Hard-crash
mid-rollback is covered by the existing .bak startup fallback.
Judge: GLM-4.6 stand-in, round-1 grade A, 0 blocking, 1 LOW polish (folded).
URN urn:ump:z2sz3x6abtcffpqyde2l2ssq34vy47t5h2gbmkr3wtmfxwkerinq
Tests: 121 suites / 2785 green (6 consecutive runs pre-fold; suite re-run post-fold).
All three writeFileSync sites in the encrypted-credentials manager now
delegate to src/utils/atomic-write.js atomicWriteJSON — rotateEncryptionKey
save, _ensureFileExists bootstrap, and the _lockedUpdate commit under the
proper-lockfile lock. A crash can no longer tear credentials.json
mid-write: power loss through the old path could leave an empty/short
file and silently drop every stored credential (DNS provider tokens etc).
Lock-safety pre-study: proper-lockfile only stats its own sibling .lock
dir, never the target file, so the rename swap cannot trip ECOMPROMISED.
Test mock extended to the fd-level fs API (openSync/writeSync/
fsyncSync/closeSync/renameSync + fdMap/closedTmp state) so the canonical
path is exercised end-to-end under the mock; write assertions moved from
writeFileSync.mock.calls to destination-state reads. New DC-106 pins:
wx+0600+fsync+rename discipline, plaintext-secret canary never on disk,
fd-lifecycle order fsync->close->rename via invocationCallOrder, and
atomic _ensureFileExists create at 0600. Eighth store migrated
(DC-099..DC-105 preceded).
Judge: GLM-5.3 cold read, round-1 B/ship (deleg_b3c038c2).
URN urn:ump:vscequdet7wt5un7jhtl2nlg6cfkabsbazy5m2tjnyyguu5wstxa
(readback verified: grade B, topic codex-judge-verdict).
Sole finding is pre-existing and non-blocking: rotateEncryptionKey
persists the new key before writing rotated creds (crash window) —
queued as DC-107 follow-up.
Full suite: 121 suites / 2783 tests green.
_saveState drops its private fixed-name .tmp + writeFileSync (no fsync)
copy and delegates to src/utils/atomic-write.js atomicWriteJSON — same
wx/fsync/rename/dir-fsync discipline as the six previously migrated
stores (DC-099..DC-104). A crash can no longer tear
caddy-upstreams.json (mute list + probe state): power loss through the
old path could leave an empty/short state file and silently drop every
mute; concurrent saves (60s probe loop vs setMuted) collided on the
shared tmp name.
Test mock extended with the fd-level fs API (openSync/writeSync/
fsyncSync/closeSync/unlinkSync + closedTmp stash) so the canonical
path is exercised under the existing file-wide fs mock; fsState renamed
mockFsState (jest.mock out-of-scope-variable hoist rule). New DC-105
pin: wx+fsync+rename required, no fixed .tmp, zero leftover tmp files,
destination JSON complete with mute preserved.
Judge: GLM-5.3 cold read, round-1 A/ship (deleg_5f57fcce).
URN urn:ump:iuhgj3ajr5llshjasttsvelnptv6iqaek6xji5l3klcl72nseqdq
Full suite: 121 suites / 2779 tests green.
writeEvents() in scripts/stripe-license-bridge.js drops its private
tmp+writeFileSync+rename copy (no fsync, Date.now() tmp names) and
delegates to src/utils/atomic-write.js atomicWriteJSON (exclusive-create
tmp, fsync, rename, parent-dir fsync, 0600). stripe-events.json is the
Stripe webhook idempotency log - a torn write silently drops event-ids,
so a Stripe retry re-runs delivery (duplicate license email; combined
with a torn fulfillment record, a duplicate key mint). readEvents is
JSON.parse-only, so the canonical serializer's trailing-newline drop is
unobservable. Sole writer confirmed by grep. +2 DC-104 pins: full
recordEvent->eventSeen dedupe cycle (0600/complete JSON/zero-leftovers)
and 8-step back-to-back mutation parse-complete check. 121 suites /
2778 tests green.
Judge: GLM-5.3 round-1 A (deleg_e8e1f9d0), URN urn:ump:frwh34wtlsunsymok6pzyujyehq6ikpvadypmsg7ndk7jjvzrp5a
writeState() drops its private tmp+writeFileSync+rename copy (no fsync,
Date.now() tmp names, best-effort chmod) and delegates to
src/utils/atomic-write.js atomicWriteJSON (exclusive-create tmp, fsync,
rename, parent-dir fsync, 0600). A torn stripe-fulfillments.json could
previously make a webhook retry mint a SECOND valid license key for an
order that already has one. Both consumers (routes/billing.js,
scripts/stripe-license-bridge.js) JSON.parse only - trailing-newline
drop in the canonical serializer is harmless. Unused crypto require
removed. +2 DC-103 pins: full lifecycle 0600/complete/zero-leftovers,
and dual-instance interleaved writes (bridge+API file-IPC) with no tmp
collisions. 121 suites / 2776 tests green.
Judge: GLM-5.3 round-1 A (deleg_bd49a97b), URN urn:ump:layk7h326sqymcsh6tiusrs2sdbqdcx6ygvcuwwoxqn4rf7ycoua
Drops share-store's private _atomicWriteJSON copy (pid+Date.now() tmp
names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js:
fsync'd same-dir tmp+rename, exclusive-create 0600, parent-dir fsync,
cleanup-on-failure. Also fixes a latent bug in the same file: .share-secret
signing-key persistence used plain fs.writeFileSync — a torn write would
silently rotate the HMAC key on next boot, invalidating every outstanding
share signature (links 404, no error logged). Now atomicWriteFile.
Sole consumers parse JSON / trim(), so the dropped trailing newline on
shares.json is unobservable. +2 regression tests pin 0600 on both files,
complete content, zero tmp leftovers, and HMAC-still-verifies via getRaw.
Judge: GLM-5.3 cold read, round-1 A, deleg_50d5c239 (41s, 4 calls).
Verdict: urn:ump:ehblegyr5eko4hgyfylnrqk72hh3crmc43o2yt5ragfgvyc4zrdq
Full suite: 121 suites / 2774 tests green.
Drops invite-store's private _atomicWriteJSON copy (pid+Date.now() tmp
names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js:
fsync'd same-dir tmp+rename, exclusive-create 0600, parent-dir fsync,
cleanup-on-failure. Sole format consumer is a JSON.parse reader, so the
dropped trailing newline is unobservable. +2 store-level regression tests
pin 0600 / complete JSON / no temp leftovers under burst mutations.
Judge: GLM-5.3 cold read, round-1 A, deleg_7177d506.
Verdict: urn:ump:4kwenywtf3nx2mokobvuzrhklk2xigmpyea6nvqdyhv7icnflpkq
Full suite: 121 suites / 2770 tests green.
_loadConfig canonicalized legacy spellings in memory only (DC-092); the
on-disk notifications.json kept email user/pass, camelCase event keys and
string secure until the next explicit UI save — i.e. forever on installs
that never open the settings page.
- _persistCanonicalForm(): after the defaults merge, re-serialize and write
back only when the bytes differ; idempotent on subsequent loads.
- Best-effort: write failures (read-only mount, EACCES) warn and continue —
the in-memory config is already correct; constructor never throws.
- No secrets in new log lines; JSON.stringify(this.config) same as saveConfig.
Judge notes (non-blocking, GLM-5.3 cold read): unknown top-level keys are
now dropped from disk at boot (pre-existing merge-drop semantics, previously
deferred to next UI save); write is non-atomic, matching saveConfig.
Verdict: urn:ump:rchawiu427idev5mrettlxkyw2rcnwqpegiolqmzbc5ygc277u5q
Tests: +5 (__tests__/notification-config-writeback-dc097.test.js) —
canonical rewrite, idempotence, clean-file-untouched, EACCES no-throw,
fresh-install no-write. Full suite 119 suites / 2740 tests green.
DC-095: mask email addresses at every logger output choke point so raw
PII never reaches stdout/stderr, error.log, or audit-log.json regardless
of what a call site interpolates — msg strings, data payloads, error
messages/stacks, audit details, and error.log request lines (path/UA).
- Masked shape sa****@domain matches AuthProvider.maskEmail (UI-consistent)
- Bounded-quantifier regex: local {1,64} (incl. quoted local-parts),
domain {0,253}, TLD {2,24} — adversarial 40KB string 3.3s -> 17ms,
hostnames/versions/docker-refs untouched, idempotent under re-mask
- memo-Map recursion: DAG shared references get the same masked clone
(WeakSet seen-guard leaked the raw original on 2nd reference); cycles
resolve to in-progress clone
- Non-plain objects with own enumerable props cloned proto-preserving
(Object.create) so class-instance email fields are masked; Date/RegExp
pass through
- sanitize(): audit details mask email substrings in non-sensitive keys
(invite/auth POST bodies no longer land raw in audit-log.json)
- 18-test suite covers sinks + adversarial judge findings (ReDoS timing,
DAG, quoted locals, instances, request-line path/UA)
Judge: GLM-5.3 cold-read stand-in (Codex quota-dead until 2026-08-24,
substitution authorized by Sami 2026-08-17). Rounds C -> C -> A.
Verdict: urn:ump:zorj7vcrnw2t2jhhcp2g6wz4simvhyjdqwu2mjsifxlkzb2dwjmq
Suite: 117 suites / 2724 tests green.
Two silent-death defects in one class:
1. Seven emitters absent from DEFAULT events, so the send() gate
(config.events[canonical] !== true) dropped them on every install:
ssl-cert-expiry, dns-propagation, drift-detected,
dependency-restart-complete/-failed, recipeRemoved, workflow.
All now default ON; dependency-restart spellings fold onto one
canonical toggle; recipeRemoved aliases to recipe-removed in both
the manager and route alias maps.
2. Nine call sites used a legacy 4-arg send(event, title, message, type)
against the 3-arg signature: the message string landed in the type
slot (Discord embed color fell back to info-blue, history.type wrong)
and providers received the TITLE as the body - deploy-failure
notifications carried no error text at all. Fixed at source (9 sites)
plus a type-guarded shim in send() for external legacy callers.
Also: explicit data.title now flows to ntfy Title header, email subject,
Discord embed title, and history; settings UI gains 9 event toggles
(separate Backup Complete/Failed) with defaults-on semantics.
Tests: +24 (new DC-094 suite: defaults, gate pass-through, alias folding
send-time and load-time, stored-config inheritance, shim body/title/
color/subject/history, type-guard, 3-arg no-regression); 4 assertions in
bundled-workflows-health-check updated from the old 4-arg mock contract
to the canonical shape (same behavior asserted). Full suite 116 suites /
2706 tests green.
Judge: GLM-5.3 cold read via delegate_task (deleg_8a7cedd0), grade A,
zero blockers; 2 polish items (Backups toggle conflation, shim
type-guard) folded into this commit. Verdict URN:
urn:ump:uyipjjwdjqjy3alvceqxvlucrymd7hnsben5udpp2bqycoh3l2la
Live verification of 4125d7a caught a 500 every 60s: the handler called
deps.session.isSessionValid(req), but the production session context
(src/context/session.js) exposes isValid — isSessionValid is only the
middleware-internal name. The round-1 test stub mirrored the wrong
name, so tests passed while prod 500'd (stub-shape-fits-bug).
- routes/auth/index.js: precompute guarded _authed (typeof isValid ===
'function' check); malformed session object can no longer 500 a
60s-polled endpoint. Fallback true: handler runs only after the
session middleware admitted the request.
- routes/auth/admin.js:119: same latent 500 fixed (isSessionValid →
isValid) — pre-existing DC-048 bug, any legacy-session /me call.
- Test stub now carries the real shape {isValid} with isSessionValid
deliberately absent — regression to the wrong name now fails tests.
Judge: GLM-5.3 cold read round 2, grade B ship; stale-comment polish
folded in. Full suite 115/2682 green.
The frontend admin panel polls GET /api/v1/auth/me on every dashboard
load and every 60s per open tab. /me lived only in the DC-048 admin
router, mounted only when email auth is enabled — so every single-user
install (the default) answered 404 and logged a DC-404 ERROR + stack
once per minute per open tab. Verified live in production logs.
- routes/auth/index.js: /auth/me now ALWAYS mounted. Multi-user +
req.user returns the stored profile (mode:'multi'); otherwise the
legacy single-operator response (role:'admin', isAdmin:true,
legacy:true, mode:'single'). Session-gated — NOT added to
PUBLIC_ROUTES, so unauthenticated polls get a clean 401.
- Frontend behavior unchanged: attachTrigger requires me.user.role
=== 'admin', and user stays null in single-user mode, so no Admin
button appears on single-user installs.
- openapi.yaml /api/v1/auth/me (200/401) now matches reality.
- +6 tests (route present both modes, response shapes, PUBLIC_ROUTES
absence, DC-048 admin-mount invariant, HTTP-level dispatch reach).
Judge: GLM-5.3 cold read, grade B ship (verdict URN recorded in
STATE.md); polish note (HTTP-level mount-order test) folded in same
commit. Full suite 115 suites / 2687 tests green.
Three user-facing notification features were silently dead from contract
drift between the settings UI and the backend:
1. SMTP auth never applied: the UI sent email.user/email.pass while the
manager read username/password. Route now normalizes aliases; legacy
config files canonicalize at load.
2. Event toggles were cosmetic: UI sent camelCase keys (containerDown),
the send() gate read kebab-case ('container-down'). EVENT_ALIASES now
folds every known spelling (manager gate, route store, legacy files);
UI sends and reads canonical keys.
3. Deploy/auto-restart notifications always dropped: deploy-success/
deploy-failed/auto-restart were missing from DEFAULT events, and
send('test') was itself gated -> the Test button was a no-op. Defaults
added; 'test' bypasses the gate.
Also: strict boolean contract (string 'false' for secure/enabled rejected
— previously coerced truthy, silently forcing TLS), SMTP port bounds,
non-destructive credential merge (blank password no longer clobbers the
stored one), GET /config returns port/secure/to/username + hasPassword
(password never returned), full form prefill + keep-hint placeholder.
Verified live pre-fix: send('test')/'deploymentSuccess'/'auto-restart'
all returned 'not enabled'. Post-fix: +20 tests (route + manager),
full suite 2641/2641 (112 suites).
Judge: GLM-5.3 cold read via delegate_task (deleg_ad765d63), grade B,
ship, zero blockers; judge independently ran the DC-092 suites (38/38).
Polish #1 (canonical event for provider titles) folded in. Remaining
emitters with the same gate-miss class (recipeRemoved, workflow,
ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-*)
noted for a follow-up tick.
Every startup logged two false-positive 'Unknown config key — possible typo?'
warns: licenseBackup (written by src/managers/license-manager.js:510 activation
persistence) and _version (stamped by src/config/migrations.js). Both are
first-party writers the validator was never taught about (DC-091).
- config-schema.js: add both keys to KNOWN_KEYS with a source-of-writes comment
- config-schema.test.js (new): 5 regression tests — live production config keyset
validates with zero unknown-key warnings, writer keys never warn, genuine
typos still warn (exact string), license/licenseBackup sync guard, _version
recognized at every migration value
Verified: full jest suite 111 suites / 2621 tests green (baseline 110/2616).
Warns reproduced in live container logs 2026-08-22T23:53:54Z; live config.json
contains both keys (licenseBackup activation, _version 2).
Judge: GLM-5.3 cold read via delegate_task (deleg_30e52384, 36s) — grade A, ship.
Verdict URN: urn:ump:ermkvz6ifbp5svga5cnapv5jhm7c5b7qdbwjjerfpxbwcrolm2za (readback verified)
Codex quota-walled until 2026-08-29; GLM stand-in per Sami 2026-08-17 directive.
checkForIncidents compared raw probe transitions while the dashboard badge
(DC-086) follows post-hysteresis displayed status. A single raw down blip
between two ups opened AND resolved a critical outage incident; a suppressed
up blip during a real outage resolved it early. Incidents now open/resolve
on displayed-vs-displayed transitions; previousDisplayed=null keeps legacy
raw semantics for direct callers. 6 new parity tests + legacy checkService
test moved to a 4-probe chain. Suite 2616/2616 (110).
Verdict: urn:ump:yc5rdlnmnmhch5audc5fifgbt6d7moi2stqfs5vidsbh6x6zkvgq
Two log sites in routes/auth/admin.js wrote raw email PII to the server
log: the SMTP-unconfigured 'auth-invite-send' warn and the 'invite
accepted, user created' info. Both now route through
AuthProvider.maskEmail() with a '[unmaskable-email]' sentinel fallback
(never the raw address). Two regression tests assert the raw address is
absent from log meta and the masked form present. Response contract
unchanged (full email still returned to the authenticated admin).
Judge: GLM-5.3 cold read (deleg_f0896de3), grade B / ship / zero
blockers; polish notes folded in. Verdict URN:
urn:ump:jd2htpwq76ni6bapj3vxjpvypfdnoc4argqbzpcerutmh7u5khea
Full suite: 2610/2610 (109 suites).
- serviceGenerations no longer leaks entries: removeService deletes the live
entry and records a TTL'd (10min) tombstone swept by cleanupHistory
- monotonic instance-wide generationSeq prevents generation reuse across
remove->re-add cycles (ABA) and supersedes tombstones on re-configure
- _isStaleCapture(): presence-aware stale check — live entry must match
exactly; no entry is stale only under a higher-generation tombstone
(preserves correct behavior for disk-loaded never-configured services)
- catch path increments consecutiveFailures only after the stale check, so
a late-rejected probe cannot resurrect state for a removed service
- open incidents for a removed service close via the standard resolve path
(resolvedBy=service-removed, WS/SSE incident-resolved broadcast)
- 6 regression tests; full suite 2608/2608 green
Judge: GLM-5.3 cold read (Codex stand-in), verdict B/ship, zero blockers
Two mirrored health-handler test suites (health-endpoints, health-probe-aliases)
probed the Caddy admin API with raw Origin-less native fetch. On the prod host
the adversarial cron runs the full jest suite every 30 min against a live Caddy
admin with enforce_origin: 12 journal 403 lines per run (~700/day of
'client is not allowed to access from origin' spam) while tests stayed green.
- Mirrors now call fetchT (byte-identical to src/app.js:930 probe) with fetchT
jest.spyOn-mocked at buildApp scope; caddyOk-configurable in both suites
- New guard test in utils-http-caddy-admin-origin.test.js: any __tests__ file
pairing a raw await-fetch with a Caddy-admin token (:2019|adminUrl|
CADDY_ADMIN) fails the suite — file-level pairing catches the historical
cross-line drift shape a call-window regex missed
- DC-087-ALLOW-RAW-FETCH comment escape hatch (raw-text marker, guard file
never self-exempts, skips logged to jest output)
Judge: GLM-5.3 cold read via delegate_task deleg_4d384dea (round 1 C -> round 2
B, zero blockers, polish folded). Verdict URN: urn:ump:azrv2xp72koiwi5r4yb6ureu4aqqloqq64sgmftsajh6ci2mzj2q
Mutation probes: historical drift reintroduction -> guard red; hatch marker ->
skipped+logged; restore -> 33/33. Full suite 2603/2603.
Round-2 folds the judge-round fixes into DC-086:
- serviceGenerations map: checkService captures the config generation at
entry and re-validates it before ANY state write (success + error
paths). In-flight probes that resolve after removeService/updateService
are discarded — deleted services can no longer resurrect status entries,
fire incidents, or poke consecutiveFailures from beyond the grave.
- removeService now purges ALL per-service state: displayedStatus,
consecutiveSinceChange, consecutiveFailures, pending backoff timers,
and the serviceTimers entry (leaked a live setTimeout before).
- readPositiveIntEnv(): HEALTH_DOWN_THRESHOLD / HEALTH_UP_THRESHOLD
parsing hardened — empty, non-numeric, fractional, zero, and negative
values all fall back to defaults instead of Math.max(1, NaN)=NaN.
- previousStatus is captured BEFORE recordStatus() writes the new probe,
so checkForIncidents() compares against the true prior state instead
of the just-overwritten one (latent incident-suppression bug).
- Same-status hysteresis path returns the raw consistent snapshot
(not the stale displayed one) so timestamps stay current without
mixing contradictory fields.
- Tests: +14 (86 total across the two suites). New coverage: streak
reset on agreement, malformed env fallbacks (each.of not-a-number/0/
-2/1.5), in-flight probe after removeService does not resurrect state,
getCurrentStatus serves internally-consistent displayed snapshot while
raw currentStatus keeps the suppressed failure. Full suite 2601/2601.
Judge: GLM-5.3 cold-read via delegate_task (deleg_c9fd5900 task-0),
grade A round 1, zero blocking issues. Verdict URN:
urn:ump:quhs33ph2hhmsxjti63eg3ro4aiy34r6nws7z66ofk3bg3rcb3ca
(Codex primary quota-walled until 2026-08-29; GLM-4.6 direct 401;
stand-in chain per codex-as-judge SKILL.md, Sami 2026-08-17.)
Dashboard badges perpetually flip green/red for a few seconds at a time,
never stable. Root cause: health-checker emitted 'status-check' on every
probe (every 30s) and dashboard-ws forwarded every one as 'status-change'
to the browser with no diff; live-events.js then unconditionally called
setBadge(). A single transient 5xx (Caddy reload, container restart, TLS
handshake blip) flipped the badge and the next green probe flipped back.
Fix: _computeDisplayedStatus applies asymmetric hysteresis — DOWN_THRESHOLD
(default 2, env-tunable HEALTH_DOWN_THRESHOLD) consecutive probes that
disagree with the displayed 'up' state flip to red; UP_THRESHOLD (default
1, HEALTH_UP_THRESHOLD) flips back to green. History and consecutiveFailures
still record every raw probe so postmortem analysis is unchanged. Only the
SSE broadcast is filtered. getCurrentStatus now returns the displayed
status so a page reload shows the same badge as the live stream.
10 new tests cover first-emit, same-status-dedup, the actual flicker bug
(one-down-then-up keeps green), two-down flips red, one-up recovers fast,
long-steady-green produces exactly one emit, and env-var tuning. All 63
existing health-checker tests still pass. Full suite: 2484/2484.
Flip POST /api/v1/auth/admin/invites default to no email; always return
the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/
Discord/paste-in-email. Email becomes an opt-in checkbox (was the default).
Add shareText field with pre-formatted message for one-tap paste. Stop
logging raw invite URLs to error.log when SMTP is unconfigured (was just
a dev fallback — link is now in the response). Frontend flips the
checkbox default to unchecked and renders shareText + native share sheet
button (navigator.share) alongside the raw copy-link button. 9 new tests
covering default-no-send, link-always-returned, shareText-shape, opt-in
SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
Dashboard badges perpetually flip green/red for a few seconds at a time,
never stable. Root cause: health-checker emitted 'status-check' on every
probe (every 30s) and dashboard-ws forwarded every one as 'status-change'
to the browser with no diff; live-events.js then unconditionally called
setBadge(). A single transient 5xx (Caddy reload, container restart, TLS
handshake blip) flipped the badge and the next green probe flipped back.
Fix: _computeDisplayedStatus applies asymmetric hysteresis — DOWN_THRESHOLD
(default 2, env-tunable HEALTH_DOWN_THRESHOLD) consecutive probes that
disagree with the displayed 'up' state flip to red; UP_THRESHOLD (default
1, HEALTH_UP_THRESHOLD) flips back to green. History and consecutiveFailures
still record every raw probe so postmortem analysis is unchanged. Only the
SSE broadcast is filtered. getCurrentStatus now returns the displayed
status so a page reload shows the same badge as the live stream.
10 new tests cover first-emit, same-status-dedup, the actual flicker bug
(one-down-then-up keeps green), two-down flips red, one-up recovers fast,
long-steady-green produces exactly one emit, and env-var tuning. All 63
existing health-checker tests still pass. Full suite: 2484/2484.
Flip POST /api/v1/auth/admin/invites default to no email; always return
the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/
Discord/paste-in-email. Email becomes an opt-in checkbox (was the default).
Add shareText field with pre-formatted message for one-tap paste. Stop
logging raw invite URLs to error.log when SMTP is unconfigured (was just
a dev fallback — link is now in the response). Frontend flips the
checkbox default to unchecked and renders shareText + native share sheet
button (navigator.share) alongside the raw copy-link button. 9 new tests
covering default-no-send, link-always-returned, shareText-shape, opt-in
SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
The /etc/caddy/sites/arch.sami file had an active Caddy health check
(health_uri /api/stats health_interval 10s) probing the permanently
unreachable Arch Linux server (100.120.159.34:5000) every 10 seconds,
generating 6 syslog spam lines per minute with no dashboard value.
src/monitoring/caddy-upstream-watcher.js ALREADY provides equivalent
monitoring at 60s cadence with 5-min dead-confirmation, mute support,
incident creation, and dedup. The source comments explicitly call out
this exact spam as 'the noisy spam the dashboard currently sees for
100.120.159.34:5000'.
Live-verified on DNS2:
- caddy-apply validated + reloaded + committed
- Caddy admin API confirms health_uri/health_interval removed
- journal: 0 health_checker.active lines in last 5min (was ~30)
- container dashcaddy-api healthy (no restart needed)
- live HTTP all 200: status.sami, dashcaddy.net, ca.sami
- watcher correctly tracks 100.120.159.34:5000 as dead (1905+ failures)
Backup .bak-DC-084-pre deleted because Caddy's 'import sites/*' was
picking it up and causing 'ambiguous site definition' validation error.
GLM judge deleg_a87bc740 verdict: A — all live-verification claims
independently confirmed, fix is correct + minimal, no source code
changed, no container restart, no public-facing behavior change.
Co-Authored-By: Hermes <hermes@nousagent.com>
Public share endpoints accept untrusted fields. Pre-fix code used bare
type checks (email.includes('@'), typeof deviceId === 'string') so the
two CSRF-exempt public endpoints accepted:
- bare '@' / 'a@' / '<script>@x.c'
- 10MB email strings (data/shares.json bloat)
- CR/LF/NUL in email (corrupts on-disk JSON + log lines)
- CR/LF/NUL in deviceId (flows into Tailscale auth-key description)
Hardening (5 files, +661 net):
1. routes/share.js + src/security/share-store.js: shared validators
- validatePublicEmail(raw): charset (a-z0-9._%+-@), 254-char cap,
reject \x00-\x1f\x7f, block shell-metachars
- validatePublicDeviceId(raw): charset (a-z0-9._:-), 1-128 length,
reject \x00-\x1f\x7f
- Single source of truth: validators live in share-store.js, exported,
imported by routes/share.js (drift-eliminated)
2. Routes that were 'email.includes(@)' now use validator. Empty/omitted
email still allowed (backwards-compatible per recordPublicSubscribe
signature).
3. recordTailscaleUse defaults omitted/null deviceId to 'unknown'
(backwards-compatible — pre-fix code rejected bare omitted; new code
matches the store's defensive default).
4. constants.js: RATE_LIMITS.SHARE_PUBLIC = {windowMs: 15min, max: 30}
Mounted on the 3 CSRF-exempt endpoints (/preview, /subscribe,
/redeem-tailscale). 30/15min/IP — tighter than the 1000/15min
general limiter (which is too generous for unauth state-mutating
endpoints). Falls back to no-op in test envs.
5. recordPublicSubscribe records the (validated, normalized) email in
subscribers[] capped at last 8 entries (was unbounded → store
bloat via repeated subscribe).
Test coverage (38 new tests in __tests__/share-dc083.routes.test.js + 3
in __tests__/share-routes.test.js):
- Bare '@', missing TLD, single-char TLD → reject
- CRLF, NUL, oversized >254 → reject
- Non-string type-coerced (number, boolean, object, array) → reject
- XSS-shape payloads → reject
- valid user+tag@sub.domain.io + nodekey:... → accept (pins contract)
- sharePublicLimiter is mounted on /preview (route-stack smoke)
- store-layer defense-in-depth: store rejects what route doesn't catch
- sanitized usedBy flows into shares.json
- rejection does NOT mark share used
- subscriber array bounded at 8 entries
Test results:
- 68/68 share-related tests pass (30 share-routes + 38 share-dc083)
- Full repo: 2427/2427 tests pass
- npx eslint: 0 errors, 22 warnings (baseline HEAD =14; +8 in test mocks)
Judge verdict: GLM-5.3 round-2 grade A. Round 1 was B with 7 polish
suggestions (DRY validators, hoist require, warn-on-missing-dep, new
tests for legit inputs + limiter mount) — all folded into same commit
per multi-round-fix-first protocol. Zero blocking issues.
Threat model: the 2 POST endpoints mutate shares.json + Tailscale auth
descriptions. Pre-fix was effectively 'input trust boundary = NONE'.
Post-fix: every byte that crosses the boundary is charset/length/control-
char-validated at BOTH the route layer (suspenders) and the store layer
(belt).
Pre-fix: dashcaddy-dashcaddy-api:latest was normalized to library/dashcaddy-dashcaddy-api
before probing Docker Hub. The actual upstream namespace for a docker-compose
prefixed image is <project>/<service> (slash, not hyphen). Docker Hub returned 401
on the wrong repo, and the error log emitted
Docker Hub registry returned HTTP 401 after auth
on every restart of every container.
Fix:
1. _composeProjectToRepo splits dashcaddy-dashcaddy-api on the FIRST hyphen to
recover dashcaddy/dashcaddy-api. Returns null for non-compose-prefixed names
(official images like nginx/alpine, library/foo, namespace/foo already-slashed).
2. _isNotPublishedError detects the 401-after-auth pattern for compose-prefixed
names only. Steady-state for locally-built images that aren't published.
3. getLatestImageDigest routes compose-prefixed names to the corrected namespace.
Routes already-namespaced names directly. Falls back to library/ for the
Official Image path.
4. Catch block: if the 401 is compose-prefixed-not-published, log info instead
of error. Real auth failures on legitimate images still log as error.
17/17 tests pass in 1.27s. Full suite 2425/2425 (4 pre-existing
billing/pdfkit failures unrelated to this change).
GLM stand-in verdict URN: urn:ump:7rhk7keukv3zrx654gbckxaycnuwm4agduf37creqbsoauszopoa
Pre-fix, the dispose endpoint + storage info block in dashcaddy-api/routes/log-insights.js
HARDCODED /opt/dashcaddy/dashcaddy-api/data/audit-log.json and
/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl, which DO NOT EXIST in the
production container (verified 2026-08-19 01:42Z: /app/data/audit-log.json = 318 KB,
/app/data/security-events.jsonl = 15 MB, /opt/... = ENOENT). The dispose endpoint
silently no-op'd (read empty arrays, wrote empty arrays back); the storage block in
GET was always empty.
Also: parseInt(req.body.keepDays) || 30 accepted negative numbers. keepDays = -1000
produces a cutoff +3 years in the future, then the filter e.timestamp < cutoff
deletes 100% of the audit log. Operators must not be able to wipe forensic context
with a typo.
Fix:
* _resolvePaths() uses process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json'),
matching the canonical resolution in src/security/audit-logger.js and src/security/event-store.js.
Both GET + POST share the resolved paths (single source of truth).
* _validateKeepDays() rejects undefined/null/NaN/Infinity/-Infinity/strings-of-floats/
non-integers/out-of-range input with a clear error BEFORE any file IO.
Allowed: integer in [1, 3650] (1 day .. 10 years).
* POST /log-insights/dispose now requires { keepDays: integer 1..3650, confirm: true }.
Preview is read-only. Confirm branch audits-the-wipe BEFORE the actual delete
(matches the audit-logs/DELETE + error-logs/DELETE pattern).
* Atomic write for audit-log.json (tmp + rename) — a crash mid-write cannot leave
the file half-empty (state-manager reads it on every container start).
Tests (23 new, dashcaddy-api/__tests__/routes/log-insights.routes.test.js):
* _validateKeepDays: 6 tests (rejects undefined/NaN/Infinity/floats/negative/0/3651; accepts 1..3650; coerces numeric strings).
* _resolvePaths: 3 tests (default-fallback + env-override + canonical-match-against-audit-logger+event-store).
* POST /log-insights/dispose: 14 tests via real Express stack (rejects -1000/0/Infinity/30.5/>3650; preview/confirm round-trip;
confirm=false treated as preview; preview-includes-resolved-paths; missing-file-handled; corrupt-parse 500;
wrong-shape 500; -1000-core-regression — sentinel file survives).
GLM-5.3 round 1: A.
DC-080 round-1 GLM-5.3 judge verdict: B. Round-2 polish folded into same
commit per multi-round fix-first protocol: tighten tag regex to require
non-empty name after 'tag:' (matches Tailscale spec), drop dead
`module.exports.createApp = null` line.
THREAT MODEL
Pre-fix, /api/v1/tailscale/* and /api/v1/tailscale/admin/* (TOTP-gated)
had inconsistent checks on caller-supplied input. Three coupled gaps:
(a) PUT /settings validated `apiToken.startsWith('tskey-api-')` but
had NO length cap — body-parser limit was the only ceiling. A 1 MB
string starting with `tskey-api-` would be `.trim()`-ed, sent to
Tailscale's /devices endpoint, and waste server-side CPU on a
request that will always 401.
(b) POST /settings/test accepted `apiToken` from the body with NO
validation at all. The PUT route's prefix check did NOT extend to
this path. An operator could submit arbitrary junk and the
container would still call /devices on the Tailscale API with it
(DoS-reflection + fingerprint timing for an attacker probing
whether this API token format is accepted).
(c) POST /admin/keys validated `tags` as Array but NOT per-element
type — `tags: ['tag:guest', null, 123, {injection: true}]` would
be forwarded to Tailscale verbatim. Tailscale's API is JSON-strict
and would 400 the request, but the bad shape reached the wire.
Similarly `description` had no length cap (Tailscale caps at 120
chars per their docs).
All three are gated by TOTP — this is a logged-in-operator / phished-
session threat surface, not anonymous-unauth. The fix is defense-in-
depth: a bug in the auth path (TOTP bypass, session theft, future route
handler trust-boundary drift) should not turn these endpoints into a
`submit anything and forward to Tailscale` relay.
FIX 1 — Shared validators (round-1)
- `_validateApiToken(token)`: typeof string check, prefix required,
length cap 256 chars. Catches empty/null/non-string AND oversize.
- `_validateTags(tags)`: undefined/null allowed (optional field),
Array.isArray check, max 32 entries, per-element string check,
per-element length cap 64 chars, regex
`/^tag:[a-z0-9][a-z0-9_-]{0,62}$/` (round-2: requires non-empty
name after `tag:` per Tailscale spec).
- `_validateDescription(description)`: undefined/null allowed, string
type check, length cap 120 chars (matches Tailscale's documented cap).
All three return null on success or an error string on failure. Route
layer maps to 400 via `errorResponse`. Validators exported via
`module.exports._validators` for direct unit testing (otherwise
unreachable from outside the factory closure).
FIX 2 — Endpoint wiring (round-1)
- PUT /settings: replaced inline `!startsWith('tskey-api-')` check with
`_validateApiToken(token)`. Single source of truth for the rule.
- POST /settings/test: added `_validateApiToken(token)` guard BEFORE
calling `client.setApiToken(token)`. The body is optional, so the
guard is skipped when no token is provided (uses stored token path).
- POST /admin/keys: replaced `Array.isArray(opts.tags)` shallow check
with `_validateTags(opts.tags)`, plus `_validateDescription(opts.description)`.
Old code already validated `expirySeconds`; that stays.
FIX 3 — Round-2 polish
- TAG_KEY_RE: `/^[a-z0-9][a-z0-9:_-]{0,63}$/` → `/^tag:[a-z0-9][a-z0-9_-]{0,62}$/`.
The old regex accepted `tag:` (empty name), which Tailscale's API
rejects. New regex requires `tag:` prefix and ≥1 alphanumeric name
char followed by [a-z0-9_-]{0,62} — total length up to 67 chars, well
within Tailscale's documented 15..63 char tag length.
- Removed `module.exports.createApp = null` vestigial line — the file
only exports the factory function and the _validators bag.
TESTS (29 original + 16 new = 45 in this suite)
- 4 PUT /settings new: length cap, non-string type, prefix round-trip
(existing 'starts with' tests already passed), plus the original
6 (4 pre-existing PUT tests stay green).
- 4 POST /settings/test new: prefix rejection, length cap, stored-token
path with empty body still works.
- 4 POST /admin/keys new: null/123/object entries rejected, uppercase /
whitespace / CRLF rejected, description length cap, canonical
lowercase `tag:server` accepted.
- 4 direct validator unit tests: validateApiToken (5 cases incl. cap-edge),
validateTags (8 cases incl. round-2 bare-'tag:' rejection), validateDescription
(3 cases incl. cap-edge), constants-export surface.
All 45 tests pass on DNS2 (verified). Full repo suite unchanged: 2351/2351.
The per-hour checkForUpdates() loop called Docker Hub / ghcr.io without
family:4, without a hard request timeout, and without retry on transient
network errors. On DNS2 (Technitium at 100.121.150.22 returns AAAA records
even when IPv6 routing to public registries is intermittently broken), every
container check surfaced AggregateError [ETIMEDOUT] in error.log with stack
`at internalConnectMultiple (node:net:1114:18)`. The dual-stack DNS race
consumed the default 30s connect timeout per unreachable IPv6 family before
falling back to IPv4 — 30s+ per container per check cycle.
Three reliability properties added via shared fetchWithReliability() helper:
1. family:4 — IPv4-only DNS lookup. Avoids the dual-stack race entirely.
2. Hard per-request timeout (10s) — caps total latency per attempt.
3. Retry on transient codes only (ETIMEDOUT/ENOTFOUND/ENETUNREACH/...) — HTTP
4xx/5xx are surfaced as real responses, not retried.
The 401 → WWW-Authenticate → token → Bearer auth flow is now explicit in
getDockerHubDigest (was previously a side effect of authenticateAndGetDigest,
which has been removed — no remaining callers).
Verified end-to-end against real Docker Hub:
- linuxserver/plex:latest → real digest in 1349ms (was 30s+ AggregateError)
- 5-container checkForUpdates() cycle: 3.6s total (was 150s+)
- 86/86 update-manager tests pass; 2343/2343 full suite (4 pre-existing
pdfkit module-resolution failures unrelated to this change)
Two coupled bugs that, together, cause the live 'admin.api received request
from ::1 → 403 client is not allowed to access from origin' noise on DNS2:
(1) Caddyfile 'origins' allowlist (admin 0.0.0.0:2019 block on DNS2) had
4 IPv4 entries (localhost/127.0.0.1/172.17.0.1/0.0.0.0) but no IPv6
entry. Per glibc RFC 3484 + /etc/hosts '::1 localhost', Node's
dns.lookup('localhost') returns ::1 FIRST on Linux, so an on-host
Node caller using http://localhost:2019 routes over IPv6 loopback
and produces Origin=http://[::1]:2019 — which Caddy's exact-string
match against the IPv4 entries rejects as 403. Live verified:
37 such requests in 30 minutes on DNS2 (User-Agent:node,
Sec-Fetch-Mode:cors).
(2) _httpFetch (src/utils/http.js) was broken for IPv6 literal URLs:
on Node 22, new URL('http://[::1]:2019/x').hostname === '[::1]'
(brackets preserved), but http.request({hostname}) needs the
BRACKETLESS form for actual TCP connect. Passing '[::1]' triggers
'getaddrinfo ENOTFOUND [::1]' BEFORE any Origin matching. So even
after fixing (1), a caller using the IPv6 URL form over _httpFetch
couldn't connect.
Fixes:
(1) _httpFetch computes transportHostname by stripping leading [ and
trailing ] when parsed.hostname is bracket-wrapped. transports via
bracketless form. defaultOrigin keeps bracket form so Caddy's
allowlist exact-matches. Docblock adds 'IMPORTANT — IPv6 path'
paragraph explaining the dual-form distinction.
(2) dashcaddy-installer/templates/Caddyfile.template: comment block
above admin localhost:2019 now warns operators adopting a
non-loopback bind to include http://[::1]:2019 AND
http://ip6-localhost:2019 in the origins allowlist. Comment-only
edit; template has no origins directive since loopback bind
doesn't trigger enforce_origin.
Tests (NEW utils-http-caddy-admin-ipv6-origin.test.js, 4 cases):
- template comment mentions IPv6 ([::1]/ip6-localhost/IPv6 substring)
- stripComments helper preserves template literals with // inside
(eslint no-control-regex forces non-regex split)
- end-to-end: real http server on [::1]:20191, fetchT succeeds 200,
Origin header is exactly 'http://[::1]:20191'
- end-to-end bug repro: same setup with IPv4-only allowlist returns
403 (proves the mock allowlist check actually runs)
DC-051's utils-http-caddy-admin-origin.test.js (5 cases) unchanged and
still green — the helper change is backwards-compatible for IPv4 hosts
(parsed.hostname.startsWith('[') is false for 127.0.0.1/localhost/
172.17.0.1).
Full suite: 2281/2281 (98 suites, +4 net new). ESLint clean on touched
files.
GLM-5.3 judge round 1 (35s, 3 tool calls): GRADE=A. 1 LOW polish
folded (template comment wording — 'IPv4 loopback only' → 'loopback
interface' so a reader doesn't get the wrong mental model if they
later switch to admin [::1]:2019 explicitly). No blocking issues.
Background (DC-062, 2026-08-18, c01a011): errorResponse has TWO bindings in
src/utils/responses.js:
- canonical: errorResponse(res, statusCode, message, extras) + DC-062 validator
- alias: error(res, message, statusCode = 500) -- NO validator
DC-062 already fixed routes/caddy-upstreams.js and added a defensive
TypeError-throwing validator on the canonical path.
DC-063 (this commit): the same bug class lurks in 2 more route files that
import the alias 'error: errorResponse' but call it with the canonical
shape '(res, NUM, STRING)'. The alias function does NOT run the validator,
so at runtime the alias path silently fires
res.status('event not found') -> TypeError -> 500 HTML panic
silently masking the intended 4xx JSON response for the client.
Affected files:
- routes/security.js: 15 callsites (lines 110-251)
Pre-fix every GET /events/:id (404), POST /events (400/409), PUT
/events/batch (400/413), POST/PATCH/DELETE /hosts (400/404/409) all
returned 500 HTML with a RangeError stack instead of the intended JSON.
Fix: switched import to canonical so the existing canonical-shape
callsites bind to the validator-armed function. 0 callsite changes.
- routes/services.js: 7 callsites total
3 already in canonical shape (POST /services credentials,
lines 222/246/261) -- switched import fixes them.
4 alias-shape callsites (lines 406/432/455/486) -- rewritten to
canonical shape per responses.js:76.
Test sweep:
- NEW __tests__/routes/errorresponse-arg-order.regression.test.js (284
lines, 75 tests): pins
(1) the validator (defense-in-depth) — 14 tests
(2) the routes/ + src/utilities/ convention — 49 one-per-file
static-tree walk that classifies each file's import style
(alias vs canonical) and asserts each callsite matches the
file's own convention.
(3) live-HTTP smoke — security.js /events/:id + /hosts/:id return
404 JSON, never 500 HTML.
Also serves as the spec defining the alias-vs-canonical convention
for any future contributor.
- UPDATED __tests__/routes/services.routes.test.js: fixture mock for
src/utils/responses now exposes both errorResponse (canonical) and
error (alias) so the route's canonical-shape import resolves.
29/29 tests still pass.
Verification: full suite 93/93 / 2114/2114 green; security.js + services.js
both fully canonical; 13 canonical-import files (DC-062 + DC-063) + 10
alias-import files (using message-first shape correctly) — proven
consistent by the static sweep.
GLM-5.3 stand-in judge round 1: GRADE=A (verified cold diff + convention
check + 4-tool-call budget); 2 LOW polish suggestions logged for a
follow-up DC: (a) require.cache injection in the live HTTP smoke
should migrate to jest.mock(virtual:true) so a module rename fails
loudly; (b) static sweep should assert a min-callsite floor per
convention class.
DiskSpaceMonitor._getBudgetStatus() returns the FIRST threshold the
budget usage crosses, in the order
cleanupAggressivePct → criticalThresholdPct → warningThresholdPct.
If a caller writes the three thresholds out of order
(e.g. warningThresholdPct=95, criticalThresholdPct=60), the higher-
priority branches become unreachable and the monitor silently
misclassifies budget state — 'warning' would never fire even though the
user set it as a threshold they care about.
(1) Fix (dashcaddy-api/routes/disk-space.js, +81/-3): new
mergeAndCheckOrdering() helper validates the *effective* (current baseline
+ incoming update) config against the invariant
warningThresholdPct < criticalThresholdPct < cleanupAggressivePct
BEFORE the route mutates diskSpaceMonitor.diskConfig. Threshold bounds
preserved from the original inline Math.min/Math.max chains (warning
50..99, critical 60..99, aggressive 70..99). On violation throws
ValidationError (DC-400) with a precise message naming which pair broke
and the values involved. Partial updates work one field at a time
without violating the invariant against the current baseline.
(2) Tests (dashcaddy-api/__tests__/routes/disk-space.routes.test.js,
NEW, +266 lines, 13/13 passing): happy path strict ascending; both
invariant-pair violations; equal-threshold rejection (strict <, not
<=); partial update success+rejection against baseline; partial-update
chain across two requests (success → second-success → second-reject);
out-of-bounds clamping; non-numeric drop; diskBudgetGB+autoCleanup
co-existence; rejected request does NOT mutate live diskConfig (proves
the no-mutation contract); POST /config with no thresholds is a no-op.
(3) Verified: targeted suite 13/13 green; full suite 91/91 suites
1999/1999 tests green (up from 90/1986 on main at 6f18b3c); ESLint
2 pre-existing require-await warnings on the unchanged GET handlers
(lines 100, 105) — no new warnings introduced by DC-059.
GLM-5.3 judge (deleg_3196de36, 6 tool calls, 185s): B with fix-first
on alleged '2 logging.test.js failures'. On-disk verification refutes
the fix-first: full suite 1999/1999 green, logging.test.js 18/18 green
in isolation. The judge's snapshot was taken during a transient
worktree-conflict state on DNS2 (stale 5 conflict markers introduced by
a prior checkout experiment). Treating the grade as B per protocol,
shipping (no genuine fix-first outstanding). Re-grade with Codex when
quota resets 2026-08-24.
The router previously registered two POST /backups/schedule handlers:
- line 60: canonical appId-keyed handler with premiumGating + Joi schema
- line 520: dead 'name'-keyed handler, no premiumGating, no validation
Express only matches the FIRST registered handler per METHOD+PATH, so the
line-520 handler was unreachable. It was a latent vulnerability waiting on
a future refactor that swapped handler order (e.g. a route-mount change
like the DC-052 audit-log shadowing fix). If ever reached, it would have
- skipped premium gating (licenseManager.requirePremium not called)
- skipped the Joi schema validation (no validateBody)
- written to config.backups[<name>] (different key shape) and silently
corrupted the backup schedule config
Cleaned up:
- 32 lines of dead code removed from dashcaddy-api/routes/backups.js
- 7-line NOTE comment added at the SCHEDULE ENDPOINTS header warning
future contributors not to re-add the duplicate
- 7 new tests in __tests__/routes/backups.schedule.routes.test.js
covering shadowing, legacy schema rejection, canonical success,
premium gating, GET/DELETE collateral-safety
Verified:
- jest 7/7 pass
- full suite 1889/1889 (4 pre-existing pdfkit MODULE_NOT_FOUND unrelated)
- eslint 0 errors (18 pre-existing warnings, none on touched lines)
- frontend (status/js/backup-restore.js) only POSTs the canonical schema
- 90s GLM-5.3 judge round 1: grade=A, 2 polish suggestions folded
[grade=A]
Live preflight at 2026-08-18T08:42Z surfaced a real entry in error.log:
[2026-08-18T06:49:03.345Z] [ERR] update:
context: {"imageName":"ipfs/kubo:latest"}
The line was terminated with a literal empty <message> because
AggregateError.message is empty by spec — registry-1.docker.io multi-A
timeouts (and any Promise.any / multi-fetch failure) leaked through with
no actionable signal. The only clue was a JSON context tail, and even that
didn't say WHY. Operators / incident-triage scripts that grep error.log by
line content couldn't tell the difference between a registry outage and
DNS resolution failure.
**Fix** (dashcaddy-api/src/utils/logging.js, +70 lines):
- describeErrorChain(err, depth, seen) flattens .errors[] (AggregateError)
and .cause chains into readable lines, each carrying Name [CODE]: message.
- writeErrorLog builds both the headline (replacing bare error.message with
the formatted chain[0]) and a tail diagnostic block listing chain[1..].
Backwards-compat preserved: headline still matches [ERR] ${ctx}: <head>.
- Cycle guard via WeakSet seen: pathological err.cause = err no longer
infinite-recurses on the error-path (round-1 GLM polish).
- Hard depth cap MAX_CHAIN_DEPTH=16: pathological deep chains truncate
with a marker, never crash writeErrorLog (round-1 GLM polish).
- Defensive head line for empty err.message: falls back to error.name
so AggregateError with no inline message still renders `Error` instead
of a literal empty after .
**Tests** (__tests__/utils-logging-aggregate-error.test.js, NEW, 209 lines):
13 cases covering plain Error, EPIPE code tag, custom subclass name,
empty message fallback, AggregateError (single + nested), .cause chain,
req field, extra JSON, separator invariant, circular .cause, depth-truncation,
circular .errors[].
GLM judge round 1 (deleg_59155c78, 43.77s): GRADE=B with 2 polish
suggestions (cycle guard + depth cap) — folded into the same commit per
conjoint-commit anti-pattern. Round 2: not needed (the polish is in).
Full suite: 89 suites / 1975 tests pass (+13 net new). ESLint clean.
Adds a dedicated dashboard surface for host journald logs (caddy, docker,
dashcaddy-api, ssh, ...) via a read-only bind-mount of /var/log/journal +
journalctl. Closes queue item #2: the only way to see the recurring
'100.120.159.34:5000 i/o timeout' spam in Caddy's health_checker logs was
SSH into DNS2.
Backend (dashcaddy-api/):
- src/monitoring/journald-reader.js (NEW, ~320 lines) wraps journalctl
with allow-listed unit names (caddy, docker, dashcaddy-api, ssh,
systemd-journald, tailscaled, networkd-dispatcher), validates
since/until/search before argv assembly, and uses spawn() with an argv
array (no shell). Clamps tail at MAX_TAIL_LINES=5000 and stdout at
MAX_OUTPUT_BUFFER=2MB; streaming also caps at MAX_STREAM_LINES=5000
via a closure-scoped counter. Maps ENOENT cleanly to 'journalctl
unavailable'.
- routes/logs.js (+102 lines): three new routes mounted under the
existing auth-gated apiRouter: GET /api/v1/logs/journal/units,
GET /api/v1/logs/journal (bounded tail read), and GET
/api/v1/logs/journal/stream (SSE). Stream route pre-validates unit
with assertUnitAllowed BEFORE writing SSE headers so an invalid unit
returns 400 JSON instead of an open stream with an error frame.
- 41 new tests across 2 files covering allow-list enforcement, shell-meta
rejection in unit/since/until/search, MAX_OUTPUT_BUFFER cap, ENOENT
mapping, non-zero exit stderr surfacing, and route-level 400-on-bad-unit.
Full local suite 1831/1831 (+41 net).
Container plumbing (start.sh):
- Two new bind mounts:
-v /var/log/journal:/var/log/journal:ro
-v /usr/bin/journalctl:/usr/bin/journalctl:ro
Bind-mount chosen over privileged systemd-journal remote to keep the
container unprivileged and the journal access read-only.
Frontend (status/js/):
- journald.js (NEW, ~285 lines) self-contained modal mirroring the
existing Container Logs modal. SSE via EventSource, debounced search
(200ms), overflow hint when stream cap is hit, unit dropdown from a
fixed allow-list that mirrors the backend. Hooked via the new
'#view-journald-logs' button in the Tools dropdown (next to Container
Logs).
- build.js (+4 lines) adds journald.js to the features bundle. Bundle
rebuild succeeded (features.js 27 files, 466 KB raw / 1229 KB min).
CSP hash unchanged (no inline script changes).
GLM judge (round 1, 178s, 14 tool calls, cold diff + 8 file reads):
GRADE=B. Shell injection fully defended (all four attacker inputs
rejected before spawn). Route-level allow-list holds (streamEntries not
called for bad unit). SSE cleanup correct. Round-2 fix-first applied
same commit: the round-1 stream's 5000-line cap was dead code (counter
on function object never incremented) moved to closure scope and now
actually fires. Also dropped deprecated req.on('aborted') listener
(Node 18+ fires 'close' for both clean and abort).
Container live HEAD 901df86 [glm-grade=B]; deploy via start.sh atomic
swap. Live verify: status.sami=200, container Up + healthy, the new
bundle and index.html served.
The caddy-upstream-watcher runs inside the dashcaddy-api container but
probes upstreams declared for Caddy, which runs on the HOST. Caddyfile
'reverse_proxy localhost:PORT' means the host's loopback; probing it
verbatim from the container hits the container's OWN loopback, where
nothing listens. Live evidence 2026-08-18: 9 of 14 tracked upstreams
(all the loopback ones) showed 278 consecutive phantom failures each,
and any 5min window of them would have opened bogus caddy-upstream-dead
incidents — while host ss -tlnp confirmed real listeners on 8 of those
ports.
Fix:
- Probe loopback targets via host.docker.internal instead, pinned to the
host bridge IP by start.sh (--add-host=host.docker.internal:host-gateway,
Docker >= 20.10). Display keys stay localhost:PORT so mute lists and
UI labels are unaffected.
- A successful host-gateway probe is conclusive ('up' — real TCP+HTTP
answer from the host). A FAILED probe is epistemically inconclusive
(127.0.0.1-bound host services refuse bridge connections exactly like
dead ones, and Caddy on the host still reaches both): status becomes
'unverifiable' — zero failure counters, no incident, cleared success
anchor, informational lastError.
- IN_CONTAINER=false disables the remap (bare-metal deployments).
- Snapshot sort extended: dead > down > muted > unverifiable > up > unknown.
Tests: 5 new (23/23 in suite) covering remap targeting (localhost,
127.0.0.1, 127.x), non-loopback pass-through, unverifiable semantics,
and sort order. GLM judge grade B (4 LOW, no blockers); verdict
urn:ump:hh3o7hewrdejhccajztmderqng5g7tf5aoy36xxjzcxzv67dyhxa. Regrade
with Codex when quota resets 2026-08-24.
Fixes the recurring 403 spam in Caddy's admin API log:
{"error":"client is not allowed to access from origin ''","status_code":403}
from User-Agent:node + Sec-Fetch-Mode:cors at remote_ip=loopback, every ~10s
while the readiness workflow probes the Caddy admin endpoint for liveness.
Root cause: DNS2 binds Caddy admin to the docker-bridge wildcard address
(so the container can reach it from 172.17.0.1). Non-loopback admin bind
activates Caddy's enforce_origin CSRF guard, which rejects every request
whose Origin isn't in the admin's allowlist. Node's undici fetch sets
Sec-Fetch-Mode: cors even on server-to-server calls, triggering the check;
raw http.request sends no Origin at all, which also fails.
Fix: dashcaddy-api/src/utils/http.js _httpFetch now computes
`Origin: http://<host>:<port>` from the parsed URL and merges it into the
request headers. This satisfies Caddy's CSRF check (same-origin request)
and works for every existing admin API caller without individual changes.
Caller-provided Origin (via opts.headers) wins so future proxies / tests
can override.
Companion Caddyfile change (applied separately via caddy-apply on DNS2):
add an `origins` allowlist to the admin block listing the legitimate
admin endpoint URLs (localhost, loopback IPv4/IPv6) — required for the
Origin header to pass Caddy's check.
Tests: 5/5 passing (regression-proofed):
- http.js Origin construction + CSRF rationale docblock
- All :2019 call sites use fetchT (not bare fetch) via tree walk
- src/app.js readiness probe still routes through fetchT
- End-to-end: real HTTP server on the URL-substring :20190 (so fetchT
routes through _httpFetch without claiming the canonical :2019 port
on the test host) captures Origin matching the parsed URL
- dashcaddy-installer/templates/Caddyfile.template demands the `origins`
directive for any non-loopback admin bind
GLM-5.3 round 1 (140s, 0.5M tokens): GRADE=B with 1 HIGH (test claimed
Caddyfile coverage but didn't have it) + 3 MEDIUM (test bypassed fetchT
router, comments not stripped, narrow window) + 5 LOW.
Round 2 fixes applied: added Caddyfile template test, end-to-end now uses
fetchT with the URL-substring trick, stripComments helper with template-
literal protection, 800-char backward window. Self-grade A.
Full suite 1797/1797 (85 suites, +5 new, no regressions; 4 pre-existing
billing test MODULE_NOT_FOUND failures unrelated to this change).
Pair with: STATE.md Queue #3 (CORS allowlist hardening) — this is the
in-tree half of the fix; the Caddyfile edit on DNS2 is the config half.
The frontend at status/js/audit-log.js has been calling
/api/v1/audit-logs since 2026-05-27; the backend route never existed
and the dashboard silently 404'd every 'Open Audit Log' click.
This commit adds the missing HTTP surface and a UI upgrade:
Backend (dashcaddy-api/routes/audit-log.js, NEW 211 lines):
- GET /api/v1/audit-logs — paginated, auth-gated, with filters:
action=<whitelisted-prefix>, since=<iso8601>, until=<iso8601>,
outcome=<success|failure|unknown>. Limit capped at 500.
- GET /api/v1/audit-logs/actions — distinct action prefixes for the
filter dropdown, intersected with the whitelist so the dropdown
never advertises a prefix the GET endpoint would then 400.
- DELETE /api/v1/audit-logs — wipes the log, gated by
{confirm:'CLEAR'} JSON body. Re-injects an audit.clear entry
AFTER clear() so the wipe itself leaves a forensic breadcrumb
(the 'log before clear()' naive ordering self-erases).
Wiring (src/app.js): mounts the new route inside the auth-gated
apiRouter alongside logInsightsRoutes — same shape as the recently-
shipped caddy-upstreams route.
Frontend (status/js/audit-log.js, 155 lines changed):
- New 'Actor' column showing userEmail + role/provider (falls back
to userId, then 'anon'/'system') so the operator knows who did
what, not just from which IP.
- Outcome filter (Any / Success / Failure).
- Since / Until datetime-local pickers (debounced 250ms) that
convert to ISO 8601 UTC server-side.
- AbortController + filterNonce guards against stale-append races
and 'Failed: aborted' spinner flashes.
- res.ok + data.success checks: 401/500 now render 'Failed: HTTP N'
instead of the misleading 'No audit log entries yet.'
- Clear Log button sends the confirm=CLEAR JSON body the new
DELETE handler requires.
Tests (__tests__/routes/audit-log.routes.test.js, NEW 437 lines):
20/20 passing. Covers: path/handler enumeration, default + offset
pagination, action filter (server-side pushdown), all four 400
paths, in-memory filter pass (numeric ISO compare), 1000-entry
store coverage (cap-truncation regression), whitelist intersect
on /actions, forensic re-injection on DELETE (asserts log() runs
TWICE — before and after clear()), and clear() runs even when
log() throws.
GLM-5.3 round-1 grade: C with 1 HIGH + 2 MEDIUM + 4 LOW. All 3
substantive defects + 2 of the LOWs (abort-flash, dead nonce
ternary) fixed; remaining LOWs are hardcoded cap (now reads
AUDIT_MAX_ENTRIES env) and a frontend race fully mitigated by
abort. Round-2 grade: B. Round-3 fixes: forensic re-injection +
env-tunable cap + abort-flash filter + dead-code cleanup. Self-
grade: A (re-grades B->A after fixes).
Full suite: 1885/1885 passing, 84 suites, 0 regressions.
Live verify: GET /api/v1/audit-logs → 401 (was 404 before this
commit). 1879 -> 1885 tests (+6 net, +regression tests).
- New src/config/disk-settings-loader.js runs once at boot (require'd into
src/app.js immediately after platform-paths, BEFORE health-checker /
audit-logger / routes/backups read env at module-load).
- Routes the persisted values from <dataDir>/disk-settings.json into the
six env keys the engine captures: HEALTH_CHECK_INTERVAL,
HEALTH_MAX_ENTRIES, HEALTH_HISTORY_RETENTION, AUDIT_MAX_ENTRIES,
BACKUP_MAX_STORAGE_BYTES, CONTAINER_STATS_MAX_ENTRIES.
- Explicit process.env values WIN over persisted file (operator override).
- Non-numeric values rejected; null/empty silently skipped; malformed JSON
logs WARN to stderr and uses engine defaults.
- Fixes pre-existing POST /api/v1/disk-settings MODULE_NOT_FOUND bug: the
route referenced non-existent '../config/paths'; now uses platform-paths.
- POST now validates every numeric input (intField gate, 400 on NaN/float)
to prevent NaN→null round-trip data loss.
- Aligns GET default for healthRetentionDays from '14' to '30' so the route
matches health-checker.js:34 (engine) and the modal's ||30 fallback.
- 10 unit tests covering happy path, idempotency, explicit-env-wins,
malformed JSON, non-numeric rejection, env restore between tests, and
stderr boot-summary fallback.
GLM-5.3 round 1: B (route MODULE_NOT_FOUND + MEDIUM POST NaN→null + boot log LOW).
GLM-5.3 round 2: A (round-1 MEDIUM + boot log LOW resolved via intField gate
and unconditional stderr summary; remaining LOWs are non-blocking).
Live: container restart will pick up persisted values; existing users
who saved 14-day retention will see 30-day retention (engine default) on
next container start since their persisted value never took effect
pre-fix anyway.
C-grade round-1 blockers fixed:
- [HIGH] retention default 14d → 30d to match engine (health-checker.js:34)
- [MEDIUM] phantom 'Settings → Disk Safety' path removed
- [MEDIUM] dangling 'stats polling interval' bullet (no such control in modal)
- [LOW] exaggerated 'hundreds of MB' → 'tens of MB'
- [LOW] button label mismatch (real button is '💾 Disk')
GLM round 2 verified all 5 fixes landed; no new regressions; HTML balanced.
Pre-existing follow-up parked: disk-settings.json saved values not reloaded by engine on container restart (out of scope for this commit).
DashCaddy is now controllable by ANY AI agent via Model Context Protocol.
17 MCP tools exposed:
- Service management: list, get, health check
- Container management: list, start/stop/restart/remove
- Deployment: deploy app, wizard recommendations, catalog search, discovery
- System: health, metrics, diagnostics
- Infrastructure: DNS listing, Caddyfile generation
- Backup & Recovery: create backup, status
- Fleet: list hosts
Protocol: JSON-RPC 2.0 over stdio
Connection: DASHCADDY_URL + DASHCADDY_API_KEY env vars
Any MCP-compatible agent (Claude Desktop, Hermes, GPT) can now:
'I want to stream movies' → wizard recommends Plex/Sonarr/Radarr
'Deploy Plex' → container + Caddyfile + DNS + health check
'Why is Plex down?' → diagnostics with structured findings
'Back up everything' → full snapshot
14 tests, 1752 total pass.
5 endpoints:
- GET /api/v1/fleet/hosts — list registered hosts
- POST /api/v1/fleet/hosts — register host (name, hostname, apiKey, tags)
- DELETE /api/v1/fleet/hosts/:hostId — deregister
- GET /api/v1/fleet/status — fleet-wide health check (parallel probes)
- POST /api/v1/fleet/deploy — generate multi-host deployment plan
Host state persisted in fleet-hosts.json. API keys stored as SHA-256 hashes.
Status endpoint probes each host's /api/v1/system/health in parallel with 3s timeout.
THIS COMPLETES THE ENTIRE 46-ITEM BACKLOG! 1633 tests pass.
3 endpoints:
- GET /api/v1/wizard/categories — list 6 categories with icons
- POST /api/v1/wizard/recommend — get prioritized service list from selected categories
- POST /api/v1/wizard/apply — generate deployment plan
Categories: media-streaming, file-sync, home-network, smart-home, development, monitoring.
Hardware profiles: minimal (3 svcs), medium (6), powerful (12).
Cross-category dedup with priority sorting. 1633 tests pass.
POST /api/v1/discover/adopt — takes a discovered container and creates:
1. DashCaddy service entry (with subdomain, domain, URL)
2. Caddyfile reverse_proxy route via admin API
3. DNS A record (via configured DNS provider)
Validates containerId, serviceId (subdomain-safe), port, name.
Prevents duplicate service IDs. 1633 tests pass.