hub v0.104.0: the guest network gets a reader (R-319), and the hub half of the naming (R-295)
gates / gates (push) Successful in 14s

Four paper debts and one fact given a reader. Hub-only — nothing to bake.

A4 — the entry about "the tester's machine" named a risk correctly and labelled it
in a way that invited deleting it. Established from the hub's own store: `peti-felhom`
is a REAL machine (482 reports, 2026-02-27 → 2026-07-15, a named person's own box) and
the 3.6 GB with no key and no backup is real. `david` → `tester-1` is a DIFFERENT record
with no host, no escrow and no report, ever — deleted 07:55:49 and re-created 07:56:47
this morning. The prompt's premise conflated the two; the register now says which is which.

A1 — R-312/R-313/R-303 recorded as DECIDED with their re-open triggers, and moved out
of STATUS's "Waiting on you", which is now empty.

A3 — day0-install §C.1 said pushing the installer publishes it. It has not since
R-110. Corrected, with the two manifest pins named and an outside-verification command;
the one copy that repeated it (a dated audit, true when written) carries a superseded note.

A5 — standing rule 5: evidence comes off the machine at the end of the phase that
produced it, before any revert. Earned twice in three days on the same box at the same
point (R-320). Four homes, plus what to do when it is already gone.

R-295 hub half — „Beállító kód" everywhere; „Visszaállító kód" retired. New `reenroll`
mail kind so the mail names the page a REBUILT box actually shows („A szerver
beállítása"), not the „Elfelejtett jelszó" page it has no login screen to reach.
Naming only; the acceptance pin proves the secret is untouched.

R-319 — the hub models `guest_net` after 23 days of receiving and discarding it. The
signal is `heals_last_hour`, not `state`: a guest the watchdog keeps repairing reads
healthy between repairs. `heal_succeeded` decoded too (R-260's lesson). Unknown is never
drawn as healthy — three absences, three sentences. No alarm, deliberately.
Three red-proofs, mutations asserted applied. Wire-gate checked tags 182 → 190.

B1 — the operator's 2026-08-12 dispositions were NOT in the register; they are now.
Third allowlist kind for the five ruled "no reader wanted"; `reporting_disabled`
reclassified redundant. 8 read · 5 deliberately unread · 1 redundant · 6 still owed.

Also filed: R-321 (a deliberately-silent box still alarms stale/down — the checker is
age-only, and decoding the flag would not have fixed it), R-322 (the claim guard has
never scanned the hub; a hand scan returns zero, so it is a scope gap, not a defect).
This commit is contained in:
2026-08-13 10:50:12 +02:00
parent 2d05b29b82
commit 4d6ec7c7bb
22 changed files with 1253 additions and 115 deletions
+16 -2
View File
@@ -29,8 +29,21 @@ type EmailKind string
const (
EmailClaim EmailKind = "claim" // first setup: "Elindult a Felhom szervered"
EmailReset EmailKind = "reset" // forgotten password
EmailReset EmailKind = "reset" // forgotten password — the box HAS a password
EmailClaimed EmailKind = "claimed" // confirmation after a successful claim (carries no code)
// EmailReenroll — the box was wiped and re-enrolled, so the fresh controller has NO password
// while the hub-side claim is still set (ReissueForReenroll).
//
// R-295 (hub half, 2026-08-13): THIS EXISTS BECAUSE THE MAIL MUST NAME THE PAGE THE MACHINE IS
// ACTUALLY SHOWING. Both situations deliver the same secret and it keeps the same name — the
// three-word „Beállító kód" — but they do NOT show the same screen, and only the hub can tell
// them apart, because it is the hub that chose which call site fired. A rebuilt box renders
// „A szerver beállítása" (controller `web/claim.go:279`: `reset := s.authEnabled()`, and a fresh
// controller has no password), and it serves no login page — so it has no „Elfelejtett jelszó"
// link at all. Sending a re-enrolled customer to that page names a route that is not on their
// screen. Splitting the KIND rather than the NAME is what the ruling asks for: one secret in two
// situations keeps its name, and the sentence around it changes.
EmailReenroll EmailKind = "reenroll"
)
// Mailer delivers a claim-arc email. The code is passed through and MUST NOT be persisted or
@@ -178,7 +191,8 @@ func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued
if cs == nil || !cs.Claimed() {
return 0, false, nil // unclaimed → first-provision path; nothing to re-issue
}
gen, err = e.rotateAndSend(cc, EmailReset)
// EmailReenroll, not EmailReset: same secret, same name, different screen — see the constant.
gen, err = e.rotateAndSend(cc, EmailReenroll)
if err != nil {
return gen, true, err // reissued=true so the caller records the attempt even on email failure
}
+8 -2
View File
@@ -166,8 +166,14 @@ func TestReissueForReenroll(t *testing.T) {
if !cs.Claimed() {
t.Fatal("re-issue must NEVER un-claim (reset rides rotation)")
}
if len(m.sends) != sendsBefore+1 || !strings.HasPrefix(m.sends[len(m.sends)-1], "reset:") {
t.Fatalf("claimed re-enroll must send exactly one RESET email, got %v", m.sends)
// R-295 hub half (2026-08-13): this used to assert "reset:". The KIND was split — same
// secret, same name („Beállító kód"), different SENTENCE — because a rebuilt box has no
// password, so it shows „A szerver beállítása" and serves no login page, and the reset mail
// sent the customer to an „Elfelejtett jelszó" page that is not on their screen. The
// assertion is deliberately kept STRICT rather than loosened to "either kind": routing a
// re-enrolment back down the reset copy is exactly the regression worth failing on.
if len(m.sends) != sendsBefore+1 || !strings.HasPrefix(m.sends[len(m.sends)-1], "reenroll:") {
t.Fatalf("claimed re-enroll must send exactly one REENROLL email, got %v", m.sends)
}
})
t.Run("unclaimed is a no-op (first-provision path)", func(t *testing.T) {
+167
View File
@@ -0,0 +1,167 @@
package claim
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
"golang.org/x/crypto/bcrypt"
)
// ── R-295, HUB HALF — ONE NAME PER SECRET, AND THE MAIL NAMES THE PAGE THE MACHINE SHOWS ────────
//
// Two different secrets were both called „Visszaállító kód": the THREE-word code that gives a person
// control of the dashboard, and the TEN-word code that opens the sealed off-site backups. They are
// near-homographs of each other and of „Helyreállítási kód", and the collision cost a real code. The
// box side shipped on 2026-08-10; the hub side was dropped twice and was the last place the retired
// name survived — in the mails, which is the one surface a customer reads BEFORE they see any screen.
//
// The ruling these tests pin:
// - the three-word dashboard code is „Beállító kód" everywhere;
// - the ten-word escrow code is „Helyreállítási kód";
// - „Visszaállító kód" is RETIRED;
// - one secret in two situations keeps its NAME and changes its SENTENCE;
// - the mail names the page the machine is ACTUALLY SHOWING.
//
// THIS IS NAMING, NOT FUNCTION — TestReenrollSplit_ChangesTheMailNotTheSecret is the pin that says
// so. On the box side the companion pin is the controller's
// TestResetCode_StillAcceptedOnTheSetupPage (claim_code_naming_test.go).
const retiredName = "Visszaállító kód"
// The three code-bearing mails all name the secret „Beállító kód", and none of them carries the
// retired name. The escrow code's name must not appear in a dashboard-code mail either: naming both
// secrets in one message is how a customer comes to believe they are the same thing.
func TestFormatClaimEmail_OneNamePerSecret(t *testing.T) {
for _, kind := range []EmailKind{EmailClaim, EmailReset, EmailReenroll} {
subject, body := notify.FormatClaimEmail(string(kind), "c1", "example.hu", "alma-korte-szilva")
whole := subject + "\n" + body
if !strings.Contains(body, "Beállító kód: alma-korte-szilva") {
t.Errorf("%s: the secret must be labelled „Beállító kód”; body was:\n%s", kind, body)
}
if strings.Contains(whole, retiredName) {
t.Errorf("%s: the retired name „%s” is back (subject or body)", kind, retiredName)
}
if strings.Contains(whole, "Helyreállítási kód") {
t.Errorf("%s: names the ESCROW code in a dashboard-code mail — that is the collision", kind)
}
}
}
// The two situations that deliver the SAME secret must not name the SAME page, because the machine
// does not show the same page. A box that still has a password shows a login screen carrying an
// „Elfelejtett jelszó" link; a REBUILT box has no password, renders „A szerver beállítása" and serves
// no login page at all (controller web/claim.go:279 — `reset := s.authEnabled()`).
//
// Sending a re-enrolled customer to „Elfelejtett jelszó" names a route that is not on their screen.
// That was the live defect, and this is the test that would have caught it.
func TestFormatClaimEmail_NamesThePageTheMachineShows(t *testing.T) {
_, resetBody := notify.FormatClaimEmail(string(EmailReset), "c1", "example.hu", "a-b-c")
if !strings.Contains(resetBody, `"Elfelejtett jelszó"`) {
t.Errorf("the forgot-password mail should name the page that IS on that customer's screen:\n%s", resetBody)
}
_, reBody := notify.FormatClaimEmail(string(EmailReenroll), "c1", "example.hu", "a-b-c")
if strings.Contains(reBody, "Elfelejtett jelszó") {
t.Errorf("a REBUILT box serves no login page, so it has no „Elfelejtett jelszó” link:\n%s", reBody)
}
if !strings.Contains(reBody, `"A szerver beállítása"`) {
t.Errorf("the re-enrol mail must name the page a rebuilt box actually shows:\n%s", reBody)
}
}
// The re-enrol mail must promise nothing about the customer's apps or backups. A clean-slate
// reinstall is exactly the situation in which such a reassurance could be false, and this project has
// spent four register rows (R-294, R-299, R-302, R-311) removing promises it could not see were still
// true. Guarding the CLAIM rather than one phrasing of it: any sentence that says the backups are
// unaffected would have to say so with one of these stems.
func TestFormatClaimEmail_ReenrollPromisesNothingAboutTheData(t *testing.T) {
_, body := notify.FormatClaimEmail(string(EmailReenroll), "c1", "example.hu", "a-b-c")
for _, claimWord := range []string{"mentés", "biztonsági", "alkalmazás", "adataid", "visszaállíthat", "visszaszerezhet"} {
if strings.Contains(body, claimWord) {
t.Errorf("the re-enrol mail must not talk about data or backups (found %q):\n%s", claimWord, body)
}
}
}
// An unrecognised kind falls through to the first-setup mail. That default is SAFE (a rebuilt box
// really is in a setup state) and it is deliberately asserted, because it is the failure mode of
// adding a kind on the engine side and forgetting the template: nothing panics, nothing errors, and
// the wrong-but-harmless mail goes out. Pinning it means a future reader knows it was chosen.
func TestFormatClaimEmail_UnknownKindFallsBackToSetup(t *testing.T) {
subject, body := notify.FormatClaimEmail("no-such-kind", "c1", "example.hu", "a-b-c")
if !strings.Contains(subject, "Elindult a Felhom szervered") {
t.Errorf("unknown kind should fall back to the first-setup mail, got subject %q", subject)
}
if strings.Contains(subject+body, retiredName) {
t.Errorf("the fallback carries the retired name")
}
}
// THE ACCEPTANCE PIN. Splitting the mail KIND must not touch the secret: ReissueForReenroll still
// rotates the generation, still stores a bcrypt hash that verifies the emailed code, and still stores
// no plaintext — byte-for-byte the same custody as the reset path it was split out of. A rename that
// quietly broke acceptance would be far worse than the collision it fixes.
func TestReenrollSplit_ChangesTheMailNotTheSecret(t *testing.T) {
e, st, m := newTestEngine(t)
cc := cust()
if _, err := e.EnsureIssued(cc); err != nil {
t.Fatalf("EnsureIssued: %v", err)
}
if err := e.MarkClaimed(cc); err != nil {
t.Fatalf("MarkClaimed: %v", err)
}
before, err := st.GetClaim(cc.CustomerID)
if err != nil {
t.Fatalf("GetClaim: %v", err)
}
gen, reissued, err := e.ReissueForReenroll(cc)
if err != nil || !reissued {
t.Fatalf("ReissueForReenroll: gen=%d reissued=%v err=%v", gen, reissued, err)
}
// It is the re-enrol mail, not the reset mail — the whole point of the split.
last := m.sends[len(m.sends)-1]
if !strings.HasPrefix(last, string(EmailReenroll)+":") {
t.Errorf("a re-enrolled box must get the reenroll mail, got %q", last)
}
// …and the secret behaves exactly as before: generation advanced, the stored hash verifies the
// newly emailed code, the previous code no longer verifies, and no plaintext is persisted.
after, err := st.GetClaim(cc.CustomerID)
if err != nil {
t.Fatalf("GetClaim after: %v", err)
}
if after.Generation != before.Generation+1 {
t.Errorf("generation %d → %d, want +1", before.Generation, after.Generation)
}
if bcrypt.CompareHashAndPassword([]byte(after.CodeHash), []byte(m.lastCode)) != nil {
t.Error("the stored hash does not verify the emailed code — acceptance moved")
}
if strings.Contains(after.CodeHash, m.lastCode) {
t.Error("the stored hash contains the plaintext code")
}
}
// RequestReset — a customer who still has a password — must keep sending the RESET mail. The split
// must not have swept the genuine forgot-password path along with it: that customer IS looking at a
// login page, and „Elfelejtett jelszó" is the right thing to name for them.
func TestRequestReset_StillSendsTheResetMail(t *testing.T) {
e, _, m := newTestEngine(t)
cc := cust()
if _, err := e.EnsureIssued(cc); err != nil {
t.Fatalf("EnsureIssued: %v", err)
}
if err := e.MarkClaimed(cc); err != nil {
t.Fatalf("MarkClaimed: %v", err)
}
if err := e.RequestReset(cc); err != nil {
t.Fatalf("RequestReset: %v", err)
}
last := m.sends[len(m.sends)-1]
if !strings.HasPrefix(last, string(EmailReset)+":") {
t.Errorf("a forgot-password request must still send the reset mail, got %q", last)
}
}
+50 -4
View File
@@ -69,7 +69,7 @@ Message: %s`, customerID, eventType, severity, now, message)
// customerMessages maps event_type → Hungarian customer message.
var customerMessages = map[string]string{
// Customer-claim arc (v0.50.0)
"claim_lockout": "Túl sok hibás beállító/visszaállító kód próbálkozás történt — a beállító oldal 15 percre zárolva lett. Ha nem te próbálkoztál, jelezd az üzemeltetőnek.",
"claim_lockout": "Túl sok hibás beállító kód próbálkozás történt — a beállító oldal 15 percre zárolva lett. Ha nem te próbálkoztál, jelezd az üzemeltetőnek.",
// Backup events
"backup_completed": "A biztonsági mentés sikeresen elkészült.",
"backup_failed": "A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert.",
@@ -207,18 +207,38 @@ Felhom.eu monitoring`
// ──────────────────────────────────────────────────────────────────────
// FormatClaimEmail returns (subject, textBody) for a claim-arc email. kind is one of
// "claim" | "reset" | "claimed" (claim.EmailKind values). The code appears ONLY in the
// "claim" | "reset" | "reenroll" | "claimed" (claim.EmailKind values). The code appears ONLY in the
// returned body — callers must never log it.
//
// R-295, HUB HALF (2026-08-13). ONE NAME PER SECRET, and it is „Beállító kód".
//
// The three-word code that gives a person control of the DASHBOARD is „Beállító kód" everywhere —
// in this mail, on the operator button, and on the box's own page, which has said so since the
// controller half shipped on 2026-08-10. The TEN-word code that opens the sealed backups is
// „Helyreállítási kód" and is a different secret entirely. **„Visszaállító kód" is retired**: it was
// a near-homograph of „Helyreállítási kód", the collision cost a real code, and the hub was the last
// place it survived — this half was dropped twice before it was finished.
//
// The rule the three branches below follow: ONE SECRET IN TWO SITUATIONS KEEPS ITS NAME, AND THE
// SENTENCE AROUND IT CHANGES. „reset" and „reenroll" carry the identical secret under the identical
// name; they differ only in which page the customer will actually be looking at.
//
// THIS IS NAMING, NOT FUNCTION. No acceptance logic moved: the code is minted, hashed, rotated,
// capped and consumed exactly as before, and a reset code is still accepted on the setup page.
// Pinned by TestFormatClaimEmail_OneNamePerSecret and, on the box side, by the controller's
// claim_code_naming_test.go.
func FormatClaimEmail(kind, customerID, domain, code string) (string, string) {
dashboardURL := "https://felhom." + domain
switch kind {
case "reset":
subject := "[Felhom] Jelszó-visszaállítási kód"
// The box HAS a password: the customer asked for a reset from the login screen, so the
// „Elfelejtett jelszó" link IS on their screen and naming it is correct here.
subject := "[Felhom] Beállító kód a jelszavad visszaállításához"
body := fmt.Sprintf(`Kedves Ügyfél!
Jelszó-visszaállítást kértél a Felhom vezérlőpultodhoz.
Visszaállító kód: %s
Beállító kód: %s
A kód 72 óráig érvényes, és egyszer használható fel. Add meg a vezérlőpult
"Elfelejtett jelszó" oldalán, majd válassz új jelszót:
@@ -227,6 +247,32 @@ A kód 72 óráig érvényes, és egyszer használható fel. Add meg a vezérlő
Ha nem te kérted, hagyd figyelmen kívül — a jelenlegi jelszavad változatlan.
Üdvözlettel,
Felhom.eu`, code, dashboardURL)
return subject, body
case "reenroll":
// The box was rebuilt and has NO password, so it shows „A szerver beállítása" and serves no
// login page — there is no „Elfelejtett jelszó" link to send anyone to. Same secret, same
// name, the page the machine is actually showing.
//
// It deliberately says NOTHING about the apps or the backups. A clean-slate reinstall is
// exactly the situation in which such a reassurance could be false, and this project has
// spent four register rows removing promises it could not see were still true.
subject := "[Felhom] Új beállító kód — újratelepült a szervered"
body := fmt.Sprintf(`Kedves Ügyfél!
A Felhom szervered újratelepült, ezért a vezérlőpultod belépését újra be kell
állítani. A korábbi jelszavad már nem érvényes.
Beállító kód: %s
A kód 72 óráig érvényes, és egyszer használható fel. Nyisd meg a vezérlőpultot —
"A szerver beállítása" oldal fogad —, add meg a kódot, majd válassz új jelszót:
%s
Ha nem te telepítetted újra a szervered, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu`, code, dashboardURL)
return subject, body
+7 -2
View File
@@ -75,8 +75,13 @@ func TestCustomerPage_ClaimCardStates(t *testing.T) {
if !strings.Contains(html, ">Claimed ") {
t.Error("missing the Claimed chip")
}
if !strings.Contains(html, "Visszaállító kód küldése") {
t.Error("claimed state should offer the reset-code button")
// R-295: the button says „Beállító kód küldése" — ONE name per secret. It used to read
// „Visszaállító kód küldése", a near-homograph of the escrow „Helyreállítási kód".
if !strings.Contains(html, "Beállító kód küldése") {
t.Error("claimed state should offer the reset-code button, named „Beállító kód küldése”")
}
if strings.Contains(html, "Visszaállító kód") {
t.Error("the retired name „Visszaállító kód” is back on the customer page")
}
}
+3 -2
View File
@@ -776,8 +776,9 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
}
// handleClaimResend (v0.50.0, customer-claim arc) rotates the claim/reset code and re-sends it to
// the REGISTERED customer address — the operator "Kód újraküldése" / "Visszaállító kód küldése"
// button. The old code stops verifying immediately (single active code); the fresh hash reaches
// the REGISTERED customer address — the operator "Kód újraküldése" / "Beállító kód küldése"
// button (R-295: the button used to read „Visszaállító kód küldése"; that name is retired).
// The old code stops verifying immediately (single active code); the fresh hash reaches
// the box on its next report ACK (no config bump needed). No plaintext is ever rendered or logged.
func (s *Server) handleClaimResend(w http.ResponseWriter, r *http.Request, customerID string) {
if s.claimEngine == nil {
+173
View File
@@ -263,6 +263,175 @@ func (s *Server) hostNetwork(host *store.Host, reportJSON string) hostNetworkVie
return v
}
// ── R-319 — the guest-network watchdog gets a reader ────────────────────────────────────────────
//
// FIRST OF THE R-264 READERS. The agent has reported `guest_net` on every heartbeat since v0.92.0
// (R-54, 2026-07-21) and the hub — the component that emails the operator — modelled NONE of it: the
// string `guest_net` occurred nowhere in this repository. It was stored as raw text inside
// `report_json` and read by nothing.
//
// WHY THIS ONE FIRST. It has a live incident behind it: a killed `dhclient` in a guest took a tunnel
// down for 1 h 15 m with nobody told (`audits/INCIDENT-guest-dhclient-killed-2026-07-20.md`), and the
// watchdog built afterwards has been reporting exactly that condition ever since — to a hub that
// modelled none of it. R-264 named it "the strongest candidate of the twenty-one".
//
// WHAT IS ACTUALLY BEING READ, and it is NOT just the current state. The signal is
// `heals_last_hour`: a guest whose network the watchdog keeps REPAIRING is healthy at every instant
// anyone looks and is nevertheless failing. Rendering only `state` would give that machine a green
// tick — which is the exact shape of the defect that drew a failed disk as a healthy empty disk.
// RepairCount is therefore surfaced beside the state, not behind it.
//
// AN UNKNOWN IS NEVER DRAWN AS HEALTHY. Three distinct absences are kept apart, following the
// companion-flag convention this project settled in August (`hostNetworkView` above is the sibling):
// - AgentTooOld — the agent predates v0.92.0, so absence is EXPECTED but still tells us nothing;
// - Reported=false — a new-enough agent sent no stanza (watchdog disabled, or a report that
// predates the feature on this box);
// - a guest whose `state` is empty or unrecognised → rendered "unknown", never healthy.
// A malformed stanza degrades to Reported=false and must never 500 the page.
// minAgentForGuestNet is the agent release that first reported `guest_net` (v0.92.0, R-54). Below it
// the stanza is absent BY CONSTRUCTION, so its absence is unknown-and-expected rather than a finding.
const minAgentForGuestNet = "0.92.0"
// guestNetGuestView is one owned guest's network health as the watchdog last saw it.
type guestNetGuestView struct {
VMID int
State string // healthy | unhealthy | static_fault | unknown ("" → unknown)
Mode string // dhcp | static | unknown
IP string
HasRoute bool
DHClientAlive bool
// RepairCount is `heals_last_hour` — THE SIGNAL. A machine repairing itself over and over is
// telling you something that its current state cannot.
RepairCount int
LastHealAt string
Damped bool
Message string
// Healed / HealSucceeded are carried DELIBERATELY, and together. R-260 is this project's
// warning: the OOB decoder mirrored five of the agent's eight fields, and the three it dropped
// included the one that decided the question the checker existed to answer. A repair that FAILED
// is a different fact from a repair that worked, and the count alone cannot express it — six
// successful repairs is a nuisance, six FAILED ones is a guest that is down right now.
Healed bool
HealSucceeded bool
}
// HealFailed reports that the watchdog TRIED to repair this guest and did not succeed. Kept separate
// from State because the two can disagree: the sweep that failed to heal is not necessarily the sweep
// that set the state.
func (g guestNetGuestView) HealFailed() bool { return g.Healed && !g.HealSucceeded }
// Unknown reports whether this guest's state is one the watchdog did not positively assert. An empty
// or unrecognised state is drawn as unknown; it is never allowed to fall through to the healthy
// branch. Value receiver — a pointer receiver compiles, vets, passes the suite and 500s at render.
func (g guestNetGuestView) Unknown() bool {
switch g.State {
case "healthy", "unhealthy", "static_fault":
return false
default:
return true
}
}
// Repairing reports whether the watchdog has had to repair this guest inside the last hour. This is
// deliberately independent of State: the whole point is that a REPAIRED guest reads healthy.
func (g guestNetGuestView) Repairing() bool { return g.RepairCount > 0 }
// guestNetView is the Guest network card's whole view-model.
type guestNetView struct {
// Reported is the ONLY thing that licenses drawing any health at all. False → unknown.
Reported bool
// AgentTooOld distinguishes "this agent cannot report it" from "a capable agent said nothing".
// Both render as unknown; they need different words, and conflating them is how an operator
// starts ignoring the card.
AgentTooOld bool
CheckedAt string
Guests []guestNetGuestView
// RepairingCount / UnhealthyCount drive the card's summary badge. Counted rather than derived in
// the template: template logic that computes a verdict is logic nobody tests.
RepairingCount int
UnhealthyCount int
UnknownCount int
// HealFailedCount — repairs ATTEMPTED and not succeeded. Counted separately from
// RepairingCount: a failing repair is a harder fact than a frequent one.
HealFailedCount int
}
// Degraded is true when any owned guest is unhealthy OR is being repeatedly repaired. The repair leg
// is the one that matters: without it a guest the watchdog fixes every ten minutes reports "healthy"
// for ever.
func (v guestNetView) Degraded() bool {
return v.UnhealthyCount > 0 || v.RepairingCount > 0 || v.HealFailedCount > 0
}
// parseGuestNet extracts the `guest_net` stanza. A missing or malformed body yields Reported=false
// (unknown), never an error and never a partial claim of health.
func parseGuestNet(reportJSON string) guestNetView {
v := guestNetView{Guests: []guestNetGuestView{}}
if reportJSON == "" {
return v
}
var body struct {
GuestNet *struct {
CheckedAt string `json:"checked_at"`
Guests []struct {
VMID int `json:"vmid"`
State string `json:"state"`
Mode string `json:"mode"`
IP string `json:"ip"`
HasRoute bool `json:"has_route"`
DHClientAlive bool `json:"dhclient_alive"`
HealsLastHour int `json:"heals_last_hour"`
Healed bool `json:"healed"`
HealSucceeded bool `json:"heal_succeeded"`
LastHealAt string `json:"last_heal_at"`
Damped bool `json:"damped"`
Message string `json:"message"`
} `json:"guests"`
} `json:"guest_net"`
}
// A decode error is NOT propagated: one box's bad field must not break the page for the rest of
// the fleet. It degrades to Reported=false, which renders as unknown — the safe direction.
if err := json.Unmarshal([]byte(reportJSON), &body); err != nil || body.GuestNet == nil {
return v
}
v.Reported = true
v.CheckedAt = body.GuestNet.CheckedAt
for _, g := range body.GuestNet.Guests {
gv := guestNetGuestView{
VMID: g.VMID, State: g.State, Mode: g.Mode, IP: g.IP,
HasRoute: g.HasRoute, DHClientAlive: g.DHClientAlive,
RepairCount: g.HealsLastHour, LastHealAt: g.LastHealAt,
Damped: g.Damped, Message: g.Message,
Healed: g.Healed, HealSucceeded: g.HealSucceeded,
}
switch {
case gv.Unknown():
v.UnknownCount++
case gv.State == "unhealthy" || gv.State == "static_fault":
v.UnhealthyCount++
}
if gv.Repairing() {
v.RepairingCount++
}
if gv.HealFailed() {
v.HealFailedCount++
}
v.Guests = append(v.Guests, gv)
}
return v
}
// guestNet builds the card's view-model, applying the version gate before the report is consulted so
// that an old agent's silence is named as such rather than rendered as a finding.
func (s *Server) guestNet(host *store.Host, reportJSON string) guestNetView {
if host.AgentVersion != "" && semver.Valid(host.AgentVersion) &&
semver.Compare(host.AgentVersion, minAgentForGuestNet) < 0 {
return guestNetView{Guests: []guestNetGuestView{}, AgentTooOld: true}
}
return parseGuestNet(reportJSON)
}
// storageTargetView is the rich per-drive row the host-detail Storage Targets table renders:
// fill %, role/state, thin-pool, and SMART health/temp/wear. Parsed from the latest report's
// storage_targets[] (the full hostStorageTarget wire shape lives in the api package; this view
@@ -533,6 +702,9 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
// v0.85.0 Network — the host's addresses + its WireGuard allocation.
network := s.hostNetwork(host, reportJSON)
// R-319 Guest network — the R-54 watchdog's per-guest verdict AND its repair count.
guestNet := s.guestNet(host, reportJSON)
// v0.84.0 Console access — presence + username + set_at ONLY. GetHostRecoveryMeta cannot carry
// the secret (its query does not select the column); the plaintext reaches the operator solely
// through POST /hosts/{id}/reveal-recovery-credential.
@@ -573,6 +745,7 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
// v0.84.0 break-glass Console access card. NEVER add a key holding the secret.
// v0.85.0 Network card (addresses + WireGuard allocation/confirmation).
"Network": network,
"GuestNet": guestNet,
"RecoveryVaulted": recoveryMeta != nil,
"RecoveryUsername": func() string {
if recoveryMeta != nil {
+259
View File
@@ -0,0 +1,259 @@
package web
// Guest-network card (R-319) — the first R-264 reader.
//
// THE FIXTURES BELOW ARE THE REAL WIRE. `liveGuestNetJSON` is the `guest_net` stanza copied verbatim
// out of `demo-felhom-8363b5`'s newest row in the live hub's `host_reports` table on 2026-08-13
// (agent 0.129.0). Testing against a hand-written shape would have proved only that the parser
// matches my own idea of the format — and the defect this whole class comes from (R-260) was exactly
// a hub-side struct that did not match what the agent actually sends.
//
// Every test drives ServeHTTP, so a template gate that never renders is visible here. That is the
// seam-wiring rule: handler tests prove nothing about reachability, and this project has shipped four
// features whose entry point was never wired.
import (
"strings"
"testing"
)
// The real stanza: one owned guest, healthy, DHCP, no repairs. `heals_last_hour` and friends are
// ABSENT because the agent marks them `omitempty` and this box has never needed a repair — which is
// itself the shape scenario A has to survive.
const liveGuestNetJSON = `{
"host": {"cpu_percent": 4.0, "memory_percent": 30.0, "disk_percent": 20.0},
"guest_net": {
"checked_at": "2026-08-13T08:15:33Z",
"guests": [
{"vmid": 9201, "state": "healthy", "mode": "dhcp", "ip": "192.168.0.149",
"has_route": true, "dhclient_alive": true, "checked_at": "2026-08-13T08:14:30Z",
"message": "address, default route and dhclient all present"}
]
}
}`
// The same box after the watchdog has had to keep fixing it — the R-54 heal fields populated. This is
// the shape the incident of 2026-07-20 would have produced had the watchdog existed then.
const repairingGuestNetJSON = `{
"host": {"cpu_percent": 4.0, "memory_percent": 30.0, "disk_percent": 20.0},
"guest_net": {
"checked_at": "2026-08-13T08:15:33Z",
"guests": [
{"vmid": 9201, "state": "healthy", "mode": "dhcp", "ip": "192.168.0.149",
"has_route": true, "dhclient_alive": true, "checked_at": "2026-08-13T08:14:30Z",
"healed": true, "heal_succeeded": true, "last_heal_at": "2026-08-13T08:09:12Z",
"heals_last_hour": 6, "message": "dhclient was absent; restarted"}
]
}
}`
// A report from a capable agent with NO guest_net stanza at all — the watchdog switched off, or a
// report predating the feature on this box.
const silentGuestNetJSON = `{"host": {"cpu_percent": 4.0, "memory_percent": 30.0, "disk_percent": 20.0}}`
// The stanza arrives, but its contents are not what the hub expects: `guests` is an object where an
// array belongs, and `heals_last_hour` is a string. This is what a wire drift or a truncated write
// looks like from the hub's side.
const malformedGuestNetJSON = `{
"host": {"cpu_percent": 4.0},
"guest_net": {"checked_at": "2026-08-13T08:15:33Z", "guests": {"vmid": "nine-two-oh-one"}}
}`
// ── A — a machine reporting healthy guest networking, no repairs → shown as healthy ─────────────
//
// WRONG OUTCOME GUARDED: an empty or alarming state on a machine that is fine. A card that cried
// unknown on every healthy box would be switched off within a week, and then the B case below would
// never be seen either.
func TestGuestNet_A_HealthyRendersHealthy(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "demo-felhom-8363b5", "0.129.0", liveGuestNetJSON, "")
body := getHostPage(t, s, cookie, "demo-felhom-8363b5")
if !strings.Contains(body, "Guest network") {
t.Fatal("no Guest network card — the reader shows nothing")
}
if !strings.Contains(body, "192.168.0.149") {
t.Error("the guest's address did not reach the page")
}
if !strings.Contains(body, "badge-ok") {
t.Error("a healthy guest must render as healthy, not as an empty or alarming state")
}
if strings.Contains(body, "needs attention") {
t.Error("a machine that is fine is being alarmed on")
}
// The unknown branches must NOT fire for a box that reported properly.
if strings.Contains(body, "does not run the guest-network watchdog") ||
strings.Contains(body, "reported no guest-network state") {
t.Error("a reporting box rendered one of the unknown sentences")
}
}
// ── B — a machine whose watchdog has repaired the guest repeatedly ──────────────────────────────
//
// WRONG OUTCOME GUARDED: a green tick because the CURRENT state is fine. This is the exact shape of
// the failed-disk-drawn-as-a-healthy-empty-disk defect, and it is the reason this card exists at all:
// `state` says "healthy" in this fixture, because between repairs it IS.
func TestGuestNet_B_RepeatedRepairsAreVisible(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "demo-felhom-8363b5", "0.129.0", repairingGuestNetJSON, "")
body := getHostPage(t, s, cookie, "demo-felhom-8363b5")
if !strings.Contains(body, "needs attention") {
t.Fatal("a guest repaired 6 times in an hour is reported as fine — the whole point of the card")
}
if !strings.Contains(body, "had their networking repaired in the last hour") {
t.Error("the climbing-repairs sentence is missing")
}
if !strings.Contains(body, ">6<") {
t.Error("the repair COUNT is not rendered; 'some repairs' is not the signal, the number is")
}
if !strings.Contains(body, "2026-08-13T08:09:12Z") {
t.Error("the last repair time is not offered (it is in the title attribute)")
}
}
// ── C — a machine that does not report the fact at all → drawn as unknown, never healthy ────────
//
// WRONG OUTCOME GUARDED: absence read as good news. THE ONE THAT MATTERS. Two distinct absences are
// checked because they need different words: an agent that cannot report it, and a capable agent that
// said nothing.
func TestGuestNet_C_SilentIsUnknownNotHealthy(t *testing.T) {
t.Run("capable agent, no stanza", func(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "demo-hp-bb76ea", "0.129.0", silentGuestNetJSON, "")
body := getHostPage(t, s, cookie, "demo-hp-bb76ea")
if !strings.Contains(body, "reported no guest-network state") {
t.Fatal("a silent box must SAY it is unknown")
}
if strings.Contains(body, "badge-ok") && strings.Contains(body, "Guest network") {
assertNoHealthyBadgeInGuestNetCard(t, body)
}
})
t.Run("agent too old to report it", func(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
// 0.91.0 predates the R-54 watchdog (v0.92.0): the absence is expected AND still unknown.
seedNetHost(t, st, "old-agent-box", "0.91.0", silentGuestNetJSON, "")
body := getHostPage(t, s, cookie, "old-agent-box")
if !strings.Contains(body, "does not run the guest-network watchdog") {
t.Fatal("an old agent's silence must be named as an old agent's silence")
}
if !strings.Contains(body, "<strong>unknown</strong>, not healthy") {
t.Error("the unknown-is-not-healthy sentence is missing")
}
assertNoHealthyBadgeInGuestNetCard(t, body)
})
}
// ── D — the fact arrives malformed → unknown, and the hub does not 500 ──────────────────────────
//
// WRONG OUTCOME GUARDED: a page that breaks on one machine's bad field. getHostPage fails the test on
// any non-200, so surviving the call IS half the assertion.
func TestGuestNet_D_MalformedIsUnknownAndDoesNotBreakThePage(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "demo-felhom-8363b5", "0.129.0", malformedGuestNetJSON, "")
body := getHostPage(t, s, cookie, "demo-felhom-8363b5") // 200 or the test dies here
if !strings.Contains(body, "reported no guest-network state") {
t.Fatal("a malformed stanza must degrade to unknown")
}
assertNoHealthyBadgeInGuestNetCard(t, body)
}
// A guest whose state string is one the watchdog never asserts (a future value, or a truncated field)
// is drawn as unknown rather than falling through to the healthy branch. The switch in
// guestNetGuestView.Unknown is an ALLOW-LIST for exactly this reason: an unrecognised value defaults
// to unknown, and adding a state to the agent cannot silently paint it green here.
func TestGuestNet_UnrecognisedStateIsUnknown(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "demo-felhom-8363b5", "0.129.0", `{
"host": {"cpu_percent": 1.0},
"guest_net": {"checked_at": "2026-08-13T08:15:33Z",
"guests": [{"vmid": 9201, "state": "quantum", "mode": "dhcp", "ip": "10.0.0.5"}]}}`, "")
body := getHostPage(t, s, cookie, "demo-felhom-8363b5")
if !strings.Contains(body, "partly unknown") {
t.Fatal("an unrecognised guest state must summarise as unknown, not healthy")
}
}
// The empty-list case is deliberately NOT the same as silence: the agent's own contract says a stanza
// with a fresh checked_at and no guests means "the watchdog ran and found nothing to report", and that
// must stay distinguishable from "the watchdog is not wired" — the shape the v0.91.0 inert seam hid
// behind.
func TestGuestNet_EmptyListIsNotSilence(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "demo-felhom-8363b5", "0.129.0",
`{"host": {"cpu_percent": 1.0}, "guest_net": {"checked_at": "2026-08-13T08:15:33Z"}}`, "")
body := getHostPage(t, s, cookie, "demo-felhom-8363b5")
if !strings.Contains(body, "The watchdog ran and has no guest to report") {
t.Fatal("an empty guest list must say the watchdog RAN")
}
if strings.Contains(body, "reported no guest-network state") {
t.Error("an empty list was collapsed into silence — the two are different facts")
}
}
// assertNoHealthyBadgeInGuestNetCard checks that the Guest network card contains no healthy badge.
// It slices the card out of the page rather than searching the whole body, because `badge-ok` occurs
// all over a host page (capabilities, storage, WireGuard) and a whole-page search would pass
// vacuously — which is the "instrument that can drop results silently" trap.
func assertNoHealthyBadgeInGuestNetCard(t *testing.T, body string) {
t.Helper()
i := strings.Index(body, "Guest network")
if i < 0 {
t.Fatal("no Guest network card to slice — the assertion would have passed vacuously")
}
card := body[i:]
if j := strings.Index(card, "</section>"); j >= 0 {
card = card[:j]
} else {
t.Fatal("could not find the end of the Guest network card")
}
if strings.Contains(card, "badge-ok") {
t.Errorf("an UNKNOWN guest-network state rendered a healthy badge:\n%s", card)
}
}
// A repair that was ATTEMPTED and FAILED is a harder fact than a repair that worked, and the count
// alone cannot express it: six successful repairs is a nuisance, six failed ones is a guest that is
// down right now. Dropping `heal_succeeded` from the decoder would be R-260 exactly — the OOB decoder
// mirrored five of eight fields and the three it dropped included the deciding one.
func TestGuestNet_FailedRepairIsDistinctFromFrequentRepair(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "demo-felhom-8363b5", "0.129.0", `{
"host": {"cpu_percent": 1.0},
"guest_net": {"checked_at": "2026-08-13T08:15:33Z",
"guests": [{"vmid": 9201, "state": "unhealthy", "mode": "dhcp",
"has_route": false, "dhclient_alive": false,
"healed": true, "heal_succeeded": false, "heals_last_hour": 3,
"message": "dhclient restart did not restore the lease"}]}}`, "")
body := getHostPage(t, s, cookie, "demo-felhom-8363b5")
if !strings.Contains(body, "repair failed") {
t.Fatal("a repair that was attempted and failed is not distinguished from one that worked")
}
if !strings.Contains(body, "needs attention") {
t.Error("a guest whose repair failed must summarise as needing attention")
}
assertNoHealthyBadgeInGuestNetCard(t, body)
}
@@ -473,7 +473,7 @@
{{if .Claim}}
<form method="POST" action="/configs/{{.CustomerID}}/claim-resend" style="margin-top: 0.5rem;">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Send a fresh code to the registered address? The previous code stops working immediately (the box activates it on its next report, ~15 min).">{{if .Claim.ClaimedAt}}Visszaállító kód küldése{{else}}Kód újraküldése{{end}}</button>
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Send a fresh code to the registered address? The previous code stops working immediately (the box activates it on its next report, ~15 min).">{{if .Claim.ClaimedAt}}Beállító kód küldése{{else}}Kód újraküldése{{end}}</button>
</form>
{{end}}
@@ -317,6 +317,82 @@
{{end}}
</section>
<!-- Guest network (R-319): the R-54 watchdog's verdict per owned guest, and — the reason this
card exists — HOW OFTEN IT HAD TO REPAIR EACH ONE. A guest the watchdog keeps fixing is
healthy every time anyone looks and is nevertheless failing; showing only the state would
give it a green tick, which is the failed-disk-drawn-as-a-healthy-empty-disk defect.
An unknown is NEVER drawn as healthy. Three absences, three sentences: an agent too old to
report it, a capable agent that reported nothing, and a guest whose own state the watchdog
did not assert. Each branch of this gate has its own render test (the template-gate rule).
The agent has sent this on every heartbeat since v0.92.0 and nothing read it until now. -->
<section class="card">
<h2>Guest network
{{if .GuestNet.AgentTooOld}}<span class="badge badge-neutral" title="This agent predates the guest-network watchdog">unknown</span>
{{else if not .GuestNet.Reported}}<span class="badge badge-neutral" title="A capable agent sent no guest-network stanza">unknown</span>
{{else if .GuestNet.Degraded}}<span class="badge badge-warn" title="A guest is unhealthy, or is being repaired repeatedly">needs attention</span>
{{else if .GuestNet.UnknownCount}}<span class="badge badge-neutral" title="The watchdog did not assert a state for every guest">partly unknown</span>
{{else if .GuestNet.Guests}}<span class="badge badge-ok" title="Every owned guest has an address, a default route and a live dhclient">healthy</span>
{{end}}
</h2>
{{if .GuestNet.AgentTooOld}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
This host's agent (<code>{{.AgentVersion}}</code>) does not run the guest-network watchdog &mdash;
its guests' networking is <strong>unknown</strong>, not healthy. Needs agent <code>0.92.0</code> or newer.
</p>
{{else if not .GuestNet.Reported}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
This host reported no guest-network state, so it is <strong>unknown</strong> &mdash; not healthy.
The watchdog is default-on; a silent capable agent means it was switched off
(<code>guest_net.disable</code>), or this report predates it on this box.
</p>
{{else if .GuestNet.Guests}}
<table class="data-table">
<thead>
<tr><th>Guest</th><th>State</th><th>Address</th><th>Route</th><th>dhclient</th><th>Repairs (1h)</th></tr>
</thead>
<tbody>
{{range .GuestNet.Guests}}
<tr>
<td><code>{{.VMID}}</code></td>
<td>
{{if .Unknown}}<span class="badge badge-neutral" title="{{.Message}}">unknown</span>
{{else if eq .State "healthy"}}<span class="badge badge-ok" title="{{.Message}}">healthy</span>
{{else if eq .State "static_fault"}}<span class="badge badge-warn" title="{{.Message}}">static fault</span>
{{else}}<span class="badge badge-error" title="{{.Message}}">unhealthy</span>{{end}}
{{if .HealFailed}}<span class="badge badge-error" title="The watchdog TRIED to repair this guest and did not succeed">repair failed</span>{{end}}
{{if .Damped}}<span class="badge badge-neutral" title="Repairs are rate-limited on this guest">damped</span>{{end}}
</td>
<td>{{if .IP}}<code>{{.IP}}</code> <span class="text-muted">({{.Mode}})</span>{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td>{{if .HasRoute}}yes{{else}}<strong>no</strong>{{end}}</td>
<td>{{if .DHClientAlive}}yes{{else if eq .Mode "static"}}<span class="text-muted">n/a</span>{{else}}<strong>no</strong>{{end}}</td>
<td>
{{if .Repairing}}<span class="badge badge-warn" title="The watchdog repaired this guest's network {{.RepairCount}} time(s) in the last hour — last at {{.LastHealAt}}">{{.RepairCount}}</span>
{{else}}<span class="text-muted">0</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{if .GuestNet.RepairingCount}}
<p class="hint" style="font-size: 0.85rem; margin-top: 0.5rem;">
<strong>{{.GuestNet.RepairingCount}} guest(s) had their networking repaired in the last hour.</strong>
A guest that keeps being repaired reads healthy between repairs and is not. A killed
<code>dhclient</code> once took a tunnel down for 1 h 15 m with nobody told; this row is that signal.
</p>
{{end}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.5rem;">
Last swept {{.GuestNet.CheckedAt}}. One row per owned <em>running</em> guest the watchdog has probed.
</p>
{{else}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
The watchdog ran and has no guest to report (swept {{.GuestNet.CheckedAt}}). This is
distinguishable from silence: the stanza arrived, its guest list is empty.
</p>
{{end}}
</section>
<!-- DR / Backup -->
<section class="card">
<h2>DR / Backup</h2>