From 700b37890bfda5be810501a78c6e1d54beed99b9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 17:30:17 +0100 Subject: [PATCH 1/9] node-uplink links answers each link's mac, master and parent (S1a links-mac, issue 1965) Both holders' uplink-tools copies read address, master, link and link_index/link_netnsid from the existing ip -j address show call. Fields are left out where a link has none (L3); a parent in another namespace is {index, namespace} (L2); the form lives in uplink.go only (L4); only the tool description changes, not seat.json (L5); bridge fdb MACs are not answered (L6); names mac, master, parent (L1). Builds: design 48 10, design 08 code:, issue 1965 note 11 L1-L6 --- .../networkmanager/cmd/uplink-tools/main.go | 2 +- .../networkmanager/cmd/uplink-tools/uplink.go | 25 ++++++++++++++++++- .../cmd/uplink-tools/uplink_test.go | 18 ++++++++++--- .../systemd-networkd/cmd/uplink-tools/main.go | 2 +- .../cmd/uplink-tools/uplink.go | 25 ++++++++++++++++++- .../cmd/uplink-tools/uplink_test.go | 18 ++++++++++--- 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/modules/networkmanager/cmd/uplink-tools/main.go b/modules/networkmanager/cmd/uplink-tools/main.go index d2aed1d5d..116251870 100644 --- a/modules/networkmanager/cmd/uplink-tools/main.go +++ b/modules/networkmanager/cmd/uplink-tools/main.go @@ -19,7 +19,7 @@ func tools(m Machine) []stdio.Tool { "— its header, a backup a VPN client left beside it, a link in its place, a known writer running. (r)", Run: func(map[string]any) (any, error) { return m.ReadResolvers() }}, {Name: seat + ".links", - Description: "Every network link on the machine: its state, its addresses, whether the default route " + + Description: "Every network link on the machine: its state, its addresses, its mac, the bridge or bond it is enslaved to (master), the link it rides on (parent: its name, or an index and namespace when that link is in another network namespace), whether the default route " + "leaves through it, and the resolvers and search domains the network manager knows for it — a VPN's " + "tunnel included. (r)", Run: func(map[string]any) (any, error) { return m.Links(context.Background()) }}, diff --git a/modules/networkmanager/cmd/uplink-tools/uplink.go b/modules/networkmanager/cmd/uplink-tools/uplink.go index d5e85ed5f..25d91e4d4 100644 --- a/modules/networkmanager/cmd/uplink-tools/uplink.go +++ b/modules/networkmanager/cmd/uplink-tools/uplink.go @@ -221,12 +221,24 @@ type Link struct { State string `json:"state"` Kind string `json:"kind,omitempty"` Addresses []string `json:"addresses,omitempty"` + // MAC is the link-layer address as ip says it (lo's all-zero included); left out where the link has + // none. Master is the bridge or bond the link is enslaved to. Parent is the link it rides on: its + // name where that link is in this machine's namespace, else a ParentPeer; both left out where none. + MAC string `json:"mac,omitempty"` + Master string `json:"master,omitempty"` + Parent any `json:"parent,omitempty"` // Default says the default route leaves through it, and Metric that route's metric. Default bool `json:"default_route,omitempty"` Metric *int `json:"metric,omitempty"` DNS *LinkDNS `json:"dns,omitempty"` } +// ParentPeer is a parent in another network namespace, which has no name here: ip's index and namespace id. +type ParentPeer struct { + Index int `json:"index"` + Namespace int `json:"namespace"` +} + // Links answers the links verb: every link from the kernel, its default route, and what the manager // knows of its names. A manager that cannot be asked is said, never read as no resolvers. func (m Machine) Links(ctx context.Context) (map[string]any, error) { @@ -238,6 +250,11 @@ func (m Machine) Links(ctx context.Context) (map[string]any, error) { Name string `json:"ifname"` State string `json:"operstate"` Kind string `json:"link_type"` + MAC string `json:"address"` + Master string `json:"master"` + Link string `json:"link"` + LinkIdx *int `json:"link_index"` + NetNSID *int `json:"link_netnsid"` AddrInfo []struct { Local string `json:"local"` Prefix int `json:"prefixlen"` @@ -276,7 +293,13 @@ func (m Machine) Links(ctx context.Context) (map[string]any, error) { } links := []Link{} for _, a := range addrs { - l := Link{Name: a.Name, State: a.State, Kind: a.Kind} + l := Link{Name: a.Name, State: a.State, Kind: a.Kind, MAC: a.MAC, Master: a.Master} + switch { + case a.Link != "": + l.Parent = a.Link + case a.LinkIdx != nil && a.NetNSID != nil: + l.Parent = ParentPeer{Index: *a.LinkIdx, Namespace: *a.NetNSID} + } for _, ai := range a.AddrInfo { l.Addresses = append(l.Addresses, a2s(ai.Local, ai.Prefix)) } diff --git a/modules/networkmanager/cmd/uplink-tools/uplink_test.go b/modules/networkmanager/cmd/uplink-tools/uplink_test.go index 376e3d874..1f0449800 100644 --- a/modules/networkmanager/cmd/uplink-tools/uplink_test.go +++ b/modules/networkmanager/cmd/uplink-tools/uplink_test.go @@ -103,8 +103,10 @@ func TestTheLinksCarryTheirDefaultRouteAndTheirNames(t *testing.T) { joined := strings.Join(args, " ") switch { case joined == "-j address show": - return `[{"ifname":"lo","operstate":"UNKNOWN","link_type":"loopback","addr_info":[{"local":"127.0.0.1","prefixlen":8}]}, -{"ifname":"wlan0","operstate":"UP","link_type":"ether","addr_info":[{"local":"192.168.1.20","prefixlen":24}]}, + return `[{"ifname":"lo","operstate":"UNKNOWN","link_type":"loopback","address":"00:00:00:00:00:00","addr_info":[{"local":"127.0.0.1","prefixlen":8}]}, +{"ifname":"wlan0","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:01","addr_info":[{"local":"192.168.1.20","prefixlen":24}]}, +{"ifname":"veth1","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:02","master":"br-1","link":"veth2","addr_info":[]}, +{"ifname":"veth3","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:03","master":"br-1","link_index":7,"link_netnsid":2,"addr_info":[]}, {"ifname":"vpn0","operstate":"UNKNOWN","link_type":"none","addr_info":[{"local":"172.16.9.9","prefixlen":32}]}]`, nil case joined == "-j -4 route show default": return `[{"dst":"default","dev":"wlan0","metric":600}]`, nil @@ -122,10 +124,20 @@ func TestTheLinksCarryTheirDefaultRouteAndTheirNames(t *testing.T) { t.Fatal(err) } links := answer["links"].([]Link) - if len(links) != 3 || links[0].Name != "wlan0" || !links[0].Default || *links[0].Metric != 600 || + if len(links) != 5 || links[0].Name != "wlan0" || !links[0].Default || *links[0].Metric != 600 || links[0].DNS == nil || links[0].DNS.Servers[0] != "192.168.1.1" { t.Fatalf("the links are %+v", links) } + byName := map[string]Link{} + for _, l := range links { + byName[l.Name] = l + } + if byName["wlan0"].MAC != "aa:bb:cc:00:00:01" || byName["lo"].MAC != "00:00:00:00:00:00" || + byName["veth1"].Master != "br-1" || byName["veth1"].Parent != "veth2" || + byName["veth3"].Parent != (ParentPeer{Index: 7, Namespace: 2}) || + byName["vpn0"].MAC != "" || byName["vpn0"].Master != "" || byName["vpn0"].Parent != nil { + t.Fatalf("mac, master and parent are %+v", byName) + } for _, l := range links[1:] { if l.Default || (l.Name == "vpn0" && l.DNS != nil) { t.Fatalf("%+v", l) diff --git a/modules/systemd-networkd/cmd/uplink-tools/main.go b/modules/systemd-networkd/cmd/uplink-tools/main.go index d2aed1d5d..116251870 100644 --- a/modules/systemd-networkd/cmd/uplink-tools/main.go +++ b/modules/systemd-networkd/cmd/uplink-tools/main.go @@ -19,7 +19,7 @@ func tools(m Machine) []stdio.Tool { "— its header, a backup a VPN client left beside it, a link in its place, a known writer running. (r)", Run: func(map[string]any) (any, error) { return m.ReadResolvers() }}, {Name: seat + ".links", - Description: "Every network link on the machine: its state, its addresses, whether the default route " + + Description: "Every network link on the machine: its state, its addresses, its mac, the bridge or bond it is enslaved to (master), the link it rides on (parent: its name, or an index and namespace when that link is in another network namespace), whether the default route " + "leaves through it, and the resolvers and search domains the network manager knows for it — a VPN's " + "tunnel included. (r)", Run: func(map[string]any) (any, error) { return m.Links(context.Background()) }}, diff --git a/modules/systemd-networkd/cmd/uplink-tools/uplink.go b/modules/systemd-networkd/cmd/uplink-tools/uplink.go index d5e85ed5f..25d91e4d4 100644 --- a/modules/systemd-networkd/cmd/uplink-tools/uplink.go +++ b/modules/systemd-networkd/cmd/uplink-tools/uplink.go @@ -221,12 +221,24 @@ type Link struct { State string `json:"state"` Kind string `json:"kind,omitempty"` Addresses []string `json:"addresses,omitempty"` + // MAC is the link-layer address as ip says it (lo's all-zero included); left out where the link has + // none. Master is the bridge or bond the link is enslaved to. Parent is the link it rides on: its + // name where that link is in this machine's namespace, else a ParentPeer; both left out where none. + MAC string `json:"mac,omitempty"` + Master string `json:"master,omitempty"` + Parent any `json:"parent,omitempty"` // Default says the default route leaves through it, and Metric that route's metric. Default bool `json:"default_route,omitempty"` Metric *int `json:"metric,omitempty"` DNS *LinkDNS `json:"dns,omitempty"` } +// ParentPeer is a parent in another network namespace, which has no name here: ip's index and namespace id. +type ParentPeer struct { + Index int `json:"index"` + Namespace int `json:"namespace"` +} + // Links answers the links verb: every link from the kernel, its default route, and what the manager // knows of its names. A manager that cannot be asked is said, never read as no resolvers. func (m Machine) Links(ctx context.Context) (map[string]any, error) { @@ -238,6 +250,11 @@ func (m Machine) Links(ctx context.Context) (map[string]any, error) { Name string `json:"ifname"` State string `json:"operstate"` Kind string `json:"link_type"` + MAC string `json:"address"` + Master string `json:"master"` + Link string `json:"link"` + LinkIdx *int `json:"link_index"` + NetNSID *int `json:"link_netnsid"` AddrInfo []struct { Local string `json:"local"` Prefix int `json:"prefixlen"` @@ -276,7 +293,13 @@ func (m Machine) Links(ctx context.Context) (map[string]any, error) { } links := []Link{} for _, a := range addrs { - l := Link{Name: a.Name, State: a.State, Kind: a.Kind} + l := Link{Name: a.Name, State: a.State, Kind: a.Kind, MAC: a.MAC, Master: a.Master} + switch { + case a.Link != "": + l.Parent = a.Link + case a.LinkIdx != nil && a.NetNSID != nil: + l.Parent = ParentPeer{Index: *a.LinkIdx, Namespace: *a.NetNSID} + } for _, ai := range a.AddrInfo { l.Addresses = append(l.Addresses, a2s(ai.Local, ai.Prefix)) } diff --git a/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go b/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go index 376e3d874..1f0449800 100644 --- a/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go +++ b/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go @@ -103,8 +103,10 @@ func TestTheLinksCarryTheirDefaultRouteAndTheirNames(t *testing.T) { joined := strings.Join(args, " ") switch { case joined == "-j address show": - return `[{"ifname":"lo","operstate":"UNKNOWN","link_type":"loopback","addr_info":[{"local":"127.0.0.1","prefixlen":8}]}, -{"ifname":"wlan0","operstate":"UP","link_type":"ether","addr_info":[{"local":"192.168.1.20","prefixlen":24}]}, + return `[{"ifname":"lo","operstate":"UNKNOWN","link_type":"loopback","address":"00:00:00:00:00:00","addr_info":[{"local":"127.0.0.1","prefixlen":8}]}, +{"ifname":"wlan0","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:01","addr_info":[{"local":"192.168.1.20","prefixlen":24}]}, +{"ifname":"veth1","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:02","master":"br-1","link":"veth2","addr_info":[]}, +{"ifname":"veth3","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:03","master":"br-1","link_index":7,"link_netnsid":2,"addr_info":[]}, {"ifname":"vpn0","operstate":"UNKNOWN","link_type":"none","addr_info":[{"local":"172.16.9.9","prefixlen":32}]}]`, nil case joined == "-j -4 route show default": return `[{"dst":"default","dev":"wlan0","metric":600}]`, nil @@ -122,10 +124,20 @@ func TestTheLinksCarryTheirDefaultRouteAndTheirNames(t *testing.T) { t.Fatal(err) } links := answer["links"].([]Link) - if len(links) != 3 || links[0].Name != "wlan0" || !links[0].Default || *links[0].Metric != 600 || + if len(links) != 5 || links[0].Name != "wlan0" || !links[0].Default || *links[0].Metric != 600 || links[0].DNS == nil || links[0].DNS.Servers[0] != "192.168.1.1" { t.Fatalf("the links are %+v", links) } + byName := map[string]Link{} + for _, l := range links { + byName[l.Name] = l + } + if byName["wlan0"].MAC != "aa:bb:cc:00:00:01" || byName["lo"].MAC != "00:00:00:00:00:00" || + byName["veth1"].Master != "br-1" || byName["veth1"].Parent != "veth2" || + byName["veth3"].Parent != (ParentPeer{Index: 7, Namespace: 2}) || + byName["vpn0"].MAC != "" || byName["vpn0"].Master != "" || byName["vpn0"].Parent != nil { + t.Fatalf("mac, master and parent are %+v", byName) + } for _, l := range links[1:] { if l.Default || (l.Name == "vpn0" && l.DNS != nil) { t.Fatalf("%+v", l) From 52572698cb9d9df1453e74cd03885483e2c30af3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 17:34:00 +0100 Subject: [PATCH 2/9] mesh-vault: secret_holders lists every record of the vault's store by name and fingerprint (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1 vault-licence-secrets-listed. secret_holders answers `records` and `recordsCount` beside holders, count and enrolments: each store record's name, generation, since, changed, changedBy/changedVia, origin, length and the vault's own fingerprint of the whole value, plus key and part for licence... A record the vault cannot open stays listed with an error. Never a value. Builds: design 39 bus-and-switch §20, §15; design 39 parts §12 (D1); design 24 Every secret lives in the vault, Beyond generate and hold; as-is 06 The vault, as it runs. Lead answers (issue 1409 note 11): V1, V-thorough, V2-V8. V3 drops the map's tokenFingerprint and AC5; V-thorough widens the licence.-prefix list to every record. --- modules/mesh-vault/README.md | 5 +- modules/mesh-vault/cmd/mesh-vault/licences.go | 46 ++++++++++++++++ .../cmd/mesh-vault/licences_test.go | 54 +++++++++++++++++++ modules/mesh-vault/cmd/mesh-vault/tools.go | 23 ++++++-- 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/modules/mesh-vault/README.md b/modules/mesh-vault/README.md index 27af4c7a5..cd22dca7f 100644 --- a/modules/mesh-vault/README.md +++ b/modules/mesh-vault/README.md @@ -257,7 +257,10 @@ runner's `ledgers` are never retired this way. A waiter on `rotated` or `own-rep `secret_export`, `secret_holders`, `secret_verify` answer as the TypeScript vault's did, and its custody ledger (`${dir:ledger}`, the same JSON files) is kept by a pass over the contributions file every minute. -`secret_handed` and `secret_vault_export` read the vault's own store. None returns a value. +`secret_handed` and `secret_vault_export` read the vault's own store. None returns a value. `secret_holders` also answers +`records`: every record the store keeps (a licence's two secrets included), by name, generation, when stored and +changed, who changed it, origin, length and fingerprint, never the value; a record the vault cannot open stays in the +list with an `error`. **The ledger follows ADR 0230.** A contributions file that does not say who is asked for (unreadable, not JSON, no `given` list or a null one, or for another requirement) is not "nobody": the pass records nothing, diff --git a/modules/mesh-vault/cmd/mesh-vault/licences.go b/modules/mesh-vault/cmd/mesh-vault/licences.go index 6eee48ee2..0079f069f 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences.go @@ -10,6 +10,8 @@ import ( "errors" "fmt" "regexp" + "strings" + "time" ) // LicencePutVerb is the verb that hands a licence's secret to the vault. @@ -78,3 +80,47 @@ func (v *Vault) LicencePutTake(caller, call, sealed string) (Changed, error) { } return v.keepWith(c.secret, string(value), caller, "", OriginAccepted, keepOptions{generation: c.generation}) } + +// RecordHeld is one record of the vault's store as secret_holders lists it (issue 1409, design 39 parts §12): by name +// and fingerprint, never the value. For a licence's secret it also says the licence's key and the part. +type RecordHeld struct { + Name string `json:"name"` + Key string `json:"key,omitempty"` + Part string `json:"part,omitempty"` + Generation int `json:"generation"` + Since time.Time `json:"since"` + Changed time.Time `json:"changed"` + ChangedBy string `json:"changedBy"` + ChangedVia string `json:"changedVia,omitempty"` + Origin string `json:"origin"` + Length int `json:"length"` + Fingerprint string `json:"fingerprint"` + // Error says why the vault could not open its copy; the record stays in the list. + Error string `json:"error,omitempty"` +} + +// RecordsHeld lists every record the store keeps, by name, ordered as the store lists them. The fingerprint is the +// vault's own of the whole value, as the ledger gives it. +func (v *Vault) RecordsHeld() ([]RecordHeld, error) { + records, err := v.Store.List() + if err != nil { + return nil, err + } + out := make([]RecordHeld, 0, len(records)) + for _, r := range records { + row := RecordHeld{Name: r.Name, Generation: r.Generation, Since: r.MadeAt, Changed: r.ChangedAt, + ChangedBy: r.ChangedBy, ChangedVia: r.ChangedVia, Origin: r.Origin} + if rest, ok := strings.CutPrefix(r.Name, "licence."); ok { + if i := strings.LastIndex(rest, "."); i > 0 { + row.Key, row.Part = rest[:i], rest[i+1:] + } + } + if value, err := openAtRest(r, v); err != nil { + row.Error = "the vault could not open its copy: " + err.Error() + } else { + row.Length, row.Fingerprint = len(value), Fingerprint(value) + } + out = append(out, row) + } + return out, nil +} diff --git a/modules/mesh-vault/cmd/mesh-vault/licences_test.go b/modules/mesh-vault/cmd/mesh-vault/licences_test.go index 02f8c3357..c0085b49c 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences_test.go @@ -1,8 +1,11 @@ package main import ( + "encoding/json" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/seal" ) // putLicence hands one part of a licence to the vault in licence-put's two steps. @@ -114,3 +117,54 @@ func TestATokenIsGivenOnlyToAMachineABindingNames(t *testing.T) { t.Fatalf("the manager's grant: %q %v", got, err) } } + +// issue 1409, design 39 bus-and-switch §20, parts §12: secret_holders lists every record of the store by name and +// fingerprint, a licence's two secrets with their key and part, a record it cannot open with an error, and no value. +func TestSecretHoldersListsEveryStoreRecordByNameAndFingerprintNeverTheValue(t *testing.T) { + v := newVault(t, nil) + const manager = "claude-licence-manager" + grant, access := `{"refreshToken":"rt-SECRET-grant"}`, `{"accessToken":"at-SECRET-access"}` + for part, value := range map[string]string{LicenceGrant: grant, LicenceAccess: access} { + if _, err := putLicence(t, v, manager, "jane_at_example.org", part, 3, value); err != nil { + t.Fatal(err) + } + } + if _, err := v.Put("pair/postgres/gitea", "other-SECRET-value", "the test"); err != nil { + t.Fatal(err) + } + broken, _, _ := v.Store.Read("pair/postgres/gitea") + broken.Name, broken.AtRest = "pair/broken/one", seal.Box{} + if err := v.Store.Write(broken); err != nil { + t.Fatal(err) + } + + got, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_holders").Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(got) + if strings.Contains(string(raw), "SECRET") { + t.Fatalf("the answer holds a value: %s", raw) + } + m := got.(map[string]any) + rows := map[string]RecordHeld{} + for _, r := range m["records"].([]RecordHeld) { + rows[r.Name] = r + } + if m["recordsCount"] != 4 || len(rows) != 4 { + t.Fatalf("records %v, want 4 (two licence secrets, a pair, a broken one)", rows) + } + for part, value := range map[string]string{LicenceGrant: grant, LicenceAccess: access} { + r := rows["licence.jane_at_example.org."+part] + if r.Key != "jane_at_example.org" || r.Part != part || r.Generation != 3 || r.ChangedBy != manager || + r.Origin != OriginAccepted || r.Length != len(value) || r.Fingerprint != Fingerprint(value) || r.Error != "" { + t.Errorf("the %s secret's row is %+v", part, r) + } + } + if r := rows["pair/postgres/gitea"]; r.Key != "" || r.Fingerprint != Fingerprint("other-SECRET-value") { + t.Errorf("the pair's row is %+v", r) + } + if r := rows["pair/broken/one"]; r.Error == "" || r.Fingerprint != "" { + t.Errorf("a record the vault cannot open must stay listed with an error: %+v", r) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 5e23494bf..ad424f8fa 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -380,15 +380,32 @@ func definitions(env *Env) []toolDef { {Name: "secret_holders", Description: "Who holds a secret from this vault: each consumer's login, node and module, when it was " + "granted, how many times it has been rotated and when, and the fingerprint of the current value. " + + "Also every record the vault's own store keeps (`records`, with `recordsCount`): its name, generation, when " + + "it was stored and last changed, who changed it, its origin, its length and its fingerprint, and for a " + + "licence's secret (licence..grant, licence..access) the key and the part. " + "Fingerprints only — the value is never returned.", run: func(string, map[string]any) (any, error) { enrolments := []EnrolmentHeld{} + records := []RecordHeld{} + recordsError := "" if env.Vault != nil { enrolments, _ = env.Vault.EnrolmentsHeld() + var err error + if records, err = env.Vault.RecordsHeld(); err != nil { + recordsError = "the vault could not list its store: " + err.Error() + records = []RecordHeld{} + } + } + stored := func(m map[string]any) map[string]any { + m["records"], m["recordsCount"] = records, len(records) + if recordsError != "" { + m["recordsError"] = recordsError + } + return m } if env.Ledger == nil { - return map[string]any{"holders": []any{}, "count": 0, "enrolments": enrolments, - "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}, nil + return stored(map[string]any{"holders": []any{}, "count": 0, "enrolments": enrolments, + "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}), nil } var asked []Contribution if env.Receives != "" { @@ -407,7 +424,7 @@ func definitions(env *Env) []toolDef { } holders = append(holders, row) } - return map[string]any{"holders": holders, "count": len(holders), "enrolments": enrolments}, nil + return stored(map[string]any{"holders": holders, "count": len(holders), "enrolments": enrolments}), nil }}, {Name: "secret_verify", Description: "Check one holder's secret without seeing it: the fingerprint the vault recorded against the " + From 45b0779b6801ecbc6997e4ebb50f1e3bb4196988 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 17:41:39 +0100 Subject: [PATCH 3/9] mesh-vault: a store file that is not a record is listed by name with its error (issue 1409, V10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store.ListReadable lists every record and, beside them, each file it cannot read by name with its error; RecordsHeld lists those as rows with an error and goes on. recordsError stays for a store that cannot be listed at all. Store.List and ListPrefix keep their behaviour for their other callers. Builds: design 39 bus-and-switch §20; design 39 parts §12. Lead answers (issue 1409): V9 (keep recordsCount and recordsError), V10. --- modules/mesh-vault/cmd/mesh-vault/licences.go | 7 +++- .../cmd/mesh-vault/licences_test.go | 15 ++++++-- modules/mesh-vault/cmd/mesh-vault/store.go | 36 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/modules/mesh-vault/cmd/mesh-vault/licences.go b/modules/mesh-vault/cmd/mesh-vault/licences.go index 0079f069f..77662e793 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "regexp" + "sort" "strings" "time" ) @@ -102,7 +103,7 @@ type RecordHeld struct { // RecordsHeld lists every record the store keeps, by name, ordered as the store lists them. The fingerprint is the // vault's own of the whole value, as the ledger gives it. func (v *Vault) RecordsHeld() ([]RecordHeld, error) { - records, err := v.Store.List() + records, bad, err := v.Store.ListReadable() if err != nil { return nil, err } @@ -122,5 +123,9 @@ func (v *Vault) RecordsHeld() ([]RecordHeld, error) { } out = append(out, row) } + for _, b := range bad { + out = append(out, RecordHeld{Name: b.Name, Error: "the vault could not read the record's file: " + b.Err.Error()}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, nil } diff --git a/modules/mesh-vault/cmd/mesh-vault/licences_test.go b/modules/mesh-vault/cmd/mesh-vault/licences_test.go index c0085b49c..6368f4572 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "os" "strings" "testing" @@ -138,6 +139,10 @@ func TestSecretHoldersListsEveryStoreRecordByNameAndFingerprintNeverTheValue(t * t.Fatal(err) } + if err := os.WriteFile(v.Store.pathOf("pair/notjson/one"), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + got, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_holders").Run(map[string]any{}) if err != nil { t.Fatal(err) @@ -151,8 +156,8 @@ func TestSecretHoldersListsEveryStoreRecordByNameAndFingerprintNeverTheValue(t * for _, r := range m["records"].([]RecordHeld) { rows[r.Name] = r } - if m["recordsCount"] != 4 || len(rows) != 4 { - t.Fatalf("records %v, want 4 (two licence secrets, a pair, a broken one)", rows) + if m["recordsCount"] != 5 || len(rows) != 5 { + t.Fatalf("records %v, want 5 (two licence secrets, a pair, a sealed-broken one, a file that is not JSON)", rows) } for part, value := range map[string]string{LicenceGrant: grant, LicenceAccess: access} { r := rows["licence.jane_at_example.org."+part] @@ -167,4 +172,10 @@ func TestSecretHoldersListsEveryStoreRecordByNameAndFingerprintNeverTheValue(t * if r := rows["pair/broken/one"]; r.Error == "" || r.Fingerprint != "" { t.Errorf("a record the vault cannot open must stay listed with an error: %+v", r) } + if r := rows["pair/notjson/one"]; r.Error == "" || r.Fingerprint != "" { + t.Errorf("a file that is not JSON must be listed by name with its error, the listing going on (V10): %+v", r) + } + if _, bad := m["recordsError"]; bad { + t.Errorf("recordsError is for a store that cannot be listed at all (V10): %v", m["recordsError"]) + } } diff --git a/modules/mesh-vault/cmd/mesh-vault/store.go b/modules/mesh-vault/cmd/mesh-vault/store.go index b7ec8b287..fd4b97f96 100644 --- a/modules/mesh-vault/cmd/mesh-vault/store.go +++ b/modules/mesh-vault/cmd/mesh-vault/store.go @@ -327,6 +327,42 @@ func (s *Store) ListPrefix(prefix string) ([]Record, error) { return out, nil } +// Unreadable is a record file the store could not read, by the name its file gives. +type Unreadable struct { + Name string + Err error +} + +// ListReadable is every record by name, and beside them each file that is not a record (not valid JSON, say) by name +// with its error: the listing goes on past it (issue 1409, V10). Only an unreadable directory is an error. +func (s *Store) ListReadable() ([]Record, []Unreadable, error) { + entries, err := os.ReadDir(filepath.Join(s.dir, "secrets")) + if err != nil { + return nil, nil, err + } + var out []Record + var bad []Unreadable + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".json") { + continue + } + name, err := url.PathUnescape(strings.TrimSuffix(e.Name(), ".json")) + if err != nil { + bad = append(bad, Unreadable{Name: strings.TrimSuffix(e.Name(), ".json"), Err: err}) + continue + } + r, ok, err := s.Read(name) + if err != nil { + bad = append(bad, Unreadable{Name: name, Err: err}) + } else if ok { + out = append(out, r) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + sort.Slice(bad, func(i, j int) bool { return bad[i].Name < bad[j].Name }) + return out, bad, nil +} + type operatorFile struct { Key string `json:"key"` Since time.Time `json:"since"` From d22a7f3a412f0d131313ac22ecc0af02a156f812 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 18:09:17 +0100 Subject: [PATCH 4/9] fix-1748-own-resolver-wait: the own resolver's file waits 3 s a try MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver file of systemd-resolved (resolvers and kept facts) carries options timeout:3 attempts:2 edns0 in place of timeout:1; manifest test asserts both facts, guard test's literal follows. Builds: design 50 resolver-wait §1, §4 (and §2 of the main file, line 73) --- modules/systemd-resolved/cmd/resolver-tools/guard_test.go | 2 +- .../systemd-resolved/cmd/resolver-tools/manifest_test.go | 6 ++++++ modules/systemd-resolved/module.json | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/modules/systemd-resolved/cmd/resolver-tools/guard_test.go b/modules/systemd-resolved/cmd/resolver-tools/guard_test.go index 2306730b4..bee912697 100644 --- a/modules/systemd-resolved/cmd/resolver-tools/guard_test.go +++ b/modules/systemd-resolved/cmd/resolver-tools/guard_test.go @@ -12,7 +12,7 @@ import ( "time" ) -const meshFile = "# Managed by the mesh\nnameserver 10.42.0.2\noptions timeout:1 attempts:2 edns0\n" +const meshFile = "# Managed by the mesh\nnameserver 10.42.0.2\noptions timeout:3 attempts:2 edns0\n" // vpnFile is a VPN client's file as one writes it; the addresses and domains are documentation's. const vpnFile = "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by forticlient\n" + diff --git a/modules/systemd-resolved/cmd/resolver-tools/manifest_test.go b/modules/systemd-resolved/cmd/resolver-tools/manifest_test.go index cdf4d1a91..c1ec63d42 100644 --- a/modules/systemd-resolved/cmd/resolver-tools/manifest_test.go +++ b/modules/systemd-resolved/cmd/resolver-tools/manifest_test.go @@ -73,6 +73,12 @@ func TestTheManifestSaysWhatTheGuardKeeps(t *testing.T) { if len(lines) != 1 || strings.Contains(m.Facts["resolvers"].Template, "search ") { t.Errorf("the resolver file lists more than this machine's own resolver: %v", lines) } + // Design 50 resolver-wait §1: both facts carry the longer wait, never the uplink files' timeout:1. + for _, fact := range []string{"resolvers", "kept"} { + if tpl := m.Facts[fact].Template; !strings.Contains(tpl, "\noptions timeout:3 attempts:2 edns0\n") || strings.Contains(tpl, "timeout:1") { + t.Errorf("fact %s does not carry options timeout:3 attempts:2 edns0: %q", fact, tpl) + } + } conf := m.Facts["resolved"].Template for _, want := range []string{`DNS={{range index .Holders "mesh-dns-resolver"}}`, "\nDomains=~.\n", "\nFallbackDNS=\n", "DNSStubListenerExtra={{$own}}\n", "\nCache=no\n", "\nLLMNR=no\n", "\nMulticastDNS=no\n"} { diff --git a/modules/systemd-resolved/module.json b/modules/systemd-resolved/module.json index 24b3bc858..2a50c835a 100644 --- a/modules/systemd-resolved/module.json +++ b/modules/systemd-resolved/module.json @@ -52,7 +52,7 @@ "facts": { "kept": { "path": "/etc/node-resolver/resolv.conf", - "template": "# Managed by the mesh, and written by the module holding this machine's own resolver\n# (module systemd-resolved, novox/hq ADR 0247). On every other machine the module holding\n# the uplink writes this file, listing the mesh's resolvers (ADR 0223); here something\n# requires names routed by domain - a VPN client's domains to its own servers - so the file\n# names this machine's own resolver alone, which sends those domains over the VPN's link and\n# every other name to the mesh's resolvers. One server listed, so one answer per name.\n#\n# Its address on the private network, not loopback: a container copies this file, and\n# this address is one it can reach. Another program writing this file is put back by the\n# module's guard, which keeps what it wrote for whoever handles it on this machine.\n# Replaced on every push; edit nothing here.\n{{$own := \"\"}}{{range .Machines}}{{if eq .Name $.Node}}{{$own = .Address}}{{end}}{{end}}nameserver {{if $own}}{{$own}}{{else}}127.0.0.53{{end}}\noptions timeout:1 attempts:2 edns0\n" + "template": "# Managed by the mesh, and written by the module holding this machine's own resolver\n# (module systemd-resolved, novox/hq ADR 0247). On every other machine the module holding\n# the uplink writes this file, listing the mesh's resolvers (ADR 0223); here something\n# requires names routed by domain - a VPN client's domains to its own servers - so the file\n# names this machine's own resolver alone, which sends those domains over the VPN's link and\n# every other name to the mesh's resolvers. One server listed, so one answer per name.\n#\n# Its address on the private network, not loopback: a container copies this file, and\n# this address is one it can reach. Another program writing this file is put back by the\n# module's guard, which keeps what it wrote for whoever handles it on this machine.\n# Replaced on every push; edit nothing here.\n{{$own := \"\"}}{{range .Machines}}{{if eq .Name $.Node}}{{$own = .Address}}{{end}}{{end}}nameserver {{if $own}}{{$own}}{{else}}127.0.0.53{{end}}\noptions timeout:3 attempts:2 edns0\n" }, "resolved": { "path": "/etc/systemd/resolved.conf.d/50-mesh.conf", @@ -60,7 +60,7 @@ }, "resolvers": { "path": "/etc/resolv.conf", - "template": "# Managed by the mesh, and written by the module holding this machine's own resolver\n# (module systemd-resolved, novox/hq ADR 0247). On every other machine the module holding\n# the uplink writes this file, listing the mesh's resolvers (ADR 0223); here something\n# requires names routed by domain - a VPN client's domains to its own servers - so the file\n# names this machine's own resolver alone, which sends those domains over the VPN's link and\n# every other name to the mesh's resolvers. One server listed, so one answer per name.\n#\n# Its address on the private network, not loopback: a container copies this file, and\n# this address is one it can reach. Another program writing this file is put back by the\n# module's guard, which keeps what it wrote for whoever handles it on this machine.\n# Replaced on every push; edit nothing here.\n{{$own := \"\"}}{{range .Machines}}{{if eq .Name $.Node}}{{$own = .Address}}{{end}}{{end}}nameserver {{if $own}}{{$own}}{{else}}127.0.0.53{{end}}\noptions timeout:1 attempts:2 edns0\n" + "template": "# Managed by the mesh, and written by the module holding this machine's own resolver\n# (module systemd-resolved, novox/hq ADR 0247). On every other machine the module holding\n# the uplink writes this file, listing the mesh's resolvers (ADR 0223); here something\n# requires names routed by domain - a VPN client's domains to its own servers - so the file\n# names this machine's own resolver alone, which sends those domains over the VPN's link and\n# every other name to the mesh's resolvers. One server listed, so one answer per name.\n#\n# Its address on the private network, not loopback: a container copies this file, and\n# this address is one it can reach. Another program writing this file is put back by the\n# module's guard, which keeps what it wrote for whoever handles it on this machine.\n# Replaced on every push; edit nothing here.\n{{$own := \"\"}}{{range .Machines}}{{if eq .Name $.Node}}{{$own = .Address}}{{end}}{{end}}nameserver {{if $own}}{{$own}}{{else}}127.0.0.53{{end}}\noptions timeout:3 attempts:2 edns0\n" }, "suffix": { "path": "/etc/node-resolver/suffix", From 5795f4d2303ebebf95b6555d7582dcffd38731ef Mon Sep 17 00:00:00 2001 From: jochens Date: Thu, 12 Nov 2026 20:52:21 +0100 Subject: [PATCH 5/9] kernel: microcode in every initramfs through mkinitcpio's microcode hook (issue 1977) Declares amd-ucode and intel-ucode, the drop-in /etc/mkinitcpio.conf.d/mesh-microcode.conf, the initramfs script and mesh-kernel-initramfs.service; kernel_boot answers the running revision and whether the images were built with the drop-in. Tests: 1977/AC1-AC5. Builds: L1, L2, L3, L4, L5 (issue 1977 note 2) --- modules/kernel/README.md | 31 +++- modules/kernel/cmd/kernel-tools/boot.go | 25 ++++ modules/kernel/cmd/kernel-tools/main.go | 3 +- .../kernel/cmd/kernel-tools/manifest_test.go | 2 +- .../kernel/cmd/kernel-tools/microcode_test.go | 137 ++++++++++++++++++ modules/kernel/files/initramfs.sh | 36 +++++ .../files/mesh-kernel-initramfs.service | 22 +++ modules/kernel/files/mesh-microcode.conf | 8 + modules/kernel/module.json | 47 ++++++ 9 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 modules/kernel/cmd/kernel-tools/microcode_test.go create mode 100644 modules/kernel/files/initramfs.sh create mode 100644 modules/kernel/files/mesh-kernel-initramfs.service create mode 100644 modules/kernel/files/mesh-microcode.conf diff --git a/modules/kernel/README.md b/modules/kernel/README.md index 032fd9059..2da9207db 100644 --- a/modules/kernel/README.md +++ b/modules/kernel/README.md @@ -1,9 +1,9 @@ # kernel -The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). It holds two things: -**transparent huge pages**, set per machine, and **the fallback kernel** (`linux-lts` with its headers) and -its place on the boot menu (novox/hq ADR 0508 §9 and §10). The default kernel's package, and microcode, are -not built yet. +The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). It holds three things: +**transparent huge pages**, set per machine, **the fallback kernel** (`linux-lts` with its headers) and +its place on the boot menu (novox/hq ADR 0508 §9 and §10), and **microcode** in every initramfs (novox/hq issue 1977). +The default kernel's package is not built yet. ## Why (novox/hq issue 746) @@ -53,6 +53,25 @@ built for the fallback kernel too) after its boot-entries service, so the entry | file | `/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook` | runs the script after a kernel image is installed, upgraded or removed | | packages | `linux-lts`, `linux-lts-headers` | declared after the service and the hook | +## Microcode in every initramfs (novox/hq issue 1977) + +Before this, `amd-ucode` was installed by hand on two machines and no initramfs carried it, so a machine booted from +its default entry ran the CPU's older microcode (ace: revision 0x0a201016 while the package held 0x0a20102e). The module +declares the packages and makes mkinitcpio's own `microcode` hook part of every image, so every boot entry loads it, +a default entry written by hand included, and no entry is edited. The fallback entry's own microcode lines stay (a second +early archive is harmless). + +| | what | notes | +|---|---|---| +| packages | `amd-ucode`, `intel-ucode` | both on every machine; the `autodetect` hook keeps only the running CPU's microcode in the default image | +| file | `/etc/mkinitcpio.conf.d/mesh-microcode.conf` | read by mkinitcpio after `mkinitcpio.conf`: appends `microcode` to `HOOKS` unless it names it | +| file | `${dir:state}/initramfs` | the script, `apply`: when the drop-in differs from the one the images were last built with (`${dir:state}/initramfs-built`) it runs `mkinitcpio -P` and then records the drop-in; otherwise it says "as it was". A failed mkinitcpio fails the unit, says so and records nothing | +| file | `/etc/systemd/system/mesh-kernel-initramfs.service` | oneshot, sandboxed: the file system read-only but for `/boot` and the module's state, a private `/tmp`, no home, no network | +| service | `mesh-kernel-initramfs.service` | running and enabled; restarted when the unit, the script or the drop-in changes. Later kernel upgrades build their images through the distribution's own hook, with the drop-in in place | + +The module does not reboot: a machine loads the new microcode at its next boot. `kernel_boot` says the revision the CPU +runs now and whether the images were built with the drop-in. + ## Kernel modules at boot, and the running kernel's modules kept A machine that runs for weeks meets a kernel upgrade that removes `/usr/lib/modules/`: from then on @@ -73,13 +92,13 @@ module says so in its health and takes no image build there meanwhile. | tool | | answers | |---|---|---| -| `kernel_boot` | r | the loader (`systemd-boot`, `grub` or `none`), the menu's entries, the kernels in `/boot`, the entry booted (systemd-boot) or the kernel image booted (GRUB), whether the fallback kernel is on the menu with its kernel in `/boot`, and the room free on `/boot` in KiB | +| `kernel_boot` | r | the loader (`systemd-boot`, `grub` or `none`), the menu's entries, the kernels in `/boot`, the entry booted (systemd-boot) or the kernel image booted (GRUB), whether the fallback kernel is on the menu with its kernel in `/boot`, the room free on `/boot` in KiB, the microcode revision the CPU runs (`/proc/cpuinfo`) and whether the initramfs images were built with the microcode drop-in (read from `/var/lib/mesh-kernel/initramfs-built`) | | `kernel_huge_pages` | r | the mode, defrag and shared-memory mode; each per-size control and what `inherit` resolves to; huge pages by kind from `/proc/meminfo` (MiB); the units holding the most file pages in huge folios (`memory.stat` `file_thp`) with their dirty and writeback bytes, khugepaged's collapses and the bytes their cgroup wrote (`io.stat`, the most any one device saw: a mirror and its members each count the same write) | ## Tests `go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the -manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the kernel modules loaded at boot (`tun`) and the package and boot service that keep the running kernel's modules; the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh +manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the kernel modules loaded at boot (`tun`) and the package and boot service that keep the running kernel's modules; the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the microcode drop-in sourced by bash, the initramfs script run with `sh` over a stand-in mkinitcpio (once, again on a changed drop-in, a failure recording nothing) and `kernel_boot`'s microcode answer; and the huge-pages script as the mesh writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once, rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel diff --git a/modules/kernel/cmd/kernel-tools/boot.go b/modules/kernel/cmd/kernel-tools/boot.go index 10a895a17..8ebfd49e0 100644 --- a/modules/kernel/cmd/kernel-tools/boot.go +++ b/modules/kernel/cmd/kernel-tools/boot.go @@ -23,6 +23,30 @@ type BootState struct { Booted string `json:"booted"` FallbackPresent bool `json:"fallback_present"` BootFreeKiB int64 `json:"boot_free_kib"` + // MicrocodeRevision is the revision the CPU runs, the first "microcode" line of /proc/cpuinfo ("" when it has none). + MicrocodeRevision string `json:"microcode_revision"` + // ImagesBuiltWithMicrocode is true when the initramfs images were last built with the microcode drop-in as it is now + // (novox/hq issue 1977). + ImagesBuiltWithMicrocode bool `json:"images_built_with_microcode"` +} + +// stateDir is the module's own directory on the machine, where the initramfs script records the drop-in it built with. +const stateDir = "var/lib/mesh-kernel" + +const microcodeDropIn = "etc/mkinitcpio.conf.d/mesh-microcode.conf" + +var cpuMicrocode = regexp.MustCompile(`(?m)^microcode\s*:\s*(\S+)`) + +// microcode reads the revision the CPU runs and whether the images carry the drop-in as it is now. +func (m *Machine) microcode(b *BootState) { + if raw, err := m.read("proc/cpuinfo"); err == nil { + if r := cpuMicrocode.FindStringSubmatch(raw); r != nil { + b.MicrocodeRevision = r[1] + } + } + built, err := m.read(stateDir + "/initramfs-built") + now, err2 := m.read(microcodeDropIn) + b.ImagesBuiltWithMicrocode = err == nil && err2 == nil && built == now } var grubKernel = regexp.MustCompile(`(?m)^\s*linux\s+(?:/boot)?/(vmlinuz-\S+)`) @@ -33,6 +57,7 @@ var bootImage = regexp.MustCompile(`BOOT_IMAGE=(?:/boot)?/?(\S+)`) // systemd-boot, a grub.cfg without it GRUB. func (m *Machine) Boot() (*BootState, error) { b := &BootState{Loader: "none", Entries: []string{}, Kernels: []string{}} + m.microcode(b) files, _ := filepath.Glob(m.path("boot/vmlinuz-*")) for _, f := range files { b.Kernels = append(b.Kernels, filepath.Base(f)) diff --git a/modules/kernel/cmd/kernel-tools/main.go b/modules/kernel/cmd/kernel-tools/main.go index 93cdece80..e93c4efb5 100644 --- a/modules/kernel/cmd/kernel-tools/main.go +++ b/modules/kernel/cmd/kernel-tools/main.go @@ -51,7 +51,8 @@ func tools(m *Machine) []stdio.Tool { Description: "How this machine boots and whether the fallback kernel is on its menu (novox/hq ADR 0508 §9): the loader that booted it " + "(systemd-boot, read from the EFI variable it sets, grub, or none), the menu's entries, the kernels in /boot, the entry " + "the machine booted (systemd-boot) or the kernel image it booted (grub), whether the fallback kernel linux-lts is on " + - "the menu with its kernel in /boot, and the room free on /boot in KiB (the fallback kernel needs about 72 MB). " + + "the menu with its kernel in /boot, and the room free on /boot in KiB (the fallback kernel needs about 72 MB), the microcode revision the CPU runs " + + "(/proc/cpuinfo) and whether the initramfs images were built with the microcode drop-in (novox/hq issue 1977). " + "Replaces ls /boot, bootctl list, reading grub.cfg and df /boot. (r)", Input: schema(map[string]any{}), Run: func(args map[string]any) (any, error) { return m.Boot() }, diff --git a/modules/kernel/cmd/kernel-tools/manifest_test.go b/modules/kernel/cmd/kernel-tools/manifest_test.go index 21195061f..e7c548f2b 100644 --- a/modules/kernel/cmd/kernel-tools/manifest_test.go +++ b/modules/kernel/cmd/kernel-tools/manifest_test.go @@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) { for _, r := range m.Resources { ids = append(ids, r["id"].(string)) } - if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers modules-load modules-keep modules-cleanup" { + if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers modules-load modules-keep modules-cleanup amd-ucode intel-ucode microcode-hook initramfs initramfs-unit initramfs-apply" { t.Fatalf("order: %v", ids) } script := m.resource(t, "huge-pages") diff --git a/modules/kernel/cmd/kernel-tools/microcode_test.go b/modules/kernel/cmd/kernel-tools/microcode_test.go new file mode 100644 index 000000000..144e1d31c --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/microcode_test.go @@ -0,0 +1,137 @@ +package main + +// Microcode in every initramfs (novox/hq issue 1977, lead answers L1-L4): the manifest, the drop-in sourced by bash, +// the initramfs script run with sh over stand-in files and a stand-in mkinitcpio, and kernel_boot's answer. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// 1977/AC1 +func TestAC1TheManifestDeclaresTheMicrocodePackagesDropInScriptAndService(t *testing.T) { + m := manifest(t) + var ids []string + for _, r := range m.Resources { + ids = append(ids, r["id"].(string)) + } + if !strings.HasSuffix(strings.Join(ids, " "), "modules-cleanup amd-ucode intel-ucode microcode-hook initramfs initramfs-unit initramfs-apply") { + t.Fatalf("order: %v", ids) + } + for id, pkg := range map[string]string{"amd-ucode": "amd-ucode", "intel-ucode": "intel-ucode"} { + if r := m.resource(t, id); r["type"] != "package" || r["package"] != pkg { + t.Fatalf("%s: %v", id, r) + } + } + if r := m.resource(t, "microcode-hook"); r["path"] != "/etc/mkinitcpio.conf.d/mesh-microcode.conf" { + t.Fatalf("drop-in: %v", r) + } + if r := m.resource(t, "initramfs"); r["path"] != "${dir:state}/initramfs" || r["mode"] != "0755" || r["trusted"] != true { + t.Fatalf("script: %v", r) + } + if r := m.resource(t, "initramfs-unit"); r["path"] != "/etc/systemd/system/mesh-kernel-initramfs.service" { + t.Fatalf("unit: %v", r) + } + a := m.resource(t, "initramfs-apply") + on := a["restart-on"].([]any) + if a["unit"] != "mesh-kernel-initramfs.service" || a["state"] != "running" || a["boot"] != "enabled" || + len(on) != 3 || on[0] != "initramfs-unit" || on[1] != "initramfs" || on[2] != "microcode-hook" { + t.Fatalf("service: %v", a) + } +} + +// 1977/AC2 +func TestAC2TheDropInAddsMicrocodeToHooksOnce(t *testing.T) { + drop := filepath.Join(t.TempDir(), "mesh-microcode.conf") + write(t, drop, manifest(t).resource(t, "microcode-hook").text(t), 0o644) + for _, c := range []struct{ hooks, want string }{ + {"base udev autodetect modconf block filesystems keyboard fsck", "base udev autodetect modconf block filesystems keyboard fsck microcode"}, + {"base udev autodetect microcode block", "base udev autodetect microcode block"}, + } { + out, err := exec.Command("bash", "-c", "HOOKS=("+c.hooks+"); . "+drop+"; echo \"${HOOKS[*]}\"").CombinedOutput() + if err != nil || strings.TrimSpace(string(out)) != c.want { + t.Errorf("%q: %v %q", c.hooks, err, out) + } + } +} + +type initBed struct{ script, dropin, state, runs, bin string } + +func newInitBed(t *testing.T, mkinitcpioExit string) initBed { + t.Helper() + dir := t.TempDir() + b := initBed{script: filepath.Join(dir, "initramfs"), dropin: filepath.Join(dir, "mesh-microcode.conf"), state: filepath.Join(dir, "state"), + runs: filepath.Join(dir, "runs"), bin: filepath.Join(dir, "bin")} + for _, d := range []string{b.state, b.bin} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + content := strings.ReplaceAll(manifest(t).resource(t, "initramfs").text(t), "${dir:state}", b.state) + if strings.Contains(content, "${dir") { + t.Fatal("a substitution is left in the script") + } + write(t, b.script, content, 0o755) + write(t, b.dropin, "HOOKS+=(microcode)\n", 0o644) + write(t, filepath.Join(b.bin, "mkinitcpio"), "#!/bin/sh\necho \"$@\" >>"+b.runs+"\nexit "+mkinitcpioExit+"\n", 0o755) + return b +} + +func (b initBed) run(t *testing.T) (string, error) { + cmd := exec.Command("sh", b.script, "apply", b.dropin, b.state) + cmd.Env = append(os.Environ(), "PATH="+b.bin+string(os.PathListSeparator)+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func (b initBed) builds(t *testing.T) string { + raw, _ := os.ReadFile(b.runs) + return string(raw) +} + +// 1977/AC3 +func TestAC3TheImagesAreBuiltOnceAndAgainWhenTheDropInChanges(t *testing.T) { + b := newInitBed(t, "0") + if out, err := b.run(t); err != nil || b.builds(t) != "-P\n" { + t.Fatalf("first: %v %s %q", err, out, b.builds(t)) + } + if raw, _ := os.ReadFile(filepath.Join(b.state, "initramfs-built")); string(raw) != "HOOKS+=(microcode)\n" { + t.Fatalf("recorded: %q", raw) + } + if out, err := b.run(t); err != nil || !strings.Contains(out, "as it was") || b.builds(t) != "-P\n" { + t.Fatalf("again: %v %s %q", err, out, b.builds(t)) + } + write(t, b.dropin, "HOOKS+=(microcode)\n# new\n", 0o644) + if _, err := b.run(t); err != nil || b.builds(t) != "-P\n-P\n" { + t.Fatalf("changed: %v %q", err, b.builds(t)) + } +} + +// 1977/AC4 +func TestAC4AFailedMkinitcpioFailsTheUnitAndRecordsNothing(t *testing.T) { + b := newInitBed(t, "1") + out, err := b.run(t) + if err == nil || !strings.Contains(out, "mkinitcpio failed") { + t.Fatalf("%v %s", err, out) + } + if _, err := os.Stat(filepath.Join(b.state, "initramfs-built")); !os.IsNotExist(err) { + t.Fatalf("recorded after a failure: %v", err) + } +} + +// 1977/AC5 +func TestAC5KernelBootNamesTheRunningMicrocodeAndWhetherTheImagesCarryTheDropIn(t *testing.T) { + root := t.TempDir() + put(t, root, "proc/cpuinfo", "processor\t: 0\nmicrocode\t: 0x0a201016\nmodel name\t: x\n") + put(t, root, "etc/mkinitcpio.conf.d/mesh-microcode.conf", "HOOKS+=(microcode)\n") + if got := bootOf(t, root); got.MicrocodeRevision != "0x0a201016" || got.ImagesBuiltWithMicrocode { + t.Fatalf("no record: %+v", got) + } + put(t, root, "var/lib/mesh-kernel/initramfs-built", "HOOKS+=(microcode)\n") + if got := bootOf(t, root); got.MicrocodeRevision != "0x0a201016" || !got.ImagesBuiltWithMicrocode { + t.Fatalf("recorded: %+v", got) + } +} diff --git a/modules/kernel/files/initramfs.sh b/modules/kernel/files/initramfs.sh new file mode 100644 index 000000000..92982f467 --- /dev/null +++ b/modules/kernel/files/initramfs.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# The initramfs images as the mesh builds them (module kernel, novox/hq issue 1977). Run by +# mesh-kernel-initramfs.service: +# +# initramfs apply [ [ []]] +# +# `apply` builds every image again (mkinitcpio -P) when the drop-in differs from the one the images were last built +# with, kept in the state as `initramfs-built`, and records the drop-in only after mkinitcpio succeeded. Later +# kernel upgrades build the images through the distribution's own hook, with the drop-in in place. The arguments +# are only for the module's tests. +set -eu + +dropin=/etc/mkinitcpio.conf.d/mesh-microcode.conf +state='${dir:state}' +[ $# -ge 2 ] && dropin=$2 +[ $# -ge 3 ] && state=$3 + +[ $# -ge 1 ] && [ "$1" = apply ] || { + echo "usage: initramfs apply [ []]" >&2 + exit 2 +} +[ -f "$dropin" ] || { + echo "initramfs: $dropin is not there, so the images cannot be built with the microcode hook" >&2 + exit 1 +} +if [ -f "$state/initramfs-built" ] && cmp -s "$dropin" "$state/initramfs-built"; then + echo "initramfs: the images were built with this drop-in, as it was" + exit 0 +fi +if ! mkinitcpio -P; then + echo "initramfs: mkinitcpio failed; nothing recorded, the next start tries again" >&2 + exit 1 +fi +cp "$dropin" "$state/initramfs-built.new" +mv "$state/initramfs-built.new" "$state/initramfs-built" +echo "initramfs: the images are built with the microcode drop-in" diff --git a/modules/kernel/files/mesh-kernel-initramfs.service b/modules/kernel/files/mesh-kernel-initramfs.service new file mode 100644 index 000000000..95df9ba3f --- /dev/null +++ b/modules/kernel/files/mesh-kernel-initramfs.service @@ -0,0 +1,22 @@ +[Unit] +Description=The initramfs images with the CPU's current microcode +# Module kernel, novox/hq issue 1977. Starting this unit builds the images again when the microcode drop-in +# changed since they were last built; a failed mkinitcpio fails the unit. The microcode packages are declared +# before this unit. +After=local-fs.target +Wants=local-fs.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/initramfs apply +# It writes the boot partition and its own state; mkinitcpio keeps its work in a private /tmp. +ProtectSystem=strict +ReadWritePaths=/boot ${dir:state} +PrivateTmp=yes +ProtectHome=yes +PrivateNetwork=yes +NoNewPrivileges=yes + +[Install] +WantedBy=multi-user.target diff --git a/modules/kernel/files/mesh-microcode.conf b/modules/kernel/files/mesh-microcode.conf new file mode 100644 index 000000000..6025eac81 --- /dev/null +++ b/modules/kernel/files/mesh-microcode.conf @@ -0,0 +1,8 @@ +# Module kernel, novox/hq issue 1977. mkinitcpio reads this after /etc/mkinitcpio.conf: it adds the microcode +# hook, which puts the CPU's current microcode (amd-ucode, intel-ucode) at the front of every initramfs, so +# every boot entry loads it, the default one too. HOOKS that name it already are left as they are. +# Written by the mesh; change the module. +case "$(declare -p HOOKS)" in +*'"microcode"'*) ;; +*) HOOKS+=(microcode) ;; +esac diff --git a/modules/kernel/module.json b/modules/kernel/module.json index 8fc90d09c..69f064d2d 100644 --- a/modules/kernel/module.json +++ b/modules/kernel/module.json @@ -119,6 +119,53 @@ "unit": "linux-modules-cleanup.service", "state": "stopped", "boot": "enabled" + }, + { + "id": "amd-ucode", + "type": "package", + "package": "amd-ucode" + }, + { + "id": "intel-ucode", + "type": "package", + "package": "intel-ucode" + }, + { + "id": "microcode-hook", + "type": "file", + "path": "/etc/mkinitcpio.conf.d/mesh-microcode.conf", + "mode": "0644", + "template": "files/mesh-microcode.conf" + }, + { + "id": "initramfs", + "type": "file", + "path": "${dir:state}/initramfs", + "mode": "0755", + "trusted": true, + "template": "files/initramfs.sh" + }, + { + "id": "initramfs-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-kernel-initramfs.service", + "mode": "0644", + "template": "files/mesh-kernel-initramfs.service" + }, + { + "id": "initramfs-apply", + "type": "service", + "unit": "mesh-kernel-initramfs.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "initramfs-unit", + "initramfs", + "microcode-hook" + ], + "health": { + "kind": "unit" + } } ], "build": { From 62c0eb90cb867f3795a47eca7721f203ca5a676d Mon Sep 17 00:00:00 2001 From: jochens Date: Thu, 12 Nov 2026 20:54:34 +0100 Subject: [PATCH 6/9] kernel: kernel_boot reads microcode from each initramfs image (issue 1977, L6) Replaces the state record read (root's 0700) with images_with_microcode per /boot/initramfs-*.img: true when the first 64 KiB hold a path under kernel/x86/microcode/; an unreadable image goes to image_errors with its error. Test: 1977/AC5. Builds: L6 (issue 1977 note 3) --- modules/kernel/README.md | 6 +-- modules/kernel/cmd/kernel-tools/boot.go | 44 ++++++++++++++----- modules/kernel/cmd/kernel-tools/main.go | 2 +- .../kernel/cmd/kernel-tools/microcode_test.go | 27 +++++++++--- 4 files changed, 57 insertions(+), 22 deletions(-) diff --git a/modules/kernel/README.md b/modules/kernel/README.md index 2da9207db..c96e91a1c 100644 --- a/modules/kernel/README.md +++ b/modules/kernel/README.md @@ -70,7 +70,7 @@ early archive is harmless). | service | `mesh-kernel-initramfs.service` | running and enabled; restarted when the unit, the script or the drop-in changes. Later kernel upgrades build their images through the distribution's own hook, with the drop-in in place | The module does not reboot: a machine loads the new microcode at its next boot. `kernel_boot` says the revision the CPU -runs now and whether the images were built with the drop-in. +runs now and which images carry microcode. ## Kernel modules at boot, and the running kernel's modules kept @@ -92,13 +92,13 @@ module says so in its health and takes no image build there meanwhile. | tool | | answers | |---|---|---| -| `kernel_boot` | r | the loader (`systemd-boot`, `grub` or `none`), the menu's entries, the kernels in `/boot`, the entry booted (systemd-boot) or the kernel image booted (GRUB), whether the fallback kernel is on the menu with its kernel in `/boot`, the room free on `/boot` in KiB, the microcode revision the CPU runs (`/proc/cpuinfo`) and whether the initramfs images were built with the microcode drop-in (read from `/var/lib/mesh-kernel/initramfs-built`) | +| `kernel_boot` | r | the loader (`systemd-boot`, `grub` or `none`), the menu's entries, the kernels in `/boot`, the entry booted (systemd-boot) or the kernel image booted (GRUB), whether the fallback kernel is on the menu with its kernel in `/boot`, the room free on `/boot` in KiB, the microcode revision the CPU runs (`/proc/cpuinfo`) and, per `/boot/initramfs-*.img`, whether the image's first 64 KiB (the early uncompressed cpio) hold a path under `kernel/x86/microcode/` (`images_with_microcode`); an image that cannot be read is named with its error in `image_errors`, never counted as false | | `kernel_huge_pages` | r | the mode, defrag and shared-memory mode; each per-size control and what `inherit` resolves to; huge pages by kind from `/proc/meminfo` (MiB); the units holding the most file pages in huge folios (`memory.stat` `file_thp`) with their dirty and writeback bytes, khugepaged's collapses and the bytes their cgroup wrote (`io.stat`, the most any one device saw: a mirror and its members each count the same write) | ## Tests `go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the -manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the kernel modules loaded at boot (`tun`) and the package and boot service that keep the running kernel's modules; the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the microcode drop-in sourced by bash, the initramfs script run with `sh` over a stand-in mkinitcpio (once, again on a changed drop-in, a failure recording nothing) and `kernel_boot`'s microcode answer; and the huge-pages script as the mesh +manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the kernel modules loaded at boot (`tun`) and the package and boot service that keep the running kernel's modules; the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the microcode drop-in sourced by bash, the initramfs script run with `sh` over a stand-in mkinitcpio (once, again on a changed drop-in, a failure recording nothing) and `kernel_boot`'s microcode answer (stand-in cpio images, one unreadable); and the huge-pages script as the mesh writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once, rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel diff --git a/modules/kernel/cmd/kernel-tools/boot.go b/modules/kernel/cmd/kernel-tools/boot.go index 8ebfd49e0..603d17e07 100644 --- a/modules/kernel/cmd/kernel-tools/boot.go +++ b/modules/kernel/cmd/kernel-tools/boot.go @@ -4,6 +4,8 @@ package main // whether the fallback kernel is on the menu (novox/hq ADR 0508 §9, issue 1841). Reads only. import ( + "bytes" + "io" "os" "path/filepath" "regexp" @@ -25,28 +27,48 @@ type BootState struct { BootFreeKiB int64 `json:"boot_free_kib"` // MicrocodeRevision is the revision the CPU runs, the first "microcode" line of /proc/cpuinfo ("" when it has none). MicrocodeRevision string `json:"microcode_revision"` - // ImagesBuiltWithMicrocode is true when the initramfs images were last built with the microcode drop-in as it is now - // (novox/hq issue 1977). - ImagesBuiltWithMicrocode bool `json:"images_built_with_microcode"` + // ImagesWithMicrocode is, per /boot/initramfs-*.img, whether the image's early uncompressed cpio (its first 64 KiB) + // holds a path under kernel/x86/microcode/ (novox/hq issue 1977). + ImagesWithMicrocode map[string]bool `json:"images_with_microcode"` + // ImageErrors names each image that could not be read and why: such an image is in no verdict above. + ImageErrors []ImageError `json:"image_errors"` } -// stateDir is the module's own directory on the machine, where the initramfs script records the drop-in it built with. -const stateDir = "var/lib/mesh-kernel" - -const microcodeDropIn = "etc/mkinitcpio.conf.d/mesh-microcode.conf" +// ImageError is an initramfs image that could not be read. +type ImageError struct { + Image string `json:"image"` + Error string `json:"error"` +} var cpuMicrocode = regexp.MustCompile(`(?m)^microcode\s*:\s*(\S+)`) -// microcode reads the revision the CPU runs and whether the images carry the drop-in as it is now. +// microcode reads the revision the CPU runs and which initramfs images carry microcode. func (m *Machine) microcode(b *BootState) { + b.ImagesWithMicrocode = map[string]bool{} + b.ImageErrors = []ImageError{} if raw, err := m.read("proc/cpuinfo"); err == nil { if r := cpuMicrocode.FindStringSubmatch(raw); r != nil { b.MicrocodeRevision = r[1] } } - built, err := m.read(stateDir + "/initramfs-built") - now, err2 := m.read(microcodeDropIn) - b.ImagesBuiltWithMicrocode = err == nil && err2 == nil && built == now + images, _ := filepath.Glob(m.path("boot/initramfs-*.img")) + sort.Strings(images) + for _, img := range images { + name := filepath.Base(img) + f, err := os.Open(img) + if err != nil { + b.ImageErrors = append(b.ImageErrors, ImageError{name, err.Error()}) + continue + } + head := make([]byte, 64*1024) + n, err := io.ReadFull(f, head) + f.Close() + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + b.ImageErrors = append(b.ImageErrors, ImageError{name, err.Error()}) + continue + } + b.ImagesWithMicrocode[name] = bytes.Contains(head[:n], []byte("kernel/x86/microcode/")) + } } var grubKernel = regexp.MustCompile(`(?m)^\s*linux\s+(?:/boot)?/(vmlinuz-\S+)`) diff --git a/modules/kernel/cmd/kernel-tools/main.go b/modules/kernel/cmd/kernel-tools/main.go index e93c4efb5..e7e729574 100644 --- a/modules/kernel/cmd/kernel-tools/main.go +++ b/modules/kernel/cmd/kernel-tools/main.go @@ -52,7 +52,7 @@ func tools(m *Machine) []stdio.Tool { "(systemd-boot, read from the EFI variable it sets, grub, or none), the menu's entries, the kernels in /boot, the entry " + "the machine booted (systemd-boot) or the kernel image it booted (grub), whether the fallback kernel linux-lts is on " + "the menu with its kernel in /boot, and the room free on /boot in KiB (the fallback kernel needs about 72 MB), the microcode revision the CPU runs " + - "(/proc/cpuinfo) and whether the initramfs images were built with the microcode drop-in (novox/hq issue 1977). " + + "(/proc/cpuinfo) and, per /boot/initramfs-*.img, whether its early cpio carries microcode (images_with_microcode; an unreadable image is named in image_errors) (novox/hq issue 1977). " + "Replaces ls /boot, bootctl list, reading grub.cfg and df /boot. (r)", Input: schema(map[string]any{}), Run: func(args map[string]any) (any, error) { return m.Boot() }, diff --git a/modules/kernel/cmd/kernel-tools/microcode_test.go b/modules/kernel/cmd/kernel-tools/microcode_test.go index 144e1d31c..e976adf09 100644 --- a/modules/kernel/cmd/kernel-tools/microcode_test.go +++ b/modules/kernel/cmd/kernel-tools/microcode_test.go @@ -123,15 +123,28 @@ func TestAC4AFailedMkinitcpioFailsTheUnitAndRecordsNothing(t *testing.T) { } // 1977/AC5 -func TestAC5KernelBootNamesTheRunningMicrocodeAndWhetherTheImagesCarryTheDropIn(t *testing.T) { +func TestAC5KernelBootNamesTheRunningMicrocodeAndWhichImagesCarryIt(t *testing.T) { root := t.TempDir() put(t, root, "proc/cpuinfo", "processor\t: 0\nmicrocode\t: 0x0a201016\nmodel name\t: x\n") - put(t, root, "etc/mkinitcpio.conf.d/mesh-microcode.conf", "HOOKS+=(microcode)\n") - if got := bootOf(t, root); got.MicrocodeRevision != "0x0a201016" || got.ImagesBuiltWithMicrocode { - t.Fatalf("no record: %+v", got) + // a stand-in early cpio: a name field, then padding, as the first member of an initramfs. + put(t, root, "boot/initramfs-linux.img", "070701000000kernel/x86/microcode/AuthenticAMD.bin\x00\x00"+strings.Repeat("z", 100)) + put(t, root, "boot/initramfs-linux-lts.img", "070701000000early/other.bin\x00"+strings.Repeat("z", 100)) + got := bootOf(t, root) + if got.MicrocodeRevision != "0x0a201016" || len(got.ImagesWithMicrocode) != 2 || + !got.ImagesWithMicrocode["initramfs-linux.img"] || got.ImagesWithMicrocode["initramfs-linux-lts.img"] || len(got.ImageErrors) != 0 { + t.Fatalf("%+v", got) } - put(t, root, "var/lib/mesh-kernel/initramfs-built", "HOOKS+=(microcode)\n") - if got := bootOf(t, root); got.MicrocodeRevision != "0x0a201016" || !got.ImagesBuiltWithMicrocode { - t.Fatalf("recorded: %+v", got) + if os.Getuid() == 0 { + t.Skip("root reads an unreadable file") + } + bad := filepath.Join(root, "boot/initramfs-linux-fallback.img") + put(t, root, "boot/initramfs-linux-fallback.img", "x") + if err := os.Chmod(bad, 0); err != nil { + t.Fatal(err) + } + got = bootOf(t, root) + if _, in := got.ImagesWithMicrocode["initramfs-linux-fallback.img"]; in || len(got.ImageErrors) != 1 || + got.ImageErrors[0].Image != "initramfs-linux-fallback.img" || got.ImageErrors[0].Error == "" { + t.Fatalf("unreadable: %+v", got) } } From f7616c6a9bd43314371fe149bc3df6244289bbed Mon Sep 17 00:00:00 2001 From: jochens Date: Thu, 12 Nov 2026 21:31:05 +0100 Subject: [PATCH 7/9] kernel: the initramfs script takes over a default-config preset and checks each image (issue 1977, L7-L9) Before mkinitcpio -P, a preset naming ALL_config="/etc/mkinitcpio.conf" is copied to the state's taken-over/ and loses that line (another ALL_config is left and named); after it, an image without kernel/x86/microcode/ in its first 64 KiB fails the unit and records nothing. The unit may write /etc/mkinitcpio.d. The README names no node. Tests: 1977/AC7, 1977/AC8; AC3/AC4 fixtures write images. Builds: L7, L8, L9 (issue 1977 note 4) --- modules/kernel/README.md | 6 +-- .../kernel/cmd/kernel-tools/microcode_test.go | 52 ++++++++++++++++--- modules/kernel/files/initramfs.sh | 41 +++++++++++++-- .../files/mesh-kernel-initramfs.service | 4 +- 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/modules/kernel/README.md b/modules/kernel/README.md index c96e91a1c..388d0664a 100644 --- a/modules/kernel/README.md +++ b/modules/kernel/README.md @@ -56,7 +56,7 @@ built for the fallback kernel too) after its boot-entries service, so the entry ## Microcode in every initramfs (novox/hq issue 1977) Before this, `amd-ucode` was installed by hand on two machines and no initramfs carried it, so a machine booted from -its default entry ran the CPU's older microcode (ace: revision 0x0a201016 while the package held 0x0a20102e). The module +its default entry ran the CPU's older microcode (one machine: revision 0x0a201016 while the package held 0x0a20102e). The module declares the packages and makes mkinitcpio's own `microcode` hook part of every image, so every boot entry loads it, a default entry written by hand included, and no entry is edited. The fallback entry's own microcode lines stay (a second early archive is harmless). @@ -65,8 +65,8 @@ early archive is harmless). |---|---|---| | packages | `amd-ucode`, `intel-ucode` | both on every machine; the `autodetect` hook keeps only the running CPU's microcode in the default image | | file | `/etc/mkinitcpio.conf.d/mesh-microcode.conf` | read by mkinitcpio after `mkinitcpio.conf`: appends `microcode` to `HOOKS` unless it names it | -| file | `${dir:state}/initramfs` | the script, `apply`: when the drop-in differs from the one the images were last built with (`${dir:state}/initramfs-built`) it runs `mkinitcpio -P` and then records the drop-in; otherwise it says "as it was". A failed mkinitcpio fails the unit, says so and records nothing | -| file | `/etc/systemd/system/mesh-kernel-initramfs.service` | oneshot, sandboxed: the file system read-only but for `/boot` and the module's state, a private `/tmp`, no home, no network | +| file | `${dir:state}/initramfs` | the script, `apply`: when the drop-in differs from the one the images were last built with (`${dir:state}/initramfs-built`) it runs `mkinitcpio -P` and then records the drop-in; otherwise it says "as it was". Before building it takes a preset's `ALL_config="/etc/mkinitcpio.conf"` line out (that line makes mkinitcpio skip `mkinitcpio.conf.d`; a copy of the preset goes to `${dir:state}/taken-over/`; another `ALL_config` is left and named); after building, an image without `kernel/x86/microcode/` in its first 64 KiB fails the unit, which names it. A failed mkinitcpio fails the unit, says so and records nothing | +| file | `/etc/systemd/system/mesh-kernel-initramfs.service` | oneshot, sandboxed: the file system read-only but for `/boot`, `/etc/mkinitcpio.d` and the module's state, a private `/tmp`, no home, no network | | service | `mesh-kernel-initramfs.service` | running and enabled; restarted when the unit, the script or the drop-in changes. Later kernel upgrades build their images through the distribution's own hook, with the drop-in in place | The module does not reboot: a machine loads the new microcode at its next boot. `kernel_boot` says the revision the CPU diff --git a/modules/kernel/cmd/kernel-tools/microcode_test.go b/modules/kernel/cmd/kernel-tools/microcode_test.go index e976adf09..75029fd7f 100644 --- a/modules/kernel/cmd/kernel-tools/microcode_test.go +++ b/modules/kernel/cmd/kernel-tools/microcode_test.go @@ -58,30 +58,35 @@ func TestAC2TheDropInAddsMicrocodeToHooksOnce(t *testing.T) { } } -type initBed struct{ script, dropin, state, runs, bin string } +type initBed struct{ script, dropin, state, runs, bin, presets, boot string } func newInitBed(t *testing.T, mkinitcpioExit string) initBed { t.Helper() dir := t.TempDir() b := initBed{script: filepath.Join(dir, "initramfs"), dropin: filepath.Join(dir, "mesh-microcode.conf"), state: filepath.Join(dir, "state"), - runs: filepath.Join(dir, "runs"), bin: filepath.Join(dir, "bin")} - for _, d := range []string{b.state, b.bin} { + runs: filepath.Join(dir, "runs"), bin: filepath.Join(dir, "bin"), presets: filepath.Join(dir, "presets"), boot: filepath.Join(dir, "boot")} + for _, d := range []string{b.state, b.bin, b.presets, b.boot} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } content := strings.ReplaceAll(manifest(t).resource(t, "initramfs").text(t), "${dir:state}", b.state) - if strings.Contains(content, "${dir") { + if strings.Contains(content, "${") { t.Fatal("a substitution is left in the script") } write(t, b.script, content, 0o755) write(t, b.dropin, "HOOKS+=(microcode)\n", 0o644) - write(t, filepath.Join(b.bin, "mkinitcpio"), "#!/bin/sh\necho \"$@\" >>"+b.runs+"\nexit "+mkinitcpioExit+"\n", 0o755) + // the stand-in mkinitcpio writes an image whose early archive holds microcode. + b.mkinitcpio(t, "kernel/x86/microcode/AuthenticAMD.bin", mkinitcpioExit) return b } +func (b initBed) mkinitcpio(t *testing.T, early, exit string) { + write(t, filepath.Join(b.bin, "mkinitcpio"), "#!/bin/sh\necho \"$@\" >>"+b.runs+"\nprintf '070701%s\\0' "+early+" >"+b.boot+"/initramfs-linux.img\nexit "+exit+"\n", 0o755) +} + func (b initBed) run(t *testing.T) (string, error) { - cmd := exec.Command("sh", b.script, "apply", b.dropin, b.state) + cmd := exec.Command("sh", b.script, "apply", b.dropin, b.state, b.presets, b.boot) cmd.Env = append(os.Environ(), "PATH="+b.bin+string(os.PathListSeparator)+os.Getenv("PATH")) out, err := cmd.CombinedOutput() return string(out), err @@ -148,3 +153,38 @@ func TestAC5KernelBootNamesTheRunningMicrocodeAndWhichImagesCarryIt(t *testing.T t.Fatalf("unreadable: %+v", got) } } + +// 1977/AC7 +func TestAC7ADefaultConfigPresetIsTakenOverAndAnotherIsLeftAndNamed(t *testing.T) { + b := newInitBed(t, "0") + preset := "ALL_kver=\"/boot/vmlinuz-linux\"\nALL_config=\"/etc/mkinitcpio.conf\"\nPRESETS=('default')\n" + write(t, filepath.Join(b.presets, "linux.preset"), preset, 0o644) + other := "ALL_config=\"/etc/other.conf\"\nPRESETS=('default')\n" + write(t, filepath.Join(b.presets, "linux-lts.preset"), other, 0o644) + out, err := b.run(t) + if err != nil { + t.Fatalf("%v %s", err, out) + } + if got, _ := os.ReadFile(filepath.Join(b.presets, "linux.preset")); string(got) != "ALL_kver=\"/boot/vmlinuz-linux\"\nPRESETS=('default')\n" { + t.Fatalf("preset: %q", got) + } + if got, _ := os.ReadFile(filepath.Join(b.state, "taken-over", "linux.preset")); string(got) != preset { + t.Fatalf("copy: %q", got) + } + if got, _ := os.ReadFile(filepath.Join(b.presets, "linux-lts.preset")); string(got) != other || !strings.Contains(out, "linux-lts.preset sets ALL_config=\"/etc/other.conf\"") { + t.Fatalf("other: %q %s", got, out) + } +} + +// 1977/AC8 +func TestAC8AnImageWithoutMicrocodeFailsTheUnitNamesItAndRecordsNothing(t *testing.T) { + b := newInitBed(t, "0") + b.mkinitcpio(t, "early/other.bin", "0") + out, err := b.run(t) + if err == nil || !strings.Contains(out, "initramfs-linux.img") { + t.Fatalf("%v %s", err, out) + } + if _, err := os.Stat(filepath.Join(b.state, "initramfs-built")); !os.IsNotExist(err) { + t.Fatalf("recorded: %v", err) + } +} diff --git a/modules/kernel/files/initramfs.sh b/modules/kernel/files/initramfs.sh index 92982f467..d7d82c185 100644 --- a/modules/kernel/files/initramfs.sh +++ b/modules/kernel/files/initramfs.sh @@ -2,18 +2,28 @@ # The initramfs images as the mesh builds them (module kernel, novox/hq issue 1977). Run by # mesh-kernel-initramfs.service: # -# initramfs apply [ [ []]] +# initramfs apply [ [ [ []]]] # # `apply` builds every image again (mkinitcpio -P) when the drop-in differs from the one the images were last built # with, kept in the state as `initramfs-built`, and records the drop-in only after mkinitcpio succeeded. Later -# kernel upgrades build the images through the distribution's own hook, with the drop-in in place. The arguments -# are only for the module's tests. +# kernel upgrades build the images through the distribution's own hook, with the drop-in in place. +# +# A preset that names the default configuration (ALL_config="/etc/mkinitcpio.conf") makes mkinitcpio pass -c and skip +# mkinitcpio.conf.d, so the drop-in is never read: before building, that line is taken out of the preset, a copy of +# the preset kept in the state's taken-over directory (novox/hq ADR 0508 §10's way with a file the mesh did not +# write); any other ALL_config is left and named. After building, every image must hold microcode in its first +# 64 KiB (the early uncompressed archive), else the unit fails and records nothing. The arguments are only for the +# module's tests. set -eu dropin=/etc/mkinitcpio.conf.d/mesh-microcode.conf state='${dir:state}' [ $# -ge 2 ] && dropin=$2 [ $# -ge 3 ] && state=$3 +presets=/etc/mkinitcpio.d +boot=/boot +[ $# -ge 4 ] && presets=$4 +[ $# -ge 5 ] && boot=$5 [ $# -ge 1 ] && [ "$1" = apply ] || { echo "usage: initramfs apply [ []]" >&2 @@ -27,10 +37,35 @@ if [ -f "$state/initramfs-built" ] && cmp -s "$dropin" "$state/initramfs-built"; echo "initramfs: the images were built with this drop-in, as it was" exit 0 fi +for preset in "$presets"/*.preset; do + [ -f "$preset" ] || continue + if grep -qx 'ALL_config="/etc/mkinitcpio.conf"' "$preset"; then + mkdir -p "$state/taken-over" + cp "$preset" "$state/taken-over/$(basename "$preset")" + grep -vx 'ALL_config="/etc/mkinitcpio.conf"' "$preset" >"$preset.new" || true + cat "$preset.new" >"$preset" + rm -f "$preset.new" + echo "initramfs: $preset named the default configuration, which skips mkinitcpio.conf.d; that line is taken out, a copy is in $state/taken-over" + elif grep -q '^ALL_config=' "$preset"; then + echo "initramfs: $preset sets $(grep '^ALL_config=' "$preset" | head -n 1), so the microcode drop-in may not be read; left as it is" + fi +done if ! mkinitcpio -P; then echo "initramfs: mkinitcpio failed; nothing recorded, the next start tries again" >&2 exit 1 fi +missing= +for img in "$boot"/initramfs-*.img; do + [ -e "$img" ] || continue + if ! head -c 65536 "$img" >"$state/image-head.tmp" || ! grep -aq 'kernel/x86/microcode/' "$state/image-head.tmp"; then + missing="$missing $(basename "$img")" + fi +done +rm -f "$state/image-head.tmp" +if [ -n "$missing" ]; then + echo "initramfs: mkinitcpio ran, but no microcode is in the early archive of:$missing; nothing recorded" >&2 + exit 1 +fi cp "$dropin" "$state/initramfs-built.new" mv "$state/initramfs-built.new" "$state/initramfs-built" echo "initramfs: the images are built with the microcode drop-in" diff --git a/modules/kernel/files/mesh-kernel-initramfs.service b/modules/kernel/files/mesh-kernel-initramfs.service index 95df9ba3f..c1a2515c3 100644 --- a/modules/kernel/files/mesh-kernel-initramfs.service +++ b/modules/kernel/files/mesh-kernel-initramfs.service @@ -10,9 +10,9 @@ Wants=local-fs.target Type=oneshot RemainAfterExit=yes ExecStart=${dir:state}/initramfs apply -# It writes the boot partition and its own state; mkinitcpio keeps its work in a private /tmp. +# It writes the boot partition, the presets and its own state; mkinitcpio keeps its work in a private /tmp. ProtectSystem=strict -ReadWritePaths=/boot ${dir:state} +ReadWritePaths=/boot /etc/mkinitcpio.d ${dir:state} PrivateTmp=yes ProtectHome=yes PrivateNetwork=yes From c100e16ba0af2ac83c38ce9193dc63665dca3475 Mon Sep 17 00:00:00 2001 From: jochens Date: Thu, 12 Nov 2026 21:49:30 +0100 Subject: [PATCH 8/9] kernel: declare amd-ucode only (issue 1977, L10) intel-ucode failed an apply on a stale package database and stopped the delivery; an Intel machine fails the unit by the image check and gets intel-ucode in the change that adds it. Tests: 1977/AC1 and the pinned order string adjusted. Builds: L10 (issue 1977 note 6) --- modules/kernel/README.md | 4 ++-- modules/kernel/cmd/kernel-tools/manifest_test.go | 2 +- modules/kernel/cmd/kernel-tools/microcode_test.go | 4 ++-- modules/kernel/files/mesh-microcode.conf | 2 +- modules/kernel/module.json | 5 ----- 5 files changed, 6 insertions(+), 11 deletions(-) diff --git a/modules/kernel/README.md b/modules/kernel/README.md index 388d0664a..7c01776a9 100644 --- a/modules/kernel/README.md +++ b/modules/kernel/README.md @@ -57,13 +57,13 @@ built for the fallback kernel too) after its boot-entries service, so the entry Before this, `amd-ucode` was installed by hand on two machines and no initramfs carried it, so a machine booted from its default entry ran the CPU's older microcode (one machine: revision 0x0a201016 while the package held 0x0a20102e). The module -declares the packages and makes mkinitcpio's own `microcode` hook part of every image, so every boot entry loads it, +declares the package and makes mkinitcpio's own `microcode` hook part of every image, so every boot entry loads it, a default entry written by hand included, and no entry is edited. The fallback entry's own microcode lines stay (a second early archive is harmless). | | what | notes | |---|---|---| -| packages | `amd-ucode`, `intel-ucode` | both on every machine; the `autodetect` hook keeps only the running CPU's microcode in the default image | +| package | `amd-ucode` | on every machine. `intel-ucode` is not declared yet (its install failed an apply on a stale package database, issue 1977): an Intel machine fails the unit by the image check, which names the images, and gets the package in the change that adds it | | file | `/etc/mkinitcpio.conf.d/mesh-microcode.conf` | read by mkinitcpio after `mkinitcpio.conf`: appends `microcode` to `HOOKS` unless it names it | | file | `${dir:state}/initramfs` | the script, `apply`: when the drop-in differs from the one the images were last built with (`${dir:state}/initramfs-built`) it runs `mkinitcpio -P` and then records the drop-in; otherwise it says "as it was". Before building it takes a preset's `ALL_config="/etc/mkinitcpio.conf"` line out (that line makes mkinitcpio skip `mkinitcpio.conf.d`; a copy of the preset goes to `${dir:state}/taken-over/`; another `ALL_config` is left and named); after building, an image without `kernel/x86/microcode/` in its first 64 KiB fails the unit, which names it. A failed mkinitcpio fails the unit, says so and records nothing | | file | `/etc/systemd/system/mesh-kernel-initramfs.service` | oneshot, sandboxed: the file system read-only but for `/boot`, `/etc/mkinitcpio.d` and the module's state, a private `/tmp`, no home, no network | diff --git a/modules/kernel/cmd/kernel-tools/manifest_test.go b/modules/kernel/cmd/kernel-tools/manifest_test.go index e7c548f2b..987712ba2 100644 --- a/modules/kernel/cmd/kernel-tools/manifest_test.go +++ b/modules/kernel/cmd/kernel-tools/manifest_test.go @@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) { for _, r := range m.Resources { ids = append(ids, r["id"].(string)) } - if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers modules-load modules-keep modules-cleanup amd-ucode intel-ucode microcode-hook initramfs initramfs-unit initramfs-apply" { + if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers modules-load modules-keep modules-cleanup amd-ucode microcode-hook initramfs initramfs-unit initramfs-apply" { t.Fatalf("order: %v", ids) } script := m.resource(t, "huge-pages") diff --git a/modules/kernel/cmd/kernel-tools/microcode_test.go b/modules/kernel/cmd/kernel-tools/microcode_test.go index 75029fd7f..0e33e8f35 100644 --- a/modules/kernel/cmd/kernel-tools/microcode_test.go +++ b/modules/kernel/cmd/kernel-tools/microcode_test.go @@ -18,10 +18,10 @@ func TestAC1TheManifestDeclaresTheMicrocodePackagesDropInScriptAndService(t *tes for _, r := range m.Resources { ids = append(ids, r["id"].(string)) } - if !strings.HasSuffix(strings.Join(ids, " "), "modules-cleanup amd-ucode intel-ucode microcode-hook initramfs initramfs-unit initramfs-apply") { + if !strings.HasSuffix(strings.Join(ids, " "), "modules-cleanup amd-ucode microcode-hook initramfs initramfs-unit initramfs-apply") { t.Fatalf("order: %v", ids) } - for id, pkg := range map[string]string{"amd-ucode": "amd-ucode", "intel-ucode": "intel-ucode"} { + for id, pkg := range map[string]string{"amd-ucode": "amd-ucode"} { if r := m.resource(t, id); r["type"] != "package" || r["package"] != pkg { t.Fatalf("%s: %v", id, r) } diff --git a/modules/kernel/files/mesh-microcode.conf b/modules/kernel/files/mesh-microcode.conf index 6025eac81..a718205b7 100644 --- a/modules/kernel/files/mesh-microcode.conf +++ b/modules/kernel/files/mesh-microcode.conf @@ -1,5 +1,5 @@ # Module kernel, novox/hq issue 1977. mkinitcpio reads this after /etc/mkinitcpio.conf: it adds the microcode -# hook, which puts the CPU's current microcode (amd-ucode, intel-ucode) at the front of every initramfs, so +# hook, which puts the CPU's current microcode (amd-ucode) at the front of every initramfs, so # every boot entry loads it, the default one too. HOOKS that name it already are left as they are. # Written by the mesh; change the module. case "$(declare -p HOOKS)" in diff --git a/modules/kernel/module.json b/modules/kernel/module.json index 69f064d2d..7a111ea0b 100644 --- a/modules/kernel/module.json +++ b/modules/kernel/module.json @@ -125,11 +125,6 @@ "type": "package", "package": "amd-ucode" }, - { - "id": "intel-ucode", - "type": "package", - "package": "intel-ucode" - }, { "id": "microcode-hook", "type": "file", From bb96fbbb3c459fa0920208fa61b8fcf616de9084 Mon Sep 17 00:00:00 2001 From: jochens Date: Thu, 12 Nov 2026 23:07:55 +0100 Subject: [PATCH 9/9] zfs: the scheduled run names its minutes, so its timer starts (issue 1979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node-engine writes a cron step (*/15) as a calendar systemd refuses, so zfs-mesh-scrub.timer never started and the pool had no scrub schedule. A list of minutes is written as *-*-* *:0,15,30,45:00, which systemd takes. Builds: design 42 zfs-owns-zfs §18. Tests: 1979/AC1. --- modules/zfs/cmd/zfs-tools/manifest_test.go | 8 +++++++- modules/zfs/module.json | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/zfs/cmd/zfs-tools/manifest_test.go b/modules/zfs/cmd/zfs-tools/manifest_test.go index ca82669c4..b917c57a1 100644 --- a/modules/zfs/cmd/zfs-tools/manifest_test.go +++ b/modules/zfs/cmd/zfs-tools/manifest_test.go @@ -121,10 +121,16 @@ func TestExactlyOneScrubScheduleAndNoSettingInAUnitFile(t *testing.T) { } run := byID["scrub-run"] args, _ := run["run"].([]any) - if run["type"] != "process" || run["name"] != "zfs-mesh-scrub" || run["schedule"] != "*/15 * * * *" || run["user"] != nil || + if run["type"] != "process" || run["name"] != "zfs-mesh-scrub" || run["schedule"] != "0,15,30,45 * * * *" || run["user"] != nil || len(args) != 2 || args[1] != "scrub-run" { t.Errorf("the scheduled run: %v", run) } + // The node-engine writes a cron step ("*/15") as a calendar systemd refuses ("*:*/15"), and the timer never + // starts (novox/hq issue 1979, on the home server 2026-11-12). A list of minutes is written as a calendar + // systemd takes ("*-*-* *:0,15,30,45:00"), so the schedule names its minutes and never a step. + if s, _ := run["schedule"].(string); strings.Contains(s, "/") { + t.Errorf("the scheduled run's schedule %q uses a step, which the node-engine writes as a calendar systemd refuses", s) + } if ScrubTimer != run["name"].(string)+".timer" || ScrubService != run["name"].(string)+".service" { t.Errorf("the tools read %s and %s; the node-engine names them after %v", ScrubTimer, ScrubService, run["name"]) } diff --git a/modules/zfs/module.json b/modules/zfs/module.json index c109320a6..195263374 100644 --- a/modules/zfs/module.json +++ b/modules/zfs/module.json @@ -195,7 +195,7 @@ "./zfs-tools", "scrub-run" ], - "schedule": "*/15 * * * *" + "schedule": "0,15,30,45 * * * *" }, { "id": "distribution-weekly",