hub v0.135.0: CSRF on the Basic-auth path (R-135), console passwords sealed at rest (R-133), boxes left behind listed and alarmed (R-604, R-530), no-e-mail banner (R-508)
gates / gates (push) Successful in 29s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 11:12:56 +02:00
parent 9bb45eaaa2
commit 3d7a2761fc
24 changed files with 1148 additions and 17 deletions
@@ -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
@@ -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)
@@ -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
@@ -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).
@@ -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
+29
View File
@@ -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.
+8
View File
@@ -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 != "" {
+51
View File
@@ -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")
}
}
+4
View File
@@ -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,
@@ -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)
}
}
}
+79 -3
View File
@@ -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
}
+59 -4
View File
@@ -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
}
@@ -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)
}
}
+57 -2
View File
@@ -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).
+55
View File
@@ -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:
//
@@ -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)
}
}
+162
View File
@@ -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/<token> 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")
}
}
@@ -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)")
}
}
@@ -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: <strong>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")
}
}
+29 -4
View File
@@ -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(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0083D8;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
}
// 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()
+70
View File
@@ -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(),
@@ -83,6 +83,14 @@
</div>
{{end}}
{{if .WaitingNoEmail}}
<div class="flash flash-error">
<strong>No registered e-mail, and no box yet</strong> — 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.
</div>
{{end}}
{{if .OffsiteUnprovisioned}}
<div class="flash flash-warn">
<strong>Offsite is enabled but was never provisioned</strong> — no descriptor exists for
+23 -3
View File
@@ -77,18 +77,38 @@
</form>
</section>
<section class="card" id="version-floors">
<h3 style="margin-top: 0;">Version floors</h3>
<p>Global controller floor: <strong>{{if .GlobalFloor}}{{.GlobalFloor}}{{else}}none{{end}}</strong> · vouched agent: <strong>{{if .VouchedAgent}}{{.VouchedAgent}}{{else}}none{{end}}</strong></p>
{{if .Floors}}
<table class="data-table">
<thead><tr><th>Customer</th><th>Own floor</th><th>Set</th><th>Global floor moves it?</th></tr></thead>
<tbody>
{{range .Floors}}
<tr>
<td><a href="/customers/{{.CustomerID}}">{{.CustomerID}}</a>{{if .CustomerName}}<br><span class="text-muted">{{.CustomerName}}</span>{{end}}</td>
<td>{{.Version}}</td>
<td class="{{if .Age.Class}}c-{{.Age.Class}}{{end}}" title="{{.Age.Title}}">{{.Age.Text}}</td>
<td>{{if .HeldBack}}<span class="c-bad">NO — its own floor is lower and wins (R-604)</span>{{else}}no — its own floor applies (at or above the global){{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}<p class="text-muted">No per-customer floors: every box follows the global floor.</p>{{end}}
</section>
{{if .Rows}}
<section class="card" style="padding: 0; overflow-x: auto;">
<table class="data-table sys">
<thead>
<tr>
<th>Box</th><th>Ring / updates</th><th>Tunnel</th>
<th class="grp">Proxmox</th><th>Kernel (running)</th><th>Kernel (next boot)</th><th>Debian</th><th>Felhom release</th><th>Pending</th><th>Not covered</th><th>Held</th><th>Reboot needed</th><th>kernel.panic</th><th>Oops</th><th>Crash restarts 24 h</th><th>Crash guard</th><th title="The root-owned config bundle: sudoers, wrappers, units (R-840)">Root files</th>
<th class="grp">Proxmox</th><th>Kernel (running)</th><th>Kernel (next boot)</th><th>Debian</th><th>Felhom release</th><th>Pending</th><th>Not covered</th><th>Held</th><th>Reboot needed</th><th>kernel.panic</th><th>Oops</th><th>Crash restarts 24 h</th><th>Crash guard</th><th title="The root-owned config bundle: sudoers, wrappers, units (R-840)">Root files</th><th title="The box's agent against the vouched one (R-530). Agents update only by a per-box signed job.">Agent</th>
<th class="grp">Guest Debian</th><th>Felhom release</th><th>Pending</th><th>Restart needed</th>
<th class="grp">Docker</th><th>containerd</th><th>live-restore</th><th>Docker release</th>
<th class="grp">Last OS leg</th>
</tr>
<tr class="text-muted"><th></th><th></th><th></th><th class="grp" colspan="14">host</th><th class="grp" colspan="4">guest</th><th class="grp" colspan="4">Docker engine</th><th class="grp"></th></tr>
<tr class="text-muted"><th></th><th></th><th></th><th class="grp" colspan="15">host</th><th class="grp" colspan="4">guest</th><th class="grp" colspan="4">Docker engine</th><th class="grp"></th></tr>
</thead>
<tbody>
{{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}}
<td class="grp {{if .GuestDebian.Class}}c-{{.GuestDebian.Class}}{{end}}">{{.GuestDebian.Text}}</td>
{{template "sys_cell" .GuestRelease}}{{template "sys_cell" .GuestPending}}{{template "sys_cell" .GuestRestart}}
<td class="grp {{if .Engine.Class}}c-{{.Engine.Class}}{{end}}">{{.Engine.Text}}</td>
+3 -1
View File
@@ -129,7 +129,9 @@ curl -fsSL -o /tmp/rt.iso https://iso.felhom.eu/felhom-installer-<VER>-pve<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://<hub-clusterIP>:8080/appliances/<id>/discard` → 303.
`curl -u ":$HUB_PW" -H "X-Felhom-Operator: cli" -X POST http://<hub-clusterIP>:8080/appliances/<id>/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