Merge main into feat/47-G-join11 (G takes in main before its push)
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
@@ -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 (one machine: revision 0x0a201016 while the package held 0x0a20102e). The module
|
||||
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 |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 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 which images carry microcode.
|
||||
|
||||
## 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/<the running kernel>`: 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, 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 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
|
||||
|
||||
@@ -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"
|
||||
@@ -23,6 +25,50 @@ 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"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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 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]
|
||||
}
|
||||
}
|
||||
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+)`)
|
||||
@@ -33,6 +79,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))
|
||||
|
||||
@@ -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, 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() },
|
||||
|
||||
@@ -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 microcode-hook initramfs initramfs-unit initramfs-apply" {
|
||||
t.Fatalf("order: %v", ids)
|
||||
}
|
||||
script := m.resource(t, "huge-pages")
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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 microcode-hook initramfs initramfs-unit initramfs-apply") {
|
||||
t.Fatalf("order: %v", ids)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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, 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"), 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, "${") {
|
||||
t.Fatal("a substitution is left in the script")
|
||||
}
|
||||
write(t, b.script, content, 0o755)
|
||||
write(t, b.dropin, "HOOKS+=(microcode)\n", 0o644)
|
||||
// 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, 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
|
||||
}
|
||||
|
||||
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 TestAC5KernelBootNamesTheRunningMicrocodeAndWhichImagesCarryIt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
put(t, root, "proc/cpuinfo", "processor\t: 0\nmicrocode\t: 0x0a201016\nmodel name\t: x\n")
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/bin/sh
|
||||
# The initramfs images as the mesh builds them (module kernel, novox/hq issue 1977). Run by
|
||||
# mesh-kernel-initramfs.service:
|
||||
#
|
||||
# initramfs apply [<the microcode drop-in> [<this module's state> [<preset directory> [<boot directory>]]]]
|
||||
#
|
||||
# `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.
|
||||
#
|
||||
# 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 [<drop-in> [<state directory>]]" >&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
|
||||
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"
|
||||
@@ -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, the presets and its own state; mkinitcpio keeps its work in a private /tmp.
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/boot /etc/mkinitcpio.d ${dir:state}
|
||||
PrivateTmp=yes
|
||||
ProtectHome=yes
|
||||
PrivateNetwork=yes
|
||||
NoNewPrivileges=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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) 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
|
||||
@@ -119,6 +119,48 @@
|
||||
"unit": "linux-modules-cleanup.service",
|
||||
"state": "stopped",
|
||||
"boot": "enabled"
|
||||
},
|
||||
{
|
||||
"id": "amd-ucode",
|
||||
"type": "package",
|
||||
"package": "amd-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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LicencePutVerb is the verb that hands a licence's secret to the vault.
|
||||
@@ -78,3 +81,51 @@ 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, bad, err := v.Store.ListReadable()
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"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 +118,64 @@ 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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"] != 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]
|
||||
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)
|
||||
}
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.<key>.grant, licence.<key>.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 " +
|
||||
|
||||
@@ -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()) }},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()) }},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
"./zfs-tools",
|
||||
"scrub-run"
|
||||
],
|
||||
"schedule": "*/15 * * * *"
|
||||
"schedule": "0,15,30,45 * * * *"
|
||||
},
|
||||
{
|
||||
"id": "distribution-weekly",
|
||||
|
||||
Reference in New Issue
Block a user