From a0df7295eac8807b024c0a5b0a95c25ee1e4a421 Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Fri, 30 Oct 2026 10:25:54 +0100 Subject: [PATCH] UEFI PXE through signed shim and GRUB (Secure Boot) UEFI machines were given syslinux.efi, which is unsigned: with Secure Boot on (the default on much server hardware) the firmware refuses it ("Access Denied", seen on an OVMF lab VM), so such a machine could not PXE-boot at all. They now get Debian's signed shim (bootx64.efi), which loads the signed network GRUB (grubx64.efi); GRUB reads grub/grub.cfg-, else grub/grub.cfg-default, and loads kernels only as shim verifies them. Rack controllers write the GRUB menus from the pxelinux menus they already get (same entries: kernel and initrd, local disk via exit, power off via halt), so both always agree; BIOS machines keep pxelinux. install.sh, install-rack.sh and bootstrap.sh install shim-signed and grub-efi-amd64-signed and lay out the files. Secure Boot covers the kernel; the initrd and the squashfs it fetches are not covered by it. Co-Authored-By: Claude Opus 5.5 --- cmd/rollcall/grub.go | 126 +++++++++++++++++++++++++++++++++++ cmd/rollcall/grub_test.go | 39 +++++++++++ cmd/rollcall/network.go | 9 ++- cmd/rollcall/network_test.go | 4 +- cmd/rollcall/rackctl.go | 29 ++++++++ deploy/bootstrap.sh | 2 +- deploy/install-rack.sh | 10 ++- deploy/install.sh | 8 +++ deploy/rollcall-rack.service | 2 +- deploy/rollcall.service | 2 +- 10 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 cmd/rollcall/grub.go create mode 100644 cmd/rollcall/grub_test.go diff --git a/cmd/rollcall/grub.go b/cmd/rollcall/grub.go new file mode 100644 index 0000000..7184f51 --- /dev/null +++ b/cmd/rollcall/grub.go @@ -0,0 +1,126 @@ +package main + +// UEFI machines boot through shim and GRUB, both signed (Debian's +// shim-signed and grub-efi-amd64-signed), so a machine with Secure Boot on +// PXE-boots at all (syslinux.efi is unsigned: the firmware refuses it) and +// GRUB loads only kernels that shim verifies. BIOS machines keep pxelinux. +// +// TFTP root: +// +// bootx64.efi shim (shimx64.efi.signed) +// grubx64.efi GRUB for network boot (grubnetx64.efi.signed) +// grub/grub.cfg picks grub.cfg-, else grub.cfg-default +// grub/grub.cfg- per machine, made from its pxelinux file +// +// The rack controller writes the GRUB files from the pxelinux ones it gets, +// so both menus always say the same. Secure Boot checks the kernel; the +// initrd and the squashfs it fetches are not covered by it. + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +const grubDispatch = `# generated by rollcall: the machine's own menu, else the one for unknown machines +set timeout=8 +configfile $prefix/grub.cfg-$net_default_mac +configfile $prefix/grub.cfg-default +` + +var grubNameRe = regexp.MustCompile(`^grub\.cfg-([0-9a-f]{2}:){5}[0-9a-f]{2}$`) + +// grubName is the GRUB file for a pxelinux one: 01-aa-bb-cc-dd-ee-ff -> grub.cfg-aa:bb:cc:dd:ee:ff. +func grubName(pxe string) string { + if !strings.HasPrefix(pxe, "01-") { + return "" + } + n := "grub.cfg-" + strings.ReplaceAll(strings.TrimPrefix(pxe, "01-"), "-", ":") + if !grubNameRe.MatchString(n) { + return "" + } + return n +} + +type pxeEntry struct { + label, kernel, initrd, append string + local, poweroff bool +} + +// pxelinuxToGrub turns one of rollcall's pxelinux menus into the same GRUB menu. +func pxelinuxToGrub(body string) string { + var entries []*pxeEntry + def, timeout := 0, 5 + var cur *pxeEntry + for _, raw := range strings.Split(body, "\n") { + line := strings.TrimSpace(raw) + f := strings.Fields(line) + if len(f) == 0 || strings.HasPrefix(line, "#") { + continue + } + key := strings.ToUpper(f[0]) + rest := strings.TrimSpace(line[len(f[0]):]) + switch { + case key == "TIMEOUT": + if n, err := strconv.Atoi(rest); err == nil { // tenths of a second + timeout = (n + 9) / 10 + } + case key == "LABEL": + cur = &pxeEntry{label: rest} + entries = append(entries, cur) + case cur == nil: + case key == "MENU" && len(f) > 1 && strings.EqualFold(f[1], "LABEL"): + cur.label = strings.TrimSpace(rest[len(f[1]):]) + case key == "MENU" && len(f) > 1 && strings.EqualFold(f[1], "DEFAULT"): + def = len(entries) - 1 + case key == "KERNEL": + if strings.HasSuffix(rest, "poweroff.c32") { + cur.poweroff = true + } else { + cur.kernel = rest + } + case key == "INITRD": + cur.initrd = rest + case key == "APPEND": + cur.append = rest + case key == "LOCALBOOT": + cur.local = true + } + } + var b strings.Builder + b.WriteString("# generated by rollcall from the pxelinux menu\n") + fmt.Fprintf(&b, "set default=%d\nset timeout=%d\n", def, timeout) + for _, e := range entries { + fmt.Fprintf(&b, "menuentry %s {\n", grubQuote(e.label)) + switch { + case e.poweroff: + b.WriteString(" halt\n") + case e.local: + b.WriteString(" exit 1\n") // back to the firmware: its next boot option is the disk + case e.kernel != "": + fmt.Fprintf(&b, " linux %s", grubQuote("/"+strings.TrimPrefix(e.kernel, "/"))) + for _, w := range strings.Fields(e.append) { + b.WriteString(" " + grubQuote(w)) + } + b.WriteString("\n") + if e.initrd != "" { + fmt.Fprintf(&b, " initrd %s\n", grubQuote("/"+strings.TrimPrefix(e.initrd, "/"))) + } + default: + b.WriteString(" exit 1\n") + } + b.WriteString("}\n") + } + return b.String() +} + +var grubPlain = regexp.MustCompile(`^[A-Za-z0-9_./:=,+@%-]+$`) + +// grubQuote makes a word safe in GRUB's script language (no $ expansion, no ;). +func grubQuote(s string) string { + if grubPlain.MatchString(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/cmd/rollcall/grub_test.go b/cmd/rollcall/grub_test.go new file mode 100644 index 0000000..cd32028 --- /dev/null +++ b/cmd/rollcall/grub_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "strings" + "testing" +) + +func TestPxelinuxToGrub(t *testing.T) { + commissioned := fmt.Sprintf(commissionedTmpl, "now", "m-1", "QEMU", "PC", "S1", "m-1", "QEMU", "PC", "S1", + "boot=live components fetch=http://10.213.77.1/rollcall/filesystem.squashfs rollcall.server=http://10.213.77.1 rollcall.token=abc") + g := pxelinuxToGrub(commissioned) + for _, want := range []string{ + "set default=0\nset timeout=15\n", + "menuentry 'Commissioned: power off (default in 15s)' {\n halt\n}", + "menuentry 'Re-run commissioning' {\n linux /rollcall/vmlinuz boot=live components fetch=http://10.213.77.1/rollcall/filesystem.squashfs", + " initrd /rollcall/initrd.img\n", + "menuentry 'Boot from local disk' {\n exit 1\n}", + } { + if !strings.Contains(g, want) { + t.Errorf("missing %q in\n%s", want, g) + } + } + deployed := fmt.Sprintf(deployedTmpl, "now", "m-1", "debian-13", "1", "node01", "debian-13", "m-1", "debian-13", "boot=live") + if g := pxelinuxToGrub(deployed); !strings.Contains(g, "set default=0\n") || !strings.Contains(g, "exit 1") { + t.Errorf("deployed:\n%s", g) + } + // nothing from a cmdline can break out of the menu entry + g = pxelinuxToGrub("LABEL x\n MENU DEFAULT\n KERNEL k\n APPEND a=$(x) b;reboot c='q'\n") + if !strings.Contains(g, `'a=$(x)' 'b;reboot' 'c='\''q'\'''`) { + t.Errorf("quoting:\n%s", g) + } + if n := grubName("01-bc-24-11-f7-53-6b"); n != "grub.cfg-bc:24:11:f7:53:6b" { + t.Errorf("name %q", n) + } + if grubName("01-../x") != "" || grubName("default") != "" { + t.Error("a non-MAC file name became a GRUB file") + } +} diff --git a/cmd/rollcall/network.go b/cmd/rollcall/network.go index 4a0d5ac..046840e 100644 --- a/cmd/rollcall/network.go +++ b/cmd/rollcall/network.go @@ -1215,6 +1215,9 @@ func (a *App) netPlan(ctx context.Context, m *Machine, allocate bool) (*NetPlan, // ---------------------------------------------------------------- dnsmasq +// uefiBoot is what UEFI machines load: signed shim, then signed GRUB (grub.go). +const uefiBoot = "bootx64.efi" + const dnsmasqBase = `# generated by rollcall for rack controller %s; do not edit (rollcall subnet/vlan commands change it) bind-dynamic %sdomain-needed @@ -1362,8 +1365,8 @@ func dnsmasqConfig(rc RackController, subnets []Subnet, dnsDir string) (string, t = "tag:" + i + "," } fmt.Fprintf(&b, "pxe-service=%sx86PC,\"Rollcall\",pxelinux\n", t) - fmt.Fprintf(&b, "pxe-service=%sBC_EFI,\"Rollcall\",efi64/syslinux.efi\n", t) - fmt.Fprintf(&b, "pxe-service=%sX86-64_EFI,\"Rollcall\",efi64/syslinux.efi\n", t) + fmt.Fprintf(&b, "pxe-service=%sBC_EFI,\"Rollcall\",%s\n", t, uefiBoot) + fmt.Fprintf(&b, "pxe-service=%sX86-64_EFI,\"Rollcall\",%s\n", t, uefiBoot) } continue } @@ -1372,7 +1375,7 @@ func dnsmasqConfig(rc RackController, subnets []Subnet, dnsDir string) (string, // boot files per served subnet: dhcp-boot matching a proxy subnet's clients as // well as its pxe-service sends dnsmasq 2.91 into a busy loop (lab, 2026-10-29) fmt.Fprintf(&b, "dhcp-boot=tag:%s,tag:bios,pxelinux.0\n", tag) - fmt.Fprintf(&b, "dhcp-boot=tag:%s,tag:efi64,efi64/syslinux.efi\n", tag) + fmt.Fprintf(&b, "dhcp-boot=tag:%s,tag:efi64,%s\n", tag, uefiBoot) } if sn.Domain != "" { fmt.Fprintf(&b, "domain=%s,%s\n", sn.Domain, sn.CIDR) diff --git a/cmd/rollcall/network_test.go b/cmd/rollcall/network_test.go index 86e26e5..3d51878 100644 --- a/cmd/rollcall/network_test.go +++ b/cmd/rollcall/network_test.go @@ -299,7 +299,7 @@ func TestProxyDHCP(t *testing.T) { if !ok { t.Fatal("no config") } - for _, want := range []string{"interface=eth1", "dhcp-range=10.0.0.0,proxy,255.255.255.0", `pxe-service=tag:eth1,x86PC,"Rollcall",pxelinux`, `pxe-service=tag:eth1,X86-64_EFI,"Rollcall",efi64/syslinux.efi`} { + for _, want := range []string{"interface=eth1", "dhcp-range=10.0.0.0,proxy,255.255.255.0", `pxe-service=tag:eth1,x86PC,"Rollcall",pxelinux`, `pxe-service=tag:eth1,X86-64_EFI,"Rollcall",bootx64.efi`} { if !strings.Contains(conf, want) { t.Errorf("proxy config lacks %s:\n%s", want, conf) } @@ -328,7 +328,7 @@ func TestDhcpBootOnlyForServedSubnets(t *testing.T) { proxy.CIDR, proxy.Gateway, proxy.DHCPMode, proxy.Ranges = "10.9.0.0/24", "", "proxy", nil rc := RackController{Name: "r1", Interfaces: []RackIface{{Name: "eth1", Addrs: []string{"10.0.0.2/24"}}, {Name: "eth2", Addrs: []string{"10.9.0.2/24"}}}} conf, _ := dnsmasqConfig(rc, []Subnet{serve, proxy}, "/d") - if !strings.Contains(conf, "dhcp-boot=tag:s0,tag:bios,pxelinux.0") || !strings.Contains(conf, "dhcp-boot=tag:s0,tag:efi64,efi64/syslinux.efi") { + if !strings.Contains(conf, "dhcp-boot=tag:s0,tag:bios,pxelinux.0") || !strings.Contains(conf, "dhcp-boot=tag:s0,tag:efi64,bootx64.efi") { t.Errorf("served subnet lacks its boot files:\n%s", conf) } for _, l := range strings.Split(conf, "\n") { diff --git a/cmd/rollcall/rackctl.go b/cmd/rollcall/rackctl.go index 272b258..af8e8e6 100644 --- a/cmd/rollcall/rackctl.go +++ b/cmd/rollcall/rackctl.go @@ -628,6 +628,30 @@ func applyRackState(st *rackState, tftp, dnsDir string) (changed []string, err e } } } + // the same menus for UEFI (shim + GRUB, grub.go), where the install laid that out + if gdir := filepath.Join(tftp, "grub"); dirExists(gdir) { + ch, e := writeIfChanged(filepath.Join(gdir, "grub.cfg"), grubDispatch, 0o644) + note("grub.cfg", ch, e) + if st.DefaultPXE != "" { + ch, e := writeIfChanged(filepath.Join(gdir, "grub.cfg-default"), pxelinuxToGrub(st.DefaultPXE), 0o644) + note("grub.cfg-default", ch, e) + } + want := map[string]bool{} + for name, body := range st.PXE { + if g := grubName(name); g != "" { + want[g] = true + ch, e := writeIfChanged(filepath.Join(gdir, g), pxelinuxToGrub(body), 0o644) + note(g, ch, e) + } + } + if olds, e := filepath.Glob(filepath.Join(gdir, "grub.cfg-*:*")); e == nil { + for _, p := range olds { + if !want[filepath.Base(p)] && !strings.HasSuffix(p, ".tmp") { + note("-"+filepath.Base(p), true, os.Remove(p)) + } + } + } + } if dnsDir != "" { ch, e := writeIfChanged(filepath.Join(dnsDir, "hosts.dhcp"), st.HostsDHCP, 0o644) note("hosts.dhcp", ch, e) @@ -969,3 +993,8 @@ func localAddrs() []string { } return out } + +func dirExists(p string) bool { + st, err := os.Stat(p) + return err == nil && st.IsDir() +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 1db5aef..34a18f6 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -95,7 +95,7 @@ fi # ------------------------------------------------------------------ 1. packages say "1/9 packages" export DEBIAN_FRONTEND=noninteractive -PKGS="postgresql dnsmasq pxelinux syslinux-common syslinux-efi qemu-utils ipmitool jq zstd curl ca-certificates python3 nftables lldpd" +PKGS="postgresql dnsmasq pxelinux syslinux-common syslinux-efi shim-signed grub-efi-amd64-signed qemu-utils ipmitool jq zstd curl ca-certificates python3 nftables lldpd" [ $BUILD = 1 ] && PKGS="$PKGS live-build debootstrap squashfs-tools xorriso" apt-get update -q >/dev/null apt-get install -yq $PKGS >/dev/null diff --git a/deploy/install-rack.sh b/deploy/install-rack.sh index f20c56a..0ab63f3 100755 --- a/deploy/install-rack.sh +++ b/deploy/install-rack.sh @@ -15,7 +15,7 @@ set -eu cd "$(dirname "$0")" [ -r /etc/rollcall/rack.json ] || { echo "enroll first: ./rollcall rack join CODE -url http://ADDR (code from: rollcall controller join-code on the region)" >&2; exit 1; } export DEBIAN_FRONTEND=noninteractive -apt-get install -y -q dnsmasq ipmitool pxelinux syslinux-common syslinux-efi jq openssl >/dev/null +apt-get install -y -q dnsmasq ipmitool pxelinux syslinux-common syslinux-efi shim-signed grub-efi-amd64-signed jq openssl >/dev/null getent passwd rollcall >/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin --user-group rollcall [ -x ./rollcall ] && install -m 0755 rollcall /usr/local/bin/rollcall @@ -38,6 +38,14 @@ for f in ldlinux.c32 menu.c32 libutil.c32 libcom32.c32 poweroff.c32; do cp /usr/ cp /usr/lib/SYSLINUX.EFI/efi64/syslinux.efi "$TFTP/efi64/" for f in ldlinux.e64 menu.c32 libutil.c32 libcom32.c32; do cp /usr/lib/syslinux/modules/efi64/$f "$TFTP/efi64/"; done ln -sfn ../pxelinux.cfg "$TFTP/efi64/pxelinux.cfg" +# UEFI, Secure Boot included: signed shim and GRUB; rollcall writes the GRUB menus next to pxelinux's +if [ -f /usr/lib/shim/shimx64.efi.signed ] && [ -f /usr/lib/grub/x86_64-efi-signed/grubnetx64.efi.signed ]; then + install -m 0644 /usr/lib/shim/shimx64.efi.signed "$TFTP"/bootx64.efi + install -m 0644 /usr/lib/grub/x86_64-efi-signed/grubnetx64.efi.signed "$TFTP"/grubx64.efi + install -d -o root -g rollcall -m 2775 "$TFTP"/grub +else + echo "WARNING: shim-signed or grub-efi-amd64-signed missing: UEFI machines cannot PXE-boot (apt install shim-signed grub-efi-amd64-signed)" >&2 +fi ln -sfn "$CACHE/rollcall" "$TFTP/rollcall" ln -sfn "$CACHE/images" "$TFTP/images" ln -sfn ../rollcall "$TFTP/efi64/rollcall" diff --git a/deploy/install.sh b/deploy/install.sh index 32cffff..9d840e7 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -16,6 +16,14 @@ chown rollcall:rollcall /etc/rollcall # rollcall writes per-MAC pxelinux configs chgrp rollcall /srv/tftp/pxelinux.cfg && chmod 2775 /srv/tftp/pxelinux.cfg install -m 0644 /usr/lib/syslinux/modules/bios/poweroff.c32 /srv/tftp/poweroff.c32 +# UEFI, Secure Boot included: signed shim and GRUB; rollcall writes the GRUB menus next to pxelinux's +if [ -f /usr/lib/shim/shimx64.efi.signed ] && [ -f /usr/lib/grub/x86_64-efi-signed/grubnetx64.efi.signed ]; then + install -m 0644 /usr/lib/shim/shimx64.efi.signed /srv/tftp/bootx64.efi + install -m 0644 /usr/lib/grub/x86_64-efi-signed/grubnetx64.efi.signed /srv/tftp/grubx64.efi + install -d -o root -g rollcall -m 2775 /srv/tftp/grub +else + echo "WARNING: shim-signed or grub-efi-amd64-signed missing: UEFI machines cannot PXE-boot (apt install shim-signed grub-efi-amd64-signed)" >&2 +fi sed -e "s/@AGENT_TOKEN@/$(jq -r .agent_token /etc/rollcall/config.json)/g" -e "s#@SERVER@#$(jq -r .server_url /etc/rollcall/config.json)#g" pxelinux-default > /srv/tftp/pxelinux.cfg/default && chmod 0644 /srv/tftp/pxelinux.cfg/default [ -e /srv/tftp/rollcall ] || ln -s /srv/rollcall/image /srv/tftp/rollcall [ -d /srv/tftp/efi64 ] && ln -sfn ../rollcall /srv/tftp/efi64/rollcall diff --git a/deploy/rollcall-rack.service b/deploy/rollcall-rack.service index 71b42e1..b2f16b7 100644 --- a/deploy/rollcall-rack.service +++ b/deploy/rollcall-rack.service @@ -15,7 +15,7 @@ NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes -ReadWritePaths=/srv/tftp/pxelinux.cfg /var/lib/rollcall /var/cache/rollcall +ReadWritePaths=/srv/tftp/pxelinux.cfg -/srv/tftp/grub /var/lib/rollcall /var/cache/rollcall [Install] WantedBy=multi-user.target diff --git a/deploy/rollcall.service b/deploy/rollcall.service index 7bf81cb..3dd00c7 100644 --- a/deploy/rollcall.service +++ b/deploy/rollcall.service @@ -16,7 +16,7 @@ NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes -ReadWritePaths=/etc/rollcall /srv/tftp/pxelinux.cfg /var/lib/rollcall +ReadWritePaths=/etc/rollcall /srv/tftp/pxelinux.cfg -/srv/tftp/grub /var/lib/rollcall [Install] WantedBy=multi-user.target