Compare commits

...
5 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 934cfbab46 v0.7.3: UI cleanup — aligned Hosts pin form, native-chrome list filters, Link row reorder
Design-language cleanup of the v0.7.2 additions (no behaviour change).

Hosts:
- The Pin MAC form collapses to a single aligned 4-up row: MAC ·
  Label · Architecture · Boot binary. Target drops full-width onto its
  own line beneath them. The per-field hints that broke the row's
  alignment moved into the explanatory note below, so every control
  shares one baseline.

Storage:
- The image and unattended filter inputs are now wrapped in a
  label.field, so they inherit the standard text-field chrome (border,
  radius, height, focus ring) instead of the raw browser
  <input type=search> look. They span the full card width for
  continuity with the rest of the page.

Network:
- The 'Link' row moved below 'Public base URL'. Its joined
  operstate · speed · duplex · MAC string runs long, so placing it last
  lets it wrap at the bottom without shoving the other rows around.

Validation: clippy clean, fmt clean, 299 workspace tests green, webui
syntax-checked. No protocol or boot-path changes in this release.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 22:44:19 -04:00
Miles WardandClaude Opus 4.8 a71057fce6 v0.7.2: UI polish — list search, unified Hosts form, NIC link details, About refresh, spacing
Storage:
- Filter inputs for the Available images table (matches filename,
  detected family, category, source) and the Unattended files list
  (name, kind). Pure client-side; shown when there's more than one
  entry. Forty-image libraries are now navigable.

Hosts — one form, one mental model:
- The separate Boot rules card is gone. The Pin form gains
  'Architecture (optional)' next to Label (plus the v0.7.1 boot-binary
  pin): a full MAC with no architecture saves a per-host pin exactly as
  before; a MAC prefix and/or architecture saves a first-match-wins
  group rule. Saved rules render as a compact read-only 'Group rules'
  card with remove buttons.
- The boot-decision webhook keeps working via /api/boot-rules but no
  longer has a UI knob (operator feedback: not needed in the UI).
- Per-machine auto-deploy fields are rejected on group rules with a
  clear message (they're per-host values).

Network:
- New 'Link' row under NIC name: operstate · speed · duplex · port MAC,
  read from sysfs at startup (detect_link_info). Empty-degrades on
  non-Linux dev builds and virtual NICs. Confirms WHICH physical port
  answers PXE in multi-NIC/trunked environments.

About:
- Hero copy rewritten: positioning lead, three-pillar feature grid
  (Boot anything / Adapt to every machine / Run it in production), and
  the privacy + no-test-cert principles restated crisply.

Spacing:
- label.field:has(+ button) collapse fixes the doubled 30px gap above
  Queue 'Launch for all waiting' and Network 'Save' (now the same 16px
  as every other card action).

Validation: clippy clean, fmt clean, 299 workspace tests green, webui
syntax-checked. No protocol or boot-path changes in this release.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 21:47:48 -04:00
Miles WardandClaude Opus 4.8 29040e8a5a v0.7.1: walk the ladder once ever — persistent learned modes, rule pins, same-boot iPXE recovery
Answers the operational question 'can a machine try all three boot
binaries in one go?' The protocol can't carry three NBPs in one cycle
(one boot file per DHCP round, the Secure-Boot refusal happens after
handoff with no error report, and the broken-NIC case specifically needs
the firmware itself to load builtin-driver iPXE — GRUB's network rides
the same broken firmware stack). What we CAN do is make the walk a
once-per-machine-ever event and give operators a way to skip it:

- Learned driver modes persist (<work_dir>/driver_modes.json). A MAC
  that reaches the Shim rung, or confirms an iPXE handoff at Builtin,
  is pinned to disk: immune to the 30-min TTL, reloaded at startup.
  The file only carries exceptions — a healthy fleet never writes it.
  Corrupt file starts empty (standard crash-cache policy).
- Boot rules gain an optional driver_mode pin (auto/firmware/builtin/
  shim), consulted by the DHCP proxy BEFORE the escalation ladder:
  'this OUI is a Secure Boot rack -> serve shim immediately' = zero
  failed cycles. Mode-only rules coexist with target rules (a pin
  doesn't shadow a later target match). Editor column on Hosts tab.
- grub.cfg now tries to chainload all-drivers iPXE before showing the
  signed menu: with SB off the chainload succeeds and the client gets
  the full iPXE feature set back in the SAME boot (self-healing for
  mis-escalations, and the handoff then pins the working mode); with
  SB on, shim's verifier refuses it inline — no reboot — and the
  signed menu appears.

DhcpProxyServer now takes the escalation table + rules store from main
(persistence path comes from the configured work dir).

Validation: clippy clean, fmt clean, 299 workspace tests green (+9:
persistence round-trip across restart, Shim pin survives TTL, learned
Builtin survives TTL, corrupt-file recovery, default-mode-never-
persisted, rule-pin matching incl. unknown-mode tolerance and
pin/target coexistence, GRUB chainload-before-menu ordering, API
round-trip of the driver_mode field).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 21:16:22 -04:00
Miles WardandClaude Opus 4.8 3a32d65fb7 v0.7.0: Secure Boot chain, boot rules + decision webhook, tokenized answer files
Three features, all zero-toggle and principle-clean (single static musl
binary, container-first, no test certs, no client trust-store changes).

Secure Boot via signed shim+GRUB (automatic):
- The v0.6.1 escalation ladder gains a third rung: Firmware -> Builtin
  -> Shim. Secure-Boot firmware downloads our unsigned iPXE but refuses
  to execute it — indistinguishable from a failed chainload — so after
  two unconfirmed attempts the MAC is offered Fedora's Microsoft-signed
  shimx64.efi, which loads the signed GRUB, which fetches a
  server-rendered grub.cfg. Fully signed chain, SB stays on.
- scripts/fetch-shim.sh pulls shim-x64/grub2-efi-x64 (+aa64 best-effort)
  from the official Fedora 43 packages and ships the EFI binaries
  byte-for-byte unmodified; Dockerfile fetch stage gained rpm2cpio/cpio.
- New grub_script renderer (Linux kernel entries only — signed GRUB only
  boots signed kernels; sanboot/wimboot have no signed equivalent and
  are omitted with an explanatory menu line).
- TFTP server gains a DynamicAsset hook for server-rendered names
  (grub.cfg); HTTP serves the same config under /ipxe/grub.cfg for
  native UEFI HTTP Boot chains. Arch-aware fallback walks back down the
  ladder where no shim exists (BIOS, IA32).

Boot rules + decision webhook (open 'Matrix Boot'):
- Ordered first-match-wins rules over MAC prefix + client arch (the DHCP
  proxy now bakes arch into the boot.ipxe chain URL), generalizing
  per-MAC pins. Persisted to boot_rules.json; GET/PUT /api/boot-rules;
  rules editor + webhook field on the Hosts tab.
- Optional pixiecore-style webhook: unmatched boots GET
  <url>?mac=&arch= and 200 {"target":"id"} chains to it. Fail-open
  with a 2s budget — a dead endpoint can never block PXE.
- Decision order: exact pin -> rules -> webhook -> menu. Empty config
  is byte-for-byte the previous behavior.

Tokenized answer files (the post-WDS/CVE-2026-0386 hardening):
- Every generated unattended URL (inst.ks / preseed url / autoinstall
  seed) now carries a 4h boot-scoped token; /unattended/{id} and the
  cloud-init seed routes require it (or an operator session) once an
  admin exists. Stops answer-file credential harvesting by anything
  else on the network. No toggle; setup-mode installs stay open.

Validation: clippy clean, fmt clean, 290 workspace tests green
(+18 new across boot_tokens, boot_rules, arch ladder, escalation,
grub renderer, and four new full-flow integration tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 20:17:18 -04:00
Miles WardandClaude Opus 4.8 7f25bb681c v0.6.3: russh 0.61 security bump (CVE batch) + bergshamra 0.5 + axum 0.8
Security-driven dependency release.

- russh =0.55.0 (pinned) -> 0.61.2: closes the advisory batch reachable
  from our SFTP *client* path — unbounded/allocation-first packet
  parsing (CVE-2026-48110, CVE-2026-46702, CVE-2026-46673, HIGH) plus
  CVE-2026-48107 in client auth. A malicious or compromised SFTP server
  an operator pointed us at could previously OOM the PXE server. Also
  drops mlock on non-secret buffers (~21% SSH throughput upstream) —
  directly in the remote-share ISO streaming path. ring backend kept;
  zero code changes needed in sftp_share.rs.
- bergshamra 0.4 -> 0.5.1: the pin's blocking condition (stable
  RustCrypto generation, pkcs8 0.11) is now met upstream, so the
  =0.55.0 pin is deleted and its comment rewritten as history. 0.5 is
  secure-by-default for DSig (flags we already set explicitly) and
  fixes an XML-Enc DerivedKey fallthrough.
- axum 0.7 -> 0.8.9: route captures /:id -> {id} across the router and
  the /api/docs listing; ConnectInfo optional extraction moves to the
  Result form. Gains the HEAD content-length fix (iPXE/sanboot clients
  probe with HEAD before Range requests) and puts us back on the
  maintained line.

Validation: clippy clean, fmt clean, all 272 workspace tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 19:47:13 -04:00
24 changed files with 2866 additions and 749 deletions
Generated
+789 -515
View File
File diff suppressed because it is too large Load Diff
+12 -17
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.6.2" version = "0.7.3"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -33,7 +33,7 @@ dhcproto = "0.15"
socket2 = { version = "0.6", features = ["all"] } socket2 = { version = "0.6", features = ["all"] }
bytes = "1.7" bytes = "1.7"
axum = { version = "0.7", features = ["macros", "multipart", "http2"] } axum = { version = "0.8", features = ["macros", "multipart", "http2"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] } tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.9" hyper = "1.9"
@@ -80,7 +80,7 @@ lettre = { version = "0.11", default-features = false, features = ["smtp-transpo
# C deps), so the static musl binary stays OpenSSL-free — samael was # C deps), so the static musl binary stays OpenSSL-free — samael was
# rejected precisely because it hard-requires OpenSSL. We build the thin # rejected precisely because it hard-requires OpenSSL. We build the thin
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top. # SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
bergshamra = "0.4" bergshamra = "0.5"
roxmltree = "0.21" roxmltree = "0.21"
quick-xml = "0.40" quick-xml = "0.40"
x509-parser = "0.18" x509-parser = "0.18"
@@ -99,24 +99,19 @@ base64 = "0.22"
# binary via rustls + bergshamra — so SFTP adds ZERO new C/crypto deps # binary via rustls + bergshamra — so SFTP adds ZERO new C/crypto deps
# and the static-musl build stays OpenSSL-free. # and the static-musl build stays OpenSSL-free.
# #
# CRITICAL #2 — pinned to EXACTLY 0.55.0, the newest russh that # CRITICAL #2 — history: this was pinned to =0.55.0 from v0.5.5 until
# coexists with bergshamra-crypto (our SAML core). The RustCrypto # v0.6.3 because bergshamra-crypto pinned release-candidate RustCrypto
# ecosystem is mid-transition: bergshamra-crypto pins a constellation of # crates that conflicted with the stable generation russh 0.56+ pulls.
# release-CANDIDATE crates (`pkcs8 =0.11.0-rc.11` and its matching # bergshamra 0.5 (2026-06) moved to the stable generation (pkcs8 0.11),
# pkcs5/spki RCs) that are API-incompatible with the STABLE versions of # lifting the pin. v0.6.3 bumps to 0.61+, which also closes a batch of
# the same crates in the same semver bucket. russh 0.56+ pulls those # RUSTSEC advisories reachable from the SFTP *client* path (unbounded
# stable crates (`pkcs5 0.8`), which silently replaces bergshamra's RC # allocations in packet parsing — CVE-2026-48110/-46702/-46673 et al.)
# copies and breaks compilation. russh ≤0.55 stays on the previous stable # and drops mlock on non-secret buffers (~21% SSH throughput upstream).
# generation (`pkcs5 0.7`, `ssh-key 0.6`), which unifies with bergshamra's
# *stable* deps and leaves the RC bucket untouched — verified to compile.
# 0.55 still has the merged `russh::keys` API (keys merged at 0.50).
# IMPORTANT: do NOT bump russh past 0.55 until bergshamra-crypto adopts
# the stable RustCrypto generation; 0.56+ will not compile in this tree.
# #
# SCP was deliberately rejected: the protocol is sequential-only (no # SCP was deliberately rejected: the protocol is sequential-only (no
# random access → no HTTP Range, unlike SFTP/NFS) and the mature SCP # random access → no HTTP Range, unlike SFTP/NFS) and the mature SCP
# crates wrap libssh2 (C + OpenSSL), which would break this build. # crates wrap libssh2 (C + OpenSSL), which would break this build.
russh = { version = "=0.55.0", default-features = false, features = ["ring"] } russh = { version = "0.61", default-features = false, features = ["ring"] }
russh-sftp = "2.3" russh-sftp = "2.3"
openpxe-core = { path = "crates/core" } openpxe-core = { path = "crates/core" }
+85 -6
View File
@@ -23,13 +23,21 @@ pub enum ClientArch {
Unknown(u16), Unknown(u16),
} }
/// Which iPXE network backend to advertise to a client (v0.6.1). /// Which boot binary family to advertise to a client (v0.6.1, extended
/// v0.7.0).
/// ///
/// OpenPXE serves [`DriverMode::Firmware`] first (the firmware's own NIC /// OpenPXE serves [`DriverMode::Firmware`] first (the firmware's own NIC
/// stack, via `snponly`/`undionly`) and only escalates a specific MAC to /// stack, via `snponly`/`undionly`) and escalates a specific MAC
/// [`DriverMode::Builtin`] (iPXE's bundled NIC drivers) automatically, when a /// automatically when a boot never completes its handoff:
/// firmware-net boot fails to chainload. There is no operator toggle — the /// `Firmware → Builtin → Shim`. There is no operator toggle — the DHCP
/// DHCP proxy decides per client. /// proxy decides per client.
///
/// The `Shim` rung (v0.7.0) covers Secure Boot: firmware with SB enabled
/// downloads our unsigned iPXE fine but refuses to *execute* it, which
/// looks exactly like a failed chainload. After both iPXE builds go
/// unconfirmed, the client is offered the Microsoft-signed shim, which
/// loads the signed GRUB, which fetches a server-rendered menu — a fully
/// signed chain that boots signed distro kernels with SB still on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DriverMode { pub enum DriverMode {
@@ -40,6 +48,9 @@ pub enum DriverMode {
/// iPXE's own bundled NIC drivers (`ipxe.efi`, `ipxe.pxe`). Fallback for /// iPXE's own bundled NIC drivers (`ipxe.efi`, `ipxe.pxe`). Fallback for
/// hardware whose firmware NIC stack is missing or buggy. /// hardware whose firmware NIC stack is missing or buggy.
Builtin, Builtin,
/// Microsoft-signed shim + GRUB chain (`shimx64.efi`). Final fallback
/// for Secure-Boot-enabled UEFI clients that refuse unsigned iPXE.
Shim,
} }
impl ClientArch { impl ClientArch {
@@ -88,20 +99,43 @@ impl ClientArch {
// IA32 UEFI. // IA32 UEFI.
(Self::Ia32Uefi, DriverMode::Firmware) => "snponly-i386.efi", (Self::Ia32Uefi, DriverMode::Firmware) => "snponly-i386.efi",
(Self::Ia32Uefi, DriverMode::Builtin) => "ipxe-i386.efi", (Self::Ia32Uefi, DriverMode::Builtin) => "ipxe-i386.efi",
// No signed Shim chain for BIOS (no Secure Boot there) or
// IA32 UEFI (Fedora publishes no 32-bit shim; SB-on IA32
// clients are vanishingly rare). Same outcome as the
// no-binary arches below, listed separately for the comment.
#[allow(clippy::match_same_arms)]
(Self::LegacyX86 | Self::Ia32Uefi, DriverMode::Shim) => return None,
// x86_64 UEFI — the overwhelmingly common modern client. // x86_64 UEFI — the overwhelmingly common modern client.
(Self::X64Uefi, DriverMode::Firmware) => "snponly.efi", (Self::X64Uefi, DriverMode::Firmware) => "snponly.efi",
(Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi", (Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi",
(Self::X64Uefi, DriverMode::Shim) => "shimx64.efi",
// ARM64 UEFI. // ARM64 UEFI.
(Self::Arm64Uefi, DriverMode::Firmware) => "snponly-arm64.efi", (Self::Arm64Uefi, DriverMode::Firmware) => "snponly-arm64.efi",
(Self::Arm64Uefi, DriverMode::Builtin) => "ipxe-arm64.efi", (Self::Arm64Uefi, DriverMode::Builtin) => "ipxe-arm64.efi",
(Self::Arm64Uefi, DriverMode::Shim) => "shimaa64.efi",
// ARM32 UEFI: upstream boot.ipxe.org publishes no prebuilt binary // ARM32 UEFI: upstream boot.ipxe.org publishes no prebuilt binary
// for this arch in either mode. Unknown arches likewise. Return // for this arch in any mode. Unknown arches likewise. Return
// None so the DHCP proxy declines rather than advertising a file // None so the DHCP proxy declines rather than advertising a file
// we can't serve. // we can't serve.
(Self::Arm32Uefi | Self::Unknown(_), _) => return None, (Self::Arm32Uefi | Self::Unknown(_), _) => return None,
}) })
} }
/// Like [`Self::ipxe_bootfile_mode`], but walks back down the
/// escalation ladder (`Shim → Builtin → Firmware`) when the requested
/// mode has no binary for this arch — e.g. a BIOS client whose
/// escalation state reached `Shim` (BIOS has no Secure Boot) falls
/// back to the all-drivers build instead of being ignored.
#[must_use]
pub fn bootfile_with_fallback(self, mode: DriverMode) -> Option<&'static str> {
let ladder: &[DriverMode] = match mode {
DriverMode::Shim => &[DriverMode::Shim, DriverMode::Builtin, DriverMode::Firmware],
DriverMode::Builtin => &[DriverMode::Builtin, DriverMode::Firmware],
DriverMode::Firmware => &[DriverMode::Firmware],
};
ladder.iter().find_map(|m| self.ipxe_bootfile_mode(*m))
}
#[must_use] #[must_use]
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
@@ -233,6 +267,51 @@ mod tests {
assert_eq!(DriverMode::default(), DriverMode::Firmware); assert_eq!(DriverMode::default(), DriverMode::Firmware);
} }
#[test]
fn shim_mode_maps_to_signed_chain_on_uefi_only() {
assert_eq!(
ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Shim),
Some("shimx64.efi")
);
assert_eq!(
ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Shim),
Some("shimaa64.efi")
);
// No Secure Boot on BIOS, no published 32-bit shim.
assert_eq!(
ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Shim),
None
);
assert_eq!(
ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Shim),
None
);
}
#[test]
fn fallback_walks_down_the_ladder() {
// BIOS escalated to Shim → falls back to the all-drivers build.
assert_eq!(
ClientArch::LegacyX86.bootfile_with_fallback(DriverMode::Shim),
Some("ipxe.pxe")
);
// UEFI x64 at Shim gets the real shim.
assert_eq!(
ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Shim),
Some("shimx64.efi")
);
// Plain modes are unchanged.
assert_eq!(
ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Firmware),
Some("snponly.efi")
);
// Arches with nothing stay None.
assert_eq!(
ClientArch::Arm32Uefi.bootfile_with_fallback(DriverMode::Shim),
None
);
}
#[test] #[test]
fn firmware_class_detects_ipxe_over_pxeclient() { fn firmware_class_detects_ipxe_over_pxeclient() {
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE")); let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
+369
View File
@@ -0,0 +1,369 @@
//! Label-based boot rules + boot-decision webhook (v0.7.0).
//!
//! Generalizes [`crate::host_bindings::HostBindings`] (exact-MAC pins)
//! into ordered, first-match-wins rules over what the boot chain knows
//! about a client — MAC prefix (OUI or longer) and firmware
//! architecture — plus an optional outbound webhook so external
//! automation (CMDB, netbox, a shell script) can decide the boot target
//! per machine, pixiecore-style.
//!
//! Decision order in the boot script handler, most-specific first:
//! 1. exact per-MAC host binding (operator pin)
//! 2. first matching enabled rule here
//! 3. webhook, if configured (fail-open: timeout/error → menu)
//! 4. interactive menu
//!
//! With no rules and no webhook configured the behavior is byte-for-byte
//! what it was before this feature existed — no toggles to flip.
//!
//! Persisted to `<work_dir>/boot_rules.json` with the same "in-memory
//! authoritative, disk is a crash cache, corruption falls back to empty"
//! policy as the host bindings — a bad rules file must never block PXE.
use crate::host_bindings::normalize_mac;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
/// One ordered rule. All present (non-empty) selectors must match —
/// empty selector fields match anything, so a rule with only `arch` set
/// applies to every client of that architecture.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootRule {
/// Case-insensitive MAC prefix, `:`-separated (e.g. `dc:a6:32` for
/// an OUI, or longer). Empty = any MAC.
#[serde(default)]
pub mac_prefix: String,
/// Client architecture selector — matches `ClientArch::as_str()`
/// (`bios`, `uefi-x64`, `uefi-ia32`, `uefi-arm64`). Empty = any.
#[serde(default)]
pub arch: String,
/// Boot entry id (a `BootEntry::id`) or reserved menu name
/// (`_local`, `_queue`, …) to chain to when this rule matches.
/// May be empty for a rule that only pins a driver mode.
#[serde(default)]
pub target: String,
/// v0.7.1: optional first-boot binary pin — `""` (auto: let the
/// escalation ladder decide), `"firmware"`, `"builtin"`, or
/// `"shim"`. Lets an operator declare "this rack is all Secure
/// Boot → serve the signed chain immediately", skipping the
/// learn-by-failing walk entirely for known fleets.
#[serde(default)]
pub driver_mode: String,
/// Rules can be parked without deleting them.
#[serde(default = "default_true")]
pub enabled: bool,
/// Operator note shown in the UI (`"all Pi 4s"`, `"QA rack"`).
#[serde(default)]
pub note: String,
}
fn default_true() -> bool {
true
}
/// The whole persisted config: ordered rules + optional webhook.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BootRulesConfig {
pub rules: Vec<BootRule>,
/// Optional boot-decision webhook URL. When set, unmatched boots GET
/// `<url>?mac=<mac>&arch=<arch>` and a `200 {"target": "<id>"}`
/// reply chains to that target. Anything else (404, timeout, bad
/// JSON) falls through to the menu. Empty = disabled.
pub webhook_url: String,
}
/// Store for the rules config. Cheap to clone; locks held briefly.
#[derive(Debug, Clone)]
pub struct BootRulesStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<BootRulesConfig>>,
}
impl BootRulesStore {
/// Load from `work_dir/boot_rules.json`, or start empty if absent /
/// unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("boot_rules.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<BootRulesConfig>(&text) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!(
target: "openpxe::boot_rules",
"boot_rules.json present but unreadable ({e}); starting empty"
);
BootRulesConfig::default()
}
},
Err(_) => BootRulesConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Current config snapshot (for the API / UI).
#[must_use]
pub fn snapshot(&self) -> BootRulesConfig {
self.inner.read().clone()
}
/// Replace the whole config (the UI saves the full table at once —
/// rules are ordered, so partial updates would be ambiguous).
pub fn replace(&self, mut cfg: BootRulesConfig) {
for r in &mut cfg.rules {
r.mac_prefix = normalize_mac(&r.mac_prefix);
r.arch = r.arch.trim().to_ascii_lowercase();
r.target = r.target.trim().to_string();
r.driver_mode = r.driver_mode.trim().to_ascii_lowercase();
r.note = r.note.trim().to_string();
}
cfg.webhook_url = cfg.webhook_url.trim().to_string();
*self.inner.write() = cfg;
self.persist();
}
/// Webhook URL, when configured.
#[must_use]
pub fn webhook_url(&self) -> Option<String> {
let g = self.inner.read();
if g.webhook_url.is_empty() {
None
} else {
Some(g.webhook_url.clone())
}
}
/// First enabled rule matching `(mac, arch)`, in stored order.
/// `arch` is the `ClientArch::as_str()` form when the boot chain
/// passed one along, `None` otherwise (older chains).
#[must_use]
pub fn match_target(&self, mac: &str, arch: Option<&str>) -> Option<String> {
self.first_match(mac, arch, |r| {
(!r.target.is_empty()).then(|| r.target.clone())
})
}
/// v0.7.1: first enabled rule that pins a driver mode for `(mac,
/// arch)`. Consulted by the DHCP proxy *before* the automatic
/// escalation ladder — an operator who knows a rack is all Secure
/// Boot pins it to `shim` and those machines never walk the ladder.
/// Unknown mode strings are ignored (forward compatibility).
#[must_use]
pub fn driver_mode_hint(&self, mac: &str, arch: Option<&str>) -> Option<crate::DriverMode> {
self.first_match(mac, arch, |r| match r.driver_mode.as_str() {
"firmware" => Some(crate::DriverMode::Firmware),
"builtin" => Some(crate::DriverMode::Builtin),
"shim" => Some(crate::DriverMode::Shim),
_ => None,
})
}
/// Shared rule-matching walk: returns the first `extract` result from
/// an enabled rule whose selectors match. Rules that match but yield
/// `None` from `extract` (e.g. no target set, or no driver mode set)
/// don't stop the walk — target rules and mode-pin rules coexist.
fn first_match<T>(
&self,
mac: &str,
arch: Option<&str>,
extract: impl Fn(&BootRule) -> Option<T>,
) -> Option<T> {
let mac = normalize_mac(mac);
let g = self.inner.read();
for r in &g.rules {
if !r.enabled {
continue;
}
if !r.mac_prefix.is_empty() && !mac.starts_with(r.mac_prefix.as_str()) {
continue;
}
if !r.arch.is_empty() {
// An arch-selective rule can only match when the chain
// told us the client's arch.
match arch {
Some(a) if a.eq_ignore_ascii_case(&r.arch) => {}
_ => continue,
}
}
if let Some(v) = extract(r) {
return Some(v);
}
}
None
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::boot_rules", "serialize boot_rules.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::boot_rules", "write boot_rules.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::boot_rules", "rename boot_rules.json: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn rule(mac_prefix: &str, arch: &str, target: &str) -> BootRule {
BootRule {
mac_prefix: mac_prefix.into(),
arch: arch.into(),
target: target.into(),
driver_mode: String::new(),
enabled: true,
note: String::new(),
}
}
#[test]
fn driver_mode_hint_pins_known_modes_and_ignores_unknown() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut sb_rack = rule("aa:bb:cc", "", "");
sb_rack.driver_mode = "SHIM".into(); // normalized on replace
let mut weird = rule("11:22:33", "", "");
weird.driver_mode = "quantum".into(); // unknown → ignored
s.replace(BootRulesConfig {
rules: vec![sb_rack, weird],
webhook_url: String::new(),
});
assert_eq!(
s.driver_mode_hint("aa:bb:cc:00:00:01", None),
Some(crate::DriverMode::Shim)
);
assert_eq!(s.driver_mode_hint("11:22:33:00:00:01", None), None);
assert_eq!(s.driver_mode_hint("99:99:99:00:00:01", None), None);
}
#[test]
fn mode_pin_rule_does_not_shadow_later_target_rule() {
// A mode-only rule and a target rule can both apply to the same
// client: the mode pin must not consume the target walk.
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut pin = rule("aa:bb", "", "");
pin.driver_mode = "builtin".into();
s.replace(BootRulesConfig {
rules: vec![pin, rule("aa:bb", "", "rack-image")],
webhook_url: String::new(),
});
assert_eq!(
s.driver_mode_hint("aa:bb:00:00:00:01", None),
Some(crate::DriverMode::Builtin)
);
assert_eq!(
s.match_target("aa:bb:00:00:00:01", None).as_deref(),
Some("rack-image")
);
}
#[test]
fn empty_config_matches_nothing() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
assert!(s
.match_target("aa:bb:cc:dd:ee:ff", Some("uefi-x64"))
.is_none());
assert!(s.webhook_url().is_none());
}
#[test]
fn first_match_wins_in_order() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![
rule("aa:bb:cc", "", "rack-image"),
rule("", "", "catch-all"),
],
webhook_url: String::new(),
});
assert_eq!(
s.match_target("AA-BB-CC-00-00-01", None).as_deref(),
Some("rack-image")
);
assert_eq!(
s.match_target("11:22:33:44:55:66", None).as_deref(),
Some("catch-all")
);
}
#[test]
fn arch_selector_requires_known_arch() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![rule("", "uefi-arm64", "arm-image")],
webhook_url: String::new(),
});
assert_eq!(
s.match_target("aa:bb:cc:00:00:01", Some("uefi-arm64"))
.as_deref(),
Some("arm-image")
);
// Wrong arch, or arch unknown to the chain → no match.
assert!(s.match_target("aa:bb:cc:00:00:01", Some("bios")).is_none());
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
}
#[test]
fn disabled_rules_are_skipped() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut r = rule("", "", "x");
r.enabled = false;
s.replace(BootRulesConfig {
rules: vec![r],
webhook_url: String::new(),
});
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
}
#[test]
fn config_round_trips_to_disk() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![rule("DC-A6-32", "", "pi-image")],
webhook_url: " http://automation/boot ".into(),
});
drop(s);
let s2 = BootRulesStore::load_or_default(dir.path());
// Prefix was normalized on replace, webhook trimmed.
assert_eq!(
s2.match_target("dc:a6:32:01:02:03", None).as_deref(),
Some("pi-image")
);
assert_eq!(s2.webhook_url().as_deref(), Some("http://automation/boot"));
}
#[test]
fn corrupt_file_falls_back_to_empty() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("boot_rules.json"), b"{nope").unwrap();
let s = BootRulesStore::load_or_default(dir.path());
assert!(s.snapshot().rules.is_empty());
}
}
+138
View File
@@ -0,0 +1,138 @@
//! One-time(ish) access tokens for unattended answer files (v0.7.0).
//!
//! Why: answer files routinely embed credentials (local admin passwords,
//! domain-join accounts, root hashes). Serving them to anyone who can
//! GET `/unattended/<id>` is exactly the exposure that got WDS
//! hands-free deployment disabled upstream (CVE-2026-0386 hardening
//! guidance). OpenPXE generates every answer-file URL it injects into a
//! boot chain, so it can scope each URL to the boot that requested it:
//! when a boot script is rendered, a short-lived token is minted and
//! appended; the serving endpoint requires it (or a logged-in operator
//! session, so browser testing keeps working).
//!
//! Deliberately multi-use within the TTL rather than strictly one-shot:
//! real installers fetch the same file more than once (initramfs +
//! installer stage, cloud-init retries), and the token's job is to stop
//! *unrelated* hosts from harvesting credentials, not to count fetches.
//!
//! In-memory only. A server restart invalidates outstanding tokens —
//! acceptable because a restart also interrupts the ISO streaming an
//! in-flight install depends on, and the next boot mints fresh ones.
use parking_lot::Mutex;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use uuid::Uuid;
/// Long enough to cover a slow OS install end-to-end (the answer file is
/// fetched early, but cloud-init can re-read late), short enough that a
/// leaked URL goes stale the same afternoon.
const TOKEN_TTL: Duration = Duration::from_hours(4);
/// Hard cap on outstanding tokens; past it the oldest is evicted. Tokens
/// are minted once per boot-script render, so this only matters under
/// abuse, and serving must never become a memory-growth vector.
const MAX_TOKENS: usize = 4096;
#[derive(Debug, Clone)]
struct Grant {
file_id: String,
issued: Instant,
}
/// In-memory token table. Cheap to clone (`Arc`-shared).
#[derive(Debug, Clone, Default)]
pub struct BootTokens {
inner: std::sync::Arc<Mutex<HashMap<String, Grant>>>,
}
impl BootTokens {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Mint a token granting access to unattended file `file_id` for the
/// next [`TOKEN_TTL`]. Returns the opaque token value to embed in the
/// generated URL.
#[must_use]
pub fn mint(&self, file_id: &str) -> String {
self.mint_at(file_id, Instant::now())
}
/// Is `token` a live grant for `file_id`?
#[must_use]
pub fn check(&self, token: &str, file_id: &str) -> bool {
self.check_at(token, file_id, Instant::now())
}
fn mint_at(&self, file_id: &str, now: Instant) -> String {
let token = Uuid::new_v4().simple().to_string();
let mut g = self.inner.lock();
g.retain(|_, gr| now.duration_since(gr.issued) < TOKEN_TTL);
if g.len() >= MAX_TOKENS {
if let Some(oldest) = g
.iter()
.min_by_key(|(_, gr)| gr.issued)
.map(|(k, _)| k.clone())
{
g.remove(&oldest);
}
}
g.insert(
token.clone(),
Grant {
file_id: file_id.to_string(),
issued: now,
},
);
token
}
fn check_at(&self, token: &str, file_id: &str, now: Instant) -> bool {
let g = self.inner.lock();
g.get(token)
.is_some_and(|gr| gr.file_id == file_id && now.duration_since(gr.issued) < TOKEN_TTL)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mint_then_check_round_trip() {
let t = BootTokens::new();
let tok = t.mint("ks-1");
assert!(t.check(&tok, "ks-1"));
// Multi-use within TTL: a second fetch still passes.
assert!(t.check(&tok, "ks-1"));
// Wrong file id never passes, even with a live token.
assert!(!t.check(&tok, "ks-2"));
// Unknown token never passes.
assert!(!t.check("nope", "ks-1"));
}
#[test]
fn token_expires_after_ttl() {
let t = BootTokens::new();
let now = Instant::now();
let tok = t.mint_at("ks-1", now);
let just_before = TOKEN_TTL.checked_sub(Duration::from_secs(1)).unwrap();
assert!(t.check_at(&tok, "ks-1", now + just_before));
assert!(!t.check_at(&tok, "ks-1", now + TOKEN_TTL + Duration::from_secs(1)));
}
#[test]
fn table_is_capped() {
let t = BootTokens::new();
let now = Instant::now();
let first = t.mint_at("f", now);
for i in 0..MAX_TOKENS {
let _ = t.mint_at(&format!("f{i}"), now + Duration::from_secs(1));
}
// The oldest grant was evicted to stay within the cap.
assert!(!t.check_at(&first, "f", now + Duration::from_secs(2)));
assert!(t.inner.lock().len() <= MAX_TOKENS);
}
}
+4
View File
@@ -5,6 +5,8 @@
pub mod arch; pub mod arch;
pub mod auth; pub mod auth;
pub mod boot_log; pub mod boot_log;
pub mod boot_rules;
pub mod boot_tokens;
pub mod branding; pub mod branding;
pub mod client; pub mod client;
pub mod config; pub mod config;
@@ -24,6 +26,8 @@ pub mod wol;
pub use arch::{ClientArch, DriverMode, FirmwareClass}; pub use arch::{ClientArch, DriverMode, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore}; pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use boot_log::{BootEvent, BootLog}; pub use boot_log::{BootEvent, BootLog};
pub use boot_rules::{BootRule, BootRulesConfig, BootRulesStore};
pub use boot_tokens::BootTokens;
pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES}; pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
+5
View File
@@ -19,3 +19,8 @@ thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
bytes.workspace = true bytes.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
# v0.7.1: learned driver modes persist to <work_dir>/driver_modes.json.
serde_json.workspace = true
[dev-dependencies]
tempfile = "3.12"
+292 -35
View File
@@ -1,24 +1,43 @@
//! Automatic per-MAC NIC driver-mode escalation (v0.6.1). //! Automatic per-MAC boot-binary escalation (v0.6.1, extended v0.7.x).
//! //!
//! OpenPXE serves the firmware-net iPXE build (`snponly`/`undionly`) by //! OpenPXE serves the firmware-net iPXE build (`snponly`/`undionly`) by
//! default — it's the most reliable choice for chainloading because the //! default — it's the most reliable choice for chainloading because the
//! firmware just proved its network works by downloading the NBP. A minority //! firmware just proved its network works by downloading the NBP. Two
//! of NICs have a missing or buggy firmware UNDI/SNP stack; those clients //! classes of machine can't run it:
//! TFTP the binary fine, but then iPXE can't bring the link up, so the
//! tell-tale second DHCP DISCOVER carrying the `iPXE` user-class never arrives
//! and the machine eventually re-PXE-boots.
//! //!
//! We detect exactly that: a *fresh* firmware DISCOVER from a MAC whose //! * a minority of NICs have a missing or buggy firmware UNDI/SNP stack —
//! previous firmware attempt was never confirmed by an iPXE handoff means the //! they TFTP the binary fine but iPXE can't bring the link up;
//! firmware-net build failed → escalate that MAC to [`DriverMode::Builtin`] //! * Secure-Boot firmware downloads it fine but refuses to *execute* an
//! (iPXE's own NIC drivers). The decision is sticky — once a MAC settles on a //! unsigned image.
//! mode that completes the handoff, later boots go straight to it. There is no //!
//! operator toggle; it just works, and the default (firmware) path is //! Both look identical from here: the tell-tale second DHCP DISCOVER
//! unchanged so hardware that already boots never regresses. //! carrying the `iPXE` user-class never arrives and the machine
//! re-PXE-boots. So a fresh firmware DISCOVER from a MAC whose previous
//! attempt was never confirmed climbs one rung:
//! `Firmware → Builtin → Shim` (the signed shim+GRUB chain). The decision
//! is sticky; there is no operator toggle; the default path is unchanged
//! so hardware that already boots never regresses.
//!
//! v0.7.1 — **learned modes persist**. Walking the ladder costs one or
//! two failed boot cycles, so a machine should pay it once *ever*, not
//! once per idle window or server restart. Two events pin a MAC's mode
//! to disk (`<work_dir>/driver_modes.json`):
//!
//! * a confirmed iPXE handoff at a non-default mode (Builtin proved to
//! work — also Shim, via the GRUB→iPXE same-boot chainload);
//! * reaching the terminal Shim rung (Secure-Boot machines never produce
//! an iPXE handoff from the signed menu, so escalation itself is the
//! best knowledge we'll ever have).
//!
//! Pinned entries are immune to the TTL and reload at startup. The
//! operator escape hatch is a rules-level driver-mode pin (which
//! overrides this table entirely) or deleting `driver_modes.json`.
use openpxe_core::DriverMode; use openpxe_core::DriverMode;
use parking_lot::Mutex; use parking_lot::Mutex;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Multiple DISCOVERs within this window belong to the *same* boot (DHCP /// Multiple DISCOVERs within this window belong to the *same* boot (DHCP
@@ -26,13 +45,14 @@ use std::time::{Duration, Instant};
/// DISCOVER). They must not be mistaken for a failed-and-retried boot. /// DISCOVER). They must not be mistaken for a failed-and-retried boot.
const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8); const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8);
/// Forget a MAC's state after this long with no activity, so a transient /// Forget an *unpinned* MAC's state after this long with no activity, so
/// escalation doesn't pin a client to Builtin forever and the map stays /// a transient mid-walk state doesn't linger and the map stays bounded.
/// bounded over a long-running deployment. /// Pinned (learned) entries are exempt — that's their whole point.
const ENTRY_TTL: Duration = Duration::from_mins(30); const ENTRY_TTL: Duration = Duration::from_mins(30);
/// Hard cap on tracked MACs. Past this we evict the least-recently-seen /// Hard cap on tracked MACs. Past this we evict the least-recently-seen
/// entry — escalation is best-effort, never a memory-growth vector. /// entry (unpinned first) — escalation is best-effort, never a
/// memory-growth vector.
const MAX_ENTRIES: usize = 4096; const MAX_ENTRIES: usize = 4096;
/// How often (at most) the whole map is swept for expired entries. /// How often (at most) the whole map is swept for expired entries.
@@ -49,6 +69,8 @@ struct Entry {
/// confirm it worked. A *new* boot arriving while this is still true means /// confirm it worked. A *new* boot arriving while this is still true means
/// the previous attempt failed and we should escalate. /// the previous attempt failed and we should escalate.
awaiting_confirm: bool, awaiting_confirm: bool,
/// Learned mode (v0.7.1): persisted to disk, exempt from the TTL.
pinned: bool,
last_seen: Instant, last_seen: Instant,
} }
@@ -72,14 +94,68 @@ impl Default for Inner {
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct DriverEscalation { pub struct DriverEscalation {
inner: Mutex<Inner>, inner: Mutex<Inner>,
/// Persistence target for learned modes; `None` = ephemeral (tests).
path: Option<Arc<PathBuf>>,
} }
impl DriverEscalation { impl DriverEscalation {
/// Ephemeral instance (no persistence) — used by tests.
#[must_use] #[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
/// Instance backed by `<work_dir>/driver_modes.json`. Learned modes
/// from previous runs are reloaded as pinned entries; a missing or
/// corrupt file starts empty (same crash-cache policy as every other
/// store — a bad file must never block PXE).
#[must_use]
pub fn load_or_default(work_dir: &Path) -> Self {
let path = work_dir.join("driver_modes.json");
let mut map = HashMap::new();
if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<HashMap<String, DriverMode>>(&text) {
Ok(loaded) => {
let now = Instant::now();
for (mac, mode) in loaded {
// Firmware is the default — persisting it would be
// noise; tolerate it in the file but don't track it.
if mode == DriverMode::Firmware {
continue;
}
map.insert(
mac,
Entry {
mode,
awaiting_confirm: false,
pinned: true,
last_seen: now,
},
);
}
tracing::info!(
target: "openpxe::dhcp",
learned = map.len(),
"loaded learned driver modes"
);
}
Err(e) => {
tracing::warn!(
target: "openpxe::dhcp",
"driver_modes.json present but unreadable ({e}); starting empty"
);
}
}
}
Self {
inner: Mutex::new(Inner {
map,
last_prune: Instant::now(),
}),
path: Some(Arc::new(path)),
}
}
/// Decide the driver mode for a firmware (PXEClient/HTTPClient) boot from /// Decide the driver mode for a firmware (PXEClient/HTTPClient) boot from
/// `mac`. `primary` is true for the main DHCP DISCOVER (:67) and false for /// `mac`. `primary` is true for the main DHCP DISCOVER (:67) and false for
/// the PXE Boot Server query (:4011); only the primary path drives /// the PXE Boot Server query (:4011); only the primary path drives
@@ -91,28 +167,32 @@ impl DriverEscalation {
/// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the /// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the
/// `iPXE` user-class). The mode we last served worked, so stop awaiting /// `iPXE` user-class). The mode we last served worked, so stop awaiting
/// confirmation and keep it sticky for next time. /// confirmation, keep it sticky, and — for non-default modes — pin it to
/// disk so the machine never re-walks the ladder (v0.7.1).
pub fn mark_ipxe_success(&self, mac: &str) { pub fn mark_ipxe_success(&self, mac: &str) {
self.confirm_at(mac, Instant::now()); self.confirm_at(mac, Instant::now());
} }
fn decide_at(&self, mac: &str, primary: bool, now: Instant) -> DriverMode { fn decide_at(&self, mac: &str, primary: bool, now: Instant) -> DriverMode {
let (mode, snapshot) = {
let mut g = self.inner.lock(); let mut g = self.inner.lock();
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL { if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
g.map g.map
.retain(|_, e| now.duration_since(e.last_seen) < ENTRY_TTL); .retain(|_, e| e.pinned || now.duration_since(e.last_seen) < ENTRY_TTL);
g.last_prune = now; g.last_prune = now;
} }
// Inline staleness check: a MAC whose entry outlived the TTL starts // Inline staleness check: an unpinned MAC whose entry outlived
// fresh even when the amortized sweep above hasn't caught it yet. // the TTL starts fresh even when the amortized sweep above
// hasn't caught it yet. Pinned entries never go stale.
if g.map if g.map
.get(mac) .get(mac)
.is_some_and(|e| now.duration_since(e.last_seen) >= ENTRY_TTL) .is_some_and(|e| !e.pinned && now.duration_since(e.last_seen) >= ENTRY_TTL)
{ {
g.map.remove(mac); g.map.remove(mac);
} }
match g.map.get_mut(mac) { let mut newly_pinned = false;
let mode = match g.map.get_mut(mac) {
None => { None => {
g.map.insert( g.map.insert(
mac.to_owned(), mac.to_owned(),
@@ -120,6 +200,7 @@ impl DriverEscalation {
mode: DriverMode::Firmware, mode: DriverMode::Firmware,
// Only the primary DISCOVER opens a confirmation window. // Only the primary DISCOVER opens a confirmation window.
awaiting_confirm: primary, awaiting_confirm: primary,
pinned: false,
last_seen: now, last_seen: now,
}, },
); );
@@ -131,37 +212,105 @@ impl DriverEscalation {
Some(entry) => { Some(entry) => {
let recent = now.duration_since(entry.last_seen) < SAME_BOOT_DEBOUNCE; let recent = now.duration_since(entry.last_seen) < SAME_BOOT_DEBOUNCE;
if primary && !recent { if primary && !recent {
// A genuinely new boot. If the previous attempt was never // A genuinely new boot. If the previous attempt was
// confirmed, the firmware-net build failed → escalate to // never confirmed, the build we served failed → climb
// the all-drivers build. Builtin is the most capable build // one rung: Firmware (firmware NIC stack) → Builtin
// we have, so it's the single escalation target (and a MAC // (iPXE's own drivers) → Shim (signed shim+GRUB —
// already on Builtin simply stays there). // covers Secure Boot firmware that downloads our
// unsigned iPXE but refuses to execute it). Shim is
// terminal and pins to disk: SB machines never emit
// an iPXE handoff from the signed menu, so reaching
// the rung *is* the durable knowledge.
if entry.awaiting_confirm { if entry.awaiting_confirm {
entry.mode = DriverMode::Builtin; entry.mode = match entry.mode {
DriverMode::Firmware => DriverMode::Builtin,
DriverMode::Builtin | DriverMode::Shim => DriverMode::Shim,
};
if entry.mode == DriverMode::Shim && !entry.pinned {
entry.pinned = true;
newly_pinned = true;
}
} }
entry.awaiting_confirm = true; entry.awaiting_confirm = true;
} }
entry.last_seen = now; entry.last_seen = now;
entry.mode entry.mode
} }
};
(mode, newly_pinned.then(|| pinned_snapshot(&g.map)))
};
if let Some(s) = snapshot {
self.persist(&s);
} }
mode
} }
fn confirm_at(&self, mac: &str, now: Instant) { fn confirm_at(&self, mac: &str, now: Instant) {
let snapshot = {
let mut g = self.inner.lock(); let mut g = self.inner.lock();
if let Some(e) = g.map.get_mut(mac) { let Some(e) = g.map.get_mut(mac) else {
return;
};
e.awaiting_confirm = false; e.awaiting_confirm = false;
e.last_seen = now; e.last_seen = now;
// A proven non-default mode is worth remembering forever —
// the machine demonstrably can't use the default path.
if e.mode != DriverMode::Firmware && !e.pinned {
e.pinned = true;
Some(pinned_snapshot(&g.map))
} else {
None
}
};
if let Some(s) = snapshot {
self.persist(&s);
}
}
/// Best-effort atomic write of the learned-mode table. No-op for
/// ephemeral instances. Failure logs and moves on — persistence is an
/// optimization, never a correctness requirement.
fn persist(&self, snapshot: &HashMap<String, DriverMode>) {
let Some(path) = &self.path else { return };
let body = match serde_json::to_vec_pretty(snapshot) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::dhcp", "serialize driver_modes.json: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::dhcp", "write driver_modes.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path.as_path()) {
tracing::warn!(target: "openpxe::dhcp", "rename driver_modes.json: {e}");
} }
} }
} }
fn pinned_snapshot(map: &HashMap<String, Entry>) -> HashMap<String, DriverMode> {
map.iter()
.filter(|(_, e)| e.pinned)
.map(|(k, e)| (k.clone(), e.mode))
.collect()
}
fn evict_oldest(map: &mut HashMap<String, Entry>) { fn evict_oldest(map: &mut HashMap<String, Entry>) {
if let Some(oldest) = map // Prefer evicting an unpinned entry; only touch learned modes when
.iter() // the whole table is pinned (4096 learned machines — at that point
// the operator has bigger questions than our memory bound).
let pick = |pinned: bool| {
map.iter()
.filter(|(_, e)| e.pinned == pinned)
.min_by_key(|(_, e)| e.last_seen) .min_by_key(|(_, e)| e.last_seen)
.map(|(k, _)| k.clone()) .map(|(k, _)| k.clone())
{ };
if let Some(oldest) = pick(false).or_else(|| pick(true)) {
map.remove(&oldest); map.remove(&oldest);
} }
} }
@@ -169,6 +318,7 @@ fn evict_oldest(map: &mut HashMap<String, Entry>) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tempfile::tempdir;
#[test] #[test]
fn firmware_first_then_escalates_on_unconfirmed_retry() { fn firmware_first_then_escalates_on_unconfirmed_retry() {
@@ -222,7 +372,30 @@ mod tests {
} }
#[test] #[test]
fn stale_entry_is_forgotten_and_resets_to_firmware() { fn third_unconfirmed_attempt_escalates_to_shim_and_stays() {
// v0.7.0: a Secure-Boot client downloads-but-refuses both unsigned
// iPXE builds; the third boot gets the signed shim chain, and the
// MAC stays there for subsequent boots.
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("ee", true, t0), DriverMode::Firmware);
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
// Shim is terminal — a fourth unconfirmed boot stays on Shim.
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(3)),
DriverMode::Shim
);
}
#[test]
fn stale_unpinned_entry_is_forgotten_and_resets_to_firmware() {
let e = DriverEscalation::new(); let e = DriverEscalation::new();
let t0 = Instant::now(); let t0 = Instant::now();
assert_eq!(e.decide_at("dd", true, t0), DriverMode::Firmware); assert_eq!(e.decide_at("dd", true, t0), DriverMode::Firmware);
@@ -230,8 +403,92 @@ mod tests {
e.decide_at("dd", true, t0 + Duration::from_mins(1)), e.decide_at("dd", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin DriverMode::Builtin
); );
// After the TTL with no activity the entry is pruned → fresh firmware. // After the TTL with no activity the (unpinned) Builtin walk is
// pruned → fresh firmware. (A *confirmed* Builtin would be pinned
// and survive — see learned_builtin_survives_ttl.)
let later = t0 + Duration::from_mins(1) + ENTRY_TTL + Duration::from_secs(1); let later = t0 + Duration::from_mins(1) + ENTRY_TTL + Duration::from_secs(1);
assert_eq!(e.decide_at("dd", true, later), DriverMode::Firmware); assert_eq!(e.decide_at("dd", true, later), DriverMode::Firmware);
} }
#[test]
fn shim_pin_survives_ttl() {
// v0.7.1: reaching the Shim rung is durable knowledge — the
// machine must NOT re-walk the ladder after an idle period.
let e = DriverEscalation::new();
let t0 = Instant::now();
let _ = e.decide_at("ff", true, t0);
let _ = e.decide_at("ff", true, t0 + Duration::from_mins(1));
assert_eq!(
e.decide_at("ff", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
let much_later = t0 + Duration::from_mins(2) + ENTRY_TTL + Duration::from_mins(5);
assert_eq!(e.decide_at("ff", true, much_later), DriverMode::Shim);
}
#[test]
fn learned_builtin_survives_ttl() {
let e = DriverEscalation::new();
let t0 = Instant::now();
let _ = e.decide_at("gg", true, t0);
assert_eq!(
e.decide_at("gg", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
// The handoff confirms Builtin → pinned.
e.confirm_at("gg", t0 + Duration::from_secs(61));
let much_later = t0 + ENTRY_TTL + Duration::from_mins(10);
assert_eq!(e.decide_at("gg", true, much_later), DriverMode::Builtin);
}
#[test]
fn learned_modes_persist_across_restart() {
let dir = tempdir().unwrap();
let t0 = Instant::now();
{
let e = DriverEscalation::load_or_default(dir.path());
// Walk one MAC to Shim (pins on escalation)...
let _ = e.decide_at("aa:01", true, t0);
let _ = e.decide_at("aa:01", true, t0 + Duration::from_mins(1));
assert_eq!(
e.decide_at("aa:01", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
// ...and another to a confirmed Builtin (pins on handoff).
let _ = e.decide_at("aa:02", true, t0);
let _ = e.decide_at("aa:02", true, t0 + Duration::from_mins(1));
e.confirm_at("aa:02", t0 + Duration::from_secs(61));
}
// "Restart": a fresh instance from the same work_dir knows both.
let e2 = DriverEscalation::load_or_default(dir.path());
assert_eq!(e2.decide_at("aa:01", true, t0), DriverMode::Shim);
assert_eq!(e2.decide_at("aa:02", true, t0), DriverMode::Builtin);
// Unlearned MACs still start at the default.
assert_eq!(e2.decide_at("aa:03", true, t0), DriverMode::Firmware);
}
#[test]
fn corrupt_persistence_file_starts_empty() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("driver_modes.json"), b"{broken").unwrap();
let e = DriverEscalation::load_or_default(dir.path());
assert_eq!(
e.decide_at("aa:bb", true, Instant::now()),
DriverMode::Firmware
);
}
#[test]
fn confirmed_firmware_is_not_persisted() {
// The default mode is never written — the file only carries
// exceptions, so a healthy fleet leaves it absent/empty.
let dir = tempdir().unwrap();
let t0 = Instant::now();
{
let e = DriverEscalation::load_or_default(dir.path());
let _ = e.decide_at("aa:09", true, t0);
e.confirm_at("aa:09", t0 + Duration::from_secs(2));
}
assert!(!dir.path().join("driver_modes.json").exists());
}
} }
+11 -5
View File
@@ -49,10 +49,13 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
// Pass the client's MAC in the query string so the HTTP // Pass the client's MAC in the query string so the HTTP
// layer can short-circuit to a per-MAC binding when one // layer can short-circuit to a per-MAC binding when one
// exists. iPXE substitutes `${mac}` literally before issuing // exists. iPXE substitutes `${mac}` literally before issuing
// the GET, so this stays static across firmwares. // the GET, so this stays static across firmwares. The arch is
// known *here* from option 93, so it's baked in literally
// (v0.7.0) — it lets boot rules select on architecture.
url: format!( url: format!(
"{}/boot.ipxe?mac=${{mac}}", "{}/boot.ipxe?mac=${{mac}}&arch={}",
ctx.public_base_url.trim_end_matches('/') ctx.public_base_url.trim_end_matches('/'),
ctx.arch.as_str()
), ),
}, },
FirmwareClass::HttpClient => { FirmwareClass::HttpClient => {
@@ -60,9 +63,12 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
// pointing at an EFI executable. We serve the iPXE EFI build for // pointing at an EFI executable. We serve the iPXE EFI build for
// the negotiated driver mode over HTTP; it'll then do the same // the negotiated driver mode over HTTP; it'll then do the same
// script-fetch the iPXE path does. // script-fetch the iPXE path does.
// `bootfile_with_fallback` (v0.7.0) walks back down the
// escalation ladder when the negotiated mode has no binary
// for this arch (e.g. Shim on an arch with no signed chain).
let name = ctx let name = ctx
.arch .arch
.ipxe_bootfile_mode(ctx.driver_mode) .bootfile_with_fallback(ctx.driver_mode)
.unwrap_or("snponly.efi"); .unwrap_or("snponly.efi");
BootDirective::HttpScript { BootDirective::HttpScript {
url: format!( url: format!(
@@ -72,7 +78,7 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
), ),
} }
} }
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile_mode(ctx.driver_mode) { FirmwareClass::PxeClient => match ctx.arch.bootfile_with_fallback(ctx.driver_mode) {
Some(name) => BootDirective::TftpIpxe { Some(name) => BootDirective::TftpIpxe {
filename: name.to_string(), filename: name.to_string(),
}, },
+27 -7
View File
@@ -5,7 +5,9 @@ use crate::escalation::DriverEscalation;
use crate::reply::{build_reply, decide, BootDirective, ReplyContext}; use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
use dhcproto::v4::{DhcpOption, Message, OptionCode}; use dhcproto::v4::{DhcpOption, Message, OptionCode};
use dhcproto::{Decodable, Decoder, Encodable, Encoder}; use dhcproto::{Decodable, Decoder, Encodable, Encoder};
use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass}; use openpxe_core::{
BootRulesStore, ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass,
};
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc; use std::sync::Arc;
@@ -19,12 +21,19 @@ pub struct DhcpProxyServer {
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1). Shared across /// Automatic per-MAC NIC driver-mode escalation (v0.6.1; persistent
/// the :67 and :4011 listener tasks via the server `Arc`. /// learned modes since v0.7.1). Shared across the :67 and :4011
/// listener tasks via the server `Arc`. Built by the caller so the
/// persistence path comes from the configured work dir.
escalation: DriverEscalation, escalation: DriverEscalation,
/// v0.7.1: boot rules — consulted for an operator driver-mode pin
/// (e.g. "this OUI is all Secure Boot → serve shim immediately")
/// before the automatic escalation ladder.
rules: BootRulesStore,
} }
impl DhcpProxyServer { impl DhcpProxyServer {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
bind: IpAddr, bind: IpAddr,
dhcp_port: u16, dhcp_port: u16,
@@ -33,6 +42,8 @@ impl DhcpProxyServer {
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
escalation: DriverEscalation,
rules: BootRulesStore,
) -> Self { ) -> Self {
Self { Self {
bind, bind,
@@ -42,7 +53,8 @@ impl DhcpProxyServer {
public_base_url, public_base_url,
clients, clients,
metrics, metrics,
escalation: DriverEscalation::new(), escalation,
rules,
} }
} }
@@ -142,9 +154,17 @@ impl DhcpProxyServer {
self.escalation.mark_ipxe_success(&mac); self.escalation.mark_ipxe_success(&mac);
DriverMode::Firmware // unused: this path serves the HTTP script DriverMode::Firmware // unused: this path serves the HTTP script
} }
FirmwareClass::PxeClient | FirmwareClass::HttpClient => self FirmwareClass::PxeClient | FirmwareClass::HttpClient => {
.escalation // v0.7.1: an operator rule pin wins over (and bypasses)
.mode_for_firmware_attempt(&mac, label == "67"), // the automatic escalation ladder — known Secure-Boot
// fleets boot the signed chain on the very first cycle.
if let Some(pinned) = self.rules.driver_mode_hint(&mac, Some(arch.as_str())) {
pinned
} else {
self.escalation
.mode_for_firmware_attempt(&mac, label == "67")
}
}
// Unreachable: FirmwareClass::Other returned above. // Unreachable: FirmwareClass::Other returned above.
FirmwareClass::Other => DriverMode::Firmware, FirmwareClass::Other => DriverMode::Firmware,
}; };
+312 -81
View File
@@ -71,7 +71,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/branding/pxe-logo", get(ui_pxe_logo)) .route("/branding/pxe-logo", get(ui_pxe_logo))
// iPXE script endpoints. // iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu)) .route("/boot.ipxe", get(boot_top_menu))
.route("/boot/:filename", get(boot_sub)) .route("/boot/{filename}", get(boot_sub))
// v0.5.2: unattended answer-file *serving* — public (like /iso), // v0.5.2: unattended answer-file *serving* — public (like /iso),
// because the booting installer fetches these with no session. // because the booting installer fetches these with no session.
// `/unattended/:id` serves a Kickstart/Preseed with `{{HOSTNAME}}` // `/unattended/:id` serves a Kickstart/Preseed with `{{HOSTNAME}}`
@@ -80,12 +80,12 @@ pub fn build_router(state: AppState) -> Router {
// autoinstall (`…/<ctx>/user-data` + `/meta-data`), where `<ctx>` // autoinstall (`…/<ctx>/user-data` + `/meta-data`), where `<ctx>`
// base64url-encodes the per-host hostname/ip/mac. Management // base64url-encodes the per-host hostname/ip/mac. Management
// (upload/list/delete) lives under the gated `/api/unattended`. // (upload/list/delete) lives under the gated `/api/unattended`.
.route("/unattended/:id", get(serve_unattended)) .route("/unattended/{id}", get(serve_unattended))
.route("/unattended/:id/:ctx/:sub", get(serve_unattended_seed)) .route("/unattended/{id}/{ctx}/{sub}", get(serve_unattended_seed))
// Bundled binaries and raw ISO access. // Bundled binaries and raw ISO access.
.route("/ipxe/:name", get(ipxe_binary)) .route("/ipxe/{name}", get(ipxe_binary))
.route("/iso/:filename", get(iso_raw)) .route("/iso/{filename}", get(iso_raw))
.route("/iso/:id/*path", get(iso_file)) .route("/iso/{id}/{*path}", get(iso_file))
// Container health/readiness probes. `/healthz` is always 200 OK // Container health/readiness probes. `/healthz` is always 200 OK
// while the HTTP task is alive. `/readyz` additionally requires at // while the HTTP task is alive. `/readyz` additionally requires at
// least one bundled iPXE binary (without one, no client can PXE). // least one bundled iPXE binary (without one, no client can PXE).
@@ -93,23 +93,23 @@ pub fn build_router(state: AppState) -> Router {
.route("/readyz", get(readyz)) .route("/readyz", get(readyz))
// JSON API. // JSON API.
.route("/api/isos", get(api_list_isos).post(api_upload_iso)) .route("/api/isos", get(api_list_isos).post(api_upload_iso))
.route("/api/isos/:id", delete(api_delete_iso)) .route("/api/isos/{id}", delete(api_delete_iso))
.route("/api/uploads", post(api_upload_begin)) .route("/api/uploads", post(api_upload_begin))
.route( .route(
"/api/uploads/:upload_id", "/api/uploads/{upload_id}",
put(api_upload_chunk).delete(api_upload_abort), put(api_upload_chunk).delete(api_upload_abort),
) )
// Per-ISO password prompt. PUT body `{ "password": "..." }` // Per-ISO password prompt. PUT body `{ "password": "..." }`
// sets, `{ "password": null }` (or DELETE) clears. // sets, `{ "password": null }` (or DELETE) clears.
.route( .route(
"/api/isos/:id/password", "/api/isos/{id}/password",
axum::routing::put(api_set_iso_password).delete(api_clear_iso_password), axum::routing::put(api_set_iso_password).delete(api_clear_iso_password),
) )
// v0.4.4: per-ISO menu category (Os / Tools). Drives whether the // v0.4.4: per-ISO menu category (Os / Tools). Drives whether the
// image appears under Linux/Windows Installers (default) or in // image appears under Linux/Windows Installers (default) or in
// the Tools submenu next to memtest / shell / NIC info. // the Tools submenu next to memtest / shell / NIC info.
.route( .route(
"/api/isos/:id/category", "/api/isos/{id}/category",
axum::routing::put(api_set_iso_category), axum::routing::put(api_set_iso_category),
) )
// v0.4.4: filesystem free-space telemetry for the ISO directory's // v0.4.4: filesystem free-space telemetry for the ISO directory's
@@ -120,7 +120,7 @@ pub fn build_router(state: AppState) -> Router {
// logo). v0.5.2: split into three slots — `light` / `dark` / // logo). v0.5.2: split into three slots — `light` / `dark` /
// `client`. Multipart upload to POST; DELETE clears one slot. // `client`. Multipart upload to POST; DELETE clears one slot.
.route( .route(
"/api/branding/logo/:slot", "/api/branding/logo/{slot}",
post(api_branding_upload).delete(api_branding_clear), post(api_branding_upload).delete(api_branding_clear),
) )
// v0.5.2: unattended-install answer-file management (gated). // v0.5.2: unattended-install answer-file management (gated).
@@ -130,7 +130,7 @@ pub fn build_router(state: AppState) -> Router {
"/api/unattended", "/api/unattended",
get(api_unattended_list).post(api_unattended_upload), get(api_unattended_list).post(api_unattended_upload),
) )
.route("/api/unattended/:id", delete(api_unattended_delete)) .route("/api/unattended/{id}", delete(api_unattended_delete))
// v0.4.4: self-rendered API reference, served as JSON so the UI // v0.4.4: self-rendered API reference, served as JSON so the UI
// can format it consistently with the rest of the chrome. Lives // can format it consistently with the rest of the chrome. Lives
// under the Settings tab — operators chasing an integration get // under the Settings tab — operators chasing an integration get
@@ -161,12 +161,12 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/settings", get(api_get_settings).put(api_put_settings)) .route("/api/settings", get(api_get_settings).put(api_put_settings))
.route("/api/queue", get(api_list_queue)) .route("/api/queue", get(api_list_queue))
.route("/api/queue/join", get(api_queue_join)) .route("/api/queue/join", get(api_queue_join))
.route("/api/queue/poll/:entry_id", get(api_queue_poll)) .route("/api/queue/poll/{entry_id}", get(api_queue_poll))
.route("/api/queue/assign", post(api_queue_assign)) .route("/api/queue/assign", post(api_queue_assign))
// v0.5.2: per-device deployment profile (auto hostname / IP / // v0.5.2: per-device deployment profile (auto hostname / IP /
// unattended file) set from the queue "Profile" button. // unattended file) set from the queue "Profile" button.
.route("/api/queue/:entry_id/profile", put(api_queue_set_profile)) .route("/api/queue/{entry_id}/profile", put(api_queue_set_profile))
.route("/api/queue/:entry_id", delete(api_queue_release)) .route("/api/queue/{entry_id}", delete(api_queue_release))
// v0.4.65: SMB share manager (userspace via smbclient). The // v0.4.65: SMB share manager (userspace via smbclient). The
// kernel-mount NFS routes that v0.4.64 shipped are gone — they // kernel-mount NFS routes that v0.4.64 shipped are gone — they
// didn't work on hosts whose kernel lacked the nfs client // didn't work on hosts whose kernel lacked the nfs client
@@ -177,8 +177,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/smb-shares", "/api/smb-shares",
get(api_smb_shares_list).post(api_smb_shares_add), get(api_smb_shares_list).post(api_smb_shares_add),
) )
.route("/api/smb-shares/:id", delete(api_smb_shares_remove)) .route("/api/smb-shares/{id}", delete(api_smb_shares_remove))
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan)) .route("/api/smb-shares/{id}/scan", post(api_smb_shares_scan))
// v0.4.67: NFSv3 share manager (pure-Rust in-process client). // v0.4.67: NFSv3 share manager (pure-Rust in-process client).
// Ships alongside SMB. Routes are parallel so the UI can // Ships alongside SMB. Routes are parallel so the UI can
// reuse the same form/error/hint rendering for both. // reuse the same form/error/hint rendering for both.
@@ -186,8 +186,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/nfs-shares", "/api/nfs-shares",
get(api_nfs_shares_list).post(api_nfs_shares_add), get(api_nfs_shares_list).post(api_nfs_shares_add),
) )
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove)) .route("/api/nfs-shares/{id}", delete(api_nfs_shares_remove))
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan)) .route("/api/nfs-shares/{id}/scan", post(api_nfs_shares_scan))
// v0.5.5: SFTP-over-SSH share manager (pure-Rust russh client). // v0.5.5: SFTP-over-SSH share manager (pure-Rust russh client).
// Parallel to SMB/NFS so the UI reuses the same form/error/hint // Parallel to SMB/NFS so the UI reuses the same form/error/hint
// rendering. Like NFS, SFTP-sourced ISOs support Range requests. // rendering. Like NFS, SFTP-sourced ISOs support Range requests.
@@ -195,8 +195,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/sftp-shares", "/api/sftp-shares",
get(api_sftp_shares_list).post(api_sftp_shares_add), get(api_sftp_shares_list).post(api_sftp_shares_add),
) )
.route("/api/sftp-shares/:id", delete(api_sftp_shares_remove)) .route("/api/sftp-shares/{id}", delete(api_sftp_shares_remove))
.route("/api/sftp-shares/:id/scan", post(api_sftp_shares_scan)) .route("/api/sftp-shares/{id}/scan", post(api_sftp_shares_scan))
// Phase 4: Network info (read-only) + DNS edit. // Phase 4: Network info (read-only) + DNS edit.
.route("/api/network", get(api_network).put(api_network_put)) .route("/api/network", get(api_network).put(api_network_put))
// Phase 4: live-log stream + recent buffer for the Terminal tab. // Phase 4: live-log stream + recent buffer for the Terminal tab.
@@ -208,10 +208,17 @@ pub fn build_router(state: AppState) -> Router {
// Phase 5: per-MAC host bindings. Operator // Phase 5: per-MAC host bindings. Operator
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly. // pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert)) .route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/:mac", delete(api_hosts_remove)) .route("/api/hosts/{mac}", delete(api_hosts_remove))
// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the // v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the
// limited broadcast + the server's own subnet broadcast. // limited broadcast + the server's own subnet broadcast.
.route("/api/hosts/:mac/wol", post(api_hosts_wol)) .route("/api/hosts/{mac}/wol", post(api_hosts_wol))
// v0.7.0: ordered boot rules (MAC prefix / arch → target) + the
// boot-decision webhook. The UI saves the whole config at once
// because rule order is significant.
.route(
"/api/boot-rules",
get(api_boot_rules_get).put(api_boot_rules_put),
)
// Rolling "host log" of boot events: what image actually // Rolling "host log" of boot events: what image actually
// started installing on what MAC/IP, and when. Persisted to disk. // started installing on what MAC/IP, and when. Persisted to disk.
.route("/api/boot-log", get(api_boot_log)) .route("/api/boot-log", get(api_boot_log))
@@ -520,14 +527,15 @@ fn text_plain(body: String) -> Response {
/// to the bound target instead of rendering the menu. /// to the bound target instead of rendering the menu.
async fn boot_top_menu( async fn boot_top_menu(
State(state): State<AppState>, State(state): State<AppState>,
peer: Option<ConnectInfo<SocketAddr>>, peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
Query(p): Query<BootMenuParams>, Query(p): Query<BootMenuParams>,
) -> Response { ) -> Response {
// `ConnectInfo` is only populated when axum was started with // `ConnectInfo` is only populated when axum was started with
// `into_make_service_with_connect_info` (production path). Tests // `into_make_service_with_connect_info` (production path). Tests
// call the router via `oneshot`, which skips that wiring — we // call the router via `oneshot`, which skips that wiring — we
// tolerate it by treating the peer as unknown rather than 500ing. // tolerate it by treating the peer as unknown rather than 500ing.
let peer_ip = peer.map(|c| c.0.ip()); // (axum 0.8: `Result<T, Rejection>` is the optional-extractor form.)
let peer_ip = peer.ok().map(|c| c.0.ip());
state state
.metrics .metrics
.record_http(openpxe_core::HttpRoute::BootScript); .record_http(openpxe_core::HttpRoute::BootScript);
@@ -569,17 +577,105 @@ async fn boot_top_menu(
// `?mac=` so the per-entry handler can record the boot into // `?mac=` so the per-entry handler can record the boot into
// the Host log without depending on iPXE substitution at // the Host log without depending on iPXE substitution at
// this stage. // this stage.
return text_plain(format!( return text_plain(chain_script(base, &target, &bound_mac, "per-MAC binding"));
"#!ipxe\n\ }
echo OpenPXE: per-MAC binding -> {target}\n\
chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n" // v0.7.0 step 2: ordered boot rules (MAC prefix / arch).
)); let mac_norm = openpxe_core::normalize_mac(mac);
if let Some(target) = state.boot_rules.match_target(&mac_norm, p.arch.as_deref()) {
tracing::info!(
target: "openpxe::http",
mac = %mac_norm, target = %target, "boot rule matched"
);
record_pre_boot(&state, &isos, &mac_norm, peer_ip, &target);
return text_plain(chain_script(base, &target, &mac_norm, "boot rule"));
}
// v0.7.0 step 3: boot-decision webhook (fail-open — any error,
// timeout, or non-200 falls through to the menu so a dead
// automation endpoint can never block PXE for the network).
if let Some(url) = state.boot_rules.webhook_url() {
if let Some(target) = webhook_decide(&url, &mac_norm, p.arch.as_deref()).await {
tracing::info!(
target: "openpxe::http",
mac = %mac_norm, target = %target, "boot webhook decided"
);
record_pre_boot(&state, &isos, &mac_norm, peer_ip, &target);
return text_plain(chain_script(base, &target, &mac_norm, "boot webhook"));
}
} }
} }
text_plain(render_menu(&isos, &settings, base)) text_plain(render_menu(&isos, &settings, base))
} }
/// The short-circuit script all three decision sources (binding, rule,
/// webhook) emit: chain to the target's boot script, falling back to the
/// interactive menu so a stale target can't lock a client out.
fn chain_script(base: &str, target: &str, mac: &str, source: &str) -> String {
format!(
"#!ipxe\n\
echo OpenPXE: {source} -> {target}\n\
chain {base}/boot/{target}.ipxe?mac={mac} || chain {base}/boot.ipxe\n"
)
}
/// Pre-record a decision-driven boot into the Host log, mirroring what
/// the per-MAC binding path does: reserved `_xxx` targets are operator
/// conveniences, not imaging events, so they're skipped.
fn record_pre_boot(
state: &AppState,
isos: &[openpxe_iso_store::IsoMeta],
mac: &str,
peer_ip: Option<std::net::IpAddr>,
target: &str,
) {
if target.starts_with('_') {
return;
}
let title = lookup_entry_title(isos, target);
state.boot_log.record(&BootEvent {
timestamp: time::OffsetDateTime::now_utc(),
mac: Some(mac.to_string()),
ip: peer_ip,
target_id: target.to_string(),
target_title: title,
});
}
/// Ask the operator's boot-decision webhook for a target. `200` with
/// `{"target": "<id>"}` chains to that target; anything else (including
/// an empty target) means "no opinion". Two-second budget — a booting
/// machine is sitting at a black screen while this runs.
async fn webhook_decide(url: &str, mac: &str, arch: Option<&str>) -> Option<String> {
#[derive(Deserialize)]
struct Decision {
target: String,
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.ok()?;
let resp = match client
.get(url)
.query(&[("mac", mac), ("arch", arch.unwrap_or(""))])
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(target: "openpxe::http", "boot webhook unreachable: {e}");
return None;
}
};
if !resp.status().is_success() {
return None;
}
let d: Decision = resp.json().await.ok()?;
let t = d.target.trim().to_string();
(!t.is_empty()).then_some(t)
}
/// Best-effort human title for a boot entry id — falls back to the id /// Best-effort human title for a boot entry id — falls back to the id
/// itself if the ISO has been deleted between record-time and now. /// itself if the ISO has been deleted between record-time and now.
fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String { fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String {
@@ -602,6 +698,11 @@ struct BootMenuParams {
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we /// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
/// fall back to the menu unconditionally. /// fall back to the menu unconditionally.
mac: Option<String>, mac: Option<String>,
/// v0.7.0: client architecture (`ClientArch::as_str()` form), baked
/// literally into the chain URL by the DHCP proxy, which knows it
/// from option 93. Lets boot rules select on architecture. Absent on
/// chains rendered by older binaries — arch rules simply don't match.
arch: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -619,11 +720,12 @@ struct BootSubParams {
async fn boot_sub( async fn boot_sub(
State(state): State<AppState>, State(state): State<AppState>,
peer: Option<ConnectInfo<SocketAddr>>, peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
AxumPath(filename): AxumPath<String>, AxumPath(filename): AxumPath<String>,
Query(p): Query<BootSubParams>, Query(p): Query<BootSubParams>,
) -> Response { ) -> Response {
let peer_ip = peer.map(|c| c.0.ip()); // axum 0.8: `Result<T, Rejection>` is the optional-extractor form.
let peer_ip = peer.ok().map(|c| c.0.ip());
// `/boot/<name>.ipxe` where `<name>` is either one of our reserved // `/boot/<name>.ipxe` where `<name>` is either one of our reserved
// submenu names (prefixed `_`) or a boot entry id. // submenu names (prefixed `_`) or a boot entry id.
let name = filename.strip_suffix(".ipxe").unwrap_or(&filename); let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
@@ -748,7 +850,13 @@ async fn boot_sub(
.as_deref() .as_deref()
.and_then(|fid| state.unattended.get(fid)) .and_then(|fid| state.unattended.get(fid))
.and_then(|meta| { .and_then(|meta| {
build_unattended_args(base, &meta, Some(m), &p) build_unattended_args(
base,
&meta,
Some(m),
&p,
&state.boot_tokens,
)
}) })
}) })
}); });
@@ -769,10 +877,19 @@ async fn boot_sub(
// ─── bundled iPXE binaries (memtest lives here too) ─────────────────────── // ─── bundled iPXE binaries (memtest lives here too) ───────────────────────
async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response { async fn ipxe_binary(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
if name.contains('/') || name.contains('\\') { if name.contains('/') || name.contains('\\') {
return (StatusCode::BAD_REQUEST, "invalid name").into_response(); return (StatusCode::BAD_REQUEST, "invalid name").into_response();
} }
// v0.7.0: when the whole Secure Boot chain rides HTTP (native UEFI
// HTTP Boot), GRUB resolves `$prefix` to this directory and fetches
// its config from here — rendered live, same as the TFTP path.
if name == "grub.cfg" || name.starts_with("grub.cfg-") {
return text_plain(crate::grub_script::render_grub_menu(
&state.iso_store.list(),
&state.public_base_url,
));
}
let Some(data) = asset_slice(&name) else { let Some(data) = asset_slice(&name) else {
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response(); return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
}; };
@@ -1380,17 +1497,54 @@ struct UnattendedServeQuery {
hostname: Option<String>, hostname: Option<String>,
#[serde(default)] #[serde(default)]
ip: Option<String>, ip: Option<String>,
/// v0.7.0: short-lived access token minted into the generated URL.
#[serde(default)]
t: Option<String>,
} }
/// Public: serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` / /// v0.7.0: answer files routinely embed credentials, so once an admin
/// account exists they're only served to (a) the boot that the URL was
/// minted for — proven by the token OpenPXE put in that URL — or (b) a
/// logged-in operator (browser testing). Pre-setup installs stay open,
/// matching the auth middleware's bootstrap behavior.
fn unattended_access_allowed(
state: &AppState,
headers: &HeaderMap,
token: Option<&str>,
file_id: &str,
) -> bool {
if !state.admin.is_configured() {
return true;
}
if token.is_some_and(|t| state.boot_tokens.check(t, file_id)) {
return true;
}
crate::auth::session_authenticated(state, headers)
}
fn unattended_denied() -> Response {
(
StatusCode::UNAUTHORIZED,
"answer files require the boot-scoped token OpenPXE mints into \
generated URLs (or an operator session)",
)
.into_response()
}
/// Serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` /
/// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns /// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns
/// `text/plain` so installers (anaconda, debian-installer, Windows setup /// `text/plain` so installers (anaconda, debian-installer, Windows setup
/// fetching over HTTP) read it verbatim. /// fetching over HTTP) read it verbatim. Token-gated since v0.7.0 — see
/// [`unattended_access_allowed`].
async fn serve_unattended( async fn serve_unattended(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<String>, AxumPath(id): AxumPath<String>,
headers: HeaderMap,
Query(q): Query<UnattendedServeQuery>, Query(q): Query<UnattendedServeQuery>,
) -> Response { ) -> Response {
if !unattended_access_allowed(&state, &headers, q.t.as_deref(), &id) {
return unattended_denied();
}
let Ok(bytes) = state.unattended.read(&id).await else { let Ok(bytes) = state.unattended.read(&id).await else {
return (StatusCode::NOT_FOUND, "no such unattended file").into_response(); return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
}; };
@@ -1412,8 +1566,15 @@ async fn serve_unattended(
async fn serve_unattended_seed( async fn serve_unattended_seed(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>, AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>,
headers: HeaderMap,
) -> Response { ) -> Response {
let (mac, hostname, ip) = decode_seed_ctx(&ctx); let (mac, hostname, ip, token) = decode_seed_ctx(&ctx);
// v0.7.0: the seed ctx carries the access token (the seedfrom URL
// can't take a query string). Same gate as the flat answer-file
// route — see `unattended_access_allowed`.
if !unattended_access_allowed(&state, &headers, token.as_deref(), &id) {
return unattended_denied();
}
match sub.as_str() { match sub.as_str() {
"user-data" => { "user-data" => {
let Ok(bytes) = state.unattended.read(&id).await else { let Ok(bytes) = state.unattended.read(&id).await else {
@@ -1437,6 +1598,22 @@ async fn serve_unattended_seed(
} }
} }
// ─── Boot rules (v0.7.0) ───────────────────────────────────────────────────
async fn api_boot_rules_get(State(state): State<AppState>) -> Json<openpxe_core::BootRulesConfig> {
Json(state.boot_rules.snapshot())
}
async fn api_boot_rules_put(
State(state): State<AppState>,
Json(cfg): Json<openpxe_core::BootRulesConfig>,
) -> StatusCode {
let n = cfg.rules.len();
state.boot_rules.replace(cfg);
tracing::info!(target: "openpxe::http", rules = n, "boot rules replaced");
StatusCode::NO_CONTENT
}
/// Resolve the deployment profile for a booting MAC: a host pin wins, else /// Resolve the deployment profile for a booting MAC: a host pin wins, else
/// a queued device's Profile. `None` when neither carries one. /// a queued device's Profile. `None` when neither carries one.
fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> { fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> {
@@ -1456,12 +1633,23 @@ fn build_unattended_args(
meta: &UnattendedMeta, meta: &UnattendedMeta,
mac: Option<&str>, mac: Option<&str>,
profile: &DeployProfile, profile: &DeployProfile,
tokens: &openpxe_core::BootTokens,
) -> Option<String> { ) -> Option<String> {
let base = base.trim_end_matches('/'); let base = base.trim_end_matches('/');
let id = &meta.id; let id = &meta.id;
let host = profile.auto_hostname.as_deref(); let host = profile.auto_hostname.as_deref();
let ip = profile.auto_ip.as_deref(); let ip = profile.auto_ip.as_deref();
let query = build_query(&[("mac", mac), ("hostname", host), ("ip", ip)]); // v0.7.0: every generated answer-file URL carries a fresh boot-scoped
// token; the serving endpoint requires it. See `crate::auth` and
// `openpxe_core::boot_tokens` for the threat model (CVE-2026-0386-
// style credential harvesting from openly-served answer files).
let token = tokens.mint(id);
let query = build_query(&[
("mac", mac),
("hostname", host),
("ip", ip),
("t", Some(&token)),
]);
match meta.kind { match meta.kind {
UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")), UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")),
UnattendedKind::Preseed => { UnattendedKind::Preseed => {
@@ -1473,7 +1661,7 @@ fn build_unattended_args(
Some(s) Some(s)
} }
UnattendedKind::Autoinstall => { UnattendedKind::Autoinstall => {
let ctx = encode_seed_ctx(mac, host, ip); let ctx = encode_seed_ctx(mac, host, ip, &token);
Some(format!( Some(format!(
"autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/" "autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/"
)) ))
@@ -1498,12 +1686,19 @@ fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above. // `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
/// Encode `(hostname, ip, mac)` into a single base64url path segment for /// Encode `(hostname, ip, mac, token)` into a single base64url path
/// the cloud-init seed directory. Empty values become empty fields. /// segment for the cloud-init seed directory. Empty values become empty
fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String { /// fields. The access token rides in here (v0.7.0) because the
/// `seedfrom` URL can't carry a query string.
fn encode_seed_ctx(
mac: Option<&str>,
hostname: Option<&str>,
ip: Option<&str>,
token: &str,
) -> String {
use base64::Engine as _; use base64::Engine as _;
let raw = format!( let raw = format!(
"{}\n{}\n{}", "{}\n{}\n{}\n{token}",
hostname.unwrap_or(""), hostname.unwrap_or(""),
ip.unwrap_or(""), ip.unwrap_or(""),
mac.unwrap_or("") mac.unwrap_or("")
@@ -1511,21 +1706,30 @@ fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>)
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes()) base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
} }
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad /// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip, token)`.
/// or empty segment yields all-`None` so the seed still serves (just /// A bad or empty segment yields all-`None`; a pre-v0.7.0 three-field
/// without per-host substitution). /// ctx decodes with `token: None` (and the gate then rejects it once an
fn decode_seed_ctx(ctx: &str) -> (Option<String>, Option<String>, Option<String>) { /// admin exists — stale URLs are exactly what tokens invalidate).
fn decode_seed_ctx(
ctx: &str,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
use base64::Engine as _; use base64::Engine as _;
let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else { let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else {
return (None, None, None); return (None, None, None, None);
}; };
let s = String::from_utf8_lossy(&bytes).into_owned(); let s = String::from_utf8_lossy(&bytes).into_owned();
let mut it = s.splitn(3, '\n'); let mut it = s.splitn(4, '\n');
let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty()); let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty());
let hostname = clean(it.next()); let hostname = clean(it.next());
let ip = clean(it.next()); let ip = clean(it.next());
let mac = clean(it.next()); let mac = clean(it.next());
(mac, hostname, ip) let token = clean(it.next());
(mac, hostname, ip, token)
} }
// ─── API reference (Settings → bottom) ──────────────────────────────────── // ─── API reference (Settings → bottom) ────────────────────────────────────
@@ -1560,13 +1764,13 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List ISOs (local + NFS) with size, family, boot entries, category."}, "summary": "List ISOs (local + NFS) with size, family, boot entries, category."},
{"method": "POST", "path": "/api/isos", {"method": "POST", "path": "/api/isos",
"summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."}, "summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."},
{"method": "DELETE", "path": "/api/isos/:id", {"method": "DELETE", "path": "/api/isos/{id}",
"summary": "Delete a local ISO and its sidecar metadata."}, "summary": "Delete a local ISO and its sidecar metadata."},
{"method": "PUT", "path": "/api/isos/:id/password", {"method": "PUT", "path": "/api/isos/{id}/password",
"summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."}, "summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."},
{"method": "DELETE", "path": "/api/isos/:id/password", {"method": "DELETE", "path": "/api/isos/{id}/password",
"summary": "Clear an ISO's boot password."}, "summary": "Clear an ISO's boot password."},
{"method": "PUT", "path": "/api/isos/:id/category", {"method": "PUT", "path": "/api/isos/{id}/category",
"summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."}, "summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."},
], ],
}, },
@@ -1575,9 +1779,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"endpoints": [ "endpoints": [
{"method": "POST", "path": "/api/uploads", {"method": "POST", "path": "/api/uploads",
"summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."}, "summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."},
{"method": "PUT", "path": "/api/uploads/:upload_id", {"method": "PUT", "path": "/api/uploads/{upload_id}",
"summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."}, "summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."},
{"method": "DELETE", "path": "/api/uploads/:upload_id", {"method": "DELETE", "path": "/api/uploads/{upload_id}",
"summary": "Abort a chunked upload session and remove the .partial file."}, "summary": "Abort a chunked upload session and remove the .partial file."},
], ],
}, },
@@ -1588,9 +1792,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured SMB shares with connection state and iso counts."}, "summary": "List configured SMB shares with connection state and iso counts."},
{"method": "POST", "path": "/api/smb-shares", {"method": "POST", "path": "/api/smb-shares",
"summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."}, "summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."},
{"method": "DELETE", "path": "/api/smb-shares/:id", {"method": "DELETE", "path": "/api/smb-shares/{id}",
"summary": "Forget a share and drop its entries from the ISO store."}, "summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/smb-shares/:id/scan", {"method": "POST", "path": "/api/smb-shares/{id}/scan",
"summary": "Re-list a share for new ISOs."}, "summary": "Re-list a share for new ISOs."},
], ],
}, },
@@ -1601,9 +1805,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured NFSv3 shares with connection state and iso counts."}, "summary": "List configured NFSv3 shares with connection state and iso counts."},
{"method": "POST", "path": "/api/nfs-shares", {"method": "POST", "path": "/api/nfs-shares",
"summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."}, "summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."},
{"method": "DELETE", "path": "/api/nfs-shares/:id", {"method": "DELETE", "path": "/api/nfs-shares/{id}",
"summary": "Forget a share and drop its entries from the ISO store."}, "summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/nfs-shares/:id/scan", {"method": "POST", "path": "/api/nfs-shares/{id}/scan",
"summary": "Re-list a share for new ISOs."}, "summary": "Re-list a share for new ISOs."},
], ],
}, },
@@ -1614,9 +1818,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured SFTP-over-SSH shares with connection state and iso counts."}, "summary": "List configured SFTP-over-SSH shares with connection state and iso counts."},
{"method": "POST", "path": "/api/sftp-shares", {"method": "POST", "path": "/api/sftp-shares",
"summary": "Register an SFTP share. Body: { server, export, username, port?, password? | private_key? + passphrase? }. The server's SSH host key is pinned trust-on-first-use."}, "summary": "Register an SFTP share. Body: { server, export, username, port?, password? | private_key? + passphrase? }. The server's SSH host key is pinned trust-on-first-use."},
{"method": "DELETE", "path": "/api/sftp-shares/:id", {"method": "DELETE", "path": "/api/sftp-shares/{id}",
"summary": "Forget a share, drop its entries from the ISO store, and scrub its credentials file."}, "summary": "Forget a share, drop its entries from the ISO store, and scrub its credentials file."},
{"method": "POST", "path": "/api/sftp-shares/:id/scan", {"method": "POST", "path": "/api/sftp-shares/{id}/scan",
"summary": "Re-list a share for new ISOs."}, "summary": "Re-list a share for new ISOs."},
], ],
}, },
@@ -1636,9 +1840,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."}, "summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
{"method": "PUT", "path": "/api/settings", {"method": "PUT", "path": "/api/settings",
"summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."}, "summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."},
{"method": "POST", "path": "/api/branding/logo/:slot", {"method": "POST", "path": "/api/branding/logo/{slot}",
"summary": "Upload a custom logo for a slot (light | dark | client). Multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB. The client slot is raster-only."}, "summary": "Upload a custom logo for a slot (light | dark | client). Multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB. The client slot is raster-only."},
{"method": "DELETE", "path": "/api/branding/logo/:slot", {"method": "DELETE", "path": "/api/branding/logo/{slot}",
"summary": "Remove the custom logo for a slot and revert to the bundled mark."}, "summary": "Remove the custom logo for a slot and revert to the bundled mark."},
{"method": "GET", "path": "/branding/pxe-logo", {"method": "GET", "path": "/branding/pxe-logo",
"summary": "Raster form of the operator's 'client' logo for the iPXE menu's `console --picture`. Default background when unset/SVG."}, "summary": "Raster form of the operator's 'client' logo for the iPXE menu's `console --picture`. Default background when unset/SVG."},
@@ -1679,9 +1883,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."}, "summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."},
{"method": "POST", "path": "/api/unattended", {"method": "POST", "path": "/api/unattended",
"summary": "Upload an answer file (multipart 'file', .ks/.cfg/.seed/.yaml/.yml/.xml/user-data, up to 1 MB)."}, "summary": "Upload an answer file (multipart 'file', .ks/.cfg/.seed/.yaml/.yml/.xml/user-data, up to 1 MB)."},
{"method": "DELETE", "path": "/api/unattended/:id", {"method": "DELETE", "path": "/api/unattended/{id}",
"summary": "Delete an uploaded answer file."}, "summary": "Delete an uploaded answer file."},
{"method": "GET", "path": "/unattended/:id", {"method": "GET", "path": "/unattended/{id}",
"summary": "Public: serve an answer file with {{HOSTNAME}}/{{IP}}/{{MAC}} substituted from the query string."}, "summary": "Public: serve an answer file with {{HOSTNAME}}/{{IP}}/{{MAC}} substituted from the query string."},
], ],
}, },
@@ -1692,9 +1896,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List queue entries (waiting + assigned, with any deployment profile)."}, "summary": "List queue entries (waiting + assigned, with any deployment profile)."},
{"method": "POST", "path": "/api/queue/assign", {"method": "POST", "path": "/api/queue/assign",
"summary": "Assign a target image to queued clients. Body: { target, entry_ids }."}, "summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
{"method": "PUT", "path": "/api/queue/:entry_id/profile", {"method": "PUT", "path": "/api/queue/{entry_id}/profile",
"summary": "Set a queued device's deployment profile. Body: { auto_hostname?, auto_ip?, unattended_file? }."}, "summary": "Set a queued device's deployment profile. Body: { auto_hostname?, auto_ip?, unattended_file? }."},
{"method": "DELETE", "path": "/api/queue/:entry_id", {"method": "DELETE", "path": "/api/queue/{entry_id}",
"summary": "Release a queue entry without assigning."}, "summary": "Release a queue entry without assigning."},
], ],
}, },
@@ -1705,9 +1909,13 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List per-MAC boot bindings."}, "summary": "List per-MAC boot bindings."},
{"method": "POST", "path": "/api/hosts", {"method": "POST", "path": "/api/hosts",
"summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."}, "summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."},
{"method": "DELETE", "path": "/api/hosts/:mac", {"method": "DELETE", "path": "/api/hosts/{mac}",
"summary": "Remove a binding."}, "summary": "Remove a binding."},
{"method": "POST", "path": "/api/hosts/:mac/wol", {"method": "GET", "path": "/api/boot-rules",
"summary": "Boot rules + decision-webhook config (v0.7.0)."},
{"method": "PUT", "path": "/api/boot-rules",
"summary": "Replace the whole boot-rules config (rules are ordered)."},
{"method": "POST", "path": "/api/hosts/{mac}/wol",
"summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."}, "summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
{"method": "GET", "path": "/api/boot-log", {"method": "GET", "path": "/api/boot-log",
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."}, "summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
@@ -2536,6 +2744,7 @@ async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
.strip_prefix("http://") .strip_prefix("http://")
.unwrap_or(&state.public_base_url), .unwrap_or(&state.public_base_url),
"nic_name": state.nic_name, "nic_name": state.nic_name,
"nic_link": state.nic_link,
"subnet_mask": state.subnet_mask, "subnet_mask": state.subnet_mask,
"gateway": state.gateway, "gateway": state.gateway,
"dns_server": state.settings.snapshot().dns_server, "dns_server": state.settings.snapshot().dns_server,
@@ -2940,11 +3149,13 @@ mod tests {
auto_ip: Some("10.0.0.7".into()), auto_ip: Some("10.0.0.7".into()),
unattended_file: Some("ks1".into()), unattended_file: Some("ks1".into()),
}; };
let tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args( let a = build_unattended_args(
"http://h", "http://h",
&meta(UnattendedKind::Kickstart), &meta(UnattendedKind::Kickstart),
Some("aa:bb:cc:dd:ee:ff"), Some("aa:bb:cc:dd:ee:ff"),
&p, &p,
&tokens,
) )
.unwrap(); .unwrap();
assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}"); assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}");
@@ -2952,6 +3163,9 @@ mod tests {
assert!(a.contains("ip=10.0.0.7"), "{a}"); assert!(a.contains("ip=10.0.0.7"), "{a}");
// MAC colons are percent-encoded. // MAC colons are percent-encoded.
assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}"); assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}");
// v0.7.0: a live access token rides in the generated URL.
let tok = a.rsplit("t=").next().unwrap();
assert!(tokens.check(tok, "ks1"), "minted token must be live: {a}");
} }
#[test] #[test]
@@ -2960,8 +3174,15 @@ mod tests {
auto_hostname: Some("deb1".into()), auto_hostname: Some("deb1".into()),
..Default::default() ..Default::default()
}; };
let a = let tokens = openpxe_core::BootTokens::new();
build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap(); let a = build_unattended_args(
"http://h/",
&meta(UnattendedKind::Preseed),
None,
&p,
&tokens,
)
.unwrap();
assert!( assert!(
a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"), a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"),
"{a}" "{a}"
@@ -2976,11 +3197,13 @@ mod tests {
auto_ip: Some("10.1.1.5".into()), auto_ip: Some("10.1.1.5".into()),
unattended_file: Some("ks1".into()), unattended_file: Some("ks1".into()),
}; };
let tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args( let a = build_unattended_args(
"http://h", "http://h",
&meta(UnattendedKind::Autoinstall), &meta(UnattendedKind::Autoinstall),
Some("aa:bb"), Some("aa:bb"),
&p, &p,
&tokens,
) )
.unwrap(); .unwrap();
assert!( assert!(
@@ -2990,10 +3213,12 @@ mod tests {
assert!(a.ends_with('/'), "seed URL must end with '/': {a}"); assert!(a.ends_with('/'), "seed URL must end with '/': {a}");
// The ctx segment round-trips back to the per-host values. // The ctx segment round-trips back to the per-host values.
let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap(); let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap();
let (mac, host, ip) = decode_seed_ctx(ctx); let (mac, host, ip, token) = decode_seed_ctx(ctx);
assert_eq!(mac.as_deref(), Some("aa:bb")); assert_eq!(mac.as_deref(), Some("aa:bb"));
assert_eq!(host.as_deref(), Some("u1")); assert_eq!(host.as_deref(), Some("u1"));
assert_eq!(ip.as_deref(), Some("10.1.1.5")); assert_eq!(ip.as_deref(), Some("10.1.1.5"));
// v0.7.0: the ctx carries a live access token for the file.
assert!(tokens.check(token.as_deref().unwrap(), "ks1"));
} }
#[test] #[test]
@@ -3002,20 +3227,26 @@ mod tests {
unattended_file: Some("ks1".into()), unattended_file: Some("ks1".into()),
..Default::default() ..Default::default()
}; };
assert!( let tokens = openpxe_core::BootTokens::new();
build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p) assert!(build_unattended_args(
.is_none() "http://h",
); &meta(UnattendedKind::AnswerFile),
None,
&p,
&tokens
)
.is_none());
} }
#[test] #[test]
fn seed_ctx_empty_segment_decodes_to_none() { fn seed_ctx_empty_segment_decodes_to_none() {
let ctx = encode_seed_ctx(None, None, None); let ctx = encode_seed_ctx(None, None, None, "tok");
let (m, h, i) = decode_seed_ctx(&ctx); let (m, h, i, t) = decode_seed_ctx(&ctx);
assert!(m.is_none() && h.is_none() && i.is_none()); assert!(m.is_none() && h.is_none() && i.is_none());
assert_eq!(t.as_deref(), Some("tok"));
// Garbage decodes safely to all-None. // Garbage decodes safely to all-None.
let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!"); let (m2, h2, i2, t2) = decode_seed_ctx("!!!not-base64!!!");
assert!(m2.is_none() && h2.is_none() && i2.is_none()); assert!(m2.is_none() && h2.is_none() && i2.is_none() && t2.is_none());
} }
#[test] #[test]
+7
View File
@@ -169,6 +169,13 @@ fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
None None
} }
/// Does this request carry a live operator session? Used by endpoints
/// outside the `/api/*` middleware that still want to honor a logged-in
/// operator (e.g. browser-testing a token-gated answer file, v0.7.0).
pub(crate) fn session_authenticated(state: &AppState, headers: &axum::http::HeaderMap) -> bool {
parse_cookie(headers).is_some_and(|t| state.sessions.touch(&t).is_some())
}
// ── Middleware ──────────────────────────────────────────────────────────── // ── Middleware ────────────────────────────────────────────────────────────
/// Return `true` if `path` is on the allowlist and should bypass the /// Return `true` if `path` is on the allowlist and should bypass the
+192
View File
@@ -0,0 +1,192 @@
//! GRUB menu rendering for the Secure Boot chain (v0.7.0).
//!
//! Secure-Boot-enabled firmware refuses our unsigned iPXE, so those
//! clients are automatically escalated (see `openpxe_dhcp_proxy::
//! escalation`) to the Microsoft-signed Fedora `shim` → signed `grub`
//! chain. GRUB then fetches `grub.cfg` from this server (TFTP `$prefix`
//! resolution, or HTTP when the whole chain came over HTTP Boot) — and
//! this module renders that config from the same boot-entry model that
//! renders `boot.ipxe`.
//!
//! Scope: **Linux kernel entries only.** A signed GRUB will only execute
//! kernels that pass shim verification — i.e. distro-signed kernels —
//! which is exactly what `LinuxKernel` boot entries point at. `sanboot`
//! ISO emulation and `wimboot` are iPXE mechanisms with no signed
//! equivalent; those entries are omitted here, and the menu says so.
//! (Windows deployment under Secure Boot has no legitimate unsigned
//! path — per project policy we never ship test-signed binaries or touch
//! client trust stores.)
//!
//! The kernel/initrd lines use GRUB's `(http,host:port)` device syntax;
//! Fedora's signed netboot GRUB carries the `http`, `tftp` and `efinet`
//! modules built in, so no unsigned module loading is required.
use openpxe_iso_store::{BootKind, IsoMeta};
use std::fmt::Write as _;
/// Render the full `grub.cfg` for the signed-GRUB menu.
///
/// `base_url` is the public HTTP base (`http://10.0.0.5` or
/// `http://10.0.0.5:8080`) — converted to GRUB's `(http,host:port)`
/// device prefix for kernel/initrd fetches.
#[must_use]
pub fn render_grub_menu(isos: &[IsoMeta], base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let dev = grub_http_device(base);
let mut s = String::new();
let _ = writeln!(s, "# OpenPXE — Secure Boot menu (signed shim+GRUB chain)");
// v0.7.1: before showing the limited signed menu, try to hand the
// boot back to full iPXE *in this same boot cycle*. With Secure Boot
// OFF the chainload succeeds and the client gets the complete iPXE
// feature set (sanboot, wimboot, the full menu) despite having been
// escalated here. With Secure Boot ON, shim's verifier refuses the
// unsigned image INLINE — no reboot, no failed cycle — and execution
// falls through to the signed menu below. The all-drivers build is
// used because a MAC only lands here after the firmware-net build
// already failed once.
let _ = writeln!(s, "if [ \"$grub_cpu\" = \"arm64\" ]; then");
let _ = writeln!(s, " set openpxe_ipxe=ipxe-arm64.efi");
let _ = writeln!(s, "else");
let _ = writeln!(s, " set openpxe_ipxe=ipxe.efi");
let _ = writeln!(s, "fi");
let _ = writeln!(s, "if chainloader {dev}/ipxe/$openpxe_ipxe ; then");
let _ = writeln!(s, " boot");
let _ = writeln!(s, "fi");
let _ = writeln!(s);
let _ = writeln!(s, "set timeout=30");
let _ = writeln!(s, "set default=0");
let _ = writeln!(s);
let mut entries = 0usize;
for iso in isos {
for entry in &iso.boot_entries {
let BootKind::LinuxKernel {
kernel_url,
initrd_urls,
args,
} = &entry.kind
else {
continue;
};
// GRUB menu titles: keep quotes out of the label.
let title = entry.title.replace('"', "'");
let cmdline = args.cmdline.replace("${base-url}", base);
let _ = writeln!(s, "menuentry \"{} — {title}\" {{", iso.filename);
let _ = writeln!(s, " linux {dev}/{kernel_url} {cmdline}");
if !initrd_urls.is_empty() {
let _ = write!(s, " initrd");
for u in initrd_urls {
let _ = write!(s, " {dev}/{u}");
}
let _ = writeln!(s);
}
let _ = writeln!(s, "}}");
let _ = writeln!(s);
entries += 1;
}
}
if entries == 0 {
let _ = writeln!(
s,
"menuentry \"No Secure-Boot-bootable images on this server yet\" {{ true }}"
);
let _ = writeln!(s);
}
// Always give the operator a way off this screen.
let _ = writeln!(s, "menuentry \"Boot from local disk\" {{");
let _ = writeln!(s, " exit");
let _ = writeln!(s, "}}");
s
}
/// `http://10.0.0.5:8080` → `(http,10.0.0.5:8080)`. GRUB wants the
/// scheme as the device type and host[:port] as the device address.
fn grub_http_device(base: &str) -> String {
let host = base
.trim_start_matches("http://")
.trim_start_matches("https://");
format!("(http,{host})")
}
#[cfg(test)]
mod tests {
use super::*;
use openpxe_iso_store::{BootEntry, IsoSource, KernelArgs};
fn linux_iso() -> IsoMeta {
IsoMeta {
id: "alp".into(),
filename: "alpine.iso".into(),
size_bytes: 1,
sha256_hex: None,
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
source: IsoSource::Local,
introspection: openpxe_iso_store::IntrospectionReport::default(),
boot_entries: vec![BootEntry {
id: "alp-linux".into(),
title: "Linux installer".into(),
kind: BootKind::LinuxKernel {
kernel_url: "iso/alp/boot/vmlinuz".into(),
initrd_urls: vec!["iso/alp/boot/initrd".into()],
args: KernelArgs {
cmdline: "quiet repo=${base-url}/iso/alp.iso".into(),
},
},
}],
category: openpxe_iso_store::IsoCategory::default(),
password_hash: None,
}
}
#[test]
fn renders_linux_entries_with_http_device_urls() {
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080/");
assert!(
cfg.contains("menuentry \"alpine.iso — Linux installer\""),
"{cfg}"
);
assert!(
cfg.contains("linux (http,10.0.0.5:8080)/iso/alp/boot/vmlinuz quiet repo=http://10.0.0.5:8080/iso/alp.iso"),
"{cfg}"
);
assert!(
cfg.contains("initrd (http,10.0.0.5:8080)/iso/alp/boot/initrd"),
"{cfg}"
);
assert!(cfg.contains("Boot from local disk"), "{cfg}");
}
#[test]
fn config_tries_ipxe_chainload_before_menu() {
// v0.7.1: SB-off machines recover full iPXE in the same boot;
// SB-on machines fail the chainload inline and reach the menu.
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080");
let chain_pos = cfg
.find("if chainloader (http,10.0.0.5:8080)/ipxe/$openpxe_ipxe ; then")
.expect("chainload attempt missing");
let menu_pos = cfg.find("menuentry").expect("menu missing");
assert!(
chain_pos < menu_pos,
"chainload must precede the menu:\n{cfg}"
);
// Arch-conditional binary selection via GRUB's $grub_cpu.
assert!(cfg.contains("set openpxe_ipxe=ipxe-arm64.efi"), "{cfg}");
assert!(cfg.contains("set openpxe_ipxe=ipxe.efi"), "{cfg}");
}
#[test]
fn sanboot_and_wimboot_entries_are_omitted() {
let mut iso = linux_iso();
iso.boot_entries = vec![BootEntry {
id: "win".into(),
title: "Windows".into(),
kind: BootKind::SanBootIso {
iso_url: "iso/win.iso".into(),
},
}];
let cfg = render_grub_menu(&[iso], "http://10.0.0.5");
assert!(!cfg.contains("Windows"), "{cfg}");
assert!(cfg.contains("No Secure-Boot-bootable images"), "{cfg}");
}
}
+1
View File
@@ -16,6 +16,7 @@
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod error; pub mod error;
pub mod grub_script;
pub mod ipxe_script; pub mod ipxe_script;
pub mod iso_fs; pub mod iso_fs;
pub mod log_stream; pub mod log_stream;
+14 -2
View File
@@ -2,8 +2,8 @@ use crate::auth::SessionStore;
use crate::saml_routes::SamlRuntime; use crate::saml_routes::SamlRuntime;
use crate::uploads::UploadSessions; use crate::uploads::UploadSessions;
use openpxe_core::{ use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
Metrics, NotifyStore, SettingsStore, SsoStore, DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore,
}; };
use openpxe_iso_store::{ use openpxe_iso_store::{
IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore, IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore,
@@ -29,6 +29,14 @@ pub struct AppState {
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script /// every `/boot/<entry>.ipxe` chain that goes on to serve a script
/// (i.e. an image actually starting to install on a machine). /// (i.e. an image actually starting to install on a machine).
pub boot_log: BootLog, pub boot_log: BootLog,
/// v0.7.0: ordered label-based boot rules (MAC prefix / arch →
/// target) plus the optional boot-decision webhook. Consulted by the
/// top-level boot script after exact host bindings, before the menu.
pub boot_rules: BootRulesStore,
/// v0.7.0: short-lived access tokens for unattended answer files.
/// Minted into every generated answer-file URL; the serving endpoint
/// requires one (or an operator session) once an admin exists.
pub boot_tokens: BootTokens,
/// Operator-controlled UI overrides (custom logo). When the /// Operator-controlled UI overrides (custom logo). When the
/// operator hasn't uploaded anything, the WebUI serves the bundled /// operator hasn't uploaded anything, the WebUI serves the bundled
/// rainbow-horizon mark. /// rainbow-horizon mark.
@@ -108,6 +116,10 @@ pub struct AppState {
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the /// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
/// interface couldn't be identified. /// interface couldn't be identified.
pub nic_name: String, pub nic_name: String,
/// v0.7.2: physical link summary for that NIC (operstate, speed,
/// duplex, port MAC) — read from sysfs at startup; empty where
/// unavailable. Helps confirm which port answers PXE.
pub nic_link: String,
/// Subnet mask of the public interface in dotted-quad form. /// Subnet mask of the public interface in dotted-quad form.
pub subnet_mask: String, pub subnet_mask: String,
/// Default gateway IPv4 address. /// Default gateway IPv4 address.
+165 -2
View File
@@ -115,6 +115,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
settings, settings,
hosts, hosts,
boot_log, boot_log,
boot_rules: openpxe_core::BootRulesStore::load_or_default(dir.path()),
boot_tokens: openpxe_core::BootTokens::new(),
branding, branding,
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(), pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin, admin,
@@ -133,6 +135,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: "http://127.0.0.1".into(), public_base_url: "http://127.0.0.1".into(),
nic_name: "lo".into(), nic_name: "lo".into(),
nic_link: String::new(),
subnet_mask: "255.0.0.0".into(), subnet_mask: "255.0.0.0".into(),
gateway: "127.0.0.1".into(), gateway: "127.0.0.1".into(),
}; };
@@ -1489,9 +1492,10 @@ async fn api_docs_lists_known_endpoints() {
} }
for needle in [ for needle in [
"/api/isos", "/api/isos",
"/api/isos/:id/category", // v0.6.3: docs use axum 0.8's `{param}` capture syntax.
"/api/isos/{id}/category",
"/api/storage/disk", "/api/storage/disk",
"/api/branding/logo/:slot", "/api/branding/logo/{slot}",
"/api/unattended", "/api/unattended",
"/api/boot-log", "/api/boot-log",
"/metrics", "/metrics",
@@ -2609,3 +2613,162 @@ async fn acs_garbage_is_rejected_without_500() {
assert!(location(&resp).contains("sso_error")); assert!(location(&resp).contains("sso_error"));
assert!(!has_session_cookie(&resp)); assert!(!has_session_cookie(&resp));
} }
// ─── v0.7.0: tokenized answer files + boot rules ────────────────────────────
#[tokio::test]
async fn unattended_requires_token_once_admin_exists() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Upload an answer file while in setup mode (everything open).
let (ct, body) =
multipart_iso_body("ks.ks", b"install\nrootpw s3cret\n%packages\n@core\n%end\n");
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// Pre-setup, the file serves openly (bootstrap parity with the
// auth middleware).
let (s, _) = get(&app, &format!("/unattended/{id}")).await;
assert_eq!(s, StatusCode::OK);
// Create the admin → the gate arms.
let (s, _, cookies) = post_collect(
&app,
"/api/setup",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
assert_eq!(s, StatusCode::CREATED);
let session = session_value(&cookies).unwrap();
// Bare fetch (the CVE-2026-0386 harvesting pattern) is refused.
let (s, _) = get(&app, &format!("/unattended/{id}")).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// Garbage token is refused.
let (s, _) = get(&app, &format!("/unattended/{id}?t=bogus")).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// A token minted for a *different* file is refused.
let other = state.boot_tokens.mint("some-other-file");
let (s, _) = get(&app, &format!("/unattended/{id}?t={other}")).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// The boot-scoped token OpenPXE mints into generated URLs passes.
let tok = state.boot_tokens.mint(&id);
let (s, b) = get(&app, &format!("/unattended/{id}?t={tok}")).await;
assert_eq!(s, StatusCode::OK);
assert!(String::from_utf8_lossy(&b).contains("rootpw"));
// A logged-in operator (browser testing) passes too.
let (s, _) = get_with_cookie(&app, &format!("/unattended/{id}"), &session).await;
assert_eq!(s, StatusCode::OK);
}
#[tokio::test]
async fn boot_script_for_pinned_unattended_carries_live_token() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n");
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let ks_id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let mac = "aa:bb:cc:dd:ee:71";
let pin =
format!(r#"{{"mac":"{mac}","target":"fake-alpine-linux","unattended_file":"{ks_id}"}}"#);
let (s, _) = post_json(&app, "/api/hosts", &pin).await;
assert_eq!(s, StatusCode::CREATED);
let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8_lossy(&b).into_owned();
// The injected inst.ks URL ends with a token that is live for the file.
let tok = script
.split("t=")
.nth(1)
.and_then(|rest| rest.split_whitespace().next())
.expect("kernel arg should carry t=<token>");
assert!(
state.boot_tokens.check(tok, &ks_id),
"token in boot script must be live:\n{script}"
);
}
#[tokio::test]
async fn boot_rules_match_and_persist_via_api() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
// Save a rule: any MAC under aa:bb:cc, any arch → the Linux entry.
let cfg = r#"{"rules":[{"mac_prefix":"AA-BB-CC","arch":"","target":"fake-alpine-linux","enabled":true,"note":"rack"}],"webhook_url":""}"#;
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
assert_eq!(s, StatusCode::NO_CONTENT);
// The config reads back (prefix normalized to colons).
let (s, b) = get(&app, "/api/boot-rules").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["rules"][0]["mac_prefix"], "aa:bb:cc");
// A matching client short-circuits to the target...
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:09&arch=uefi-x64").await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8_lossy(&b);
assert!(
script.contains("boot rule -> fake-alpine-linux"),
"rule did not chain:\n{script}"
);
// ...while a non-matching one still gets the menu.
let (s, b) = get(&app, "/boot.ipxe?mac=11:22:33:00:00:09&arch=uefi-x64").await;
assert_eq!(s, StatusCode::OK);
assert!(
String::from_utf8_lossy(&b).contains("menu"),
"non-matching client should see the menu"
);
}
#[tokio::test]
async fn arch_selective_rule_ignores_other_arches() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let cfg = r#"{"rules":[{"mac_prefix":"","arch":"uefi-arm64","target":"fake-alpine-linux","enabled":true,"note":""}],"webhook_url":""}"#;
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
assert_eq!(s, StatusCode::NO_CONTENT);
// x64 client: no match → menu.
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-x64").await;
assert_eq!(s, StatusCode::OK);
assert!(!String::from_utf8_lossy(&b).contains("boot rule ->"));
// arm64 client: match.
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-arm64").await;
assert_eq!(s, StatusCode::OK);
assert!(String::from_utf8_lossy(&b).contains("boot rule -> fake-alpine-linux"));
}
#[tokio::test]
async fn boot_rule_driver_mode_pin_round_trips_via_api() {
// v0.7.1: a rule may pin only a boot binary (no target) — the API
// must persist and return it for the DHCP proxy to consult.
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let cfg = r#"{"rules":[{"mac_prefix":"aa:bb:cc","arch":"","target":"","driver_mode":"shim","enabled":true,"note":"SB rack"}],"webhook_url":""}"#;
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
assert_eq!(s, StatusCode::NO_CONTENT);
let (s, b) = get(&app, "/api/boot-rules").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["rules"][0]["driver_mode"], "shim");
// And the store the DHCP proxy shares resolves the pin.
assert_eq!(
state.boot_rules.driver_mode_hint("aa:bb:cc:00:00:07", None),
Some(openpxe_core::DriverMode::Shim)
);
}
+8 -2
View File
@@ -67,7 +67,7 @@ pub fn log_availability() {
ClientArch::Arm64Uefi, ClientArch::Arm64Uefi,
]; ];
for arch in arches { for arch in arches {
for mode in [DriverMode::Firmware, DriverMode::Builtin] { for mode in [DriverMode::Firmware, DriverMode::Builtin, DriverMode::Shim] {
let Some(name) = arch.ipxe_bootfile_mode(mode) else { let Some(name) = arch.ipxe_bootfile_mode(mode) else {
continue; continue;
}; };
@@ -82,12 +82,18 @@ pub fn log_availability() {
"MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot", "MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
arch.as_str() arch.as_str()
); );
} else { } else if mode == DriverMode::Builtin {
tracing::info!( tracing::info!(
target: "openpxe::ipxe", target: "openpxe::ipxe",
"no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch", "no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch",
arch.as_str() arch.as_str()
); );
} else {
tracing::info!(
target: "openpxe::ipxe",
"no signed shim chain for {} [{mode:?}]: {name} — Secure Boot clients of this arch can't be served",
arch.as_str()
);
} }
} }
} }
+69
View File
@@ -184,6 +184,10 @@ async fn main() -> anyhow::Result<()> {
"network info" "network info"
); );
// v0.7.1: boot rules are shared between the HTTP layer (target rules,
// webhook, the editor API) and the DHCP proxy (driver-mode pins).
let boot_rules = openpxe_core::BootRulesStore::load_or_default(&config.paths.work_dir);
let state = AppState { let state = AppState {
iso_store: iso_store.clone(), iso_store: iso_store.clone(),
clients: clients.clone(), clients: clients.clone(),
@@ -191,6 +195,8 @@ async fn main() -> anyhow::Result<()> {
queue: queue.clone(), queue: queue.clone(),
hosts: hosts.clone(), hosts: hosts.clone(),
boot_log: boot_log.clone(), boot_log: boot_log.clone(),
boot_rules: boot_rules.clone(),
boot_tokens: openpxe_core::BootTokens::new(),
branding: branding.clone(), branding: branding.clone(),
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(), pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin: admin.clone(), admin: admin.clone(),
@@ -209,6 +215,7 @@ async fn main() -> anyhow::Result<()> {
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: public_base_url.clone(), public_base_url: public_base_url.clone(),
nic_name: net.nic_name, nic_name: net.nic_name,
nic_link: net.nic_link,
subnet_mask: net.subnet_mask, subnet_mask: net.subnet_mask,
gateway: net.gateway, gateway: net.gateway,
}; };
@@ -230,11 +237,28 @@ async fn main() -> anyhow::Result<()> {
Ok::<_, anyhow::Error>(()) Ok::<_, anyhow::Error>(())
}); });
// v0.7.0: TFTP names that aren't embedded assets get a dynamic
// renderer — `grub.cfg` for the Secure Boot shim+GRUB chain is
// generated from the live boot-entry list on every fetch, so menu
// changes apply without restart.
let grub_isos = iso_store.clone();
let grub_base = public_base_url.clone();
let tftp_dynamic: openpxe_tftp::DynamicAsset = std::sync::Arc::new(move |name: &str| {
if name == "grub.cfg" || name.starts_with("grub.cfg-") {
Some(
openpxe_http_api::grub_script::render_grub_menu(&grub_isos.list(), &grub_base)
.into_bytes(),
)
} else {
None
}
});
let tftp = TftpServer::new( let tftp = TftpServer::new(
config.server.tftp_bind, config.server.tftp_bind,
config.server.tftp_port, config.server.tftp_port,
clients.clone(), clients.clone(),
metrics.clone(), metrics.clone(),
Some(tftp_dynamic),
); );
let tftp_task = tokio::spawn(tftp.run()); let tftp_task = tokio::spawn(tftp.run());
@@ -248,6 +272,10 @@ async fn main() -> anyhow::Result<()> {
public_base_url.clone(), public_base_url.clone(),
clients.clone(), clients.clone(),
metrics.clone(), metrics.clone(),
// v0.7.1: learned driver modes persist next to the other
// state files, so a machine walks the ladder once *ever*.
openpxe_dhcp_proxy::DriverEscalation::load_or_default(&config.paths.work_dir),
boot_rules.clone(),
); );
tokio::spawn(s.run()) tokio::spawn(s.run())
} }
@@ -421,6 +449,12 @@ struct NetworkInfo {
nic_name: String, nic_name: String,
subnet_mask: String, subnet_mask: String,
gateway: String, gateway: String,
/// v0.7.2: physical link summary for the Network tab — operstate,
/// negotiated speed/duplex, and the port's own MAC. Helps operators
/// in multi-NIC / trunked environments confirm *which* port the PXE
/// server actually answers on. Empty when sysfs isn't available
/// (non-Linux dev builds) or the NIC wasn't identified.
nic_link: String,
} }
/// Best-effort population of the Network tab's read-only fields. We shell /// Best-effort population of the Network tab's read-only fields. We shell
@@ -481,9 +515,44 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
} }
} }
info.nic_link = detect_link_info(&info.nic_name);
info info
} }
/// v0.7.2: read the NIC's physical link details from sysfs. Every field
/// is optional — virtual NICs report no speed (`-1` or absent), and
/// non-Linux dev machines have no `/sys/class/net` at all — so the
/// result is whatever could be read, joined human-readably, or empty.
fn detect_link_info(nic: &str) -> String {
if nic.is_empty() {
return String::new();
}
let read = |file: &str| {
std::fs::read_to_string(format!("/sys/class/net/{nic}/{file}"))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
let mut parts: Vec<String> = Vec::new();
let state = read("operstate");
if !state.is_empty() {
parts.push(format!("link {state}"));
}
let speed = read("speed");
if !speed.is_empty() && speed != "-1" {
parts.push(format!("{speed} Mb/s"));
}
let duplex = read("duplex");
if !duplex.is_empty() && duplex != "unknown" {
parts.push(format!("{duplex} duplex"));
}
let mac = read("address");
if !mac.is_empty() {
parts.push(format!("port {mac}"));
}
parts.join(" · ")
}
fn prefix_to_dotted(prefix: u8) -> String { fn prefix_to_dotted(prefix: u8) -> String {
let prefix = prefix.min(32); let prefix = prefix.min(32);
let mask: u32 = if prefix == 0 { let mask: u32 = if prefix == 0 {
+1 -1
View File
@@ -14,4 +14,4 @@
pub mod server; pub mod server;
pub use server::TftpServer; pub use server::{DynamicAsset, TftpServer};
+24 -2
View File
@@ -32,11 +32,19 @@ const ERR_NOT_DEFINED: u16 = 0;
const ERR_FILE_NOT_FOUND: u16 = 1; const ERR_FILE_NOT_FOUND: u16 = 1;
const ERR_ILLEGAL_OP: u16 = 4; const ERR_ILLEGAL_OP: u16 = 4;
/// Server-rendered TFTP content for names that aren't embedded assets —
/// e.g. `grub.cfg` for the signed shim+GRUB Secure Boot chain (v0.7.0),
/// which is generated from the live boot-entry list per fetch. Kept as a
/// closure so this crate stays decoupled from the ISO store; the binary
/// wires it up in `main`.
pub type DynamicAsset = Arc<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
pub struct TftpServer { pub struct TftpServer {
bind: IpAddr, bind: IpAddr,
port: u16, port: u16,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
} }
impl TftpServer { impl TftpServer {
@@ -45,12 +53,14 @@ impl TftpServer {
port: u16, port: u16,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
) -> Self { ) -> Self {
Self { Self {
bind, bind,
port, port,
clients, clients,
metrics, metrics,
dynamic,
} }
} }
@@ -72,8 +82,11 @@ impl TftpServer {
let clients = clients.clone(); let clients = clients.clone();
let metrics = metrics.clone(); let metrics = metrics.clone();
let bind_ip = self.bind; let bind_ip = self.bind;
let dynamic = self.dynamic.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await { if let Err(e) =
handle_rrq(data, from, bind_ip, clients, metrics.clone(), dynamic).await
{
metrics.record_tftp_err(); metrics.record_tftp_err();
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}"); tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
} }
@@ -88,6 +101,7 @@ async fn handle_rrq(
bind_ip: IpAddr, bind_ip: IpAddr,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let Some(req) = parse_rrq(&packet) else { let Some(req) = parse_rrq(&packet) else {
// Not a well-formed RRQ. A WRQ deserves an explicit refusal — // Not a well-formed RRQ. A WRQ deserves an explicit refusal —
@@ -117,7 +131,15 @@ async fn handle_rrq(
return Ok(()); return Ok(());
} }
let Some(file_bytes) = asset_slice(&filename) else { // Embedded assets first; otherwise the dynamic renderer (server-
// generated content like the Secure Boot chain's grub.cfg, v0.7.0).
let resolved = asset_slice(&filename).or_else(|| {
dynamic
.as_ref()
.and_then(|f| f(&filename))
.map(std::borrow::Cow::Owned)
});
let Some(file_bytes) = resolved else {
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await; let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404"); tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
clients.record( clients.record(
+13
View File
@@ -912,3 +912,16 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
} }
.modal-actions .submit { width: auto; padding: 8px 18px; } .modal-actions .submit { width: auto; padding: 8px 18px; }
/* v0.7.2: a label.field directly followed by the card's action button
stacked its own 14px bottom margin onto the button's 16px top margin
(30px total) visible on Queue "Launch for all waiting" and the
Network "Save". Collapse the doubled gap so every primary action sits
the same 16px below its form. */
.card .body > label.field:has(+ button) { margin-bottom: 0; }
/* v0.7.2: inline list filter above a table (Available images). The input
is wrapped in a label.field so it borrows the standard text-field chrome
and matches every other input in the app; this wrapper just insets it
from the card edges so it lines up with the header text above. */
.list-search { padding: 14px 16px; }
+182 -32
View File
@@ -199,6 +199,51 @@
})[k] || (k || 'Unknown'); })[k] || (k || 'Unknown');
} }
// v0.7.2: compact read-out of saved group rules — created from the
// unified "Pin MAC" form on the Hosts tab (a prefix or an architecture
// there saves a rule instead of a pin). First match wins, top to
// bottom. The boot-decision webhook remains available via the API
// (/api/boot-rules `webhook_url`) but no longer has a UI knob.
function groupRulesCard(cfg, targetOptions) {
const rules = (cfg && cfg.rules) || [];
if (!rules.length) return null;
const titleFor = id => {
const t = targetOptions.find(x => x.id === id);
return t ? t.title : id;
};
const modeLabel = {firmware:'Firmware NIC', builtin:'iPXE drivers', shim:'Secure Boot (shim)'};
const rows = rules.map((r, i) => el('tr', r.enabled === false ? {style:'opacity:.5'} : {}, [
el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')),
el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')),
el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')),
el('td', {}, r.driver_mode
? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode)
: el('span', {class:'tag'}, 'auto')),
el('td', {}, r.note || ''),
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this group rule?')) return;
const fresh = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
(fresh.rules = fresh.rules || []).splice(i, 1);
await putJSON('/api/boot-rules', fresh);
render('hosts');
}}, 'Remove')),
]));
return el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Group rules'),
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
]),
el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''),
])),
el('tbody', {}, rows),
]),
]);
}
// v0.5.2: build the shared "deployment profile" field group — auto // v0.5.2: build the shared "deployment profile" field group — auto
// hostname, auto IP, and an unattended-file picker — reused by the // hostname, auto IP, and an unattended-file picker — reused by the
// Hosts pin form and the Queue "Profile" modal. `files` is the // Hosts pin form and the Queue "Profile" modal. `files` is the
@@ -400,6 +445,12 @@
el('div', {class:'v'}, net.gateway || '?'), el('div', {class:'v'}, net.gateway || '?'),
el('div', {class:'k'}, 'Public base URL'), el('div', {class:'k'}, 'Public base URL'),
el('div', {class:'v'}, net.public_base_url), el('div', {class:'v'}, net.public_base_url),
// v0.7.2: physical link details (operstate · speed · duplex ·
// port MAC) so the operator can confirm WHICH port answers PXE
// in multi-NIC / trunked environments. Kept last — the joined
// value runs long, so it wraps cleanly at the bottom of the list.
el('div', {class:'k'}, 'Link'),
el('div', {class:'v'}, net.nic_link || '—'),
]), ]),
el('p', {class:'msg'}, el('p', {class:'msg'},
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' + 'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
@@ -789,6 +840,12 @@
render('storage'); render('storage');
}; };
// v0.7.2: searchable haystack for the list filter — filename,
// detected family, category, and source all match.
const searchText = [
i.filename, familyLabel(i.introspection.family), i.category || '',
isSmb ? 'smb' : isNfs ? 'nfs' : 'local', i.id,
].join(' ').toLowerCase();
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [ const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
el('td', {}, [ el('td', {}, [
el('div', {style:'display:flex;align-items:center;gap:8px'}, [ el('div', {style:'display:flex;align-items:center;gap:8px'}, [
@@ -833,8 +890,24 @@
}}, 'Remove'), }}, 'Remove'),
]), ]),
]); ]);
tr.dataset.search = searchText;
rowsAndEditors.push(tr, editorRow); rowsAndEditors.push(tr, editorRow);
}); });
// v0.7.2: client-side filter over the image table. Rows travel in
// (row, password-editor) pairs; filtering hides both, and an open
// editor stays closed for filtered-out rows.
const isoSearch = el('input', {type:'search', placeholder:'Filter images by name, type, or source',
spellcheck:'false', oninput: () => {
const q = isoSearch.value.trim().toLowerCase();
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
const row = rowsAndEditors[k];
const editor = rowsAndEditors[k + 1];
const show = !q || (row.dataset.search || '').includes(q);
row.style.display = show ? '' : 'none';
if (!show) editor.style.display = 'none';
}
}});
const isoTable = isos.length const isoTable = isos.length
? el('table', {}, [ ? el('table', {}, [
el('thead', {}, el('tr', {}, [ el('thead', {}, el('tr', {}, [
@@ -1172,7 +1245,10 @@
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); }; unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
const unattRows = unattendedFiles.length const unattRows = unattendedFiles.length
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [ ? unattendedFiles.map(f => el('div', {
class:'nfs-row',
'data-search': (f.filename + ' ' + unattendedKindLabel(f.kind) + ' ' + f.id).toLowerCase(),
}, [
el('span', {class:'dot ok'}), el('span', {class:'dot ok'}),
el('div', {}, [ el('div', {}, [
el('div', {class:'id'}, [ el('div', {class:'id'}, [
@@ -1200,6 +1276,17 @@
]), ]),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
unattDrop, unattFile, unattMsg, unattDrop, unattFile, unattMsg,
// v0.7.2: filter for big answer-file libraries.
unattendedFiles.length > 1 ? (() => {
const search = el('input', {type:'search', placeholder:'Filter files by name or kind',
spellcheck:'false', oninput: () => {
const q = search.value.trim().toLowerCase();
unattRows.forEach(r => {
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
});
}});
return el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, search);
})() : null,
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows), el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'These answer files drive unattended installs. Attach one to a ' + 'These answer files drive unattended installs. Attach one to a ' +
@@ -1246,16 +1333,20 @@
el('h2', {}, 'Available images'), el('h2', {}, 'Available images'),
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')), el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
]), ]),
isos.length > 1
? el('div', {class:'list-search'}, el('label', {class:'field', style:'margin-bottom:0'}, isoSearch))
: null,
isoTable, isoTable,
]), ]),
]), unattendedAdvanced]); ]), unattendedAdvanced]);
}, },
hosts: async () => { hosts: async () => {
const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([ const [{ hosts = [] }, isos, bootLogRes, unattRes, rulesCfg] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'), getJSON('/api/hosts'), getJSON('/api/isos'),
getJSON('/api/boot-log').catch(() => ({ events: [] })), getJSON('/api/boot-log').catch(() => ({ events: [] })),
getJSON('/api/unattended').catch(() => ({ files: [] })), getJSON('/api/unattended').catch(() => ({ files: [] })),
getJSON('/api/boot-rules').catch(() => ({ rules: [], webhook_url: '' })),
]); ]);
const bootEvents = bootLogRes.events || []; const bootEvents = bootLogRes.events || [];
const unattendedFiles = unattRes.files || []; const unattendedFiles = unattRes.files || [];
@@ -1270,8 +1361,22 @@
{id: '_tools_menu', title: '↳ Tools menu (built-in)'}, {id: '_tools_menu', title: '↳ Tools menu (built-in)'},
]; ];
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'}); const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or aa:bb:cc', spellcheck:'false'});
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'}); const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
// v0.7.2: the former separate "Boot rules" card folded into this
// form. A full MAC with no architecture saves a per-host pin
// exactly as before; a MAC *prefix* and/or an architecture saves a
// first-match-wins group rule instead. Same form, one mental model.
const archSel = el('select', {}, [
['', 'any (this exact MAC)'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
].map(([v, t]) => el('option', {value: v}, t)));
// v0.7.1's boot-binary pin keeps its home here too (auto = let the
// escalation ladder learn; shim = known Secure Boot fleet).
const binSel = el('select', {}, [
['', 'auto (learn per machine)'], ['firmware', 'Firmware NIC'],
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
].map(([v, t]) => el('option', {value: v}, t)));
const targetSel = el('select', {}, const targetSel = el('select', {},
[el('option', {value:''}, '— choose a target —')] [el('option', {value:''}, '— choose a target —')]
.concat(reserved.map(t => el('option', {value: t.id}, t.title))) .concat(reserved.map(t => el('option', {value: t.id}, t.title)))
@@ -1284,21 +1389,40 @@
// hostname/IP templated into the served answer file. // hostname/IP templated into the served answer file.
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3'); const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
const FULL_MAC = /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i;
const upsertBtn = el('button', {onclick: async () => { const upsertBtn = el('button', {onclick: async () => {
if (!macInput.value || !targetSel.value) { const mac = macInput.value.trim();
const isGroup = !!archSel.value || !!binSel.value || (mac !== '' && !FULL_MAC.test(mac));
if (!isGroup) {
// Exact-MAC pin — unchanged behavior.
if (!mac || !targetSel.value) {
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return; msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
} }
const r = await postJSON('/api/hosts', Object.assign({ const r = await postJSON('/api/hosts', Object.assign({
mac: macInput.value, target: targetSel.value, label: labelInput.value, mac, target: targetSel.value, label: labelInput.value,
}, profileFields.read())); }, profileFields.read()));
if (r.ok) { if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
msg.textContent = 'Saved.'; msg.className = 'msg ok'; else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
render('hosts'); return;
} else {
const t = await r.text();
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
} }
}}, 'Bind MAC to target'); // Group rule (prefix and/or architecture). Per-host profile
// fields don't apply to a group — they're per-machine values.
if (!targetSel.value && !binSel.value) {
msg.textContent = 'A group rule needs a target or a boot binary.'; msg.className = 'msg err'; return;
}
const p = profileFields.read();
if (p.auto_hostname || p.auto_ip || p.unattended_file) {
msg.textContent = 'Auto-deploy fields are per-machine — clear them, or use a full MAC.'; msg.className = 'msg err'; return;
}
const cfg = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
(cfg.rules = cfg.rules || []).push({
mac_prefix: mac, arch: archSel.value, target: targetSel.value,
driver_mode: binSel.value, enabled: true, note: labelInput.value,
});
const r = await putJSON('/api/boot-rules', cfg);
if (r.ok) { msg.textContent = 'Group rule saved.'; msg.className = 'msg ok'; render('hosts'); }
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
}}, 'Bind to target');
const rows = hosts.map(h => { const rows = hosts.map(h => {
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole // v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
@@ -1355,23 +1479,32 @@
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Pin MAC to boot target')), el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
// v0.7.3: MAC · Label · Architecture · Boot binary share one
// 4-up row so the controls line up across the page; the
// per-field guidance that used to sit under them moved into the
// note below to keep the inputs flush. Target spans full width
// on its own line beneath them.
el('div', {class:'form-row'}, [ el('div', {class:'form-row'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]), el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address or prefix'), macInput]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]), el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
el('label', {class:'field', style:'grid-column:1 / -1'}, [ el('label', {class:'field'}, [el('span', {class:'name'}, 'Architecture (optional)'), archSel]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Boot binary (optional)'), binSel]),
]),
el('label', {class:'field', style:'margin-top:14px'}, [
el('span', {class:'name'}, 'Target'), el('span', {class:'name'}, 'Target'),
targetSel, targetSel,
el('span', {class:'hint'},
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]),
]), ]),
el('div', {style:'margin-top:16px'}, profileFields.wrap), el('div', {style:'margin-top:16px'}, profileFields.wrap),
upsertBtn, msg, upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' + 'A full MAC pins one machine; a MAC prefix (OUI) or an architecture ' +
'short-circuits past the interactive menu and chains directly. ' + 'saves a first-match group rule. Pin the boot binary to “shim” for ' +
'If an unattended file is selected, the matching kernel argument ' + 'Secure Boot racks — zero failed boot cycles. When a matching client ' +
'is injected and the hostname/IP are templated into the answer file.'), 'requests boot.ipxe, OpenPXE short-circuits past the interactive menu ' +
'and chains directly; decision order is exact MAC pin → first matching ' +
'group rule → menu. If an unattended file is selected on a pin, the ' +
'matching kernel argument is injected and the hostname/IP are templated ' +
'into the answer file.'),
]), ]),
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
@@ -1381,6 +1514,7 @@
]), ]),
table, table,
]), ]),
groupRulesCard(rulesCfg, reserved.concat(targets)),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, [ el('header', {}, [
el('h2', {}, 'Host log'), el('h2', {}, 'Host log'),
@@ -2057,9 +2191,24 @@
el('div', {class:'about-hero'}, [ el('div', {class:'about-hero'}, [
el('h2', {}, 'OpenPXE'), el('h2', {}, 'OpenPXE'),
el('p', {class:'lead'}, el('p', {class:'lead'},
'Air-gapped network PXE boot, container-native, that anyone can run. ' + 'The network-boot platform for modern infrastructure. Drop in an ISO ' +
'No CDN calls, no telemetry, no surprise external dependencies — ship ' + 'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
'the image once, run it forever.'), 'boot it, image from it, and install unattended. One container, one ' +
'static binary, nothing installed on clients, nothing leaving your network.'),
el('div', {style:'display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0'}, [
['Boot anything', 'Linux, Windows, hypervisors, rescue tools — uploaded ' +
'ISOs become menu entries automatically, served on demand from local ' +
'disk or your existing NFS, SMB, or SFTP libraries.'],
['Adapt to every machine', 'Per-machine boot intelligence: firmware quirks, ' +
'NIC driver fallback, and a Microsoft-signed Secure Boot chain are ' +
'negotiated automatically and remembered — no toggles, no client prep.'],
['Run it in production', 'SAML single sign-on, token-scoped answer files, ' +
'fleet routing rules, Wake-on-LAN, queued mass deployment, Prometheus ' +
'metrics. Built in Rust for boot infrastructure that cannot flinch.'],
].map(([h, body]) => el('div', {}, [
el('h3', {style:'margin:0 0 6px;font-size:13.5px'}, h),
el('p', {class:'msg', style:'font-size:12px;margin:0'}, body),
]))),
el('div', {class:'who'}, [ el('div', {class:'who'}, [
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'), el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'), el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
@@ -2070,15 +2219,16 @@
]), ]),
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]), el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
el('p', {class:'msg', style:'margin-top:18px'}, el('p', {class:'msg', style:'margin-top:18px'},
'iPXE is an internal implementation detail. Everything the firmware ' + 'Private by design: no telemetry, no CDN calls, no runtime ' +
'executes is generated from the settings on these tabs — there is no ' + 'dependencies on the outside world. Air-gapped labs, customer sites ' +
'hand-written .ipxe path anywhere in this product.'), 'without internet, and locked-down OpenShift clusters run the same ' +
'image, the same way, indefinitely.'),
el('p', {class:'msg'}, el('p', {class:'msg'},
'Vision: a deployment-grade tool that works on first try in the most ' + 'Principled by default: OpenPXE never asks an operator to install ' +
'awkward environments — air-gapped labs, customer sites without ' + 'test-signed drivers, modify a clients trust store, or weaken ' +
'internet, OpenShift clusters with strict SCCs — without ever asking ' + 'Secure Boot. Everything the firmware executes is generated from the ' +
'an operator to install drivers signed with test certificates or to ' + 'settings on these tabs — there are no hand-written boot scripts to ' +
'flip "testsigning" on a target machine.'), 'maintain and no internals to learn.'),
]), ]),
]); ]);
+9 -1
View File
@@ -23,11 +23,19 @@ ARG RUST_VERSION=1.95
# and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI # and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
# binaries from the `ipxe-build` stage overlay on top of. # binaries from the `ipxe-build` stage overlay on top of.
FROM debian:12-slim AS fetch FROM debian:12-slim AS fetch
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ # rpm2cpio + cpio: extract Fedora's Microsoft-signed shim/GRUB RPMs for
# the Secure Boot chain (v0.7.0, scripts/fetch-shim.sh).
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates rpm2cpio cpio \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
COPY scripts/fetch-shim.sh scripts/fetch-shim.sh
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
# Signed shim+GRUB (Secure Boot escalation rung). Redistributed
# unmodified from the official Fedora packages — see fetch-shim.sh for
# the trust model.
RUN bash scripts/fetch-shim.sh /src/assets/ipxe
########## build PNG-enabled iPXE from source ########## ########## build PNG-enabled iPXE from source ##########
# v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG # v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Fetch Fedora's Microsoft-signed Secure Boot chain — shim + GRUB — and
# place the EFI binaries under assets/ipxe/ with the filenames OpenPXE's
# DriverMode::Shim mapping expects:
#
# shimx64.efi x86_64: Microsoft-signed shim (first stage)
# grubx64.efi x86_64: Fedora-signed GRUB (loaded by shim, fetches
# the server-rendered grub.cfg over TFTP/HTTP)
# shimaa64.efi arm64 equivalents (best-effort — see below)
# grubaa64.efi
#
# Why Fedora: a supply-chain decision made deliberately (v0.7.0) — one
# vendor, fast security turnaround, and the same chain most netboot
# projects redistribute. The binaries are extracted from the official
# distro RPMs and shipped BYTE-FOR-BYTE UNMODIFIED; their signatures are
# what make the chain work, and modifying them would break it. This is
# the standard documented netboot path for Secure Boot (Red Hat
# Satellite, SUSE HTTPBoot) and involves no test certificates and no
# client trust-store changes.
#
# Trust model matches fetch-ipxe.sh: HTTPS to the official distribution
# point, no sha pinning because we track the latest signed build (which
# rotates on SBAT revocations — pinning would mean shipping revoked
# shims). Mirror to your own artifact store for deterministic builds.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="${1:-$ROOT/assets/ipxe}"
mkdir -p "$DEST"
FEDORA_RELEASE="${FEDORA_RELEASE:-43}"
BASE="${FEDORA_MIRROR:-https://dl.fedoraproject.org/pub/fedora/linux/releases/$FEDORA_RELEASE/Everything}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# Find the newest RPM in a repo directory whose name starts with
# `$pattern` followed by a version digit (anchoring on the digit keeps
# `grub2-efi-x64` from matching `grub2-efi-x64-cdboot`).
latest_rpm() {
local dir_url="$1" pattern="$2"
curl -fsSL "$dir_url/" \
| grep -oE "href=\"${pattern}-[0-9][^\"]*\.rpm\"" \
| sed 's/^href="//; s/"$//' \
| sort -V | tail -1
}
# fetch_chain <repo-arch> <shim-pkg> <grub-pkg> <shim-out> <grub-out> <hard|soft>
fetch_chain() {
local arch="$1" shim_pkg="$2" grub_pkg="$3" shim_out="$4" grub_out="$5" mode="$6"
local pkg_base="$BASE/$arch/os/Packages"
local sdir="$pkg_base/${shim_pkg:0:1}" gdir="$pkg_base/${grub_pkg:0:1}"
local shim_rpm grub_rpm
shim_rpm="$(latest_rpm "$sdir" "$shim_pkg" || true)"
grub_rpm="$(latest_rpm "$gdir" "$grub_pkg" || true)"
if [ -z "$shim_rpm" ] || [ -z "$grub_rpm" ]; then
echo "!! could not locate $shim_pkg/$grub_pkg RPMs under $pkg_base"
[ "$mode" = "hard" ] && exit 2
echo " skipping $arch Secure Boot chain (best-effort)"
return 0
fi
echo ">> $arch: $shim_rpm + $grub_rpm"
local exdir="$WORK/$arch"
mkdir -p "$exdir"
curl -fsSL -o "$exdir/shim.rpm" "$sdir/$shim_rpm"
curl -fsSL -o "$exdir/grub.rpm" "$gdir/$grub_rpm"
( cd "$exdir" \
&& rpm2cpio shim.rpm | cpio -idm --quiet "./boot/efi/EFI/*/$shim_out" \
&& rpm2cpio grub.rpm | cpio -idm --quiet "./boot/efi/EFI/*/$grub_out" )
local shim_path grub_path
shim_path="$(find "$exdir/boot" -name "$shim_out" | head -1)"
grub_path="$(find "$exdir/boot" -name "$grub_out" | head -1)"
if [ -z "$shim_path" ] || [ -z "$grub_path" ]; then
echo "!! RPM layout changed — $shim_out/$grub_out not found inside the packages"
[ "$mode" = "hard" ] && exit 2
return 0
fi
cp "$shim_path" "$DEST/$shim_out"
cp "$grub_path" "$DEST/$grub_out"
echo " installed $shim_out + $grub_out"
}
# x86_64 is the headline Secure Boot audience — fail the build if it
# can't be assembled so a regression is loud, not silent.
fetch_chain x86_64 shim-x64 grub2-efi-x64 shimx64.efi grubx64.efi hard
# arm64 is best-effort: skipping just means no Shim escalation rung for
# that arch (logged at startup by ipxe-assets::log_availability).
fetch_chain aarch64 shim-aa64 grub2-efi-aa64 shimaa64.efi grubaa64.efi soft
echo
echo "Secure Boot chain assets now in $DEST:"
ls -lh "$DEST"/shim*.efi "$DEST"/grub*.efi 2>/dev/null || true