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
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:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user