From ca9408f1531cb747098f8cc57086177c05eca38e Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 10 Oct 2026 09:27:54 +0200 Subject: [PATCH] =?UTF-8?q?hub=20v0.145.0:=20the=20kernel=20approval=20rul?= =?UTF-8?q?e=20(operator=20ruling=202026-10-10,=2009=20=C2=A73=20decision?= =?UTF-8?q?=20195)=20+=20release=20entry=20for=20the=20security=20fix,=20R?= =?UTF-8?q?-922,=20MAIL-HOLD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- .../architecture/09-update-architecture.md | 12 ++ documentation/architecture/11-os-updates.md | 7 +- .../kernel-rule-red-proof.txt | 15 ++ hub/CHANGELOG.md | 11 +- hub/internal/osupdates/kernel.go | 175 +++++++++++++----- .../osupdates/kernel_approval_test.go | 108 +++++++++++ 6 files changed, 276 insertions(+), 52 deletions(-) create mode 100644 documentation/audits/release-2026-10-10/kernel-rule-red-proof.txt create mode 100644 hub/internal/osupdates/kernel_approval_test.go diff --git a/documentation/architecture/09-update-architecture.md b/documentation/architecture/09-update-architecture.md index eebaf876..2b03a228 100644 --- a/documentation/architecture/09-update-architecture.md +++ b/documentation/architecture/09-update-architecture.md @@ -915,6 +915,18 @@ its length, and both fixes cost something the household would notice — operato 127. **The agent's three by-design abilities (`03` §3.1) stay for now**; revisited before the first paying customer. *Operator ruling 2026-10-05.* (R-861) +### 2026-10-10 — operator ruling: the kernel approval rule (recorded before the work; the release brief) + +195. **„Approve kernel set" offers the NEWEST kernel version that EVERY ring-0 box has booted healthily at least once + after a night stage** (a box may have moved on to a newer kernel since). It no longer requires every ring-0 box's + newest step to be the SAME kernel at the same moment. Per box and per kernel only the newest ended step counts: a + kernel whose latest step fell back, self-reverted or failed is out of that box's set (an older healthy kernel stays + in). Everything else unchanged: the operator clicks; ring 1 takes it only through a signed `os_kernel_step`. + *Operator ruling 2026-10-10 (the reviewer's pick).* Why: kernels came out on three days in one week (demo-felhom + booted -22 then -23, demo-hp -22), so the old rule showed „the ring-0 boxes booted different kernels" and could + chase a moving kernel for ever. Hub v0.145.0, `internal/osupdates/kernel.go` `KernelStatus`, pinned by + `kernel_approval_test.go` (red-proved against the old rule). `11` §5.11. + ### 2026-10-08 (14:16) — the decision sheet D1–D10, every one as picked (recorded before the work) All ten designs: `audits/day-2026-10-08/`. *Operator ruling 2026-10-08 14:16 („all as picked").* Built the same day on diff --git a/documentation/architecture/11-os-updates.md b/documentation/architecture/11-os-updates.md index 99c0af79..31de7484 100644 --- a/documentation/architecture/11-os-updates.md +++ b/documentation/architecture/11-os-updates.md @@ -562,8 +562,11 @@ block says `tonight` only within 24 h of a mail the mail service ACCEPTED — ** `os_kernel_step` and `os_kernel_notice`; the household's timeline gets the usual "security fixes installed" line after `applied`. -**Approval** (hub): "Approve kernel set" on the System page appears when every ring-0 box's newest ended kernel step is -`applied` for the same kernel, after a NIGHT stage. The approved set is the series meta-package and the signed image. An +**Approval** (hub): "Approve kernel set" on the System page offers the **newest kernel version that every ring-0 box has +booted healthily at least once after a NIGHT stage** — a box may have moved on since (operator ruling 2026-10-10, `09` §3 +decision 195; hub v0.145.0). Per box and per kernel only the newest ended step counts: a kernel whose latest step fell +back, self-reverted or failed is out of that box's set. *(Before 2026-10-10: every ring-0 box's newest ended step had to +be `applied` for the SAME kernel.)* The approved set is the series meta-package and the signed image. An approval nudges no box: ring 1 takes it only through a signed `os_kernel_step` (stage), then its own told night. **The System page** shows per box: the running kernel, the next boot, the default (amber while a one-shot flag names diff --git a/documentation/audits/release-2026-10-10/kernel-rule-red-proof.txt b/documentation/audits/release-2026-10-10/kernel-rule-red-proof.txt new file mode 100644 index 00000000..c3044fcf --- /dev/null +++ b/documentation/audits/release-2026-10-10/kernel-rule-red-proof.txt @@ -0,0 +1,15 @@ +### RED: the pre-ruling KernelStatus, same tests +=== RUN TestKernelApproval_OffersNewestEveryBoxBooted + kernel_approval_test.go:52: n100 booted -22,-23 and hp booted -22: offered "WAITING: the ring-0 boxes booted different kernels (7.0.14-22-pve, 7.0.14-23-pve)", want 7.0.14-22-pve +--- FAIL: TestKernelApproval_OffersNewestEveryBoxBooted (0.04s) +=== RUN TestKernelApproval_BothBootedNewerOffersNewer +--- PASS: TestKernelApproval_BothBootedNewerOffersNewer (0.04s) +=== RUN TestKernelApproval_ABoxThatNeverBootedOneBlocks +--- PASS: TestKernelApproval_ABoxThatNeverBootedOneBlocks (0.04s) +=== RUN TestKernelApproval_AFailedLatestStepDropsThatKernelOnly + kernel_approval_test.go:89: -23 fell back on hp (its newest step): offered "WAITING: hp: the kernel step to 7.0.14-23-pve ended fell_back", want 7.0.14-22-pve +--- FAIL: TestKernelApproval_AFailedLatestStepDropsThatKernelOnly (0.04s) +=== RUN TestKernelApproval_ByDayBootDoesNotCount +--- PASS: TestKernelApproval_ByDayBootDoesNotCount (0.03s) +### GREEN: the ruling's KernelStatus +ok gitea.dooplex.hu/admin/felhom-hub/internal/osupdates 2.484s diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 886d984e..82212451 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,6 +1,13 @@ -## Unreleased — a household's cleared mail address is deleted (R-922) and a restored hub starts quiet (MAIL-HOLD) (2026-10-09) +## v0.145.0 — a SECURITY fix (one box's key acted for any household), R-922 (a household's clear deletes its address), MAIL-HOLD (a restored hub starts quiet), and the kernel approval rule (2026-10-10) -Not released: ships with the next hub release. No version bump here. +Released 2026-10-10 (operator present for the deploy). + +- **Kernel approval rule** (operator ruling 2026-10-10, `09` §3 decision 195, `11` §5.11): „Approve kernel set" offers + the NEWEST kernel every ring-0 box booted healthily at least once after a night stage — no longer the same kernel on + every box at the same moment (kernels on three days in one week kept demo-felhom on -23 and demo-hp on -22, and the + old rule waited for ever). Per box and kernel only the newest ended step counts (a kernel whose latest step fell back + is out; an older healthy one stays). `kernel_approval_test.go` (6 tests; red against the old rule: „booted different + kernels" where -22 was due). - **SECURITY — one box's key can no longer act for another household** (found 2026-10-09 by a security review of the R-922 change; fixed without a row). `POST /api/v1/preferences` and `POST /api/v1/notify` checked only that the key diff --git a/hub/internal/osupdates/kernel.go b/hub/internal/osupdates/kernel.go index 10d7e37b..7695955c 100644 --- a/hub/internal/osupdates/kernel.go +++ b/hub/internal/osupdates/kernel.go @@ -12,8 +12,9 @@ package osupdates // back in. Only a mail the mail service ACCEPTED is recorded. // - The box's os_update block carries `kernel: {kver, tonight}`; tonight is true only within 24 h of such a mail. No // mail → no step: the agent's night leg refuses a kernel without `tonight`. -// - The operator approves a kernel set ("Approve kernel set") once every ring-0 box booted it healthily as the -// default after a night step; a ring-1 box takes it only through a signed os_kernel_step. +// - The operator approves a kernel set ("Approve kernel set"): the NEWEST kernel every ring-0 box booted healthily at +// least once after a night step (operator ruling 2026-10-10; before: the same kernel as every box's current +// default); a ring-1 box takes it only through a signed os_kernel_step. // // Pinned by kernel_test.go. @@ -22,6 +23,7 @@ import ( "fmt" "regexp" "sort" + "strconv" "strings" "time" @@ -281,6 +283,95 @@ func (s *Service) kernelIngest(hostID, customerID string, r Report, details map[ } } +// healthyNightKernels reads one ring-0 box's kernel reports (newest first) and returns every kernel whose NEWEST ended +// step is a healthy "applied" one-shot boot that followed a night stage of the same kernel. why explains an empty set: +// the newest failed step, or a healthy boot without a night stage. +func healthyNightKernels(reps []store.OSReport) (map[string]bool, string) { + out := map[string]bool{} + decided := map[string]bool{} + why := "" + for _, r := range reps { + var kr Report + if json.Unmarshal([]byte(r.ReportJSON), &kr) != nil || !kernelEnded[r.Outcome] { + continue + } + k := kernelOf(kr) + if k == "" || decided[k] { + continue + } + decided[k] = true + if r.Outcome != "applied" || !r.Healthy { + if why == "" { + why = fmt.Sprintf("— the kernel step to %s ended %s", k, r.Outcome) + } + continue + } + night := false + for _, o := range reps { + var ok Report + if json.Unmarshal([]byte(o.ReportJSON), &ok) == nil && o.Outcome == "staged" && ok.Trigger == "night" && + kernelOf(ok) == k && !o.ReceivedAt.After(r.ReceivedAt) { + night = true + break + } + } + if !night { + if why == "" { + why = fmt.Sprintf("booted %s healthily, but not after a night step", k) + } + continue + } + out[k] = true + } + return out, why +} + +// kernelKey orders "7.0.14-22-pve" numerically: [7 0 14 22]. An unparsable version sorts first (never offered over a +// parsable one). +func kernelKey(k string) []int { + var n []int + for _, f := range strings.FieldsFunc(strings.TrimSuffix(k, "-pve"), func(r rune) bool { return r == '.' || r == '-' }) { + v, err := strconv.Atoi(f) + if err != nil { + return nil + } + n = append(n, v) + } + return n +} + +func kernelLess(a, b string) bool { + x, y := kernelKey(a), kernelKey(b) + for i := 0; i < len(x) && i < len(y); i++ { + if x[i] != y[i] { + return x[i] < y[i] + } + } + if len(x) != len(y) { + return len(x) < len(y) + } + return a < b +} + +func newestKernel(set map[string]bool) string { + best := "" + for k := range set { + if best == "" || kernelLess(best, k) { + best = k + } + } + return best +} + +func sortedKernels(set map[string]bool) []string { + var l []string + for k := range set { + l = append(l, k) + } + sort.Slice(l, func(i, j int) bool { return kernelLess(l[i], l[j]) }) + return l +} + // kernelSet is the approved set for a kernel version: the series meta-package and the signed image. func kernelSet(kver string) []Package { m := kverRE.FindStringSubmatch(kver) @@ -292,9 +383,12 @@ func kernelSet(kver string) []Package { {Name: "proxmox-kernel-" + kver + "-signed", Version: v, Origin: "Proxmox Debian Repository"}} } -// KernelStatus is the kernel set ring 0 booted and whether the operator's button may approve it: every ring-0 box's -// newest ended kernel step is "applied" (a healthy one-shot boot made it the default) for the SAME kernel, after a -// night stage; none fell back or reverted. +// KernelStatus is the kernel set ring 0 booted and whether the operator's button may approve it. Operator ruling +// 2026-10-10 (`09` §3, `11` §5.11): the button offers the NEWEST kernel version that EVERY ring-0 box has booted +// healthily at least once after a night stage — a box may have moved on to a newer kernel since. (Before: every box's +// newest ended step had to be "applied" for the SAME kernel at the same moment, which a kernel released on three days +// in one week could keep from ever happening.) Per box and per kernel, only the NEWEST ended step counts: a kernel +// whose latest step fell back, self-reverted or failed is not in that box's set. Pinned by kernel_approval_test.go. func (s *Service) KernelStatus() (Status, error) { st := Status{Layer: LayerKernel} ring0, err := s.ring0Hosts() @@ -302,52 +396,37 @@ func (s *Service) KernelStatus() (Status, error) { st.Waiting = "no ring-0 box" return st, err } - kver := "" + var common map[string]bool + var perBox []string for _, h := range ring0 { reps, err := s.Store.OSReportsDesc(h, LayerKernel, 100) if err != nil { return st, err } - var applied Report - var appliedAt time.Time - for _, r := range reps { - var kr Report - if json.Unmarshal([]byte(r.ReportJSON), &kr) != nil { - continue + booted, why := healthyNightKernels(reps) + if len(booted) == 0 { + if why == "" { + why = "has not booted a new kernel healthily yet" } - if kernelEnded[r.Outcome] { - if r.Outcome != "applied" || !r.Healthy { - st.Waiting = fmt.Sprintf("%s: the kernel step to %s ended %s", h, kernelOf(kr), r.Outcome) - return st, nil - } - applied, appliedAt = kr, r.ReceivedAt - break - } - } - k := kernelOf(applied) - if k == "" { - st.Waiting = h + " has not booted a new kernel healthily yet" + st.Waiting = h + " " + why return st, nil } - night := false - for _, r := range reps { - var kr Report - if json.Unmarshal([]byte(r.ReportJSON), &kr) == nil && r.Outcome == "staged" && kr.Trigger == "night" && - kernelOf(kr) == k && !r.ReceivedAt.After(appliedAt) { - night = true - break + perBox = append(perBox, h+": "+strings.Join(sortedKernels(booted), ", ")) + if common == nil { + common = booted + continue + } + for k := range common { + if !booted[k] { + delete(common, k) } } - if !night { - st.Waiting = fmt.Sprintf("%s booted %s healthily, but not after a night step", h, k) - return st, nil - } - if kver != "" && k != kver { - st.Waiting = fmt.Sprintf("the ring-0 boxes booted different kernels (%s, %s)", kver, k) - return st, nil - } - kver = k } + if len(common) == 0 { + st.Waiting = "no kernel every ring-0 box booted healthily (" + strings.Join(perBox, "; ") + ")" + return st, nil + } + kver := newestKernel(common) set := kernelSet(kver) c := map[string]Package{} for _, p := range set { @@ -392,14 +471,14 @@ func (s *Service) candidatePackages(fp string) string { // KernelLine is one box's kernel view for the System page. type KernelLine struct { - Due, Why string // the kernel it is due to step to, or why none - LastOutcome string - LastKernel string - LastAt time.Time - LastReason string - NoticeAt time.Time // the newest day-before mail (any kernel) - NoticeKver string - Tonight bool + Due, Why string // the kernel it is due to step to, or why none + LastOutcome string + LastKernel string + LastAt time.Time + LastReason string + NoticeAt time.Time // the newest day-before mail (any kernel) + NoticeKver string + Tonight bool } // KernelLineFor reads one box's kernel line. diff --git a/hub/internal/osupdates/kernel_approval_test.go b/hub/internal/osupdates/kernel_approval_test.go new file mode 100644 index 00000000..d3de4c7f --- /dev/null +++ b/hub/internal/osupdates/kernel_approval_test.go @@ -0,0 +1,108 @@ +package osupdates + +import ( + "encoding/json" + "strings" + "testing" +) + +// Operator ruling 2026-10-10 (`09` §3, `11` §5.11): "Approve kernel set" offers the NEWEST kernel version that EVERY +// ring-0 box booted healthily at least once after a night step — a box may have moved on since. Before, every box's +// newest step had to be the SAME kernel, and with kernels released on three days in one week (demo-felhom -22 then +// -23, demo-hp -22) the button showed „the ring-0 boxes booted different kernels" and could chase a moving kernel for +// ever. COMPANION RED-PROOF (observed): the pre-ruling KernelStatus fails the -22/-23 cases below. + +func kstep(outcome, trigger string, healthy bool, from, to string) Report { + return Report{Layer: LayerKernel, Trigger: trigger, Mode: "apply", Outcome: outcome, Healthy: healthy, ReleaseID: to, + Kernel: json.RawMessage(`{"from":"` + from + `","to":"` + to + `"}`)} +} + +const k20, k22, k23 = "7.0.14-20-pve", "7.0.14-22-pve", "7.0.14-23-pve" + +func (f *fix) booted(t *testing.T, host, from, to string) { + t.Helper() + f.ingest(t, host, kstep("staged", "night", true, from, to)) + f.ingest(t, host, kstep("applied", "boot", true, from, to)) +} + +func offered(t *testing.T, f *fix) string { + t.Helper() + st, err := f.s.KernelStatus() + if err != nil { + t.Fatal(err) + } + if st.Waiting != "" || st.Fingerprint == "" { + return "WAITING: " + st.Waiting + } + pj := f.s.candidatePackages(st.Fingerprint) + for _, k := range []string{k20, k22, k23} { + if strings.Contains(pj, "proxmox-kernel-"+k+"-signed") { + return k + } + } + return "UNKNOWN: " + pj +} + +func TestKernelApproval_OffersNewestEveryBoxBooted(t *testing.T) { + f := newFix(t) + f.booted(t, "n100", k20, k22) + f.booted(t, "n100", k22, k23) // moved on + f.booted(t, "hp", k20, k22) + if got := offered(t, f); got != k22 { + t.Fatalf("n100 booted -22,-23 and hp booted -22: offered %q, want %s", got, k22) + } +} + +func TestKernelApproval_BothBootedNewerOffersNewer(t *testing.T) { + f := newFix(t) + f.booted(t, "n100", k20, k22) + f.booted(t, "n100", k22, k23) + f.booted(t, "hp", k20, k22) + f.booted(t, "hp", k22, k23) + if got := offered(t, f); got != k23 { + t.Fatalf("both booted -23: offered %q, want %s", got, k23) + } +} + +func TestKernelApproval_ABoxThatNeverBootedOneBlocks(t *testing.T) { + f := newFix(t) + f.booted(t, "n100", k20, k22) + got := offered(t, f) + if !strings.HasPrefix(got, "WAITING: hp ") { + t.Fatalf("hp never booted a new kernel: offered %q, want waiting", got) + } + if _, err := f.s.ApproveKernel(); err == nil { + t.Fatal("approved while one ring-0 box never booted a new kernel") + } +} + +// A kernel whose NEWEST step on a box failed is not in that box's set; an older healthy kernel still is. +func TestKernelApproval_AFailedLatestStepDropsThatKernelOnly(t *testing.T) { + f := newFix(t) + f.booted(t, "n100", k20, k22) + f.booted(t, "n100", k22, k23) + f.booted(t, "hp", k20, k22) + f.booted(t, "hp", k22, k23) + f.ingest(t, "hp", kstep("staged", "night", true, k22, k23)) + f.ingest(t, "hp", kstep("fell_back", "boot", false, k22, k23)) + if got := offered(t, f); got != k22 { + t.Fatalf("-23 fell back on hp (its newest step): offered %q, want %s", got, k22) + } +} + +// A healthy boot without a night stage does not count (the night rule is unchanged). +func TestKernelApproval_ByDayBootDoesNotCount(t *testing.T) { + f := newFix(t) + f.booted(t, "n100", k20, k22) + f.ingest(t, "hp", kstep("staged", "signed", true, k20, k22)) + f.ingest(t, "hp", kstep("applied", "boot", true, k20, k22)) + if got := offered(t, f); !strings.Contains(got, "not after a night step") { + t.Fatalf("offered %q, want waiting on the night step", got) + } +} + +func TestKernelKeyOrdersNumerically(t *testing.T) { + if !kernelLess("7.0.14-9-pve", "7.0.14-22-pve") || !kernelLess("7.0.14-22-pve", "7.1.0-1-pve") || kernelLess(k23, k22) { + t.Fatal("kernel versions must order numerically, not as text") + } +}