diff --git a/documentation/audits/hub-safety-2026-10-05/partA/red-proof.txt b/documentation/audits/hub-safety-2026-10-05/partA/red-proof.txt new file mode 100644 index 00000000..251854a7 --- /dev/null +++ b/documentation/audits/hub-safety-2026-10-05/partA/red-proof.txt @@ -0,0 +1,18 @@ +== RED-PROOF R-135: validateCSRF returns true when there is no cookie (pre-v0.135.0) +--- FAIL: TestR135_BasicAuthWithoutHeaderIsRefused (3.41s) + r135_csrf_test.go:92: POST /configuration with Basic auth and no X-Felhom-Operator header: 303, want 403 + r135_csrf_test.go:92: POST /apps/demo/reset-telemetry with Basic auth and no X-Felhom-Operator header: 303, want 403 + r135_csrf_test.go:92: POST /apps/demo/dismiss-issues with Basic auth and no X-Felhom-Operator header: 400, want 403 + r135_csrf_test.go:92: POST /offsite/endpoints with Basic auth and no X-Felhom-Operator header: 400, want 403 + r135_csrf_test.go:92: POST /offsite/endpoints/1/delete with Basic auth and no X-Felhom-Operator header: 404, want 403 + r135_csrf_test.go:92: POST /appliances/1/bind with Basic auth and no X-Felhom-Operator header: 400, want 403 + r135_csrf_test.go:92: POST /appliances/1/discard with Basic auth and no X-Felhom-Operator header: 409, want 403 + r135_csrf_test.go:92: POST /hosts/h1/delete with Basic auth and no X-Felhom-Operator header: 404, want 403 + r135_csrf_test.go:92: POST /hosts/h1/reveal-recovery-credential with Basic auth and no X-Felhom-Operator header: 404, want 403 + r135_csrf_test.go:92: POST /hosts/h1/request-logs with Basic auth and no X-Felhom-Operator header: 404, want 403 + r135_csrf_test.go:92: POST /customers/c1/block with Basic auth and no X-Felhom-Operator header: 404, want 403 +rc=1 + +== restored +ok gitea.dooplex.hu/admin/felhom-hub/internal/web (cached) +convicted routes: 39 diff --git a/documentation/audits/hub-safety-2026-10-05/partB/red-proof.txt b/documentation/audits/hub-safety-2026-10-05/partB/red-proof.txt new file mode 100644 index 00000000..e6036156 --- /dev/null +++ b/documentation/audits/hub-safety-2026-10-05/partB/red-proof.txt @@ -0,0 +1,29 @@ +== RED-PROOF 1 (R-133): SaveHostRecoveryCredential stores the plaintext (pre-v0.135.0) +FAIL gitea.dooplex.hu/admin/felhom-hub/internal/store [build failed] +FAIL + +== RED-PROOF 2 (R-133 wiring): main.go does not call SealLegacyRecoverySecrets +=== RUN TestR133_MainSealsLegacyRecoverySecrets + r133_wiring_test.go:28: cmd/hub/main.go never calls SealLegacyRecoverySecrets — legacy console passwords stay in plaintext +--- FAIL: TestR133_MainSealsLegacyRecoverySecrets (0.00s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-hub/cmd/hub 0.028s +FAIL + +== restored +ok gitea.dooplex.hu/admin/felhom-hub/internal/store 0.139s +ok gitea.dooplex.hu/admin/felhom-hub/cmd/hub 0.022s + +== RED-PROOF 1 (re-run, compiling): SaveHostRecoveryCredential stores the plaintext (pre-v0.135.0) +=== RUN TestR133_RawRowHoldsNoPassword + r133_recovery_seal_test.go:29: raw host_recovery.secret is not sealed: "Console-Pw-7741" +--- FAIL: TestR133_RawRowHoldsNoPassword (0.04s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-hub/internal/store 0.043s +FAIL +== restored +--- PASS: TestR133_RawRowHoldsNoPassword (0.03s) +--- PASS: TestR133_SealLegacyRecoverySecrets (0.03s) +--- PASS: TestR133_WrongKeyFailsClosed (0.03s) +--- PASS: TestR133_NoKeyRefusesToSave (0.03s) +ok gitea.dooplex.hu/admin/felhom-hub/internal/store (cached) diff --git a/documentation/audits/hub-safety-2026-10-05/partD/red-proof.txt b/documentation/audits/hub-safety-2026-10-05/partD/red-proof.txt new file mode 100644 index 00000000..51f40e84 --- /dev/null +++ b/documentation/audits/hub-safety-2026-10-05/partD/red-proof.txt @@ -0,0 +1,31 @@ +== RED-PROOF 1 (R-530): alarm block 6 skipped (break out before any host) +=== RUN TestAgentAlarm_AfterSevenDaysBehind + r530_agent_alarm_test.go:43: the clock must start for the behind box only +--- FAIL: TestAgentAlarm_AfterSevenDaysBehind (0.04s) +=== RUN TestAgentAlarm_UnknownAndNothingVouchedSayNothing + r530_agent_alarm_test.go:76: control: a box on 0.130.0 must start the clock +--- FAIL: TestAgentAlarm_UnknownAndNothingVouchedSayNothing (0.04s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-hub/internal/osupdates 0.082s +FAIL + +== RED-PROOF 2 (R-604): handleSetGlobalFloor does not call reportFloorHeldBack +=== RUN TestR604_GlobalRaiseNamesHeldBackBoxes + r604_floor_held_back_test.go:64: no log line for the held-back box: +--- FAIL: TestR604_GlobalRaiseNamesHeldBackBoxes (0.05s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.067s +FAIL + +== RED-PROOF 3 (R-604 wiring): main.go does not call SetEventEmitter +=== RUN TestR604_MainWiresTheWebEventEmitter + r133_wiring_test.go:49: cmd/hub/main.go never calls webServer.SetEventEmitter — the R-604 mail is never sent +--- FAIL: TestR604_MainWiresTheWebEventEmitter (0.00s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-hub/cmd/hub 0.025s +FAIL + +== restored +ok gitea.dooplex.hu/admin/felhom-hub/internal/osupdates 0.132s +ok gitea.dooplex.hu/admin/felhom-hub/internal/web 0.301s +ok gitea.dooplex.hu/admin/felhom-hub/cmd/hub 0.029s diff --git a/documentation/audits/hub-safety-2026-10-05/partG/r509-real-mails.txt b/documentation/audits/hub-safety-2026-10-05/partG/r509-real-mails.txt new file mode 100644 index 00000000..91f35ae7 --- /dev/null +++ b/documentation/audits/hub-safety-2026-10-05/partG/r509-real-mails.txt @@ -0,0 +1,21 @@ +R-509 — "one real mail from either trigger" (the closing condition). Read-only check 2026-10-05. + +Source 1: the hub's own log/timeline lines, copied in earlier sessions' evidence (no secrets): + evidence-drill-0243-2026-09-16/phase3-hostdelete.txt:44 2026/09/16 14:22:59 [INFO] self-bind link auto-minted for tester-1 on host delete + evidence-drill-0243-2026-09-16/phase3-hostdelete.txt:46 Sep 16 12:22 | selfbind_link_sent | Self-bind link e-mailed (host delete) + evidence-backup-promise-2026-09-16/phaseE-teardown.txt:46 2026/09/16 20:17:46 [INFO] self-bind link auto-minted for tester-1 on host delete + evidence-drill-new-household-2026-09-30/teardown/layer3-hub.txt:43 2026/09/30 09:23:03 [INFO] self-bind link auto-minted for tester-1 on host delete + (hub log times are Europe/Budapest, CEST = UTC+2) + +Source 2: the tester1@felhom.eu mailbox (Gmail connector, metadata only — no bodies, no snippets read): + query: to:tester1@felhom.eu after:2026/09/15 subject:dobozodat (subject „[Felhom] Kösd össze a Felhom dobozodat") + 2026-09-16T12:23:00Z msg 1a0aa2ba7154efec <- 12:22:59 UTC host delete (match, 1 s) + 2026-09-16T18:17:46Z msg 1a0ab70803fe7275 <- 18:17:46 UTC host delete (match, 0 s) + 2026-09-30T07:23:04Z msg 1a0f13216bc8fe32 <- 07:23:03 UTC host delete (match, 1 s) + also: 2026-09-16T09:59:56Z, 2026-09-17T07:25:15Z, 2026-09-29T18:54:52Z (not matched to a log line here), + 2026-10-04T19:26:08Z = the operator button (night-2026-10-04/tester1/t0-operator-selfbind.txt 19:26:07Z) + +Verdict: the automatic "host delete" trigger (hub v0.114.0) has delivered real mails three times, each within 1 s +of the hub's own send line. The 2026-10-04 Tester 1 install used the button; its link was used (t4-bind-result.txt). +The "e-mail set on a waiting customer" trigger has no matched live mail here; it shares the send core +(mintAndSendSelfBindLink) with the proven trigger and is unit-proven (TestSelfBind_EmailSetOnWaitingCustomerSendsLink). diff --git a/documentation/audits/hub-safety-2026-10-05/partG/red-proof.txt b/documentation/audits/hub-safety-2026-10-05/partG/red-proof.txt new file mode 100644 index 00000000..ffb04bdc --- /dev/null +++ b/documentation/audits/hub-safety-2026-10-05/partG/red-proof.txt @@ -0,0 +1,9 @@ +== RED-PROOF R-508: WaitingNoEmail forced false +=== RUN TestR508_NoEmailBannerBranches + r508_no_email_banner_test.go:25: no banner for a waiting customer with no e-mail +--- FAIL: TestR508_NoEmailBannerBranches (0.05s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.068s +FAIL +== restored +ok gitea.dooplex.hu/admin/felhom-hub/internal/web 0.063s diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 30bc7ee8..fb7a65a9 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,32 @@ +## v0.135.0 — the hub's own safety: form protection for the password path, console passwords sealed at rest; boxes left behind are listed and alarmed; a waiting customer with no e-mail is flagged (R-135, R-133, R-604, R-530, R-508) (2026-10-05) + +**Operator action on deploy: none.** Scripts that POST to the hub with the operator password must now send +`X-Felhom-Operator: cli` (the build-deploy skill and the memory note say so). + +- **R-135 (`05` §8.1).** A state-changing request without a session cookie used to pass the CSRF gate unconditionally + (measured: a Basic-auth POST with no cookie reached the handler). Now it passes only with Basic credentials AND the + `X-Felhom-Operator` header — a page on another site cannot add a custom header (no CORS preflight is answered), so a + browser with cached Basic credentials can no longer be made to POST. The session path is unchanged (cookie + token). + `web/r135_csrf_test.go` drives all 38 state-changing routes plus an unknown path (39 paths) through RequireAuth → ServeHTTP; + red-proof: the old `return true` lets all 39 through (none answers 403). +- **R-133 (`05` §8.2).** `host_recovery.secret` (each box's break-glass `root@pam` password) is sealed with the SAME + AES-256-GCM seal and key as the off-site passwords (`OFFSITE_SECRET_KEY`, `store/offsite_seal.go` — reused, not a second + scheme). Existing rows are sealed in place at start-up (`SealLegacyRecoverySecrets`, beside the off-site one). No key → + the save is refused; a wrong key → the reveal is a 500 with nothing in the body or the log, and no "revealed" event. + Both retrieval paths (the operator page and the global-key API) open it through the same store call. + `store/r133_recovery_seal_test.go`, `web/r133_reveal_wrongkey_test.go`, `cmd/hub/r133_wiring_test.go`; 2 red-proofs. +- **R-604 (`05` §7).** A global floor raise now logs one line per customer whose OWN floor is lower (it wins, so the + raise does not move that box) and sends ONE operator mail naming them (`floor_raise_skipped`, warning, operator-only). + A per-customer floor now records when it was set (`customer_configs.min_controller_set_at`); the System page has a + "Version floors" table: the global floor, every per-customer floor with its age, and which ones the global cannot + move. `web/r604_floor_held_back_test.go`; 2 red-proofs (the call, the wiring). +- **R-530 (`08` §6.3).** The System page shows each box's agent against the vouched one ("0.142.0 → 0.145.0 (since …)", + amber; red after the wait). A box behind the vouched agent for 7 days raises `agent_behind` (warning, operator-only; + `OS_ALARM_AGENT_BEHIND_AFTER`, decided by CC — operator may reverse). Signing stays per box (R-530's ruling). + `osupdates/r530_agent_alarm_test.go`; red-proof: skip the block → no clock, no alarm. +- **R-508.** The customer page shows a red line when a configured customer has no box and no registered e-mail: the + connect link and the setup code cannot reach anyone. `web/r508_no_email_banner_test.go` (three branches); red-proof. + ## v0.134.0 — a box that is off at night: the missed-backup alarm judges a down box; the household hears an outage at most weekly; the catch-up line is allowed (R-872, R-873, R-871) (2026-10-05) **Controller v0.295.0** sends `backup_catchup_done`; an older controller never does. diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 5dbe0981..d9d680bf 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -201,6 +201,12 @@ func main() { } else { logger.Printf("[INFO] off-site secrets sealed at rest (%d legacy plaintext row(s) sealed now)", n) } + // R-133 (v0.135.0): the break-glass console passwords use the same key and the same seal. + if n, serr := dataStore.SealLegacyRecoverySecrets(); serr != nil { + logger.Printf("[ERROR] sealing legacy console passwords failed after %d row(s): %v", n, serr) + } else { + logger.Printf("[INFO] console passwords sealed at rest (%d legacy plaintext row(s) sealed now)", n) + } } logger.Printf("[INFO] Database opened at %s", dbPath) @@ -321,6 +327,7 @@ func main() { webServer.SetAssetManager(assetsMgr) webServer.SetClaimEngine(claimEngine) // v0.50.0 — Setup-tab claim chip + resend button webServer.SetSelfBindMailer(dispatcher) // v0.66.0 (R-27) — customer self-bind link button (sibling of claim mailer) + webServer.SetEventEmitter(dispatcher.ProcessEvent) // v0.135.0 (R-604) — the "floor raise skipped boxes" mail // Day-0 artifact version dropdowns: let the operator pick a version and have the hub derive the // sha256 from Gitea (no hand-copied checksums). Reuses the registry creds; degrades to manual text // entry when they're absent. @@ -453,6 +460,7 @@ func main() { {"OS_ALARM_RING0_STALL_AFTER", &osSvc.Ring0StallAfter, 7 * 24 * time.Hour}, {"OS_ALARM_NOT_COVERED_AFTER", &osSvc.NotCoveredAfter, 14 * 24 * time.Hour}, {"OS_ALARM_BUNDLE_BEHIND_AFTER", &osSvc.BundleBehindAfter, 7 * 24 * time.Hour}, + {"OS_ALARM_AGENT_BEHIND_AFTER", &osSvc.AgentBehindAfter, 7 * 24 * time.Hour}, } { *a.dst = a.def if v := os.Getenv(a.env); v != "" { diff --git a/hub/cmd/hub/r133_wiring_test.go b/hub/cmd/hub/r133_wiring_test.go new file mode 100644 index 00000000..7923713b --- /dev/null +++ b/hub/cmd/hub/r133_wiring_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// R-133 seam wiring: main() must CALL SealLegacyRecoverySecrets (a comment or a string does not count — +// the AST is walked). Without the call, every console password written before v0.135.0 stays in plaintext. +// RED-PROOF: comment the call out → this test fails. +func TestR133_MainSealsLegacyRecoverySecrets(t *testing.T) { + f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0) + if err != nil { + t.Fatal(err) + } + found := false + ast.Inspect(f, func(n ast.Node) bool { + if c, ok := n.(*ast.CallExpr); ok { + if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SealLegacyRecoverySecrets" { + found = true + } + } + return true + }) + if !found { + t.Fatal("cmd/hub/main.go never calls SealLegacyRecoverySecrets — legacy console passwords stay in plaintext") + } +} + +// R-604 seam wiring: main() must hand the web server the dispatcher (SetEventEmitter), or the "floor raise skipped +// boxes" mail is logged and never sent. RED-PROOF: delete the call → this test fails. +func TestR604_MainWiresTheWebEventEmitter(t *testing.T) { + f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0) + if err != nil { + t.Fatal(err) + } + found := false + ast.Inspect(f, func(n ast.Node) bool { + if c, ok := n.(*ast.CallExpr); ok { + if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetEventEmitter" && len(c.Args) == 1 { + found = true + } + } + return true + }) + if !found { + t.Fatal("cmd/hub/main.go never calls webServer.SetEventEmitter — the R-604 mail is never sent") + } +} diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 9c24c91c..616e03c7 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -690,6 +690,10 @@ var operatorOnlyEvents = map[string]bool{ "os_release_cancelled": true, // R-840 (hub v0.133.0): a box's root-owned config bundle behind the vouched one for 7 days. "os_config_bundle_behind": true, + // hub v0.135.0: R-530 (a box behind the vouched agent for 7 days) and R-604 (a global floor raise that did not + // move every box). Fleet facts only the operator can act on — listed in the SAME commit that mints them. + "agent_behind": true, + "floor_raise_skipped": true, "os_update_settings_changed": true, // R-841 (hub v0.131.0): the tunnel alarm — a box fact the household can do nothing about from inside. "tunnel_down": true, diff --git a/hub/internal/osupdates/r530_agent_alarm_test.go b/hub/internal/osupdates/r530_agent_alarm_test.go new file mode 100644 index 00000000..bb059f76 --- /dev/null +++ b/hub/internal/osupdates/r530_agent_alarm_test.go @@ -0,0 +1,101 @@ +package osupdates + +import ( + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-530 (hub v0.135.0): a box running an agent OLDER than the vouched one is told to the operator after 7 days — +// not before, once, and the clock clears when the box catches up. An unreadable version is never a fact. +// RED-PROOF (audits/hub-safety-2026-10-05/partD/red-proof.txt): delete alarm block 6 in Alarms() → +// TestAgentAlarm_AfterSevenDaysBehind fails ("no alarm after 7 days"). + +func agentReport(t *testing.T, f *fix, host, agent string) { + t.Helper() + h, err := f.s.Store.GetHost(host) + if err != nil || h == nil { + t.Fatalf("no host %s", host) + } + if err := f.s.Store.SaveHostReport(host, h.CustomerID, []byte(`{"host":{"cpu_percent":1}}`), store.HostReportDenorm{AgentVersion: agent}); err != nil { + t.Fatal(err) + } +} + +func vouchAgent(t *testing.T, f *fix, v string) { + t.Helper() + if err := f.s.Store.SetArtifactManifest(store.ArtifactManifest{AgentVersion: v, AgentSHA256: "x"}); err != nil { + t.Fatal(err) + } +} + +func TestAgentAlarm_AfterSevenDaysBehind(t *testing.T) { + f := newFix(t) + vouchAgent(t, f, "0.145.0") + agentReport(t, f, "cust1", "0.142.0") + agentReport(t, f, "hp", "0.145.0") // current: never alarms + sent, _ := f.s.Alarms() + if count(sent, EventAgentBehind) != 0 { + t.Fatal("alarm on the first sight") + } + if f.s.Store.AgentBehindSince("cust1").IsZero() || !f.s.Store.AgentBehindSince("hp").IsZero() { + t.Fatal("the clock must start for the behind box only") + } + f.now = f.now.Add(6 * 24 * time.Hour) + if sent, _ = f.s.Alarms(); count(sent, EventAgentBehind) != 0 { + t.Fatal("alarm before 7 days") + } + f.now = f.now.Add(25 * time.Hour) + if sent, _ = f.s.Alarms(); count(sent, EventAgentBehind) != 1 { + t.Fatalf("no alarm after 7 days: %v", sent) + } + if sent, _ = f.s.Alarms(); count(sent, EventAgentBehind) != 0 { + t.Fatal("the alarm must not repeat at once") + } + agentReport(t, f, "cust1", "0.145.0") + f.s.Alarms() + if !f.s.Store.AgentBehindSince("cust1").IsZero() { + t.Fatal("caught up: the clock must clear") + } +} + +func TestAgentAlarm_UnknownAndNothingVouchedSayNothing(t *testing.T) { + f := newFix(t) + vouchAgent(t, f, "0.145.0") + agentReport(t, f, "cust1", "") // an agent too old to say, or a report without the field + f.s.Alarms() + f.now = f.now.Add(30 * 24 * time.Hour) + if sent, _ := f.s.Alarms(); count(sent, EventAgentBehind) != 0 || !f.s.Store.AgentBehindSince("cust1").IsZero() { + t.Fatalf("an unreadable version is not a fact: %v", sent) + } + // control: the same box naming an old version IS behind (proves the report was read at all) + agentReport(t, f, "cust1", "0.130.0") + f.s.Alarms() + if f.s.Store.AgentBehindSince("cust1").IsZero() { + t.Fatal("control: a box on 0.130.0 must start the clock") + } + g := newFix(t) + agentReport(t, g, "cust1", "0.130.0") + g.s.Alarms() + g.now = g.now.Add(30 * 24 * time.Hour) + if sent, _ := g.s.Alarms(); count(sent, EventAgentBehind) != 0 { + t.Fatalf("nothing vouched, nothing behind: %v", sent) + } +} + +func TestReleasesBehind(t *testing.T) { + for _, c := range []struct{ a, b, want string }{ + {"0.142.0", "0.145.0", "3 minor releases behind"}, + {"0.144.0", "0.145.0", "1 minor release behind"}, + {"0.145.0", "0.145.2", "2 patch releases behind"}, + {"0.145.0", "1.0.0", "a major release behind"}, + {"0.145.0", "0.145.0", ""}, + {"0.146.0", "0.145.0", ""}, + {"", "0.145.0", ""}, + } { + if got := ReleasesBehind(c.a, c.b); got != c.want { + t.Errorf("ReleasesBehind(%q, %q) = %q, want %q", c.a, c.b, got, c.want) + } + } +} diff --git a/hub/internal/osupdates/service.go b/hub/internal/osupdates/service.go index 0fc4c365..490aef87 100644 --- a/hub/internal/osupdates/service.go +++ b/hub/internal/osupdates/service.go @@ -24,6 +24,7 @@ import ( "log" "regexp" "sort" + "strconv" "strings" "time" @@ -73,6 +74,9 @@ const ( EventCancelled = "os_release_cancelled" // warning, operator // EventBundleBehind: a box's root-owned config bundle has differed from the vouched one for BundleBehindAfter (R-840). EventBundleBehind = "os_config_bundle_behind" // warning, operator + // EventAgentBehind: a box has run an agent older than the vouched one for AgentBehindAfter (R-530, hub v0.135.0). + // Agents update only by a per-box signed job (R-530's ruling), so a box nobody signed for silently stays behind. + EventAgentBehind = "agent_behind" // warning, operator ) // Package is one name=version with its origin ("Debian" | "Debian-Security"). @@ -173,9 +177,12 @@ type Service struct { // BundleBehindAfter: a box's config bundle differs from the vouched one this long → an operator alarm (R-840; // decided by CC unattended — operator may reverse). Zero = 7 d. BundleBehindAfter time.Duration - Logger *log.Logger - Now func() time.Time - Bump func(hostID string) + // AgentBehindAfter: a box runs an agent older than the vouched one this long → an operator alarm (R-530; decided + // by CC — operator may reverse, env OS_ALARM_AGENT_BEHIND_AFTER). Zero = 7 d. + AgentBehindAfter time.Duration + Logger *log.Logger + Now func() time.Time + Bump func(hostID string) // TestOverride names the TEST overrides active at start ("" = none, the ruled waits). Every approval made while it // is set carries the `test` mark; CancelTestReleases cancels them at a start without it (`11` §5.3.1). TestOverride string @@ -484,6 +491,9 @@ func (s *Service) Candidates() []Status { // BundleThreshold is the config-bundle alarm's wait (the System page turns the cell red at it). func (s *Service) BundleThreshold() time.Duration { return dflt(s.BundleBehindAfter, 7*24*time.Hour) } +// AgentThreshold is the agent-behind alarm's wait (R-530; the System page turns the cell red at it). +func (s *Service) AgentThreshold() time.Duration { return dflt(s.AgentBehindAfter, 7*24*time.Hour) } + // Thresholds are the alarm numbers the System page colours by (the same values the alarms use). func (s *Service) Thresholds() (stale, reboot, notCovered time.Duration) { return dflt(s.StaleAfter, 7*24*time.Hour), dflt(s.RebootAfter, 14*24*time.Hour), dflt(s.NotCoveredAfter, 14*24*time.Hour) @@ -966,6 +976,34 @@ func (s *Service) Alarms() ([]string, error) { sent = append(sent, EventBundleBehind) } } + // 6. R-530 (v0.135.0): a box runs an agent OLDER than the vouched one for AgentBehindAfter. Agents update only by a + // per-box signed job, so a box nobody signed for stays behind silently. An unreadable version (empty, not + // semver) is never a fact; nothing vouched → nothing is behind. + for _, h := range hosts { + if !semver.Valid(man.AgentVersion) { + break + } + if !semver.Valid(h.AgentVersion) { + continue + } + behind := semver.Compare(h.AgentVersion, man.AgentVersion) < 0 + since := s.Store.AgentBehindSince(h.HostID) + switch { + case !behind && !since.IsZero(): + _ = s.Store.SetAgentBehindSince(h.HostID, time.Time{}) + since = time.Time{} + case behind && since.IsZero(): + since = now + _ = s.Store.SetAgentBehindSince(h.HostID, since) + } + if s.raise("agent:"+h.HostID, behind && now.Sub(since) >= s.AgentThreshold(), h.CustomerID, EventAgentBehind, "warning", + fmt.Sprintf("Agent: %s still runs agent %s; the vouched agent is %s (%s, behind since %s; last report %s). "+ + "Sign an agent_update for this box (felhom-opsign, `04` §3.1).", h.HostID, h.AgentVersion, man.AgentVersion, + ReleasesBehind(h.AgentVersion, man.AgentVersion), since.UTC().Format("2006-01-02"), fmtTime(h.LastReportAt)), + map[string]any{"host_id": h.HostID, "box_agent": h.AgentVersion, "vouched_agent": man.AgentVersion, "since": since}) { + sent = append(sent, EventAgentBehind) + } + } // 3. Ring 0 approved nothing for `stall` while ring 0 has pending fast-lane updates: the whole fleet stopped // getting fixes. ring0, _ := s.ring0Hosts() @@ -1013,3 +1051,41 @@ func fmtTime(t *time.Time) string { } return t.UTC().Format("2006-01-02 15:04") } + +// ReleasesBehind says how far version a is behind b, for the operator: "3 minor releases behind", +// "2 patch releases behind", "a major release behind". "" when a is not behind b or either is not semver. +func ReleasesBehind(a, b string) string { + if !semver.Valid(a) || !semver.Valid(b) || semver.Compare(a, b) >= 0 { + return "" + } + pa, pb := semverParts(a), semverParts(b) + switch { + case pa[0] != pb[0]: + return "a major release behind" + case pa[1] != pb[1]: + n := pb[1] - pa[1] + if n == 1 { + return "1 minor release behind" + } + return fmt.Sprintf("%d minor releases behind", n) + } + n := pb[2] - pa[2] + if n == 1 { + return "1 patch release behind" + } + return fmt.Sprintf("%d patch releases behind", n) +} + +func semverParts(v string) [3]int { + var p [3]int + v = strings.TrimPrefix(v, "v") + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + for i, part := range strings.SplitN(v, ".", 3) { + if i < 3 { + p[i], _ = strconv.Atoi(part) + } + } + return p +} diff --git a/hub/internal/store/host_recovery.go b/hub/internal/store/host_recovery.go index a1184a7d..f5a659b6 100644 --- a/hub/internal/store/host_recovery.go +++ b/hub/internal/store/host_recovery.go @@ -3,6 +3,8 @@ package store import ( "database/sql" "encoding/json" + "fmt" + "strings" "time" ) @@ -16,17 +18,24 @@ type HostRecoveryCredential struct { } // SaveHostRecoveryCredential upserts a host's break-glass credential (last-write-wins: day-0 sets it, -// --rotate re-sets). The secret is stored as-is at rest; the hub NEVER logs it and only ever returns -// it over the operator-authenticated retrieval path. +// --rotate re-sets). R-133 (hub v0.135.0): the secret is SEALED at rest with the same key and the same +// helpers as the off-site sub-account passwords (offsite_seal.go, OFFSITE_SECRET_KEY) — a copy of hub.db +// alone no longer holds any box's console password. No key → the save is REFUSED (ErrNoSealKey): a hub that +// cannot seal must not fall back to plaintext. The hub NEVER logs the secret and only ever returns it over +// the operator-authenticated retrieval paths. Pinned by r133_recovery_seal_test.go. func (s *Store) SaveHostRecoveryCredential(hostID, username, secret string) error { - _, err := s.db.Exec(` + sealed, err := s.sealSecret(secret) + if err != nil { + return err + } + _, err = s.db.Exec(` INSERT INTO host_recovery (host_id, username, secret, set_at, updated_at) VALUES (?, ?, ?, datetime('now'), datetime('now')) ON CONFLICT(host_id) DO UPDATE SET username = excluded.username, secret = excluded.secret, updated_at = datetime('now')`, - hostID, username, secret) + hostID, username, sealed) return err } @@ -43,6 +52,13 @@ func (s *Store) GetHostRecoveryCredential(hostID string) (*HostRecoveryCredentia if err != nil { return nil, err } + // R-133: open the sealed value. A wrong or missing key, or a row still in plaintext (the start-up + // sealing has not run), is an ERROR — never a fallback that hands out what the column holds. + plain, err := s.openSecret(c.Secret) + if err != nil { + return nil, fmt.Errorf("host_recovery %s: %w", hostID, err) + } + c.Secret = plain c.SetAt = parseSQLiteTime(setAt) return &c, nil } @@ -136,3 +152,42 @@ func (s *Store) GetHostMgmtPlaneStates() ([]HostMgmtPlaneRow, error) { } return out, rows.Err() } + +// SealLegacyRecoverySecrets seals, in place, every host_recovery row still holding a plaintext console +// password (written before hub v0.135.0, R-133). Idempotent; returns how many rows it sealed. Values are +// never logged. Called at start-up right after the key is installed (cmd/hub/main.go), beside +// SealLegacyOffsiteSecrets. +func (s *Store) SealLegacyRecoverySecrets() (int, error) { + if s.sealer == nil { + return 0, ErrNoSealKey + } + rows, err := s.db.Query(`SELECT host_id, secret FROM host_recovery`) + if err != nil { + return 0, err + } + type row struct{ id, v string } + var todo []row + for rows.Next() { + var r row + if err := rows.Scan(&r.id, &r.v); err != nil { + rows.Close() + return 0, err + } + if !strings.HasPrefix(r.v, sealPrefix) { + todo = append(todo, r) + } + } + rows.Close() + n := 0 + for _, r := range todo { + sealed, err := s.sealSecret(r.v) + if err != nil { + return n, err + } + if _, err := s.db.Exec(`UPDATE host_recovery SET secret = ? WHERE host_id = ? AND secret = ?`, sealed, r.id, r.v); err != nil { + return n, err + } + n++ + } + return n, nil +} diff --git a/hub/internal/store/r133_recovery_seal_test.go b/hub/internal/store/r133_recovery_seal_test.go new file mode 100644 index 00000000..87b904a7 --- /dev/null +++ b/hub/internal/store/r133_recovery_seal_test.go @@ -0,0 +1,96 @@ +package store + +import ( + "strings" + "testing" +) + +// R-133 (hub v0.135.0): the break-glass console password is sealed at rest with the off-site seal +// (offsite_seal.go). RED-PROOF (audits/hub-safety-2026-10-05/partB/red-proof.txt): make +// SaveHostRecoveryCredential store `secret` instead of `sealed` → TestR133_RawRowHoldsNoPassword fails. + +func rawRecovery(t *testing.T, st *Store, hostID string) string { + t.Helper() + var v string + if err := st.db.QueryRow(`SELECT secret FROM host_recovery WHERE host_id = ?`, hostID).Scan(&v); err != nil { + t.Fatal(err) + } + return v +} + +// A copy of hub.db alone holds no console password: asserted on the raw column, and the value still opens. +func TestR133_RawRowHoldsNoPassword(t *testing.T) { + st := sealTestStore(t) + if err := st.SaveHostRecoveryCredential("h1", "root@pam", "Console-Pw-7741"); err != nil { + t.Fatal(err) + } + raw := rawRecovery(t, st, "h1") + if strings.Contains(raw, "Console-Pw-7741") || !strings.HasPrefix(raw, sealPrefix) { + t.Fatalf("raw host_recovery.secret is not sealed: %q", raw) + } + c, err := st.GetHostRecoveryCredential("h1") + if err != nil || c == nil || c.Secret != "Console-Pw-7741" || c.Username != "root@pam" { + t.Fatalf("GetHostRecoveryCredential = %+v, %v", c, err) + } + // The page path never carried the secret, and still does not. + m, err := st.GetHostRecoveryMeta("h1") + if err != nil || m == nil || m.Username != "root@pam" { + t.Fatalf("meta = %+v, %v", m, err) + } +} + +// The migration: a row written in plaintext before v0.135.0 is sealed in place, once; the value survives. +func TestR133_SealLegacyRecoverySecrets(t *testing.T) { + st := sealTestStore(t) + if _, err := st.db.Exec(`INSERT INTO host_recovery (host_id, username, secret) VALUES ('old', 'root@pam', 'Legacy-Plain-1')`); err != nil { + t.Fatal(err) + } + if err := st.SaveHostRecoveryCredential("new", "root@pam", "Fresh-2"); err != nil { + t.Fatal(err) + } + if _, err := st.GetHostRecoveryCredential("old"); err == nil { + t.Fatal("a plaintext row was handed out before the migration sealed it") + } + n, err := st.SealLegacyRecoverySecrets() + if err != nil || n != 1 { + t.Fatalf("SealLegacyRecoverySecrets = %d, %v — want exactly the one plaintext row", n, err) + } + if raw := rawRecovery(t, st, "old"); strings.Contains(raw, "Legacy-Plain-1") || !strings.HasPrefix(raw, sealPrefix) { + t.Fatalf("legacy row not sealed: %q", raw) + } + if c, err := st.GetHostRecoveryCredential("old"); err != nil || c.Secret != "Legacy-Plain-1" { + t.Fatalf("after migration = %+v, %v", c, err) + } + if n, err := st.SealLegacyRecoverySecrets(); err != nil || n != 0 { + t.Fatalf("second run sealed %d (%v) — want 0 (idempotent)", n, err) + } +} + +// A wrong key fails CLOSED: an error, never the column's bytes. +func TestR133_WrongKeyFailsClosed(t *testing.T) { + st := sealTestStore(t) + if err := st.SaveHostRecoveryCredential("h1", "root@pam", "Console-Pw-7741"); err != nil { + t.Fatal(err) + } + if err := st.SetOffsiteSecretKey([]byte("another-key-of-exactly-32-bytes!")); err != nil { + t.Fatal(err) + } + c, err := st.GetHostRecoveryCredential("h1") + if err == nil || c != nil { + t.Fatalf("a wrong key returned %+v (err %v) — want an error and nothing", c, err) + } +} + +// No key → the save is refused; nothing is written in the clear. +func TestR133_NoKeyRefusesToSave(t *testing.T) { + st := sealTestStore(t) + st.sealer = nil + if err := st.SaveHostRecoveryCredential("h1", "root@pam", "Console-Pw-7741"); err != ErrNoSealKey { + t.Fatalf("save without a key = %v, want ErrNoSealKey", err) + } + var n int + _ = st.db.QueryRow(`SELECT COUNT(*) FROM host_recovery`).Scan(&n) + if n != 0 { + t.Fatalf("%d row(s) written without a key", n) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index b32c7b37..e135a8a3 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -184,6 +184,8 @@ func (s *Store) migrate() error { // per-customer override → the effective floor falls back to the global default (hub_settings / // config). Idempotent. s.db.Exec("ALTER TABLE customer_configs ADD COLUMN min_controller_version TEXT NOT NULL DEFAULT ''") + // R-604 (v0.135.0): when the per-customer floor override was set (its age, on the System page). + s.db.Exec("ALTER TABLE customer_configs ADD COLUMN min_controller_set_at TEXT NOT NULL DEFAULT ''") // v0.112.0 (R-472): the MinAgent DECLARED with a per-customer floor, stored as "FLOOR=MINAGENT" so // it only ever applies to the exact floor it was declared for (see floor_declared.go). Idempotent. @@ -1986,14 +1988,47 @@ func (s *Store) SetCustomerConfigStatus(customerID, status string) error { // SetMinControllerVersion sets (or clears, with "") the per-customer controller-version floor // override. The customer config must already exist. func (s *Store) SetMinControllerVersion(customerID, version string) error { + // R-604 (v0.135.0): the override's SET TIME travels with it, so the System page can show its age. Cleared + // with the override; an override written before v0.135.0 has none ("age unknown"). _, err := s.db.Exec(` - UPDATE customer_configs SET min_controller_version = ?, updated_at = datetime('now') + UPDATE customer_configs SET min_controller_version = ?, + min_controller_set_at = CASE WHEN ? = '' THEN '' ELSE datetime('now') END, + updated_at = datetime('now') WHERE customer_id = ?`, - version, customerID, + version, version, customerID, ) return err } +// CustomerFloorOverride is one per-customer controller-floor override (R-604). +type CustomerFloorOverride struct { + CustomerID, CustomerName, Version string + SetAt time.Time // zero = set before v0.135.0 (age unknown) +} + +// CustomerFloorOverrides lists every customer whose config carries its own controller floor, by customer id. +func (s *Store) CustomerFloorOverrides() ([]CustomerFloorOverride, error) { + rows, err := s.db.Query(`SELECT customer_id, customer_name, min_controller_version, min_controller_set_at + FROM customer_configs WHERE min_controller_version != '' ORDER BY customer_id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []CustomerFloorOverride + for rows.Next() { + var o CustomerFloorOverride + var at string + if err := rows.Scan(&o.CustomerID, &o.CustomerName, &o.Version, &at); err != nil { + return nil, err + } + if at != "" { + o.SetAt = parseSQLiteTime(at) + } + out = append(out, o) + } + return out, rows.Err() +} + // GetGlobalMinControllerVersion returns the operator-set global floor from hub_settings if present, // else the config/env-supplied default. Empty string = no global floor. func (s *Store) GetGlobalMinControllerVersion() string { @@ -2231,6 +2266,26 @@ func (s *Store) SetBundleBehindSince(hostID string, t time.Time) error { return s.setSetting("bundle_behind_since:"+hostID, v) } +// AgentBehindSince returns since when the hub has seen a box run an agent OLDER than the vouched one (zero = it +// is current, or was never seen behind). R-530's 7-day operator alarm counts from here (hub v0.135.0). +func (s *Store) AgentBehindSince(hostID string) time.Time { + v := s.getSetting("agent_behind_since:" + hostID) + if v == "" { + return time.Time{} + } + t, _ := time.Parse(time.RFC3339, v) + return t +} + +// SetAgentBehindSince records (or, with a zero time, clears) the first moment a box was seen behind the vouched agent. +func (s *Store) SetAgentBehindSince(hostID string, t time.Time) error { + v := "" + if !t.IsZero() { + v = t.UTC().Format(time.RFC3339) + } + return s.setSetting("agent_behind_since:"+hostID, v) +} + // EffectiveMinControllerVersion resolves the floor that actually applies to a customer: the // per-customer override when set (non-empty), otherwise the global floor (hub_settings → config/env // default). Returns "" when no floor applies at all (Phase 2 inert for that customer). diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index a33a1f6e..30b46f12 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -375,6 +375,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c // Save-triggered (applyOffsite), and the re-enroll auto-re-issue deliberately skips an // unprovisioned target — so this state is stable and silent until someone presses Save. OffsiteUnprovisioned bool + + // WaitingNoEmail (R-508, v0.135.0): a configured customer with NO box and NO registered e-mail — the + // connect link and the setup code can reach nobody. The hub logged it; the page now says it. + WaitingNoEmail bool } pendingSet := make(map[string]bool, len(pendingTails)) @@ -476,6 +480,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c StaleSinceReset: staleSinceReset, ResetAt: resetAt, OffsiteUnprovisioned: offsiteUnprovisioned, + WaitingNoEmail: cfg != nil && len(hostViews) == 0 && strings.TrimSpace(email) == "", LatestVersion: latestVersion, UpdateAvailable: updateAvailable, @@ -1162,6 +1167,7 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) { return } s.logger.Printf("[INFO] Global controller-version floor set to %q (declared MinAgent %q)", v, ma) + s.reportFloorHeldBack(v) // Direction-2: the global floor affects every config-managed customer — wake each long-polling // box so the new floor lands in seconds (nil-safe; a customer with no held wait just advances). if configs, cerr := s.store.ListCustomerConfigs(); cerr == nil { @@ -1172,6 +1178,55 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/configuration?flash=floor_set", http.StatusSeeOther) } +// heldBackByOwnFloor lists the customers a global floor `global` does NOT move because their own per-customer +// floor override is LOWER (R-604). An override at or above the global is not held back — it already asks for +// at least as much. Pure on the store; ordered by customer id. +func (s *Server) heldBackByOwnFloor(global string) []store.CustomerFloorOverride { + if global == "" || !semver.Valid(global) { + return nil + } + ovs, err := s.store.CustomerFloorOverrides() + if err != nil { + s.logger.Printf("[ERROR] floor overrides: %v", err) + return nil + } + var out []store.CustomerFloorOverride + for _, o := range ovs { + if semver.Valid(o.Version) && semver.Compare(global, o.Version) > 0 { + out = append(out, o) + } + } + return out +} + +// reportFloorHeldBack says, at the moment of the raise, which boxes it did NOT move (R-604, hub v0.135.0): one log +// line per customer and ONE operator mail naming them all. Before v0.135.0 the raise logged nothing for such a box, +// and demo-hp silently missed four raises. Pinned by r604_floor_held_back_test.go. +func (s *Server) reportFloorHeldBack(global string) { + held := s.heldBackByOwnFloor(global) + if len(held) == 0 { + return + } + var names []string + for _, o := range held { + age := "set before hub v0.135.0 — age unknown" + if !o.SetAt.IsZero() { + age = "set " + o.SetAt.UTC().Format("2006-01-02 15:04") + " UTC" + } + s.logger.Printf("[WARN] global floor %s does NOT move customer %s: its own floor %s wins (%s) — clear it on the customer page to let the global floor apply", + global, o.CustomerID, o.Version, age) + names = append(names, fmt.Sprintf("%s (own floor %s, %s)", o.CustomerID, o.Version, age)) + } + if s.emit == nil { + return + } + details, _ := json.Marshal(map[string]any{"global_floor": global, "held_back": names}) + s.emit("", "floor_raise_skipped", "warning", + fmt.Sprintf("Floor: the global controller floor is now %s, but %d box(es) keep their own LOWER floor and were not moved: %s. "+ + "Clear each per-customer floor (or raise it) on the customer page.", global, len(held), strings.Join(names, "; ")), + string(details), "hub") +} + // floorDeclaredMinAgent reads and validates the `min_agent` a floor form declares (R-472). It returns // the normalised value, or a flash key when the form must be REFUSED with nothing stored: // diff --git a/hub/internal/web/r133_reveal_wrongkey_test.go b/hub/internal/web/r133_reveal_wrongkey_test.go new file mode 100644 index 00000000..ada7b6dd --- /dev/null +++ b/hub/internal/web/r133_reveal_wrongkey_test.go @@ -0,0 +1,32 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// R-133: with the wrong sealing key the reveal endpoint fails CLOSED — 500, no password in the body, nothing +// in the log, and no "revealed" event (nothing was delivered). The right-key path is TestReveal_B. +func TestR133_RevealWithWrongKeyFailsClosed(t *testing.T) { + s, st, logBuf := newRevealServer(t) + cookie, csrf := newRevealSession(t, s) + seedRevealHost(t, st, "demo-hp-bb76ea", "demo-hp", revealCanary) + if err := st.SetOffsiteSecretKey([]byte("another-key-of-exactly-32-bytes!")); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/hosts/demo-hp-bb76ea/reveal-recovery-credential", nil) + req.AddCookie(cookie) + req.Header.Set("X-CSRF-Token", csrf) + rr := serveReveal(t, s, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("reveal with a wrong key = %d, want 500", rr.Code) + } + if strings.Contains(rr.Body.String(), revealCanary) || strings.Contains(logBuf.String(), revealCanary) { + t.Fatal("the secret leaked into the body or the log") + } + if n := countEvents(t, st, "demo-hp", "recovery_credential_revealed"); n != 0 { + t.Fatalf("%d reveal event(s) for a reveal that delivered nothing", n) + } +} diff --git a/hub/internal/web/r135_csrf_test.go b/hub/internal/web/r135_csrf_test.go new file mode 100644 index 00000000..683536a4 --- /dev/null +++ b/hub/internal/web/r135_csrf_test.go @@ -0,0 +1,162 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// R-135 (hub v0.135.0): a state-changing request passes the gate only with (a) a live session cookie AND its +// token, or (b) NO cookie, Basic credentials AND the OperatorCLIHeader. Everything else is 403 — on every +// route, because the gate sits in ServeHTTP BEFORE the route switch. +// +// RED-PROOF (recorded in felhom.eu/documentation/audits/hub-safety-2026-10-05/partA/red-proof.txt): restore +// the pre-v0.135.0 `return true` for a request with no cookie → TestR135_BasicAuthWithoutHeaderIsRefused +// fails on every route (the handlers answer 303/404/400/200 instead of 403). + +// r135PostRoutes is EVERY state-changing route of the hub web server (server.go ServeHTTP), one +// representative path each. /login and /bind/ are the two documented exemptions (no operator session +// to ride; the bind URL token is the capability) and are NOT in this list. +var r135PostRoutes = []string{ + "/configuration", + "/apps/demo/reset-telemetry", + "/apps/demo/dismiss-issues", + "/offsite/endpoints", + "/offsite/endpoints/1/delete", + "/appliances/1/bind", + "/appliances/1/discard", + "/hosts/h1/delete", + "/hosts/h1/reveal-recovery-credential", + "/hosts/h1/request-logs", + "/customers/c1/block", + "/customers/c1/selfbind-link", + "/customers/c1/unblock", + "/customers/c1/geo/disable", + "/customers/c1/floor", + "/customers/c1/create-config", + "/customers/c1/request-log-tail", + "/configs/new", + "/configuration/global-floor", + "/configuration/artifacts", + "/configuration/password", + "/configs/c1/delete", + "/configs/c1/edit", + "/configs/c1/offsite-reissue", + "/configs/c1/claim-resend", + "/configs/c1/pbsdr-reissue", + "/configs/c1/offsite-freeze", + "/configs/c1/regen-password", + "/configs/c1/reset", + "/offsite/remove-unpinned/c1", + "/offsite/abandon-cancel/c1", + "/offsite/window-grant/c1", + "/offsite/windows-enabled", + "/offsite/key-audit", + "/os/ring/h1", + "/os/enabled/h1", + "/os/approve-now", + "/os/approve-docker", + // Not a route: the gate must refuse BEFORE routing, so even an unknown path is 403, never 404. + "/no-such-route", +} + +// r135Handler is the production wiring: RequireAuth around ServeHTTP (cmd/hub/main.go). +func r135Handler(t *testing.T) (*Server, http.Handler) { + t.Helper() + s, _ := serverWithPassword(t, "op-pass") + return s, s.RequireAuth(http.HandlerFunc(s.ServeHTTP)) +} + +func r135Post(h http.Handler, path string, mut func(*http.Request)) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(url.Values{"x": {"1"}}.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mut(r) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +// The measured shape of R-135: Basic credentials and no cookie — what a browser with cached Basic auth sends +// when another site makes it POST a form. Refused on every route. +func TestR135_BasicAuthWithoutHeaderIsRefused(t *testing.T) { + _, h := r135Handler(t) + for _, p := range r135PostRoutes { + w := r135Post(h, p, func(r *http.Request) { + r.SetBasicAuth("", "op-pass") + r.Header.Set("Origin", "https://evil.example") + }) + if w.Code != http.StatusForbidden { + t.Errorf("POST %s with Basic auth and no %s header: %d, want 403", p, OperatorCLIHeader, w.Code) + } + } +} + +// A browser session without its token: refused on every route (this half was already right; pinned here). +func TestR135_SessionWithoutTokenIsRefused(t *testing.T) { + s, h := r135Handler(t) + s.sessionsMu.Lock() + s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"} + s.sessionsMu.Unlock() + for _, p := range r135PostRoutes { + w := r135Post(h, p, func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "hub_session", Value: "sess1"}) }) + if w.Code != http.StatusForbidden { + t.Errorf("POST %s with a session and no token: %d, want 403", p, w.Code) + } + w = r135Post(h, p, func(r *http.Request) { + r.AddCookie(&http.Cookie{Name: "hub_session", Value: "sess1"}) + r.Header.Set("X-CSRF-Token", "wrong") + }) + if w.Code != http.StatusForbidden { + t.Errorf("POST %s with a session and a wrong token: %d, want 403", p, w.Code) + } + } +} + +// The two ways that pass: they reach the handler (any answer but the gate's 403 body). +func TestR135_TheTwoAllowedShapesPassTheGate(t *testing.T) { + s, h := r135Handler(t) + s.sessionsMu.Lock() + s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"} + s.sessionsMu.Unlock() + gate := "CSRF token missing or invalid" + for _, p := range r135PostRoutes { + w := r135Post(h, p, func(r *http.Request) { + r.AddCookie(&http.Cookie{Name: "hub_session", Value: "sess1"}) + r.Header.Set("X-CSRF-Token", "tok1") + }) + if strings.Contains(w.Body.String(), gate) { + t.Errorf("POST %s with a session and its token was refused by the gate", p) + } + w = r135Post(h, p, func(r *http.Request) { + r.SetBasicAuth("", "op-pass") + r.Header.Set(OperatorCLIHeader, "cli") + }) + if strings.Contains(w.Body.String(), gate) { + t.Errorf("POST %s with Basic auth and the %s header was refused by the gate", p, OperatorCLIHeader) + } + } +} + +// The header alone proves nothing: without Basic credentials RequireAuth stops it before the gate. +func TestR135_HeaderWithoutCredentialsIsNotEnough(t *testing.T) { + _, h := r135Handler(t) + w := r135Post(h, "/configuration/global-floor", func(r *http.Request) { r.Header.Set(OperatorCLIHeader, "cli") }) + if w.Code != http.StatusFound && w.Code != http.StatusUnauthorized { + t.Fatalf("header with no credentials: %d, want a redirect to /login or 401", w.Code) + } +} + +// Reads are not gated: a GET with Basic auth and no header still works (the page renders or redirects). +func TestR135_GetIsNotGated(t *testing.T) { + _, h := r135Handler(t) + r := httptest.NewRequest(http.MethodGet, "/hosts", nil) + r.SetBasicAuth("", "op-pass") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code == http.StatusForbidden { + t.Fatalf("GET /hosts with Basic auth was refused by the CSRF gate") + } +} diff --git a/hub/internal/web/r508_no_email_banner_test.go b/hub/internal/web/r508_no_email_banner_test.go new file mode 100644 index 00000000..191d3686 --- /dev/null +++ b/hub/internal/web/r508_no_email_banner_test.go @@ -0,0 +1,33 @@ +package web + +import ( + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-508 (hub v0.135.0): a configured customer with no box and no e-mail is told on the page — one render test per +// branch of the gate (absent with an e-mail; absent once a box is bound; present when both are missing). +// RED-PROOF (audits/hub-safety-2026-10-05/partG/red-proof.txt): set WaitingNoEmail to false → the "present" case fails. +const noEmailMarker = "No registered e-mail, and no box yet" + +func TestR508_NoEmailBannerBranches(t *testing.T) { + s, st := newTestServer(t) + seedCustomer(t, st, "with-mail", "") + if contains(renderCustomerPageWithQuery(t, s, "with-mail", ""), noEmailMarker) { + t.Fatal("banner shown for a customer WITH an e-mail") + } + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "no-mail", CustomerName: "x", Domain: "x.hu", + RetrievalPassword: "pw", APIKey: "k2", Status: "active"}); err != nil { + t.Fatal(err) + } + if !contains(renderCustomerPageWithQuery(t, s, "no-mail", ""), noEmailMarker) { + t.Fatal("no banner for a waiting customer with no e-mail") + } + if err := st.UpsertHost(&store.Host{HostID: "h-no-mail", CustomerID: "no-mail", APIKey: "hk"}); err != nil { + t.Fatal(err) + } + if contains(renderCustomerPageWithQuery(t, s, "no-mail", ""), noEmailMarker) { + t.Fatal("banner shown for a customer whose box is already bound (nothing is waiting)") + } +} diff --git a/hub/internal/web/r604_floor_held_back_test.go b/hub/internal/web/r604_floor_held_back_test.go new file mode 100644 index 00000000..108ed791 --- /dev/null +++ b/hub/internal/web/r604_floor_held_back_test.go @@ -0,0 +1,141 @@ +package web + +import ( + "bytes" + "log" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-604 (hub v0.135.0): a global floor raise says which boxes it did NOT move — one log line per customer whose +// own floor is LOWER, and ONE operator mail naming them. An override at or above the global is not named; a raise +// that moves everyone sends nothing. +// RED-PROOF (audits/hub-safety-2026-10-05/partD/red-proof.txt): remove the s.reportFloorHeldBack(v) call in +// handleSetGlobalFloor → TestR604_GlobalRaiseNamesHeldBackBoxes fails (no log line, no mail). + +type emitted struct{ customer, typ, sev, msg string } + +func r604Server(t *testing.T) (*Server, *store.Store, *bytes.Buffer, *[]emitted) { + t.Helper() + s, st := newTestServer(t) + var buf bytes.Buffer + s.logger = log.New(&buf, "", 0) + var got []emitted + s.SetEventEmitter(func(c, typ, sev, msg, _, _ string) { got = append(got, emitted{c, typ, sev, msg}) }) + // vouch a golden ABOVE the floors used here, so a floor needs no declared MinAgent (R-472 is not under test) + if err := st.SetArtifactManifest(store.ArtifactManifest{GoldenVersion: "0.400.0", AgentVersion: "0.145.0"}); err != nil { + t.Fatal(err) + } + for _, c := range []struct{ id, floor string }{{"c-low", "0.240.0"}, {"c-high", "0.300.0"}, {"c-none", ""}} { + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: c.id, CustomerName: c.id, RetrievalPassword: "x", APIKey: "k-" + c.id, ConfigJSON: "{}"}); err != nil { + t.Fatal(err) + } + if c.floor != "" { + if err := st.SetMinControllerVersion(c.id, c.floor); err != nil { + t.Fatal(err) + } + } + } + return s, st, &buf, &got +} + +func setGlobal(t *testing.T, s *Server, v string) { + t.Helper() + r := httptest.NewRequest(http.MethodPost, "/configuration/global-floor", strings.NewReader(url.Values{"min_controller_version": {v}}.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=floor_set") { + t.Fatalf("global floor %s: %d %s", v, w.Code, w.Header().Get("Location")) + } +} + +func TestR604_GlobalRaiseNamesHeldBackBoxes(t *testing.T) { + s, _, buf, got := r604Server(t) + setGlobal(t, s, "0.295.0") + logs := buf.String() + if !strings.Contains(logs, "does NOT move customer c-low: its own floor 0.240.0 wins") { + t.Fatalf("no log line for the held-back box:\n%s", logs) + } + if strings.Contains(logs, "customer c-high") || strings.Contains(logs, "customer c-none") { + t.Fatalf("a box that is NOT held back was named:\n%s", logs) + } + if len(*got) != 1 { + t.Fatalf("want exactly ONE operator mail, got %d: %+v", len(*got), *got) + } + e := (*got)[0] + if e.typ != "floor_raise_skipped" || e.sev != "warning" || e.customer != "" || + !strings.Contains(e.msg, "c-low (own floor 0.240.0") || strings.Contains(e.msg, "c-high") { + t.Fatalf("the mail does not name exactly the held-back box: %+v", e) + } + if !strings.Contains(e.msg, "set 20") { + t.Fatalf("the mail must give the override's age (set time): %q", e.msg) + } +} + +func TestR604_RaiseThatMovesEveryoneSendsNothing(t *testing.T) { + s, _, buf, got := r604Server(t) + setGlobal(t, s, "0.200.0") // below both overrides: nobody is held back by a LOWER own floor + if len(*got) != 0 || strings.Contains(buf.String(), "does NOT move") { + t.Fatalf("nothing held back, yet: mails %+v, log %q", *got, buf.String()) + } +} + +// The System page shows every per-customer floor with its age, and flags the one the global floor cannot move. +func TestR604_SystemPageListsFloorsWithAge(t *testing.T) { + s, st, _ := systemServer(t) + if err := st.SetArtifactManifest(store.ArtifactManifest{GoldenVersion: "0.400.0", AgentVersion: "0.145.0"}); err != nil { + t.Fatal(err) + } + if err := st.SetGlobalMinControllerVersion("0.295.0"); err != nil { + t.Fatal(err) + } + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c-full", CustomerName: "Full", RetrievalPassword: "x", APIKey: "k", ConfigJSON: "{}"}); err != nil { + t.Fatal(err) + } + if err := st.SetMinControllerVersion("c-full", "0.243.0"); err != nil { + t.Fatal(err) + } + b := getSystem(t, s) + for _, want := range []string{`id="version-floors"`, "Global controller floor: 0.295.0", ">c-full<", "0.243.0", + time.Now().UTC().Format("2006-01-02"), "NO — its own floor is lower and wins"} { + if !strings.Contains(b, want) { + t.Errorf("System page lacks %q", want) + } + } + // the other branch of the gate: no override → the plain sentence + _ = st.SetMinControllerVersion("c-full", "") + if b := getSystem(t, s); !strings.Contains(b, "No per-customer floors") { + t.Error("no overrides: the page must say every box follows the global floor") + } +} + +// R-530 on the page: the Agent cell shows box → vouched and is amber; red once the alarm's wait has passed. +func TestR530_SystemPageAgentCell(t *testing.T) { + s, st, svc := systemServer(t) // every box reports agent 0.142.0 + if err := st.SetArtifactManifest(store.ArtifactManifest{AgentVersion: "0.145.0", AgentSHA256: "x"}); err != nil { + t.Fatal(err) + } + if _, err := svc.Alarms(); err != nil { // starts the behind-clock for every box + t.Fatal(err) + } + b := getSystem(t, s) + if !strings.Contains(b, "0.142.0 → 0.145.0") || !strings.Contains(b, `class="c-warn" title="3 minor releases behind`) { + t.Fatalf("the Agent cell does not show the box behind the vouched agent") + } + _ = st.SetAgentBehindSince("full-1", time.Now().Add(-8*24*time.Hour)) + if b := getSystem(t, s); !strings.Contains(b, `class="c-bad" title="3 minor releases behind`) { + t.Fatal("past the alarm's wait the cell must be red") + } + // current branch + _ = st.SetArtifactManifest(store.ArtifactManifest{AgentVersion: "0.142.0", AgentSHA256: "x"}) + if b := getSystem(t, s); strings.Contains(b, "→ 0.142.0") || !strings.Contains(b, `title="current (vouched 0.142.0)"`) { + t.Fatal("a current box must read current, not behind") + } +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 4344c7c0..c75d8f72 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -100,6 +100,10 @@ type Server struct { // the CONTROLLER plane (customer/app config) via the long-poll wait channel. poke *poke.Notifier + // emit sends an operator event through the notification dispatcher (R-604, hub v0.135.0: the "a floor raise + // skipped boxes" mail). nil = log only. Wired in cmd/hub/main.go (SetEventEmitter). + emit func(customerID, eventType, severity, message, detailsJSON, source string) + sessions map[string]*hubSession sessionsMu sync.RWMutex @@ -372,6 +376,11 @@ func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifa return out } +// SetEventEmitter wires the notification dispatcher (R-604). INIT-ONLY. +func (s *Server) SetEventEmitter(f func(customerID, eventType, severity, message, detailsJSON, source string)) { + s.emit = f +} + // ServeHTTP routes web requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path @@ -865,13 +874,29 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`Felhom Hub — Bejelentkezés

Felhom Hub

`)) } -// validateCSRF checks the CSRF token for a session-based request. -// Returns true if CSRF is valid or if no session cookie is present (Basic Auth path). +// OperatorCLIHeader is the header a programmatic (Basic-auth, cookie-less) operator request must carry +// to change state (R-135, hub v0.135.0). Any non-empty value; the docs and scripts send "cli". +// +// WHY A HEADER. Browsers cache HTTP Basic credentials per origin and resend them on cross-site +// requests, and SameSite does not govern the Authorization header — so "Basic auth and no cookie" +// does NOT prove the request is programmatic. A page on another site can make the browser POST a +// form with the operator's cached Basic credentials; it cannot add a custom header (that needs a +// CORS preflight, which the hub never answers). So the header is the proof the old check assumed. +// Decided by CC — operator may reverse (`05` §8.1). Pinned by r135_csrf_test.go. +const OperatorCLIHeader = "X-Felhom-Operator" + +// validateCSRF checks a state-changing request (R-135). Two ways pass, nothing else: +// - a browser session: the hub_session cookie names a live session AND the form/header token matches it; +// - a programmatic operator call: NO session cookie, HTTP Basic credentials present (RequireAuth has +// already checked them) AND the OperatorCLIHeader is set. +// +// Before v0.135.0 a request with no session cookie passed unconditionally (measured live: a Basic-auth +// POST with no cookie reached the handler). func (s *Server) validateCSRF(r *http.Request) bool { cookie, err := r.Cookie("hub_session") if err != nil { - // No session cookie — likely Basic Auth or programmatic access; skip CSRF - return true + _, _, basic := r.BasicAuth() + return basic && strings.TrimSpace(r.Header.Get(OperatorCLIHeader)) != "" } s.sessionsMu.RLock() diff --git a/hub/internal/web/system.go b/hub/internal/web/system.go index 49a8214a..82f9398f 100644 --- a/hub/internal/web/system.go +++ b/hub/internal/web/system.go @@ -8,6 +8,8 @@ import ( "time" "gitea.dooplex.hu/admin/felhom-hub/internal/osupdates" + "gitea.dooplex.hu/admin/felhom-hub/internal/semver" + "gitea.dooplex.hu/admin/felhom-hub/internal/store" "gitea.dooplex.hu/admin/felhom-hub/internal/sysfacts" ) @@ -35,6 +37,7 @@ type systemRow struct { Held, RebootSince, KernelPanic, Oops cell CrashRestarts24h, Guard cell Bundle cell // R-840: the root-owned config bundle + Agent cell // R-530: the box's agent against the vouched one // guest GuestDebian, GuestRelease, GuestPending, GuestRestart cell // docker @@ -51,9 +54,62 @@ type OSSystemView interface { Candidates() []osupdates.Status Thresholds() (stale, reboot, notCovered time.Duration) BundleThreshold() time.Duration + AgentThreshold() time.Duration ApproveDocker() (string, error) } +// agentCell is the "Agent" cell (R-530, hub v0.135.0): the box's agent against the vouched one, how far behind and +// since when. Amber while behind; red from the alarm's wait on (the operator alarm fires then). An unreadable +// version is "unknown", never a guess; nothing vouched → the version alone. +func agentCell(boxAgent, vouched string, since time.Time, after time.Duration, now time.Time) cell { + if !semver.Valid(boxAgent) { + return unknownCell("") + } + c := cell{Text: boxAgent} + if !semver.Valid(vouched) { + c.Title = "no vouched agent to compare with" + return c + } + if semver.Compare(boxAgent, vouched) >= 0 { + c.Title = "current (vouched " + vouched + ")" + return c + } + c.Class = "warn" + c.Text = boxAgent + " → " + vouched + c.Title = osupdates.ReleasesBehind(boxAgent, vouched) + " — sign an agent_update for this box" + if !since.IsZero() { + c.Text += " (since " + since.UTC().Format("2006-01-02") + ")" + if now.Sub(since) >= after { + c.Class = "bad" + } + } + return c +} + +// floorRow is one line of the System page's "Version floors" table (R-604). +type floorRow struct { + CustomerID, CustomerName, Version string + Age cell + HeldBack bool // the override is BELOW the global floor: the global does not move this box +} + +func buildFloorRows(ovs []store.CustomerFloorOverride, global string, now time.Time) []floorRow { + var out []floorRow + for _, o := range ovs { + r := floorRow{CustomerID: o.CustomerID, CustomerName: o.CustomerName, Version: o.Version} + if o.SetAt.IsZero() { + r.Age = cell{Text: "unknown", Class: "warn", Title: "set before hub v0.135.0 — the hub did not record when"} + } else { + r.Age = plain(ago(o.SetAt, now) + " (" + o.SetAt.UTC().Format("2006-01-02") + ")") + } + if semver.Valid(global) && semver.Valid(o.Version) && semver.Compare(global, o.Version) > 0 { + r.HeldBack = true + } + out = append(out, r) + } + return out +} + func plain(s string) cell { return cell{Text: s} } // bundleCell is the "Root files" cell (R-840): the box's config bundle against the vouched agent's. Amber while behind, @@ -240,12 +296,26 @@ func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) { stale, reboot, notCov := view.Thresholds() rows := buildSystemRows(lines, facts, names, stale, reboot, notCov, time.Now()) man := s.store.GetArtifactManifest() + agents := map[string]string{} + for _, h := range hosts { + agents[h.HostID] = h.AgentVersion + } for i := range rows { rows[i].Bundle = bundleCell(facts[rows[i].HostID], man.AgentVersion, man.BundleSHA256, s.store.BundleBehindSince(rows[i].HostID), view.BundleThreshold(), time.Now()) + rows[i].Agent = agentCell(agents[rows[i].HostID], man.AgentVersion, + s.store.AgentBehindSince(rows[i].HostID), view.AgentThreshold(), time.Now()) + } + global := s.store.GetGlobalMinControllerVersion() + ovs, oerr := s.store.CustomerFloorOverrides() + if oerr != nil { + s.logger.Printf("[ERROR] system page: floor overrides: %v", oerr) } data := map[string]interface{}{ "Rows": rows, + "GlobalFloor": global, + "VouchedAgent": man.AgentVersion, + "Floors": buildFloorRows(ovs, global, time.Now()), "Releases": view.Releases(), "Cancelled": view.CancelledReleases(), "Candidates": view.Candidates(), diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 08bcfb17..df526b77 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -83,6 +83,14 @@ {{end}} + {{if .WaitingNoEmail}} +
+ No registered e-mail, and no box yet — the connect link and the setup code cannot reach + this household. Set an address on the Edit tab before you send the install guide (R-508); the link then + goes out by itself. +
+ {{end}} + {{if .OffsiteUnprovisioned}}
Offsite is enabled but was never provisioned — no descriptor exists for diff --git a/hub/internal/web/templates/system.html b/hub/internal/web/templates/system.html index 6aa20900..c8372eee 100644 --- a/hub/internal/web/templates/system.html +++ b/hub/internal/web/templates/system.html @@ -77,18 +77,38 @@ +
+

Version floors

+

Global controller floor: {{if .GlobalFloor}}{{.GlobalFloor}}{{else}}none{{end}} · vouched agent: {{if .VouchedAgent}}{{.VouchedAgent}}{{else}}none{{end}}

+ {{if .Floors}} + + + + {{range .Floors}} + + + + + + + {{end}} + +
CustomerOwn floorSetGlobal floor moves it?
{{.CustomerID}}{{if .CustomerName}}
{{.CustomerName}}{{end}}
{{.Version}}{{.Age.Text}}{{if .HeldBack}}NO — its own floor is lower and wins (R-604){{else}}no — its own floor applies (at or above the global){{end}}
+ {{else}}

No per-customer floors: every box follows the global floor.

{{end}} +
+ {{if .Rows}}
- + - + {{range .Rows}} @@ -114,7 +134,7 @@ {{template "sys_cell" .KernelRunning}}{{template "sys_cell" .KernelNextBoot}}{{template "sys_cell" .HostDebian}} {{template "sys_cell" .HostRelease}}{{template "sys_cell" .HostPending}}{{template "sys_cell" .HostNotCovered}} {{template "sys_cell" .Held}}{{template "sys_cell" .RebootSince}}{{template "sys_cell" .KernelPanic}}{{template "sys_cell" .Oops}} - {{template "sys_cell" .CrashRestarts24h}}{{template "sys_cell" .Guard}}{{template "sys_cell" .Bundle}} + {{template "sys_cell" .CrashRestarts24h}}{{template "sys_cell" .Guard}}{{template "sys_cell" .Bundle}}{{template "sys_cell" .Agent}} {{template "sys_cell" .GuestRelease}}{{template "sys_cell" .GuestPending}}{{template "sys_cell" .GuestRestart}} diff --git a/skills/felhom-build-deploy/SKILL.md b/skills/felhom-build-deploy/SKILL.md index b904cd4c..c661734c 100644 --- a/skills/felhom-build-deploy/SKILL.md +++ b/skills/felhom-build-deploy/SKILL.md @@ -129,7 +129,9 @@ curl -fsSL -o /tmp/rt.iso https://iso.felhom.eu/felhom-installer--pve. - **Verify focus by screendump before every `Enter`.** TUI: red-highlighted button, tab order. GTK: dashed focus ring, and `Enter` lands in text *fields*, not `Next`. Not checking once aborted an install. - **Proof installs register unclaimed appliances at the hub — discard them** or R-131 grows: - `curl -u ":$HUB_PW" -X POST http://:8080/appliances//discard` → 303. + `curl -u ":$HUB_PW" -H "X-Felhom-Operator: cli" -X POST http://:8080/appliances//discard` → 303. + **Every Basic-auth POST to the hub needs `-H "X-Felhom-Operator: cli"` since hub v0.135.0 (R-135)** — without it the + CSRF gate answers 403 (a browser on another site cannot add that header; that is the protection). > **Never pair `-w '%{redirect_url}'` with `-u` or `--netrc` (R-580).** curl rebuilds the request URL > for that variable **with the credentials in it**, so the hub password is printed even though it
BoxRing / updatesTunnelProxmoxKernel (running)Kernel (next boot)DebianFelhom releasePendingNot coveredHeldReboot neededkernel.panicOopsCrash restarts 24 hCrash guardRoot filesProxmoxKernel (running)Kernel (next boot)DebianFelhom releasePendingNot coveredHeldReboot neededkernel.panicOopsCrash restarts 24 hCrash guardRoot filesAgent Guest DebianFelhom releasePendingRestart needed Dockercontainerdlive-restoreDocker release Last OS leg
hostguestDocker engine
hostguestDocker engine
{{.GuestDebian.Text}}{{.Engine.Text}}