468bc001069005807a76598c25548ed2dda0ee33
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
65a4d825fb |
fix(dns): container DNS fallback must serve internal .sami TLD — replace 8.8.8.8 with DNS1 Technitium secondary (DC-121)
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) |
||
|
|
1744d1c86e |
fix(security): caddy-event self-noise conjunction filter for host curl probes (DC-118) [glm-grade=A]
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 |
||
|
|
65457ff8e0 |
feat(security): activate caddy security-event pipeline — bounded first-start replay, self-noise filter, host fidelity (DC-113) [glm-grade=A]
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 |
||
|
|
3a74cc423a |
[glm-grade=B] feat(monitoring): host journald log viewer (DC-055)
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
|
||
|
|
901df8608b |
[glm-grade=B] fix(monitoring): restore dead-detection for verified loopback upstreams (DC-054)
DC-053 follow-ups (queue item 2b). Three small fixes to the caddy-upstream-watcher: 1. verifiedViaBridge flag: a loopback upstream whose PRIOR probe succeeded via host-gateway proves the bridge CAN reach the host. A later failed probe is then near-conclusive evidence the upstream itself went dead. The DC-053 code unconditionally marked loopback failures as unverifiable, throwing away this signal. Now: track verifiedViaBridge per-upstream and treat verified-then-failed as down (count failures, open incident after DEAD_AFTER_MS=5min). 2. IN_CONTAINER=false kill-switch test (B-grade polish, folded into same commit per conjoint-commit anti-pattern). 3. git.sami intermittent ENOTFOUND (~2/h in ssl-monitor TLS handshake): pin git.sami -> 100.121.150.22 (DNS2 Tailscale) in container /etc/hosts via --add-host in start.sh. Existing comment explicitly forbids pinning to DNS3/100.81.59.99 (no HTTPS listener there); DNS2/100.121.150.22 is correct (Caddy serves git.sami on DNS2:443 and routes to DNS3:3030 internally). GLM-5.3 judge round 1 (208s, 6 tool calls, on-disk verified): grade B, all 25 tests green, no blocking issues, 3 LOW polish suggestions. Folded two actionable LOWs (persistence + JSDoc) into this commit: - verifiedViaBridge now persisted in _saveState/_restoreUpstreamStates so a known-good loopback upstream stays labeled across container restarts (1-tick blip becomes 0-tick). - snapshot() gained JSDoc describing the verifiedViaBridge semantic for dashboard consumers. Third LOW (long-term: prefer host-side liveness signal from Caddy) is a roadmap note, not actionable now. Tests: 1921/1921 (was 1910; +11 net: 6 new for items 2b-a/2b-b/persistence + 5 previously-skipped baseline). Full suite 86/86 green. |
||
|
|
71e04d0a86 |
[glm-grade=B] fix(monitoring): remap loopback upstream probes to host gateway (DC-053)
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.
|
||
|
|
d79d19b769 |
chore(start): mount /etc/caddy/sites into container for upstream watcher (DC-049 fixup)
The DC-049 dead-upstream watcher reads reverse_proxy host directives from /etc/caddy/sites/*. Bind-mount the directory into the container so the in-container watcher can see what the host's Caddy is configured to proxy. Without this mount the watcher would see zero sites and silently no-op. |
||
|
|
503de258b8 |
[grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work
Committed by Hermes autonomous QA sprint 2026-08-13. These files were modified during the Aug 12 sprint but never committed. |
||
|
|
56f1a001f2 |
start.sh auto-sync dashboard bundle into /var/www/dashcaddy-status/
After every rebuild the freshly-baked dashboard bundle lives in /opt/dashcaddy/status/dist/ + sw.js. DNS2 also serves files from /var/www/dashcaddy-status/dist/ + sw.js (the original Windows-installer mirror path). Without an explicit copy step between build and start.sh, the served bundle stays on whatever hash was there before, while the API responds with new code. That mismatch is what shows up in the dashboard as "version unavailable" + "no data" widgets — saw it in the deploy that followed DC-046/047 (fixed by a manual cp this time, never again). Sync block runs before docker run: cp /opt/dashcaddy/status/dist/*.js /var/www/dashcaddy-status/dist/ cp /opt/dashcaddy/status/sw.js /var/www/dashcaddy-status/ cp /opt/dashcaddy/status/index.html /var/www/dashcaddy-status/ All guarded so set -e doesn't kill the container start on a single per-file failure (e.g. read-only mount, missing dir). Missing source dir is a WARN + no-op rather than a fatal — fresh installs without status/dist/ don't get a stale-bundle problem, just a log line. Test: scripts/test-start-sh-sync.sh — 7 assertions across 4 cases (fresh-copy, missing-source, idempotent-re-sync, set-e-survives-permission- denied). All pass. |
||
|
|
894e091335 |
DC-050 harden dataDir + add image-layer migration
Three-part fix for the silent data-loss failure mode that survives DC-039:
If SERVICES_FILE env was unset, platformPaths.dataDir resolved to /etc/dashcaddy
(image-layer path), and audit/license/error logs would silently land there and
vanish on every container recreate.
1. platform-paths.assertSafe({mode:'production'}) — throws FATAL on forbidden
zones (/app/src,routes,scripts,utils,managers,security + /etc/* + /usr + /var).
Bypassed with SKIP_DATA_DIR_GUARD=1.
2. server.js calls assertSafe() before any runtime work.
3. start.sh one-time migration: scans 6 known image-layer zombie paths,
copies non-empty content to bind mount with 'migrated-' prefix,
gated by sentinel file. Survives set -e per-file failures.
19/19 platform-paths tests + 5/5 shell migration tests.
Suite: 1066/1067 (1 pre-existing public-routes-drift failure from in-flight
auth refactor, untouched by this commit).
Verified live on DNS2: live audit log at /app/data/audit-log.json (315KB,
active) is unaffected; vestigial 2-byte /app/src/security/audit-log.json +
140KB /app/src/utils/error.log (pre-DC-039 era) will be recovered on next
container recreate.
|
||
|
|
0f04bb3638 |
DC-044: mount Sami CA + fix ca.sami /etc/hosts in DashCaddy container
Two related fixes from the dashboard 11/15 false-negatives: 1. The Sami Home Network CA cert (/etc/ssl/sami-ca/root.crt) was not mounted into the container, so the health-checker's HTTPS probe to *.sami hosts failed with "certificate verify failed". Added a bind mount + CA_CERT_PATH env var so the app's httpsAgent picks it up (verified at startup: "HTTPS agent configured with CA certificate"). 2. The --add-host=ca.sami:127.0.0.1 line pinned ca.sami to the container's loopback, but nothing listens on 443 inside the container. Probe failed with ECONNREFUSED 127.0.0.1:443. Removed the override so ca.sami resolves via DNS to 100.121.150.22 (Caddy on DNS2) and the probe reaches the real service. After both fixes: 15/15 services healthy, 0 429s on the health checker, caddy.ok=true on /health/ready. |
||
|
|
d04238621f |
DC-042: implement real Tailscale manager — replace null stub
The previous getTailscaleStatus() in src/app.js was a hard-coded `return null` stub with a TODO saying it would be populated by context. The context had a tailscale.* namespace declared with null function stubs (routes/context.js:71), but nothing ever set them to real functions. routes/tailscale.js has been calling ctx.tailscale.getStatus() / getLocalIP() / isTailscaleIP() and getting undefined back, silently returning empty device lists. The tailscaleAuthMiddleware's allowedTailnet check (DC-121, device-not-in-tailnet 403) was dead code for the same reason. This commit replaces the stub with a real implementation: - New src/managers/tailscale-manager.js shells out to the host's `tailscale status --json` (cached 5 minutes), parses the result, and exposes getStatus / getLocalIP / getSummary / getDevices / isTailscaleIP / invalidateCache / getAccessToken (stub) / startSyncTimer / stopSyncTimer / syncAPI (stub). All failure modes (CLI missing, tailscaled down, malformed JSON, EACCES) are handled gracefully — return null with no cache poisoning. - src/context/index.js now wires the manager into ctx.tailscale.* so routes/tailscale.js and middleware.js's allowedTailnet gate get the real functions. - src/app.js:189 getTailscaleStatus() now delegates to the manager instead of returning null. - The duplicate isTailscaleIP() in src/app.js:179 (no malformed-input guards) is removed in favor of the canonical version in src/utilities/network-detector.js (DC-031) which the manager also uses. - start.sh now bind-mounts /usr/bin/tailscale (statically linked Go binary — works under Alpine libc) and /var/run/tailscale/ into the container, read-only. Lets the container invoke the CLI without needing its own tailscale install. - 41 new unit tests in __tests__/tailscale-manager.test.js cover: CLI success/missing/daemon-down/malformed-JSON paths, 5-min cache hit/miss, 1-hour installed-cache hit/miss, getLocalIP IPv4/IPv6/missing-choices, getSummary shape, getDevices shape with full + minimal peer fields, startSyncTimer/stopSyncTimer interval + idempotency, TAILSCALE_BIN env override. Total: 1138 tests pass (was 1097, +41 new), 0 new ESLint warnings. What this unlocks: - /api/v1/tailscale/status → real installed/connected/hostname/ip/ peerCount/onlinePeerCount summary instead of empty - /api/v1/tailscale/devices → real device list (was returning []) - /api/v1/tailscale/check-connection → works (uses real isTailscaleIP) - tailscaleAuthMiddleware allowedTailnet check (DC-121) is no longer dead code — a request from a Tailscale IP not in the allowed tailnet now actually gets 403 instead of being silently allowed. |
||
|
|
95f558c49f |
DC-030: bake /etc/hosts overrides into start.sh (fix git.sami resolution in container)
The container's health-checker runs against Caddy via /etc/hosts resolution. The node:20-alpine base image has no entries for *.sami, so without explicit --add-host flags every *.sami probe resolves via the configured DNS server (100.121.150.22 Technitium or 8.8.8.8) — both of which DO resolve *.sami but return the WAN/Tailscale IP. That works for most services because Caddy on DNS2:443 handles them. BUT: a previous container run passed --add-host=git.sami:100.81.59.99 (DNS3's Tailscale IP). DNS3 does NOT serve HTTPS on 443 — Gitea listens on :3030 only. So git.sami health checks inside the container hit DNS3:443, get ECONNREFUSED, and the dashboard shows git.sami as down even though Caddy on DNS2:443 correctly routes git.sami → 100.81.59.99:3030. Fix: inject the correct --add-host flags from start.sh (the source of truth for container setup) so future recreates get consistent resolution. git.sami is intentionally left OUT — Caddy on DNS2:443 is the only correct ingress for git.sami traffic. Also documents the rationale so the next person doesn't reintroduce the git.sami override by accident. Live verified: - container /etc/hosts has all needed entries except git.sami - curl https://git.sami/ from inside container → 200 (via Caddy on :443) - curl https://sync.sami/ from inside container → 302 (upstream redirect) - curl https://router.sami/ from inside container → 302 (upstream redirect) |
||
|
|
fef7e07b49 |
DC-026/027/028: close 3 more auth security holes + rate limit /auth/* + audit credential exposures
[DC-026] routes/auth/sso-gate.js — fix sessionDuration='never' bypass Both /auth/gate/:serviceId and /auth/app-token/:serviceId had a session check gated on `sessionDuration !== 'never'`. An admin setting TOTP to never-expire accidentally created an authentication-free path to credential injection (Basic Auth, X-Api-Key, Plex/Prowlarr tokens). Patched: session required whenever TOTP is enabled, period. Added 8 regression tests. [DC-027] src/utilities/middleware.js — rate limit /auth/* New authLimiter (20 req / 15 min) on /auth/keys, /auth/jwt, /auth/gate, /auth/app-token. These endpoints expose credentials and were unmetered. Without this, an attacker with a guessed session cookie could burn through every credential-touching endpoint. Added 5 tests. [DC-028] src/security/audit-logger.js — log credential exposures /auth/gate and /auth/app-token were in SKIP_PATHS, silently dropping every credential-exposure event from the audit log. Combined with the GET-skip rule, NONE of these events were being recorded. Now logged with named actions: auth.credential-injection, auth.app-token-issue, auth.api-key-generate, auth.api-key-revoke, auth.jwt-mint. Added 9 tests. [start.sh] Disable in-container self-updater DASHCADDY_UPDATE_ENABLED=false. Without this, the container kept writing trigger.json every 30 min and clobbered my in-progress host edits. The path unit on the host is still active for manual triggers, but the container won't auto-update itself — only when an admin clicks the update button or a new release is manually published. [package.json] Bump to 1.14.7 Test results: 1066/1066 passing across 39 suites (added 22 new tests). |
||
|
|
a5f51e4a0c |
DC-023: operational fixes — DNS, rate limiter, version sync
- VERSION: bump from 1.14.4 to 1.14.6 to match package.json (HEAD had stale value) - middleware.js: apply existing totpLimiter (10/15min) to /totp/setup endpoint (was previously unmetered, allowing secret enumeration) - dashcaddy-update.sh: hook post-deploy-patches.sh into the update flow so the container can survive transitions between broken → fixed tarballs - start.sh: add --add-host flags for get.dashcaddy.net and get2.dashcaddy.net so the container can resolve the release server (was failing with ENOTFOUND) |
||
|
|
44af47d344 | feat: add Sami Files logPath to template + mount in start.sh |