From f56ee23c2a8cbe28b8f4049033478ad0130c7fd1 Mon Sep 17 00:00:00 2001 From: BarbUk Date: Tue, 14 Jul 2026 15:04:12 +0100 Subject: [PATCH 01/13] Add archlinux aur packages information --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index da093c9..6ce1364 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,27 @@ cd bootimus docker-compose up -d ``` +### Archlinux aur packages + +[![bootimus](https://img.shields.io/aur/version/bootimus?label=bootimus)](https://aur.archlinux.org/packages/bootimus/) +[![bootimus-bin](https://img.shields.io/aur/version/bootimus-bin?label=bootimus-bin)](https://aur.archlinux.org/packages/bootimus-bin/) + +bootimus is available on the [AUR](https://wiki.archlinux.org/index.php/Arch_User_Repository): +- [bootimus](https://aur.archlinux.org/packages/bootimus/) (release package with systemd integration) +- [bootimus-bin](https://aur.archlinux.org/packages/bootimus-bin/) (standalone binary release package) + +You can install it using your [AUR helper](https://wiki.archlinux.org/index.php/AUR_helpers) of choice. + +Example: +```shell +$ yay -Sy bootimus + +# Edit /etc/bootimus/bootimus.yaml with your preference + +# And start the service +$ systemctl start bootimus +``` + ## Documentation - **[Deployment Guide](docs/en/deployment.md)** - Docker, binary, networking, and storage From 251dee6ea060f30b94c374c097a2a5171b3ad5e0 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Sat, 18 Jul 2026 20:24:17 +0100 Subject: [PATCH 02/13] add support for booting windows pe you must also configure the image boot parameters to, including a initial blank line: initrd {{BASE_URL}}/boot/{{CACHE_DIR}}/iso/Boot/BCD bcd initrd {{BASE_URL}}/boot/{{CACHE_DIR}}/iso/Boot/boot.sdi boot.sdi --- distro-profiles.json | 4 ++-- internal/server/menu.go | 14 +++----------- internal/wim/wim.go | 16 +++++++++++++++- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/distro-profiles.json b/distro-profiles.json index b5858af..faa497c 100644 --- a/distro-profiles.json +++ b/distro-profiles.json @@ -470,7 +470,7 @@ "display_name": "Windows 7", "family": "windows", "filename_patterns": ["windows_7", "win7", "windows7"], - "kernel_paths": ["/sources/boot.wim"], + "kernel_paths": [], "initrd_paths": [], "squashfs_paths": [], "default_boot_params": "rawbcd", @@ -482,7 +482,7 @@ "display_name": "Windows", "family": "windows", "filename_patterns": ["windows", "win10", "win11", "win8", "server2022", "server2019", "server2016"], - "kernel_paths": ["/sources/boot.wim"], + "kernel_paths": [], "initrd_paths": [], "squashfs_paths": [], "default_boot_params": "", diff --git a/internal/server/menu.go b/internal/server/menu.go index 504e403..8f49ae7 100644 --- a/internal/server/menu.go +++ b/internal/server/menu.go @@ -307,16 +307,8 @@ func (mb *MenuBuilder) buildKernelBootSection(img *models.Image, encodedFilename switch img.Distro { case "windows", "windows7": - // wimboot's command line only takes its own flags — kernel parameters - // and the generic iso-url fallback are meaningless here and make it - // abort with "Unrecognised argument" (the UI locks the field for the - // same reason). Only the legacy windows7 profile passes flags (rawbcd). - wimbootArgs := "" - if img.Distro == "windows7" { - wimbootArgs = bootParams - } sb.WriteString("echo Loading Windows boot files via wimboot...\n") - sb.WriteString(fmt.Sprintf("kernel %s/wimboot%s\n", baseURL, wimbootArgs)) + sb.WriteString(fmt.Sprintf("kernel %s/wimboot%s\n", baseURL, bootParams)) // Ship only boot.wim and let wimboot synthesize the ramdisk BCD + // boot.sdi (the documented minimal setup). Feeding the ISO's DVD BCD // hangs 24H2/25H2 media on a black screen after the loading bar. @@ -340,7 +332,7 @@ func (mb *MenuBuilder) resolveBootParams(img *models.Image, baseURL, encodedFile params = mb.profileManager.GetBootParams(img.Distro, hasSquashfs) } - if params == "" { + if params == "" && !strings.HasPrefix(img.Distro, "windows") { params = fmt.Sprintf("iso-url=%s/isos/%s ip=dhcp", baseURL, encodedFilename) } @@ -352,7 +344,7 @@ func (mb *MenuBuilder) resolveBootParams(img *models.Image, baseURL, encodedFile params = strings.ReplaceAll(params, "{{SQUASHFS}}", fmt.Sprintf("%s/boot/%s/%s", baseURL, cacheDir, img.SquashfsPath)) } - return strings.TrimSpace(params) + return params } func (mb *MenuBuilder) buildFooter() string { diff --git a/internal/wim/wim.go b/internal/wim/wim.go index c2ba2f5..49f7b4c 100644 --- a/internal/wim/wim.go +++ b/internal/wim/wim.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" ) @@ -131,7 +132,20 @@ func (m *Manager) PatchStartnetCmd(wimPath, content string) error { "add %s /Windows/System32/startnet.cmd\nadd %s /Windows/System32/winpeshl.ini\n", startnetTmp, winpeshlTmp, ) - cmd := exec.Command(m.wimlibPath, "update", wimPath, "2", "--rebuild") + + imageCount, err := m.GetImageCount(wimPath) + if err != nil { + return fmt.Errorf("failed to get WIM image count: %w", err) + } + + // Validate that we have one or two images. + // NB In Windows PE ISO boot.wim: only one image (index 1) is present. + // NB In Windows Setup ISO boot.wim: two images (index 1 and 2) are present. + if imageCount < 1 || imageCount > 2 { + return fmt.Errorf("unexpected WIM image count: %d (expected 1 or 2)", imageCount) + } + + cmd := exec.Command(m.wimlibPath, "update", wimPath, strconv.Itoa(imageCount), "--rebuild") cmd.Stdin = strings.NewReader(script) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("wimlib-imagex update failed: %w\nOutput: %s", err, string(output)) From 2c7d5ea2699964dfae50b9dae5e9b2e8f101ba49 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Tue, 28 Jul 2026 21:22:28 +0000 Subject: [PATCH 03/13] use a go template to generate the startnet script --- internal/admin/handlers.go | 64 +++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/internal/admin/handlers.go b/internal/admin/handlers.go index 303dd41..7a7ddba 100644 --- a/internal/admin/handlers.go +++ b/internal/admin/handlers.go @@ -19,6 +19,7 @@ import ( "strconv" "strings" "sync" + "text/template" "time" "bootimus/bootloaders" @@ -188,8 +189,11 @@ func buildStartnetScript(serverAddr, shareName string, smbPort, httpPort int, is httpPort = 8080 } - base := fmt.Sprintf(`@echo off + scriptTemplate := template.Must(template.New("script").Parse(`@echo off +setlocal EnableDelayedExpansion + wpeinit + rem Windows 11 24H2+ WinPE ships with insecure guest auth disabled and SMB rem signing required; guest sessions cannot sign, so mapping the read-only rem guest share fails with access denied. Re-enable guest SMB for this @@ -207,15 +211,15 @@ ipconfig /renew >nul 2>&1 echo Waiting for network... set /a TRIES=0 :waitnet -ping -n 1 -w 1000 %s >nul 2>&1 +ping -n 1 -w 1000 {{ .ServerAddr }} >nul 2>&1 if not errorlevel 1 goto netready set /a TRIES+=1 -if %%TRIES%% geq 60 goto netfail +if %TRIES% geq 60 goto netfail ping 127.0.0.1 -n 2 >nul 2>&1 goto waitnet :netfail -echo ERROR: Could not reach %s after 60 seconds. -echo Dropping to shell. Try: ipconfig, ping %s +echo ERROR: Could not reach {{ .ServerAddr }} after 60 seconds. +echo Dropping to shell. Try: ipconfig, ping {{ .ServerAddr }} echo Type 'exit' to reboot. cmd.exe exit /b 1 @@ -226,17 +230,17 @@ set /a TRIES=0 :mapshare rem Expected to fail with "System error 53" for the first few tries while the rem SMB client stack and the server's 445 path come up — the loop handles it. -net use Z: \\%s\%s /persistent:no >nul 2>&1 +net use Z: \\{{ .ServerAddr }}\{{ .ShareName }} /persistent:no >nul 2>&1 if not errorlevel 1 goto mapped set /a TRIES+=1 -if %%TRIES%% geq 30 goto mapfail -echo Still trying to connect (%%TRIES%%/30), please wait... +if %TRIES% geq 30 goto mapfail +echo Still trying to connect (%TRIES%/30), please wait... ping 127.0.0.1 -n 4 >nul 2>&1 goto mapshare :mapfail echo. -echo ERROR: Failed to connect to \\%s\%s after 90 seconds (SMB port %d) -echo Dropping to shell for debugging. Try: net use Z: \\%s\%s +echo ERROR: Failed to connect to \\{{ .ServerAddr }}\{{ .ShareName }} after 90 seconds (SMB port {{ .SmbPort }}) +echo Dropping to shell for debugging. Try: net use Z: \\{{ .ServerAddr }}\{{ .ShareName }} /persistent:no echo Type 'exit' to reboot. cmd.exe exit /b 1 @@ -250,22 +254,38 @@ if not exist Z:\setup.exe ( cmd.exe exit /b 1 ) -`, serverAddr, serverAddr, serverAddr, serverAddr, shareName, serverAddr, shareName, smbPort, serverAddr, shareName) - launch := "echo Starting Windows Setup...\r\nZ:\\setup.exe\r\n" - if autoInstall { - launch = `copy /Y Z:\AutoUnattend.xml X:\AutoUnattend.xml >nul -if not exist X:\AutoUnattend.xml ( +set SETUP_ARGS= + +{{- if .AutoInstall }} + +if exist Z:\AutoUnattend.xml ( + copy /Y Z:\AutoUnattend.xml X:\AutoUnattend.xml >nul + set SETUP_ARGS=!SETUP_ARGS! /unattend X:\AutoUnattend.xml +) else ( echo WARNING: AutoUnattend.xml not on share, running interactive setup. - Z:\setup.exe - exit /b 0 ) -echo Starting Windows Setup (unattended)... -Z:\setup.exe /unattend:X:\AutoUnattend.xml -` - } - return base + launch +{{- end }} + +echo Starting Windows Setup... +Z:\setup.exe !SETUP_ARGS! +`)) + + var buf bytes.Buffer + err := scriptTemplate.Execute(&buf, &struct { + ServerAddr string + ShareName string + AutoInstall bool + }{ + ServerAddr: serverAddr, + ShareName: shareName, + AutoInstall: autoInstall, + }) + if err != nil { + return "" + } + return buf.String() } func isRunningInDocker() bool { From 5f72ced666426df58a8538775c94b8e6c824badd Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Tue, 28 Jul 2026 21:22:28 +0000 Subject: [PATCH 04/13] use the --windows-smb-port parameter value when mapping the actual smb share NB to use a non-default port, windows 11 24H2 (or later) or windows server 2025 (or later) is required. --- internal/admin/handlers.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/admin/handlers.go b/internal/admin/handlers.go index 7a7ddba..094174c 100644 --- a/internal/admin/handlers.go +++ b/internal/admin/handlers.go @@ -189,6 +189,8 @@ func buildStartnetScript(serverAddr, shareName string, smbPort, httpPort int, is httpPort = 8080 } + // NB to use a non-default smb port, windows 11 24H2 (or later) or windows server 2025 (or later) is required. + // see https://learn.microsoft.com/en-us/windows-server/storage/file-server/smb-ports?tabs=command-line scriptTemplate := template.Must(template.New("script").Parse(`@echo off setlocal EnableDelayedExpansion @@ -230,7 +232,7 @@ set /a TRIES=0 :mapshare rem Expected to fail with "System error 53" for the first few tries while the rem SMB client stack and the server's 445 path come up — the loop handles it. -net use Z: \\{{ .ServerAddr }}\{{ .ShareName }} /persistent:no >nul 2>&1 +net use Z: \\{{ .ServerAddr }}\{{ .ShareName }} /persistent:no{{ if ne .SmbPort 445 }} /tcpport:{{ .SmbPort }}{{ end }} >nul 2>&1 if not errorlevel 1 goto mapped set /a TRIES+=1 if %TRIES% geq 30 goto mapfail @@ -240,7 +242,7 @@ goto mapshare :mapfail echo. echo ERROR: Failed to connect to \\{{ .ServerAddr }}\{{ .ShareName }} after 90 seconds (SMB port {{ .SmbPort }}) -echo Dropping to shell for debugging. Try: net use Z: \\{{ .ServerAddr }}\{{ .ShareName }} /persistent:no +echo Dropping to shell for debugging. Try: net use Z: \\{{ .ServerAddr }}\{{ .ShareName }} /persistent:no{{ if ne .SmbPort 445 }} /tcpport:{{ .SmbPort }}{{ end }} echo Type 'exit' to reboot. cmd.exe exit /b 1 @@ -276,10 +278,12 @@ Z:\setup.exe !SETUP_ARGS! err := scriptTemplate.Execute(&buf, &struct { ServerAddr string ShareName string + SmbPort int AutoInstall bool }{ ServerAddr: serverAddr, ShareName: shareName, + SmbPort: smbPort, AutoInstall: autoInstall, }) if err != nil { From 09d57d0d2d520ec990548c6e22635f2fef1604d9 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Tue, 28 Jul 2026 21:22:28 +0000 Subject: [PATCH 05/13] load the injected windows drivers --- internal/admin/handlers.go | 8 ++++++++ internal/admin/windows.go | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/admin/handlers.go b/internal/admin/handlers.go index 094174c..c7e6e3c 100644 --- a/internal/admin/handlers.go +++ b/internal/admin/handlers.go @@ -194,6 +194,12 @@ func buildStartnetScript(serverAddr, shareName string, smbPort, httpPort int, is scriptTemplate := template.Must(template.New("script").Parse(`@echo off setlocal EnableDelayedExpansion +if exist x:\drivers ( + echo Loading drivers... + reg add "HKLM\Software\Microsoft\Windows NT\CurrentVersion\UnattendSettings\PnPUnattend\DriverPaths\1" /v Path /t REG_SZ /d x:\drivers /f >nul 2>&1 + PnPUnattend auditSystem /l +) + wpeinit rem Windows 11 24H2+ WinPE ships with insecure guest auth disabled and SMB @@ -259,6 +265,8 @@ if not exist Z:\setup.exe ( set SETUP_ARGS= +if exist x:\drivers set SETUP_ARGS=!SETUP_ARGS! /installdrivers x:\drivers + {{- if .AutoInstall }} if exist Z:\AutoUnattend.xml ( diff --git a/internal/admin/windows.go b/internal/admin/windows.go index fb67f94..0daf6aa 100644 --- a/internal/admin/windows.go +++ b/internal/admin/windows.go @@ -109,7 +109,7 @@ func (h *Handler) RebuildBootWim(imageID uint) error { log.Printf(" Updating image %d...", idx) extractCmd := exec.Command("wimupdate", bootWimPath, fmt.Sprintf("%d", idx)) - extractCmd.Stdin = strings.NewReader(fmt.Sprintf("add \"%s\" \"/Windows/System32/DriverStore/FileRepository\"\n", driversDir)) + extractCmd.Stdin = strings.NewReader(fmt.Sprintf("add \"%s\" \"/drivers\"\n", driversDir)) if output, err := extractCmd.CombinedOutput(); err != nil { log.Printf("wimupdate output: %s", string(output)) return fmt.Errorf("failed to update WIM image %d: %w", idx, err) From dd36486325ba60e74a8ef4f8c3f419190a158056 Mon Sep 17 00:00:00 2001 From: Harrison Caldicott Date: Tue, 11 Aug 2026 13:00:15 +1000 Subject: [PATCH 06/13] fix(menu): boot one-time image directly --- internal/server/menu.go | 18 ++++++++++++++++- internal/server/menu_test.go | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/internal/server/menu.go b/internal/server/menu.go index c4207cc..b1bf4a4 100644 --- a/internal/server/menu.go +++ b/internal/server/menu.go @@ -91,7 +91,11 @@ func (mb *MenuBuilder) Build() string { var sb strings.Builder sb.WriteString("#!ipxe\n\n") - sb.WriteString(mb.buildMainMenu()) + if mb.hasEnabledImage(mb.nextBootImageID) { + sb.WriteString(fmt.Sprintf("goto iso%d\n\n", mb.nextBootImageID)) + } else { + sb.WriteString(mb.buildMainMenu()) + } sb.WriteString(mb.buildGroupMenus()) sb.WriteString(mb.buildImageBootSections()) sb.WriteString(mb.buildFooter()) @@ -99,6 +103,18 @@ func (mb *MenuBuilder) Build() string { return sb.String() } +func (mb *MenuBuilder) hasEnabledImage(id uint) bool { + if id == 0 { + return false + } + for i := range mb.images { + if mb.images[i].ID == id && mb.images[i].Enabled { + return true + } + } + return false +} + func (mb *MenuBuilder) menuTimeoutMs() int { if mb.theme != nil && mb.theme.MenuTimeout == 0 { return 0 diff --git a/internal/server/menu_test.go b/internal/server/menu_test.go index 83e31a1..9afab76 100644 --- a/internal/server/menu_test.go +++ b/internal/server/menu_test.go @@ -16,6 +16,44 @@ func testMenuBuilder(types map[uint]string) *MenuBuilder { } } +func TestBuildNextBootBypassesMenuForGroupedImage(t *testing.T) { + groupID := uint(3) + mb := testMenuBuilder(map[uint]string{7: "kickstart"}) + mb.nextBootImageID = 7 + mb.images = []models.Image{ + { + ID: 7, + Name: "AlmaLinux", + Filename: "almalinux.iso", + Enabled: true, + BootMethod: "kernel", + Distro: "alma", + GroupID: &groupID, + }, + } + + out := mb.Build() + if !strings.HasPrefix(out, "#!ipxe\n\ngoto iso7\n\n") { + t.Fatalf("expected next boot to jump directly to the image, got:\n%s", out) + } + if !strings.Contains(out, ":iso7\n") { + t.Fatalf("expected next boot image section to be emitted, got:\n%s", out) + } + if strings.Contains(out, ":start\nmenu ") { + t.Fatalf("expected next boot to bypass the interactive menu, got:\n%s", out) + } +} + +func TestBuildMissingNextBootFallsBackToMenu(t *testing.T) { + mb := testMenuBuilder(nil) + mb.nextBootImageID = 99 + + out := mb.Build() + if !strings.Contains(out, ":start\nmenu ") { + t.Fatalf("expected an unavailable next boot image to fall back to the menu, got:\n%s", out) + } +} + func TestBuildKernelBootSectionAutoInstallParams(t *testing.T) { img := &models.Image{ ID: 7, From 2ebef152feac6f7fe29c1340cfaa2723a3ac2f67 Mon Sep 17 00:00:00 2001 From: Harrison Caldicott Date: Tue, 11 Aug 2026 13:59:39 +1000 Subject: [PATCH 07/13] Apply per-client bootloader sets --- README.md | 6 + docs/en/clients.md | 17 ++ internal/proxydhcp/proxydhcp.go | 13 +- internal/proxydhcp/proxydhcp_test.go | 39 ++++ internal/server/bootloader_selection_test.go | 195 +++++++++++++++++++ internal/server/server.go | 174 ++++++++++++++--- 6 files changed, 414 insertions(+), 30 deletions(-) create mode 100644 internal/proxydhcp/proxydhcp_test.go create mode 100644 internal/server/bootloader_selection_test.go diff --git a/README.md b/README.md index 6ce1364..09b7d32 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,12 @@ Bootimus ships with embedded iPXE bootloaders for UEFI (x86_64, ARM64) and Legac The built-in set is always available as a fallback. Files not present in the active custom set are served from the built-in set automatically. +When the built-in proxyDHCP server is enabled, clients can override the global +set from the **Clients** page. Client settings take precedence over client-group +settings, which take precedence over the globally active set. External DHCP +servers cannot apply MAC-specific Bootimus settings and continue to use the +globally active set. + ## Supported Distributions ### Arch-based diff --git a/docs/en/clients.md b/docs/en/clients.md index 4a8ddbb..b5e3e61 100644 --- a/docs/en/clients.md +++ b/docs/en/clients.md @@ -26,6 +26,7 @@ Bootimus uses MAC address-based access control to manage which clients can boot - **Assigned Images**: When a client has images assigned, it sees **only those images** (not the full public list) - **Show Public Images**: When enabled alongside assigned images, client sees both assigned and public images - **Next Boot Action**: A one-time boot image override that auto-clears after use +- **Bootloader Set**: An optional initial bootloader override for this client ### Client Auto-Discovery @@ -86,6 +87,22 @@ All formats are normalized to colon-separated lowercase. ## Client Permissions +### Select a Bootloader Set + +The bootloader set used for the initial PXE download is resolved in this order: + +1. Client bootloader set +2. Client-group bootloader set +3. Globally active bootloader set + +Select a set in the client's edit dialog, or leave **Default (global setting)** +to inherit from its group and then the global setting. + +Per-client and client-group selection requires Bootimus's built-in proxyDHCP +server, because it uses the PXE request's MAC address to advertise a +set-qualified boot filename. External DHCP servers continue to advertise their +statically configured filename and therefore use the globally active set. + ### Assign Images to Client **Via Web Interface**: diff --git a/internal/proxydhcp/proxydhcp.go b/internal/proxydhcp/proxydhcp.go index 3a309ed..7aa3ff2 100644 --- a/internal/proxydhcp/proxydhcp.go +++ b/internal/proxydhcp/proxydhcp.go @@ -26,10 +26,7 @@ type Config struct { BootfileBIOS string BootfileUEFI string BootfileARM64 string - // Bootfiles, when set, is consulted on every request; any non-empty value - // it returns overrides the static Bootfile* fields. This lets the server - // switch bootloader sets at runtime without restarting proxyDHCP. - Bootfiles func() (bios, uefi, arm64 string) + Bootfiles func(clientHWAddr net.HardwareAddr) (bios, uefi, arm64 string) } type Server struct { @@ -86,7 +83,7 @@ func (s *Server) Start() error { } s.conn4011 = conn4011 - bios, uefi, arm64 := s.effectiveBootfiles() + bios, uefi, arm64 := s.effectiveBootfiles(nil) log.Printf("proxyDHCP: listening on UDP/67 + UDP/4011, advertising next-server=%s (BIOS=%s, UEFI=%s, ARM64=%s)", s.cfg.ServerIP, bios, uefi, arm64) @@ -193,10 +190,10 @@ func pxeVendorOptions() []byte { } } -func (s *Server) effectiveBootfiles() (bios, uefi, arm64 string) { +func (s *Server) effectiveBootfiles(clientHWAddr net.HardwareAddr) (bios, uefi, arm64 string) { bios, uefi, arm64 = s.cfg.BootfileBIOS, s.cfg.BootfileUEFI, s.cfg.BootfileARM64 if s.cfg.Bootfiles != nil { - overrideBIOS, overrideUEFI, overrideARM64 := s.cfg.Bootfiles() + overrideBIOS, overrideUEFI, overrideARM64 := s.cfg.Bootfiles(clientHWAddr) if overrideBIOS != "" { bios = overrideBIOS } @@ -211,7 +208,7 @@ func (s *Server) effectiveBootfiles() (bios, uefi, arm64 string) { } func (s *Server) bootfileFor(req *dhcpv4.DHCPv4) string { - bios, uefi, arm64 := s.effectiveBootfiles() + bios, uefi, arm64 := s.effectiveBootfiles(req.ClientHWAddr) switch clientArch(req) { case iana.EFI_IA32, iana.EFI_X86_64, iana.EFI_BC: return uefi diff --git a/internal/proxydhcp/proxydhcp_test.go b/internal/proxydhcp/proxydhcp_test.go new file mode 100644 index 0000000..3015075 --- /dev/null +++ b/internal/proxydhcp/proxydhcp_test.go @@ -0,0 +1,39 @@ +package proxydhcp + +import ( + "net" + "testing" + + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/iana" +) + +func TestBootfileForPassesClientHardwareAddress(t *testing.T) { + mac, err := net.ParseMAC("02:00:00:00:00:01") + if err != nil { + t.Fatalf("ParseMAC: %v", err) + } + request, err := dhcpv4.New( + dhcpv4.WithHwAddr(mac), + dhcpv4.WithOption(dhcpv4.OptClientArch(iana.EFI_X86_64)), + ) + if err != nil { + t.Fatalf("New DHCP request: %v", err) + } + + server := &Server{cfg: Config{ + BootfileBIOS: DefaultBootfileBIOS, + BootfileUEFI: DefaultBootfileUEFI, + BootfileARM64: DefaultBootfileARM64, + Bootfiles: func(clientHWAddr net.HardwareAddr) (string, string, string) { + if clientHWAddr.String() != mac.String() { + t.Fatalf("callback MAC = %q, want %q", clientHWAddr, mac) + } + return "custom/legacy.kpxe", "custom/client.efi", "custom/client-arm64.efi" + }, + }} + + if got := server.bootfileFor(request); got != "custom/client.efi" { + t.Fatalf("bootfileFor = %q, want custom/client.efi", got) + } +} diff --git a/internal/server/bootloader_selection_test.go b/internal/server/bootloader_selection_test.go new file mode 100644 index 0000000..bc13ecf --- /dev/null +++ b/internal/server/bootloader_selection_test.go @@ -0,0 +1,195 @@ +package server + +import ( + "net" + "os" + "path/filepath" + "testing" + + "bootimus/internal/models" + "bootimus/internal/storage" +) + +func newBootloaderSelectionTestServer(t *testing.T) (*Server, *storage.SQLiteStore) { + t.Helper() + store, err := storage.NewSQLiteStore(t.TempDir()) + if err != nil { + t.Fatalf("NewSQLiteStore: %v", err) + } + if err := store.AutoMigrate(); err != nil { + t.Fatalf("AutoMigrate: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + bootDir := t.TempDir() + setDir := filepath.Join(bootDir, "se350") + if err := os.MkdirAll(setDir, 0o755); err != nil { + t.Fatalf("create bootloader set: %v", err) + } + manifest := `{ + "name": "se350", + "bootfiles": { + "bios": "undionly-se350.kpxe", + "uefi": "bootimus-se350.efi", + "arm64": "bootimus-se350-arm64.efi" + } +}` + if err := os.WriteFile(filepath.Join(setDir, "manifest.json"), []byte(manifest), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + return &Server{config: &Config{Storage: store, BootDir: bootDir}}, store +} + +func mustParseMAC(t *testing.T, value string) net.HardwareAddr { + t.Helper() + mac, err := net.ParseMAC(value) + if err != nil { + t.Fatalf("ParseMAC(%q): %v", value, err) + } + return mac +} + +func TestEffectiveBootloaderSetPrecedence(t *testing.T) { + server, store := newBootloaderSelectionTestServer(t) + server.SetActiveBootloaderSet("default") + + group := &models.ClientGroup{Name: "rack-a", BootloaderSet: "secureboot"} + if err := store.CreateClientGroup(group); err != nil { + t.Fatalf("CreateClientGroup: %v", err) + } + + clients := []*models.Client{ + {MACAddress: "02:00:00:00:00:01", BootloaderSet: "se350", ClientGroupID: &group.ID}, + {MACAddress: "02:00:00:00:00:02", ClientGroupID: &group.ID}, + {MACAddress: "02:00:00:00:00:03"}, + } + for _, client := range clients { + if err := store.CreateClient(client); err != nil { + t.Fatalf("CreateClient(%s): %v", client.MACAddress, err) + } + } + + tests := []struct { + name string + mac string + wantSet string + wantDirect bool + }{ + {name: "client overrides group", mac: clients[0].MACAddress, wantSet: "se350", wantDirect: true}, + {name: "group overrides global", mac: clients[1].MACAddress, wantSet: "secureboot", wantDirect: true}, + {name: "global fallback", mac: clients[2].MACAddress, wantSet: "default", wantDirect: false}, + {name: "unknown client", mac: "02:00:00:00:00:ff", wantSet: "default", wantDirect: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSet, gotDirect := server.effectiveBootloaderSet(mustParseMAC(t, tt.mac)) + if gotSet != tt.wantSet || gotDirect != tt.wantDirect { + t.Fatalf("effectiveBootloaderSet = (%q, %v), want (%q, %v)", gotSet, gotDirect, tt.wantSet, tt.wantDirect) + } + }) + } +} + +func TestProxyDHCPBootfilesForClientQualifiesOverrides(t *testing.T) { + server, store := newBootloaderSelectionTestServer(t) + server.SetActiveBootloaderSet("default") + + direct := &models.Client{MACAddress: "02:00:00:00:01:01", BootloaderSet: "se350"} + inherited := &models.Client{MACAddress: "02:00:00:00:01:02"} + secureBoot := &models.Client{MACAddress: "02:00:00:00:01:03", BootloaderSet: "secureboot"} + defaultOverride := &models.Client{MACAddress: "02:00:00:00:01:04", BootloaderSet: "default"} + for _, client := range []*models.Client{direct, inherited, secureBoot, defaultOverride} { + if err := store.CreateClient(client); err != nil { + t.Fatalf("CreateClient(%s): %v", client.MACAddress, err) + } + } + + bios, uefi, arm64 := server.proxyDHCPBootfilesForClient(mustParseMAC(t, direct.MACAddress)) + if bios != "bootloader-sets/se350/undionly-se350.kpxe" { + t.Errorf("BIOS bootfile = %q", bios) + } + if uefi != "bootloader-sets/se350/bootimus-se350.efi" { + t.Errorf("UEFI bootfile = %q", uefi) + } + if arm64 != "bootloader-sets/se350/bootimus-se350-arm64.efi" { + t.Errorf("ARM64 bootfile = %q", arm64) + } + + bios, uefi, arm64 = server.proxyDHCPBootfilesForClient(mustParseMAC(t, inherited.MACAddress)) + if bios != "undionly.kpxe" || uefi != "bootimus.efi" || arm64 != "bootimus-arm64.efi" { + t.Fatalf("global bootfiles = (%q, %q, %q), want unqualified defaults", bios, uefi, arm64) + } + + bios, uefi, arm64 = server.proxyDHCPBootfilesForClient(mustParseMAC(t, secureBoot.MACAddress)) + if bios != "bootloader-sets/secureboot/undionly.kpxe" || + uefi != "bootloader-sets/secureboot/ipxe-shimx64.efi" || + arm64 != "bootloader-sets/secureboot/ipxe-shimaa64.efi" { + t.Fatalf("secure boot files = (%q, %q, %q), want qualified manifest files", bios, uefi, arm64) + } + + server.SetActiveBootloaderSet("se350") + bios, uefi, arm64 = server.proxyDHCPBootfilesForClient(mustParseMAC(t, inherited.MACAddress)) + if bios != "undionly-se350.kpxe" || uefi != "bootimus-se350.efi" || arm64 != "bootimus-se350-arm64.efi" { + t.Fatalf("global custom files = (%q, %q, %q), want unqualified manifest files", bios, uefi, arm64) + } + bios, uefi, arm64 = server.proxyDHCPBootfilesForClient(mustParseMAC(t, defaultOverride.MACAddress)) + if bios != "bootloader-sets/default/undionly.kpxe" || + uefi != "bootloader-sets/default/bootimus.efi" || + arm64 != "bootloader-sets/default/bootimus-arm64.efi" { + t.Fatalf("default override files = (%q, %q, %q), want qualified default files", bios, uefi, arm64) + } +} + +func TestResolveBootloaderRequest(t *testing.T) { + server := &Server{config: &Config{}} + server.SetActiveBootloaderSet("default") + + tests := []struct { + name string + request string + wantSet string + wantFilename string + wantError bool + }{ + {name: "global request", request: "bootimus.efi", wantSet: "default", wantFilename: "bootimus.efi"}, + {name: "qualified request", request: "bootloader-sets/se350/bootimus.efi", wantSet: "se350", wantFilename: "bootimus.efi"}, + {name: "qualified nested file", request: "bootloader-sets/se350/efi/ipxe.efi", wantSet: "se350", wantFilename: "efi/ipxe.efi"}, + {name: "traversal", request: "../secret", wantError: true}, + {name: "windows traversal", request: `..\secret`, wantError: true}, + {name: "missing set filename", request: "bootloader-sets/se350", wantError: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSet, gotFilename, err := server.resolveBootloaderRequest(tt.request) + if tt.wantError { + if err == nil { + t.Fatalf("resolveBootloaderRequest(%q) unexpectedly succeeded", tt.request) + } + return + } + if err != nil { + t.Fatalf("resolveBootloaderRequest(%q): %v", tt.request, err) + } + if gotSet != tt.wantSet || gotFilename != tt.wantFilename { + t.Fatalf("resolveBootloaderRequest = (%q, %q), want (%q, %q)", gotSet, gotFilename, tt.wantSet, tt.wantFilename) + } + }) + } +} + +func TestResolveBootloaderFileStaysWithinSelectedSet(t *testing.T) { + server, _ := newBootloaderSelectionTestServer(t) + setDir := filepath.Join(server.config.BootDir, "se350") + bootfile := filepath.Join(setDir, "bootimus-se350.efi") + if err := os.WriteFile(bootfile, []byte("test"), 0o644); err != nil { + t.Fatalf("write bootfile: %v", err) + } + + if got := server.resolveBootloaderFile("se350", "bootimus-se350.efi"); got != bootfile { + t.Fatalf("resolveBootloaderFile = %q, want %q", got, bootfile) + } + if got := server.resolveBootloaderFile("se350", "../manifest.json"); got != "" { + t.Fatalf("traversal resolved outside selected set: %q", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a29a206..c71736c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -325,6 +325,8 @@ type bootloaderConfigFile struct { ActiveSet string `json:"active_set"` } +const bootloaderSetRequestPrefix = "bootloader-sets/" + func (s *Server) loadBootloaderConfig() { data, err := os.ReadFile(s.bootloaderConfigPath()) if err != nil { @@ -368,12 +370,21 @@ func (s *Server) SetActiveBootloaderSet(name string) { log.Printf("Bootloader set %q active: PXE bootfiles BIOS=%s UEFI=%s ARM64=%s", display, bios, uefi, arm64) } -// activeSetManifest loads manifest.json for the active bootloader set — from -// the on-disk set directory when present, otherwise from the embedded sets. -func (s *Server) activeSetManifest() *bootloaders.Manifest { - setName := s.GetActiveBootloaderSet() +func normalizedBootloaderSetName(setName string) string { + setName = strings.TrimSpace(setName) if setName == "" { - setName = bootloaders.DefaultSet + return bootloaders.DefaultSet + } + if strings.ContainsAny(setName, `/\`) || setName != path.Base(setName) || setName == "." || setName == ".." { + return "" + } + return setName +} + +func (s *Server) bootloaderSetManifest(setName string) *bootloaders.Manifest { + setName = normalizedBootloaderSetName(setName) + if setName == "" { + return nil } if s.config.BootDir != "" { diskPath := filepath.Join(s.config.BootDir, setName, "manifest.json") @@ -390,6 +401,12 @@ func (s *Server) activeSetManifest() *bootloaders.Manifest { return m } +// activeSetManifest loads manifest.json for the active bootloader set — from +// the on-disk set directory when present, otherwise from the embedded sets. +func (s *Server) activeSetManifest() *bootloaders.Manifest { + return s.bootloaderSetManifest(s.GetActiveBootloaderSet()) +} + // proxyDHCPBootfiles returns the bootfile names proxyDHCP should advertise. // Precedence: explicitly configured value (differs from the compiled default) // > active bootloader set manifest > compiled default. Evaluated per DHCP @@ -412,12 +429,117 @@ func (s *Server) proxyDHCPBootfiles() (bios, uefi, arm64 string) { return bios, uefi, arm64 } -func (s *Server) resolveBootloaderFile(filename string) string { - setName := s.GetActiveBootloaderSet() +func (s *Server) effectiveBootloaderSet(clientHWAddr net.HardwareAddr) (string, bool) { + globalSet := normalizedBootloaderSetName(s.GetActiveBootloaderSet()) + if globalSet == "" { + globalSet = bootloaders.DefaultSet + } + if len(clientHWAddr) == 0 || s.config.Storage == nil { + return globalSet, false + } + + mac := strings.ToLower(clientHWAddr.String()) + client, err := s.config.Storage.GetClient(mac) + if err != nil { + return globalSet, false + } + if configuredSet := strings.TrimSpace(client.BootloaderSet); configuredSet != "" { + if setName := normalizedBootloaderSetName(configuredSet); setName != "" { + return setName, true + } + } + if client.ClientGroupID != nil { + group, err := s.config.Storage.GetClientGroup(*client.ClientGroupID) + if err == nil { + if configuredSet := strings.TrimSpace(group.BootloaderSet); configuredSet != "" { + if setName := normalizedBootloaderSetName(configuredSet); setName != "" { + return setName, true + } + } + } + } + return globalSet, false +} + +func qualifyBootloaderSetRequest(setName, filename string) string { + setName = normalizedBootloaderSetName(setName) + filename = path.Clean(strings.TrimPrefix(filename, "/")) + if setName == "" || strings.Contains(filename, `\`) || filename == "." || filename == ".." || strings.HasPrefix(filename, "../") { + return "" + } + return path.Join(bootloaderSetRequestPrefix, setName, filename) +} + +func (s *Server) proxyDHCPBootfilesForClient(clientHWAddr net.HardwareAddr) (bios, uefi, arm64 string) { + setName, overridden := s.effectiveBootloaderSet(clientHWAddr) + if !overridden { + return s.proxyDHCPBootfiles() + } + + bios = s.config.ProxyDHCPBootfileBIOS + if bios == "" { + bios = proxydhcp.DefaultBootfileBIOS + } + uefi = s.config.ProxyDHCPBootfileUEFI + if uefi == "" { + uefi = proxydhcp.DefaultBootfileUEFI + } + arm64 = s.config.ProxyDHCPBootfileARM + if arm64 == "" { + arm64 = proxydhcp.DefaultBootfileARM64 + } + if manifest := s.bootloaderSetManifest(setName); manifest != nil { + if (bios == "" || bios == proxydhcp.DefaultBootfileBIOS) && manifest.Bootfiles.BIOS != "" { + bios = manifest.Bootfiles.BIOS + } + if (uefi == "" || uefi == proxydhcp.DefaultBootfileUEFI) && manifest.Bootfiles.UEFI != "" { + uefi = manifest.Bootfiles.UEFI + } + if (arm64 == "" || arm64 == proxydhcp.DefaultBootfileARM64) && manifest.Bootfiles.ARM64 != "" { + arm64 = manifest.Bootfiles.ARM64 + } + } + return qualifyBootloaderSetRequest(setName, bios), + qualifyBootloaderSetRequest(setName, uefi), + qualifyBootloaderSetRequest(setName, arm64) +} + +func (s *Server) resolveBootloaderRequest(requestPath string) (setName, filename string, err error) { + if strings.Contains(requestPath, `\`) { + return "", "", fmt.Errorf("invalid bootloader path: %s", requestPath) + } + cleanPath := path.Clean(strings.TrimPrefix(requestPath, "/")) + if cleanPath == "." || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") { + return "", "", fmt.Errorf("invalid bootloader path: %s", requestPath) + } + if !strings.HasPrefix(cleanPath, bootloaderSetRequestPrefix) { + return normalizedBootloaderSetName(s.GetActiveBootloaderSet()), cleanPath, nil + } + + relativePath := strings.TrimPrefix(cleanPath, bootloaderSetRequestPrefix) + separator := strings.IndexByte(relativePath, '/') + if separator <= 0 || separator == len(relativePath)-1 { + return "", "", fmt.Errorf("invalid qualified bootloader path: %s", requestPath) + } + setName = normalizedBootloaderSetName(relativePath[:separator]) + filename = path.Clean(relativePath[separator+1:]) + if setName == "" || filename == "." || filename == ".." || strings.HasPrefix(filename, "../") { + return "", "", fmt.Errorf("invalid qualified bootloader path: %s", requestPath) + } + return setName, filename, nil +} + +func (s *Server) resolveBootloaderFile(setName, filename string) string { + setName = normalizedBootloaderSetName(setName) if setName == "" || s.config.BootDir == "" { return "" } - fullPath := filepath.Join(s.config.BootDir, setName, filename) + setDir := filepath.Join(s.config.BootDir, setName) + fullPath := filepath.Join(setDir, filepath.FromSlash(filename)) + relativePath, err := filepath.Rel(setDir, fullPath) + if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) { + return "" + } if _, err := os.Stat(fullPath); err == nil { return fullPath } @@ -596,7 +718,7 @@ func (s *Server) Start() error { BootfileBIOS: s.config.ProxyDHCPBootfileBIOS, BootfileUEFI: s.config.ProxyDHCPBootfileUEFI, BootfileARM64: s.config.ProxyDHCPBootfileARM, - Bootfiles: s.proxyDHCPBootfiles, + Bootfiles: s.proxyDHCPBootfilesForClient, }) if err != nil { log.Printf("proxyDHCP: failed to construct server: %v", err) @@ -787,10 +909,7 @@ func (s *Server) startTFTPServer() error { server := tftp.NewServer( func(filename string, rf io.ReaderFrom) error { - cleanPath := filepath.Clean(filename) - if filepath.IsAbs(cleanPath) { - cleanPath = filepath.Base(cleanPath) - } + cleanPath := path.Clean(strings.TrimPrefix(filename, "/")) remote := tftpRemote(rf) start := time.Now() @@ -837,11 +956,16 @@ goto dhcp return nil } - if customPath := s.resolveBootloaderFile(cleanPath); customPath != "" { + setName, bootloaderFilename, err := s.resolveBootloaderRequest(cleanPath) + if err != nil { + return err + } + + if customPath := s.resolveBootloaderFile(setName, bootloaderFilename); customPath != "" { file, err := os.Open(customPath) if err == nil { defer file.Close() - log.Printf("TFTP: Serving from set '%s': %s", s.GetActiveBootloaderSet(), cleanPath) + log.Printf("TFTP: Serving from set '%s': %s", setName, bootloaderFilename) fileInfo, err := file.Stat() if err != nil { @@ -863,9 +987,9 @@ goto dhcp } } - data, resolvedSet, err := bootloaders.Resolve(s.GetActiveBootloaderSet(), cleanPath) + data, resolvedSet, err := bootloaders.Resolve(setName, bootloaderFilename) if err == nil { - log.Printf("TFTP: Serving embedded bootloader from set '%s': %s", resolvedSet, cleanPath) + log.Printf("TFTP: Serving embedded bootloader from set '%s': %s", resolvedSet, bootloaderFilename) if rfs, ok := rf.(interface{ SetSize(int64) error }); ok { rfs.SetSize(int64(len(data))) @@ -925,9 +1049,15 @@ func (s *Server) startHTTPServer() error { return } - if customPath := s.resolveBootloaderFile(cleanPath); customPath != "" { - log.Printf("HTTP: Serving from set '%s': %s", s.GetActiveBootloaderSet(), cleanPath) - ext := filepath.Ext(cleanPath) + setName, bootloaderFilename, err := s.resolveBootloaderRequest(cleanPath) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + if customPath := s.resolveBootloaderFile(setName, bootloaderFilename); customPath != "" { + log.Printf("HTTP: Serving from set '%s': %s", setName, bootloaderFilename) + ext := filepath.Ext(bootloaderFilename) if ext == ".efi" || ext == ".img" || ext == ".iso" || ext == ".kpxe" || ext == ".usb" { w.Header().Set("Content-Type", "application/octet-stream") } @@ -935,9 +1065,9 @@ func (s *Server) startHTTPServer() error { return } - data, resolvedSet, err := bootloaders.Resolve(s.GetActiveBootloaderSet(), cleanPath) + data, resolvedSet, err := bootloaders.Resolve(setName, bootloaderFilename) if err == nil { - log.Printf("HTTP: Serving embedded bootloader from set '%s': %s", resolvedSet, cleanPath) + log.Printf("HTTP: Serving embedded bootloader from set '%s': %s", resolvedSet, bootloaderFilename) w.Header().Set("Content-Type", "application/octet-stream") w.Write(data) return From 129dccf70e8166b44f09a8d193d4d8fc5401344a Mon Sep 17 00:00:00 2001 From: Harrison Caldicott Date: Tue, 11 Aug 2026 14:08:13 +1000 Subject: [PATCH 08/13] Preserve bootloader behavior comments --- internal/proxydhcp/proxydhcp.go | 6 +++++- internal/server/server.go | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/proxydhcp/proxydhcp.go b/internal/proxydhcp/proxydhcp.go index 7aa3ff2..dc81c98 100644 --- a/internal/proxydhcp/proxydhcp.go +++ b/internal/proxydhcp/proxydhcp.go @@ -26,7 +26,11 @@ type Config struct { BootfileBIOS string BootfileUEFI string BootfileARM64 string - Bootfiles func(clientHWAddr net.HardwareAddr) (bios, uefi, arm64 string) + // Bootfiles, when set, is consulted on every request with the client's + // hardware address; any non-empty value it returns overrides the static + // Bootfile* fields. This lets the server switch global or client-specific + // bootloader sets at runtime without restarting proxyDHCP. + Bootfiles func(clientHWAddr net.HardwareAddr) (bios, uefi, arm64 string) } type Server struct { diff --git a/internal/server/server.go b/internal/server/server.go index c71736c..16cbb84 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -401,8 +401,9 @@ func (s *Server) bootloaderSetManifest(setName string) *bootloaders.Manifest { return m } -// activeSetManifest loads manifest.json for the active bootloader set — from -// the on-disk set directory when present, otherwise from the embedded sets. +// activeSetManifest loads manifest.json for the globally active bootloader set — +// from the on-disk set directory when present, otherwise from the embedded sets. +// Client-specific selections are loaded through bootloaderSetManifest. func (s *Server) activeSetManifest() *bootloaders.Manifest { return s.bootloaderSetManifest(s.GetActiveBootloaderSet()) } From 744379dcfd5891ee522a23486467d762ce791b03 Mon Sep 17 00:00:00 2001 From: Harrison Caldicott Date: Tue, 11 Aug 2026 23:52:25 +1000 Subject: [PATCH 09/13] Use Anaconda stage2 for AlmaLinux boot images --- distro-profiles.json | 2 +- internal/profiles/distro-profiles.json | 2 +- internal/profiles/manager_test.go | 29 ++++++++++++++++++++++++++ web/static/app.js | 2 ++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/distro-profiles.json b/distro-profiles.json index faa497c..d053884 100644 --- a/distro-profiles.json +++ b/distro-profiles.json @@ -212,7 +212,7 @@ "kernel_paths": ["/images/pxeboot/vmlinuz"], "initrd_paths": ["/images/pxeboot/initrd.img"], "squashfs_paths": ["/LiveOS/squashfs.img"], - "default_boot_params": "initrd=initrd root=live:{{BASE_URL}}/isos/{{FILENAME}} rd.live.image inst.repo={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ inst.stage2={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ rd.neednet=1", + "default_boot_params": "initrd=initrd ip=dhcp rd.neednet=1 inst.stage2={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/", "auto_install_type": "kickstart", "boot_method": "kernel", "mirrors": [ diff --git a/internal/profiles/distro-profiles.json b/internal/profiles/distro-profiles.json index b5858af..9697ca4 100644 --- a/internal/profiles/distro-profiles.json +++ b/internal/profiles/distro-profiles.json @@ -212,7 +212,7 @@ "kernel_paths": ["/images/pxeboot/vmlinuz"], "initrd_paths": ["/images/pxeboot/initrd.img"], "squashfs_paths": ["/LiveOS/squashfs.img"], - "default_boot_params": "initrd=initrd root=live:{{BASE_URL}}/isos/{{FILENAME}} rd.live.image inst.repo={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ inst.stage2={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ rd.neednet=1", + "default_boot_params": "initrd=initrd ip=dhcp rd.neednet=1 inst.stage2={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/", "auto_install_type": "kickstart", "boot_method": "kernel", "mirrors": [ diff --git a/internal/profiles/manager_test.go b/internal/profiles/manager_test.go index 90b8c2b..abaceab 100644 --- a/internal/profiles/manager_test.go +++ b/internal/profiles/manager_test.go @@ -2,6 +2,7 @@ package profiles import ( "encoding/json" + "strings" "testing" "bootimus/internal/models" @@ -65,6 +66,34 @@ func TestMatchProfile_CaseInsensitive(t *testing.T) { } } +func TestAlmaProfileUsesAnacondaStage2(t *testing.T) { + profiles := loadEmbeddedForTest(t) + + var alma *models.DistroProfile + for _, profile := range profiles { + if profile.ProfileID == "alma" { + alma = profile + break + } + } + if alma == nil { + t.Fatal("embedded AlmaLinux profile not found") + } + + params := alma.DefaultBootParams + if strings.Contains(params, "root=live:") || strings.Contains(params, "rd.live.image") { + t.Fatalf("AlmaLinux installer profile must not use a live root: %q", params) + } + if strings.Contains(params, "inst.repo=") { + t.Fatalf("AlmaLinux Boot ISO profile must leave package sources to Kickstart: %q", params) + } + for _, required := range []string{"initrd=initrd", "ip=dhcp", "rd.neednet=1", "inst.stage2="} { + if !strings.Contains(params, required) { + t.Errorf("AlmaLinux installer profile missing %q in %q", required, params) + } + } +} + func TestMatchProfile_CustomBeatsBuiltin(t *testing.T) { profiles := []*models.DistroProfile{ {ProfileID: "ubuntu", Custom: false, FilenamePatterns: models.StringSlice{"ubuntu"}}, diff --git a/web/static/app.js b/web/static/app.js index 3a80d40..9a1bf23 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -5095,6 +5095,8 @@ function getDefaultBootParams(img) { return 'archiso_http_srv={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ ip=dhcp'; case 'nixos': return 'ip=dhcp'; + case 'alma': + return 'initrd=initrd ip=dhcp rd.neednet=1 inst.stage2={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/'; case 'fedora': case 'centos': return 'root=live:{{BASE_URL}}/isos/{{FILENAME}} rd.live.image inst.repo={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ inst.stage2={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ rd.neednet=1 ip=dhcp'; From cb7504a5f11d33549835fb2fddd619d0a7d932b6 Mon Sep 17 00:00:00 2001 From: Harrison Caldicott Date: Wed, 12 Aug 2026 20:15:38 +1000 Subject: [PATCH 10/13] Boot directly into one-shot next boot image --- internal/server/server.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/server/server.go b/internal/server/server.go index 16cbb84..3d9daa5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1990,6 +1990,13 @@ func (s *Server) handleIPXEMenu(w http.ResponseWriter, r *http.Request) { } menu := s.generateIPXEMenuWithGroups(images, macAddress, nextBootImageID) + if nextBootImageID > 0 { + // A one-shot next-boot assignment is an automation contract, not an + // interactive preference. Jump straight to the selected image so a + // headless Redfish restart cannot remain parked at the boot menu. + menu = fmt.Sprintf("#!ipxe\n\ngoto iso%d\n\n%s", nextBootImageID, + strings.TrimPrefix(menu, "#!ipxe\n\n")) + } w.Header().Set("Content-Type", "text/plain") w.Write([]byte(menu)) } From 5e5dffcec63f4d631b89982feb7581451be9036f Mon Sep 17 00:00:00 2001 From: Harrison Caldicott Date: Wed, 12 Aug 2026 20:18:04 +1000 Subject: [PATCH 11/13] Revert "Boot directly into one-shot next boot image" This reverts commit cb7504a5f11d33549835fb2fddd619d0a7d932b6. --- internal/server/server.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 3d9daa5..16cbb84 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1990,13 +1990,6 @@ func (s *Server) handleIPXEMenu(w http.ResponseWriter, r *http.Request) { } menu := s.generateIPXEMenuWithGroups(images, macAddress, nextBootImageID) - if nextBootImageID > 0 { - // A one-shot next-boot assignment is an automation contract, not an - // interactive preference. Jump straight to the selected image so a - // headless Redfish restart cannot remain parked at the boot menu. - menu = fmt.Sprintf("#!ipxe\n\ngoto iso%d\n\n%s", nextBootImageID, - strings.TrimPrefix(menu, "#!ipxe\n\n")) - } w.Header().Set("Content-Type", "text/plain") w.Write([]byte(menu)) } From 75830b32c80cbc401e3e6c9caa16e23df207d2ca Mon Sep 17 00:00:00 2001 From: Harrison Caldicott Date: Wed, 12 Aug 2026 20:51:28 +1000 Subject: [PATCH 12/13] Fix next-boot selection for grouped images --- internal/server/menu.go | 57 ++++++++++++++++++++++++++++++------ internal/server/menu_test.go | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/internal/server/menu.go b/internal/server/menu.go index c4207cc..927b4e6 100644 --- a/internal/server/menu.go +++ b/internal/server/menu.go @@ -91,6 +91,11 @@ func (mb *MenuBuilder) Build() string { var sb strings.Builder sb.WriteString("#!ipxe\n\n") + if groupID, ok := mb.nextBootGroupID(); ok { + // A grouped image is not an item on the root menu. Enter its containing + // menu first so iPXE can display that menu with the image pre-selected. + sb.WriteString(fmt.Sprintf("goto group%d\n\n", groupID)) + } sb.WriteString(mb.buildMainMenu()) sb.WriteString(mb.buildGroupMenus()) sb.WriteString(mb.buildImageBootSections()) @@ -109,9 +114,42 @@ func (mb *MenuBuilder) menuTimeoutMs() int { return 30000 } +func (mb *MenuBuilder) effectiveMenuTimeoutMs() int { + timeoutMs := mb.menuTimeoutMs() + if mb.nextBootImageID > 0 && timeoutMs == 0 { + return 10000 // one-shot selections must not wait forever + } + return timeoutMs +} + +func (mb *MenuBuilder) nextBootImage() *models.Image { + if mb.nextBootImageID == 0 { + return nil + } + for i := range mb.images { + if mb.images[i].Enabled && mb.images[i].ID == mb.nextBootImageID { + return &mb.images[i] + } + } + return nil +} + +func (mb *MenuBuilder) nextBootGroupID() (uint, bool) { + img := mb.nextBootImage() + if img == nil || img.GroupID == nil { + return 0, false + } + for _, group := range mb.groups { + if group.ID == *img.GroupID && group.Enabled && mb.groupHasImages(group.ID) { + return group.ID, true + } + } + return 0, false +} + func (mb *MenuBuilder) resolveDefaultItem(visibleGroups []*models.ImageGroup, ungroupedImages []models.Image) string { - if mb.nextBootImageID > 0 { - return fmt.Sprintf("iso%d", mb.nextBootImageID) + if img := mb.nextBootImage(); img != nil && img.GroupID == nil { + return fmt.Sprintf("iso%d", img.ID) } if mb.forceLocalDefault { return "local" @@ -192,10 +230,7 @@ func (mb *MenuBuilder) buildMainMenu() string { sb.WriteString("item reboot Reboot\n") defaultItem := mb.resolveDefaultItem(visibleGroups, ungroupedImages) - timeoutMs := mb.menuTimeoutMs() - if mb.nextBootImageID > 0 && timeoutMs == 0 { - timeoutMs = 10000 // 10s override when next boot is set but global timeout is disabled - } + timeoutMs := mb.effectiveMenuTimeoutMs() if timeoutMs > 0 { sb.WriteString(fmt.Sprintf("choose --default %s --timeout %d selected || goto start\n", defaultItem, timeoutMs)) @@ -257,10 +292,14 @@ func (mb *MenuBuilder) buildGroupMenus() string { sb.WriteString("item local Boot from Local Disk\n") sb.WriteString("item shell Drop to iPXE shell\n") sb.WriteString("item reboot Reboot\n") - if timeoutMs := mb.menuTimeoutMs(); timeoutMs > 0 { - sb.WriteString(fmt.Sprintf("choose --timeout %d selected || goto group%d\n", timeoutMs, group.ID)) + defaultOption := "" + if img := mb.nextBootImage(); img != nil && img.GroupID != nil && *img.GroupID == group.ID { + defaultOption = fmt.Sprintf(" --default iso%d", img.ID) + } + if timeoutMs := mb.effectiveMenuTimeoutMs(); timeoutMs > 0 { + sb.WriteString(fmt.Sprintf("choose%s --timeout %d selected || goto group%d\n", defaultOption, timeoutMs, group.ID)) } else { - sb.WriteString(fmt.Sprintf("choose selected || goto group%d\n", group.ID)) + sb.WriteString(fmt.Sprintf("choose%s selected || goto group%d\n", defaultOption, group.ID)) } sb.WriteString("goto ${selected}\n\n") } diff --git a/internal/server/menu_test.go b/internal/server/menu_test.go index 83e31a1..205cfe4 100644 --- a/internal/server/menu_test.go +++ b/internal/server/menu_test.go @@ -93,3 +93,59 @@ func TestResolveBootParamsPlaceholders(t *testing.T) { t.Errorf("expected %q, got %q", want, got) } } + +func TestGroupedNextBootStartsInContainingMenuAndSelectsImage(t *testing.T) { + rootID := uint(10) + childID := uint(20) + root := &models.ImageGroup{ID: rootID, Name: "Linux", Enabled: true} + child := &models.ImageGroup{ID: childID, Name: "Alma", ParentID: &rootID, Parent: root, Enabled: true} + mb := testMenuBuilder(nil) + mb.groups = []*models.ImageGroup{root, child} + mb.images = []models.Image{{ + ID: 42, + Name: "AlmaLinux", + Filename: "linux/alma/AlmaLinux.iso", + Enabled: true, + GroupID: &childID, + }} + mb.nextBootImageID = 42 + + out := mb.Build() + if !strings.HasPrefix(out, "#!ipxe\n\ngoto group20\n\n:start\n") { + t.Fatalf("expected grouped next boot to enter its containing menu first:\n%s", out) + } + if !strings.Contains(out, ":group20\nmenu Bootimus - Boot Menu - Alma") { + t.Fatalf("expected target group menu to be present:\n%s", out) + } + if !strings.Contains(out, "choose --default iso42 --timeout 30000 selected || goto group20") { + t.Fatalf("expected grouped image to be the timed default in its menu:\n%s", out) + } +} + +func TestGroupedNextBootUsesTimeoutWhenMenusNormallyWaitForever(t *testing.T) { + groupID := uint(10) + mb := testMenuBuilder(nil) + mb.theme = &models.MenuTheme{MenuTimeout: 0} + mb.groups = []*models.ImageGroup{{ID: groupID, Name: "Alma", Enabled: true}} + mb.images = []models.Image{{ID: 42, Name: "AlmaLinux", Filename: "alma/AlmaLinux.iso", Enabled: true, GroupID: &groupID}} + mb.nextBootImageID = 42 + + out := mb.Build() + if !strings.Contains(out, "choose --default iso42 --timeout 10000 selected || goto group10") { + t.Fatalf("expected one-shot grouped image to receive the 10-second timeout override:\n%s", out) + } +} + +func TestUngroupedNextBootRemainsSelectedOnRootMenu(t *testing.T) { + mb := testMenuBuilder(nil) + mb.images = []models.Image{{ID: 42, Name: "AlmaLinux", Filename: "AlmaLinux.iso", Enabled: true}} + mb.nextBootImageID = 42 + + out := mb.Build() + if strings.HasPrefix(out, "#!ipxe\n\ngoto group") { + t.Fatalf("did not expect an ungrouped image to enter a group menu:\n%s", out) + } + if !strings.Contains(out, "choose --default iso42 --timeout 30000 selected || goto start") { + t.Fatalf("expected ungrouped image to remain the root-menu default:\n%s", out) + } +} From beba3f06627961b13919c31282a2497c6fe697cc Mon Sep 17 00:00:00 2001 From: Harrison Caldicott Date: Wed, 12 Aug 2026 21:20:35 +1000 Subject: [PATCH 13/13] Attribute kernel boot requests to clients --- internal/server/menu.go | 21 +++++++++++++++++---- internal/server/menu_test.go | 13 +++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/internal/server/menu.go b/internal/server/menu.go index 927b4e6..6e274eb 100644 --- a/internal/server/menu.go +++ b/internal/server/menu.go @@ -184,6 +184,18 @@ func encodePathSegments(path string) string { return strings.Join(segments, "/") } +// bootFileURL preserves the client identity while iPXE fetches a kernel or +// initrd. The /boot/ handler uses this MAC to attribute a boot to the correct +// client and update its boot count. Keep URLs without a query string for menu +// builders that do not have a client identity (for example, static previews). +func (mb *MenuBuilder) bootFileURL(baseURL, cacheDir, filename string) string { + bootURL := fmt.Sprintf("%s/boot/%s/%s", baseURL, cacheDir, filename) + if mb.macAddress == "" { + return bootURL + } + return bootURL + "?mac=" + url.QueryEscape(mb.macAddress) +} + func (mb *MenuBuilder) buildMainMenu() string { var sb strings.Builder @@ -332,8 +344,9 @@ func (mb *MenuBuilder) buildImageBootSections() string { case "nfs": sb.WriteString("echo Using NFS root (streamed, low memory)...\n") nfsPath := strings.TrimSuffix(img.Filename, filepath.Ext(img.Filename)) - sb.WriteString(fmt.Sprintf("kernel http://%s:%d/boot/%s/vmlinuz initrd=initrd root=/dev/nfs boot=casper netboot=nfs nfsroot=%s:/%s/iso,vers=3,tcp,port=%d,mountport=%d,nolock ip=dhcp\n", mb.serverAddr, mb.httpPort, cacheDir, mb.serverAddr, nfsPath, mb.nfsPort, mb.nfsPort)) - sb.WriteString(fmt.Sprintf("initrd http://%s:%d/boot/%s/initrd\n", mb.serverAddr, mb.httpPort, cacheDir)) + bootURL := fmt.Sprintf("http://%s:%d", mb.serverAddr, mb.httpPort) + sb.WriteString(fmt.Sprintf("kernel %s initrd=initrd root=/dev/nfs boot=casper netboot=nfs nfsroot=%s:/%s/iso,vers=3,tcp,port=%d,mountport=%d,nolock ip=dhcp\n", mb.bootFileURL(bootURL, cacheDir, "vmlinuz"), mb.serverAddr, nfsPath, mb.nfsPort, mb.nfsPort)) + sb.WriteString(fmt.Sprintf("initrd %s\n", mb.bootFileURL(bootURL, cacheDir, "initrd"))) sb.WriteString("boot || goto failed\n") case "kernel": @@ -401,8 +414,8 @@ func (mb *MenuBuilder) buildKernelBootSection(img *models.Image, encodedFilename initrdPath = encodePathSegments(img.InitrdOverride) initrdName = " initrd" } - sb.WriteString(fmt.Sprintf("kernel %s/boot/%s/%s%s%s\n", baseURL, cacheDir, kernelPath, autoInstallParam, bootParams)) - sb.WriteString(fmt.Sprintf("initrd %s/boot/%s/%s%s\n", baseURL, cacheDir, initrdPath, initrdName)) + sb.WriteString(fmt.Sprintf("kernel %s%s%s\n", mb.bootFileURL(baseURL, cacheDir, kernelPath), autoInstallParam, bootParams)) + sb.WriteString(fmt.Sprintf("initrd %s%s\n", mb.bootFileURL(baseURL, cacheDir, initrdPath), initrdName)) sb.WriteString("boot || goto failed\n") } diff --git a/internal/server/menu_test.go b/internal/server/menu_test.go index 3b04d6e..2833a3d 100644 --- a/internal/server/menu_test.go +++ b/internal/server/menu_test.go @@ -68,6 +68,19 @@ func TestBuildKernelBootSectionNoAutoInstall(t *testing.T) { } } +func TestBuildKernelBootSectionAttributesBootFilesToClient(t *testing.T) { + mb := testMenuBuilder(nil) + img := &models.Image{ID: 7, Filename: "test.iso", Enabled: true, BootMethod: "kernel"} + + out := mb.buildKernelBootSection(img, "test.iso", "test") + if !strings.Contains(out, "kernel http://10.0.0.1:8080/boot/test/vmlinuz?mac=aa%3Abb%3Acc%3Add%3Aee%3Aff") { + t.Errorf("expected kernel request to include the client MAC, got:\n%s", out) + } + if !strings.Contains(out, "initrd http://10.0.0.1:8080/boot/test/initrd?mac=aa%3Abb%3Acc%3Add%3Aee%3Aff") { + t.Errorf("expected initrd request to include the client MAC, got:\n%s", out) + } +} + func TestBuildKernelBootSectionStripsBareNocloudParam(t *testing.T) { img := &models.Image{ ID: 7,