Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da05a519d | ||
|
|
4f193cac05 | ||
|
|
24879fcc90 | ||
|
|
5df0fd5972 | ||
|
|
06695c3d77 | ||
|
|
cb51b8db75 | ||
|
|
d6a9df85d7 | ||
|
|
9fc9a9a1af | ||
|
|
ac433b30e9 |
@@ -11,3 +11,6 @@ data/work/
|
|||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.claude/scheduled_tasks.lock
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
|
# local editor / agent settings (not part of the project)
|
||||||
|
.claude/
|
||||||
|
|||||||
Generated
+388
-278
File diff suppressed because it is too large
Load Diff
+11
-7
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.6"
|
version = "0.6.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -20,21 +20,23 @@ repository = "https://gitea.milesward.dev/mward4/OpenPXE"
|
|||||||
authors = ["OpenPXE contributors"]
|
authors = ["OpenPXE contributors"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1.40", features = ["full"] }
|
tokio = { version = "1.52", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
dhcproto = "0.12"
|
# v0.6.2: dhcproto 0.15 drops the deprecated trust-dns-proto dependency
|
||||||
socket2 = { version = "0.5", features = ["all"] }
|
# (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"
|
bytes = "1.7"
|
||||||
nom = "7.1"
|
|
||||||
|
|
||||||
axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
|
axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
|
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
|
||||||
hyper = "1.4"
|
hyper = "1.9"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
@@ -52,9 +54,11 @@ thiserror = "2.0"
|
|||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
uuid = { version = "1.10", features = ["v4", "serde"] }
|
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||||
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] }
|
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"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
bcrypt = "0.15"
|
bcrypt = "0.19"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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="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="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" />
|
<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
|
them — Linux installers, live tools, or stock Windows setup — with **zero iPXE knowledge
|
||||||
required by the operator.**
|
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
|
> (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
|
> metrics are implemented and test-covered. The release checklist gates every tag on the
|
||||||
> full test suite + `clippy`. Currently in real-hardware validation.
|
> full test suite + `clippy`. Currently in real-hardware validation.
|
||||||
|
|||||||
+110
-10
@@ -23,6 +23,25 @@ pub enum ClientArch {
|
|||||||
Unknown(u16),
|
Unknown(u16),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which iPXE network backend to advertise to a client (v0.6.1).
|
||||||
|
///
|
||||||
|
/// OpenPXE serves [`DriverMode::Firmware`] first (the firmware's own NIC
|
||||||
|
/// stack, via `snponly`/`undionly`) and only escalates a specific MAC to
|
||||||
|
/// [`DriverMode::Builtin`] (iPXE's bundled NIC drivers) automatically, when a
|
||||||
|
/// firmware-net boot fails to chainload. There is no operator toggle — the
|
||||||
|
/// DHCP proxy decides per client.
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
impl ClientArch {
|
impl ClientArch {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn from_option_93(value: u16) -> Self {
|
pub fn from_option_93(value: u16) -> Self {
|
||||||
@@ -39,18 +58,47 @@ impl ClientArch {
|
|||||||
/// Default iPXE binary filename to return via TFTP for this architecture.
|
/// Default iPXE binary filename to return via TFTP for this architecture.
|
||||||
/// Uses `snponly` variants which reuse the firmware's UNDI/SNP network
|
/// Uses `snponly` variants which reuse the firmware's UNDI/SNP network
|
||||||
/// stack — smaller binaries and broader hardware compatibility than the
|
/// 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]
|
#[must_use]
|
||||||
pub fn ipxe_bootfile(self) -> Option<&'static str> {
|
pub fn ipxe_bootfile(self) -> Option<&'static str> {
|
||||||
Some(match self {
|
self.ipxe_bootfile_mode(DriverMode::Firmware)
|
||||||
Self::LegacyX86 => "undionly.kpxe",
|
}
|
||||||
Self::Ia32Uefi => "snponly-i386.efi",
|
|
||||||
Self::X64Uefi => "snponly.efi",
|
/// iPXE binary filename for this architecture under a given network
|
||||||
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
|
/// [`DriverMode`].
|
||||||
// snponly variant for this arch. We return None so the DHCP
|
///
|
||||||
// proxy declines rather than advertising a file we can't serve.
|
/// * [`DriverMode::Firmware`] — the `snponly`/`undionly` builds that reuse
|
||||||
Self::Arm32Uefi | Self::Unknown(_) => return None,
|
/// the firmware's UNDI/SNP NIC stack. Smallest, and the most reliable
|
||||||
Self::Arm64Uefi => "snponly-arm64.efi",
|
/// 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",
|
||||||
|
// x86_64 UEFI — the overwhelmingly common modern client.
|
||||||
|
(Self::X64Uefi, DriverMode::Firmware) => "snponly.efi",
|
||||||
|
(Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi",
|
||||||
|
// ARM64 UEFI.
|
||||||
|
(Self::Arm64Uefi, DriverMode::Firmware) => "snponly-arm64.efi",
|
||||||
|
(Self::Arm64Uefi, DriverMode::Builtin) => "ipxe-arm64.efi",
|
||||||
|
// ARM32 UEFI: upstream boot.ipxe.org publishes no prebuilt binary
|
||||||
|
// for this arch in either 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +181,58 @@ mod tests {
|
|||||||
assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None);
|
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]
|
#[test]
|
||||||
fn firmware_class_detects_ipxe_over_pxeclient() {
|
fn firmware_class_detects_ipxe_over_pxeclient() {
|
||||||
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
|
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
|
||||||
|
|||||||
@@ -125,9 +125,7 @@ impl AdminStore {
|
|||||||
{
|
{
|
||||||
let mut g = self.inner.write();
|
let mut g = self.inner.write();
|
||||||
if g.admin.is_some() {
|
if g.admin.is_some() {
|
||||||
return Err(Error::Invalid(
|
return Err(Error::Invalid("admin account already configured".into()));
|
||||||
"admin account already configured".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
g.admin = Some(admin.clone());
|
g.admin = Some(admin.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ use time::OffsetDateTime;
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum ClientEvent {
|
pub enum ClientEvent {
|
||||||
DhcpDiscover,
|
DhcpDiscover,
|
||||||
DhcpRequest,
|
|
||||||
PxeBootServerRequest,
|
PxeBootServerRequest,
|
||||||
TftpRead { file: String },
|
TftpRead { file: String },
|
||||||
HttpScriptFetch { target: String },
|
HttpScriptFetch { target: String },
|
||||||
HttpIsoAsset { file: String },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -32,8 +30,6 @@ pub struct ClientSnapshot {
|
|||||||
// Events are left with default serialization (9-tuple) — they're
|
// Events are left with default serialization (9-tuple) — they're
|
||||||
// diagnostic only and not consumed by the UI today.
|
// diagnostic only and not consumed by the UI today.
|
||||||
pub events: Vec<(OffsetDateTime, ClientEvent)>,
|
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)]
|
#[derive(Debug, Default)]
|
||||||
@@ -66,7 +62,6 @@ impl ClientRegistry {
|
|||||||
first_seen: now,
|
first_seen: now,
|
||||||
last_seen: now,
|
last_seen: now,
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
selected_target: None,
|
|
||||||
});
|
});
|
||||||
entry.last_seen = now;
|
entry.last_seen = now;
|
||||||
if ip.is_some() {
|
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]
|
#[must_use]
|
||||||
pub fn list(&self) -> Vec<ClientSnapshot> {
|
pub fn list(&self) -> Vec<ClientSnapshot> {
|
||||||
let guard = self.inner.read();
|
let guard = self.inner.read();
|
||||||
@@ -99,9 +87,4 @@ impl ClientRegistry {
|
|||||||
v.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
|
v.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
|
||||||
v
|
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,
|
pub dhcp_port: u16,
|
||||||
/// UDP port for PXE Boot Server discovery. Standard is 4011.
|
/// UDP port for PXE Boot Server discovery. Standard is 4011.
|
||||||
pub pxe_port: u16,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
@@ -105,8 +101,6 @@ impl Default for NetworkConfig {
|
|||||||
dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||||
dhcp_port: 67,
|
dhcp_port: 67,
|
||||||
pxe_port: 4011,
|
pxe_port: 4011,
|
||||||
mac_allowlist: Vec::new(),
|
|
||||||
subnet_allowlist: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub mod settings;
|
|||||||
pub mod sso;
|
pub mod sso;
|
||||||
pub mod wol;
|
pub mod wol;
|
||||||
|
|
||||||
pub use arch::{ClientArch, FirmwareClass};
|
pub use arch::{ClientArch, DriverMode, FirmwareClass};
|
||||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||||
pub use boot_log::{BootEvent, BootLog};
|
pub use boot_log::{BootEvent, BootLog};
|
||||||
pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
|
pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
|
||||||
@@ -36,4 +36,4 @@ pub use profile::DeployProfile;
|
|||||||
pub use queue::{DeploymentQueue, QueueEntry};
|
pub use queue::{DeploymentQueue, QueueEntry};
|
||||||
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
|
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
|
||||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
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(),
|
smtp_host: "smtp.example.com".into(),
|
||||||
..Default::default()
|
..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 {
|
s.replace(NotifyConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: NotifyKind::Smtp,
|
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.
|
/// In-memory + on-disk SSO settings registry.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SsoStore {
|
pub struct SsoStore {
|
||||||
@@ -111,6 +128,19 @@ impl SsoStore {
|
|||||||
self.inner.read().clone()
|
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
|
/// Replace the whole config in one shot. Light validation: metadata
|
||||||
/// XML and URL are length-capped so an operator can't OOM us by
|
/// 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,
|
/// 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)
|
Ipv4Addr::new(192, 168, 1, 255)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
subnet_broadcast(
|
subnet_broadcast(Ipv4Addr::new(10, 5, 3, 7), Ipv4Addr::new(255, 255, 0, 0)),
|
||||||
Ipv4Addr::new(10, 5, 3, 7),
|
|
||||||
Ipv4Addr::new(255, 255, 0, 0)
|
|
||||||
),
|
|
||||||
Ipv4Addr::new(10, 5, 255, 255)
|
Ipv4Addr::new(10, 5, 255, 255)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -196,7 +193,8 @@ mod tests {
|
|||||||
// and confirm send_magic transmits the exact 102-byte packet.
|
// and confirm send_magic transmits the exact 102-byte packet.
|
||||||
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
|
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||||
let port = rx.local_addr().unwrap().port();
|
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 packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
|
||||||
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
|
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ tracing.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
|
parking_lot.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
//! Automatic per-MAC NIC driver-mode escalation (v0.6.1).
|
||||||
|
//!
|
||||||
|
//! OpenPXE serves the firmware-net iPXE build (`snponly`/`undionly`) by
|
||||||
|
//! default — it's the most reliable choice for chainloading because the
|
||||||
|
//! firmware just proved its network works by downloading the NBP. A minority
|
||||||
|
//! of NICs have a missing or buggy firmware UNDI/SNP stack; those clients
|
||||||
|
//! TFTP the binary fine, but then iPXE can't bring the link up, so the
|
||||||
|
//! tell-tale second DHCP DISCOVER carrying the `iPXE` user-class never arrives
|
||||||
|
//! and the machine eventually re-PXE-boots.
|
||||||
|
//!
|
||||||
|
//! We detect exactly that: a *fresh* firmware DISCOVER from a MAC whose
|
||||||
|
//! previous firmware attempt was never confirmed by an iPXE handoff means the
|
||||||
|
//! firmware-net build failed → escalate that MAC to [`DriverMode::Builtin`]
|
||||||
|
//! (iPXE's own NIC drivers). The decision is sticky — once a MAC settles on a
|
||||||
|
//! mode that completes the handoff, later boots go straight to it. There is no
|
||||||
|
//! operator toggle; it just works, and the default (firmware) path is
|
||||||
|
//! unchanged so hardware that already boots never regresses.
|
||||||
|
|
||||||
|
use openpxe_core::DriverMode;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
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 a MAC's state after this long with no activity, so a transient
|
||||||
|
/// escalation doesn't pin a client to Builtin forever and the map stays
|
||||||
|
/// bounded over a long-running deployment.
|
||||||
|
const ENTRY_TTL: Duration = Duration::from_mins(30);
|
||||||
|
|
||||||
|
/// Hard cap on tracked MACs. Past this we evict the least-recently-seen
|
||||||
|
/// entry — escalation is best-effort, never a memory-growth vector.
|
||||||
|
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,
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DriverEscalation {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 and keep it sticky for next time.
|
||||||
|
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 mut g = self.inner.lock();
|
||||||
|
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
|
||||||
|
g.map
|
||||||
|
.retain(|_, e| now.duration_since(e.last_seen) < ENTRY_TTL);
|
||||||
|
g.last_prune = now;
|
||||||
|
}
|
||||||
|
// Inline staleness check: a MAC whose entry outlived the TTL starts
|
||||||
|
// fresh even when the amortized sweep above hasn't caught it yet.
|
||||||
|
if g.map
|
||||||
|
.get(mac)
|
||||||
|
.is_some_and(|e| now.duration_since(e.last_seen) >= ENTRY_TTL)
|
||||||
|
{
|
||||||
|
g.map.remove(mac);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 firmware-net build failed → escalate to
|
||||||
|
// the all-drivers build. Builtin is the most capable build
|
||||||
|
// we have, so it's the single escalation target (and a MAC
|
||||||
|
// already on Builtin simply stays there).
|
||||||
|
if entry.awaiting_confirm {
|
||||||
|
entry.mode = DriverMode::Builtin;
|
||||||
|
}
|
||||||
|
entry.awaiting_confirm = true;
|
||||||
|
}
|
||||||
|
entry.last_seen = now;
|
||||||
|
entry.mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_at(&self, mac: &str, now: Instant) {
|
||||||
|
let mut g = self.inner.lock();
|
||||||
|
if let Some(e) = g.map.get_mut(mac) {
|
||||||
|
e.awaiting_confirm = false;
|
||||||
|
e.last_seen = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evict_oldest(map: &mut HashMap<String, Entry>) {
|
||||||
|
if let Some(oldest) = map
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, e)| e.last_seen)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
{
|
||||||
|
map.remove(&oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[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 stale_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 entry is pruned → fresh firmware.
|
||||||
|
let later = t0 + Duration::from_mins(1) + ENTRY_TTL + Duration::from_secs(1);
|
||||||
|
assert_eq!(e.decide_at("dd", true, later), DriverMode::Firmware);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
//! clients silently drop them.
|
//! clients silently drop them.
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod escalation;
|
||||||
pub mod reply;
|
pub mod reply;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|
||||||
|
pub use escalation::DriverEscalation;
|
||||||
pub use server::DhcpProxyServer;
|
pub use server::DhcpProxyServer;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//! pass, or the HTTP URL of the boot script once iPXE has chained.
|
//! pass, or the HTTP URL of the boot script once iPXE has chained.
|
||||||
|
|
||||||
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
|
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
|
||||||
use openpxe_core::{ClientArch, FirmwareClass};
|
use openpxe_core::{ClientArch, DriverMode, FirmwareClass};
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
/// Where the reply directs the client next.
|
/// Where the reply directs the client next.
|
||||||
@@ -31,6 +31,11 @@ pub struct ReplyContext<'a> {
|
|||||||
pub our_ip: Ipv4Addr,
|
pub our_ip: Ipv4Addr,
|
||||||
pub arch: ClientArch,
|
pub arch: ClientArch,
|
||||||
pub class: FirmwareClass,
|
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.
|
/// Public base URL (scheme://host[:port]) used in HTTP directives.
|
||||||
pub public_base_url: &'a str,
|
pub public_base_url: &'a str,
|
||||||
}
|
}
|
||||||
@@ -52,9 +57,13 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
|
|||||||
},
|
},
|
||||||
FirmwareClass::HttpClient => {
|
FirmwareClass::HttpClient => {
|
||||||
// UEFI HTTP boot: client wants an http:// URL in option 67
|
// UEFI HTTP boot: client wants an http:// URL in option 67
|
||||||
// pointing at an EFI executable. We serve ipxe.efi over HTTP;
|
// pointing at an EFI executable. We serve the iPXE EFI build for
|
||||||
// it'll then do the same script-fetch the iPXE path does.
|
// the negotiated driver mode over HTTP; it'll then do the same
|
||||||
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
|
// script-fetch the iPXE path does.
|
||||||
|
let name = ctx
|
||||||
|
.arch
|
||||||
|
.ipxe_bootfile_mode(ctx.driver_mode)
|
||||||
|
.unwrap_or("snponly.efi");
|
||||||
BootDirective::HttpScript {
|
BootDirective::HttpScript {
|
||||||
url: format!(
|
url: format!(
|
||||||
"{}/ipxe/{}",
|
"{}/ipxe/{}",
|
||||||
@@ -63,7 +72,7 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() {
|
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile_mode(ctx.driver_mode) {
|
||||||
Some(name) => BootDirective::TftpIpxe {
|
Some(name) => BootDirective::TftpIpxe {
|
||||||
filename: name.to_string(),
|
filename: name.to_string(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
//! UDP listener loop for the DHCP proxy. Accepts on :67 (and :4011 on a
|
//! 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.
|
//! second socket) and dispatches each datagram through the pure reply logic.
|
||||||
|
|
||||||
|
use crate::escalation::DriverEscalation;
|
||||||
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
|
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
|
||||||
use dhcproto::v4::{DhcpOption, Message, OptionCode};
|
use dhcproto::v4::{DhcpOption, Message, OptionCode};
|
||||||
use dhcproto::{Decodable, Decoder, Encodable, Encoder};
|
use dhcproto::{Decodable, Decoder, Encodable, Encoder};
|
||||||
use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, FirmwareClass};
|
use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass};
|
||||||
use socket2::{Domain, Protocol, Socket, Type};
|
use socket2::{Domain, Protocol, Socket, Type};
|
||||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -18,6 +19,9 @@ pub struct DhcpProxyServer {
|
|||||||
public_base_url: String,
|
public_base_url: String,
|
||||||
clients: Arc<ClientRegistry>,
|
clients: Arc<ClientRegistry>,
|
||||||
metrics: openpxe_core::Metrics,
|
metrics: openpxe_core::Metrics,
|
||||||
|
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1). Shared across
|
||||||
|
/// the :67 and :4011 listener tasks via the server `Arc`.
|
||||||
|
escalation: DriverEscalation,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DhcpProxyServer {
|
impl DhcpProxyServer {
|
||||||
@@ -38,6 +42,7 @@ impl DhcpProxyServer {
|
|||||||
public_base_url,
|
public_base_url,
|
||||||
clients,
|
clients,
|
||||||
metrics,
|
metrics,
|
||||||
|
escalation: DriverEscalation::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,11 +131,30 @@ 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 => self
|
||||||
|
.escalation
|
||||||
|
.mode_for_firmware_attempt(&mac, label == "67"),
|
||||||
|
// Unreachable: FirmwareClass::Other returned above.
|
||||||
|
FirmwareClass::Other => DriverMode::Firmware,
|
||||||
|
};
|
||||||
|
|
||||||
let ctx = ReplyContext {
|
let ctx = ReplyContext {
|
||||||
request: &request,
|
request: &request,
|
||||||
our_ip: self.our_ip,
|
our_ip: self.our_ip,
|
||||||
arch,
|
arch,
|
||||||
class,
|
class,
|
||||||
|
driver_mode,
|
||||||
public_base_url: &self.public_base_url,
|
public_base_url: &self.public_base_url,
|
||||||
};
|
};
|
||||||
let directive = decide(&ctx);
|
let directive = decide(&ctx);
|
||||||
@@ -154,7 +178,7 @@ impl DhcpProxyServer {
|
|||||||
sock.send_to(&out, dest).await?;
|
sock.send_to(&out, dest).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "openpxe::dhcp",
|
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"
|
"PXE reply sent"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -213,11 +237,16 @@ fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocke
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn format_mac(chaddr: &[u8]) -> String {
|
fn format_mac(chaddr: &[u8]) -> String {
|
||||||
let take = chaddr.iter().take(6).copied().collect::<Vec<_>>();
|
use std::fmt::Write;
|
||||||
take.iter()
|
// One allocation — this runs for every PXE datagram we answer.
|
||||||
.map(|b| format!("{b:02x}"))
|
let mut s = String::with_capacity(17);
|
||||||
.collect::<Vec<_>>()
|
for (i, b) in chaddr.iter().take(6).enumerate() {
|
||||||
.join(":")
|
if i > 0 {
|
||||||
|
s.push(':');
|
||||||
|
}
|
||||||
|
let _ = write!(s, "{b:02x}");
|
||||||
|
}
|
||||||
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk raw DHCP options looking for option 93 (Client System Architecture)
|
/// Walk raw DHCP options looking for option 93 (Client System Architecture)
|
||||||
|
|||||||
+71
-14
@@ -35,7 +35,7 @@ use openpxe_core::{
|
|||||||
encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
|
encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
|
||||||
LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
|
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::{
|
use openpxe_iso_store::{
|
||||||
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
|
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
|
||||||
SmbState, UnattendedKind, UnattendedMeta,
|
SmbState, UnattendedKind, UnattendedMeta,
|
||||||
@@ -406,6 +406,22 @@ fn bundled_logo_response() -> Response {
|
|||||||
/// brand mark falls back to the *default* background for the PXE screen
|
/// brand mark falls back to the *default* background for the PXE screen
|
||||||
/// (the WebUI still renders the SVG natively in the top-left).
|
/// (the WebUI still renders the SVG natively in the top-left).
|
||||||
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
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
|
// Resolve the operator's raster upload, if any and if it's a format
|
||||||
// iPXE/our compositor can consume. SVG (or a missing/unreadable
|
// iPXE/our compositor can consume. SVG (or a missing/unreadable
|
||||||
// file) yields `None`, which composes the default background.
|
// file) yields `None`, which composes the default background.
|
||||||
@@ -450,6 +466,12 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
|||||||
.into_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")),
|
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
|
||||||
@@ -460,7 +482,7 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
|||||||
HeaderValue::from_static("no-cache, max-age=0"),
|
HeaderValue::from_static("no-cache, max-age=0"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
composed,
|
png,
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -638,7 +660,23 @@ async fn boot_sub(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
Some(token) => {
|
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(true) => { /* fall through to render the entry */ }
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
// Don't log the candidate — just the
|
// Don't log the candidate — just the
|
||||||
@@ -735,9 +773,15 @@ async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
|
|||||||
if name.contains('/') || name.contains('\\') {
|
if name.contains('/') || name.contains('\\') {
|
||||||
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
|
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
|
||||||
}
|
}
|
||||||
let Some(bytes) = asset_bytes(&name) else {
|
let Some(data) = asset_slice(&name) else {
|
||||||
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
|
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
|
||||||
};
|
};
|
||||||
|
// 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 +812,10 @@ async fn iso_raw(
|
|||||||
};
|
};
|
||||||
match &meta.source {
|
match &meta.source {
|
||||||
IsoSource::Local => {
|
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();
|
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||||
};
|
};
|
||||||
match stream_file_range(&path, headers.get(header::RANGE)).await {
|
match stream_file_range(&path, headers.get(header::RANGE)).await {
|
||||||
@@ -1027,15 +1074,25 @@ fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)>
|
|||||||
return Some((total.saturating_sub(n), total.saturating_sub(1), true));
|
return Some((total.saturating_sub(n), total.saturating_sub(1), true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut parts = spec.splitn(2, '-');
|
// RFC 7233 §3.1: a Range header we can't parse is *ignored* (200 +
|
||||||
let start = parts
|
// full body), never coerced into a bogus 206 claiming the whole
|
||||||
.next()
|
// file. Only `first-pos[-last-pos]` with numeric positions reaches
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
// the partial path; `None` is reserved for syntactically valid but
|
||||||
.unwrap_or(0);
|
// unsatisfiable ranges (→ 416).
|
||||||
let end = parts
|
let full = Some((0, total.saturating_sub(1), false));
|
||||||
.next()
|
let Some((start_s, end_s)) = spec.split_once('-') else {
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
return full;
|
||||||
.unwrap_or(total.saturating_sub(1));
|
};
|
||||||
|
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 {
|
if start >= total {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,10 +154,15 @@ pub fn session_cookie(session: &str) -> String {
|
|||||||
|
|
||||||
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
|
// `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()?;
|
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||||
for part in raw.split(';') {
|
for part in raw.split(';') {
|
||||||
let part = part.trim();
|
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());
|
return Some(v.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,7 +251,14 @@ pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody
|
|||||||
)
|
)
|
||||||
.into_response();
|
.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_) => {
|
Ok(pub_) => {
|
||||||
let session = state.sessions.create(&pub_.username);
|
let session = state.sessions.create(&pub_.username);
|
||||||
login_response(StatusCode::CREATED, &pub_, &session)
|
login_response(StatusCode::CREATED, &pub_, &session)
|
||||||
@@ -271,8 +283,13 @@ pub struct LoginBody {
|
|||||||
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
|
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
|
||||||
// Brief, deliberately vague — "invalid credentials" rather than
|
// Brief, deliberately vague — "invalid credentials" rather than
|
||||||
// "no such user" / "wrong password". Same anti-enumeration posture
|
// "no such user" / "wrong password". Same anti-enumeration posture
|
||||||
// as Sonarr/Radarr.
|
// as Sonarr/Radarr. The bcrypt verify is ~100-200 ms of pure CPU on
|
||||||
let pub_ = match state.admin.verify(&body.username, &body.password) {
|
// 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(Some(u)) => u,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return (
|
return (
|
||||||
@@ -319,6 +336,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.
|
// and the logo asset is public, so this leaks nothing sensitive.
|
||||||
let has_custom_logo = state.branding.has_any_web_logo();
|
let has_custom_logo = state.branding.has_any_web_logo();
|
||||||
let logo_rev = state.branding.logo_rev();
|
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() {
|
if !state.admin.is_configured() {
|
||||||
return (
|
return (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -327,6 +350,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
"has_custom_logo": has_custom_logo,
|
"has_custom_logo": has_custom_logo,
|
||||||
"logo_rev": logo_rev,
|
"logo_rev": logo_rev,
|
||||||
|
"sso": sso,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
@@ -343,6 +367,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
"session_user": u,
|
"session_user": u,
|
||||||
"has_custom_logo": has_custom_logo,
|
"has_custom_logo": has_custom_logo,
|
||||||
"logo_rev": logo_rev,
|
"logo_rev": logo_rev,
|
||||||
|
"sso": sso,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
@@ -353,6 +378,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
"has_custom_logo": has_custom_logo,
|
"has_custom_logo": has_custom_logo,
|
||||||
"logo_rev": logo_rev,
|
"logo_rev": logo_rev,
|
||||||
|
"sso": sso,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
@@ -385,11 +411,18 @@ pub async fn api_update_credentials(
|
|||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
let result = state.admin.update_credentials(
|
// Two bcrypt operations (verify current + hash new) plus a sync disk
|
||||||
|
// persist — run the lot on the blocking pool.
|
||||||
|
let admin = state.admin.clone();
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
admin.update_credentials(
|
||||||
&body.current_password,
|
&body.current_password,
|
||||||
body.new_username.as_deref(),
|
body.new_username.as_deref(),
|
||||||
body.new_password.as_deref(),
|
body.new_password.as_deref(),
|
||||||
);
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
|
||||||
match result {
|
match result {
|
||||||
Ok(pub_) => {
|
Ok(pub_) => {
|
||||||
state.sessions.revoke_all();
|
state.sessions.revoke_all();
|
||||||
|
|||||||
@@ -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
|
// 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
|
// dark field, or a default OpenPXE mark when none is uploaded. The
|
||||||
// `--top 290` reserves the top band (where the logo paints) so 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` +
|
// menu text lands below it.
|
||||||
// `CONSOLE_FRAMEBUFFER` (our x86_64 UEFI binaries, built from source
|
//
|
||||||
// — see deploy/docker/Dockerfile) this paints the background and
|
// v0.5.7: gate the whole command behind `iseq ${platform} efi`.
|
||||||
// overlays the menu. On a build *without* PNG support (the fetched
|
// `console --picture` needs IMAGE_PNG + CONSOLE_FRAMEBUFFER, which
|
||||||
// BIOS/i386/arm64 binaries) the whole `console --picture …` command
|
// only our from-source UEFI binaries carry (x86_64/arm64 UEFI — see
|
||||||
// fails and the `|| console` resets to a clean full-screen text
|
// deploy/docker/Dockerfile). The fetched BIOS `undionly.kpxe` has
|
||||||
// menu. Either way there's no ASCII placeholder anymore.
|
// 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!(
|
let _ = writeln!(
|
||||||
s,
|
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
|
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
|
||||||
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
|
// 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 {
|
} else {
|
||||||
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
|
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 >");
|
let _ = writeln!(s, "item windows Windows Installers >");
|
||||||
} else if settings.windows_enabled {
|
|
||||||
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
|
|
||||||
} else {
|
} else {
|
||||||
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
|
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
|
||||||
}
|
}
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
s,
|
s,
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(),
|
|||||||
.trim()
|
.trim()
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
|
.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())
|
.body(body.to_string())
|
||||||
.map_err(|e| format!("could not build email: {e}"))?;
|
.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 base-url http://10.0.0.5
|
||||||
set esc:hex 1b
|
set esc:hex 1b
|
||||||
set cls ${esc:string}[2J
|
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}
|
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
|
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
|
:menu
|
||||||
@@ -17,7 +17,7 @@ item --gap -- ------------------------- Default -------------------------
|
|||||||
item local Boot from Local HDD
|
item local Boot from Local HDD
|
||||||
item --gap -- ----------------------- Installers -----------------------
|
item --gap -- ----------------------- Installers -----------------------
|
||||||
item --gap -- (no Linux ISOs uploaded)
|
item --gap -- (no Linux ISOs uploaded)
|
||||||
item --gap -- (Windows support disabled in Settings)
|
item --gap -- (no Windows ISOs uploaded)
|
||||||
item --gap -- -------------------------- Tools --------------------------
|
item --gap -- -------------------------- Tools --------------------------
|
||||||
item tools Tools >
|
item tools Tools >
|
||||||
item --gap -- ---------------------- Queued Deployment ---------------------
|
item --gap -- ---------------------- Queued Deployment ---------------------
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ use openpxe_iso_store::{
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use time::OffsetDateTime;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub iso_store: IsoStore,
|
pub iso_store: IsoStore,
|
||||||
@@ -29,6 +33,12 @@ pub struct AppState {
|
|||||||
/// operator hasn't uploaded anything, the WebUI serves the bundled
|
/// operator hasn't uploaded anything, the WebUI serves the bundled
|
||||||
/// rainbow-horizon mark.
|
/// rainbow-horizon mark.
|
||||||
pub branding: BrandingStore,
|
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
|
/// Forms-auth admin record + first-run bootstrap state. When
|
||||||
/// `admin.is_configured() == false`, the auth middleware passes
|
/// `admin.is_configured() == false`, the auth middleware passes
|
||||||
/// every request through and `/api/me` reports `setup_required`.
|
/// every request through and `/api/me` reports `setup_required`.
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
hosts,
|
hosts,
|
||||||
boot_log,
|
boot_log,
|
||||||
branding,
|
branding,
|
||||||
|
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||||
admin,
|
admin,
|
||||||
sessions,
|
sessions,
|
||||||
sso,
|
sso,
|
||||||
@@ -693,7 +694,7 @@ async fn log_recent_returns_buffered_lines() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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
|
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
|
||||||
// sentinel so introspection labels it WindowsPe with has_boot_wim.
|
// sentinel so introspection labels it WindowsPe with has_boot_wim.
|
||||||
let mut buf = vec![0u8; 32 * 2048];
|
let mut buf = vec![0u8; 32 * 2048];
|
||||||
@@ -763,38 +764,35 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
|||||||
"introspection should detect sources/boot.wim sentinel"
|
"introspection should detect sources/boot.wim sentinel"
|
||||||
);
|
);
|
||||||
|
|
||||||
// The boot entry should be a wimboot kind with the canonical 5-file
|
// v0.5.8: Windows boots via iPXE HTTP sanboot of the raw ISO — no SMB,
|
||||||
// chain documented in the LinusTechTips iPXE-Windows guide.
|
// 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];
|
let entry = &meta["boot_entries"][0];
|
||||||
assert_eq!(entry["kind"]["kind"], "wimboot");
|
assert_eq!(entry["kind"]["kind"], "san_boot_iso");
|
||||||
let files = entry["kind"]["files"].as_array().unwrap();
|
let iso_url = entry["kind"]["iso_url"].as_str().unwrap();
|
||||||
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect();
|
assert!(
|
||||||
assert!(names.contains(&"bootmgr"));
|
std::path::Path::new(iso_url)
|
||||||
assert!(names.contains(&"bootmgr.efi"));
|
.extension()
|
||||||
assert!(names.contains(&"bcd"));
|
.is_some_and(|e| e.eq_ignore_ascii_case("iso")),
|
||||||
assert!(names.contains(&"boot.sdi"));
|
"sanboot should target the raw ISO, got: {iso_url}"
|
||||||
assert!(names.contains(&"boot.wim"));
|
);
|
||||||
|
|
||||||
// Render the entry script and verify:
|
// Render the entry script and verify:
|
||||||
// 1. It uses wimboot
|
// 1. It uses `sanboot` against the raw ISO over HTTP
|
||||||
// 2. All 5 files are referenced via `initrd --name`
|
// 2. NO trust-store / driver / testsigning operations slip in
|
||||||
// 3. NO trust-store / driver / testsigning operations slip in
|
|
||||||
let entry_id = entry["id"].as_str().unwrap();
|
let entry_id = entry["id"].as_str().unwrap();
|
||||||
let url = format!("/boot/{entry_id}.ipxe");
|
let url = format!("/boot/{entry_id}.ipxe");
|
||||||
let (s, body) = get(&app, &url).await;
|
let (s, body) = get(&app, &url).await;
|
||||||
assert_eq!(s, StatusCode::OK);
|
assert_eq!(s, StatusCode::OK);
|
||||||
let script = String::from_utf8(body).unwrap();
|
let script = String::from_utf8(body).unwrap();
|
||||||
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
|
|
||||||
assert!(
|
assert!(
|
||||||
script.contains("ipxe/wimboot"),
|
script.contains("sanboot"),
|
||||||
"missing wimboot loader:\n{script}"
|
"missing sanboot line:\n{script}"
|
||||||
);
|
);
|
||||||
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
|
|
||||||
assert!(
|
assert!(
|
||||||
script.contains(&format!("initrd --name {tag}")),
|
script.contains(&format!("/{iso_url}")),
|
||||||
"missing `initrd --name {tag}` line:\n{script}"
|
"sanboot should reference the raw ISO url:\n{script}"
|
||||||
);
|
);
|
||||||
}
|
|
||||||
// Hard guarantees we never want to see in any client-facing script.
|
// Hard guarantees we never want to see in any client-facing script.
|
||||||
let lower = script.to_lowercase();
|
let lower = script.to_lowercase();
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
|
|||||||
@@ -6,43 +6,40 @@
|
|||||||
//! missing, that architecture simply won't have PXE support — we log at
|
//! missing, that architecture simply won't have PXE support — we log at
|
||||||
//! startup and serve what we have.
|
//! 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
|
//! - `undionly.kpxe` — Legacy x86 BIOS
|
||||||
//! - `snponly-i386.efi` — IA32 UEFI
|
//! - `snponly-i386.efi` — IA32 UEFI
|
||||||
//! - `snponly.efi` — x86_64 UEFI
|
//! - `snponly.efi` — x86_64 UEFI
|
||||||
//! - `snponly-arm32.efi` — ARM32 UEFI
|
|
||||||
//! - `snponly-arm64.efi` — ARM64 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)
|
//! - `wimboot` — Windows boot shim (fetched separately for WIM chains)
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
use openpxe_core::ClientArch;
|
use openpxe_core::{ClientArch, DriverMode};
|
||||||
use rust_embed::Embed;
|
use rust_embed::Embed;
|
||||||
|
|
||||||
#[derive(Embed)]
|
#[derive(Embed)]
|
||||||
#[folder = "../../assets/ipxe/"]
|
#[folder = "../../assets/ipxe/"]
|
||||||
#[include = "*.kpxe"]
|
#[include = "*.kpxe"]
|
||||||
#[include = "*.efi"]
|
#[include = "*.efi"]
|
||||||
|
#[include = "*.pxe"]
|
||||||
#[include = "wimboot"]
|
#[include = "wimboot"]
|
||||||
pub struct IpxeAssets;
|
pub struct IpxeAssets;
|
||||||
|
|
||||||
/// Return the embedded iPXE binary for `arch`, or `None` if we didn't bundle
|
/// Return a named embedded asset (e.g. `snponly.efi`, `wimboot`) as a
|
||||||
/// one for that architecture.
|
/// `Cow` over the embedded bytes. In release builds the data is borrowed
|
||||||
#[must_use]
|
/// straight from the binary's rodata — **zero copy** — which matters
|
||||||
pub fn bootfile_bytes(arch: ClientArch) -> Option<Vec<u8>> {
|
/// because the TFTP and HTTP serving paths hit this for every boot
|
||||||
let name = arch.ipxe_bootfile()?;
|
/// (`ipxe.efi` is ~1 MiB). Debug builds read from disk and return Owned.
|
||||||
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.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
|
pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
|
||||||
IpxeAssets::get(name).map(|f| f.data)
|
IpxeAssets::get(name).map(|f| f.data)
|
||||||
@@ -56,25 +53,42 @@ pub fn list_assets() -> Vec<String> {
|
|||||||
.collect()
|
.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() {
|
pub fn log_availability() {
|
||||||
let have: std::collections::HashSet<String> = list_assets().into_iter().collect();
|
let have: std::collections::HashSet<String> = list_assets().into_iter().collect();
|
||||||
let needed = [
|
let arches = [
|
||||||
(ClientArch::LegacyX86, "undionly.kpxe"),
|
ClientArch::LegacyX86,
|
||||||
(ClientArch::Ia32Uefi, "snponly-i386.efi"),
|
ClientArch::Ia32Uefi,
|
||||||
(ClientArch::X64Uefi, "snponly.efi"),
|
ClientArch::X64Uefi,
|
||||||
// ARM32 UEFI deferred — no upstream snponly binary published.
|
// ARM32 UEFI deferred — no upstream binary published in either mode.
|
||||||
(ClientArch::Arm64Uefi, "snponly-arm64.efi"),
|
ClientArch::Arm64Uefi,
|
||||||
];
|
];
|
||||||
for (arch, name) in needed {
|
for arch in arches {
|
||||||
|
for mode in [DriverMode::Firmware, DriverMode::Builtin] {
|
||||||
|
let Some(name) = arch.ipxe_bootfile_mode(mode) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if have.contains(name) {
|
if have.contains(name) {
|
||||||
tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
|
tracing::info!(
|
||||||
} else {
|
target: "openpxe::ipxe",
|
||||||
|
"bundled iPXE for {} [{mode:?}]: {name}", arch.as_str()
|
||||||
|
);
|
||||||
|
} else if mode == DriverMode::Firmware {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "openpxe::ipxe",
|
target: "openpxe::ipxe",
|
||||||
"MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot",
|
"MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
|
||||||
arch.as_str(), name
|
arch.as_str()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::ipxe",
|
||||||
|
"no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch",
|
||||||
|
arch.as_str()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ pub enum BootKind {
|
|||||||
wimboot_url: String,
|
wimboot_url: String,
|
||||||
files: Vec<(String, String)>,
|
files: Vec<(String, String)>,
|
||||||
},
|
},
|
||||||
/// Last-resort: SAN-boot the ISO as an emulated CD. Only works for small
|
/// SAN-boot the raw ISO as an emulated CD (iPXE `sanboot`). The emulated
|
||||||
/// ISOs (<~1 GiB) and older distros. Kept for completeness, not the
|
/// CD is backed by on-demand HTTP range reads, so ISO size is *not* a
|
||||||
/// default.
|
/// 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 },
|
SanBootIso { iso_url: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::Path;
|
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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum DistroFamily {
|
pub enum DistroFamily {
|
||||||
DebianUbuntu,
|
DebianUbuntu,
|
||||||
@@ -22,10 +22,22 @@ pub enum DistroFamily {
|
|||||||
Arch,
|
Arch,
|
||||||
Alpine,
|
Alpine,
|
||||||
WindowsPe,
|
WindowsPe,
|
||||||
|
#[default]
|
||||||
Unknown,
|
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 — e.g. a Windows 11 ISO tagged `Unknown` by an older binary —
|
||||||
|
/// without the operator having to delete and re-upload it.
|
||||||
|
///
|
||||||
|
/// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened
|
||||||
|
/// Windows (UDF/UTF-16) detection becomes retroactive.
|
||||||
|
pub const INTROSPECT_REV: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct IntrospectionReport {
|
pub struct IntrospectionReport {
|
||||||
pub family: DistroFamily,
|
pub family: DistroFamily,
|
||||||
pub volume_label: Option<String>,
|
pub volume_label: Option<String>,
|
||||||
@@ -35,17 +47,28 @@ pub struct IntrospectionReport {
|
|||||||
pub initrd_paths: Vec<String>,
|
pub initrd_paths: Vec<String>,
|
||||||
/// True if `sources/boot.wim` present — Windows install media.
|
/// True if `sources/boot.wim` present — Windows install media.
|
||||||
pub has_boot_wim: bool,
|
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. Old
|
||||||
|
/// `meta.json` files without the field deserialize as 0, which is
|
||||||
|
/// below [`INTROSPECT_REV`], triggering a one-time re-introspect on
|
||||||
|
/// the next startup. v0.5.9.
|
||||||
|
#[serde(default)]
|
||||||
|
pub introspect_rev: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log
|
/// 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.
|
/// and return an `Unknown` family so the uploader still sees a record.
|
||||||
pub fn introspect(path: &Path) -> IntrospectionReport {
|
pub fn introspect(path: &Path) -> IntrospectionReport {
|
||||||
let mut report = IntrospectionReport {
|
let mut report = IntrospectionReport {
|
||||||
family: DistroFamily::Unknown,
|
introspect_rev: INTROSPECT_REV,
|
||||||
volume_label: None,
|
..Default::default()
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: Vec::new(),
|
|
||||||
has_boot_wim: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(mut f) = std::fs::File::open(path) else {
|
let Ok(mut f) = std::fs::File::open(path) else {
|
||||||
@@ -68,6 +91,11 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Does the ISO have an El Torito boot catalog? This is what decides
|
||||||
|
// whether an ISO we *can't* otherwise classify is bootable at all —
|
||||||
|
// a bootable ISO sanboots; a data/appliance ISO (no catalog) can't.
|
||||||
|
report.el_torito = detect_el_torito(&mut f);
|
||||||
|
|
||||||
// Cheap content scan: read the first ~64 MiB, look for signature filenames.
|
// Cheap content scan: read the first ~64 MiB, look for signature filenames.
|
||||||
// This is enough to identify `sources/boot.wim` (Windows) and common
|
// This is enough to identify `sources/boot.wim` (Windows) and common
|
||||||
// kernel/initrd paths for the major Linux distros.
|
// kernel/initrd paths for the major Linux distros.
|
||||||
@@ -75,7 +103,13 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
|
|||||||
let scan_bytes = 64 * 1024 * 1024;
|
let scan_bytes = 64 * 1024 * 1024;
|
||||||
let mut buf = vec![0u8; 1024 * 1024];
|
let mut buf = vec![0u8; 1024 * 1024];
|
||||||
let mut read_total = 0usize;
|
let mut read_total = 0usize;
|
||||||
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
|
// Size the haystack to what will actually be read — the scan cap or
|
||||||
|
// the file itself, whichever is smaller — so the fill never reallocs
|
||||||
|
// and a small ISO doesn't reserve the full 64 MiB.
|
||||||
|
let file_len = f.metadata().map_or(usize::MAX, |m| {
|
||||||
|
usize::try_from(m.len()).unwrap_or(usize::MAX)
|
||||||
|
});
|
||||||
|
let mut haystack = Vec::with_capacity(scan_bytes.min(file_len));
|
||||||
while read_total < scan_bytes {
|
while read_total < scan_bytes {
|
||||||
let n = f.read(&mut buf).unwrap_or(0);
|
let n = f.read(&mut buf).unwrap_or(0);
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
@@ -85,12 +119,37 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
|
|||||||
read_total += n;
|
read_total += n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `sources/boot.wim` is the definitive Windows-install-media marker
|
||||||
|
// when the ISO exposes ASCII (ISO9660/Joliet) names. `contains_ascii`
|
||||||
|
// is case-insensitive, so one form covers BOOT.WIM / boot.wim and the
|
||||||
|
// backslash variant.
|
||||||
if contains_ascii(&haystack, b"sources/boot.wim")
|
if contains_ascii(&haystack, b"sources/boot.wim")
|
||||||
|| contains_ascii(&haystack, b"SOURCES/BOOT.WIM")
|
|| contains_ascii(&haystack, b"sources\\boot.wim")
|
||||||
|| contains_ascii(&haystack, b"SOURCES\\BOOT.WIM")
|
|
||||||
{
|
{
|
||||||
report.has_boot_wim = true;
|
report.has_boot_wim = true;
|
||||||
|
report.family = DistroFamily::WindowsPe;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.5.8: broaden Windows detection. Modern Windows 10/11 ISOs are
|
||||||
|
// UDF — filenames are stored as UTF-16 (so the ASCII scan above misses
|
||||||
|
// them) and the volume label is a cryptic Microsoft string (so
|
||||||
|
// `family_from_label` misses it too). Booting is via HTTP sanboot of
|
||||||
|
// the raw ISO (no boot.wim extraction), so we only need the *family*.
|
||||||
|
// Catch the common cases: well-known Windows markers in either ASCII
|
||||||
|
// or UTF-16LE within the first 16 MiB, plus a filename hint.
|
||||||
if report.family == DistroFamily::Unknown {
|
if report.family == DistroFamily::Unknown {
|
||||||
|
let head = &haystack[..haystack.len().min(16 * 1024 * 1024)];
|
||||||
|
let ascii_markers: [&[u8]; 4] = [
|
||||||
|
b"bootmgr",
|
||||||
|
b"sources/install.wim",
|
||||||
|
b"sources/install.esd",
|
||||||
|
b"efi/microsoft",
|
||||||
|
];
|
||||||
|
let utf16_markers = ["bootmgr", "boot.wim", "install.wim", "microsoft"];
|
||||||
|
let looks_windows = ascii_markers.iter().any(|m| contains_ascii(head, m))
|
||||||
|
|| utf16_markers.iter().any(|m| contains_utf16le_ci(head, m))
|
||||||
|
|| filename_looks_windows(path);
|
||||||
|
if looks_windows {
|
||||||
report.family = DistroFamily::WindowsPe;
|
report.family = DistroFamily::WindowsPe;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,6 +217,83 @@ fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
|
|||||||
.any(|w| w.eq_ignore_ascii_case(needle))
|
.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`).
|
||||||
|
/// Used only as a last-resort family hint when the content scan and volume
|
||||||
|
/// label are inconclusive. v0.5.8.
|
||||||
|
fn filename_looks_windows(path: &Path) -> bool {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.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 (offset 0x8000) 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 an El Torito 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/huge image can't spin us. v0.5.9.
|
||||||
|
fn detect_el_torito(f: &mut std::fs::File) -> bool {
|
||||||
|
let mut vd = [0u8; 2048];
|
||||||
|
for lba in 16u64..32 {
|
||||||
|
if f.seek(SeekFrom::Start(lba * 2048)).is_err() || f.read_exact(&mut vd).is_err() {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -179,4 +315,72 @@ mod tests {
|
|||||||
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
|
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
|
||||||
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
|
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf16le_marker_matches_case_insensitively() {
|
||||||
|
// "boot.wim" encoded UTF-16LE, mixed case — UDF stores Windows
|
||||||
|
// filenames this way, which the ASCII scan can't see.
|
||||||
|
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"));
|
||||||
|
// An ASCII (not UTF-16) occurrence must NOT match the UTF-16 scan.
|
||||||
|
assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn el_torito_boot_catalog_detected() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
// Helper: stamp a 2048-byte descriptor at `lba` with type + magic.
|
||||||
|
let stamp = |img: &mut [u8], lba: usize, ty: u8| {
|
||||||
|
let off = lba * 2048;
|
||||||
|
img[off] = ty;
|
||||||
|
img[off + 1..off + 6].copy_from_slice(b"CD001");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bootable image: PVD @16, El Torito Boot Record @17, terminator @18.
|
||||||
|
let mut boot = vec![0u8; 2048 * 19];
|
||||||
|
stamp(&mut boot, 16, 0x01);
|
||||||
|
stamp(&mut boot, 17, 0x00);
|
||||||
|
boot[17 * 2048 + 7..17 * 2048 + 7 + EL_TORITO_ID.len()].copy_from_slice(EL_TORITO_ID);
|
||||||
|
stamp(&mut boot, 18, 0xFF);
|
||||||
|
let bp = dir.path().join("boot.iso");
|
||||||
|
std::fs::write(&bp, &boot).unwrap();
|
||||||
|
let mut f = std::fs::File::open(&bp).unwrap();
|
||||||
|
assert!(
|
||||||
|
detect_el_torito(&mut f),
|
||||||
|
"El Torito boot record should match"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Data/appliance image: PVD @16, terminator @17, no boot record.
|
||||||
|
let mut data = vec![0u8; 2048 * 18];
|
||||||
|
stamp(&mut data, 16, 0x01);
|
||||||
|
stamp(&mut data, 17, 0xFF);
|
||||||
|
let dp = dir.path().join("data.iso");
|
||||||
|
std::fs::write(&dp, &data).unwrap();
|
||||||
|
let mut f2 = std::fs::File::open(&dp).unwrap();
|
||||||
|
assert!(!detect_el_torito(&mut f2), "data ISO has no boot catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filename_hint_catches_windows_isos() {
|
||||||
|
use std::path::Path;
|
||||||
|
assert!(filename_looks_windows(Path::new(
|
||||||
|
"en-us_windows_11_iot_enterprise_ltsc_2024_x64_dvd.iso"
|
||||||
|
)));
|
||||||
|
assert!(filename_looks_windows(Path::new(
|
||||||
|
"Win10_22H2_English_x64.iso"
|
||||||
|
)));
|
||||||
|
assert!(filename_looks_windows(Path::new("winserver2022.iso")));
|
||||||
|
assert!(!filename_looks_windows(Path::new(
|
||||||
|
"ubuntu-24.04-desktop.iso"
|
||||||
|
)));
|
||||||
|
assert!(!filename_looks_windows(Path::new(
|
||||||
|
"Rocky-9.4-x86_64-dvd.iso"
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,14 +57,14 @@
|
|||||||
//! UI to ask for. (If a future server needs Kerberos or non-default
|
//! 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.)
|
//! uid mapping we can add those, but for ISO read access nobody does.)
|
||||||
|
|
||||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
use crate::introspect::IntrospectionReport;
|
||||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use nfs3_client::tokio::TokioConnector;
|
use nfs3_client::tokio::TokioConnector;
|
||||||
use nfs3_client::Nfs3ConnectionBuilder;
|
use nfs3_client::Nfs3ConnectionBuilder;
|
||||||
use nfs3_types::nfs3::{
|
use nfs3_types::nfs3::{
|
||||||
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args,
|
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args, Nfs3Result,
|
||||||
Nfs3Result, READ3args, READDIR3args,
|
READ3args, READDIR3args,
|
||||||
};
|
};
|
||||||
use nfs3_types::rpc::{auth_unix, opaque_auth};
|
use nfs3_types::rpc::{auth_unix, opaque_auth};
|
||||||
use nfs3_types::xdr_codec::Opaque;
|
use nfs3_types::xdr_codec::Opaque;
|
||||||
@@ -220,10 +220,7 @@ impl NfsShareManager {
|
|||||||
/// Register an NFS share. Validates, probes connectivity by
|
/// Register an NFS share. Validates, probes connectivity by
|
||||||
/// performing a real MOUNT3 + READDIR3, and registers the
|
/// performing a real MOUNT3 + READDIR3, and registers the
|
||||||
/// resulting ISOs with the store.
|
/// resulting ISOs with the store.
|
||||||
pub async fn add(
|
pub async fn add(&self, req: NfsAddRequest) -> std::result::Result<NfsShare, NfsShareError> {
|
||||||
&self,
|
|
||||||
req: NfsAddRequest,
|
|
||||||
) -> std::result::Result<NfsShare, NfsShareError> {
|
|
||||||
let server = normalize_server(&req.server);
|
let server = normalize_server(&req.server);
|
||||||
let export = req.export.trim().to_string();
|
let export = req.export.trim().to_string();
|
||||||
if server.is_empty() {
|
if server.is_empty() {
|
||||||
@@ -258,7 +255,9 @@ impl NfsShareManager {
|
|||||||
if let Err(e) = self.rescan_inner(&id).await {
|
if let Err(e) = self.rescan_inner(&id).await {
|
||||||
let m = self.get(&id);
|
let m = self.get(&id);
|
||||||
return Err(NfsShareError {
|
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()),
|
.unwrap_or_else(|| e.to_string()),
|
||||||
stderr: String::new(),
|
stderr: String::new(),
|
||||||
hint: m.and_then(|m| m.last_hint),
|
hint: m.and_then(|m| m.last_hint),
|
||||||
@@ -333,8 +332,7 @@ impl NfsShareManager {
|
|||||||
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (tx, rx) =
|
let (tx, rx) = tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
|
||||||
tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
|
|
||||||
let server = share.server.clone();
|
let server = share.server.clone();
|
||||||
let export = share.export.clone();
|
let export = share.export.clone();
|
||||||
let port = share.port;
|
let port = share.port;
|
||||||
@@ -358,16 +356,11 @@ impl NfsShareManager {
|
|||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
// Best-effort signal of the error to the consumer.
|
// Best-effort signal of the error to the consumer.
|
||||||
// If the receiver has already dropped we just exit.
|
// If the receiver has already dropped we just exit.
|
||||||
let _ = tx
|
let _ = tx.send(Err(std::io::Error::other(e.to_string()))).await;
|
||||||
.send(Err(std::io::Error::other(e.to_string())))
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(NfsStream {
|
Ok(NfsStream { rx, _task: task })
|
||||||
rx,
|
|
||||||
_task: task,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── internals ─────────────────────────────────────────────────────
|
// ── internals ─────────────────────────────────────────────────────
|
||||||
@@ -399,13 +392,7 @@ impl NfsShareManager {
|
|||||||
// Same approach as SMB: no real introspection over the
|
// Same approach as SMB: no real introspection over the
|
||||||
// network in v0.4.67. The boot-entry generator falls back
|
// network in v0.4.67. The boot-entry generator falls back
|
||||||
// to filename-based sanboot detection.
|
// to filename-based sanboot detection.
|
||||||
let report = IntrospectionReport {
|
let report = IntrospectionReport::default();
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: Vec::new(),
|
|
||||||
has_boot_wim: false,
|
|
||||||
};
|
|
||||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||||
let source = IsoSource::Nfs {
|
let source = IsoSource::Nfs {
|
||||||
share_id: share.id.clone(),
|
share_id: share.id.clone(),
|
||||||
@@ -988,8 +975,14 @@ mod tests {
|
|||||||
// the allow-list and the secure/insecure angle.
|
// the allow-list and the secure/insecure angle.
|
||||||
let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap();
|
let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap();
|
||||||
let lc = h.to_lowercase();
|
let lc = h.to_lowercase();
|
||||||
assert!(lc.contains("insecure") || lc.contains("privileged"), "got: {h}");
|
assert!(
|
||||||
assert!(lc.contains("allow") || lc.contains("permission"), "got: {h}");
|
lc.contains("insecure") || lc.contains("privileged"),
|
||||||
|
"got: {h}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
lc.contains("allow") || lc.contains("permission"),
|
||||||
|
"got: {h}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
//! hint}` error shape is shared so the storage tab renders all three
|
//! hint}` error shape is shared so the storage tab renders all three
|
||||||
//! protocols through one code path.
|
//! protocols through one code path.
|
||||||
|
|
||||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
use crate::introspect::IntrospectionReport;
|
||||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use openpxe_core::{Error, Result};
|
use openpxe_core::{Error, Result};
|
||||||
@@ -480,13 +480,7 @@ impl SftpShareManager {
|
|||||||
// register `Unknown` and let the boot-entry generator fall
|
// register `Unknown` and let the boot-entry generator fall
|
||||||
// back to filename-based detection. SFTP *could* do bounded
|
// back to filename-based detection. SFTP *could* do bounded
|
||||||
// PVD reads (it has random access) — a follow-up can add it.
|
// PVD reads (it has random access) — a follow-up can add it.
|
||||||
let report = IntrospectionReport {
|
let report = IntrospectionReport::default();
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: Vec::new(),
|
|
||||||
has_boot_wim: false,
|
|
||||||
};
|
|
||||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||||
let source = IsoSource::Sftp {
|
let source = IsoSource::Sftp {
|
||||||
share_id: share.id.clone(),
|
share_id: share.id.clone(),
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
//! streaming. A follow-up release can add libsmbclient-based seek if
|
//! streaming. A follow-up release can add libsmbclient-based seek if
|
||||||
//! a real workload needs it.
|
//! a real workload needs it.
|
||||||
|
|
||||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
use crate::introspect::IntrospectionReport;
|
||||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||||
use openpxe_core::{Error, Result};
|
use openpxe_core::{Error, Result};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
@@ -231,10 +231,7 @@ impl SmbShareManager {
|
|||||||
|
|
||||||
/// Add or refresh a share. Validates the input, writes a creds
|
/// Add or refresh a share. Validates the input, writes a creds
|
||||||
/// file, probes connectivity, and scans for ISOs.
|
/// file, probes connectivity, and scans for ISOs.
|
||||||
pub async fn add(
|
pub async fn add(&self, req: SmbAddRequest) -> std::result::Result<SmbShare, SmbShareError> {
|
||||||
&self,
|
|
||||||
req: SmbAddRequest,
|
|
||||||
) -> std::result::Result<SmbShare, SmbShareError> {
|
|
||||||
let server = normalize_server(&req.server);
|
let server = normalize_server(&req.server);
|
||||||
let share = req.share.trim().trim_start_matches('/').to_string();
|
let share = req.share.trim().trim_start_matches('/').to_string();
|
||||||
if server.is_empty() {
|
if server.is_empty() {
|
||||||
@@ -309,7 +306,9 @@ impl SmbShareManager {
|
|||||||
if let Err(e) = self.rescan_inner(&id).await {
|
if let Err(e) = self.rescan_inner(&id).await {
|
||||||
let m = self.get(&id);
|
let m = self.get(&id);
|
||||||
return Err(SmbShareError {
|
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()),
|
.unwrap_or_else(|| e.to_string()),
|
||||||
stderr: String::new(),
|
stderr: String::new(),
|
||||||
hint: m.and_then(|m| m.last_hint),
|
hint: m.and_then(|m| m.last_hint),
|
||||||
@@ -365,11 +364,7 @@ impl SmbShareManager {
|
|||||||
/// throttling concurrent smbclients) would need to await without
|
/// throttling concurrent smbclients) would need to await without
|
||||||
/// changing the call sites.
|
/// changing the call sites.
|
||||||
#[allow(clippy::unused_async)]
|
#[allow(clippy::unused_async)]
|
||||||
pub async fn stream_iso(
|
pub async fn stream_iso(&self, share_id: &str, filename: &str) -> Result<SmbStream> {
|
||||||
&self,
|
|
||||||
share_id: &str,
|
|
||||||
filename: &str,
|
|
||||||
) -> Result<SmbStream> {
|
|
||||||
let share = self
|
let share = self
|
||||||
.get(share_id)
|
.get(share_id)
|
||||||
.ok_or_else(|| Error::Invalid(format!("no such SMB share '{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. smbclient itself accepts only filenames at the
|
||||||
// share root in our `get` form, but belt-and-suspenders.
|
// share root in our `get` form, but belt-and-suspenders.
|
||||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
return Err(Error::Invalid(format!(
|
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
||||||
"invalid filename '{filename}'"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
let creds = share
|
let creds = share
|
||||||
.creds_path
|
.creds_path
|
||||||
@@ -470,13 +463,7 @@ impl SmbShareManager {
|
|||||||
// and the operator gets *something* bootable. A follow-up
|
// and the operator gets *something* bootable. A follow-up
|
||||||
// release can do a bounded `smbclient get` of the first
|
// release can do a bounded `smbclient get` of the first
|
||||||
// 64 KiB for real detection.
|
// 64 KiB for real detection.
|
||||||
let report = IntrospectionReport {
|
let report = IntrospectionReport::default();
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: Vec::new(),
|
|
||||||
has_boot_wim: false,
|
|
||||||
};
|
|
||||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||||
let source = IsoSource::Smb {
|
let source = IsoSource::Smb {
|
||||||
share_id: share.id.clone(),
|
share_id: share.id.clone(),
|
||||||
@@ -543,10 +530,7 @@ impl SmbShareManager {
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
return Err((
|
return Err((format!("could not exec smbclient: {e}"), stderr));
|
||||||
format!("could not exec smbclient: {e}"),
|
|
||||||
stderr,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
@@ -757,10 +741,7 @@ fn parse_ls_iso(out: &str) -> Vec<SmbListEntry> {
|
|||||||
|
|
||||||
/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
|
/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
|
||||||
/// probe so the UI banner reads consistently.
|
/// probe so the UI banner reads consistently.
|
||||||
async fn tcp_probe(
|
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
|
||||||
server: &str,
|
|
||||||
port: u16,
|
|
||||||
) -> std::result::Result<(), (String, String)> {
|
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
let addr = format!("{server}:{port}");
|
let addr = format!("{server}:{port}");
|
||||||
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
|
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
|
||||||
@@ -937,8 +918,8 @@ mod tests {
|
|||||||
// exec error in `error` plus an empty `stderr`. The
|
// exec error in `error` plus an empty `stderr`. The
|
||||||
// SmbShareError constructor's hint_for fallback checks error
|
// SmbShareError constructor's hint_for fallback checks error
|
||||||
// too, so this pattern needs to translate as well.
|
// 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)")
|
let h2 =
|
||||||
.unwrap();
|
hint_for("could not exec smbclient: No such file or directory (os error 2)").unwrap();
|
||||||
assert!(h2.contains("smbclient"));
|
assert!(h2.contains("smbclient"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+132
-27
@@ -223,7 +223,8 @@ impl IsoStore {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(text) = tokio::fs::read_to_string(&p).await {
|
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);
|
self.insert(meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +232,46 @@ impl IsoStore {
|
|||||||
Ok(())
|
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) {
|
fn insert(&self, meta: IsoMeta) {
|
||||||
self.inner.write().isos.insert(meta.id.clone(), meta);
|
self.inner.write().isos.insert(meta.id.clone(), meta);
|
||||||
}
|
}
|
||||||
@@ -301,9 +342,18 @@ impl IsoStore {
|
|||||||
/// SMB sources or when the file is missing.
|
/// SMB sources or when the file is missing.
|
||||||
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
|
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
|
||||||
let meta = self.get(id)?;
|
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 {
|
match &meta.source {
|
||||||
IsoSource::Local => {
|
IsoSource::Local => {
|
||||||
let path = self.iso_path(id);
|
let path = self.iso_path(&meta.id);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
Some(path)
|
Some(path)
|
||||||
} else {
|
} else {
|
||||||
@@ -557,22 +607,22 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| filename.to_string());
|
.unwrap_or_else(|| filename.to_string());
|
||||||
match r.family {
|
match r.family {
|
||||||
DistroFamily::WindowsPe if r.has_boot_wim => {
|
DistroFamily::WindowsPe => {
|
||||||
// Standard wimboot chain. Paths are in-ISO; the HTTP layer maps
|
// v0.5.8: boot Windows directly via iPXE HTTP sanboot. iPXE
|
||||||
// `iso/<id>/<path>` to on-disk extraction via ISO9660 lookup.
|
// exposes the raw ISO as an emulated CD backed by on-demand
|
||||||
let base = format!("iso/{id}");
|
// 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 {
|
vec![BootEntry {
|
||||||
id: format!("{id}-winpe"),
|
id: format!("{id}-windows"),
|
||||||
title: format!("{title} (Windows / wimboot)"),
|
title: format!("{title} (Windows)"),
|
||||||
kind: BootKind::Wimboot {
|
kind: BootKind::SanBootIso {
|
||||||
wimboot_url: "ipxe/wimboot".to_string(),
|
iso_url: format!("iso/{id}.iso"),
|
||||||
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")),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
@@ -598,15 +648,33 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Last-resort SAN boot. Won't work for large modern ISOs, but
|
// No Windows-install media and no Linux kernel/initrd. Decide
|
||||||
// lets the ISO at least appear in the menu.
|
// 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 {
|
vec![BootEntry {
|
||||||
id: format!("{id}-sanboot"),
|
id: format!("{id}-sanboot"),
|
||||||
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
|
title,
|
||||||
kind: BootKind::SanBootIso {
|
kind: BootKind::SanBootIso {
|
||||||
iso_url: format!("iso/{id}.iso"),
|
iso_url: format!("iso/{id}.iso"),
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,6 +748,49 @@ mod tests {
|
|||||||
assert!(!s.contains(" --- "), "stray ---: {s}");
|
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 {
|
fn fake_meta(id: &str) -> IsoMeta {
|
||||||
IsoMeta {
|
IsoMeta {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
@@ -687,13 +798,7 @@ mod tests {
|
|||||||
size_bytes: 0,
|
size_bytes: 0,
|
||||||
sha256_hex: None,
|
sha256_hex: None,
|
||||||
uploaded_at: OffsetDateTime::now_utc(),
|
uploaded_at: OffsetDateTime::now_utc(),
|
||||||
introspection: IntrospectionReport {
|
introspection: IntrospectionReport::default(),
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: vec![],
|
|
||||||
has_boot_wim: false,
|
|
||||||
},
|
|
||||||
boot_entries: vec![],
|
boot_entries: vec![],
|
||||||
source: IsoSource::Local,
|
source: IsoSource::Local,
|
||||||
password_hash: None,
|
password_hash: None,
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
hosts: hosts.clone(),
|
hosts: hosts.clone(),
|
||||||
boot_log: boot_log.clone(),
|
boot_log: boot_log.clone(),
|
||||||
branding: branding.clone(),
|
branding: branding.clone(),
|
||||||
|
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||||
admin: admin.clone(),
|
admin: admin.clone(),
|
||||||
sessions: sessions.clone(),
|
sessions: sessions.clone(),
|
||||||
sso: sso.clone(),
|
sso: sso.clone(),
|
||||||
|
|||||||
+30
-16
@@ -7,12 +7,12 @@
|
|||||||
//! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT:
|
//! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT:
|
||||||
//! the ephemeral ports must be reachable from the client.
|
//! 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
|
//! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so
|
||||||
//! `../` path traversal attempts simply return ENOENT.
|
//! `../` path traversal attempts simply return ENOENT.
|
||||||
|
|
||||||
use openpxe_core::{ClientEvent, ClientRegistry};
|
use openpxe_core::{ClientEvent, ClientRegistry};
|
||||||
use openpxe_ipxe_assets::asset_bytes;
|
use openpxe_ipxe_assets::asset_slice;
|
||||||
use socket2::{Domain, Protocol, Socket, Type};
|
use socket2::{Domain, Protocol, Socket, Type};
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -21,6 +21,7 @@ use tokio::net::UdpSocket;
|
|||||||
|
|
||||||
// TFTP opcodes.
|
// TFTP opcodes.
|
||||||
const OP_RRQ: u16 = 1;
|
const OP_RRQ: u16 = 1;
|
||||||
|
const OP_WRQ: u16 = 2;
|
||||||
const OP_DATA: u16 = 3;
|
const OP_DATA: u16 = 3;
|
||||||
const OP_ACK: u16 = 4;
|
const OP_ACK: u16 = 4;
|
||||||
const OP_ERROR: u16 = 5;
|
const OP_ERROR: u16 = 5;
|
||||||
@@ -89,16 +90,34 @@ async fn handle_rrq(
|
|||||||
metrics: openpxe_core::Metrics,
|
metrics: openpxe_core::Metrics,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let Some(req) = parse_rrq(&packet) else {
|
let Some(req) = parse_rrq(&packet) else {
|
||||||
|
// Not a well-formed RRQ. A WRQ deserves an explicit refusal —
|
||||||
|
// 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(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let Request {
|
let Request {
|
||||||
filename, options, ..
|
filename,
|
||||||
|
mode,
|
||||||
|
options,
|
||||||
} = req;
|
} = req;
|
||||||
|
|
||||||
// Per-transfer ephemeral socket.
|
// Per-transfer ephemeral socket.
|
||||||
let sock = bind_udp(bind_ip, 0)?;
|
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(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(file_bytes) = asset_slice(&filename) else {
|
||||||
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
|
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
|
||||||
tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
|
tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
|
||||||
clients.record(
|
clients.record(
|
||||||
@@ -262,7 +281,6 @@ async fn handle_rrq(
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct Request {
|
struct Request {
|
||||||
filename: String,
|
filename: String,
|
||||||
#[allow(dead_code)]
|
|
||||||
mode: String,
|
mode: String,
|
||||||
options: Vec<(String, String)>,
|
options: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
@@ -393,17 +411,13 @@ fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
|
|||||||
Ok(UdpSocket::from_std(std_sock)?)
|
Ok(UdpSocket::from_std(std_sock)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
/// Pure-logic mirror of `handle_rrq`'s windowing math, exercised by the
|
||||||
const _UNUSED: (u16, u16) = (ERR_NOT_DEFINED, ERR_ILLEGAL_OP);
|
/// 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
|
||||||
/// Pure-logic helper used by the unit tests below and (in a refactor) by
|
/// edge cases (exact-blksize tail, short tail, single-block window,
|
||||||
/// `handle_rrq`. Given a position in the file and the window, return the
|
/// block-number wraparound).
|
||||||
/// (block_no, chunk_len) list this window will emit. Useful as a sanity
|
#[cfg(test)]
|
||||||
/// check that our windowing math matches the wire behavior the spec
|
fn plan_window(
|
||||||
/// requires — tested against edge cases (exact-blksize tail, short tail,
|
|
||||||
/// single-block window).
|
|
||||||
#[must_use]
|
|
||||||
pub fn plan_window(
|
|
||||||
total: usize,
|
total: usize,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
blksize: usize,
|
blksize: usize,
|
||||||
|
|||||||
+80
-26
@@ -160,20 +160,36 @@
|
|||||||
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
||||||
function bootability(iso, settings) {
|
function bootability(iso, settings) {
|
||||||
const fam = iso.introspection.family;
|
const fam = iso.introspection.family;
|
||||||
const isWin = fam === 'windows_pe';
|
// v0.5.8: Windows ISOs boot via iPXE HTTP sanboot of the raw image —
|
||||||
if (isWin && !settings.windows_enabled) {
|
// no Settings toggle, no SMB, no size limit. Always bootable.
|
||||||
return { ok: false, reason: 'Windows boot disabled in Settings' };
|
if (fam === 'windows_pe') {
|
||||||
}
|
|
||||||
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' };
|
|
||||||
}
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
// Linux with a detected kernel/initrd — direct kernel+initrd boot.
|
||||||
|
if (iso.introspection.kernel_path) {
|
||||||
|
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)' };
|
||||||
|
}
|
||||||
|
// Remote-share ISOs aren't introspected (no random access over the
|
||||||
|
// network), so el_torito is unknown — assume bootable and let sanboot
|
||||||
|
// try rather than cry wolf.
|
||||||
|
const remote = iso.source && iso.source.kind && iso.source.kind !== 'local';
|
||||||
|
if (remote) {
|
||||||
|
return { ok: true, warn: 'remote ISO — not introspected; 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.
|
// v0.5.2: pretty label for an unattended file's detected kind.
|
||||||
function unattendedKindLabel(k) {
|
function unattendedKindLabel(k) {
|
||||||
@@ -288,14 +304,23 @@
|
|||||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||||
el('div', {class: 'label'}, 'Images available'),
|
el('div', {class: 'label'}, 'Images available'),
|
||||||
el('div', {class: 'value'}, String(isos.length)),
|
el('div', {class: 'value'}, String(isos.length)),
|
||||||
el('div', {class: 'trend'},
|
el('div', {class: 'trend'}, (() => {
|
||||||
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
|
// v0.5.9: count families honestly. Anything that isn't a known
|
||||||
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
|
// 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
|
// v0.4.67+v0.5.5: count all remote-share protocols. Label
|
||||||
// generically since operators may use any mix of SMB/NFS/SFTP.
|
// 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)) +
|
const remote = (status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0);
|
||||||
' remote share' +
|
const parts = [win + ' Windows', lin + ' Linux'];
|
||||||
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) === 1 ? '' : 's')),
|
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: 'card'}, el('div', {class: 'stat'}, [
|
||||||
el('div', {class: 'label'}, 'Uptime'),
|
el('div', {class: 'label'}, 'Uptime'),
|
||||||
@@ -340,7 +365,7 @@
|
|||||||
const settings = status.settings;
|
const settings = status.settings;
|
||||||
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
|
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
|
||||||
const problemsBlock = problems.length ? el('div', {class:'card'}, [
|
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'},
|
el('div', {class:'body'},
|
||||||
problems.map(({i, b}) => el('div', {class:'row-warn'},
|
problems.map(({i, b}) => el('div', {class:'row-warn'},
|
||||||
'⚠ ' + i.filename + ' — ' + b.reason)))
|
'⚠ ' + i.filename + ' — ' + b.reason)))
|
||||||
@@ -533,6 +558,9 @@
|
|||||||
style:'display:none', id:'file'});
|
style:'display:none', id:'file'});
|
||||||
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
|
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
|
||||||
const upMsg = el('div', {class:'msg', id:'upmsg'});
|
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.onclick = () => file.click();
|
||||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
|
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
|
||||||
@@ -575,6 +603,16 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let uploadId = null;
|
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) + ')');
|
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
||||||
prog.classList.add('active');
|
prog.classList.add('active');
|
||||||
bar.style.width = '1%';
|
bar.style.width = '1%';
|
||||||
@@ -601,6 +639,7 @@
|
|||||||
'x-openpxe-upload-complete': complete ? 'true' : 'false',
|
'x-openpxe-upload-complete': complete ? 'true' : 'false',
|
||||||
},
|
},
|
||||||
body: f.slice(offset, end),
|
body: f.slice(offset, end),
|
||||||
|
signal: ac.signal,
|
||||||
});
|
});
|
||||||
if (!r.ok) throw new Error(await failText(r));
|
if (!r.ok) throw new Error(await failText(r));
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
@@ -616,8 +655,15 @@
|
|||||||
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
|
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
|
||||||
catch {}
|
catch {}
|
||||||
}
|
}
|
||||||
|
if (canceled || (err && err.name === 'AbortError')) {
|
||||||
|
setStatus('Upload canceled — partial file discarded.', '');
|
||||||
|
} else {
|
||||||
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
window.removeEventListener('beforeunload', warnLeave);
|
||||||
|
cancelUpload.style.display = 'none';
|
||||||
|
cancelUpload.onclick = null;
|
||||||
prog.classList.remove('active');
|
prog.classList.remove('active');
|
||||||
if (!upMsg.className.includes('ok')) bar.style.width = '0';
|
if (!upMsg.className.includes('ok')) bar.style.width = '0';
|
||||||
}
|
}
|
||||||
@@ -629,7 +675,11 @@
|
|||||||
// *next* row of the table. Keeps the markup flat and avoids the
|
// *next* row of the table. Keeps the markup flat and avoids the
|
||||||
// overhead of a real modal.
|
// overhead of a real modal.
|
||||||
const rowsAndEditors = [];
|
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);
|
const b = bootability(i, settings);
|
||||||
// v0.4.65: SMB userspace consumer (smbclient).
|
// v0.4.65: SMB userspace consumer (smbclient).
|
||||||
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
|
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
|
||||||
@@ -1164,7 +1214,7 @@
|
|||||||
diskCard,
|
diskCard,
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
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
|
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
||||||
// protocol dropdown. Backend endpoints are unchanged; this is a
|
// protocol dropdown. Backend endpoints are unchanged; this is a
|
||||||
@@ -2252,7 +2302,9 @@
|
|||||||
// own self-contained <form>; when SSO is enabled, a distinct
|
// own self-contained <form>; when SSO is enabled, a distinct
|
||||||
// "Sign in with …" button sits below a divider — the credential
|
// "Sign in with …" button sits below a divider — the credential
|
||||||
// fields no longer double as the SSO trigger.
|
// 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
|
const ssoBlock = ssoLive
|
||||||
? el('div', {class:'sso-block'}, [
|
? el('div', {class:'sso-block'}, [
|
||||||
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
|
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
|
||||||
@@ -2497,10 +2549,12 @@
|
|||||||
])));
|
])));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Preload the SSO config so the login card can offer the operator
|
// v0.5.9: the login card's "Sign in with …" button keys off the SSO
|
||||||
// an "Sign in with X" button when configured. Failure is harmless.
|
// descriptor that /api/me now carries (public, non-sensitive: enabled
|
||||||
try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); }
|
// + idp_name + idp_logo_url). It's available signed in or out, so the
|
||||||
catch { ssoConfig = null; }
|
// 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) {
|
if (me.setup_required) {
|
||||||
showAuthScreen('setup');
|
showAuthScreen('setup');
|
||||||
|
|||||||
+23
-7
@@ -41,15 +41,31 @@ DEST="${1:-$ROOT/assets/ipxe}"
|
|||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
trap 'rm -rf "$WORK"' EXIT
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin
|
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin keeps
|
||||||
# keeps builds reproducible and protects against a transient master
|
# builds reproducible, protects against a transient master breakage, and —
|
||||||
# breakage. Bump deliberately.
|
# 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_REPO="https://github.com/ipxe/ipxe.git"
|
||||||
IPXE_REF="${IPXE_REF:-master}"
|
IPXE_REF="${IPXE_REF:-95ffbf4745553e8a207922389929e1943c0237c0}"
|
||||||
|
|
||||||
echo ">> cloning iPXE ($IPXE_REF)"
|
echo ">> fetching iPXE ($IPXE_REF)"
|
||||||
git clone --depth 1 --branch "$IPXE_REF" "$IPXE_REPO" "$WORK/ipxe" 2>/dev/null \
|
# Shallow-fetch the exact ref: works for a full commit SHA (GitHub allows
|
||||||
|| git clone "$IPXE_REPO" "$WORK/ipxe"
|
# 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"
|
SRC="$WORK/ipxe/src"
|
||||||
|
|
||||||
echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)"
|
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
|
# Upstream uses arch-scoped subdirectories; we flatten to the names our
|
||||||
# ClientArch::ipxe_bootfile() expects.
|
# ClientArch::ipxe_bootfile() expects.
|
||||||
declare -a MAP=(
|
declare -a MAP=(
|
||||||
|
# DriverMode::Firmware (default) — reuse the firmware UNDI/SNP NIC stack.
|
||||||
"undionly.kpxe=undionly.kpxe"
|
"undionly.kpxe=undionly.kpxe"
|
||||||
"snponly.efi=x86_64-efi/snponly.efi"
|
"snponly.efi=x86_64-efi/snponly.efi"
|
||||||
"snponly-i386.efi=i386-efi/snponly.efi"
|
"snponly-i386.efi=i386-efi/snponly.efi"
|
||||||
"snponly-arm64.efi=arm64-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"
|
BASE="https://boot.ipxe.org"
|
||||||
|
|||||||
Reference in New Issue
Block a user