Compare commits

...
2 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 6524aa4118 v0.7.4: probe-based introspection — remote shares classify, gparted bug fixed, Storage pagination
Introspection (the headline): detection is now probe-based. Instead of
grepping raw sectors for filename strings, we walk the ISO9660
directory tree and check whether the well-known boot files actually
exist — and the same probes run over NFS READ3 / SFTP seek-reads, so
share-hosted ISOs finally classify instead of registering as Unknown.

iso-store:
- New iso_fs module: the read-only ISO9660 walker (generalized from
  http-api) over an IsoReadAt trait — local files, NFS, SFTP, and the
  in-memory test images all share it. Iterative walk, 4 MiB directory
  cap, strict-mastering trailing-dot normalization (VMLINUZ.;1 now
  matches /vmlinuz), CachingReadAt collapses repeated directory reads
  during the probe pass (~60 → ~6 round-trips per remote ISO).
- introspect.rs rewritten (INTROSPECT_REV 2): PVD label → El Torito →
  /sources/boot.wim probe → verified Linux kernel+initrd probe table →
  local-only 16 MiB UDF-Windows scan → filename-token fallback.
  * Fixes the false-Windows bug: any Linux ISO shipping GRUB/syslinux
    chainload modules contains the literal "bootmgr", so gparted-live
    classified as WindowsPe. Linux probes now run first; the byte scan
    only sees ISOs nothing else claimed. Local ISOs re-probe once on
    startup via the rev bump — no re-upload.
  * Kernel entries are emitted only when kernel+initrd verifiably
    exist (no more guessed paths that 404 at boot). Debian-live /
    d-i netinst / CoreOS shapes classify for the UI but keep their
    working sanboot entries (their boot protocols need args we don't
    render yet; CoreOS additionally needs its embedded ignition).
  * Label + filename vocab extended: rhcos/coreos/openshift/okd,
    gparted/clonezilla/kali/tails, almalinux/rocky, sles, manjaro.
- NFS + SFTP managers: per-ISO IsoReadAt readers (READ3-at-offset with
  short-read looping / seek+read_exact), background introspection pass
  after each scan — entries register instantly with a provisional
  filename-based report (rev 0, optimistic sanboot preserved) and
  upgrade in place as probes land (30s/ISO timeout, failures keep the
  provisional). locate_in_iso() exposes the walker to the HTTP layer.
- remote_cache: introspection results persisted per protocol keyed
  share/path@size and gated on INTROSPECT_REV — container restarts
  re-probe only new/replaced ISOs; upgrades re-probe exactly once.
- SMB: smbclient can't seek, so SMB ISOs get the filename-token family
  (rev stays 0 → sanboot entry + "awaiting introspection" label).
- IsoStore::update_external_introspection swaps in completed reports
  and regenerates boot entries, preserving category/password.

http-api:
- /iso/{id}/{*path} now serves files from inside NFS/SFTP-hosted ISOs
  (remote ISO9660 lookup + ranged share stream) — verified kernel
  entries on remote Linux ISOs are actually bootable, end to end.
- iso_fs.rs deleted in favor of the shared iso-store module.
- full_flow fixtures build real directory trees via the shared
  test-image builder (new iso-store feature) — a label-only blob no
  longer earns a kernel entry, by design.

webui:
- Available images: paged 5 per page with a quiet footer pager
  (Showing X–Y of N · Prev/Next), filter-then-paginate, page resets on
  search input. Fifty images is five clean pages, not a scroll wall.
- Hosts/Queue profile: "Unattended file (in Storage → Advanced)" so
  the picker says where the files live.
- Row badge keys on introspect_rev: probed remote ISOs read like local
  ones; un-probed say "awaiting introspection".

Validation: clippy pedantic clean, fmt clean, 316 workspace tests
green (+17: walker, probe shapes incl. gparted regression + CoreOS,
filename table, cache round-trips), webui syntax-checked.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-12 15:21:14 -04:00
Miles WardandClaude Opus 4.8 934cfbab46 v0.7.3: UI cleanup — aligned Hosts pin form, native-chrome list filters, Link row reorder
Design-language cleanup of the v0.7.2 additions (no behaviour change).

Hosts:
- The Pin MAC form collapses to a single aligned 4-up row: MAC ·
  Label · Architecture · Boot binary. Target drops full-width onto its
  own line beneath them. The per-field hints that broke the row's
  alignment moved into the explanatory note below, so every control
  shares one baseline.

Storage:
- The image and unattended filter inputs are now wrapped in a
  label.field, so they inherit the standard text-field chrome (border,
  radius, height, focus ring) instead of the raw browser
  <input type=search> look. They span the full card width for
  continuity with the rest of the page.

Network:
- The 'Link' row moved below 'Public base URL'. Its joined
  operstate · speed · duplex · MAC string runs long, so placing it last
  lets it wrap at the bottom without shoving the other rows around.

Validation: clippy clean, fmt clean, 299 workspace tests green, webui
syntax-checked. No protocol or boot-path changes in this release.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 22:44:19 -04:00
18 changed files with 1850 additions and 442 deletions
Generated
+8 -8
View File
@@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openpxe" name = "openpxe"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2858,7 +2858,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-core" name = "openpxe-core"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -2885,7 +2885,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-dhcp-proxy" name = "openpxe-dhcp-proxy"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2902,7 +2902,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-http-api" name = "openpxe-http-api"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2938,7 +2938,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-ipxe-assets" name = "openpxe-ipxe-assets"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"openpxe-core", "openpxe-core",
"rust-embed", "rust-embed",
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-iso-store" name = "openpxe-iso-store"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bcrypt", "bcrypt",
@@ -2977,7 +2977,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-tftp" name = "openpxe-tftp"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2991,7 +2991,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-webui" name = "openpxe-webui"
version = "0.7.2" version = "0.7.4"
[[package]] [[package]]
name = "p256" name = "p256"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.7.2" version = "0.7.4"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+4
View File
@@ -49,6 +49,10 @@ base64.workspace = true
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
tower = { workspace = true } tower = { workspace = true }
tempfile = "3.12" tempfile = "3.12"
# v0.7.4: probe-based introspection verifies kernel paths against the
# real ISO9660 tree, so the full-flow tests synthesize images with the
# shared test builder instead of label-only blobs.
openpxe-iso-store = { workspace = true, features = ["test-image"] }
serde_json = { workspace = true } serde_json = { workspace = true }
time = { workspace = true } time = { workspace = true }
# v0.4.61: integration tests need to generate real PNG bytes for the # v0.4.61: integration tests need to generate real PNG bytes for the
+85 -8
View File
@@ -19,7 +19,6 @@ use crate::ipxe_script::{
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info, render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
render_queue_entry, render_shell, render_tools_menu, render_util, render_queue_entry, render_shell, render_tools_menu, render_util,
}; };
use crate::iso_fs;
use crate::log_stream; use crate::log_stream;
use crate::state::AppState; use crate::state::AppState;
use crate::terminal; use crate::terminal;
@@ -36,6 +35,7 @@ use openpxe_core::{
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_slice; use openpxe_ipxe_assets::asset_slice;
use openpxe_iso_store::iso_fs;
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,
@@ -1087,16 +1087,17 @@ async fn iso_file(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath((id, path)): AxumPath<(String, String)>, AxumPath((id, path)): AxumPath<(String, String)>,
) -> Response { ) -> Response {
// In-ISO file extraction is only supported for local ISOs — it let Some(meta) = state.iso_store.get(&id) else {
// needs random-access reads into the ISO9660 directory tree, which return (StatusCode::NOT_FOUND, "no such iso").into_response();
// smbclient's whole-file streaming can't do efficiently. SMB- };
// sourced ISOs use the raw streaming endpoint above instead. let in_path = format!("/{path}");
let Some(iso_path) = state.iso_store.iso_path_for(&id) else { match &meta.source {
IsoSource::Local => {
let Some(iso_path) = state.iso_store.local_path(&meta) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response(); return (StatusCode::NOT_FOUND, "no such iso").into_response();
}; };
let p = iso_path.clone(); let p = iso_path.clone();
let in_path = format!("/{path}"); let loc = tokio::task::spawn_blocking(move || iso_fs::lookup_local(&p, &in_path))
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
.await .await
.ok() .ok()
.flatten(); .flatten();
@@ -1107,6 +1108,82 @@ async fn iso_file(
Ok(r) => r, Ok(r) => r,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
} }
}
// v0.7.4: remote ISOs serve in-ISO files too — the same ISO9660
// walk runs over NFS READ3 / SFTP seek-reads, then the located
// byte range streams through the share manager. This is what
// makes the verified kernel/initrd boot entries on share-hosted
// Linux ISOs actually bootable.
IsoSource::Nfs {
share_id,
relative_path,
} => {
match state
.nfs_shares
.locate_in_iso(share_id, relative_path, &in_path)
.await
{
Ok(Some(loc)) => {
match state
.nfs_shares
.stream_iso(share_id, relative_path, loc.offset, Some(loc.length))
.await
{
Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length),
Err(e) => {
(StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response()
}
}
}
Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs lookup: {e}")).into_response(),
}
}
IsoSource::Sftp {
share_id,
relative_path,
} => {
match state
.sftp_shares
.locate_in_iso(share_id, relative_path, &in_path)
.await
{
Ok(Some(loc)) => {
match state
.sftp_shares
.stream_iso(share_id, relative_path, loc.offset, Some(loc.length))
.await
{
Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length),
Err(e) => {
(StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response()
}
}
}
Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp lookup: {e}")).into_response(),
}
}
// smbclient streams sequentially — no seeks, no ISO9660 walk.
// SMB ISOs never emit kernel entries, so nothing requests this.
IsoSource::Smb { .. } => (
StatusCode::NOT_FOUND,
"in-ISO files are not available for SMB-sourced ISOs",
)
.into_response(),
}
}
/// 200 response wrapping an in-ISO byte-range stream from a share
/// manager. Content-Length is the located file's length — the stream is
/// already bounded to exactly that range.
fn in_iso_stream_response(body: Body, length: u64) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_LENGTH, length)
.body(body)
.unwrap()
} }
async fn stream_file_range( async fn stream_file_range(
-135
View File
@@ -1,135 +0,0 @@
//! Minimal read-only ISO9660 lookup. Given an uploaded ISO file and an
//! in-ISO path (e.g. `/casper/vmlinuz`), locate the file and return a
//! `(start_byte, length_bytes)` pair so the HTTP handler can stream just
//! that range from the on-disk ISO without full extraction.
//!
//! We only implement what we need: the Primary Volume Descriptor and Rock
//! Ridge / Joliet extensions are ignored. Paths are matched case-insensitive
//! against plain ISO9660 filenames (uppercase, `;1` version suffix stripped).
//! This is sufficient for the kernel/initrd and wimboot files we serve;
//! if a requested path isn't found, the handler returns 404 and the user
//! can still download the whole ISO via `/iso/<id>.iso`.
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
const SECTOR: u64 = 2048;
#[derive(Debug, Clone)]
pub struct FileLocation {
pub offset: u64,
pub length: u64,
}
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in the
/// ISO at `iso_path`. Returns None on any parsing or IO failure.
pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
let mut f = std::fs::File::open(iso_path).ok()?;
let root = read_root_directory(&mut f)?;
let components: Vec<&str> = in_iso_path
.trim_start_matches('/')
.split('/')
.filter(|c| !c.is_empty())
.collect();
if components.is_empty() {
return None;
}
walk(&mut f, root.offset, root.length, &components)
}
fn read_root_directory(f: &mut std::fs::File) -> Option<FileLocation> {
// Primary Volume Descriptor at LBA 16.
let mut pvd = [0u8; 2048];
f.seek(SeekFrom::Start(16 * SECTOR)).ok()?;
f.read_exact(&mut pvd).ok()?;
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
return None;
}
// Root directory record is at offset 156, length 34.
let rec = &pvd[156..156 + 34];
let (offset, length) = parse_dir_record_ext(rec)?;
Some(FileLocation {
offset: offset * SECTOR,
length,
})
}
/// Walk components down the directory tree starting at `dir_offset`.
fn walk(
f: &mut std::fs::File,
dir_offset: u64,
dir_len: u64,
components: &[&str],
) -> Option<FileLocation> {
let mut dir = vec![0u8; dir_len as usize];
f.seek(SeekFrom::Start(dir_offset)).ok()?;
f.read_exact(&mut dir).ok()?;
let target = components[0];
let rest = &components[1..];
let mut i = 0;
while i < dir.len() {
let len = dir[i] as usize;
if len == 0 {
// Padding to sector boundary.
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
if next <= i {
break;
}
i = next;
continue;
}
if i + len > dir.len() {
break;
}
let rec = &dir[i..i + len];
let name = dir_record_name(rec);
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
let is_pseudo = matches!(rec.get(32).copied(), Some(1))
&& rec.get(33).copied() == Some(0x00)
|| matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01);
if !is_pseudo && name.eq_ignore_ascii_case(target) {
let (child_off, child_len) = parse_dir_record_ext(rec)?;
if rest.is_empty() && !is_dir {
return Some(FileLocation {
offset: child_off * SECTOR,
length: child_len,
});
} else if !rest.is_empty() && is_dir {
return walk(f, child_off * SECTOR, child_len, rest);
}
}
i += len;
}
None
}
/// Extract (extent LBA, data length in bytes) from a directory record.
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
/// data length (LE+BE duplicate). We trust the little-endian copy.
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
if rec.len() < 34 {
return None;
}
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64;
let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64;
Some((lba, len))
}
/// Extract the identifier from a directory record, stripping ISO9660's
/// `;1` version suffix.
fn dir_record_name(rec: &[u8]) -> String {
let name_len = *rec.get(32).unwrap_or(&0) as usize;
if name_len == 0 || rec.len() < 33 + name_len {
return String::new();
}
let raw = &rec[33..33 + name_len];
let s = String::from_utf8_lossy(raw).to_string();
// Strip `;N` version suffix.
if let Some(i) = s.rfind(';') {
s[..i].to_string()
} else {
s
}
}
+3 -3
View File
@@ -9,8 +9,9 @@
//! and Linux kernel/initrd, without having to //! and Linux kernel/initrd, without having to
//! re-extract on every request) //! re-extract on every request)
//! //!
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`) //! The `<id>/<path>` handler uses the read-only ISO9660 walker from
//! that lseeks into the ISO on disk — so we never keep extracted copies. //! `openpxe_iso_store::iso_fs` — seeking into the image wherever it
//! lives (local disk, NFS, SFTP), so we never keep extracted copies.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod app; pub mod app;
@@ -18,7 +19,6 @@ pub mod auth;
pub mod error; pub mod error;
pub mod grub_script; pub mod grub_script;
pub mod ipxe_script; pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream; pub mod log_stream;
pub mod notify; pub mod notify;
pub mod saml_routes; pub mod saml_routes;
+13 -19
View File
@@ -18,23 +18,16 @@ use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbShareMan
use tempfile::tempdir; use tempfile::tempdir;
use tower::ServiceExt; use tower::ServiceExt;
/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so /// Build a tiny Alpine-shaped ISO9660 image: volume label "ALPINE-TEST"
/// introspection identifies it as Alpine. /// plus the real `/boot/vmlinuz-lts` + `/boot/initramfs-lts` tree.
/// v0.7.4's probe-based introspection verifies those paths exist before
/// emitting a kernel boot entry — a label-only blob no longer counts.
fn fake_alpine_iso() -> Vec<u8> { fn fake_alpine_iso() -> Vec<u8> {
let mut buf = vec![0u8; 32 * 2048]; openpxe_iso_store::iso_fs::testiso::TestIsoBuilder::new("ALPINE-TEST")
let off = 16 * 2048; .el_torito(true)
buf[off] = 0x01; .file("/boot/vmlinuz-lts", b"fake-kernel-bytes")
buf[off + 1..off + 6].copy_from_slice(b"CD001"); .file("/boot/initramfs-lts", b"fake-initramfs-bytes")
buf[off + 6] = 0x01; .build()
let label = b"ALPINE-TEST".to_vec();
let mut padded = label.clone();
padded.resize(32, b' ');
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
let term = 17 * 2048;
buf[term] = 0xFF;
buf[term + 1..term + 6].copy_from_slice(b"CD001");
buf[term + 6] = 0x01;
buf
} }
fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) { fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
@@ -1248,9 +1241,10 @@ async fn chunked_upload_writes_progressively_and_finishes_iso() {
.method("POST") .method("POST")
.uri("/api/uploads") .uri("/api/uploads")
.header("content-type", "application/json") .header("content-type", "application/json")
.body(Body::from( .body(Body::from(format!(
r#"{"filename":"chunked-alpine.iso","size_bytes":65536}"#, r#"{{"filename":"chunked-alpine.iso","size_bytes":{}}}"#,
)) iso.len()
)))
.unwrap(), .unwrap(),
) )
.await .await
+8
View File
@@ -49,3 +49,11 @@ futures = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
[features]
# v0.7.4: exposes the in-memory ISO9660 test-image builder
# (`iso_fs::testiso`) to other crates' integration tests, so http-api's
# full-flow tests can synthesize ISOs with real directory trees — the
# probe-based introspection no longer classifies label-only blobs.
# Never enabled in production builds.
test-image = []
+514 -175
View File
@@ -1,16 +1,33 @@
//! ISO introspection — identify the distro family and locate kernel/initrd. //! ISO introspection — identify the distro family and locate kernel/initrd.
//! //!
//! We avoid a full ISO9660/Joliet/Rock-Ridge parser by reading a small number //! v0.7.4 rewrite: detection is **probe-based**. Instead of grepping raw
//! of well-known files via `isoinfo` (from cdrtools/genisoimage) when it's on //! sectors for filename strings (which false-positived — any Linux ISO
//! the path. As a pure-Rust fallback we do a crude scan: read the volume //! shipping GRUB/syslinux chainload modules contains the literal
//! descriptor at offset 0x8000 to grab the volume label, and grep for known //! "bootmgr", so gparted-live classified as Windows), we walk the
//! filenames by scanning raw sectors — good enough to tell Debian from RHEL //! ISO9660 directory tree via [`crate::iso_fs`] and check whether the
//! most of the time, without shelling out. //! well-known boot files actually exist. The same probes run over local
//! files and remote NFS/SFTP shares — remote ISOs finally classify
//! instead of registering as `Unknown`.
//! //!
//! The returned `IntrospectionReport` is what `BootEntry`s get generated from. //! Layered, first-decisive-answer-wins:
//! 1. PVD volume label → family hint.
//! 2. El Torito boot-catalog presence (the "bootable at all" signal).
//! 3. `/sources/boot.wim` directory probe → Windows install media.
//! 4. Linux probe table → verified kernel+initrd paths. A probe match
//! both classifies the family and (for the families whose boot
//! arguments we render) yields kernel paths that are *known to
//! exist* — no more guessed paths that 404 at boot.
//! 5. Bulk byte scan for UDF Windows markers — local images only
//! (modern Windows ISOs hide their tree from ISO9660; remote scans
//! skip this so a share rescan doesn't stream 16 MiB per ISO).
//! 6. Filename tokens — the last-resort hint, and the only signal
//! available for SMB shares (smbclient cannot seek).
//!
//! The returned `IntrospectionReport` is what `BootEntry`s get generated
//! from.
use crate::iso_fs::{self, CachingReadAt, FileReadAt, IsoReadAt, SECTOR};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path; use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -30,18 +47,26 @@ pub enum DistroFamily {
/// re-classify already-uploaded ISOs. On startup the store re-runs /// re-classify already-uploaded ISOs. On startup the store re-runs
/// `introspect` on any *local* ISO whose persisted report predates this /// `introspect` on any *local* ISO whose persisted report predates this
/// revision (see `IsoStore::load_from_disk`), so an upgrade fixes stale /// revision (see `IsoStore::load_from_disk`), so an upgrade fixes stale
/// metadata — e.g. a Windows 11 ISO tagged `Unknown` by an older binary — /// metadata without the operator having to delete and re-upload.
/// without the operator having to delete and re-upload it.
/// ///
/// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened /// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened
/// Windows (UDF/UTF-16) detection becomes retroactive. /// Windows (UDF/UTF-16) detection.
pub const INTROSPECT_REV: u32 = 1; /// rev 2 (v0.7.4): probe-based detection. Fixes Linux live ISOs that
/// classified as Windows via the raw "bootmgr" byte grep, verifies
/// kernel/initrd paths exist before emitting them, and adds the Debian
/// live / netinst / CoreOS shapes. Remote (NFS/SFTP) introspection
/// caches key off this rev too, so the cache self-invalidates.
pub const INTROSPECT_REV: u32 = 2;
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[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>,
/// Kernel path inside the ISO (e.g. `/casper/vmlinuz`, `/isolinux/vmlinuz`). /// Kernel path inside the ISO (e.g. `/casper/vmlinuz`). v0.7.4: only
/// set when the path was verified to exist *and* the family's boot
/// arguments are known-good for direct kernel boot; families we can
/// only classify (Debian live, CoreOS live) leave it `None` so the
/// entry generator falls back to sanboot instead of a broken boot.
pub kernel_path: Option<String>, pub kernel_path: Option<String>,
/// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups. /// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups.
pub initrd_paths: Vec<String>, pub initrd_paths: Vec<String>,
@@ -55,124 +80,242 @@ pub struct IntrospectionReport {
/// appliance bundle) has no boot catalog and reports `false`. v0.5.9. /// appliance bundle) has no boot catalog and reports `false`. v0.5.9.
#[serde(default)] #[serde(default)]
pub el_torito: bool, pub el_torito: bool,
/// Revision of the introspection logic that produced this report. Old /// Revision of the introspection logic that produced this report.
/// `meta.json` files without the field deserialize as 0, which is /// `0` means "never introspected" (pre-v0.5.9 metadata, or a remote
/// below [`INTROSPECT_REV`], triggering a one-time re-introspect on /// ISO whose probe hasn't run / can't run) — the entry generator
/// the next startup. v0.5.9. /// treats those optimistically (sanboot) and the UI labels them.
#[serde(default)] #[serde(default)]
pub introspect_rev: u32, pub introspect_rev: u32,
} }
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log /// One row of the Linux detection table.
/// and return an `Unknown` family so the uploader still sees a record. ///
/// `emit_kernel` distinguishes "we can boot this directly" from "we can
/// only classify it". Families marked `false` have boot protocols our
/// cmdline renderer doesn't speak yet (Debian-live `boot=live fetch=`,
/// d-i netinst, CoreOS `coreos.live.rootfs_url=`) — for those the probe
/// sets the family for the UI/menu but leaves `kernel_path` unset so the
/// ISO keeps its (working) sanboot entry instead of gaining a broken
/// kernel one. Strictly fewer broken boots than guessing.
struct LinuxProbe {
family: DistroFamily,
kernel: &'static str,
initrd_candidates: &'static [&'static str],
emit_kernel: bool,
}
const LINUX_PROBES: &[LinuxProbe] = &[
// Ubuntu and friends (casper) — the classic direct-boot shape.
LinuxProbe {
family: DistroFamily::DebianUbuntu,
kernel: "/casper/vmlinuz",
initrd_candidates: &["/casper/initrd", "/casper/initrd.lz", "/casper/initrd.gz"],
emit_kernel: true,
},
// Debian-live derivatives: gparted-live, Clonezilla, Kali live, tails.
// Classification only — live-boot needs `boot=live fetch=<squashfs>`
// which we don't render yet; sanboot of these images works today.
LinuxProbe {
family: DistroFamily::DebianUbuntu,
kernel: "/live/vmlinuz",
initrd_candidates: &["/live/initrd.img", "/live/initrd"],
emit_kernel: false,
},
// Debian installer (netinst/DVD). Classification only for the same
// reason — d-i sanboots fine.
LinuxProbe {
family: DistroFamily::DebianUbuntu,
kernel: "/install.amd/vmlinuz",
initrd_candidates: &["/install.amd/initrd.gz"],
emit_kernel: false,
},
// Anaconda family: RHEL, CentOS, Alma, Rocky, Fedora — and their
// many derivatives (Cisco ISE, Nagios appliances, …). The CoreOS
// variant of this shape is special-cased after the table.
LinuxProbe {
family: DistroFamily::RhelFedora,
kernel: "/images/pxeboot/vmlinuz",
initrd_candidates: &["/images/pxeboot/initrd.img"],
emit_kernel: true,
},
LinuxProbe {
family: DistroFamily::OpenSuse,
kernel: "/boot/x86_64/loader/linux",
initrd_candidates: &["/boot/x86_64/loader/initrd"],
emit_kernel: true,
},
LinuxProbe {
family: DistroFamily::Arch,
kernel: "/arch/boot/x86_64/vmlinuz-linux",
initrd_candidates: &["/arch/boot/x86_64/initramfs-linux.img"],
emit_kernel: true,
},
LinuxProbe {
family: DistroFamily::Alpine,
kernel: "/boot/vmlinuz-lts",
initrd_candidates: &["/boot/initramfs-lts"],
emit_kernel: true,
},
];
/// CoreOS-style live images (RHCOS, FCOS, OpenShift agent ISOs) carry
/// the anaconda pxeboot layout *plus* a rootfs image. Direct kernel boot
/// of those requires `coreos.live.rootfs_url=` (and for agent ISOs, the
/// ignition config embedded in the ISO device) — neither of which a
/// plain `inst.repo=` cmdline provides. Their sanboot path works, so
/// they classify as RHEL-family but keep the sanboot entry.
const COREOS_ROOTFS: &str = "/images/pxeboot/rootfs.img";
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we
/// log and return an `Unknown` family so the uploader still sees a record.
pub fn introspect(path: &Path) -> IntrospectionReport { pub fn introspect(path: &Path) -> IntrospectionReport {
let filename = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let Ok(f) = std::fs::File::open(path) else {
tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display());
return IntrospectionReport {
introspect_rev: INTROSPECT_REV,
..Default::default()
};
};
let len = f.metadata().map_or(0, |m| m.len());
// `FileReadAt` completes every read inline (no real awaits), so this
// light-weight block_on never parks; callers already run us on the
// blocking pool.
futures::executor::block_on(introspect_reader(
&mut FileReadAt::new(f),
len,
&filename,
true,
))
}
/// The detection core, generic over any random-access source. `total_len`
/// is the image size (every caller knows it — file metadata locally, the
/// share listing remotely) and bounds the bulk scan, since `IsoReadAt`
/// reads are exact-or-error. `filename` feeds the last-resort token
/// heuristics; `allow_bulk_scan` gates the 16 MiB UDF-Windows byte scan
/// (local files only — remote shares would stream that much per ISO per
/// rescan).
pub async fn introspect_reader<R: IsoReadAt + Send>(
r: &mut R,
total_len: u64,
filename: &str,
allow_bulk_scan: bool,
) -> IntrospectionReport {
let mut report = IntrospectionReport { let mut report = IntrospectionReport {
introspect_rev: INTROSPECT_REV, introspect_rev: INTROSPECT_REV,
..Default::default() ..Default::default()
}; };
let Ok(mut f) = std::fs::File::open(path) else { // ISO9660 Primary Volume Descriptor at LBA 16. Bytes 40..72 are the
tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display()); // volume identifier (space-padded).
return report; if let Ok(pvd) = r.read_at(16 * SECTOR, 2048).await {
};
// 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" { if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" {
let label_raw = &pvd[40..72]; let label = String::from_utf8_lossy(&pvd[40..72]).trim().to_string();
let label = String::from_utf8_lossy(label_raw).trim().to_string();
if !label.is_empty() { if !label.is_empty() {
report.volume_label = Some(label.clone());
report.family = family_from_label(&label); report.family = family_from_label(&label);
report.volume_label = Some(label);
} }
} }
} }
// Does the ISO have an El Torito boot catalog? This is what decides report.el_torito = detect_el_torito(r).await;
// 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. // Directory-tree probes. The caching wrapper collapses the repeated
// This is enough to identify `sources/boot.wim` (Windows) and common // root/subdirectory reads the probe table would otherwise issue —
// kernel/initrd paths for the major Linux distros. // over NFS/SFTP that's the difference between ~6 and ~60 round-trips.
let _ = f.seek(SeekFrom::Start(0));
let scan_bytes = 64 * 1024 * 1024;
let mut buf = vec![0u8; 1024 * 1024];
let mut read_total = 0usize;
// Size the haystack to what will actually be read — the scan cap or
// the file itself, whichever is smaller — so the fill never reallocs
// and a small ISO doesn't reserve the full 64 MiB.
let file_len = f.metadata().map_or(usize::MAX, |m| {
usize::try_from(m.len()).unwrap_or(usize::MAX)
});
let mut haystack = Vec::with_capacity(scan_bytes.min(file_len));
while read_total < scan_bytes {
let n = f.read(&mut buf).unwrap_or(0);
if n == 0 {
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")
{ {
let mut cr = CachingReadAt::new(r);
if iso_fs::exists(&mut cr, "/sources/boot.wim").await {
report.has_boot_wim = true; report.has_boot_wim = true;
report.family = DistroFamily::WindowsPe; report.family = DistroFamily::WindowsPe;
} else {
for probe in LINUX_PROBES {
if !iso_fs::exists(&mut cr, probe.kernel).await {
continue;
}
let mut initrd = None;
for cand in probe.initrd_candidates {
if iso_fs::exists(&mut cr, cand).await {
initrd = Some((*cand).to_string());
break;
}
}
let Some(initrd) = initrd else { continue };
// Content beats label: a rebadged derivative (volume
// label "ISE-3.2") with the anaconda layout is
// RHEL-family no matter what the label says.
report.family = probe.family;
let coreos = probe.family == DistroFamily::RhelFedora
&& iso_fs::exists(&mut cr, COREOS_ROOTFS).await;
if probe.emit_kernel && !coreos {
report.kernel_path = Some(probe.kernel.to_string());
report.initrd_paths = vec![initrd];
}
break;
}
}
} }
// v0.5.8: broaden Windows detection. Modern Windows 10/11 ISOs are // Modern Windows 10/11 ISOs are UDF — their tree is invisible to the
// UDF — filenames are stored as UTF-16 (so the ASCII scan above misses // ISO9660 walk and the volume label is a cryptic Microsoft string.
// them) and the volume label is a cryptic Microsoft string (so // Scan the first 16 MiB for well-known markers, ASCII and UTF-16LE.
// `family_from_label` misses it too). Booting is via HTTP sanboot of // Runs after the Linux probes so a Linux ISO that *contains* the
// the raw ISO (no boot.wim extraction), so we only need the *family*. // string "bootmgr" (GRUB/syslinux chainload modules do) has already
// Catch the common cases: well-known Windows markers in either ASCII // classified and never reaches this — that ordering is the v0.7.4
// or UTF-16LE within the first 16 MiB, plus a filename hint. // gparted-misdetection fix.
if report.family == DistroFamily::Unknown { if report.family == DistroFamily::Unknown && allow_bulk_scan {
let head = &haystack[..haystack.len().min(16 * 1024 * 1024)]; if let Some(win) = bulk_windows_scan(r, total_len).await {
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;
report.has_boot_wim = win;
} }
} }
// Best-effort kernel/initrd path guess from family. These paths are what // Last resort: filename tokens. The only signal for SMB-sourced ISOs
// distro ISOs conventionally ship at — we don't verify extraction here; // and renamed/UDF images that defeated everything above.
// that happens in the store after introspection. if report.family == DistroFamily::Unknown {
let (k, i) = guess_kernel_initrd(report.family); report.family = family_from_filename(filename);
report.kernel_path = k.map(str::to_string); }
report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
report report
} }
/// Provisional report for a remote ISO that hasn't been (or can't be)
/// content-probed yet: family from the filename, `introspect_rev` left
/// at 0 so the entry generator keeps the optimistic sanboot entry and
/// the UI shows it as awaiting introspection. Used by all three share
/// managers at registration; NFS/SFTP upgrade it in the background.
#[must_use]
pub fn provisional_report(filename: &str) -> IntrospectionReport {
IntrospectionReport {
family: family_from_filename(filename),
..Default::default()
}
}
fn family_from_label(label: &str) -> DistroFamily { fn family_from_label(label: &str) -> DistroFamily {
let l = label.to_ascii_lowercase(); let l = label.to_ascii_lowercase();
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") { if l.contains("ubuntu")
|| l.contains("debian")
|| l.contains("mint")
|| l.contains("kali")
|| l.contains("gparted")
|| l.contains("clonezilla")
{
DistroFamily::DebianUbuntu DistroFamily::DebianUbuntu
} else if l.contains("rhel") } else if l.contains("rhel")
|| l.contains("centos") || l.contains("centos")
|| l.contains("fedora") || l.contains("fedora")
|| l.contains("rocky") || l.contains("rocky")
|| l.contains("alma") || l.contains("alma")
|| l.contains("rhcos")
|| l.contains("coreos")
|| l.contains("openshift")
|| l.contains("okd")
{ {
DistroFamily::RhelFedora DistroFamily::RhelFedora
} else if l.contains("suse") || l.contains("opensuse") { } else if l.contains("suse") || l.contains("opensuse") {
@@ -188,23 +331,91 @@ fn family_from_label(label: &str) -> DistroFamily {
} }
} }
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) { /// Filename token heuristic — `AlmaLinux-9.5-x86_64-dvd.iso` says what
match family { /// it is even when we can't read a byte of it. Tokens are the filename
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]), /// split on every non-alphanumeric character, so "almalinux", "rhel",
DistroFamily::RhelFedora => ( /// "win11" match without "search" tripping the "arch" token.
Some("/images/pxeboot/vmlinuz"), pub fn family_from_filename(filename: &str) -> DistroFamily {
vec!["/images/pxeboot/initrd.img"], if filename_looks_windows(filename) {
), return DistroFamily::WindowsPe;
DistroFamily::OpenSuse => ( }
Some("/boot/x86_64/loader/linux"), let lower = filename.to_ascii_lowercase();
vec!["/boot/x86_64/loader/initrd"], let tokens: Vec<&str> = lower
), .split(|c: char| !c.is_ascii_alphanumeric())
DistroFamily::Arch => ( .filter(|t| !t.is_empty())
Some("/arch/boot/x86_64/vmlinuz-linux"), .collect();
vec!["/arch/boot/x86_64/initramfs-linux.img"], let has = |t: &str| tokens.contains(&t);
), if has("ubuntu")
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]), || has("debian")
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()), || has("mint")
|| has("kali")
|| has("gparted")
|| has("clonezilla")
|| has("tails")
{
DistroFamily::DebianUbuntu
} else if has("rhel")
|| has("centos")
|| has("almalinux")
|| has("alma")
|| has("rocky")
|| has("rockylinux")
|| has("fedora")
|| has("rhcos")
|| has("coreos")
|| has("openshift")
|| has("okd")
{
DistroFamily::RhelFedora
} else if has("opensuse") || has("suse") || has("sles") {
DistroFamily::OpenSuse
} else if has("arch") || has("archlinux") || has("manjaro") {
DistroFamily::Arch
} else if has("alpine") {
DistroFamily::Alpine
} else {
DistroFamily::Unknown
}
}
/// Scan the first 16 MiB (or the whole image when smaller) for Windows
/// markers. Returns `Some(has_boot_wim)` on a hit, `None` when nothing
/// Windows-shaped is found.
async fn bulk_windows_scan<R: IsoReadAt + Send>(r: &mut R, total_len: u64) -> Option<bool> {
const SCAN_BYTES: u64 = 16 * 1024 * 1024;
const CHUNK: u64 = 1024 * 1024;
let budget = SCAN_BYTES.min(total_len);
let mut haystack = Vec::with_capacity(usize::try_from(budget).unwrap_or(0));
let mut offset = 0u64;
while offset < budget {
// Reads are exact-or-error, so clamp the final chunk to what the
// image actually has — netboot.xyz is 2.3 MB, not 16.
let want = u32::try_from(CHUNK.min(budget - offset)).unwrap_or(u32::MAX);
let Ok(chunk) = r.read_at(offset, want).await else {
break; // read error: scan what we have
};
offset += chunk.len() as u64;
haystack.extend_from_slice(&chunk);
}
if haystack.is_empty() {
return None;
}
let boot_wim = contains_ascii(&haystack, b"sources/boot.wim")
|| contains_ascii(&haystack, b"sources\\boot.wim")
|| contains_utf16le_ci(&haystack, "boot.wim");
if boot_wim {
return Some(true);
}
let ascii_markers: [&[u8]; 3] = [b"bootmgr", b"sources/install.wim", b"sources/install.esd"];
let utf16_markers = ["bootmgr", "install.wim", "microsoft"];
let hit = ascii_markers.iter().any(|m| contains_ascii(&haystack, m))
|| utf16_markers
.iter()
.any(|m| contains_utf16le_ci(&haystack, m));
if hit {
Some(false)
} else {
None
} }
} }
@@ -237,14 +448,10 @@ fn contains_utf16le_ci(haystack: &[u8], ascii: &str) -> bool {
/// Filename heuristic: a stock Windows ISO almost always carries an obvious /// Filename heuristic: a stock Windows ISO almost always carries an obvious
/// token in its name (e.g. `..._windows_11_...`, `Win10`, `winserver`). /// token in its name (e.g. `..._windows_11_...`, `Win10`, `winserver`).
/// Used only as a last-resort family hint when the content scan and volume /// v0.7.4: takes the bare filename instead of a `Path` so the same check
/// label are inconclusive. v0.5.8. /// runs against remote share listings.
fn filename_looks_windows(path: &Path) -> bool { fn filename_looks_windows(filename: &str) -> bool {
let name = path let name = filename.to_ascii_lowercase();
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
const TOKENS: [&str; 6] = [ const TOKENS: [&str; 6] = [
"windows", "windows",
"winpe", "winpe",
@@ -263,19 +470,18 @@ const EL_TORITO_ID: &[u8] = b"EL TORITO SPECIFICATION";
/// Detect an El Torito boot catalog — the marker that an ISO is bootable /// Detect an El Torito boot catalog — the marker that an ISO is bootable
/// by BIOS/UEFI firmware (and thus by iPXE `sanboot`). /// by BIOS/UEFI firmware (and thus by iPXE `sanboot`).
/// ///
/// The ISO9660 Volume Descriptor Set starts at LBA 16 (offset 0x8000) and /// The ISO9660 Volume Descriptor Set starts at LBA 16 and runs one
/// runs one 2048-byte descriptor per sector until a Set Terminator /// 2048-byte descriptor per sector until a Set Terminator (type 0xFF).
/// (type 0xFF). A Boot Record descriptor (type 0x00) whose 32-byte boot /// A Boot Record descriptor (type 0x00) whose 32-byte boot system
/// system identifier reads "EL TORITO SPECIFICATION" means the image /// identifier reads "EL TORITO SPECIFICATION" means the image declares a
/// declares an El Torito boot catalog. We only confirm its presence — we /// boot catalog. We only confirm its presence — we don't parse the
/// don't parse the catalog (sanboot/the firmware does that). The walk is /// catalog (sanboot/the firmware does that). The walk is capped so a
/// capped so a malformed/huge image can't spin us. v0.5.9. /// malformed image can't spin us. v0.5.9; reader-generic since v0.7.4.
fn detect_el_torito(f: &mut std::fs::File) -> bool { async fn detect_el_torito<R: IsoReadAt + Send>(r: &mut R) -> bool {
let mut vd = [0u8; 2048];
for lba in 16u64..32 { for lba in 16u64..32 {
if f.seek(SeekFrom::Start(lba * 2048)).is_err() || f.read_exact(&mut vd).is_err() { let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else {
return false; return false;
} };
// Every descriptor in the set carries the "CD001" magic; once it's // Every descriptor in the set carries the "CD001" magic; once it's
// missing we've walked off the end of a valid set. // missing we've walked off the end of a valid set.
if &vd[1..6] != b"CD001" { if &vd[1..6] != b"CD001" {
@@ -297,6 +503,12 @@ fn detect_el_torito(f: &mut std::fs::File) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::iso_fs::testiso::{MemReadAt, TestIsoBuilder};
fn introspect_mem(img: Vec<u8>, filename: &str, bulk: bool) -> IntrospectionReport {
let len = img.len() as u64;
futures::executor::block_on(introspect_reader(&mut MemReadAt(img), len, filename, bulk))
}
#[test] #[test]
fn label_matching() { fn label_matching() {
@@ -313,13 +525,158 @@ mod tests {
DistroFamily::OpenSuse DistroFamily::OpenSuse
); );
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch); assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
assert_eq!(
family_from_label("GParted-live"),
DistroFamily::DebianUbuntu
);
assert_eq!(family_from_label("rhcos-417"), DistroFamily::RhelFedora);
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown); assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
} }
#[test]
fn gparted_shape_is_not_windows() {
// The v0.7.4 regression test: a Debian-live image whose payload
// contains the literal string "bootmgr" (as GRUB/syslinux
// chainload modules do). The old byte-grep classified this as
// WindowsPe; the probe order must classify Debian first.
let img = TestIsoBuilder::new("GParted-live")
.el_torito(true)
.file("/live/vmlinuz", b"KERNEL")
.file("/live/initrd.img", b"INITRD")
.file("/boot/grub/chain.mod", b"xxx bootmgr xxx")
.build();
let r = introspect_mem(img, "gparted-live-1.8.1-3-amd64.iso", true);
assert_eq!(r.family, DistroFamily::DebianUbuntu);
// Classification only — live-boot args aren't rendered yet, so no
// kernel entry; sanboot (via el_torito) keeps working.
assert!(r.kernel_path.is_none());
assert!(r.el_torito);
assert_eq!(r.introspect_rev, INTROSPECT_REV);
}
#[test]
fn casper_shape_verifies_kernel_and_initrd() {
let img = TestIsoBuilder::new("Ubuntu-Server 24.04.1 LTS amd64")
.el_torito(true)
.file("/casper/vmlinuz", b"K")
.file("/casper/initrd", b"I")
.build();
let r = introspect_mem(img, "ubuntu-24.04.1-live-server-amd64.iso", true);
assert_eq!(r.family, DistroFamily::DebianUbuntu);
assert_eq!(r.kernel_path.as_deref(), Some("/casper/vmlinuz"));
assert_eq!(r.initrd_paths, vec!["/casper/initrd".to_string()]);
}
#[test]
fn anaconda_shape_emits_verified_paths() {
let img = TestIsoBuilder::new("AlmaLinux-9-5-x86_64-dvd")
.el_torito(true)
.file("/images/pxeboot/vmlinuz", b"K")
.file("/images/pxeboot/initrd.img", b"I")
.build();
let r = introspect_mem(img, "AlmaLinux-9.5-x86_64-dvd.iso", true);
assert_eq!(r.family, DistroFamily::RhelFedora);
assert_eq!(r.kernel_path.as_deref(), Some("/images/pxeboot/vmlinuz"));
}
#[test]
fn coreos_shape_classifies_but_keeps_sanboot() {
// RHCOS / OpenShift agent ISOs: anaconda layout + rootfs.img.
// Direct kernel boot needs coreos.live.rootfs_url (and agent
// ISOs their embedded ignition), so kernel_path must stay None.
let img = TestIsoBuilder::new("rhcos-417.94.202501")
.el_torito(true)
.file("/images/pxeboot/vmlinuz", b"K")
.file("/images/pxeboot/initrd.img", b"I")
.file("/images/pxeboot/rootfs.img", b"R")
.build();
let r = introspect_mem(img, "rhcos-live.x86_64.iso", true);
assert_eq!(r.family, DistroFamily::RhelFedora);
assert!(
r.kernel_path.is_none(),
"CoreOS must not get a kernel entry"
);
assert!(r.el_torito);
}
#[test]
fn boot_wim_probe_classifies_windows() {
let img = TestIsoBuilder::new("CCCOMA_X64FRE_EN-US_DV9")
.el_torito(true)
.file("/sources/boot.wim", b"MSWIMMSWIM")
.build();
let r = introspect_mem(img, "whatever.iso", false);
assert_eq!(r.family, DistroFamily::WindowsPe);
assert!(r.has_boot_wim);
}
#[test]
fn label_only_linux_without_verified_kernel_gets_no_kernel_path() {
// Label says RHEL but the tree has no pxeboot files — the old
// code guessed `/images/pxeboot/vmlinuz` and emitted an entry
// that 404'd at boot. Now: family yes, kernel paths no.
let img = TestIsoBuilder::new("RHEL-9-5-CUSTOM")
.el_torito(true)
.file("/readme.txt", b"hi")
.build();
let r = introspect_mem(img, "rhel-custom.iso", true);
assert_eq!(r.family, DistroFamily::RhelFedora);
assert!(r.kernel_path.is_none());
assert!(r.initrd_paths.is_empty());
}
#[test]
fn remote_skips_bulk_scan_but_filename_still_hints() {
// No ISO9660 signatures at all (e.g. pure-UDF image read over a
// share), bulk scan off: filename is the only signal.
let img = vec![0u8; 64 * 1024];
let r = introspect_mem(img.clone(), "Win11_24H2_English_x64.iso", false);
assert_eq!(r.family, DistroFamily::WindowsPe);
let r2 = introspect_mem(img, "mystery.iso", false);
assert_eq!(r2.family, DistroFamily::Unknown);
}
#[test]
fn filename_family_table() {
use DistroFamily::*;
let cases = [
("AlmaLinux-9.5-x86_64-dvd.iso", RhelFedora),
("CentOS-Stream-10-latest-x86_64-dvd1.iso", RhelFedora),
("rhel-9.0-x86_64-boot.iso", RhelFedora),
("rhcos-live.x86_64.iso", RhelFedora),
("openshift-4-21-9.agent.x86_64.iso", RhelFedora),
("ubuntu-24.04-desktop.iso", DebianUbuntu),
("gparted-live-1.8.1-3-amd64.iso", DebianUbuntu),
("archlinux-2026.05.01-x86_64.iso", Arch),
("arch-2026.05.01.iso", Arch),
("alpine-standard-3.21.0-x86_64.iso", Alpine),
("openSUSE-Leap-15.6-DVD-x86_64.iso", OpenSuse),
("en-us_windows_11_iot_enterprise.iso", WindowsPe),
("Win10_22H2_English_x64.iso", WindowsPe),
("netboot.xyz.iso", Unknown),
("ise-3.2.0.542a.SPA.x86_64.iso", Unknown),
("Macrium_5860_v2.iso", Unknown),
// "search" must not trip the "arch" token.
("research-data.iso", Unknown),
];
for (name, want) in cases {
assert_eq!(family_from_filename(name), want, "{name}");
}
}
#[test]
fn provisional_report_keeps_rev_zero() {
let r = provisional_report("rhel-9.0-x86_64-dvd.iso");
assert_eq!(r.family, DistroFamily::RhelFedora);
assert_eq!(
r.introspect_rev, 0,
"provisional must keep optimistic sanboot"
);
assert!(!r.el_torito);
}
#[test] #[test]
fn utf16le_marker_matches_case_insensitively() { 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 s = "BOOT.WIM";
let utf16: Vec<u8> = s.bytes().flat_map(|b| [b, 0]).collect(); let utf16: Vec<u8> = s.bytes().flat_map(|b| [b, 0]).collect();
let mut hay = vec![0u8; 8]; let mut hay = vec![0u8; 8];
@@ -328,59 +685,41 @@ mod tests {
assert!(contains_utf16le_ci(&hay, "boot.wim")); assert!(contains_utf16le_ci(&hay, "boot.wim"));
assert!(contains_utf16le_ci(&hay, "Boot.Wim")); assert!(contains_utf16le_ci(&hay, "Boot.Wim"));
assert!(!contains_utf16le_ci(&hay, "install.wim")); assert!(!contains_utf16le_ci(&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")); assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim"));
} }
#[test] #[test]
fn el_torito_boot_catalog_detected() { fn el_torito_detected_through_reader() {
let dir = tempfile::tempdir().unwrap(); let with = TestIsoBuilder::new("BOOTABLE").el_torito(true).build();
// Helper: stamp a 2048-byte descriptor at `lba` with type + magic. let without = TestIsoBuilder::new("DATA").build();
let stamp = |img: &mut [u8], lba: usize, ty: u8| { assert!(futures::executor::block_on(detect_el_torito(
let off = lba * 2048; &mut MemReadAt(with)
img[off] = ty; )));
img[off + 1..off + 6].copy_from_slice(b"CD001"); assert!(!futures::executor::block_on(detect_el_torito(
}; &mut MemReadAt(without)
)));
// 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] #[test]
fn filename_hint_catches_windows_isos() { fn filename_hint_catches_windows_isos() {
use std::path::Path; assert!(filename_looks_windows(
assert!(filename_looks_windows(Path::new(
"en-us_windows_11_iot_enterprise_ltsc_2024_x64_dvd.iso" "en-us_windows_11_iot_enterprise_ltsc_2024_x64_dvd.iso"
))); ));
assert!(filename_looks_windows(Path::new( assert!(filename_looks_windows("Win10_22H2_English_x64.iso"));
"Win10_22H2_English_x64.iso" assert!(filename_looks_windows("winserver2022.iso"));
))); assert!(!filename_looks_windows("ubuntu-24.04-desktop.iso"));
assert!(filename_looks_windows(Path::new("winserver2022.iso"))); assert!(!filename_looks_windows("Rocky-9.4-x86_64-dvd.iso"));
assert!(!filename_looks_windows(Path::new( }
"ubuntu-24.04-desktop.iso"
))); #[test]
assert!(!filename_looks_windows(Path::new( fn bulk_scan_catches_udf_windows_markers() {
"Rocky-9.4-x86_64-dvd.iso" // A blob with no ISO9660 tree but a UTF-16 "install.wim" — the
))); // UDF Windows shape after every probe missed.
let mut img = vec![0u8; 256 * 1024];
let marker: Vec<u8> = "install.wim".bytes().flat_map(|b| [b, 0]).collect();
img[100_000..100_000 + marker.len()].copy_from_slice(&marker);
let r = introspect_mem(img, "renamed.iso", true);
assert_eq!(r.family, DistroFamily::WindowsPe);
assert!(!r.has_boot_wim);
} }
} }
+568
View File
@@ -0,0 +1,568 @@
//! Read-only ISO9660 lookup over any random-access byte source.
//!
//! v0.7.4: generalized from the http-api crate's local-file-only walker so
//! the same directory walk drives three consumers:
//!
//! 1. `iso_file` HTTP serving — locate `/casper/vmlinuz` inside a local
//! *or remote* (NFS/SFTP) ISO and stream just that byte range.
//! 2. Introspection — probe for well-known kernel/initrd/boot.wim paths
//! instead of grepping raw sectors for filename strings (which
//! false-positived: any Linux ISO shipping GRUB/syslinux chainload
//! modules contains the literal "bootmgr" and used to classify as
//! Windows).
//! 3. Remote introspection — the same probes over an NFSv3 READ3 /
//! SFTP seek-read connection, which is what finally classifies
//! share-sourced ISOs instead of registering them all as `Unknown`.
//!
//! We parse only the Primary Volume Descriptor namespace. Joliet and Rock
//! Ridge are deliberately ignored — matching is case-insensitive against
//! plain ISO9660 identifiers (`;1` version suffix and the trailing dot of
//! extension-less strict-mastered names stripped), which is how the local
//! serving path has always behaved in production.
use std::collections::HashMap;
use std::future::Future;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
pub const SECTOR: u64 = 2048;
/// Upper bound on a single directory extent we'll buffer. Real distro ISO
/// directories are a handful of KiB; the cap keeps a malformed or hostile
/// image from asking us to allocate gigabytes.
const MAX_DIR_BYTES: u64 = 4 * 1024 * 1024;
/// Byte range of one file inside the ISO image.
#[derive(Debug, Clone)]
pub struct FileLocation {
pub offset: u64,
pub length: u64,
}
/// Random-access reads into an ISO image. Implemented by a local
/// `std::fs::File`, the NFS and SFTP share readers, and the in-memory
/// test image.
///
/// The contract is `read_exact`-like: the returned buffer is exactly
/// `len` bytes or the call errors. The future must be `Send` because
/// remote introspection runs inside spawned tokio tasks.
pub trait IsoReadAt {
fn read_at(
&mut self,
offset: u64,
len: u32,
) -> impl Future<Output = std::io::Result<Vec<u8>>> + Send;
}
/// Local-file reader. The reads are synchronous inside an async fn —
/// callers run it either on the blocking pool (introspection at upload)
/// or through [`lookup_local`]'s `block_on`, never on a hot runtime
/// worker with real awaits pending.
pub struct FileReadAt(std::fs::File);
impl FileReadAt {
#[must_use]
pub fn new(f: std::fs::File) -> Self {
Self(f)
}
}
impl IsoReadAt for FileReadAt {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
self.0.seek(SeekFrom::Start(offset))?;
let mut buf = vec![0u8; len as usize];
self.0.read_exact(&mut buf)?;
Ok(buf)
}
}
/// Exact-key read cache for the probe phase of introspection. The probe
/// table looks up ~20 paths and every one of them re-reads the root
/// directory (and usually one shared subdirectory); over NFS/SFTP that
/// would be 20 identical round-trips. Directory reads repeat with the
/// exact same `(offset, len)`, so a plain map keyed on the pair hits
/// every time. Large data reads bypass the cache.
pub struct CachingReadAt<'a, R: IsoReadAt + Send> {
inner: &'a mut R,
cache: HashMap<(u64, u32), Vec<u8>>,
}
/// Don't cache reads bigger than this (file payloads, bulk scans).
const CACHE_MAX_READ: u32 = 256 * 1024;
/// Bound the cache so a pathological image can't grow it unbounded.
const CACHE_MAX_ENTRIES: usize = 256;
impl<'a, R: IsoReadAt + Send> CachingReadAt<'a, R> {
pub fn new(inner: &'a mut R) -> Self {
Self {
inner,
cache: HashMap::new(),
}
}
}
impl<R: IsoReadAt + Send> IsoReadAt for CachingReadAt<'_, R> {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
let key = (offset, len);
if let Some(hit) = self.cache.get(&key) {
return Ok(hit.clone());
}
let buf = self.inner.read_at(offset, len).await?;
if len <= CACHE_MAX_READ && self.cache.len() < CACHE_MAX_ENTRIES {
self.cache.insert(key, buf.clone());
}
Ok(buf)
}
}
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in
/// the image behind `r`. Returns `None` on any parsing or IO failure —
/// "not found" and "couldn't read" are the same answer to a prober.
pub async fn lookup<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> Option<FileLocation> {
let pvd = r.read_at(16 * SECTOR, 2048).await.ok()?;
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
return None;
}
// Root directory record at PVD offset 156, 34 bytes.
let (mut lba, mut len) = parse_dir_record_ext(&pvd[156..156 + 34])?;
let components: Vec<&str> = in_iso_path
.trim_start_matches('/')
.split('/')
.filter(|c| !c.is_empty())
.collect();
if components.is_empty() {
return None;
}
// The original walk was tail-recursive; iterate instead so the future
// stays a plain (non-boxed) state machine.
for (idx, comp) in components.iter().enumerate() {
if len == 0 || len > MAX_DIR_BYTES {
return None;
}
let dir = r.read_at(lba * SECTOR, len as u32).await.ok()?;
let hit = scan_dir(&dir, comp)?;
let last = idx + 1 == components.len();
match (last, hit.is_dir) {
(true, false) => {
return Some(FileLocation {
offset: hit.lba * SECTOR,
length: hit.len,
})
}
(false, true) => {
lba = hit.lba;
len = hit.len;
}
_ => return None,
}
}
None
}
/// Convenience probe: does `in_iso_path` exist as a file?
pub async fn exists<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> bool {
lookup(r, in_iso_path).await.is_some()
}
/// Synchronous wrapper for local files — the shape the HTTP handler's
/// `spawn_blocking` call site wants. `block_on` is safe here because
/// `FileReadAt`'s reads never actually await (they complete inline), so
/// the executor never parks.
#[must_use]
pub fn lookup_local(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
let f = std::fs::File::open(iso_path).ok()?;
futures::executor::block_on(lookup(&mut FileReadAt::new(f), in_iso_path))
}
struct DirHit {
lba: u64,
len: u64,
is_dir: bool,
}
/// Scan one directory extent for an identifier. Pure function over the
/// buffered extent — all protocol/IO concerns live in the caller.
fn scan_dir(dir: &[u8], target: &str) -> Option<DirHit> {
let mut i = 0;
while i < dir.len() {
let len = dir[i] as usize;
if len == 0 {
// Records never span sectors; a zero length byte means the
// rest of this sector is padding. Hop to the next one.
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
if next <= i {
break;
}
i = next;
continue;
}
if i + len > dir.len() {
break;
}
let rec = &dir[i..i + len];
let name = dir_record_name(rec);
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
let is_pseudo =
rec.get(32).copied() == Some(1) && matches!(rec.get(33).copied(), Some(0x00 | 0x01));
if !is_pseudo && name.eq_ignore_ascii_case(target) {
let (lba, dlen) = parse_dir_record_ext(rec)?;
return Some(DirHit {
lba,
len: dlen,
is_dir,
});
}
i += len;
}
None
}
/// Extract (extent LBA, data length in bytes) from a directory record.
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
/// data length (LE+BE duplicate). We trust the little-endian copy.
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
if rec.len() < 34 {
return None;
}
let lba = u64::from(u32::from_le_bytes(rec[2..6].try_into().ok()?));
let len = u64::from(u32::from_le_bytes(rec[10..14].try_into().ok()?));
Some((lba, len))
}
/// Extract the identifier from a directory record, normalizing ISO9660
/// quirks: the `;N` version suffix and the trailing dot that strict
/// mastering appends to extension-less names (`VMLINUZ.;1`). Without the
/// dot strip, level-1 images' kernels never matched `/casper/vmlinuz`.
fn dir_record_name(rec: &[u8]) -> String {
let name_len = *rec.get(32).unwrap_or(&0) as usize;
if name_len == 0 || rec.len() < 33 + name_len {
return String::new();
}
let raw = &rec[33..33 + name_len];
let s = String::from_utf8_lossy(raw);
let s = s.rfind(';').map_or_else(|| s.as_ref(), |i| &s[..i]);
s.strip_suffix('.').unwrap_or(s).to_string()
}
// ── test support ─────────────────────────────────────────────────────
//
// A tiny ISO9660 image builder used by this module's tests, the
// introspection tests, and (behind the `test-image` feature) other
// crates' integration tests. Lays out: PVD @ LBA 16, optional El Torito
// boot record @ 17, set terminator @ 18, directories from LBA 20, file
// data after. Only what `lookup`/introspection read is populated.
#[cfg(any(test, feature = "test-image"))]
#[doc(hidden)]
pub mod testiso {
use super::SECTOR;
use std::collections::BTreeMap;
#[derive(Default)]
struct Node {
children: BTreeMap<String, Node>,
content: Option<Vec<u8>>,
}
pub struct TestIsoBuilder {
root: Node,
volume_label: String,
el_torito: bool,
}
impl TestIsoBuilder {
pub fn new(volume_label: &str) -> Self {
Self {
root: Node::default(),
volume_label: volume_label.to_string(),
el_torito: false,
}
}
#[must_use]
pub fn el_torito(mut self, on: bool) -> Self {
self.el_torito = on;
self
}
/// Add a file at `path` (e.g. "/casper/vmlinuz") with `content`.
#[must_use]
pub fn file(mut self, path: &str, content: &[u8]) -> Self {
let mut node = &mut self.root;
let comps: Vec<&str> = path
.trim_start_matches('/')
.split('/')
.filter(|c| !c.is_empty())
.collect();
for (i, c) in comps.iter().enumerate() {
node = node.children.entry((*c).to_string()).or_default();
if i + 1 == comps.len() {
node.content = Some(content.to_vec());
}
}
self
}
pub fn build(self) -> Vec<u8> {
// Pass 1: allocate extents. Directories first (1 sector each),
// then file contents.
let mut next_lba: u64 = 20;
let mut dirs: Vec<(*const Node, u64)> = Vec::new();
fn alloc_dirs(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64)>) {
out.push((std::ptr::from_ref(n), *next));
*next += 1;
for child in n.children.values() {
if child.content.is_none() {
alloc_dirs(child, next, out);
}
}
}
alloc_dirs(&self.root, &mut next_lba, &mut dirs);
let lba_of = |n: &Node| -> u64 {
dirs.iter()
.find(|(p, _)| std::ptr::eq(*p, n))
.map(|(_, l)| *l)
.expect("dir allocated")
};
let mut file_lbas: Vec<(*const Node, u64, usize)> = Vec::new();
fn alloc_files(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64, usize)>) {
for child in n.children.values() {
if let Some(c) = &child.content {
out.push((std::ptr::from_ref(child), *next, c.len()));
*next += c.len().div_ceil(SECTOR as usize).max(1) as u64;
} else {
alloc_files(child, next, out);
}
}
}
alloc_files(&self.root, &mut next_lba, &mut file_lbas);
let file_lba_of = |n: &Node| -> u64 {
file_lbas
.iter()
.find(|(p, _, _)| std::ptr::eq(*p, n))
.map(|(_, l, _)| *l)
.expect("file allocated")
};
let total = next_lba as usize * SECTOR as usize;
let mut img = vec![0u8; total];
// Directory record encoder.
fn record(name_bytes: &[u8], lba: u64, len: u64, is_dir: bool) -> Vec<u8> {
let mut rec_len = 33 + name_bytes.len();
if rec_len % 2 == 1 {
rec_len += 1; // pad to even
}
let rec_len = rec_len.max(34);
let mut r = vec![0u8; rec_len];
r[0] = rec_len as u8;
r[2..6].copy_from_slice(&(lba as u32).to_le_bytes());
r[6..10].copy_from_slice(&(lba as u32).to_be_bytes());
r[10..14].copy_from_slice(&(len as u32).to_le_bytes());
r[14..18].copy_from_slice(&(len as u32).to_be_bytes());
if is_dir {
r[25] = 0x02;
}
r[32] = name_bytes.len() as u8;
r[33..33 + name_bytes.len()].copy_from_slice(name_bytes);
r
}
// Pass 2: write each directory extent.
fn write_dir(
img: &mut [u8],
n: &Node,
self_lba: u64,
parent_lba: u64,
lba_of: &dyn Fn(&Node) -> u64,
file_lba_of: &dyn Fn(&Node) -> u64,
) {
let base = self_lba as usize * SECTOR as usize;
let mut off = 0usize;
let mut put = |rec: Vec<u8>, off: &mut usize| {
img[base + *off..base + *off + rec.len()].copy_from_slice(&rec);
*off += rec.len();
};
put(record(&[0x00], self_lba, SECTOR, true), &mut off);
put(record(&[0x01], parent_lba, SECTOR, true), &mut off);
for (name, child) in &n.children {
if let Some(c) = &child.content {
// Files get the ISO9660 uppercase `;1` treatment so
// the case-insensitive + version-strip matching is
// what the tests actually exercise.
let stored = format!("{};1", name.to_ascii_uppercase());
put(
record(stored.as_bytes(), file_lba_of(child), c.len() as u64, false),
&mut off,
);
} else {
let stored = name.to_ascii_uppercase();
put(
record(stored.as_bytes(), lba_of(child), SECTOR, true),
&mut off,
);
}
}
for (name, child) in &n.children {
if child.content.is_none() {
write_dir(img, child, lba_of(child), self_lba, lba_of, file_lba_of);
} else if let Some(c) = &child.content {
let b = file_lba_of(child) as usize * SECTOR as usize;
img[b..b + c.len()].copy_from_slice(c);
}
let _ = name;
}
}
let root_lba = lba_of(&self.root);
write_dir(
&mut img,
&self.root,
root_lba,
root_lba,
&lba_of,
&file_lba_of,
);
// PVD @ 16.
let pvd = 16 * SECTOR as usize;
img[pvd] = 0x01;
img[pvd + 1..pvd + 6].copy_from_slice(b"CD001");
let label = self.volume_label.as_bytes();
let label_field = &mut img[pvd + 40..pvd + 72];
label_field.fill(b' ');
label_field[..label.len().min(32)].copy_from_slice(&label[..label.len().min(32)]);
let root_rec = record(&[0x00], root_lba, SECTOR, true);
img[pvd + 156..pvd + 156 + 34].copy_from_slice(&root_rec[..34]);
// Optional El Torito boot record @ 17, terminator after.
let mut vd = 17 * SECTOR as usize;
if self.el_torito {
img[vd] = 0x00;
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
let id = b"EL TORITO SPECIFICATION";
img[vd + 7..vd + 7 + id.len()].copy_from_slice(id);
vd += SECTOR as usize;
}
img[vd] = 0xFF;
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
img
}
}
/// In-memory `IsoReadAt` over a built test image.
pub struct MemReadAt(pub Vec<u8>);
impl super::IsoReadAt for MemReadAt {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
let start = usize::try_from(offset).unwrap_or(usize::MAX);
let end = start.saturating_add(len as usize);
if end > self.0.len() {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"read past end of test image",
));
}
Ok(self.0[start..end].to_vec())
}
}
}
#[cfg(test)]
mod tests {
use super::testiso::{MemReadAt, TestIsoBuilder};
use super::*;
fn block_on<T>(f: impl Future<Output = T>) -> T {
futures::executor::block_on(f)
}
#[test]
fn lookup_finds_nested_file_case_insensitively() {
let img = TestIsoBuilder::new("UBUNTU 24.04")
.file("/casper/vmlinuz", b"KERNELDATA")
.file("/casper/initrd", b"INITRDDATA")
.build();
let mut r = MemReadAt(img);
let loc = block_on(lookup(&mut r, "/CASPER/VMLINUZ")).expect("found");
assert_eq!(loc.length, 10);
let bytes = block_on(r.read_at(loc.offset, 10)).unwrap();
assert_eq!(&bytes, b"KERNELDATA");
// Missing file and missing dir both miss cleanly.
assert!(block_on(lookup(&mut r, "/casper/missing")).is_none());
assert!(block_on(lookup(&mut r, "/nodir/vmlinuz")).is_none());
// A directory path that resolves to a directory is not a file hit.
assert!(block_on(lookup(&mut r, "/casper")).is_none());
}
#[test]
fn strict_mastered_extensionless_names_match() {
// Strict level-1 mastering stores "VMLINUZ" as "VMLINUZ.;1" — the
// trailing dot must be normalized away or kernels never match.
let img = TestIsoBuilder::new("STRICT")
.file("/boot/vmlinuz.", b"K") // builder stores "VMLINUZ.;1"
.build();
let mut r = MemReadAt(img);
assert!(
block_on(lookup(&mut r, "/boot/vmlinuz")).is_some(),
"trailing-dot ISO9660 name must match the dotless path"
);
}
#[test]
fn lookup_three_levels_deep() {
let img = TestIsoBuilder::new("DEEP")
.file("/images/pxeboot/vmlinuz", b"ANACONDA")
.build();
let mut r = MemReadAt(img);
let loc = block_on(lookup(&mut r, "images/pxeboot/vmlinuz")).expect("no leading slash ok");
assert_eq!(loc.length, 8);
}
#[test]
fn caching_reader_dedupes_repeated_directory_reads() {
struct Counting<'a> {
inner: &'a mut MemReadAt,
calls: usize,
}
impl IsoReadAt for Counting<'_> {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
self.calls += 1;
self.inner.read_at(offset, len).await
}
}
let img = TestIsoBuilder::new("CACHE")
.file("/a/one", b"1")
.file("/a/two", b"2")
.build();
let mut mem = MemReadAt(img);
let mut counting = Counting {
inner: &mut mem,
calls: 0,
};
let mut cr = CachingReadAt::new(&mut counting);
assert!(block_on(exists(&mut cr, "/a/one")));
assert!(block_on(exists(&mut cr, "/a/two")));
assert!(!block_on(exists(&mut cr, "/a/three")));
drop(cr);
// 3 probes × (PVD + root dir + subdir) = 9 uncached; the cache
// collapses the repeats to the 3 distinct extents.
assert_eq!(counting.calls, 3, "all repeat reads must hit the cache");
}
#[test]
fn lookup_local_reads_a_real_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("t.iso");
let img = TestIsoBuilder::new("LOCAL")
.file("/sources/boot.wim", b"WIMWIM")
.build();
std::fs::write(&p, &img).unwrap();
let loc = lookup_local(&p, "/sources/boot.wim").expect("found");
assert_eq!(loc.length, 6);
assert!(lookup_local(&p, "/sources/none").is_none());
}
}
+8
View File
@@ -18,8 +18,15 @@
pub mod entry; pub mod entry;
pub mod introspect; pub mod introspect;
// v0.7.4: read-only ISO9660 walker generic over any random-access byte
// source (local file, NFS READ3, SFTP seek-read). Powers both in-ISO
// HTTP serving and the probe-based introspection.
pub mod iso_fs;
pub mod nfs_share; pub mod nfs_share;
pub mod pxe_logo; pub mod pxe_logo;
// v0.7.4: persisted cache of remote-share introspection results so a
// container restart doesn't re-probe an unchanged 40-ISO library.
pub mod remote_cache;
pub mod sftp_share; pub mod sftp_share;
pub mod smb; pub mod smb;
pub mod smb_share; pub mod smb_share;
@@ -29,6 +36,7 @@ pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs}; pub use entry::{BootEntry, BootKind, KernelArgs};
pub use introspect::{DistroFamily, IntrospectionReport}; pub use introspect::{DistroFamily, IntrospectionReport};
pub use iso_fs::FileLocation;
// v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace // v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace
// `smbclient` CLI replaced it — works in any container (no // `smbclient` CLI replaced it — works in any container (no
// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and // CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and
+204 -5
View File
@@ -57,7 +57,9 @@
//! 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::IntrospectionReport; use crate::introspect::{introspect_reader, provisional_report};
use crate::iso_fs::{self, FileLocation, IsoReadAt};
use crate::remote_cache::RemoteIntrospectCache;
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use 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;
@@ -100,6 +102,18 @@ const READ_CHUNK_BYTES: u32 = 64 * 1024;
/// client park gigabytes of decoded ISO in RAM. /// client park gigabytes of decoded ISO in RAM.
const STREAM_BUFFER_DEPTH: usize = 16; const STREAM_BUFFER_DEPTH: usize = 16;
/// v0.7.4: per-ISO budget for a background introspection probe. A probe
/// is one connection plus a few dozen KiB-sized reads — sub-second on a
/// LAN — so anything past this is a wedged server, not a slow one.
const INTROSPECT_TIMEOUT: Duration = Duration::from_secs(30);
/// One queued background-introspection unit (v0.7.4).
struct ProbeJob {
iso_id: String,
filename: String,
size: u64,
}
/// One configured NFS share. The id is derived from server+export so /// One configured NFS share. The id is derived from server+export so
/// re-adding the same coordinates is idempotent. /// re-adding the same coordinates is idempotent.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -177,6 +191,9 @@ pub struct NfsShareManager {
/// opens its own NFS connection so concurrency isn't a hard /// opens its own NFS connection so concurrency isn't a hard
/// requirement, but serializing keeps log output predictable. /// requirement, but serializing keeps log output predictable.
op_lock: Arc<tokio::sync::Mutex<()>>, op_lock: Arc<tokio::sync::Mutex<()>>,
/// v0.7.4: persisted introspection results keyed `share/path@size`,
/// so a restart re-probes only new or replaced ISOs.
introspect_cache: RemoteIntrospectCache,
} }
impl NfsShareManager { impl NfsShareManager {
@@ -190,6 +207,7 @@ impl NfsShareManager {
inner: Arc::new(Mutex::new(Inner::default())), inner: Arc::new(Mutex::new(Inner::default())),
iso_store, iso_store,
op_lock: Arc::new(tokio::sync::Mutex::new(())), op_lock: Arc::new(tokio::sync::Mutex::new(())),
introspect_cache: RemoteIntrospectCache::open(work_dir, "nfs_introspect_cache.json"),
} }
} }
@@ -387,12 +405,26 @@ impl NfsShareManager {
}; };
let mut count = 0u32; let mut count = 0u32;
let mut to_probe: Vec<ProbeJob> = Vec::new();
for entry in listing { for entry in listing {
let iso_id = format!("nfs-{}-{}", share.id, slugify_str(&entry.filename)); let iso_id = format!("nfs-{}-{}", share.id, slugify_str(&entry.filename));
// Same approach as SMB: no real introspection over the // v0.7.4: real introspection over the share — NFSv3 READ3
// network in v0.4.67. The boot-entry generator falls back // takes an offset, so the ISO9660 probes work remotely. A
// to filename-based sanboot detection. // cache hit registers the full report immediately; a miss
let report = IntrospectionReport::default(); // registers a provisional filename-based report (so the scan
// returns fast) and queues a background probe that upgrades
// the entry in place.
let cached = self
.introspect_cache
.get(&share.id, &entry.filename, entry.size);
let report = cached.unwrap_or_else(|| {
to_probe.push(ProbeJob {
iso_id: iso_id.clone(),
filename: entry.filename.clone(),
size: entry.size,
});
provisional_report(&entry.filename)
});
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let 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(),
@@ -413,11 +445,88 @@ impl NfsShareManager {
target: "openpxe::nfs", target: "openpxe::nfs",
id = %id, server = %share.server, export = %share.export, id = %id, server = %share.server, export = %share.export,
iso_count = count, iso_count = count,
pending_introspection = to_probe.len(),
"NFS share scanned" "NFS share scanned"
); );
if !to_probe.is_empty() {
self.spawn_introspection_pass(&share, to_probe);
}
Ok(count) Ok(count)
} }
/// v0.7.4: probe each queued ISO over its own NFS connection and swap
/// the full introspection into the store as results land. Runs
/// detached so neither startup nor the share-add API call waits on a
/// 40-ISO library; per-ISO failures (or a share removed mid-pass)
/// leave the provisional entry in place, which still sanboots.
fn spawn_introspection_pass(&self, share: &NfsShare, work: Vec<ProbeJob>) {
let store = self.iso_store.clone();
let cache = self.introspect_cache.clone();
let share_id = share.id.clone();
let server = share.server.clone();
let export = share.export.clone();
let port = share.port;
let queued = work.len();
tokio::spawn(async move {
let mut upgraded = 0usize;
for job in work {
let probe = async {
let mut reader = NfsReadAt::open(&server, &export, port, &job.filename).await?;
let report =
introspect_reader(&mut reader, job.size, &job.filename, false).await;
reader.finish().await;
Ok::<_, NfsClientError>(report)
};
match tokio::time::timeout(INTROSPECT_TIMEOUT, probe).await {
Ok(Ok(report)) => {
cache.put(&share_id, &job.filename, job.size, report.clone());
if store.update_external_introspection(&job.iso_id, report) {
upgraded += 1;
}
}
Ok(Err(e)) => tracing::warn!(
target: "openpxe::nfs",
share = %share_id, iso = %job.filename,
"introspection failed: {e}"
),
Err(_) => tracing::warn!(
target: "openpxe::nfs",
share = %share_id, iso = %job.filename,
"introspection timed out after {}s", INTROSPECT_TIMEOUT.as_secs()
),
}
}
tracing::info!(
target: "openpxe::nfs",
share = %share_id, queued, upgraded,
"remote introspection pass complete"
);
});
}
/// v0.7.4: locate `in_iso_path` inside a share-hosted ISO. Returns the
/// byte range so the HTTP layer can serve kernel/initrd files out of
/// remote ISOs with a follow-up ranged [`Self::stream_iso`].
pub async fn locate_in_iso(
&self,
share_id: &str,
filename: &str,
in_iso_path: &str,
) -> Result<Option<FileLocation>> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such NFS share '{share_id}'")))?;
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
}
let mut reader = NfsReadAt::open(&share.server, &share.export, share.port, filename)
.await
.map_err(|e| Error::Invalid(format!("nfs open '{filename}': {e}")))?;
let loc = iso_fs::lookup(&mut reader, in_iso_path).await;
reader.finish().await;
Ok(loc)
}
fn update_status( fn update_status(
&self, &self,
id: &str, id: &str,
@@ -490,6 +599,96 @@ struct NfsListEntry {
size: u64, size: u64,
} }
/// The connection type [`build_connection`] yields.
type NfsConn = nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>;
/// v0.7.4: random-access reader over one NFS connection + file handle —
/// the [`IsoReadAt`] impl that lets the ISO9660 walker and introspection
/// probes run against share-hosted images.
struct NfsReadAt {
conn: NfsConn,
fh: nfs_fh3,
}
impl NfsReadAt {
/// Connect, mount, and LOOKUP `filename` at the export root.
async fn open(
server: &str,
export: &str,
port: u16,
filename: &str,
) -> std::result::Result<Self, NfsClientError> {
let mut conn = build_connection(server, export, port).await?;
let root = conn.root_nfs_fh3();
let lookup = conn
.lookup(&LOOKUP3args {
what: diropargs3 {
dir: root,
name: filename3(Opaque::borrowed(filename.as_bytes())),
},
})
.await
.map_err(NfsClientError::Rpc)?;
let fh = match lookup {
Nfs3Result::Ok(o) => o.object,
Nfs3Result::Err((status, _)) => {
return Err(NfsClientError::Nfsstat(status_label(status)));
}
};
Ok(Self { conn, fh })
}
/// Best-effort unmount. Consumes the reader — it's done.
async fn finish(self) {
let _ = self.conn.unmount().await;
}
}
impl IsoReadAt for NfsReadAt {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
let mut out: Vec<u8> = Vec::with_capacity(len as usize);
let mut off = offset;
// READ3 may legally return fewer bytes than asked (server cap);
// loop until the exact-read contract is satisfied or the file
// genuinely ends short.
while (out.len() as u32) < len {
let want = (len - out.len() as u32).min(READ_CHUNK_BYTES);
let res = self
.conn
.read(&READ3args {
file: self.fh.clone(),
offset: off,
count: want,
})
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let ok = match res {
Nfs3Result::Ok(o) => o,
Nfs3Result::Err((status, _)) => {
return Err(std::io::Error::other(status_label(status)));
}
};
let data = ok.data.as_ref();
if data.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"NFS read past end of file",
));
}
out.extend_from_slice(data);
off += data.len() as u64;
if ok.eof && (out.len() as u32) < len {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"NFS read past end of file",
));
}
}
out.truncate(len as usize);
Ok(out)
}
}
/// Connect, READDIR the export root, look up each `*.iso` to get its /// Connect, READDIR the export root, look up each `*.iso` to get its
/// size + file handle. Returns a flat list. Errors are returned with /// size + file handle. Returns a flat list. Errors are returned with
/// a human-readable message; the caller decides how to surface them. /// a human-readable message; the caller decides how to surface them.
+142
View File
@@ -0,0 +1,142 @@
//! Persisted cache of remote-share introspection results.
//!
//! NFS/SFTP introspection costs a connection plus a few dozen small
//! reads per ISO. Shares are rescanned on every startup and share-add,
//! so without a cache a 40-ISO library would re-probe 40 ISOs on every
//! container restart. The cache keys on `share/path@size` — a replaced
//! file (new size) re-probes, an untouched one is free — and entries
//! only count as hits when their `introspect_rev` matches the current
//! logic, so an upgrade that changes detection re-probes everything
//! exactly once.
//!
//! One file per protocol (`nfs_introspect_cache.json`,
//! `sftp_introspect_cache.json`) so the two managers never contend over
//! one writer.
use crate::introspect::{IntrospectionReport, INTROSPECT_REV};
use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Hard cap on cached entries; beyond it the cache resets rather than
/// growing unbounded (a cache wipe only costs one re-probe pass).
const MAX_ENTRIES: usize = 4096;
#[derive(Clone, Debug)]
pub struct RemoteIntrospectCache {
path: Arc<PathBuf>,
map: Arc<Mutex<HashMap<String, IntrospectionReport>>>,
}
impl RemoteIntrospectCache {
/// Open (or start empty) the cache at `<work_dir>/<file_name>`.
/// A corrupt or missing file is an empty cache, never an error.
#[must_use]
pub fn open(work_dir: &Path, file_name: &str) -> Self {
let path = work_dir.join(file_name);
let map = std::fs::read_to_string(&path)
.ok()
.and_then(|text| {
serde_json::from_str::<HashMap<String, IntrospectionReport>>(&text).ok()
})
.unwrap_or_default();
Self {
path: Arc::new(path),
map: Arc::new(Mutex::new(map)),
}
}
fn key(share_id: &str, relative_path: &str, size: u64) -> String {
format!("{share_id}/{relative_path}@{size}")
}
/// A hit requires the entry to have been produced by the *current*
/// introspection logic — stale-rev entries are misses, which is how
/// the cache self-invalidates across upgrades.
#[must_use]
pub fn get(
&self,
share_id: &str,
relative_path: &str,
size: u64,
) -> Option<IntrospectionReport> {
self.map
.lock()
.get(&Self::key(share_id, relative_path, size))
.filter(|r| r.introspect_rev == INTROSPECT_REV)
.cloned()
}
pub fn put(&self, share_id: &str, relative_path: &str, size: u64, report: IntrospectionReport) {
let snapshot = {
let mut g = self.map.lock();
if g.len() >= MAX_ENTRIES {
g.clear();
}
g.insert(Self::key(share_id, relative_path, size), report);
g.clone()
};
// Persist outside the lock; tmp+rename so a crash mid-write
// leaves the previous cache intact.
let path = self.path.as_path();
let tmp = path.with_extension("json.tmp");
let Ok(body) = serde_json::to_vec_pretty(&snapshot) else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::write(&tmp, body).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::introspect::DistroFamily;
#[test]
fn round_trips_across_reopen_and_rev_gates() {
let dir = tempfile::tempdir().unwrap();
let cache = RemoteIntrospectCache::open(dir.path(), "t.json");
assert!(cache.get("s1", "a.iso", 100).is_none());
let fresh = IntrospectionReport {
family: DistroFamily::RhelFedora,
introspect_rev: INTROSPECT_REV,
el_torito: true,
..Default::default()
};
cache.put("s1", "a.iso", 100, fresh.clone());
assert_eq!(
cache.get("s1", "a.iso", 100).unwrap().family,
DistroFamily::RhelFedora
);
// Different size = different file = miss.
assert!(cache.get("s1", "a.iso", 101).is_none());
// Survives a reopen.
let cache2 = RemoteIntrospectCache::open(dir.path(), "t.json");
assert!(cache2.get("s1", "a.iso", 100).is_some());
// Stale-rev entries never hit.
let stale = IntrospectionReport {
family: DistroFamily::Arch,
introspect_rev: INTROSPECT_REV - 1,
..Default::default()
};
cache2.put("s1", "b.iso", 7, stale);
assert!(cache2.get("s1", "b.iso", 7).is_none());
}
#[test]
fn corrupt_cache_file_starts_empty() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("t.json"), b"{nope").unwrap();
let cache = RemoteIntrospectCache::open(dir.path(), "t.json");
assert!(cache.get("s", "x.iso", 1).is_none());
}
}
+150 -6
View File
@@ -56,7 +56,9 @@
//! 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::IntrospectionReport; use crate::introspect::{introspect_reader, provisional_report};
use crate::iso_fs::{self, FileLocation, IsoReadAt};
use crate::remote_cache::RemoteIntrospectCache;
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use 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};
@@ -96,6 +98,18 @@ const READ_CHUNK_BYTES: usize = 64 * 1024;
/// body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream. /// body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream.
const STREAM_BUFFER_DEPTH: usize = 16; const STREAM_BUFFER_DEPTH: usize = 16;
/// v0.7.4: per-ISO budget for a background introspection probe — one SSH
/// connection plus a few dozen KiB-sized reads. SSH handshakes cost more
/// than NFS mounts, but 30s still only trips on a wedged server.
const INTROSPECT_TIMEOUT: Duration = Duration::from_secs(30);
/// One queued background-introspection unit (v0.7.4).
struct ProbeJob {
iso_id: String,
filename: String,
size: u64,
}
/// Which credential the share authenticates with. The secret itself /// Which credential the share authenticates with. The secret itself
/// lives in the 0600 creds file, never here. /// lives in the 0600 creds file, never here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -209,6 +223,9 @@ pub struct SftpShareManager {
/// Serializes scan operations on the same manager for predictable /// Serializes scan operations on the same manager for predictable
/// log output; each scan opens its own SSH connection. /// log output; each scan opens its own SSH connection.
op_lock: Arc<tokio::sync::Mutex<()>>, op_lock: Arc<tokio::sync::Mutex<()>>,
/// v0.7.4: persisted introspection results keyed `share/path@size`,
/// so a restart re-probes only new or replaced ISOs.
introspect_cache: RemoteIntrospectCache,
} }
impl SftpShareManager { impl SftpShareManager {
@@ -222,6 +239,7 @@ impl SftpShareManager {
inner: Arc::new(Mutex::new(Inner::default())), inner: Arc::new(Mutex::new(Inner::default())),
iso_store, iso_store,
op_lock: Arc::new(tokio::sync::Mutex::new(())), op_lock: Arc::new(tokio::sync::Mutex::new(())),
introspect_cache: RemoteIntrospectCache::open(work_dir, "sftp_introspect_cache.json"),
} }
} }
@@ -474,13 +492,25 @@ impl SftpShareManager {
}; };
let mut count = 0u32; let mut count = 0u32;
let mut to_probe: Vec<ProbeJob> = Vec::new();
for entry in listing { for entry in listing {
let iso_id = format!("sftp-{}-{}", share.id, slugify_str(&entry.filename)); let iso_id = format!("sftp-{}-{}", share.id, slugify_str(&entry.filename));
// Same as NFS/SMB: no over-the-network introspection yet, so // v0.7.4: real introspection over the share — SFTP file
// register `Unknown` and let the boot-entry generator fall // handles are seekable, so the ISO9660 probes work remotely.
// back to filename-based detection. SFTP *could* do bounded // Cache hit → full report now; miss → provisional filename-
// PVD reads (it has random access) a follow-up can add it. // based report (scan returns fast) + a queued background
let report = IntrospectionReport::default(); // probe that upgrades the entry in place.
let cached = self
.introspect_cache
.get(&share.id, &entry.filename, entry.size);
let report = cached.unwrap_or_else(|| {
to_probe.push(ProbeJob {
iso_id: iso_id.clone(),
filename: entry.filename.clone(),
size: entry.size,
});
provisional_report(&entry.filename)
});
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let 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(),
@@ -506,11 +536,88 @@ impl SftpShareManager {
target: "openpxe::sftp", target: "openpxe::sftp",
id = %id, server = %share.server, export = %share.export, id = %id, server = %share.server, export = %share.export,
iso_count = count, iso_count = count,
pending_introspection = to_probe.len(),
"SFTP share scanned" "SFTP share scanned"
); );
if !to_probe.is_empty() {
self.spawn_introspection_pass(&share, &creds, to_probe);
}
Ok(count) Ok(count)
} }
/// v0.7.4: probe each queued ISO over its own SSH connection and swap
/// the full introspection into the store as results land. Detached so
/// neither startup nor the share-add API call waits on a big library;
/// per-ISO failures leave the provisional entry, which still sanboots.
fn spawn_introspection_pass(&self, share: &SftpShare, creds: &SftpCreds, work: Vec<ProbeJob>) {
let store = self.iso_store.clone();
let cache = self.introspect_cache.clone();
let share_id = share.id.clone();
let params = ConnParams::from_share(share);
let creds = creds.clone();
let queued = work.len();
tokio::spawn(async move {
let mut upgraded = 0usize;
for job in work {
let probe = async {
let mut reader = SftpReadAt::open(&params, &creds, &job.filename).await?;
let report =
introspect_reader(&mut reader, job.size, &job.filename, false).await;
Ok::<_, SftpClientError>(report)
};
match tokio::time::timeout(INTROSPECT_TIMEOUT, probe).await {
Ok(Ok(report)) => {
cache.put(&share_id, &job.filename, job.size, report.clone());
if store.update_external_introspection(&job.iso_id, report) {
upgraded += 1;
}
}
Ok(Err(e)) => tracing::warn!(
target: "openpxe::sftp",
share = %share_id, iso = %job.filename,
"introspection failed: {e}"
),
Err(_) => tracing::warn!(
target: "openpxe::sftp",
share = %share_id, iso = %job.filename,
"introspection timed out after {}s", INTROSPECT_TIMEOUT.as_secs()
),
}
}
tracing::info!(
target: "openpxe::sftp",
share = %share_id, queued, upgraded,
"remote introspection pass complete"
);
});
}
/// v0.7.4: locate `in_iso_path` inside a share-hosted ISO. Returns the
/// byte range so the HTTP layer can serve kernel/initrd files out of
/// remote ISOs with a follow-up ranged [`Self::stream_iso`].
pub async fn locate_in_iso(
&self,
share_id: &str,
filename: &str,
in_iso_path: &str,
) -> Result<Option<FileLocation>> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such SFTP share '{share_id}'")))?;
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
}
let creds = self
.read_creds(share_id)
.await
.map_err(|e| Error::Invalid(format!("could not read credentials: {e}")))?;
let params = ConnParams::from_share(&share);
let mut reader = SftpReadAt::open(&params, &creds, filename)
.await
.map_err(|e| Error::Invalid(format!("sftp open '{filename}': {e}")))?;
Ok(iso_fs::lookup(&mut reader, in_iso_path).await)
}
fn pin_fingerprint(&self, id: &str, fingerprint: String) { fn pin_fingerprint(&self, id: &str, fingerprint: String) {
if fingerprint.is_empty() { if fingerprint.is_empty() {
return; return;
@@ -652,6 +759,43 @@ struct SftpConn {
sftp: SftpSession, sftp: SftpSession,
} }
/// v0.7.4: random-access reader over one SSH connection + open file
/// handle — the [`IsoReadAt`] impl that lets the ISO9660 walker and
/// introspection probes run against share-hosted images. Holds the
/// `SftpConn` so the SSH session outlives every read.
struct SftpReadAt {
_conn: SftpConn,
file: russh_sftp::client::fs::File,
}
impl SftpReadAt {
async fn open(
p: &ConnParams,
creds: &SftpCreds,
filename: &str,
) -> std::result::Result<Self, SftpClientError> {
let (conn, _fp) = connect(p, creds).await?;
let full = format!("{}/{}", p.export.trim_end_matches('/'), filename);
let file = conn
.sftp
.open(full)
.await
.map_err(|e| SftpClientError::Sftp(e.to_string()))?;
Ok(Self { _conn: conn, file })
}
}
impl IsoReadAt for SftpReadAt {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
self.file.seek(SeekFrom::Start(offset)).await?;
let mut buf = vec![0u8; len as usize];
// read_exact loops over the transport's short reads and fails
// with UnexpectedEof past end-of-file — exactly the contract.
self.file.read_exact(&mut buf).await?;
Ok(buf)
}
}
/// russh client handler implementing trust-on-first-use host-key /// russh client handler implementing trust-on-first-use host-key
/// verification. We never construct an `Err` from `check_server_key`; /// verification. We never construct an `Err` from `check_server_key`;
/// returning `Ok(false)` makes russh abort the handshake, and the /// returning `Ok(false)` makes russh abort the handshake, and the
+9 -10
View File
@@ -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::IntrospectionReport; use crate::introspect::provisional_report;
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;
@@ -455,15 +455,14 @@ impl SmbShareManager {
let mut count = 0u32; let mut count = 0u32;
for entry in listing { for entry in listing {
let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename)); let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename));
// SMB sources don't get a real introspection pass — that // SMB sources can't get the content-probe pass NFS/SFTP got
// would require seeking into the ISO9660 PVD over the // in v0.7.4 — the ISO9660 probes need seeks, and smbclient's
// network, and smbclient CLI doesn't seek. We register an // CLI streaming doesn't seek. The provisional filename-token
// `Unknown` family so the boot-entry generator falls back // report is as far as SMB detection goes: family for the UI
// to generic sanboot/wimboot detection from the filename // when the name says it ("rhel-9.0…", "Win11_…"), and
// and the operator gets *something* bootable. A follow-up // `introspect_rev = 0` so the entry generator keeps the
// release can do a bounded `smbclient get` of the first // optimistic sanboot entry.
// 64 KiB for real detection. let report = provisional_report(&entry.filename);
let report = IntrospectionReport::default();
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(),
+22
View File
@@ -415,6 +415,28 @@ impl IsoStore {
self.inner.write().isos.insert(id, meta); self.inner.write().isos.insert(id, meta);
} }
/// v0.7.4: swap in a completed introspection for an external ISO and
/// regenerate its boot entries. Used by the NFS/SFTP managers'
/// background probe pass — the scan registers a provisional
/// (filename-only) report immediately so startup and share-add stay
/// fast, then this upgrades each entry as its probe finishes.
/// Operator-set fields (category, password) are preserved; returns
/// `false` when the id is gone (share removed or re-scanned away
/// mid-probe), which callers treat as a benign no-op.
pub fn update_external_introspection(
&self,
id: &str,
introspection: IntrospectionReport,
) -> bool {
let mut g = self.inner.write();
let Some(m) = g.isos.get_mut(id) else {
return false;
};
m.boot_entries = generate_boot_entries(&m.id, &m.filename, &introspection);
m.introspection = introspection;
true
}
/// Drop every entry that belongs to `share_id`. Used by the SMB /// Drop every entry that belongs to `share_id`. Used by the SMB
/// and NFS share managers when an operator removes a share, or /// and NFS share managers when an operator removes a share, or
/// before re-scanning to clean out stale entries. The same id /// before re-scanning to clean out stale entries. The same id
+17 -3
View File
@@ -920,6 +920,20 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
the same 16px below its form. */ the same 16px below its form. */
.card .body > label.field:has(+ button) { margin-bottom: 0; } .card .body > label.field:has(+ button) { margin-bottom: 0; }
/* v0.7.2: inline list filter (Available images / Unattended files). */ /* v0.7.2: inline list filter above a table (Available images). The input
.list-search { padding: 14px 16px 0; } is wrapped in a label.field so it borrows the standard text-field chrome
.list-search input { width: 100%; } and matches every other input in the app; this wrapper just insets it
from the card edges so it lines up with the header text above. */
.list-search { padding: 14px 16px; }
/* v0.7.4: pager footer under the Available-images table quiet status
text on the left, ghost Prev/Next on the right. */
.list-pager {
display: flex; align-items: center; gap: 8px;
padding: 12px 16px;
color: var(--fg-dim); font-size: 12px;
font-variant-numeric: tabular-nums;
}
.list-pager .spacer { flex: 1; }
.list-pager button { padding: 4px 12px; font-size: 12px; }
.list-pager button:disabled { opacity: 0.45; cursor: default; }
+75 -50
View File
@@ -178,12 +178,14 @@
if (iso.introspection.el_torito) { if (iso.introspection.el_torito) {
return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' }; return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' };
} }
// Remote-share ISOs aren't introspected (no random access over the // v0.7.4: NFS/SFTP ISOs now introspect over the share, so a probed
// network), so el_torito is unknown — assume bootable and let sanboot // remote ISO flows through the kernel/el_torito branches above like
// try rather than cry wolf. // a local one. introspect_rev 0 means the probe hasn't landed yet
// (it runs in the background right after a scan) or never can (SMB —
// smbclient can't seek): stay optimistic and let sanboot try.
const remote = iso.source && iso.source.kind && iso.source.kind !== 'local'; const remote = iso.source && iso.source.kind && iso.source.kind !== 'local';
if (remote) { if (remote && (iso.introspection.introspect_rev || 0) === 0) {
return { ok: true, warn: 'remote ISO — not introspected; sanboot is attempted at boot' }; return { ok: true, warn: 'remote ISO — awaiting introspection; sanboot is attempted at boot' };
} }
// Local ISO with no Windows/Linux boot files and no El Torito catalog: // 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 // a data/appliance image (e.g. a VMware vCenter bundle), not a bootable
@@ -267,7 +269,7 @@
el('label', {class:'field'}, [ el('label', {class:'field'}, [
el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]), el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]),
el('label', {class:'field'}, [ el('label', {class:'field'}, [
el('span', {class:'name'}, 'Unattended file'), sel]), el('span', {class:'name'}, 'Unattended file (in Storage → Advanced)'), sel]),
]); ]);
return { return {
wrap, wrap,
@@ -439,17 +441,18 @@
el('div', {class:'v'}, net.server_ip || '?'), el('div', {class:'v'}, net.server_ip || '?'),
el('div', {class:'k'}, 'NIC name'), el('div', {class:'k'}, 'NIC name'),
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'), el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
// v0.7.2: physical link details (operstate · speed · duplex ·
// port MAC) so the operator can confirm WHICH port answers
// PXE in multi-NIC / trunked environments.
el('div', {class:'k'}, 'Link'),
el('div', {class:'v'}, net.nic_link || '—'),
el('div', {class:'k'}, 'Subnet mask'), el('div', {class:'k'}, 'Subnet mask'),
el('div', {class:'v'}, net.subnet_mask || '?'), el('div', {class:'v'}, net.subnet_mask || '?'),
el('div', {class:'k'}, 'Gateway'), el('div', {class:'k'}, 'Gateway'),
el('div', {class:'v'}, net.gateway || '?'), el('div', {class:'v'}, net.gateway || '?'),
el('div', {class:'k'}, 'Public base URL'), el('div', {class:'k'}, 'Public base URL'),
el('div', {class:'v'}, net.public_base_url), el('div', {class:'v'}, net.public_base_url),
// v0.7.2: physical link details (operstate · speed · duplex ·
// port MAC) so the operator can confirm WHICH port answers PXE
// in multi-NIC / trunked environments. Kept last — the joined
// value runs long, so it wraps cleanly at the bottom of the list.
el('div', {class:'k'}, 'Link'),
el('div', {class:'v'}, net.nic_link || '—'),
]), ]),
el('p', {class:'msg'}, el('p', {class:'msg'},
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' + 'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
@@ -893,20 +896,38 @@
rowsAndEditors.push(tr, editorRow); rowsAndEditors.push(tr, editorRow);
}); });
// v0.7.2: client-side filter over the image table. Rows travel in // v0.7.2: client-side filter over the image table; v0.7.4: paged
// (row, password-editor) pairs; filtering hides both, and an open // 5 at a time so a 50-image library doesn't become a scroll wall.
// editor stays closed for filtered-out rows. // Rows travel in (row, password-editor) pairs. One view function
const isoSearch = el('input', {type:'search', placeholder:'Filter images… (name, family, category, source)', // applies filter-then-page; editors close on any view change.
spellcheck:'false', oninput: () => { const ISO_PAGE_SIZE = 5;
let isoPage = 0;
const pagerInfo = el('span', {});
const prevBtn = el('button', {class:'ghost', onclick: () => { isoPage -= 1; applyIsoListView(); }}, ' Prev');
const nextBtn = el('button', {class:'ghost', onclick: () => { isoPage += 1; applyIsoListView(); }}, 'Next ');
function applyIsoListView() {
const q = isoSearch.value.trim().toLowerCase(); const q = isoSearch.value.trim().toLowerCase();
const visible = [];
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) { for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
const row = rowsAndEditors[k]; const row = rowsAndEditors[k];
const editor = rowsAndEditors[k + 1]; rowsAndEditors[k + 1].style.display = 'none';
const show = !q || (row.dataset.search || '').includes(q); row.style.display = 'none';
row.style.display = show ? '' : 'none'; if (!q || (row.dataset.search || '').includes(q)) visible.push(row);
if (!show) editor.style.display = 'none';
} }
}}); const pages = Math.max(1, Math.ceil(visible.length / ISO_PAGE_SIZE));
if (isoPage >= pages) isoPage = pages - 1;
if (isoPage < 0) isoPage = 0;
visible.slice(isoPage * ISO_PAGE_SIZE, (isoPage + 1) * ISO_PAGE_SIZE)
.forEach(r => { r.style.display = ''; });
pagerInfo.textContent = visible.length
? 'Showing ' + (isoPage * ISO_PAGE_SIZE + 1) + '' +
Math.min(visible.length, (isoPage + 1) * ISO_PAGE_SIZE) + ' of ' + visible.length
: 'No images match';
prevBtn.disabled = isoPage === 0;
nextBtn.disabled = isoPage >= pages - 1;
}
const isoSearch = el('input', {type:'search', placeholder:'Filter images by name, type, or source',
spellcheck:'false', oninput: () => { isoPage = 0; applyIsoListView(); }});
const isoTable = isos.length const isoTable = isos.length
? el('table', {}, [ ? el('table', {}, [
el('thead', {}, el('tr', {}, [ el('thead', {}, el('tr', {}, [
@@ -918,6 +939,13 @@
el('tbody', {}, rowsAndEditors), el('tbody', {}, rowsAndEditors),
]) ])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.'); : el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
// v0.7.4: pager footer, shown once the library outgrows one page.
const isoPager = isos.length > ISO_PAGE_SIZE
? el('div', {class:'list-pager'}, [
pagerInfo, el('span', {class:'spacer'}), prevBtn, nextBtn,
])
: null;
if (isos.length) applyIsoListView();
// ── Remote shares section (v0.5.1) ── // ── Remote shares section (v0.5.1) ──
// SMB + NFS unified into one "Remote shares" card with a protocol // SMB + NFS unified into one "Remote shares" card with a protocol
@@ -1277,14 +1305,14 @@
unattDrop, unattFile, unattMsg, unattDrop, unattFile, unattMsg,
// v0.7.2: filter for big answer-file libraries. // v0.7.2: filter for big answer-file libraries.
unattendedFiles.length > 1 ? (() => { unattendedFiles.length > 1 ? (() => {
const search = el('input', {type:'search', placeholder:'Filter files… (name, kind)', const search = el('input', {type:'search', placeholder:'Filter files by name or kind',
spellcheck:'false', style:'margin-top:14px', oninput: () => { spellcheck:'false', oninput: () => {
const q = search.value.trim().toLowerCase(); const q = search.value.trim().toLowerCase();
unattRows.forEach(r => { unattRows.forEach(r => {
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none'; r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
}); });
}}); }});
return search; return el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, search);
})() : null, })() : null,
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows), el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
@@ -1332,8 +1360,11 @@
el('h2', {}, 'Available images'), el('h2', {}, 'Available images'),
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')), el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
]), ]),
isos.length > 1 ? el('div', {class:'list-search'}, isoSearch) : null, isos.length > 1
? el('div', {class:'list-search'}, el('label', {class:'field', style:'margin-bottom:0'}, isoSearch))
: null,
isoTable, isoTable,
isoPager,
]), ]),
]), unattendedAdvanced]); ]), unattendedAdvanced]);
}, },
@@ -1358,7 +1389,7 @@
{id: '_tools_menu', title: '↳ Tools menu (built-in)'}, {id: '_tools_menu', title: '↳ Tools menu (built-in)'},
]; ];
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or prefix aa:bb:cc', spellcheck:'false'}); const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or aa:bb:cc', spellcheck:'false'});
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'}); const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
// v0.7.2: the former separate "Boot rules" card folded into this // v0.7.2: the former separate "Boot rules" card folded into this
// form. A full MAC with no architecture saves a per-host pin // form. A full MAC with no architecture saves a per-host pin
@@ -1476,38 +1507,32 @@
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Pin MAC to boot target')), el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
el('div', {class:'form-row cols-3'}, [ // v0.7.3: MAC · Label · Architecture · Boot binary share one
el('label', {class:'field'}, [ // 4-up row so the controls line up across the page; the
el('span', {class:'name'}, 'MAC address or prefix'), // per-field guidance that used to sit under them moved into the
macInput, // note below to keep the inputs flush. Target spans full width
el('span', {class:'hint'}, 'Full MAC pins one machine; a prefix (OUI) makes a group rule.'), // on its own line beneath them.
]), el('div', {class:'form-row'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address or prefix'), macInput]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]), el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
el('label', {class:'field'}, [ el('label', {class:'field'}, [el('span', {class:'name'}, 'Architecture (optional)'), archSel]),
el('span', {class:'name'}, 'Architecture (optional)'), el('label', {class:'field'}, [el('span', {class:'name'}, 'Boot binary (optional)'), binSel]),
archSel,
el('span', {class:'hint'}, 'Selecting one makes a group rule for that firmware.'),
]), ]),
el('label', {class:'field', style:'grid-column:1 / -1'}, [ el('label', {class:'field', style:'margin-top:14px'}, [
el('span', {class:'name'}, 'Target'), el('span', {class:'name'}, 'Target'),
targetSel, targetSel,
el('span', {class:'hint'},
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Boot binary (optional)'),
binSel,
el('span', {class:'hint'}, 'Pin Secure Boot racks to "shim" — zero failed boot cycles.'),
]),
]), ]),
el('div', {style:'margin-top:16px'}, profileFields.wrap), el('div', {style:'margin-top:16px'}, profileFields.wrap),
upsertBtn, msg, upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'When a matching client requests boot.ipxe, OpenPXE short-circuits ' + 'A full MAC pins one machine; a MAC prefix (OUI) or an architecture ' +
'past the interactive menu and chains directly. Decision order: ' + 'saves a first-match group rule. Pin the boot binary to “shim” for ' +
'exact MAC pin → first matching group rule → menu. ' + 'Secure Boot racks — zero failed boot cycles. When a matching client ' +
'If an unattended file is selected on a pin, the matching kernel ' + 'requests boot.ipxe, OpenPXE short-circuits past the interactive menu ' +
'argument is injected and the hostname/IP are templated into the answer file.'), 'and chains directly; decision order is exact MAC pin → first matching ' +
'group rule → menu. If an unattended file is selected on a pin, the ' +
'matching kernel argument is injected and the hostname/IP are templated ' +
'into the answer file.'),
]), ]),
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [