Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6524aa4118 | ||
|
|
934cfbab46 | ||
|
|
a71057fce6 | ||
|
|
29040e8a5a | ||
|
|
3a32d65fb7 | ||
|
|
7f25bb681c | ||
|
|
5da05a519d | ||
|
|
4f193cac05 | ||
|
|
24879fcc90 | ||
|
|
5df0fd5972 | ||
|
|
06695c3d77 | ||
|
|
cb51b8db75 | ||
|
|
d6a9df85d7 | ||
|
|
9fc9a9a1af | ||
|
|
ac433b30e9 |
@@ -11,3 +11,6 @@ data/work/
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# local editor / agent settings (not part of the project)
|
||||
.claude/
|
||||
|
||||
Generated
+1146
-762
File diff suppressed because it is too large
Load Diff
+22
-23
@@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.6"
|
||||
version = "0.7.4"
|
||||
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" }
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="release" src="https://img.shields.io/badge/release-v0.5.5-2874d7" />
|
||||
<img alt="release" src="https://img.shields.io/badge/release-v0.6.0-2874d7" />
|
||||
<img alt="license" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-59824f" />
|
||||
<img alt="rust" src="https://img.shields.io/badge/built%20with-Rust-fb8841?logo=rust&logoColor=white" />
|
||||
<img alt="container" src="https://img.shields.io/badge/container--native-OCI%20%C2%B7%20OpenShift-2496ED?logo=docker&logoColor=white" />
|
||||
@@ -32,7 +32,7 @@ Upload `.iso` files (or point at a remote share), and any machine on the network
|
||||
them — Linux installers, live tools, or stock Windows setup — with **zero iPXE knowledge
|
||||
required by the operator.**
|
||||
|
||||
> **Status — v0.5.5, late pre-beta.** The full PXE stack, web UI, remote ISO libraries
|
||||
> **Status — v0.6.0, late pre-beta.** The full PXE stack, web UI, remote ISO libraries
|
||||
> (SMB/NFS/SFTP), Windows deployment, queued fleet rollout, SAML SSO, and Prometheus
|
||||
> metrics are implemented and test-covered. The release checklist gates every tag on the
|
||||
> full test suite + `clippy`. Currently in real-hardware validation.
|
||||
|
||||
+189
-10
@@ -23,6 +23,36 @@ pub enum ClientArch {
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
/// 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 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 {
|
||||
/// Reuse the firmware UNDI/SNP NIC stack (`snponly.efi`, `undionly.kpxe`).
|
||||
/// Default, smallest, most reliable for chainloading.
|
||||
#[default]
|
||||
Firmware,
|
||||
/// 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 {
|
||||
#[must_use]
|
||||
pub fn from_option_93(value: u16) -> Self {
|
||||
@@ -39,21 +69,73 @@ impl ClientArch {
|
||||
/// Default iPXE binary filename to return via TFTP for this architecture.
|
||||
/// Uses `snponly` variants which reuse the firmware's UNDI/SNP network
|
||||
/// stack — smaller binaries and broader hardware compatibility than the
|
||||
/// all-drivers-included `ipxe.efi`.
|
||||
/// all-drivers-included `ipxe.efi`. Equivalent to
|
||||
/// [`Self::ipxe_bootfile_mode`] with [`DriverMode::Firmware`]; kept as a
|
||||
/// convenience for the common firmware-net path.
|
||||
#[must_use]
|
||||
pub fn ipxe_bootfile(self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
Self::LegacyX86 => "undionly.kpxe",
|
||||
Self::Ia32Uefi => "snponly-i386.efi",
|
||||
Self::X64Uefi => "snponly.efi",
|
||||
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
|
||||
// snponly variant for this arch. We return None so the DHCP
|
||||
// proxy declines rather than advertising a file we can't serve.
|
||||
Self::Arm32Uefi | Self::Unknown(_) => return None,
|
||||
Self::Arm64Uefi => "snponly-arm64.efi",
|
||||
self.ipxe_bootfile_mode(DriverMode::Firmware)
|
||||
}
|
||||
|
||||
/// iPXE binary filename for this architecture under a given network
|
||||
/// [`DriverMode`].
|
||||
///
|
||||
/// * [`DriverMode::Firmware`] — the `snponly`/`undionly` builds that reuse
|
||||
/// the firmware's UNDI/SNP NIC stack. Smallest, and the most reliable
|
||||
/// choice for chainloading because the firmware just proved its network
|
||||
/// works by downloading the NBP. This is the default first attempt.
|
||||
/// * [`DriverMode::Builtin`] — the all-drivers `ipxe.efi`/`ipxe.pxe`
|
||||
/// builds that carry iPXE's *own* NIC drivers. The automatic fallback
|
||||
/// for clients whose firmware NIC stack is missing or buggy (v0.6.1):
|
||||
/// the DHCP proxy escalates a MAC to this mode when a firmware-net boot
|
||||
/// never completes the iPXE handoff. iPXE still includes the `snp`
|
||||
/// driver here too, so it degrades gracefully.
|
||||
#[must_use]
|
||||
pub fn ipxe_bootfile_mode(self, mode: DriverMode) -> Option<&'static str> {
|
||||
Some(match (self, mode) {
|
||||
// Legacy x86 BIOS: UNDI (firmware) vs full native-driver build.
|
||||
(Self::LegacyX86, DriverMode::Firmware) => "undionly.kpxe",
|
||||
(Self::LegacyX86, DriverMode::Builtin) => "ipxe.pxe",
|
||||
// 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 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 {
|
||||
@@ -133,6 +215,103 @@ mod tests {
|
||||
assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootfile_default_is_firmware_mode() {
|
||||
// The convenience method must equal the explicit Firmware mode.
|
||||
for a in [
|
||||
ClientArch::LegacyX86,
|
||||
ClientArch::Ia32Uefi,
|
||||
ClientArch::X64Uefi,
|
||||
ClientArch::Arm64Uefi,
|
||||
ClientArch::Arm32Uefi,
|
||||
ClientArch::Unknown(0x99),
|
||||
] {
|
||||
assert_eq!(
|
||||
a.ipxe_bootfile(),
|
||||
a.ipxe_bootfile_mode(DriverMode::Firmware)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_mode_maps_to_all_drivers_binaries() {
|
||||
assert_eq!(
|
||||
ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
Some("ipxe.pxe")
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
Some("ipxe.efi")
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
Some("ipxe-i386.efi")
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
Some("ipxe-arm64.efi")
|
||||
);
|
||||
// No binary for ARM32 / unknown in either mode.
|
||||
assert_eq!(
|
||||
ClientArch::Arm32Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::Unknown(0x99).ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driver_mode_default_is_firmware() {
|
||||
assert_eq!(DriverMode::default(), DriverMode::Firmware);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shim_mode_maps_to_signed_chain_on_uefi_only() {
|
||||
assert_eq!(
|
||||
ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Shim),
|
||||
Some("shimx64.efi")
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Shim),
|
||||
Some("shimaa64.efi")
|
||||
);
|
||||
// No Secure Boot on BIOS, no published 32-bit shim.
|
||||
assert_eq!(
|
||||
ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Shim),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Shim),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_walks_down_the_ladder() {
|
||||
// BIOS escalated to Shim → falls back to the all-drivers build.
|
||||
assert_eq!(
|
||||
ClientArch::LegacyX86.bootfile_with_fallback(DriverMode::Shim),
|
||||
Some("ipxe.pxe")
|
||||
);
|
||||
// UEFI x64 at Shim gets the real shim.
|
||||
assert_eq!(
|
||||
ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Shim),
|
||||
Some("shimx64.efi")
|
||||
);
|
||||
// Plain modes are unchanged.
|
||||
assert_eq!(
|
||||
ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Firmware),
|
||||
Some("snponly.efi")
|
||||
);
|
||||
// Arches with nothing stay None.
|
||||
assert_eq!(
|
||||
ClientArch::Arm32Uefi.bootfile_with_fallback(DriverMode::Shim),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_class_detects_ipxe_over_pxeclient() {
|
||||
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -346,7 +344,7 @@ mod tests {
|
||||
assert!(s.bootstrap("", "hunter2hunter2").is_err());
|
||||
assert!(s.bootstrap("ad:min", "hunter2hunter2").is_err()); // ':' reserved
|
||||
assert!(s.bootstrap("admin", "short").is_err()); // <8 chars
|
||||
// 65-char username is too long.
|
||||
// 65-char username is too long.
|
||||
let long = "a".repeat(65);
|
||||
assert!(s.bootstrap(&long, "hunter2hunter2").is_err());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -21,9 +23,11 @@ pub mod settings;
|
||||
pub mod sso;
|
||||
pub mod wol;
|
||||
|
||||
pub use arch::{ClientArch, FirmwareClass};
|
||||
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};
|
||||
@@ -36,4 +40,4 @@ pub use profile::DeployProfile;
|
||||
pub use queue::{DeploymentQueue, QueueEntry};
|
||||
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
|
||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||
pub use sso::{SsoConfig, SsoStore};
|
||||
pub use sso::{SsoConfig, SsoLoginInfo, SsoStore};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -73,6 +73,23 @@ impl SsoConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimal, non-sensitive slice of the SSO config that the **pre-auth**
|
||||
/// login screen needs to render the "Sign in with …" button. Carries only
|
||||
/// the display affordances — never the metadata XML/URL or entity ID, which
|
||||
/// stay behind the auth-gated `/api/sso`. Served as part of the public
|
||||
/// `/api/me` so the button renders reliably whether or not anyone is signed
|
||||
/// in (v0.5.9: fixes the button vanishing because `/api/sso` 401s pre-auth).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SsoLoginInfo {
|
||||
/// True only when SSO is *usable* (enabled AND a metadata source is
|
||||
/// present) — i.e. clicking the button will actually reach an IdP.
|
||||
pub enabled: bool,
|
||||
/// Button label, e.g. "STC AD". Empty falls back to "SSO" in the UI.
|
||||
pub idp_name: String,
|
||||
/// Optional IdP logo rendered on the button. Empty = no image.
|
||||
pub idp_logo_url: String,
|
||||
}
|
||||
|
||||
/// In-memory + on-disk SSO settings registry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SsoStore {
|
||||
@@ -111,6 +128,19 @@ impl SsoStore {
|
||||
self.inner.read().clone()
|
||||
}
|
||||
|
||||
/// Public, non-sensitive descriptor for the login screen. Safe to
|
||||
/// expose pre-auth — it's exactly what the "Sign in with …" button
|
||||
/// keys off, with no metadata/entity-ID leakage. v0.5.9.
|
||||
#[must_use]
|
||||
pub fn login_info(&self) -> SsoLoginInfo {
|
||||
let cfg = self.inner.read();
|
||||
SsoLoginInfo {
|
||||
enabled: cfg.is_usable(),
|
||||
idp_name: cfg.idp_name.clone(),
|
||||
idp_logo_url: cfg.idp_logo_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the whole config in one shot. Light validation: metadata
|
||||
/// XML and URL are length-capped so an operator can't OOM us by
|
||||
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -18,3 +18,9 @@ tracing.workspace = true
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
//! 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. Two
|
||||
//! classes of machine can't run it:
|
||||
//!
|
||||
//! * 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
|
||||
/// retransmits, plus the :4011 PXE Boot Server query that follows the :67
|
||||
/// DISCOVER). They must not be mistaken for a failed-and-retried boot.
|
||||
const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8);
|
||||
|
||||
/// 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 (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,
|
||||
/// True once we've served `mode` and are waiting for the iPXE handoff to
|
||||
/// 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<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
|
||||
/// escalation, and only when it's clearly a *new* boot (outside the
|
||||
/// same-boot debounce). The :4011 path just echoes the current mode.
|
||||
pub fn mode_for_firmware_attempt(&self, mac: &str, primary: bool) -> DriverMode {
|
||||
self.decide_at(mac, primary, Instant::now())
|
||||
}
|
||||
|
||||
/// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the
|
||||
/// `iPXE` user-class). The mode we last served worked, so stop awaiting
|
||||
/// 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();
|
||||
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);
|
||||
}
|
||||
|
||||
let mut newly_pinned = false;
|
||||
let mode = match g.map.get_mut(mac) {
|
||||
None => {
|
||||
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.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 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 = 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();
|
||||
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>) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn firmware_first_then_escalates_on_unconfirmed_retry() {
|
||||
let e = DriverEscalation::new();
|
||||
let t0 = Instant::now();
|
||||
// Boot 1, primary DISCOVER: firmware.
|
||||
assert_eq!(e.decide_at("aa", true, t0), DriverMode::Firmware);
|
||||
// Same boot's :4011 query (+1s, within debounce): still firmware, no escalation.
|
||||
assert_eq!(
|
||||
e.decide_at("aa", false, t0 + Duration::from_secs(1)),
|
||||
DriverMode::Firmware
|
||||
);
|
||||
// Firmware net failed → no iPXE handoff → machine re-PXE-boots much
|
||||
// later: escalate to builtin drivers.
|
||||
assert_eq!(
|
||||
e.decide_at("aa", true, t0 + Duration::from_mins(1)),
|
||||
DriverMode::Builtin
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_is_sticky_after_success() {
|
||||
let e = DriverEscalation::new();
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(e.decide_at("bb", true, t0), DriverMode::Firmware);
|
||||
assert_eq!(
|
||||
e.decide_at("bb", true, t0 + Duration::from_mins(1)),
|
||||
DriverMode::Builtin
|
||||
);
|
||||
// Builtin worked this time — confirm the handoff.
|
||||
e.confirm_at("bb", t0 + Duration::from_secs(61));
|
||||
// Next cold boot goes straight to builtin (no wasted firmware attempt).
|
||||
assert_eq!(
|
||||
e.decide_at("bb", true, t0 + Duration::from_mins(2)),
|
||||
DriverMode::Builtin
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_firmware_never_escalates() {
|
||||
let e = DriverEscalation::new();
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(e.decide_at("cc", true, t0), DriverMode::Firmware);
|
||||
// snponly worked: handoff confirmed.
|
||||
e.confirm_at("cc", t0 + Duration::from_secs(2));
|
||||
// A later boot stays on firmware — no spurious escalation.
|
||||
assert_eq!(
|
||||
e.decide_at("cc", true, t0 + Duration::from_mins(5)),
|
||||
DriverMode::Firmware
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
assert_eq!(
|
||||
e.decide_at("dd", true, t0 + Duration::from_mins(1)),
|
||||
DriverMode::Builtin
|
||||
);
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@
|
||||
//! clients silently drop them.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod escalation;
|
||||
pub mod reply;
|
||||
pub mod server;
|
||||
|
||||
pub use escalation::DriverEscalation;
|
||||
pub use server::DhcpProxyServer;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! pass, or the HTTP URL of the boot script once iPXE has chained.
|
||||
|
||||
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
|
||||
use openpxe_core::{ClientArch, FirmwareClass};
|
||||
use openpxe_core::{ClientArch, DriverMode, FirmwareClass};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Where the reply directs the client next.
|
||||
@@ -31,6 +31,11 @@ pub struct ReplyContext<'a> {
|
||||
pub our_ip: Ipv4Addr,
|
||||
pub arch: ClientArch,
|
||||
pub class: FirmwareClass,
|
||||
/// Which iPXE network backend to advertise for this client. The DHCP
|
||||
/// proxy fills this from the automatic per-MAC escalation state: normally
|
||||
/// [`DriverMode::Firmware`], escalated to [`DriverMode::Builtin`] for a
|
||||
/// MAC whose firmware-net boot failed to chainload (v0.6.1).
|
||||
pub driver_mode: DriverMode,
|
||||
/// Public base URL (scheme://host[:port]) used in HTTP directives.
|
||||
pub public_base_url: &'a str,
|
||||
}
|
||||
@@ -44,17 +49,27 @@ 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 => {
|
||||
// UEFI HTTP boot: client wants an http:// URL in option 67
|
||||
// pointing at an EFI executable. We serve ipxe.efi over HTTP;
|
||||
// it'll then do the same script-fetch the iPXE path does.
|
||||
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
|
||||
// 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
|
||||
.bootfile_with_fallback(ctx.driver_mode)
|
||||
.unwrap_or("snponly.efi");
|
||||
BootDirective::HttpScript {
|
||||
url: format!(
|
||||
"{}/ipxe/{}",
|
||||
@@ -63,7 +78,7 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
|
||||
),
|
||||
}
|
||||
}
|
||||
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() {
|
||||
FirmwareClass::PxeClient => match ctx.arch.bootfile_with_fallback(ctx.driver_mode) {
|
||||
Some(name) => BootDirective::TftpIpxe {
|
||||
filename: name.to_string(),
|
||||
},
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! UDP listener loop for the DHCP proxy. Accepts on :67 (and :4011 on a
|
||||
//! second socket) and dispatches each datagram through the pure reply logic.
|
||||
|
||||
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, 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;
|
||||
@@ -18,9 +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; 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,
|
||||
@@ -29,6 +42,8 @@ impl DhcpProxyServer {
|
||||
public_base_url: String,
|
||||
clients: Arc<ClientRegistry>,
|
||||
metrics: openpxe_core::Metrics,
|
||||
escalation: DriverEscalation,
|
||||
rules: BootRulesStore,
|
||||
) -> Self {
|
||||
Self {
|
||||
bind,
|
||||
@@ -38,6 +53,8 @@ impl DhcpProxyServer {
|
||||
public_base_url,
|
||||
clients,
|
||||
metrics,
|
||||
escalation,
|
||||
rules,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,11 +143,38 @@ impl DhcpProxyServer {
|
||||
},
|
||||
);
|
||||
|
||||
// Automatic NIC driver-mode selection (v0.6.1). The default is
|
||||
// firmware-net (snponly/undionly). A successful iPXE handoff confirms
|
||||
// the current mode works for this MAC; a fresh firmware boot whose
|
||||
// predecessor never handed off escalates the MAC to iPXE's built-in
|
||||
// NIC drivers. No operator toggle — the firmware path is unchanged so
|
||||
// hardware that already boots never regresses.
|
||||
let driver_mode = match class {
|
||||
FirmwareClass::IpxeUserClass => {
|
||||
self.escalation.mark_ipxe_success(&mac);
|
||||
DriverMode::Firmware // unused: this path serves the HTTP script
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
let ctx = ReplyContext {
|
||||
request: &request,
|
||||
our_ip: self.our_ip,
|
||||
arch,
|
||||
class,
|
||||
driver_mode,
|
||||
public_base_url: &self.public_base_url,
|
||||
};
|
||||
let directive = decide(&ctx);
|
||||
@@ -154,7 +198,7 @@ impl DhcpProxyServer {
|
||||
sock.send_to(&out, dest).await?;
|
||||
tracing::info!(
|
||||
target: "openpxe::dhcp",
|
||||
mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive,
|
||||
mac=%mac, arch=arch.as_str(), class=?class, driver=?driver_mode, dest=%dest, directive=?directive,
|
||||
"PXE reply sent"
|
||||
);
|
||||
Ok(())
|
||||
@@ -213,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)
|
||||
|
||||
@@ -49,6 +49,10 @@ base64.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
|
||||
tower = { workspace = true }
|
||||
tempfile = "3.12"
|
||||
# v0.7.4: probe-based introspection verifies kernel paths against the
|
||||
# real ISO9660 tree, so the full-flow tests synthesize images with the
|
||||
# shared test builder instead of label-only blobs.
|
||||
openpxe-iso-store = { workspace = true, features = ["test-image"] }
|
||||
serde_json = { workspace = true }
|
||||
time = { workspace = true }
|
||||
# v0.4.61: integration tests need to generate real PNG bytes for the
|
||||
|
||||
+477
-112
@@ -19,7 +19,6 @@ use crate::ipxe_script::{
|
||||
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
|
||||
render_queue_entry, render_shell, render_tools_menu, render_util,
|
||||
};
|
||||
use crate::iso_fs;
|
||||
use crate::log_stream;
|
||||
use crate::state::AppState;
|
||||
use crate::terminal;
|
||||
@@ -35,7 +34,8 @@ 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::iso_fs;
|
||||
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 {
|
||||
@@ -923,28 +1087,105 @@ async fn iso_file(
|
||||
State(state): State<AppState>,
|
||||
AxumPath((id, path)): AxumPath<(String, String)>,
|
||||
) -> Response {
|
||||
// In-ISO file extraction is only supported for local ISOs — it
|
||||
// needs random-access reads into the ISO9660 directory tree, which
|
||||
// smbclient's whole-file streaming can't do efficiently. SMB-
|
||||
// sourced ISOs use the raw streaming endpoint above instead.
|
||||
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
|
||||
let Some(meta) = state.iso_store.get(&id) else {
|
||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||
};
|
||||
let p = iso_path.clone();
|
||||
let in_path = format!("/{path}");
|
||||
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(loc) = loc else {
|
||||
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
|
||||
};
|
||||
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
match &meta.source {
|
||||
IsoSource::Local => {
|
||||
let Some(iso_path) = state.iso_store.local_path(&meta) else {
|
||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||
};
|
||||
let p = iso_path.clone();
|
||||
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup_local(&p, &in_path))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(loc) = loc else {
|
||||
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
|
||||
};
|
||||
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
// v0.7.4: remote ISOs serve in-ISO files too — the same ISO9660
|
||||
// walk runs over NFS READ3 / SFTP seek-reads, then the located
|
||||
// byte range streams through the share manager. This is what
|
||||
// makes the verified kernel/initrd boot entries on share-hosted
|
||||
// Linux ISOs actually bootable.
|
||||
IsoSource::Nfs {
|
||||
share_id,
|
||||
relative_path,
|
||||
} => {
|
||||
match state
|
||||
.nfs_shares
|
||||
.locate_in_iso(share_id, relative_path, &in_path)
|
||||
.await
|
||||
{
|
||||
Ok(Some(loc)) => {
|
||||
match state
|
||||
.nfs_shares
|
||||
.stream_iso(share_id, relative_path, loc.offset, Some(loc.length))
|
||||
.await
|
||||
{
|
||||
Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length),
|
||||
Err(e) => {
|
||||
(StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(),
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs lookup: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
IsoSource::Sftp {
|
||||
share_id,
|
||||
relative_path,
|
||||
} => {
|
||||
match state
|
||||
.sftp_shares
|
||||
.locate_in_iso(share_id, relative_path, &in_path)
|
||||
.await
|
||||
{
|
||||
Ok(Some(loc)) => {
|
||||
match state
|
||||
.sftp_shares
|
||||
.stream_iso(share_id, relative_path, loc.offset, Some(loc.length))
|
||||
.await
|
||||
{
|
||||
Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length),
|
||||
Err(e) => {
|
||||
(StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(),
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp lookup: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
// smbclient streams sequentially — no seeks, no ISO9660 walk.
|
||||
// SMB ISOs never emit kernel entries, so nothing requests this.
|
||||
IsoSource::Smb { .. } => (
|
||||
StatusCode::NOT_FOUND,
|
||||
"in-ISO files are not available for SMB-sourced ISOs",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 200 response wrapping an in-ISO byte-range stream from a share
|
||||
/// manager. Content-Length is the located file's length — the stream is
|
||||
/// already bounded to exactly that range.
|
||||
fn in_iso_stream_response(body: Body, length: u64) -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::CONTENT_LENGTH, length)
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn stream_file_range(
|
||||
path: &std::path::Path,
|
||||
range: Option<&HeaderValue>,
|
||||
@@ -1027,15 +1268,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 +1574,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 +1643,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 +1675,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 +1710,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 +1738,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 +1763,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 +1783,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 +1841,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 +1856,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 +1869,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 +1882,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 +1895,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 +1917,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 +1960,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 +1973,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 +1986,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)."},
|
||||
@@ -2479,6 +2821,7 @@ async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
.strip_prefix("http://")
|
||||
.unwrap_or(&state.public_base_url),
|
||||
"nic_name": state.nic_name,
|
||||
"nic_link": state.nic_link,
|
||||
"subnet_mask": state.subnet_mask,
|
||||
"gateway": state.gateway,
|
||||
"dns_server": state.settings.snapshot().dns_server,
|
||||
@@ -2883,11 +3226,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 +3240,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 +3251,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 +3274,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 +3290,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 +3304,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]
|
||||
|
||||
@@ -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 (
|
||||
@@ -319,6 +343,12 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
||||
// and the logo asset is public, so this leaks nothing sensitive.
|
||||
let has_custom_logo = state.branding.has_any_web_logo();
|
||||
let logo_rev = state.branding.logo_rev();
|
||||
// v0.5.9: ship the non-sensitive SSO descriptor with every /api/me so
|
||||
// the pre-auth login screen can render the "Sign in with …" button
|
||||
// reliably. Previously the button keyed off the auth-gated /api/sso,
|
||||
// which 401s when logged out — the button only survived on a stale
|
||||
// in-memory config and vanished on any fresh login-page load.
|
||||
let sso = state.sso.login_info();
|
||||
if !state.admin.is_configured() {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
@@ -327,6 +357,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
||||
"authenticated": false,
|
||||
"has_custom_logo": has_custom_logo,
|
||||
"logo_rev": logo_rev,
|
||||
"sso": sso,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
@@ -343,6 +374,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
||||
"session_user": u,
|
||||
"has_custom_logo": has_custom_logo,
|
||||
"logo_rev": logo_rev,
|
||||
"sso": sso,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
@@ -353,6 +385,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
||||
"authenticated": false,
|
||||
"has_custom_logo": has_custom_logo,
|
||||
"logo_rev": logo_rev,
|
||||
"sso": sso,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
@@ -385,11 +418,18 @@ pub async fn api_update_credentials(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let result = state.admin.update_credentials(
|
||||
&body.current_password,
|
||||
body.new_username.as_deref(),
|
||||
body.new_password.as_deref(),
|
||||
);
|
||||
// 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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -61,16 +61,24 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
||||
// returns a full-screen 1024×768 PNG now — the operator's logo on a
|
||||
// dark field, or a default OpenPXE mark when none is uploaded. The
|
||||
// `--top 290` reserves the top band (where the logo paints) so the
|
||||
// menu text lands below it. On an iPXE build *with* `IMAGE_PNG` +
|
||||
// `CONSOLE_FRAMEBUFFER` (our x86_64 UEFI binaries, built from source
|
||||
// — see deploy/docker/Dockerfile) this paints the background and
|
||||
// overlays the menu. On a build *without* PNG support (the fetched
|
||||
// BIOS/i386/arm64 binaries) the whole `console --picture …` command
|
||||
// fails and the `|| console` resets to a clean full-screen text
|
||||
// menu. Either way there's no ASCII placeholder anymore.
|
||||
// menu text lands below it.
|
||||
//
|
||||
// v0.5.7: gate the whole command behind `iseq ${platform} efi`.
|
||||
// `console --picture` needs IMAGE_PNG + CONSOLE_FRAMEBUFFER, which
|
||||
// only our from-source UEFI binaries carry (x86_64/arm64 UEFI — see
|
||||
// deploy/docker/Dockerfile). The fetched BIOS `undionly.kpxe` has
|
||||
// neither, and on legacy BIOS the `--picture` attempt misbehaves
|
||||
// *before* the trailing `|| console` fallback can recover (it tries
|
||||
// to set a framebuffer mode the BIOS console can't honour). Guarding
|
||||
// on platform means BIOS clients never issue the command at all —
|
||||
// they drop straight to the plain text menu — while UEFI clients
|
||||
// still get the graphical background. A PNG-less UEFI build (e.g. the
|
||||
// upstream i386-efi baseline) still falls back gracefully through the
|
||||
// same `|| console`. No operator toggle needed; mixed BIOS+UEFI
|
||||
// fleets each get the right treatment automatically.
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"console --picture {base}/branding/pxe-logo --top 290 || console"
|
||||
"iseq ${{platform}} efi && console --picture {base}/branding/pxe-logo --top 290 || console"
|
||||
);
|
||||
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
|
||||
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
|
||||
@@ -101,12 +109,13 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
||||
} else {
|
||||
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
|
||||
}
|
||||
if settings.windows_enabled && has_family(isos, is_windows_family) {
|
||||
// v0.5.8: Windows just works — no Settings toggle. Show the Windows
|
||||
// installers submenu whenever a Windows ISO is present; entries boot
|
||||
// via HTTP sanboot of the raw ISO, so no SMB/extraction is required.
|
||||
if has_family(isos, is_windows_family) {
|
||||
let _ = writeln!(s, "item windows Windows Installers >");
|
||||
} else if settings.windows_enabled {
|
||||
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
|
||||
} else {
|
||||
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
|
||||
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
|
||||
}
|
||||
let _ = writeln!(
|
||||
s,
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
//! Minimal read-only ISO9660 lookup. Given an uploaded ISO file and an
|
||||
//! in-ISO path (e.g. `/casper/vmlinuz`), locate the file and return a
|
||||
//! `(start_byte, length_bytes)` pair so the HTTP handler can stream just
|
||||
//! that range from the on-disk ISO without full extraction.
|
||||
//!
|
||||
//! We only implement what we need: the Primary Volume Descriptor and Rock
|
||||
//! Ridge / Joliet extensions are ignored. Paths are matched case-insensitive
|
||||
//! against plain ISO9660 filenames (uppercase, `;1` version suffix stripped).
|
||||
//! This is sufficient for the kernel/initrd and wimboot files we serve;
|
||||
//! if a requested path isn't found, the handler returns 404 and the user
|
||||
//! can still download the whole ISO via `/iso/<id>.iso`.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
const SECTOR: u64 = 2048;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileLocation {
|
||||
pub offset: u64,
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in the
|
||||
/// ISO at `iso_path`. Returns None on any parsing or IO failure.
|
||||
pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
|
||||
let mut f = std::fs::File::open(iso_path).ok()?;
|
||||
let root = read_root_directory(&mut f)?;
|
||||
let components: Vec<&str> = in_iso_path
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect();
|
||||
if components.is_empty() {
|
||||
return None;
|
||||
}
|
||||
walk(&mut f, root.offset, root.length, &components)
|
||||
}
|
||||
|
||||
fn read_root_directory(f: &mut std::fs::File) -> Option<FileLocation> {
|
||||
// Primary Volume Descriptor at LBA 16.
|
||||
let mut pvd = [0u8; 2048];
|
||||
f.seek(SeekFrom::Start(16 * SECTOR)).ok()?;
|
||||
f.read_exact(&mut pvd).ok()?;
|
||||
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
|
||||
return None;
|
||||
}
|
||||
// Root directory record is at offset 156, length 34.
|
||||
let rec = &pvd[156..156 + 34];
|
||||
let (offset, length) = parse_dir_record_ext(rec)?;
|
||||
Some(FileLocation {
|
||||
offset: offset * SECTOR,
|
||||
length,
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk components down the directory tree starting at `dir_offset`.
|
||||
fn walk(
|
||||
f: &mut std::fs::File,
|
||||
dir_offset: u64,
|
||||
dir_len: u64,
|
||||
components: &[&str],
|
||||
) -> Option<FileLocation> {
|
||||
let mut dir = vec![0u8; dir_len as usize];
|
||||
f.seek(SeekFrom::Start(dir_offset)).ok()?;
|
||||
f.read_exact(&mut dir).ok()?;
|
||||
|
||||
let target = components[0];
|
||||
let rest = &components[1..];
|
||||
let mut i = 0;
|
||||
while i < dir.len() {
|
||||
let len = dir[i] as usize;
|
||||
if len == 0 {
|
||||
// Padding to sector boundary.
|
||||
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
|
||||
if next <= i {
|
||||
break;
|
||||
}
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
if i + len > dir.len() {
|
||||
break;
|
||||
}
|
||||
let rec = &dir[i..i + len];
|
||||
let name = dir_record_name(rec);
|
||||
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
|
||||
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
|
||||
let is_pseudo = matches!(rec.get(32).copied(), Some(1))
|
||||
&& rec.get(33).copied() == Some(0x00)
|
||||
|| matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01);
|
||||
if !is_pseudo && name.eq_ignore_ascii_case(target) {
|
||||
let (child_off, child_len) = parse_dir_record_ext(rec)?;
|
||||
if rest.is_empty() && !is_dir {
|
||||
return Some(FileLocation {
|
||||
offset: child_off * SECTOR,
|
||||
length: child_len,
|
||||
});
|
||||
} else if !rest.is_empty() && is_dir {
|
||||
return walk(f, child_off * SECTOR, child_len, rest);
|
||||
}
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract (extent LBA, data length in bytes) from a directory record.
|
||||
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
|
||||
/// data length (LE+BE duplicate). We trust the little-endian copy.
|
||||
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
|
||||
if rec.len() < 34 {
|
||||
return None;
|
||||
}
|
||||
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64;
|
||||
let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64;
|
||||
Some((lba, len))
|
||||
}
|
||||
|
||||
/// Extract the identifier from a directory record, stripping ISO9660's
|
||||
/// `;1` version suffix.
|
||||
fn dir_record_name(rec: &[u8]) -> String {
|
||||
let name_len = *rec.get(32).unwrap_or(&0) as usize;
|
||||
if name_len == 0 || rec.len() < 33 + name_len {
|
||||
return String::new();
|
||||
}
|
||||
let raw = &rec[33..33 + name_len];
|
||||
let s = String::from_utf8_lossy(raw).to_string();
|
||||
// Strip `;N` version suffix.
|
||||
if let Some(i) = s.rfind(';') {
|
||||
s[..i].to_string()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,16 @@
|
||||
//! and Linux kernel/initrd, without having to
|
||||
//! re-extract on every request)
|
||||
//!
|
||||
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`)
|
||||
//! that lseeks into the ISO on disk — so we never keep extracted copies.
|
||||
//! The `<id>/<path>` handler uses the read-only ISO9660 walker from
|
||||
//! `openpxe_iso_store::iso_fs` — seeking into the image wherever it
|
||||
//! lives (local disk, NFS, SFTP), so we never keep extracted copies.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
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;
|
||||
pub mod notify;
|
||||
pub mod saml_routes;
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ expression: rendered
|
||||
set base-url http://10.0.0.5
|
||||
set esc:hex 1b
|
||||
set cls ${esc:string}[2J
|
||||
console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
|
||||
iseq ${platform} efi && console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
|
||||
set arch-label ${buildarch} ${platform}
|
||||
iseq ${buildarch} i386 && iseq ${platform} pcbios && set arch-label x86 BIOS || iseq ${buildarch} x86_64 && iseq ${platform} efi && set arch-label x86_64 UEFI || iseq ${buildarch} arm64 && iseq ${platform} efi && set arch-label arm64 UEFI || true
|
||||
:menu
|
||||
@@ -17,7 +17,7 @@ item --gap -- ------------------------- Default -------------------------
|
||||
item local Boot from Local HDD
|
||||
item --gap -- ----------------------- Installers -----------------------
|
||||
item --gap -- (no Linux ISOs uploaded)
|
||||
item --gap -- (Windows support disabled in Settings)
|
||||
item --gap -- (no Windows ISOs uploaded)
|
||||
item --gap -- -------------------------- Tools --------------------------
|
||||
item tools Tools >
|
||||
item --gap -- ---------------------- Queued Deployment ---------------------
|
||||
|
||||
@@ -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`.
|
||||
@@ -98,6 +116,10 @@ pub struct AppState {
|
||||
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
|
||||
/// interface couldn't be identified.
|
||||
pub nic_name: String,
|
||||
/// v0.7.2: physical link summary for that NIC (operstate, speed,
|
||||
/// duplex, port MAC) — read from sysfs at startup; empty where
|
||||
/// unavailable. Helps confirm which port answers PXE.
|
||||
pub nic_link: String,
|
||||
/// Subnet mask of the public interface in dotted-quad form.
|
||||
pub subnet_mask: String,
|
||||
/// Default gateway IPv4 address.
|
||||
|
||||
@@ -18,23 +18,16 @@ use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbShareMan
|
||||
use tempfile::tempdir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so
|
||||
/// introspection identifies it as Alpine.
|
||||
/// Build a tiny Alpine-shaped ISO9660 image: volume label "ALPINE-TEST"
|
||||
/// plus the real `/boot/vmlinuz-lts` + `/boot/initramfs-lts` tree.
|
||||
/// v0.7.4's probe-based introspection verifies those paths exist before
|
||||
/// emitting a kernel boot entry — a label-only blob no longer counts.
|
||||
fn fake_alpine_iso() -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 32 * 2048];
|
||||
let off = 16 * 2048;
|
||||
buf[off] = 0x01;
|
||||
buf[off + 1..off + 6].copy_from_slice(b"CD001");
|
||||
buf[off + 6] = 0x01;
|
||||
let label = b"ALPINE-TEST".to_vec();
|
||||
let mut padded = label.clone();
|
||||
padded.resize(32, b' ');
|
||||
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
|
||||
let term = 17 * 2048;
|
||||
buf[term] = 0xFF;
|
||||
buf[term + 1..term + 6].copy_from_slice(b"CD001");
|
||||
buf[term + 6] = 0x01;
|
||||
buf
|
||||
openpxe_iso_store::iso_fs::testiso::TestIsoBuilder::new("ALPINE-TEST")
|
||||
.el_torito(true)
|
||||
.file("/boot/vmlinuz-lts", b"fake-kernel-bytes")
|
||||
.file("/boot/initramfs-lts", b"fake-initramfs-bytes")
|
||||
.build()
|
||||
}
|
||||
|
||||
fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
|
||||
@@ -115,7 +108,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,
|
||||
@@ -132,6 +128,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
public_base_url: "http://127.0.0.1".into(),
|
||||
nic_name: "lo".into(),
|
||||
nic_link: String::new(),
|
||||
subnet_mask: "255.0.0.0".into(),
|
||||
gateway: "127.0.0.1".into(),
|
||||
};
|
||||
@@ -693,7 +690,7 @@ async fn log_recent_returns_buffered_lines() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
||||
async fn windows_iso_renders_clean_sanboot_script_with_no_trust_store_writes() {
|
||||
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
|
||||
// sentinel so introspection labels it WindowsPe with has_boot_wim.
|
||||
let mut buf = vec![0u8; 32 * 2048];
|
||||
@@ -763,38 +760,35 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
||||
"introspection should detect sources/boot.wim sentinel"
|
||||
);
|
||||
|
||||
// The boot entry should be a wimboot kind with the canonical 5-file
|
||||
// chain documented in the LinusTechTips iPXE-Windows guide.
|
||||
// v0.5.8: Windows boots via iPXE HTTP sanboot of the raw ISO — no SMB,
|
||||
// no extraction, no in-ISO file serving, no operator toggle. The boot
|
||||
// entry is a `san_boot_iso` kind pointing at the raw image.
|
||||
let entry = &meta["boot_entries"][0];
|
||||
assert_eq!(entry["kind"]["kind"], "wimboot");
|
||||
let files = entry["kind"]["files"].as_array().unwrap();
|
||||
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"bootmgr"));
|
||||
assert!(names.contains(&"bootmgr.efi"));
|
||||
assert!(names.contains(&"bcd"));
|
||||
assert!(names.contains(&"boot.sdi"));
|
||||
assert!(names.contains(&"boot.wim"));
|
||||
assert_eq!(entry["kind"]["kind"], "san_boot_iso");
|
||||
let iso_url = entry["kind"]["iso_url"].as_str().unwrap();
|
||||
assert!(
|
||||
std::path::Path::new(iso_url)
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("iso")),
|
||||
"sanboot should target the raw ISO, got: {iso_url}"
|
||||
);
|
||||
|
||||
// Render the entry script and verify:
|
||||
// 1. It uses wimboot
|
||||
// 2. All 5 files are referenced via `initrd --name`
|
||||
// 3. NO trust-store / driver / testsigning operations slip in
|
||||
// 1. It uses `sanboot` against the raw ISO over HTTP
|
||||
// 2. NO trust-store / driver / testsigning operations slip in
|
||||
let entry_id = entry["id"].as_str().unwrap();
|
||||
let url = format!("/boot/{entry_id}.ipxe");
|
||||
let (s, body) = get(&app, &url).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let script = String::from_utf8(body).unwrap();
|
||||
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
|
||||
assert!(
|
||||
script.contains("ipxe/wimboot"),
|
||||
"missing wimboot loader:\n{script}"
|
||||
script.contains("sanboot"),
|
||||
"missing sanboot line:\n{script}"
|
||||
);
|
||||
assert!(
|
||||
script.contains(&format!("/{iso_url}")),
|
||||
"sanboot should reference the raw ISO url:\n{script}"
|
||||
);
|
||||
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
|
||||
assert!(
|
||||
script.contains(&format!("initrd --name {tag}")),
|
||||
"missing `initrd --name {tag}` line:\n{script}"
|
||||
);
|
||||
}
|
||||
// Hard guarantees we never want to see in any client-facing script.
|
||||
let lower = script.to_lowercase();
|
||||
for forbidden in [
|
||||
@@ -1247,9 +1241,10 @@ async fn chunked_upload_writes_progressively_and_finishes_iso() {
|
||||
.method("POST")
|
||||
.uri("/api/uploads")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"filename":"chunked-alpine.iso","size_bytes":65536}"#,
|
||||
))
|
||||
.body(Body::from(format!(
|
||||
r#"{{"filename":"chunked-alpine.iso","size_bytes":{}}}"#,
|
||||
iso.len()
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
@@ -1491,9 +1486,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",
|
||||
@@ -2611,3 +2607,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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,43 +6,40 @@
|
||||
//! missing, that architecture simply won't have PXE support — we log at
|
||||
//! startup and serve what we have.
|
||||
//!
|
||||
//! Filename convention (matches `ClientArch::ipxe_bootfile`):
|
||||
//! Filename convention (matches `ClientArch::ipxe_bootfile_mode`):
|
||||
//!
|
||||
//! DriverMode::Firmware (default — reuse the firmware UNDI/SNP NIC stack):
|
||||
//! - `undionly.kpxe` — Legacy x86 BIOS
|
||||
//! - `snponly-i386.efi` — IA32 UEFI
|
||||
//! - `snponly.efi` — x86_64 UEFI
|
||||
//! - `snponly-arm32.efi` — ARM32 UEFI
|
||||
//! - `snponly-arm64.efi` — ARM64 UEFI
|
||||
//! - `ipxe.efi` (fallback) — UEFI with bundled drivers, if snponly fails on a NIC
|
||||
//!
|
||||
//! DriverMode::Builtin (v0.6.1 automatic fallback — iPXE's own NIC drivers,
|
||||
//! advertised when a firmware-net boot fails to chainload):
|
||||
//! - `ipxe.pxe` — Legacy x86 BIOS
|
||||
//! - `ipxe-i386.efi` — IA32 UEFI
|
||||
//! - `ipxe.efi` — x86_64 UEFI (built from source with PNG)
|
||||
//! - `ipxe-arm64.efi` — ARM64 UEFI
|
||||
//!
|
||||
//! - `wimboot` — Windows boot shim (fetched separately for WIM chains)
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use openpxe_core::ClientArch;
|
||||
use openpxe_core::{ClientArch, DriverMode};
|
||||
use rust_embed::Embed;
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "../../assets/ipxe/"]
|
||||
#[include = "*.kpxe"]
|
||||
#[include = "*.efi"]
|
||||
#[include = "*.pxe"]
|
||||
#[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)
|
||||
@@ -56,25 +53,48 @@ pub fn list_assets() -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Log at startup which iPXE binaries are present and which are missing.
|
||||
/// Log at startup which iPXE binaries are present and which are missing, for
|
||||
/// both driver modes. The Firmware-mode binaries are required for PXE on each
|
||||
/// arch; the Builtin-mode binaries are the optional automatic NIC-driver
|
||||
/// fallback (v0.6.1) — without one, escalation simply can't help that arch.
|
||||
pub fn log_availability() {
|
||||
let have: std::collections::HashSet<String> = list_assets().into_iter().collect();
|
||||
let needed = [
|
||||
(ClientArch::LegacyX86, "undionly.kpxe"),
|
||||
(ClientArch::Ia32Uefi, "snponly-i386.efi"),
|
||||
(ClientArch::X64Uefi, "snponly.efi"),
|
||||
// ARM32 UEFI deferred — no upstream snponly binary published.
|
||||
(ClientArch::Arm64Uefi, "snponly-arm64.efi"),
|
||||
let arches = [
|
||||
ClientArch::LegacyX86,
|
||||
ClientArch::Ia32Uefi,
|
||||
ClientArch::X64Uefi,
|
||||
// ARM32 UEFI deferred — no upstream binary published in either mode.
|
||||
ClientArch::Arm64Uefi,
|
||||
];
|
||||
for (arch, name) in needed {
|
||||
if have.contains(name) {
|
||||
tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "openpxe::ipxe",
|
||||
"MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot",
|
||||
arch.as_str(), name
|
||||
);
|
||||
for arch in arches {
|
||||
for mode in [DriverMode::Firmware, DriverMode::Builtin, DriverMode::Shim] {
|
||||
let Some(name) = arch.ipxe_bootfile_mode(mode) else {
|
||||
continue;
|
||||
};
|
||||
if have.contains(name) {
|
||||
tracing::info!(
|
||||
target: "openpxe::ipxe",
|
||||
"bundled iPXE for {} [{mode:?}]: {name}", arch.as_str()
|
||||
);
|
||||
} else if mode == DriverMode::Firmware {
|
||||
tracing::warn!(
|
||||
target: "openpxe::ipxe",
|
||||
"MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
|
||||
arch.as_str()
|
||||
);
|
||||
} 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,3 +49,11 @@ futures = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.12"
|
||||
|
||||
[features]
|
||||
# v0.7.4: exposes the in-memory ISO9660 test-image builder
|
||||
# (`iso_fs::testiso`) to other crates' integration tests, so http-api's
|
||||
# full-flow tests can synthesize ISOs with real directory trees — the
|
||||
# probe-based introspection no longer classifies label-only blobs.
|
||||
# Never enabled in production builds.
|
||||
test-image = []
|
||||
|
||||
@@ -25,9 +25,11 @@ pub enum BootKind {
|
||||
wimboot_url: String,
|
||||
files: Vec<(String, String)>,
|
||||
},
|
||||
/// Last-resort: SAN-boot the ISO as an emulated CD. Only works for small
|
||||
/// ISOs (<~1 GiB) and older distros. Kept for completeness, not the
|
||||
/// default.
|
||||
/// SAN-boot the raw ISO as an emulated CD (iPXE `sanboot`). The emulated
|
||||
/// CD is backed by on-demand HTTP range reads, so ISO size is *not* a
|
||||
/// constraint — this is the primary path for Windows (v0.5.8) and for any
|
||||
/// El Torito-bootable image we don't special-case: ESXi/VMvisor
|
||||
/// installers, BSDs, firmware/diagnostic tools, custom spins (v0.6.0).
|
||||
SanBootIso { iso_url: String },
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
//! ISO introspection — identify the distro family and locate kernel/initrd.
|
||||
//!
|
||||
//! We avoid a full ISO9660/Joliet/Rock-Ridge parser by reading a small number
|
||||
//! of well-known files via `isoinfo` (from cdrtools/genisoimage) when it's on
|
||||
//! the path. As a pure-Rust fallback we do a crude scan: read the volume
|
||||
//! descriptor at offset 0x8000 to grab the volume label, and grep for known
|
||||
//! filenames by scanning raw sectors — good enough to tell Debian from RHEL
|
||||
//! most of the time, without shelling out.
|
||||
//! v0.7.4 rewrite: detection is **probe-based**. Instead of grepping raw
|
||||
//! sectors for filename strings (which false-positived — any Linux ISO
|
||||
//! shipping GRUB/syslinux chainload modules contains the literal
|
||||
//! "bootmgr", so gparted-live classified as Windows), we walk the
|
||||
//! ISO9660 directory tree via [`crate::iso_fs`] and check whether the
|
||||
//! well-known boot files actually exist. The same probes run over local
|
||||
//! files and remote NFS/SFTP shares — remote ISOs finally classify
|
||||
//! instead of registering as `Unknown`.
|
||||
//!
|
||||
//! The returned `IntrospectionReport` is what `BootEntry`s get generated from.
|
||||
//! Layered, first-decisive-answer-wins:
|
||||
//! 1. PVD volume label → family hint.
|
||||
//! 2. El Torito boot-catalog presence (the "bootable at all" signal).
|
||||
//! 3. `/sources/boot.wim` directory probe → Windows install media.
|
||||
//! 4. Linux probe table → verified kernel+initrd paths. A probe match
|
||||
//! both classifies the family and (for the families whose boot
|
||||
//! arguments we render) yields kernel paths that are *known to
|
||||
//! exist* — no more guessed paths that 404 at boot.
|
||||
//! 5. Bulk byte scan for UDF Windows markers — local images only
|
||||
//! (modern Windows ISOs hide their tree from ISO9660; remote scans
|
||||
//! skip this so a share rescan doesn't stream 16 MiB per ISO).
|
||||
//! 6. Filename tokens — the last-resort hint, and the only signal
|
||||
//! available for SMB shares (smbclient cannot seek).
|
||||
//!
|
||||
//! The returned `IntrospectionReport` is what `BootEntry`s get generated
|
||||
//! from.
|
||||
|
||||
use crate::iso_fs::{self, CachingReadAt, FileReadAt, IsoReadAt, SECTOR};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DistroFamily {
|
||||
DebianUbuntu,
|
||||
@@ -22,98 +39,283 @@ pub enum DistroFamily {
|
||||
Arch,
|
||||
Alpine,
|
||||
WindowsPe,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// Bumped whenever the introspection logic changes in a way that should
|
||||
/// re-classify already-uploaded ISOs. On startup the store re-runs
|
||||
/// `introspect` on any *local* ISO whose persisted report predates this
|
||||
/// revision (see `IsoStore::load_from_disk`), so an upgrade fixes stale
|
||||
/// metadata without the operator having to delete and re-upload.
|
||||
///
|
||||
/// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened
|
||||
/// Windows (UDF/UTF-16) detection.
|
||||
/// rev 2 (v0.7.4): probe-based detection. Fixes Linux live ISOs that
|
||||
/// classified as Windows via the raw "bootmgr" byte grep, verifies
|
||||
/// kernel/initrd paths exist before emitting them, and adds the Debian
|
||||
/// live / netinst / CoreOS shapes. Remote (NFS/SFTP) introspection
|
||||
/// caches key off this rev too, so the cache self-invalidates.
|
||||
pub const INTROSPECT_REV: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct IntrospectionReport {
|
||||
pub family: DistroFamily,
|
||||
pub volume_label: Option<String>,
|
||||
/// Kernel path inside the ISO (e.g. `/casper/vmlinuz`, `/isolinux/vmlinuz`).
|
||||
/// Kernel path inside the ISO (e.g. `/casper/vmlinuz`). v0.7.4: only
|
||||
/// set when the path was verified to exist *and* the family's boot
|
||||
/// arguments are known-good for direct kernel boot; families we can
|
||||
/// only classify (Debian live, CoreOS live) leave it `None` so the
|
||||
/// entry generator falls back to sanboot instead of a broken boot.
|
||||
pub kernel_path: Option<String>,
|
||||
/// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups.
|
||||
pub initrd_paths: Vec<String>,
|
||||
/// True if `sources/boot.wim` present — Windows install media.
|
||||
pub has_boot_wim: bool,
|
||||
/// True if the ISO carries an El Torito boot catalog — i.e. it is
|
||||
/// bootable by BIOS/UEFI firmware and therefore by iPXE `sanboot`
|
||||
/// (emulated CD). This is the authoritative "can this boot at all?"
|
||||
/// signal for ISOs we can't classify as Linux or Windows (BSDs, ESXi,
|
||||
/// firmware tools, custom spins). A *data* ISO (e.g. a VMware vCenter
|
||||
/// appliance bundle) has no boot catalog and reports `false`. v0.5.9.
|
||||
#[serde(default)]
|
||||
pub el_torito: bool,
|
||||
/// Revision of the introspection logic that produced this report.
|
||||
/// `0` means "never introspected" (pre-v0.5.9 metadata, or a remote
|
||||
/// ISO whose probe hasn't run / can't run) — the entry generator
|
||||
/// treats those optimistically (sanboot) and the UI labels them.
|
||||
#[serde(default)]
|
||||
pub introspect_rev: u32,
|
||||
}
|
||||
|
||||
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log
|
||||
/// and return an `Unknown` family so the uploader still sees a record.
|
||||
/// One row of the Linux detection table.
|
||||
///
|
||||
/// `emit_kernel` distinguishes "we can boot this directly" from "we can
|
||||
/// only classify it". Families marked `false` have boot protocols our
|
||||
/// cmdline renderer doesn't speak yet (Debian-live `boot=live fetch=`,
|
||||
/// d-i netinst, CoreOS `coreos.live.rootfs_url=`) — for those the probe
|
||||
/// sets the family for the UI/menu but leaves `kernel_path` unset so the
|
||||
/// ISO keeps its (working) sanboot entry instead of gaining a broken
|
||||
/// kernel one. Strictly fewer broken boots than guessing.
|
||||
struct LinuxProbe {
|
||||
family: DistroFamily,
|
||||
kernel: &'static str,
|
||||
initrd_candidates: &'static [&'static str],
|
||||
emit_kernel: bool,
|
||||
}
|
||||
|
||||
const LINUX_PROBES: &[LinuxProbe] = &[
|
||||
// Ubuntu and friends (casper) — the classic direct-boot shape.
|
||||
LinuxProbe {
|
||||
family: DistroFamily::DebianUbuntu,
|
||||
kernel: "/casper/vmlinuz",
|
||||
initrd_candidates: &["/casper/initrd", "/casper/initrd.lz", "/casper/initrd.gz"],
|
||||
emit_kernel: true,
|
||||
},
|
||||
// Debian-live derivatives: gparted-live, Clonezilla, Kali live, tails.
|
||||
// Classification only — live-boot needs `boot=live fetch=<squashfs>`
|
||||
// which we don't render yet; sanboot of these images works today.
|
||||
LinuxProbe {
|
||||
family: DistroFamily::DebianUbuntu,
|
||||
kernel: "/live/vmlinuz",
|
||||
initrd_candidates: &["/live/initrd.img", "/live/initrd"],
|
||||
emit_kernel: false,
|
||||
},
|
||||
// Debian installer (netinst/DVD). Classification only for the same
|
||||
// reason — d-i sanboots fine.
|
||||
LinuxProbe {
|
||||
family: DistroFamily::DebianUbuntu,
|
||||
kernel: "/install.amd/vmlinuz",
|
||||
initrd_candidates: &["/install.amd/initrd.gz"],
|
||||
emit_kernel: false,
|
||||
},
|
||||
// Anaconda family: RHEL, CentOS, Alma, Rocky, Fedora — and their
|
||||
// many derivatives (Cisco ISE, Nagios appliances, …). The CoreOS
|
||||
// variant of this shape is special-cased after the table.
|
||||
LinuxProbe {
|
||||
family: DistroFamily::RhelFedora,
|
||||
kernel: "/images/pxeboot/vmlinuz",
|
||||
initrd_candidates: &["/images/pxeboot/initrd.img"],
|
||||
emit_kernel: true,
|
||||
},
|
||||
LinuxProbe {
|
||||
family: DistroFamily::OpenSuse,
|
||||
kernel: "/boot/x86_64/loader/linux",
|
||||
initrd_candidates: &["/boot/x86_64/loader/initrd"],
|
||||
emit_kernel: true,
|
||||
},
|
||||
LinuxProbe {
|
||||
family: DistroFamily::Arch,
|
||||
kernel: "/arch/boot/x86_64/vmlinuz-linux",
|
||||
initrd_candidates: &["/arch/boot/x86_64/initramfs-linux.img"],
|
||||
emit_kernel: true,
|
||||
},
|
||||
LinuxProbe {
|
||||
family: DistroFamily::Alpine,
|
||||
kernel: "/boot/vmlinuz-lts",
|
||||
initrd_candidates: &["/boot/initramfs-lts"],
|
||||
emit_kernel: true,
|
||||
},
|
||||
];
|
||||
|
||||
/// CoreOS-style live images (RHCOS, FCOS, OpenShift agent ISOs) carry
|
||||
/// the anaconda pxeboot layout *plus* a rootfs image. Direct kernel boot
|
||||
/// of those requires `coreos.live.rootfs_url=` (and for agent ISOs, the
|
||||
/// ignition config embedded in the ISO device) — neither of which a
|
||||
/// plain `inst.repo=` cmdline provides. Their sanboot path works, so
|
||||
/// they classify as RHEL-family but keep the sanboot entry.
|
||||
const COREOS_ROOTFS: &str = "/images/pxeboot/rootfs.img";
|
||||
|
||||
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we
|
||||
/// log and return an `Unknown` family so the uploader still sees a record.
|
||||
pub fn introspect(path: &Path) -> IntrospectionReport {
|
||||
let mut report = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: Vec::new(),
|
||||
has_boot_wim: false,
|
||||
};
|
||||
|
||||
let Ok(mut f) = std::fs::File::open(path) else {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let Ok(f) = std::fs::File::open(path) else {
|
||||
tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display());
|
||||
return report;
|
||||
return IntrospectionReport {
|
||||
introspect_rev: INTROSPECT_REV,
|
||||
..Default::default()
|
||||
};
|
||||
};
|
||||
let len = f.metadata().map_or(0, |m| m.len());
|
||||
// `FileReadAt` completes every read inline (no real awaits), so this
|
||||
// light-weight block_on never parks; callers already run us on the
|
||||
// blocking pool.
|
||||
futures::executor::block_on(introspect_reader(
|
||||
&mut FileReadAt::new(f),
|
||||
len,
|
||||
&filename,
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
/// The detection core, generic over any random-access source. `total_len`
|
||||
/// is the image size (every caller knows it — file metadata locally, the
|
||||
/// share listing remotely) and bounds the bulk scan, since `IsoReadAt`
|
||||
/// reads are exact-or-error. `filename` feeds the last-resort token
|
||||
/// heuristics; `allow_bulk_scan` gates the 16 MiB UDF-Windows byte scan
|
||||
/// (local files only — remote shares would stream that much per ISO per
|
||||
/// rescan).
|
||||
pub async fn introspect_reader<R: IsoReadAt + Send>(
|
||||
r: &mut R,
|
||||
total_len: u64,
|
||||
filename: &str,
|
||||
allow_bulk_scan: bool,
|
||||
) -> IntrospectionReport {
|
||||
let mut report = IntrospectionReport {
|
||||
introspect_rev: INTROSPECT_REV,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// ISO9660 Primary Volume Descriptor at LBA 16 (offset 0x8000), 2048 bytes.
|
||||
// Bytes 40..72 are the Volume Identifier (space-padded, d-characters).
|
||||
let mut pvd = [0u8; 2048];
|
||||
if f.seek(SeekFrom::Start(0x8000)).is_ok() && f.read_exact(&mut pvd).is_ok() {
|
||||
// Byte 0 must be 0x01 (primary descriptor), bytes 1..6 = "CD001".
|
||||
// ISO9660 Primary Volume Descriptor at LBA 16. Bytes 40..72 are the
|
||||
// volume identifier (space-padded).
|
||||
if let Ok(pvd) = r.read_at(16 * SECTOR, 2048).await {
|
||||
if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" {
|
||||
let label_raw = &pvd[40..72];
|
||||
let label = String::from_utf8_lossy(label_raw).trim().to_string();
|
||||
let label = String::from_utf8_lossy(&pvd[40..72]).trim().to_string();
|
||||
if !label.is_empty() {
|
||||
report.volume_label = Some(label.clone());
|
||||
report.family = family_from_label(&label);
|
||||
report.volume_label = Some(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cheap content scan: read the first ~64 MiB, look for signature filenames.
|
||||
// This is enough to identify `sources/boot.wim` (Windows) and common
|
||||
// kernel/initrd paths for the major Linux distros.
|
||||
let _ = f.seek(SeekFrom::Start(0));
|
||||
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));
|
||||
while read_total < scan_bytes {
|
||||
let n = f.read(&mut buf).unwrap_or(0);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
haystack.extend_from_slice(&buf[..n]);
|
||||
read_total += n;
|
||||
}
|
||||
report.el_torito = detect_el_torito(r).await;
|
||||
|
||||
if contains_ascii(&haystack, b"sources/boot.wim")
|
||||
|| contains_ascii(&haystack, b"SOURCES/BOOT.WIM")
|
||||
|| contains_ascii(&haystack, b"SOURCES\\BOOT.WIM")
|
||||
// Directory-tree probes. The caching wrapper collapses the repeated
|
||||
// root/subdirectory reads the probe table would otherwise issue —
|
||||
// over NFS/SFTP that's the difference between ~6 and ~60 round-trips.
|
||||
{
|
||||
report.has_boot_wim = true;
|
||||
if report.family == DistroFamily::Unknown {
|
||||
let mut cr = CachingReadAt::new(r);
|
||||
|
||||
if iso_fs::exists(&mut cr, "/sources/boot.wim").await {
|
||||
report.has_boot_wim = true;
|
||||
report.family = DistroFamily::WindowsPe;
|
||||
} else {
|
||||
for probe in LINUX_PROBES {
|
||||
if !iso_fs::exists(&mut cr, probe.kernel).await {
|
||||
continue;
|
||||
}
|
||||
let mut initrd = None;
|
||||
for cand in probe.initrd_candidates {
|
||||
if iso_fs::exists(&mut cr, cand).await {
|
||||
initrd = Some((*cand).to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(initrd) = initrd else { continue };
|
||||
// Content beats label: a rebadged derivative (volume
|
||||
// label "ISE-3.2") with the anaconda layout is
|
||||
// RHEL-family no matter what the label says.
|
||||
report.family = probe.family;
|
||||
let coreos = probe.family == DistroFamily::RhelFedora
|
||||
&& iso_fs::exists(&mut cr, COREOS_ROOTFS).await;
|
||||
if probe.emit_kernel && !coreos {
|
||||
report.kernel_path = Some(probe.kernel.to_string());
|
||||
report.initrd_paths = vec![initrd];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort kernel/initrd path guess from family. These paths are what
|
||||
// distro ISOs conventionally ship at — we don't verify extraction here;
|
||||
// that happens in the store after introspection.
|
||||
let (k, i) = guess_kernel_initrd(report.family);
|
||||
report.kernel_path = k.map(str::to_string);
|
||||
report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
|
||||
// Modern Windows 10/11 ISOs are UDF — their tree is invisible to the
|
||||
// ISO9660 walk and the volume label is a cryptic Microsoft string.
|
||||
// Scan the first 16 MiB for well-known markers, ASCII and UTF-16LE.
|
||||
// Runs after the Linux probes so a Linux ISO that *contains* the
|
||||
// string "bootmgr" (GRUB/syslinux chainload modules do) has already
|
||||
// classified and never reaches this — that ordering is the v0.7.4
|
||||
// gparted-misdetection fix.
|
||||
if report.family == DistroFamily::Unknown && allow_bulk_scan {
|
||||
if let Some(win) = bulk_windows_scan(r, total_len).await {
|
||||
report.family = DistroFamily::WindowsPe;
|
||||
report.has_boot_wim = win;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: filename tokens. The only signal for SMB-sourced ISOs
|
||||
// and renamed/UDF images that defeated everything above.
|
||||
if report.family == DistroFamily::Unknown {
|
||||
report.family = family_from_filename(filename);
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
/// Provisional report for a remote ISO that hasn't been (or can't be)
|
||||
/// content-probed yet: family from the filename, `introspect_rev` left
|
||||
/// at 0 so the entry generator keeps the optimistic sanboot entry and
|
||||
/// the UI shows it as awaiting introspection. Used by all three share
|
||||
/// managers at registration; NFS/SFTP upgrade it in the background.
|
||||
#[must_use]
|
||||
pub fn provisional_report(filename: &str) -> IntrospectionReport {
|
||||
IntrospectionReport {
|
||||
family: family_from_filename(filename),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn family_from_label(label: &str) -> DistroFamily {
|
||||
let l = label.to_ascii_lowercase();
|
||||
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") {
|
||||
if l.contains("ubuntu")
|
||||
|| l.contains("debian")
|
||||
|| l.contains("mint")
|
||||
|| l.contains("kali")
|
||||
|| l.contains("gparted")
|
||||
|| l.contains("clonezilla")
|
||||
{
|
||||
DistroFamily::DebianUbuntu
|
||||
} else if l.contains("rhel")
|
||||
|| l.contains("centos")
|
||||
|| l.contains("fedora")
|
||||
|| l.contains("rocky")
|
||||
|| l.contains("alma")
|
||||
|| l.contains("rhcos")
|
||||
|| l.contains("coreos")
|
||||
|| l.contains("openshift")
|
||||
|| l.contains("okd")
|
||||
{
|
||||
DistroFamily::RhelFedora
|
||||
} else if l.contains("suse") || l.contains("opensuse") {
|
||||
@@ -129,23 +331,91 @@ fn family_from_label(label: &str) -> DistroFamily {
|
||||
}
|
||||
}
|
||||
|
||||
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
|
||||
match family {
|
||||
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
|
||||
DistroFamily::RhelFedora => (
|
||||
Some("/images/pxeboot/vmlinuz"),
|
||||
vec!["/images/pxeboot/initrd.img"],
|
||||
),
|
||||
DistroFamily::OpenSuse => (
|
||||
Some("/boot/x86_64/loader/linux"),
|
||||
vec!["/boot/x86_64/loader/initrd"],
|
||||
),
|
||||
DistroFamily::Arch => (
|
||||
Some("/arch/boot/x86_64/vmlinuz-linux"),
|
||||
vec!["/arch/boot/x86_64/initramfs-linux.img"],
|
||||
),
|
||||
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]),
|
||||
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
|
||||
/// Filename token heuristic — `AlmaLinux-9.5-x86_64-dvd.iso` says what
|
||||
/// it is even when we can't read a byte of it. Tokens are the filename
|
||||
/// split on every non-alphanumeric character, so "almalinux", "rhel",
|
||||
/// "win11" match without "search" tripping the "arch" token.
|
||||
pub fn family_from_filename(filename: &str) -> DistroFamily {
|
||||
if filename_looks_windows(filename) {
|
||||
return DistroFamily::WindowsPe;
|
||||
}
|
||||
let lower = filename.to_ascii_lowercase();
|
||||
let tokens: Vec<&str> = lower
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
let has = |t: &str| tokens.contains(&t);
|
||||
if has("ubuntu")
|
||||
|| has("debian")
|
||||
|| has("mint")
|
||||
|| has("kali")
|
||||
|| has("gparted")
|
||||
|| has("clonezilla")
|
||||
|| has("tails")
|
||||
{
|
||||
DistroFamily::DebianUbuntu
|
||||
} else if has("rhel")
|
||||
|| has("centos")
|
||||
|| has("almalinux")
|
||||
|| has("alma")
|
||||
|| has("rocky")
|
||||
|| has("rockylinux")
|
||||
|| has("fedora")
|
||||
|| has("rhcos")
|
||||
|| has("coreos")
|
||||
|| has("openshift")
|
||||
|| has("okd")
|
||||
{
|
||||
DistroFamily::RhelFedora
|
||||
} else if has("opensuse") || has("suse") || has("sles") {
|
||||
DistroFamily::OpenSuse
|
||||
} else if has("arch") || has("archlinux") || has("manjaro") {
|
||||
DistroFamily::Arch
|
||||
} else if has("alpine") {
|
||||
DistroFamily::Alpine
|
||||
} else {
|
||||
DistroFamily::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan the first 16 MiB (or the whole image when smaller) for Windows
|
||||
/// markers. Returns `Some(has_boot_wim)` on a hit, `None` when nothing
|
||||
/// Windows-shaped is found.
|
||||
async fn bulk_windows_scan<R: IsoReadAt + Send>(r: &mut R, total_len: u64) -> Option<bool> {
|
||||
const SCAN_BYTES: u64 = 16 * 1024 * 1024;
|
||||
const CHUNK: u64 = 1024 * 1024;
|
||||
let budget = SCAN_BYTES.min(total_len);
|
||||
let mut haystack = Vec::with_capacity(usize::try_from(budget).unwrap_or(0));
|
||||
let mut offset = 0u64;
|
||||
while offset < budget {
|
||||
// Reads are exact-or-error, so clamp the final chunk to what the
|
||||
// image actually has — netboot.xyz is 2.3 MB, not 16.
|
||||
let want = u32::try_from(CHUNK.min(budget - offset)).unwrap_or(u32::MAX);
|
||||
let Ok(chunk) = r.read_at(offset, want).await else {
|
||||
break; // read error: scan what we have
|
||||
};
|
||||
offset += chunk.len() as u64;
|
||||
haystack.extend_from_slice(&chunk);
|
||||
}
|
||||
if haystack.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let boot_wim = contains_ascii(&haystack, b"sources/boot.wim")
|
||||
|| contains_ascii(&haystack, b"sources\\boot.wim")
|
||||
|| contains_utf16le_ci(&haystack, "boot.wim");
|
||||
if boot_wim {
|
||||
return Some(true);
|
||||
}
|
||||
let ascii_markers: [&[u8]; 3] = [b"bootmgr", b"sources/install.wim", b"sources/install.esd"];
|
||||
let utf16_markers = ["bootmgr", "install.wim", "microsoft"];
|
||||
let hit = ascii_markers.iter().any(|m| contains_ascii(&haystack, m))
|
||||
|| utf16_markers
|
||||
.iter()
|
||||
.any(|m| contains_utf16le_ci(&haystack, m));
|
||||
if hit {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,9 +428,87 @@ fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
.any(|w| w.eq_ignore_ascii_case(needle))
|
||||
}
|
||||
|
||||
/// Case-insensitive search for an ASCII string encoded as UTF-16LE — the
|
||||
/// way UDF (and thus modern Windows ISOs) store filenames. Each character
|
||||
/// is two bytes: the ASCII low byte (compared case-insensitively) followed
|
||||
/// by a 0 high byte. v0.5.8.
|
||||
fn contains_utf16le_ci(haystack: &[u8], ascii: &str) -> bool {
|
||||
let n = ascii.len();
|
||||
if n == 0 || haystack.len() < n * 2 {
|
||||
return false;
|
||||
}
|
||||
let lower: Vec<u8> = ascii.bytes().map(|b| b.to_ascii_lowercase()).collect();
|
||||
haystack.windows(n * 2).any(|w| {
|
||||
lower
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, &c)| w[i * 2 + 1] == 0 && w[i * 2].to_ascii_lowercase() == c)
|
||||
})
|
||||
}
|
||||
|
||||
/// Filename heuristic: a stock Windows ISO almost always carries an obvious
|
||||
/// token in its name (e.g. `..._windows_11_...`, `Win10`, `winserver`).
|
||||
/// v0.7.4: takes the bare filename instead of a `Path` so the same check
|
||||
/// runs against remote share listings.
|
||||
fn filename_looks_windows(filename: &str) -> bool {
|
||||
let name = filename.to_ascii_lowercase();
|
||||
const TOKENS: [&str; 6] = [
|
||||
"windows",
|
||||
"winpe",
|
||||
"win10",
|
||||
"win11",
|
||||
"winserver",
|
||||
"win-server",
|
||||
];
|
||||
TOKENS.iter().any(|t| name.contains(t))
|
||||
}
|
||||
|
||||
/// The boot-system identifier string in an El Torito Boot Record Volume
|
||||
/// Descriptor (offset 7, NUL-padded to 32 bytes).
|
||||
const EL_TORITO_ID: &[u8] = b"EL TORITO SPECIFICATION";
|
||||
|
||||
/// Detect an El Torito boot catalog — the marker that an ISO is bootable
|
||||
/// by BIOS/UEFI firmware (and thus by iPXE `sanboot`).
|
||||
///
|
||||
/// The ISO9660 Volume Descriptor Set starts at LBA 16 and runs one
|
||||
/// 2048-byte descriptor per sector until a Set Terminator (type 0xFF).
|
||||
/// A Boot Record descriptor (type 0x00) whose 32-byte boot system
|
||||
/// identifier reads "EL TORITO SPECIFICATION" means the image declares a
|
||||
/// boot catalog. We only confirm its presence — we don't parse the
|
||||
/// catalog (sanboot/the firmware does that). The walk is capped so a
|
||||
/// malformed image can't spin us. v0.5.9; reader-generic since v0.7.4.
|
||||
async fn detect_el_torito<R: IsoReadAt + Send>(r: &mut R) -> bool {
|
||||
for lba in 16u64..32 {
|
||||
let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else {
|
||||
return false;
|
||||
};
|
||||
// Every descriptor in the set carries the "CD001" magic; once it's
|
||||
// missing we've walked off the end of a valid set.
|
||||
if &vd[1..6] != b"CD001" {
|
||||
return false;
|
||||
}
|
||||
match vd[0] {
|
||||
// Boot Record descriptor carrying the El Torito signature.
|
||||
0x00 if vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID => return true,
|
||||
// Volume Descriptor Set Terminator — nothing bootable found.
|
||||
0xFF => return false,
|
||||
// Any other descriptor (incl. a non-El-Torito boot record) —
|
||||
// keep walking the set.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::iso_fs::testiso::{MemReadAt, TestIsoBuilder};
|
||||
|
||||
fn introspect_mem(img: Vec<u8>, filename: &str, bulk: bool) -> IntrospectionReport {
|
||||
let len = img.len() as u64;
|
||||
futures::executor::block_on(introspect_reader(&mut MemReadAt(img), len, filename, bulk))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_matching() {
|
||||
@@ -177,6 +525,201 @@ mod tests {
|
||||
DistroFamily::OpenSuse
|
||||
);
|
||||
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
|
||||
assert_eq!(
|
||||
family_from_label("GParted-live"),
|
||||
DistroFamily::DebianUbuntu
|
||||
);
|
||||
assert_eq!(family_from_label("rhcos-417"), DistroFamily::RhelFedora);
|
||||
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gparted_shape_is_not_windows() {
|
||||
// The v0.7.4 regression test: a Debian-live image whose payload
|
||||
// contains the literal string "bootmgr" (as GRUB/syslinux
|
||||
// chainload modules do). The old byte-grep classified this as
|
||||
// WindowsPe; the probe order must classify Debian first.
|
||||
let img = TestIsoBuilder::new("GParted-live")
|
||||
.el_torito(true)
|
||||
.file("/live/vmlinuz", b"KERNEL")
|
||||
.file("/live/initrd.img", b"INITRD")
|
||||
.file("/boot/grub/chain.mod", b"xxx bootmgr xxx")
|
||||
.build();
|
||||
let r = introspect_mem(img, "gparted-live-1.8.1-3-amd64.iso", true);
|
||||
assert_eq!(r.family, DistroFamily::DebianUbuntu);
|
||||
// Classification only — live-boot args aren't rendered yet, so no
|
||||
// kernel entry; sanboot (via el_torito) keeps working.
|
||||
assert!(r.kernel_path.is_none());
|
||||
assert!(r.el_torito);
|
||||
assert_eq!(r.introspect_rev, INTROSPECT_REV);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn casper_shape_verifies_kernel_and_initrd() {
|
||||
let img = TestIsoBuilder::new("Ubuntu-Server 24.04.1 LTS amd64")
|
||||
.el_torito(true)
|
||||
.file("/casper/vmlinuz", b"K")
|
||||
.file("/casper/initrd", b"I")
|
||||
.build();
|
||||
let r = introspect_mem(img, "ubuntu-24.04.1-live-server-amd64.iso", true);
|
||||
assert_eq!(r.family, DistroFamily::DebianUbuntu);
|
||||
assert_eq!(r.kernel_path.as_deref(), Some("/casper/vmlinuz"));
|
||||
assert_eq!(r.initrd_paths, vec!["/casper/initrd".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anaconda_shape_emits_verified_paths() {
|
||||
let img = TestIsoBuilder::new("AlmaLinux-9-5-x86_64-dvd")
|
||||
.el_torito(true)
|
||||
.file("/images/pxeboot/vmlinuz", b"K")
|
||||
.file("/images/pxeboot/initrd.img", b"I")
|
||||
.build();
|
||||
let r = introspect_mem(img, "AlmaLinux-9.5-x86_64-dvd.iso", true);
|
||||
assert_eq!(r.family, DistroFamily::RhelFedora);
|
||||
assert_eq!(r.kernel_path.as_deref(), Some("/images/pxeboot/vmlinuz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coreos_shape_classifies_but_keeps_sanboot() {
|
||||
// RHCOS / OpenShift agent ISOs: anaconda layout + rootfs.img.
|
||||
// Direct kernel boot needs coreos.live.rootfs_url (and agent
|
||||
// ISOs their embedded ignition), so kernel_path must stay None.
|
||||
let img = TestIsoBuilder::new("rhcos-417.94.202501")
|
||||
.el_torito(true)
|
||||
.file("/images/pxeboot/vmlinuz", b"K")
|
||||
.file("/images/pxeboot/initrd.img", b"I")
|
||||
.file("/images/pxeboot/rootfs.img", b"R")
|
||||
.build();
|
||||
let r = introspect_mem(img, "rhcos-live.x86_64.iso", true);
|
||||
assert_eq!(r.family, DistroFamily::RhelFedora);
|
||||
assert!(
|
||||
r.kernel_path.is_none(),
|
||||
"CoreOS must not get a kernel entry"
|
||||
);
|
||||
assert!(r.el_torito);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boot_wim_probe_classifies_windows() {
|
||||
let img = TestIsoBuilder::new("CCCOMA_X64FRE_EN-US_DV9")
|
||||
.el_torito(true)
|
||||
.file("/sources/boot.wim", b"MSWIMMSWIM")
|
||||
.build();
|
||||
let r = introspect_mem(img, "whatever.iso", false);
|
||||
assert_eq!(r.family, DistroFamily::WindowsPe);
|
||||
assert!(r.has_boot_wim);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_only_linux_without_verified_kernel_gets_no_kernel_path() {
|
||||
// Label says RHEL but the tree has no pxeboot files — the old
|
||||
// code guessed `/images/pxeboot/vmlinuz` and emitted an entry
|
||||
// that 404'd at boot. Now: family yes, kernel paths no.
|
||||
let img = TestIsoBuilder::new("RHEL-9-5-CUSTOM")
|
||||
.el_torito(true)
|
||||
.file("/readme.txt", b"hi")
|
||||
.build();
|
||||
let r = introspect_mem(img, "rhel-custom.iso", true);
|
||||
assert_eq!(r.family, DistroFamily::RhelFedora);
|
||||
assert!(r.kernel_path.is_none());
|
||||
assert!(r.initrd_paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_skips_bulk_scan_but_filename_still_hints() {
|
||||
// No ISO9660 signatures at all (e.g. pure-UDF image read over a
|
||||
// share), bulk scan off: filename is the only signal.
|
||||
let img = vec![0u8; 64 * 1024];
|
||||
let r = introspect_mem(img.clone(), "Win11_24H2_English_x64.iso", false);
|
||||
assert_eq!(r.family, DistroFamily::WindowsPe);
|
||||
let r2 = introspect_mem(img, "mystery.iso", false);
|
||||
assert_eq!(r2.family, DistroFamily::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_family_table() {
|
||||
use DistroFamily::*;
|
||||
let cases = [
|
||||
("AlmaLinux-9.5-x86_64-dvd.iso", RhelFedora),
|
||||
("CentOS-Stream-10-latest-x86_64-dvd1.iso", RhelFedora),
|
||||
("rhel-9.0-x86_64-boot.iso", RhelFedora),
|
||||
("rhcos-live.x86_64.iso", RhelFedora),
|
||||
("openshift-4-21-9.agent.x86_64.iso", RhelFedora),
|
||||
("ubuntu-24.04-desktop.iso", DebianUbuntu),
|
||||
("gparted-live-1.8.1-3-amd64.iso", DebianUbuntu),
|
||||
("archlinux-2026.05.01-x86_64.iso", Arch),
|
||||
("arch-2026.05.01.iso", Arch),
|
||||
("alpine-standard-3.21.0-x86_64.iso", Alpine),
|
||||
("openSUSE-Leap-15.6-DVD-x86_64.iso", OpenSuse),
|
||||
("en-us_windows_11_iot_enterprise.iso", WindowsPe),
|
||||
("Win10_22H2_English_x64.iso", WindowsPe),
|
||||
("netboot.xyz.iso", Unknown),
|
||||
("ise-3.2.0.542a.SPA.x86_64.iso", Unknown),
|
||||
("Macrium_5860_v2.iso", Unknown),
|
||||
// "search" must not trip the "arch" token.
|
||||
("research-data.iso", Unknown),
|
||||
];
|
||||
for (name, want) in cases {
|
||||
assert_eq!(family_from_filename(name), want, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provisional_report_keeps_rev_zero() {
|
||||
let r = provisional_report("rhel-9.0-x86_64-dvd.iso");
|
||||
assert_eq!(r.family, DistroFamily::RhelFedora);
|
||||
assert_eq!(
|
||||
r.introspect_rev, 0,
|
||||
"provisional must keep optimistic sanboot"
|
||||
);
|
||||
assert!(!r.el_torito);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16le_marker_matches_case_insensitively() {
|
||||
let s = "BOOT.WIM";
|
||||
let utf16: Vec<u8> = s.bytes().flat_map(|b| [b, 0]).collect();
|
||||
let mut hay = vec![0u8; 8];
|
||||
hay.extend_from_slice(&utf16);
|
||||
hay.extend_from_slice(&[1, 2, 3]);
|
||||
assert!(contains_utf16le_ci(&hay, "boot.wim"));
|
||||
assert!(contains_utf16le_ci(&hay, "Boot.Wim"));
|
||||
assert!(!contains_utf16le_ci(&hay, "install.wim"));
|
||||
assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn el_torito_detected_through_reader() {
|
||||
let with = TestIsoBuilder::new("BOOTABLE").el_torito(true).build();
|
||||
let without = TestIsoBuilder::new("DATA").build();
|
||||
assert!(futures::executor::block_on(detect_el_torito(
|
||||
&mut MemReadAt(with)
|
||||
)));
|
||||
assert!(!futures::executor::block_on(detect_el_torito(
|
||||
&mut MemReadAt(without)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_hint_catches_windows_isos() {
|
||||
assert!(filename_looks_windows(
|
||||
"en-us_windows_11_iot_enterprise_ltsc_2024_x64_dvd.iso"
|
||||
));
|
||||
assert!(filename_looks_windows("Win10_22H2_English_x64.iso"));
|
||||
assert!(filename_looks_windows("winserver2022.iso"));
|
||||
assert!(!filename_looks_windows("ubuntu-24.04-desktop.iso"));
|
||||
assert!(!filename_looks_windows("Rocky-9.4-x86_64-dvd.iso"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_scan_catches_udf_windows_markers() {
|
||||
// A blob with no ISO9660 tree but a UTF-16 "install.wim" — the
|
||||
// UDF Windows shape after every probe missed.
|
||||
let mut img = vec![0u8; 256 * 1024];
|
||||
let marker: Vec<u8> = "install.wim".bytes().flat_map(|b| [b, 0]).collect();
|
||||
img[100_000..100_000 + marker.len()].copy_from_slice(&marker);
|
||||
let r = introspect_mem(img, "renamed.iso", true);
|
||||
assert_eq!(r.family, DistroFamily::WindowsPe);
|
||||
assert!(!r.has_boot_wim);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Read-only ISO9660 lookup over any random-access byte source.
|
||||
//!
|
||||
//! v0.7.4: generalized from the http-api crate's local-file-only walker so
|
||||
//! the same directory walk drives three consumers:
|
||||
//!
|
||||
//! 1. `iso_file` HTTP serving — locate `/casper/vmlinuz` inside a local
|
||||
//! *or remote* (NFS/SFTP) ISO and stream just that byte range.
|
||||
//! 2. Introspection — probe for well-known kernel/initrd/boot.wim paths
|
||||
//! instead of grepping raw sectors for filename strings (which
|
||||
//! false-positived: any Linux ISO shipping GRUB/syslinux chainload
|
||||
//! modules contains the literal "bootmgr" and used to classify as
|
||||
//! Windows).
|
||||
//! 3. Remote introspection — the same probes over an NFSv3 READ3 /
|
||||
//! SFTP seek-read connection, which is what finally classifies
|
||||
//! share-sourced ISOs instead of registering them all as `Unknown`.
|
||||
//!
|
||||
//! We parse only the Primary Volume Descriptor namespace. Joliet and Rock
|
||||
//! Ridge are deliberately ignored — matching is case-insensitive against
|
||||
//! plain ISO9660 identifiers (`;1` version suffix and the trailing dot of
|
||||
//! extension-less strict-mastered names stripped), which is how the local
|
||||
//! serving path has always behaved in production.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
pub const SECTOR: u64 = 2048;
|
||||
|
||||
/// Upper bound on a single directory extent we'll buffer. Real distro ISO
|
||||
/// directories are a handful of KiB; the cap keeps a malformed or hostile
|
||||
/// image from asking us to allocate gigabytes.
|
||||
const MAX_DIR_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// Byte range of one file inside the ISO image.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileLocation {
|
||||
pub offset: u64,
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
/// Random-access reads into an ISO image. Implemented by a local
|
||||
/// `std::fs::File`, the NFS and SFTP share readers, and the in-memory
|
||||
/// test image.
|
||||
///
|
||||
/// The contract is `read_exact`-like: the returned buffer is exactly
|
||||
/// `len` bytes or the call errors. The future must be `Send` because
|
||||
/// remote introspection runs inside spawned tokio tasks.
|
||||
pub trait IsoReadAt {
|
||||
fn read_at(
|
||||
&mut self,
|
||||
offset: u64,
|
||||
len: u32,
|
||||
) -> impl Future<Output = std::io::Result<Vec<u8>>> + Send;
|
||||
}
|
||||
|
||||
/// Local-file reader. The reads are synchronous inside an async fn —
|
||||
/// callers run it either on the blocking pool (introspection at upload)
|
||||
/// or through [`lookup_local`]'s `block_on`, never on a hot runtime
|
||||
/// worker with real awaits pending.
|
||||
pub struct FileReadAt(std::fs::File);
|
||||
|
||||
impl FileReadAt {
|
||||
#[must_use]
|
||||
pub fn new(f: std::fs::File) -> Self {
|
||||
Self(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl IsoReadAt for FileReadAt {
|
||||
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
|
||||
self.0.seek(SeekFrom::Start(offset))?;
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
self.0.read_exact(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact-key read cache for the probe phase of introspection. The probe
|
||||
/// table looks up ~20 paths and every one of them re-reads the root
|
||||
/// directory (and usually one shared subdirectory); over NFS/SFTP that
|
||||
/// would be 20 identical round-trips. Directory reads repeat with the
|
||||
/// exact same `(offset, len)`, so a plain map keyed on the pair hits
|
||||
/// every time. Large data reads bypass the cache.
|
||||
pub struct CachingReadAt<'a, R: IsoReadAt + Send> {
|
||||
inner: &'a mut R,
|
||||
cache: HashMap<(u64, u32), Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Don't cache reads bigger than this (file payloads, bulk scans).
|
||||
const CACHE_MAX_READ: u32 = 256 * 1024;
|
||||
/// Bound the cache so a pathological image can't grow it unbounded.
|
||||
const CACHE_MAX_ENTRIES: usize = 256;
|
||||
|
||||
impl<'a, R: IsoReadAt + Send> CachingReadAt<'a, R> {
|
||||
pub fn new(inner: &'a mut R) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: IsoReadAt + Send> IsoReadAt for CachingReadAt<'_, R> {
|
||||
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
|
||||
let key = (offset, len);
|
||||
if let Some(hit) = self.cache.get(&key) {
|
||||
return Ok(hit.clone());
|
||||
}
|
||||
let buf = self.inner.read_at(offset, len).await?;
|
||||
if len <= CACHE_MAX_READ && self.cache.len() < CACHE_MAX_ENTRIES {
|
||||
self.cache.insert(key, buf.clone());
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in
|
||||
/// the image behind `r`. Returns `None` on any parsing or IO failure —
|
||||
/// "not found" and "couldn't read" are the same answer to a prober.
|
||||
pub async fn lookup<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> Option<FileLocation> {
|
||||
let pvd = r.read_at(16 * SECTOR, 2048).await.ok()?;
|
||||
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
|
||||
return None;
|
||||
}
|
||||
// Root directory record at PVD offset 156, 34 bytes.
|
||||
let (mut lba, mut len) = parse_dir_record_ext(&pvd[156..156 + 34])?;
|
||||
|
||||
let components: Vec<&str> = in_iso_path
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect();
|
||||
if components.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The original walk was tail-recursive; iterate instead so the future
|
||||
// stays a plain (non-boxed) state machine.
|
||||
for (idx, comp) in components.iter().enumerate() {
|
||||
if len == 0 || len > MAX_DIR_BYTES {
|
||||
return None;
|
||||
}
|
||||
let dir = r.read_at(lba * SECTOR, len as u32).await.ok()?;
|
||||
let hit = scan_dir(&dir, comp)?;
|
||||
let last = idx + 1 == components.len();
|
||||
match (last, hit.is_dir) {
|
||||
(true, false) => {
|
||||
return Some(FileLocation {
|
||||
offset: hit.lba * SECTOR,
|
||||
length: hit.len,
|
||||
})
|
||||
}
|
||||
(false, true) => {
|
||||
lba = hit.lba;
|
||||
len = hit.len;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Convenience probe: does `in_iso_path` exist as a file?
|
||||
pub async fn exists<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> bool {
|
||||
lookup(r, in_iso_path).await.is_some()
|
||||
}
|
||||
|
||||
/// Synchronous wrapper for local files — the shape the HTTP handler's
|
||||
/// `spawn_blocking` call site wants. `block_on` is safe here because
|
||||
/// `FileReadAt`'s reads never actually await (they complete inline), so
|
||||
/// the executor never parks.
|
||||
#[must_use]
|
||||
pub fn lookup_local(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
|
||||
let f = std::fs::File::open(iso_path).ok()?;
|
||||
futures::executor::block_on(lookup(&mut FileReadAt::new(f), in_iso_path))
|
||||
}
|
||||
|
||||
struct DirHit {
|
||||
lba: u64,
|
||||
len: u64,
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
/// Scan one directory extent for an identifier. Pure function over the
|
||||
/// buffered extent — all protocol/IO concerns live in the caller.
|
||||
fn scan_dir(dir: &[u8], target: &str) -> Option<DirHit> {
|
||||
let mut i = 0;
|
||||
while i < dir.len() {
|
||||
let len = dir[i] as usize;
|
||||
if len == 0 {
|
||||
// Records never span sectors; a zero length byte means the
|
||||
// rest of this sector is padding. Hop to the next one.
|
||||
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
|
||||
if next <= i {
|
||||
break;
|
||||
}
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
if i + len > dir.len() {
|
||||
break;
|
||||
}
|
||||
let rec = &dir[i..i + len];
|
||||
let name = dir_record_name(rec);
|
||||
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
|
||||
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
|
||||
let is_pseudo =
|
||||
rec.get(32).copied() == Some(1) && matches!(rec.get(33).copied(), Some(0x00 | 0x01));
|
||||
if !is_pseudo && name.eq_ignore_ascii_case(target) {
|
||||
let (lba, dlen) = parse_dir_record_ext(rec)?;
|
||||
return Some(DirHit {
|
||||
lba,
|
||||
len: dlen,
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract (extent LBA, data length in bytes) from a directory record.
|
||||
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
|
||||
/// data length (LE+BE duplicate). We trust the little-endian copy.
|
||||
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
|
||||
if rec.len() < 34 {
|
||||
return None;
|
||||
}
|
||||
let lba = u64::from(u32::from_le_bytes(rec[2..6].try_into().ok()?));
|
||||
let len = u64::from(u32::from_le_bytes(rec[10..14].try_into().ok()?));
|
||||
Some((lba, len))
|
||||
}
|
||||
|
||||
/// Extract the identifier from a directory record, normalizing ISO9660
|
||||
/// quirks: the `;N` version suffix and the trailing dot that strict
|
||||
/// mastering appends to extension-less names (`VMLINUZ.;1`). Without the
|
||||
/// dot strip, level-1 images' kernels never matched `/casper/vmlinuz`.
|
||||
fn dir_record_name(rec: &[u8]) -> String {
|
||||
let name_len = *rec.get(32).unwrap_or(&0) as usize;
|
||||
if name_len == 0 || rec.len() < 33 + name_len {
|
||||
return String::new();
|
||||
}
|
||||
let raw = &rec[33..33 + name_len];
|
||||
let s = String::from_utf8_lossy(raw);
|
||||
let s = s.rfind(';').map_or_else(|| s.as_ref(), |i| &s[..i]);
|
||||
s.strip_suffix('.').unwrap_or(s).to_string()
|
||||
}
|
||||
|
||||
// ── test support ─────────────────────────────────────────────────────
|
||||
//
|
||||
// A tiny ISO9660 image builder used by this module's tests, the
|
||||
// introspection tests, and (behind the `test-image` feature) other
|
||||
// crates' integration tests. Lays out: PVD @ LBA 16, optional El Torito
|
||||
// boot record @ 17, set terminator @ 18, directories from LBA 20, file
|
||||
// data after. Only what `lookup`/introspection read is populated.
|
||||
|
||||
#[cfg(any(test, feature = "test-image"))]
|
||||
#[doc(hidden)]
|
||||
pub mod testiso {
|
||||
use super::SECTOR;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Node {
|
||||
children: BTreeMap<String, Node>,
|
||||
content: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub struct TestIsoBuilder {
|
||||
root: Node,
|
||||
volume_label: String,
|
||||
el_torito: bool,
|
||||
}
|
||||
|
||||
impl TestIsoBuilder {
|
||||
pub fn new(volume_label: &str) -> Self {
|
||||
Self {
|
||||
root: Node::default(),
|
||||
volume_label: volume_label.to_string(),
|
||||
el_torito: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn el_torito(mut self, on: bool) -> Self {
|
||||
self.el_torito = on;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a file at `path` (e.g. "/casper/vmlinuz") with `content`.
|
||||
#[must_use]
|
||||
pub fn file(mut self, path: &str, content: &[u8]) -> Self {
|
||||
let mut node = &mut self.root;
|
||||
let comps: Vec<&str> = path
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect();
|
||||
for (i, c) in comps.iter().enumerate() {
|
||||
node = node.children.entry((*c).to_string()).or_default();
|
||||
if i + 1 == comps.len() {
|
||||
node.content = Some(content.to_vec());
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Vec<u8> {
|
||||
// Pass 1: allocate extents. Directories first (1 sector each),
|
||||
// then file contents.
|
||||
let mut next_lba: u64 = 20;
|
||||
let mut dirs: Vec<(*const Node, u64)> = Vec::new();
|
||||
fn alloc_dirs(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64)>) {
|
||||
out.push((std::ptr::from_ref(n), *next));
|
||||
*next += 1;
|
||||
for child in n.children.values() {
|
||||
if child.content.is_none() {
|
||||
alloc_dirs(child, next, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
alloc_dirs(&self.root, &mut next_lba, &mut dirs);
|
||||
let lba_of = |n: &Node| -> u64 {
|
||||
dirs.iter()
|
||||
.find(|(p, _)| std::ptr::eq(*p, n))
|
||||
.map(|(_, l)| *l)
|
||||
.expect("dir allocated")
|
||||
};
|
||||
let mut file_lbas: Vec<(*const Node, u64, usize)> = Vec::new();
|
||||
fn alloc_files(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64, usize)>) {
|
||||
for child in n.children.values() {
|
||||
if let Some(c) = &child.content {
|
||||
out.push((std::ptr::from_ref(child), *next, c.len()));
|
||||
*next += c.len().div_ceil(SECTOR as usize).max(1) as u64;
|
||||
} else {
|
||||
alloc_files(child, next, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
alloc_files(&self.root, &mut next_lba, &mut file_lbas);
|
||||
let file_lba_of = |n: &Node| -> u64 {
|
||||
file_lbas
|
||||
.iter()
|
||||
.find(|(p, _, _)| std::ptr::eq(*p, n))
|
||||
.map(|(_, l, _)| *l)
|
||||
.expect("file allocated")
|
||||
};
|
||||
|
||||
let total = next_lba as usize * SECTOR as usize;
|
||||
let mut img = vec![0u8; total];
|
||||
|
||||
// Directory record encoder.
|
||||
fn record(name_bytes: &[u8], lba: u64, len: u64, is_dir: bool) -> Vec<u8> {
|
||||
let mut rec_len = 33 + name_bytes.len();
|
||||
if rec_len % 2 == 1 {
|
||||
rec_len += 1; // pad to even
|
||||
}
|
||||
let rec_len = rec_len.max(34);
|
||||
let mut r = vec![0u8; rec_len];
|
||||
r[0] = rec_len as u8;
|
||||
r[2..6].copy_from_slice(&(lba as u32).to_le_bytes());
|
||||
r[6..10].copy_from_slice(&(lba as u32).to_be_bytes());
|
||||
r[10..14].copy_from_slice(&(len as u32).to_le_bytes());
|
||||
r[14..18].copy_from_slice(&(len as u32).to_be_bytes());
|
||||
if is_dir {
|
||||
r[25] = 0x02;
|
||||
}
|
||||
r[32] = name_bytes.len() as u8;
|
||||
r[33..33 + name_bytes.len()].copy_from_slice(name_bytes);
|
||||
r
|
||||
}
|
||||
|
||||
// Pass 2: write each directory extent.
|
||||
fn write_dir(
|
||||
img: &mut [u8],
|
||||
n: &Node,
|
||||
self_lba: u64,
|
||||
parent_lba: u64,
|
||||
lba_of: &dyn Fn(&Node) -> u64,
|
||||
file_lba_of: &dyn Fn(&Node) -> u64,
|
||||
) {
|
||||
let base = self_lba as usize * SECTOR as usize;
|
||||
let mut off = 0usize;
|
||||
let mut put = |rec: Vec<u8>, off: &mut usize| {
|
||||
img[base + *off..base + *off + rec.len()].copy_from_slice(&rec);
|
||||
*off += rec.len();
|
||||
};
|
||||
put(record(&[0x00], self_lba, SECTOR, true), &mut off);
|
||||
put(record(&[0x01], parent_lba, SECTOR, true), &mut off);
|
||||
for (name, child) in &n.children {
|
||||
if let Some(c) = &child.content {
|
||||
// Files get the ISO9660 uppercase `;1` treatment so
|
||||
// the case-insensitive + version-strip matching is
|
||||
// what the tests actually exercise.
|
||||
let stored = format!("{};1", name.to_ascii_uppercase());
|
||||
put(
|
||||
record(stored.as_bytes(), file_lba_of(child), c.len() as u64, false),
|
||||
&mut off,
|
||||
);
|
||||
} else {
|
||||
let stored = name.to_ascii_uppercase();
|
||||
put(
|
||||
record(stored.as_bytes(), lba_of(child), SECTOR, true),
|
||||
&mut off,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (name, child) in &n.children {
|
||||
if child.content.is_none() {
|
||||
write_dir(img, child, lba_of(child), self_lba, lba_of, file_lba_of);
|
||||
} else if let Some(c) = &child.content {
|
||||
let b = file_lba_of(child) as usize * SECTOR as usize;
|
||||
img[b..b + c.len()].copy_from_slice(c);
|
||||
}
|
||||
let _ = name;
|
||||
}
|
||||
}
|
||||
let root_lba = lba_of(&self.root);
|
||||
write_dir(
|
||||
&mut img,
|
||||
&self.root,
|
||||
root_lba,
|
||||
root_lba,
|
||||
&lba_of,
|
||||
&file_lba_of,
|
||||
);
|
||||
|
||||
// PVD @ 16.
|
||||
let pvd = 16 * SECTOR as usize;
|
||||
img[pvd] = 0x01;
|
||||
img[pvd + 1..pvd + 6].copy_from_slice(b"CD001");
|
||||
let label = self.volume_label.as_bytes();
|
||||
let label_field = &mut img[pvd + 40..pvd + 72];
|
||||
label_field.fill(b' ');
|
||||
label_field[..label.len().min(32)].copy_from_slice(&label[..label.len().min(32)]);
|
||||
let root_rec = record(&[0x00], root_lba, SECTOR, true);
|
||||
img[pvd + 156..pvd + 156 + 34].copy_from_slice(&root_rec[..34]);
|
||||
|
||||
// Optional El Torito boot record @ 17, terminator after.
|
||||
let mut vd = 17 * SECTOR as usize;
|
||||
if self.el_torito {
|
||||
img[vd] = 0x00;
|
||||
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
|
||||
let id = b"EL TORITO SPECIFICATION";
|
||||
img[vd + 7..vd + 7 + id.len()].copy_from_slice(id);
|
||||
vd += SECTOR as usize;
|
||||
}
|
||||
img[vd] = 0xFF;
|
||||
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
|
||||
|
||||
img
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory `IsoReadAt` over a built test image.
|
||||
pub struct MemReadAt(pub Vec<u8>);
|
||||
|
||||
impl super::IsoReadAt for MemReadAt {
|
||||
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
|
||||
let start = usize::try_from(offset).unwrap_or(usize::MAX);
|
||||
let end = start.saturating_add(len as usize);
|
||||
if end > self.0.len() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"read past end of test image",
|
||||
));
|
||||
}
|
||||
Ok(self.0[start..end].to_vec())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::testiso::{MemReadAt, TestIsoBuilder};
|
||||
use super::*;
|
||||
|
||||
fn block_on<T>(f: impl Future<Output = T>) -> T {
|
||||
futures::executor::block_on(f)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_finds_nested_file_case_insensitively() {
|
||||
let img = TestIsoBuilder::new("UBUNTU 24.04")
|
||||
.file("/casper/vmlinuz", b"KERNELDATA")
|
||||
.file("/casper/initrd", b"INITRDDATA")
|
||||
.build();
|
||||
let mut r = MemReadAt(img);
|
||||
let loc = block_on(lookup(&mut r, "/CASPER/VMLINUZ")).expect("found");
|
||||
assert_eq!(loc.length, 10);
|
||||
let bytes = block_on(r.read_at(loc.offset, 10)).unwrap();
|
||||
assert_eq!(&bytes, b"KERNELDATA");
|
||||
// Missing file and missing dir both miss cleanly.
|
||||
assert!(block_on(lookup(&mut r, "/casper/missing")).is_none());
|
||||
assert!(block_on(lookup(&mut r, "/nodir/vmlinuz")).is_none());
|
||||
// A directory path that resolves to a directory is not a file hit.
|
||||
assert!(block_on(lookup(&mut r, "/casper")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_mastered_extensionless_names_match() {
|
||||
// Strict level-1 mastering stores "VMLINUZ" as "VMLINUZ.;1" — the
|
||||
// trailing dot must be normalized away or kernels never match.
|
||||
let img = TestIsoBuilder::new("STRICT")
|
||||
.file("/boot/vmlinuz.", b"K") // builder stores "VMLINUZ.;1"
|
||||
.build();
|
||||
let mut r = MemReadAt(img);
|
||||
assert!(
|
||||
block_on(lookup(&mut r, "/boot/vmlinuz")).is_some(),
|
||||
"trailing-dot ISO9660 name must match the dotless path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_three_levels_deep() {
|
||||
let img = TestIsoBuilder::new("DEEP")
|
||||
.file("/images/pxeboot/vmlinuz", b"ANACONDA")
|
||||
.build();
|
||||
let mut r = MemReadAt(img);
|
||||
let loc = block_on(lookup(&mut r, "images/pxeboot/vmlinuz")).expect("no leading slash ok");
|
||||
assert_eq!(loc.length, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caching_reader_dedupes_repeated_directory_reads() {
|
||||
struct Counting<'a> {
|
||||
inner: &'a mut MemReadAt,
|
||||
calls: usize,
|
||||
}
|
||||
impl IsoReadAt for Counting<'_> {
|
||||
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
|
||||
self.calls += 1;
|
||||
self.inner.read_at(offset, len).await
|
||||
}
|
||||
}
|
||||
let img = TestIsoBuilder::new("CACHE")
|
||||
.file("/a/one", b"1")
|
||||
.file("/a/two", b"2")
|
||||
.build();
|
||||
let mut mem = MemReadAt(img);
|
||||
let mut counting = Counting {
|
||||
inner: &mut mem,
|
||||
calls: 0,
|
||||
};
|
||||
let mut cr = CachingReadAt::new(&mut counting);
|
||||
assert!(block_on(exists(&mut cr, "/a/one")));
|
||||
assert!(block_on(exists(&mut cr, "/a/two")));
|
||||
assert!(!block_on(exists(&mut cr, "/a/three")));
|
||||
drop(cr);
|
||||
// 3 probes × (PVD + root dir + subdir) = 9 uncached; the cache
|
||||
// collapses the repeats to the 3 distinct extents.
|
||||
assert_eq!(counting.calls, 3, "all repeat reads must hit the cache");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_local_reads_a_real_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("t.iso");
|
||||
let img = TestIsoBuilder::new("LOCAL")
|
||||
.file("/sources/boot.wim", b"WIMWIM")
|
||||
.build();
|
||||
std::fs::write(&p, &img).unwrap();
|
||||
let loc = lookup_local(&p, "/sources/boot.wim").expect("found");
|
||||
assert_eq!(loc.length, 6);
|
||||
assert!(lookup_local(&p, "/sources/none").is_none());
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,15 @@
|
||||
|
||||
pub mod entry;
|
||||
pub mod introspect;
|
||||
// v0.7.4: read-only ISO9660 walker generic over any random-access byte
|
||||
// source (local file, NFS READ3, SFTP seek-read). Powers both in-ISO
|
||||
// HTTP serving and the probe-based introspection.
|
||||
pub mod iso_fs;
|
||||
pub mod nfs_share;
|
||||
pub mod pxe_logo;
|
||||
// v0.7.4: persisted cache of remote-share introspection results so a
|
||||
// container restart doesn't re-probe an unchanged 40-ISO library.
|
||||
pub mod remote_cache;
|
||||
pub mod sftp_share;
|
||||
pub mod smb;
|
||||
pub mod smb_share;
|
||||
@@ -29,6 +36,7 @@ pub mod windows;
|
||||
|
||||
pub use entry::{BootEntry, BootKind, KernelArgs};
|
||||
pub use introspect::{DistroFamily, IntrospectionReport};
|
||||
pub use iso_fs::FileLocation;
|
||||
// v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace
|
||||
// `smbclient` CLI replaced it — works in any container (no
|
||||
// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and
|
||||
|
||||
@@ -57,14 +57,16 @@
|
||||
//! UI to ask for. (If a future server needs Kerberos or non-default
|
||||
//! uid mapping we can add those, but for ISO read access nobody does.)
|
||||
|
||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
||||
use crate::introspect::{introspect_reader, provisional_report};
|
||||
use crate::iso_fs::{self, FileLocation, IsoReadAt};
|
||||
use crate::remote_cache::RemoteIntrospectCache;
|
||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||
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;
|
||||
@@ -100,6 +102,18 @@ const READ_CHUNK_BYTES: u32 = 64 * 1024;
|
||||
/// client park gigabytes of decoded ISO in RAM.
|
||||
const STREAM_BUFFER_DEPTH: usize = 16;
|
||||
|
||||
/// v0.7.4: per-ISO budget for a background introspection probe. A probe
|
||||
/// is one connection plus a few dozen KiB-sized reads — sub-second on a
|
||||
/// LAN — so anything past this is a wedged server, not a slow one.
|
||||
const INTROSPECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// One queued background-introspection unit (v0.7.4).
|
||||
struct ProbeJob {
|
||||
iso_id: String,
|
||||
filename: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// One configured NFS share. The id is derived from server+export so
|
||||
/// re-adding the same coordinates is idempotent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -177,6 +191,9 @@ pub struct NfsShareManager {
|
||||
/// opens its own NFS connection so concurrency isn't a hard
|
||||
/// requirement, but serializing keeps log output predictable.
|
||||
op_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
/// v0.7.4: persisted introspection results keyed `share/path@size`,
|
||||
/// so a restart re-probes only new or replaced ISOs.
|
||||
introspect_cache: RemoteIntrospectCache,
|
||||
}
|
||||
|
||||
impl NfsShareManager {
|
||||
@@ -190,6 +207,7 @@ impl NfsShareManager {
|
||||
inner: Arc::new(Mutex::new(Inner::default())),
|
||||
iso_store,
|
||||
op_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
introspect_cache: RemoteIntrospectCache::open(work_dir, "nfs_introspect_cache.json"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,10 +238,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 +273,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 +350,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 +374,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 ─────────────────────────────────────────────────────
|
||||
@@ -394,18 +405,26 @@ impl NfsShareManager {
|
||||
};
|
||||
|
||||
let mut count = 0u32;
|
||||
let mut to_probe: Vec<ProbeJob> = Vec::new();
|
||||
for entry in listing {
|
||||
let iso_id = format!("nfs-{}-{}", share.id, slugify_str(&entry.filename));
|
||||
// Same approach as SMB: no real introspection over the
|
||||
// network in v0.4.67. The boot-entry generator falls back
|
||||
// to filename-based sanboot detection.
|
||||
let report = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: Vec::new(),
|
||||
has_boot_wim: false,
|
||||
};
|
||||
// v0.7.4: real introspection over the share — NFSv3 READ3
|
||||
// takes an offset, so the ISO9660 probes work remotely. A
|
||||
// cache hit registers the full report immediately; a miss
|
||||
// registers a provisional filename-based report (so the scan
|
||||
// returns fast) and queues a background probe that upgrades
|
||||
// the entry in place.
|
||||
let cached = self
|
||||
.introspect_cache
|
||||
.get(&share.id, &entry.filename, entry.size);
|
||||
let report = cached.unwrap_or_else(|| {
|
||||
to_probe.push(ProbeJob {
|
||||
iso_id: iso_id.clone(),
|
||||
filename: entry.filename.clone(),
|
||||
size: entry.size,
|
||||
});
|
||||
provisional_report(&entry.filename)
|
||||
});
|
||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||
let source = IsoSource::Nfs {
|
||||
share_id: share.id.clone(),
|
||||
@@ -426,11 +445,88 @@ impl NfsShareManager {
|
||||
target: "openpxe::nfs",
|
||||
id = %id, server = %share.server, export = %share.export,
|
||||
iso_count = count,
|
||||
pending_introspection = to_probe.len(),
|
||||
"NFS share scanned"
|
||||
);
|
||||
if !to_probe.is_empty() {
|
||||
self.spawn_introspection_pass(&share, to_probe);
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// v0.7.4: probe each queued ISO over its own NFS connection and swap
|
||||
/// the full introspection into the store as results land. Runs
|
||||
/// detached so neither startup nor the share-add API call waits on a
|
||||
/// 40-ISO library; per-ISO failures (or a share removed mid-pass)
|
||||
/// leave the provisional entry in place, which still sanboots.
|
||||
fn spawn_introspection_pass(&self, share: &NfsShare, work: Vec<ProbeJob>) {
|
||||
let store = self.iso_store.clone();
|
||||
let cache = self.introspect_cache.clone();
|
||||
let share_id = share.id.clone();
|
||||
let server = share.server.clone();
|
||||
let export = share.export.clone();
|
||||
let port = share.port;
|
||||
let queued = work.len();
|
||||
tokio::spawn(async move {
|
||||
let mut upgraded = 0usize;
|
||||
for job in work {
|
||||
let probe = async {
|
||||
let mut reader = NfsReadAt::open(&server, &export, port, &job.filename).await?;
|
||||
let report =
|
||||
introspect_reader(&mut reader, job.size, &job.filename, false).await;
|
||||
reader.finish().await;
|
||||
Ok::<_, NfsClientError>(report)
|
||||
};
|
||||
match tokio::time::timeout(INTROSPECT_TIMEOUT, probe).await {
|
||||
Ok(Ok(report)) => {
|
||||
cache.put(&share_id, &job.filename, job.size, report.clone());
|
||||
if store.update_external_introspection(&job.iso_id, report) {
|
||||
upgraded += 1;
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => tracing::warn!(
|
||||
target: "openpxe::nfs",
|
||||
share = %share_id, iso = %job.filename,
|
||||
"introspection failed: {e}"
|
||||
),
|
||||
Err(_) => tracing::warn!(
|
||||
target: "openpxe::nfs",
|
||||
share = %share_id, iso = %job.filename,
|
||||
"introspection timed out after {}s", INTROSPECT_TIMEOUT.as_secs()
|
||||
),
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
target: "openpxe::nfs",
|
||||
share = %share_id, queued, upgraded,
|
||||
"remote introspection pass complete"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// v0.7.4: locate `in_iso_path` inside a share-hosted ISO. Returns the
|
||||
/// byte range so the HTTP layer can serve kernel/initrd files out of
|
||||
/// remote ISOs with a follow-up ranged [`Self::stream_iso`].
|
||||
pub async fn locate_in_iso(
|
||||
&self,
|
||||
share_id: &str,
|
||||
filename: &str,
|
||||
in_iso_path: &str,
|
||||
) -> Result<Option<FileLocation>> {
|
||||
let share = self
|
||||
.get(share_id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such NFS share '{share_id}'")))?;
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
||||
}
|
||||
let mut reader = NfsReadAt::open(&share.server, &share.export, share.port, filename)
|
||||
.await
|
||||
.map_err(|e| Error::Invalid(format!("nfs open '{filename}': {e}")))?;
|
||||
let loc = iso_fs::lookup(&mut reader, in_iso_path).await;
|
||||
reader.finish().await;
|
||||
Ok(loc)
|
||||
}
|
||||
|
||||
fn update_status(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -503,6 +599,96 @@ struct NfsListEntry {
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// The connection type [`build_connection`] yields.
|
||||
type NfsConn = nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>;
|
||||
|
||||
/// v0.7.4: random-access reader over one NFS connection + file handle —
|
||||
/// the [`IsoReadAt`] impl that lets the ISO9660 walker and introspection
|
||||
/// probes run against share-hosted images.
|
||||
struct NfsReadAt {
|
||||
conn: NfsConn,
|
||||
fh: nfs_fh3,
|
||||
}
|
||||
|
||||
impl NfsReadAt {
|
||||
/// Connect, mount, and LOOKUP `filename` at the export root.
|
||||
async fn open(
|
||||
server: &str,
|
||||
export: &str,
|
||||
port: u16,
|
||||
filename: &str,
|
||||
) -> std::result::Result<Self, NfsClientError> {
|
||||
let mut conn = build_connection(server, export, port).await?;
|
||||
let root = conn.root_nfs_fh3();
|
||||
let lookup = conn
|
||||
.lookup(&LOOKUP3args {
|
||||
what: diropargs3 {
|
||||
dir: root,
|
||||
name: filename3(Opaque::borrowed(filename.as_bytes())),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.map_err(NfsClientError::Rpc)?;
|
||||
let fh = match lookup {
|
||||
Nfs3Result::Ok(o) => o.object,
|
||||
Nfs3Result::Err((status, _)) => {
|
||||
return Err(NfsClientError::Nfsstat(status_label(status)));
|
||||
}
|
||||
};
|
||||
Ok(Self { conn, fh })
|
||||
}
|
||||
|
||||
/// Best-effort unmount. Consumes the reader — it's done.
|
||||
async fn finish(self) {
|
||||
let _ = self.conn.unmount().await;
|
||||
}
|
||||
}
|
||||
|
||||
impl IsoReadAt for NfsReadAt {
|
||||
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
|
||||
let mut out: Vec<u8> = Vec::with_capacity(len as usize);
|
||||
let mut off = offset;
|
||||
// READ3 may legally return fewer bytes than asked (server cap);
|
||||
// loop until the exact-read contract is satisfied or the file
|
||||
// genuinely ends short.
|
||||
while (out.len() as u32) < len {
|
||||
let want = (len - out.len() as u32).min(READ_CHUNK_BYTES);
|
||||
let res = self
|
||||
.conn
|
||||
.read(&READ3args {
|
||||
file: self.fh.clone(),
|
||||
offset: off,
|
||||
count: want,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let ok = match res {
|
||||
Nfs3Result::Ok(o) => o,
|
||||
Nfs3Result::Err((status, _)) => {
|
||||
return Err(std::io::Error::other(status_label(status)));
|
||||
}
|
||||
};
|
||||
let data = ok.data.as_ref();
|
||||
if data.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"NFS read past end of file",
|
||||
));
|
||||
}
|
||||
out.extend_from_slice(data);
|
||||
off += data.len() as u64;
|
||||
if ok.eof && (out.len() as u32) < len {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"NFS read past end of file",
|
||||
));
|
||||
}
|
||||
}
|
||||
out.truncate(len as usize);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect, READDIR the export root, look up each `*.iso` to get its
|
||||
/// size + file handle. Returns a flat list. Errors are returned with
|
||||
/// a human-readable message; the caller decides how to surface them.
|
||||
@@ -988,8 +1174,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]
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Persisted cache of remote-share introspection results.
|
||||
//!
|
||||
//! NFS/SFTP introspection costs a connection plus a few dozen small
|
||||
//! reads per ISO. Shares are rescanned on every startup and share-add,
|
||||
//! so without a cache a 40-ISO library would re-probe 40 ISOs on every
|
||||
//! container restart. The cache keys on `share/path@size` — a replaced
|
||||
//! file (new size) re-probes, an untouched one is free — and entries
|
||||
//! only count as hits when their `introspect_rev` matches the current
|
||||
//! logic, so an upgrade that changes detection re-probes everything
|
||||
//! exactly once.
|
||||
//!
|
||||
//! One file per protocol (`nfs_introspect_cache.json`,
|
||||
//! `sftp_introspect_cache.json`) so the two managers never contend over
|
||||
//! one writer.
|
||||
|
||||
use crate::introspect::{IntrospectionReport, INTROSPECT_REV};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Hard cap on cached entries; beyond it the cache resets rather than
|
||||
/// growing unbounded (a cache wipe only costs one re-probe pass).
|
||||
const MAX_ENTRIES: usize = 4096;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteIntrospectCache {
|
||||
path: Arc<PathBuf>,
|
||||
map: Arc<Mutex<HashMap<String, IntrospectionReport>>>,
|
||||
}
|
||||
|
||||
impl RemoteIntrospectCache {
|
||||
/// Open (or start empty) the cache at `<work_dir>/<file_name>`.
|
||||
/// A corrupt or missing file is an empty cache, never an error.
|
||||
#[must_use]
|
||||
pub fn open(work_dir: &Path, file_name: &str) -> Self {
|
||||
let path = work_dir.join(file_name);
|
||||
let map = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|text| {
|
||||
serde_json::from_str::<HashMap<String, IntrospectionReport>>(&text).ok()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
path: Arc::new(path),
|
||||
map: Arc::new(Mutex::new(map)),
|
||||
}
|
||||
}
|
||||
|
||||
fn key(share_id: &str, relative_path: &str, size: u64) -> String {
|
||||
format!("{share_id}/{relative_path}@{size}")
|
||||
}
|
||||
|
||||
/// A hit requires the entry to have been produced by the *current*
|
||||
/// introspection logic — stale-rev entries are misses, which is how
|
||||
/// the cache self-invalidates across upgrades.
|
||||
#[must_use]
|
||||
pub fn get(
|
||||
&self,
|
||||
share_id: &str,
|
||||
relative_path: &str,
|
||||
size: u64,
|
||||
) -> Option<IntrospectionReport> {
|
||||
self.map
|
||||
.lock()
|
||||
.get(&Self::key(share_id, relative_path, size))
|
||||
.filter(|r| r.introspect_rev == INTROSPECT_REV)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn put(&self, share_id: &str, relative_path: &str, size: u64, report: IntrospectionReport) {
|
||||
let snapshot = {
|
||||
let mut g = self.map.lock();
|
||||
if g.len() >= MAX_ENTRIES {
|
||||
g.clear();
|
||||
}
|
||||
g.insert(Self::key(share_id, relative_path, size), report);
|
||||
g.clone()
|
||||
};
|
||||
// Persist outside the lock; tmp+rename so a crash mid-write
|
||||
// leaves the previous cache intact.
|
||||
let path = self.path.as_path();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let Ok(body) = serde_json::to_vec_pretty(&snapshot) else {
|
||||
return;
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if std::fs::write(&tmp, body).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::introspect::DistroFamily;
|
||||
|
||||
#[test]
|
||||
fn round_trips_across_reopen_and_rev_gates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = RemoteIntrospectCache::open(dir.path(), "t.json");
|
||||
assert!(cache.get("s1", "a.iso", 100).is_none());
|
||||
|
||||
let fresh = IntrospectionReport {
|
||||
family: DistroFamily::RhelFedora,
|
||||
introspect_rev: INTROSPECT_REV,
|
||||
el_torito: true,
|
||||
..Default::default()
|
||||
};
|
||||
cache.put("s1", "a.iso", 100, fresh.clone());
|
||||
assert_eq!(
|
||||
cache.get("s1", "a.iso", 100).unwrap().family,
|
||||
DistroFamily::RhelFedora
|
||||
);
|
||||
// Different size = different file = miss.
|
||||
assert!(cache.get("s1", "a.iso", 101).is_none());
|
||||
|
||||
// Survives a reopen.
|
||||
let cache2 = RemoteIntrospectCache::open(dir.path(), "t.json");
|
||||
assert!(cache2.get("s1", "a.iso", 100).is_some());
|
||||
|
||||
// Stale-rev entries never hit.
|
||||
let stale = IntrospectionReport {
|
||||
family: DistroFamily::Arch,
|
||||
introspect_rev: INTROSPECT_REV - 1,
|
||||
..Default::default()
|
||||
};
|
||||
cache2.put("s1", "b.iso", 7, stale);
|
||||
assert!(cache2.get("s1", "b.iso", 7).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_cache_file_starts_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("t.json"), b"{nope").unwrap();
|
||||
let cache = RemoteIntrospectCache::open(dir.path(), "t.json");
|
||||
assert!(cache.get("s", "x.iso", 1).is_none());
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,9 @@
|
||||
//! hint}` error shape is shared so the storage tab renders all three
|
||||
//! protocols through one code path.
|
||||
|
||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
||||
use crate::introspect::{introspect_reader, provisional_report};
|
||||
use crate::iso_fs::{self, FileLocation, IsoReadAt};
|
||||
use crate::remote_cache::RemoteIntrospectCache;
|
||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||
use bytes::Bytes;
|
||||
use openpxe_core::{Error, Result};
|
||||
@@ -96,6 +98,18 @@ const READ_CHUNK_BYTES: usize = 64 * 1024;
|
||||
/// body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream.
|
||||
const STREAM_BUFFER_DEPTH: usize = 16;
|
||||
|
||||
/// v0.7.4: per-ISO budget for a background introspection probe — one SSH
|
||||
/// connection plus a few dozen KiB-sized reads. SSH handshakes cost more
|
||||
/// than NFS mounts, but 30s still only trips on a wedged server.
|
||||
const INTROSPECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// One queued background-introspection unit (v0.7.4).
|
||||
struct ProbeJob {
|
||||
iso_id: String,
|
||||
filename: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// Which credential the share authenticates with. The secret itself
|
||||
/// lives in the 0600 creds file, never here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -209,6 +223,9 @@ pub struct SftpShareManager {
|
||||
/// Serializes scan operations on the same manager for predictable
|
||||
/// log output; each scan opens its own SSH connection.
|
||||
op_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
/// v0.7.4: persisted introspection results keyed `share/path@size`,
|
||||
/// so a restart re-probes only new or replaced ISOs.
|
||||
introspect_cache: RemoteIntrospectCache,
|
||||
}
|
||||
|
||||
impl SftpShareManager {
|
||||
@@ -222,6 +239,7 @@ impl SftpShareManager {
|
||||
inner: Arc::new(Mutex::new(Inner::default())),
|
||||
iso_store,
|
||||
op_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
introspect_cache: RemoteIntrospectCache::open(work_dir, "sftp_introspect_cache.json"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,19 +492,25 @@ impl SftpShareManager {
|
||||
};
|
||||
|
||||
let mut count = 0u32;
|
||||
let mut to_probe: Vec<ProbeJob> = Vec::new();
|
||||
for entry in listing {
|
||||
let iso_id = format!("sftp-{}-{}", share.id, slugify_str(&entry.filename));
|
||||
// Same as NFS/SMB: no over-the-network introspection yet, so
|
||||
// register `Unknown` and let the boot-entry generator fall
|
||||
// back to filename-based detection. SFTP *could* do bounded
|
||||
// PVD reads (it has random access) — a follow-up can add it.
|
||||
let report = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: Vec::new(),
|
||||
has_boot_wim: false,
|
||||
};
|
||||
// v0.7.4: real introspection over the share — SFTP file
|
||||
// handles are seekable, so the ISO9660 probes work remotely.
|
||||
// Cache hit → full report now; miss → provisional filename-
|
||||
// based report (scan returns fast) + a queued background
|
||||
// probe that upgrades the entry in place.
|
||||
let cached = self
|
||||
.introspect_cache
|
||||
.get(&share.id, &entry.filename, entry.size);
|
||||
let report = cached.unwrap_or_else(|| {
|
||||
to_probe.push(ProbeJob {
|
||||
iso_id: iso_id.clone(),
|
||||
filename: entry.filename.clone(),
|
||||
size: entry.size,
|
||||
});
|
||||
provisional_report(&entry.filename)
|
||||
});
|
||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||
let source = IsoSource::Sftp {
|
||||
share_id: share.id.clone(),
|
||||
@@ -512,11 +536,88 @@ impl SftpShareManager {
|
||||
target: "openpxe::sftp",
|
||||
id = %id, server = %share.server, export = %share.export,
|
||||
iso_count = count,
|
||||
pending_introspection = to_probe.len(),
|
||||
"SFTP share scanned"
|
||||
);
|
||||
if !to_probe.is_empty() {
|
||||
self.spawn_introspection_pass(&share, &creds, to_probe);
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// v0.7.4: probe each queued ISO over its own SSH connection and swap
|
||||
/// the full introspection into the store as results land. Detached so
|
||||
/// neither startup nor the share-add API call waits on a big library;
|
||||
/// per-ISO failures leave the provisional entry, which still sanboots.
|
||||
fn spawn_introspection_pass(&self, share: &SftpShare, creds: &SftpCreds, work: Vec<ProbeJob>) {
|
||||
let store = self.iso_store.clone();
|
||||
let cache = self.introspect_cache.clone();
|
||||
let share_id = share.id.clone();
|
||||
let params = ConnParams::from_share(share);
|
||||
let creds = creds.clone();
|
||||
let queued = work.len();
|
||||
tokio::spawn(async move {
|
||||
let mut upgraded = 0usize;
|
||||
for job in work {
|
||||
let probe = async {
|
||||
let mut reader = SftpReadAt::open(¶ms, &creds, &job.filename).await?;
|
||||
let report =
|
||||
introspect_reader(&mut reader, job.size, &job.filename, false).await;
|
||||
Ok::<_, SftpClientError>(report)
|
||||
};
|
||||
match tokio::time::timeout(INTROSPECT_TIMEOUT, probe).await {
|
||||
Ok(Ok(report)) => {
|
||||
cache.put(&share_id, &job.filename, job.size, report.clone());
|
||||
if store.update_external_introspection(&job.iso_id, report) {
|
||||
upgraded += 1;
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => tracing::warn!(
|
||||
target: "openpxe::sftp",
|
||||
share = %share_id, iso = %job.filename,
|
||||
"introspection failed: {e}"
|
||||
),
|
||||
Err(_) => tracing::warn!(
|
||||
target: "openpxe::sftp",
|
||||
share = %share_id, iso = %job.filename,
|
||||
"introspection timed out after {}s", INTROSPECT_TIMEOUT.as_secs()
|
||||
),
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
target: "openpxe::sftp",
|
||||
share = %share_id, queued, upgraded,
|
||||
"remote introspection pass complete"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// v0.7.4: locate `in_iso_path` inside a share-hosted ISO. Returns the
|
||||
/// byte range so the HTTP layer can serve kernel/initrd files out of
|
||||
/// remote ISOs with a follow-up ranged [`Self::stream_iso`].
|
||||
pub async fn locate_in_iso(
|
||||
&self,
|
||||
share_id: &str,
|
||||
filename: &str,
|
||||
in_iso_path: &str,
|
||||
) -> Result<Option<FileLocation>> {
|
||||
let share = self
|
||||
.get(share_id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such SFTP share '{share_id}'")))?;
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
||||
}
|
||||
let creds = self
|
||||
.read_creds(share_id)
|
||||
.await
|
||||
.map_err(|e| Error::Invalid(format!("could not read credentials: {e}")))?;
|
||||
let params = ConnParams::from_share(&share);
|
||||
let mut reader = SftpReadAt::open(¶ms, &creds, filename)
|
||||
.await
|
||||
.map_err(|e| Error::Invalid(format!("sftp open '{filename}': {e}")))?;
|
||||
Ok(iso_fs::lookup(&mut reader, in_iso_path).await)
|
||||
}
|
||||
|
||||
fn pin_fingerprint(&self, id: &str, fingerprint: String) {
|
||||
if fingerprint.is_empty() {
|
||||
return;
|
||||
@@ -658,6 +759,43 @@ struct SftpConn {
|
||||
sftp: SftpSession,
|
||||
}
|
||||
|
||||
/// v0.7.4: random-access reader over one SSH connection + open file
|
||||
/// handle — the [`IsoReadAt`] impl that lets the ISO9660 walker and
|
||||
/// introspection probes run against share-hosted images. Holds the
|
||||
/// `SftpConn` so the SSH session outlives every read.
|
||||
struct SftpReadAt {
|
||||
_conn: SftpConn,
|
||||
file: russh_sftp::client::fs::File,
|
||||
}
|
||||
|
||||
impl SftpReadAt {
|
||||
async fn open(
|
||||
p: &ConnParams,
|
||||
creds: &SftpCreds,
|
||||
filename: &str,
|
||||
) -> std::result::Result<Self, SftpClientError> {
|
||||
let (conn, _fp) = connect(p, creds).await?;
|
||||
let full = format!("{}/{}", p.export.trim_end_matches('/'), filename);
|
||||
let file = conn
|
||||
.sftp
|
||||
.open(full)
|
||||
.await
|
||||
.map_err(|e| SftpClientError::Sftp(e.to_string()))?;
|
||||
Ok(Self { _conn: conn, file })
|
||||
}
|
||||
}
|
||||
|
||||
impl IsoReadAt for SftpReadAt {
|
||||
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
|
||||
self.file.seek(SeekFrom::Start(offset)).await?;
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
// read_exact loops over the transport's short reads and fails
|
||||
// with UnexpectedEof past end-of-file — exactly the contract.
|
||||
self.file.read_exact(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// russh client handler implementing trust-on-first-use host-key
|
||||
/// verification. We never construct an `Err` from `check_server_key`;
|
||||
/// returning `Ok(false)` makes russh abort the handshake, and the
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
//! streaming. A follow-up release can add libsmbclient-based seek if
|
||||
//! a real workload needs it.
|
||||
|
||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
||||
use crate::introspect::provisional_report;
|
||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||
use openpxe_core::{Error, Result};
|
||||
use parking_lot::Mutex;
|
||||
@@ -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
|
||||
@@ -462,21 +455,14 @@ impl SmbShareManager {
|
||||
let mut count = 0u32;
|
||||
for entry in listing {
|
||||
let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename));
|
||||
// SMB sources don't get a real introspection pass — that
|
||||
// would require seeking into the ISO9660 PVD over the
|
||||
// network, and smbclient CLI doesn't seek. We register an
|
||||
// `Unknown` family so the boot-entry generator falls back
|
||||
// to generic sanboot/wimboot detection from the filename
|
||||
// and the operator gets *something* bootable. A follow-up
|
||||
// release can do a bounded `smbclient get` of the first
|
||||
// 64 KiB for real detection.
|
||||
let report = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: Vec::new(),
|
||||
has_boot_wim: false,
|
||||
};
|
||||
// SMB sources can't get the content-probe pass NFS/SFTP got
|
||||
// in v0.7.4 — the ISO9660 probes need seeks, and smbclient's
|
||||
// CLI streaming doesn't seek. The provisional filename-token
|
||||
// report is as far as SMB detection goes: family for the UI
|
||||
// when the name says it ("rhel-9.0…", "Win11_…"), and
|
||||
// `introspect_rev = 0` so the entry generator keeps the
|
||||
// optimistic sanboot entry.
|
||||
let report = provisional_report(&entry.filename);
|
||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||
let source = IsoSource::Smb {
|
||||
share_id: share.id.clone(),
|
||||
@@ -543,10 +529,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() {
|
||||
@@ -757,10 +740,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 {
|
||||
@@ -937,8 +917,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"));
|
||||
}
|
||||
|
||||
|
||||
+160
-33
@@ -223,7 +223,8 @@ impl IsoStore {
|
||||
continue;
|
||||
}
|
||||
if let Ok(text) = tokio::fs::read_to_string(&p).await {
|
||||
if let Ok(meta) = serde_json::from_str::<IsoMeta>(&text) {
|
||||
if let Ok(mut meta) = serde_json::from_str::<IsoMeta>(&text) {
|
||||
self.reintrospect_if_stale(&mut meta).await;
|
||||
self.insert(meta);
|
||||
}
|
||||
}
|
||||
@@ -231,6 +232,46 @@ impl IsoStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v0.5.9: re-run introspection on a *local* ISO whose persisted report
|
||||
/// predates the current logic. ISOs uploaded by an older binary carry a
|
||||
/// stale family/boot profile — most visibly a Windows 11 ISO tagged
|
||||
/// `Unknown` before the UDF/El-Torito detection landed, which then shows
|
||||
/// as "won't boot" forever. Re-probing on startup fixes them in place,
|
||||
/// no delete-and-re-upload. Bounded: only `Local` sources (we have the
|
||||
/// bytes locally) below [`introspect::INTROSPECT_REV`], so it runs at
|
||||
/// most once per ISO per upgrade. The probe reads up to ~64 MiB, so we
|
||||
/// push it onto the blocking pool to keep the async runtime responsive.
|
||||
async fn reintrospect_if_stale(&self, meta: &mut IsoMeta) {
|
||||
if !matches!(meta.source, IsoSource::Local)
|
||||
|| meta.introspection.introspect_rev >= crate::introspect::INTROSPECT_REV
|
||||
{
|
||||
return;
|
||||
}
|
||||
let path = self.iso_path(&meta.id);
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let Ok(fresh) = tokio::task::spawn_blocking(move || introspect(&path)).await else {
|
||||
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect task failed");
|
||||
return;
|
||||
};
|
||||
let before = meta.introspection.family;
|
||||
meta.introspection = fresh;
|
||||
meta.boot_entries = generate_boot_entries(&meta.id, &meta.filename, &meta.introspection);
|
||||
if let Err(e) = self.persist_meta(meta).await {
|
||||
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect persist: {e}");
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
target: "openpxe::iso",
|
||||
id = %meta.id,
|
||||
from = ?before,
|
||||
to = ?meta.introspection.family,
|
||||
el_torito = meta.introspection.el_torito,
|
||||
"re-introspected stale ISO metadata"
|
||||
);
|
||||
}
|
||||
|
||||
fn insert(&self, meta: IsoMeta) {
|
||||
self.inner.write().isos.insert(meta.id.clone(), meta);
|
||||
}
|
||||
@@ -301,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 {
|
||||
@@ -365,6 +415,28 @@ impl IsoStore {
|
||||
self.inner.write().isos.insert(id, meta);
|
||||
}
|
||||
|
||||
/// v0.7.4: swap in a completed introspection for an external ISO and
|
||||
/// regenerate its boot entries. Used by the NFS/SFTP managers'
|
||||
/// background probe pass — the scan registers a provisional
|
||||
/// (filename-only) report immediately so startup and share-add stay
|
||||
/// fast, then this upgrades each entry as its probe finishes.
|
||||
/// Operator-set fields (category, password) are preserved; returns
|
||||
/// `false` when the id is gone (share removed or re-scanned away
|
||||
/// mid-probe), which callers treat as a benign no-op.
|
||||
pub fn update_external_introspection(
|
||||
&self,
|
||||
id: &str,
|
||||
introspection: IntrospectionReport,
|
||||
) -> bool {
|
||||
let mut g = self.inner.write();
|
||||
let Some(m) = g.isos.get_mut(id) else {
|
||||
return false;
|
||||
};
|
||||
m.boot_entries = generate_boot_entries(&m.id, &m.filename, &introspection);
|
||||
m.introspection = introspection;
|
||||
true
|
||||
}
|
||||
|
||||
/// Drop every entry that belongs to `share_id`. Used by the SMB
|
||||
/// and NFS share managers when an operator removes a share, or
|
||||
/// before re-scanning to clean out stale entries. The same id
|
||||
@@ -557,22 +629,22 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
|
||||
.clone()
|
||||
.unwrap_or_else(|| filename.to_string());
|
||||
match r.family {
|
||||
DistroFamily::WindowsPe if r.has_boot_wim => {
|
||||
// Standard wimboot chain. Paths are in-ISO; the HTTP layer maps
|
||||
// `iso/<id>/<path>` to on-disk extraction via ISO9660 lookup.
|
||||
let base = format!("iso/{id}");
|
||||
DistroFamily::WindowsPe => {
|
||||
// v0.5.8: boot Windows directly via iPXE HTTP sanboot. iPXE
|
||||
// exposes the raw ISO as an emulated CD backed by on-demand
|
||||
// HTTP range reads, and Windows Setup boots from it. This
|
||||
// replaces the old wimboot+SMB chain, which (a) needed an SMB
|
||||
// server the host often can't provide (port 445 collisions),
|
||||
// (b) served in-ISO files via an ISO9660 lookup that failed on
|
||||
// UDF-only Windows 11 ISOs, and (c) required an operator
|
||||
// toggle. sanboot needs none of that — just the HTTP port,
|
||||
// which works in any environment. The unmodified, stock ISO is
|
||||
// served at iso/<id>.iso; nothing is injected into Windows.
|
||||
vec![BootEntry {
|
||||
id: format!("{id}-winpe"),
|
||||
title: format!("{title} (Windows / wimboot)"),
|
||||
kind: BootKind::Wimboot {
|
||||
wimboot_url: "ipxe/wimboot".to_string(),
|
||||
files: vec![
|
||||
("bootmgr".into(), format!("{base}/bootmgr")),
|
||||
("bootmgr.efi".into(), format!("{base}/bootmgr.efi")),
|
||||
("bcd".into(), format!("{base}/boot/bcd")),
|
||||
("boot.sdi".into(), format!("{base}/boot/boot.sdi")),
|
||||
("boot.wim".into(), format!("{base}/sources/boot.wim")),
|
||||
],
|
||||
id: format!("{id}-windows"),
|
||||
title: format!("{title} (Windows)"),
|
||||
kind: BootKind::SanBootIso {
|
||||
iso_url: format!("iso/{id}.iso"),
|
||||
},
|
||||
}]
|
||||
}
|
||||
@@ -598,15 +670,33 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
|
||||
}]
|
||||
}
|
||||
_ => {
|
||||
// Last-resort SAN boot. Won't work for large modern ISOs, but
|
||||
// lets the ISO at least appear in the menu.
|
||||
vec![BootEntry {
|
||||
id: format!("{id}-sanboot"),
|
||||
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
|
||||
kind: BootKind::SanBootIso {
|
||||
iso_url: format!("iso/{id}.iso"),
|
||||
},
|
||||
}]
|
||||
// No Windows-install media and no Linux kernel/initrd. Decide
|
||||
// whether the ISO is bootable at all (v0.6.0):
|
||||
// * `el_torito` — it carries a boot catalog, so iPXE sanboots
|
||||
// the raw image as an emulated CD: BSDs, ESXi/VMvisor
|
||||
// installers, firmware tools, custom spins. The emulated CD
|
||||
// is backed by HTTP range reads, so ISO size is a non-issue
|
||||
// (this is the same path Windows uses since v0.5.8) — hence
|
||||
// no more "may fail for >1GiB ISOs" disclaimer.
|
||||
// * `introspect_rev == 0` — a remote-share ISO we couldn't
|
||||
// introspect (SMB/NFS/SFTP listings don't seek into the ISO).
|
||||
// Offer sanboot optimistically rather than hide a
|
||||
// likely-bootable installer.
|
||||
// Otherwise it's a local image we *did* introspect and found to
|
||||
// carry no boot catalog — a data/appliance ISO (e.g. a VMware
|
||||
// vCenter Server Appliance bundle). It genuinely cannot boot, so
|
||||
// we expose no menu entry; the dashboard flags it instead.
|
||||
if r.el_torito || r.introspect_rev == 0 {
|
||||
vec![BootEntry {
|
||||
id: format!("{id}-sanboot"),
|
||||
title,
|
||||
kind: BootKind::SanBootIso {
|
||||
iso_url: format!("iso/{id}.iso"),
|
||||
},
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,6 +770,49 @@ mod tests {
|
||||
assert!(!s.contains(" --- "), "stray ---: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boot_entries_respect_el_torito_and_source() {
|
||||
use crate::introspect::INTROSPECT_REV;
|
||||
|
||||
// ESXi / VMvisor installer shape: bootable (carries an El Torito
|
||||
// catalog) but not classifiable as Windows or Linux. Must yield a
|
||||
// single sanboot entry so it's selectable + boots via emulated CD.
|
||||
let esxi = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: Some("ESXI-7.0U3".into()),
|
||||
el_torito: true,
|
||||
introspect_rev: INTROSPECT_REV,
|
||||
..Default::default()
|
||||
};
|
||||
let e = generate_boot_entries("esxi", "VMware-VMvisor-Installer-7.0U3n.iso", &esxi);
|
||||
assert_eq!(e.len(), 1, "ESXi should get exactly one boot entry");
|
||||
assert!(matches!(e[0].kind, BootKind::SanBootIso { .. }));
|
||||
// Clean title — no stale ">1GiB may fail" disclaimer.
|
||||
assert!(!e[0].title.contains("may fail"), "title: {}", e[0].title);
|
||||
|
||||
// VCSA / data-appliance shape: locally introspected (rev set), no
|
||||
// boot catalog, not Windows/Linux. Genuinely unbootable → no entry,
|
||||
// so it stays out of the iPXE menu (the dashboard flags it instead).
|
||||
let vcsa = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
el_torito: false,
|
||||
introspect_rev: INTROSPECT_REV,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
generate_boot_entries("vcsa", "VMware-VCSA-all-8.0.iso", &vcsa).is_empty(),
|
||||
"data/appliance ISO must produce no boot entry"
|
||||
);
|
||||
|
||||
// Remote-share ISO: never introspected (rev 0, no random access over
|
||||
// SMB/NFS/SFTP). Assume bootable and offer sanboot rather than hide a
|
||||
// likely-bootable installer.
|
||||
let remote = IntrospectionReport::default();
|
||||
let r = generate_boot_entries("remote", "unknown-remote.iso", &remote);
|
||||
assert_eq!(r.len(), 1, "remote (uninspected) ISO keeps a sanboot entry");
|
||||
assert!(matches!(r[0].kind, BootKind::SanBootIso { .. }));
|
||||
}
|
||||
|
||||
fn fake_meta(id: &str) -> IsoMeta {
|
||||
IsoMeta {
|
||||
id: id.into(),
|
||||
@@ -687,13 +820,7 @@ mod tests {
|
||||
size_bytes: 0,
|
||||
sha256_hex: None,
|
||||
uploaded_at: OffsetDateTime::now_utc(),
|
||||
introspection: IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: vec![],
|
||||
has_boot_wim: false,
|
||||
},
|
||||
introspection: IntrospectionReport::default(),
|
||||
boot_entries: vec![],
|
||||
source: IsoSource::Local,
|
||||
password_hash: None,
|
||||
|
||||
@@ -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(),
|
||||
@@ -208,6 +215,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
public_base_url: public_base_url.clone(),
|
||||
nic_name: net.nic_name,
|
||||
nic_link: net.nic_link,
|
||||
subnet_mask: net.subnet_mask,
|
||||
gateway: net.gateway,
|
||||
};
|
||||
@@ -229,11 +237,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 +272,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())
|
||||
}
|
||||
@@ -420,6 +449,12 @@ struct NetworkInfo {
|
||||
nic_name: String,
|
||||
subnet_mask: String,
|
||||
gateway: String,
|
||||
/// v0.7.2: physical link summary for the Network tab — operstate,
|
||||
/// negotiated speed/duplex, and the port's own MAC. Helps operators
|
||||
/// in multi-NIC / trunked environments confirm *which* port the PXE
|
||||
/// server actually answers on. Empty when sysfs isn't available
|
||||
/// (non-Linux dev builds) or the NIC wasn't identified.
|
||||
nic_link: String,
|
||||
}
|
||||
|
||||
/// Best-effort population of the Network tab's read-only fields. We shell
|
||||
@@ -480,9 +515,44 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
|
||||
}
|
||||
}
|
||||
|
||||
info.nic_link = detect_link_info(&info.nic_name);
|
||||
|
||||
info
|
||||
}
|
||||
|
||||
/// v0.7.2: read the NIC's physical link details from sysfs. Every field
|
||||
/// is optional — virtual NICs report no speed (`-1` or absent), and
|
||||
/// non-Linux dev machines have no `/sys/class/net` at all — so the
|
||||
/// result is whatever could be read, joined human-readably, or empty.
|
||||
fn detect_link_info(nic: &str) -> String {
|
||||
if nic.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let read = |file: &str| {
|
||||
std::fs::read_to_string(format!("/sys/class/net/{nic}/{file}"))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let state = read("operstate");
|
||||
if !state.is_empty() {
|
||||
parts.push(format!("link {state}"));
|
||||
}
|
||||
let speed = read("speed");
|
||||
if !speed.is_empty() && speed != "-1" {
|
||||
parts.push(format!("{speed} Mb/s"));
|
||||
}
|
||||
let duplex = read("duplex");
|
||||
if !duplex.is_empty() && duplex != "unknown" {
|
||||
parts.push(format!("{duplex} duplex"));
|
||||
}
|
||||
let mac = read("address");
|
||||
if !mac.is_empty() {
|
||||
parts.push(format!("port {mac}"));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
fn prefix_to_dotted(prefix: u8) -> String {
|
||||
let prefix = prefix.min(32);
|
||||
let mask: u32 = if prefix == 0 {
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
|
||||
pub mod server;
|
||||
|
||||
pub use server::TftpServer;
|
||||
pub use server::{DynamicAsset, TftpServer};
|
||||
|
||||
+53
-17
@@ -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,
|
||||
|
||||
@@ -912,3 +912,28 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
|
||||
}
|
||||
.modal-actions .submit { width: auto; padding: 8px 18px; }
|
||||
|
||||
/* v0.7.2: a label.field directly followed by the card's action button
|
||||
stacked its own 14px bottom margin onto the button's 16px top margin
|
||||
(30px total) — visible on Queue "Launch for all waiting" and the
|
||||
Network "Save". Collapse the doubled gap so every primary action sits
|
||||
the same 16px below its form. */
|
||||
.card .body > label.field:has(+ button) { margin-bottom: 0; }
|
||||
|
||||
/* v0.7.2: inline list filter above a table (Available images). The input
|
||||
is wrapped in a label.field so it borrows the standard text-field chrome
|
||||
and matches every other input in the app; this wrapper just insets it
|
||||
from the card edges so it lines up with the header text above. */
|
||||
.list-search { padding: 14px 16px; }
|
||||
|
||||
/* v0.7.4: pager footer under the Available-images table — quiet status
|
||||
text on the left, ghost Prev/Next on the right. */
|
||||
.list-pager {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 12px 16px;
|
||||
color: var(--fg-dim); font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.list-pager .spacer { flex: 1; }
|
||||
.list-pager button { padding: 4px 12px; font-size: 12px; }
|
||||
.list-pager button:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
+296
-64
@@ -160,19 +160,37 @@
|
||||
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
||||
function bootability(iso, settings) {
|
||||
const fam = iso.introspection.family;
|
||||
const isWin = fam === 'windows_pe';
|
||||
if (isWin && !settings.windows_enabled) {
|
||||
return { ok: false, reason: 'Windows boot disabled in Settings' };
|
||||
// v0.5.8: Windows ISOs boot via iPXE HTTP sanboot of the raw image —
|
||||
// no Settings toggle, no SMB, no size limit. Always bootable.
|
||||
if (fam === 'windows_pe') {
|
||||
return { ok: true };
|
||||
}
|
||||
if (!isWin && !iso.introspection.kernel_path && fam !== 'windows_pe') {
|
||||
// Linux without a detected kernel falls through to sanboot which
|
||||
// rarely works for >1 GiB ISOs.
|
||||
if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
|
||||
return { ok: false, reason: 'no kernel/initrd detected; ISO too large for sanboot fallback' };
|
||||
}
|
||||
return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
|
||||
// Linux with a detected kernel/initrd — direct kernel+initrd boot.
|
||||
if (iso.introspection.kernel_path) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: true };
|
||||
// v0.5.9: any other ISO that carries an El Torito boot catalog is
|
||||
// bootable via iPXE sanboot (emulated CD) — BSDs, ESXi, firmware
|
||||
// tools, custom Linux spins. This replaces the old "> 1.5 GB ⇒
|
||||
// unbootable" size guess with the authoritative on-disk boot signal,
|
||||
// so a large bootable ISO is no longer mislabeled and a Windows ISO
|
||||
// re-introspected on upgrade lights up correctly.
|
||||
if (iso.introspection.el_torito) {
|
||||
return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' };
|
||||
}
|
||||
// v0.7.4: NFS/SFTP ISOs now introspect over the share, so a probed
|
||||
// remote ISO flows through the kernel/el_torito branches above like
|
||||
// a local one. introspect_rev 0 means the probe hasn't landed yet
|
||||
// (it runs in the background right after a scan) or never can (SMB —
|
||||
// smbclient can't seek): stay optimistic and let sanboot try.
|
||||
const remote = iso.source && iso.source.kind && iso.source.kind !== 'local';
|
||||
if (remote && (iso.introspection.introspect_rev || 0) === 0) {
|
||||
return { ok: true, warn: 'remote ISO — awaiting introspection; sanboot is attempted at boot' };
|
||||
}
|
||||
// Local ISO with no Windows/Linux boot files and no El Torito catalog:
|
||||
// a data/appliance image (e.g. a VMware vCenter bundle), not a bootable
|
||||
// installer.
|
||||
return { ok: false, reason: 'data/appliance ISO — no El Torito boot catalog and no Windows/Linux installer files, so it can’t be PXE-booted' };
|
||||
}
|
||||
|
||||
// v0.5.2: pretty label for an unattended file's detected kind.
|
||||
@@ -183,6 +201,51 @@
|
||||
})[k] || (k || 'Unknown');
|
||||
}
|
||||
|
||||
// v0.7.2: compact read-out of saved group rules — created from the
|
||||
// unified "Pin MAC" form on the Hosts tab (a prefix or an architecture
|
||||
// there saves a rule instead of a pin). First match wins, top to
|
||||
// bottom. The boot-decision webhook remains available via the API
|
||||
// (/api/boot-rules `webhook_url`) but no longer has a UI knob.
|
||||
function groupRulesCard(cfg, targetOptions) {
|
||||
const rules = (cfg && cfg.rules) || [];
|
||||
if (!rules.length) return null;
|
||||
const titleFor = id => {
|
||||
const t = targetOptions.find(x => x.id === id);
|
||||
return t ? t.title : id;
|
||||
};
|
||||
const modeLabel = {firmware:'Firmware NIC', builtin:'iPXE drivers', shim:'Secure Boot (shim)'};
|
||||
const rows = rules.map((r, i) => el('tr', r.enabled === false ? {style:'opacity:.5'} : {}, [
|
||||
el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')),
|
||||
el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')),
|
||||
el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')),
|
||||
el('td', {}, r.driver_mode
|
||||
? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode)
|
||||
: el('span', {class:'tag'}, 'auto')),
|
||||
el('td', {}, r.note || ''),
|
||||
el('td', {style:'text-align:right'},
|
||||
el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Remove this group rule?')) return;
|
||||
const fresh = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||||
(fresh.rules = fresh.rules || []).splice(i, 1);
|
||||
await putJSON('/api/boot-rules', fresh);
|
||||
render('hosts');
|
||||
}}, 'Remove')),
|
||||
]));
|
||||
return el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Group rules'),
|
||||
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
|
||||
]),
|
||||
el('table', {}, [
|
||||
el('thead', {}, el('tr', {}, [
|
||||
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
|
||||
el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''),
|
||||
])),
|
||||
el('tbody', {}, rows),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// v0.5.2: build the shared "deployment profile" field group — auto
|
||||
// hostname, auto IP, and an unattended-file picker — reused by the
|
||||
// Hosts pin form and the Queue "Profile" modal. `files` is the
|
||||
@@ -206,7 +269,7 @@
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Unattended file'), sel]),
|
||||
el('span', {class:'name'}, 'Unattended file (in Storage → Advanced)'), sel]),
|
||||
]);
|
||||
return {
|
||||
wrap,
|
||||
@@ -288,14 +351,23 @@
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Images available'),
|
||||
el('div', {class: 'value'}, String(isos.length)),
|
||||
el('div', {class: 'trend'},
|
||||
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
|
||||
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
|
||||
el('div', {class: 'trend'}, (() => {
|
||||
// v0.5.9: count families honestly. Anything that isn't a known
|
||||
// Linux family or Windows lands in "other" (data/appliance ISOs
|
||||
// like VMware VCSA, or as-yet-unclassified images) instead of
|
||||
// being lumped under "Linux".
|
||||
const LINUX = ['debian_ubuntu', 'rhel_fedora', 'opensuse', 'arch', 'alpine'];
|
||||
const win = isos.filter(i => i.introspection.family === 'windows_pe').length;
|
||||
const lin = isos.filter(i => LINUX.includes(i.introspection.family)).length;
|
||||
const other = isos.length - win - lin;
|
||||
// v0.4.67+v0.5.5: count all remote-share protocols. Label
|
||||
// generically since operators may use any mix of SMB/NFS/SFTP.
|
||||
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) +
|
||||
' remote share' +
|
||||
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) === 1 ? '' : 's')),
|
||||
const remote = (status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0);
|
||||
const parts = [win + ' Windows', lin + ' Linux'];
|
||||
if (other > 0) parts.push(other + ' other');
|
||||
parts.push(remote + ' remote share' + (remote === 1 ? '' : 's'));
|
||||
return parts.join(' · ');
|
||||
})()),
|
||||
])),
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Uptime'),
|
||||
@@ -340,7 +412,7 @@
|
||||
const settings = status.settings;
|
||||
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
|
||||
const problemsBlock = problems.length ? el('div', {class:'card'}, [
|
||||
el('header', {}, [el('h2', {}, 'Images that won\'t boot with current settings')]),
|
||||
el('header', {}, [el('h2', {}, 'Non-bootable images')]),
|
||||
el('div', {class:'body'},
|
||||
problems.map(({i, b}) => el('div', {class:'row-warn'},
|
||||
'⚠ ' + i.filename + ' — ' + b.reason)))
|
||||
@@ -375,6 +447,12 @@
|
||||
el('div', {class:'v'}, net.gateway || '?'),
|
||||
el('div', {class:'k'}, 'Public base URL'),
|
||||
el('div', {class:'v'}, net.public_base_url),
|
||||
// v0.7.2: physical link details (operstate · speed · duplex ·
|
||||
// port MAC) so the operator can confirm WHICH port answers PXE
|
||||
// in multi-NIC / trunked environments. Kept last — the joined
|
||||
// value runs long, so it wraps cleanly at the bottom of the list.
|
||||
el('div', {class:'k'}, 'Link'),
|
||||
el('div', {class:'v'}, net.nic_link || '—'),
|
||||
]),
|
||||
el('p', {class:'msg'},
|
||||
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
|
||||
@@ -533,6 +611,9 @@
|
||||
style:'display:none', id:'file'});
|
||||
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
|
||||
const upMsg = el('div', {class:'msg', id:'upmsg'});
|
||||
// v0.5.8: cancel button — shown only while an upload is in flight.
|
||||
const cancelUpload = el('button', {class:'danger', type:'button',
|
||||
style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload');
|
||||
|
||||
drop.onclick = () => file.click();
|
||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
|
||||
@@ -575,6 +656,16 @@
|
||||
};
|
||||
|
||||
let uploadId = null;
|
||||
// v0.5.8: cancel + leave-page guard. The AbortController stops the
|
||||
// in-flight chunk; the beforeunload listener warns the operator
|
||||
// that navigating away aborts the upload (the server-side partial
|
||||
// is then cleaned up by the DELETE in the catch below).
|
||||
const ac = new AbortController();
|
||||
let canceled = false;
|
||||
const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; };
|
||||
window.addEventListener('beforeunload', warnLeave);
|
||||
cancelUpload.style.display = '';
|
||||
cancelUpload.onclick = () => { canceled = true; ac.abort(); };
|
||||
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
||||
prog.classList.add('active');
|
||||
bar.style.width = '1%';
|
||||
@@ -601,6 +692,7 @@
|
||||
'x-openpxe-upload-complete': complete ? 'true' : 'false',
|
||||
},
|
||||
body: f.slice(offset, end),
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (!r.ok) throw new Error(await failText(r));
|
||||
const j = await r.json();
|
||||
@@ -616,8 +708,15 @@
|
||||
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
|
||||
catch {}
|
||||
}
|
||||
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
||||
if (canceled || (err && err.name === 'AbortError')) {
|
||||
setStatus('Upload canceled — partial file discarded.', '');
|
||||
} else {
|
||||
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
||||
}
|
||||
} finally {
|
||||
window.removeEventListener('beforeunload', warnLeave);
|
||||
cancelUpload.style.display = 'none';
|
||||
cancelUpload.onclick = null;
|
||||
prog.classList.remove('active');
|
||||
if (!upMsg.className.includes('ok')) bar.style.width = '0';
|
||||
}
|
||||
@@ -629,7 +728,11 @@
|
||||
// *next* row of the table. Keeps the markup flat and avoids the
|
||||
// overhead of a real modal.
|
||||
const rowsAndEditors = [];
|
||||
isos.forEach(i => {
|
||||
// v0.5.8: list Available images alphabetically by filename
|
||||
// (case-insensitive, natural numeric order) instead of newest-first.
|
||||
const sortedIsos = [...isos].sort((a, b) =>
|
||||
(a.filename || '').localeCompare(b.filename || '', undefined, { sensitivity: 'base', numeric: true }));
|
||||
sortedIsos.forEach(i => {
|
||||
const b = bootability(i, settings);
|
||||
// v0.4.65: SMB userspace consumer (smbclient).
|
||||
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
|
||||
@@ -739,6 +842,12 @@
|
||||
render('storage');
|
||||
};
|
||||
|
||||
// v0.7.2: searchable haystack for the list filter — filename,
|
||||
// detected family, category, and source all match.
|
||||
const searchText = [
|
||||
i.filename, familyLabel(i.introspection.family), i.category || '',
|
||||
isSmb ? 'smb' : isNfs ? 'nfs' : 'local', i.id,
|
||||
].join(' ').toLowerCase();
|
||||
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
||||
el('td', {}, [
|
||||
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
||||
@@ -783,8 +892,42 @@
|
||||
}}, 'Remove'),
|
||||
]),
|
||||
]);
|
||||
tr.dataset.search = searchText;
|
||||
rowsAndEditors.push(tr, editorRow);
|
||||
});
|
||||
|
||||
// v0.7.2: client-side filter over the image table; v0.7.4: paged
|
||||
// 5 at a time so a 50-image library doesn't become a scroll wall.
|
||||
// Rows travel in (row, password-editor) pairs. One view function
|
||||
// applies filter-then-page; editors close on any view change.
|
||||
const ISO_PAGE_SIZE = 5;
|
||||
let isoPage = 0;
|
||||
const pagerInfo = el('span', {});
|
||||
const prevBtn = el('button', {class:'ghost', onclick: () => { isoPage -= 1; applyIsoListView(); }}, '‹ Prev');
|
||||
const nextBtn = el('button', {class:'ghost', onclick: () => { isoPage += 1; applyIsoListView(); }}, 'Next ›');
|
||||
function applyIsoListView() {
|
||||
const q = isoSearch.value.trim().toLowerCase();
|
||||
const visible = [];
|
||||
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
|
||||
const row = rowsAndEditors[k];
|
||||
rowsAndEditors[k + 1].style.display = 'none';
|
||||
row.style.display = 'none';
|
||||
if (!q || (row.dataset.search || '').includes(q)) visible.push(row);
|
||||
}
|
||||
const pages = Math.max(1, Math.ceil(visible.length / ISO_PAGE_SIZE));
|
||||
if (isoPage >= pages) isoPage = pages - 1;
|
||||
if (isoPage < 0) isoPage = 0;
|
||||
visible.slice(isoPage * ISO_PAGE_SIZE, (isoPage + 1) * ISO_PAGE_SIZE)
|
||||
.forEach(r => { r.style.display = ''; });
|
||||
pagerInfo.textContent = visible.length
|
||||
? 'Showing ' + (isoPage * ISO_PAGE_SIZE + 1) + '–' +
|
||||
Math.min(visible.length, (isoPage + 1) * ISO_PAGE_SIZE) + ' of ' + visible.length
|
||||
: 'No images match';
|
||||
prevBtn.disabled = isoPage === 0;
|
||||
nextBtn.disabled = isoPage >= pages - 1;
|
||||
}
|
||||
const isoSearch = el('input', {type:'search', placeholder:'Filter images by name, type, or source',
|
||||
spellcheck:'false', oninput: () => { isoPage = 0; applyIsoListView(); }});
|
||||
const isoTable = isos.length
|
||||
? el('table', {}, [
|
||||
el('thead', {}, el('tr', {}, [
|
||||
@@ -796,6 +939,13 @@
|
||||
el('tbody', {}, rowsAndEditors),
|
||||
])
|
||||
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
|
||||
// v0.7.4: pager footer, shown once the library outgrows one page.
|
||||
const isoPager = isos.length > ISO_PAGE_SIZE
|
||||
? el('div', {class:'list-pager'}, [
|
||||
pagerInfo, el('span', {class:'spacer'}), prevBtn, nextBtn,
|
||||
])
|
||||
: null;
|
||||
if (isos.length) applyIsoListView();
|
||||
|
||||
// ── Remote shares section (v0.5.1) ──
|
||||
// SMB + NFS unified into one "Remote shares" card with a protocol
|
||||
@@ -1122,7 +1272,10 @@
|
||||
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
||||
|
||||
const unattRows = unattendedFiles.length
|
||||
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [
|
||||
? unattendedFiles.map(f => el('div', {
|
||||
class:'nfs-row',
|
||||
'data-search': (f.filename + ' ' + unattendedKindLabel(f.kind) + ' ' + f.id).toLowerCase(),
|
||||
}, [
|
||||
el('span', {class:'dot ok'}),
|
||||
el('div', {}, [
|
||||
el('div', {class:'id'}, [
|
||||
@@ -1150,6 +1303,17 @@
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
unattDrop, unattFile, unattMsg,
|
||||
// v0.7.2: filter for big answer-file libraries.
|
||||
unattendedFiles.length > 1 ? (() => {
|
||||
const search = el('input', {type:'search', placeholder:'Filter files by name or kind',
|
||||
spellcheck:'false', oninput: () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
unattRows.forEach(r => {
|
||||
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
|
||||
});
|
||||
}});
|
||||
return el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, search);
|
||||
})() : null,
|
||||
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'These answer files drive unattended installs. Attach one to a ' +
|
||||
@@ -1164,7 +1328,7 @@
|
||||
diskCard,
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||
el('div', {class:'body'}, [drop, file, prog, upMsg]),
|
||||
el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]),
|
||||
]),
|
||||
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
||||
// protocol dropdown. Backend endpoints are unchanged; this is a
|
||||
@@ -1196,16 +1360,21 @@
|
||||
el('h2', {}, 'Available images'),
|
||||
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
||||
]),
|
||||
isos.length > 1
|
||||
? el('div', {class:'list-search'}, el('label', {class:'field', style:'margin-bottom:0'}, isoSearch))
|
||||
: null,
|
||||
isoTable,
|
||||
isoPager,
|
||||
]),
|
||||
]), unattendedAdvanced]);
|
||||
},
|
||||
|
||||
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 || [];
|
||||
@@ -1220,8 +1389,22 @@
|
||||
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
||||
];
|
||||
|
||||
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
|
||||
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or aa:bb:cc', spellcheck:'false'});
|
||||
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
|
||||
// v0.7.2: the former separate "Boot rules" card folded into this
|
||||
// form. A full MAC with no architecture saves a per-host pin
|
||||
// exactly as before; a MAC *prefix* and/or an architecture saves a
|
||||
// first-match-wins group rule instead. Same form, one mental model.
|
||||
const archSel = el('select', {}, [
|
||||
['', 'any (this exact MAC)'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
|
||||
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
|
||||
].map(([v, t]) => el('option', {value: v}, t)));
|
||||
// v0.7.1's boot-binary pin keeps its home here too (auto = let the
|
||||
// escalation ladder learn; shim = known Secure Boot fleet).
|
||||
const binSel = el('select', {}, [
|
||||
['', 'auto (learn per machine)'], ['firmware', 'Firmware NIC'],
|
||||
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
|
||||
].map(([v, t]) => el('option', {value: v}, t)));
|
||||
const targetSel = el('select', {},
|
||||
[el('option', {value:''}, '— choose a target —')]
|
||||
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
||||
@@ -1234,21 +1417,40 @@
|
||||
// hostname/IP templated into the served answer file.
|
||||
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
|
||||
|
||||
const FULL_MAC = /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i;
|
||||
const upsertBtn = el('button', {onclick: async () => {
|
||||
if (!macInput.value || !targetSel.value) {
|
||||
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
||||
const mac = macInput.value.trim();
|
||||
const isGroup = !!archSel.value || !!binSel.value || (mac !== '' && !FULL_MAC.test(mac));
|
||||
if (!isGroup) {
|
||||
// Exact-MAC pin — unchanged behavior.
|
||||
if (!mac || !targetSel.value) {
|
||||
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
||||
}
|
||||
const r = await postJSON('/api/hosts', Object.assign({
|
||||
mac, target: targetSel.value, label: labelInput.value,
|
||||
}, profileFields.read()));
|
||||
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||||
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||
return;
|
||||
}
|
||||
const r = await postJSON('/api/hosts', Object.assign({
|
||||
mac: macInput.value, target: targetSel.value, label: labelInput.value,
|
||||
}, profileFields.read()));
|
||||
if (r.ok) {
|
||||
msg.textContent = 'Saved.'; msg.className = 'msg ok';
|
||||
render('hosts');
|
||||
} else {
|
||||
const t = await r.text();
|
||||
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
|
||||
// Group rule (prefix and/or architecture). Per-host profile
|
||||
// fields don't apply to a group — they're per-machine values.
|
||||
if (!targetSel.value && !binSel.value) {
|
||||
msg.textContent = 'A group rule needs a target or a boot binary.'; msg.className = 'msg err'; return;
|
||||
}
|
||||
}}, 'Bind MAC to target');
|
||||
const p = profileFields.read();
|
||||
if (p.auto_hostname || p.auto_ip || p.unattended_file) {
|
||||
msg.textContent = 'Auto-deploy fields are per-machine — clear them, or use a full MAC.'; msg.className = 'msg err'; return;
|
||||
}
|
||||
const cfg = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||||
(cfg.rules = cfg.rules || []).push({
|
||||
mac_prefix: mac, arch: archSel.value, target: targetSel.value,
|
||||
driver_mode: binSel.value, enabled: true, note: labelInput.value,
|
||||
});
|
||||
const r = await putJSON('/api/boot-rules', cfg);
|
||||
if (r.ok) { msg.textContent = 'Group rule saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||||
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||
}}, 'Bind to target');
|
||||
|
||||
const rows = hosts.map(h => {
|
||||
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
||||
@@ -1305,23 +1507,32 @@
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
||||
el('div', {class:'body'}, [
|
||||
// v0.7.3: MAC · Label · Architecture · Boot binary share one
|
||||
// 4-up row so the controls line up across the page; the
|
||||
// per-field guidance that used to sit under them moved into the
|
||||
// note below to keep the inputs flush. Target spans full width
|
||||
// on its own line beneath them.
|
||||
el('div', {class:'form-row'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address or prefix'), macInput]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
||||
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
||||
el('span', {class:'name'}, 'Target'),
|
||||
targetSel,
|
||||
el('span', {class:'hint'},
|
||||
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
|
||||
]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Architecture (optional)'), archSel]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Boot binary (optional)'), binSel]),
|
||||
]),
|
||||
el('label', {class:'field', style:'margin-top:14px'}, [
|
||||
el('span', {class:'name'}, 'Target'),
|
||||
targetSel,
|
||||
]),
|
||||
el('div', {style:'margin-top:16px'}, profileFields.wrap),
|
||||
upsertBtn, msg,
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
|
||||
'short-circuits past the interactive menu and chains directly. ' +
|
||||
'If an unattended file is selected, the matching kernel argument ' +
|
||||
'is injected and the hostname/IP are templated into the answer file.'),
|
||||
'A full MAC pins one machine; a MAC prefix (OUI) or an architecture ' +
|
||||
'saves a first-match group rule. Pin the boot binary to “shim” for ' +
|
||||
'Secure Boot racks — zero failed boot cycles. When a matching client ' +
|
||||
'requests boot.ipxe, OpenPXE short-circuits past the interactive menu ' +
|
||||
'and chains directly; decision order is exact MAC pin → first matching ' +
|
||||
'group rule → menu. If an unattended file is selected on a pin, the ' +
|
||||
'matching kernel argument is injected and the hostname/IP are templated ' +
|
||||
'into the answer file.'),
|
||||
]),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
@@ -1331,6 +1542,7 @@
|
||||
]),
|
||||
table,
|
||||
]),
|
||||
groupRulesCard(rulesCfg, reserved.concat(targets)),
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Host log'),
|
||||
@@ -2007,9 +2219,24 @@
|
||||
el('div', {class:'about-hero'}, [
|
||||
el('h2', {}, 'OpenPXE'),
|
||||
el('p', {class:'lead'},
|
||||
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
|
||||
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
|
||||
'the image once, run it forever.'),
|
||||
'The network-boot platform for modern infrastructure. Drop in an ISO ' +
|
||||
'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
|
||||
'boot it, image from it, and install unattended. One container, one ' +
|
||||
'static binary, nothing installed on clients, nothing leaving your network.'),
|
||||
el('div', {style:'display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0'}, [
|
||||
['Boot anything', 'Linux, Windows, hypervisors, rescue tools — uploaded ' +
|
||||
'ISOs become menu entries automatically, served on demand from local ' +
|
||||
'disk or your existing NFS, SMB, or SFTP libraries.'],
|
||||
['Adapt to every machine', 'Per-machine boot intelligence: firmware quirks, ' +
|
||||
'NIC driver fallback, and a Microsoft-signed Secure Boot chain are ' +
|
||||
'negotiated automatically and remembered — no toggles, no client prep.'],
|
||||
['Run it in production', 'SAML single sign-on, token-scoped answer files, ' +
|
||||
'fleet routing rules, Wake-on-LAN, queued mass deployment, Prometheus ' +
|
||||
'metrics. Built in Rust for boot infrastructure that cannot flinch.'],
|
||||
].map(([h, body]) => el('div', {}, [
|
||||
el('h3', {style:'margin:0 0 6px;font-size:13.5px'}, h),
|
||||
el('p', {class:'msg', style:'font-size:12px;margin:0'}, body),
|
||||
]))),
|
||||
el('div', {class:'who'}, [
|
||||
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
||||
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
||||
@@ -2020,15 +2247,16 @@
|
||||
]),
|
||||
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
||||
el('p', {class:'msg', style:'margin-top:18px'},
|
||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
||||
'executes is generated from the settings on these tabs — there is no ' +
|
||||
'hand-written .ipxe path anywhere in this product.'),
|
||||
'Private by design: no telemetry, no CDN calls, no runtime ' +
|
||||
'dependencies on the outside world. Air-gapped labs, customer sites ' +
|
||||
'without internet, and locked-down OpenShift clusters run the same ' +
|
||||
'image, the same way, indefinitely.'),
|
||||
el('p', {class:'msg'},
|
||||
'Vision: a deployment-grade tool that works on first try in the most ' +
|
||||
'awkward environments — air-gapped labs, customer sites without ' +
|
||||
'internet, OpenShift clusters with strict SCCs — without ever asking ' +
|
||||
'an operator to install drivers signed with test certificates or to ' +
|
||||
'flip "testsigning" on a target machine.'),
|
||||
'Principled by default: OpenPXE never asks an operator to install ' +
|
||||
'test-signed drivers, modify a client’s trust store, or weaken ' +
|
||||
'Secure Boot. Everything the firmware executes is generated from the ' +
|
||||
'settings on these tabs — there are no hand-written boot scripts to ' +
|
||||
'maintain and no internals to learn.'),
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -2252,7 +2480,9 @@
|
||||
// own self-contained <form>; when SSO is enabled, a distinct
|
||||
// "Sign in with …" button sits below a divider — the credential
|
||||
// fields no longer double as the SSO trigger.
|
||||
const ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata);
|
||||
// `enabled` from /api/me already means "usable" (enabled AND a metadata
|
||||
// source is configured), so the button only shows when SSO will work.
|
||||
const ssoLive = !!(ssoConfig && ssoConfig.enabled);
|
||||
const ssoBlock = ssoLive
|
||||
? el('div', {class:'sso-block'}, [
|
||||
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
|
||||
@@ -2497,10 +2727,12 @@
|
||||
])));
|
||||
return;
|
||||
}
|
||||
// Preload the SSO config so the login card can offer the operator
|
||||
// an "Sign in with X" button when configured. Failure is harmless.
|
||||
try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); }
|
||||
catch { ssoConfig = null; }
|
||||
// v0.5.9: the login card's "Sign in with …" button keys off the SSO
|
||||
// descriptor that /api/me now carries (public, non-sensitive: enabled
|
||||
// + idp_name + idp_logo_url). It's available signed in or out, so the
|
||||
// button is static — it no longer relied on the auth-gated /api/sso,
|
||||
// which 401s pre-auth and made the button vanish on fresh login loads.
|
||||
ssoConfig = me.sso || null;
|
||||
|
||||
if (me.setup_required) {
|
||||
showAuthScreen('setup');
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-7
@@ -41,15 +41,31 @@ DEST="${1:-$ROOT/assets/ipxe}"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin
|
||||
# keeps builds reproducible and protects against a transient master
|
||||
# breakage. Bump deliberately.
|
||||
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin keeps
|
||||
# builds reproducible, protects against a transient master breakage, and —
|
||||
# crucially for the Docker image — busting this value invalidates the cached
|
||||
# ipxe-build layer so an "update iPXE" release actually recompiles from the
|
||||
# new upstream. Bump deliberately to a recent master commit.
|
||||
#
|
||||
# v0.6.1: ipxe/ipxe master @ 2026-06-09 (newer NIC drivers + EFI fixes;
|
||||
# mirrors iVentoy 1.0.35 "Update iPXE").
|
||||
IPXE_REPO="https://github.com/ipxe/ipxe.git"
|
||||
IPXE_REF="${IPXE_REF:-master}"
|
||||
IPXE_REF="${IPXE_REF:-95ffbf4745553e8a207922389929e1943c0237c0}"
|
||||
|
||||
echo ">> cloning iPXE ($IPXE_REF)"
|
||||
git clone --depth 1 --branch "$IPXE_REF" "$IPXE_REPO" "$WORK/ipxe" 2>/dev/null \
|
||||
|| git clone "$IPXE_REPO" "$WORK/ipxe"
|
||||
echo ">> fetching iPXE ($IPXE_REF)"
|
||||
# Shallow-fetch the exact ref: works for a full commit SHA (GitHub allows
|
||||
# reachable-SHA1-in-want) and for branch/tag names. Fall back to a full
|
||||
# clone + checkout if the server refuses a direct fetch of this ref.
|
||||
git init -q "$WORK/ipxe"
|
||||
git -C "$WORK/ipxe" remote add origin "$IPXE_REPO"
|
||||
if git -C "$WORK/ipxe" fetch -q --depth 1 origin "$IPXE_REF"; then
|
||||
git -C "$WORK/ipxe" checkout -q FETCH_HEAD
|
||||
else
|
||||
echo " direct fetch failed; falling back to full clone + checkout"
|
||||
rm -rf "$WORK/ipxe"
|
||||
git clone -q "$IPXE_REPO" "$WORK/ipxe"
|
||||
git -C "$WORK/ipxe" checkout -q "$IPXE_REF"
|
||||
fi
|
||||
SRC="$WORK/ipxe/src"
|
||||
|
||||
echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)"
|
||||
|
||||
@@ -29,11 +29,19 @@ mkdir -p "$DEST"
|
||||
# Upstream uses arch-scoped subdirectories; we flatten to the names our
|
||||
# ClientArch::ipxe_bootfile() expects.
|
||||
declare -a MAP=(
|
||||
# DriverMode::Firmware (default) — reuse the firmware UNDI/SNP NIC stack.
|
||||
"undionly.kpxe=undionly.kpxe"
|
||||
"snponly.efi=x86_64-efi/snponly.efi"
|
||||
"snponly-i386.efi=i386-efi/snponly.efi"
|
||||
"snponly-arm64.efi=arm64-efi/snponly.efi"
|
||||
"ipxe.efi=x86_64-efi/ipxe.efi" # fallback with bundled drivers
|
||||
# DriverMode::Builtin (v0.6.1 automatic fallback) — iPXE's own all-drivers
|
||||
# builds, advertised by the DHCP proxy to a MAC whose firmware NIC stack
|
||||
# failed to chainload. (x86_64 ipxe.efi is rebuilt from source with PNG in
|
||||
# build-ipxe.sh and overlaid on top of this fetched baseline.)
|
||||
"ipxe.efi=x86_64-efi/ipxe.efi"
|
||||
"ipxe.pxe=ipxe.pxe"
|
||||
"ipxe-i386.efi=i386-efi/ipxe.efi"
|
||||
"ipxe-arm64.efi=arm64-efi/ipxe.efi"
|
||||
)
|
||||
|
||||
BASE="https://boot.ipxe.org"
|
||||
|
||||
Executable
+96
@@ -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
|
||||
Reference in New Issue
Block a user