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,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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user