From b17e9e993c7eb4ebf1afceec8fb87642919cebec Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 16 Oct 2026 12:24:39 +0200 Subject: [PATCH] hub v0.155.0: the floor can never deliver a withdrawn controller build (R-1085); RomM/Ghost evidence Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- .../architecture/09-update-architecture.md | 18 +++ .../romm-ghost-2026-10-16/A1/a1-install.txt | 89 ++++++++++++ .../romm-ghost-2026-10-16/A1/a1-measure.txt | 56 ++++++++ .../A1/a1-remove-broken.txt | 29 ++++ .../romm-ghost-2026-10-16/A1/a1-start.txt | 14 ++ .../romm-ghost-2026-10-16/A2/a-fresh.txt | 56 ++++++++ .../romm-ghost-2026-10-16/B/b-after.txt | 55 ++++++++ .../romm-ghost-2026-10-16/B/b-before.txt | 13 ++ .../romm-ghost-2026-10-16/B/hub-redproofs.txt | 11 ++ .../romm-ghost-2026-10-16/C1/c1-measure.txt | 38 +++++ .../romm-ghost-2026-10-16/C2/c2-e-off.txt | 19 +++ .../romm-ghost-2026-10-16/C2/c2-e-on.txt | 42 ++++++ .../teardown/docker-ps-after.txt | 22 +++ .../teardown/docker-ps-before.txt | 22 +++ .../teardown/teardown.txt | 35 +++++ hub/CHANGELOG.md | 17 +++ hub/cmd/hub/main.go | 6 + hub/internal/api/handler.go | 8 ++ hub/internal/api/spoiled_floor_ack_test.go | 53 +++++++ hub/internal/store/spoiled_tags.go | 41 ++++++ hub/internal/store/spoiled_tags_test.go | 110 +++++++++++++++ hub/internal/store/store.go | 17 +++ hub/internal/web/configs.go | 33 ++++- hub/internal/web/server.go | 11 +- hub/internal/web/spoiled_floor_test.go | 130 ++++++++++++++++++ hub/internal/web/templates/configuration.html | 4 + .../web/templates/customer_unified.html | 4 +- skills/felhom-build-deploy/SKILL.md | 8 +- 28 files changed, 955 insertions(+), 6 deletions(-) create mode 100644 documentation/audits/romm-ghost-2026-10-16/A1/a1-install.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/A1/a1-measure.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/A1/a1-remove-broken.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/A1/a1-start.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/A2/a-fresh.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/B/b-after.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/B/b-before.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/B/hub-redproofs.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/C1/c1-measure.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/C2/c2-e-off.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/C2/c2-e-on.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/teardown/docker-ps-after.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/teardown/docker-ps-before.txt create mode 100644 documentation/audits/romm-ghost-2026-10-16/teardown/teardown.txt create mode 100644 hub/internal/api/spoiled_floor_ack_test.go create mode 100644 hub/internal/store/spoiled_tags.go create mode 100644 hub/internal/store/spoiled_tags_test.go create mode 100644 hub/internal/web/spoiled_floor_test.go diff --git a/documentation/architecture/09-update-architecture.md b/documentation/architecture/09-update-architecture.md index 31810d03..dce5b69e 100644 --- a/documentation/architecture/09-update-architecture.md +++ b/documentation/architecture/09-update-architecture.md @@ -43,6 +43,24 @@ this closes the half that let it happen. number. A box already on the bad image is moved by the normal update path — never by re-tagging, which would be the same defect wearing the fix's clothes. +**[DESIGN] A withdrawn build is never selected (hub v0.155.0, R-1081, R-1085).** The rule above +leaves the spoiled numbers in the registry, so nothing may pick them. Two controller tags hold +other code than their number says: **`0.315.0`** (re-pushed with v0.316.0's code under it) and +**`0.316.3`** (built from a working copy holding another session's uncommitted files). The +household's button and the daily auto-update take the HIGHEST tag and cannot reach them; the +**floor** targets a version exactly, so the floor is where the guard lives, in two halves: +- **at input** — both floor forms (global and per customer, by session or by token) and the + confirm dialog's impact probe refuse a listed tag (`flash=floor_spoiled`; the probe answers + `valid:false, spoiled:true`). The check is its own, NOT inside `normalizeFloorInput`, which also + validates agent and golden numbers; +- **at serve time** — `store.ResolveManagedFloor` HOLDS a listed floor (served as none, + `HeldSpoiled`, its own reason text) however it arrived: a value stored before the refusal, or the + config/env default (which no form checks; the hub also warns about it at start). The stored + value is not rewritten; the Configuration and customer pages say „withdrawn". +The list is `hub/internal/store/spoiled_tags.go`; the hand deploy reads the same list as a marked +line in the `felhom-build-deploy` skill, and a test fails when the two differ. A new spoiled +build is added to both in the same commit. + ## 1. How an update works today, as measured ### 1.1 The button diff --git a/documentation/audits/romm-ghost-2026-10-16/A1/a1-install.txt b/documentation/audits/romm-ghost-2026-10-16/A1/a1-install.txt new file mode 100644 index 00000000..3e49a90a --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/A1/a1-install.txt @@ -0,0 +1,89 @@ +11:29:43 catalog compose on the box: 73: - ${USERDATA_PATH}/roms:/romm/library + +11:29:47 [1] made the drive paths this app requires: ['/mnt/felhom-drives/hdd_1/userdata/romm'] +11:29:47 [1] required fields filled beyond DOMAIN/SUBDOMAIN: ['HDD_PATH'] +11:29:49 [1] deploy -> 409 {'ok': False, 'data': {'code': 'kept_data_choice', 'title': 'Ennek az alkalmazásnak megvannak a régi adatai', 'body': 'A +11:29:49 deploy ok: False +11:29:49 gate: arcade not routed right now (404) — not cached, asked again next call +11:29:54 gate: arcade not routed right now (404) — not cached, asked again next call +11:29:59 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:04 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:09 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:15 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:20 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:25 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:30 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:35 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:40 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:45 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:50 gate: arcade not routed right now (404) — not cached, asked again next call +11:30:55 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:00 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:05 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:10 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:15 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:20 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:25 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:30 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:35 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:40 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:45 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:50 gate: arcade not routed right now (404) — not cached, asked again next call +11:31:55 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:00 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:06 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:11 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:16 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:21 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:26 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:31 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:36 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:41 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:46 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:51 gate: arcade not routed right now (404) — not cached, asked again next call +11:32:56 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:01 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:06 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:11 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:16 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:21 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:26 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:31 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:36 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:41 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:46 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:51 gate: arcade not routed right now (404) — not cached, asked again next call +11:33:57 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:02 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:07 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:12 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:17 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:22 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:27 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:32 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:37 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:42 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:47 gate: arcade not routed right now (404) — not cached, asked again next call +11:34:52 app never answered on arcade/api/heartbeat (last rc=0 code=404) +11:34:52 app answers: False +11:34:55 + +11:35:38 [1] made the drive paths this app requires: ['/mnt/felhom-drives/hdd_1/userdata/romm'] +11:35:38 [1] required fields filled beyond DOMAIN/SUBDOMAIN: ['HDD_PATH'] +11:35:39 [1] deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'} +11:36:59 [1] deployed, controller state=running, pinned={'romm': 'rommapp/romm:5.3.1', 'romm-db': 'mariadb:11.8', 'romm-redis': 'redis:7-alpine'} +11:36:59 deploy ok: True +11:36:59 gate: arcade is gated — passed as the household (cookie set) +11:37:10 app answers: True +11:37:13 romm Up 58 seconds (healthy) +romm-db Up About a minute (healthy) +romm-redis Up About a minute (healthy) +/var/lib/docker/volumes/6445a3c2c18f80fdeda1d2de2652be8872e5795b2f2a764d967f2b283ab7f4b9/_data -> /redis-data +/var/lib/docker/volumes/0f9051c4da8c562072e97a3ff57d36dadd9377699effea716b3eb8a9be4bac93/_data -> /romm +/var/lib/docker/volumes/romm_romm_config/_data -> /romm/config +/mnt/felhom-drives/hdd_1/userdata/roms -> /romm/library +/mnt/felhom-drives/hdd_1/appdata/romm/resources -> /romm/resources + +drwxr-xr-x 3 root root 4096 Oct 16 09:36 romm +resources + diff --git a/documentation/audits/romm-ghost-2026-10-16/A1/a1-measure.txt b/documentation/audits/romm-ghost-2026-10-16/A1/a1-measure.txt new file mode 100644 index 00000000..5596943b --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/A1/a1-measure.txt @@ -0,0 +1,56 @@ +11:37:36 gate: arcade is gated — passed as the household (cookie set) +11:37:36 heartbeat VERSION 5.3.1 SHOW_SETUP_WIZARD True platforms dir (FILESYSTEM) {"FS_PLATFORMS": [], "TITLE_ID_EXTRACTION_ENABLED": true} +11:37:36 first admin -> 403 CSRF token verification failed +11:37:36 me -> 401 None +11:37:36 == A1 step 1: a dummy file where the first steps say: roms/gba/Test Game (World).gba +11:37:40 /mnt/felhom-drives/hdd_1/userdata/roms +/mnt/felhom-drives/hdd_1/userdata/roms/gba +/mnt/felhom-drives/hdd_1/userdata/roms/gba/Test Game (World).gba +/mnt/felhom-drives/hdd_1/userdata/roms/roms + +11:37:40 scan_library -> 401 {'detail': 'Not authenticated'} +11:40:40 task did not finish in 180 s +11:40:40 library after scan 1: ({'detail': 'Not authenticated'}, None) +11:41:12 gate: arcade is gated — passed as the household (cookie set) +11:41:12 == retry with RomM's CSRF token (token present: True ) +11:41:12 first admin -> 201 ('felhomadmin', 'admin') +11:41:12 me -> 200 felhomadmin +11:41:12 SHOW_SETUP_WIZARD now False +11:41:13 scan_library -> 400 {'detail': "Task 'scan_library' cannot be run"} +11:44:13 task did not finish in 180 s +11:44:13 library after scan 1 (file at roms/gba/): ([], []) +11:44:17 /mnt/felhom-drives/hdd_1/userdata/roms +/mnt/felhom-drives/hdd_1/userdata/roms/gba +/mnt/felhom-drives/hdd_1/userdata/roms/gba/Test Game (World).gba +/mnt/felhom-drives/hdd_1/userdata/roms/roms +INFO: [RomM][nginx][2026-10-16 11:40:40] 172.18.0.4 | 192.168.0.180 | GET /api/platforms 401 | 30 | Unknown Unknown | 0.007 +INFO: [RomM][nginx][2026-10-16 11:41:13] 172.18.0.4 | 192.168.0.180 | POST /api/tasks/run/scan_library 400 | 46 | Unknown Unknown | 0.255 + +11:45:20 == A1 step 1 (scan through the Scan page's socket event, quick scan, all platforms) +11:45:21 romm login -> 200 session cookie set +11:45:22 socket namespace: 40{"sid":"0R_1yzCv45NHDv4BAAAB"} +11:45:22 scan event sent -> 200 OK +11:45:30 scan finished: scan:done [{"total_platforms": 0, "total_roms": 0, "scanned_platforms": 0, "new_platforms": 0, "identified_platforms": 0, "scanned_roms": 0, "new_roms": 0, "identified_roms": 0, "scanned_firmware": 0, "new_firmware": 0, "updated_roms": 0, "new_files": 0}] +11:45:31 library after scan 1 (file at drive roms/gba/ = container /romm/library/gba): ([], []) +11:45:34 INFO: [RomM][auth][2026-10-16 11:45:21] Auto-created web device c0b92a75-760c-4bb6-bc2d-b28aeadfe537 for user felhomadmin +INFO: [RomM][scan][2026-10-16 11:45:22] 🔎 Scanning +11:45:22 Worker dd5e9075c9ed4cbeaea36aa313d3eb48: scans: endpoints.sockets.scan.scan_platforms(launchbox_remote_enabled=True, metadata_sources=[], platform_fs_slugs=[], platform_ids=[], roms_ids=[], scan_type=) job in 0: +11:45:30 Worker dd5e9075c9ed4cbeaea36aa313d3eb48: scans: Job OK (0a5e6873-4731-42a7-a826-bec76bbc9d7a) +11:45:30 Worker dd5e9075c9ed4cbeaea36aa313d3eb48: job 0a5e6873-4731-42a7-a826-bec76bbc9d7a result is kept for 86400 seconds + +11:45:43 == A1 step 2: the same dummy file at drive roms/roms/gba/ (= container /romm/library/roms/gba) +11:45:46 /mnt/felhom-drives/hdd_1/userdata/roms +/mnt/felhom-drives/hdd_1/userdata/roms/gba +/mnt/felhom-drives/hdd_1/userdata/roms/gba/Test Game (World).gba +/mnt/felhom-drives/hdd_1/userdata/roms/roms +/mnt/felhom-drives/hdd_1/userdata/roms/roms/gba +/mnt/felhom-drives/hdd_1/userdata/roms/roms/gba/Test Game (World).gba + +11:45:46 romm login -> 200 session cookie set +11:45:46 socket namespace: 40{"sid":"Tl74vJttkDfal5zMAAAD"} +11:45:46 scan event sent -> 200 OK +11:45:55 scan finished: scan:done [{"total_platforms": 1, "total_roms": 1, "scanned_platforms": 1, "new_platforms": 1, "identified_platforms": 1, "scanned_roms": 1, "new_roms": 1, "identified_roms": 0, "scanned_firmware": 0, "new_firmware": 0, "updated_roms": 0, "new_files": 0}] +11:45:56 library after scan 2: ([('gba', 1)], [('Test Game (World).gba', 'gba', 'roms/gba')]) diff --git a/documentation/audits/romm-ghost-2026-10-16/A1/a1-remove-broken.txt b/documentation/audits/romm-ghost-2026-10-16/A1/a1-remove-broken.txt new file mode 100644 index 00000000..c3fb9a19 --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/A1/a1-remove-broken.txt @@ -0,0 +1,29 @@ +11:28:46 BEFORE: romm stack {'deployed': True, 'state': 'stopped'} +11:28:49 /mnt/felhom-drives/hdd_1/userdata/roms +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21 +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/SENTINEL.txt +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/binary-1mb.bin +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested/őszibarack.md +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/plain.txt +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/árvíztűrő-tükörfúrógép.txt +/mnt/felhom-drives/hdd_1/userdata/roms/roms +romm_romm_config +romm_romm_db_data +romm_romm_redis_data +romm_romm_redis_data.pre-aof-fix-20260924T135937Z + +11:29:16 remove (keep drive data, keep backups) -> 200 {'ok': True, 'data': {'removed': 'romm', 'volumes_removed': ['romm_romm_config', 'romm_romm_db_data', 'romm_romm_redis_data'], 'hdd_paths_removed': [], 'hdd_paths_preserved': ['/mnt/felhom-drives/hdd_ +11:29:24 AFTER: romm stack {'deployed': False, 'state': 'not_deployed'} +11:29:28 /mnt/felhom-drives/hdd_1/userdata/roms +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21 +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/SENTINEL.txt +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/binary-1mb.bin +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested/őszibarack.md +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/plain.txt +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/árvíztűrő-tükörfúrógép.txt +/mnt/felhom-drives/hdd_1/userdata/roms/roms +romm_romm_redis_data.pre-aof-fix-20260924T135937Z +docker-compose.yml + diff --git a/documentation/audits/romm-ghost-2026-10-16/A1/a1-start.txt b/documentation/audits/romm-ghost-2026-10-16/A1/a1-start.txt new file mode 100644 index 00000000..9a42cb18 --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/A1/a1-start.txt @@ -0,0 +1,14 @@ +11:15:22 start -> 500 {'ok': False, 'error': 'starting stack romm: exit code 1\nstderr: ...romm-internal Created \n Container romm-redis Creating \n Container romm-db Creating \n Con +11:20:23 state stopped +11:23:48 + Container romm-db Started + Container romm-redis Started + Container romm-redis Error dependency romm-redis failed to start +dependency failed to start: container romm-redis is unhealthy +2026/10/16 09:15:26 manager.go:904: [DEBUG] [stacks] restart-policy of down member "romm" = "unless-stopped" +2026/10/16 09:15:26 healthprobe.go:105: [DEBUG] [stacks] RunHealthProbes: romm has a health check but no container to probe — candidates: [romm(stopped) romm-redis(restarting) romm-db(starting)] +2026/10/16 09:15:26 notifier.go:353: [INFO] Event pushed: app_start_failed (warning) [hu-only] — Telepített alkalmazás nem fut: RomM +2026/10/16 09:15:57 update_guard.go:860: [WARN] [backup] romm is STOPPED by the box: crash_loop (trip 1 within 24h0m0s) — Start gives it one more try (decision 28) + +Error response from daemon: No such container: romm-redis + diff --git a/documentation/audits/romm-ghost-2026-10-16/A2/a-fresh.txt b/documentation/audits/romm-ghost-2026-10-16/A2/a-fresh.txt new file mode 100644 index 00000000..77220fe6 --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/A2/a-fresh.txt @@ -0,0 +1,56 @@ +12:06:23 stop -> 200 +12:06:55 remove (keep drive data) -> 200 {'ok': True, 'data': {'removed': 'romm', 'volumes_removed': ['romm_romm_config', 'romm_romm_db_data', 'romm_romm_redis_data'], 'hdd_paths_removed': [], 'hdd_paths_preserved': ['/mnt/felhom-drives/hdd_1/appdata/romm/resou +12:06:59 REMAINING: +/mnt/felhom-drives/hdd_1/userdata/roms +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21 +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/SENTINEL.txt +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/binary-1mb.bin +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested/őszibarack.md +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/plain.txt +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/árvíztűrő-tükörfúrógép.txt +/mnt/felhom-drives/hdd_1/userdata/roms/roms +find: '/mnt/felhom-drives/hdd_1/userdata/romm': No such file or directory +find: '/mnt/felhom-drives/hdd_1/userdata/roms-bios': No such file or directory +APPDATA: +/mnt/felhom-drives/hdd_1/appdata/romm +total 12 +drwxr-xr-x 3 root root 4096 Oct 16 09:36 . +drwxr-xr-x 4 root root 4096 Oct 14 15:45 .. +drwxr-xr-x 3 root root 4096 Oct 16 09:36 resources + +12:07:27 [1] made the drive paths this app requires: ['/nonexistent-not-used/romm'] +12:07:27 [1] required fields filled beyond DOMAIN/SUBDOMAIN: ['HDD_PATH'] +12:07:28 [1] deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'} +12:08:48 [1] deployed, controller state=running, pinned={'romm': 'rommapp/romm:5.3.1', 'romm-db': 'mariadb:11.8', 'romm-redis': 'redis:7-alpine'} +12:08:48 gate: arcade is gated — passed as the household (cookie set) +12:09:00 deploy ok: True | answers: True +12:09:03 /mnt/felhom-drives/hdd_1/appdata/romm/resources -> /romm/resources +/mnt/felhom-drives/hdd_1/userdata/roms-bios -> /romm/library/bios +/mnt/felhom-drives/hdd_1/userdata/roms -> /romm/library/roms + + +12:09:04 first admin -> 201 +12:09:04 == Scenario A: one dummy file EXACTLY where the fixed first steps say: roms/gba/ +12:09:07 total 12 +drwxr-sr-x 2 root 1000 4096 Oct 16 10:09 . +drwxrwsr-x 5 root 1000 4096 Oct 16 10:09 .. +-rw-r--r-- 1 root 1000 4096 Oct 16 10:09 Test Game (World).gba + +12:09:07 romm login -> 200 session cookie set +12:09:08 socket namespace: 40{"sid":"eRLGRdKd24uTdIOcAAAB"} +12:09:08 scan event sent -> 200 OK +12:09:18 scan finished: scan:done [{"total_platforms": 3, "total_roms": 6, "scanned_platforms": 3, "new_platforms": 3, "identified_platforms": 1, "scanned_roms": 6, "new_roms": 6, "identified_roms": 0, "scanned_firmware": 0, "new_firmware": 0, "updated_roms": 0, "new_files": 0}] +12:09:18 rom: árvíztűrő-tükörfúrógép.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing False +12:09:18 rom: binary-1mb.bin | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing False +12:09:18 rom: nested | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing False +12:09:18 rom: plain.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing False +12:09:18 rom: SENTINEL.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing False +12:09:18 rom: Test Game (World).gba | platform gba | fs_path roms/gba | missing False +total 12 +drwxr-xr-x 3 root root 4096 Oct 16 10:07 . +drwxr-xr-x 19 root root 4096 Oct 16 10:07 .. +drwxr-xr-x 2 root root 4096 Oct 16 10:07 romm +removed +ls: cannot access '/nonexistent-not-used': No such file or directory + diff --git a/documentation/audits/romm-ghost-2026-10-16/B/b-after.txt b/documentation/audits/romm-ghost-2026-10-16/B/b-after.txt new file mode 100644 index 00000000..88b0eb5f --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/B/b-after.txt @@ -0,0 +1,55 @@ +12:03:44 drive BEFORE: roms/gba/Test Game (World).gba +roms/roms/gba/Test Game (World).gba +roms/roms/snes/Uploaded Game (World).sfc +ls: cannot access 'roms-bios': No such file or directory + +12:03:44 sync -> 200 {'ok': True, 'data': {'ok': True, 'updated': ['ghost', 'romm'], 'message': 'Sablonok frissítve — frissítve: ghost, romm' +12:03:48 synced compose has library/roms (compose, applied): ['1', '1'] +12:03:51 container mounts still OLD before restart: /mnt/felhom-drives/hdd_1/userdata/roms -> /romm/library +/mnt/felhom-drives/hdd_1/appdata/romm/resources -> /romm/resources + + +12:04:09 restart -> 200 {'ok': True, 'data': {'state': 'starting'}, 'message': 'Stack romm restart requested — state now: starting'} +12:04:49 state running +12:04:53 container mounts AFTER restart: /mnt/felhom-drives/hdd_1/appdata/romm/resources -> /romm/resources +/mnt/felhom-drives/hdd_1/userdata/roms-bios -> /romm/library/bios +/mnt/felhom-drives/hdd_1/userdata/roms -> /romm/library/roms + +1 + +12:04:54 romm login -> 200 session cookie set +12:04:54 socket namespace: 40{"sid":"2tHxxZMy6Ed5YlRoAAAB"} +12:04:54 scan event sent -> 200 OK +12:05:05 scan finished: scan:done [{"total_platforms": 3, "total_roms": 8, "scanned_platforms": 3, "new_platforms": 2, "identified_platforms": 1, "scanned_roms": 8, "new_roms": 7, "identified_roms": 0, "scanned_firmware": 0, "new_firmware": 0, "updated_roms": 1, "new_files": 0}] +12:05:06 library AFTER the change: [[["DRILL-2026-08-21", 5], ["gba", 1], ["roms", 2], ["snes", 1]], [["árvíztűrő-tükörfúrógép.txt", "DRILL-2026-08-21", "roms/DRILL-2026-08-21"], ["binary-1mb.bin", "DRILL-2026-08-21", "roms/DRILL-2026-08-21"], ["gba", "roms", "roms/roms"], ["nested", "DRILL-2026-08-21", "roms/DRILL-2026-08-21"], ["plain.txt", "DRILL-2026-08-21", "roms/DRILL-2026-08-21"], ["SENTINEL.txt", "DRILL-2026-08-21", "roms/DRILL-2026-08-21"], ["snes", "roms", "roms/roms"], ["Test Game (World).gba", "gba", "roms/gba"], ["Uploaded Game (World).sfc", "snes", "roms/snes"]]] +12:05:06 rom: árvíztűrő-tükörfúrógép.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing_from_fs False +12:05:06 rom: binary-1mb.bin | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing_from_fs False +12:05:06 rom: gba | platform roms | fs_path roms/roms | missing_from_fs False +12:05:06 rom: nested | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing_from_fs False +12:05:06 rom: plain.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing_from_fs False +12:05:06 rom: SENTINEL.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing_from_fs False +12:05:06 rom: snes | platform roms | fs_path roms/roms | missing_from_fs False +12:05:06 rom: Test Game (World).gba | platform gba | fs_path roms/gba | missing_from_fs False +12:05:06 rom: Uploaded Game (World).sfc | platform snes | fs_path roms/snes | missing_from_fs True +12:05:10 drive AFTER: roms/gba/Test Game (World).gba +roms/roms/gba/Test Game (World).gba +roms/roms/snes/Uploaded Game (World).sfc +drwxrwsr-x 2 root 1000 4096 Oct 16 10:03 roms-bios +1061011 + +12:05:32 data tab [BIOS]: először be kell jelentkezned a Fájlkezelőbe. ROM gyűjtemény ↗ Itt tárolódnak a fájljaid. Biztonsági mentés készül róla. BIOS fájlok (RomM) ↗ Itt tárolódnak a fájljaid. Biztonsági mentés készül róla. Biztonsági mentés Az alkalmazás adatbázisa és +12:05:32 data tab [ROM gy]: a fájlokat? Ezeket a mappákat a Fájlkezelőben éred el. Előfordulhat, hogy először be kell jelentkezned a Fájlkezelőbe. ROM gyűjtemény ↗ Itt tárolódnak a fájljaid. Biztonsági mentés készül róla. BIOS fájlok (RomM) ↗ Itt tárolódnak a fájljaid. Biz +12:05:35 2026/10/16 09:29:16 delete.go:824: [DEBUG] [stacks] RemoveStack romm: processing 0 backup paths for removal (base=/mnt/felhom-drives/hdd_1/backups) +2026/10/16 09:35:39 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DB_PASSWORD, MYSQL_ROOT_PASSWORD, ROMM_AUTH_SECRET_KEY, HDD_PATH, DOMAIN, SUBDOMAIN, DOMAIN, USERDATA_PATH, IMPORT_PATH] (13 app + 0 syst +2026/10/16 09:36:17 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, ROMM_AUTH_SECRET_KEY, SUBDOMAIN, DB_PASSWORD, DOMAIN, HDD_PATH, MYSQL_ROOT_PASSWORD, USERDATA_PATH, IMPORT_PATH] (13 app + 0 syst +2026/10/16 10:03:51 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, HDD_PATH, MYSQL_ROOT_PASSWORD, ROMM_AUTH_SECRET_KEY, SUBDOMAIN, DB_PASSWORD, DOMAIN, USERDATA_PATH, IMPORT_PATH] (13 app + 0 syst +2026/10/16 10:04:12 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, HDD_PATH, MYSQL_ROOT_PASSWORD, ROMM_AUTH_SECRET_KEY, SUBDOMAIN, DB_PASSWORD, DOMAIN, USERDATA_PATH, IMPORT_PATH] (13 app + 0 syst + +12:05:46 == BIOS: a dummy file at the drive's roms-bios/psx/ (= container /romm/library/bios/psx), and a psx game so the platform exists +12:05:50 /mnt/felhom-drives/hdd_1/userdata/roms-bios/psx/scph1001.bin + +12:05:50 romm login -> 200 session cookie set +12:05:50 socket namespace: 40{"sid":"mzdx6XqGxwCwz9fuAAAD"} +12:05:51 scan event sent -> 200 OK +12:06:00 scan finished: scan:done [{"total_platforms": 4, "total_roms": 9, "scanned_platforms": 4, "new_platforms": 1, "identified_platforms": 2, "scanned_roms": 9, "new_roms": 1, "identified_roms": 0, "scanned_firmware": 1, "new_firmware": 1, "updated_roms": 0, "new_files": 0}] +12:06:01 psx platform: roms 1 firmware [('scph1001.bin', 'bios/psx')] diff --git a/documentation/audits/romm-ghost-2026-10-16/B/b-before.txt b/documentation/audits/romm-ghost-2026-10-16/B/b-before.txt new file mode 100644 index 00000000..1b2cde36 --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/B/b-before.txt @@ -0,0 +1,13 @@ +11:55:59 == Scenario B base (OLD mount): roms/gba/Test Game (World).gba = placed per the OLD first steps; +11:55:59 roms/roms// = where RomM's own upload writes under the old mount; add one there +11:56:02 /mnt/felhom-drives/hdd_1/userdata/roms/gba/Test Game (World).gba +/mnt/felhom-drives/hdd_1/userdata/roms/roms/gba/Test Game (World).gba +/mnt/felhom-drives/hdd_1/userdata/roms/roms/snes/Uploaded Game (World).sfc + +1061011 + +11:56:03 romm login -> 200 session cookie set +11:56:04 socket namespace: 40{"sid":"YpuQ3Mat1FaN_fSeAAAB"} +11:56:04 scan event sent -> 200 OK +11:56:13 scan finished: scan:done [{"total_platforms": 2, "total_roms": 2, "scanned_platforms": 2, "new_platforms": 1, "identified_platforms": 2, "scanned_roms": 2, "new_roms": 1, "identified_roms": 0, "scanned_firmware": 0, "new_firmware": 0, "updated_roms": 0, "new_files": 0}] +11:56:13 library BEFORE the template change: ([('gba', 1), ('snes', 1)], [('Test Game (World).gba', 'gba', 'roms/gba'), ('Uploaded Game (World).sfc', 'snes', 'roms/snes')]) diff --git a/documentation/audits/romm-ghost-2026-10-16/B/hub-redproofs.txt b/documentation/audits/romm-ghost-2026-10-16/B/hub-redproofs.txt new file mode 100644 index 00000000..7b2f5f2c --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/B/hub-redproofs.txt @@ -0,0 +1,11 @@ +RED (good) serve-time hold removed: rc=1 ran=5 failed=['--- FAIL: TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource (0.11s)', '--- FAIL: TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource/stored_global_value (0.04s)', '--- FAIL: TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource/per-customer_override (0.03s)', '--- FAIL: TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource/config/env_default (0.04s)', '--- FAIL: TestReportACK_NeverServesAWithdrawnBuild (0.04s)'] +RED (good) global form refusal removed: rc=1 ran=1 failed=['--- FAIL: TestGlobalFloorForm_RefusesAWithdrawnBuild (0.04s)'] +RED (good) customer form refusal removed: rc=1 ran=1 failed=['--- FAIL: TestCustomerFloorForm_RefusesAWithdrawnBuild (0.05s)'] +RED (good) impact probe check removed: rc=1 ran=1 failed=['--- FAIL: TestGlobalFloorImpact_WithdrawnBuildIsNotValid (0.04s)'] +RED (good) configuration page notice removed: rc=1 ran=1 failed=['--- FAIL: TestConfigurationPage_SaysWithdrawnOnlyForAWithdrawnFloor (0.09s)'] +RED (good) customer page notice removed: rc=1 ran=1 failed=['--- FAIL: TestCustomerPage_SaysWithdrawnOnlyForAWithdrawnFloor (0.10s)'] +RED (good) warn logged on every report: rc=1 ran=1 failed=['--- FAIL: TestReportACK_NeverServesAWithdrawnBuild (0.04s)'] +RED (good) hub list gains a tag: rc=1 ran=1 failed=['--- FAIL: TestSpoiledControllerTags_MatchTheSkill (0.00s)'] +RED (good) skill pre-check loses a tag: rc=1 ran=1 failed=['--- FAIL: TestSpoiledControllerTags_MatchTheSkill (0.00s)'] +RED (good) skill marked line loses a tag: rc=1 ran=1 failed=['--- FAIL: TestSpoiledControllerTags_MatchTheSkill (0.00s)'] +restored, all green again: True ok gitea.dooplex.hu/admin/felhom-hub/internal/store 0.152s | ok gitea.dooplex.hu/admin/felhom-hub/internal/web 0.414s | ok gitea.dooplex.hu/admin/felhom-hub/internal/api 0.061s diff --git a/documentation/audits/romm-ghost-2026-10-16/C1/c1-measure.txt b/documentation/audits/romm-ghost-2026-10-16/C1/c1-measure.txt new file mode 100644 index 00000000..d56159c9 --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/C1/c1-measure.txt @@ -0,0 +1,38 @@ +11:51:42 [1] deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'} +11:53:38 [1] deployed, controller state=running, pinned={'ghost': 'ghost:6.67.0-alpine'} +11:53:38 gate: blog is gated — passed as the household (cookie set) +11:53:38 deploy ok: True | app answers: True +11:53:39 owner setup (device 1, through the gate as the household) -> 201 {"users":[{"id":"6ad1f4164cd89c00016e5779","name":"Felhom Teszt","slug":"felhom","email":"owner@example.invalid","profile_image":null,"cover_image":null,"bio":n +11:53:39 setup status now -> 200 {"setup":[{"status":true}]} +11:53:39 DEVICE 2 sign-in (public address, fresh jar) -> 401 {"error":"this app is waiting for its first setup"} +11:53:39 DEVICE 2 /users/me -> 401 {"error":"this app is waiting for its first setup"} +11:53:43 [2026-10-16 11:53:39] WARN Missing mail.from config, falling back to a generated email address. Please update your config file and set a valid from address +[2026-10-16 11:53:39] WARN Missing mail.from config, falling back to a generated email address. Please update your config file and set a valid from address +[2026-10-16 11:53:39] ERROR Failed to send email. Reason: connect ECONNREFUSED 127.0.0.1:587. +Failed to send email. Reason: connect ECONNREFUSED 127.0.0.1:587. +"Unable to send welcome email, your site will continue to function." +"Please see https://docs.ghost.org/config/#mail for instructions on configuring email." +Error ID: +Error Code:  +Error: connect ECONNREFUSED 127.0.0.1:587 + at createMailError (/var/lib/ghost/versions/6.67.0/core/server/services/mail/ghost-mailer.js:109:10) + +11:54:10 public setup probe -> 200 after 15s: {"setup":[{"status":true}]} +11:54:10 DEVICE 2 sign-in (public address, fresh cookie jar) -> 201 Created +11:54:10 DEVICE 2 /users/me -> 200 {"users":[{"id":"6ad1f4164cd89c00016e5779","name":"Felhom Teszt","slug":"felhom","email":"owner@example.invalid","profile_image":null,"cover_image":null,"bio":n +11:54:14 Failed to send email. Reason: connect ECONNREFUSED 127.0.0.1:587. +"Unable to send welcome email, your site will continue to function." +"Please see https://docs.ghost.org/config/#mail for instructions on configuring email." +Error Code:  +Error: connect ECONNREFUSED 127.0.0.1:587 + at createMailError (/var/lib/ghost/versions/6.67.0/core/server/services/mail/ghost-mailer.js:109:10) + +11:54:43 == DEVICE 3: another fresh client after the user HAS logged in once (Ghost skips the code only on the first-ever login, api/endpoints/session.js:27) +11:54:43 DEVICE 3 sign-in -> 500 {"errors":[{"message":"Failed to send email. Please check your site configuration and try again.","context":null,"type":"EmailError","details":null,"property":null,"help":"Please see https://docs.ghost.org/config/#mail for instructions on configuring email.","code":"ESOCKET","id":"a100a239-2a07-4fe1-8203-13aba1f87e1b","ghostErrorCode":null}]} +11:54:43 DEVICE 3 /users/me -> 403 {"errors":[{"message":"Authorization failed","context":"Unable to determine the authenticated user or integration. Check that cookies are being passed through if using session authentication.","type": +11:54:43 DEVICE 2 (already verified) /users/me -> 200 {"users":[{"id":"6ad1f4164cd89c00016e5779","name":"Felhom Teszt","slug":"felhom" +11:54:47 [2026-10-16 11:54:43] WARN Missing mail.from config, falling back to a generated email address. Please update your config file and set a valid from address +Failed to send email. Please check your site configuration and try again. +"Please see https://docs.ghost.org/config/#mail for instructions on configuring email." +EmailError: Failed to send email. Please check your site configuration and try again. + diff --git a/documentation/audits/romm-ghost-2026-10-16/C2/c2-e-off.txt b/documentation/audits/romm-ghost-2026-10-16/C2/c2-e-off.txt new file mode 100644 index 00000000..d7d3a6d4 --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/C2/c2-e-off.txt @@ -0,0 +1,19 @@ +12:09:50 settings tab: app e-mail box present: True | checked: False | global-off notice: True +12:09:53 compose on box: 21: # device it has not seen (`security.staffDeviceVerification`, default true: ghost/core/core/shared/ +29: - security__staffDeviceVerification=${GHOST_STAFF_DEVICE_VERIFICATION:-false} +31: - mail__transport=SMTP +32: - mail__options__host=${GHOST_MAIL_HOST:-} +33: - mail__options__port=${GHOST_MAIL_PORT:-} +34: - mail__options__secure=false +35: - mail__from=${GHOST_MAIL_FROM:-} + +12:09:55 restart -> 200 +12:10:13 env in container (keys + the flag): mail__options__host= +mail__options__secure=false +security__staffDeviceVerification=false +mail__options__port= +mail__from= +mail__transport=SMTP + +12:10:14 E (app e-mail OFF) — new device sign-in -> 201 Created +12:10:14 /users/me -> 200 {"users":[{"id":"6ad1f4164cd89c00016e5779","name":"Felhom Te diff --git a/documentation/audits/romm-ghost-2026-10-16/C2/c2-e-on.txt b/documentation/audits/romm-ghost-2026-10-16/C2/c2-e-on.txt new file mode 100644 index 00000000..cd069e83 --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/C2/c2-e-on.txt @@ -0,0 +1,42 @@ +12:11:17 FOUND: box-wide app e-mail on = False | from name = '' +12:11:18 owner e-mail -> 200 ghost-r1090@felhom.eu +12:11:18 box-wide app e-mail ON -> 200 +12:11:19 Ghost's own switch ON -> 303 https://192.168.0.155/apps/ghost/settings?flash=flash.tier2.app_email_on +12:11:27 env in container: mail__from= +mail__options__host=felhom-controller +security__staffDeviceVerification=true +mail__transport=SMTP +mail__options__port=2526 +mail__options__secure=false + +12:11:28 E (app e-mail ON) — new device sign-in -> 404 {"errors":[{"message":"There is no user with that email address.","context":null,"type":"NotFoundError","details":null,"property":null,"help":null,"code":null,"id":"3f64c1e8-4577-43fa-a2ea-25e86869d845","ghostErrorCode":null}]} +12:11:39 controller shim/relay log: 2026/10/16 10:11:18 lifecycle.go:50: [INFO] [mailrelay] app-email shim ON (host=felhom-controller plain=[::]:2525 tls=[::]:2465) +2026/10/16 10:11:18 handlers.go:3094: [INFO] [web] App-email globally enabled (from_name="") +2026/10/16 10:11:18 auth.go:142: [DEBUG] [web] auth: valid session for POST /stacks/ghost/app-email +2026/10/16 10:11:18 server.go:624: [DEBUG] [web] ServeHTTP: POST /stacks/ghost/app-email from 172.18.0.4:37816 +2026/10/16 10:11:18 mailenv.go:137: [INFO] [stacks] App-email for ghost set to true — recreating to apply +2026/10/16 10:11:18 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, SUBDOMAIN, DOMAIN, IMPORT_PATH, GHOST_MAIL_HOST, GHOST_MAIL_PORT, GHOST_MAIL_FROM, GHOST_STAFF_DEVICE_VERIFICATION] (12 app + 0 system) +2026/10/16 10:11:19 tier2_config_handler.go:92: [INFO] [web] App-email for ghost set to true +2026/10/16 10:11:22 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, SUBDOMAIN, DOMAIN, IMPORT_PATH, GHOST_MAIL_HOST, GHOST_MAIL_PORT, GHOST_MAIL_FROM, GHOST_STAFF_DEVICE_VERIFICATION] (12 app + 0 system) + +12:11:42 ghost log: [2026-10-16 12:11:22] INFO [EmailAnalytics:gifts] Initialized with SEQUENTIAL processing mode +[2026-10-16 12:11:23] INFO Running milestone emails job on Sun Oct 18 2026 12:11:23 GMT+0200 (Central European Summer Time) +[2026-10-16 12:11:23] ERROR Could not get webhook secret for ActivityPub SyntaxError: Unexpected non-whitespace character after JSON at position 4 (line 1 column 5) +[2026-10-16 12:11:23] ERROR No webhook secret found - cannot initialise + +12:11:52 == retry with the owner's new address +12:11:54 E (app e-mail ON) — new device sign-in -> 403 {"errors":[{"message":"User must verify session to login.","context":"A 6-digit sign-in verification code has been sent to your email to keep your account safe.","type":"Needs2FAError","details":null,"property":null,"help":null,"code":"2FA_NEW_DEVICE_DETECTED","id":"a93c0867-4fc3-4b0e-a334-ebb454607657","ghostErrorCode":null}]} +12:11:55 /users/me before the code -> 403 {"errors":[{"message":"Authorization failed","context":"Unable to determine the +12:12:08 controller mail log: 2026/10/16 10:11:18 server.go:153: [INFO] [mailrelay] plaintext+STARTTLS listener on [::]:2525 +2026/10/16 10:11:18 server.go:154: [INFO] [mailrelay] implicit-TLS listener on [::]:2465 +2026/10/16 10:11:18 server.go:155: [INFO] [mailrelay] plaintext-only (no STARTTLS) listener on [::]:2526 +2026/10/16 10:11:18 lifecycle.go:50: [INFO] [mailrelay] app-email shim ON (host=felhom-controller plain=[::]:2525 tls=[::]:2465) +2026/10/16 10:11:54 server.go:299: [INFO] [mailrelay] message relayed (from="ghost@felhom.eu" rcpts=1 bytes=14585 hub=200) + +12:12:11 ghost log: [2026-10-16 12:11:22] INFO [EmailAnalytics:gifts] Initialized with SEQUENTIAL processing mode +[2026-10-16 12:11:23] INFO Running milestone emails job on Sun Oct 18 2026 12:11:23 GMT+0200 (Central European Summer Time) +"A 6-digit sign-in verification code has been sent to your email to keep your account safe." + +12:12:29 mail in the catch-all: from ghost@felhom.eu to ghost-r1090@felhom.eu at 10:11:55Z, subject ' is your Ghost sign in verification code' +12:12:29 verify with the mailed code -> 200 OK +12:12:29 /users/me after the code -> 200 {"users":[{"id":"6ad1f4164cd89c00016e5779","name":"Felhom Te diff --git a/documentation/audits/romm-ghost-2026-10-16/teardown/docker-ps-after.txt b/documentation/audits/romm-ghost-2026-10-16/teardown/docker-ps-after.txt new file mode 100644 index 00000000..a1156c78 --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/teardown/docker-ps-after.txt @@ -0,0 +1,22 @@ +Fri Oct 16 10:14:13 UTC 2026 +adventurelog ghcr.io/seanmorley15/adventurelog-backend:v0.13.0 Up 7 hours (healthy) +adventurelog-frontend ghcr.io/seanmorley15/adventurelog-frontend:v0.13.0 Up 7 hours (healthy) +adventurelog-postgres postgis/postgis:16-3.5-alpine Up 7 hours (healthy) +bentopdf ghcr.io/alam00000/bentopdf:v2.8.6 Up 7 hours (healthy) +bookstack lscr.io/linuxserver/bookstack:26.09.1 Up 7 hours (healthy) +bookstack-db mariadb:12.3 Up 7 hours (healthy) +calibre-web crocodilestick/calibre-web-automated:v4.0.8 Up 7 hours (healthy) +cloudflared cloudflare/cloudflared:2026.9.3 Up 7 hours (healthy) +docmost docmost/docmost:0.96.0 Up 7 hours (healthy) +docmost-postgres postgres:18-alpine Up 7 hours (healthy) +docmost-redis redis:7-alpine Up 7 hours (healthy) +felhom-controller gitea.dooplex.hu/admin/felhom-controller:0.319.0-rc.1 Up 8 seconds (health: starting) +filebrowser gtstef/filebrowser:1.5.6-stable Up 7 hours (healthy) +home-assistant ghcr.io/home-assistant/home-assistant:2026.9.4 Up 7 hours (healthy) +opengist ghcr.io/thomiceli/opengist:1.15 Up 7 hours (healthy) +paperless-postgres postgres:18-alpine Up 7 hours (healthy) +paperless-redis redis:7-alpine Up 7 hours (healthy) +paperless-webserver ghcr.io/paperless-ngx/paperless-ngx:2.20.15 Up 7 hours (healthy) +plex plexinc/pms-docker:1.41.4.9463-630c9f557 Up 7 hours (healthy) +privatebin privatebin/pdo:2.0.6 Up 7 hours (healthy) +traefik traefik:v3.7.13 Up 7 hours diff --git a/documentation/audits/romm-ghost-2026-10-16/teardown/docker-ps-before.txt b/documentation/audits/romm-ghost-2026-10-16/teardown/docker-ps-before.txt new file mode 100644 index 00000000..9ca6bb95 --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/teardown/docker-ps-before.txt @@ -0,0 +1,22 @@ +Fri Oct 16 09:11:23 UTC 2026 +adventurelog ghcr.io/seanmorley15/adventurelog-backend:v0.13.0 Up 6 hours (healthy) +adventurelog-frontend ghcr.io/seanmorley15/adventurelog-frontend:v0.13.0 Up 6 hours (healthy) +adventurelog-postgres postgis/postgis:16-3.5-alpine Up 6 hours (healthy) +bentopdf ghcr.io/alam00000/bentopdf:v2.8.6 Up 6 hours (healthy) +bookstack lscr.io/linuxserver/bookstack:26.09.1 Up 6 hours (healthy) +bookstack-db mariadb:12.3 Up 6 hours (healthy) +calibre-web crocodilestick/calibre-web-automated:v4.0.8 Up 6 hours (healthy) +cloudflared cloudflare/cloudflared:2026.9.3 Up 6 hours (healthy) +docmost docmost/docmost:0.96.0 Up 6 hours (healthy) +docmost-postgres postgres:18-alpine Up 6 hours (healthy) +docmost-redis redis:7-alpine Up 6 hours (healthy) +felhom-controller gitea.dooplex.hu/admin/felhom-controller:0.318.0 Up 2 hours (healthy) +filebrowser gtstef/filebrowser:1.5.6-stable Up 6 hours (healthy) +home-assistant ghcr.io/home-assistant/home-assistant:2026.9.4 Up 6 hours (healthy) +opengist ghcr.io/thomiceli/opengist:1.15 Up 6 hours (healthy) +paperless-postgres postgres:18-alpine Up 6 hours (healthy) +paperless-redis redis:7-alpine Up 6 hours (healthy) +paperless-webserver ghcr.io/paperless-ngx/paperless-ngx:2.20.15 Up 6 hours (healthy) +plex plexinc/pms-docker:1.41.4.9463-630c9f557 Up 6 hours (healthy) +privatebin privatebin/pdo:2.0.6 Up 6 hours (healthy) +traefik traefik:v3.7.13 Up 6 hours diff --git a/documentation/audits/romm-ghost-2026-10-16/teardown/teardown.txt b/documentation/audits/romm-ghost-2026-10-16/teardown/teardown.txt new file mode 100644 index 00000000..3cd79dda --- /dev/null +++ b/documentation/audits/romm-ghost-2026-10-16/teardown/teardown.txt @@ -0,0 +1,35 @@ +12:12:48 Ghost's switch OFF -> 303 https://192.168.0.155/apps/ghost/settings?flash=flash.tier2.app_email_off +12:12:48 box-wide app e-mail back to found (on=False) -> 200 +12:12:48 box-wide now on = False +12:12:59 ghost stop -> 200 +12:13:31 ghost remove (drive data kept=False) -> 200 {'ok': True, 'data': {'removed': 'ghost', 'volumes_removed': ['ghost_ghost_content'], 'hdd_paths_removed': [], 'hdd_paths_preserved': [], 'hdd_note': 'Az alkalmazás nem tárolt saját adatot külső megha +12:13:36 romm stop -> 200 +12:14:05 romm remove (drive data kept=True) -> 502 {'_raw': 'Bad Gateway'} +12:14:13 DRIVE: +/mnt/felhom-drives/hdd_1/userdata/roms +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21 +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/SENTINEL.txt +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/binary-1mb.bin +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested/őszibarack.md +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/plain.txt +/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/árvíztűrő-tükörfúrógép.txt +/mnt/felhom-drives/hdd_1/userdata/roms/roms +find: '/mnt/felhom-drives/hdd_1/userdata/roms-bios': No such file or directory +STACKS: +romm app.yaml: /opt/docker/stacks/romm/app.yaml +ghost app.yaml: ls: cannot access '/opt/docker/stacks/ghost/app.yaml': No such file or directory +VOLUMES: +romm_romm_redis_data.pre-aof-fix-20260924T135937Z +LEFT: +ls: cannot access '/nonexistent-not-used': No such file or directory + +12:14:44 romm now: {'deployed': False, 'state': 'not_deployed'} +12:14:44 romm after: {'deployed': False, 'state': 'not_deployed'} +12:14:48 docker-compose.yml +0 +romm_romm_redis_data.pre-aof-fix-20260924T135937Z +9 +2026-10-16_113539 +2026-10-16_120728 + diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index d5910db0..995f4902 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,20 @@ +## v0.155.0 — the floor can never deliver a withdrawn controller build (R-1085) (2026-10-16) + +`documentation/architecture/09-update-architecture.md` §0 (the [DESIGN] paragraph on withdrawn builds). + +- One list: `internal/store/spoiled_tags.go` — `0.315.0` and `0.316.3` (R-1081), each with why it is withdrawn. +- **Input:** the global and per-customer floor forms refuse a listed tag (with or without `v`) with the new flash + `floor_spoiled`, store nothing, and log one `[WARN]`. The impact probe answers `valid:false, spoiled:true`. + A valid other version still saves. +- **Serve time:** `ResolveManagedFloor` holds a listed floor first (`HeldSpoiled`, its own `HoldReason`), whatever + stored it — an old value, a per-customer override, or the config/env default (also warned about at start). + The report ACK omits it and logs ONE `[WARN]` per change naming the tag. The Configuration and customer pages say + „this version is withdrawn"; the stored value is not rewritten. +- Tests: the store (every source held; an ordinary floor served), the forms, the probe, both branches of both page + notices, the real report handler (no floor in the ACK, one WARN across three reports), and the parity pin against + the `felhom-build-deploy` skill's two lines. Ten red-proofs, each seen failing + (`documentation/audits/romm-ghost-2026-10-16/B/hub-redproofs.txt`). + ## v0.154.0 — the host page shows which physical disk each storage place sits on (2026-10-15) Storage identity build A (`documentation/architecture/16-storage-physical-identity.md` §3; agent v0.157.0). diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 3688e1cc..9f6b6f7d 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -252,6 +252,12 @@ func main() { // Phase 2 managed updates: seed the global controller-version floor fallback (config/env). A // hub_settings row set via the operator UI overrides this at runtime. dataStore.SetDefaultMinControllerVersion(cfg.ControllerUpdates.DefaultMinVersion) + if reason, spoiled := store.SpoiledControllerTag(cfg.ControllerUpdates.DefaultMinVersion); spoiled { + // R-1085: the config/env default is the one floor no form checks — ResolveManagedFloor holds it at serve + // time; say so at start, where whoever set it will look. + logger.Printf("[WARN] Default controller-version floor %s is a WITHDRAWN controller build (%s) — it is HELD for every box (R-1085)", + cfg.ControllerUpdates.DefaultMinVersion, reason) + } if cfg.ControllerUpdates.DefaultMinVersion != "" { logger.Printf("[INFO] Default controller-version floor: %s", cfg.ControllerUpdates.DefaultMinVersion) } diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index aea215fe..fbcbf755 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -677,6 +677,14 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { h.logger.Printf("[INFO] managed floor SERVED for %s: floor %s, agent requirement %q from %s (golden %s)", payload.CustomerID, fd.Floor, fd.MinAgent, src, fd.GoldenVersion) } + } else if fd.HeldSpoiled { + // R-1085: a withdrawn build — ONE [WARN] per change naming the tag and why, not one per report. + note := "spoiled|" + fd.RequestedFloor + if prev, ok := h.floorNotes.Load(payload.CustomerID); !ok || prev.(string) != note { + h.floorNotes.Store(payload.CustomerID, note) + h.logger.Printf("[WARN] managed floor HELD for %s: %s (controller floor withheld)", + payload.CustomerID, fd.HoldReason()) + } } else if fd.Held { h.floorNotes.Delete(payload.CustomerID) // ONE sentence, from the decision itself — the two hold reasons must never drift apart diff --git a/hub/internal/api/spoiled_floor_ack_test.go b/hub/internal/api/spoiled_floor_ack_test.go new file mode 100644 index 00000000..ecbe1d1b --- /dev/null +++ b/hub/internal/api/spoiled_floor_ack_test.go @@ -0,0 +1,53 @@ +package api + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "strings" + "testing" +) + +// R-1085 over the wire — Scenario C through the PRODUCTION path (the real report handler → ResolveManagedFloor): +// a box is never told to install a withdrawn build, and the hub says why in ONE [WARN] naming the tag, not one per +// report. Red-proof: delete the SpoiledControllerTag block in store.ResolveManagedFloor → the ACK carries 0.316.3. +func TestReportACK_NeverServesAWithdrawnBuild(t *testing.T) { + h, st, _ := newTestHandler(t) + var logs bytes.Buffer + h.logger = log.New(&logs, "", 0) + ack := func() (interface{}, bool) { + rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`) + if rr.Code != http.StatusOK { + t.Fatalf("report status = %d", rr.Code) + } + var m map[string]interface{} + _ = json.Unmarshal(rr.Body.Bytes(), &m) + v, ok := m["min_controller_version"] + return v, ok + } + + // A value stored the way an old hub (no form refusal) would have stored it. + if err := st.SetGlobalMinControllerVersion("0.316.3"); err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + if v, ok := ack(); ok { + t.Fatalf("report %d: the ACK must OMIT a withdrawn floor, got %v", i, v) + } + } + if n := strings.Count(logs.String(), "[WARN] managed floor HELD for c:"); n != 1 { + t.Errorf("want exactly ONE [WARN] across three reports, got %d:\n%s", n, logs.String()) + } + if !strings.Contains(logs.String(), "0.316.3") || !strings.Contains(logs.String(), "WITHDRAWN") { + t.Errorf("the [WARN] must name the tag and say withdrawn:\n%s", logs.String()) + } + + // The control, on the same handler: an ordinary floor is served. + if err := st.SetGlobalMinControllerVersion("0.317.0"); err != nil { + t.Fatal(err) + } + if v, ok := ack(); !ok || v != "0.317.0" { + t.Fatalf("an ordinary floor must be served, got %v (present=%v)", v, ok) + } +} diff --git a/hub/internal/store/spoiled_tags.go b/hub/internal/store/spoiled_tags.go new file mode 100644 index 00000000..c3aa49a8 --- /dev/null +++ b/hub/internal/store/spoiled_tags.go @@ -0,0 +1,41 @@ +package store + +import ( + "sort" + "strings" +) + +// spoiledControllerTags are published felhom-controller tags whose image does NOT hold the code their number +// says (R-1081). They are never re-pushed and never deleted — a version number is pushed once — so the only +// safe thing left is that nothing ever SELECTS them (R-1085). The household's button and the daily auto-update +// take the highest tag and cannot reach them; the floor targets a version EXACTLY, so the floor is the path this +// list closes: both floor forms refuse a listed tag (web/configs.go), and ResolveManagedFloor HOLDS one that +// arrives any other way (an old stored value, the config/env default). +// +// THE SAME LIST is the marked „never deploy" line in `skills/felhom-build-deploy/SKILL.md`, for the hand deploy +// that names a tag. TestSpoiledControllerTags_MatchTheSkill fails when the two differ. +var spoiledControllerTags = map[string]string{ + // 2026-10-15: running the build refusal against a real tag rebuilt and re-pushed 0.315.0 a fourth time, with + // v0.316.0's code under it (digest 8887343011d6 → b809201841f7). + "0.315.0": "re-pushed with v0.316.0's code under it", + // 2026-10-15: built from the SHARED working copy, which held 31 uncommitted files of another session's work. + "0.316.3": "built from a working copy holding another session's uncommitted files", +} + +// SpoiledControllerTag reports whether v (a leading "v" and spaces allowed) is a withdrawn controller build, and +// why. A version that is not listed answers ("", false). +func SpoiledControllerTag(v string) (reason string, spoiled bool) { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + reason, spoiled = spoiledControllerTags[v] + return reason, spoiled +} + +// SpoiledControllerTags lists the withdrawn controller builds, sorted — for the parity test and the logs. +func SpoiledControllerTags() []string { + out := make([]string, 0, len(spoiledControllerTags)) + for v := range spoiledControllerTags { + out = append(out, v) + } + sort.Strings(out) + return out +} diff --git a/hub/internal/store/spoiled_tags_test.go b/hub/internal/store/spoiled_tags_test.go new file mode 100644 index 00000000..cb4be62f --- /dev/null +++ b/hub/internal/store/spoiled_tags_test.go @@ -0,0 +1,110 @@ +package store + +import ( + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" +) + +// R-1085, the serve-time half: a withdrawn build is HELD however it reached the floor — a stored global value (as +// one written before the form refusal existed), a per-customer override, or the config/env default. Red-proof: +// delete the SpoiledControllerTag block in ResolveManagedFloor → every case below serves the tag. +func TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, st *Store) + tag string + }{ + {"stored global value", func(t *testing.T, st *Store) { + if err := st.SetGlobalMinControllerVersion("0.316.3"); err != nil { + t.Fatal(err) + } + }, "0.316.3"}, + {"per-customer override", func(t *testing.T, st *Store) { + if err := st.SetMinControllerVersion("c", "0.315.0"); err != nil { + t.Fatal(err) + } + }, "0.315.0"}, + {"config/env default", func(t *testing.T, st *Store) { st.SetDefaultMinControllerVersion("0.316.3") }, "0.316.3"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := newTestStore(t) + if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c", RetrievalPassword: "pw", APIKey: "k"}); err != nil { + t.Fatal(err) + } + tc.setup(t, st) + d := st.ResolveManagedFloor("c") + if d.Floor != "" || !d.Held || !d.HeldSpoiled { + t.Fatalf("a withdrawn build must be HELD and served as no floor, got %+v", d) + } + if d.RequestedFloor != tc.tag { + t.Errorf("RequestedFloor = %q, want %q (the message needs it)", d.RequestedFloor, tc.tag) + } + if r := d.HoldReason(); !strings.Contains(r, "WITHDRAWN") || !strings.Contains(r, tc.tag) { + t.Errorf("the hold reason must say withdrawn and name the tag, got %q", r) + } + if got := st.EffectiveMinControllerVersion("c"); got != tc.tag { + t.Errorf("the stored value must be left as it is (the page shows why), got %q", got) + } + }) + } +} + +// The control: a version that is not listed is served exactly as before (uncoupled, no manifest). +func TestResolveManagedFloor_AnUnlistedFloorIsServed(t *testing.T) { + st := newTestStore(t) + if err := st.SetGlobalMinControllerVersion("0.317.0"); err != nil { + t.Fatal(err) + } + if d := st.ResolveManagedFloor("c"); d.Floor != "0.317.0" || d.Held || d.HeldSpoiled { + t.Fatalf("an ordinary floor must be served, got %+v", d) + } +} + +func TestSpoiledControllerTag_AcceptsTheFormsSpellings(t *testing.T) { + for _, v := range []string{"0.316.3", "v0.316.3", " 0.315.0 ", "v0.315.0"} { + if _, ok := SpoiledControllerTag(v); !ok { + t.Errorf("%q must be recognised as withdrawn", v) + } + } + for _, v := range []string{"", "0.317.0", "0.316.30", "0.316", "0.315.0-rc.1"} { + if _, ok := SpoiledControllerTag(v); ok { + t.Errorf("%q must NOT be withdrawn", v) + } + } +} + +// THE PARITY PIN (R-1085 B3): the hub's list equals BOTH places the hand deploy reads it — the marked „never deploy" +// line and the step-3 pre-check in skills/felhom-build-deploy/SKILL.md. A missing skill file is a failure, never a +// skip. Red-proof: add "0.316.4" to spoiledControllerTags, or drop a tag from either skill line → this fails. +func TestSpoiledControllerTags_MatchTheSkill(t *testing.T) { + p := filepath.Join("..", "..", "..", "skills", "felhom-build-deploy", "SKILL.md") + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("the skill holds the hand deploy's copy of the list and must be readable: %v", err) + } + ver := regexp.MustCompile(`\d+\.\d+\.\d+`) + var marked, precheck []string + for _, line := range strings.Split(string(b), "\n") { + switch { + case strings.Contains(line, ""): + for _, m := range regexp.MustCompile("`(\\d+\\.\\d+\\.\\d+)`").FindAllStringSubmatch(line, -1) { + marked = append(marked, m[1]) + } + case strings.HasPrefix(line, "VER=; case \""): + q := line[len("VER=; case \""):] + precheck = ver.FindAllString(q[:strings.Index(q, "\"")], -1) + } + } + want := SpoiledControllerTags() + if !reflect.DeepEqual(marked, want) { + t.Errorf("the skill's marked never-deploy line lists %v, the hub %v", marked, want) + } + if !reflect.DeepEqual(precheck, want) { + t.Errorf("the skill's step-3 pre-check refuses %v, the hub %v", precheck, want) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 401698cb..0ddc3058 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -2431,6 +2431,11 @@ type ManagedFloorDecision struct { // MinAgentSourceDeclared (the floor's own declaration, used ONLY above the golden). "" = no // requirement at all (an uncoupled release). MinAgentSource string + // HeldSpoiled is the third hold reason (R-1085): the floor names a WITHDRAWN controller build + // (SpoiledControllerTag). It wins over the other two — no agent check makes a faulty image safe. + HeldSpoiled bool + // SpoiledReason says why that build is withdrawn ("" unless HeldSpoiled). + SpoiledReason string } // Where a managed floor's agent requirement came from. See ManagedFloorDecision.MinAgentSource. @@ -2452,6 +2457,9 @@ func (d ManagedFloorDecision) HoldReason() string { switch { case !d.Held: return "" + case d.HeldSpoiled: + return fmt.Sprintf("held: floor %s is a WITHDRAWN controller build (%s) — set another version (R-1085)", + d.EffectiveFloorForMessage(), d.SpoiledReason) case d.HeldBeyondGolden: return fmt.Sprintf("held: floor %s is ABOVE the vouched golden %s, so its agent requirement is unknown — vouch a golden carrying the floor's controller (publish-train rule 1)", d.EffectiveFloorForMessage(), goldenOrUnknown(d.GoldenVersion)) @@ -2511,6 +2519,7 @@ func agentOrUnknownVersion(v string) string { // // Logic: // - effective floor "" → nothing to serve (no floor configured); +// - effective floor is a WITHDRAWN build (spoiled_tags.go) → HOLD + HeldSpoiled (R-1085); // - manifest MinAgent "" → UNCOUPLED release: serve the floor as-is (no agent gating); // - floor ABOVE the vouched golden's controller → HOLD (requirement unknown) + flag; // - agent_version known AND ≥ MinAgent → serve the floor; @@ -2523,6 +2532,14 @@ func (s *Store) ResolveManagedFloor(customerID string) ManagedFloorDecision { if d.Floor == "" { return d } + // R-1085: a withdrawn build is never served, whatever put it here — the forms refuse it, but a value stored + // before that refusal existed, or the config/env default, reaches this line unchecked. Checked FIRST: no agent + // comparison makes a faulty image safe. The stored value is left as it is; the pages say why it is held. + if reason, spoiled := SpoiledControllerTag(d.Floor); spoiled { + d.Held, d.HeldSpoiled, d.SpoiledReason = true, true, reason + d.Floor = "" + return d + } man := s.GetArtifactManifest() d.MinAgent, d.GoldenVersion = man.MinAgent, man.GoldenVersion if d.MinAgent != "" { diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index c86d7b54..5b522fa3 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -320,6 +320,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c GlobalFloor string // global default (hub_settings → config/env) EffectiveFloor string // override else global ("" = no floor) BelowFloor bool // current < effective floor (would auto-update) + // EffectiveFloorWithdrawn (R-1085) — why the effective floor is a withdrawn build, "" when it is not. The + // box is served no floor then (store.ResolveManagedFloor); the page says so instead of „will auto-update". + EffectiveFloorWithdrawn string NotifPrefs *store.NotificationPrefs RecentNotifications []store.NotificationLogEntry @@ -500,6 +503,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c GlobalFloor: globalFloor, EffectiveFloor: effectiveFloor, BelowFloor: belowFloor, + EffectiveFloorWithdrawn: func() string { + r, _ := store.SpoiledControllerTag(effectiveFloor) + return r + }(), NotifPrefs: notifPrefs, RecentNotifications: recentNotifs, @@ -1216,12 +1223,28 @@ func (s *Server) countBoxesBelowFloor(proposedGlobal string) int { // (GET /configuration/global-floor/impact?v=X.Y.Z). Read-only JSON; blank v = 0. func (s *Server) handleGlobalFloorImpact(w http.ResponseWriter, r *http.Request) { v, ok := normalizeFloorInput(r.URL.Query().Get("v")) + _, spoiled := store.SpoiledControllerTag(v) + if spoiled { + ok = false // R-1085: a withdrawn build is not a floor the dialog may offer to confirm + } count := 0 if ok && v != "" { count = s.countBoxesBelowFloor(v) } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"version": v, "valid": ok, "below": count}) + _ = json.NewEncoder(w).Encode(map[string]any{"version": v, "valid": ok, "spoiled": spoiled, "below": count}) +} + +// refuseSpoiledFloor is the input half of R-1085, shared by the two floor forms (NOT normalizeFloorInput, which +// also validates agent and golden versions, whose numbering is their own): a withdrawn controller build is never +// saved as a floor. It logs the refusal and reports whether the caller must stop. The serve-time half is +// store.ResolveManagedFloor. +func (s *Server) refuseSpoiledFloor(v, where string) bool { + reason, spoiled := store.SpoiledControllerTag(v) + if spoiled { + s.logger.Printf("[WARN] %s floor %q REFUSED: a withdrawn controller build (%s) — nothing saved (R-1085)", where, v, reason) + } + return spoiled } // handleSetGlobalFloor sets (or clears) the global controller-version floor (Phase 2 managed @@ -1236,6 +1259,10 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/configuration?flash=floor_invalid", http.StatusSeeOther) return } + if s.refuseSpoiledFloor(v, "global") { + http.Redirect(w, r, "/configuration?flash=floor_spoiled", http.StatusSeeOther) + return + } ma, flash := s.floorDeclaredMinAgent(r, v) if flash != "" { http.Redirect(w, r, "/configuration?flash="+flash, http.StatusSeeOther) @@ -1598,6 +1625,10 @@ func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_invalid", http.StatusSeeOther) return } + if s.refuseSpoiledFloor(v, "customer "+customerID) { + http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_spoiled", http.StatusSeeOther) + return + } ma, flash := s.floorDeclaredMinAgent(r, v) if flash != "" { http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash, http.StatusSeeOther) diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 3d65a6df..6ba966db 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -73,10 +73,10 @@ type Server struct { // offsiteKeyAudit runs the daily off-site key check on demand (decision 69). nil → 503. offsiteKeyAudit func(ctx context.Context) any // offsiteWindowAdmin: operator one-shot grant / weekly switch (decision 68). nil → 503. - offsiteWindowGrant func(customerID string) error + offsiteWindowGrant func(customerID string) error offsiteWindowGrantMax func(customerID string, maxRemove int) error osUpdates OSUpdateAdmin - offsiteWindowSwitch func(on bool) error + offsiteWindowSwitch func(on bool) error // operator key-file clean-up (decision 72) and abandonment cancel (decision 74). nil → 503. offsiteRemoveUnpinned func(ctx context.Context, customerID string) (int, error) offsiteAbandonCancel func(customerID, by string) (int, error) @@ -139,7 +139,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol // mailHeld drives the MAIL-HOLD banner on every operator page (internal/mailhold). Read at render time, so the // banner disappears the moment the marker is gone. Pinned by TestMailHoldBanner_*. "mailHeld": func() bool { return srv != nil && srv.mailHold.Held() }, - "timeAgo": timeAgo, + "timeAgo": timeAgo, "timeAgoPtr": func(t *time.Time) string { if t == nil { return "—" @@ -1234,6 +1234,11 @@ func (s *Server) renderConfiguration(w http.ResponseWriter, r *http.Request, cre "AssetLastSync": assetLastSync, "GlobalFloor": s.store.GetGlobalMinControllerVersion(), "FloorRes": s.store.ResolveGlobalFloor(), + // R-1085: why the effective global floor is a withdrawn build ("" = it is not) — boxes are served no floor. + "FloorWithdrawn": func() string { + r, _ := store.SpoiledControllerTag(s.store.ResolveGlobalFloor().Effective) + return r + }(), // R-472: the MinAgent declared with the global floor, and the golden it is compared against. "FloorDeclaredMinAgent": s.store.GlobalFloorDeclaredMinAgent(), "FloorGoldenVersion": s.store.GetArtifactManifest().GoldenVersion, diff --git a/hub/internal/web/spoiled_floor_test.go b/hub/internal/web/spoiled_floor_test.go new file mode 100644 index 00000000..38cb628d --- /dev/null +++ b/hub/internal/web/spoiled_floor_test.go @@ -0,0 +1,130 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-1085, the input half — Scenario D: both floor forms refuse a withdrawn build (with or without the "v"), store +// NOTHING, and still save an ordinary version. Red-proof: remove a refuseSpoiledFloor call → its form saves the tag. +func TestGlobalFloorForm_RefusesAWithdrawnBuild(t *testing.T) { + s, st := newTestServer(t) + if err := st.SetGlobalMinControllerVersion("0.314.0"); err != nil { + t.Fatal(err) + } + for _, v := range []string{"0.315.0", "v0.316.3"} { + loc := postGlobalFloor(t, s, url.Values{"min_controller_version": {v}}) + if !strings.Contains(loc, "flash=floor_spoiled") { + t.Fatalf("%s: want flash=floor_spoiled, got %q", v, loc) + } + if got := st.GetGlobalMinControllerVersion(); got != "0.314.0" { + t.Fatalf("%s: a refused form must store nothing — the floor is now %q", v, got) + } + } + if loc := postGlobalFloor(t, s, url.Values{"min_controller_version": {"0.317.0"}}); !strings.Contains(loc, "flash=floor_set") || + st.GetGlobalMinControllerVersion() != "0.317.0" { + t.Fatalf("an ordinary version must still save, got %q / %q", loc, st.GetGlobalMinControllerVersion()) + } +} + +func TestCustomerFloorForm_RefusesAWithdrawnBuild(t *testing.T) { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", RetrievalPassword: "x", APIKey: "y"}); err != nil { + t.Fatal(err) + } + post := func(v string) string { + form := url.Values{"min_controller_version": {v}} + r := httptest.NewRequest(http.MethodPost, "/customers/c1/floor", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.handleSetCustomerFloor(w, r, "c1") + return w.Header().Get("Location") + } + for _, v := range []string{"v0.315.0", "0.316.3"} { + if loc := post(v); !strings.Contains(loc, "flash=floor_spoiled") { + t.Fatalf("%s: want flash=floor_spoiled, got %q", v, loc) + } + if cfg, _ := st.GetCustomerConfig("c1"); cfg.MinControllerVersion != "" { + t.Fatalf("%s: a refused override must store nothing, got %q", v, cfg.MinControllerVersion) + } + } + if loc := post("0.317.0"); !strings.Contains(loc, "flash=floor_set") { + t.Fatalf("an ordinary version must still save, got %q", loc) + } +} + +// The confirm dialog's probe answers valid:false, spoiled:true — and counts nothing — for a withdrawn build. +func TestGlobalFloorImpact_WithdrawnBuildIsNotValid(t *testing.T) { + s, st := newTestServer(t) + seedReport(t, st, "old", "0.300.0") + probe := func(v string) (valid, spoiled bool, below int) { + w := httptest.NewRecorder() + s.handleGlobalFloorImpact(w, httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v="+v, nil)) + var resp struct { + Valid bool `json:"valid"` + Spoiled bool `json:"spoiled"` + Below int `json:"below"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + return resp.Valid, resp.Spoiled, resp.Below + } + if valid, spoiled, below := probe("0.316.3"); valid || !spoiled || below != 0 { + t.Errorf("withdrawn: want valid=false spoiled=true below=0, got %v %v %d", valid, spoiled, below) + } + if valid, spoiled, below := probe("0.317.0"); !valid || spoiled || below != 1 { + t.Errorf("ordinary: want valid=true spoiled=false below=1, got %v %v %d", valid, spoiled, below) + } +} + +// Template gates, one render per branch (hub rules: seam-wiring covers template gates): the Configuration page and +// the customer page say „withdrawn" when the effective floor is a withdrawn build, and say nothing otherwise. +func TestConfigurationPage_SaysWithdrawnOnlyForAWithdrawnFloor(t *testing.T) { + for _, tc := range []struct { + floor string + want bool + }{{"0.316.3", true}, {"0.317.0", false}} { + s, st := newTestServer(t) + if err := st.SetGlobalMinControllerVersion(tc.floor); err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + s.handleConfiguration(rr, httptest.NewRequest(http.MethodGet, "/configuration", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + if got := strings.Contains(rr.Body.String(), `data-floor-withdrawn="1"`); got != tc.want { + t.Errorf("global floor %s: withdrawn notice shown = %v, want %v", tc.floor, got, tc.want) + } + } +} + +func TestCustomerPage_SaysWithdrawnOnlyForAWithdrawnFloor(t *testing.T) { + for _, tc := range []struct { + floor string + want bool + }{{"0.315.0", true}, {"0.317.0", false}} { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active"}); err != nil { + t.Fatal(err) + } + if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil { // the floor card renders only for a reporting box + t.Fatal(err) + } + if err := st.SetMinControllerVersion("acme", tc.floor); err != nil { + t.Fatal(err) + } + html := renderCustomerPage(t, s, "acme") + if got := strings.Contains(html, `data-floor-withdrawn="1"`); got != tc.want { + t.Errorf("customer floor %s: withdrawn notice shown = %v, want %v", tc.floor, got, tc.want) + } + } +} diff --git a/hub/internal/web/templates/configuration.html b/hub/internal/web/templates/configuration.html index 5292e306..c616faeb 100644 --- a/hub/internal/web/templates/configuration.html +++ b/hub/internal/web/templates/configuration.html @@ -56,6 +56,9 @@ {{if eq .Flash "floor_invalid"}}
Invalid version — use X.Y.Z (or blank to clear).
{{end}} + {{if eq .Flash "floor_spoiled"}} +
This version is withdrawn (a faulty build) and cannot be set as the minimum. Nothing was saved.
+ {{end}} {{if eq .Flash "floor_needs_min_agent"}}
This floor is above the vouched golden — declare its MinAgent (from the release's CHANGELOG header). Nothing was saved.
{{end}} @@ -125,6 +128,7 @@

Effective floor: {{if .FloorRes.Effective}}v{{.FloorRes.Effective}}{{else}}none{{end}} + {{if .FloorWithdrawn}}— this version is withdrawn ({{.FloorWithdrawn}}): every box is served no floor; set another version{{end}} {{if eq .FloorRes.Source "db"}} — source: DB (hub_settings){{if .FloorRes.EnvValue}}; env fallback would be v{{.FloorRes.EnvValue}}{{end}} {{else if eq .FloorRes.Source "env"}} diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index f8c523bf..977b2fab 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -53,6 +53,7 @@ {{else if eq .Flash "pbsdr_reissued"}}PBS DR credentials re-issued — a fresh one-time token secret is staged for the agent. Confirm it actually landed: the host's pvesm status must show the PBS entry active. A converged agent can report applied while the storage still authenticates 401 (R-39). {{else if eq .Flash "floor_set"}}Controller-version floor saved. {{else if eq .Flash "floor_invalid"}}Invalid version — use X.Y.Z (or blank to clear). + {{else if eq .Flash "floor_spoiled"}}This version is withdrawn (a faulty build) and cannot be set as the minimum. Nothing was saved. {{else if eq .Flash "floor_needs_min_agent"}}This floor is above the vouched golden — declare its MinAgent (from the release's CHANGELOG header). Nothing was saved. {{else if eq .Flash "floor_min_agent_invalid"}}Invalid MinAgent — use X.Y.Z. Nothing was saved. {{else if eq .Flash "offsite_frozen"}}Offsite storage FROZEN (read-only) — new backups and prune will fail until unfrozen. @@ -696,7 +697,8 @@ Effective floor (min. version) {{if .EffectiveFloor}}v{{.EffectiveFloor}} - {{if .BelowFloor}}● below floor — will auto-update + {{if .EffectiveFloorWithdrawn}}● this version is withdrawn ({{.EffectiveFloorWithdrawn}}) — the box is served no floor; set another version + {{else if .BelowFloor}}● below floor — will auto-update {{else}}— at/above floor{{end}} {{else}}none (Phase 2 inert){{end}} diff --git a/skills/felhom-build-deploy/SKILL.md b/skills/felhom-build-deploy/SKILL.md index 8f8cceeb..f34d3674 100644 --- a/skills/felhom-build-deploy/SKILL.md +++ b/skills/felhom-build-deploy/SKILL.md @@ -39,11 +39,17 @@ Version bumps are ldflags-only (`-X main.version` / `-X main.Version`) — build 9201 is golden/bootstrap-managed — **NO compose file**. `felhom-controller-bootstrap.service` docker-runs the tag written in `/etc/felhom-controller-image` (anonymous Gitea pull). Data volume + encryption key persist. +**Never deploy these controller tags** `0.315.0` `0.316.3` — withdrawn builds (R-1081, +R-1085): their image is not the code their number says, and neither is ever re-pushed. The hub refuses them as a floor +and holds one that arrives any other way; step 3 below refuses them by hand. The hub's list +(`hub/internal/store/spoiled_tags.go`) and these two lines must agree — `TestSpoiledControllerTags_MatchTheSkill`. + ```bash # 1. commit+push the repo (clean-tree gate above) # 2. build+push image, LOCAL (build.sh does NOT git-pull — the explicit pull is load-bearing) cd $FELHOM_ROOT/build/felhom-controller && git -C $FELHOM_ROOT/git/felhom-controller pull && ./build.sh --push -# 3. deploy in the guest +# 3. deploy in the guest — refused for a withdrawn build (the list is the marked line above; spoiled-controller-tags) +VER=; case " 0.315.0 0.316.3 " in *" ${VER#v} "*) echo "REFUSED: $VER is a withdrawn build (R-1085)"; false;; esac && \ ssh felhom-pve "pct exec 9201 -- bash -c 'docker pull gitea.dooplex.hu/admin/felhom-controller: && echo gitea.dooplex.hu/admin/felhom-controller: > /etc/felhom-controller-image && systemctl restart felhom-controller-bootstrap.service'" # 4. verify ssh felhom-pve "pct exec 9201 -- docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"