Data-loss hunt: R-1191 (P1, proven live on 9202) — a .fab import makes a family-only app public (stranger 401 -> 200); R-1187 narrowed; register 169 -> 170; STATUS P1 line
gates / gates (push) Successful in 12m52s
gates / gates (push) Successful in 12m52s
This commit is contained in:
1 parent
bffe2d9dea
commit
a57a8363df
9 files changed
+225
-4
No files matched your search
@@ -15,7 +15,7 @@
|
||||
- The hub mails alarms and recoveries, and keeps one whole-box copy per week on ep0; CC uses its own limited token, not your password (map F1, F11, G15).
|
||||
|
||||
## What is broken
|
||||
- **P1 (the data-loss hunt, 2026-10-20 — still running):** shared folders on two drives lose their second copy every night, and a dead drive's copy is deleted the same night (R-1175); a box rebuilt after „delete the old host" cannot reach the household's off-site history (R-1176).
|
||||
- **P1 (the data-loss hunt, 2026-10-20 — still running):** shared folders on two drives lose their second copy every night, and a dead drive's copy is deleted the same night (R-1175); a box rebuilt after „delete the old host" cannot reach the household's off-site history (R-1176); importing an app bundle makes a family-only app public (R-1191).
|
||||
- **P1:** about 12 homelab logins still use a password that was public, and the public Gitea repo is not decided (R-925, yours).
|
||||
- Two root-level weaknesses wait on your decisions D3 and D4 (R-985, R-982).
|
||||
- Tester 2 is off since 2026-10-04: old emergency password, no off-site check, no bootstrap (R-1139, R-1104, R-862).
|
||||
|
||||
@@ -24,7 +24,7 @@ Register: none of the candidates below is in OPEN-ITEMS or CLOSED-ITEMS (searche
|
||||
| R5 second-drive whole restore: files swapped, then the unit half refuses → app left STOPPED, text says „untouched" | **CONFIRMED** (unit test, production code: `C3-R5-whole-restore/` — 1 file restored + 1 replaced, app stopped, no start, message „az alkalmazás és az adatai érintetlenek") | defect | `tier2_whole.go:301-340`: stop + merge, then `RestoreFromRecoveryUnitAtWith` whose early refusals return before its own stop/start; `hu.json` `err.backup.unit_versions_mixed` „Az alkalmazás érintetlen" |
|
||||
| R6 `.fab` import into a stopped app merges volumes | PLAUSIBLE | defect | `appexport/restore.go:361-374` |
|
||||
| R7 `.fab` import has no guard / undo / record | PLAUSIBLE | defect | |
|
||||
| R8 `.fab` import drops the family gate | PLAUSIBLE | defect | `cmd/controller/main.go:3517-3530`, `stacks/family_gate.go:173-190` |
|
||||
| R8 `.fab` import drops the family gate | **CONFIRMED LIVE** on 9202 (`C3-R1187e-fab-family-gate/`: stranger 401 before, 200 after the import) → **R-1191 (P1)** | defect | `cmd/controller/main.go:3517-3530`, `stacks/family_gate.go:173-190` |
|
||||
| R9–R11, R13, R14 | PLAUSIBLE | defect / design (R13) | low |
|
||||
| R12 interrupted whole restore leaves `*.felhom-restoring`, every retry fails | CONFIRMED-CODE | defect | `tier2_whole.go:86-87` O_EXCL; removed only on an in-process error |
|
||||
| **D2** a missing / emptied app folder deletes its second-drive copy within one night | **CONFIRMED** for a MISSING folder (unit test, production code + real copy seam: `C3-D2-missing-leg/` — mirror of `photos` deleted, run „ok", the warning says only „nem kerültek a másodlagos mentésbe"); the EMPTIED case CONFIRMED-CODE (`rsyncMirror` = `rsync -a --delete`, `tier2.go:744-751`; the test seam does not delete) | defect-shaped corner of a choice: 07 §8.2 „data legs are NOT guarded" is about a leg LEAVING the class set; this is a declared leg that is MISSING, which the code itself logs as a gap (`tier2_capture.go:55-61`) and then reconciles away (`tier2.go:454`) | P1 for optional legs (no other tier holds them) |
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
10:48:52Z == 1 deploy glance
|
||||
10:48:52Z deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'}
|
||||
10:49:12Z state: running family_gate in app_config: True
|
||||
10:49:34Z [1 POSITIVE CONTROL after install] gate file: /opt/docker/stacks/traefik/dynamic/family-gate-glance.yml
|
||||
10:49:34Z [1 POSITIVE CONTROL after install] a stranger's GET https://r1187glance.enkisfelhom.hu/ -> 401
|
||||
10:49:34Z == 2 export to the data drive
|
||||
10:49:34Z export start -> 200 {'ok': True}
|
||||
10:49:34Z /api/export/status {"display_name": "Glance", "done": false, "error": "", "job_type": "export", "ok": true, "output_path": "", "output_size": "", "running": true, "stack_name": "glance", "steps": [{"label": "Előkészítés", "status": "running"}, {"label": "Konfiguráció mentése", "status": "pending"}, {"label": "Adatbázis mentése", "status": "pending"}, {"label": "Felhasználói adatok", "status": "pending"}, {"label": "
|
||||
10:49:37Z /api/export/status {"display_name": "Glance", "done": true, "error": "", "job_type": "export", "ok": true, "output_path": "/mnt/felhom-drives/scratch_hdd/exports/glance_20261020-104935.fab", "output_size": "5.6 KB", "running": false, "stack_name": "glance", "steps": [{"label": "Előkészítés", "status": "done"}, {"label": "Konfiguráció mentése", "status": "done"}, {"label": "Adatbázis mentése", "status": "done"}, {"la
|
||||
10:49:37Z bundles for glance: []
|
||||
10:49:37Z == 3 remove glance (keep nothing)
|
||||
10:50:09Z remove -> 200 {'ok': True, 'data': {'removed': 'glance', 'volumes_removed': ['glance_glance_config'], 'hdd_paths_removed': [], 'hdd_paths_preserved': [], 'hdd_note': 'Az alka
|
||||
10:50:21Z [3 after remove] gate file: /opt/docker/stacks/traefik/dynamic/family-gate-glance.yml
|
||||
10:50:21Z [3 after remove] a stranger's GET https://r1187glance.enkisfelhom.hu/ -> 404
|
||||
10:50:21Z == 4 import the bundle
|
||||
10:50:21Z import start -> 400 {'error': 'Missing path', 'ok': False}
|
||||
10:50:21Z /api/export/import/status {"display_name": "Glance", "done": true, "error": "", "job_type": "export", "ok": true, "output_path": "/mnt/felhom-drives/scratch_hdd/exports/glance_20261020-104935.fab", "output_size": "5.6 KB", "running": false, "stack_name": "glance", "steps": [{"label": "Előkészítés", "status": "done"}, {"label": "Konfiguráció mentése", "status": "done"}, {"label": "Adatbázis mentése", "status": "done"}, {"la
|
||||
10:53:42Z state after import: not_deployed family_gate in app_config: False app_config keys: []
|
||||
10:54:14Z [4 FAULT after import] gate file: (none)
|
||||
10:54:14Z [4 FAULT after import] a stranger's GET https://r1187glance.enkisfelhom.hu/ -> 404
|
||||
10:54:15Z controller log: 2026/10/20 10:48:52 manager.go:1686: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, SUBDOMAIN, DOMAIN, IMPORT_PATH] (8 app + 0 system)
|
||||
2026/10/20 10:48:58 manager.go:1686: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, SUBDOMAIN, DOMAIN, IMPORT_PATH] (8 app + 0 system)
|
||||
2026/10/20 10:49:34 family_gate.go:325: [DEBUG] [web] family gate glance: GET / without a pass — 401 (visitor 192.168.0.180)
|
||||
2026/10/20 10:49:35 manager.go:1686: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, DOMAIN, SUBDOMAIN, IMPORT_PATH] (8 app + 0 system)
|
||||
2026/10/20 10:49:35 manager.go:1686: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, SUBDOMAIN, DOMAIN, IMPORT_PATH] (8 app + 0 system)
|
||||
2026/10/20 10:49:37 manager.go:1686: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, DOMAIN, SUBDOMAIN, IMPORT_PATH] (8 app + 0 system)
|
||||
2026/10/20 10:49:39 manager.go:1686: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, SUBDOMAIN, DOMAIN, IMPORT_PATH] (8 app + 0 system)
|
||||
2026/10/20 10:49:43 manager.go:1686: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, DOMAIN, SUBDOMAIN, IMPORT_PATH] (8 app + 0 system)
|
||||
2026/10/20 10:50:09 manager.go:675: [DEBUG] [stacks] ScanStacks: found stack "recipe-importer" deployed=false composePath=/opt/docker/stacks/recipe-importer/docker-compose.yml
|
||||
2026/10/20 10:50:09 manager.go:675: [DEBUG] [stacks] ScanStacks: found stack "recipe-importer" deployed=false composePath=/opt/docker/stacks/recipe-importer/docker-compose.yml
|
||||
2026/10/20 10:50:21 auth.go:144: [DEBUG] [web] auth: valid session for POST /api/export/import
|
||||
2026/10/20 10:50:21 handler_export.go:328: [DEBUG] [web] apiImportStart: path="" hasPassword=false
|
||||
2026/10/20 10:50:29 family_gate.go:190: [INFO] [stacks] glance: removed the family-gate file of an app that is not installed
|
||||
2026/10/20 10:52:09 manager.go:675: [DEBUG] [stacks] ScanStacks: found stack "recipe-importer" deployed=false composePath=/opt/docker/stacks/recipe-importer/docker-compose.yml
|
||||
2026/10/20 10:54:09 manager.go:675: [DEBUG] [stacks] ScanStacks: found stack "recipe-importer" deployed=false composePath=/opt/docker/stacks/recipe-importer/docker-compose.yml
|
||||
exit 0
|
||||
@@ -0,0 +1,24 @@
|
||||
10:54:26Z == 4 import the bundle /mnt/felhom-drives/scratch_hdd/exports/glance_20261020-104935.fab
|
||||
10:54:26Z import start -> 200 {'ok': True}
|
||||
10:54:26Z /api/export/import/status {"display_name": "Glance", "done": false, "error": "", "job_type": "import", "ok": true, "output_path": "", "output_size": "", "running": true, "stack_name": "glance", "steps": [{"label": "Csomag megnyitása", "status": "done"}, {"label": "Alkalmazás előkészítése", "status": "done"}, {"label": "Konfiguráció visszaállítása", "status": "running"}, {"label": "Felhasználói adatok visszaállítása", "stat
|
||||
10:54:29Z /api/export/import/status {"display_name": "Glance", "done": false, "error": "", "job_type": "import", "ok": true, "output_path": "", "output_size": "", "running": true, "stack_name": "glance", "steps": [{"label": "Csomag megnyitása", "status": "done"}, {"label": "Alkalmazás előkészítése", "status": "done"}, {"label": "Konfiguráció visszaállítása", "status": "done"}, {"label": "Felhasználói adatok visszaállítása", "status"
|
||||
10:54:32Z /api/export/import/status {"display_name": "Glance", "done": true, "error": "", "job_type": "import", "ok": true, "output_path": "", "output_size": "", "running": false, "stack_name": "glance", "steps": [{"label": "Csomag megnyitása", "status": "done"}, {"label": "Alkalmazás előkészítése", "status": "done"}, {"label": "Konfiguráció visszaállítása", "status": "done"}, {"label": "Felhasználói adatok visszaállítása", "status"
|
||||
10:56:13Z state after import: running family_gate in app_config: False app_config keys: ['deployed', 'deployed_at', 'desired_state', 'env', 'installed_images', 'locked_fields']
|
||||
10:56:55Z [4 FAULT after import] gate file: (none)
|
||||
10:56:55Z [4 FAULT after import] a stranger's GET https://r1187glance.enkisfelhom.hu/ -> 200
|
||||
10:56:56Z controller log: 2026/10/20 10:54:33 manager.go:1814: [INFO] [stacks] glance glanceapp/glance:v0.8.5 running Up 3 seconds (health: starting)
|
||||
2026/10/20 10:54:39 healthprobe.go:187: [DEBUG] Health probe glance: HTTP GET :8080/ → 200 (1ms)
|
||||
2026/10/20 10:54:49 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 10s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:54:59 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 20s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:55:09 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 30s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:55:19 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 40s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:55:29 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 50s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:55:39 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 1m0s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:55:49 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 1m10s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:55:59 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 1m20s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:56:09 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 1m30s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:56:19 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 1m40s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:56:29 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 1m50s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:56:39 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 2m0s ago, effective interval 5m0s, healthy=true
|
||||
2026/10/20 10:56:49 healthprobe.go:64: [DEBUG] [stacks] RunHealthProbes: skipping glance — last check 2m10s ago, effective interval 5m0s, healthy=true
|
||||
exit 0
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
200
|
||||
{
|
||||
200
|
||||
{
|
||||
"ok": true,
|
||||
Exception in thread Thread-1 (pump):
|
||||
Traceback (most recent call last):
|
||||
File "/usr/lib/python3.13/threading.py", line 1043, in _bootstrap_inner
|
||||
self.run()
|
||||
~~~~~~~~^^
|
||||
File "/usr/lib/python3.13/threading.py", line 994, in run
|
||||
self._target(*self._args, **self._kwargs)
|
||||
~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/mnt/5_hdd/felhom.eu/git/cc-secrets/ccsec/cli.py", line 153, in pump
|
||||
dst.write(m.close()); dst.flush()
|
||||
~~~~~~~~~^^
|
||||
BrokenPipeError: [Errno 32] Broken pipe
|
||||
Exception ignored on flushing sys.stdout:
|
||||
BrokenPipeError: [Errno 32] Broken pipe
|
||||
removed '/mnt/felhom-drives/scratch_hdd/exports/glance_20261020-104935.fab'
|
||||
0
|
||||
0
|
||||
0
|
||||
stranger GET after teardown: 404
|
||||
@@ -0,0 +1,23 @@
|
||||
# C3 — proven live: a `.fab` export → import makes a family-only app public
|
||||
|
||||
Scratch guest 9202, controller 0.331.0 (the `.fab` import code — `cmd/controller/main.go:3517-3530`
|
||||
`SaveEncryptedAppConfig`, `stacks/family_gate.go:173-190` — read the same at 0.336.0, `7ca59e2`). Architecture: `09` §3
|
||||
decisions 63/64 (the family gate), 221 (Glance only behind it), `07` §6.6 („never open by default").
|
||||
|
||||
| UTC | act (the dashboard's own endpoints) | observed (a stranger's GET through traefik; the gate file on disk) |
|
||||
|---|---|---|
|
||||
| 10:48:52 | `POST /api/stacks/glance/deploy` | running, `app_config.family_gate` set |
|
||||
| 10:49:34 | **control** | gate file `traefik/dynamic/family-gate-glance.yml` present; stranger → **401** |
|
||||
| 10:49:35 | `POST /api/export/start` (stop ticked, no password) | `glance_20261020-104935.fab`, 5.6 KB |
|
||||
| 10:50:09 | stop + `POST /api/stacks/glance/remove` (data + backups) | removed; 10:50:29 „removed the family-gate file of an app that is not installed" |
|
||||
| 10:54:26 | `POST /api/export/import` (the household's „Importálás") | import done in 6 s; app running |
|
||||
| 10:56:13 | the app's record | `app_config` keys: deployed, deployed_at, desired_state, env, installed_images, locked_fields — **no family_gate** |
|
||||
| 10:56:55 | **fault** | gate file **none**; stranger → **200** (Glance answers anyone who reaches the address) |
|
||||
|
||||
The first run's bundle-list parse was a harness slip (`01-run.log`, step 4 sent an empty path → 400, nothing done);
|
||||
step 4 re-ran alone with the path the export's own status gave (`02-import.log`, `step4.py`). Teardown
|
||||
(`03-teardown.txt`): Glance removed through the product, the bundle deleted (own file), no container, volume or gate
|
||||
file left; the address answers 404.
|
||||
|
||||
On a household box the address is reachable from the internet through the tunnel. Family-gated apps in the catalog:
|
||||
Glance, Grimmory, MeTube (the last two hold the family's books and videos).
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""fab_gate_proof.py — C3 for R-1187 (e) on scratch 9202: does a `.fab` export → remove → import drop the family gate?
|
||||
|
||||
Glance has no login of its own; the catalog puts it ONLY behind the family gate (decision 221). Observables, each
|
||||
read from a DIFFERENT channel than the act: (1) the gate file `family-gate-glance.yml` on the guest's disk; (2) what a
|
||||
stranger's request to the app's host gets back from traefik (302 to the family page vs 200 from Glance).
|
||||
Acts are the dashboard's own endpoints: deploy, /api/export/start, stop+remove, /api/export/import.
|
||||
Env: CTL_BASE, CTL_DOMAIN, CTL_PW_ENV (tools/ctl.py).
|
||||
"""
|
||||
import json, os, subprocess, sys, time
|
||||
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/rehearsal-2026-10-20/tools")
|
||||
import ctl # noqa: E402
|
||||
|
||||
APP, SUB = "glance", "r1187glance"
|
||||
DOM = os.environ["CTL_DOMAIN"]
|
||||
|
||||
|
||||
def say(*a):
|
||||
print(time.strftime("%H:%M:%SZ", time.gmtime()), *a, flush=True)
|
||||
|
||||
|
||||
def guest(cmd):
|
||||
r = subprocess.run(["ssh", "demo-hp", f"export LC_ALL=C; pct exec 9202 -- bash -c {json.dumps(cmd)}"],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
return (r.stdout + r.stderr).strip()
|
||||
|
||||
|
||||
def observe(tag):
|
||||
say(f"[{tag}] gate file:", guest("find / -xdev -name 'family-gate-glance.yml' 2>/dev/null | head -3; true") or "(none)")
|
||||
out = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code} %{redirect_url}",
|
||||
"-H", f"Host: {SUB}.{DOM}", ctl.BASE + "/"], capture_output=True, text=True).stdout
|
||||
say(f"[{tag}] a stranger's GET https://{SUB}.{DOM}/ ->", out)
|
||||
|
||||
|
||||
def wait_stack(pred, n=60):
|
||||
for _ in range(n):
|
||||
code, d = ctl.call("GET", f"/api/stacks/{APP}")
|
||||
st = (d.get("data") or {}) if isinstance(d, dict) else {}
|
||||
if pred(st):
|
||||
return st
|
||||
time.sleep(5)
|
||||
return st
|
||||
|
||||
|
||||
def poll(path, n=120):
|
||||
last = None
|
||||
for _ in range(n):
|
||||
code, d = ctl.call("GET", path)
|
||||
dd = (d.get("data") or d) if isinstance(d, dict) else {}
|
||||
s = json.dumps(dd, ensure_ascii=False)[:400]
|
||||
if s != last:
|
||||
say(" ", path, s)
|
||||
last = s
|
||||
if isinstance(dd, dict) and dd.get("status") in ("completed", "failed", "done", "error") or (
|
||||
isinstance(dd, dict) and dd.get("running") is False and dd.get("status") not in ("running", "pending")):
|
||||
return dd
|
||||
time.sleep(3)
|
||||
return dd
|
||||
|
||||
|
||||
say("== 1 deploy glance")
|
||||
code, d = ctl.call("POST", f"/api/stacks/{APP}/deploy", {"values": {"DOMAIN": DOM, "SUBDOMAIN": SUB}})
|
||||
say("deploy ->", code, str(d)[:150])
|
||||
st = wait_stack(lambda s: s.get("deployed") and s.get("state") in ("running", "unhealthy", "degraded"))
|
||||
say("state:", st.get("state"), "family_gate in app_config:", bool((st.get("app_config") or {}).get("family_gate")))
|
||||
time.sleep(20)
|
||||
observe("1 POSITIVE CONTROL after install")
|
||||
|
||||
say("== 2 export to the data drive")
|
||||
drive = "/mnt/felhom-drives/scratch_hdd"
|
||||
code, d = ctl.call("POST", "/api/export/start", {"stack_name": APP, "dest_drive": drive, "password": "", "stop_app": True})
|
||||
say("export start ->", code, str(d)[:200])
|
||||
poll("/api/export/status")
|
||||
code, d = ctl.call("GET", "/api/export/bundles")
|
||||
bundles = [b for b in ((d.get("data") or {}).get("bundles") or d.get("data") or []) if isinstance(b, dict) and APP in json.dumps(b)]
|
||||
say("bundles for glance:", json.dumps(bundles, ensure_ascii=False)[:500])
|
||||
path = (bundles[-1].get("path") if bundles else None)
|
||||
|
||||
say("== 3 remove glance (keep nothing)")
|
||||
ctl.call("POST", f"/api/stacks/{APP}/stop")
|
||||
time.sleep(5)
|
||||
code, d = ctl.call("POST", f"/api/stacks/{APP}/remove", {"remove_hdd_data": True, "remove_backups": True})
|
||||
say("remove ->", code, str(d)[:160])
|
||||
time.sleep(10)
|
||||
observe("3 after remove")
|
||||
|
||||
say("== 4 import the bundle")
|
||||
code, d = ctl.call("POST", "/api/export/import", {"path": path, "password": ""})
|
||||
say("import start ->", code, str(d)[:200])
|
||||
poll("/api/export/import/status")
|
||||
st = wait_stack(lambda s: s.get("deployed") and s.get("state") in ("running", "unhealthy", "degraded"), n=40)
|
||||
say("state after import:", st.get("state"), "family_gate in app_config:", bool((st.get("app_config") or {}).get("family_gate")),
|
||||
"app_config keys:", sorted((st.get("app_config") or {}).keys()))
|
||||
time.sleep(30)
|
||||
observe("4 FAULT after import")
|
||||
say("controller log:", guest("docker logs --since 15m felhom-controller 2>&1 | grep -iE 'family|import' | grep -v 'GET /api' | tail -15"))
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Step 4 of fab_gate_proof.py re-run alone: the first run's bundle-list parse found nothing (harness slip); the
|
||||
export's own status gave the path."""
|
||||
import sys
|
||||
sys.argv = ["x"]
|
||||
exec(open("fab_gate_proof.py").read().split('say("== 1 deploy glance")')[0])
|
||||
path = "/mnt/felhom-drives/scratch_hdd/exports/glance_20261020-104935.fab"
|
||||
say("== 4 import the bundle", path)
|
||||
code, d = ctl.call("POST", "/api/export/import", {"path": path, "password": ""})
|
||||
say("import start ->", code, str(d)[:200])
|
||||
poll("/api/export/import/status")
|
||||
st = wait_stack(lambda s: s.get("deployed") and s.get("state") in ("running", "unhealthy", "degraded"), n=40)
|
||||
say("state after import:", st.get("state"), "family_gate in app_config:", bool((st.get("app_config") or {}).get("family_gate")),
|
||||
"app_config keys:", sorted((st.get("app_config") or {}).keys()))
|
||||
time.sleep(40)
|
||||
observe("4 FAULT after import")
|
||||
say("controller log:", guest("docker logs --since 6m felhom-controller 2>&1 | grep -iE 'family|import|glance' | grep -vE 'GET /api|ScanStacks|Env vars' | tail -15"))
|
||||
@@ -192,7 +192,7 @@ stopping line that lies.
|
||||
| **R-1184** | Backup & restore | P3 | **A cut-off shared-folder restore can be „placed" and reported restored while files are missing; the download is then deleted.** Readiness is only „the scratch folder is not empty" (`internal/backup/shares_restore.go:131-140`) — the R-358 class fixed for apps, not for shares; 30-min download window (`web/offbox_handlers.go:716`); fixed 2 GiB free-space gate that fails open (`:107`). CONFIRMED in code. Data-loss hunt 2026-10-20 (C1 reviewer 1, R3). | OPEN | — | Fix: a completion marker after restic succeeds, as for apps | CC |
|
||||
| **R-1185** | Backup & restore | P3 | **A database dump that fails its own content check has already replaced last night's good dump, and the second drive copies it an hour later.** `appbackup/dbdump.go:388-422`: size check, rename over the good file (`:399`), then `ValidateDump` (`:419`). Volume tars are promoted with no content check either (`backup.go:902`). Tier 1 is one point per app by design (07 §6.1); the ORDER is the defect. CONFIRMED in code. Data-loss hunt 2026-10-20 (D5). | OPEN | — | Fix: validate the temp file before the rename; keep the last good dump on a failed check | CC |
|
||||
| **R-1186** | Backup & restore | P3 | **The nightly off-site copy is skipped silently when another backup holds the flag, and the catch-up never re-runs it.** `offbox.go:981-996` returns nil with an INFO line on the nightly path; `withLeg` marks the leg ended anyway (`cmd/controller/main.go:1246`). The 05:30 proof and 06:00 integrity check take the same flag, so a backup time near them collides every night. Only the hub's 48 h `offsite_stale` notices. CONFIRMED in code. Data-loss hunt 2026-10-20 (T5). | OPEN | — | Fix: mark the leg NOT ended on a busy skip so the catch-up retries; say it on the backups page | CC |
|
||||
| **R-1187** | Backup & restore | P3 | **`.fab` export/import candidates from the data-loss hunt, not yet proven:** (a) an export silently drops a drive folder that holds a symlink (`appexport/fabplan.go:292`, `export.go:714`); (b) the import never sets file owners and drops symlinks (`restore.go:1157-1176`); (c) import into a STOPPED app merges into the existing volumes, and the default export (stop ticked) carries no SQL dump (`restore.go:361-374`, `export.go:262,366`, `appbackup/dbdump.go:142`); (d) no concurrency guard, no undo, no record (`restore.go:181-186`, `web/handler_export.go:312-353`); (e) the import drops the family gate, the sign-up lock and the version pin (`cmd/controller/main.go:3517-3530`, `stacks/family_gate.go:173-190`); (f) it trusts the bundle's HDD_PATH (`restore.go:589-631`); (g) the DB import runs psql without ON_ERROR_STOP and misses postgis (`restore.go:1052-1053, 928-937`). Read by reviewer 1; main session spot-checked (a), (e). Evidence `audits/dataloss-hunt-2026-10-20/C1-restore-rollback-candidates.md`. | VERIFY | — | Prove (e) and (c) first on 9202 with a throwaway app; split into rows only what reproduces | CC |
|
||||
| **R-1187** | Backup & restore | P3 | **`.fab` export/import candidates from the data-loss hunt, not yet proven:** (a) an export silently drops a drive folder that holds a symlink (`appexport/fabplan.go:292`, `export.go:714`); (b) the import never sets file owners and drops symlinks (`restore.go:1157-1176`); (c) import into a STOPPED app merges into the existing volumes, and the default export (stop ticked) carries no SQL dump (`restore.go:361-374`, `export.go:262,366`, `appbackup/dbdump.go:142`); (d) no concurrency guard, no undo, no record (`restore.go:181-186`, `web/handler_export.go:312-353`); (e) PROVEN and split out as R-1191 (the import drops the family gate); (f) it trusts the bundle's HDD_PATH (`restore.go:589-631`); (g) the DB import runs psql without ON_ERROR_STOP and misses postgis (`restore.go:1052-1053, 928-937`). Read by reviewer 1; main session spot-checked (a); (e) proven live → R-1191. Evidence `audits/dataloss-hunt-2026-10-20/C1-restore-rollback-candidates.md`. | VERIFY | — | Prove (c) first on 9202 with a throwaway app; split into rows only what reproduces | CC |
|
||||
| **R-1188** | Backup & restore | P3 | **Restore-path candidates from the data-loss hunt, not yet proven (one confirmed in code):** (a) a crash or power cut mid-restore restarts the app on a mixed state and never reads the undo copy (`appstop_marker.go:307`, `offbox_restore_undo.go:137`, `bootrecon/bootrecon.go:210-213`); (b) a failed `volume rm` is ignored before the untar (`restore.go:218`); (c) CONFIRMED in code: an interrupted second-drive whole restore leaves `<file>.felhom-restoring`, and every retry fails „file exists" (`tier2_whole.go:86-87`, O_EXCL); (d) the full off-site download has a 30-min cap per press and the household is not told to retry (`web/offbox_handlers.go:455`); (e) place and full restore re-read the LATEST snapshot instead of the downloaded one (`offbox_restore.go:675-679`). Evidence `audits/dataloss-hunt-2026-10-20/C1-restore-rollback-candidates.md`. | VERIFY | — | Prove (a) with the B6 power-cut-mid-restore scenario; (c) with a kill test on 9202 | CC |
|
||||
| **R-1189** | Backup & restore | P3 | **Time and concurrency candidates from the data-loss hunt, not yet proven:** (a) a host clock ahead during one whole-guest backup silences that tier until real time passes it, and the hub reads the same clock (agent `localapi/server.go:1178-1192`, `backup/runner.go:607-629`, hub `monitor/deadline.go:243`; R-973 cap counts future runs `backup_cap.go:61`); (b) the whole-guest start creeps ~5 min/night until a night is skipped silently (`server.go:1184`, `quiesce.go:150, 807-818`); (c) the whole-guest quiesce stops apps mid-restore or mid-update (`quiesce.go:213-275, 565`); (d) the off-site proof and integrity check never run on a box that is off at 05:30–06:00 (`cmd/controller/main.go:1575-1619`, comment says the opposite); (e) hub false „no DB dump" for a late or evening backup time (`monitor/deadline.go:416-419, 556-565`); (f) the OS leg and kernel reboot follow a daytime valve backup (agent `server.go:982-986`, `kernel.go:101-114`) — design question; (g) spring forward 2027-03-28 fires dump and second copy together; (h) self-update busy check before the pull only (`updater.go:532-545, 764`); (i) a double-clicked restore shows failed while it runs (`web/handlers.go:1853-1874`). Evidence `audits/dataloss-hunt-2026-10-20/C1-time-concurrency-candidates.md`. | VERIFY | — | (a) is B6 #8's live test; (b) read the agent journal on demo-hp; the rest by unit tests | CC |
|
||||
| **R-1190** | Backup & restore | P3 | **Key and retention candidates from the data-loss hunt, not yet proven:** (a) a recovery ceremony while off-site is not configured seals a package with no repo password and supersedes the good one (`escrow_handlers.go:217`, agent `main.go:2975-2981`, hub `store.go:3392`); (b) the global „create your recovery code" bar links to the ceremony the backups page hides during a recovery offer (`escrow_banner.go:23-33` vs `backups_remote.html:144-160`); (c) after a reuse-path reinstall the new PBS key K is never escrowed and nothing compares fingerprints (`felhom-pbs-apply:99`, `escrow_confirm.go:182-205`); (d) whole-guest off-site copies run before any escrow exists (design question vs 07 §6.1's Tier-3 gate); (e) the escrow PUT is two statements; (f) the operator's off-site re-issue resets the provider password before saving it (hub `offsite.go:217-245`); (g) „Megőrzött adatok" can list a running app's live folder when its app.yaml is unreadable (`kept.go:220-235, 330-402`); (h) a unit restore can point an app back at an old drive and F5 then prunes the current drive's unit (`backup.go:1517-1574`). Evidence `audits/dataloss-hunt-2026-10-20/C1-keys-candidates.md`, `C1-delete-retention-candidates.md`. | VERIFY | — | Unit tests first for (a), (e), (g) | CC |
|
||||
@@ -211,7 +211,7 @@ stopping line that lies.
|
||||
| **R-1077** | Storage & devices | P4 | **No USB enclosure that hides or fakes a disk's serial has been measured.** The storage-identity spike (2026-10-15) had none at hand and found none in earlier evidence; the design's `usb_bridge` unknown rule (`architecture/16-storage-physical-identity.md` §3) is therefore an assumption. Cheap single- and dual-bay bridges are known to report one bridge serial for any disk. Evidence: `audits/storage-identity-2026-10-15/README.md` case 4b. | OPEN | — | Buy or borrow one cheap USB enclosure (single and dual bay), run `tools/physdisk_probe.py` against it, and set the rule from what it reports. | Operator (a device) then CC |
|
||||
| **R-1094** | Storage & devices | P4 | **The whole-disk format refusal tells the household to format the free part — which is refused while the other part is in use.** SEEN 2026-10-16 (controller 0.318.0): 409 „Ezen a lemezen egy használatban lévő meghajtó van, ezért a teljes lemez nem formázható. Csak a szabad részét formázhatod." — since agent 0.158.1 a part next to a mounted part can only be USED (the root wrapper trusts no „Felhom's own" claim). Evidence: `audits/storage-identity-buildB-2026-10-15/live/14-samedisk-refusal-offer.txt`. | READY | — | Reword `err.web.meghajto_hasznalatban_formazas` (hu + en): „… nem formázható. A szabad részét használatba veheted." Next controller release; the copy gates run. | CC |
|
||||
|
||||
## Security & access — 18 rows (P1 1, P2 2, P3 9, P4 6)
|
||||
## Security & access — 19 rows (P1 2, P2 2, P3 9, P4 6)
|
||||
|
||||
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
@@ -228,6 +228,7 @@ stopping line that lies.
|
||||
| **R-908** | Security & access | P3 | **An old Resend API key is still in the history of the `homelab-manifests` repository; it was replaced on 2026-06-29, but whether it is also revoked at Resend is unknown.** FOUND 2026-10-08 by the contact-mailer source search (`audits/mailer-source-2026-10-08/SEARCH.md`, „A side finding"): commit c9648cd (2026-02-05, „added mailer pod") put a key literal in the old `felhom-system/contact-mailer.yaml` comment; the file was deleted in ee93b50 but the history keeps it. Compared by hash only, never printed: it is NOT today's key (`Secret/resend-api`, rotated 2026-06-29, felhom.eu feea0606). If the old key still works at Resend, anyone with read access to that repository can send mail as felhom.eu. | **OPEN — owner: operator** **Note 2026-10-12 (security review):** gitleaks 8.24.3 with its default rules did NOT flag this key in the full history scan, so a scanner gate alone would not have caught it (R-986). | — | In the Resend console, check that the old key is revoked (revoke it if not); rewriting the repository's history is NOT needed once it is revoked | operator |
|
||||
| **R-525** | Security & access | P4 | **[P3-LOW] FileBrowser has its own login; putting it behind the dashboard session (traefik forwardAuth or Quantum proxy auth) is a new mechanism nobody has measured.** Filed 2026-09-15 by the P1-fixes task (B.5). R-513 closed the default-password hole with a generated password; a household still has two logins. **What it needs:** a spike on a scratch guest — forwardAuth to the controller session, and what FileBrowser Quantum does with a trusted header. | **READY — rank P3-LOW; owner: CC (spike)** **Re-ranked 2026-10-03: P3->P4: comfort feature needing a new unmeasured mechanism; the default password hole is closed.** | — | — | CC |
|
||||
| **R-925** | Security & access | P1 | **`manifests/felhom.secret.yaml` carries REAL secret values, and the Gitea repo that holds it is readable by anyone on the internet with no login.** FOUND 2026-10-09 by a background security review of the legal-pages commit, which flagged the served `<!-- source -->` comments; chasing what those comments pointed at found this instead. **MEASURED, in this order:** (1) `scripts/manifest_bearer_gate.py` itself prints `manifests/felhom.secret.yaml:39 KNOWN-BACKLOG committed secret 65cee3c4…7a86`; (2) the file holds **live-shaped values, not placeholders** — `SECRET_KEY` (69 chars), `SUPERUSER_EMAIL`/`SUPERUSER_PASSWORD` (18), Umami `APP_SECRET` (66) and `POSTGRES_PASSWORD` (34), and a `username`/`password` pair (18); values were never printed, only measured by length; (3) `gitea.dooplex.hu` resolves to **37.191.56.193**, the same public address as the website; (4) an **anonymous** `curl` returns HTTP 200 and 1686 bytes for that exact file, and 200 for `hub/internal/store/store.go` and `manifests/hub.yaml`; (5) **the off-network control**, which is what makes (4) mean anything — fetched from outside the operator's network entirely: a real path returns the file's first line, a nonsense path returns 404, so the 200 is genuine anonymous read from the internet and not a LAN-only ACL. **This contradicts the project's own written model**: `documentation/runbooks/secrets.md:3-4` says *“Secret values are never committed to git. The manifests in `manifests/` carry only placeholders + comments that point here.”* That promise is false today, which is the P1 wording in this register's own scale — see the ranking note below. The same runbook already states the rule that governs the fix: *“The git-history copy stays alive until the value is ROTATED — de-git alone kills nothing.”* **Known neighbours, none of which cover this:** R-887 records that an outside crawler walks the public Gitea pages and leaves *“Gitea's exposure to the crawler”* as the operator's undone item; R-580 still owes a Gitea admin token rotation. Neither says a secret file is world-readable. **Why P2 and not P1, stated so the operator can overrule it:** the exposed credentials guard **analytics and an undeployed healthchecks instance, not household data** — measured: `umami-db` is a **ClusterIP** service on 5432 with no external IP, so the Postgres password is not reachable from the internet; the realistic harm is session forgery against the public `stats.felhom.eu` via `APP_SECRET`, and reuse of those passwords anywhere else. No customer box, hub token or escrow key is in this file. **CC did NOT change anything**: making the repo private could break the public day-0 path (the installer is fetched from a public tag, R-110), and rotation plus repository visibility are operator decisions on production infrastructure. **PARTLY FIXED 2026-10-09, and RE-RANKED P2 -> P1 on what it turned out to be.** The exposed value was not an analytics password: it was the **Gitea `admin` account password** (`is_admin: true`; `/api/v1/admin/users` answered 200), published in a repo `gitea.dooplex.hu` serves anonymously to the internet. That is push access to every repo — including the one whose `website/` is git-synced live and whose `scripts/` is published by tag to **every new box installer** (R-110), i.e. a supply-chain path onto customer hardware. My first ranking said P2 because I had only measured the analytics blast radius; the credential test is what corrected it. **DONE (CC, operator-instructed):** (1) `umami-config` `APP_SECRET` + `POSTGRES_PASSWORD` rotated — the password changed **inside Postgres** too (`ALTER USER`), because the env var is only read at first init; verified by a real beacon returning 200 with a bogus-site-id control still returning 400. (2) `healthchecks-config` `SECRET_KEY` + `SUPERUSER_PASSWORD` rotated (nothing consumes them — there is no healthchecks Deployment). (3) `gitea-creds` no longer holds the admin password at all: it holds a **scoped token** (`read:package` + `read:repository`). **Both scopes are load-bearing and the second was found by breaking it** — a package-only token made the hub log `Template fetch: unexpected status 403`, because the template fetcher reads a raw file out of the `felhom-controller` repo. (4) The Gitea **admin password** was changed and `gitea-system/gitea-admin` updated; **verified: new password 200, old published password 401 — the leak is dead.** (5) The file is removed from git and `.gitignore`'s `*secret*` now applies to it; `manifest_bearer_gate.py`'s `KNOWN_BACKLOG` carve-out is gone, red-proofed with a decoy (exit 1 with, 0 without). **AN INCIDENT CAUSED BY THE FIX, recorded because it is the useful part:** the `rollout restart` needed to pick up the new umami secret put umami into **CrashLoopBackOff and took `stats.felhom.eu` down (503)**. Cause was not the rotation — at `memory: 512Mi` the pod runs for months but **cannot restarLine truncated
|
||||
| **R-1191** | Security & access | P1 | **Importing a `.fab` bundle makes a family-only app open to anyone: the family gate is dropped.** PROVEN LIVE on scratch 9202 (`audits/dataloss-hunt-2026-10-20/C3-R1187e-fab-family-gate/`): Glance installed → a stranger gets 401 and the gate file exists; export → remove → the household's import → the app runs, its record has no `family_gate`, the gate file is gone, a stranger gets **200**. The import writes a bare app record (`cmd/controller/main.go:3517-3530` `SaveEncryptedAppConfig`: Deployed, DeployedAt, Env, DesiredState) and the gate reconcile then removes the file of any app with no `FamilyGate` (`stacks/family_gate.go:173-190`); the R-773 carry-over exists only on the unit-restore path (`stacks/deploy.go:840-870`). Family-gated apps: Glance, Grimmory (books), MeTube (videos); on a household box the address is reachable through the tunnel. The sign-up lock and the version pin are dropped the same way (not measured). Data-loss hunt 2026-10-20 (C1 reviewer 1, R8). | OPEN | — | Fix: the import rebuilds the app record from the catalog's metadata (family gate, setup gate, pin) like the unit restore does; red-proof with the C3 sequence; release by the normal train | CC |
|
||||
| **R-779** | Security & access | P4 | **[P3-LOW] Part A's "two outside addresses seen as two" is proven through the simulated tunnel only; on the REAL tunnel the second outside address (ep0, one request allowed) was refused by Cloudflare's edge with 403 and never reached the box.** Measured 2026-10-01 19:51 UTC (`audits/visitors-2026-10-01/A/L2-demo-hp-real-tunnel.txt`): no log line on demo-hp; demo-hp's box has no geo restriction in its settings, so a Cloudflare ZONE rule (country or bot, not read) refused a German datacenter address. DooPlex's own address on the real tunnel was seen as itself. **Needs:** one sign-in from a second Hungarian address (the operator's phone off wifi) while DooPlex is locked out — 2 minutes; and say which Cloudflare rule refused ep0. | **WAITING-ON-OPERATOR — rank P3-LOW; owner: operator (a phone), CC reads the logs** **Re-ranked 2026-10-03: P3→P4: a proof gap on the real tunnel; operator-only follow-up.** | — | — | CC + operator |
|
||||
| **R-904** | Security & access | P4 | **Cloudflare can read every household's app traffic; replacing it with our own relay is a later item.** Facts (reviewer discussion 2026-10-08; `01`): app traffic and the dashboard reach the box through the Cloudflare Tunnel (`01` §5 trust table, rows end-user ↔ apps and customer ↔ controller UI; §7), and the tunnel's public end is Cloudflare's edge, where TLS ends — so Cloudflare can technically read that traffic (the FAQ says so since 2026-10-08, R-900; „TLS ends at the edge" is not written in `01` — add it there). What Cloudflare gives today, free: inbound reach with no router setup, the CGNAT answer (`01` §4, §7); certificates (`01` §7, the free tier covers one level below a zone); the geo-WAF the hub enforces (`01` §5 last row, §7); flood protection (not in `01`). The alternative named: our own EU relay over WireGuard with TLS passthrough by SNI, certificates on the box, the geo-block on the relay. Its costs: one more machine the operator keeps up, and a single point of reach for every box; weaker flood protection; about a week of work after a spike. Operator ruling 2026-10-08 09:07 (`09` §3 decision 184): a later item. | **DEFERRED** — **2026-10-10: the asked sentence is in `01` §7** („TLS ends at Cloudflare's edge …”); the spike stays deferred. **DEFERRED — after the first customers (operator ruling 2026-10-08 09:07)** | — | A spike after the first customers (the relay's reach, cost and flood behaviour, measured) | operator |
|
||||
| **R-1040** | Security & access | P4 | **Until the household makes Sonarr's or Radarr's first admin, any container on the box can read the app's API key.** Measured on the bench 2026-10-14 with the R-1030 seed in place: `AuthenticationRequired=Enabled`, `AuthenticationMethod=None` — `/initialize.json` from another container answers 200 with `apiKey`. Outsiders are blocked by the box's setup gate in that window; a compromised other app is not. | **OPEN** — filed 2026-10-14 | — | Seed the API key at install (`SONARR__AUTH__APIKEY`, measured to work) so the box knows it, and close the window by telling the household in the first steps; or seed a Forms user | CC |
|
||||
|
||||
Reference in new issue
Block a user