Compare commits

...
4 Commits
Author SHA1 Message Date
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
Miles WardandClaude Opus 4.8 5da05a519d v0.6.2: Mythos Validation — full-codebase polish, hot-path optimizations, dhcproto 0.15
Codebase-wide review pass: finish or remove every loose end, take the
safe performance wins on the serving hot paths, and refresh the
dependency tree for reliability. No behavior changes for working
clients; legacy clients get clearer protocol errors.

Finalize / cleanup:
- Remove mac_allowlist/subnet_allowlist config fields — parsed but never
  enforced since introduction; the operator wants line-of-sight serving,
  so the honest fix is deletion, not wiring.
- Remove dead ClientRegistry API (get, set_selected_target,
  always-None selected_target field, never-emitted DhcpRequest/
  HttpIsoAsset events).
- TFTP: reject WRQ with ERR_ILLEGAL_OP and non-octet modes with a clear
  error instead of silent timeouts (legacy-client friendliness); fold
  plan_window into cfg(test); drop the unused-constant keep-alive hack.
- rustfmt sweep over the six files with accumulated drift.

Hot-path optimizations (all behavior-preserving):
- Serve embedded iPXE binaries zero-copy (Cow over rodata) on both TFTP
  and HTTP — was a ~1 MiB heap copy per boot file request.
- Cache the composited PXE boot-menu background PNG keyed on the
  branding logo revision — was ~50-200 ms of image work per booting
  client; now one compose per logo change.
- Run bcrypt verify/hash on the blocking pool (boot password gate,
  login, setup, credential rotation) so CPU-heavy auth can't stall the
  workers streaming ISO ranges to imaging machines.
- iso_raw: reuse the already-cloned IsoMeta for path resolution instead
  of a second registry lock + deep clone per range request.
- DriverEscalation: amortize the TTL sweep (1-min interval + inline
  staleness check) instead of an O(map) retain per DHCP packet.
- format_mac: one allocation instead of four per datagram.
- Introspection haystack sized to min(scan cap, file size) — was
  guaranteed a 32 MiB realloc on every large-ISO probe.

Robustness:
- parse_range: malformed Range headers are now ignored per RFC 7233
  (200 + full body) instead of answered with a bogus 206.

Dependencies:
- dhcproto 0.12 -> 0.15: drops the deprecated/unmaintained
  trust-dns-proto from the tree (hickory-proto), three releases of DHCP
  option coverage. Compiles + passes the full suite unchanged.
- socket2 0.6 (dedupes tree), bcrypt 0.19, tower-http 0.6.11 (sheds
  iri-string), tokio 1.52.3 / hyper 1.10 lockfile refresh; dead nom
  workspace entry removed; requested versions synced to shipped reality.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 16:44:55 -04:00
33 changed files with 3336 additions and 1092 deletions
Generated
+1145 -762
View File
File diff suppressed because it is too large Load Diff
+22 -23
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.6.1"
version = "0.7.1"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
@@ -20,21 +20,23 @@ repository = "https://gitea.milesward.dev/mward4/OpenPXE"
authors = ["OpenPXE contributors"]
[workspace.dependencies]
tokio = { version = "1.40", features = ["full"] }
tokio = { version = "1.52", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
tokio-stream = { version = "0.1", features = ["sync"] }
futures = "0.3"
async-trait = "0.1"
dhcproto = "0.12"
socket2 = { version = "0.5", features = ["all"] }
# v0.6.2: dhcproto 0.15 drops the deprecated trust-dns-proto dependency
# (replaced by hickory-proto) and carries three releases of DHCP option
# coverage accumulated upstream — both directly relevant to the proxy core.
dhcproto = "0.15"
socket2 = { version = "0.6", features = ["all"] }
bytes = "1.7"
nom = "7.1"
axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
axum = { version = "0.8", features = ["macros", "multipart", "http2"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.4"
hyper = "1.9"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
serde = { version = "1.0", features = ["derive"] }
@@ -52,9 +54,11 @@ thiserror = "2.0"
clap = { version = "4.5", features = ["derive", "env"] }
uuid = { version = "1.10", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] }
# sha2 stays 0.10 deliberately: bergshamra-crypto requires ^0.10, and
# bumping to 0.11 would split the RustCrypto digest stack in the tree.
sha2 = "0.10"
hex = "0.4"
bcrypt = "0.15"
bcrypt = "0.19"
parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] }
@@ -76,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
# rejected precisely because it hard-requires OpenSSL. We build the thin
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
bergshamra = "0.4"
bergshamra = "0.5"
roxmltree = "0.21"
quick-xml = "0.40"
x509-parser = "0.18"
@@ -95,24 +99,19 @@ base64 = "0.22"
# binary via rustls + bergshamra — so SFTP adds ZERO new C/crypto deps
# and the static-musl build stays OpenSSL-free.
#
# CRITICAL #2 — pinned to EXACTLY 0.55.0, the newest russh that
# coexists with bergshamra-crypto (our SAML core). The RustCrypto
# ecosystem is mid-transition: bergshamra-crypto pins a constellation of
# release-CANDIDATE crates (`pkcs8 =0.11.0-rc.11` and its matching
# pkcs5/spki RCs) that are API-incompatible with the STABLE versions of
# the same crates in the same semver bucket. russh 0.56+ pulls those
# stable crates (`pkcs5 0.8`), which silently replaces bergshamra's RC
# copies and breaks compilation. russh ≤0.55 stays on the previous stable
# 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.
# CRITICAL #2 — history: this was pinned to =0.55.0 from v0.5.5 until
# v0.6.3 because bergshamra-crypto pinned release-candidate RustCrypto
# crates that conflicted with the stable generation russh 0.56+ pulls.
# bergshamra 0.5 (2026-06) moved to the stable generation (pkcs8 0.11),
# lifting the pin. v0.6.3 bumps to 0.61+, which also closes a batch of
# RUSTSEC advisories reachable from the SFTP *client* path (unbounded
# allocations in packet parsing — CVE-2026-48110/-46702/-46673 et al.)
# and drops mlock on non-secret buffers (~21% SSH throughput upstream).
#
# SCP was deliberately rejected: the protocol is sequential-only (no
# random access → no HTTP Range, unlike SFTP/NFS) and the mature SCP
# 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"
openpxe-core = { path = "crates/core" }
+85 -6
View File
@@ -23,13 +23,21 @@ pub enum ClientArch {
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
/// stack, via `snponly`/`undionly`) and only escalates a specific MAC to
/// [`DriverMode::Builtin`] (iPXE's bundled NIC drivers) automatically, when a
/// firmware-net boot fails to chainload. There is no operator toggle — the
/// DHCP proxy decides per client.
/// stack, via `snponly`/`undionly`) and escalates a specific MAC
/// automatically when a boot never completes its handoff:
/// `Firmware → Builtin → Shim`. There is no operator toggle — the DHCP
/// 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)]
#[serde(rename_all = "snake_case")]
pub enum DriverMode {
@@ -40,6 +48,9 @@ pub enum DriverMode {
/// iPXE's own bundled NIC drivers (`ipxe.efi`, `ipxe.pxe`). Fallback for
/// hardware whose firmware NIC stack is missing or buggy.
Builtin,
/// Microsoft-signed shim + GRUB chain (`shimx64.efi`). Final fallback
/// for Secure-Boot-enabled UEFI clients that refuse unsigned iPXE.
Shim,
}
impl ClientArch {
@@ -88,20 +99,43 @@ impl ClientArch {
// IA32 UEFI.
(Self::Ia32Uefi, DriverMode::Firmware) => "snponly-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.
(Self::X64Uefi, DriverMode::Firmware) => "snponly.efi",
(Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi",
(Self::X64Uefi, DriverMode::Shim) => "shimx64.efi",
// ARM64 UEFI.
(Self::Arm64Uefi, DriverMode::Firmware) => "snponly-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
// 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
// we can't serve.
(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]
pub fn as_str(self) -> &'static str {
match self {
@@ -233,6 +267,51 @@ mod tests {
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]
fn firmware_class_detects_ipxe_over_pxeclient() {
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
+1 -3
View File
@@ -125,9 +125,7 @@ impl AdminStore {
{
let mut g = self.inner.write();
if g.admin.is_some() {
return Err(Error::Invalid(
"admin account already configured".into(),
));
return Err(Error::Invalid("admin account already configured".into()));
}
g.admin = Some(admin.clone());
}
+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);
}
}
-17
View File
@@ -12,11 +12,9 @@ use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientEvent {
DhcpDiscover,
DhcpRequest,
PxeBootServerRequest,
TftpRead { file: String },
HttpScriptFetch { target: String },
HttpIsoAsset { file: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -32,8 +30,6 @@ pub struct ClientSnapshot {
// Events are left with default serialization (9-tuple) — they're
// diagnostic only and not consumed by the UI today.
pub events: Vec<(OffsetDateTime, ClientEvent)>,
/// The boot target (ISO id) last selected via the iPXE menu, if any.
pub selected_target: Option<String>,
}
#[derive(Debug, Default)]
@@ -66,7 +62,6 @@ impl ClientRegistry {
first_seen: now,
last_seen: now,
events: Vec::new(),
selected_target: None,
});
entry.last_seen = now;
if ip.is_some() {
@@ -84,13 +79,6 @@ impl ClientRegistry {
}
}
pub fn set_selected_target(&self, mac: &str, target: Option<String>) {
let mut guard = self.inner.write();
if let Some(c) = guard.get_mut(mac) {
c.selected_target = target;
}
}
#[must_use]
pub fn list(&self) -> Vec<ClientSnapshot> {
let guard = self.inner.read();
@@ -99,9 +87,4 @@ impl ClientRegistry {
v.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
v
}
#[must_use]
pub fn get(&self, mac: &str) -> Option<ClientSnapshot> {
self.inner.read().get(mac).cloned()
}
}
-6
View File
@@ -40,10 +40,6 @@ pub struct NetworkConfig {
pub dhcp_port: u16,
/// UDP port for PXE Boot Server discovery. Standard is 4011.
pub pxe_port: u16,
/// Optional allowlist of client MAC prefixes (OUI). Empty = serve everyone.
pub mac_allowlist: Vec<String>,
/// Optional allowlist of subnets (CIDR). Empty = serve everyone.
pub subnet_allowlist: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -105,8 +101,6 @@ impl Default for NetworkConfig {
dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
dhcp_port: 67,
pxe_port: 4011,
mac_allowlist: Vec::new(),
subnet_allowlist: Vec::new(),
}
}
}
+4
View File
@@ -5,6 +5,8 @@
pub mod arch;
pub mod auth;
pub mod boot_log;
pub mod boot_rules;
pub mod boot_tokens;
pub mod branding;
pub mod client;
pub mod config;
@@ -24,6 +26,8 @@ pub mod wol;
pub use arch::{ClientArch, DriverMode, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore};
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 client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
+4 -1
View File
@@ -303,7 +303,10 @@ mod tests {
smtp_host: "smtp.example.com".into(),
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject");
assert!(
matches!(r, Err(Error::Invalid(_))),
"missing recipient should reject"
);
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
+3 -5
View File
@@ -181,10 +181,7 @@ mod tests {
Ipv4Addr::new(192, 168, 1, 255)
);
assert_eq!(
subnet_broadcast(
Ipv4Addr::new(10, 5, 3, 7),
Ipv4Addr::new(255, 255, 0, 0)
),
subnet_broadcast(Ipv4Addr::new(10, 5, 3, 7), Ipv4Addr::new(255, 255, 0, 0)),
Ipv4Addr::new(10, 5, 255, 255)
);
}
@@ -196,7 +193,8 @@ mod tests {
// and confirm send_magic transmits the exact 102-byte packet.
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = rx.local_addr().unwrap().port();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
+5
View File
@@ -19,3 +19,8 @@ thiserror.workspace = true
anyhow.workspace = true
bytes.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"
+328 -36
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
//! default — it's the most reliable choice for chainloading because the
//! firmware just proved its network works by downloading the NBP. A minority
//! of NICs have a missing or buggy firmware UNDI/SNP stack; those clients
//! 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.
//! firmware just proved its network works by downloading the NBP. Two
//! classes of machine can't run it:
//!
//! We detect exactly that: a *fresh* firmware DISCOVER from a MAC whose
//! previous firmware attempt was never confirmed by an iPXE handoff means the
//! firmware-net build failed → escalate that MAC to [`DriverMode::Builtin`]
//! (iPXE's own NIC drivers). The decision is sticky — once a MAC settles on a
//! 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
//! unchanged so hardware that already boots never regresses.
//! * a minority of NICs have a missing or buggy firmware UNDI/SNP stack —
//! they TFTP the binary fine but iPXE can't bring the link up;
//! * Secure-Boot firmware downloads it fine but refuses to *execute* an
//! unsigned image.
//!
//! Both look identical from here: the tell-tale second DHCP DISCOVER
//! 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 parking_lot::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Multiple DISCOVERs within this window belong to the *same* boot (DHCP
@@ -26,15 +45,23 @@ use std::time::{Duration, Instant};
/// DISCOVER). They must not be mistaken for a failed-and-retried boot.
const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8);
/// Forget a MAC's state after this long with no activity, so a transient
/// escalation doesn't pin a client to Builtin forever and the map stays
/// bounded over a long-running deployment.
/// Forget an *unpinned* MAC's state after this long with no activity, so
/// a transient mid-walk state doesn't linger and the map stays bounded.
/// Pinned (learned) entries are exempt — that's their whole point.
const ENTRY_TTL: Duration = Duration::from_mins(30);
/// 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;
/// How often (at most) the whole map is swept for expired entries.
/// Correctness doesn't depend on the sweep — a stale entry is also
/// detected inline when its MAC next appears — so the sweep only bounds
/// memory for MACs that never return, and amortizing it keeps the
/// per-packet path O(1) instead of O(map).
const PRUNE_INTERVAL: Duration = Duration::from_mins(1);
#[derive(Debug, Clone, Copy)]
struct Entry {
mode: DriverMode,
@@ -42,21 +69,93 @@ struct Entry {
/// confirm it worked. A *new* boot arriving while this is still true means
/// the previous attempt failed and we should escalate.
awaiting_confirm: bool,
/// Learned mode (v0.7.1): persisted to disk, exempt from the TTL.
pinned: bool,
last_seen: Instant,
}
#[derive(Debug)]
struct Inner {
map: HashMap<String, Entry>,
/// When the last full TTL sweep ran — see [`PRUNE_INTERVAL`].
last_prune: Instant,
}
impl Default for Inner {
fn default() -> Self {
Self {
map: HashMap::new(),
last_prune: Instant::now(),
}
}
}
/// Tracks per-MAC driver-mode escalation. Cheap to share via `Arc`.
#[derive(Debug, Default)]
pub struct DriverEscalation {
inner: Mutex<HashMap<String, Entry>>,
inner: Mutex<Inner>,
/// Persistence target for learned modes; `None` = ephemeral (tests).
path: Option<Arc<PathBuf>>,
}
impl DriverEscalation {
/// Ephemeral instance (no persistence) — used by tests.
#[must_use]
pub fn new() -> Self {
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
/// `mac`. `primary` is true for the main DHCP DISCOVER (:67) and false for
/// the PXE Boot Server query (:4011); only the primary path drives
@@ -68,65 +167,150 @@ impl DriverEscalation {
/// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the
/// `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) {
self.confirm_at(mac, Instant::now());
}
fn decide_at(&self, mac: &str, primary: bool, now: Instant) -> DriverMode {
let (mode, snapshot) = {
let mut g = self.inner.lock();
g.retain(|_, e| now.duration_since(e.last_seen) < ENTRY_TTL);
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
g.map
.retain(|_, e| e.pinned || now.duration_since(e.last_seen) < ENTRY_TTL);
g.last_prune = now;
}
// Inline staleness check: an unpinned MAC whose entry outlived
// the TTL starts fresh even when the amortized sweep above
// hasn't caught it yet. Pinned entries never go stale.
if g.map
.get(mac)
.is_some_and(|e| !e.pinned && now.duration_since(e.last_seen) >= ENTRY_TTL)
{
g.map.remove(mac);
}
match g.get_mut(mac) {
let mut newly_pinned = false;
let mode = match g.map.get_mut(mac) {
None => {
g.insert(
g.map.insert(
mac.to_owned(),
Entry {
mode: DriverMode::Firmware,
// Only the primary DISCOVER opens a confirmation window.
awaiting_confirm: primary,
pinned: false,
last_seen: now,
},
);
if g.len() > MAX_ENTRIES {
evict_oldest(&mut g);
if g.map.len() > MAX_ENTRIES {
evict_oldest(&mut g.map);
}
DriverMode::Firmware
}
Some(entry) => {
let recent = now.duration_since(entry.last_seen) < SAME_BOOT_DEBOUNCE;
if primary && !recent {
// A genuinely new boot. If the previous attempt was never
// confirmed, the firmware-net build failed → escalate to
// the all-drivers build. Builtin is the most capable build
// we have, so it's the single escalation target (and a MAC
// already on Builtin simply stays there).
// A genuinely new boot. If the previous attempt was
// never confirmed, the build we served failed → climb
// one rung: Firmware (firmware NIC stack) → Builtin
// (iPXE's own drivers) → Shim (signed shim+GRUB —
// 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 {
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.last_seen = now;
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) {
let snapshot = {
let mut g = self.inner.lock();
if let Some(e) = g.get_mut(mac) {
let Some(e) = g.map.get_mut(mac) else {
return;
};
e.awaiting_confirm = false;
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>) {
if let Some(oldest) = map
.iter()
// Prefer evicting an unpinned entry; only touch learned modes when
// 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)
.map(|(k, _)| k.clone())
{
};
if let Some(oldest) = pick(false).or_else(|| pick(true)) {
map.remove(&oldest);
}
}
@@ -134,6 +318,7 @@ fn evict_oldest(map: &mut HashMap<String, Entry>) {
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn firmware_first_then_escalates_on_unconfirmed_retry() {
@@ -187,7 +372,30 @@ mod tests {
}
#[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 t0 = Instant::now();
assert_eq!(e.decide_at("dd", true, t0), DriverMode::Firmware);
@@ -195,8 +403,92 @@ mod tests {
e.decide_at("dd", true, t0 + Duration::from_mins(1)),
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);
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
// layer can short-circuit to a per-MAC binding when one
// 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!(
"{}/boot.ipxe?mac=${{mac}}",
ctx.public_base_url.trim_end_matches('/')
"{}/boot.ipxe?mac=${{mac}}&arch={}",
ctx.public_base_url.trim_end_matches('/'),
ctx.arch.as_str()
),
},
FirmwareClass::HttpClient => {
@@ -60,9 +63,12 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
// pointing at an EFI executable. We serve the iPXE EFI build for
// the negotiated driver mode over HTTP; it'll then do the same
// 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
.arch
.ipxe_bootfile_mode(ctx.driver_mode)
.bootfile_with_fallback(ctx.driver_mode)
.unwrap_or("snponly.efi");
BootDirective::HttpScript {
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 {
filename: name.to_string(),
},
+37 -12
View File
@@ -5,7 +5,9 @@ use crate::escalation::DriverEscalation;
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
use dhcproto::v4::{DhcpOption, Message, OptionCode};
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 std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
@@ -19,12 +21,19 @@ pub struct DhcpProxyServer {
public_base_url: String,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1). Shared across
/// the :67 and :4011 listener tasks via the server `Arc`.
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1; persistent
/// 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,
/// 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 {
#[allow(clippy::too_many_arguments)]
pub fn new(
bind: IpAddr,
dhcp_port: u16,
@@ -33,6 +42,8 @@ impl DhcpProxyServer {
public_base_url: String,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
escalation: DriverEscalation,
rules: BootRulesStore,
) -> Self {
Self {
bind,
@@ -42,7 +53,8 @@ impl DhcpProxyServer {
public_base_url,
clients,
metrics,
escalation: DriverEscalation::new(),
escalation,
rules,
}
}
@@ -142,9 +154,17 @@ impl DhcpProxyServer {
self.escalation.mark_ipxe_success(&mac);
DriverMode::Firmware // unused: this path serves the HTTP script
}
FirmwareClass::PxeClient | FirmwareClass::HttpClient => self
.escalation
.mode_for_firmware_attempt(&mac, label == "67"),
FirmwareClass::PxeClient | FirmwareClass::HttpClient => {
// v0.7.1: an operator rule pin wins over (and bypasses)
// 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.
FirmwareClass::Other => DriverMode::Firmware,
};
@@ -237,11 +257,16 @@ fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocke
}
fn format_mac(chaddr: &[u8]) -> String {
let take = chaddr.iter().take(6).copied().collect::<Vec<_>>();
take.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
use std::fmt::Write;
// One allocation — this runs for every PXE datagram we answer.
let mut s = String::with_capacity(17);
for (i, b) in chaddr.iter().take(6).enumerate() {
if i > 0 {
s.push(':');
}
let _ = write!(s, "{b:02x}");
}
s
}
/// Walk raw DHCP options looking for option 93 (Client System Architecture)
+382 -95
View File
@@ -35,7 +35,7 @@ use openpxe_core::{
encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_ipxe_assets::asset_slice;
use openpxe_iso_store::{
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
SmbState, UnattendedKind, UnattendedMeta,
@@ -71,7 +71,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/branding/pxe-logo", get(ui_pxe_logo))
// iPXE script endpoints.
.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),
// because the booting installer fetches these with no session.
// `/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>`
// base64url-encodes the per-host hostname/ip/mac. Management
// (upload/list/delete) lives under the gated `/api/unattended`.
.route("/unattended/:id", get(serve_unattended))
.route("/unattended/:id/:ctx/:sub", get(serve_unattended_seed))
.route("/unattended/{id}", get(serve_unattended))
.route("/unattended/{id}/{ctx}/{sub}", get(serve_unattended_seed))
// Bundled binaries and raw ISO access.
.route("/ipxe/:name", get(ipxe_binary))
.route("/iso/:filename", get(iso_raw))
.route("/iso/:id/*path", get(iso_file))
.route("/ipxe/{name}", get(ipxe_binary))
.route("/iso/{filename}", get(iso_raw))
.route("/iso/{id}/{*path}", get(iso_file))
// Container health/readiness probes. `/healthz` is always 200 OK
// while the HTTP task is alive. `/readyz` additionally requires at
// 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))
// JSON API.
.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/:upload_id",
"/api/uploads/{upload_id}",
put(api_upload_chunk).delete(api_upload_abort),
)
// Per-ISO password prompt. PUT body `{ "password": "..." }`
// sets, `{ "password": null }` (or DELETE) clears.
.route(
"/api/isos/:id/password",
"/api/isos/{id}/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
// image appears under Linux/Windows Installers (default) or in
// the Tools submenu next to memtest / shell / NIC info.
.route(
"/api/isos/:id/category",
"/api/isos/{id}/category",
axum::routing::put(api_set_iso_category),
)
// 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` /
// `client`. Multipart upload to POST; DELETE clears one slot.
.route(
"/api/branding/logo/:slot",
"/api/branding/logo/{slot}",
post(api_branding_upload).delete(api_branding_clear),
)
// v0.5.2: unattended-install answer-file management (gated).
@@ -130,7 +130,7 @@ pub fn build_router(state: AppState) -> Router {
"/api/unattended",
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
// can format it consistently with the rest of the chrome. Lives
// 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/queue", get(api_list_queue))
.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))
// v0.5.2: per-device deployment profile (auto hostname / IP /
// unattended file) set from the queue "Profile" button.
.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}/profile", put(api_queue_set_profile))
.route("/api/queue/{entry_id}", delete(api_queue_release))
// v0.4.65: SMB share manager (userspace via smbclient). The
// kernel-mount NFS routes that v0.4.64 shipped are gone — they
// 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",
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/scan", post(api_smb_shares_scan))
.route("/api/smb-shares/{id}", delete(api_smb_shares_remove))
.route("/api/smb-shares/{id}/scan", post(api_smb_shares_scan))
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
// Ships alongside SMB. Routes are parallel so the UI can
// reuse the same form/error/hint rendering for both.
@@ -186,8 +186,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/nfs-shares",
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/scan", post(api_nfs_shares_scan))
.route("/api/nfs-shares/{id}", delete(api_nfs_shares_remove))
.route("/api/nfs-shares/{id}/scan", post(api_nfs_shares_scan))
// 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
// rendering. Like NFS, SFTP-sourced ISOs support Range requests.
@@ -195,8 +195,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/sftp-shares",
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/scan", post(api_sftp_shares_scan))
.route("/api/sftp-shares/{id}", delete(api_sftp_shares_remove))
.route("/api/sftp-shares/{id}/scan", post(api_sftp_shares_scan))
// Phase 4: Network info (read-only) + DNS edit.
.route("/api/network", get(api_network).put(api_network_put))
// 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
// 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/: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
// 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
// started installing on what MAC/IP, and when. Persisted to disk.
.route("/api/boot-log", get(api_boot_log))
@@ -406,6 +413,22 @@ fn bundled_logo_response() -> Response {
/// brand mark falls back to the *default* background for the PXE screen
/// (the WebUI still renders the SVG natively in the top-left).
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
// The composite is a pure function of the uploaded logo, so the
// encoded PNG is cached keyed on the branding revision — an upload
// or clear bumps the rev and invalidates it. The response headers
// stay `no-cache` (clients must refetch); only the server-side
// ~50-200 ms decode/compose/encode is skipped per boot.
let rev = state.branding.logo_rev();
let cached = state
.pxe_bg_cache
.lock()
.as_ref()
.filter(|(r, _)| *r == rev)
.map(|(_, png)| png.clone());
if let Some(png) = cached {
return pxe_png_response(png);
}
// Resolve the operator's raster upload, if any and if it's a format
// iPXE/our compositor can consume. SVG (or a missing/unreadable
// file) yields `None`, which composes the default background.
@@ -450,6 +473,12 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
.into_response();
}
};
let png = bytes::Bytes::from(composed);
*state.pxe_bg_cache.lock() = Some((rev, png.clone()));
pxe_png_response(png)
}
fn pxe_png_response(png: bytes::Bytes) -> Response {
(
[
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
@@ -460,7 +489,7 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
HeaderValue::from_static("no-cache, max-age=0"),
),
],
composed,
png,
)
.into_response()
}
@@ -498,14 +527,15 @@ fn text_plain(body: String) -> Response {
/// to the bound target instead of rendering the menu.
async fn boot_top_menu(
State(state): State<AppState>,
peer: Option<ConnectInfo<SocketAddr>>,
peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
Query(p): Query<BootMenuParams>,
) -> Response {
// `ConnectInfo` is only populated when axum was started with
// `into_make_service_with_connect_info` (production path). Tests
// call the router via `oneshot`, which skips that wiring — we
// 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
.metrics
.record_http(openpxe_core::HttpRoute::BootScript);
@@ -547,17 +577,105 @@ async fn boot_top_menu(
// `?mac=` so the per-entry handler can record the boot into
// the Host log without depending on iPXE substitution at
// this stage.
return text_plain(format!(
"#!ipxe\n\
echo OpenPXE: per-MAC binding -> {target}\n\
chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n"
));
return text_plain(chain_script(base, &target, &bound_mac, "per-MAC binding"));
}
// 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))
}
/// 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
/// 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 {
@@ -580,6 +698,11 @@ struct BootMenuParams {
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
/// fall back to the menu unconditionally.
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)]
@@ -597,11 +720,12 @@ struct BootSubParams {
async fn boot_sub(
State(state): State<AppState>,
peer: Option<ConnectInfo<SocketAddr>>,
peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
AxumPath(filename): AxumPath<String>,
Query(p): Query<BootSubParams>,
) -> 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
// submenu names (prefixed `_`) or a boot entry id.
let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
@@ -638,7 +762,23 @@ async fn boot_sub(
));
}
Some(token) => {
match state.iso_store.verify_password(&iso.id, token) {
// bcrypt verify costs ~100-200 ms of pure
// CPU and this path is unauthenticated —
// run it on the blocking pool so password
// probes can't stall the workers that are
// streaming ISO bytes to imaging machines.
let store = state.iso_store.clone();
let iso_id = iso.id.clone();
let tok = token.to_string();
let verdict = match tokio::task::spawn_blocking(move || {
store.verify_password(&iso_id, &tok)
})
.await
{
Ok(v) => v,
Err(e) => Err(openpxe_core::Error::Other(e.into())),
};
match verdict {
Ok(true) => { /* fall through to render the entry */ }
Ok(false) => {
// Don't log the candidate — just the
@@ -710,7 +850,13 @@ async fn boot_sub(
.as_deref()
.and_then(|fid| state.unattended.get(fid))
.and_then(|meta| {
build_unattended_args(base, &meta, Some(m), &p)
build_unattended_args(
base,
&meta,
Some(m),
&p,
&state.boot_tokens,
)
})
})
});
@@ -731,13 +877,28 @@ async fn boot_sub(
// ─── 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('\\') {
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
}
let Some(bytes) = asset_bytes(&name) else {
// 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 {
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
};
// Release builds embed the asset in rodata — serve it without the
// ~1 MiB per-request heap copy `into_owned` would cost.
let bytes = match data {
std::borrow::Cow::Borrowed(b) => bytes::Bytes::from_static(b),
std::borrow::Cow::Owned(v) => bytes::Bytes::from(v),
};
(
[
(
@@ -768,7 +929,10 @@ async fn iso_raw(
};
match &meta.source {
IsoSource::Local => {
let Some(path) = state.iso_store.iso_path_for(id) else {
// `local_path(&meta)` reuses the meta we already cloned —
// `iso_path_for(id)` would re-lock and deep-clone it again,
// hundreds of times per sanboot install.
let Some(path) = state.iso_store.local_path(&meta) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
match stream_file_range(&path, headers.get(header::RANGE)).await {
@@ -1027,15 +1191,25 @@ fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)>
return Some((total.saturating_sub(n), total.saturating_sub(1), true));
}
}
let mut parts = spec.splitn(2, '-');
let start = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let end = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(total.saturating_sub(1));
// RFC 7233 §3.1: a Range header we can't parse is *ignored* (200 +
// full body), never coerced into a bogus 206 claiming the whole
// file. Only `first-pos[-last-pos]` with numeric positions reaches
// the partial path; `None` is reserved for syntactically valid but
// unsatisfiable ranges (→ 416).
let full = Some((0, total.saturating_sub(1), false));
let Some((start_s, end_s)) = spec.split_once('-') else {
return full;
};
let Ok(start) = start_s.trim().parse::<u64>() else {
return full;
};
let end = if end_s.trim().is_empty() {
total.saturating_sub(1)
} else if let Ok(e) = end_s.trim().parse::<u64>() {
e
} else {
return full;
};
if start >= total {
return None;
}
@@ -1323,17 +1497,54 @@ struct UnattendedServeQuery {
hostname: Option<String>,
#[serde(default)]
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
/// `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(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
headers: HeaderMap,
Query(q): Query<UnattendedServeQuery>,
) -> Response {
if !unattended_access_allowed(&state, &headers, q.t.as_deref(), &id) {
return unattended_denied();
}
let Ok(bytes) = state.unattended.read(&id).await else {
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
};
@@ -1355,8 +1566,15 @@ async fn serve_unattended(
async fn serve_unattended_seed(
State(state): State<AppState>,
AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>,
headers: HeaderMap,
) -> 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() {
"user-data" => {
let Ok(bytes) = state.unattended.read(&id).await else {
@@ -1380,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
/// a queued device's Profile. `None` when neither carries one.
fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> {
@@ -1399,12 +1633,23 @@ fn build_unattended_args(
meta: &UnattendedMeta,
mac: Option<&str>,
profile: &DeployProfile,
tokens: &openpxe_core::BootTokens,
) -> Option<String> {
let base = base.trim_end_matches('/');
let id = &meta.id;
let host = profile.auto_hostname.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 {
UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")),
UnattendedKind::Preseed => {
@@ -1416,7 +1661,7 @@ fn build_unattended_args(
Some(s)
}
UnattendedKind::Autoinstall => {
let ctx = encode_seed_ctx(mac, host, ip);
let ctx = encode_seed_ctx(mac, host, ip, &token);
Some(format!(
"autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/"
))
@@ -1441,12 +1686,19 @@ fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
/// the cloud-init seed directory. Empty values become empty fields.
fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String {
/// Encode `(hostname, ip, mac, token)` into a single base64url path
/// segment for the cloud-init seed directory. Empty values become empty
/// 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 _;
let raw = format!(
"{}\n{}\n{}",
"{}\n{}\n{}\n{token}",
hostname.unwrap_or(""),
ip.unwrap_or(""),
mac.unwrap_or("")
@@ -1454,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())
}
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad
/// or empty segment yields all-`None` so the seed still serves (just
/// without per-host substitution).
fn decode_seed_ctx(ctx: &str) -> (Option<String>, Option<String>, Option<String>) {
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip, token)`.
/// A bad or empty segment yields all-`None`; a pre-v0.7.0 three-field
/// ctx decodes with `token: None` (and the gate then rejects it once an
/// 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 _;
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 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 hostname = clean(it.next());
let ip = clean(it.next());
let mac = clean(it.next());
(mac, hostname, ip)
let token = clean(it.next());
(mac, hostname, ip, token)
}
// ─── API reference (Settings → bottom) ────────────────────────────────────
@@ -1503,13 +1764,13 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List ISOs (local + NFS) with size, family, boot entries, category."},
{"method": "POST", "path": "/api/isos",
"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."},
{"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)."},
{"method": "DELETE", "path": "/api/isos/:id/password",
{"method": "DELETE", "path": "/api/isos/{id}/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\" }."},
],
},
@@ -1518,9 +1779,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"endpoints": [
{"method": "POST", "path": "/api/uploads",
"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."},
{"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."},
],
},
@@ -1531,9 +1792,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured SMB shares with connection state and iso counts."},
{"method": "POST", "path": "/api/smb-shares",
"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."},
{"method": "POST", "path": "/api/smb-shares/:id/scan",
{"method": "POST", "path": "/api/smb-shares/{id}/scan",
"summary": "Re-list a share for new ISOs."},
],
},
@@ -1544,9 +1805,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured NFSv3 shares with connection state and iso counts."},
{"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."},
{"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."},
{"method": "POST", "path": "/api/nfs-shares/:id/scan",
{"method": "POST", "path": "/api/nfs-shares/{id}/scan",
"summary": "Re-list a share for new ISOs."},
],
},
@@ -1557,9 +1818,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured SFTP-over-SSH shares with connection state and iso counts."},
{"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."},
{"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."},
{"method": "POST", "path": "/api/sftp-shares/:id/scan",
{"method": "POST", "path": "/api/sftp-shares/{id}/scan",
"summary": "Re-list a share for new ISOs."},
],
},
@@ -1579,9 +1840,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
{"method": "PUT", "path": "/api/settings",
"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."},
{"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."},
{"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."},
@@ -1622,9 +1883,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."},
{"method": "POST", "path": "/api/unattended",
"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."},
{"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."},
],
},
@@ -1635,9 +1896,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List queue entries (waiting + assigned, with any deployment profile)."},
{"method": "POST", "path": "/api/queue/assign",
"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? }."},
{"method": "DELETE", "path": "/api/queue/:entry_id",
{"method": "DELETE", "path": "/api/queue/{entry_id}",
"summary": "Release a queue entry without assigning."},
],
},
@@ -1648,9 +1909,13 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List per-MAC boot bindings."},
{"method": "POST", "path": "/api/hosts",
"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."},
{"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)."},
{"method": "GET", "path": "/api/boot-log",
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
@@ -2883,11 +3148,13 @@ mod tests {
auto_ip: Some("10.0.0.7".into()),
unattended_file: Some("ks1".into()),
};
let tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args(
"http://h",
&meta(UnattendedKind::Kickstart),
Some("aa:bb:cc:dd:ee:ff"),
&p,
&tokens,
)
.unwrap();
assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}");
@@ -2895,6 +3162,9 @@ mod tests {
assert!(a.contains("ip=10.0.0.7"), "{a}");
// MAC colons are percent-encoded.
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]
@@ -2903,8 +3173,15 @@ mod tests {
auto_hostname: Some("deb1".into()),
..Default::default()
};
let a =
build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap();
let tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args(
"http://h/",
&meta(UnattendedKind::Preseed),
None,
&p,
&tokens,
)
.unwrap();
assert!(
a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"),
"{a}"
@@ -2919,11 +3196,13 @@ mod tests {
auto_ip: Some("10.1.1.5".into()),
unattended_file: Some("ks1".into()),
};
let tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args(
"http://h",
&meta(UnattendedKind::Autoinstall),
Some("aa:bb"),
&p,
&tokens,
)
.unwrap();
assert!(
@@ -2933,10 +3212,12 @@ mod tests {
assert!(a.ends_with('/'), "seed URL must end with '/': {a}");
// The ctx segment round-trips back to the per-host values.
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!(host.as_deref(), Some("u1"));
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]
@@ -2945,20 +3226,26 @@ mod tests {
unattended_file: Some("ks1".into()),
..Default::default()
};
assert!(
build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p)
.is_none()
);
let tokens = openpxe_core::BootTokens::new();
assert!(build_unattended_args(
"http://h",
&meta(UnattendedKind::AnswerFile),
None,
&p,
&tokens
)
.is_none());
}
#[test]
fn seed_ctx_empty_segment_decodes_to_none() {
let ctx = encode_seed_ctx(None, None, None);
let (m, h, i) = decode_seed_ctx(&ctx);
let ctx = encode_seed_ctx(None, None, None, "tok");
let (m, h, i, t) = decode_seed_ctx(&ctx);
assert!(m.is_none() && h.is_none() && i.is_none());
assert_eq!(t.as_deref(), Some("tok"));
// Garbage decodes safely to all-None.
let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!");
assert!(m2.is_none() && h2.is_none() && i2.is_none());
let (m2, h2, i2, t2) = decode_seed_ctx("!!!not-base64!!!");
assert!(m2.is_none() && h2.is_none() && i2.is_none() && t2.is_none());
}
#[test]
+37 -6
View File
@@ -154,16 +154,28 @@ pub fn session_cookie(session: &str) -> String {
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
// Two-step strip (name, then '=') keeps this allocation-free per
// candidate and can't match a longer cookie name sharing the prefix.
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
let part = part.trim();
if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) {
if let Some(v) = part
.strip_prefix(SESSION_COOKIE)
.and_then(|rest| rest.strip_prefix('='))
{
return Some(v.to_string());
}
}
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 ────────────────────────────────────────────────────────────
/// Return `true` if `path` is on the allowlist and should bypass the
@@ -246,7 +258,14 @@ pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody
)
.into_response();
}
match state.admin.bootstrap(&body.username, &body.password) {
// bcrypt hashing is ~100-200 ms of pure CPU (and `bootstrap` also
// persists to disk synchronously) — keep it off the async workers.
let admin = state.admin.clone();
let result =
tokio::task::spawn_blocking(move || admin.bootstrap(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
match result {
Ok(pub_) => {
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::CREATED, &pub_, &session)
@@ -271,8 +290,13 @@ pub struct LoginBody {
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
// Brief, deliberately vague — "invalid credentials" rather than
// "no such user" / "wrong password". Same anti-enumeration posture
// as Sonarr/Radarr.
let pub_ = match state.admin.verify(&body.username, &body.password) {
// as Sonarr/Radarr. The bcrypt verify is ~100-200 ms of pure CPU on
// an unauthenticated endpoint, so it runs on the blocking pool.
let admin = state.admin.clone();
let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
let pub_ = match verdict {
Ok(Some(u)) => u,
Ok(None) => {
return (
@@ -394,11 +418,18 @@ pub async fn api_update_credentials(
)
.into_response();
}
let result = state.admin.update_credentials(
// Two bcrypt operations (verify current + hash new) plus a sync disk
// persist — run the lot on the blocking pool.
let admin = state.admin.clone();
let result = tokio::task::spawn_blocking(move || {
admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
);
)
})
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
match result {
Ok(pub_) => {
state.sessions.revoke_all();
+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 auth;
pub mod error;
pub mod grub_script;
pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream;
+5 -1
View File
@@ -105,7 +105,11 @@ async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(),
.trim()
.parse()
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
.subject(if subject.is_empty() { "OpenPXE" } else { subject })
.subject(if subject.is_empty() {
"OpenPXE"
} else {
subject
})
.body(body.to_string())
.map_err(|e| format!("could not build email: {e}"))?;
+20 -2
View File
@@ -2,8 +2,8 @@ use crate::auth::SessionStore;
use crate::saml_routes::SamlRuntime;
use crate::uploads::UploadSessions;
use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, NotifyStore, SettingsStore, SsoStore,
AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore,
};
use openpxe_iso_store::{
IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore,
@@ -11,6 +11,10 @@ use openpxe_iso_store::{
use std::sync::Arc;
use time::OffsetDateTime;
/// Cached composited PXE boot-menu background: `(logo_rev, encoded PNG)`.
/// See `AppState::pxe_bg_cache`.
pub type PxeBgCache = Arc<parking_lot::Mutex<Option<(u64, bytes::Bytes)>>>;
#[derive(Clone)]
pub struct AppState {
pub iso_store: IsoStore,
@@ -25,10 +29,24 @@ pub struct AppState {
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
/// (i.e. an image actually starting to install on a machine).
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 hasn't uploaded anything, the WebUI serves the bundled
/// rainbow-horizon mark.
pub branding: BrandingStore,
/// v0.6.2: cache of the composited PXE boot-menu background PNG,
/// keyed on the branding logo revision. Composing costs ~50-200 ms
/// of image decode/encode and **every** booting client fetches it
/// for `console --picture` — caching makes that one compose per
/// logo change instead of one per boot.
pub pxe_bg_cache: PxeBgCache,
/// Forms-auth admin record + first-run bootstrap state. When
/// `admin.is_configured() == false`, the auth middleware passes
/// every request through and `/api/me` reports `setup_required`.
+165 -2
View File
@@ -115,7 +115,10 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
settings,
hosts,
boot_log,
boot_rules: openpxe_core::BootRulesStore::load_or_default(dir.path()),
boot_tokens: openpxe_core::BootTokens::new(),
branding,
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin,
sessions,
sso,
@@ -1488,9 +1491,10 @@ async fn api_docs_lists_known_endpoints() {
}
for needle in [
"/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/branding/logo/:slot",
"/api/branding/logo/{slot}",
"/api/unattended",
"/api/boot-log",
"/metrics",
@@ -2608,3 +2612,162 @@ async fn acs_garbage_is_rejected_without_500() {
assert!(location(&resp).contains("sso_error"));
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)
);
}
+13 -19
View File
@@ -35,23 +35,11 @@ use rust_embed::Embed;
#[include = "wimboot"]
pub struct IpxeAssets;
/// Return the embedded iPXE binary for `arch`, or `None` if we didn't bundle
/// one for that architecture.
#[must_use]
pub fn bootfile_bytes(arch: ClientArch) -> Option<Vec<u8>> {
let name = arch.ipxe_bootfile()?;
IpxeAssets::get(name).map(|f| f.data.into_owned())
}
/// Return a named asset directly (e.g. `wimboot`, or a fallback `ipxe.efi`).
#[must_use]
pub fn asset_bytes(name: &str) -> Option<Vec<u8>> {
IpxeAssets::get(name).map(|f| f.data.into_owned())
}
/// Same as [`asset_bytes`] but returns the embedded slice directly,
/// avoiding the heap copy when the caller only needs to read the
/// payload. Falls back to None for unknown names.
/// Return a named embedded asset (e.g. `snponly.efi`, `wimboot`) as a
/// `Cow` over the embedded bytes. In release builds the data is borrowed
/// straight from the binary's rodata — **zero copy** — which matters
/// because the TFTP and HTTP serving paths hit this for every boot
/// (`ipxe.efi` is ~1 MiB). Debug builds read from disk and return Owned.
#[must_use]
pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
IpxeAssets::get(name).map(|f| f.data)
@@ -79,7 +67,7 @@ pub fn log_availability() {
ClientArch::Arm64Uefi,
];
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 {
continue;
};
@@ -94,12 +82,18 @@ pub fn log_availability() {
"MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
arch.as_str()
);
} else {
} else if mode == DriverMode::Builtin {
tracing::info!(
target: "openpxe::ipxe",
"no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch",
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()
);
}
}
}
+7 -1
View File
@@ -103,7 +103,13 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
let scan_bytes = 64 * 1024 * 1024;
let mut buf = vec![0u8; 1024 * 1024];
let mut read_total = 0usize;
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
// Size the haystack to what will actually be read — the scan cap or
// the file itself, whichever is smaller — so the fill never reallocs
// and a small ISO doesn't reserve the full 64 MiB.
let file_len = f.metadata().map_or(usize::MAX, |m| {
usize::try_from(m.len()).unwrap_or(usize::MAX)
});
let mut haystack = Vec::with_capacity(scan_bytes.min(file_len));
while read_total < scan_bytes {
let n = f.read(&mut buf).unwrap_or(0);
if n == 0 {
+17 -18
View File
@@ -63,8 +63,8 @@ use bytes::Bytes;
use nfs3_client::tokio::TokioConnector;
use nfs3_client::Nfs3ConnectionBuilder;
use nfs3_types::nfs3::{
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args,
Nfs3Result, READ3args, READDIR3args,
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args, Nfs3Result,
READ3args, READDIR3args,
};
use nfs3_types::rpc::{auth_unix, opaque_auth};
use nfs3_types::xdr_codec::Opaque;
@@ -220,10 +220,7 @@ impl NfsShareManager {
/// Register an NFS share. Validates, probes connectivity by
/// performing a real MOUNT3 + READDIR3, and registers the
/// resulting ISOs with the store.
pub async fn add(
&self,
req: NfsAddRequest,
) -> std::result::Result<NfsShare, NfsShareError> {
pub async fn add(&self, req: NfsAddRequest) -> std::result::Result<NfsShare, NfsShareError> {
let server = normalize_server(&req.server);
let export = req.export.trim().to_string();
if server.is_empty() {
@@ -258,7 +255,9 @@ impl NfsShareManager {
if let Err(e) = self.rescan_inner(&id).await {
let m = self.get(&id);
return Err(NfsShareError {
error: m.as_ref().and_then(|m| m.last_error.clone())
error: m
.as_ref()
.and_then(|m| m.last_error.clone())
.unwrap_or_else(|| e.to_string()),
stderr: String::new(),
hint: m.and_then(|m| m.last_hint),
@@ -333,8 +332,7 @@ impl NfsShareManager {
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
}
let (tx, rx) =
tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
let (tx, rx) = tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
let server = share.server.clone();
let export = share.export.clone();
let port = share.port;
@@ -358,16 +356,11 @@ impl NfsShareManager {
if let Err(e) = result {
// Best-effort signal of the error to the consumer.
// If the receiver has already dropped we just exit.
let _ = tx
.send(Err(std::io::Error::other(e.to_string())))
.await;
let _ = tx.send(Err(std::io::Error::other(e.to_string()))).await;
}
});
Ok(NfsStream {
rx,
_task: task,
})
Ok(NfsStream { rx, _task: task })
}
// ── internals ─────────────────────────────────────────────────────
@@ -982,8 +975,14 @@ mod tests {
// the allow-list and the secure/insecure angle.
let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap();
let lc = h.to_lowercase();
assert!(lc.contains("insecure") || lc.contains("privileged"), "got: {h}");
assert!(lc.contains("allow") || lc.contains("permission"), "got: {h}");
assert!(
lc.contains("insecure") || lc.contains("privileged"),
"got: {h}"
);
assert!(
lc.contains("allow") || lc.contains("permission"),
"got: {h}"
);
}
#[test]
+10 -23
View File
@@ -231,10 +231,7 @@ impl SmbShareManager {
/// Add or refresh a share. Validates the input, writes a creds
/// file, probes connectivity, and scans for ISOs.
pub async fn add(
&self,
req: SmbAddRequest,
) -> std::result::Result<SmbShare, SmbShareError> {
pub async fn add(&self, req: SmbAddRequest) -> std::result::Result<SmbShare, SmbShareError> {
let server = normalize_server(&req.server);
let share = req.share.trim().trim_start_matches('/').to_string();
if server.is_empty() {
@@ -309,7 +306,9 @@ impl SmbShareManager {
if let Err(e) = self.rescan_inner(&id).await {
let m = self.get(&id);
return Err(SmbShareError {
error: m.as_ref().and_then(|m| m.last_error.clone())
error: m
.as_ref()
.and_then(|m| m.last_error.clone())
.unwrap_or_else(|| e.to_string()),
stderr: String::new(),
hint: m.and_then(|m| m.last_hint),
@@ -365,11 +364,7 @@ impl SmbShareManager {
/// throttling concurrent smbclients) would need to await without
/// changing the call sites.
#[allow(clippy::unused_async)]
pub async fn stream_iso(
&self,
share_id: &str,
filename: &str,
) -> Result<SmbStream> {
pub async fn stream_iso(&self, share_id: &str, filename: &str) -> Result<SmbStream> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?;
@@ -377,9 +372,7 @@ impl SmbShareManager {
// share root. smbclient itself accepts only filenames at the
// share root in our `get` form, but belt-and-suspenders.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!(
"invalid filename '{filename}'"
)));
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
}
let creds = share
.creds_path
@@ -537,10 +530,7 @@ impl SmbShareManager {
} else {
String::new()
};
return Err((
format!("could not exec smbclient: {e}"),
stderr,
));
return Err((format!("could not exec smbclient: {e}"), stderr));
}
};
if !output.status.success() {
@@ -751,10 +741,7 @@ fn parse_ls_iso(out: &str) -> Vec<SmbListEntry> {
/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
/// probe so the UI banner reads consistently.
async fn tcp_probe(
server: &str,
port: u16,
) -> std::result::Result<(), (String, String)> {
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
use tokio::net::TcpStream;
let addr = format!("{server}:{port}");
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
@@ -931,8 +918,8 @@ mod tests {
// exec error in `error` plus an empty `stderr`. The
// SmbShareError constructor's hint_for fallback checks error
// too, so this pattern needs to translate as well.
let h2 = hint_for("could not exec smbclient: No such file or directory (os error 2)")
.unwrap();
let h2 =
hint_for("could not exec smbclient: No such file or directory (os error 2)").unwrap();
assert!(h2.contains("smbclient"));
}
+10 -1
View File
@@ -342,9 +342,18 @@ impl IsoStore {
/// SMB sources or when the file is missing.
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
let meta = self.get(id)?;
self.local_path(&meta)
}
/// Same resolution as [`Self::iso_path_for`], but for a meta the
/// caller already holds — skips the second registry lock + deep
/// clone, which matters on the per-range-request ISO serving path
/// (a sanboot install issues hundreds of those).
#[must_use]
pub fn local_path(&self, meta: &IsoMeta) -> Option<PathBuf> {
match &meta.source {
IsoSource::Local => {
let path = self.iso_path(id);
let path = self.iso_path(&meta.id);
if path.exists() {
Some(path)
} else {
+28
View File
@@ -184,6 +184,10 @@ async fn main() -> anyhow::Result<()> {
"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 {
iso_store: iso_store.clone(),
clients: clients.clone(),
@@ -191,7 +195,10 @@ async fn main() -> anyhow::Result<()> {
queue: queue.clone(),
hosts: hosts.clone(),
boot_log: boot_log.clone(),
boot_rules: boot_rules.clone(),
boot_tokens: openpxe_core::BootTokens::new(),
branding: branding.clone(),
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin: admin.clone(),
sessions: sessions.clone(),
sso: sso.clone(),
@@ -229,11 +236,28 @@ async fn main() -> anyhow::Result<()> {
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(
config.server.tftp_bind,
config.server.tftp_port,
clients.clone(),
metrics.clone(),
Some(tftp_dynamic),
);
let tftp_task = tokio::spawn(tftp.run());
@@ -247,6 +271,10 @@ async fn main() -> anyhow::Result<()> {
public_base_url.clone(),
clients.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())
}
+1 -1
View File
@@ -14,4 +14,4 @@
pub mod server;
pub use server::TftpServer;
pub use server::{DynamicAsset, TftpServer};
+53 -17
View File
@@ -7,12 +7,12 @@
//! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT:
//! the ephemeral ports must be reachable from the client.
//!
//! We only serve files from `openpxe_ipxe_assets::asset_bytes` — that is,
//! We only serve files from `openpxe_ipxe_assets::asset_slice` — that is,
//! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so
//! `../` path traversal attempts simply return ENOENT.
use openpxe_core::{ClientEvent, ClientRegistry};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_ipxe_assets::asset_slice;
use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
@@ -21,6 +21,7 @@ use tokio::net::UdpSocket;
// TFTP opcodes.
const OP_RRQ: u16 = 1;
const OP_WRQ: u16 = 2;
const OP_DATA: u16 = 3;
const OP_ACK: u16 = 4;
const OP_ERROR: u16 = 5;
@@ -31,11 +32,19 @@ const ERR_NOT_DEFINED: u16 = 0;
const ERR_FILE_NOT_FOUND: u16 = 1;
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 {
bind: IpAddr,
port: u16,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
}
impl TftpServer {
@@ -44,12 +53,14 @@ impl TftpServer {
port: u16,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
) -> Self {
Self {
bind,
port,
clients,
metrics,
dynamic,
}
}
@@ -71,8 +82,11 @@ impl TftpServer {
let clients = clients.clone();
let metrics = metrics.clone();
let bind_ip = self.bind;
let dynamic = self.dynamic.clone();
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();
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
}
@@ -87,18 +101,45 @@ async fn handle_rrq(
bind_ip: IpAddr,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
) -> anyhow::Result<()> {
let Some(req) = parse_rrq(&packet) else {
// Not a well-formed RRQ. A WRQ deserves an explicit refusal —
// legacy clients retry a silently-dropped write until they time
// out; an ERROR packet fails them fast with a readable reason.
if packet.len() >= 2 && u16::from_be_bytes([packet[0], packet[1]]) == OP_WRQ {
let sock = bind_udp(bind_ip, 0)?;
let _ = send_error(&sock, peer, ERR_ILLEGAL_OP, "writes not supported").await;
}
return Ok(());
};
let Request {
filename, options, ..
filename,
mode,
options,
} = req;
// Per-transfer ephemeral socket.
let sock = bind_udp(bind_ip, 0)?;
let Some(file_bytes) = asset_bytes(&filename) else {
// We serve binary boot artifacts; netascii line-ending translation
// would corrupt them. Refuse loudly instead of timing out silently —
// matters for legacy clients that default to netascii.
if !mode.eq_ignore_ascii_case("octet") {
let _ = send_error(&sock, peer, ERR_NOT_DEFINED, "only octet mode is supported").await;
tracing::info!(target: "openpxe::tftp", peer=%peer, %mode, "rejected non-octet transfer");
return Ok(());
}
// 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;
tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
clients.record(
@@ -262,7 +303,6 @@ async fn handle_rrq(
#[derive(Debug)]
struct Request {
filename: String,
#[allow(dead_code)]
mode: String,
options: Vec<(String, String)>,
}
@@ -393,17 +433,13 @@ fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
Ok(UdpSocket::from_std(std_sock)?)
}
#[allow(dead_code)]
const _UNUSED: (u16, u16) = (ERR_NOT_DEFINED, ERR_ILLEGAL_OP);
/// Pure-logic helper used by the unit tests below and (in a refactor) by
/// `handle_rrq`. Given a position in the file and the window, return the
/// (block_no, chunk_len) list this window will emit. Useful as a sanity
/// check that our windowing math matches the wire behavior the spec
/// requires — tested against edge cases (exact-blksize tail, short tail,
/// single-block window).
#[must_use]
pub fn plan_window(
/// Pure-logic mirror of `handle_rrq`'s windowing math, exercised by the
/// unit tests below. Given a position in the file and the window, return
/// the (block_no, chunk_len) list this window will emit — tested against
/// edge cases (exact-blksize tail, short tail, single-block window,
/// block-number wraparound).
#[cfg(test)]
fn plan_window(
total: usize,
offset: usize,
blksize: usize,
+110 -1
View File
@@ -199,6 +199,113 @@
})[k] || (k || 'Unknown');
}
// v0.7.0: the Boot rules card — ordered first-match-wins rules
// (MAC prefix / architecture → target) plus the optional
// boot-decision webhook. Saved as one config because rule order
// matters. With no rules and no webhook, behavior is identical to
// before the feature existed.
function bootRulesCard(cfg, targetOptions) {
const archChoices = [
['', 'any arch'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
];
// v0.7.1: optional first-boot binary pin. "Auto" lets the
// escalation ladder learn per machine; pinning skips the learning
// walk entirely (e.g. a rack known to run Secure Boot → shim).
const modeChoices = [
['', 'auto (learn)'], ['firmware', 'Firmware NIC'],
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
];
const rules = (cfg.rules || []).map(r => Object.assign({}, r));
const tbody = el('tbody', {});
const msg = el('div', {class:'msg'});
const webhookInput = el('input', {type:'text', spellcheck:'false',
placeholder:'http://automation.example/boot-decision (optional)',
value: cfg.webhook_url || ''});
const targetSelect = (val) => el('select', {},
[el('option', {value:''}, '— target —')]
.concat(targetOptions.map(t =>
el('option', Object.assign({value: t.id}, t.id === val ? {selected:''} : {}), t.title))));
const redraw = () => {
tbody.innerHTML = '';
if (!rules.length) {
tbody.appendChild(el('tr', {}, el('td', {colspan:'7', class:'empty', style:'padding:14px'},
'No rules. Add one to route whole groups of machines (an OUI, an architecture) to a target — or to pin a boot binary (e.g. Secure Boot racks → shim, zero failed cycles).')));
}
rules.forEach((r, i) => {
const macIn = el('input', {type:'text', spellcheck:'false', placeholder:'aa:bb:cc (prefix)',
value: r.mac_prefix || '', oninput: e => { r.mac_prefix = e.target.value; }});
const archSel = el('select', {onchange: e => { r.arch = e.target.value; }},
archChoices.map(([v, label]) =>
el('option', Object.assign({value: v}, v === (r.arch || '') ? {selected:''} : {}), label)));
const tgtSel = targetSelect(r.target || '');
tgtSel.onchange = e => { r.target = e.target.value; };
const modeSel = el('select', {onchange: e => { r.driver_mode = e.target.value; }},
modeChoices.map(([v, label]) =>
el('option', Object.assign({value: v}, v === (r.driver_mode || '') ? {selected:''} : {}), label)));
const noteIn = el('input', {type:'text', placeholder:'note',
value: r.note || '', oninput: e => { r.note = e.target.value; }});
const enabled = el('input', {type:'checkbox', onchange: e => { r.enabled = e.target.checked; }});
enabled.checked = r.enabled !== false;
tbody.appendChild(el('tr', {}, [
el('td', {}, macIn),
el('td', {}, archSel),
el('td', {}, tgtSel),
el('td', {}, modeSel),
el('td', {}, noteIn),
el('td', {style:'text-align:center'}, enabled),
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: () => { rules.splice(i, 1); redraw(); }}, '✕')),
]));
});
};
redraw();
const addBtn = el('button', {class:'ghost', onclick: () => {
rules.push({mac_prefix:'', arch:'', target:'', enabled:true, note:''});
redraw();
}}, '+ Add rule');
const saveBtn = el('button', {onclick: async () => {
// A rule needs at least one effect: a target or a boot-binary pin.
const bad = rules.find(r => r.enabled !== false && !r.target && !r.driver_mode);
if (bad) { msg.textContent = 'Every enabled rule needs a target or a boot-binary pin.'; msg.className = 'msg err'; return; }
const r = await putJSON('/api/boot-rules', {rules, webhook_url: webhookInput.value.trim()});
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; }
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
}}, 'Save rules');
return el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Boot rules'),
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
]),
el('div', {class:'body'}, [
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',{},'On'), el('th',{},''),
])),
tbody,
]),
el('div', {style:'margin-top:12px'}, [addBtn, saveBtn]),
el('label', {class:'field', style:'margin-top:16px;display:block'}, [
el('span', {class:'name'}, 'Boot-decision webhook (optional)'),
webhookInput,
el('span', {class:'hint'},
'When no pin or rule matches, OpenPXE GETs this URL with ?mac=…&arch=… ' +
'A 200 reply of {"target": "<entry-id>"} chains to that target; anything ' +
'else (404, timeout, error) falls through to the menu — a dead endpoint ' +
'can never block PXE.'),
]),
msg,
el('p', {class:'msg', style:'margin-top:10px'},
'Decision order per boot: exact MAC pin → first matching rule → webhook → interactive menu.'),
]),
]);
}
// v0.5.2: build the shared "deployment profile" field group — auto
// hostname, auto IP, and an unattended-file picker — reused by the
// Hosts pin form and the Queue "Profile" modal. `files` is the
@@ -1252,10 +1359,11 @@
},
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/boot-log').catch(() => ({ events: [] })),
getJSON('/api/unattended').catch(() => ({ files: [] })),
getJSON('/api/boot-rules').catch(() => ({ rules: [], webhook_url: '' })),
]);
const bootEvents = bootLogRes.events || [];
const unattendedFiles = unattRes.files || [];
@@ -1381,6 +1489,7 @@
]),
table,
]),
bootRulesCard(rulesCfg, reserved.concat(targets)),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Host log'),
+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
# binaries from the `ipxe-build` stage overlay on top of.
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/*
WORKDIR /src
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
# 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 ##########
# 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