Skip to content

Apply per-client bootloader set overrides - #115

Open
hcaldicott wants to merge 21 commits into
garybowers:mainfrom
hcaldicott:agent/fix-per-client-bootloader-selection
Open

Apply per-client bootloader set overrides#115
hcaldicott wants to merge 21 commits into
garybowers:mainfrom
hcaldicott:agent/fix-per-client-bootloader-selection

Conversation

@hcaldicott

@hcaldicott hcaldicott commented Aug 11, 2026

Copy link
Copy Markdown

Summary

  • apply bootloader-set precedence as client, client group, then global
  • pass the requesting PXE MAC into the proxyDHCP bootfile selector
  • carry client-specific selections into TFTP/HTTP with set-qualified paths
  • retain existing unqualified global behaviour and built-in file fallback
  • document the built-in proxyDHCP requirement for MAC-specific selection

Root cause

The client and client-group bootloader_set fields are persisted and exposed
by the UI, but the boot-serving path only consults GetActiveBootloaderSet().
Consequently, the per-client override advertised in the v0.1.13 release notes
has no effect during PXE boot.

proxyDHCP knows the requesting MAC, while the later TFTP request only carries a
filename. This change resolves the effective set during proxyDHCP and advertises
a path such as bootloader-sets/custom/bootimus.efi, preserving that choice for
the subsequent transfer.

Unqualified requests continue to resolve through the global active set, so
external DHCP configurations retain their existing behaviour.

Validation

  • gofmt -l .
  • go vet ./...
  • go test ./...
  • go test -race ./internal/proxydhcp ./internal/server
  • go build ./...
  • GOOS=windows GOARCH=amd64 go build ./...
  • GOOS=darwin GOARCH=arm64 go build ./...

Fixes #116. Related global bootloader/proxyDHCP wiring: #78 and #96.

garybowers and others added 15 commits July 14, 2026 08:32
0.1.73 Fix admin API auth bypass, add set-password CLI, ISO/description fixes
Add archlinux aur packages information
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
…b share

NB to use a non-default port, windows 11 24H2 (or later) or windows server 2025 (or later) is required.
add support for booting windows pe and load the associated image drivers
wip: Adding bind addr option, kubernetes node feature
@hcaldicott

Copy link
Copy Markdown
Author

GPT 5.6 was used in this PR, however I have completed a manual (human) review of the codebase. I (personally) am happy with the changes here and feel they're as minimal as possible to implement the given functionality. I am still pulling apart the unit tests though and validating them.

@hcaldicott
hcaldicott marked this pull request as ready for review August 11, 2026 05:23

@garybowers garybowers left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please standardise on British English spelling where possible to keep consistency.

@garybowers

Copy link
Copy Markdown
Owner

Thanks for this — the core mechanism is solid and the test coverage is genuinely good. The precedence resolution, the set-qualified path scheme, and the traversal hardening in resolveBootloaderFile are all improvements I'm happy to take. There are a few bugs that break real boot scenarios though, including the feature's own headline use case, so I'd like those addressed before merging.

Blocking

1. Per-client sets break script-less iPXE builds. Stock iPXE binaries without an embedded script fetch autoexec.ipxe relative to the directory they were loaded from. A client on an override set loads bootloader-sets/se350/custom.efi, then requests bootloader-sets/se350/autoexec.ipxe — but the TFTP handler's dynamic autoexec special case matches the literal path autoexec.ipxe before the set prefix is decoded, so the request falls through to the file lookups and returns file-not-found, dropping the machine to the iPXE shell. The same set works when selected globally, so the per-client path silently behaves differently. Our secureboot-official set explicitly relies on autoexec over TFTP.

Remedy: move the autoexec special case below the resolveBootloaderRequest call and match on the decoded bootloaderFilename instead of the raw path, so bootloader-sets/<set>/autoexec.ipxe serves the same dynamic script as the unqualified request. A test asserting a qualified autoexec fetch returns the chain script would lock it in.

2. Explicitly configured bootfile names get re-qualified into the override set. With --proxy-dhcp-bootfile-uefi=snponly.efi (file living in the global set's directory), an override client is advertised bootloader-sets/<set>/snponly.efi, which doesn't exist in that set on disk or embedded — TFTP file-not-found and the client fails to PXE boot entirely, while non-override clients boot fine. Related: because the configured value always beats the manifest, selecting a different set for a client never changes which binary is actually served when any ProxyDHCPBootfile* is customised — only the path prefix changes.

Remedy: only qualify names that came from the override set itself. Extract a shared bootfilesForSet(setName) helper resolving manifest → compiled default (this also removes the duplicated precedence block noted under Style), and in proxyDHCPBootfilesForClient return an explicitly configured value unqualified — an operator's explicit setting should mean the same file for every client. If you'd rather explicit config lose to a per-client set, that's defensible too, but then it should resolve entirely from the set's manifest rather than qualifying the configured name into a directory where it doesn't exist.

3. One-shot next-boot is silently swallowed when the image's group is disabled. handleIPXEMenu clears the next-boot flag when the menu is generated, but nextBootGroupID requires the containing group to be enabled and loaded — if it isn't, no goto and no --default are emitted, so the ordinary menu renders and the one-time reinstall is consumed without ever running. The previous goto isoN bypass handled this correctly, since boot sections are emitted for all enabled images regardless of group state.

Remedy: in Build(), when nextBootImage() finds an enabled image but nextBootGroupID() can't produce a renderable group (disabled group, or groups failed to load), fall back to the old direct goto iso<N> — the boot section exists whenever the image is enabled, so the one-shot still fires. Worth a companion test: next-boot image enabled, containing group disabled, expect the script to boot it.

Behaviour changes I'd like to talk through

  • Next-boot no longer boots immediately. It now waits out the full configured menu timeout (30s default) with the image pre-selected, and anyone at the console can redirect it after the one-shot flag has already been consumed. If the interactive pre-selection is intentional, fine — but the unattended semantics change deserves a mention in the PR body, and arguably the timeout should be clamped for one-shots generally, not only when the theme timeout is 0.
  • The 10-second override leaks into every group menu. When a next-boot is pending and the theme timeout is 0, all group menus get choose --timeout 10000; menus without a --default fail the choose and redraw every 10 seconds, resetting the cursor while someone is browsing. It also triggers off the raw nextBootImageID even when the image isn't rendered in the menu at all.
  • TFTP absolute multi-segment paths regress. /tftpboot/undionly.kpxe was previously flattened to its basename and served; it now preserves the directory and 404s. External DHCP configurations that advertise directory-qualified filenames will stop booting after this change.
  • The AlmaLinux inst.repo removal looks intentional per the new test, but interactive installs without a kickstart may now stall at "error setting up base repository". That change, the next-boot menu rework, and the ?mac= boot attribution are all unrelated to per-client bootloader sets and none is mentioned in the PR body — I'd prefer them split out, or at minimum documented.

Performance

Every PXE packet now runs one or two synchronous database queries plus a manifest read/parse inside the single-threaded proxyDHCP UDP loop — and on Postgres, GetClient also preloads the client's full image associations. A rack-wide mass reboot serialises behind those queries and can overflow the UDP socket buffer, causing PXE-E51/E53 timeouts. A short-TTL MAC→set cache (5–10 seconds covers a whole DHCP exchange) or a dedicated two-column lookup would sort it.

Style

  • House style here is British English. normalizedBootloaderSetName should be normalisedBootloaderSetName — I know there's existing American in a couple of identifiers (normalizeMAC and friends); those predate the convention and I'd rather not add to them. Docs seem fine.
  • Please drop the new explanatory comments (menu.go, server.go, proxydhcp.go) — we keep code comment-free and rely on naming, with rationale living in tests and docs.
  • The "configured > manifest > compiled default" block is now duplicated between proxyDHCPBootfiles and proxyDHCPBootfilesForClient, with dead == "" arms in the copy — a shared bootfilesForSet(setName) helper would stop the two drifting. Similarly, set-name normalisation runs at three-plus layers and isn't idempotent ("" gets resurrected to "default" by the next layer), and the clean-and-reject traversal idiom appears three times alongside a fourth filepath.Rel mechanism — one helper each, please.

Happy to go through any of these if you disagree

@garybowers

Copy link
Copy Markdown
Owner

Thanks for this — the core mechanism is solid and the test coverage is genuinely good. The precedence resolution, the set-qualified path scheme, and the traversal hardening in resolveBootloaderFile are all improvements I'm happy to take. There are a few bugs that break real boot scenarios though, including the feature's own headline use case, so I'd like those addressed before merging.

Blocking

1. Per-client sets break script-less iPXE builds. Stock iPXE binaries without an embedded script fetch autoexec.ipxe relative to the directory they were loaded from. A client on an override set loads bootloader-sets/se350/custom.efi, then requests bootloader-sets/se350/autoexec.ipxe — but the TFTP handler's dynamic autoexec special case matches the literal path autoexec.ipxe before the set prefix is decoded, so the request falls through to the file lookups and returns file-not-found, dropping the machine to the iPXE shell. The same set works when selected globally, so the per-client path silently behaves differently. Our secureboot-official set explicitly relies on autoexec over TFTP.

Remedy: move the autoexec special case below the resolveBootloaderRequest call and match on the decoded bootloaderFilename instead of the raw path, so bootloader-sets/<set>/autoexec.ipxe serves the same dynamic script as the unqualified request. A test asserting a qualified autoexec fetch returns the chain script would lock it in.

2. Explicitly configured bootfile names get re-qualified into the override set. With --proxy-dhcp-bootfile-uefi=snponly.efi (file living in the global set's directory), an override client is advertised bootloader-sets/<set>/snponly.efi, which doesn't exist in that set on disk or embedded — TFTP file-not-found and the client fails to PXE boot entirely, while non-override clients boot fine. Related: because the configured value always beats the manifest, selecting a different set for a client never changes which binary is actually served when any ProxyDHCPBootfile* is customised — only the path prefix changes.

Remedy: only qualify names that came from the override set itself. Extract a shared bootfilesForSet(setName) helper resolving manifest → compiled default (this also removes the duplicated precedence block noted under Style), and in proxyDHCPBootfilesForClient return an explicitly configured value unqualified — an operator's explicit setting should mean the same file for every client. If you'd rather explicit config lose to a per-client set, that's defensible too, but then it should resolve entirely from the set's manifest rather than qualifying the configured name into a directory where it doesn't exist.

3. One-shot next-boot is silently swallowed when the image's group is disabled. handleIPXEMenu clears the next-boot flag when the menu is generated, but nextBootGroupID requires the containing group to be enabled and loaded — if it isn't, no goto and no --default are emitted, so the ordinary menu renders and the one-time reinstall is consumed without ever running. The previous goto isoN bypass handled this correctly, since boot sections are emitted for all enabled images regardless of group state.

Remedy: in Build(), when nextBootImage() finds an enabled image but nextBootGroupID() can't produce a renderable group (disabled group, or groups failed to load), fall back to the old direct goto iso<N> — the boot section exists whenever the image is enabled, so the one-shot still fires. Worth a companion test: next-boot image enabled, containing group disabled, expect the script to boot it.

Behaviour changes I'd like to talk through

  • Next-boot no longer boots immediately. It now waits out the full configured menu timeout (30s default) with the image pre-selected, and anyone at the console can redirect it after the one-shot flag has already been consumed. If the interactive pre-selection is intentional, fine — but the unattended semantics change deserves a mention in the PR body, and arguably the timeout should be clamped for one-shots generally, not only when the theme timeout is 0.
  • The 10-second override leaks into every group menu. When a next-boot is pending and the theme timeout is 0, all group menus get choose --timeout 10000; menus without a --default fail the choose and redraw every 10 seconds, resetting the cursor while someone is browsing. It also triggers off the raw nextBootImageID even when the image isn't rendered in the menu at all.
  • TFTP absolute multi-segment paths regress. /tftpboot/undionly.kpxe was previously flattened to its basename and served; it now preserves the directory and 404s. External DHCP configurations that advertise directory-qualified filenames will stop booting after this change.
  • The AlmaLinux inst.repo removal looks intentional per the new test, but interactive installs without a kickstart may now stall at "error setting up base repository". That change, the next-boot menu rework, and the ?mac= boot attribution are all unrelated to per-client bootloader sets and none is mentioned in the PR body — I'd prefer them split out, or at minimum documented.

Performance

Every PXE packet now runs one or two synchronous database queries plus a manifest read/parse inside the single-threaded proxyDHCP UDP loop — and on Postgres, GetClient also preloads the client's full image associations. A rack-wide mass reboot serialises behind those queries and can overflow the UDP socket buffer, causing PXE-E51/E53 timeouts. A short-TTL MAC→set cache (5–10 seconds covers a whole DHCP exchange) or a dedicated two-column lookup would sort it.

Style

  • House style here is British English, and the remaining American-spelled identifiers (normalizeMAC and friends) have just been renamed on a branch heading into dev. Please rename normalizedBootloaderSetName to normalisedBootloaderSetName to match. The docs prose in this PR is already fine.
  • Please drop the new explanatory comments (menu.go, server.go, proxydhcp.go) — we keep code comment-free and rely on naming, with rationale living in tests and docs.
  • The "configured > manifest > compiled default" block is now duplicated between proxyDHCPBootfiles and proxyDHCPBootfilesForClient, with dead == "" arms in the copy — a shared bootfilesForSet(setName) helper would stop the two drifting. Similarly, set-name normalisation runs at three-plus layers and isn't idempotent ("" gets resurrected to "default" by the next layer), and the clean-and-reject traversal idiom appears three times alongside a fourth filepath.Rel mechanism — one helper each, please.

Happy to go through any of these if you disagree — the direction is right and I'd like to land it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-client and client-group bootloader-set overrides are stored but not applied during PXE boot

4 participants