Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f193cac05 | ||
|
|
24879fcc90 | ||
|
|
5df0fd5972 | ||
|
|
06695c3d77 | ||
|
|
cb51b8db75 | ||
|
|
d6a9df85d7 |
@@ -11,3 +11,6 @@ data/work/
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# local editor / agent settings (not part of the project)
|
||||
.claude/
|
||||
|
||||
Generated
+9
-8
@@ -2669,7 +2669,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openpxe"
|
||||
version = "0.5.8"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2691,7 +2691,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-core"
|
||||
version = "0.5.8"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -2718,12 +2718,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-dhcp-proxy"
|
||||
version = "0.5.8"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"dhcproto",
|
||||
"openpxe-core",
|
||||
"parking_lot",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -2732,7 +2733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-http-api"
|
||||
version = "0.5.8"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2768,7 +2769,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-ipxe-assets"
|
||||
version = "0.5.8"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"openpxe-core",
|
||||
"rust-embed",
|
||||
@@ -2778,7 +2779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-iso-store"
|
||||
version = "0.5.8"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
@@ -2807,7 +2808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-tftp"
|
||||
version = "0.5.8"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -2821,7 +2822,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-webui"
|
||||
version = "0.5.8"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.8"
|
||||
version = "0.6.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="release" src="https://img.shields.io/badge/release-v0.5.5-2874d7" />
|
||||
<img alt="release" src="https://img.shields.io/badge/release-v0.6.0-2874d7" />
|
||||
<img alt="license" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-59824f" />
|
||||
<img alt="rust" src="https://img.shields.io/badge/built%20with-Rust-fb8841?logo=rust&logoColor=white" />
|
||||
<img alt="container" src="https://img.shields.io/badge/container--native-OCI%20%C2%B7%20OpenShift-2496ED?logo=docker&logoColor=white" />
|
||||
@@ -32,7 +32,7 @@ Upload `.iso` files (or point at a remote share), and any machine on the network
|
||||
them — Linux installers, live tools, or stock Windows setup — with **zero iPXE knowledge
|
||||
required by the operator.**
|
||||
|
||||
> **Status — v0.5.5, late pre-beta.** The full PXE stack, web UI, remote ISO libraries
|
||||
> **Status — v0.6.0, late pre-beta.** The full PXE stack, web UI, remote ISO libraries
|
||||
> (SMB/NFS/SFTP), Windows deployment, queued fleet rollout, SAML SSO, and Prometheus
|
||||
> metrics are implemented and test-covered. The release checklist gates every tag on the
|
||||
> full test suite + `clippy`. Currently in real-hardware validation.
|
||||
|
||||
+110
-10
@@ -23,6 +23,25 @@ pub enum ClientArch {
|
||||
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 {
|
||||
#[must_use]
|
||||
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.
|
||||
/// Uses `snponly` variants which reuse the firmware's UNDI/SNP network
|
||||
/// stack — smaller binaries and broader hardware compatibility than the
|
||||
/// all-drivers-included `ipxe.efi`.
|
||||
/// all-drivers-included `ipxe.efi`. Equivalent to
|
||||
/// [`Self::ipxe_bootfile_mode`] with [`DriverMode::Firmware`]; kept as a
|
||||
/// convenience for the common firmware-net path.
|
||||
#[must_use]
|
||||
pub fn ipxe_bootfile(self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
Self::LegacyX86 => "undionly.kpxe",
|
||||
Self::Ia32Uefi => "snponly-i386.efi",
|
||||
Self::X64Uefi => "snponly.efi",
|
||||
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
|
||||
// snponly variant for this arch. We return None so the DHCP
|
||||
// proxy declines rather than advertising a file we can't serve.
|
||||
Self::Arm32Uefi | Self::Unknown(_) => return None,
|
||||
Self::Arm64Uefi => "snponly-arm64.efi",
|
||||
self.ipxe_bootfile_mode(DriverMode::Firmware)
|
||||
}
|
||||
|
||||
/// iPXE binary filename for this architecture under a given network
|
||||
/// [`DriverMode`].
|
||||
///
|
||||
/// * [`DriverMode::Firmware`] — the `snponly`/`undionly` builds that reuse
|
||||
/// the firmware's UNDI/SNP NIC stack. Smallest, and the most reliable
|
||||
/// choice for chainloading because the firmware just proved its network
|
||||
/// works by downloading the NBP. This is the default first attempt.
|
||||
/// * [`DriverMode::Builtin`] — the all-drivers `ipxe.efi`/`ipxe.pxe`
|
||||
/// builds that carry iPXE's *own* NIC drivers. The automatic fallback
|
||||
/// for clients whose firmware NIC stack is missing or buggy (v0.6.1):
|
||||
/// the DHCP proxy escalates a MAC to this mode when a firmware-net boot
|
||||
/// never completes the iPXE handoff. iPXE still includes the `snp`
|
||||
/// driver here too, so it degrades gracefully.
|
||||
#[must_use]
|
||||
pub fn ipxe_bootfile_mode(self, mode: DriverMode) -> Option<&'static str> {
|
||||
Some(match (self, mode) {
|
||||
// Legacy x86 BIOS: UNDI (firmware) vs full native-driver build.
|
||||
(Self::LegacyX86, DriverMode::Firmware) => "undionly.kpxe",
|
||||
(Self::LegacyX86, DriverMode::Builtin) => "ipxe.pxe",
|
||||
// IA32 UEFI.
|
||||
(Self::Ia32Uefi, DriverMode::Firmware) => "snponly-i386.efi",
|
||||
(Self::Ia32Uefi, DriverMode::Builtin) => "ipxe-i386.efi",
|
||||
// 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootfile_default_is_firmware_mode() {
|
||||
// The convenience method must equal the explicit Firmware mode.
|
||||
for a in [
|
||||
ClientArch::LegacyX86,
|
||||
ClientArch::Ia32Uefi,
|
||||
ClientArch::X64Uefi,
|
||||
ClientArch::Arm64Uefi,
|
||||
ClientArch::Arm32Uefi,
|
||||
ClientArch::Unknown(0x99),
|
||||
] {
|
||||
assert_eq!(
|
||||
a.ipxe_bootfile(),
|
||||
a.ipxe_bootfile_mode(DriverMode::Firmware)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_mode_maps_to_all_drivers_binaries() {
|
||||
assert_eq!(
|
||||
ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
Some("ipxe.pxe")
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
Some("ipxe.efi")
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
Some("ipxe-i386.efi")
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
Some("ipxe-arm64.efi")
|
||||
);
|
||||
// No binary for ARM32 / unknown in either mode.
|
||||
assert_eq!(
|
||||
ClientArch::Arm32Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::Unknown(0x99).ipxe_bootfile_mode(DriverMode::Builtin),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driver_mode_default_is_firmware() {
|
||||
assert_eq!(DriverMode::default(), DriverMode::Firmware);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_class_detects_ipxe_over_pxeclient() {
|
||||
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
|
||||
|
||||
@@ -21,7 +21,7 @@ pub mod settings;
|
||||
pub mod sso;
|
||||
pub mod wol;
|
||||
|
||||
pub use arch::{ClientArch, FirmwareClass};
|
||||
pub use arch::{ClientArch, DriverMode, FirmwareClass};
|
||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||
pub use boot_log::{BootEvent, BootLog};
|
||||
pub use 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 saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
|
||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||
pub use sso::{SsoConfig, SsoStore};
|
||||
pub use sso::{SsoConfig, SsoLoginInfo, SsoStore};
|
||||
|
||||
@@ -73,6 +73,23 @@ impl SsoConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimal, non-sensitive slice of the SSO config that the **pre-auth**
|
||||
/// login screen needs to render the "Sign in with …" button. Carries only
|
||||
/// the display affordances — never the metadata XML/URL or entity ID, which
|
||||
/// stay behind the auth-gated `/api/sso`. Served as part of the public
|
||||
/// `/api/me` so the button renders reliably whether or not anyone is signed
|
||||
/// in (v0.5.9: fixes the button vanishing because `/api/sso` 401s pre-auth).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SsoLoginInfo {
|
||||
/// True only when SSO is *usable* (enabled AND a metadata source is
|
||||
/// present) — i.e. clicking the button will actually reach an IdP.
|
||||
pub enabled: bool,
|
||||
/// Button label, e.g. "STC AD". Empty falls back to "SSO" in the UI.
|
||||
pub idp_name: String,
|
||||
/// Optional IdP logo rendered on the button. Empty = no image.
|
||||
pub idp_logo_url: String,
|
||||
}
|
||||
|
||||
/// In-memory + on-disk SSO settings registry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SsoStore {
|
||||
@@ -111,6 +128,19 @@ impl SsoStore {
|
||||
self.inner.read().clone()
|
||||
}
|
||||
|
||||
/// Public, non-sensitive descriptor for the login screen. Safe to
|
||||
/// expose pre-auth — it's exactly what the "Sign in with …" button
|
||||
/// keys off, with no metadata/entity-ID leakage. v0.5.9.
|
||||
#[must_use]
|
||||
pub fn login_info(&self) -> SsoLoginInfo {
|
||||
let cfg = self.inner.read();
|
||||
SsoLoginInfo {
|
||||
enabled: cfg.is_usable(),
|
||||
idp_name: cfg.idp_name.clone(),
|
||||
idp_logo_url: cfg.idp_logo_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the whole config in one shot. Light validation: metadata
|
||||
/// XML and URL are length-capped so an operator can't OOM us by
|
||||
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
|
||||
|
||||
@@ -18,3 +18,4 @@ tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
parking_lot.workspace = true
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! 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;
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Tracks per-MAC driver-mode escalation. Cheap to share via `Arc`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DriverEscalation {
|
||||
inner: Mutex<HashMap<String, Entry>>,
|
||||
}
|
||||
|
||||
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();
|
||||
g.retain(|_, e| now.duration_since(e.last_seen) < ENTRY_TTL);
|
||||
|
||||
match g.get_mut(mac) {
|
||||
None => {
|
||||
g.insert(
|
||||
mac.to_owned(),
|
||||
Entry {
|
||||
mode: DriverMode::Firmware,
|
||||
// Only the primary DISCOVER opens a confirmation window.
|
||||
awaiting_confirm: primary,
|
||||
last_seen: now,
|
||||
},
|
||||
);
|
||||
if g.len() > MAX_ENTRIES {
|
||||
evict_oldest(&mut g);
|
||||
}
|
||||
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.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.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod escalation;
|
||||
pub mod reply;
|
||||
pub mod server;
|
||||
|
||||
pub use escalation::DriverEscalation;
|
||||
pub use server::DhcpProxyServer;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! pass, or the HTTP URL of the boot script once iPXE has chained.
|
||||
|
||||
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
|
||||
use openpxe_core::{ClientArch, FirmwareClass};
|
||||
use openpxe_core::{ClientArch, DriverMode, FirmwareClass};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Where the reply directs the client next.
|
||||
@@ -31,6 +31,11 @@ pub struct ReplyContext<'a> {
|
||||
pub our_ip: Ipv4Addr,
|
||||
pub arch: ClientArch,
|
||||
pub class: FirmwareClass,
|
||||
/// Which iPXE network backend to advertise for this client. The DHCP
|
||||
/// proxy fills this from the automatic per-MAC escalation state: normally
|
||||
/// [`DriverMode::Firmware`], escalated to [`DriverMode::Builtin`] for a
|
||||
/// MAC whose firmware-net boot failed to chainload (v0.6.1).
|
||||
pub driver_mode: DriverMode,
|
||||
/// Public base URL (scheme://host[:port]) used in HTTP directives.
|
||||
pub public_base_url: &'a str,
|
||||
}
|
||||
@@ -52,9 +57,13 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
|
||||
},
|
||||
FirmwareClass::HttpClient => {
|
||||
// UEFI HTTP boot: client wants an http:// URL in option 67
|
||||
// pointing at an EFI executable. We serve ipxe.efi over HTTP;
|
||||
// it'll then do the same script-fetch the iPXE path does.
|
||||
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
|
||||
// pointing at an EFI executable. We serve the iPXE EFI build for
|
||||
// the negotiated driver mode over HTTP; it'll then do the same
|
||||
// script-fetch the iPXE path does.
|
||||
let name = ctx
|
||||
.arch
|
||||
.ipxe_bootfile_mode(ctx.driver_mode)
|
||||
.unwrap_or("snponly.efi");
|
||||
BootDirective::HttpScript {
|
||||
url: format!(
|
||||
"{}/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 {
|
||||
filename: name.to_string(),
|
||||
},
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! UDP listener loop for the DHCP proxy. Accepts on :67 (and :4011 on a
|
||||
//! second socket) and dispatches each datagram through the pure reply logic.
|
||||
|
||||
use crate::escalation::DriverEscalation;
|
||||
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
|
||||
use dhcproto::v4::{DhcpOption, Message, OptionCode};
|
||||
use dhcproto::{Decodable, Decoder, Encodable, Encoder};
|
||||
use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, FirmwareClass};
|
||||
use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::sync::Arc;
|
||||
@@ -18,6 +19,9 @@ pub struct DhcpProxyServer {
|
||||
public_base_url: String,
|
||||
clients: Arc<ClientRegistry>,
|
||||
metrics: openpxe_core::Metrics,
|
||||
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1). Shared across
|
||||
/// the :67 and :4011 listener tasks via the server `Arc`.
|
||||
escalation: DriverEscalation,
|
||||
}
|
||||
|
||||
impl DhcpProxyServer {
|
||||
@@ -38,6 +42,7 @@ impl DhcpProxyServer {
|
||||
public_base_url,
|
||||
clients,
|
||||
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 {
|
||||
request: &request,
|
||||
our_ip: self.our_ip,
|
||||
arch,
|
||||
class,
|
||||
driver_mode,
|
||||
public_base_url: &self.public_base_url,
|
||||
};
|
||||
let directive = decide(&ctx);
|
||||
@@ -154,7 +178,7 @@ impl DhcpProxyServer {
|
||||
sock.send_to(&out, dest).await?;
|
||||
tracing::info!(
|
||||
target: "openpxe::dhcp",
|
||||
mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive,
|
||||
mac=%mac, arch=arch.as_str(), class=?class, driver=?driver_mode, dest=%dest, directive=?directive,
|
||||
"PXE reply sent"
|
||||
);
|
||||
Ok(())
|
||||
|
||||
@@ -319,6 +319,12 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
||||
// and the logo asset is public, so this leaks nothing sensitive.
|
||||
let has_custom_logo = state.branding.has_any_web_logo();
|
||||
let logo_rev = state.branding.logo_rev();
|
||||
// v0.5.9: ship the non-sensitive SSO descriptor with every /api/me so
|
||||
// the pre-auth login screen can render the "Sign in with …" button
|
||||
// reliably. Previously the button keyed off the auth-gated /api/sso,
|
||||
// which 401s when logged out — the button only survived on a stale
|
||||
// in-memory config and vanished on any fresh login-page load.
|
||||
let sso = state.sso.login_info();
|
||||
if !state.admin.is_configured() {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
@@ -327,6 +333,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
||||
"authenticated": false,
|
||||
"has_custom_logo": has_custom_logo,
|
||||
"logo_rev": logo_rev,
|
||||
"sso": sso,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
@@ -343,6 +350,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
||||
"session_user": u,
|
||||
"has_custom_logo": has_custom_logo,
|
||||
"logo_rev": logo_rev,
|
||||
"sso": sso,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
@@ -353,6 +361,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
||||
"authenticated": false,
|
||||
"has_custom_logo": has_custom_logo,
|
||||
"logo_rev": logo_rev,
|
||||
"sso": sso,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
|
||||
@@ -6,23 +6,32 @@
|
||||
//! missing, that architecture simply won't have PXE support — we log at
|
||||
//! startup and serve what we have.
|
||||
//!
|
||||
//! Filename convention (matches `ClientArch::ipxe_bootfile`):
|
||||
//! Filename convention (matches `ClientArch::ipxe_bootfile_mode`):
|
||||
//!
|
||||
//! DriverMode::Firmware (default — reuse the firmware UNDI/SNP NIC stack):
|
||||
//! - `undionly.kpxe` — Legacy x86 BIOS
|
||||
//! - `snponly-i386.efi` — IA32 UEFI
|
||||
//! - `snponly.efi` — x86_64 UEFI
|
||||
//! - `snponly-arm32.efi` — ARM32 UEFI
|
||||
//! - `snponly-arm64.efi` — ARM64 UEFI
|
||||
//! - `ipxe.efi` (fallback) — UEFI with bundled drivers, if snponly fails on a NIC
|
||||
//!
|
||||
//! DriverMode::Builtin (v0.6.1 automatic fallback — iPXE's own NIC drivers,
|
||||
//! advertised when a firmware-net boot fails to chainload):
|
||||
//! - `ipxe.pxe` — Legacy x86 BIOS
|
||||
//! - `ipxe-i386.efi` — IA32 UEFI
|
||||
//! - `ipxe.efi` — x86_64 UEFI (built from source with PNG)
|
||||
//! - `ipxe-arm64.efi` — ARM64 UEFI
|
||||
//!
|
||||
//! - `wimboot` — Windows boot shim (fetched separately for WIM chains)
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use openpxe_core::ClientArch;
|
||||
use openpxe_core::{ClientArch, DriverMode};
|
||||
use rust_embed::Embed;
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "../../assets/ipxe/"]
|
||||
#[include = "*.kpxe"]
|
||||
#[include = "*.efi"]
|
||||
#[include = "*.pxe"]
|
||||
#[include = "wimboot"]
|
||||
pub struct IpxeAssets;
|
||||
|
||||
@@ -56,25 +65,42 @@ pub fn list_assets() -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Log at startup which iPXE binaries are present and which are missing.
|
||||
/// Log at startup which iPXE binaries are present and which are missing, for
|
||||
/// both driver modes. The Firmware-mode binaries are required for PXE on each
|
||||
/// arch; the Builtin-mode binaries are the optional automatic NIC-driver
|
||||
/// fallback (v0.6.1) — without one, escalation simply can't help that arch.
|
||||
pub fn log_availability() {
|
||||
let have: std::collections::HashSet<String> = list_assets().into_iter().collect();
|
||||
let needed = [
|
||||
(ClientArch::LegacyX86, "undionly.kpxe"),
|
||||
(ClientArch::Ia32Uefi, "snponly-i386.efi"),
|
||||
(ClientArch::X64Uefi, "snponly.efi"),
|
||||
// ARM32 UEFI deferred — no upstream snponly binary published.
|
||||
(ClientArch::Arm64Uefi, "snponly-arm64.efi"),
|
||||
let arches = [
|
||||
ClientArch::LegacyX86,
|
||||
ClientArch::Ia32Uefi,
|
||||
ClientArch::X64Uefi,
|
||||
// ARM32 UEFI deferred — no upstream binary published in either mode.
|
||||
ClientArch::Arm64Uefi,
|
||||
];
|
||||
for (arch, name) in needed {
|
||||
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) {
|
||||
tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target: "openpxe::ipxe",
|
||||
"bundled iPXE for {} [{mode:?}]: {name}", arch.as_str()
|
||||
);
|
||||
} else if mode == DriverMode::Firmware {
|
||||
tracing::warn!(
|
||||
target: "openpxe::ipxe",
|
||||
"MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot",
|
||||
arch.as_str(), name
|
||||
"MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
|
||||
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,
|
||||
files: Vec<(String, String)>,
|
||||
},
|
||||
/// Last-resort: SAN-boot the ISO as an emulated CD. Only works for small
|
||||
/// ISOs (<~1 GiB) and older distros. Kept for completeness, not the
|
||||
/// default.
|
||||
/// SAN-boot the raw ISO as an emulated CD (iPXE `sanboot`). The emulated
|
||||
/// CD is backed by on-demand HTTP range reads, so ISO size is *not* a
|
||||
/// constraint — this is the primary path for Windows (v0.5.8) and for any
|
||||
/// El Torito-bootable image we don't special-case: ESXi/VMvisor
|
||||
/// installers, BSDs, firmware/diagnostic tools, custom spins (v0.6.0).
|
||||
SanBootIso { iso_url: String },
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DistroFamily {
|
||||
DebianUbuntu,
|
||||
@@ -22,10 +22,22 @@ pub enum DistroFamily {
|
||||
Arch,
|
||||
Alpine,
|
||||
WindowsPe,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// Bumped whenever the introspection logic changes in a way that should
|
||||
/// re-classify already-uploaded ISOs. On startup the store re-runs
|
||||
/// `introspect` on any *local* ISO whose persisted report predates this
|
||||
/// revision (see `IsoStore::load_from_disk`), so an upgrade fixes stale
|
||||
/// metadata — 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 family: DistroFamily,
|
||||
pub volume_label: Option<String>,
|
||||
@@ -35,17 +47,28 @@ pub struct IntrospectionReport {
|
||||
pub initrd_paths: Vec<String>,
|
||||
/// True if `sources/boot.wim` present — Windows install media.
|
||||
pub has_boot_wim: bool,
|
||||
/// True if the ISO carries an El Torito boot catalog — i.e. it is
|
||||
/// bootable by BIOS/UEFI firmware and therefore by iPXE `sanboot`
|
||||
/// (emulated CD). This is the authoritative "can this boot at all?"
|
||||
/// signal for ISOs we can't classify as Linux or Windows (BSDs, ESXi,
|
||||
/// firmware tools, custom spins). A *data* ISO (e.g. a VMware vCenter
|
||||
/// appliance bundle) has no boot catalog and reports `false`. v0.5.9.
|
||||
#[serde(default)]
|
||||
pub el_torito: bool,
|
||||
/// Revision of the introspection logic that produced this report. 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
|
||||
/// and return an `Unknown` family so the uploader still sees a record.
|
||||
pub fn introspect(path: &Path) -> IntrospectionReport {
|
||||
let mut report = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: Vec::new(),
|
||||
has_boot_wim: false,
|
||||
introspect_rev: INTROSPECT_REV,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
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.
|
||||
// This is enough to identify `sources/boot.wim` (Windows) and common
|
||||
// kernel/initrd paths for the major Linux distros.
|
||||
@@ -222,6 +250,44 @@ fn filename_looks_windows(path: &Path) -> bool {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -260,6 +326,40 @@ mod tests {
|
||||
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;
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
//! UI to ask for. (If a future server needs Kerberos or non-default
|
||||
//! uid mapping we can add those, but for ISO read access nobody does.)
|
||||
|
||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
||||
use crate::introspect::IntrospectionReport;
|
||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||
use bytes::Bytes;
|
||||
use nfs3_client::tokio::TokioConnector;
|
||||
@@ -399,13 +399,7 @@ impl NfsShareManager {
|
||||
// Same approach as SMB: no real introspection over the
|
||||
// network in v0.4.67. The boot-entry generator falls back
|
||||
// to filename-based sanboot detection.
|
||||
let report = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: Vec::new(),
|
||||
has_boot_wim: false,
|
||||
};
|
||||
let report = IntrospectionReport::default();
|
||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||
let source = IsoSource::Nfs {
|
||||
share_id: share.id.clone(),
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
//! hint}` error shape is shared so the storage tab renders all three
|
||||
//! protocols through one code path.
|
||||
|
||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
||||
use crate::introspect::IntrospectionReport;
|
||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||
use bytes::Bytes;
|
||||
use openpxe_core::{Error, Result};
|
||||
@@ -480,13 +480,7 @@ impl SftpShareManager {
|
||||
// register `Unknown` and let the boot-entry generator fall
|
||||
// back to filename-based detection. SFTP *could* do bounded
|
||||
// PVD reads (it has random access) — a follow-up can add it.
|
||||
let report = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: Vec::new(),
|
||||
has_boot_wim: false,
|
||||
};
|
||||
let report = IntrospectionReport::default();
|
||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||
let source = IsoSource::Sftp {
|
||||
share_id: share.id.clone(),
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
//! streaming. A follow-up release can add libsmbclient-based seek if
|
||||
//! a real workload needs it.
|
||||
|
||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
||||
use crate::introspect::IntrospectionReport;
|
||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||
use openpxe_core::{Error, Result};
|
||||
use parking_lot::Mutex;
|
||||
@@ -470,13 +470,7 @@ impl SmbShareManager {
|
||||
// and the operator gets *something* bootable. A follow-up
|
||||
// release can do a bounded `smbclient get` of the first
|
||||
// 64 KiB for real detection.
|
||||
let report = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: Vec::new(),
|
||||
has_boot_wim: false,
|
||||
};
|
||||
let report = IntrospectionReport::default();
|
||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||
let source = IsoSource::Smb {
|
||||
share_id: share.id.clone(),
|
||||
|
||||
+107
-11
@@ -223,7 +223,8 @@ impl IsoStore {
|
||||
continue;
|
||||
}
|
||||
if let Ok(text) = tokio::fs::read_to_string(&p).await {
|
||||
if let Ok(meta) = serde_json::from_str::<IsoMeta>(&text) {
|
||||
if let Ok(mut meta) = serde_json::from_str::<IsoMeta>(&text) {
|
||||
self.reintrospect_if_stale(&mut meta).await;
|
||||
self.insert(meta);
|
||||
}
|
||||
}
|
||||
@@ -231,6 +232,46 @@ impl IsoStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v0.5.9: re-run introspection on a *local* ISO whose persisted report
|
||||
/// predates the current logic. ISOs uploaded by an older binary carry a
|
||||
/// stale family/boot profile — most visibly a Windows 11 ISO tagged
|
||||
/// `Unknown` before the UDF/El-Torito detection landed, which then shows
|
||||
/// as "won't boot" forever. Re-probing on startup fixes them in place,
|
||||
/// no delete-and-re-upload. Bounded: only `Local` sources (we have the
|
||||
/// bytes locally) below [`introspect::INTROSPECT_REV`], so it runs at
|
||||
/// most once per ISO per upgrade. The probe reads up to ~64 MiB, so we
|
||||
/// push it onto the blocking pool to keep the async runtime responsive.
|
||||
async fn reintrospect_if_stale(&self, meta: &mut IsoMeta) {
|
||||
if !matches!(meta.source, IsoSource::Local)
|
||||
|| meta.introspection.introspect_rev >= crate::introspect::INTROSPECT_REV
|
||||
{
|
||||
return;
|
||||
}
|
||||
let path = self.iso_path(&meta.id);
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let Ok(fresh) = tokio::task::spawn_blocking(move || introspect(&path)).await else {
|
||||
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect task failed");
|
||||
return;
|
||||
};
|
||||
let before = meta.introspection.family;
|
||||
meta.introspection = fresh;
|
||||
meta.boot_entries = generate_boot_entries(&meta.id, &meta.filename, &meta.introspection);
|
||||
if let Err(e) = self.persist_meta(meta).await {
|
||||
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect persist: {e}");
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
target: "openpxe::iso",
|
||||
id = %meta.id,
|
||||
from = ?before,
|
||||
to = ?meta.introspection.family,
|
||||
el_torito = meta.introspection.el_torito,
|
||||
"re-introspected stale ISO metadata"
|
||||
);
|
||||
}
|
||||
|
||||
fn insert(&self, meta: IsoMeta) {
|
||||
self.inner.write().isos.insert(meta.id.clone(), meta);
|
||||
}
|
||||
@@ -598,15 +639,33 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
|
||||
}]
|
||||
}
|
||||
_ => {
|
||||
// Last-resort SAN boot. Won't work for large modern ISOs, but
|
||||
// lets the ISO at least appear in the menu.
|
||||
// No Windows-install media and no Linux kernel/initrd. Decide
|
||||
// whether the ISO is bootable at all (v0.6.0):
|
||||
// * `el_torito` — it carries a boot catalog, so iPXE sanboots
|
||||
// the raw image as an emulated CD: BSDs, ESXi/VMvisor
|
||||
// installers, firmware tools, custom spins. The emulated CD
|
||||
// is backed by HTTP range reads, so ISO size is a non-issue
|
||||
// (this is the same path Windows uses since v0.5.8) — hence
|
||||
// no more "may fail for >1GiB ISOs" disclaimer.
|
||||
// * `introspect_rev == 0` — a remote-share ISO we couldn't
|
||||
// introspect (SMB/NFS/SFTP listings don't seek into the ISO).
|
||||
// Offer sanboot optimistically rather than hide a
|
||||
// likely-bootable installer.
|
||||
// Otherwise it's a local image we *did* introspect and found to
|
||||
// carry no boot catalog — a data/appliance ISO (e.g. a VMware
|
||||
// vCenter Server Appliance bundle). It genuinely cannot boot, so
|
||||
// we expose no menu entry; the dashboard flags it instead.
|
||||
if r.el_torito || r.introspect_rev == 0 {
|
||||
vec![BootEntry {
|
||||
id: format!("{id}-sanboot"),
|
||||
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
|
||||
title,
|
||||
kind: BootKind::SanBootIso {
|
||||
iso_url: format!("iso/{id}.iso"),
|
||||
},
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,6 +739,49 @@ mod tests {
|
||||
assert!(!s.contains(" --- "), "stray ---: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boot_entries_respect_el_torito_and_source() {
|
||||
use crate::introspect::INTROSPECT_REV;
|
||||
|
||||
// ESXi / VMvisor installer shape: bootable (carries an El Torito
|
||||
// catalog) but not classifiable as Windows or Linux. Must yield a
|
||||
// single sanboot entry so it's selectable + boots via emulated CD.
|
||||
let esxi = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: Some("ESXI-7.0U3".into()),
|
||||
el_torito: true,
|
||||
introspect_rev: INTROSPECT_REV,
|
||||
..Default::default()
|
||||
};
|
||||
let e = generate_boot_entries("esxi", "VMware-VMvisor-Installer-7.0U3n.iso", &esxi);
|
||||
assert_eq!(e.len(), 1, "ESXi should get exactly one boot entry");
|
||||
assert!(matches!(e[0].kind, BootKind::SanBootIso { .. }));
|
||||
// Clean title — no stale ">1GiB may fail" disclaimer.
|
||||
assert!(!e[0].title.contains("may fail"), "title: {}", e[0].title);
|
||||
|
||||
// VCSA / data-appliance shape: locally introspected (rev set), no
|
||||
// boot catalog, not Windows/Linux. Genuinely unbootable → no entry,
|
||||
// so it stays out of the iPXE menu (the dashboard flags it instead).
|
||||
let vcsa = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
el_torito: false,
|
||||
introspect_rev: INTROSPECT_REV,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
generate_boot_entries("vcsa", "VMware-VCSA-all-8.0.iso", &vcsa).is_empty(),
|
||||
"data/appliance ISO must produce no boot entry"
|
||||
);
|
||||
|
||||
// Remote-share ISO: never introspected (rev 0, no random access over
|
||||
// SMB/NFS/SFTP). Assume bootable and offer sanboot rather than hide a
|
||||
// likely-bootable installer.
|
||||
let remote = IntrospectionReport::default();
|
||||
let r = generate_boot_entries("remote", "unknown-remote.iso", &remote);
|
||||
assert_eq!(r.len(), 1, "remote (uninspected) ISO keeps a sanboot entry");
|
||||
assert!(matches!(r[0].kind, BootKind::SanBootIso { .. }));
|
||||
}
|
||||
|
||||
fn fake_meta(id: &str) -> IsoMeta {
|
||||
IsoMeta {
|
||||
id: id.into(),
|
||||
@@ -687,13 +789,7 @@ mod tests {
|
||||
size_bytes: 0,
|
||||
sha256_hex: None,
|
||||
uploaded_at: OffsetDateTime::now_utc(),
|
||||
introspection: IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: vec![],
|
||||
has_boot_wim: false,
|
||||
},
|
||||
introspection: IntrospectionReport::default(),
|
||||
boot_entries: vec![],
|
||||
source: IsoSource::Local,
|
||||
password_hash: None,
|
||||
|
||||
+48
-22
@@ -165,18 +165,31 @@
|
||||
if (fam === 'windows_pe') {
|
||||
return { ok: true };
|
||||
}
|
||||
if (!iso.introspection.kernel_path) {
|
||||
// Not Windows and no Linux kernel/initrd detected. Small images can
|
||||
// still try the sanboot fallback; large ones almost certainly aren't
|
||||
// network-bootable installers (e.g. appliance bundles like VMware
|
||||
// VCSA) — flag them clearly instead of with a Linux-centric message.
|
||||
if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
|
||||
return { ok: false, reason: "not a recognized network-bootable installer (no Windows or Linux boot files found) — this image can't be PXE-booted" };
|
||||
}
|
||||
return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
|
||||
}
|
||||
// Linux with a detected kernel/initrd — direct kernel+initrd boot.
|
||||
if (iso.introspection.kernel_path) {
|
||||
return { ok: true };
|
||||
}
|
||||
// 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.
|
||||
function unattendedKindLabel(k) {
|
||||
@@ -291,14 +304,23 @@
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Images available'),
|
||||
el('div', {class: 'value'}, String(isos.length)),
|
||||
el('div', {class: 'trend'},
|
||||
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
|
||||
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
|
||||
el('div', {class: 'trend'}, (() => {
|
||||
// v0.5.9: count families honestly. Anything that isn't a known
|
||||
// Linux family or Windows lands in "other" (data/appliance ISOs
|
||||
// like VMware VCSA, or as-yet-unclassified images) instead of
|
||||
// being lumped under "Linux".
|
||||
const LINUX = ['debian_ubuntu', 'rhel_fedora', 'opensuse', 'arch', 'alpine'];
|
||||
const win = isos.filter(i => i.introspection.family === 'windows_pe').length;
|
||||
const lin = isos.filter(i => LINUX.includes(i.introspection.family)).length;
|
||||
const other = isos.length - win - lin;
|
||||
// v0.4.67+v0.5.5: count all remote-share protocols. Label
|
||||
// generically since operators may use any mix of SMB/NFS/SFTP.
|
||||
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) +
|
||||
' remote share' +
|
||||
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) === 1 ? '' : 's')),
|
||||
const remote = (status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0);
|
||||
const parts = [win + ' Windows', lin + ' Linux'];
|
||||
if (other > 0) parts.push(other + ' other');
|
||||
parts.push(remote + ' remote share' + (remote === 1 ? '' : 's'));
|
||||
return parts.join(' · ');
|
||||
})()),
|
||||
])),
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Uptime'),
|
||||
@@ -343,7 +365,7 @@
|
||||
const settings = status.settings;
|
||||
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
|
||||
const problemsBlock = problems.length ? el('div', {class:'card'}, [
|
||||
el('header', {}, [el('h2', {}, 'Images that won\'t boot with current settings')]),
|
||||
el('header', {}, [el('h2', {}, 'Non-bootable images')]),
|
||||
el('div', {class:'body'},
|
||||
problems.map(({i, b}) => el('div', {class:'row-warn'},
|
||||
'⚠ ' + i.filename + ' — ' + b.reason)))
|
||||
@@ -2280,7 +2302,9 @@
|
||||
// own self-contained <form>; when SSO is enabled, a distinct
|
||||
// "Sign in with …" button sits below a divider — the credential
|
||||
// fields no longer double as the SSO trigger.
|
||||
const ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata);
|
||||
// `enabled` from /api/me already means "usable" (enabled AND a metadata
|
||||
// source is configured), so the button only shows when SSO will work.
|
||||
const ssoLive = !!(ssoConfig && ssoConfig.enabled);
|
||||
const ssoBlock = ssoLive
|
||||
? el('div', {class:'sso-block'}, [
|
||||
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
|
||||
@@ -2525,10 +2549,12 @@
|
||||
])));
|
||||
return;
|
||||
}
|
||||
// Preload the SSO config so the login card can offer the operator
|
||||
// an "Sign in with X" button when configured. Failure is harmless.
|
||||
try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); }
|
||||
catch { ssoConfig = null; }
|
||||
// v0.5.9: the login card's "Sign in with …" button keys off the SSO
|
||||
// descriptor that /api/me now carries (public, non-sensitive: enabled
|
||||
// + idp_name + idp_logo_url). It's available signed in or out, so the
|
||||
// button is static — it no longer relied on the auth-gated /api/sso,
|
||||
// which 401s pre-auth and made the button vanish on fresh login loads.
|
||||
ssoConfig = me.sso || null;
|
||||
|
||||
if (me.setup_required) {
|
||||
showAuthScreen('setup');
|
||||
|
||||
+23
-7
@@ -41,15 +41,31 @@ DEST="${1:-$ROOT/assets/ipxe}"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin
|
||||
# keeps builds reproducible and protects against a transient master
|
||||
# breakage. Bump deliberately.
|
||||
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin keeps
|
||||
# builds reproducible, protects against a transient master breakage, and —
|
||||
# crucially for the Docker image — busting this value invalidates the cached
|
||||
# ipxe-build layer so an "update iPXE" release actually recompiles from the
|
||||
# new upstream. Bump deliberately to a recent master commit.
|
||||
#
|
||||
# v0.6.1: ipxe/ipxe master @ 2026-06-09 (newer NIC drivers + EFI fixes;
|
||||
# mirrors iVentoy 1.0.35 "Update iPXE").
|
||||
IPXE_REPO="https://github.com/ipxe/ipxe.git"
|
||||
IPXE_REF="${IPXE_REF:-master}"
|
||||
IPXE_REF="${IPXE_REF:-95ffbf4745553e8a207922389929e1943c0237c0}"
|
||||
|
||||
echo ">> cloning iPXE ($IPXE_REF)"
|
||||
git clone --depth 1 --branch "$IPXE_REF" "$IPXE_REPO" "$WORK/ipxe" 2>/dev/null \
|
||||
|| git clone "$IPXE_REPO" "$WORK/ipxe"
|
||||
echo ">> fetching iPXE ($IPXE_REF)"
|
||||
# Shallow-fetch the exact ref: works for a full commit SHA (GitHub allows
|
||||
# reachable-SHA1-in-want) and for branch/tag names. Fall back to a full
|
||||
# clone + checkout if the server refuses a direct fetch of this ref.
|
||||
git init -q "$WORK/ipxe"
|
||||
git -C "$WORK/ipxe" remote add origin "$IPXE_REPO"
|
||||
if git -C "$WORK/ipxe" fetch -q --depth 1 origin "$IPXE_REF"; then
|
||||
git -C "$WORK/ipxe" checkout -q FETCH_HEAD
|
||||
else
|
||||
echo " direct fetch failed; falling back to full clone + checkout"
|
||||
rm -rf "$WORK/ipxe"
|
||||
git clone -q "$IPXE_REPO" "$WORK/ipxe"
|
||||
git -C "$WORK/ipxe" checkout -q "$IPXE_REF"
|
||||
fi
|
||||
SRC="$WORK/ipxe/src"
|
||||
|
||||
echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)"
|
||||
|
||||
@@ -29,11 +29,19 @@ mkdir -p "$DEST"
|
||||
# Upstream uses arch-scoped subdirectories; we flatten to the names our
|
||||
# ClientArch::ipxe_bootfile() expects.
|
||||
declare -a MAP=(
|
||||
# DriverMode::Firmware (default) — reuse the firmware UNDI/SNP NIC stack.
|
||||
"undionly.kpxe=undionly.kpxe"
|
||||
"snponly.efi=x86_64-efi/snponly.efi"
|
||||
"snponly-i386.efi=i386-efi/snponly.efi"
|
||||
"snponly-arm64.efi=arm64-efi/snponly.efi"
|
||||
"ipxe.efi=x86_64-efi/ipxe.efi" # fallback with bundled drivers
|
||||
# DriverMode::Builtin (v0.6.1 automatic fallback) — iPXE's own all-drivers
|
||||
# builds, advertised by the DHCP proxy to a MAC whose firmware NIC stack
|
||||
# failed to chainload. (x86_64 ipxe.efi is rebuilt from source with PNG in
|
||||
# build-ipxe.sh and overlaid on top of this fetched baseline.)
|
||||
"ipxe.efi=x86_64-efi/ipxe.efi"
|
||||
"ipxe.pxe=ipxe.pxe"
|
||||
"ipxe-i386.efi=i386-efi/ipxe.efi"
|
||||
"ipxe-arm64.efi=arm64-efi/ipxe.efi"
|
||||
)
|
||||
|
||||
BASE="https://boot.ipxe.org"
|
||||
|
||||
Reference in New Issue
Block a user