Controller v0.314.0 released under decision 250: floor 0.314.0 raised by CC's token at 13:11 UTC after the three test boxes
gates / gates (push) Successful in 6m45s

- 09 §3 decision 253: a restore puts the setup gate back as it was (R-1057 P2); 07 (what a per-app backup carries); 15 §6
  (Prowlarr links itself; Plex by address).
- Evidence audits/gate-restore-2026-10-15/: 9202 live proofs (R-1057, R-1058), the release (per-customer floors, Tester 1
  rc.1 -> 0.314.0 by itself, box walk, global floor, read-back), Plex fake-code check on 9201, red-proofs.
- Register: R-1023, R-1053, R-1057, R-1058 closed; R-1061 filed (P4); R-1060 narrowed; R-1052 blocked (customer creation
  is session only). STATUS, CONTEXT, the build skill's release steps, Prowlarr's website card.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-15 15:21:28 +02:00
parent bd478afe14
commit 15cb62097c
40 changed files with 945 additions and 22 deletions
@@ -886,6 +886,8 @@ both. Files written under different pins (a failed leg) make the unit `mixed`.
**[DESIGN] What a restore keeps from the app it replaces (controller v0.276.0, R-697).** The restore writes a fresh `app.yaml` from the unit — env, locked fields, and the pin from the unit's definition — but keeps the records of the app's life on this box: the household's `desired_state`, the kept pre-conversion copies (`conversion_copy`, `earlier_conversion_copies` — a restore does not remove the volume, so it must not forget it), and the ladder history (`failed_update_step`, `last_update_undone`, `last_auto_update`). Not kept: `installed_images` (what ran before, maybe another version). **A drive move is not a restore:** it changes `HDD_PATH` and nothing else (R-700 — before v0.276.0 it dropped the pin, and the syncer then gave the app the catalog's newest version at its next start). Pinned by `internal/stacks/r700_records_carried_test.go`.
**[DESIGN] The setup gate travels with the backup (controller v0.314.0, R-1057, `09` §3 decision 253).** The recovery unit's `app.yaml` carries `setup_gate:` (state, and who opened it) as it stood at the backup — the only life record the unit carries, because it decides who may reach the restored data. The restore's gate step runs inside the same write, before anything starts: closed then → closed now (the traefik gate file first, also over an installed app); open then → open; a unit with no record (made before v0.314.0) of a removed gated app → closed until the app's probe or the household's „Kész" opens it. Never open by default. An ungated app's unit bytes are unchanged (the checksum-skip guard). Code: `internal/stacks/restore_gate.go`; pinned by `restore_gate_test.go` and `internal/backup/r1057_unit_gate_test.go`.
**[DESIGN] The jump guard.** An installed version that matches no ladder entry's `from` jumps to the catalog's
current definition when a PERSON presses Update (`ladder.go`, measured live on vikunja 2.5.0, `09` §6.4 part 5);
the AUTOMATIC leg never presses it (`LegSkipOlderThanLadder`, and a template without a ladder is
@@ -948,6 +948,24 @@ its length, and both fixes cost something the household would notice — operato
**Extended the same day (operator, R-1059 = A):** the off-site and PBS-DR re-issues (they mint a household
credential for the box) are session only too; „Off-site: write" keeps the windows and freeze.
### 2026-10-15 (afternoon) — a restore puts the setup gate back as it was (operator brief, R-1057 raised to P2)
253. **A restore puts the setup gate back exactly as it was when the backup was taken.** *Operator brief 2026-10-15
(„A restored app never comes back open"); R-1057 raised to P2 by the reviewer the same day, seen by the operator.*
Narrows decision 46's restore clause („no prior record = no gate: the data comes back with its admin"), whose
assumption fails for a backup made before the household's first setup (measured on 9202: a stranger's `/ping` 200
after the restore of a Prowlarr backed up before its setup).
1. The per-app backup records the gate (`setup_gate:` in the unit's `app.yaml`: closed / open, and by whom; an app
installed before gates existed is recorded open, as it was).
2. **Closed then → closed now**, also over an installed app (the data it gets back has no admin); the gate file is
written BEFORE anything starts (decision 46 F2); the probe or „Kész, beállítottam" opens it as at install.
3. **Open then → open** (the rule's intent, unchanged); an app re-gated since (a reinstall) is opened again.
4. **No record** (a backup made before controller 0.314.0) of a REMOVED gated app → closed; the gate's loop opens it at
once if the app's own probe says the setup is done; without a probe (Plex) the household's „Kész" opens it.
**Never open by default.** An installed app restored from a no-record backup keeps its own record.
Built in controller v0.314.0 (`internal/stacks/restore_gate.go`); pinned by `restore_gate_test.go` and
`r1057_unit_gate_test.go` (ten mutations each seen failing). Proof: `audits/gate-restore-2026-10-15/9202/`.
### 2026-10-15 (07:48–08:20) — bake first; Plex through its own key; HSTS one day (recorded before the media build)
247. ~~**Bake first.** A controller version is RELEASED only when its golden is baked.~~ **REVERSED by decision 250,
@@ -141,6 +141,13 @@ directory) with that tag. Facts the link code needs:
name (`http://sonarr:8989`, `http://radarr:7878`, `prowlarrUrl http://prowlarr:9696`); both entries pass Prowlarr's own
connection test; the sync command completes (measured with zero indexers — the push of a real one waits for the
household's own). It needs no drive and no media folder. FlareSolverr only for an indexer that asks for it.
**When the link is made (controller v0.314.0, R-1058):** in every wiring run — a `media_library` app coming up, the media
restart, „Bekötés most" — and, since v0.314.0, Prowlarr's own start (`linkOnlyApps`): installed after Sonarr/Radarr it
links itself within the 60 s debounce, with no button (before: no link until a press, measured on demo-hp 9201,
`audits/prowlarr-2026-10-15/box9201/`). Idempotent: a link already there is not written again.
**Plex is called by its ADDRESS (v0.314.0, R-1060)**: Plex refuses the Host name `plex` as non-local (its claim
answered 401), so every call — claim, identity, library wiring, sessions — goes to the container's address, resolved at
each call (`mediawire.PlexURL`).
- **Call the apps with `Host: <container name>`**: once the household sets „Allowed hosts”, Sonarr/Radarr answer a bare
IP with 400 „Invalid Hostname” (measured).
- **A setting made through Transmission's RPC is lost on a power cut** (it writes `settings.json` only on a clean exit):
@@ -0,0 +1,5 @@
before: gitea.dooplex.hu/admin/felhom-controller:0.304.0
gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1
gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1 Up 25 seconds (healthy)
2026/10/15 12:35:53 main.go:354: [INFO] felhom-controller 0.314.0-rc.1 starting (customer: demo-hp, domain: enkisfelhom.hu)
2026/10/15 12:35:53 undo.go:676: [INFO] [stacks] applied-meta backfill (R-646): recorded 0 []; skipped 0 [] — not current with the catalog, their pinned version's .felhom.yml is no longer on the box
@@ -0,0 +1,6 @@
drill repo main: ebf3668
mirror main ebf3668 DRILL prowlarr: the step -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (R-1048, checklist 2.5)
cache HEAD ebf3668 DRILL prowlarr: the step -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (R-1048, checklist 2.5)
file:///opt/docker/felhom-controller/drill-catalog.git
@@ -0,0 +1,7 @@
2026/10/15 12:38:45 setup_gate.go:295: [INFO] [stacks] prowlarr: setup gate CLOSED before the first start — only the household reaches [prowlarr.enkisfelhom.hu] until the first setup is done
2026/10/15 12:40:07 recovery_unit.go:289: [INFO] [backup] Recovery unit captured for prowlarr → /mnt/sys_drive/felhom-data/backups/primary/prowlarr (images=1, secrets-referenced=0, data_keys=0, portable-carried=0/0, withheld=0)
2026/10/15 12:41:36 restore_unit.go:381: [INFO] [backup] Restore prowlarr: the data belongs to [lscr.io/linuxserver/prowlarr:version-2.5.2.5491] (written 2026-10-15T12:40:06Z); the app ran [lscr.io/linuxserver/prowlarr:version-2.6.5.5623] — it comes back at
2026/10/15 12:41:36 restore_unit.go:393: [INFO] [backup] Restoring prowlarr from recovery unit /mnt/sys_drive/felhom-data/backups/primary/prowlarr: images=1, secrets recovered=0/0, data_keys=0
2026/10/15 12:41:37 setup_gate.go:295: [INFO] [stacks] prowlarr: setup gate CLOSED before the first start — only the household reaches [prowlarr.enkisfelhom.hu] until the first setup is done
2026/10/15 12:41:37 restore_gate.go:86: [INFO] [stacks] prowlarr: restored with its setup gate CLOSED — it was closed when the backup was taken (R-1057)
2026/10/15 12:41:54 restore_unit.go:487: [INFO] [backup] Restore-from-unit completed: prowlarr — 1 volume(s) of 1 listed, 0 database(s) of 0 listed
@@ -0,0 +1,7 @@
27: sync_interval: 15m
-rw------- 1 root root 1944 Oct 12 20:52 /var/lib/docker/volumes/felhom-controller-data/_data/controller.yaml.pre-prowlarr1015
mirror main c42d92b DRILL prowlarr: the step -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (R-1048, checklist 2.5)
cache HEAD 73b45a6 Prowlarr into the live catalog, with its complete onboarding record (R-1048)
https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git
repo_url: https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git
@@ -0,0 +1,2 @@
gitea.dooplex.hu/admin/felhom-controller:0.314.0 Up 25 seconds (healthy)
0
@@ -0,0 +1,17 @@
14:49:28 [0] controller: gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1
14:49:28 deploy sonarr {"DOMAIN": "enkisfelhom.hu", "SUBDOMAIN": "sonarr", "HDD_PATH": "/mnt/felhom-drives/scratch_hdd"} -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
14:49:53 sonarr: deployed, running, pinned={'sonarr': 'lscr.io/linuxserver/sonarr:4.0.20'}
14:51:15 [1] wiring after Sonarr came up:
2026/10/15 12:50:37 media_events.go:69: [INFO] [media] wiring the drives into the media apps (a media app came up: sonarr)
2026/10/15 12:50:38 mediawire.go:154: [INFO] [mediawire] 1 drive(s) wired in 984ms: folders=done(0) sonarr=done(3)
14:51:15 deploy prowlarr {"DOMAIN": "enkisfelhom.hu", "SUBDOMAIN": "prowlarr"} -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
14:51:45 prowlarr: deployed, running, pinned={'prowlarr': 'lscr.io/linuxserver/prowlarr:version-2.6.5.5623'}
14:52:42 [2] R-1058 Sonarr appeared in Prowlarr's Apps with NO button, ~40 s after Prowlarr ran: [('Sonarr', 'fullSync', 'http://sonarr:8989')]
14:52:46 [2] wiring runs since Prowlarr's install:
2026/10/15 12:52:27 media_events.go:69: [INFO] [media] wiring the drives into the media apps (a media app came up: prowlarr)
2026/10/15 12:52:28 mediawire.go:154: [INFO] [mediawire] 1 drive(s) wired in 1.817s: folders=done(0) sonarr=unchanged(0) prowlarr=done(1)
14:52:46 [3] a second run („Bekötés most”) -> 200; prowlarr result: [{"app": "prowlarr", "state": "unchanged"}] (expected: unchanged, nothing written)
14:52:52 [4] Prowlarr's connection test for Sonarr -> 200
14:53:28 [X] prowlarr: remove (keep drive data, drop backups) -> 200 {"ok": true, "data": {"removed": "prowlarr", "volumes_removed": ["prowlarr_prowlarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": [], "verified": true}, "message": "St
14:54:03 [X] sonarr: remove (keep drive data, drop backups) -> 200 {"ok": true, "data": {"removed": "sonarr", "volumes_removed": ["sonarr_sonarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": ["/mnt/felhom-drives/scratch_hdd/userdata/m
14:54:12 [X] left: containers=0 volumes=0
@@ -0,0 +1,9 @@
{
"linked_after_s": 40,
"second_run": [
{
"app": "prowlarr",
"state": "unchanged"
}
]
}
@@ -0,0 +1,17 @@
[0] controller: gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1
deploy sonarr {"DOMAIN": "enkisfelhom.hu", "SUBDOMAIN": "sonarr", "HDD_PATH": "/mnt/felhom-drives/scratch_hdd"} -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
sonarr: deployed, running, pinned={'sonarr': 'lscr.io/linuxserver/sonarr:4.0.20'}
[1] wiring after Sonarr came up:
2026/10/15 12:50:37 media_events.go:69: [INFO] [media] wiring the drives into the media apps (a media app came up: sonarr)
2026/10/15 12:50:38 mediawire.go:154: [INFO] [mediawire] 1 drive(s) wired in 984ms: folders=done(0) sonarr=done(3)
deploy prowlarr {"DOMAIN": "enkisfelhom.hu", "SUBDOMAIN": "prowlarr"} -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
prowlarr: deployed, running, pinned={'prowlarr': 'lscr.io/linuxserver/prowlarr:version-2.6.5.5623'}
[2] R-1058 Sonarr appeared in Prowlarr's Apps with NO button, ~40 s after Prowlarr ran: [('Sonarr', 'fullSync', 'http://sonarr:8989')]
[2] wiring runs since Prowlarr's install:
2026/10/15 12:52:27 media_events.go:69: [INFO] [media] wiring the drives into the media apps (a media app came up: prowlarr)
2026/10/15 12:52:28 mediawire.go:154: [INFO] [mediawire] 1 drive(s) wired in 1.817s: folders=done(0) sonarr=unchanged(0) prowlarr=done(1)
[3] a second run („Bekötés most”) -> 200; prowlarr result: [{"app": "prowlarr", "state": "unchanged"}] (expected: unchanged, nothing written)
[4] Prowlarr's connection test for Sonarr -> 200
[X] prowlarr: remove (keep drive data, drop backups) -> 200 {"ok": true, "data": {"removed": "prowlarr", "volumes_removed": ["prowlarr_prowlarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": [], "verified": true}, "message": "St
[X] sonarr: remove (keep drive data, drop backups) -> 200 {"ok": true, "data": {"removed": "sonarr", "volumes_removed": ["sonarr_sonarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": ["/mnt/felhom-drives/scratch_hdd/userdata/m
[X] left: containers=0 volumes=0
@@ -0,0 +1,60 @@
14:37:21 drill commit fd4b9f1541db: -> lscr.io/linuxserver/prowlarr:version-2.5.2.5491 (push rc=0)
mirror main fd4b9f1 DRILL prowlarr: back to the previous image for a step -> lscr.io/linuxserver/prowlarr:version-2.5.2.5491 (R-1048, checklist 2.5)
drill repo main: fd4b9f1
14:38:45 [sync] the badge NEVER caught up to lscr.io/linuxserver/prowlarr:version-2.5.2.5491 in 78.5s — catalog_images = None
14:38:45 [1] deploy -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
14:39:03 gate: prowlarr is gated — passed as the household (cookie set)
14:39:07 [1] through traefik: 401 (gated: 302 to the gate is the healthy answer for a stranger)
14:39:10 [1] stack .felhom.yml bytes: 3948
14:39:13 [1] running: lscr.io/linuxserver/prowlarr:version-2.5.2.5491 health=healthy
14:39:22 prowlarr: POST /tag felhom20fd668a http=201
14:39:26 prowlarr: POST /appprofile felhom-profile-c7f82e http=201
14:39:39 prowlarr: tag kept, app profile present=True
14:39:39 [2] seed {'tag': 'felhom20fd668a', 'profile': 'felhom-profile-c7f82e'} read back: True
14:39:42 [2] 2.7 config volume size after the seed: 5.4M
14:39:48 [2] 4.4 negative control, the same probe on a port nothing serves: rc=7 ; the real probe: rc=0
14:39:51 [2] 1.8 the app's own start lines: [Info] Microsoft.Hosting.Lifetime: Application started. Press Ctrl+C to shut down. | [Info] Microsoft.Hosting.Lifetime: Hosting environment: Production
14:39:52 drill commit c42d92bc425d: -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (push rc=0)
mirror main c42d92b DRILL prowlarr: the step -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (R-1048, checklist 2.5)
drill repo main: c42d92b
14:40:02 [6] Update -> 202 {'ok': True, 'data': {'accepted': True, 'completed': False}, 'message': 'Frissítés elindult – az állapot a kártyán követhető'}
14:40:02 + 0.0s phase=backing-up label=Biztonsági mentés készül a frissítés előtt… err=None hold=None
14:40:07 + 5.1s phase=safety-dump label=Adatbázis pillanatkép… err=None hold=None
14:40:09 + 6.2s phase=pulling label=Új verzió letöltése… err=None hold=None
14:40:17 + 14.4s phase=copying label=Az adatok másolása a frissítés előtt… err=None hold=None
14:40:22 + 19.5s phase=verifying label=Működés ellenőrzése… err=None hold=None
14:40:32 + 29.7s phase=done label=Frissítve err=None hold=None
14:40:32 [3] guarded Update (its backing-up phase is the per-app backup): final=done phases=['backing-up', 'safety-dump', 'pulling', 'copying', 'verifying', 'done']
14:40:46 prowlarr: tag kept, app profile present=True
14:40:46 [3] seed read back after the Update: True
14:40:49 [3] after: {"pinned_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "installed_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "catalog_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "live_compose_image_lines": ["image: lscr.io/linuxserver/prowlarr:version-2.6.5.5623"], "docker_inspect": ["prowlarr lscr.io/linuxserver/prowlarr:ve
14:40:52 [3] R-1057 the per-app backup's unit app.yaml, its gate lines:
14:40:52 [4] restore points offered BEFORE the remove: [{"time": "2026-10-15T12:40:06Z", "short_id": "helyi", "tier": 1, "drive_label": "Belső SSD (rendszer)"}]
14:40:56 [4] stop -> 200
14:41:27 [4] remove KEEPING the backups -> 200 {"ok": true, "data": {"removed": "prowlarr", "volumes_removed": ["prowlarr_prowlarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": [], "verified": true}, "message": "Stack prowlarr removed"}
14:41:36 [4] left after remove: containers=0 volumes=0
14:41:36 [R] restoring prowlarr from snapshot 'helyi' (of 1 offered)
14:41:36 [R] POST /backup/restore -> HTTP/2 302 ['location: /backups/restore?flash=flash.restore.started']
14:41:36 + 0.0s restore (True, None, None)
14:41:54 + 18.2s restore (False, None, None)
14:41:54 [R] after restore: state=running hold=None phase=None
14:41:58 [5] restore: {"ok": true, "snapshot_id": "helyi", "http": "HTTP/2 302", "seconds": 18.3, "state_after": "running", "hold_after": null, "why": null}
14:42:11 prowlarr: tag kept, app profile present=True
14:42:11 [5] 2.5 the seed reads back after remove + restore: True
14:42:11 [6] R-1057 a STRANGER after the restore: GET /ping -> 401
14:42:11 [6] R-1057 a STRANGER after the restore: GET / -> 302 loc=https://felhom.enkisfelhom.hu/__gate/start?rd=https%3A%2F%2Fprowlarr.e
14:42:11 [6] R-1057 a STRANGER after the restore: GET /initialize.json -> 401
14:42:11 [6] the HOUSEHOLD (gate cookie) after the restore: GET /ping -> 200
14:42:11 [6] gate record after the restore: {"state": "closed", "since": "2026-10-15T12:41:37Z", "hosts": ["prowlarr.enkisfelhom.hu"]}
14:42:14 [6] gate file on the box: /opt/docker/stacks/traefik/dynamic/setup-gate-prowlarr.yml
14:42:14 [7] the household's „Kész, beállítottam” -> 200 {"data": {"opened": true}, "error": "", "ok": true}
14:42:22 [7] a stranger after „Kész”: GET /ping -> 200 (the app itself answers now)
14:42:26 [5] through traefik after restore: 200
14:42:29 [5] app.yaml env keys after restore: DOMAIN SUBDOMAIN
14:42:32 [5] image after restore: lscr.io/linuxserver/prowlarr:version-2.5.2.5491 health=healthy
14:42:35 [X] stop -> 200 {'ok': True, 'message': 'Stack prowlarr stop completed'}
14:43:07 [X] remove (with drive data) -> 200 {'ok': True, 'data': {'removed': 'prowlarr', 'volumes_removed': ['prowlarr_prowlarr_config'], 'hdd_paths_removed': [], 'hdd_paths_preserved': [], 'hdd_note': 'A
14:43:16 [X] after remove: deployed=False leftovers='/opt/docker/stacks/prowlarr'
14:43:19 [9] after remove: containers=0 volumes=0
@@ -0,0 +1,18 @@
{
"seed": {
"tag": "felhom20fd668a",
"profile": "felhom-profile-c7f82e"
},
"seed_before": true,
"after_update": true,
"after_restore": true,
"update_final": "done",
"restore": {
"ok": true,
"snapshot_id": "helyi",
"http": "HTTP/2 302",
"seconds": 18.3,
"state_after": "running"
},
"measured_at": "2026-10-15T12:43:19Z"
}
@@ -0,0 +1,54 @@
drill commit fd4b9f1541db: -> lscr.io/linuxserver/prowlarr:version-2.5.2.5491 (push rc=0)
[sync] the badge NEVER caught up to lscr.io/linuxserver/prowlarr:version-2.5.2.5491 in 78.5s — catalog_images = None
[1] deploy -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
gate: prowlarr is gated — passed as the household (cookie set)
[1] through traefik: 401 (gated: 302 to the gate is the healthy answer for a stranger)
[1] stack .felhom.yml bytes: 3948
[1] running: lscr.io/linuxserver/prowlarr:version-2.5.2.5491 health=healthy
prowlarr: POST /tag felhom20fd668a http=201
prowlarr: POST /appprofile felhom-profile-c7f82e http=201
prowlarr: tag kept, app profile present=True
[2] seed {'tag': 'felhom20fd668a', 'profile': 'felhom-profile-c7f82e'} read back: True
[2] 2.7 config volume size after the seed: 5.4M
[2] 4.4 negative control, the same probe on a port nothing serves: rc=7 ; the real probe: rc=0
[2] 1.8 the app's own start lines: [Info] Microsoft.Hosting.Lifetime: Application started. Press Ctrl+C to shut down. | [Info] Microsoft.Hosting.Lifetime: Hosting environment: Production
drill commit c42d92bc425d: -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (push rc=0)
[6] Update -> 202 {'ok': True, 'data': {'accepted': True, 'completed': False}, 'message': 'Frissítés elindult – az állapot a kártyán követhető'}
+ 0.0s phase=backing-up label=Biztonsági mentés készül a frissítés előtt… err=None hold=None
+ 5.1s phase=safety-dump label=Adatbázis pillanatkép… err=None hold=None
+ 6.2s phase=pulling label=Új verzió letöltése… err=None hold=None
+ 14.4s phase=copying label=Az adatok másolása a frissítés előtt… err=None hold=None
+ 19.5s phase=verifying label=Működés ellenőrzése… err=None hold=None
+ 29.7s phase=done label=Frissítve err=None hold=None
[3] guarded Update (its backing-up phase is the per-app backup): final=done phases=['backing-up', 'safety-dump', 'pulling', 'copying', 'verifying', 'done']
prowlarr: tag kept, app profile present=True
[3] seed read back after the Update: True
[3] after: {"pinned_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "installed_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "catalog_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "live_compose_image_lines": ["image: lscr.io/linuxserver/prowlarr:version-2.6.5.5623"], "docker_inspect": ["prowlarr lscr.io/linuxserver/prowlarr:ve
[3] R-1057 the per-app backup's unit app.yaml, its gate lines:
[4] restore points offered BEFORE the remove: [{"time": "2026-10-15T12:40:06Z", "short_id": "helyi", "tier": 1, "drive_label": "Belső SSD (rendszer)"}]
[4] stop -> 200
[4] remove KEEPING the backups -> 200 {"ok": true, "data": {"removed": "prowlarr", "volumes_removed": ["prowlarr_prowlarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": [], "verified": true}, "message": "Stack prowlarr removed"}
[4] left after remove: containers=0 volumes=0
[R] restoring prowlarr from snapshot 'helyi' (of 1 offered)
[R] POST /backup/restore -> HTTP/2 302 ['location: /backups/restore?flash=flash.restore.started']
+ 0.0s restore (True, None, None)
+ 18.2s restore (False, None, None)
[R] after restore: state=running hold=None phase=None
[5] restore: {"ok": true, "snapshot_id": "helyi", "http": "HTTP/2 302", "seconds": 18.3, "state_after": "running", "hold_after": null, "why": null}
prowlarr: tag kept, app profile present=True
[5] 2.5 the seed reads back after remove + restore: True
[6] R-1057 a STRANGER after the restore: GET /ping -> 401
[6] R-1057 a STRANGER after the restore: GET / -> 302 loc=https://felhom.enkisfelhom.hu/__gate/start?rd=https%3A%2F%2Fprowlarr.e
[6] R-1057 a STRANGER after the restore: GET /initialize.json -> 401
[6] the HOUSEHOLD (gate cookie) after the restore: GET /ping -> 200
[6] gate record after the restore: {"state": "closed", "since": "2026-10-15T12:41:37Z", "hosts": ["prowlarr.enkisfelhom.hu"]}
[6] gate file on the box: /opt/docker/stacks/traefik/dynamic/setup-gate-prowlarr.yml
[7] the household's „Kész, beállítottam” -> 200 {"data": {"opened": true}, "error": "", "ok": true}
[7] a stranger after „Kész”: GET /ping -> 200 (the app itself answers now)
[5] through traefik after restore: 200
[5] app.yaml env keys after restore: DOMAIN SUBDOMAIN
[5] image after restore: lscr.io/linuxserver/prowlarr:version-2.5.2.5491 health=healthy
[X] stop -> 200 {'ok': True, 'message': 'Stack prowlarr stop completed'}
[X] remove (with drive data) -> 200 {'ok': True, 'data': {'removed': 'prowlarr', 'volumes_removed': ['prowlarr_prowlarr_config'], 'hdd_paths_removed': [], 'hdd_paths_preserved': [], 'hdd_note': 'A
[X] after remove: deployed=False leftovers='/opt/docker/stacks/prowlarr'
[9] after remove: containers=0 volumes=0
@@ -0,0 +1,23 @@
# Controller v0.314.0 — the gate on restore, test builds and the floor, Prowlarr's link, Plex by address (2026-10-15)
Design read: `architecture/09-update-architecture.md` §3 decisions 46–49, 245–253; `07-backup-architecture.md`;
`15-media-library.md` §6. Code: felhom-controller `17863fe` + `97c992a`.
| folder / file | what it proves |
|---|---|
| `9202/00-rc1.txt` | scratch guest 9202 on `0.314.0-rc.1` |
| `9202/restore.txt` | **R-1057 live:** Prowlarr installed (no account), the Update's per-app backup, remove keeping backups, restore → a stranger: `/ping` 401, `/` 302 to the gate, `/initialize.json` 401; the household 200; „Kész" opens it |
| `9202/02-controller-log.txt` | the box's own line: „restored with its setup gate CLOSED — it was closed when the backup was taken (R-1057)" |
| `9202/link.txt` | **R-1058 live:** Sonarr, then Prowlarr → „a media app came up: prowlarr", linked ~40 s later, no button; a second run „unchanged" |
| `release/01-tester1-rc1.txt` | Tester 1 put on `0.314.0-rc.1` by hand (rc.4 runs the old check and cannot leave by itself) |
| `release/02-customer-floors.txt` | per-customer floors 0.314.0 / MinAgent 0.155.0 through CC's token: 303 `floor_set` ×3 |
| `release/03-boxes-move.txt` | demo-hp and demo-felhom on 0.314.0 within 13 s; Tester 1 at 13:09:38 by itself (**R-1053 live**), at its regular report (R-1061) |
| `release/04-https-only.txt`, `05-health.txt` | the box walk's checks on all three: https-only OK; 0 unhealthy, 0 restarting, 0 exited; controller healthy; dashboard 200 |
| `release/06-global-floor.txt` | **the global floor 0.314.0, MinAgent 0.155.0, by CC's token:** 303 `floor_set` at 13:11:04 UTC; read back from the hub DB with its WAL |
| `release/07-clear-customer-floors.txt` | the three per-customer floors cleared (empty), read back |
| `plex9201/01-fake-code.txt` | **R-1060 live, partly:** a FAKE code through the dashboard's claim call — Plex took it as local and asked plex.tv (500 after 938 ms; before the fix 401 in 0 ms) |
| `plex9201/02-claim.txt` | the real claim, when the operator's code arrives (watcher) |
| `tools/` | `walk_gate_restore.py`, `walk_link.py`, `repoint9202.py` |
Red-proofs (mutation runs) are quoted in the controller CHANGELOG entry and the report
(`REPORT-gate-restore-release-2026-10-15.md`).
@@ -0,0 +1,8 @@
controller: gitea.dooplex.hu/admin/felhom-controller:0.314.0
dashboard claim with a FAKE code -> 409 {"data": null, "error": "Plex did not accept the code. It may have run out: get a new one at plex.tv/claim.", "ok": false}
2026/10/15 13:14:14 media_events.go:382: [INFO] [web] plex: the household's claim code given to the running server -> HTTP 500 (err <nil>; the code is not logged)
Oct 15, 2026 15:11:43.440 [139838624131896] DEBUG - Request came in with unrecognized domain / IP 'plex' in header Host; treating as non-local
Oct 15, 2026 15:14:13.878 [139838519249720] DEBUG - Request: [172.18.0.5:57144 (Subnet)] POST /myplex/claim?token=<fake> (2 live) #866 GZIP
Oct 15, 2026 15:14:14.817 [139838624131896] DEBUG - Completed: [172.18.0.5:57144] 500 POST /myplex/claim?token=<fake> (2 live) #866 GZIP 938ms 405 bytes (pipelined: 1)
@@ -0,0 +1,12 @@
M1 no gate on restore: rc=1 failing=['TestR1057_ClosedAtTheBackupComesBackClosed', 'TestR1057_InPlaceRestoreOfAClosedBackupRegates', 'TestR1057_InPlaceRestoreOfAnOpenBackupOpens', 'TestR1057_NoRecordNoProbeStaysClosed', 'TestR1057_NoRecordProbeDoneOpensByTheLoop', 'TestR1057_NoRecordProbeNotDoneStaysClosed', 'TestR1057_TheGateFileStandsBeforeTheRestoredAppStarts']
M2 every known record is closed: rc=1 failing=['TestR1057_InPlaceRestoreOfAnOpenBackupOpens', 'TestR1057_OpenAtTheBackupComesBackOpen', 'TestR773_ARemovedAppComesBackWithSignupClosed']
M3 no-record case dropped: rc=1 failing=['TestR1057_NoRecordNoProbeStaysClosed', 'TestR1057_NoRecordProbeDoneOpensByTheLoop', 'TestR1057_NoRecordProbeNotDoneStaysClosed']
M4 closed case only for removed: rc=1 failing=['TestR1057_InPlaceRestoreOfAClosedBackupRegates']
M5 never-gated captured as nil: rc=1 failing=['TestR1057_CaptureAndReadBack']
M6 unknown state fails open: rc=1 failing=['TestR1057_CaptureAndReadBack']
M7 reopen case dropped: rc=1 failing=['TestR1057_InPlaceRestoreOfAnOpenBackupOpens']
M8 gate written after nothing (prepare dropped): rc=1 failing=['TestR1057_ClosedAtTheBackupComesBackClosed', 'TestR1057_InPlaceRestoreOfAClosedBackupRegates', 'TestR1057_TheGateFileStandsBeforeTheRestoredAppStarts']
restored: ok gitea.dooplex.hu/admin/felhom-controller/internal/stacks 0.049s
M9 no-record closes an installed app too: --- FAIL: TestR1057_InPlaceNoRecordKeepsTheAppsRecord (0.00s)
M10 unit drops the gate: --- FAIL: TestR1057_TheUnitCarriesTheGateAndTheRestoreReadsIt (0.00s)
M10 unit drops the gate: r1057_unit_gate_test.go:33: the restore must read the closed gate back from the unit, got {Known:false Closed:false OpenedBy:}
@@ -0,0 +1,8 @@
M1 comparePre returns 0: rc=1 failing=['TestR1053_ATestBuildIsMovedToTheFloor', 'TestR1053_PreReleaseOrdersBelowItsRelease']
M2 floor parses strictly again: rc=1 failing=['TestR1053_ATestBuildIsMovedToTheFloor']
M3 warn every call: rc=1 failing=['TestR1053_AnUnreadableVersionWarnsOncePerChange']
M4 the button parses pre-releases: rc=1 failing=['TestR1053_TheButtonNeverOffersATestBuild']
M5 a numeric id ordered as text: rc=1 failing=['TestR1053_PreReleaseOrdersBelowItsRelease']
restored: ok gitea.dooplex.hu/admin/felhom-controller/internal/util 0.041s | ok gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate 0.044s
M6 every test build moved: --- FAIL: TestR1053_ATestBuildAboveTheFloorStays (0.00s)
M7 at-floor updates anyway: --- FAIL: TestMaybeAutoUpdate_AtFloor_NoAction (0.00s)
@@ -0,0 +1,2 @@
C-M1 linkOnlyApps empty: --- FAIL: TestR1058_ProwlarrUpRunsTheWiring (0.01s)
C-M1 linkOnlyApps empty: r1058_prowlarr_wires_test.go:36: wiring runs = [sonarr], want [prowlarr sonarr] (Prowlarr's up, Sonarr's up; never another app, never a non-up command)
@@ -0,0 +1,6 @@
C2-M1 Plex by name again: --- FAIL: TestR1060_TheClaimReachesPlexByAddress (0.00s)
C2-M1 Plex by name again: r1060_plex_by_address_test.go:57: claim → 401 (<nil>); Plex refuses the name „plex” as non-local — the box must call it by address
C2-M1 Plex by name again: --- FAIL: TestR1060_ProductionURLCallsPlexByAddress (0.00s)
C2-M1 Plex by name again: plexaddr_test.go:22: plex → "http://plex:32400", want its IPv4 address
C2-M2 wiring by name: --- FAIL: TestR1060_ProductionURLCallsPlexByAddress (0.00s)
C2-M2 wiring by name: plexaddr_test.go:22: plex → "http://plex:32400", want its IPv4 address
@@ -0,0 +1,4 @@
before: gitea.dooplex.hu/admin/felhom-controller:0.313.0-rc.4
17
gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1
gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1 Up 30 seconds (healthy)
@@ -0,0 +1,3 @@
12:55:51 POST /customers/tester-1/floor 0.314.0 MinAgent 0.155.0 -> HTTP/1.1 303 See Other Location: /customers/tester-1?flash=floor_set
12:55:51 POST /customers/demo-hp/floor 0.314.0 MinAgent 0.155.0 -> HTTP/1.1 303 See Other Location: /customers/demo-hp?flash=floor_set
12:55:51 POST /customers/demo-felhom/floor 0.314.0 MinAgent 0.155.0 -> HTTP/1.1 303 See Other Location: /customers/demo-felhom?flash=floor_set
@@ -0,0 +1,51 @@
12:56:04 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:56:23 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:56:42 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:57:01 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:57:20 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:57:40 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:57:59 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:58:18 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:58:37 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:58:56 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:59:16 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:59:35 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:59:54 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:00:13 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:00:33 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:00:52 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:01:11 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:01:30 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:01:49 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:02:08 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:02:28 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:02:47 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:03:06 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:03:25 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:03:45 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:04:04 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:04:23 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:04:42 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:05:02 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:05:21 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:05:40 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:05:59 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:06:18 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:06:21 tester-1=0.314.0-rc.1
13:06:37 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:06:43 tester-1=0.314.0-rc.1
13:06:55 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:07:04 tester-1=0.314.0-rc.1
13:07:14 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:07:26 tester-1=0.314.0-rc.1
13:07:33 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:07:48 tester-1=0.314.0-rc.1
13:07:51 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:08:09 tester-1=0.314.0-rc.1
13:08:10 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:08:30 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:08:30 tester-1=0.314.0-rc.1
13:08:52 tester-1=0.314.0-rc.1
13:09:14 tester-1=0.314.0-rc.1
13:09:36 tester-1=0.314.0-rc.1
13:09:58 tester-1=0.314.0
@@ -0,0 +1,35 @@
control NEGATIVE (a stub answering 200 over plain http): convicted, as it must (200 -)
== tester-1 (enkicsifelhom.hu): 13 public name(s)
OK control POSITIVE http://felhom.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://felhom.enkicsifelhom.hu/r1033-check?q=1
OK http://files.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://files.enkicsifelhom.hu/r1033-check?q=1
OK http://media.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://media.enkicsifelhom.hu/r1033-check?q=1
OK http://no-such-name-r1033.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://no-such-name-r1033.enkicsifelhom.hu/r1033-check?q=1
OK http://paperless.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://paperless.enkicsifelhom.hu/r1033-check?q=1
OK http://paste.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://paste.enkicsifelhom.hu/r1033-check?q=1
OK http://plex.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://plex.enkicsifelhom.hu/r1033-check?q=1
OK http://prowlarr.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://prowlarr.enkicsifelhom.hu/r1033-check?q=1
OK http://radarr.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://radarr.enkicsifelhom.hu/r1033-check?q=1
OK http://sonarr.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://sonarr.enkicsifelhom.hu/r1033-check?q=1
OK http://torrent.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://torrent.enkicsifelhom.hu/r1033-check?q=1
OK http://transmission.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://transmission.enkicsifelhom.hu/r1033-check?q=1
OK http://wiki.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://wiki.enkicsifelhom.hu/r1033-check?q=1
== demo-hp (enkisfelhom.hu): 13 public name(s)
OK http://books.enkisfelhom.hu/r1033-check?q=1 -> 301 https://books.enkisfelhom.hu/r1033-check?q=1
OK http://docs.enkisfelhom.hu/r1033-check?q=1 -> 301 https://docs.enkisfelhom.hu/r1033-check?q=1
OK http://felhom.enkisfelhom.hu/r1033-check?q=1 -> 301 https://felhom.enkisfelhom.hu/r1033-check?q=1
OK http://files.enkisfelhom.hu/r1033-check?q=1 -> 301 https://files.enkisfelhom.hu/r1033-check?q=1
OK http://gist.enkisfelhom.hu/r1033-check?q=1 -> 301 https://gist.enkisfelhom.hu/r1033-check?q=1
OK http://ha.enkisfelhom.hu/r1033-check?q=1 -> 301 https://ha.enkisfelhom.hu/r1033-check?q=1
OK http://no-such-name-r1033.enkisfelhom.hu/r1033-check?q=1 -> 301 https://no-such-name-r1033.enkisfelhom.hu/r1033-check?q=1
OK http://papir.enkisfelhom.hu/r1033-check?q=1 -> 301 https://papir.enkisfelhom.hu/r1033-check?q=1
OK http://paste.enkisfelhom.hu/r1033-check?q=1 -> 301 https://paste.enkisfelhom.hu/r1033-check?q=1
OK http://pdf.enkisfelhom.hu/r1033-check?q=1 -> 301 https://pdf.enkisfelhom.hu/r1033-check?q=1
OK http://plex.enkisfelhom.hu/r1033-check?q=1 -> 301 https://plex.enkisfelhom.hu/r1033-check?q=1
OK http://travel.enkisfelhom.hu/r1033-check?q=1 -> 301 https://travel.enkisfelhom.hu/r1033-check?q=1
OK http://wiki.enkisfelhom.hu/r1033-check?q=1 -> 301 https://wiki.enkisfelhom.hu/r1033-check?q=1
== demo-felhom (demo-felhom.eu): 4 public name(s)
OK http://felhom.demo-felhom.eu/r1033-check?q=1 -> 301 https://felhom.demo-felhom.eu/r1033-check?q=1
OK http://files.demo-felhom.eu/r1033-check?q=1 -> 301 https://files.demo-felhom.eu/r1033-check?q=1
OK http://gist.demo-felhom.eu/r1033-check?q=1 -> 301 https://gist.demo-felhom.eu/r1033-check?q=1
OK http://no-such-name-r1033.demo-felhom.eu/r1033-check?q=1 -> 301 https://no-such-name-r1033.demo-felhom.eu/r1033-check?q=1
== every public name redirects http -> https
@@ -0,0 +1,15 @@
== tester-1
gitea.dooplex.hu/admin/felhom-controller:0.314.0
containers=17 unhealthy=0 restarting=0 exited=0
controller ERROR lines last 10m: 0
== demo-hp
gitea.dooplex.hu/admin/felhom-controller:0.314.0
containers=21 unhealthy=0 restarting=0 exited=0
controller ERROR lines last 10m: 0
== demo-felhom
gitea.dooplex.hu/admin/felhom-controller:0.314.0
containers=5 unhealthy=0 restarting=0 exited=0
controller ERROR lines last 10m: 0
== tester-1 (2nd pass) controller-docker-health=healthy dashboard-via-traefik=200
== demo-hp (2nd pass) controller-docker-health=healthy dashboard-via-traefik=200
== demo-felhom (2nd pass) controller-docker-health=healthy dashboard-via-traefik=200
@@ -0,0 +1 @@
2026-10-15T13:11:04Z POST /configuration/global-floor 0.314.0 MinAgent 0.155.0 -> HTTP/1.1 303 See Other Location: /configuration?flash=floor_set
@@ -0,0 +1,9 @@
13:13:43 POST /customers/tester-1/floor (empty = clear) -> HTTP/1.1 303 See Other Location: /customers/tester-1?flash=floor_set
13:13:43 POST /customers/demo-hp/floor (empty = clear) -> HTTP/1.1 303 See Other Location: /customers/demo-hp?flash=floor_set
13:13:43 POST /customers/demo-felhom/floor (empty = clear) -> HTTP/1.1 303 See Other Location: /customers/demo-felhom?flash=floor_set
demo-felhom|''
demo-hp|''
tester-1|''
Tester-2|''
min_controller_version|0.314.0
min_controller_version_declared_min_agent|0.314.0=0.155.0
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""repoint.py mirror|drill|restore|status — point the Tester 1 box at the drill catalog WITHOUT a credential (R-992).
WHY A LOCAL MIRROR. `09` §6.5's route needs a catalog credential on the box: the drill repo is private (R-992). This
session could not mint a READ-ONLY one — the Gitea credential on DooPlex is itself a token, and Gitea answers 403 to a
token asking to list or create tokens (measured 2026-10-14) — and the night of 2026-10-06 put the admin WRITE token
into the box's controller.yaml, which this task forbids. The cache-staging workaround of 2026-10-12 does not hold for a
test that lasts days: every sync (and every controller start) re-applies the live catalog over the staged templates
and over the installed apps' compose files (Syncer.copyTemplates, no deployed check).
So the drill catalog is MIRRORED onto the box: a bare git repository inside the controller's own data volume, and
`git.repo_url: file:///opt/docker/felhom-controller/drill-catalog.git`. The controller's sync runs exactly as for a
remote (clone/fetch/reset, the R-615 re-clone on a changed URL); no secret is on the box. DooPlex updates the mirror
from the drill repo with a git bundle (`mirror`). The drill repo on Gitea stays the record.
SC=~/.felhom-tester1 TARGET=tester-1 python3 repoint.py mirror # (re)load the mirror from the drill repo main
... repoint.py drill # save controller.yaml (once), set repo_url to the mirror, restart, sync, rescan
... repoint.py restore # put the saved controller.yaml back (byte-identical), restart, sync, rescan
... repoint.py status
"""
import os, subprocess, sys, time
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w
DRILL = "/mnt/5_hdd/felhom.eu/drill/app-catalog-drill"
VOL = "/var/lib/docker/volumes/felhom-controller-data/_data" # guest path of the controller's data volume
CVOL = "/opt/docker/felhom-controller" # the same, inside the controller container
SAVE = f"{VOL}/controller.yaml.pre-prowlarr1015"
MIRROR_URL = f"file://{CVOL}/drill-catalog.git"
def status():
print(w.guest(f"""grep -nE 'repo_url|sync_interval' {VOL}/controller.yaml; ls -l {SAVE} 2>&1 | tail -1
docker exec felhom-controller sh -c 'git -C {CVOL}/drill-catalog.git log -1 --format="mirror main %h %s" main 2>&1;
git -C {CVOL}/data/catalog-cache log -1 --format="cache HEAD %h %s" 2>&1; git -C {CVOL}/data/catalog-cache remote get-url origin'"""))
def mirror():
b = "/tmp/drill-prowlarr.bundle"
subprocess.run(["git", "-C", DRILL, "bundle", "create", b, "main"], check=True, capture_output=True)
head = subprocess.run(["git", "-C", DRILL, "rev-parse", "--short", "main"], capture_output=True, text=True).stdout.strip()
ssh = w.ssh_args()
# bundle -> PVE host -> guest -> controller volume; the bare repo is made and fetched BY the controller's own user
subprocess.run(["scp", "-q", "-o", "StrictHostKeyChecking=accept-new"] + (["-J", w._T["jump"]] if w._T["jump"] else [])
+ [b, f"{w._T['ssh']}:{b}"], check=True)
subprocess.run(ssh + [f"pct push {w.GUEST} {b} {VOL}/drill-prowlarr.bundle && rm -f {b}"], check=True)
print(w.guest(f"""set -e
docker exec felhom-controller sh -c 'test -d {CVOL}/drill-catalog.git || git init -q --bare -b main {CVOL}/drill-catalog.git
git -C {CVOL}/drill-catalog.git fetch -q {CVOL}/drill-prowlarr.bundle +main:main && rm -f {CVOL}/drill-prowlarr.bundle
git -C {CVOL}/drill-catalog.git log -1 --format="mirror main %h %s" main'"""))
print("drill repo main:", head)
os.remove(b)
def restart_sync():
w.guest("docker restart felhom-controller >/dev/null")
for _ in range(60):
time.sleep(3)
try:
w.login()
break
except SystemExit:
continue
w.sync_rescan()
time.sleep(3)
print(w.guest("docker logs --since 3m felhom-controller 2>&1 | grep -E '\\[sync\\]' | grep -v DEBUG | tail -8"))
def drill():
print(w.guest(f"""set -e
test -f {SAVE} || cp -p {VOL}/controller.yaml {SAVE}
sed -i -E 's#^(\\s+repo_url: ).*$#\\1{MIRROR_URL}#' {VOL}/controller.yaml
grep -n 'repo_url' {VOL}/controller.yaml; diff {SAVE} {VOL}/controller.yaml || true"""))
restart_sync()
status()
def restore():
print(w.guest(f"""set -e
cp -p {SAVE} {VOL}/controller.yaml
cmp {SAVE} {VOL}/controller.yaml && echo RESTORED-IDENTICAL; grep -n repo_url {VOL}/controller.yaml"""))
restart_sync()
status()
{"mirror": mirror, "drill": drill, "restore": restore, "status": status}[sys.argv[1]]()
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""walk_gate_restore.py (from prowlarr-2026-10-15/tools/walk_restore.py) — R-1057 live proof on controller 0.314.0-rc.1, and
NEW-APP-CHECKLIST 2.5 for Prowlarr on scratch guest 9202: seed -> backup -> remove -> restore ->
read back, THROUGH THE PRODUCT (R-1048, 2026-10-15, second 9202 run).
The box has no per-app „Mentés most" (R-648: the only backup press is whole-box and is never pressed from a drill), so
the per-app backup is the one the guarded Update takes in its `backing-up` phase — the same route as grocy's and
lubelogger's restore records. Runs ON DOOPLEX with TARGET=9202, SC=<0600 dir holding .ctlpw>, EV=<evidence dir>; 9202
must already read the drill mirror, whose prowlarr template is at FROM. Presses: deploy, the drill bump + Update,
remove KEEPING the backups, the household's restore button (POST /backup/restore), remove with the backups.
"""
import json
import os
import re
import subprocess
import sys
import time
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w # noqa: E402
import upgrade_fixtures_box as fx # noqa: E402
EV = os.environ["EV"]
APP, SUB = "prowlarr", "prowlarr"
FROM = "lscr.io/linuxserver/prowlarr:version-2.5.2.5491"
TO = "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"
TOOLS = os.path.dirname(os.path.abspath(__file__))
LOG = open(os.path.join(EV, "restore.txt"), "a", buffering=1)
_lib_say = w.say
def say(*a):
_lib_say(*a)
LOG.write(" ".join(str(x) for x in a) + "\n")
w.say = say
def drill_set(ref, why):
"""Point the drill template at `ref`, staged by explicit path, then reload the 9202 mirror."""
D = w.DRILL
comp, fy = f"{D}/templates/{APP}/docker-compose.yml", f"{D}/templates/{APP}/.felhom.yml"
w.sh(["git", "-C", D, "pull", "-q", "--rebase", "origin", "main"], timeout=120)
c_ = open(comp).read()
other = TO if ref == FROM else FROM
if f"image: {ref}" not in c_:
open(comp, "w").write(c_.replace(f"image: {other}", f"image: {ref}"))
w.sh(["git", "-C", D, "add", f"templates/{APP}/docker-compose.yml", f"templates/{APP}/.felhom.yml"])
w.sh(["git", "-C", D, "commit", "-q", "-m", f"DRILL {APP}: {why} -> {ref} (R-1048, checklist 2.5)"])
r_ = w.sh(["git", "-C", D, "push", "-q", "origin", "main"], timeout=120)
say(f"drill commit {w.sh(['git', '-C', D, 'rev-parse', '--short=12', 'HEAD']).stdout.strip()}: -> {ref} (push rc={r_.returncode})")
subprocess.run([sys.executable, os.path.join(TOOLS, "repoint9202.py"), "mirror"], check=True, env=dict(os.environ))
def main():
w.login()
if w.stack(APP).get("deployed"):
sys.exit(say("prowlarr is installed on 9202 — STOP, not a fresh install") or 1)
drill_set(FROM, "back to the previous image for a step")
w.sync_rescan(expect_app=APP, expect_ref=FROM)
code, d = w.ctl("POST", f"/api/stacks/{APP}/deploy", {"values": {"DOMAIN": w.DOMAIN, "SUBDOMAIN": SUB}})
say(f"[1] deploy -> {code} {json.dumps(d, ensure_ascii=False)[:120]}")
w.DEPLOYED_THIS_RUN.add(APP)
for _ in range(60):
s = w.stack(APP)
if s.get("deployed") and s.get("state") == "running":
break
time.sleep(3)
w.wait_app(SUB, "/ping", want=("200",), tries=40)
say("[1] through traefik: " + w.guest(f"curl -sk -o /dev/null -w '%{{http_code}}' -H 'Host: {SUB}.{w.DOMAIN}' https://127.0.0.1/ping").strip()
+ " (gated: 302 to the gate is the healthy answer for a stranger)")
say("[1] stack .felhom.yml bytes: " + w.guest(f"wc -c < /opt/docker/stacks/{APP}/.felhom.yml").strip())
say("[1] running: " + w.guest("docker inspect prowlarr -f '{{.Config.Image}} health={{.State.Health.Status}}'").strip())
fxp = fx.FIXTURES[APP]
tok = fxp.seed(w, SUB, say)
ok1 = bool(tok) and fxp.verify(w, SUB, tok, say)
say(f"[2] seed {tok} read back: {ok1}")
say("[2] 2.7 config volume size after the seed: " + w.guest("du -sh /var/lib/docker/volumes/prowlarr_prowlarr_config/_data | cut -f1").strip())
say("[2] 4.4 negative control, the same probe on a port nothing serves: rc=" + w.guest("docker exec prowlarr curl -sf -o /dev/null http://127.0.0.1:9697/ping; echo $?").strip()
+ " ; the real probe: rc=" + w.guest("docker exec prowlarr curl -sf -o /dev/null http://127.0.0.1:9696/ping; echo $?").strip())
say("[2] 1.8 the app's own start lines: " + w.guest("docker logs prowlarr 2>&1 | grep -m2 -E 'Hosting environment|Application started'").strip().replace("\n", " | "))
drill_set(TO, "the step")
w.sync_rescan(expect_app=APP, expect_ref=TO)
up = w.press_update(APP)
say(f"[3] guarded Update (its backing-up phase is the per-app backup): final={up.get('final_phase')} "
f"phases={[p.get('phase') for p in up.get('phases', [])]}")
ok2 = bool(tok) and fxp.verify(w, SUB, tok, say)
say(f"[3] seed read back after the Update: {ok2}")
obs = w.observables(APP)
say(f"[3] after: {json.dumps(obs, ensure_ascii=False)[:400]}")
s = w.stack(APP)
if os.environ.get("VERDICT") == "1":
verdict = {"app": APP, "venue": "box 9202 (drill catalog), the product's guarded Update — run 3, the full .felhom.yml",
"from": {APP: FROM}, "to": {APP: TO},
"verdict": "proven" if (ok1 and ok2 and up.get("final_phase") == "done" and s.get("state") == "running") else "failed",
"seed_read_before": ok1, "seed_read_after": ok2, "healthy_after": s.get("state") == "running",
"duration_s": up.get("duration_s"), "final_phase": up.get("final_phase"),
"measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"evidence": "felhom.eu/documentation/audits/gate-restore-2026-10-15/9202/restore.txt"}
os.makedirs(os.path.join(EV, "..", APP), exist_ok=True)
json.dump(verdict, open(os.path.join(EV, "..", APP, "box-verdict-prowlarr.json"), "w"), indent=2)
say(f"[3] box verdict {verdict['verdict']}")
say("[3] R-1057 the per-app backup's unit app.yaml, its gate lines: " + w.guest(
f"f=$(find / -xdev -path '*{APP}*' -name app.yaml -path '*compose*' -newer /etc/felhom-controller-image 2>/dev/null | head -3); "
"for x in $f; do echo \"$x: $(grep -A2 '^setup_gate:' $x | tr '\\n' ' ')\"; done").strip())
snaps = w.snapshots(APP)
say(f"[4] restore points offered BEFORE the remove: {json.dumps(snaps, ensure_ascii=False)[:400]}")
c1, d1 = w.ctl("POST", f"/api/stacks/{APP}/stop")
say(f"[4] stop -> {c1}")
for _ in range(24):
time.sleep(5)
if w.stack(APP).get("state") != "running":
break
code, d = w.ctl("POST", f"/api/stacks/{APP}/remove", {"remove_hdd_data": False, "remove_backups": False})
say(f"[4] remove KEEPING the backups -> {code} {json.dumps(d, ensure_ascii=False)[:300]}")
time.sleep(5)
say("[4] left after remove: " + w.guest("echo containers=$(docker ps -aq -f name=^prowlarr$ | wc -l) volumes=$(docker volume ls -q | grep -c prowlarr)").strip())
rr = w.restore(APP)
say(f"[5] restore: {json.dumps({k: rr.get(k) for k in ('ok', 'snapshot_id', 'http', 'seconds', 'state_after', 'hold_after', 'why')}, ensure_ascii=False)}")
w.wait_app(SUB, "/ping", want=("200",), tries=40)
ok3 = bool(tok) and fxp.verify(w, SUB, tok, say)
say(f"[5] 2.5 the seed reads back after remove + restore: {ok3}")
for p, acc in (("/ping", "application/json"), ("/", "text/html"), ("/initialize.json", "application/json")):
r = w.sh(["curl", "-sSk", "--max-time", "20", "-o", "/dev/null", "-D", "-", "-H", f"Host: {SUB}.{w.DOMAIN}",
"-H", f"Accept: {acc}", f"{w.BASE}{p}"])
st = re.match(r"HTTP/\S+ (\d+)", r.stdout or ""); loc = re.search(r"(?im)^location:\s*(\S+)", r.stdout or "")
say(f"[6] R-1057 a STRANGER after the restore: GET {p} -> {st.group(1) if st else '000'} {('loc=' + loc.group(1)[:70]) if loc else ''}")
rc, code, _ = w.app_curl(SUB, "/ping", timeout=20)
say(f"[6] the HOUSEHOLD (gate cookie) after the restore: GET /ping -> {code}")
st = w.stack(APP)
say(f"[6] gate record after the restore: {json.dumps((st.get('app_config') or {}).get('setup_gate'))}")
say("[6] gate file on the box: " + w.guest(f"ls /opt/docker/stacks/traefik/dynamic/setup-gate-{APP}.yml 2>&1").strip())
code, d = w.ctl("POST", f"/apps/{APP}/setup-gate/open")
say(f"[7] the household's „Kész, beállítottam” -> {code} {json.dumps(d, ensure_ascii=False)[:120]}")
time.sleep(8)
r = w.sh(["curl", "-sSk", "--max-time", "20", "-o", "/dev/null", "-w", "%{http_code}", "-H", f"Host: {SUB}.{w.DOMAIN}", f"{w.BASE}/ping"])
say(f"[7] a stranger after „Kész”: GET /ping -> {r.stdout} (the app itself answers now)")
say("[5] through traefik after restore: " + w.guest(f"curl -sk -o /dev/null -w '%{{http_code}}' -H 'Host: {SUB}.{w.DOMAIN}' https://127.0.0.1/ping").strip())
say("[5] app.yaml env keys after restore: " + w.guest(f"sed -n '/^env:/,/^[a-z]/p' /opt/docker/stacks/{APP}/app.yaml | grep -oE '^ +[A-Z_]+' | paste -sd' '").strip())
say("[5] image after restore: " + w.guest("docker inspect prowlarr -f '{{.Config.Image}} health={{.State.Health.Status}}'").strip())
# teardown through the product, backups too (this is a scratch install)
w.remove(APP)
say("[9] after remove: " + w.guest("echo containers=$(docker ps -aq -f name=^prowlarr$ | wc -l) volumes=$(docker volume ls -q | grep -c prowlarr)").strip())
json.dump({"seed": tok, "seed_before": ok1, "after_update": ok2, "after_restore": ok3,
"update_final": up.get("final_phase"), "restore": {k: rr.get(k) for k in ("ok", "snapshot_id", "http", "seconds", "state_after")},
"measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())},
open(os.path.join(EV, "restore-results.json"), "w"), indent=2, ensure_ascii=False)
main()
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""walk_link.py (from prowlarr-2026-10-15/tools/walk_9201_link.py) — R-1058 live proof on controller 0.314.0-rc.1:
Sonarr first, then Prowlarr: the link must appear with NO button; a second run changes nothing.
(original docstring:) walk_9201_link.py — the read-back on demo-hp 9201 from the LIVE catalog after the publish (R-1048, brief Part D3).
Runs ON DOOPLEX with TARGET=9201, SC=<0600 dir holding .ctlpw>, EV=<evidence dir>. Presses only what a person presses:
POST /api/sync, the deploy wizard's POST /api/stacks/<n>/deploy, the dashboard's „Bekötés most" (POST
/api/storage/media/wire), stop + remove KEEPING data. Reads Prowlarr's own API inside its container with the key from
its own config.xml (never printed), exactly as the box's wiring does.
The two orders the controller's code implies (mediawire runs when a media_library app comes up; Prowlarr has no marker):
B Sonarr first, Prowlarr after -> expected NO link by itself; „Bekötés most" makes it
A Radarr after Prowlarr -> expected the link by itself, ~60 s after Radarr comes up
Then: Prowlarr's own connection test for each link, and its sync command to completion.
"""
import json
import os
import re
import shlex
import sys
import time
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w # noqa: E402
EV = os.environ["EV"]
os.makedirs(EV, exist_ok=True)
LOG = open(os.path.join(EV, "link.txt"), "a", buffering=1)
TO = "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"
HDD = os.environ.get("HDD", "/mnt/felhom-drives/hdd_1")
RES = {}
_lib_say = w.say
def say(*a):
_lib_say(*a)
LOG.write(" ".join(str(x) for x in a) + "\n")
w.say = say
def papi(path, body=None, method=None):
# FIXED 2026-10-15 after the first run: json.dumps() quoted this with DOUBLE quotes, so the guest shell expanded
# $(sed …) and $K before docker exec — empty key, every call 401 (link.txt [B3]/[C2]). shlex.quote keeps it literal.
"""Prowlarr's own API from inside its container (the key read from its config.xml at use, never printed)."""
m = method or ("POST" if body is not None else "GET")
data = json.dumps(body) if body is not None else ""
script = ("K=$(sed -n 's:.*<ApiKey>\\(.*\\)</ApiKey>.*:\\1:p' /config/config.xml); "
f"curl -s -o /tmp/pb.json -w '%{{http_code}}' -X {m} -H \"X-Api-Key: $K\" -H 'Content-Type: application/json' "
f"--data-binary @- http://127.0.0.1:9696/api/v1{path}; echo; cat /tmp/pb.json")
out = w.guest(f"docker exec -i prowlarr sh -c {shlex.quote(script)} <<'EOF'\n{data}\nEOF")
code, _, body_ = out.partition("\n")
try:
return code.strip(), json.loads(body_) if body_.strip() else None
except Exception:
return code.strip(), body_[:300]
def apps():
c, d = papi("/applications")
return [(a.get("name"), a.get("syncLevel"), next((f.get("value") for f in a.get("fields", []) if f.get("name") == "baseUrl"), None))
for a in (d or [])] if c == "200" else c
def wiring_lines(since):
return w.guest(f"docker logs --since {since} felhom-controller 2>&1 | grep -E '\\[media\\] wiring|\\[mediawire\\]' | cut -c1-400").strip()
def install(name, values):
if w.stack(name).get("deployed"):
sys.exit(say(f"{name} is already installed on 9201 — STOP, a household's app is never reused") or 1)
code, d = w.ctl("POST", f"/api/stacks/{name}/deploy", {"values": values})
say(f" deploy {name} {json.dumps(values)} -> {code} {json.dumps(d, ensure_ascii=False)[:120]}")
w.DEPLOYED_THIS_RUN.add(name)
for _ in range(90):
time.sleep(5)
st = w.stack(name)
if st.get("deployed") and (st.get("app_config") or {}).get("pinned_images") and st.get("state") == "running":
say(f" {name}: deployed, running, pinned={(st.get('app_config') or {}).get('pinned_images')}")
return True
sys.exit(say(f" {name} never became running — STOP") or 1)
def wait_healthy(c, tries=60):
for _ in range(tries):
if w.guest(f"docker inspect {c} -f '{{{{.State.Health.Status}}}}'").strip() == "healthy":
return True
time.sleep(5)
return False
def main():
w.login()
for a in ("prowlarr", "sonarr"):
if w.stack(a).get("deployed"):
sys.exit(say(f"{a} is installed — STOP") or 1)
say("[0] controller: " + w.guest("cat /etc/felhom-controller-image").strip())
t0 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
install("sonarr", {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "sonarr", "HDD_PATH": HDD})
wait_healthy("sonarr")
time.sleep(75)
say("[1] wiring after Sonarr came up:\n" + wiring_lines(t0))
tP = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
install("prowlarr", {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "prowlarr"})
wait_healthy("prowlarr")
linked = None
for i in range(18):
time.sleep(10)
a = apps()
if isinstance(a, list) and any(n == "Sonarr" for n, _, _ in a):
linked = (i + 1) * 10
say(f"[2] R-1058 Sonarr appeared in Prowlarr's Apps with NO button, ~{linked} s after Prowlarr ran: {a}")
break
if linked is None:
say(f"[2] R-1058 FAILED: no link 180 s after Prowlarr ran; Apps = {apps()}")
say("[2] wiring runs since Prowlarr's install:\n" + wiring_lines(tP))
RES["linked_after_s"] = linked
code, d = w.ctl("POST", "/api/storage/media/wire", {})
pr = [r for r in ((d or {}).get("data") or []) if isinstance(r, dict) and r.get("app") == "prowlarr"]
say(f"[3] a second run („Bekötés most”) -> {code}; prowlarr result: {json.dumps(pr, ensure_ascii=False)} (expected: unchanged, nothing written)")
RES["second_run"] = pr
c, full = papi("/applications")
for a in (full or []):
tc, td = papi("/applications/test", a)
say(f"[4] Prowlarr's connection test for {a.get('name')} -> {tc}")
for a in ("prowlarr", "sonarr"):
c1, _ = w.ctl("POST", f"/api/stacks/{a}/stop")
for _ in range(24):
time.sleep(5)
if w.stack(a).get("state") != "running":
break
code, d = w.ctl("POST", f"/api/stacks/{a}/remove", {"remove_hdd_data": False, "remove_backups": True})
say(f"[X] {a}: remove (keep drive data, drop backups) -> {code} {json.dumps(d, ensure_ascii=False)[:200]}")
time.sleep(5)
say("[X] left: " + w.guest("echo containers=$(docker ps -aq -f name='^(prowlarr|sonarr)$' | wc -l) volumes=$(docker volume ls -q | grep -cE '^(prowlarr|sonarr)_')").strip())
json.dump(RES, open(os.path.join(EV, "link-results.json"), "w"), indent=2, ensure_ascii=False)
main()
@@ -0,0 +1,9 @@
Host plex: claim(fake code)=401 identity=200 sections-no-token=401
Host 172.18.0.15: claim(fake code)=500 identity=200 sections-no-token=200
Oct 15, 2026 14:22:19.292 [139838622022456] DEBUG - Request came in with unrecognized domain / IP 'plex' in header Host; treating as non-local
Oct 15, 2026 14:22:19.292 [139838622022456] DEBUG - Request: [172.18.0.5:52552 (Subnet)] POST /myplex/claim?token=<fake> (2 live) #700
Oct 15, 2026 14:22:19.292 [139838622022456] DEBUG - Completed: [172.18.0.5:52552] 401 POST /myplex/claim?token=<fake> (2 live) #700 0ms 268 bytes
Oct 15, 2026 14:22:19.378 [139838622022456] DEBUG - Request came in with unrecognized domain / IP 'plex' in header Host; treating as non-local
Oct 15, 2026 14:22:19.463 [139838622022456] DEBUG - Request came in with unrecognized domain / IP 'plex' in header Host; treating as non-local
Oct 15, 2026 14:22:19.552 [139838519249720] DEBUG - Request: [172.18.0.5:52584 (Subnet)] POST /myplex/claim?token=<fake> (2 live) #708
Oct 15, 2026 14:22:20.519 [139838622022456] DEBUG - Completed: [172.18.0.5:52584] 500 POST /myplex/claim?token=<fake> (2 live) #708 966ms 325 bytes (pipelined: 1)
+9
View File
@@ -26,6 +26,15 @@
---
## 2026-10-15 (evening) — controller v0.314.0, the first release under decision 250 (the floor raised by CC's token)
| Row | What | Closed | Evidence |
|---|---|---|---|
| **R-1057** | **A restore of a REMOVED gated app came back without its setup gate, even from a backup made before the setup.** (P2 — raised from P3 by the reviewer 2026-10-15) | CLOSED 2026-10-15 — **controller v0.314.0** (`09` §3 decision 253): the per-app backup records the gate; a restore puts it back as it was (closed then → closed now, file first; open then → open; no record → closed until the probe or „Kész"). Unit tests red-proved by ten mutations. **Live on 9202** (rc.1): Prowlarr installed, no account, the Update's per-app backup, remove keeping backups, restore → a stranger got `/ping` 401, `/` 302 to the gate, `/initialize.json` 401; the household 200; the box's log: „restored with its setup gate CLOSED — it was closed when the backup was taken (R-1057)"; „Kész" then opened it. Before the fix the same walk gave the stranger 200. Plex: the same rule, proven by a unit test with Plex's gate shape (no live Plex restore). | `audits/gate-restore-2026-10-15/9202/restore.txt`, `02-controller-log.txt` |
| **R-1053** | **A box on an `-rc` tag was never moved by the floor.** (P3) | CLOSED 2026-10-15 — **controller v0.314.0**: `util.ParseVersionPre` puts `X.Y.Z-rc.N` below `X.Y.Z` for the floor only; the button never offers a test build; an unreadable version WARNs once per change. **Live:** Tester 1 put on 0.314.0-rc.1 by hand (rc.4 runs the old check and could never leave by itself), then the floor 0.314.0 moved it: „Post-update startup: update successful (0.314.0-rc.1 → 0.314.0)" at 13:09:38 UTC. | `audits/gate-restore-2026-10-15/release/01-tester1-rc1.txt`, `03-boxes-move.txt` |
| **R-1023** | **Build: media apps see every drive, and the box says when they must restart.** (P2) | CLOSED 2026-10-15 — delivered by controller v0.313.0 and the floor; its last step was Tester 1 off the test build (R-1053): Tester 1 runs the release 0.314.0 since 13:09:38 UTC, moved by the floor by itself. | `audits/gate-restore-2026-10-15/release/03-boxes-move.txt`, `audits/media-build-2026-10-15/` |
| **R-1058** | **Prowlarr installed AFTER Sonarr/Radarr was not linked until „Bekötés most".** (P3) | CLOSED 2026-10-15 — **controller v0.314.0**: Prowlarr's own start runs the media wiring (`linkOnlyApps`). **Live on 9202** (rc.1): Sonarr first, then Prowlarr → „a media app came up: prowlarr", Sonarr in Prowlarr's Apps ~40 s later, no button; a second run „unchanged"; Prowlarr's connection test 200. Catalog `e67aad4` and the website card drop the button sentence. | `audits/gate-restore-2026-10-15/9202/link.txt` |
## 2026-10-15 (evening) — Prowlarr in the live catalog (catalog 73b45a6)
| Row | What | Closed | Evidence |
+7 -10
View File
@@ -124,7 +124,7 @@ stopping line that lies.
| **R-494** | Install & onboarding | P4 | **NARROWED 2026-09-14 by operator ruling → [P3-LOW] the hub COULD create the tunnel at customer creation, for a domain already on Cloudflare. Not blocking: every customer has their own domain and the operator creates the tunnel per day-0 A.1 (`architecture/01-topology-and-trust.md`).** *Original finding, kept:* **[P1-HIGH] A new customer's dashboard has NO reachable address unless the operator hand-makes a Cloudflare tunnel — the link in the setup-code mail is dead.** MEASURED 2026-09-14 on a fresh install from the public ISO (drill intervention **I1**): the claim mail points at `https://felhom.drill0242.felhom.eu`; that name has **no A and no AAAA** record (`dig @1.1.1.1`, control `felhom.enkisfelhom.hu` resolves); the hub has **no tunnel- or DNS-creation code** (`hub/internal/cloudflare/` holds only geo-rule removal; `cf_tunnel_token` is a pasted, optional form field, `configs.go:1478`) — day-0 runbook A.1 makes it a manual Cloudflare-dashboard step that nothing on the customer-create page asks for; the box's own split-horizon resolver on the appliance LAN IP answered `google.com` but not the dashboard name at 13:27:39Z; the agent applied the record at **13:27:44Z** (`lanresolver: applied split-horizon record … ip=192.168.0.158`, 3 m 46 s after the controller started), so the box CAN answer the name — **but only to a device that uses the box as its DNS server, and no document, screen or mail tells a household to do that**; the router and the installer-offered DNS answer nothing. The page was reachable only at the guest's LAN address with the name forced (`curl --resolve …:443:192.168.0.158`). **A volunteer could not have done that.** **What it needs:** an operator ruling — the hub creates the tunnel and DNS at customer creation, or the product gives a household a LAN address that works with no DNS change. | **READY — rank P3-LOW; owner: CC** **Re-ranked 2026-10-03: P3→P4: operator-side automation; the operator creates the tunnel by hand per the day-0 runbook.** | — | — | CC |
| **R-504** | Install & onboarding | P4 | **[P3-LOW] `iso.felhom.eu` cannot show an index page on its own — its root returns 404, and the download page lives on the website instead.** MEASURED 2026-09-14: `https://iso.felhom.eu/` and `/index.html` → 404; only named objects answer. The host is an R2 bucket behind a custom domain; whether R2 would serve an uploaded `index.html` at `/` was **not measured** (uploading anything to the public bucket is a publication). The ISO v1.27.0 task puts the Hungarian download page at `felhom.eu/letoltes` (published with the ISO, after the operator's yes). **Remaining:** a redirect from `iso.felhom.eu/` to that page needs a Cloudflare rule the session has no credential for. | **WAITING-ON-OPERATOR — rank P3-LOW; owner: operator (Cloudflare rule)** **Re-ranked 2026-10-03: P3→P4: households are sent to the website's download page; the bare address is cosmetic.** | — | — | operator |
## Apps & catalog — 15 rows (P2 2, P3 8, P4 5)
## Apps & catalog — 14 rows (P2 2, P3 7, P4 5)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
@@ -134,10 +134,9 @@ stopping line that lies.
| **R-994** | Apps & catalog | P3 | **Seven apps peak above 80 % of a memory limit, and 23 apps' typical use is above their catalog `mem_request`.** MEASURED 2026-10-13 (`documentation/audits/sizing-2026-10-13/SIZING.md` §3, `evidence/catalog-check.json`). Near a limit — risk: the container is stopped for using too much memory: docmost (container 475 of 512 MB), romm (703 of 768), termix (117 of 128), uptime-kuma (227 of 256, tester-1 telemetry), grafana (435 of 512), immich (`immich-server` 1,665 of 2,048 during a 200-photo import), tandoor (827 of 1,024). No container was killed during the bench. Under-stated request (typical ÷ request): calcom ×4.0, adventurelog ×3.6, termix ×3.4, claper ×2.5, grafana ×2.2, komga ×2.2, docmost ×2.1, recipe-importer ×2.1, uptime-kuma ×2.1, zipline ×2.0, and 13 more ≤ ×1.8. Templates NOT changed (read-only run). | **READY** — filed 2026-10-13 | — | Raise the seven limits and the 23 requests in their templates, each through the catalog's harness; re-measure Immich with a larger library first | CC |
| **R-1018** | Apps & catalog | P3 | **The OnlyOffice links (FileBrowser, Nextcloud) have no recorded live proof since the move to Proxmox.** `controller/internal/integrations/` holds two handlers; no audit after the Proxmox move shows a document opened from FileBrowser or Nextcloud in OnlyOffice (searched 2026-10-14). The media-app links of `15` §6 will copy this code, including its revoke-on-stop rule (`lifecycle.go:10-13`). | **OPEN** — filed 2026-10-14 | — | One live walk on a Tier-0 box: install OnlyOffice + FileBrowser, open a document, save, read it back; then stop one app and record what the link does | CC |
| **R-1028** | Apps & catalog | P3 | **Plex's claim code can run out during the install, and the box cannot recover it.** plex.tv's code lives 4 minutes from the moment the household copies it; the clock covers the wizard, the image pull (`plexinc/pms-docker` is 351 MB, measured) and the first start. Plex reads `PLEX_CLAIM` only on its first run, and the field is `locked_after_deploy` — so a late code leaves an unclaimed server and the only way back is remove + install again. Reported by the operator during the 2026-10-14 test; on Tester 1's line the pull+start took 8.5 s and the claim worked (`audits/media-all-drives-2026-10-14/``G-auth/G3-plex-claim.txt`), so it bites on a slow line or a slow hand. **K6, 2026-10-14 night (`audits/media-drive-events-2026-10-14/``K6-1-unclaimed-plex.txt`):** an unclaimed Plex starts with no code at all, and `POST /myplex/claim?token=` on the RUNNING server reaches plex.tv (a wrong code → 500 after plex.tv's 401). So the box can pull + start first and ask for the code after: no race. A real claim needs the household's code — the morning step on the throwaway `plex-k6` on Tester 1. Narrowed 2026-10-14 night. **PROVEN 2026-10-15 morning (`K6-2-claim-after-start.txt`):** the operator's real code, given to the RUNNING unclaimed `plex-k6` → 200, `claimed` 0→1, `PlexOnlineToken` written, and the open door closed (no token → 401). `ADVERTISE_IP` was unset and the claim did not need it; whether clients find the server without it was not measurable (the throwaway had no published port). **BUILT 2026-10-15 (v0.313.0):** `after_start` field — not asked at install; the Plex page asks for the code once Plex runs and claims the running server; the gate opens only when Plex says claimed. Proven up to the code on 9201 (G5). | **NARROWED** — the dashboard claim with a real code is the last step | a household Plex code | Claim through the Plex page with a real code (G5c); then close | CC |
| **R-1060** | Apps & catalog | P2 | **On controller 0.313.0 (the floor) the Plex claim button can never work: Plex refuses the code with 401 because the box calls it as `http://plex:32400`.** Plex's own log: „Request came in with unrecognized domain / IP 'plex' in header Host; treating as non-local" → `401 POST /myplex/claim` in 0 ms (plex.tv never asked). Measured 2026-10-15 on demo-hp 9201, two codes, the second used ~1 minute after it was made (`audits/media-build-2026-10-15/G7/G5c-claim.txt`, `G5d-claim.txt`). The K6 spike on Tester 1 called the container by its IP address and got 200 (`audits/media-drive-events-2026-10-14/K6-2-claim-after-start.txt`); the product's `plexClaim` (`felhom-controller` `internal/web/media_events.go`) uses the name, and its test replaces the call with a seam, so no test ever met a real Plex. Since 0.313.0 does not ask the code in the wizard (`after_start`), a new Plex on any box at the floor stays unclaimed and behind its setup gate — safe, but unusable. | **OPEN** — filed 2026-10-15 | — | Dial Plex by the container's IP (or send a Host header Plex accepts); a test against a real Plex, red first; then claim 9201's waiting Plex with a fresh code | CC |
| **R-1060** | Apps & catalog | P2 | **On controller 0.313.0 (the floor) the Plex claim button can never work: Plex refuses the code with 401 because the box calls it as `http://plex:32400`.** Plex's own log: „Request came in with unrecognized domain / IP 'plex' in header Host; treating as non-local" → `401 POST /myplex/claim` in 0 ms (plex.tv never asked). Measured 2026-10-15 on demo-hp 9201, two codes, the second used ~1 minute after it was made (`audits/media-build-2026-10-15/G7/G5c-claim.txt`, `G5d-claim.txt`). The K6 spike on Tester 1 called the container by its IP address and got 200 (`audits/media-drive-events-2026-10-14/K6-2-claim-after-start.txt`); the product's `plexClaim` (`felhom-controller` `internal/web/media_events.go`) uses the name, and its test replaces the call with a seam, so no test ever met a real Plex. Since 0.313.0 does not ask the code in the wizard (`after_start`), a new Plex on any box at the floor stays unclaimed and behind its setup gate — safe, but unusable. | **NARROWED** — filed 2026-10-15; the fix shipped in controller v0.314.0 (the floor since 13:11 UTC): all Plex calls by address (`mediawire.PlexURL`), red-proved against a fake Plex that refuses the name. Live on 9201 with a FAKE code through the dashboard's claim call: Plex took it as local and asked plex.tv (500 after 938 ms; before: 401 in 0 ms) — `audits/gate-restore-2026-10-15/plex9201/01-fake-code.txt`. A fifth call by name remains — the generic health probe's `GET :32400/identity` — and is harmless: `/identity` answers anyone (200 measured) | the operator's claim code | Claim 9201's waiting Plex through the dashboard with a fresh code (a watcher waits for `~/.felhom-demohp/plex-claim`); prove claimed, gate open, a stranger meets Plex's own login, one library wiring run; then close | CC |
| **R-1029** | Apps & catalog | P3 | **Plex's folder picker shows the drives as identical „media” folders.** With one bind per drive at the same path (`/mnt/felhom-drives/<drive>/userdata/media`), the leaf of every library folder is `media`; the drive's name sits two levels up, so in Plex's picker the household sees several folders all called „media” (reported by the operator during the 2026-10-14 test on Tester 1). It conflicts with `15` §2 reason 2 (the same path everywhere). | **READY** — filed 2026-10-14 | the layout decision (STATUS D9) | Decide in `15` §2: the players (`:ro`, never linked) may get a readable path per drive (e.g. `/media/<drive label>`) without breaking the hardlink rule, which binds only the download/library apps | CC |
| **R-1036** | Apps & catalog | P3 | **The apps page lags the catalog by two apps.** `app-catalog-felhom.eu/templates/` holds **64** apps; `/alkalmazasok` and `/en/apps` render **63** cards, one of which (`filebrowser`) is built in and not a catalog template — so **62 catalog apps are listed**. Missing: **`plant-it`** and **`wger`**, both in the catalog since 2026-10-06/08. Their website assets are ALREADY present (`plant-it-logo.svg` + 3 screenshots, `wger-logo.png` + 3 screenshots, renamed to the catalog slug by `a3507761`, 2026-10-08) — only the cards were never written, so this is not a new-asset job. Found 2026-10-14 while reconciling the app count for the „felhő tárhely” page, where four places said **60** and the rest said **62**. **Done in that session:** every stated count on the site set to **62** (`index.html`, `szolgaltatasok.html`, `en/services.html` were 60), which is what the apps page actually shows — so no visitor can count the cards and find the number wrong. **62 is still not the catalog's number.** **To close:** write the two cards on both apps pages (gate 15 keeps the two pages' app sets equal, so they move together), then raise every stated count 62 → 64 in the same commit. **The operator chose this split at the 2026-10-14 STOP** over doing it inside the SEO change. **2026-10-15 (R-1048):** Prowlarr's card added on both pages and every stated count 62 → 63 (the media group 19 → 20), so the site still states what it shows; the two owed cards would make it 65. **Note before writing them:** `wger` is `lifecycle: hidden` and `plant-it` `lifecycle: abandoned` — neither is offered for a new install, so a card would advertise an app the wizard refuses. | **OPEN** | — | write the `plant-it` + `wger` cards on both apps pages, then 62 → 64 everywhere, one commit | CC |
| **R-1058** | Apps & catalog | P3 | **Prowlarr installed AFTER Sonarr/Radarr is not linked to them until the household presses „Bekötés most" (or a media app restarts).** The box's wiring runs when an app with the `media_library` marker comes up (`felhom-controller` `internal/stacks/manager.go` `onMediaUp`); Prowlarr has no marker (it uses no drive), so its own install starts no run. Measured 2026-10-15 on demo-hp 9201, live catalog, controller 0.313.0 (`audits/prowlarr-2026-10-15/box9201/link.txt`): Sonarr first → its run; Prowlarr after → no run in 150 s; „Bekötés most" wrote `application Sonarr`; Radarr after Prowlarr → linked by itself ~60 s later. The template copy and the website card tell the household this today. | **OPEN** — filed 2026-10-15 by the Prowlarr read-back | — | Start the wiring when Prowlarr comes up too (a list of link-only apps beside the marker, no drive binds); red-proof with the 9201 order (Sonarr, then Prowlarr); then drop the button sentence from the copy | CC |
| **R-76** | Apps & catalog | P4 | **FileBrowser-created folders break the setgid chain, and a drop-zone's mode is not stable** **MIGRATED FROM `ROADMAP.md` 2026-08-22 (R-369) — originally filed 2026-07-26, size S, roadmap state `idea (surfaced by the R-75 spike, 2026-07-26)`.** Moved verbatim; nothing added or reinterpreted. The roadmap keeps its copy as history, marked moved. | **OPEN — migrated from ROADMAP 2026-08-22, rank unchanged** **2026-10-06 night: not a catalog fix** — the setgid chain is set by the controller's FileBrowser setup; a controller row. | — | Two related findings from `audits/SPIKE-catalog-data-paths-2026-07-26.md` P3/P5, both **pre-existing** and deliberately left alone by that spike. **(a)** FileBrowser Quantum 1.3.3 creates files `0644` and folders `0755` and does **not** propagate the setgid bit — even though the entrypoint wrapper's `umask 002` really is in effect (`/proc/1/status` `Umask: 0002`). Group inheritance itself works (a file uploaded into a 2775 group-100 dir landed group 100, not the process gid 1000), so the convention's *group* half holds and only its *mode* half is lost. The consequence is proven with a control: inside a UI-created `0755` folder a gid-1000 process's file landed group **1000**, while the identical write into the 2775 parent landed group **100**. So **any folder a customer creates through FileBrowser breaks the shared-group chain one level down.** Latent today — every userdata-touching catalog app that declares an identity declares uid/gid **1000**, the same uid FileBrowser runs as, so owner permissions mask it; it bites the day a content app runs as a different non-root uid with gid 1000. The comment at `infra/infra.go:156` is right that the image ignores `-e UMASK` but does not say t | CC |
| **R-707** | Apps & catalog | P4 | **[P2] 37 apps still start with a login a stranger can take (`09` §3 decision 45).** Audit of all 53 apps: `app-catalog-felhom.eu/FIRST-ADMIN.md` (class, fix route, status, measured or read). Open: **3 hard-coded defaults** — calibre-web (`admin / admin123`, measured working on demo-hp and 9202; its own `cps.py -s` route needs a generated password WITH a special character — our generator is letters+digits, a controller change), mealie (`changeme@example.com / MyPassword`), wger (`admin / adminadmin`); **34 open first-run screens** (the first visitor creates the admin: actualbudget, adventurelog, audiobookshelf, calcom, docmost, emby, ghost, gitea, gramps-web, home-assistant, homebox, immich, jellyfin, komga, n8n, navidrome, opengist, outline, papra, plant-it, radarr, rallly, recipe-importer, romm, seerr, sonarr, sparkyfitness, tandoor, termix, uptime-kuma, vikunja, wanderer, wishlist, zipline). **Stale notes:** romm's `default_creds` `admin / admin` answers 401 on demo-hp (like a wrong password) — the page now warns with a login that does not exist; zipline's looks stale too. **Measured on demo-hp 2026-09-28 (read-only):** bookstack's default still logs in on the INSTALLED app (the fix is for new installs; the page now warns). Each fix: route (a) env or (b) the app's own CLI/API via `after_install:`, proven on 9202 with the default failing and the generated password working; route (c) a page sentence. Several sessions (operator, 2026-09-28). **2026-09-29 (controller v0.280.0, catalog `d0e7e2e`):** every class-3 app fixed — mealie, wger, calibre-web by `after_install` (calibre-web with the new `password:24:special`), proven on 9202 fresh installs (`audits/login-gate-2026-09-29/D/`); the setup gate (decision 46, spike PASSED) built and live on immich, n8n, audiobookshelf (probes measured) and uptime-kuma (button) (`…/C/`); romm's and zipline's stale notes removed. **Left: 30 class-4 apps** — gate each (probe measured on 9202 where one exists — 11 upstream candidates listed in `…/B/B-VERDICT.md` §3; the button otherwise). **2026-09-29 afternoon (controller v0.281.0, catalog `6faf432`):** 28 more class-4 apps gated — 32 of 34 — each proven on 9202 (`audits/gate-rollout-2026-09-29/`B): stranger → gate page / 401, household reached the first-setup screen, the gate opened (9 by a measured probe, the rest by the press), the app answered after. seerr, outline, rallly: gated, their opening needs a media server / e-mail (not proven). **Left:** wanderer (R-714); plant-it is not installable. | **NARROWED** (2026-10-03 triage: the row's verdict was finished, but it names open work no other row carries — seerr, outline and rallly are gated, but the gate OPENING is not proven (needs a media server / e-mail)) — **CLOSED — 2026-09-29 (the rest → R-714)** | — | — | CC |
| **R-905** | Apps & catalog | P4 | **wger's collected style files (283 MB) go into every wger backup, though the app rebuilds them at every start.** Since R-762's fix (`DJANGO_DEBUG=False` + `collectstatic`, catalog `cf1ed43`) the static files sit in a named volume that the backup legs copy like data (measured 283 MB, `audits/design-build-2026-10-06/`); `collectstatic` regenerates them, so they are rebuildable bytes in every Tier-1 unit, Tier-2 copy and off-site snapshot. wger is `lifecycle: hidden` and no box runs it (hub read 2026-10-08). Options to weigh: a non-backed-up volume class for regenerable data, or an anonymous volume. Found by the R-762 helpers 2026-10-08. | **OPEN — needs a design before wger is shown** | — | Design the „regenerable volume" exclusion (catalog + controller backup legs) | CC |
@@ -189,16 +188,15 @@ stopping line that lies.
| **R-878** | Backup & restore | P4 | **A catch-up (R-871) runs the database-dump leg in the DAY, and that leg stops an app with a volume for its copy — the household may notice the stop, and a large volume makes it longer.** MEASURED 2026-10-05 on demo-felhom: the catch-up at 08:25:02 stopped opengist, copied 182.5 KB, started it again — about 1 s, then a few seconds of `health: starting`; the night does exactly the same, unseen. Nothing measured for a large volume. Fix direction (if it matters): skip the volume copy of a running app in a DAYTIME catch-up and leave it to the next night, or warn. `audits/catchup-2026-10-05/partA/live-demo-felhom.txt` | **READY — owner: CC** **2026-10-05 (burn-down night): NEEDS A LIVE MEASUREMENT** — a timed catch-up on 9202 for an app with a multi-GB volume, then the skip-or-warn choice. | — | measure a large volume first | CC |
| **R-32** | Backup & restore | P4 | **[P2-HIGH] RESET must purge the customer base dir; the orphan card must stay honest; unattributed bytes must be visible.** The rehearsal's S7 said in advance that an orphan card would BE a finding — and one appeared (16:58:14). Cause: RESET's `"hetzner":"ok"` leg destroys the sub-account, but **a Hetzner sub-account is an access-control object, not a data object** — its directory survives, so re-enabling offsite recreated an account over the previous lifecycle's ciphertext, encrypted under a key that same RESET had destroyed. **MIGRATED FROM `ROADMAP.md` 2026-08-22 (R-369) — originally filed 2026-07-21, size M, roadmap state `idea`.** Moved verbatim; nothing added or reinterpreted. The roadmap keeps its copy as history, marked moved. | **OPEN — migrated from ROADMAP 2026-08-22, rank unchanged** **2026-10-06 night: design written** (`audits/night-burndown-2026-10-06/design-R-32.md`) — pick A: purge the repo + set-aside copies through the sub-account's own password login BEFORE deleting it (no main-account credential); the „repo data destroyed" comment at `hub/internal/offsite/offsite.go:276-280` is false for the shared tier; part (3) waits on a read-only measurement (`du` on port 23). Operator: agree to route A. **2026-10-07 07:58: `09` §3 decision 167 — option A yes, and delete the old RESET leftovers once.** **2026-10-07 (Part D step 2, read only):** the hub shows bx11 holding 3.3 GB of data while the four live customers account for ~0.6 GB → **~2.7 GB belongs to no live customer**; no live customer shows a set-aside folder, so the leftovers are in homes whose sub-account is gone, reachable only by the pool box's MAIN account. **The one-time clean-up is STOPPED:** no main-account login for bx11 is held on DooPlex (the API token in the credentials file sees no box). Needs: the main-account login, or the operator lists and deletes in the Hetzner console (`audits/day-2026-10-07/D/D2-listing-readonly.txt`). **2026-10-07: option A DELIVERED (hub 0.142.0):** RESET purges the repo and every `.orphaned-*` through the sub-account's own login before deleting it, and keeps the sub-account if the purge fails (red-proved, `audits/day-2026-10-07/D/`). Not yet seen live (no scratch RESET today). **The one-time clean-up of the ~2.7 GB of leftovers is STOPPED:** it needs the pool box's main-account login (see above). **2026-10-07 14:43 — `09` §3 decision 173: leave the ~2.7 GB of old leftovers for now; re-ranked P2 → P4.** Option A itself is delivered (hub 0.142.0). What stays open is ONLY the leftovers: they were computed (box total minus live customers), never listed, and deleting them needs the pool box's MAIN login, which reaches every customer's data. **Blocker: a safe main-account path.** | — | **Ruling from the run (three parts, deliberately separate):** (1) because RESET destroys custody, the ciphertext it leaves behind is unrecoverable **BY DESIGN** → RESET gains a **main-account purge of the customer base dir** (the existing operator ack already covers it); (2) the **move-aside guard STAYS** for reinstall-*without*-RESET — there custody survives and the card's "history recoverable" promise is true (R-26 depends on exactly that); (3) the operator **Restic tab shows per-customer directory bytes vs attributed snapshot bytes**, so dead data cannot hide. Measured on the pool box that night: **49 M attributed** (2 snapshots, 48.717 MiB) against **1.4 G + 3.0 M unattributed** across TWO `.orphaned-*` dirs. Evidence `restic-and-pool.txt` | CC |
## Storage & devices — 4 rows (P2 1, P3 2, P4 1)
## Storage & devices — 3 rows (P3 2, P4 1)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
| **R-1023** | Storage & devices | P2 | **Build: media apps see every drive, and the box says when they must restart (operator rulings 2026-10-14, `09` §3).** Scope from `architecture/15-media-library.md`: one bind per drive of `userdata/media/<library folders>` at the same path; the drive-absent gate per bind (today it keys on the app's one `HDD_PATH`, and the boot gate waits only for THAT drive — measured after the 17:25 power cut); the restart notice after a drive is added, removed or comes back, with „restart now” / „later” and a per-drive „apps restarted since” mark; removal, export and backup classes taught that a shared library is no app's own (R-1027, R-1019). **S5/S6 measured 2026-10-14:** a pulled drive stops none of the media apps (they key on another drive), they keep a dead view; on return nothing restarts them — so the restart notice MUST also cover „a drive came back”, the restart must be stop+start, and Sonarr/Radarr's own health page says nothing about an unreachable root folder, so the box must say it. **Night 2026-10-14 measured every event × app (`15` §10, `audits/media-drive-events-2026-10-14/`):** nothing is lost while a drive is gone (K1), so the build needs NO scan/unmonitor/torrent guard; it DOES need: (1) one bind per PRESENT drive only — a bind to an absent path makes the app unstartable (K3-4: 500 „error while creating mount source path … permission denied”); (2) the media apps recreated without the drive BEFORE a safe removal unmounts it (R-1044); (3) a returned drive restarts every container that binds it, FileBrowser included (K5: 2–10 s per app); (4) „restart now” cuts a playing stream (K5-4: cut, back in 22 s) — check Plex/Jellyfin sessions first or offer „later”; (5) the stale view is found with NO disk I/O: each container's `/proc/<pid>/mountinfo` shows the old device with `shutdown` (K4). Narrowed 2026-10-14 night. **BUILT 2026-10-15 — controller v0.313.0 + catalog `0188430` (`15` §11; `audits/media-build-2026-10-15/`): proven on Tester 1 (G1–G4, G6) and demo-hp 9201 (G7).** What is left is delivery. **2026-10-15 11:29: the operator vouched golden 0.313.0 and raised the global floor to 0.313.0 (read back from the hub DB).** demo-felhom moved to 0.313.0 by itself through the floor; demo-hp was put back on 0.313.0 by hand (it ran the test tag rc.4 and has its own floor override 0.311.0); Tester 1 still runs rc.4, which the floor never moves (R-1053) — fenced in the 2026-10-15 afternoon task. | **NARROWED** — delivered by the floor; Tester 1 waits on R-1053's step | R-1053 (Tester 1 back on a release tag) | Put Tester 1 on the 0.313.0 tag (or let R-1053's fix move it); read it back; close | CC |
| **R-330** | Storage & devices | P3 | **Disk health Phase 2 — the three SMART attributes the wire does not carry.** The failing drive's most telling counter was **187 `Reported_Uncorrect`**, sitting at normalized **1** against threshold **0** with a raw count of **1001** — one point from failing and structurally unable to get there. Also wanted: **199 `UDMA_CRC_Error_Count`** (cabling) and **188 `Command_Timeout`**. None are on the agent→controller wire today, so v0.215.0's ladder could not use them. Phase 2 also persists periodic SMART **samples** (the right home is `metrics.MetricsStore`, NOT the Phase-1 state file, which is one record per disk and must stay that way). **This is a declared WIRE change, so under the G-1 gate the hub must model the new fields in the SAME session** — that is precisely why it was kept out of Phase 1, where it would have turned a one-word severity fix into a three-repo change | **READY** — **2026-10-12: the wire half is CLOSED in this row** — released in agent v0.150.0 (boxes run 0.154.0), controller and hub decode 187/188/199. **Only open work:** the verdict slice (use the three counters in the disk verdict and the household's words) and persisting samples. **READY (M) — NEW 2026-08-14** **2026-10-06 night: the wire half BUILT on main (unreleased):** the agent sends SMART 187 `reported_uncorrect`, 188 `command_timeout` (raw, vendor-packed on some drives — carried as reported), 199 `udma_crc_errors` (pointer + omitempty: unknown is absent, never 0); the controller decodes them and the hub's host-report mirror models them (G-1 wire gate green; it convicted the agent half alone). **Carried only** — no verdict, banner, mail or alarm reads them (`TestR330_CountersChangeNoVerdictYet`); using them changes what a household is told and is the next slice, with persisting samples. Red-proofs `audits/night-burndown-2026-10-06/r330/`. Ships with agent v0.150.0 and the next controller and hub releases. | R-328 (closed) | The verdict slice: 187/188/199 into the disk verdict with tests; then persist samples | CC |
| **R-332** | Storage & devices | P3 | **The new Hiba-from-counters path has never fired on real hardware.** v0.215.0's whole point is a verdict the product could not previously reach, and it is proven only against the committed fixture's values in unit tests (12 scenario groups, 11 of 12 red-proofs failing as required). The live validation on demo-hp proved the **negative** — three healthy disks still read Rendben across the deploy, no false alert — and the **severity wire** end to end, but no live disk has actually reached Hiba. **This is the honest gap and it must not be closed by pointing at the fixture tests**: the drive that produced the fixture is in DooPlex, which is Tier 2 and never a drill target, and the demo boxes are all-flash and healthy | **WATCHING — NEW 2026-08-14, NARROWED same day.** One item originally in this gap is now PROVEN LIVE: the **persisted state surviving a controller restart**. The v0.215.0→v0.216.0 redeploy destroyed and rebuilt the container, and the new one read back a `changed_at` written by the PREVIOUS version (`2026-08-14T07:23:14.640216851Z`, still intact at 09:31:35Z) instead of re-baselining — Scenario L on real hardware, not just the production-path unit test. **What remains unproven is the verdict itself, plus the stronger restart half: an already-ALERTED disk not re-alerting** | a real degrading disk, or an injection harness | **Closing condition:** a live disk reaching Hiba from counters, OR a deliberate injection through the REAL pipeline (agent `/disks` → controller check → hub event), not a hand-set verdict | CC |
| **R-331** | Storage & devices | P4 | **Disk health Phase 3 — growth-rate detection, and retiring the static 64.** The v0.215.0 count backstop (64 unreadable sectors → Hiba) is **a judgement from ONE drive**: the observed benign excursion peaked at 16 and cleared inside an hour, and the terminal run passed 64 at 13 Aug 11:28 and never came back. It is deliberately a backstop BEHIND the sustain rule, not the primary signal, but it is still a magic number tuned on a single sample and it will be wrong for some drive. With Phase 2's history the box can ask the question that actually matters — *is this count climbing, and how fast* — which distinguishes a drive with eight stable aging sectors from one adding forty a day, something no static threshold can do. Revisit 64 when that exists | **READY (M) — NEW 2026-08-14** | R-330 | Growth-rate rule over persisted samples; re-derive or delete the static 64 | CC |
## Security & access — 22 rows (P1 2, P2 3, P3 11, P4 6)
## Security & access — 21 rows (P1 2, P2 3, P3 10, P4 6)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
@@ -223,9 +221,8 @@ stopping line that lies.
| **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 |
| **R-1050** | Security & access | P4 | **Raise HSTS to one year on or after 2026-11-15, if no https trouble was reported.** `09` §3 decision 249 (D2 = A, operator 2026-10-15): HSTS stays `max-age=86400` now; after a month with no https trouble it becomes one year (`31536000`); never preload, never `includeSubDomains`. The value is `HSTSMaxAgeSeconds` in `felhom-controller/controller/internal/infra/infra.go`, pinned by `TestRenderForwardedHeaders_HTTPSOnly`. „Trouble” = any report of a name that will not open, or a box answering http after the floor. | **WATCHING** — filed 2026-10-15 | the date | On or after 2026-11-15: read the register and the operator's mail for https trouble since 2026-10-15; none → a controller release with one year (test updated), bake → vouch → floor | CC |
| **R-1054** | Security & access | P3 | **The hub's operator/global API key is still one bearer that can do everything on `/api/v1`, including reading every box's break-glass root password (`GET /admin/hosts/{id}/recovery-credential`).** Hub v0.152.0 gave the web surface scoped tokens (`05` §17) but tokens are refused on `/api/v1` by design. Who uses the global key today is not listed in one place. Fix: list its consumers; move each operator use to a scoped token on the web side (or a token class on the API), and keep the global key only where a box path needs it — or retire it. | OPEN — filed 2026-10-15 by the token build | — | List the global key's consumers (pod env, scripts, runbooks) | CC |
| **R-1057** | Security & access | P3 | **A restore of a REMOVED gated app comes back without its setup gate, even when the backup was taken before the household's setup — so the app returns open, with no account.** Measured 2026-10-15 on 9202 with Prowlarr (`audits/prowlarr-2026-10-15/box/run3/restore.txt`): installed (gated, no account made), the guarded Update's per-app backup, remove keeping the backups, the household's restore button — afterwards a stranger's `/ping` through traefik answered 200, where before the remove it answered 401 (the gate). This follows the documented rule (`felhom-controller` `internal/stacks/life_records.go` `carryLifeRecords`: no prior record = no gate, „the data comes back with its admin"); the rule's assumption fails for a backup made before the setup. For a first-visitor app (Prowlarr, n8n, …) the first stranger sets the sign-in; for Prowlarr `/initialize.json` also hands out the API key (seen before the setup in the install window — NOT measured after this restore). Needs: the household to install, let a backup run before finishing the setup, remove, restore. | **OPEN** — filed 2026-10-15 by the Prowlarr walk | — | Record the gate's state in the per-app backup and re-close it on restore when it was closed then; red-proof with this walk (`tools/walk_restore.py`) | CC |
## Box system & updates — 13 rows (P2 1, P3 9, P4 3)
## Box system & updates — 13 rows (P2 1, P3 8, P4 4)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
@@ -240,8 +237,8 @@ stopping line that lies.
| **R-1004** | Box system & updates | P4 | **After an unclean stop every guest ext4 volume waits ~43 s in multi-mount protection before it mounts, so each restart after a power cut takes ~1.5 min longer.** Kernel: „EXT4-fs warning (device dm-6): ext4_multi_mount_protect: MMP interval 42 higher than expected, please wait" on dm-6/dm-7 and loop0/loop1, demo-hp and Tester 1, every round of `audits/night-power-2026-10-13/` (R1 `repairs.txt`). | **READY** — filed 2026-10-14 | — | CC: find where MMP is switched on for the guest volumes (Proxmox `pct` create, or our own mkfs), whether it protects anything on a one-host disk, and measure the restart without it on a scratch guest | CC |
| **R-1013** | Box system & updates | P4 | **After a power cut the agent's start-up stale-lock pass logs an ERROR „pct unlock failed … no lock found" when Proxmox's own start-all removed the lock in the same second.** The outcome is the goal (no lock), so the line is a false alarm in the log. Seen on demo-hp 2026-10-14 12:07:44–45 UTC (`audits/power-cut-fixes-2026-10-14/E/after-boot.txt`; Proxmox: „removed left over backup lock from '9201'!"). Small, but the agent's release for this session is spent (v0.156.1). | **OPEN** — filed 2026-10-14 | — | CC: treat „no lock found" from `pct unlock` as already clear (INFO), with a test; ride the next agent release | CC |
| **R-1010** | Box system & updates | P3 | **The agent's stale-lock janitor cannot remove a half-removed `vzdump` snapshot: Proxmox's delete fails on the first missing volume („Failed to find logical volume pve/snap_vm-9201-disk-0_vzdump") and the janitor retries the same call every 10 min forever, leaving the other volume's snapshot and the snapshot entry.** Seen 2026-10-14 after a backup ran out of space on a full host root (LVM could not write its metadata; `audits/power-cut-fixes-2026-10-14/README.md` C4). Fixed by hand with `pct delsnapshot 9201 vzdump --force`. | **READY** — filed 2026-10-14 | — | CC: on that error, retry once with `--force` (a sudoers line or the API's `force=1`), with a test that replays the error text; next agent release | CC |
| **R-1052** | Box system & updates | P3 | **The new-box path is not proven: a fresh install from a golden older than the floor reaching the floor by itself at first boot.** `09` §3 decision 250 relies on it (the golden is weekly, releases ride the floor). Proof owed at the next controller release: a throwaway VM on demo-hp installed from the ISO (the vouched golden one release behind the floor); time from claim to the floor; the controller's self-update log lines; then the VM and its hub records removed. | **OPEN** — filed 2026-10-15 | the next controller release | Run it at the next release; quote the times | CC |
| **R-1053** | Box system & updates | P3 | **A box running a controller test tag (`0.313.0-rc.N`) is never moved by the floor, and says so only at DEBUG.** `util.ParseVersion` wants X.Y.Z, so `maybeAutoUpdate` logs `current … not parseable (dev?) — skip` and the box stays on the test build forever (measured 2026-10-15 on demo-hp and Tester 1 after the floor reached 0.313.0; demo-felhom, on a release tag, moved by itself). The same parser keeps an rc tag off the household's update button — that half is wanted. Today's guard is procedural (the `felhom-build-deploy` skill: write the release tag back after testing). Tester 1 still runs rc.4. | **READY** — filed 2026-10-15 | — | A controller fix: treat `X.Y.Z-rc.N` as a pre-release BELOW `X.Y.Z` for the floor (and keep it off the button), log the skip at WARN; a test per case. Meanwhile put Tester 1 on 0.313.0 | CC |
| **R-1052** | Box system & updates | P3 | **The new-box path is not proven: a fresh install from a golden older than the floor reaching the floor by itself at first boot.** `09` §3 decision 250 relies on it (the golden is weekly, releases ride the floor). Proof owed at the next controller release: a throwaway VM on demo-hp installed from the ISO (the vouched golden one release behind the floor); time from claim to the floor; the controller's self-update log lines; then the VM and its hub records removed. | **BLOCKED** — filed 2026-10-15; not run with the 0.314.0 release: the proof needs a throwaway customer, and creating a customer is session only since hub 0.153.0 (`09` §3 decision 252) — CC's token is refused by design | the operator: create a throwaway customer (or name one to reuse) in the browser | Then: a VM on demo-hp from the current ISO, claimed as that customer; time from claim to the floor; remove the VM; the operator deletes the customer (session only) | CC |
| **R-1061** | Box system & updates | P4 | **A per-customer floor reached Tester 1 only at its next 15-minute report, not in seconds.** Set 2026-10-15 12:55:51 UTC for tester-1, demo-hp and demo-felhom together (hub 0.153.0, CC's token): demo-hp and demo-felhom ran 0.314.0 within 13 s (the hub's wake); Tester 1 moved at 13:09:35, its regular report (`audits/gate-restore-2026-10-15/release/03-boxes-move.txt`). It had restarted at 12:54:29 onto rc.1 and logged „hub wait channel active". Why the wake did not reach it is not read (the wake line is DEBUG). No harm: the floor arrived. | **OPEN** — filed 2026-10-15 | — | Read Tester 1's DEBUG ring at the next floor change; compare with the hub's bump log | CC |
## Monitoring & notifications — 10 rows (P2 1, P3 6, P4 3)