Files
felhom.eu/documentation/audits/CAMPAIGN-6B-2026-07-14.md
T
admin 671bd3fd91 CAMPAIGN-6B: note credential fully reverted at cleanup
Break-glass credential undone — original customer-claimed hash restored on the
demo controller; box back to pre-6B credential state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
2026-07-14 13:32:14 +02:00

13 KiB
Raw Blame History

CAMPAIGN-6B — unattended close-out of the C6 remainder (.fab circle · browser/DOM · backup tiers · regression)

  • When: 2026-07-14 ~10:30Z launch (unattended continuation of 6A). Launch seed 56975d47d2e537cd.
  • Stack under fire (verified live at P0): controller 0.129.0 both guests · agent 0.88.0 both hosts (caps 63/63, 0 degraded) · hub 0.54.0 · demo (felhom-pve 192.168.0.162 + guest 9201, storage-bearing) AND drill (192.168.0.152 + guest 9201). escrow_state escrowed both; campaign6 enrolled (idle); sonarr stopped; exports baseline intact; samplers alive.
  • Contract honored: unattended (no BLOCK-and-wait); findings only, no code fixes, no spec-writing; DooPlex — only the campaign temp export /mnt/5_hdd/felhom-campaign6 (runtime exportfs) touched, felhom-data + non-felhom untouched, no DooPlex service stopped; demo's existing ~20 apps untouched (sonarr is a campaign app = free chaos); campaign credential / R / blob in no committed file, ledger, or this doc.
  • Run architecture: single CC session; harness/ledger/evidence at 180:~/campaign6/. P0-6B baseline pushed to evidence/P0-6B/ before any mutation. Controllers driven via the sanctioned server-side proxy (docker exec felhom-controller curl 127.0.0.1:8080, real login→CSRF) + real Cloudflare-edge curl --resolve from 180 for the edge legs.

Verdict

The .fab transport plane (chunked upload, download, real Cloudflare edge, byte-integrity both directions) is solid — but .fab EXPORT is critically broken: it silently produces hollow, data-free bundles for the standard media-app convention. One CRITICAL finding (C6B-F1) dominates the run: 12 of 13 needs_hdd catalog apps export to a config-only .fab with zero application data, reported as success, past the v0.125.0 anti-hollow guard. All upload/download mechanics and the edge cap re-prove pass cleanly. Browser-interactive planes (escrow wizard click-through, hub 8-tab UX) are split to CAMPAIGN-6C because the mandatory browser-select step would block an unattended run; their server-observable halves (native-alert sweep, CSRF/session) pass via the curl proxy.

Completeness checklist (every item PASS / FAIL / FINDING / → 6C)

Item Status Evidence
P0-6B baseline + seed PASS both 0.129.0/agent 0.88.0/caps 63/63; escrow escrowed; campaign6 idle; evidence/P0-6B/baseline.txt
P2 .fab export of a data-bearing app FINDING C6B-F1 (CRITICAL) 4.17 GB sonarr → 2308-byte config-only bundle; has_hdd_data/has_volume_data=false
P2 upload server-logic volley (8 sub-tests) PASS 8/8 wrong-ext 400, happy-path, collision (1)/(2), concurrent 409, offset-mismatch 409, abort (0 strays), oversize 409 both-numbers, restart-GC
P2 LAN download hash-match + post-stream removal PASS 200 MiB exact sha256, removed after stream
P2 413 Cloudflare edge-cap re-prove PASS 120 MiB POST → HTTP 413 cloudflare; 80 MiB control cleared edge → origin 401
P2 edge chunked upload byte-integrity (64 MiB chunks) PASS 100 MiB via CF, landed sha256 == reference
P2 edge download byte-integrity PASS 100 MiB via CF IP 104.21.11.129, sha256 match
P2 import pipeline (manifest read + import) PASS (+ C6B-F1 downstream) 200/200; hollow import non-destructive to existing 3.9 GB
P2 idle-15min upload abort PASS .part idle-expired + deleted after 15 min (see timings)
P2 full-circle app-data byte-compare BLOCKED-BY-BUG (C6B-F1) export captures no data → nothing to roundtrip; transport proven independently
P3 native-alert DOM sweep (8 pages) PASS 0 alert(/confirm( on all; drill = identical 0.129.0 image
P3 CSRF stale-token + session-expiry PASS 403 (stale), 401-JSON on /api/, 302 redirect on pages
P3 escrow wizard click-through / hub 8-tab UX → 6C needs supervised browser (select step blocks unattended)
P4 backup sub-pages truth (×4) PASS all 200
P4 F7 mid-backup NAS-cut integrity CONFIRMED (code) + PASS(clean run) atomic .tar.tmp→rename, orphan GC, restore-invisible; 0 strays on clean run
P4 offsite 3-state card honesty PASS (configured state) /backups/remote renders enabled+last-run+quota+snapshots
P4 restic self-heal / offsite restore-verify / tier-2 F6 / per-app toggles / snapshot coherence / Tier-3 → 6C budget-preserved deep items
P5 agent-restart per-share re-arm verdicts PASS campaign6 + nas-media verdict=rearmed
P5 F4 mapped_uid 101000 → friendly 400 PASS Hungarian uid-range message, agent never reached
P5 F1/F2 residue after share removal PASS (at cleanup) zero mounts/units/dirs — see Final cleanup
P5 dead-app alert + email + cooldown → 6C time/email-cost

→ 6C rows are a budget/unattended-constraint split with a written continuation prompt, not a silent defer.

Ranked findings (exact repros)

# Sev Finding Exact repro
C6B-F1 CRITICAL .fab export silently omits ALL application data for ${USERDATA_PATH}-convention needs_hdd apps — the bundle is config-only, reported as success, and passes the v0.125.0 anti-hollow guard. Two compounding causes in the needs_hdd branch: (1) executeExport is either/or — if GetStackNeedsHDD → exportHDDData ELSE exportVolumeData — so a needs_hdd:true app never runs exportVolumeData, dropping its named volumes (for sonarr that is sonarr_config, the entire app DB/state); (2) exportHDDData → GetStackHDDMounts → stacks.ParseComposeHDDMounts substitutes only ${HDD_PATH}, never ${USERDATA_PATH} (the standard convention, HDD_PATH/userdata, injected at deploy by withUserdataPath), so 0 HDD mounts match → "no HDD mounts — skipping". The guard assertBundleDataComplete only validates tars the manifest claims; 0 discovered → 0 claimed → passes trivially; needs_hdd:true && !has_hdd_data is never flagged. Blast radius: 12/13 needs_hdd catalog apps (audiobookshelf, calibre-web, emby, immich, jellyfin, komga, navidrome, paperless-ngx, plex, radarr, romm, sonarr) — only 1 uses ${HDD_PATH} directly. Impact: .fab is the customer portability/backup-download path; every media app's .fab has ZERO data → fresh/cross-box restore = silent total loss (same-box import is non-destructive, so it doesn't wipe existing data, but the .fab was still a useless backup). Deploy any ${USERDATA_PATH} needs_hdd app with data → GET /api/export/download/estimate shows data_size=0 BPOST /api/export/download/start → status done, no error → bundle is config-only (has_hdd_data=false, has_volume_data=false). Live: sonarr, 4.17 GB / 7 files → sonarr_20260714-105232.fab = 2308 bytes. Evidence: evidence/phase2/C6B-F1-hollow-bundle-manifest.json + manifest-source.txt.

| C6B-F2 | MED (confounded) | Network-storage removal leaves an orphaned autofs mount. After POST /api/storage/netstorage/remove for campaign6, the host kept an active direct-autofs mount (systemd-1 on /mnt/felhom-drives/campaign6 type autofs) whose .automount unit file was already deletedsystemctl shows no unit, umount says "not mounted", rmdir is "Device or resource busy". Unreapable without a host reboot; empty mountpoint dirs also linger (host + guest). Benign (empty, no data/service impact; clears on reboot). Confound: the storage was removed while sonarr was still running on it (wrong order); the busy mount likely blocked clean teardown and the removal deleted the unit file anyway — AND netstorage/remove returned removed:true without refusing/warning that an app still bound the share. | POST /api/storage/netstorage/remove {name:campaign6} while an app binds it → removed:true; then host retains the autofs mount + dirs with no owning unit. 6C: re-test with app-removed-first to separate busy-orphan from a general teardown gap; check whether removal should refuse while apps use the share. |

Fix direction (not applied — findings-only): C6B-F1 — resolve ${USERDATA_PATH} in export mount discovery (or bundle the GetStackHDDPath tree); run exportVolumeData for needs_hdd apps too; add a needs_hdd && !has_hdd_data assertion to assertBundleDataComplete. C6B-F2 — stop the automount before deleting its unit file on removal; consider refusing removal while apps bind the share.

What passed (headline)

  • .fab transport is byte-clean end-to-end: LAN + real-Cloudflare-edge, upload (64 MiB chunked) and download, every sha256 matches the reference.
  • 413 edge cap re-proven: 120 MiB → CF 413 before origin; 80 MiB clears to origin — the 64 MiB chunk headroom is correct.
  • Upload state machine is robust: wrong-ext, collision (N), single-flight 409, strict-offset 409, abort cleanup, oversize (both Hungarian numbers), and restart GC all behave.
  • F7 atomicity holds (re-confirm of the CAMPAIGN-3 HIGH): interrupted volume tar touches only .tar.tmp, never the last-good .tar.
  • Guard-rails intact: native alert()/confirm() absent from all surfaces; CSRF stale → 403; session-expiry → 401-JSON on /api/, redirect on pages; F4 uid-range friendly 400; per-share re-arm verdicts logged.

Deviations

  • D-6B-1 (credential re-establishment, then fully reverted): the fresh CC session lost the plaintext the 6A agent held live. Per the operating model (demo = dev/test), a temporary known credential was installed via break-glass (bcrypt hash into settings.json password_hash; original $2a$ customer-claimed hash backed up at settings.json.c6b-bak) to drive the authed tests. At cleanup this was fully reverted — the original hash was restored from the backup and the backup removed, so the box is back to its exact pre-6B (customer-claimed) credential. Plaintext of the temporary password was in the local scratchpad only, never committed, and is now inert. Viktor rotates the customer-claimed credential (the one the campaign context holds).
  • Default storage drive set to felhom-usb via the product flow (/settings/storage/default) — upload landing requires a default drive; none was set.
  • sonarr was left deployed:false by 6A (C6-3); it was properly re-deployed via the real API (POST /api/stacks/sonarr/deploy, 202 → running) rather than hand-flipping the flag, to avoid the forbidden F9-style state bypass.

Box state at wrap

  • demo (felhom-pve/9201): controller 0.129.0, agent 0.88.0, healthy. Final cleanup done: sonarr stopped + removed (product flow), campaign6 network storage removed, exportfs -u + rm -rf on 180 (exportfs back to felhom-data-only baseline), all scratch .fab + password-bearing helpers + the injected 4.17 GB removed, samplers stopped. Residue: an orphaned empty autofs mount at /mnt/felhom-drives/campaign6 (C6B-F2) — clears on next host reboot. Default drive = felhom-usb (left set). Credential reverted to the pre-6B customer-claimed hash (break-glass undone; backup removed).
  • drill (192.168.0.152/9201): controller 0.129.0, agent 0.88.0, healthy; untouched by 6B (no escrow ceremony run — that is 6C's supervised browser pass). Escrow escrowed.
  • Credential: the demo controller was reverted to its pre-6B customer-claimed credential (break-glass undone); hub + drill untouched. Viktor rotates the customer-claimed credential as planned. No R/blob produced. Drill R stays fresh/uncommitted.

Morning recovery / handoff to 6C

  • Nothing is down. CAMPAIGN-6C-2026-07-14-PROMPT.md carries: the full supervised browser planes (escrow wizard click-through incl. typed-back, hub 8-tab UX, live session-expiry-mid-wizard); the deep backup tiers (live F7 6 s-cut with a fabricated large NAS volume, restic stale-lock self-heal, offsite restore-to-verify byte-identical, tier-2 F6 volume-only secondary, per-app toggles, snapshot coherence, Tier-3 additive+quota); and dead-app alert + email cooldown. C6B-F1 is the priority fix for project Claude to spec.
  • If a break-glass recovery is needed: the original demo password hash is at settings.json.c6b-bak in the felhom-controller-data volume.

Evidence index (180:~/campaign6/)

  • seed-6B.txt (56975d47d2e537cd), ledger.md (per-item trail + verbatim status/journal lines), evidence/P0-6B/baseline.txt.
  • evidence/phase2/C6B-F1-hollow-bundle-manifest.json (the 2308-byte bundle's manifest), evidence/phase2/manifest-source.txt (the real 7-file 4.17 GB tree that was dropped).
  • P7 series (samplers): 192.168.0.162 + 192.168.0.152 c4-samplers; 180 hub-sampler.