UEFI PXE through signed shim and GRUB (Secure Boot)
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 40s

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-<mac>, 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 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-30 10:25:54 +01:00
1 parent ab20c277b2
commit a0df7295ea
10 files changed
+222 -9

No files matched your search

+126
View File
@@ -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-<mac>, else grub.cfg-default
// grub/grub.cfg-<mac> 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, "'", `'\''`) + "'"
}
+39
View File
@@ -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")
}
}
+6 -3
View File
@@ -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)
+2 -2
View File
@@ -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") {
+29
View File
@@ -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()
}
+1 -1
View File
@@ -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
+9 -1
View File
@@ -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"
+8
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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