Storage / boot detection - Add El Torito boot-catalog detection to ISO introspection. This is the authoritative "can this boot at all?" signal: any ISO with a boot catalog (BSDs, ESXi, firmware tools, custom spins) is bootable via iPXE sanboot; a data/appliance ISO (e.g. a VMware vCenter bundle) has none and is honestly flagged. Replaces the crude ">1.5 GB ⇒ unbootable" size guess. - Re-introspect stale LOCAL ISOs on startup via an introspection-revision gate (INTROSPECT_REV). ISOs uploaded by an older binary carried a frozen family/boot profile — most visibly a Windows 11 ISO tagged Unknown before the UDF/UTF-16 detection landed, which then showed "won't boot" forever. An upgrade now re-probes and fixes them in place; no delete-and-re-upload. - WebUI bootability() keys off family / kernel / el_torito / remote-source instead of the size heuristic; dashboard family counts now bucket Windows / Linux / other honestly instead of lumping everything non-Windows under "Linux". SSO login button - The "Sign in with …" button keyed off the auth-gated /api/sso, which 401s pre-auth — so the button only survived on a stale in-memory config and vanished instance-wide on any fresh login-page load. Ship a minimal, non-sensitive SSO descriptor (enabled + idp_name + idp_logo_url, no metadata/entity-ID) on the public /api/me; the login card reads that. The button is now static whenever SSO is usable. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
381 lines
15 KiB
Rust
381 lines
15 KiB
Rust
//! ISO introspection — identify the distro family and locate kernel/initrd.
|
|
//!
|
|
//! We avoid a full ISO9660/Joliet/Rock-Ridge parser by reading a small number
|
|
//! of well-known files via `isoinfo` (from cdrtools/genisoimage) when it's on
|
|
//! the path. As a pure-Rust fallback we do a crude scan: read the volume
|
|
//! descriptor at offset 0x8000 to grab the volume label, and grep for known
|
|
//! filenames by scanning raw sectors — good enough to tell Debian from RHEL
|
|
//! most of the time, without shelling out.
|
|
//!
|
|
//! The returned `IntrospectionReport` is what `BootEntry`s get generated from.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
use std::path::Path;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DistroFamily {
|
|
DebianUbuntu,
|
|
RhelFedora,
|
|
OpenSuse,
|
|
Arch,
|
|
Alpine,
|
|
WindowsPe,
|
|
#[default]
|
|
Unknown,
|
|
}
|
|
|
|
/// 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>,
|
|
/// Kernel path inside the ISO (e.g. `/casper/vmlinuz`, `/isolinux/vmlinuz`).
|
|
pub kernel_path: Option<String>,
|
|
/// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups.
|
|
pub initrd_paths: Vec<String>,
|
|
/// True if `sources/boot.wim` present — Windows install media.
|
|
pub has_boot_wim: bool,
|
|
/// True if the ISO carries an El Torito boot catalog — i.e. it is
|
|
/// bootable by BIOS/UEFI firmware and therefore by iPXE `sanboot`
|
|
/// (emulated CD). This is the authoritative "can this boot at all?"
|
|
/// signal for ISOs we can't classify as Linux or Windows (BSDs, ESXi,
|
|
/// firmware tools, custom spins). A *data* ISO (e.g. a VMware vCenter
|
|
/// appliance bundle) has no boot catalog and reports `false`. v0.5.9.
|
|
#[serde(default)]
|
|
pub el_torito: bool,
|
|
/// Revision of the introspection logic that produced this report. 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 {
|
|
introspect_rev: INTROSPECT_REV,
|
|
..Default::default()
|
|
};
|
|
|
|
let Ok(mut f) = std::fs::File::open(path) else {
|
|
tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display());
|
|
return report;
|
|
};
|
|
|
|
// ISO9660 Primary Volume Descriptor at LBA 16 (offset 0x8000), 2048 bytes.
|
|
// Bytes 40..72 are the Volume Identifier (space-padded, d-characters).
|
|
let mut pvd = [0u8; 2048];
|
|
if f.seek(SeekFrom::Start(0x8000)).is_ok() && f.read_exact(&mut pvd).is_ok() {
|
|
// Byte 0 must be 0x01 (primary descriptor), bytes 1..6 = "CD001".
|
|
if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" {
|
|
let label_raw = &pvd[40..72];
|
|
let label = String::from_utf8_lossy(label_raw).trim().to_string();
|
|
if !label.is_empty() {
|
|
report.volume_label = Some(label.clone());
|
|
report.family = family_from_label(&label);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
let _ = f.seek(SeekFrom::Start(0));
|
|
let scan_bytes = 64 * 1024 * 1024;
|
|
let mut buf = vec![0u8; 1024 * 1024];
|
|
let mut read_total = 0usize;
|
|
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
|
|
while read_total < scan_bytes {
|
|
let n = f.read(&mut buf).unwrap_or(0);
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
haystack.extend_from_slice(&buf[..n]);
|
|
read_total += n;
|
|
}
|
|
|
|
// `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")
|
|
|| contains_ascii(&haystack, b"sources\\boot.wim")
|
|
{
|
|
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 {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Best-effort kernel/initrd path guess from family. These paths are what
|
|
// distro ISOs conventionally ship at — we don't verify extraction here;
|
|
// that happens in the store after introspection.
|
|
let (k, i) = guess_kernel_initrd(report.family);
|
|
report.kernel_path = k.map(str::to_string);
|
|
report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
|
|
|
|
report
|
|
}
|
|
|
|
fn family_from_label(label: &str) -> DistroFamily {
|
|
let l = label.to_ascii_lowercase();
|
|
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") {
|
|
DistroFamily::DebianUbuntu
|
|
} else if l.contains("rhel")
|
|
|| l.contains("centos")
|
|
|| l.contains("fedora")
|
|
|| l.contains("rocky")
|
|
|| l.contains("alma")
|
|
{
|
|
DistroFamily::RhelFedora
|
|
} else if l.contains("suse") || l.contains("opensuse") {
|
|
DistroFamily::OpenSuse
|
|
} else if l.contains("arch") {
|
|
DistroFamily::Arch
|
|
} else if l.contains("alpine") {
|
|
DistroFamily::Alpine
|
|
} else if l.contains("windows") || l.contains("winpe") {
|
|
DistroFamily::WindowsPe
|
|
} else {
|
|
DistroFamily::Unknown
|
|
}
|
|
}
|
|
|
|
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
|
|
match family {
|
|
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
|
|
DistroFamily::RhelFedora => (
|
|
Some("/images/pxeboot/vmlinuz"),
|
|
vec!["/images/pxeboot/initrd.img"],
|
|
),
|
|
DistroFamily::OpenSuse => (
|
|
Some("/boot/x86_64/loader/linux"),
|
|
vec!["/boot/x86_64/loader/initrd"],
|
|
),
|
|
DistroFamily::Arch => (
|
|
Some("/arch/boot/x86_64/vmlinuz-linux"),
|
|
vec!["/arch/boot/x86_64/initramfs-linux.img"],
|
|
),
|
|
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]),
|
|
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
|
|
}
|
|
}
|
|
|
|
fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
|
|
if needle.is_empty() || haystack.len() < needle.len() {
|
|
return false;
|
|
}
|
|
haystack
|
|
.windows(needle.len())
|
|
.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)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn label_matching() {
|
|
assert_eq!(
|
|
family_from_label("Ubuntu 24.04"),
|
|
DistroFamily::DebianUbuntu
|
|
);
|
|
assert_eq!(
|
|
family_from_label("Rocky-9-x86_64-dvd"),
|
|
DistroFamily::RhelFedora
|
|
);
|
|
assert_eq!(
|
|
family_from_label("openSUSE-Leap-15.6"),
|
|
DistroFamily::OpenSuse
|
|
);
|
|
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
|
|
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"
|
|
)));
|
|
}
|
|
}
|