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,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
}