diff --git a/documentation/audits/evidence-fixes-first-tester-2026-09-30/part0/checklist.md b/documentation/audits/evidence-fixes-first-tester-2026-09-30/part0/checklist.md index 64a6dce0..7db646cc 100644 --- a/documentation/audits/evidence-fixes-first-tester-2026-09-30/part0/checklist.md +++ b/documentation/audits/evidence-fixes-first-tester-2026-09-30/part0/checklist.md @@ -11,7 +11,7 @@ listing (`ep0-storagebox.txt`). | 3 | The tunnel's published route (`*.sajatfelhom.hu` → `https://traefik`, **No TLS Verify** ticked) | **UNKNOWN** — the API token on the record may read DNS only; the tunnel read answered `Authentication error` (stopped there) | Cloudflare → Zero Trust → Networks → Tunnels → this tunnel → Published application routes. Without the route the dashboard answers 503; without No TLS Verify, 502 (R-505, R-510) | | 4 | Off-site | **done** — ON, shared, 100 GB; new sub-account id 322460 (username `u629488-sub2` reused; Peti's sub-account 269130 was emptied and deleted 2026-09-25) | — | | 5 | DR tier (ep0) | **done** — ON; ep0 holds nothing for Tester-2 yet (made when the box connects) and nothing of Peti's | — | -| 6 | The connect e-mail | **sent 07:36 UTC — TWICE** (the customer was created twice in the same second; two different links). Both valid until **2026-10-07 07:36 UTC** | If your friend installs after that: hub → Tester-2 → „Send self-bind link" (Part D makes this automatic) | +| 6 | The connect e-mail | **sent 07:36 UTC — TWICE** (the customer was created twice in the same second). A new link REPLACES the previous one (single-active), so **only the SECOND mail works; the first mail's link is dead** (it answers „expired"). The live one is valid until **2026-10-07 07:36 UTC** | Tell your friend: two identical mails came; if one link says „expired", use the other one. Or press „Send self-bind link" once more just before the install — that leaves exactly one live link (a change on Tester-2, so it is yours to make). After 2026-10-07: hub → Tester-2 → „Send self-bind link", or (after hub v0.126.0) the friend presses „Új linket kérek" on the expired page | | 7 | E-mail language | **English** — the connect mail, the bind page, the setup-code mail and the box itself start in English | Hub → Tester-2 → Edit → „Customer e-mail language", if your friend should get Hungarian | | 8 | Owner passphrase | yours to hand over (5 words, on the customer page) | in person or by phone | | 9 | Customer id `Tester-2` has a capital letter | **never walked before** with a capital; no known break | — (note only) | diff --git a/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-control-after.txt b/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-control-after.txt new file mode 100644 index 00000000..5b8ec003 --- /dev/null +++ b/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-control-after.txt @@ -0,0 +1,14 @@ +## 2026-09-30T08:11:43Z AFTER — every namespace (control) +ns demo-felhom archives 2 bytes 12797189880 newest 1790655403 +ns demo-hp archives 2 bytes 41655240423 newest 1790280385 +ns tester-1 archives 0 bytes 0 newest 0 +root ns archives 0 +datastore used: 19666792448 +--- tester-1 namespace dir: +total 8 +drwxr-xr-x 2 backup backup 4096 Sep 30 08:11 . +drwxr-xr-x 3 backup backup 4096 Sep 14 16:04 .. +--- tester-1 token kept: +1 +--- GC schedule (chunks are released by the datastore GC, not by this session): +| gc-schedule | sun 04:30 | diff --git a/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-control-before.txt b/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-control-before.txt new file mode 100644 index 00000000..fd56af2b --- /dev/null +++ b/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-control-before.txt @@ -0,0 +1,6 @@ +## 2026-09-30T08:11:01Z BEFORE — every namespace (control) +ns demo-felhom archives 2 bytes 12797189880 newest 1790655403 +ns demo-hp archives 2 bytes 41655240423 newest 1790280385 +ns tester-1 archives 3 bytes 29076305272 newest 1790710627 +root ns archives 0 +datastore used: 19667234816 diff --git a/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-removal.txt b/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-removal.txt new file mode 100644 index 00000000..fdc2a826 --- /dev/null +++ b/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-removal.txt @@ -0,0 +1,13 @@ +## 2026-09-30T08:11:32Z operator ruling 51 (2026-09-30); scope: datastore felhom-offsite, ns tester-1, these three archives ONLY +--- target ns=tester-1 ct/9201/2026-09-16T17:27:32Z (1789579652) + cmd: proxmox-backup-debug api delete /admin/datastore/felhom-offsite/snapshots --ns tester-1 --backup-type ct --backup-id 9201 --backup-time 1789579652 + null + rc=0 +--- target ns=tester-1 ct/9201/2026-09-16T21:59:54Z (1789595994) + cmd: proxmox-backup-debug api delete /admin/datastore/felhom-offsite/snapshots --ns tester-1 --backup-type ct --backup-id 9201 --backup-time 1789595994 + null + rc=0 +--- target ns=tester-1 ct/9201/2026-09-29T19:37:07Z (1790710627) + cmd: proxmox-backup-debug api delete /admin/datastore/felhom-offsite/snapshots --ns tester-1 --backup-type ct --backup-id 9201 --backup-time 1790710627 + null + rc=0 diff --git a/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-tester-1-before.txt b/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-tester-1-before.txt new file mode 100644 index 00000000..cc875bd5 --- /dev/null +++ b/documentation/audits/evidence-fixes-first-tester-2026-09-30/partC/ep0-tester-1-before.txt @@ -0,0 +1,3 @@ +ct 9201 1790710627 2026-09-29T19:37:07Z size 3490689830 owner felhom@pbs!tester-1 fingerprint de:51:7a:18:cb:39:22:30:2c:84:f5:8b:d1:91:4b:7e:81:bb:69:b8:89:0f:57:ac:d3:59:e1:1a:62:25:11:2c comment felhom local-api verify ok protected False +ct 9201 1789579652 2026-09-16T17:27:32Z size 4774114206 owner felhom@pbs!tester-1 fingerprint fe:3d:db:95:d4:df:ab:e1:7d:4a:89:fa:2b:07:53:6a:e4:d2:85:95:d1:90:27:4b:d9:c6:92:20:95:04:e5:d4 comment felhom local-api verify ok protected False +ct 9201 1789595994 2026-09-16T21:59:54Z size 20811501236 owner felhom@pbs!tester-1 fingerprint 6b:ca:5f:3f:ca:0f:e2:3f:fb:24:62:89:bf:e7:64:59:9a:41:c5:e6:e3:9f:3f:5f:e1:71:7b:a1:9d:24:67:82 comment felhom local-api verify ok protected False diff --git a/documentation/audits/evidence-fixes-first-tester-2026-09-30/release/agent-release.log b/documentation/audits/evidence-fixes-first-tester-2026-09-30/release/agent-release.log new file mode 100644 index 00000000..95b81cc6 --- /dev/null +++ b/documentation/audits/evidence-fixes-first-tester-2026-09-30/release/agent-release.log @@ -0,0 +1,103 @@ +[release-agent] building 0.138.0 … +[release-agent] built ok: sha256 55916026001790a79ebf97d32c032610cfde8e09d02979b9b9d8c2cbc5d88195 +[release-agent] tagging v0.138.0 at e1b8269 … +[release-agent] publishing … +[publish-agent] publishing /tmp/felhom-agent-hty6yT (14278858 bytes, sha256 55916026001790a7…) → https://gitea.dooplex.hu/api/packages/admin/generic/felhom-agent/0.138.0/felhom-agent +[publish-agent] pre-delete existing artifact: HTTP 404 (404/204 expected) +[publish-agent] upload OK (HTTP 201) +[publish-agent] round-trip GET verified (sha256 matches) +AGENT_VERSION=0.138.0 +AGENT_SHA256=55916026001790a79ebf97d32c032610cfde8e09d02979b9b9d8c2cbc5d88195 +[publish-agent] DONE. Record in the hub operator UI (Configs → Day-0 artifacts): agent 0.138.0 / 55916026001790a79ebf97d32c032610cfde8e09d02979b9b9d8c2cbc5d88195 +[release-agent] pushing v0.138.0 … +pre-push [felhom-agent]: running scripts/agent_gates.py --fast ... +agent_gates — 4 gate(s) [--fast] + --fast SKIPPED (deliberate periodic runs, never in a hook): published + +============================================================================== +== gate: reuse-refs (reuse_refs_check.py /mnt/5_hdd/felhom.eu/git/felhom-agent) +============================================================================== +note [felhom-agent] line 169: localapi/debuglogs_test.go (resolved by suffix → internal/localapi/debuglogs_test.go) +note [felhom-agent] line 185: hub/internal/store/dr_recipe.go (cross-repo → felhom.eu/hub/internal/store/dr_recipe.go) +OK [felhom-agent]: 98 cited paths — exact 96, suffix 1, ambiguous 0, cross-repo 1, FAILED 0 (siblings searched: app-catalog-felhom.eu, felhom-controller, felhom.eu) + +============================================================================== +== gate: instructions (instructions_gate.py /mnt/5_hdd/felhom.eu/git/felhom-agent) +============================================================================== +instructions_gate: /mnt/5_hdd/felhom.eu/git/felhom-agent + CLAUDE.md effective lines : 107 (ceiling 200) + version literals : 0 + TEMPORARY blocks : 0 + rule files : 5 (5 path-scoped) + workspace file : SYMLINK -> felhom.eu/documentation/runbooks/workspace-CLAUDE.md (resolves to the versioned copy) + memory index : 200 lines (ceiling 200), 25594 bytes (ceiling 25600) + memory index content : 33 version literal(s), 5 host address(es), 0 expired statement(s) [WARN only] + memory topic files : 166 indexed, 2 orphaned, 41 archived + register citations : 3 cited, 692 register items known + +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:30: version literal '0.14.0' — the fleet is not uniform, so it is stale within a day. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - **[restic 0.14.0 `--verify` is size+mtime, NOT content](restic-0140-verify-and-lock-sema +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:45: version literal '1.25.0' — the fleet is not uniform, so it is stale within a day. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - [ISO train v1.25.0](iso-train-v1.25.0-2026-07-23.md) — R-71 build-gate (golden≥floor), r +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:48: version literal '0.263.2' — the fleet is not uniform, so it is stale within a day. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - **[.felhom.yml flows on SYNC, not at pull](felhom-yml-flows-on-sync-not-pull.md)** — the +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:74: version literal '1.2.0' — the fleet is not uniform, so it is stale within a day. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - [Offsite pool-box aggregate](offsite-pool-box-aggregate-2026-07-17.md) — R-5 hub 0.64/0. +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:78: version literal '0.90.0' — the fleet is not uniform, so it is stale within a day. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - [Guest RAM resize + fast-tick](guest-ram-resize-fasttick-2026-07-17.md) — R-24 controlle +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:78: version literal '0.143.0' — the fleet is not uniform, so it is stale within a day. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - [Guest RAM resize + fast-tick](guest-ram-resize-fasttick-2026-07-17.md) — R-24 controlle +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md: ... and 27 more version literal(s) — full list from the tally counts above. +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:54: host address '192.168.0.192' — operations/nodes.md is the single home for these. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - [hub.felhom.eu resolves to the LAN here](hub-resolves-to-lan-on-dooplex-network.md) — 19 +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:69: host address '192.168.0.0' — operations/nodes.md is the single home for these. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - [Tailscale N100 location-independent](tailscale-n100-location-independent-2026-07-19.md) +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:73: host address '167.233.158.164' — operations/nodes.md is the single home for these. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - [Offsite PBS box RAM ceiling](offsite-pbs-box-ram-ceiling-2026-07-27.md) — **root SSH = +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:73: host address '10.77.0.1' — operations/nodes.md is the single home for these. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - [Offsite PBS box RAM ceiling](offsite-pbs-box-ram-ceiling-2026-07-27.md) — **root SSH = +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory/MEMORY.md:181: host address '192.168.0.180' — operations/nodes.md is the single home for these. Not a failure: Claude writes this file between sessions, so this warning is aimed at the model that will next edit it, not at whoever is pushing. + - **CC runs ON DooPlex** (192.168.0.180) — repos `/mnt/5_hdd/felhom.eu/git/felhom-*`, buil +WARNING: /mnt/5_hdd/felhom.eu/git/.claude-memory: 2 top-level topic file(s) are not referenced by MEMORY.md, so nothing will ever read them: peti-return-p1-stop-2026-07-13.md, stopped-not-fault-v0164-2026-07-24.md + +instructions_gate: OK (13 warning(s)) + +============================================================================== +== gate: release-complete (check-release-complete.py) +============================================================================== +check-release-complete — the newest CHANGELOG version is a complete release + newest CHANGELOG version: v0.137.0 + ok tag v0.137.0 -> dd81866b16, an ancestor of HEAD + ok package 0.137.0 is downloadable + +check-release-complete: v0.137.0 is tagged, placed and published. + +============================================================================== +== gate: observations (observations_gate.py /mnt/5_hdd/felhom.eu/git/felhom-agent) +============================================================================== +observations gate OK — REPORT.md has no observations section (nothing to check) + +============================================================================== +== summary +============================================================================== + reuse-refs OK (exit 0) + instructions OK (exit 0) + release-complete OK (exit 0) + observations OK (exit 0) + +all agent gates OK +pre-push [felhom-agent]: gates OK - push proceeding. +remote: . Processing 1 references +remote: Processed 1 references in total +To https://gitea.dooplex.hu/admin/felhom-agent.git + * [new tag] v0.138.0 -> v0.138.0 +[release-agent] verifying by independent download … + + RELEASED — and installable, verified by download, not by this script's own say-so. + + version : 0.138.0 + tag : v0.138.0 + sha256 : 55916026001790a79ebf97d32c032610cfde8e09d02979b9b9d8c2cbc5d88195 + + NOT VOUCHED. Vouching is what points machines at this version and stays your deliberate act: + hub operator UI → Configs → Day-0 artifacts. Until then boxes keep installing the previous one. diff --git a/documentation/audits/evidence-fixes-first-tester-2026-09-30/release/agent-signed-jobs.txt b/documentation/audits/evidence-fixes-first-tester-2026-09-30/release/agent-signed-jobs.txt new file mode 100644 index 00000000..dfaac989 --- /dev/null +++ b/documentation/audits/evidence-fixes-first-tester-2026-09-30/release/agent-signed-jobs.txt @@ -0,0 +1,8 @@ +## 2026-09-30T08:34:43Z signed agent_update → demo-hp-bb76ea (0.138.0, sha 55916026…) +signed: op=agent_update host=demo-hp-bb76ea guest="" key_id=felhom-op-1 nonce=f7a2c193d6132305cb29561d32f9709f expires=2026-09-30T09:34:43Z +wrote envelope to /tmp/claude-1000/-mnt-5-hdd-felhom-eu-git/753351c4-7b70-46b6-9338-40951c9512c5/scratchpad/fx/env-demo-hp-bb76ea.json +uploaded signed op to the hub jobs queue +## 2026-09-30T08:34:43Z signed agent_update → demo-felhom-8363b5 (0.138.0, sha 55916026…) +signed: op=agent_update host=demo-felhom-8363b5 guest="" key_id=felhom-op-1 nonce=cc2e7feffbe7e6b61f2d757c4fbbe724 expires=2026-09-30T09:34:43Z +wrote envelope to /tmp/claude-1000/-mnt-5-hdd-felhom-eu-git/753351c4-7b70-46b6-9338-40951c9512c5/scratchpad/fx/env-demo-felhom-8363b5.json +uploaded signed op to the hub jobs queue diff --git a/documentation/audits/evidence-fixes-first-tester-2026-09-30/release/controller-floor.txt b/documentation/audits/evidence-fixes-first-tester-2026-09-30/release/controller-floor.txt new file mode 100644 index 00000000..c3ba4edb --- /dev/null +++ b/documentation/audits/evidence-fixes-first-tester-2026-09-30/release/controller-floor.txt @@ -0,0 +1,4 @@ +## 2026-09-30T08:33:18Z raise the global floor to 0.283.0, MinAgent 0.131.0 declared (above golden 0.282.0 → declaration required, R-472) +HTTP/1.1 303 See Other +Location: /configuration?flash=floor_set +2026/09/30 10:33:19 [INFO] Global controller-version floor set to "0.283.0" (declared MinAgent "0.131.0") diff --git a/documentation/runbooks/VOLUNTEER-first-hour.en.md b/documentation/runbooks/VOLUNTEER-first-hour.en.md index 9e8481ad..7108b5c7 100644 --- a/documentation/runbooks/VOLUNTEER-first-hour.en.md +++ b/documentation/runbooks/VOLUNTEER-first-hour.en.md @@ -24,17 +24,22 @@ 1. Creates the customer on the hub (name, e-mail, domain), **with the language set to English** — the claim e-mail, the bind page and the box's first screen all follow that setting. The hub **sends the connect e-mail by itself** when the customer has an e-mail address and no box - yet (at creation, when an address is added, and when an earlier box is deleted). There is no - button to press; the customer page shows when it went out (R-509, 2026-09-15). + yet (at creation, when an address is added, and when an earlier box is deleted). The link is valid + for **7 days**, and every new link replaces the previous one. If the install is later than that, press + "Send self-bind link" on the customer page that day — or the volunteer presses **"Send me a new link"** + on the expired link's page (hub v0.126.0). *(2026-09-30)* 2. **Creates the Cloudflare tunnel and enters its token** on the customer's page — without it the - dashboard address does not open (R-494). + dashboard address does not open (R-494). The tunnel's route: `*.` → `https://traefik`, with + **No TLS Verify** on (day0-install A.1). *(2026-09-30)* + **Checks the domain's Cloudflare DNS records**: if an earlier box used the domain, an old record may + still point at the old tunnel — the name must point at the NEW tunnel. *(2026-09-30)* 3. **Hands over the "Owner passphrase" in person or in a message.** No e-mail contains it. It is six English words for an English account, five Hungarian ones for a Hungarian account (R-597). ## What you will need - A machine where **all data will be erased** (the install overwrites the chosen disk completely). -- A USB stick of at least 2 GB. +- A USB stick of at least 4 GB. *(2026-09-30)* - A network cable to your router. - The **Owner passphrase** (a few hyphen-joined words) you received from Felhom. - The e-mail account you gave to Felhom. @@ -44,7 +49,7 @@ Open **https://felhom.eu/en/download** and download the installer it names (about 1.7 GB). The page also gives the file's checksum (SHA-256). -Write it to a USB stick (Windows: Rufus, **in "DD" mode**; Mac/Linux: balenaEtcher). +Write it to a USB stick: balenaEtcher (Windows, Mac, Linux), or on Windows Rufus **in "DD" mode**. *(2026-09-30)* ## 2. Install (~15 minutes, about 3 of them copying) @@ -54,13 +59,13 @@ Start the machine from the USB stick. **The installer screens are in English** |---|---| | Felhom logo, two lines | Choose **"Felhom telepítés / Install Felhom"** (or wait; it starts by itself). The second line, **"… (szöveges mód) / … (text mode)"**, installs exactly the same thing without the graphics — take it if the first one shows nothing. | | END USER LICENSE AGREEMENT | **I agree** | -| Target harddisk | **The installer never chooses for you.** It lists the machine's disks with their size and type; choose the one **the system should go on — that disk will be erased.** Unplug your external backup drive for the install. If there are several internal disks and you do not know which is the right one, stop and ask the operator. | +| Target harddisk | The installer **pre-selects the first disk** — check that it is the right one. *(2026-09-30)* It lists the machine's disks with their size and type; choose the one **the system should go on — that disk will be erased.** Unplug your external backup drive for the install. If there are several internal disks and you do not know which is the right one, stop and ask the operator. | | Country / Timezone / Keyboard | Set the **Keyboard Layout** to the one your keyboard actually has (**U.S. English** for most English keyboards). The Hungarian guide says to leave this alone, and for a Hungarian keyboard that is right — but if the layout does not match your keyboard you will type a root password you cannot reproduce. Country and Time zone can stay as they are. | | Root password | Enter a password of at least 8 characters, twice. **You do not need to remember it** — Felhom replaces it after the first start. | | Administrator email | Type your own e-mail address (instead of `mail@example.invalid`) | | Hostname (FQDN) | **Change it**, because the default is refused. Type: `felhom.` (the domain you got from Felhom) | | IP address, Gateway, DNS | Leave as they are | -| Summary | **Install** | +| Summary | The highlight rests on **Previous**: move to **Install** with the right arrow, and press that. *(2026-09-30)* | | Summary → **"Automatically reboot after successful installation"** | This is **ticked by default**, so the machine reboots ON ITS OWN when the install finishes — you will not be asked. **Take the USB stick out while the install is still running**, or untick that box if you would rather press Reboot yourself. (The Hungarian guide describes a *"Installation finished — reboot now?"* prompt; with the default settings that prompt does not appear.) | ## 3. The first start (~2 minutes) @@ -75,7 +80,8 @@ Write the **pairing code** down. It is printed once in each language; both are t ## 4. Connect the box to your account (~2 minutes) 1. Open the e-mail with the subject **"[Felhom] Link your Felhom box to your account"** and follow the link in it - (valid for 7 days). + (valid for 7 days). If it says the link has expired, press **"Send me a new link"** — a new e-mail + arrives in a few minutes. If you received two such e-mails, only the newer one works. *(2026-09-30)* 2. Enter: - the **pairing code** from the box's screen; - the **Owner passphrase** you received from the Felhom operator. @@ -86,7 +92,9 @@ fault, and you do not need to connect it again.)* ## 5. Set up the dashboard (~1 minute) -1. Open the e-mail with the subject **"[Felhom] Your Felhom server is up — setup code"**. +1. Open the e-mail with the subject **"[Felhom] Your Felhom server is up — setup code"**. If you had a + box before, the subject says your server was **reinstalled** and asks for a new setup code — use it + the same way. *(2026-09-30)* 2. Follow the address in it (`https://felhom.`). If the address does not open, tell the operator. 3. On the **"Set up the server"** page enter the **setup code** and choose a password of **at least @@ -102,8 +110,12 @@ fault, and you do not need to connect it again.)* 1. On the dashboard: **Apps**. Find **BookStack** (a family wiki) and **PrivateBin** (encrypted notes), and select **Install**. -2. On the install page the only question is the **subdomain** — leave the default (`wiki`, `paste`). - You do not need to write down the "Automatically generated values". +2. On the install page leave the default **subdomain** (`wiki`, `paste`). BookStack also asks for an + **admin password**: press **Generate**. You do not need to write down the "Automatically generated + values" — you can see the password later (step 8). *(2026-09-30)* + *Many apps open **only for you** after the install, while you are signed in to the dashboard: create + your first (admin) account in it. After that it opens for everyone by itself, or press "Done, I set it + up" on the app's page.* *(2026-09-30)* 3. **Start the install.** BookStack takes about a minute, PrivateBin about 20 seconds. ## 7. The recovery code (~2 minutes) — do not skip this @@ -112,7 +124,7 @@ The box sends an **encrypted** copy of your files to Felhom's remote storage. ** key to that encryption: it is the **recovery code**. Until you create it, **the remote backup does not start**. -**When the yellow bar appears (a few minutes after the setup)**, that is the moment. The box spends +**When the yellow bar appears (about 15 minutes after the box is connected)**, that is the moment. *(2026-09-30)* The box spends its first minutes preparing the remote storage, and until then it cannot create the code — which is why the bar only appears once it can. The bar says: "Remote backup is paused until you create your recovery code." @@ -129,13 +141,18 @@ it refreshes itself. > so that we ourselves cannot open them. If the code is lost, the remote copies remain, but > **nobody — not us either — can open them again**. -Once you are done, the bar disappears and the remote backup starts by itself. +Once you are done, the bar disappears and the remote backup starts by itself. New apps go into the +remote backup by themselves (controller v0.283.0). If the Backup page says "Some apps have no off-site +copy. Turn it on for all of them?", press **"Yes, all of them"**. If the page says the apps do not fit in +the off-site storage, turn the off-site copy off for the apps that do not need one outside the house. *(2026-09-30)* ## 8. Signing in to the apps for the first time - **BookStack:** on the app's page the "First steps" give the address as `wiki.DOMAIN` — put your own - domain in place of DOMAIN (known fault). Sign in: `admin@admin.com` / `password`. Change it - **straight away**: your profile at the top right → Settings → password and e-mail. + domain in place of DOMAIN (known fault). Sign in: `admin@admin.com` and the password generated at the + install — the 👁 button beside "Admin password" on the app's **Settings** page shows it. The old + `password` no longer works. Then change the e-mail to your own (your profile at the top right → + Settings). *(2026-09-30)* - **PrivateBin:** there is no sign-in. Type your text, select **Send**, and share the link you get — the key is in the link, and the server does not see the content. diff --git a/documentation/runbooks/VOLUNTEER-first-hour.md b/documentation/runbooks/VOLUNTEER-first-hour.md index 9b667502..0867b999 100644 --- a/documentation/runbooks/VOLUNTEER-first-hour.md +++ b/documentation/runbooks/VOLUNTEER-first-hour.md @@ -35,19 +35,25 @@ ## Az üzemeltető előtte (nem az önkéntes feladata) -1. Létrehozza az ügyfelet a hubon (név, e-mail, domain). +1. Létrehozza az ügyfelet a hubon (név, e-mail, domain), **és beállítja az e-mailek nyelvét** (Magyar / + English) — a kapcsolódó levél, az összekötő oldal és a doboz első képernyője ezt követi. *(2026-09-30)* A kapcsolódó linket tartalmazó e-mailt a hub **magától küldi**, amikor az ügyfélnek van e-mail címe és - még nincs gépe (létrehozáskor, e-mail megadásakor, egy korábbi gép törlésekor). Gombot nyomni nem kell; - az ügyfél oldalán látszik, mikor ment ki (R-509, 2026-09-15). + még nincs gépe (létrehozáskor, e-mail megadásakor, egy korábbi gép törlésekor). A link **7 napig** + érvényes, és minden új link érvényteleníti az előzőt. Ha a telepítés ennél később lesz, nyomd meg az + ügyfél oldalán a „Send self-bind link" gombot aznap — vagy az önkéntes a lejárt link oldalán az + **„Új linket kérek"** gombbal kér újat (hub v0.126.0). *(2026-09-30)* 2. **Létrehozza a Cloudflare tunnelt és beírja a tokent** az ügyfél adatlapján — enélkül a - vezérlőpult címe nem nyílik meg (R-494). + vezérlőpult címe nem nyílik meg (R-494). A tunnel útvonala: `*.` → `https://traefik`, **No TLS + Verify** bekapcsolva (day0-install A.1). *(2026-09-30)* + **Ellenőrzi a domain Cloudflare DNS-rekordjait**: ha a domaint korábban másik doboz használta, egy régi + rekord még a régi tunnelre mutathat — a névnek az ÚJ tunnelre kell mutatnia. *(2026-09-30)* 3. **Személyesen vagy üzenetben átadja a 5 szóból álló „Tulajdonosi jelmondatot".** Ezt semmilyen e-mail nem tartalmazza. ## Mire lesz szükséged - Egy gép, amelyen **minden adat törlődik** (a telepítés a kiválasztott lemezt teljesen felülírja). -- Egy legalább 2 GB-os USB-kulcs. +- Egy legalább 4 GB-os USB-kulcs. *(2026-09-30)* - Hálózati kábel a routeredhez. - A Felhomtól kapott **Tulajdonosi jelmondat** (5 szó). - Az e-mail fiókod, amelyet a Felhomnak megadtál. @@ -57,7 +63,7 @@ Nyisd meg a **https://felhom.eu/letoltes** oldalt, és töltsd le az ott megadott telepítőt (kb. 1,7 GB). Az oldal a fájl ellenőrző összegét (SHA-256) is megadja. -Írd ki USB-kulcsra (Windows: Rufus, **„DD" módban**; Mac/Linux: balenaEtcher). +Írd ki USB-kulcsra: balenaEtcher (Windows, Mac, Linux), vagy Windows-on Rufus **„DD" módban**. *(2026-09-30)* ## 2. Telepítés (~15 perc, ebből ~3 perc másolás) @@ -67,14 +73,14 @@ Indítsd a gépet az USB-kulcsról. **A telepítő képernyői angolul vannak** |---|---| | Felhom logó, két sor | Válaszd a **„Felhom telepítés"** sort (vagy várj, magától elindul) | | END USER LICENSE AGREEMENT | **I agree** | -| Target harddisk | **A telepítő soha nem választ helyetted.** Felsorolja a gép lemezeit méretükkel és típusukkal; válaszd azt, **amelyre a rendszer kerüljön — ez a lemez törlődik.** A külső mentőlemezt a telepítés idejére húzd ki. Ha több belső lemez van és nem tudod, melyik a jó, állj meg és szólj az üzemeltetőnek. | +| Target harddisk | A telepítő **előre kijelöli az első lemezt** — ellenőrizd, hogy az a jó. *(2026-09-30)* Felsorolja a gép lemezeit méretükkel és típusukkal; válaszd azt, **amelyre a rendszer kerüljön — ez a lemez törlődik.** A külső mentőlemezt a telepítés idejére húzd ki. Ha több belső lemez van és nem tudod, melyik a jó, állj meg és szólj az üzemeltetőnek. | | Country / Timezone / Keyboard | Hagyd így: Hungary, Europe/Budapest, Hungarian | | Root password | Adj meg egy legalább 8 karakteres jelszót kétszer. **Nem kell megjegyezned** — a Felhom az első indulás után lecseréli. | | Administrator email | Írd be a saját e-mail címedet (a `mail@example.invalid` helyett) | | Hostname (FQDN) | **Írd át**, mert az alapértelmezettet nem fogadja el. Írd be: `felhom.` (a Felhomtól kapott domain) | | IP address, Gateway, DNS | Hagyd, ahogy van | -| Summary | **Install** | -| „Installation finished — reboot now?" | **Húzd ki az USB-kulcsot**, majd **Reboot now** | +| Summary | A kijelölés a **Previous** gombon áll: lépj a jobbra nyíllal az **Install**-ra, és azt nyomd meg. *(2026-09-30)* | +| Summary → „Automatically reboot after successful installation" | **Alapból be van pipálva**, így a gép a telepítés végén **magától újraindul**, kérdés nélkül. **A másolás alatt húzd ki az USB-kulcsot**, vagy vedd ki a pipát, ha inkább te nyomnád meg a „Reboot now"-t. *(2026-09-30)* | ## 3. Az első indulás (~2 perc) @@ -89,7 +95,8 @@ sem kell." Alatta, kb. 2 perc múlva: ## 4. A doboz összekötése a fiókoddal (~2 perc) 1. Nyisd meg a **„[Felhom] Kösd össze a Felhom dobozodat"** tárgyú e-mailt, és kattints a benne lévő - hivatkozásra (7 napig érvényes). + hivatkozásra (7 napig érvényes). Ha azt írja, hogy lejárt, nyomd meg az **„Új linket kérek"** gombot — + pár perc múlva új levél jön. Ha két ilyen levelet kaptál, csak az újabb működik. *(2026-09-30)* 2. Add meg: - a **Párosító kódot** a doboz képernyőjéről; - a **Tulajdonosi jelmondatot** (5 szó), amelyet a Felhom üzemeltetőjétől kaptál. @@ -100,7 +107,9 @@ nem kell újra összekötni.)* ## 5. A vezérlőpult beállítása (~1 perc) -1. Nyisd meg a **„[Felhom] Elindult a Felhom szervered — beállító kód"** tárgyú e-mailt. +1. Nyisd meg a **„[Felhom] Elindult a Felhom szervered — beállító kód"** tárgyú e-mailt. Ha korábban + már volt dobozod, a levél tárgya **„[Felhom] Új beállító kód — újratelepült a szervered"** — ugyanúgy + használd. *(2026-09-30)* 2. Kattints a benne lévő címre (`https://felhom.`). Ha a cím nem nyílik meg, szólj az üzemeltetőnek. 3. „A szerver beállítása" oldalon add meg a **Beállító kódot** (3 szó, pl. `szó-szó-szó`), és válassz @@ -111,8 +120,12 @@ nem kell újra összekötni.)* 1. A vezérlőpulton: **Alkalmazások**. Keresd meg például a **BookStack**-et (családi wiki) és a **PrivateBin**-t (titkosított jegyzet), és nyomd meg a **Telepítés** gombot. -2. A telepítő oldalon csak az **aldomain** a kérdés — hagyd az alapértelmezettet (`wiki`, `paste`). - Az „Automatikusan generált értékek" részt nem kell felírnod. +2. A telepítő oldalon hagyd az alapértelmezett **aldomaint** (`wiki`, `paste`). A BookStack egy + **Admin jelszót** is kér: nyomd meg a **Generálás** gombot. Az „Automatikusan generált értékek" részt + nem kell felírnod — a jelszót később is megnézheted (8. lépés). *(2026-09-30)* + *Sok alkalmazás a telepítés után **csak neked** nyílik meg, amíg be vagy jelentkezve a vezérlőpultba: + hozd létre benne az első (admin) fiókodat. Utána magától megnyílik mindenkinek, vagy az alkalmazás + oldalán nyomd meg a „Kész, beállítottam" gombot.* *(2026-09-30)* 3. **Telepítés indítása.** A BookStack kb. 1 perc, a PrivateBin kb. 20 másodperc. ## 7. A helyreállítási kód (~2 perc) — ezt ne hagyd ki @@ -121,7 +134,7 @@ A doboz a fájljaidról **titkosított** másolatot küld a Felhom távoli tárh kulcsát **csak te** kapod meg: ez a **helyreállítási kód**. Amíg nem hozod létre, **a távoli mentés nem indul el**. -**Amikor a sárga sáv megjelenik (a beállítás után néhány perccel),** akkor jött el az ideje. A doboz az +**Amikor a sárga sáv megjelenik (a doboz összekötése után kb. 15 perccel),** akkor jött el az ideje. *(2026-09-30)* A doboz az első perceket a távoli tárhely előkészítésével tölti, és addig a kódot még nem tudja létrehozni — ezért a sáv csak akkor jelenik meg, amikor már lehet. A sáv ezt írja: „A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot." @@ -139,13 +152,19 @@ magától frissül. > úgy titkosítják, hogy mi magunk se tudjuk megnyitni őket. Ha a kód elvész, a távoli másolatok > megmaradnak, de **senki — mi sem — nem tudja többé megnyitni őket**. -Ha ezzel megvagy, a sáv eltűnik, és a távoli mentés magától elindul. +Ha ezzel megvagy, a sáv eltűnik, és a távoli mentés magától elindul. Az új alkalmazások maguktól +bekerülnek a távoli mentésbe (vezérlő v0.283.0). Ha a Biztonsági mentés oldalon azt látod: „Van +alkalmazás, amelyről nem készül távoli mentés. Bekapcsolod mindegyikre?", nyomd meg: **„Igen, +mindegyikre"**. Ha az oldal szerint az alkalmazások nem férnek el a távoli tárhelyen, kapcsold ki a +távoli mentést annál, amelyiknek nem kell a házon kívül is lennie. *(2026-09-30)* ## 8. Első belépés az alkalmazásokba - **BookStack:** az alkalmazás oldalán az „Első lépések" rész a címet `wiki.DOMAIN` alakban írja — - a DOMAIN helyére a saját domained kerül (ismert hiba). Belépés: `admin@admin.com` / `password`. - **Azonnal** változtasd meg: jobb felül a profilod → Beállítások → jelszó és e-mail. + a DOMAIN helyére a saját domained kerül (ismert hiba). Belépés: `admin@admin.com` és a telepítéskor + generált jelszó — az alkalmazás **Beállítások** oldalán az „Admin jelszó" melletti 👁 gomb mutatja meg. + A régi `password` már nem működik. Utána változtasd meg az e-mail címet a sajátodra (jobb felül a + profilod → Beállítások). *(2026-09-30)* - **PrivateBin:** nincs belépés. Írj be szöveget, **Küldés**, és a kapott linket oszd meg — a kulcs a linkben van, a szerver nem látja a tartalmat. diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 2e5e09ee..01984967 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,19 @@ +## v0.126.0 — a fresh connect link from the old one (R-719); no "recovered" for a new box and no first-hour tier-skip mail (R-723); the bind page names who hands over the phrase (R-725) (2026-09-30) + +- **R-719:** a box that registers is UNCLAIMED — the registration carries uuid, MACs, host keys and hardware, nothing + of a customer (measured, `internal/api/appliance.go`) — so the hub cannot send "your" link when "your" box + registers without mailing every waiting customer. Instead the self-bind page's **expired** and **already used** + states offer „Új linket kérek" (`POST /bind//resend`): the hub mints and mails a fresh link to the address + REGISTERED for that link's customer, only when the customer has no box, at most once an hour per customer. The + answer is the same page in the default language whatever the token was (no oracle — `TestBindExpiredIsAlwaysDefaultLanguage` + now compares the two pages with each visitor's own token normalised; every other byte must still match). +- **R-723:** `node_recovered` is not emitted when the customer's current host was enrolled after the outage began + (or, after a hub restart, within the stale threshold) — a new box is not a recovery; `backup_tier_skipped` in a box's + first hour is recorded, not mailed (provisioning in flight). Real recoveries and old boxes' skips still mail. +- **R-725 (hub half):** the bind page's passphrase hint says „amelyet a Felhom üzemeltetőjétől kaptál", as the mail + and the console do. +- Tests: `TestR719_*`, `TestR723_*`. Red-proofs RP40–RP42, each seen failing on an assertion. + ## v0.125.0 — the customer delete stops promising a Cloudflare removal it never did (2026-09-25, R-688) - **Customer delete dialog** (`web/customer_delete.go`, `templates/customer_unified.html`): no leg of the cascade diff --git a/hub/internal/i18n/locales/en.json b/hub/internal/i18n/locales/en.json index dd195cc2..3b9e7d43 100644 --- a/hub/internal/i18n/locales/en.json +++ b/hub/internal/i18n/locales/en.json @@ -66,7 +66,7 @@ "bind.hint.pairing": "It appears on the box's monitor, after the install.", "bind.label.passphrase": "Owner passphrase", "bind.placeholder.passphrase": "the words, with hyphens or spaces", - "bind.hint.passphrase": "The word phrase you received from your operator during setup. It proves the account is yours.", + "bind.hint.passphrase": "The five-word phrase you received from your Felhom operator. It proves the account is yours.", "bind.submit": "Link the box", "bind.note": "For safety the link locks after 5 failed attempts. If that happens, contact support.", "bind.success.lead": "Linked successfully.", @@ -82,5 +82,9 @@ "mail.test.body": "Dear Customer,\n\nThis is a test notification from the Felhom monitoring system.\nNotifications are working correctly.\n\nBest regards,\nFelhom.eu monitoring", "mail.event.app_update_undone": "%s: the update did not work; the app runs on its previous version", "mail.event.app_update_held": "%s: the app is stopped and needs a restore", - "mail.event.app_stopped_unhealthy": "%s: the app was stopped because it kept crashing" + "mail.event.app_stopped_unhealthy": "%s: the app was stopped because it kept crashing", + "bind.resend.hint": "If this was your link and your box is not linked yet, we send a fresh link to the e-mail address you gave Felhom.", + "bind.resend.button": "Send me a new link", + "bind.resent.lead": "Done.", + "bind.resent.body": "If this was a real link and your box is not linked yet, a new e-mail reaches your registered address within a few minutes. If it does not, contact support." } diff --git a/hub/internal/i18n/locales/hu.json b/hub/internal/i18n/locales/hu.json index 17cfdae0..b40efafe 100644 --- a/hub/internal/i18n/locales/hu.json +++ b/hub/internal/i18n/locales/hu.json @@ -66,7 +66,7 @@ "bind.hint.pairing": "A doboz monitorán jelenik meg, a telepítés után.", "bind.label.passphrase": "Tulajdonosi jelmondat", "bind.placeholder.passphrase": "öt szó, kötőjellel vagy szóközzel", - "bind.hint.passphrase": "Az öt szóból álló kifejezés, amelyet a beállításkor kaptál. Ez igazolja, hogy a fiók a tiéd.", + "bind.hint.passphrase": "Az öt szóból álló kifejezés, amelyet a Felhom üzemeltetőjétől kaptál. Ez igazolja, hogy a fiók a tiéd.", "bind.submit": "Összekötés", "bind.note": "Biztonsági okból 5 sikertelen próbálkozás után a hivatkozás zárolódik. Ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.", "bind.success.lead": "Sikeres összekötés.", @@ -82,5 +82,9 @@ "mail.test.body": "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring", "mail.event.app_update_undone": "%s: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut", "mail.event.app_update_held": "%s: az alkalmazás leállítva, visszaállítás szükséges", - "mail.event.app_stopped_unhealthy": "%s: az alkalmazást leállítottuk, mert újra és újra összeomlott" + "mail.event.app_stopped_unhealthy": "%s: az alkalmazást leállítottuk, mert újra és újra összeomlott", + "bind.resend.hint": "Ha ez a te linked volt, és a dobozod még nincs összekötve, új linket küldünk arra az e-mail címre, amelyet a Felhomnál megadtál.", + "bind.resend.button": "Új linket kérek", + "bind.resent.lead": "Kész.", + "bind.resent.body": "Ha ez egy valódi hivatkozás volt, és a dobozod még nincs összekötve, néhány percen belül új e-mailt kapsz a regisztrált címedre. Ha nem jön, szólj az ügyfélszolgálatnak." } diff --git a/hub/internal/monitor/r723_new_box_test.go b/hub/internal/monitor/r723_new_box_test.go new file mode 100644 index 00000000..f56ace6b --- /dev/null +++ b/hub/internal/monitor/r723_new_box_test.go @@ -0,0 +1,58 @@ +package monitor + +import ( + "database/sql" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +func hostFor(t *testing.T, st *store.Store, path string, createdAgo time.Duration) { + t.Helper() + if err := st.UpsertHost(&store.Host{HostID: "c1-abc123", CustomerID: "c1", APIKey: "hk"}); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`UPDATE hosts SET created_at = ? WHERE host_id = 'c1-abc123'`, + time.Now().UTC().Add(-createdAgo).Format("2006-01-02 15:04:05")); err != nil { + t.Fatal(err) + } +} + +// R-723 (v0.126.0) — the 2026-09-29 shape: the customer's previous box went silent 12 days ago (the hub +// seeds the customer as down), a NEW box enrolls and sends its first report. The CONSEQUENCE asserted: no +// `node_recovered` (it is the operator mail „recovered" for an outage the new box never had). +// COMPANION RED-PROOF: make isNewBox return false → the event list is [node_recovered]. +func TestR723_ANewBoxIsNotARecovery(t *testing.T) { + st, path := seedStalenessCustomer(t, "ok", 12*24*time.Hour) + sc, events := newChecker(t, st) // seeds c1 as down (the old box's silence) + if sc.GetState("c1") != "down" { + t.Fatalf("setup: want down, got %q", sc.GetState("c1")) + } + hostFor(t, st, path, 2*time.Minute) // the new box enrolled 2 min ago + saveReportAged(t, st, path, "ok", 0) // …and reported + sc.Check() + if len(*events) != 0 { + t.Fatalf("a new box's first report emitted %v", *events) + } + if sc.GetState("c1") != "ok" { + t.Fatalf("state %q, want ok", sc.GetState("c1")) + } +} + +// Control: the SAME box, enrolled long ago, coming back after an outage IS a recovery and still mails. +func TestR723_AnOldBoxComingBackIsStillARecovery(t *testing.T) { + st, path := seedStalenessCustomer(t, "ok", 12*24*time.Hour) + sc, events := newChecker(t, st) + hostFor(t, st, path, 40*24*time.Hour) + saveReportAged(t, st, path, "ok", 0) + sc.Check() + if len(*events) != 1 || (*events)[0] != "node_recovered" { + t.Fatalf("a real recovery must still emit node_recovered, got %v", *events) + } +} diff --git a/hub/internal/monitor/staleness.go b/hub/internal/monitor/staleness.go index 29034161..e5ace67c 100644 --- a/hub/internal/monitor/staleness.go +++ b/hub/internal/monitor/staleness.go @@ -171,6 +171,19 @@ func (sc *StalenessChecker) Check() { continue } + // R-723 (v0.126.0): a customer's NEW box is not a recovery. Staleness is tracked per CUSTOMER, so a + // customer whose previous box went silent reads stale/down, and the new box's first report used to + // send `node_recovered` for an outage the new box never had (measured 2026-09-29: tester-1, silent 12 + // days, new box enrolled 19:20:07Z, operator mail „recovered" 2 s after its first report). The + // discriminator is the host record: enrolled AFTER the outage began (or, when the hub restarted during + // the outage and has no start time, within the stale threshold) → a first observation, no event. + if newState == "ok" && (oldState == "stale" || oldState == "down") && sc.isNewBox(c.CustomerID) { + sc.logger.Printf("[INFO] Staleness: %s → ok on the first reports of a NEW box — not a recovery, no event", c.CustomerID) + sc.states[c.CustomerID] = newState + delete(sc.downtimeStart, c.CustomerID) + continue + } + // State transition — emit event sc.states[c.CustomerID] = newState if newState == "stale" && oldState == "ok" { @@ -250,3 +263,15 @@ func formatDuration(d time.Duration) string { } return fmt.Sprintf("%dh%dm", h, m) } + +// isNewBox reports whether the customer's current host was enrolled after its outage began (R-723). +func (sc *StalenessChecker) isNewBox(customerID string) bool { + h, err := sc.store.GetHostByCustomer(customerID) + if err != nil || h == nil || h.CreatedAt.IsZero() { + return false // no evidence of a new box → keep the old behaviour (a real recovery mails) + } + if since, ok := sc.downtimeStart[customerID]; ok { + return h.CreatedAt.After(since) + } + return time.Since(h.CreatedAt) < sc.threshold +} diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 44e5bab4..f69bbb85 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -531,6 +531,18 @@ func (d *Dispatcher) processOperator(customerID, eventType, severity, message, d cooldownTierSuffix(detailsJSON) + cooldownRunSuffix(detailsJSON) + cooldownStackSuffix(eventType, detailsJSON) + cooldownStorageSuffix(eventType, detailsJSON) window := operatorCooldownFor(eventType) + // R-723 (v0.126.0): a whole-guest tier skipped for absent storage in a box's FIRST HOUR is provisioning + // still in flight, not a fault (measured 2026-09-29: the first local backup ran 7 min after enrolment, + // the off-site descriptor arrived with the next 15-min host report, the off-site tier then backed up + // fine — and the operator had been mailed „skipped"). Recorded, not mailed. + if eventType == "backup_tier_skipped" && d.hostEnrolledWithin(customerID, firstHourOfABox) { + if err := d.store.LogNotification(customerID, eventType, severity, message, + "suppressed", "first hour of a new box (R-723)", "operator"); err != nil { + d.logger.Printf("[WARN] Failed to record suppressed operator notification for %s/%s: %v", customerID, eventType, err) + } + d.logger.Printf("[INFO] Operator email suppressed for %s/%s — the box enrolled less than %s ago", customerID, eventType, firstHourOfABox) + return + } d.mu.Lock() if last, ok := d.opCooldowns[cooldownKey]; ok && time.Since(last) < window { d.mu.Unlock() @@ -856,3 +868,18 @@ func (d *Dispatcher) SendSelfBindEmail(customerID, email, link string) error { d.store.LogNotification(customerID, "selfbind_link", "info", subject, "sent", "", "customer") return nil } + +// firstHourOfABox is how long after enrolment a whole-guest tier skip is provisioning, not a fault (R-723). +const firstHourOfABox = time.Hour + +// hostEnrolledWithin reports whether the customer's current host was enrolled less than d ago. +func (d *Dispatcher) hostEnrolledWithin(customerID string, within time.Duration) bool { + if d.store == nil { + return false + } + h, err := d.store.GetHostByCustomer(customerID) + if err != nil || h == nil || h.CreatedAt.IsZero() { + return false + } + return time.Since(h.CreatedAt) < within +} diff --git a/hub/internal/notify/r723_first_hour_test.go b/hub/internal/notify/r723_first_hour_test.go new file mode 100644 index 00000000..57b75bdc --- /dev/null +++ b/hub/internal/notify/r723_first_hour_test.go @@ -0,0 +1,65 @@ +package notify + +import ( + "database/sql" + "io" + "log" + "path/filepath" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + _ "modernc.org/sqlite" +) + +// storeWithHost opens a store with customer c1 and one host enrolled `ago` before now. +func storeWithHost(t *testing.T, ago time.Duration) *store.Store { + t.Helper() + path := filepath.Join(t.TempDir(), "d.db") + st, err := store.New(path, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "k", RetrievalPassword: "p"}) + if err := st.UpsertHost(&store.Host{HostID: "c1-abc123", CustomerID: "c1", APIKey: "hk"}); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`UPDATE hosts SET created_at = ? WHERE host_id = 'c1-abc123'`, + time.Now().UTC().Add(-ago).Format("2006-01-02 15:04:05")); err != nil { + t.Fatal(err) + } + return st +} + +const tierSkipMsg = "Whole-guest backup tier felhom-pbs skipped: its storage does not exist on the host (never provisioned or removed). No app was stopped for it." + +// R-723 (v0.126.0) — the 2026-09-29 shape: the first whole-guest run fired 7 min after enrolment, before +// the off-site descriptor arrived, and the operator was mailed „skipped". The CONSEQUENCE asserted: no +// operator mail in a box's first hour. +// COMPANION RED-PROOF: drop the first-hour check in processOperator → 1 operator mail. +func TestR723_FirstHourTierSkipIsNotMailed(t *testing.T) { + st := storeWithHost(t, 7*time.Minute) + d := NewDispatcher(st, "test-key", "hub@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0)) + mails := captureSeam(d) + d.ProcessEvent("c1", "backup_tier_skipped", "warning", tierSkipMsg, `{"tier":"felhom-pbs"}`, "controller") + if op := mailsFor(*mails, "op@felhom.eu"); len(op) != 0 { + t.Fatalf("a first-hour tier skip mailed the operator (%d mails)", len(op)) + } +} + +// Control: the same skip on a box enrolled a week ago is a real provisioning gap and still mails. +func TestR723_TierSkipOnAnOldBoxStillMails(t *testing.T) { + st := storeWithHost(t, 7*24*time.Hour) + d := NewDispatcher(st, "test-key", "hub@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0)) + mails := captureSeam(d) + d.ProcessEvent("c1", "backup_tier_skipped", "warning", tierSkipMsg, `{"tier":"felhom-pbs"}`, "controller") + if op := mailsFor(*mails, "op@felhom.eu"); len(op) != 1 { + t.Fatalf("an old box's tier skip must still mail the operator, got %d", len(op)) + } +} diff --git a/hub/internal/web/bind_language_test.go b/hub/internal/web/bind_language_test.go index d16610b6..531139af 100644 --- a/hub/internal/web/bind_language_test.go +++ b/hub/internal/web/bind_language_test.go @@ -83,6 +83,11 @@ func TestBindExpiredIsAlwaysDefaultLanguage(t *testing.T) { // A token that was never real. unknown := getBind(t, s, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + // v0.126.0 (R-719): the expired page echoes the visitor's OWN token into its „Új linket kérek" form. The + // visitor already holds that token (it is the URL they opened), so it discloses nothing; every OTHER + // byte must still be identical, which is what the comparison below keeps asserting. + realExpired = strings.ReplaceAll(realExpired, expiredTok, "") + unknown = strings.ReplaceAll(unknown, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "") if realExpired != unknown { t.Errorf("an expired REAL token and an unknown token render differently — the page is an "+ "oracle for whether a link existed.\n--- real ---\n%s\n--- unknown ---\n%s", realExpired, unknown) diff --git a/hub/internal/web/selfbind.go b/hub/internal/web/selfbind.go index 2ce6382f..2f42d954 100644 --- a/hub/internal/web/selfbind.go +++ b/hub/internal/web/selfbind.go @@ -97,7 +97,7 @@ func bindClientIP(r *http.Request) string { // --- the page --- type bindPageData struct { - State string // "form" | "success" | "expired" | "consumed" | "locked" + State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent" Token string // echoed into the form action (the capability itself; already in the URL) Failed bool // generic factor-check failure (form state only) // Lang is the language to render in. It is the CUSTOMER'S CREATION-TIME language and nothing @@ -157,6 +157,11 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) { return } token := strings.TrimPrefix(r.URL.Path, "/bind/") + // R-719 (v0.126.0): „Új linket kérek" on an expired or used link. + if t, ok := strings.CutSuffix(token, "/resend"); ok && r.Method == http.MethodPost && t != "" && !strings.Contains(t, "/") { + s.handleBindResend(w, t) + return + } // A trailing segment only — reject anything with further path structure (defence in depth atop // the ServeMux path-clean; the token is a flat hex string). if token == "" || strings.Contains(token, "/") { @@ -194,10 +199,11 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) { // (nil) is folded into "expired": no oracle for "was this link ever real". switch { case tok == nil || tok.Expired(now): - s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default}) + // The token is echoed for the resend form whether or not it is real: the page must not differ. + s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default, Token: token}) return case tok.Consumed(): - s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang}) + s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang, Token: token}) return case tok.Locked: s.renderBind(w, http.StatusOK, bindPageData{State: "locked", Lang: lang}) @@ -327,15 +333,60 @@ const bindPageHTML = ` {{else if eq .State "consumed"}}

{{T "bind.consumed.lead"}}

{{T "bind.consumed.body"}}

+

{{T "bind.resend.hint"}}

+ {{else if eq .State "resent"}} +

{{T "bind.resent.lead"}}

+

{{T "bind.resent.body"}}

{{else if eq .State "locked"}}

{{T "bind.locked.lead"}}

{{T "bind.locked.body"}}

{{else}}

{{T "bind.invalid.lead"}}

{{T "bind.invalid.body"}}

+ {{if .Token}}

{{T "bind.resend.hint"}}

{{end}} {{end}}

Felhom.eu

` + +// ── R-719 (v0.126.0): a returning customer asks for a fresh link from the old one ───────────────────── +// +// A box that registers is UNCLAIMED — the hub cannot know whose it is until the bind (measured 2026-09-30: +// the registration carries uuid, MACs, host keys and hardware, nothing of a customer). So "send the link +// when their box registers" cannot be built without mailing every waiting customer. What the returning +// customer DOES have is their old mail: its link now answers „expired" (7-day TTL) or „already used". That +// page offers one press; the hub mints and mails a fresh link to the address REGISTERED for that link's +// customer, and only when the customer has no box (the same guard as every other auto-send). +// +// No oracle: the answer is the same „resent" page, in the default language, whether the token was real, +// unknown, still live, or the customer already has a box. Limits: the per-IP bind limiter, and one mail +// per customer per bindResendEvery. Pinned by internal/web/selfbind_resend_test.go. + +const bindResendEvery = time.Hour + +func (s *Server) handleBindResend(w http.ResponseWriter, token string) { + defer s.renderBind(w, http.StatusOK, bindPageData{State: "resent", Lang: i18n.Default}) + tok, err := s.store.SelfBindTokenByHash(selfBindHash(token)) + if err != nil || tok == nil { + return + } + now := time.Now() + if !tok.Expired(now) && !tok.Consumed() { + return // a live link needs no replacement + } + s.bindResendMu.Lock() + last := s.bindResendAt[tok.CustomerID] + if now.Sub(last) < bindResendEvery { + s.bindResendMu.Unlock() + s.logger.Printf("[INFO] self-bind: fresh link for %s NOT sent — one was sent %s ago (limit %s)", tok.CustomerID, now.Sub(last).Round(time.Second), bindResendEvery) + return + } + if s.bindResendAt == nil { + s.bindResendAt = map[string]time.Time{} + } + s.bindResendAt[tok.CustomerID] = now + s.bindResendMu.Unlock() + s.autoMintSelfBindIfWaiting(tok.CustomerID, "fresh link asked on an expired or used link") +} diff --git a/hub/internal/web/selfbind_resend_test.go b/hub/internal/web/selfbind_resend_test.go new file mode 100644 index 00000000..5f529658 --- /dev/null +++ b/hub/internal/web/selfbind_resend_test.go @@ -0,0 +1,78 @@ +package web + +import ( + "net/http/httptest" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +func resendPOST(s *Server, token string) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + s.handleBind(rr, httptest.NewRequest("POST", "/bind/"+token+"/resend", nil)) + return rr +} + +// R-719 (v0.126.0) — a returning customer's old link has expired; the page offers a fresh one. +// The CONSEQUENCE asserted: a NEW link is mailed to the registered address, and it binds (a live token). +// COMPANION RED-PROOF: make handleBindResend return before autoMintSelfBindIfWaiting → no link mailed. +func TestR719_AnExpiredLinkOffersAndSendsAFreshOne(t *testing.T) { + s, st := newTestServer(t) + m := &stubMailer{} + s.SetSelfBindMailer(m) + seedForMint(t, st, "tester", "tester1@felhom.example") + old := mintLink(t, st, "tester", -time.Hour) // expired a while ago + + if body := bindGET(t, s, old).Body.String(); !strings.Contains(body, "/bind/"+old+"/resend") || !strings.Contains(body, "Új linket kérek") { + t.Fatal("the expired page does not offer a fresh link") + } + rr := resendPOST(s, old) + if !strings.Contains(rr.Body.String(), "Kész.") { + t.Fatalf("resend page: %s", rr.Body.String()) + } + if m.link == "" { + t.Fatal("no fresh link was mailed for an expired link of a box-less customer") + } + fresh := m.link[strings.LastIndexByte(m.link, '/')+1:] + if tok, _ := st.SelfBindTokenByHash(selfBindHash(fresh)); tok == nil || tok.Expired(time.Now()) { + t.Fatal("the mailed link is not live") + } +} + +// No oracle, and no spam: an unknown token, a customer that already has a box, and a second press within +// the hour all get the SAME page and NO mail. +func TestR719_ResendIsNoOracleAndRateLimited(t *testing.T) { + s, st := newTestServer(t) + m := &stubMailer{} + s.SetSelfBindMailer(m) + seedForMint(t, st, "tester", "tester1@felhom.example") + old := mintLink(t, st, "tester", -time.Hour) + + unknown := resendPOST(s, strings.Repeat("a", 64)).Body.String() + if m.link != "" { + t.Fatal("an unknown token mailed someone") + } + _ = resendPOST(s, old) // first press → mail + first := m.link + m.link = "" + again := resendPOST(s, old).Body.String() + if m.link != "" { + t.Fatal("a second press within the hour mailed again") + } + if first == "" || unknown != again { + t.Fatal("the answer differs between an unknown token and a real one — an oracle") + } + + // A customer that already has a box gets no link either. + seedForMint(t, st, "boxed", "b@felhom.example") + if err := st.UpsertHost(&store.Host{HostID: "boxed-1", CustomerID: "boxed", APIKey: "hk"}); err != nil { + t.Fatal(err) + } + b := mintLink(t, st, "boxed", -time.Hour) + _ = resendPOST(s, b) + if m.link != "" { + t.Fatal("a customer with a box was mailed a bind link") + } +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index c0b9404e..26c8b97e 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -73,6 +73,8 @@ type Server struct { claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0) selfBindMailer SelfBindMailer // optional; enables the customer self-bind link button (v0.66.0, R-27) bindLimiter *bindRateLimiter // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27) + bindResendMu sync.Mutex // R-719: the fresh-link resend limiter + bindResendAt map[string]time.Time // customer → last fresh-link mail (R-719) // intentHub (v0.58.0, Direction-2 immediate-sync) is Bumped by every operator-intent handler // (config save/delete, claim resend, offsite re-issue/freeze, floor, block/unblock, log pull) // so a box long-polling GET /api/v1/wait wakes in seconds. Shared with the API handler. nil =