hub v0.145.0: the kernel approval rule (operator ruling 2026-10-10, 09 §3 decision 195) + release entry for the security fix, R-922, MAIL-HOLD
gates / gates (push) Successful in 6m3s

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-10 09:27:54 +02:00
parent ca584d30b6
commit ca9408f153
6 changed files with 276 additions and 52 deletions
@@ -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
+5 -2
View File
@@ -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
@@ -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
+9 -2
View File
@@ -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
+127 -48
View File
@@ -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.
@@ -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")
}
}