hub v0.126.0: fresh connect link from the old one (R-719); day-one mails (R-723); bind-page wording (R-725); volunteer guide current (R-722); ep0 cleanup and release evidence
gates / gates (push) Successful in 29s
gates / gates (push) Successful in 29s
Red-proofs RP40-RP42. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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) |
|
||||
|
||||
+14
@@ -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 |
|
||||
+6
@@ -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
|
||||
@@ -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
|
||||
+3
@@ -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
|
||||
+103
@@ -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.
|
||||
+8
@@ -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
|
||||
+4
@@ -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")
|
||||
@@ -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: `*.<domain>` → `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.<your-domain>` (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.<your-domain>`).
|
||||
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.
|
||||
|
||||
|
||||
@@ -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: `*.<domain>` → `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-te-domained>` (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.<a-te-domained>`).
|
||||
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.
|
||||
|
||||
|
||||
@@ -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/<token>/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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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, "<token>")
|
||||
unknown = strings.ReplaceAll(unknown, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "<token>")
|
||||
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)
|
||||
|
||||
@@ -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 = `<!DOCTYPE html>
|
||||
{{else if eq .State "consumed"}}
|
||||
<p class="lead">{{T "bind.consumed.lead"}}</p>
|
||||
<p>{{T "bind.consumed.body"}}</p>
|
||||
<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>
|
||||
{{else if eq .State "resent"}}
|
||||
<p class="lead">{{T "bind.resent.lead"}}</p>
|
||||
<p>{{T "bind.resent.body"}}</p>
|
||||
{{else if eq .State "locked"}}
|
||||
<p class="lead">{{T "bind.locked.lead"}}</p>
|
||||
<p>{{T "bind.locked.body"}}</p>
|
||||
{{else}}
|
||||
<p class="lead">{{T "bind.invalid.lead"}}</p>
|
||||
<p>{{T "bind.invalid.body"}}</p>
|
||||
{{if .Token}}<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="foot">Felhom.eu</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// ── 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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user