Commit Graph
204 Commits
Author SHA1 Message Date
Hermes 9215037553 DC-146: Technitium zone/record sync (idempotent upserts) + split-DNS gate + setup check + tests 2026-10-03 21:39:33 -07:00
Hermes b7d9e2b983 merge dc/DC-143 into p7-wave1 2026-10-03 21:32:45 -07:00
Hermes 4e4306b00f DC-143: block/unblock/remove person + revoke device orchestration + tests 2026-10-03 21:29:41 -07:00
Hermes 486d0e8d11 DC-148: Tailscale ACL compiler + lockout guard + preview/push/rollback + drift detection + tests 2026-10-03 21:26:29 -07:00
Hermes c09fc7b1ef Merge branch 'dc/DC-145' into dc/DC-143 2026-10-03 21:23:14 -07:00
Hermes 5d786afcce Merge branch 'dc/DC-141' into dc/DC-143 2026-10-03 21:23:14 -07:00
Hermes b7df8f5fe0 Merge branch 'dc/DC-140' into dc/DC-148 2026-10-03 21:20:01 -07:00
Hermes 5e03f120b4 Merge branch 'dc/DC-141' into dc/DC-148 2026-10-03 21:20:00 -07:00
Hermes 7ef3914730 DC-140: network provider abstraction (src/network/) + tailscale provider + registry + tests 2026-10-03 21:16:20 -07:00
Hermes 4b560b9c40 DC-141: people store (atomic JSON) + /network/people routes + last-admin guard + tests 2026-10-03 21:14:20 -07:00
Hermes 09152dfde2 DC-145: access map store (grants, effective access, change events) + /network/access routes + tests 2026-10-03 21:12:35 -07:00
DashCaddy Polish Loop 1fc61c3fdb DC-130: shipdeck deploys panel — bridge-proxied source deploys (routes + tests, opt-in via SHIPDECK_BRIDGE_URL) 2026-09-14 03:33:31 -07:00
DashCaddy Polish Loop 70e252c8a5 [grade=A urn:ump:seyasvbntxsjibq5jiaqmfowc6xgchwe4jvcc54jd6355ujgm75q] DC-122: self-updater hardening + auto-update on + docker disk discipline (v1.16.0)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- _isNewer: same-version releases are never 'newer' (commit labels are
  opaque stamps) — kills the same-version auto-apply regression loop
- _autoCheckAndApply: identical version@sha256 never re-applied
- dashcaddy-update.sh: truthful rollback verdicts (failed rebuild/health
  = exit 1 + failure result), exact frontend snapshot/restore (incl.
  update-introduced owned subtrees), contents-copy cp fallbacks with
  manifest reconciliation, JSON-encoded results/meta/stamp, prune on
  every exit path
- self-updater: frontend-only Linux releases fail loudly (no more silent
  no-op success stuck in 'applying')
- start.sh: DASHCADDY_UPDATE_ENABLED=true (Sami 2026-09-13), json-file
  log caps 10M x3, source->webroot sync via update-stamp contract
- tests: _isNewer regression suite, functional cycle + json/escape suites
  (scripts/test-frontend-cycle.sh, scripts/test-json-escape.sh)

15 judge rounds: C,C,D,D,C,C,D,C,C,C,D,C,A
2026-09-13 05:32:14 -07:00
Hermes c28322eb46 [grade=B] api: fix undeclared 'format' runtime bug in routes/ca.js + 10 lint errors
- 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.
2026-08-31 23:37:00 -07:00
Hermes 46b6952c36 [glm-grade=A] fix(security): DC-120 surface caddy-source perimeter events in Log Insights dashboard — new GET /api/v1/security/events/perimeter endpoint with per-IP/per-vhost aggregations, event-store compileFilter/filterEvents primitives, and Perimeter section in Log Insights modal with XSS-safe rendering and stale-request guards
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-23 17:26:07 -07:00
Hermes 1744d1c86e fix(security): caddy-event self-noise conjunction filter for host curl probes (DC-118) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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
2026-08-23 15:53:58 -07:00
Hermes 2dce6dca5e fix(security): event-store retention + race + total-cap defects (DC-116) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- 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.
2026-08-23 15:02:00 -07:00
Hermes 65457ff8e0 feat(security): activate caddy security-event pipeline — bounded first-start replay, self-noise filter, host fidelity (DC-113) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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
2026-08-23 12:40:32 -07:00
Hermes a29a59a320 fix(security): name caddy-worker SSO gate events + dead-path visibility (DC-112) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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
2026-08-23 09:37:18 -07:00
Hermes 56b807543c fix(audit): restore named audit actions for SSO gate traffic — 3 live defects (DC-111) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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
2026-08-23 09:05:27 -07:00
Hermes 0721b1cb04 fix(security): audit-logger PII masking parity with unified logger (DC-110) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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).
2026-08-23 08:01:29 -07:00
Hermes 9322831f1b fix(security): quoted local-part email mask — strip delimiter quotes, split on last @ (DC-109) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-23 07:28:10 -07:00
Hermes c429b8fdd7 fix(security): redact-on-rotate for error.log archive + README PII docs (DC-108) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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).
2026-08-23 05:22:04 -07:00
Hermes 5efacd11e8 fix(security): close rotateEncryptionKey crash window with in-process key rollback (DC-107) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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).
2026-08-23 04:40:41 -07:00
Hermes eb2bab7a96 refactor(persistence): migrate credential-manager to canonical atomic-write util (DC-106) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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.
2026-08-23 03:06:58 -07:00
Hermes b1464d9b85 refactor(persistence): migrate caddy-upstream-watcher state to canonical atomic-write util (DC-105) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
_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.
2026-08-23 02:31:16 -07:00
Hermes 6f8fac142f refactor(persistence): migrate fulfillment-store to canonical atomic-write util (DC-103) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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
2026-08-23 01:21:28 -07:00
Hermes 521b2f24a1 refactor(persistence): migrate share-store to canonical atomic-write util (DC-102) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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.
2026-08-23 00:50:47 -07:00
Hermes 09d56fde2c refactor(persistence): migrate user-store to canonical atomic-write util (DC-101) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Drops user-store's private _atomicWriteJSON copy (pid+Date.now() tmp
names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js
(DC-099 canonical: fsync'd same-dir exclusive-create 0600 tmp -> rename ->
parent-dir fsync, cleanup-on-failure). All 3 persisted files routed:
users.json, authorized-users.json, .bootstrapped sentinel. Sole consumers
are JSON.parse readers — dropped trailing newline unobservable (judge
verified repo-wide). +2 store-level regression tests pin 0600 / complete
JSON / no temp leftovers across all three files, incl. the bootstrap path
writing three files back-to-back in one login.
Judge: GLM-5.3 cold read, round-1 A, deleg_f632f05c.
Verdict: urn:ump:5fivvveqhcbkl6os4dhidchkvjjzbvi7rgj6znaovp6bfnvmcmsq
Full suite: 121 suites / 2772 tests green.
2026-08-23 00:21:41 -07:00
Hermes 3742e2658d refactor(persistence): migrate invite-store to canonical atomic-write util (DC-100) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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.
2026-08-22 23:54:37 -07:00
Hermes 80a82c4cae refactor(persistence): canonical atomic file writer + notifications.json crash-safety (DC-099) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- src/utils/atomic-write.js: single shared tmp+fsync+rename writer
  (exclusive-create 0600, unique tmp names, cleanup-on-failure,
  best-effort parent-dir fsync after rename for swap durability)
- notification-manager: both write paths (load-time canonicalization
  write-back + saveConfig) converted from plain writeFileSync — a
  crash mid-write can no longer truncate notifications.json
- DC-097/098 test seams migrated to the atomic path; new suite pins
  syscall discipline (order, wx flags, tmp naming, error cleanup,
  dir-fsync swallow)
- 121 suites / 2768 tests green
- Judge: GLM-5.3 cold read grade B/ship (deleg_23f7abad); polish items
  folded: dir-fsync added, header copy-count corrected. Remaining:
  migrate invite/user/share-store private _atomicWriteJSON copies as
  they are touched (queued).

URN: pending (recorded post-commit)
2026-08-22 23:28:36 -07:00
Hermes 8d42eae6ac feat(maintenance): one-shot PII redaction tool for pre-DC-095 log files (DC-098) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- scripts/redact-log-pii.js: atomic in-place email redaction reusing the
  canonical DC-095 masker (no second regex), dry-run/keep-raw modes,
  dir walk with skip-set, post-verify (exit 2 if raw addresses remain).
- src/utils/logging.js: export EMAIL_RE/maskEmailAddress/maskEmailsInString
  (additive; no logger behavior change).
- __tests__/redact-log-pii.test.js: 11 tests (shape, idempotence,
  clean-untouched, dry-run, keep-raw, skip-set, passthroughs, exit codes).
- Judge: GLM-5.3 cold read, round-1 A/ship (deleg_4e684a18), URN
  urn:ump:7y2q5upoht7xq2mhlum764y2h36qpsgufyqsgx4cbijfpmfpmrcq.
- Suite: 120/120 suites, 2751 tests green.
2026-08-22 22:24:38 -07:00
Hermes 3ccd00d1a1 fix(notifications): persist canonicalized config on load — legacy keys no longer stale on disk (DC-097) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
_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.
2026-08-22 21:52:19 -07:00
Hermes bb59595d6d fix(config): monitoring.public gate was dead 4 ways — live gate + schema + dedupe (DC-096) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
The documented hardening option for exposed deploys (monitoring: {public: false}
in config.json / MONITORING_PUBLIC env) never worked:
1. applyConfigFields dropped the monitoring key entirely
2. monitoring missing from config-schema KNOWN_KEYS (Unknown-key warnings)
3. MONITORING_PUBLIC frozen at mount + re-required singleton instead of injected dep
4. PUBLIC_ROUTES had unconditional duplicate entries defeating the gated spread

- site.js: copy monitoring through; drop dead write-only siteConfig.caName
- config-schema: +monitoring key, validateMonitoring (public must be boolean);
  remove never-written typo-footgun keys setupCompleted/setupMode (git -S: zero writers ever)
- middleware: live isMonitoringPublic() (env > config > default public), per-request
  gate via monitoring:true flag, remove duplicate unconditional route entries
- default unchanged (endpoints stay public — System Overview widget)

Tests: +11 (__tests__/monitoring-public-gate-dc096.test.js); suite 118/2735 green.
Judge: GLM-5.3 cold-read A (deleg_98aba845), URN urn:ump:zgvtskqljurasdagc632atnybb2p6gakk4cxcmjd3i4ivy7rvwta
2026-08-22 21:35:51 -07:00
Hermes 83ef84d218 feat(logging): central email PII masking across all log sinks [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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.
2026-08-22 20:49:09 -07:00
Hermes 97672f7e74 [glm-grade=A] fix(notifications): un-gate 7 dead emitters + repair legacy 4-arg send shape (DC-094)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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
2026-08-22 19:42:25 -07:00
Hermes 7e4ee60dcf [glm-grade=B] fix(notifications): repair UI/API contract drift — SMTP auth, event gate, test button (DC-092)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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.
2026-08-22 18:17:14 -07:00
Hermes 5e60c27f2b [grade=A] Harden server-managed license renewals
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 18:15:37 -07:00
Hermes ddbea0a040 [glm-grade=A] fix(config): teach schema KNOWN_KEYS the licenseBackup/_version writer keys (DC-091)
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.
2026-08-22 17:22:13 -07:00
Hermes 88f1d4a414 [glm-grade=A] fix(monitoring): DC-090 outage incidents follow displayed hysteresis status
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
2026-08-22 16:52:53 -07:00
Hermes 3ccf66754a [glm-grade=B] fix(monitoring): DC-088 removeService generation tombstones + incident closure
- 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
2026-08-22 15:55:26 -07:00
Hermes 628bbe32f6 [glm-grade=A] fix(monitoring): DC-086 round-2 — probe/config race hardening + env parse + incident compare
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.)
2026-08-22 14:23:25 -07:00
Hermes f8b99f9b5a DC-086 service-status flicker fix — asymmetric hysteresis
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.
2026-08-22 06:14:48 -07:00
Hermes 84edb035e3 [grade=B] feat(auth): onboard missing credentials into encrypted vault
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 05:41:38 -07:00
Hermes 7e68955e66 [glm-grade=A] fix(share): public-endpoint input hardening + rate limit (DC-083)
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).
2026-08-19 00:10:37 -07:00
Hermes 089f5d2902 [glm-grade=A] fix(update-manager): compose-prefixed image names probe <project>/<service> not library/<project>-<service> (DC-082)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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
2026-08-18 19:45:08 -07:00
Hermes a4e4b24732 fix(update-manager): force IPv4 + per-request timeout + transient-only retry on registry digest probes (DC-078) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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)
2026-08-18 17:35:59 -07:00
DashCaddy Polish 18ffd2e519 fix(nesting-guard): export dataDir from src/config/paths; harden fallback to platform-paths (DC-077) [glm-grade=B]
Pre-fix, every dashcaddy-api container startup logged:
  [nesting-guard] Skipped: The "path" argument must be of type string. Received undefined
because src/utilities/nesting-guard.js does require('../config/paths') and
calls paths.dataDir — but src/config/paths.js imported platformPaths and
only re-exported its specific files (SERVICES_FILE, CONFIG_FILE, etc);
dataDir was never re-exported, so paths.dataDir was undefined.

Result: path.join(undefined, 'data') threw TypeError, the outer try/catch
swallowed it, and the entire nesting-guard became a silent no-op. The
cleanup that prevents recursive data/data/data/... directory duplicates
never ran on any startup. Bug class is 'silent functional no-op' (same
family as DC-056 AggregateError visibility).

(1) src/config/paths.js (+11): re-export dataDir as
SERVICES_DIR-derived (with platformPaths.dataDir fallback). dataDir is
the dirname of SERVICES_FILE in container (env override wins), which
equals /app/data — same value platform-paths.dataDir computes for the
default config. Either path is fine; SERVICES_DIR is preferred because it
respects env-override.

(2) src/utilities/nesting-guard.js (+13/-2): defensive fallback to
require('../../platform-paths').dataDir if paths.dataDir is missing
(any future export-shape drift or older caller). Explicit skip-warn
instead of silent catch when both paths fail.

(3) __tests__/nesting-guard.test.js (NEW, 112 lines, 4/4 passing):
isolates module cache per test, exercises (a) cleanup when nested
data/data exists, (b) no-op when clean, (c) dataDir export contract,
(d) dataDir === dirname(SERVICES_FILE) under env override. No jest.doMock
leaks across tests (verified via 4-call probe sequence).

Verified: 4/4 tests passing. Full repo suite: 100/104 suites / 2335/2335
tests passing (4 pre-existing failures in __tests__/billing/* are
unrelated module-resolution issues in src/billing/invoice.js, confirmed
unaffected by this change via stash+rerun).

GLM-5.3 round 1: B (ship, one polish nit — trailing newline on test
file, folded in same commit per multi-round-fix-first protocol).

Deploy plan: container rebuild + atomic swap via /opt/dashcaddy/start.sh
on DNS2; live-verify status.sami=200, dashcaddy-api=Up+healthy, and
absence of [nesting-guard] Skipped log line in container logs.
2026-08-18 17:07:13 -07:00
DashCaddy Polish Loop 2fef1c47e5 fix(ca): gate per-service cert/key download behind TOTP+admin scope; require explicit PFX password; add rate limit (DC-076) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-18 16:40:28 -07:00
DashCaddy Polish Loop 270e8d57e3 fix(sites): SSRF hardening — validate upstream + externalUrl reject private/reserved hosts (DC-074) [glm-grade=A]
Pre-fix, an authenticated dashboard operator could call:
  POST /api/v1/site         {domain:"evil.example.com", upstream:"10.0.0.1:80"}
  POST /api/v1/site/external {subdomain:"x", externalUrl:"http://192.168.1.5"}
and end up with a Caddy site block that proxies PUBLIC traffic at
evil.example.com to an INTERNAL host. Caddy runs on DNS2 (same
network as the targets), so the SSRF lands.

The pre-fix /site upstream regex /^[a-z0-9.-]+:\d{1,5}$/i only
checked charset — it happily accepted 192.168.1.1:80 and
169.254.169.254:80 (AWS metadata IP). /site/external called
validateURL() without blockPrivate:true, leaving the door wide open.

(1) New helper validateUpstream() in fleet-validation.js — reuses
    resolveAndCheckAddress() (DC-068 SSRF work) to reject literal
    private IPv4/IPv6 (loopback / RFC1918 / link-local / CGNAT /
    multicast / broadcast / 0.0.0.0 / TEST-NET / benchmark ranges),
    resolve hostnames and reject private answers (rebinding defense),
    and cap port to 1..65535. Opt-in via SITES_ALLOW_PRIVATE_UPSTREAMS=true.

(2) /site calls validateUpstream() BEFORE caddy.modify() — gate
    happens before any state mutation. Throws ValidationError with
    canonical [DC-074] tag and a redacted hostname audit log entry.

(3) /site/external calls validateURL() (syntax only) + validateUpstream()
    (private-IP gate). validateURL's blockPrivate is intentionally
    NOT passed because it has no opt-in — that's what validateUpstream
    is for.

(4) Tests (__tests__/routes/sites-dc074.routes.test.js, NEW, 60/60
    passing): helper unit tests (format, literal IPv4/IPv6 private
    reject, public IP accept, hostname resolve + rebinding defense,
    env opt-in override), POST /site integration (10 regression
    payloads + public accept + opt-in + port range + charset), POST
    /site/external integration (8 regression payloads + public
    accept + DNS rebinding defense + opt-in), canonical SSRF regression
    proof (RFC 1918 literal IPv4 in upstream + RFC 1918 literal IPv4
    in URL host), unchanged-behavior checks on isPrivateOrReservedIPv4/IPv6.

Full repo suite: 2402/2402 tests in 102 suites (zero regressions).
GLM-5.3 stand-in judge round 1 (deleg_384b9f53, 41.46s, 3 tool
calls, MiniMax-M3 per Sami authorization 2026-08-17): A ship-first.

Refs: codex-as-judge SKILL.md 'Stand-in fallback chain'. Verdict
record: /root/dashcaddy-polish/.ump-verdicts/2026-08-18T22-35-00Z-dc-074-round-1-A.json
2026-08-18 15:31:07 -07:00