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-<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:
1 parent
ab20c277b2
commit
a0df7295ea
10 files changed
+222
-9
No files matched your search
@@ -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, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user