Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6524aa4118 | ||
|
|
934cfbab46 | ||
|
|
a71057fce6 |
Generated
+8
-8
@@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe"
|
name = "openpxe"
|
||||||
version = "0.7.1"
|
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.1"
|
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.1"
|
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.1"
|
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.1"
|
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.1"
|
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.1"
|
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.1"
|
version = "0.7.4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "p256"
|
name = "p256"
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.7.1"
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+95
-17
@@ -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,28 +1087,105 @@ 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
|
|
||||||
// smbclient's whole-file streaming can't do efficiently. SMB-
|
|
||||||
// sourced ISOs use the raw streaming endpoint above instead.
|
|
||||||
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
|
|
||||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||||
};
|
};
|
||||||
let p = iso_path.clone();
|
|
||||||
let in_path = format!("/{path}");
|
let in_path = format!("/{path}");
|
||||||
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
|
match &meta.source {
|
||||||
.await
|
IsoSource::Local => {
|
||||||
.ok()
|
let Some(iso_path) = state.iso_store.local_path(&meta) else {
|
||||||
.flatten();
|
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||||
let Some(loc) = loc else {
|
};
|
||||||
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
|
let p = iso_path.clone();
|
||||||
};
|
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup_local(&p, &in_path))
|
||||||
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
|
.await
|
||||||
Ok(r) => r,
|
.ok()
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
.flatten();
|
||||||
|
let Some(loc) = loc else {
|
||||||
|
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
|
||||||
|
};
|
||||||
|
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v0.7.4: remote ISOs serve in-ISO files too — the same ISO9660
|
||||||
|
// walk runs over NFS READ3 / SFTP seek-reads, then the located
|
||||||
|
// byte range streams through the share manager. This is what
|
||||||
|
// makes the verified kernel/initrd boot entries on share-hosted
|
||||||
|
// Linux ISOs actually bootable.
|
||||||
|
IsoSource::Nfs {
|
||||||
|
share_id,
|
||||||
|
relative_path,
|
||||||
|
} => {
|
||||||
|
match state
|
||||||
|
.nfs_shares
|
||||||
|
.locate_in_iso(share_id, relative_path, &in_path)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(loc)) => {
|
||||||
|
match state
|
||||||
|
.nfs_shares
|
||||||
|
.stream_iso(share_id, relative_path, loc.offset, Some(loc.length))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length),
|
||||||
|
Err(e) => {
|
||||||
|
(StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(),
|
||||||
|
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs lookup: {e}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IsoSource::Sftp {
|
||||||
|
share_id,
|
||||||
|
relative_path,
|
||||||
|
} => {
|
||||||
|
match state
|
||||||
|
.sftp_shares
|
||||||
|
.locate_in_iso(share_id, relative_path, &in_path)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(loc)) => {
|
||||||
|
match state
|
||||||
|
.sftp_shares
|
||||||
|
.stream_iso(share_id, relative_path, loc.offset, Some(loc.length))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length),
|
||||||
|
Err(e) => {
|
||||||
|
(StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(),
|
||||||
|
Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp lookup: {e}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// smbclient streams sequentially — no seeks, no ISO9660 walk.
|
||||||
|
// SMB ISOs never emit kernel entries, so nothing requests this.
|
||||||
|
IsoSource::Smb { .. } => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"in-ISO files are not available for SMB-sourced ISOs",
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 200 response wrapping an in-ISO byte-range stream from a share
|
||||||
|
/// manager. Content-Length is the located file's length — the stream is
|
||||||
|
/// already bounded to exactly that range.
|
||||||
|
fn in_iso_stream_response(body: Body, length: u64) -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
|
.header(header::CONTENT_LENGTH, length)
|
||||||
|
.body(body)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
async fn stream_file_range(
|
async fn stream_file_range(
|
||||||
path: &std::path::Path,
|
path: &std::path::Path,
|
||||||
range: Option<&HeaderValue>,
|
range: Option<&HeaderValue>,
|
||||||
@@ -2744,6 +2821,7 @@ async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|||||||
.strip_prefix("http://")
|
.strip_prefix("http://")
|
||||||
.unwrap_or(&state.public_base_url),
|
.unwrap_or(&state.public_base_url),
|
||||||
"nic_name": state.nic_name,
|
"nic_name": state.nic_name,
|
||||||
|
"nic_link": state.nic_link,
|
||||||
"subnet_mask": state.subnet_mask,
|
"subnet_mask": state.subnet_mask,
|
||||||
"gateway": state.gateway,
|
"gateway": state.gateway,
|
||||||
"dns_server": state.settings.snapshot().dns_server,
|
"dns_server": state.settings.snapshot().dns_server,
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
//! Minimal read-only ISO9660 lookup. Given an uploaded ISO file and an
|
|
||||||
//! in-ISO path (e.g. `/casper/vmlinuz`), locate the file and return a
|
|
||||||
//! `(start_byte, length_bytes)` pair so the HTTP handler can stream just
|
|
||||||
//! that range from the on-disk ISO without full extraction.
|
|
||||||
//!
|
|
||||||
//! We only implement what we need: the Primary Volume Descriptor and Rock
|
|
||||||
//! Ridge / Joliet extensions are ignored. Paths are matched case-insensitive
|
|
||||||
//! against plain ISO9660 filenames (uppercase, `;1` version suffix stripped).
|
|
||||||
//! This is sufficient for the kernel/initrd and wimboot files we serve;
|
|
||||||
//! if a requested path isn't found, the handler returns 404 and the user
|
|
||||||
//! can still download the whole ISO via `/iso/<id>.iso`.
|
|
||||||
|
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
const SECTOR: u64 = 2048;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct FileLocation {
|
|
||||||
pub offset: u64,
|
|
||||||
pub length: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in the
|
|
||||||
/// ISO at `iso_path`. Returns None on any parsing or IO failure.
|
|
||||||
pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
|
|
||||||
let mut f = std::fs::File::open(iso_path).ok()?;
|
|
||||||
let root = read_root_directory(&mut f)?;
|
|
||||||
let components: Vec<&str> = in_iso_path
|
|
||||||
.trim_start_matches('/')
|
|
||||||
.split('/')
|
|
||||||
.filter(|c| !c.is_empty())
|
|
||||||
.collect();
|
|
||||||
if components.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
walk(&mut f, root.offset, root.length, &components)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_root_directory(f: &mut std::fs::File) -> Option<FileLocation> {
|
|
||||||
// Primary Volume Descriptor at LBA 16.
|
|
||||||
let mut pvd = [0u8; 2048];
|
|
||||||
f.seek(SeekFrom::Start(16 * SECTOR)).ok()?;
|
|
||||||
f.read_exact(&mut pvd).ok()?;
|
|
||||||
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Root directory record is at offset 156, length 34.
|
|
||||||
let rec = &pvd[156..156 + 34];
|
|
||||||
let (offset, length) = parse_dir_record_ext(rec)?;
|
|
||||||
Some(FileLocation {
|
|
||||||
offset: offset * SECTOR,
|
|
||||||
length,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk components down the directory tree starting at `dir_offset`.
|
|
||||||
fn walk(
|
|
||||||
f: &mut std::fs::File,
|
|
||||||
dir_offset: u64,
|
|
||||||
dir_len: u64,
|
|
||||||
components: &[&str],
|
|
||||||
) -> Option<FileLocation> {
|
|
||||||
let mut dir = vec![0u8; dir_len as usize];
|
|
||||||
f.seek(SeekFrom::Start(dir_offset)).ok()?;
|
|
||||||
f.read_exact(&mut dir).ok()?;
|
|
||||||
|
|
||||||
let target = components[0];
|
|
||||||
let rest = &components[1..];
|
|
||||||
let mut i = 0;
|
|
||||||
while i < dir.len() {
|
|
||||||
let len = dir[i] as usize;
|
|
||||||
if len == 0 {
|
|
||||||
// Padding to sector boundary.
|
|
||||||
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
|
|
||||||
if next <= i {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
i = next;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if i + len > dir.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let rec = &dir[i..i + len];
|
|
||||||
let name = dir_record_name(rec);
|
|
||||||
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
|
|
||||||
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
|
|
||||||
let is_pseudo = matches!(rec.get(32).copied(), Some(1))
|
|
||||||
&& rec.get(33).copied() == Some(0x00)
|
|
||||||
|| matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01);
|
|
||||||
if !is_pseudo && name.eq_ignore_ascii_case(target) {
|
|
||||||
let (child_off, child_len) = parse_dir_record_ext(rec)?;
|
|
||||||
if rest.is_empty() && !is_dir {
|
|
||||||
return Some(FileLocation {
|
|
||||||
offset: child_off * SECTOR,
|
|
||||||
length: child_len,
|
|
||||||
});
|
|
||||||
} else if !rest.is_empty() && is_dir {
|
|
||||||
return walk(f, child_off * SECTOR, child_len, rest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += len;
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract (extent LBA, data length in bytes) from a directory record.
|
|
||||||
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
|
|
||||||
/// data length (LE+BE duplicate). We trust the little-endian copy.
|
|
||||||
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
|
|
||||||
if rec.len() < 34 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64;
|
|
||||||
let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64;
|
|
||||||
Some((lba, len))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the identifier from a directory record, stripping ISO9660's
|
|
||||||
/// `;1` version suffix.
|
|
||||||
fn dir_record_name(rec: &[u8]) -> String {
|
|
||||||
let name_len = *rec.get(32).unwrap_or(&0) as usize;
|
|
||||||
if name_len == 0 || rec.len() < 33 + name_len {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
let raw = &rec[33..33 + name_len];
|
|
||||||
let s = String::from_utf8_lossy(raw).to_string();
|
|
||||||
// Strip `;N` version suffix.
|
|
||||||
if let Some(i) = s.rfind(';') {
|
|
||||||
s[..i].to_string()
|
|
||||||
} else {
|
|
||||||
s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,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;
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ pub struct AppState {
|
|||||||
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
|
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
|
||||||
/// interface couldn't be identified.
|
/// interface couldn't be identified.
|
||||||
pub nic_name: String,
|
pub nic_name: String,
|
||||||
|
/// v0.7.2: physical link summary for that NIC (operstate, speed,
|
||||||
|
/// duplex, port MAC) — read from sysfs at startup; empty where
|
||||||
|
/// unavailable. Helps confirm which port answers PXE.
|
||||||
|
pub nic_link: String,
|
||||||
/// Subnet mask of the public interface in dotted-quad form.
|
/// Subnet mask of the public interface in dotted-quad form.
|
||||||
pub subnet_mask: String,
|
pub subnet_mask: String,
|
||||||
/// Default gateway IPv4 address.
|
/// Default gateway IPv4 address.
|
||||||
|
|||||||
@@ -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>) {
|
||||||
@@ -135,6 +128,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
started_at: time::OffsetDateTime::now_utc(),
|
started_at: time::OffsetDateTime::now_utc(),
|
||||||
public_base_url: "http://127.0.0.1".into(),
|
public_base_url: "http://127.0.0.1".into(),
|
||||||
nic_name: "lo".into(),
|
nic_name: "lo".into(),
|
||||||
|
nic_link: String::new(),
|
||||||
subnet_mask: "255.0.0.0".into(),
|
subnet_mask: "255.0.0.0".into(),
|
||||||
gateway: "127.0.0.1".into(),
|
gateway: "127.0.0.1".into(),
|
||||||
};
|
};
|
||||||
@@ -1247,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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
+517
-178
@@ -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")
|
|
||||||
{
|
{
|
||||||
report.has_boot_wim = true;
|
let mut cr = CachingReadAt::new(r);
|
||||||
report.family = DistroFamily::WindowsPe;
|
|
||||||
}
|
|
||||||
|
|
||||||
// v0.5.8: broaden Windows detection. Modern Windows 10/11 ISOs are
|
if iso_fs::exists(&mut cr, "/sources/boot.wim").await {
|
||||||
// UDF — filenames are stored as UTF-16 (so the ASCII scan above misses
|
report.has_boot_wim = true;
|
||||||
// them) and the volume label is a cryptic Microsoft string (so
|
|
||||||
// `family_from_label` misses it too). Booting is via HTTP sanboot of
|
|
||||||
// the raw ISO (no boot.wim extraction), so we only need the *family*.
|
|
||||||
// Catch the common cases: well-known Windows markers in either ASCII
|
|
||||||
// or UTF-16LE within the first 16 MiB, plus a filename hint.
|
|
||||||
if report.family == DistroFamily::Unknown {
|
|
||||||
let head = &haystack[..haystack.len().min(16 * 1024 * 1024)];
|
|
||||||
let ascii_markers: [&[u8]; 4] = [
|
|
||||||
b"bootmgr",
|
|
||||||
b"sources/install.wim",
|
|
||||||
b"sources/install.esd",
|
|
||||||
b"efi/microsoft",
|
|
||||||
];
|
|
||||||
let utf16_markers = ["bootmgr", "boot.wim", "install.wim", "microsoft"];
|
|
||||||
let looks_windows = ascii_markers.iter().any(|m| contains_ascii(head, m))
|
|
||||||
|| utf16_markers.iter().any(|m| contains_utf16le_ci(head, m))
|
|
||||||
|| filename_looks_windows(path);
|
|
||||||
if looks_windows {
|
|
||||||
report.family = DistroFamily::WindowsPe;
|
report.family = DistroFamily::WindowsPe;
|
||||||
|
} else {
|
||||||
|
for probe in LINUX_PROBES {
|
||||||
|
if !iso_fs::exists(&mut cr, probe.kernel).await {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut initrd = None;
|
||||||
|
for cand in probe.initrd_candidates {
|
||||||
|
if iso_fs::exists(&mut cr, cand).await {
|
||||||
|
initrd = Some((*cand).to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(initrd) = initrd else { continue };
|
||||||
|
// Content beats label: a rebadged derivative (volume
|
||||||
|
// label "ISE-3.2") with the anaconda layout is
|
||||||
|
// RHEL-family no matter what the label says.
|
||||||
|
report.family = probe.family;
|
||||||
|
let coreos = probe.family == DistroFamily::RhelFedora
|
||||||
|
&& iso_fs::exists(&mut cr, COREOS_ROOTFS).await;
|
||||||
|
if probe.emit_kernel && !coreos {
|
||||||
|
report.kernel_path = Some(probe.kernel.to_string());
|
||||||
|
report.initrd_paths = vec![initrd];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort kernel/initrd path guess from family. These paths are what
|
// Modern Windows 10/11 ISOs are UDF — their tree is invisible to the
|
||||||
// distro ISOs conventionally ship at — we don't verify extraction here;
|
// ISO9660 walk and the volume label is a cryptic Microsoft string.
|
||||||
// that happens in the store after introspection.
|
// Scan the first 16 MiB for well-known markers, ASCII and UTF-16LE.
|
||||||
let (k, i) = guess_kernel_initrd(report.family);
|
// Runs after the Linux probes so a Linux ISO that *contains* the
|
||||||
report.kernel_path = k.map(str::to_string);
|
// string "bootmgr" (GRUB/syslinux chainload modules do) has already
|
||||||
report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
|
// classified and never reaches this — that ordering is the v0.7.4
|
||||||
|
// gparted-misdetection fix.
|
||||||
|
if report.family == DistroFamily::Unknown && allow_bulk_scan {
|
||||||
|
if let Some(win) = bulk_windows_scan(r, total_len).await {
|
||||||
|
report.family = DistroFamily::WindowsPe;
|
||||||
|
report.has_boot_wim = win;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: filename tokens. The only signal for SMB-sourced ISOs
|
||||||
|
// and renamed/UDF images that defeated everything above.
|
||||||
|
if report.family == DistroFamily::Unknown {
|
||||||
|
report.family = family_from_filename(filename);
|
||||||
|
}
|
||||||
|
|
||||||
report
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
//! Read-only ISO9660 lookup over any random-access byte source.
|
||||||
|
//!
|
||||||
|
//! v0.7.4: generalized from the http-api crate's local-file-only walker so
|
||||||
|
//! the same directory walk drives three consumers:
|
||||||
|
//!
|
||||||
|
//! 1. `iso_file` HTTP serving — locate `/casper/vmlinuz` inside a local
|
||||||
|
//! *or remote* (NFS/SFTP) ISO and stream just that byte range.
|
||||||
|
//! 2. Introspection — probe for well-known kernel/initrd/boot.wim paths
|
||||||
|
//! instead of grepping raw sectors for filename strings (which
|
||||||
|
//! false-positived: any Linux ISO shipping GRUB/syslinux chainload
|
||||||
|
//! modules contains the literal "bootmgr" and used to classify as
|
||||||
|
//! Windows).
|
||||||
|
//! 3. Remote introspection — the same probes over an NFSv3 READ3 /
|
||||||
|
//! SFTP seek-read connection, which is what finally classifies
|
||||||
|
//! share-sourced ISOs instead of registering them all as `Unknown`.
|
||||||
|
//!
|
||||||
|
//! We parse only the Primary Volume Descriptor namespace. Joliet and Rock
|
||||||
|
//! Ridge are deliberately ignored — matching is case-insensitive against
|
||||||
|
//! plain ISO9660 identifiers (`;1` version suffix and the trailing dot of
|
||||||
|
//! extension-less strict-mastered names stripped), which is how the local
|
||||||
|
//! serving path has always behaved in production.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub const SECTOR: u64 = 2048;
|
||||||
|
|
||||||
|
/// Upper bound on a single directory extent we'll buffer. Real distro ISO
|
||||||
|
/// directories are a handful of KiB; the cap keeps a malformed or hostile
|
||||||
|
/// image from asking us to allocate gigabytes.
|
||||||
|
const MAX_DIR_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Byte range of one file inside the ISO image.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FileLocation {
|
||||||
|
pub offset: u64,
|
||||||
|
pub length: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Random-access reads into an ISO image. Implemented by a local
|
||||||
|
/// `std::fs::File`, the NFS and SFTP share readers, and the in-memory
|
||||||
|
/// test image.
|
||||||
|
///
|
||||||
|
/// The contract is `read_exact`-like: the returned buffer is exactly
|
||||||
|
/// `len` bytes or the call errors. The future must be `Send` because
|
||||||
|
/// remote introspection runs inside spawned tokio tasks.
|
||||||
|
pub trait IsoReadAt {
|
||||||
|
fn read_at(
|
||||||
|
&mut self,
|
||||||
|
offset: u64,
|
||||||
|
len: u32,
|
||||||
|
) -> impl Future<Output = std::io::Result<Vec<u8>>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local-file reader. The reads are synchronous inside an async fn —
|
||||||
|
/// callers run it either on the blocking pool (introspection at upload)
|
||||||
|
/// or through [`lookup_local`]'s `block_on`, never on a hot runtime
|
||||||
|
/// worker with real awaits pending.
|
||||||
|
pub struct FileReadAt(std::fs::File);
|
||||||
|
|
||||||
|
impl FileReadAt {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(f: std::fs::File) -> Self {
|
||||||
|
Self(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IsoReadAt for FileReadAt {
|
||||||
|
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
|
||||||
|
self.0.seek(SeekFrom::Start(offset))?;
|
||||||
|
let mut buf = vec![0u8; len as usize];
|
||||||
|
self.0.read_exact(&mut buf)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact-key read cache for the probe phase of introspection. The probe
|
||||||
|
/// table looks up ~20 paths and every one of them re-reads the root
|
||||||
|
/// directory (and usually one shared subdirectory); over NFS/SFTP that
|
||||||
|
/// would be 20 identical round-trips. Directory reads repeat with the
|
||||||
|
/// exact same `(offset, len)`, so a plain map keyed on the pair hits
|
||||||
|
/// every time. Large data reads bypass the cache.
|
||||||
|
pub struct CachingReadAt<'a, R: IsoReadAt + Send> {
|
||||||
|
inner: &'a mut R,
|
||||||
|
cache: HashMap<(u64, u32), Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Don't cache reads bigger than this (file payloads, bulk scans).
|
||||||
|
const CACHE_MAX_READ: u32 = 256 * 1024;
|
||||||
|
/// Bound the cache so a pathological image can't grow it unbounded.
|
||||||
|
const CACHE_MAX_ENTRIES: usize = 256;
|
||||||
|
|
||||||
|
impl<'a, R: IsoReadAt + Send> CachingReadAt<'a, R> {
|
||||||
|
pub fn new(inner: &'a mut R) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
cache: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: IsoReadAt + Send> IsoReadAt for CachingReadAt<'_, R> {
|
||||||
|
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
|
||||||
|
let key = (offset, len);
|
||||||
|
if let Some(hit) = self.cache.get(&key) {
|
||||||
|
return Ok(hit.clone());
|
||||||
|
}
|
||||||
|
let buf = self.inner.read_at(offset, len).await?;
|
||||||
|
if len <= CACHE_MAX_READ && self.cache.len() < CACHE_MAX_ENTRIES {
|
||||||
|
self.cache.insert(key, buf.clone());
|
||||||
|
}
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in
|
||||||
|
/// the image behind `r`. Returns `None` on any parsing or IO failure —
|
||||||
|
/// "not found" and "couldn't read" are the same answer to a prober.
|
||||||
|
pub async fn lookup<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> Option<FileLocation> {
|
||||||
|
let pvd = r.read_at(16 * SECTOR, 2048).await.ok()?;
|
||||||
|
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Root directory record at PVD offset 156, 34 bytes.
|
||||||
|
let (mut lba, mut len) = parse_dir_record_ext(&pvd[156..156 + 34])?;
|
||||||
|
|
||||||
|
let components: Vec<&str> = in_iso_path
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.split('/')
|
||||||
|
.filter(|c| !c.is_empty())
|
||||||
|
.collect();
|
||||||
|
if components.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The original walk was tail-recursive; iterate instead so the future
|
||||||
|
// stays a plain (non-boxed) state machine.
|
||||||
|
for (idx, comp) in components.iter().enumerate() {
|
||||||
|
if len == 0 || len > MAX_DIR_BYTES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dir = r.read_at(lba * SECTOR, len as u32).await.ok()?;
|
||||||
|
let hit = scan_dir(&dir, comp)?;
|
||||||
|
let last = idx + 1 == components.len();
|
||||||
|
match (last, hit.is_dir) {
|
||||||
|
(true, false) => {
|
||||||
|
return Some(FileLocation {
|
||||||
|
offset: hit.lba * SECTOR,
|
||||||
|
length: hit.len,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(false, true) => {
|
||||||
|
lba = hit.lba;
|
||||||
|
len = hit.len;
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience probe: does `in_iso_path` exist as a file?
|
||||||
|
pub async fn exists<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> bool {
|
||||||
|
lookup(r, in_iso_path).await.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous wrapper for local files — the shape the HTTP handler's
|
||||||
|
/// `spawn_blocking` call site wants. `block_on` is safe here because
|
||||||
|
/// `FileReadAt`'s reads never actually await (they complete inline), so
|
||||||
|
/// the executor never parks.
|
||||||
|
#[must_use]
|
||||||
|
pub fn lookup_local(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
|
||||||
|
let f = std::fs::File::open(iso_path).ok()?;
|
||||||
|
futures::executor::block_on(lookup(&mut FileReadAt::new(f), in_iso_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DirHit {
|
||||||
|
lba: u64,
|
||||||
|
len: u64,
|
||||||
|
is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan one directory extent for an identifier. Pure function over the
|
||||||
|
/// buffered extent — all protocol/IO concerns live in the caller.
|
||||||
|
fn scan_dir(dir: &[u8], target: &str) -> Option<DirHit> {
|
||||||
|
let mut i = 0;
|
||||||
|
while i < dir.len() {
|
||||||
|
let len = dir[i] as usize;
|
||||||
|
if len == 0 {
|
||||||
|
// Records never span sectors; a zero length byte means the
|
||||||
|
// rest of this sector is padding. Hop to the next one.
|
||||||
|
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
|
||||||
|
if next <= i {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i = next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if i + len > dir.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let rec = &dir[i..i + len];
|
||||||
|
let name = dir_record_name(rec);
|
||||||
|
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
|
||||||
|
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
|
||||||
|
let is_pseudo =
|
||||||
|
rec.get(32).copied() == Some(1) && matches!(rec.get(33).copied(), Some(0x00 | 0x01));
|
||||||
|
if !is_pseudo && name.eq_ignore_ascii_case(target) {
|
||||||
|
let (lba, dlen) = parse_dir_record_ext(rec)?;
|
||||||
|
return Some(DirHit {
|
||||||
|
lba,
|
||||||
|
len: dlen,
|
||||||
|
is_dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
i += len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract (extent LBA, data length in bytes) from a directory record.
|
||||||
|
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
|
||||||
|
/// data length (LE+BE duplicate). We trust the little-endian copy.
|
||||||
|
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
|
||||||
|
if rec.len() < 34 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lba = u64::from(u32::from_le_bytes(rec[2..6].try_into().ok()?));
|
||||||
|
let len = u64::from(u32::from_le_bytes(rec[10..14].try_into().ok()?));
|
||||||
|
Some((lba, len))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the identifier from a directory record, normalizing ISO9660
|
||||||
|
/// quirks: the `;N` version suffix and the trailing dot that strict
|
||||||
|
/// mastering appends to extension-less names (`VMLINUZ.;1`). Without the
|
||||||
|
/// dot strip, level-1 images' kernels never matched `/casper/vmlinuz`.
|
||||||
|
fn dir_record_name(rec: &[u8]) -> String {
|
||||||
|
let name_len = *rec.get(32).unwrap_or(&0) as usize;
|
||||||
|
if name_len == 0 || rec.len() < 33 + name_len {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let raw = &rec[33..33 + name_len];
|
||||||
|
let s = String::from_utf8_lossy(raw);
|
||||||
|
let s = s.rfind(';').map_or_else(|| s.as_ref(), |i| &s[..i]);
|
||||||
|
s.strip_suffix('.').unwrap_or(s).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── test support ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A tiny ISO9660 image builder used by this module's tests, the
|
||||||
|
// introspection tests, and (behind the `test-image` feature) other
|
||||||
|
// crates' integration tests. Lays out: PVD @ LBA 16, optional El Torito
|
||||||
|
// boot record @ 17, set terminator @ 18, directories from LBA 20, file
|
||||||
|
// data after. Only what `lookup`/introspection read is populated.
|
||||||
|
|
||||||
|
#[cfg(any(test, feature = "test-image"))]
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub mod testiso {
|
||||||
|
use super::SECTOR;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Node {
|
||||||
|
children: BTreeMap<String, Node>,
|
||||||
|
content: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TestIsoBuilder {
|
||||||
|
root: Node,
|
||||||
|
volume_label: String,
|
||||||
|
el_torito: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestIsoBuilder {
|
||||||
|
pub fn new(volume_label: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
root: Node::default(),
|
||||||
|
volume_label: volume_label.to_string(),
|
||||||
|
el_torito: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn el_torito(mut self, on: bool) -> Self {
|
||||||
|
self.el_torito = on;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a file at `path` (e.g. "/casper/vmlinuz") with `content`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file(mut self, path: &str, content: &[u8]) -> Self {
|
||||||
|
let mut node = &mut self.root;
|
||||||
|
let comps: Vec<&str> = path
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.split('/')
|
||||||
|
.filter(|c| !c.is_empty())
|
||||||
|
.collect();
|
||||||
|
for (i, c) in comps.iter().enumerate() {
|
||||||
|
node = node.children.entry((*c).to_string()).or_default();
|
||||||
|
if i + 1 == comps.len() {
|
||||||
|
node.content = Some(content.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> Vec<u8> {
|
||||||
|
// Pass 1: allocate extents. Directories first (1 sector each),
|
||||||
|
// then file contents.
|
||||||
|
let mut next_lba: u64 = 20;
|
||||||
|
let mut dirs: Vec<(*const Node, u64)> = Vec::new();
|
||||||
|
fn alloc_dirs(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64)>) {
|
||||||
|
out.push((std::ptr::from_ref(n), *next));
|
||||||
|
*next += 1;
|
||||||
|
for child in n.children.values() {
|
||||||
|
if child.content.is_none() {
|
||||||
|
alloc_dirs(child, next, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alloc_dirs(&self.root, &mut next_lba, &mut dirs);
|
||||||
|
let lba_of = |n: &Node| -> u64 {
|
||||||
|
dirs.iter()
|
||||||
|
.find(|(p, _)| std::ptr::eq(*p, n))
|
||||||
|
.map(|(_, l)| *l)
|
||||||
|
.expect("dir allocated")
|
||||||
|
};
|
||||||
|
let mut file_lbas: Vec<(*const Node, u64, usize)> = Vec::new();
|
||||||
|
fn alloc_files(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64, usize)>) {
|
||||||
|
for child in n.children.values() {
|
||||||
|
if let Some(c) = &child.content {
|
||||||
|
out.push((std::ptr::from_ref(child), *next, c.len()));
|
||||||
|
*next += c.len().div_ceil(SECTOR as usize).max(1) as u64;
|
||||||
|
} else {
|
||||||
|
alloc_files(child, next, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alloc_files(&self.root, &mut next_lba, &mut file_lbas);
|
||||||
|
let file_lba_of = |n: &Node| -> u64 {
|
||||||
|
file_lbas
|
||||||
|
.iter()
|
||||||
|
.find(|(p, _, _)| std::ptr::eq(*p, n))
|
||||||
|
.map(|(_, l, _)| *l)
|
||||||
|
.expect("file allocated")
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = next_lba as usize * SECTOR as usize;
|
||||||
|
let mut img = vec![0u8; total];
|
||||||
|
|
||||||
|
// Directory record encoder.
|
||||||
|
fn record(name_bytes: &[u8], lba: u64, len: u64, is_dir: bool) -> Vec<u8> {
|
||||||
|
let mut rec_len = 33 + name_bytes.len();
|
||||||
|
if rec_len % 2 == 1 {
|
||||||
|
rec_len += 1; // pad to even
|
||||||
|
}
|
||||||
|
let rec_len = rec_len.max(34);
|
||||||
|
let mut r = vec![0u8; rec_len];
|
||||||
|
r[0] = rec_len as u8;
|
||||||
|
r[2..6].copy_from_slice(&(lba as u32).to_le_bytes());
|
||||||
|
r[6..10].copy_from_slice(&(lba as u32).to_be_bytes());
|
||||||
|
r[10..14].copy_from_slice(&(len as u32).to_le_bytes());
|
||||||
|
r[14..18].copy_from_slice(&(len as u32).to_be_bytes());
|
||||||
|
if is_dir {
|
||||||
|
r[25] = 0x02;
|
||||||
|
}
|
||||||
|
r[32] = name_bytes.len() as u8;
|
||||||
|
r[33..33 + name_bytes.len()].copy_from_slice(name_bytes);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: write each directory extent.
|
||||||
|
fn write_dir(
|
||||||
|
img: &mut [u8],
|
||||||
|
n: &Node,
|
||||||
|
self_lba: u64,
|
||||||
|
parent_lba: u64,
|
||||||
|
lba_of: &dyn Fn(&Node) -> u64,
|
||||||
|
file_lba_of: &dyn Fn(&Node) -> u64,
|
||||||
|
) {
|
||||||
|
let base = self_lba as usize * SECTOR as usize;
|
||||||
|
let mut off = 0usize;
|
||||||
|
let mut put = |rec: Vec<u8>, off: &mut usize| {
|
||||||
|
img[base + *off..base + *off + rec.len()].copy_from_slice(&rec);
|
||||||
|
*off += rec.len();
|
||||||
|
};
|
||||||
|
put(record(&[0x00], self_lba, SECTOR, true), &mut off);
|
||||||
|
put(record(&[0x01], parent_lba, SECTOR, true), &mut off);
|
||||||
|
for (name, child) in &n.children {
|
||||||
|
if let Some(c) = &child.content {
|
||||||
|
// Files get the ISO9660 uppercase `;1` treatment so
|
||||||
|
// the case-insensitive + version-strip matching is
|
||||||
|
// what the tests actually exercise.
|
||||||
|
let stored = format!("{};1", name.to_ascii_uppercase());
|
||||||
|
put(
|
||||||
|
record(stored.as_bytes(), file_lba_of(child), c.len() as u64, false),
|
||||||
|
&mut off,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let stored = name.to_ascii_uppercase();
|
||||||
|
put(
|
||||||
|
record(stored.as_bytes(), lba_of(child), SECTOR, true),
|
||||||
|
&mut off,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (name, child) in &n.children {
|
||||||
|
if child.content.is_none() {
|
||||||
|
write_dir(img, child, lba_of(child), self_lba, lba_of, file_lba_of);
|
||||||
|
} else if let Some(c) = &child.content {
|
||||||
|
let b = file_lba_of(child) as usize * SECTOR as usize;
|
||||||
|
img[b..b + c.len()].copy_from_slice(c);
|
||||||
|
}
|
||||||
|
let _ = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let root_lba = lba_of(&self.root);
|
||||||
|
write_dir(
|
||||||
|
&mut img,
|
||||||
|
&self.root,
|
||||||
|
root_lba,
|
||||||
|
root_lba,
|
||||||
|
&lba_of,
|
||||||
|
&file_lba_of,
|
||||||
|
);
|
||||||
|
|
||||||
|
// PVD @ 16.
|
||||||
|
let pvd = 16 * SECTOR as usize;
|
||||||
|
img[pvd] = 0x01;
|
||||||
|
img[pvd + 1..pvd + 6].copy_from_slice(b"CD001");
|
||||||
|
let label = self.volume_label.as_bytes();
|
||||||
|
let label_field = &mut img[pvd + 40..pvd + 72];
|
||||||
|
label_field.fill(b' ');
|
||||||
|
label_field[..label.len().min(32)].copy_from_slice(&label[..label.len().min(32)]);
|
||||||
|
let root_rec = record(&[0x00], root_lba, SECTOR, true);
|
||||||
|
img[pvd + 156..pvd + 156 + 34].copy_from_slice(&root_rec[..34]);
|
||||||
|
|
||||||
|
// Optional El Torito boot record @ 17, terminator after.
|
||||||
|
let mut vd = 17 * SECTOR as usize;
|
||||||
|
if self.el_torito {
|
||||||
|
img[vd] = 0x00;
|
||||||
|
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
|
||||||
|
let id = b"EL TORITO SPECIFICATION";
|
||||||
|
img[vd + 7..vd + 7 + id.len()].copy_from_slice(id);
|
||||||
|
vd += SECTOR as usize;
|
||||||
|
}
|
||||||
|
img[vd] = 0xFF;
|
||||||
|
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
|
||||||
|
|
||||||
|
img
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory `IsoReadAt` over a built test image.
|
||||||
|
pub struct MemReadAt(pub Vec<u8>);
|
||||||
|
|
||||||
|
impl super::IsoReadAt for MemReadAt {
|
||||||
|
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
|
||||||
|
let start = usize::try_from(offset).unwrap_or(usize::MAX);
|
||||||
|
let end = start.saturating_add(len as usize);
|
||||||
|
if end > self.0.len() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::UnexpectedEof,
|
||||||
|
"read past end of test image",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(self.0[start..end].to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::testiso::{MemReadAt, TestIsoBuilder};
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn block_on<T>(f: impl Future<Output = T>) -> T {
|
||||||
|
futures::executor::block_on(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_finds_nested_file_case_insensitively() {
|
||||||
|
let img = TestIsoBuilder::new("UBUNTU 24.04")
|
||||||
|
.file("/casper/vmlinuz", b"KERNELDATA")
|
||||||
|
.file("/casper/initrd", b"INITRDDATA")
|
||||||
|
.build();
|
||||||
|
let mut r = MemReadAt(img);
|
||||||
|
let loc = block_on(lookup(&mut r, "/CASPER/VMLINUZ")).expect("found");
|
||||||
|
assert_eq!(loc.length, 10);
|
||||||
|
let bytes = block_on(r.read_at(loc.offset, 10)).unwrap();
|
||||||
|
assert_eq!(&bytes, b"KERNELDATA");
|
||||||
|
// Missing file and missing dir both miss cleanly.
|
||||||
|
assert!(block_on(lookup(&mut r, "/casper/missing")).is_none());
|
||||||
|
assert!(block_on(lookup(&mut r, "/nodir/vmlinuz")).is_none());
|
||||||
|
// A directory path that resolves to a directory is not a file hit.
|
||||||
|
assert!(block_on(lookup(&mut r, "/casper")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_mastered_extensionless_names_match() {
|
||||||
|
// Strict level-1 mastering stores "VMLINUZ" as "VMLINUZ.;1" — the
|
||||||
|
// trailing dot must be normalized away or kernels never match.
|
||||||
|
let img = TestIsoBuilder::new("STRICT")
|
||||||
|
.file("/boot/vmlinuz.", b"K") // builder stores "VMLINUZ.;1"
|
||||||
|
.build();
|
||||||
|
let mut r = MemReadAt(img);
|
||||||
|
assert!(
|
||||||
|
block_on(lookup(&mut r, "/boot/vmlinuz")).is_some(),
|
||||||
|
"trailing-dot ISO9660 name must match the dotless path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_three_levels_deep() {
|
||||||
|
let img = TestIsoBuilder::new("DEEP")
|
||||||
|
.file("/images/pxeboot/vmlinuz", b"ANACONDA")
|
||||||
|
.build();
|
||||||
|
let mut r = MemReadAt(img);
|
||||||
|
let loc = block_on(lookup(&mut r, "images/pxeboot/vmlinuz")).expect("no leading slash ok");
|
||||||
|
assert_eq!(loc.length, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caching_reader_dedupes_repeated_directory_reads() {
|
||||||
|
struct Counting<'a> {
|
||||||
|
inner: &'a mut MemReadAt,
|
||||||
|
calls: usize,
|
||||||
|
}
|
||||||
|
impl IsoReadAt for Counting<'_> {
|
||||||
|
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
|
||||||
|
self.calls += 1;
|
||||||
|
self.inner.read_at(offset, len).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let img = TestIsoBuilder::new("CACHE")
|
||||||
|
.file("/a/one", b"1")
|
||||||
|
.file("/a/two", b"2")
|
||||||
|
.build();
|
||||||
|
let mut mem = MemReadAt(img);
|
||||||
|
let mut counting = Counting {
|
||||||
|
inner: &mut mem,
|
||||||
|
calls: 0,
|
||||||
|
};
|
||||||
|
let mut cr = CachingReadAt::new(&mut counting);
|
||||||
|
assert!(block_on(exists(&mut cr, "/a/one")));
|
||||||
|
assert!(block_on(exists(&mut cr, "/a/two")));
|
||||||
|
assert!(!block_on(exists(&mut cr, "/a/three")));
|
||||||
|
drop(cr);
|
||||||
|
// 3 probes × (PVD + root dir + subdir) = 9 uncached; the cache
|
||||||
|
// collapses the repeats to the 3 distinct extents.
|
||||||
|
assert_eq!(counting.calls, 3, "all repeat reads must hit the cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_local_reads_a_real_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join("t.iso");
|
||||||
|
let img = TestIsoBuilder::new("LOCAL")
|
||||||
|
.file("/sources/boot.wim", b"WIMWIM")
|
||||||
|
.build();
|
||||||
|
std::fs::write(&p, &img).unwrap();
|
||||||
|
let loc = lookup_local(&p, "/sources/boot.wim").expect("found");
|
||||||
|
assert_eq!(loc.length, 6);
|
||||||
|
assert!(lookup_local(&p, "/sources/none").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,15 @@
|
|||||||
|
|
||||||
pub mod entry;
|
pub mod 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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! Persisted cache of remote-share introspection results.
|
||||||
|
//!
|
||||||
|
//! NFS/SFTP introspection costs a connection plus a few dozen small
|
||||||
|
//! reads per ISO. Shares are rescanned on every startup and share-add,
|
||||||
|
//! so without a cache a 40-ISO library would re-probe 40 ISOs on every
|
||||||
|
//! container restart. The cache keys on `share/path@size` — a replaced
|
||||||
|
//! file (new size) re-probes, an untouched one is free — and entries
|
||||||
|
//! only count as hits when their `introspect_rev` matches the current
|
||||||
|
//! logic, so an upgrade that changes detection re-probes everything
|
||||||
|
//! exactly once.
|
||||||
|
//!
|
||||||
|
//! One file per protocol (`nfs_introspect_cache.json`,
|
||||||
|
//! `sftp_introspect_cache.json`) so the two managers never contend over
|
||||||
|
//! one writer.
|
||||||
|
|
||||||
|
use crate::introspect::{IntrospectionReport, INTROSPECT_REV};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Hard cap on cached entries; beyond it the cache resets rather than
|
||||||
|
/// growing unbounded (a cache wipe only costs one re-probe pass).
|
||||||
|
const MAX_ENTRIES: usize = 4096;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RemoteIntrospectCache {
|
||||||
|
path: Arc<PathBuf>,
|
||||||
|
map: Arc<Mutex<HashMap<String, IntrospectionReport>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteIntrospectCache {
|
||||||
|
/// Open (or start empty) the cache at `<work_dir>/<file_name>`.
|
||||||
|
/// A corrupt or missing file is an empty cache, never an error.
|
||||||
|
#[must_use]
|
||||||
|
pub fn open(work_dir: &Path, file_name: &str) -> Self {
|
||||||
|
let path = work_dir.join(file_name);
|
||||||
|
let map = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|text| {
|
||||||
|
serde_json::from_str::<HashMap<String, IntrospectionReport>>(&text).ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
path: Arc::new(path),
|
||||||
|
map: Arc::new(Mutex::new(map)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(share_id: &str, relative_path: &str, size: u64) -> String {
|
||||||
|
format!("{share_id}/{relative_path}@{size}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hit requires the entry to have been produced by the *current*
|
||||||
|
/// introspection logic — stale-rev entries are misses, which is how
|
||||||
|
/// the cache self-invalidates across upgrades.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(
|
||||||
|
&self,
|
||||||
|
share_id: &str,
|
||||||
|
relative_path: &str,
|
||||||
|
size: u64,
|
||||||
|
) -> Option<IntrospectionReport> {
|
||||||
|
self.map
|
||||||
|
.lock()
|
||||||
|
.get(&Self::key(share_id, relative_path, size))
|
||||||
|
.filter(|r| r.introspect_rev == INTROSPECT_REV)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put(&self, share_id: &str, relative_path: &str, size: u64, report: IntrospectionReport) {
|
||||||
|
let snapshot = {
|
||||||
|
let mut g = self.map.lock();
|
||||||
|
if g.len() >= MAX_ENTRIES {
|
||||||
|
g.clear();
|
||||||
|
}
|
||||||
|
g.insert(Self::key(share_id, relative_path, size), report);
|
||||||
|
g.clone()
|
||||||
|
};
|
||||||
|
// Persist outside the lock; tmp+rename so a crash mid-write
|
||||||
|
// leaves the previous cache intact.
|
||||||
|
let path = self.path.as_path();
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
let Ok(body) = serde_json::to_vec_pretty(&snapshot) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if std::fs::write(&tmp, body).is_ok() {
|
||||||
|
let _ = std::fs::rename(&tmp, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::introspect::DistroFamily;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_across_reopen_and_rev_gates() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = RemoteIntrospectCache::open(dir.path(), "t.json");
|
||||||
|
assert!(cache.get("s1", "a.iso", 100).is_none());
|
||||||
|
|
||||||
|
let fresh = IntrospectionReport {
|
||||||
|
family: DistroFamily::RhelFedora,
|
||||||
|
introspect_rev: INTROSPECT_REV,
|
||||||
|
el_torito: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
cache.put("s1", "a.iso", 100, fresh.clone());
|
||||||
|
assert_eq!(
|
||||||
|
cache.get("s1", "a.iso", 100).unwrap().family,
|
||||||
|
DistroFamily::RhelFedora
|
||||||
|
);
|
||||||
|
// Different size = different file = miss.
|
||||||
|
assert!(cache.get("s1", "a.iso", 101).is_none());
|
||||||
|
|
||||||
|
// Survives a reopen.
|
||||||
|
let cache2 = RemoteIntrospectCache::open(dir.path(), "t.json");
|
||||||
|
assert!(cache2.get("s1", "a.iso", 100).is_some());
|
||||||
|
|
||||||
|
// Stale-rev entries never hit.
|
||||||
|
let stale = IntrospectionReport {
|
||||||
|
family: DistroFamily::Arch,
|
||||||
|
introspect_rev: INTROSPECT_REV - 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
cache2.put("s1", "b.iso", 7, stale);
|
||||||
|
assert!(cache2.get("s1", "b.iso", 7).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_cache_file_starts_empty() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("t.json"), b"{nope").unwrap();
|
||||||
|
let cache = RemoteIntrospectCache::open(dir.path(), "t.json");
|
||||||
|
assert!(cache.get("s", "x.iso", 1).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,9 @@
|
|||||||
//! hint}` error shape is shared so the storage tab renders all three
|
//! 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(¶ms, &creds, &job.filename).await?;
|
||||||
|
let report =
|
||||||
|
introspect_reader(&mut reader, job.size, &job.filename, false).await;
|
||||||
|
Ok::<_, SftpClientError>(report)
|
||||||
|
};
|
||||||
|
match tokio::time::timeout(INTROSPECT_TIMEOUT, probe).await {
|
||||||
|
Ok(Ok(report)) => {
|
||||||
|
cache.put(&share_id, &job.filename, job.size, report.clone());
|
||||||
|
if store.update_external_introspection(&job.iso_id, report) {
|
||||||
|
upgraded += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => tracing::warn!(
|
||||||
|
target: "openpxe::sftp",
|
||||||
|
share = %share_id, iso = %job.filename,
|
||||||
|
"introspection failed: {e}"
|
||||||
|
),
|
||||||
|
Err(_) => tracing::warn!(
|
||||||
|
target: "openpxe::sftp",
|
||||||
|
share = %share_id, iso = %job.filename,
|
||||||
|
"introspection timed out after {}s", INTROSPECT_TIMEOUT.as_secs()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::sftp",
|
||||||
|
share = %share_id, queued, upgraded,
|
||||||
|
"remote introspection pass complete"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v0.7.4: locate `in_iso_path` inside a share-hosted ISO. Returns the
|
||||||
|
/// byte range so the HTTP layer can serve kernel/initrd files out of
|
||||||
|
/// remote ISOs with a follow-up ranged [`Self::stream_iso`].
|
||||||
|
pub async fn locate_in_iso(
|
||||||
|
&self,
|
||||||
|
share_id: &str,
|
||||||
|
filename: &str,
|
||||||
|
in_iso_path: &str,
|
||||||
|
) -> Result<Option<FileLocation>> {
|
||||||
|
let share = self
|
||||||
|
.get(share_id)
|
||||||
|
.ok_or_else(|| Error::Invalid(format!("no such SFTP share '{share_id}'")))?;
|
||||||
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
|
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
||||||
|
}
|
||||||
|
let creds = self
|
||||||
|
.read_creds(share_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::Invalid(format!("could not read credentials: {e}")))?;
|
||||||
|
let params = ConnParams::from_share(&share);
|
||||||
|
let mut reader = SftpReadAt::open(¶ms, &creds, filename)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::Invalid(format!("sftp open '{filename}': {e}")))?;
|
||||||
|
Ok(iso_fs::lookup(&mut reader, in_iso_path).await)
|
||||||
|
}
|
||||||
|
|
||||||
fn pin_fingerprint(&self, id: &str, fingerprint: String) {
|
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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
started_at: time::OffsetDateTime::now_utc(),
|
started_at: time::OffsetDateTime::now_utc(),
|
||||||
public_base_url: public_base_url.clone(),
|
public_base_url: public_base_url.clone(),
|
||||||
nic_name: net.nic_name,
|
nic_name: net.nic_name,
|
||||||
|
nic_link: net.nic_link,
|
||||||
subnet_mask: net.subnet_mask,
|
subnet_mask: net.subnet_mask,
|
||||||
gateway: net.gateway,
|
gateway: net.gateway,
|
||||||
};
|
};
|
||||||
@@ -448,6 +449,12 @@ struct NetworkInfo {
|
|||||||
nic_name: String,
|
nic_name: String,
|
||||||
subnet_mask: String,
|
subnet_mask: String,
|
||||||
gateway: String,
|
gateway: String,
|
||||||
|
/// v0.7.2: physical link summary for the Network tab — operstate,
|
||||||
|
/// negotiated speed/duplex, and the port's own MAC. Helps operators
|
||||||
|
/// in multi-NIC / trunked environments confirm *which* port the PXE
|
||||||
|
/// server actually answers on. Empty when sysfs isn't available
|
||||||
|
/// (non-Linux dev builds) or the NIC wasn't identified.
|
||||||
|
nic_link: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort population of the Network tab's read-only fields. We shell
|
/// Best-effort population of the Network tab's read-only fields. We shell
|
||||||
@@ -508,9 +515,44 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info.nic_link = detect_link_info(&info.nic_name);
|
||||||
|
|
||||||
info
|
info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.7.2: read the NIC's physical link details from sysfs. Every field
|
||||||
|
/// is optional — virtual NICs report no speed (`-1` or absent), and
|
||||||
|
/// non-Linux dev machines have no `/sys/class/net` at all — so the
|
||||||
|
/// result is whatever could be read, joined human-readably, or empty.
|
||||||
|
fn detect_link_info(nic: &str) -> String {
|
||||||
|
if nic.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let read = |file: &str| {
|
||||||
|
std::fs::read_to_string(format!("/sys/class/net/{nic}/{file}"))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
let state = read("operstate");
|
||||||
|
if !state.is_empty() {
|
||||||
|
parts.push(format!("link {state}"));
|
||||||
|
}
|
||||||
|
let speed = read("speed");
|
||||||
|
if !speed.is_empty() && speed != "-1" {
|
||||||
|
parts.push(format!("{speed} Mb/s"));
|
||||||
|
}
|
||||||
|
let duplex = read("duplex");
|
||||||
|
if !duplex.is_empty() && duplex != "unknown" {
|
||||||
|
parts.push(format!("{duplex} duplex"));
|
||||||
|
}
|
||||||
|
let mac = read("address");
|
||||||
|
if !mac.is_empty() {
|
||||||
|
parts.push(format!("port {mac}"));
|
||||||
|
}
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
fn prefix_to_dotted(prefix: u8) -> String {
|
fn prefix_to_dotted(prefix: u8) -> String {
|
||||||
let prefix = prefix.min(32);
|
let prefix = prefix.min(32);
|
||||||
let mask: u32 = if prefix == 0 {
|
let mask: u32 = if prefix == 0 {
|
||||||
|
|||||||
@@ -912,3 +912,28 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
|||||||
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
|
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
|
||||||
}
|
}
|
||||||
.modal-actions .submit { width: auto; padding: 8px 18px; }
|
.modal-actions .submit { width: auto; padding: 8px 18px; }
|
||||||
|
|
||||||
|
/* v0.7.2: a label.field directly followed by the card's action button
|
||||||
|
stacked its own 14px bottom margin onto the button's 16px top margin
|
||||||
|
(30px total) — visible on Queue "Launch for all waiting" and the
|
||||||
|
Network "Save". Collapse the doubled gap so every primary action sits
|
||||||
|
the same 16px below its form. */
|
||||||
|
.card .body > label.field:has(+ button) { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* v0.7.2: inline list filter above a table (Available images). The input
|
||||||
|
is wrapped in a label.field so it borrows the standard text-field chrome
|
||||||
|
and matches every other input in the app; this wrapper just insets it
|
||||||
|
from the card edges so it lines up with the header text above. */
|
||||||
|
.list-search { padding: 14px 16px; }
|
||||||
|
|
||||||
|
/* v0.7.4: pager footer under the Available-images table — quiet status
|
||||||
|
text on the left, ghost Prev/Next on the right. */
|
||||||
|
.list-pager {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--fg-dim); font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.list-pager .spacer { flex: 1; }
|
||||||
|
.list-pager button { padding: 4px 12px; font-size: 12px; }
|
||||||
|
.list-pager button:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
|||||||
+210
-141
@@ -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
|
||||||
@@ -199,109 +201,47 @@
|
|||||||
})[k] || (k || 'Unknown');
|
})[k] || (k || 'Unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.7.0: the Boot rules card — ordered first-match-wins rules
|
// v0.7.2: compact read-out of saved group rules — created from the
|
||||||
// (MAC prefix / architecture → target) plus the optional
|
// unified "Pin MAC" form on the Hosts tab (a prefix or an architecture
|
||||||
// boot-decision webhook. Saved as one config because rule order
|
// there saves a rule instead of a pin). First match wins, top to
|
||||||
// matters. With no rules and no webhook, behavior is identical to
|
// bottom. The boot-decision webhook remains available via the API
|
||||||
// before the feature existed.
|
// (/api/boot-rules `webhook_url`) but no longer has a UI knob.
|
||||||
function bootRulesCard(cfg, targetOptions) {
|
function groupRulesCard(cfg, targetOptions) {
|
||||||
const archChoices = [
|
const rules = (cfg && cfg.rules) || [];
|
||||||
['', 'any arch'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
|
if (!rules.length) return null;
|
||||||
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
|
const titleFor = id => {
|
||||||
];
|
const t = targetOptions.find(x => x.id === id);
|
||||||
// v0.7.1: optional first-boot binary pin. "Auto" lets the
|
return t ? t.title : id;
|
||||||
// escalation ladder learn per machine; pinning skips the learning
|
|
||||||
// walk entirely (e.g. a rack known to run Secure Boot → shim).
|
|
||||||
const modeChoices = [
|
|
||||||
['', 'auto (learn)'], ['firmware', 'Firmware NIC'],
|
|
||||||
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
|
|
||||||
];
|
|
||||||
const rules = (cfg.rules || []).map(r => Object.assign({}, r));
|
|
||||||
const tbody = el('tbody', {});
|
|
||||||
const msg = el('div', {class:'msg'});
|
|
||||||
const webhookInput = el('input', {type:'text', spellcheck:'false',
|
|
||||||
placeholder:'http://automation.example/boot-decision (optional)',
|
|
||||||
value: cfg.webhook_url || ''});
|
|
||||||
|
|
||||||
const targetSelect = (val) => el('select', {},
|
|
||||||
[el('option', {value:''}, '— target —')]
|
|
||||||
.concat(targetOptions.map(t =>
|
|
||||||
el('option', Object.assign({value: t.id}, t.id === val ? {selected:''} : {}), t.title))));
|
|
||||||
|
|
||||||
const redraw = () => {
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
if (!rules.length) {
|
|
||||||
tbody.appendChild(el('tr', {}, el('td', {colspan:'7', class:'empty', style:'padding:14px'},
|
|
||||||
'No rules. Add one to route whole groups of machines (an OUI, an architecture) to a target — or to pin a boot binary (e.g. Secure Boot racks → shim, zero failed cycles).')));
|
|
||||||
}
|
|
||||||
rules.forEach((r, i) => {
|
|
||||||
const macIn = el('input', {type:'text', spellcheck:'false', placeholder:'aa:bb:cc (prefix)',
|
|
||||||
value: r.mac_prefix || '', oninput: e => { r.mac_prefix = e.target.value; }});
|
|
||||||
const archSel = el('select', {onchange: e => { r.arch = e.target.value; }},
|
|
||||||
archChoices.map(([v, label]) =>
|
|
||||||
el('option', Object.assign({value: v}, v === (r.arch || '') ? {selected:''} : {}), label)));
|
|
||||||
const tgtSel = targetSelect(r.target || '');
|
|
||||||
tgtSel.onchange = e => { r.target = e.target.value; };
|
|
||||||
const modeSel = el('select', {onchange: e => { r.driver_mode = e.target.value; }},
|
|
||||||
modeChoices.map(([v, label]) =>
|
|
||||||
el('option', Object.assign({value: v}, v === (r.driver_mode || '') ? {selected:''} : {}), label)));
|
|
||||||
const noteIn = el('input', {type:'text', placeholder:'note',
|
|
||||||
value: r.note || '', oninput: e => { r.note = e.target.value; }});
|
|
||||||
const enabled = el('input', {type:'checkbox', onchange: e => { r.enabled = e.target.checked; }});
|
|
||||||
enabled.checked = r.enabled !== false;
|
|
||||||
tbody.appendChild(el('tr', {}, [
|
|
||||||
el('td', {}, macIn),
|
|
||||||
el('td', {}, archSel),
|
|
||||||
el('td', {}, tgtSel),
|
|
||||||
el('td', {}, modeSel),
|
|
||||||
el('td', {}, noteIn),
|
|
||||||
el('td', {style:'text-align:center'}, enabled),
|
|
||||||
el('td', {style:'text-align:right'},
|
|
||||||
el('button', {class:'danger', onclick: () => { rules.splice(i, 1); redraw(); }}, '✕')),
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
redraw();
|
const modeLabel = {firmware:'Firmware NIC', builtin:'iPXE drivers', shim:'Secure Boot (shim)'};
|
||||||
|
const rows = rules.map((r, i) => el('tr', r.enabled === false ? {style:'opacity:.5'} : {}, [
|
||||||
const addBtn = el('button', {class:'ghost', onclick: () => {
|
el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')),
|
||||||
rules.push({mac_prefix:'', arch:'', target:'', enabled:true, note:''});
|
el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')),
|
||||||
redraw();
|
el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')),
|
||||||
}}, '+ Add rule');
|
el('td', {}, r.driver_mode
|
||||||
const saveBtn = el('button', {onclick: async () => {
|
? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode)
|
||||||
// A rule needs at least one effect: a target or a boot-binary pin.
|
: el('span', {class:'tag'}, 'auto')),
|
||||||
const bad = rules.find(r => r.enabled !== false && !r.target && !r.driver_mode);
|
el('td', {}, r.note || ''),
|
||||||
if (bad) { msg.textContent = 'Every enabled rule needs a target or a boot-binary pin.'; msg.className = 'msg err'; return; }
|
el('td', {style:'text-align:right'},
|
||||||
const r = await putJSON('/api/boot-rules', {rules, webhook_url: webhookInput.value.trim()});
|
el('button', {class:'danger', onclick: async () => {
|
||||||
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; }
|
if (!confirm('Remove this group rule?')) return;
|
||||||
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
const fresh = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||||||
}}, 'Save rules');
|
(fresh.rules = fresh.rules || []).splice(i, 1);
|
||||||
|
await putJSON('/api/boot-rules', fresh);
|
||||||
|
render('hosts');
|
||||||
|
}}, 'Remove')),
|
||||||
|
]));
|
||||||
return el('div', {class:'card'}, [
|
return el('div', {class:'card'}, [
|
||||||
el('header', {}, [
|
el('header', {}, [
|
||||||
el('h2', {}, 'Boot rules'),
|
el('h2', {}, 'Group rules'),
|
||||||
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
|
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'body'}, [
|
el('table', {}, [
|
||||||
el('table', {}, [
|
el('thead', {}, el('tr', {}, [
|
||||||
el('thead', {}, el('tr', {}, [
|
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
|
||||||
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
|
el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''),
|
||||||
el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},'On'), el('th',{},''),
|
])),
|
||||||
])),
|
el('tbody', {}, rows),
|
||||||
tbody,
|
|
||||||
]),
|
|
||||||
el('div', {style:'margin-top:12px'}, [addBtn, saveBtn]),
|
|
||||||
el('label', {class:'field', style:'margin-top:16px;display:block'}, [
|
|
||||||
el('span', {class:'name'}, 'Boot-decision webhook (optional)'),
|
|
||||||
webhookInput,
|
|
||||||
el('span', {class:'hint'},
|
|
||||||
'When no pin or rule matches, OpenPXE GETs this URL with ?mac=…&arch=… ' +
|
|
||||||
'A 200 reply of {"target": "<entry-id>"} chains to that target; anything ' +
|
|
||||||
'else (404, timeout, error) falls through to the menu — a dead endpoint ' +
|
|
||||||
'can never block PXE.'),
|
|
||||||
]),
|
|
||||||
msg,
|
|
||||||
el('p', {class:'msg', style:'margin-top:10px'},
|
|
||||||
'Decision order per boot: exact MAC pin → first matching rule → webhook → interactive menu.'),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -329,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,
|
||||||
@@ -507,6 +447,12 @@
|
|||||||
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. ' +
|
||||||
@@ -896,6 +842,12 @@
|
|||||||
render('storage');
|
render('storage');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v0.7.2: searchable haystack for the list filter — filename,
|
||||||
|
// detected family, category, and source all match.
|
||||||
|
const searchText = [
|
||||||
|
i.filename, familyLabel(i.introspection.family), i.category || '',
|
||||||
|
isSmb ? 'smb' : isNfs ? 'nfs' : 'local', i.id,
|
||||||
|
].join(' ').toLowerCase();
|
||||||
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
||||||
el('td', {}, [
|
el('td', {}, [
|
||||||
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
||||||
@@ -940,8 +892,42 @@
|
|||||||
}}, 'Remove'),
|
}}, 'Remove'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
tr.dataset.search = searchText;
|
||||||
rowsAndEditors.push(tr, editorRow);
|
rowsAndEditors.push(tr, editorRow);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v0.7.2: client-side filter over the image table; v0.7.4: paged
|
||||||
|
// 5 at a time so a 50-image library doesn't become a scroll wall.
|
||||||
|
// Rows travel in (row, password-editor) pairs. One view function
|
||||||
|
// applies filter-then-page; editors close on any view change.
|
||||||
|
const ISO_PAGE_SIZE = 5;
|
||||||
|
let isoPage = 0;
|
||||||
|
const pagerInfo = el('span', {});
|
||||||
|
const prevBtn = el('button', {class:'ghost', onclick: () => { isoPage -= 1; applyIsoListView(); }}, '‹ Prev');
|
||||||
|
const nextBtn = el('button', {class:'ghost', onclick: () => { isoPage += 1; applyIsoListView(); }}, 'Next ›');
|
||||||
|
function applyIsoListView() {
|
||||||
|
const q = isoSearch.value.trim().toLowerCase();
|
||||||
|
const visible = [];
|
||||||
|
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
|
||||||
|
const row = rowsAndEditors[k];
|
||||||
|
rowsAndEditors[k + 1].style.display = 'none';
|
||||||
|
row.style.display = 'none';
|
||||||
|
if (!q || (row.dataset.search || '').includes(q)) visible.push(row);
|
||||||
|
}
|
||||||
|
const pages = Math.max(1, Math.ceil(visible.length / ISO_PAGE_SIZE));
|
||||||
|
if (isoPage >= pages) isoPage = pages - 1;
|
||||||
|
if (isoPage < 0) isoPage = 0;
|
||||||
|
visible.slice(isoPage * ISO_PAGE_SIZE, (isoPage + 1) * ISO_PAGE_SIZE)
|
||||||
|
.forEach(r => { r.style.display = ''; });
|
||||||
|
pagerInfo.textContent = visible.length
|
||||||
|
? 'Showing ' + (isoPage * ISO_PAGE_SIZE + 1) + '–' +
|
||||||
|
Math.min(visible.length, (isoPage + 1) * ISO_PAGE_SIZE) + ' of ' + visible.length
|
||||||
|
: 'No images match';
|
||||||
|
prevBtn.disabled = isoPage === 0;
|
||||||
|
nextBtn.disabled = isoPage >= pages - 1;
|
||||||
|
}
|
||||||
|
const isoSearch = el('input', {type:'search', placeholder:'Filter images by name, type, or source',
|
||||||
|
spellcheck:'false', oninput: () => { isoPage = 0; applyIsoListView(); }});
|
||||||
const isoTable = isos.length
|
const isoTable = isos.length
|
||||||
? el('table', {}, [
|
? el('table', {}, [
|
||||||
el('thead', {}, el('tr', {}, [
|
el('thead', {}, el('tr', {}, [
|
||||||
@@ -953,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
|
||||||
@@ -1279,7 +1272,10 @@
|
|||||||
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
||||||
|
|
||||||
const unattRows = unattendedFiles.length
|
const unattRows = unattendedFiles.length
|
||||||
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [
|
? unattendedFiles.map(f => el('div', {
|
||||||
|
class:'nfs-row',
|
||||||
|
'data-search': (f.filename + ' ' + unattendedKindLabel(f.kind) + ' ' + f.id).toLowerCase(),
|
||||||
|
}, [
|
||||||
el('span', {class:'dot ok'}),
|
el('span', {class:'dot ok'}),
|
||||||
el('div', {}, [
|
el('div', {}, [
|
||||||
el('div', {class:'id'}, [
|
el('div', {class:'id'}, [
|
||||||
@@ -1307,6 +1303,17 @@
|
|||||||
]),
|
]),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
unattDrop, unattFile, unattMsg,
|
unattDrop, unattFile, unattMsg,
|
||||||
|
// v0.7.2: filter for big answer-file libraries.
|
||||||
|
unattendedFiles.length > 1 ? (() => {
|
||||||
|
const search = el('input', {type:'search', placeholder:'Filter files by name or kind',
|
||||||
|
spellcheck:'false', oninput: () => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
unattRows.forEach(r => {
|
||||||
|
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}});
|
||||||
|
return el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, search);
|
||||||
|
})() : null,
|
||||||
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
el('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'},
|
||||||
'These answer files drive unattended installs. Attach one to a ' +
|
'These answer files drive unattended installs. Attach one to a ' +
|
||||||
@@ -1353,7 +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'}, el('label', {class:'field', style:'margin-bottom:0'}, isoSearch))
|
||||||
|
: null,
|
||||||
isoTable,
|
isoTable,
|
||||||
|
isoPager,
|
||||||
]),
|
]),
|
||||||
]), unattendedAdvanced]);
|
]), unattendedAdvanced]);
|
||||||
},
|
},
|
||||||
@@ -1378,8 +1389,22 @@
|
|||||||
{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', 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
|
||||||
|
// form. A full MAC with no architecture saves a per-host pin
|
||||||
|
// exactly as before; a MAC *prefix* and/or an architecture saves a
|
||||||
|
// first-match-wins group rule instead. Same form, one mental model.
|
||||||
|
const archSel = el('select', {}, [
|
||||||
|
['', 'any (this exact MAC)'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
|
||||||
|
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
|
||||||
|
].map(([v, t]) => el('option', {value: v}, t)));
|
||||||
|
// v0.7.1's boot-binary pin keeps its home here too (auto = let the
|
||||||
|
// escalation ladder learn; shim = known Secure Boot fleet).
|
||||||
|
const binSel = el('select', {}, [
|
||||||
|
['', 'auto (learn per machine)'], ['firmware', 'Firmware NIC'],
|
||||||
|
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
|
||||||
|
].map(([v, t]) => el('option', {value: v}, t)));
|
||||||
const targetSel = el('select', {},
|
const targetSel = el('select', {},
|
||||||
[el('option', {value:''}, '— choose a target —')]
|
[el('option', {value:''}, '— choose a target —')]
|
||||||
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
||||||
@@ -1392,21 +1417,40 @@
|
|||||||
// hostname/IP templated into the served answer file.
|
// hostname/IP templated into the served answer file.
|
||||||
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
|
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
|
||||||
|
|
||||||
|
const FULL_MAC = /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i;
|
||||||
const upsertBtn = el('button', {onclick: async () => {
|
const upsertBtn = el('button', {onclick: async () => {
|
||||||
if (!macInput.value || !targetSel.value) {
|
const mac = macInput.value.trim();
|
||||||
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
const isGroup = !!archSel.value || !!binSel.value || (mac !== '' && !FULL_MAC.test(mac));
|
||||||
|
if (!isGroup) {
|
||||||
|
// Exact-MAC pin — unchanged behavior.
|
||||||
|
if (!mac || !targetSel.value) {
|
||||||
|
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
const r = await postJSON('/api/hosts', Object.assign({
|
||||||
|
mac, target: targetSel.value, label: labelInput.value,
|
||||||
|
}, profileFields.read()));
|
||||||
|
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||||||
|
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const r = await postJSON('/api/hosts', Object.assign({
|
// Group rule (prefix and/or architecture). Per-host profile
|
||||||
mac: macInput.value, target: targetSel.value, label: labelInput.value,
|
// fields don't apply to a group — they're per-machine values.
|
||||||
}, profileFields.read()));
|
if (!targetSel.value && !binSel.value) {
|
||||||
if (r.ok) {
|
msg.textContent = 'A group rule needs a target or a boot binary.'; msg.className = 'msg err'; return;
|
||||||
msg.textContent = 'Saved.'; msg.className = 'msg ok';
|
|
||||||
render('hosts');
|
|
||||||
} else {
|
|
||||||
const t = await r.text();
|
|
||||||
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
|
|
||||||
}
|
}
|
||||||
}}, 'Bind MAC to target');
|
const p = profileFields.read();
|
||||||
|
if (p.auto_hostname || p.auto_ip || p.unattended_file) {
|
||||||
|
msg.textContent = 'Auto-deploy fields are per-machine — clear them, or use a full MAC.'; msg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
const cfg = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||||||
|
(cfg.rules = cfg.rules || []).push({
|
||||||
|
mac_prefix: mac, arch: archSel.value, target: targetSel.value,
|
||||||
|
driver_mode: binSel.value, enabled: true, note: labelInput.value,
|
||||||
|
});
|
||||||
|
const r = await putJSON('/api/boot-rules', cfg);
|
||||||
|
if (r.ok) { msg.textContent = 'Group rule saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||||||
|
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||||
|
}}, 'Bind to target');
|
||||||
|
|
||||||
const rows = hosts.map(h => {
|
const rows = hosts.map(h => {
|
||||||
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
||||||
@@ -1463,23 +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'}, [
|
||||||
|
// v0.7.3: MAC · Label · Architecture · Boot binary share one
|
||||||
|
// 4-up row so the controls line up across the page; the
|
||||||
|
// per-field guidance that used to sit under them moved into the
|
||||||
|
// note below to keep the inputs flush. Target spans full width
|
||||||
|
// on its own line beneath them.
|
||||||
el('div', {class:'form-row'}, [
|
el('div', {class:'form-row'}, [
|
||||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address or prefix'), macInput]),
|
||||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
||||||
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Architecture (optional)'), archSel]),
|
||||||
el('span', {class:'name'}, 'Target'),
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Boot binary (optional)'), binSel]),
|
||||||
targetSel,
|
]),
|
||||||
el('span', {class:'hint'},
|
el('label', {class:'field', style:'margin-top:14px'}, [
|
||||||
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
|
el('span', {class:'name'}, 'Target'),
|
||||||
]),
|
targetSel,
|
||||||
]),
|
]),
|
||||||
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 client with a bound MAC requests boot.ipxe, OpenPXE ' +
|
'A full MAC pins one machine; a MAC prefix (OUI) or an architecture ' +
|
||||||
'short-circuits past the interactive menu and chains directly. ' +
|
'saves a first-match group rule. Pin the boot binary to “shim” for ' +
|
||||||
'If an unattended file is selected, the matching kernel argument ' +
|
'Secure Boot racks — zero failed boot cycles. When a matching client ' +
|
||||||
'is injected and the hostname/IP are templated into the answer file.'),
|
'requests boot.ipxe, OpenPXE short-circuits past the interactive menu ' +
|
||||||
|
'and chains directly; decision order is exact MAC pin → first matching ' +
|
||||||
|
'group rule → menu. If an unattended file is selected on a pin, the ' +
|
||||||
|
'matching kernel argument is injected and the hostname/IP are templated ' +
|
||||||
|
'into the answer file.'),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
@@ -1489,7 +1542,7 @@
|
|||||||
]),
|
]),
|
||||||
table,
|
table,
|
||||||
]),
|
]),
|
||||||
bootRulesCard(rulesCfg, reserved.concat(targets)),
|
groupRulesCard(rulesCfg, reserved.concat(targets)),
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, [
|
el('header', {}, [
|
||||||
el('h2', {}, 'Host log'),
|
el('h2', {}, 'Host log'),
|
||||||
@@ -2166,9 +2219,24 @@
|
|||||||
el('div', {class:'about-hero'}, [
|
el('div', {class:'about-hero'}, [
|
||||||
el('h2', {}, 'OpenPXE'),
|
el('h2', {}, 'OpenPXE'),
|
||||||
el('p', {class:'lead'},
|
el('p', {class:'lead'},
|
||||||
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
|
'The network-boot platform for modern infrastructure. Drop in an ISO ' +
|
||||||
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
|
'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
|
||||||
'the image once, run it forever.'),
|
'boot it, image from it, and install unattended. One container, one ' +
|
||||||
|
'static binary, nothing installed on clients, nothing leaving your network.'),
|
||||||
|
el('div', {style:'display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0'}, [
|
||||||
|
['Boot anything', 'Linux, Windows, hypervisors, rescue tools — uploaded ' +
|
||||||
|
'ISOs become menu entries automatically, served on demand from local ' +
|
||||||
|
'disk or your existing NFS, SMB, or SFTP libraries.'],
|
||||||
|
['Adapt to every machine', 'Per-machine boot intelligence: firmware quirks, ' +
|
||||||
|
'NIC driver fallback, and a Microsoft-signed Secure Boot chain are ' +
|
||||||
|
'negotiated automatically and remembered — no toggles, no client prep.'],
|
||||||
|
['Run it in production', 'SAML single sign-on, token-scoped answer files, ' +
|
||||||
|
'fleet routing rules, Wake-on-LAN, queued mass deployment, Prometheus ' +
|
||||||
|
'metrics. Built in Rust for boot infrastructure that cannot flinch.'],
|
||||||
|
].map(([h, body]) => el('div', {}, [
|
||||||
|
el('h3', {style:'margin:0 0 6px;font-size:13.5px'}, h),
|
||||||
|
el('p', {class:'msg', style:'font-size:12px;margin:0'}, body),
|
||||||
|
]))),
|
||||||
el('div', {class:'who'}, [
|
el('div', {class:'who'}, [
|
||||||
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
||||||
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
||||||
@@ -2179,15 +2247,16 @@
|
|||||||
]),
|
]),
|
||||||
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
||||||
el('p', {class:'msg', style:'margin-top:18px'},
|
el('p', {class:'msg', style:'margin-top:18px'},
|
||||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
'Private by design: no telemetry, no CDN calls, no runtime ' +
|
||||||
'executes is generated from the settings on these tabs — there is no ' +
|
'dependencies on the outside world. Air-gapped labs, customer sites ' +
|
||||||
'hand-written .ipxe path anywhere in this product.'),
|
'without internet, and locked-down OpenShift clusters run the same ' +
|
||||||
|
'image, the same way, indefinitely.'),
|
||||||
el('p', {class:'msg'},
|
el('p', {class:'msg'},
|
||||||
'Vision: a deployment-grade tool that works on first try in the most ' +
|
'Principled by default: OpenPXE never asks an operator to install ' +
|
||||||
'awkward environments — air-gapped labs, customer sites without ' +
|
'test-signed drivers, modify a client’s trust store, or weaken ' +
|
||||||
'internet, OpenShift clusters with strict SCCs — without ever asking ' +
|
'Secure Boot. Everything the firmware executes is generated from the ' +
|
||||||
'an operator to install drivers signed with test certificates or to ' +
|
'settings on these tabs — there are no hand-written boot scripts to ' +
|
||||||
'flip "testsigning" on a target machine.'),
|
'maintain and no internals to learn.'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user