Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f98e6e03f | ||
|
|
1c262a6d61 | ||
|
|
27703c437a | ||
|
|
1ded291c7b | ||
|
|
6524aa4118 | ||
|
|
934cfbab46 |
Generated
+9
-136
@@ -175,45 +175,6 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
@@ -1144,20 +1105,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der_derive"
|
||||
version = "0.7.3"
|
||||
@@ -2445,7 +2392,7 @@ dependencies = [
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom 8.0.0",
|
||||
"nom",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls",
|
||||
@@ -2564,12 +2511,6 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2702,16 +2643,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
@@ -2803,15 +2734,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2836,7 +2758,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openpxe"
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2848,17 +2770,15 @@ dependencies = [
|
||||
"openpxe-ipxe-assets",
|
||||
"openpxe-iso-store",
|
||||
"openpxe-tftp",
|
||||
"serde",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-core"
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -2867,7 +2787,6 @@ dependencies = [
|
||||
"figment",
|
||||
"flate2",
|
||||
"parking_lot",
|
||||
"quick-xml",
|
||||
"rcgen",
|
||||
"roxmltree",
|
||||
"serde",
|
||||
@@ -2880,29 +2799,26 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-dhcp-proxy"
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"dhcproto",
|
||||
"openpxe-core",
|
||||
"parking_lot",
|
||||
"serde_json",
|
||||
"socket2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-http-api"
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2910,7 +2826,6 @@ dependencies = [
|
||||
"bergshamra",
|
||||
"bytes",
|
||||
"futures",
|
||||
"hyper",
|
||||
"image",
|
||||
"insta",
|
||||
"lettre",
|
||||
@@ -2924,7 +2839,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -2938,17 +2852,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-ipxe-assets"
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"openpxe-core",
|
||||
"rust-embed",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-iso-store"
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
@@ -2967,31 +2880,26 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-tftp"
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"openpxe-core",
|
||||
"openpxe-ipxe-assets",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-webui"
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
@@ -3438,15 +3346,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -3933,15 +3832,6 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -5644,23 +5534,6 @@ dependencies = [
|
||||
"tls_codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
|
||||
+1
-3
@@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -82,8 +82,6 @@ lettre = { version = "0.11", default-features = false, features = ["smtp-transpo
|
||||
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
|
||||
bergshamra = "0.5"
|
||||
roxmltree = "0.21"
|
||||
quick-xml = "0.40"
|
||||
x509-parser = "0.18"
|
||||
# flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the
|
||||
# zlib/zlib-ng C backends, which would break the musl-static build.
|
||||
flate2 = "1.1"
|
||||
|
||||
@@ -65,7 +65,10 @@ OpenPXE collapses that whole stack into **one statically-linked binary in one co
|
||||
hierarchy — generated fresh on every request from current settings.
|
||||
|
||||
#### ISO management & remote libraries
|
||||
- **Drag-and-drop chunked uploads** that don't 502 on multi-GB images.
|
||||
- **Drag-and-drop chunked uploads** that don't 502 on multi-GB images — drop several at once
|
||||
and they upload concurrently.
|
||||
- **Add by URL** — paste an ISO link and the server streams it straight into storage and
|
||||
auto-detects it, with progress; no download-then-reupload.
|
||||
- **Automatic introspection** — detects the distro family and generates the right
|
||||
kernel+initrd or Windows `wimboot` chain. No manual config.
|
||||
- **Remote ISO libraries, streamed on demand** (no local cache) over **SMB, NFS, or SFTP** —
|
||||
@@ -88,6 +91,9 @@ OpenPXE collapses that whole stack into **one statically-linked binary in one co
|
||||
- **Prometheus `/metrics`**, a built-in operator **terminal**, live tracing log, and
|
||||
`/healthz` · `/readyz` probes.
|
||||
- **Layered config** — defaults → TOML file → `OPENPXE_*` env, in that order.
|
||||
- **Zero-touch first run** — set `OPENPXE_ADMIN_USERNAME` + `OPENPXE_ADMIN_PASSWORD` and a
|
||||
fresh container comes up with the admin already created, no setup wizard. Automate the rest
|
||||
from scripts/Postman with a per-install **API key** (`x-api-key`), shown in Settings → Advanced.
|
||||
|
||||
## Built in Rust
|
||||
|
||||
@@ -236,6 +242,8 @@ All settings have defaults and layer **defaults → TOML (`--config` / `OPENPXE_
|
||||
| `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Uploaded ISOs |
|
||||
| `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch, settings, share + branding state |
|
||||
| `OPENPXE_LOG` | `info,openpxe=info` | `tracing` filter |
|
||||
| `OPENPXE_ADMIN_USERNAME` | — | First-run only: with `OPENPXE_ADMIN_PASSWORD`, auto-creates the admin so no setup wizard is needed. Ignored once an admin exists. |
|
||||
| `OPENPXE_ADMIN_PASSWORD` | — | First-run admin password. Use `OPENPXE_ADMIN_PASSWORD_FILE` to read it from a file (Docker/K8s secret). |
|
||||
|
||||
## OpenShift
|
||||
|
||||
|
||||
@@ -27,13 +27,11 @@ tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
|
||||
bcrypt.workspace = true
|
||||
|
||||
# v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive
|
||||
# c14n (no OpenSSL/C). roxmltree/quick-xml parse + build SAML XML;
|
||||
# x509-parser pulls the IdP signing cert out of metadata; flate2+base64
|
||||
# encode the HTTP-Redirect binding's SAMLRequest.
|
||||
# c14n (no OpenSSL/C), plus IdP signing-cert extraction from metadata.
|
||||
# roxmltree parses the SAML/metadata XML; flate2+base64 encode the
|
||||
# HTTP-Redirect binding's SAMLRequest.
|
||||
bergshamra.workspace = true
|
||||
roxmltree.workspace = true
|
||||
quick-xml.workspace = true
|
||||
x509-parser.workspace = true
|
||||
flate2.workspace = true
|
||||
base64.workspace = true
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Operator API key — a single persisted secret that authenticates
|
||||
//! programmatic `/api/*` callers (Postman, scripts, CI) via the
|
||||
//! `x-api-key` header, as an alternative to the browser session cookie.
|
||||
//!
|
||||
//! Generated on first load and persisted to `<work_dir>/api_key.json` so
|
||||
//! it survives restarts — an operator pastes it into their client once.
|
||||
//! Regenerable from Settings → Advanced; the previous key stops working
|
||||
//! the moment a new one is minted. Grants the same access as a logged-in
|
||||
//! operator (the middleware treats a valid key exactly like a session).
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ApiKeyFile {
|
||||
key: String,
|
||||
}
|
||||
|
||||
/// Persisted operator API key. Cheap to clone (Arc-shared); contention is
|
||||
/// nil (read on every authenticated request, written only on regenerate).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyStore {
|
||||
path: Arc<PathBuf>,
|
||||
inner: Arc<RwLock<String>>,
|
||||
}
|
||||
|
||||
impl ApiKeyStore {
|
||||
/// Load the stored key, minting + persisting a fresh one on first run
|
||||
/// (or when the file is missing / corrupt / empty).
|
||||
#[must_use]
|
||||
pub fn load_or_init(work_dir: &std::path::Path) -> Self {
|
||||
let path = work_dir.join("api_key.json");
|
||||
let key = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str::<ApiKeyFile>(&text)
|
||||
.map(|f| f.key)
|
||||
.ok()
|
||||
.filter(|k| !k.is_empty())
|
||||
.unwrap_or_else(generate_key),
|
||||
Err(_) => generate_key(),
|
||||
};
|
||||
let store = Self {
|
||||
path: Arc::new(path),
|
||||
inner: Arc::new(RwLock::new(key)),
|
||||
};
|
||||
// Land a first-run (or repaired) key on disk immediately so it's
|
||||
// stable across the very next restart.
|
||||
store.persist();
|
||||
store
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn current(&self) -> String {
|
||||
self.inner.read().clone()
|
||||
}
|
||||
|
||||
/// Constant-time comparison against the stored key. An empty candidate
|
||||
/// never matches, so a blank/absent header can't authenticate.
|
||||
#[must_use]
|
||||
pub fn verify(&self, candidate: &str) -> bool {
|
||||
if candidate.is_empty() {
|
||||
return false;
|
||||
}
|
||||
ct_eq(self.inner.read().as_bytes(), candidate.as_bytes())
|
||||
}
|
||||
|
||||
/// Mint a fresh key, persist it, and return it. The previous key is
|
||||
/// invalid the instant this returns.
|
||||
#[must_use]
|
||||
pub fn regenerate(&self) -> String {
|
||||
let key = generate_key();
|
||||
self.inner.write().clone_from(&key);
|
||||
self.persist();
|
||||
tracing::info!(target: "openpxe::auth", "operator API key regenerated");
|
||||
key
|
||||
}
|
||||
|
||||
fn persist(&self) {
|
||||
let body = match serde_json::to_vec_pretty(&ApiKeyFile {
|
||||
key: self.current(),
|
||||
}) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "openpxe::auth", "serialize api_key.json: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, body) {
|
||||
tracing::warn!(target: "openpxe::auth", "write api_key.json tmp: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
||||
tracing::warn!(target: "openpxe::auth", "rename api_key.json: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 128 random bits as 32 lowercase hex chars — unambiguous to copy-paste
|
||||
/// into an `x-api-key` header. UUID v4 is already our CSPRNG-backed source
|
||||
/// for session ids, so no new dependency.
|
||||
fn generate_key() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
/// Length-checked constant-time byte compare — keeps key verification from
|
||||
/// leaking the matched-prefix length via timing. A 128-bit random secret
|
||||
/// isn't practically timing-attackable over a network, but the check is
|
||||
/// four lines, so we keep it.
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn generates_persists_and_reloads() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = ApiKeyStore::load_or_init(dir.path());
|
||||
let k = s.current();
|
||||
assert_eq!(k.len(), 32, "32 hex chars = 128 bits");
|
||||
assert!(s.verify(&k));
|
||||
assert!(!s.verify("wrong"));
|
||||
assert!(!s.verify(""), "blank header must not authenticate");
|
||||
// Reload from disk → same key (survives restart).
|
||||
let s2 = ApiKeyStore::load_or_init(dir.path());
|
||||
assert_eq!(s2.current(), k);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regenerate_invalidates_old() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = ApiKeyStore::load_or_init(dir.path());
|
||||
let old = s.current();
|
||||
let new = s.regenerate();
|
||||
assert_ne!(old, new);
|
||||
assert!(s.verify(&new));
|
||||
assert!(!s.verify(&old), "old key must stop working");
|
||||
// Persisted: a reload sees the new key.
|
||||
let s2 = ApiKeyStore::load_or_init(dir.path());
|
||||
assert_eq!(s2.current(), new);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! runtime settings, and the Queued Deployment queue.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod api_key;
|
||||
pub mod arch;
|
||||
pub mod auth;
|
||||
pub mod boot_log;
|
||||
@@ -23,6 +24,7 @@ pub mod settings;
|
||||
pub mod sso;
|
||||
pub mod wol;
|
||||
|
||||
pub use api_key::ApiKeyStore;
|
||||
pub use arch::{ClientArch, DriverMode, FirmwareClass};
|
||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||
pub use boot_log::{BootEvent, BootLog};
|
||||
|
||||
@@ -15,9 +15,7 @@ tokio.workspace = true
|
||||
socket2.workspace = true
|
||||
dhcproto.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
parking_lot.workspace = true
|
||||
# v0.7.1: learned driver modes persist to <work_dir>/driver_modes.json.
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -25,11 +25,9 @@ time.workspace = true
|
||||
axum.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
hyper.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
futures.workspace = true
|
||||
@@ -49,6 +47,10 @@ base64.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
|
||||
tower = { workspace = true }
|
||||
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 }
|
||||
time = { workspace = true }
|
||||
# v0.4.61: integration tests need to generate real PNG bytes for the
|
||||
|
||||
+114
-8
@@ -19,7 +19,6 @@ use crate::ipxe_script::{
|
||||
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
|
||||
render_queue_entry, render_shell, render_tools_menu, render_util,
|
||||
};
|
||||
use crate::iso_fs;
|
||||
use crate::log_stream;
|
||||
use crate::state::AppState;
|
||||
use crate::terminal;
|
||||
@@ -36,6 +35,7 @@ use openpxe_core::{
|
||||
LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
|
||||
};
|
||||
use openpxe_ipxe_assets::asset_slice;
|
||||
use openpxe_iso_store::iso_fs;
|
||||
use openpxe_iso_store::{
|
||||
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
|
||||
SmbState, UnattendedKind, UnattendedMeta,
|
||||
@@ -94,6 +94,17 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// JSON API.
|
||||
.route("/api/isos", get(api_list_isos).post(api_upload_iso))
|
||||
.route("/api/isos/{id}", delete(api_delete_iso))
|
||||
// v0.8.1: add ISO by URL — server-side streaming download + progress
|
||||
// polling. Static `fetch` coexists with `{id}` above (matchit
|
||||
// prioritizes the literal), same as `/api/queue/join` vs `{entry_id}`.
|
||||
.route(
|
||||
"/api/isos/fetch",
|
||||
get(crate::fetch::api_iso_fetch_list).post(crate::fetch::api_iso_fetch_start),
|
||||
)
|
||||
.route(
|
||||
"/api/isos/fetch/{id}",
|
||||
delete(crate::fetch::api_iso_fetch_cancel),
|
||||
)
|
||||
.route("/api/uploads", post(api_upload_begin))
|
||||
.route(
|
||||
"/api/uploads/{upload_id}",
|
||||
@@ -147,6 +158,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/logout", post(auth_api::api_logout))
|
||||
.route("/api/me", get(auth_api::api_me))
|
||||
.route("/api/me/credentials", put(auth_api::api_update_credentials))
|
||||
// v0.8.0: operator API key surface (read current + regenerate).
|
||||
// Gated by require_auth like the rest of /api/*; a logged-in
|
||||
// operator or an x-api-key holder can read/rotate it.
|
||||
.route("/api/api-key", get(auth_api::api_api_key_get))
|
||||
.route(
|
||||
"/api/api-key/regenerate",
|
||||
post(auth_api::api_api_key_regenerate),
|
||||
)
|
||||
// SAML SSO configuration (FleetDM-shaped). Gated behind auth — the
|
||||
// operator pastes their IdP metadata, Entity ID, and toggles here.
|
||||
.route("/api/sso", get(api_sso_get).put(api_sso_put))
|
||||
@@ -1087,16 +1106,17 @@ async fn iso_file(
|
||||
State(state): State<AppState>,
|
||||
AxumPath((id, path)): AxumPath<(String, String)>,
|
||||
) -> Response {
|
||||
// In-ISO file extraction is only supported for local ISOs — it
|
||||
// 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 {
|
||||
let Some(meta) = state.iso_store.get(&id) else {
|
||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||
};
|
||||
let in_path = format!("/{path}");
|
||||
match &meta.source {
|
||||
IsoSource::Local => {
|
||||
let Some(iso_path) = state.iso_store.local_path(&meta) else {
|
||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||
};
|
||||
let p = iso_path.clone();
|
||||
let in_path = format!("/{path}");
|
||||
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
|
||||
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup_local(&p, &in_path))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
@@ -1107,6 +1127,82 @@ async fn iso_file(
|
||||
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(
|
||||
@@ -1772,6 +1868,12 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"summary": "Clear an ISO's boot password."},
|
||||
{"method": "PUT", "path": "/api/isos/{id}/category",
|
||||
"summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."},
|
||||
{"method": "POST", "path": "/api/isos/fetch",
|
||||
"summary": "Add an ISO by URL. Body: { \"url\", \"filename\"? }. The server streams the .iso into storage and introspects it. Returns { fetch_id }."},
|
||||
{"method": "GET", "path": "/api/isos/fetch",
|
||||
"summary": "Poll URL-fetch progress — [{ id, filename, url, downloaded, total, state }]. Completed jobs are returned once."},
|
||||
{"method": "DELETE", "path": "/api/isos/fetch/{id}",
|
||||
"summary": "Cancel an in-flight URL fetch, or dismiss a finished/failed one."},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1867,6 +1969,10 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"summary": "Auth status — { setup_required, authenticated, user }. Always 200."},
|
||||
{"method": "PUT", "path": "/api/me/credentials",
|
||||
"summary": "Rotate the admin's credentials. Body: { current_password, new_username?, new_password? }. Revokes all other sessions on success."},
|
||||
{"method": "GET", "path": "/api/api-key",
|
||||
"summary": "Return the operator API key + the header to send it in (x-api-key). That header authenticates API calls without a browser session — full operator access."},
|
||||
{"method": "POST", "path": "/api/api-key/regenerate",
|
||||
"summary": "Mint a fresh API key, invalidating the previous one immediately."},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -52,6 +52,12 @@ const SESSION_TTL: Duration = Duration::from_hours(24);
|
||||
/// to avoid collisions with anything else sharing the host.
|
||||
pub const SESSION_COOKIE: &str = "openpxe_session";
|
||||
|
||||
/// Header an API client sends to authenticate without a browser session.
|
||||
/// Matches the de-facto `x-api-key` convention operators already use with
|
||||
/// other appliances. A valid key grants the same access as a logged-in
|
||||
/// operator. See [`crate::state::AppState::api_key`].
|
||||
pub const API_KEY_HEADER: &str = "x-api-key";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Session {
|
||||
username: String,
|
||||
@@ -223,14 +229,19 @@ pub async fn require_auth(
|
||||
if is_public_path(path) {
|
||||
return next.run(req).await;
|
||||
}
|
||||
// Authenticated path. The cookie must be present, map to a live
|
||||
// session, and the TTL refresh happens as a side-effect.
|
||||
let token = parse_cookie(req.headers());
|
||||
if let Some(t) = token {
|
||||
if state.sessions.touch(&t).is_some() {
|
||||
// Authenticated path: either a live operator session cookie (the
|
||||
// browser) or the x-api-key header (scripts / Postman). Touching the
|
||||
// cookie refreshes its idle TTL as a side-effect.
|
||||
let session_ok =
|
||||
parse_cookie(req.headers()).is_some_and(|t| state.sessions.touch(&t).is_some());
|
||||
let key_ok = req
|
||||
.headers()
|
||||
.get(API_KEY_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|k| state.api_key.verify(k));
|
||||
if session_ok || key_ok {
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "authentication required" })),
|
||||
@@ -447,6 +458,28 @@ pub async fn api_update_credentials(
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the current operator API key plus the header to send it in.
|
||||
/// Gated by the auth middleware, so only a logged-in operator (or a
|
||||
/// caller already holding the key) can read it.
|
||||
pub async fn api_api_key_get(State(state): State<AppState>) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({ "key": state.api_key.current(), "header": API_KEY_HEADER })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Mint a fresh operator API key, invalidating the previous one, and
|
||||
/// return it. Same gating as the GET.
|
||||
pub async fn api_api_key_regenerate(State(state): State<AppState>) -> Response {
|
||||
let key = state.api_key.regenerate();
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({ "key": key, "header": API_KEY_HEADER })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LoginPayload<'a> {
|
||||
user: &'a AdminPublic,
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Server-side "add ISO by URL" — stream a remote `.iso` straight into the
|
||||
//! store, reusing the chunked-upload handle + introspection pipeline so a
|
||||
//! URL-fetched image classifies and gains boot entries exactly like an
|
||||
//! uploaded one. A small in-memory job registry tracks progress; the web UI
|
||||
//! polls it and renders rows just like browser uploads.
|
||||
//!
|
||||
//! This is operator-initiated and auth-gated (`/api/*`), never runs at boot,
|
||||
//! and adds no CDN assets — so it doesn't touch OpenPXE's offline-boot
|
||||
//! guarantee. On an air-gapped network it simply goes unused (upload
|
||||
//! instead). It's the same class of optional outbound the server already
|
||||
//! makes for webhooks and the update check.
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use openpxe_core::{Error, Result};
|
||||
use openpxe_iso_store::IsoStore;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Hard ceiling on a URL-fetched image — matches the HTTP upload body cap
|
||||
/// (`DefaultBodyLimit` in `app.rs`).
|
||||
const MAX_ISO_BYTES: u64 = 16 * 1024 * 1024 * 1024;
|
||||
|
||||
/// How long a finished-with-error job lingers so the operator can read the
|
||||
/// failure before it's swept. Successful jobs are read-once (see
|
||||
/// [`FetchJobs::snapshot`]).
|
||||
const FAILED_TTL: Duration = Duration::from_mins(10);
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Phase {
|
||||
Downloading,
|
||||
Done { iso_id: String },
|
||||
Failed { error: String },
|
||||
Canceled,
|
||||
}
|
||||
|
||||
struct Job {
|
||||
/// Best-known target filename (provisional from the URL, refined once
|
||||
/// the response headers arrive).
|
||||
filename: String,
|
||||
/// Display-safe source URL — any `user:pass@` userinfo is stripped so
|
||||
/// the UI/logs never echo embedded credentials.
|
||||
url: String,
|
||||
downloaded: u64,
|
||||
/// Total bytes from `Content-Length`, or `0` when the server didn't
|
||||
/// send one (progress then shows bytes-so-far without a percentage).
|
||||
total: u64,
|
||||
phase: Phase,
|
||||
cancel: bool,
|
||||
finished_at: Option<Instant>,
|
||||
}
|
||||
|
||||
/// One row in the fetch-progress list the UI polls.
|
||||
#[derive(Serialize)]
|
||||
pub struct JobDto {
|
||||
id: String,
|
||||
filename: String,
|
||||
url: String,
|
||||
downloaded: u64,
|
||||
total: u64,
|
||||
/// `downloading` | `done` | `failed` | `canceled`.
|
||||
state: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
iso_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// In-memory registry of in-flight and recently-finished URL fetches.
|
||||
/// Cheap to clone (Arc-shared); contention is nil (a handful of jobs, brief
|
||||
/// per-chunk progress bumps).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FetchJobs {
|
||||
inner: Arc<Mutex<HashMap<String, Arc<Mutex<Job>>>>>,
|
||||
}
|
||||
|
||||
impl FetchJobs {
|
||||
/// Validate the URL, register a job, and spawn the background download.
|
||||
/// Returns the job id. Fails fast on a bad/unsupported URL so the POST
|
||||
/// gets an immediate error instead of a job that dies a moment later.
|
||||
pub fn start(
|
||||
&self,
|
||||
store: IsoStore,
|
||||
raw_url: &str,
|
||||
filename_hint: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let (url, display) = parse_and_sanitize(raw_url)?;
|
||||
// Provisional filename for the first render; the task refines it
|
||||
// from Content-Disposition / the post-redirect URL.
|
||||
let provisional = filename_hint
|
||||
.map(sanitize_filename)
|
||||
.or_else(|| basename(&url))
|
||||
.unwrap_or_else(|| "download.iso".to_string());
|
||||
|
||||
let id = Uuid::new_v4().simple().to_string();
|
||||
let job = Arc::new(Mutex::new(Job {
|
||||
filename: provisional,
|
||||
url: display,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
phase: Phase::Downloading,
|
||||
cancel: false,
|
||||
finished_at: None,
|
||||
}));
|
||||
self.inner.lock().insert(id.clone(), job.clone());
|
||||
|
||||
let hint = filename_hint.map(str::to_string);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = download(&store, &job, url, hint).await {
|
||||
let mut g = job.lock();
|
||||
// A cancel flips the phase itself; don't overwrite it.
|
||||
if !matches!(g.phase, Phase::Canceled) {
|
||||
g.phase = Phase::Failed {
|
||||
error: format!("{e}"),
|
||||
};
|
||||
}
|
||||
g.finished_at = Some(Instant::now());
|
||||
}
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Snapshot every job for the UI, then sweep the terminal ones:
|
||||
/// `Done`/`Canceled` are **read-once** (removed after this call, so the
|
||||
/// UI reacts to completion exactly once and never loops on a lingering
|
||||
/// "done" row), while `Failed` is retained until [`FAILED_TTL`] so the
|
||||
/// error stays visible.
|
||||
pub fn snapshot(&self) -> Vec<JobDto> {
|
||||
let mut g = self.inner.lock();
|
||||
let mut out = Vec::with_capacity(g.len());
|
||||
for (id, job) in g.iter() {
|
||||
let j = job.lock();
|
||||
let (state, iso_id, error) = match &j.phase {
|
||||
Phase::Downloading => ("downloading", None, None),
|
||||
Phase::Done { iso_id } => ("done", Some(iso_id.clone()), None),
|
||||
Phase::Failed { error } => ("failed", None, Some(error.clone())),
|
||||
Phase::Canceled => ("canceled", None, None),
|
||||
};
|
||||
out.push(JobDto {
|
||||
id: id.clone(),
|
||||
filename: j.filename.clone(),
|
||||
url: j.url.clone(),
|
||||
downloaded: j.downloaded,
|
||||
total: j.total,
|
||||
state,
|
||||
iso_id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
let now = Instant::now();
|
||||
g.retain(|_, job| {
|
||||
let j = job.lock();
|
||||
match &j.phase {
|
||||
Phase::Downloading => true,
|
||||
Phase::Done { .. } | Phase::Canceled => false, // read-once
|
||||
Phase::Failed { .. } => j
|
||||
.finished_at
|
||||
.is_none_or(|t| now.duration_since(t) < FAILED_TTL),
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Cancel an in-flight download, or dismiss a terminal one. Returns
|
||||
/// `true` if a job with that id existed.
|
||||
pub fn cancel(&self, id: &str) -> bool {
|
||||
let g = self.inner.lock();
|
||||
let Some(job) = g.get(id) else { return false };
|
||||
let mut j = job.lock();
|
||||
if matches!(j.phase, Phase::Downloading) {
|
||||
j.cancel = true; // the download loop checks this each chunk
|
||||
} else {
|
||||
drop(j);
|
||||
drop(g);
|
||||
self.inner.lock().remove(id);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The background download: GET the URL, derive + validate the filename,
|
||||
/// then stream the body through an `UploadHandle` (which hashes, writes the
|
||||
/// `.partial`, and on `finish` renames + introspects).
|
||||
async fn download(
|
||||
store: &IsoStore,
|
||||
job: &Arc<Mutex<Job>>,
|
||||
url: reqwest::Url,
|
||||
filename_hint: Option<String>,
|
||||
) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.user_agent(concat!("OpenPXE/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|e| Error::Other(e.into()))?;
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::Invalid(format!("request failed: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(Error::Invalid(format!("server returned {}", resp.status())));
|
||||
}
|
||||
|
||||
let cd = resp
|
||||
.headers()
|
||||
.get(header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
let filename = pick_filename(filename_hint.as_deref(), cd, resp.url())?;
|
||||
let total = resp.content_length().unwrap_or(0);
|
||||
if total > MAX_ISO_BYTES {
|
||||
return Err(Error::Invalid(format!(
|
||||
"declared size {total} exceeds the {MAX_ISO_BYTES}-byte cap"
|
||||
)));
|
||||
}
|
||||
{
|
||||
let mut g = job.lock();
|
||||
g.filename.clone_from(&filename);
|
||||
g.total = total;
|
||||
}
|
||||
|
||||
let mut handle = store.begin_upload(&filename).await?;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut received: u64 = 0;
|
||||
while let Some(item) = stream.next().await {
|
||||
if job.lock().cancel {
|
||||
let _ = handle.abort().await;
|
||||
job.lock().phase = Phase::Canceled;
|
||||
job.lock().finished_at = Some(Instant::now());
|
||||
return Ok(());
|
||||
}
|
||||
let chunk = item.map_err(|e| Error::Invalid(format!("transfer error: {e}")))?;
|
||||
received += chunk.len() as u64;
|
||||
if received > MAX_ISO_BYTES {
|
||||
let _ = handle.abort().await;
|
||||
return Err(Error::Invalid(format!(
|
||||
"download exceeded the {MAX_ISO_BYTES}-byte cap"
|
||||
)));
|
||||
}
|
||||
if let Err(e) = handle.write_chunk(&chunk).await {
|
||||
let _ = handle.abort().await;
|
||||
return Err(e);
|
||||
}
|
||||
job.lock().downloaded = received;
|
||||
}
|
||||
|
||||
let meta = handle.finish(store).await?;
|
||||
tracing::info!(
|
||||
target: "openpxe::http::fetch",
|
||||
filename = %filename, bytes = received, family = ?meta.introspection.family,
|
||||
"fetched ISO from URL"
|
||||
);
|
||||
let mut g = job.lock();
|
||||
g.downloaded = received;
|
||||
g.phase = Phase::Done { iso_id: meta.id };
|
||||
g.finished_at = Some(Instant::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse the URL, require an `http`/`https` scheme (no `file:`/`gopher:`/…),
|
||||
/// and return it alongside a credential-stripped display form.
|
||||
fn parse_and_sanitize(raw: &str) -> Result<(reqwest::Url, String)> {
|
||||
let url = reqwest::Url::parse(raw.trim())
|
||||
.map_err(|_| Error::Invalid("not a valid URL".to_string()))?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err(Error::Invalid(
|
||||
"only http:// and https:// URLs are accepted".to_string(),
|
||||
));
|
||||
}
|
||||
let mut display = url.clone();
|
||||
let _ = display.set_username("");
|
||||
let _ = display.set_password(None);
|
||||
Ok((url, display.to_string()))
|
||||
}
|
||||
|
||||
/// Choose the target filename: explicit hint > `Content-Disposition` >
|
||||
/// post-redirect URL basename. Must end in `.iso` (case-insensitive).
|
||||
fn pick_filename(
|
||||
explicit: Option<&str>,
|
||||
content_disposition: Option<&str>,
|
||||
final_url: &reqwest::Url,
|
||||
) -> Result<String> {
|
||||
let candidate = explicit
|
||||
.map(sanitize_filename)
|
||||
.or_else(|| content_disposition.and_then(filename_from_disposition))
|
||||
.or_else(|| basename(final_url))
|
||||
.ok_or_else(|| Error::Invalid("could not determine a filename".to_string()))?;
|
||||
if !candidate.to_ascii_lowercase().ends_with(".iso") {
|
||||
return Err(Error::Invalid(format!(
|
||||
"URL does not point at an .iso (got '{candidate}')"
|
||||
)));
|
||||
}
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
/// Last path segment of a URL, percent-decoded and reduced to a bare
|
||||
/// filename. `None` for a pathless URL.
|
||||
fn basename(url: &reqwest::Url) -> Option<String> {
|
||||
let seg = url.path_segments()?.next_back()?;
|
||||
if seg.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let decoded = percent_decode(seg);
|
||||
Some(sanitize_filename(&decoded))
|
||||
}
|
||||
|
||||
/// Pull `filename="x.iso"` (or bare `filename=x.iso`) out of a
|
||||
/// `Content-Disposition` header. RFC 5987 `filename*` is ignored — the
|
||||
/// common case is enough, and the URL basename is the fallback.
|
||||
fn filename_from_disposition(cd: &str) -> Option<String> {
|
||||
let idx = cd.to_ascii_lowercase().find("filename=")?;
|
||||
let rest = &cd[idx + "filename=".len()..];
|
||||
let val = rest.trim_start().trim_start_matches('"');
|
||||
let end = val.find(['"', ';']).unwrap_or(val.len());
|
||||
let name = val[..end].trim();
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sanitize_filename(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduce any path-ish string to a safe bare filename: last component only,
|
||||
/// no `/`, `\`, or NULs. Prevents a crafted `Content-Disposition`/URL from
|
||||
/// escaping the ISO directory.
|
||||
fn sanitize_filename(s: &str) -> String {
|
||||
s.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(s)
|
||||
.replace('\0', "")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Minimal percent-decoding for a single path segment (enough for `%20`
|
||||
/// spaces in an ISO name); leaves malformed escapes untouched.
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let (Some(h), Some(l)) = (hexval(bytes[i + 1]), hexval(bytes[i + 2])) {
|
||||
out.push(h << 4 | l);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
fn hexval(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FetchBody {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub filename: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /api/isos/fetch` — start a URL download.
|
||||
pub async fn api_iso_fetch_start(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<FetchBody>,
|
||||
) -> Response {
|
||||
match state
|
||||
.fetch_jobs
|
||||
.start(state.iso_store.clone(), &body.url, body.filename.as_deref())
|
||||
{
|
||||
Ok(id) => (StatusCode::ACCEPTED, Json(json!({ "fetch_id": id }))).into_response(),
|
||||
Err(Error::Invalid(msg)) => {
|
||||
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("{e}") })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/isos/fetch` — poll progress. Successful jobs appear once.
|
||||
pub async fn api_iso_fetch_list(State(state): State<AppState>) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({ "jobs": state.fetch_jobs.snapshot() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// `DELETE /api/isos/fetch/{id}` — cancel an in-flight download or dismiss a
|
||||
/// finished/failed row.
|
||||
pub async fn api_iso_fetch_cancel(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
if state.fetch_jobs.cancel(&id) {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "no such fetch job" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scheme_is_restricted_and_credentials_are_stripped() {
|
||||
assert!(parse_and_sanitize("file:///etc/passwd").is_err());
|
||||
assert!(parse_and_sanitize("gopher://x/1").is_err());
|
||||
assert!(parse_and_sanitize("not a url").is_err());
|
||||
let (_, display) = parse_and_sanitize("https://user:[email protected]/a.iso").unwrap();
|
||||
assert!(
|
||||
!display.contains("secret"),
|
||||
"credentials must be stripped: {display}"
|
||||
);
|
||||
assert!(display.starts_with("https://example.com/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_prefers_explicit_then_disposition_then_url() {
|
||||
let u = reqwest::Url::parse("https://example.com/path/final.iso").unwrap();
|
||||
// Explicit wins, and path traversal is stripped.
|
||||
assert_eq!(
|
||||
pick_filename(Some("../../evil/custom.iso"), None, &u).unwrap(),
|
||||
"custom.iso"
|
||||
);
|
||||
// Content-Disposition next.
|
||||
assert_eq!(
|
||||
pick_filename(None, Some(r#"attachment; filename="rescue.iso""#), &u).unwrap(),
|
||||
"rescue.iso"
|
||||
);
|
||||
// URL basename fallback (with percent-decoding).
|
||||
let sp = reqwest::Url::parse("https://example.com/System%20Rescue.iso").unwrap();
|
||||
assert_eq!(pick_filename(None, None, &sp).unwrap(), "System Rescue.iso");
|
||||
// Non-.iso is rejected.
|
||||
assert!(pick_filename(
|
||||
None,
|
||||
None,
|
||||
&reqwest::Url::parse("https://x/y.tar.gz").unwrap()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposition_parsing_handles_quotes_and_bare() {
|
||||
assert_eq!(
|
||||
filename_from_disposition(r#"attachment; filename="a.iso"; size=1"#).as_deref(),
|
||||
Some("a.iso")
|
||||
);
|
||||
assert_eq!(
|
||||
filename_from_disposition("inline; filename=b.iso").as_deref(),
|
||||
Some("b.iso")
|
||||
);
|
||||
assert_eq!(filename_from_disposition("attachment").as_deref(), None);
|
||||
}
|
||||
}
|
||||
@@ -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,16 +9,17 @@
|
||||
//! and Linux kernel/initrd, without having to
|
||||
//! re-extract on every request)
|
||||
//!
|
||||
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`)
|
||||
//! that lseeks into the ISO on disk — so we never keep extracted copies.
|
||||
//! The `<id>/<path>` handler uses the read-only ISO9660 walker from
|
||||
//! `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)]
|
||||
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod fetch;
|
||||
pub mod grub_script;
|
||||
pub mod ipxe_script;
|
||||
pub mod iso_fs;
|
||||
pub mod log_stream;
|
||||
pub mod notify;
|
||||
pub mod saml_routes;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::auth::SessionStore;
|
||||
use crate::fetch::FetchJobs;
|
||||
use crate::saml_routes::SamlRuntime;
|
||||
use crate::uploads::UploadSessions;
|
||||
use openpxe_core::{
|
||||
AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
|
||||
AdminStore, ApiKeyStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
|
||||
DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore,
|
||||
};
|
||||
use openpxe_iso_store::{
|
||||
@@ -55,6 +56,11 @@ pub struct AppState {
|
||||
/// process restart (sessions are tied to UI state, not persisted —
|
||||
/// matches Sonarr/Radarr behaviour).
|
||||
pub sessions: SessionStore,
|
||||
/// v0.8.0: persisted operator API key. A request carrying a matching
|
||||
/// `x-api-key` header authenticates exactly like an operator session,
|
||||
/// so scripts / Postman can drive `/api/*` without a browser login.
|
||||
/// Generated on first run; regenerable from Settings → Advanced.
|
||||
pub api_key: ApiKeyStore,
|
||||
/// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles).
|
||||
pub sso: SsoStore,
|
||||
/// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs
|
||||
@@ -103,6 +109,10 @@ pub struct AppState {
|
||||
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
|
||||
/// can show deterministic progress and leave visible partial files.
|
||||
pub uploads: UploadSessions,
|
||||
/// v0.8.1: server-side "add ISO by URL" jobs. Background downloads
|
||||
/// streamed straight into the store (reusing the upload handle +
|
||||
/// introspection); the Storage view polls their progress.
|
||||
pub fetch_jobs: FetchJobs,
|
||||
/// Live log bus consumed by the Terminal tab via SSE. Operator-issued
|
||||
/// terminal commands also push synthetic lines onto it so the tail
|
||||
/// shows them inline.
|
||||
|
||||
@@ -18,23 +18,16 @@ use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbShareMan
|
||||
use tempfile::tempdir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so
|
||||
/// introspection identifies it as Alpine.
|
||||
/// Build a tiny Alpine-shaped ISO9660 image: volume label "ALPINE-TEST"
|
||||
/// 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> {
|
||||
let mut buf = vec![0u8; 32 * 2048];
|
||||
let off = 16 * 2048;
|
||||
buf[off] = 0x01;
|
||||
buf[off + 1..off + 6].copy_from_slice(b"CD001");
|
||||
buf[off + 6] = 0x01;
|
||||
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
|
||||
openpxe_iso_store::iso_fs::testiso::TestIsoBuilder::new("ALPINE-TEST")
|
||||
.el_torito(true)
|
||||
.file("/boot/vmlinuz-lts", b"fake-kernel-bytes")
|
||||
.file("/boot/initramfs-lts", b"fake-initramfs-bytes")
|
||||
.build()
|
||||
}
|
||||
|
||||
fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
|
||||
@@ -106,6 +99,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
|
||||
let sso = openpxe_core::SsoStore::load_or_default(dir.path());
|
||||
let notify = openpxe_core::NotifyStore::load_or_default(dir.path());
|
||||
let api_key = openpxe_core::ApiKeyStore::load_or_init(dir.path());
|
||||
let sessions = openpxe_http_api::auth::SessionStore::default();
|
||||
let metrics = Metrics::new();
|
||||
let state = AppState {
|
||||
@@ -121,6 +115,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||
admin,
|
||||
sessions,
|
||||
api_key,
|
||||
sso,
|
||||
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
|
||||
notify,
|
||||
@@ -131,6 +126,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
sftp_shares,
|
||||
unattended,
|
||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||
fetch_jobs: openpxe_http_api::fetch::FetchJobs::default(),
|
||||
log_bus,
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
public_base_url: "http://127.0.0.1".into(),
|
||||
@@ -142,6 +138,109 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
(state, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_authenticates_gated_endpoints() {
|
||||
// v0.8.0: the x-api-key header authenticates /api/* like an operator
|
||||
// session. The middleware only enforces once an admin is configured
|
||||
// (before that everything is open), so bootstrap one first.
|
||||
let (state, _dir) = build_state().await;
|
||||
state
|
||||
.admin
|
||||
.bootstrap("admin", "correct-horse-battery-staple")
|
||||
.unwrap();
|
||||
let key = state.api_key.current();
|
||||
let app = build_router(state);
|
||||
|
||||
// No credentials → 401.
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/isos")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "no auth must 401");
|
||||
|
||||
// Wrong key → 401.
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/isos")
|
||||
.header("x-api-key", "not-the-key")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "wrong key must 401");
|
||||
|
||||
// Correct key → 200 (operator-equivalent access).
|
||||
let res = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/isos")
|
||||
.header("x-api-key", key)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK, "valid key must authenticate");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_iso_by_url_downloads_into_store() {
|
||||
// v0.8.1: add-ISO-by-URL. Serve a real ISO over HTTP, POST its URL, poll
|
||||
// the fetch registry until the background download finishes, then assert
|
||||
// the image landed in the store (classified like an upload).
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
|
||||
let iso = fake_alpine_iso();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/rescue.iso"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(iso))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let url = format!("{}/rescue.iso", server.uri());
|
||||
|
||||
let (code, _) = post_json(&app, "/api/isos/fetch", &format!(r#"{{"url":"{url}"}}"#)).await;
|
||||
assert_eq!(code, StatusCode::ACCEPTED, "fetch should start");
|
||||
|
||||
// Bounded poll for completion (the download runs on a spawned task).
|
||||
let mut done = false;
|
||||
for _ in 0..100 {
|
||||
let (_, body) = get(&app, "/api/isos/fetch").await;
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let jobs = v["jobs"].as_array().cloned().unwrap_or_default();
|
||||
assert!(
|
||||
!jobs.iter().any(|j| j["state"] == "failed"),
|
||||
"fetch failed: {jobs:?}"
|
||||
);
|
||||
if jobs.iter().any(|j| j["state"] == "done") {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
assert!(done, "fetch did not complete in time");
|
||||
|
||||
// The fetched ISO is now in the store under the URL basename.
|
||||
let (_, body) = get(&app, "/api/isos").await;
|
||||
assert!(
|
||||
String::from_utf8_lossy(&body).contains("rescue.iso"),
|
||||
"fetched ISO should appear in /api/isos"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_and_ready_endpoints() {
|
||||
let (state, _dir) = build_state().await;
|
||||
@@ -1248,9 +1347,10 @@ async fn chunked_upload_writes_progressively_and_finishes_iso() {
|
||||
.method("POST")
|
||||
.uri("/api/uploads")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"filename":"chunked-alpine.iso","size_bytes":65536}"#,
|
||||
))
|
||||
.body(Body::from(format!(
|
||||
r#"{{"filename":"chunked-alpine.iso","size_bytes":{}}}"#,
|
||||
iso.len()
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -13,4 +13,3 @@ workspace = true
|
||||
openpxe-core.workspace = true
|
||||
rust-embed.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -12,16 +12,13 @@ workspace = true
|
||||
[dependencies]
|
||||
openpxe-core.workspace = true
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
bcrypt.workspace = true
|
||||
uuid.workspace = true
|
||||
time.workspace = true
|
||||
parking_lot.workspace = true
|
||||
bytes.workspace = true
|
||||
@@ -49,3 +46,11 @@ futures = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
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 = []
|
||||
|
||||
+615
-190
@@ -1,16 +1,33 @@
|
||||
//! ISO introspection — identify the distro family and locate kernel/initrd.
|
||||
//!
|
||||
//! We avoid a full ISO9660/Joliet/Rock-Ridge parser by reading a small number
|
||||
//! of well-known files via `isoinfo` (from cdrtools/genisoimage) when it's on
|
||||
//! the path. As a pure-Rust fallback we do a crude scan: read the volume
|
||||
//! descriptor at offset 0x8000 to grab the volume label, and grep for known
|
||||
//! filenames by scanning raw sectors — good enough to tell Debian from RHEL
|
||||
//! most of the time, without shelling out.
|
||||
//! v0.7.4 rewrite: detection is **probe-based**. Instead of grepping raw
|
||||
//! sectors for filename strings (which false-positived — any Linux ISO
|
||||
//! shipping GRUB/syslinux chainload modules contains the literal
|
||||
//! "bootmgr", so gparted-live classified as Windows), we walk the
|
||||
//! ISO9660 directory tree via [`crate::iso_fs`] and check whether the
|
||||
//! 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 std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -30,18 +47,35 @@ pub enum DistroFamily {
|
||||
/// re-classify already-uploaded ISOs. On startup the store re-runs
|
||||
/// `introspect` on any *local* ISO whose persisted report predates this
|
||||
/// revision (see `IsoStore::load_from_disk`), so an upgrade fixes stale
|
||||
/// metadata — e.g. a Windows 11 ISO tagged `Unknown` by an older binary —
|
||||
/// without the operator having to delete and re-upload it.
|
||||
/// metadata without the operator having to delete and re-upload.
|
||||
///
|
||||
/// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened
|
||||
/// Windows (UDF/UTF-16) detection becomes retroactive.
|
||||
pub const INTROSPECT_REV: u32 = 1;
|
||||
/// Windows (UDF/UTF-16) detection.
|
||||
/// 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.
|
||||
/// rev 3 (v0.7.5): Joliet namespace fallback + gap-tolerant El Torito /
|
||||
/// descriptor scans. Without this bump, images the rev-2 logic flagged
|
||||
/// as data ISOs (mangled-primary appliance images, filler-sector boot
|
||||
/// records) would never re-probe and stay mislabeled.
|
||||
/// rev 4 (v0.8.0): dropped the over-broad "microsoft" UTF-16 bulk-scan
|
||||
/// marker that classified any Secure-Boot-signed non-Windows bootable
|
||||
/// (memtest86, signed BSDs, firmware tools) as Windows — the string
|
||||
/// lives in the FAT long-filename entries of their MS-signed EFI loader.
|
||||
/// The bump re-probes those so they drop the bogus Windows label.
|
||||
pub const INTROSPECT_REV: u32 = 4;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct IntrospectionReport {
|
||||
pub family: DistroFamily,
|
||||
pub volume_label: Option<String>,
|
||||
/// Kernel path inside the ISO (e.g. `/casper/vmlinuz`, `/isolinux/vmlinuz`).
|
||||
/// 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>,
|
||||
/// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups.
|
||||
pub initrd_paths: Vec<String>,
|
||||
@@ -55,124 +89,245 @@ pub struct IntrospectionReport {
|
||||
/// appliance bundle) has no boot catalog and reports `false`. v0.5.9.
|
||||
#[serde(default)]
|
||||
pub el_torito: bool,
|
||||
/// Revision of the introspection logic that produced this report. Old
|
||||
/// `meta.json` files without the field deserialize as 0, which is
|
||||
/// below [`INTROSPECT_REV`], triggering a one-time re-introspect on
|
||||
/// the next startup. v0.5.9.
|
||||
/// Revision of the introspection logic that produced this report.
|
||||
/// `0` means "never introspected" (pre-v0.5.9 metadata, or a remote
|
||||
/// ISO whose probe hasn't run / can't run) — the entry generator
|
||||
/// treats those optimistically (sanboot) and the UI labels them.
|
||||
#[serde(default)]
|
||||
pub introspect_rev: u32,
|
||||
}
|
||||
|
||||
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log
|
||||
/// and return an `Unknown` family so the uploader still sees a record.
|
||||
/// One row of the Linux detection table.
|
||||
///
|
||||
/// `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 {
|
||||
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 {
|
||||
introspect_rev: INTROSPECT_REV,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let Ok(mut f) = std::fs::File::open(path) else {
|
||||
tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display());
|
||||
return report;
|
||||
};
|
||||
|
||||
// ISO9660 Primary Volume Descriptor at LBA 16 (offset 0x8000), 2048 bytes.
|
||||
// Bytes 40..72 are the Volume Identifier (space-padded, d-characters).
|
||||
let mut pvd = [0u8; 2048];
|
||||
if f.seek(SeekFrom::Start(0x8000)).is_ok() && f.read_exact(&mut pvd).is_ok() {
|
||||
// Byte 0 must be 0x01 (primary descriptor), bytes 1..6 = "CD001".
|
||||
if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" {
|
||||
let label_raw = &pvd[40..72];
|
||||
let label = String::from_utf8_lossy(label_raw).trim().to_string();
|
||||
if !label.is_empty() {
|
||||
report.volume_label = Some(label.clone());
|
||||
report.family = family_from_label(&label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Does the ISO have an El Torito boot catalog? This is what decides
|
||||
// whether an ISO we *can't* otherwise classify is bootable at all —
|
||||
// a bootable ISO sanboots; a data/appliance ISO (no catalog) can't.
|
||||
report.el_torito = detect_el_torito(&mut f);
|
||||
|
||||
// Cheap content scan: read the first ~64 MiB, look for signature filenames.
|
||||
// This is enough to identify `sources/boot.wim` (Windows) and common
|
||||
// kernel/initrd paths for the major Linux distros.
|
||||
let _ = f.seek(SeekFrom::Start(0));
|
||||
let scan_bytes = 64 * 1024 * 1024;
|
||||
let mut buf = vec![0u8; 1024 * 1024];
|
||||
let mut read_total = 0usize;
|
||||
// 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")
|
||||
// Everything sector-shaped goes through one caching wrapper: the
|
||||
// descriptor-set sectors are read once and shared between the label
|
||||
// scan, the El Torito walk, and the namespace-root lookups; the
|
||||
// probe table's repeated root/subdirectory reads collapse the same
|
||||
// way — over NFS/SFTP that's the difference between ~6 and ~60+
|
||||
// round-trips per ISO.
|
||||
{
|
||||
let mut cr = CachingReadAt::new(r);
|
||||
|
||||
// ISO9660 Primary Volume Descriptor: bytes 40..72 are the volume
|
||||
// identifier (space-padded). v0.7.5: located by scanning the
|
||||
// descriptor set (tolerating filler sectors) instead of assuming
|
||||
// a pristine sector 16.
|
||||
if let Some(pvd) = iso_fs::find_descriptor(&mut cr, false).await {
|
||||
let label = String::from_utf8_lossy(&pvd[40..72]).trim().to_string();
|
||||
if !label.is_empty() {
|
||||
report.family = family_from_label(&label);
|
||||
report.volume_label = Some(label);
|
||||
}
|
||||
}
|
||||
|
||||
report.el_torito = detect_el_torito(&mut cr).await;
|
||||
|
||||
if iso_fs::exists(&mut cr, "/sources/boot.wim").await {
|
||||
report.has_boot_wim = true;
|
||||
report.family = DistroFamily::WindowsPe;
|
||||
} else {
|
||||
for probe in LINUX_PROBES {
|
||||
if !iso_fs::exists(&mut cr, probe.kernel).await {
|
||||
continue;
|
||||
}
|
||||
let mut initrd = None;
|
||||
for cand in probe.initrd_candidates {
|
||||
if iso_fs::exists(&mut cr, cand).await {
|
||||
initrd = Some((*cand).to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(initrd) = initrd else { continue };
|
||||
// Content beats label: a rebadged derivative (volume
|
||||
// label "ISE-3.2") with the anaconda layout is
|
||||
// RHEL-family no matter what the label says.
|
||||
report.family = probe.family;
|
||||
let coreos = probe.family == DistroFamily::RhelFedora
|
||||
&& iso_fs::exists(&mut cr, COREOS_ROOTFS).await;
|
||||
if probe.emit_kernel && !coreos {
|
||||
report.kernel_path = Some(probe.kernel.to_string());
|
||||
report.initrd_paths = vec![initrd];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v0.5.8: broaden Windows detection. Modern Windows 10/11 ISOs are
|
||||
// UDF — filenames are stored as UTF-16 (so the ASCII scan above misses
|
||||
// them) and the volume label is a cryptic Microsoft string (so
|
||||
// `family_from_label` misses it too). Booting is via HTTP sanboot of
|
||||
// the raw ISO (no boot.wim extraction), so we only need the *family*.
|
||||
// Catch the common cases: well-known Windows markers in either ASCII
|
||||
// or UTF-16LE within the first 16 MiB, plus a filename hint.
|
||||
if report.family == DistroFamily::Unknown {
|
||||
let head = &haystack[..haystack.len().min(16 * 1024 * 1024)];
|
||||
let ascii_markers: [&[u8]; 4] = [
|
||||
b"bootmgr",
|
||||
b"sources/install.wim",
|
||||
b"sources/install.esd",
|
||||
b"efi/microsoft",
|
||||
];
|
||||
let utf16_markers = ["bootmgr", "boot.wim", "install.wim", "microsoft"];
|
||||
let looks_windows = ascii_markers.iter().any(|m| contains_ascii(head, m))
|
||||
|| utf16_markers.iter().any(|m| contains_utf16le_ci(head, m))
|
||||
|| filename_looks_windows(path);
|
||||
if looks_windows {
|
||||
// Modern Windows 10/11 ISOs are UDF — their tree is invisible to the
|
||||
// ISO9660 walk and the volume label is a cryptic Microsoft string.
|
||||
// Scan the first 16 MiB for well-known markers, ASCII and UTF-16LE.
|
||||
// Runs after the Linux probes so a Linux ISO that *contains* the
|
||||
// string "bootmgr" (GRUB/syslinux chainload modules do) has already
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort kernel/initrd path guess from family. These paths are what
|
||||
// distro ISOs conventionally ship at — we don't verify extraction here;
|
||||
// that happens in the store after introspection.
|
||||
let (k, i) = guess_kernel_initrd(report.family);
|
||||
report.kernel_path = k.map(str::to_string);
|
||||
report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
|
||||
// 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
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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
|
||||
} else if l.contains("rhel")
|
||||
|| l.contains("centos")
|
||||
|| l.contains("fedora")
|
||||
|| l.contains("rocky")
|
||||
|| l.contains("alma")
|
||||
|| l.contains("rhcos")
|
||||
|| l.contains("coreos")
|
||||
|| l.contains("openshift")
|
||||
|| l.contains("okd")
|
||||
{
|
||||
DistroFamily::RhelFedora
|
||||
} else if l.contains("suse") || l.contains("opensuse") {
|
||||
@@ -188,23 +343,97 @@ fn family_from_label(label: &str) -> DistroFamily {
|
||||
}
|
||||
}
|
||||
|
||||
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
|
||||
match family {
|
||||
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
|
||||
DistroFamily::RhelFedora => (
|
||||
Some("/images/pxeboot/vmlinuz"),
|
||||
vec!["/images/pxeboot/initrd.img"],
|
||||
),
|
||||
DistroFamily::OpenSuse => (
|
||||
Some("/boot/x86_64/loader/linux"),
|
||||
vec!["/boot/x86_64/loader/initrd"],
|
||||
),
|
||||
DistroFamily::Arch => (
|
||||
Some("/arch/boot/x86_64/vmlinuz-linux"),
|
||||
vec!["/arch/boot/x86_64/initramfs-linux.img"],
|
||||
),
|
||||
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]),
|
||||
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
|
||||
/// Filename token heuristic — `AlmaLinux-9.5-x86_64-dvd.iso` says what
|
||||
/// it is even when we can't read a byte of it. Tokens are the filename
|
||||
/// split on every non-alphanumeric character, so "almalinux", "rhel",
|
||||
/// "win11" match without "search" tripping the "arch" token.
|
||||
pub fn family_from_filename(filename: &str) -> DistroFamily {
|
||||
if filename_looks_windows(filename) {
|
||||
return DistroFamily::WindowsPe;
|
||||
}
|
||||
let lower = filename.to_ascii_lowercase();
|
||||
let tokens: Vec<&str> = lower
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
let has = |t: &str| tokens.contains(&t);
|
||||
if has("ubuntu")
|
||||
|| has("debian")
|
||||
|| 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"];
|
||||
// v0.8.0: dropped the bare "microsoft" marker. It matched the
|
||||
// Microsoft-signed Secure-Boot EFI loader that memtest86 (and signed
|
||||
// BSDs / firmware tools) ship — the string lives in the loader's FAT
|
||||
// long-filename entries — so any signed non-Windows bootable
|
||||
// false-classified as Windows. The remaining markers are all
|
||||
// Windows-exclusive filenames.
|
||||
let utf16_markers = ["bootmgr", "install.wim"];
|
||||
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 +466,10 @@ fn contains_utf16le_ci(haystack: &[u8], ascii: &str) -> bool {
|
||||
|
||||
/// Filename heuristic: a stock Windows ISO almost always carries an obvious
|
||||
/// token in its name (e.g. `..._windows_11_...`, `Win10`, `winserver`).
|
||||
/// Used only as a last-resort family hint when the content scan and volume
|
||||
/// label are inconclusive. v0.5.8.
|
||||
fn filename_looks_windows(path: &Path) -> bool {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
/// v0.7.4: takes the bare filename instead of a `Path` so the same check
|
||||
/// runs against remote share listings.
|
||||
fn filename_looks_windows(filename: &str) -> bool {
|
||||
let name = filename.to_ascii_lowercase();
|
||||
const TOKENS: [&str; 6] = [
|
||||
"windows",
|
||||
"winpe",
|
||||
@@ -263,32 +488,32 @@ const EL_TORITO_ID: &[u8] = b"EL TORITO SPECIFICATION";
|
||||
/// Detect an El Torito boot catalog — the marker that an ISO is bootable
|
||||
/// by BIOS/UEFI firmware (and thus by iPXE `sanboot`).
|
||||
///
|
||||
/// The ISO9660 Volume Descriptor Set starts at LBA 16 (offset 0x8000) and
|
||||
/// runs one 2048-byte descriptor per sector until a Set Terminator
|
||||
/// (type 0xFF). A Boot Record descriptor (type 0x00) whose 32-byte boot
|
||||
/// system identifier reads "EL TORITO SPECIFICATION" means the image
|
||||
/// declares an El Torito boot catalog. We only confirm its presence — we
|
||||
/// don't parse the catalog (sanboot/the firmware does that). The walk is
|
||||
/// capped so a malformed/huge image can't spin us. v0.5.9.
|
||||
fn detect_el_torito(f: &mut std::fs::File) -> bool {
|
||||
let mut vd = [0u8; 2048];
|
||||
/// The ISO9660 Volume Descriptor Set starts at LBA 16 and runs one
|
||||
/// 2048-byte descriptor per sector; a Boot Record descriptor (type 0x00)
|
||||
/// whose 32-byte boot system identifier reads "EL TORITO SPECIFICATION"
|
||||
/// means the image declares a boot catalog. We only confirm its presence
|
||||
/// — we don't parse the catalog (sanboot/the firmware does that).
|
||||
///
|
||||
/// v0.7.5: the walk no longer aborts at the first non-`CD001` sector or
|
||||
/// stops at a Set Terminator. Sloppy mastering tools (appliance ISOs
|
||||
/// especially) leave zeroed filler sectors inside the descriptor area or
|
||||
/// odd descriptor ordering, which used to hide a real boot record and
|
||||
/// flag a bootable image as a data ISO. All 16 sectors are examined —
|
||||
/// the signature is 25 exact bytes, so scanning past the terminator
|
||||
/// (into e.g. a UDF volume recognition sequence) cannot false-positive.
|
||||
/// The cap keeps a malformed image from spinning us. v0.5.9 originally;
|
||||
/// reader-generic since v0.7.4.
|
||||
async fn detect_el_torito<R: IsoReadAt + Send>(r: &mut R) -> bool {
|
||||
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 {
|
||||
// Past end of a tiny image — nothing more to examine.
|
||||
return false;
|
||||
}
|
||||
// Every descriptor in the set carries the "CD001" magic; once it's
|
||||
// missing we've walked off the end of a valid set.
|
||||
};
|
||||
if &vd[1..6] != b"CD001" {
|
||||
return false;
|
||||
continue; // filler/garbage sector — keep walking
|
||||
}
|
||||
match vd[0] {
|
||||
// Boot Record descriptor carrying the El Torito signature.
|
||||
0x00 if vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID => return true,
|
||||
// Volume Descriptor Set Terminator — nothing bootable found.
|
||||
0xFF => return false,
|
||||
// Any other descriptor (incl. a non-El-Torito boot record) —
|
||||
// keep walking the set.
|
||||
_ => {}
|
||||
if vd[0] == 0x00 && vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -297,6 +522,12 @@ fn detect_el_torito(f: &mut std::fs::File) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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]
|
||||
fn label_matching() {
|
||||
@@ -313,13 +544,158 @@ mod tests {
|
||||
DistroFamily::OpenSuse
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn utf16le_marker_matches_case_insensitively() {
|
||||
// "boot.wim" encoded UTF-16LE, mixed case — UDF stores Windows
|
||||
// filenames this way, which the ASCII scan can't see.
|
||||
let s = "BOOT.WIM";
|
||||
let utf16: Vec<u8> = s.bytes().flat_map(|b| [b, 0]).collect();
|
||||
let mut hay = vec![0u8; 8];
|
||||
@@ -328,59 +704,108 @@ mod tests {
|
||||
assert!(contains_utf16le_ci(&hay, "boot.wim"));
|
||||
assert!(contains_utf16le_ci(&hay, "Boot.Wim"));
|
||||
assert!(!contains_utf16le_ci(&hay, "install.wim"));
|
||||
// An ASCII (not UTF-16) occurrence must NOT match the UTF-16 scan.
|
||||
assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn el_torito_boot_catalog_detected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Helper: stamp a 2048-byte descriptor at `lba` with type + magic.
|
||||
let stamp = |img: &mut [u8], lba: usize, ty: u8| {
|
||||
let off = lba * 2048;
|
||||
img[off] = ty;
|
||||
img[off + 1..off + 6].copy_from_slice(b"CD001");
|
||||
};
|
||||
fn el_torito_detected_through_reader() {
|
||||
let with = TestIsoBuilder::new("BOOTABLE").el_torito(true).build();
|
||||
let without = TestIsoBuilder::new("DATA").build();
|
||||
assert!(futures::executor::block_on(detect_el_torito(
|
||||
&mut MemReadAt(with)
|
||||
)));
|
||||
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();
|
||||
#[test]
|
||||
fn el_torito_survives_filler_sector_in_descriptor_area() {
|
||||
// v0.7.5 tolerance test: sloppy appliance mastering leaves a
|
||||
// zeroed sector inside the Volume Descriptor Set. The old walk
|
||||
// aborted at the first non-CD001 sector and flagged a genuinely
|
||||
// bootable image as a data ISO.
|
||||
let sector = SECTOR as usize;
|
||||
let mut img = TestIsoBuilder::new("GAPPY").el_torito(true).build();
|
||||
// Builder layout: PVD @16, Boot Record @17, terminator @18.
|
||||
// Move the BR to 18 (over the terminator) and zero out 17.
|
||||
img.copy_within(17 * sector..18 * sector, 18 * sector);
|
||||
img[17 * sector..18 * sector].fill(0);
|
||||
assert!(
|
||||
detect_el_torito(&mut f),
|
||||
"El Torito boot record should match"
|
||||
futures::executor::block_on(detect_el_torito(&mut MemReadAt(img))),
|
||||
"boot record behind a zeroed filler sector must still be found"
|
||||
);
|
||||
}
|
||||
|
||||
// Data/appliance image: PVD @16, terminator @17, no boot record.
|
||||
let mut data = vec![0u8; 2048 * 18];
|
||||
stamp(&mut data, 16, 0x01);
|
||||
stamp(&mut data, 17, 0xFF);
|
||||
let dp = dir.path().join("data.iso");
|
||||
std::fs::write(&dp, &data).unwrap();
|
||||
let mut f2 = std::fs::File::open(&dp).unwrap();
|
||||
assert!(!detect_el_torito(&mut f2), "data ISO has no boot catalog");
|
||||
#[test]
|
||||
fn joliet_only_image_classifies_via_fallback() {
|
||||
// Primary namespace bare, real tree only in Joliet — the v0.7.5
|
||||
// fallback must classify it (boot.wim probe) where v0.7.4 saw
|
||||
// "no installer files".
|
||||
let img = TestIsoBuilder::new("WIN_APPLIANCE")
|
||||
.el_torito(true)
|
||||
.joliet_only(true)
|
||||
.file("/sources/boot.wim", b"WIMWIM")
|
||||
.build();
|
||||
let r = introspect_mem(img, "appliance.iso", false);
|
||||
assert_eq!(r.family, DistroFamily::WindowsPe);
|
||||
assert!(r.has_boot_wim);
|
||||
assert!(r.el_torito);
|
||||
|
||||
// Same for a Linux shape: verified kernel paths via Joliet.
|
||||
let img2 = TestIsoBuilder::new("CUSTOM-EL9")
|
||||
.el_torito(true)
|
||||
.joliet_only(true)
|
||||
.file("/images/pxeboot/vmlinuz", b"K")
|
||||
.file("/images/pxeboot/initrd.img", b"I")
|
||||
.build();
|
||||
let r2 = introspect_mem(img2, "custom-el9.iso", false);
|
||||
assert_eq!(r2.family, DistroFamily::RhelFedora);
|
||||
assert_eq!(r2.kernel_path.as_deref(), Some("/images/pxeboot/vmlinuz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_hint_catches_windows_isos() {
|
||||
use std::path::Path;
|
||||
assert!(filename_looks_windows(Path::new(
|
||||
assert!(filename_looks_windows(
|
||||
"en-us_windows_11_iot_enterprise_ltsc_2024_x64_dvd.iso"
|
||||
)));
|
||||
assert!(filename_looks_windows(Path::new(
|
||||
"Win10_22H2_English_x64.iso"
|
||||
)));
|
||||
assert!(filename_looks_windows(Path::new("winserver2022.iso")));
|
||||
assert!(!filename_looks_windows(Path::new(
|
||||
"ubuntu-24.04-desktop.iso"
|
||||
)));
|
||||
assert!(!filename_looks_windows(Path::new(
|
||||
"Rocky-9.4-x86_64-dvd.iso"
|
||||
)));
|
||||
));
|
||||
assert!(filename_looks_windows("Win10_22H2_English_x64.iso"));
|
||||
assert!(filename_looks_windows("winserver2022.iso"));
|
||||
assert!(!filename_looks_windows("ubuntu-24.04-desktop.iso"));
|
||||
assert!(!filename_looks_windows("Rocky-9.4-x86_64-dvd.iso"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_scan_catches_udf_windows_markers() {
|
||||
// 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memtest_signed_efi_is_not_windows() {
|
||||
// v0.8.0 regression: PassMark MemTest86 ships a Microsoft-signed
|
||||
// Secure-Boot EFI loader, and "Microsoft" appears in its FAT
|
||||
// long-filename entries as UTF-16LE. The old bulk-scan "microsoft"
|
||||
// marker classified it (and any signed BSD / firmware tool) as
|
||||
// Windows. It must now classify as a generic bootable (sanboot).
|
||||
let mut img = TestIsoBuilder::new("MEMTEST86")
|
||||
.el_torito(true)
|
||||
.file("/EFI/BOOT/BOOTX64.EFI", b"signed-efi-app")
|
||||
.build();
|
||||
let marker: Vec<u8> = "Microsoft".bytes().flat_map(|b| [b, 0]).collect();
|
||||
img.extend_from_slice(&marker);
|
||||
let r = introspect_mem(img, "memtest86-iso.iso", true);
|
||||
assert_ne!(
|
||||
r.family,
|
||||
DistroFamily::WindowsPe,
|
||||
"a Microsoft-signed EFI loader is not Windows media"
|
||||
);
|
||||
assert!(r.el_torito, "still a bootable image");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
//! 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`.
|
||||
//!
|
||||
//! Namespaces: the primary ISO9660 tree is tried first; on a miss the
|
||||
//! walk falls back to the **Joliet** supplementary namespace (v0.7.5) —
|
||||
//! Windows-oriented mastering tools often write a minimal/mangled
|
||||
//! primary tree with the real names only in Joliet. Rock Ridge stays
|
||||
//! ignored. Matching is case-insensitive with the `;1` version suffix
|
||||
//! and the trailing dot of extension-less strict-mastered names
|
||||
//! stripped.
|
||||
|
||||
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.
|
||||
///
|
||||
/// v0.7.5: tries the primary ISO9660 namespace first, then falls back
|
||||
/// to the **Joliet** supplementary namespace. Windows-oriented mastering
|
||||
/// tools (common for appliance ISOs) often write a minimal or mangled
|
||||
/// primary tree and keep the real filenames only in Joliet — without the
|
||||
/// fallback those images probed as "no installer files" and their in-ISO
|
||||
/// kernel fetches 404'd.
|
||||
pub async fn lookup<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> Option<FileLocation> {
|
||||
let components: Vec<&str> = in_iso_path
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect();
|
||||
if components.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(root) = find_root(r, false).await {
|
||||
if let Some(loc) = walk_namespace(r, root, &components, false).await {
|
||||
return Some(loc);
|
||||
}
|
||||
}
|
||||
if let Some(root) = find_root(r, true).await {
|
||||
if let Some(loc) = walk_namespace(r, root, &components, true).await {
|
||||
return Some(loc);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the namespace root: the Primary Volume Descriptor (`joliet =
|
||||
/// false`) or the Joliet Supplementary Volume Descriptor (`joliet =
|
||||
/// true`, identified by its UCS-2 escape sequence). Scans the whole
|
||||
/// descriptor area rather than assuming fixed sectors, skipping any
|
||||
/// non-`CD001` sector — sloppy mastering tools leave gaps. Returns the
|
||||
/// root directory's `(lba, len)`.
|
||||
async fn find_root<R: IsoReadAt + Send>(r: &mut R, joliet: bool) -> Option<(u64, u64)> {
|
||||
let vd = find_descriptor(r, joliet).await?;
|
||||
// Root directory record at descriptor offset 156, 34 bytes.
|
||||
parse_dir_record_ext(&vd[156..156 + 34])
|
||||
}
|
||||
|
||||
/// Scan the Volume Descriptor Set (LBA 16..32) for the wanted
|
||||
/// descriptor: PVD (type 0x01) or Joliet SVD (type 0x02 carrying a
|
||||
/// UCS-2 level 1/2/3 escape sequence at offset 88). Tolerant of
|
||||
/// non-`CD001` filler sectors; stops at the Set Terminator.
|
||||
pub(crate) async fn find_descriptor<R: IsoReadAt + Send>(
|
||||
r: &mut R,
|
||||
joliet: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
for lba in 16u64..32 {
|
||||
let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else {
|
||||
return None;
|
||||
};
|
||||
if &vd[1..6] != b"CD001" {
|
||||
continue;
|
||||
}
|
||||
match vd[0] {
|
||||
0x01 if !joliet => return Some(vd),
|
||||
0x02 if joliet && has_joliet_escape(&vd) => return Some(vd),
|
||||
0xFF => return None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Joliet SVDs declare a UCS-2 escape sequence at offset 88: `%/@`,
|
||||
/// `%/C`, or `%/E` (levels 1–3).
|
||||
fn has_joliet_escape(vd: &[u8]) -> bool {
|
||||
matches!(vd.get(88..91), Some([0x25, 0x2F, 0x40 | 0x43 | 0x45]))
|
||||
}
|
||||
|
||||
/// Walk path components down one namespace's directory tree. The
|
||||
/// original walk was tail-recursive; iterate instead so the future
|
||||
/// stays a plain (non-boxed) state machine.
|
||||
async fn walk_namespace<R: IsoReadAt + Send>(
|
||||
r: &mut R,
|
||||
root: (u64, u64),
|
||||
components: &[&str],
|
||||
joliet: bool,
|
||||
) -> Option<FileLocation> {
|
||||
let (mut lba, mut len) = root;
|
||||
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, joliet)?;
|
||||
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.
|
||||
/// `joliet` switches the identifier decoding (UCS-2 big-endian vs
|
||||
/// d-characters); the record layout is otherwise identical.
|
||||
fn scan_dir(dir: &[u8], target: &str, joliet: bool) -> 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, joliet);
|
||||
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`.
|
||||
/// Joliet identifiers are UCS-2 big-endian; decode then normalize the
|
||||
/// same way (the `;1` suffix is two UCS-2 characters there).
|
||||
fn dir_record_name(rec: &[u8], joliet: bool) -> String {
|
||||
if joliet {
|
||||
let name_len = *rec.get(32).unwrap_or(&0) as usize;
|
||||
if name_len < 2 || rec.len() < 33 + name_len {
|
||||
return String::new();
|
||||
}
|
||||
let raw = &rec[33..33 + name_len];
|
||||
let units: Vec<u16> = raw
|
||||
.chunks_exact(2)
|
||||
.map(|p| u16::from_be_bytes([p[0], p[1]]))
|
||||
.collect();
|
||||
let s = String::from_utf16_lossy(&units);
|
||||
let s = s.rfind(';').map_or_else(|| s.as_str(), |i| &s[..i]);
|
||||
return s.strip_suffix('.').unwrap_or(s).to_string();
|
||||
}
|
||||
primary_record_name(rec)
|
||||
}
|
||||
|
||||
fn primary_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,
|
||||
joliet_only: bool,
|
||||
}
|
||||
|
||||
impl TestIsoBuilder {
|
||||
pub fn new(volume_label: &str) -> Self {
|
||||
Self {
|
||||
root: Node::default(),
|
||||
volume_label: volume_label.to_string(),
|
||||
el_torito: false,
|
||||
joliet_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn el_torito(mut self, on: bool) -> Self {
|
||||
self.el_torito = on;
|
||||
self
|
||||
}
|
||||
|
||||
/// Model the Windows-mastering worst case: the primary ISO9660
|
||||
/// tree is empty (just `.`/`..` in the root) and every real name
|
||||
/// lives only in the Joliet supplementary namespace. Exercises
|
||||
/// the v0.7.5 Joliet fallback end to end.
|
||||
#[must_use]
|
||||
pub fn joliet_only(mut self, on: bool) -> Self {
|
||||
self.joliet_only = 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. Primary directories first (1
|
||||
// sector each), then an optional parallel set of Joliet
|
||||
// directory extents, then file contents (shared by both
|
||||
// namespaces — only the directory trees differ).
|
||||
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 jdirs: Vec<(*const Node, u64)> = Vec::new();
|
||||
if self.joliet_only {
|
||||
alloc_dirs(&self.root, &mut next_lba, &mut jdirs);
|
||||
}
|
||||
let jlba_of = |n: &Node| -> u64 {
|
||||
jdirs
|
||||
.iter()
|
||||
.find(|(p, _)| std::ptr::eq(*p, n))
|
||||
.map(|(_, l)| *l)
|
||||
.expect("joliet 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. `joliet` switches the
|
||||
// identifier encoding; `skip_children` writes a bare ./..
|
||||
// directory (the mangled-primary worst case).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_dir(
|
||||
img: &mut [u8],
|
||||
n: &Node,
|
||||
self_lba: u64,
|
||||
parent_lba: u64,
|
||||
dir_lba_of: &dyn Fn(&Node) -> u64,
|
||||
file_lba_of: &dyn Fn(&Node) -> u64,
|
||||
joliet: bool,
|
||||
skip_children: bool,
|
||||
) {
|
||||
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);
|
||||
if skip_children {
|
||||
return;
|
||||
}
|
||||
let encode = |name: &str, file: bool| -> Vec<u8> {
|
||||
if joliet {
|
||||
// Joliet preserves case; files still carry `;1`.
|
||||
let s = if file {
|
||||
format!("{name};1")
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
s.encode_utf16().flat_map(u16::to_be_bytes).collect()
|
||||
} else if file {
|
||||
// Primary gets the ISO9660 uppercase `;1` treatment
|
||||
// so case-insensitive + version-strip matching is
|
||||
// what the tests actually exercise.
|
||||
format!("{};1", name.to_ascii_uppercase()).into_bytes()
|
||||
} else {
|
||||
name.to_ascii_uppercase().into_bytes()
|
||||
}
|
||||
};
|
||||
for (name, child) in &n.children {
|
||||
if let Some(c) = &child.content {
|
||||
put(
|
||||
record(
|
||||
&encode(name, true),
|
||||
file_lba_of(child),
|
||||
c.len() as u64,
|
||||
false,
|
||||
),
|
||||
&mut off,
|
||||
);
|
||||
} else {
|
||||
put(
|
||||
record(&encode(name, false), dir_lba_of(child), SECTOR, true),
|
||||
&mut off,
|
||||
);
|
||||
}
|
||||
}
|
||||
for child in n.children.values() {
|
||||
if child.content.is_none() {
|
||||
write_dir(
|
||||
img,
|
||||
child,
|
||||
dir_lba_of(child),
|
||||
self_lba,
|
||||
dir_lba_of,
|
||||
file_lba_of,
|
||||
joliet,
|
||||
false,
|
||||
);
|
||||
} 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 root_lba = lba_of(&self.root);
|
||||
write_dir(
|
||||
&mut img,
|
||||
&self.root,
|
||||
root_lba,
|
||||
root_lba,
|
||||
&lba_of,
|
||||
&file_lba_of,
|
||||
false,
|
||||
self.joliet_only,
|
||||
);
|
||||
let jroot_lba = if self.joliet_only {
|
||||
let jroot = jlba_of(&self.root);
|
||||
write_dir(
|
||||
&mut img,
|
||||
&self.root,
|
||||
jroot,
|
||||
jroot,
|
||||
&jlba_of,
|
||||
&file_lba_of,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
Some(jroot)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// 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, then the optional
|
||||
// Joliet SVD, then the set terminator.
|
||||
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;
|
||||
}
|
||||
if let Some(jroot) = jroot_lba {
|
||||
img[vd] = 0x02;
|
||||
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
|
||||
// Joliet level-3 UCS-2 escape sequence.
|
||||
img[vd + 88..vd + 91].copy_from_slice(&[0x25, 0x2F, 0x45]);
|
||||
let jroot_rec = record(&[0x00], jroot, SECTOR, true);
|
||||
img[vd + 156..vd + 156 + 34].copy_from_slice(&jroot_rec[..34]);
|
||||
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) collapse to the 3 distinct
|
||||
// extents, plus one: the "/a/three" miss falls back to the Joliet
|
||||
// namespace search (v0.7.5), which reads the terminator sector
|
||||
// once before concluding there is no SVD.
|
||||
assert_eq!(counting.calls, 4, "all repeat reads must hit the cache");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joliet_fallback_finds_names_missing_from_primary() {
|
||||
// Windows-mastering worst case: primary tree is bare (./.. only),
|
||||
// real names live only in the Joliet SVD. The lookup must fall
|
||||
// back and still resolve nested paths case-insensitively.
|
||||
let img = TestIsoBuilder::new("APPLIANCE")
|
||||
.joliet_only(true)
|
||||
.file("/images/pxeboot/vmlinuz", b"JKERNEL")
|
||||
.file("/sources/boot.wim", b"JWIM")
|
||||
.build();
|
||||
let mut r = MemReadAt(img);
|
||||
let loc = block_on(lookup(&mut r, "/images/pxeboot/vmlinuz")).expect("joliet fallback");
|
||||
let bytes = block_on(r.read_at(loc.offset, loc.length as u32)).unwrap();
|
||||
assert_eq!(&bytes, b"JKERNEL");
|
||||
assert!(block_on(lookup(&mut r, "/SOURCES/BOOT.WIM")).is_some());
|
||||
assert!(block_on(lookup(&mut r, "/images/pxeboot/missing")).is_none());
|
||||
}
|
||||
|
||||
#[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 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 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 smb;
|
||||
pub mod smb_share;
|
||||
@@ -29,6 +36,7 @@ pub mod windows;
|
||||
|
||||
pub use entry::{BootEntry, BootKind, KernelArgs};
|
||||
pub use introspect::{DistroFamily, IntrospectionReport};
|
||||
pub use iso_fs::FileLocation;
|
||||
// v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace
|
||||
// `smbclient` CLI replaced it — works in any container (no
|
||||
// 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
|
||||
//! 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 bytes::Bytes;
|
||||
use nfs3_client::tokio::TokioConnector;
|
||||
@@ -100,6 +102,18 @@ const READ_CHUNK_BYTES: u32 = 64 * 1024;
|
||||
/// client park gigabytes of decoded ISO in RAM.
|
||||
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
|
||||
/// re-adding the same coordinates is idempotent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -177,6 +191,9 @@ pub struct NfsShareManager {
|
||||
/// opens its own NFS connection so concurrency isn't a hard
|
||||
/// requirement, but serializing keeps log output predictable.
|
||||
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 {
|
||||
@@ -190,6 +207,7 @@ impl NfsShareManager {
|
||||
inner: Arc::new(Mutex::new(Inner::default())),
|
||||
iso_store,
|
||||
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 to_probe: Vec<ProbeJob> = Vec::new();
|
||||
for entry in listing {
|
||||
let iso_id = format!("nfs-{}-{}", share.id, slugify_str(&entry.filename));
|
||||
// Same approach as SMB: no real introspection over the
|
||||
// network in v0.4.67. The boot-entry generator falls back
|
||||
// to filename-based sanboot detection.
|
||||
let report = IntrospectionReport::default();
|
||||
// v0.7.4: real introspection over the share — NFSv3 READ3
|
||||
// takes an offset, so the ISO9660 probes work remotely. A
|
||||
// cache hit registers the full report immediately; a miss
|
||||
// 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 source = IsoSource::Nfs {
|
||||
share_id: share.id.clone(),
|
||||
@@ -413,11 +445,88 @@ impl NfsShareManager {
|
||||
target: "openpxe::nfs",
|
||||
id = %id, server = %share.server, export = %share.export,
|
||||
iso_count = count,
|
||||
pending_introspection = to_probe.len(),
|
||||
"NFS share scanned"
|
||||
);
|
||||
if !to_probe.is_empty() {
|
||||
self.spawn_introspection_pass(&share, to_probe);
|
||||
}
|
||||
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(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -490,6 +599,96 @@ struct NfsListEntry {
|
||||
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
|
||||
/// size + file handle. Returns a flat list. Errors are returned with
|
||||
/// 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
|
||||
//! 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 bytes::Bytes;
|
||||
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.
|
||||
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
|
||||
/// lives in the 0600 creds file, never here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -209,6 +223,9 @@ pub struct SftpShareManager {
|
||||
/// Serializes scan operations on the same manager for predictable
|
||||
/// log output; each scan opens its own SSH connection.
|
||||
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 {
|
||||
@@ -222,6 +239,7 @@ impl SftpShareManager {
|
||||
inner: Arc::new(Mutex::new(Inner::default())),
|
||||
iso_store,
|
||||
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 to_probe: Vec<ProbeJob> = Vec::new();
|
||||
for entry in listing {
|
||||
let iso_id = format!("sftp-{}-{}", share.id, slugify_str(&entry.filename));
|
||||
// Same as NFS/SMB: no over-the-network introspection yet, so
|
||||
// register `Unknown` and let the boot-entry generator fall
|
||||
// back to filename-based detection. SFTP *could* do bounded
|
||||
// PVD reads (it has random access) — a follow-up can add it.
|
||||
let report = IntrospectionReport::default();
|
||||
// v0.7.4: real introspection over the share — SFTP file
|
||||
// handles are seekable, so the ISO9660 probes work remotely.
|
||||
// Cache hit → full report now; miss → provisional filename-
|
||||
// based report (scan returns fast) + a queued 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 source = IsoSource::Sftp {
|
||||
share_id: share.id.clone(),
|
||||
@@ -506,11 +536,88 @@ impl SftpShareManager {
|
||||
target: "openpxe::sftp",
|
||||
id = %id, server = %share.server, export = %share.export,
|
||||
iso_count = count,
|
||||
pending_introspection = to_probe.len(),
|
||||
"SFTP share scanned"
|
||||
);
|
||||
if !to_probe.is_empty() {
|
||||
self.spawn_introspection_pass(&share, &creds, to_probe);
|
||||
}
|
||||
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) {
|
||||
if fingerprint.is_empty() {
|
||||
return;
|
||||
@@ -652,6 +759,43 @@ struct SftpConn {
|
||||
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
|
||||
/// verification. We never construct an `Err` from `check_server_key`;
|
||||
/// 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
|
||||
//! 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 openpxe_core::{Error, Result};
|
||||
use parking_lot::Mutex;
|
||||
@@ -455,15 +455,14 @@ impl SmbShareManager {
|
||||
let mut count = 0u32;
|
||||
for entry in listing {
|
||||
let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename));
|
||||
// SMB sources don't get a real introspection pass — that
|
||||
// would require seeking into the ISO9660 PVD over the
|
||||
// network, and smbclient CLI doesn't seek. We register an
|
||||
// `Unknown` family so the boot-entry generator falls back
|
||||
// to generic sanboot/wimboot detection from the filename
|
||||
// and the operator gets *something* bootable. A follow-up
|
||||
// release can do a bounded `smbclient get` of the first
|
||||
// 64 KiB for real detection.
|
||||
let report = IntrospectionReport::default();
|
||||
// SMB sources can't get the content-probe pass NFS/SFTP got
|
||||
// in v0.7.4 — the ISO9660 probes need seeks, and smbclient's
|
||||
// CLI streaming doesn't seek. The provisional filename-token
|
||||
// report is as far as SMB detection goes: family for the UI
|
||||
// when the name says it ("rhel-9.0…", "Win11_…"), and
|
||||
// `introspect_rev = 0` so the entry generator keeps the
|
||||
// optimistic sanboot entry.
|
||||
let report = provisional_report(&entry.filename);
|
||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||
let source = IsoSource::Smb {
|
||||
share_id: share.id.clone(),
|
||||
|
||||
@@ -300,7 +300,19 @@ impl IsoStore {
|
||||
}
|
||||
let partial_path = self.iso_dir.join(format!("{id}.partial"));
|
||||
if partial_path.exists() {
|
||||
return Err(Error::Invalid(format!("iso '{id}' is already uploading")));
|
||||
// A leftover .partial is an upload abandoned mid-flight (browser
|
||||
// refresh, tab close, dropped connection) — nothing reaps it
|
||||
// otherwise, and the operator hits a bogus "already uploading"
|
||||
// on retry. The chunked protocol can't resume it anyway (a
|
||||
// fresh session restarts at offset 0), so reclaim it.
|
||||
// ponytail: two tabs uploading the *same filename* at once would
|
||||
// race here — last writer wins, and the truncating create below
|
||||
// keeps that from corrupting a half-written file.
|
||||
tracing::info!(
|
||||
target: "openpxe::iso", %id,
|
||||
"reclaiming abandoned .partial from a prior upload attempt"
|
||||
);
|
||||
tokio::fs::remove_file(&partial_path).await.ok();
|
||||
}
|
||||
let file = tokio::fs::File::create(&partial_path).await?;
|
||||
Ok(UploadHandle {
|
||||
@@ -415,6 +427,28 @@ impl IsoStore {
|
||||
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
|
||||
/// and NFS share managers when an operator removes a share, or
|
||||
/// before re-scanning to clean out stale entries. The same id
|
||||
@@ -850,15 +884,39 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_upload_rejects_existing_partial_file() {
|
||||
async fn begin_upload_reclaims_stale_partial_file() {
|
||||
// v0.8.0: an abandoned .partial (browser refresh / crash / dropped
|
||||
// connection) must not block a re-upload with a bogus "already
|
||||
// uploading" — begin_upload reclaims it and starts fresh, since the
|
||||
// chunked protocol can't resume a dead session anyway.
|
||||
let dir = tempdir().unwrap();
|
||||
let store = IsoStore::new(dir.path().to_path_buf());
|
||||
store.ensure_dirs().await.unwrap();
|
||||
tokio::fs::write(dir.path().join("ubuntu.partial"), b"in-flight")
|
||||
let partial = dir.path().join("ubuntu.partial");
|
||||
tokio::fs::write(&partial, b"in-flight").await.unwrap();
|
||||
|
||||
let handle = store
|
||||
.begin_upload("ubuntu.iso")
|
||||
.await
|
||||
.expect("stale .partial is reclaimed, not rejected");
|
||||
assert_eq!(handle.id, "ubuntu");
|
||||
// Reclaimed: the leftover bytes are gone (fresh, empty file).
|
||||
let meta = tokio::fs::metadata(&partial).await.unwrap();
|
||||
assert_eq!(meta.len(), 0, "stale .partial must be truncated on reclaim");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_upload_still_rejects_completed_iso() {
|
||||
// A finished upload (final .iso on disk) is a genuine duplicate, not
|
||||
// an abandoned attempt — that case must still be refused.
|
||||
let dir = tempdir().unwrap();
|
||||
let store = IsoStore::new(dir.path().to_path_buf());
|
||||
store.ensure_dirs().await.unwrap();
|
||||
tokio::fs::write(dir.path().join("rocky.iso"), b"done")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let r = store.begin_upload("ubuntu.iso").await;
|
||||
let r = store.begin_upload("rocky.iso").await;
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,5 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
bytes.workspace = true
|
||||
time.workspace = true
|
||||
|
||||
@@ -120,8 +120,41 @@ async fn main() -> anyhow::Result<()> {
|
||||
let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir);
|
||||
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
|
||||
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
|
||||
// v0.8.1: zero-touch first-run bootstrap. If no admin exists yet and the
|
||||
// operator supplied OPENPXE_ADMIN_USERNAME + OPENPXE_ADMIN_PASSWORD (or
|
||||
// …_PASSWORD_FILE, for Docker/K8s secrets), create the admin now so a
|
||||
// fresh container is usable without the web setup wizard. Seeds the
|
||||
// first run only — once an admin exists (including one made in the UI)
|
||||
// this is a no-op, so a lingering env var can't reset a rotated password.
|
||||
if !admin.is_configured() {
|
||||
if let Ok(username) = std::env::var("OPENPXE_ADMIN_USERNAME") {
|
||||
let password = std::env::var("OPENPXE_ADMIN_PASSWORD_FILE")
|
||||
.ok()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.map(|s| s.trim_end_matches(['\n', '\r']).to_string())
|
||||
.or_else(|| std::env::var("OPENPXE_ADMIN_PASSWORD").ok());
|
||||
if let Some(password) = password {
|
||||
match admin.bootstrap(&username, &password) {
|
||||
Ok(p) => tracing::info!(
|
||||
target: "openpxe::auth", username = %p.username,
|
||||
"admin bootstrapped from environment"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
target: "openpxe::auth",
|
||||
"env admin bootstrap failed ({e}); use the web setup wizard"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "openpxe::auth",
|
||||
"OPENPXE_ADMIN_USERNAME set without OPENPXE_ADMIN_PASSWORD[_FILE]; skipping bootstrap"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
|
||||
let notify = openpxe_core::NotifyStore::load_or_default(&config.paths.work_dir);
|
||||
let api_key = openpxe_core::ApiKeyStore::load_or_init(&config.paths.work_dir);
|
||||
let sessions = openpxe_http_api::auth::SessionStore::default();
|
||||
let metrics = Metrics::new();
|
||||
|
||||
@@ -201,6 +234,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||
admin: admin.clone(),
|
||||
sessions: sessions.clone(),
|
||||
api_key,
|
||||
sso: sso.clone(),
|
||||
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
|
||||
notify: notify.clone(),
|
||||
@@ -211,6 +245,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
sftp_shares: sftp_shares.clone(),
|
||||
unattended: unattended.clone(),
|
||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||
fetch_jobs: openpxe_http_api::fetch::FetchJobs::default(),
|
||||
log_bus: log_bus.clone(),
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
public_base_url: public_base_url.clone(),
|
||||
|
||||
@@ -15,6 +15,4 @@ openpxe-ipxe-assets.workspace = true
|
||||
tokio.workspace = true
|
||||
socket2.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
|
||||
@@ -920,6 +920,20 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
the same 16px below its form. */
|
||||
.card .body > label.field:has(+ button) { margin-bottom: 0; }
|
||||
|
||||
/* v0.7.2: inline list filter (Available images / Unattended files). */
|
||||
.list-search { padding: 14px 16px 0; }
|
||||
.list-search input { width: 100%; }
|
||||
/* 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; }
|
||||
|
||||
+324
-119
@@ -178,12 +178,14 @@
|
||||
if (iso.introspection.el_torito) {
|
||||
return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' };
|
||||
}
|
||||
// Remote-share ISOs aren't introspected (no random access over the
|
||||
// network), so el_torito is unknown — assume bootable and let sanboot
|
||||
// try rather than cry wolf.
|
||||
// v0.7.4: NFS/SFTP ISOs now introspect over the share, so a probed
|
||||
// remote ISO flows through the kernel/el_torito branches above like
|
||||
// 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';
|
||||
if (remote) {
|
||||
return { ok: true, warn: 'remote ISO — not introspected; sanboot is attempted at boot' };
|
||||
if (remote && (iso.introspection.introspect_rev || 0) === 0) {
|
||||
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:
|
||||
// a data/appliance image (e.g. a VMware vCenter bundle), not a bootable
|
||||
@@ -267,7 +269,7 @@
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Unattended file'), sel]),
|
||||
el('span', {class:'name'}, 'Unattended file (in Storage → Advanced)'), sel]),
|
||||
]);
|
||||
return {
|
||||
wrap,
|
||||
@@ -439,17 +441,18 @@
|
||||
el('div', {class:'v'}, net.server_ip || '?'),
|
||||
el('div', {class:'k'}, 'NIC name'),
|
||||
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
|
||||
// v0.7.2: physical link details (operstate · speed · duplex ·
|
||||
// port MAC) so the operator can confirm WHICH port answers
|
||||
// PXE in multi-NIC / trunked environments.
|
||||
el('div', {class:'k'}, 'Link'),
|
||||
el('div', {class:'v'}, net.nic_link || '—'),
|
||||
el('div', {class:'k'}, 'Subnet mask'),
|
||||
el('div', {class:'v'}, net.subnet_mask || '?'),
|
||||
el('div', {class:'k'}, 'Gateway'),
|
||||
el('div', {class:'v'}, net.gateway || '?'),
|
||||
el('div', {class:'k'}, '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'},
|
||||
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
|
||||
@@ -600,48 +603,40 @@
|
||||
|
||||
// ── Upload card ──
|
||||
const drop = el('div', {class:'drop', id:'drop'}, [
|
||||
el('div', {}, ['Drop an ', el('strong', {}, '.iso'), ' here, or click to choose.']),
|
||||
el('div', {}, ['Drop one or more ', el('strong', {}, '.iso'), ' files here, or click to choose.']),
|
||||
el('div', {style:'font-size:12px;margin-top:6px'},
|
||||
'Linux + Windows installers auto-detected on upload. Streaming, no 502s on big files.'),
|
||||
'Linux + Windows installers auto-detected on upload. Multiple files upload at once. Streaming, no 502s on big files.'),
|
||||
]);
|
||||
const file = el('input', {type:'file', accept:'.iso,application/octet-stream',
|
||||
style:'display:none', id:'file'});
|
||||
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
|
||||
const upMsg = el('div', {class:'msg', id:'upmsg'});
|
||||
// v0.5.8: cancel button — shown only while an upload is in flight.
|
||||
const cancelUpload = el('button', {class:'danger', type:'button',
|
||||
style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload');
|
||||
multiple:true, style:'display:none', id:'file'});
|
||||
// v0.8.0: one progress row per file, appended here. Replaces the
|
||||
// single shared bar/msg/cancel that a second concurrent upload used
|
||||
// to clobber.
|
||||
const uploadsList = el('div', {id:'uploads', style:'display:grid;gap:12px'});
|
||||
|
||||
drop.onclick = () => file.click();
|
||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('hover'));
|
||||
drop.addEventListener('drop', e => {
|
||||
e.preventDefault(); drop.classList.remove('hover');
|
||||
if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]);
|
||||
});
|
||||
file.onchange = () => { if (file.files[0]) upload(file.files[0]); };
|
||||
|
||||
// Chunked upload telemetry. The old browser path posted one huge
|
||||
// multipart body, which left operators staring at 0% when a reverse
|
||||
// proxy buffered or rejected the request before OpenPXE saw it. This
|
||||
// path writes small raw chunks; each acknowledged chunk advances the
|
||||
// bar and leaves a visible .partial file in the ISO directory.
|
||||
async function upload(f) {
|
||||
const started = Date.now();
|
||||
const bar = $('#bar');
|
||||
const setStatus = (text, cls) => { upMsg.textContent = text; upMsg.className = 'msg ' + (cls || ''); };
|
||||
const update = (loaded, total, phase) => {
|
||||
const pct = total > 0 ? Math.min(100, (loaded / total) * 100) : 100;
|
||||
bar.style.width = pct.toFixed(1) + '%';
|
||||
const elapsed = Math.max(0.001, (Date.now() - started) / 1000);
|
||||
const rate = loaded > 0 ? loaded / elapsed : 0;
|
||||
const remain = rate > 0 ? (total - loaded) / rate : 0;
|
||||
setStatus(
|
||||
phase + ' ' + f.name + ' - ' +
|
||||
fmtBytes(loaded) + ' of ' + fmtBytes(total) +
|
||||
' (' + pct.toFixed(1) + '%, ' + fmtBytes(rate) + '/s' +
|
||||
(remain > 0 ? ', ' + Math.ceil(remain) + 's left' : '') + ')');
|
||||
// One page-leave guard + one tab-hide cleanup for the whole card,
|
||||
// registered only while ≥1 upload is in flight (added on 0→1, removed
|
||||
// on 1→0) so listeners never pile up across re-renders.
|
||||
let activeUploads = 0;
|
||||
const activeIds = new Set();
|
||||
const warnLeave = (e) => { if (activeUploads > 0) { e.preventDefault(); e.returnValue = ''; return ''; } };
|
||||
const abortOnHide = () => {
|
||||
// keepalive lets these DELETEs outlive the unload; the server also
|
||||
// reclaims an orphaned .partial on the next upload, so best-effort
|
||||
// is fine here.
|
||||
for (const id of activeIds) {
|
||||
try { fetch('/api/uploads/' + encodeURIComponent(id), {method:'DELETE', keepalive:true}); } catch (_) {}
|
||||
}
|
||||
};
|
||||
const addGuards = () => {
|
||||
window.addEventListener('beforeunload', warnLeave);
|
||||
window.addEventListener('pagehide', abortOnHide);
|
||||
};
|
||||
const removeGuards = () => {
|
||||
window.removeEventListener('beforeunload', warnLeave);
|
||||
window.removeEventListener('pagehide', abortOnHide);
|
||||
};
|
||||
|
||||
const failText = async (r) => {
|
||||
const text = (await r.text()).slice(0, 240);
|
||||
let hint = '';
|
||||
@@ -652,19 +647,66 @@
|
||||
return 'HTTP ' + r.status + ' ' + text + hint;
|
||||
};
|
||||
|
||||
// Launch an upload per dropped/selected .iso. The browser's ~6
|
||||
// connections-per-origin cap naturally bounds how many stream at
|
||||
// once, so there's no hand-rolled queue. Non-.iso files are ignored.
|
||||
const startMany = (fileList) => {
|
||||
[...fileList].filter(f => /\.iso$/i.test(f.name)).forEach(uploadOne);
|
||||
};
|
||||
|
||||
drop.onclick = () => file.click();
|
||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('hover'));
|
||||
drop.addEventListener('drop', e => {
|
||||
e.preventDefault(); drop.classList.remove('hover');
|
||||
startMany(e.dataTransfer.files);
|
||||
});
|
||||
// Reset value so re-selecting the same filename still fires onchange.
|
||||
file.onchange = () => { startMany(file.files); file.value = ''; };
|
||||
|
||||
// One independent chunked upload with its own progress row. The old
|
||||
// browser path posted one huge multipart body, which left operators
|
||||
// staring at 0% when a reverse proxy buffered or rejected the request
|
||||
// before OpenPXE saw it. This path writes small raw chunks; each
|
||||
// acknowledged chunk advances the bar and leaves a visible .partial.
|
||||
async function uploadOne(f) {
|
||||
const started = Date.now();
|
||||
const bar = el('div', {class:'bar'});
|
||||
const prog = el('div', {class:'progress active'}, bar);
|
||||
const rowMsg = el('div', {class:'msg'});
|
||||
const cancelBtn = el('button', {class:'danger', type:'button', style:'margin-top:8px'}, 'Cancel');
|
||||
const row = el('div', {}, [
|
||||
el('div', {style:'font-weight:600;font-size:13px;margin-bottom:6px;word-break:break-all'}, f.name),
|
||||
prog, rowMsg, cancelBtn,
|
||||
]);
|
||||
uploadsList.appendChild(row);
|
||||
|
||||
const setStatus = (text, cls) => { rowMsg.textContent = text; rowMsg.className = 'msg ' + (cls || ''); };
|
||||
const update = (loaded, total, phase) => {
|
||||
const pct = total > 0 ? Math.min(100, (loaded / total) * 100) : 100;
|
||||
bar.style.width = pct.toFixed(1) + '%';
|
||||
const elapsed = Math.max(0.001, (Date.now() - started) / 1000);
|
||||
const rate = loaded > 0 ? loaded / elapsed : 0;
|
||||
const remain = rate > 0 ? (total - loaded) / rate : 0;
|
||||
setStatus(
|
||||
phase + ' - ' +
|
||||
fmtBytes(loaded) + ' of ' + fmtBytes(total) +
|
||||
' (' + pct.toFixed(1) + '%, ' + fmtBytes(rate) + '/s' +
|
||||
(remain > 0 ? ', ' + Math.ceil(remain) + 's left' : '') + ')');
|
||||
};
|
||||
|
||||
let uploadId = null;
|
||||
// v0.5.8: cancel + leave-page guard. The AbortController stops the
|
||||
// in-flight chunk; the beforeunload listener warns the operator
|
||||
// that navigating away aborts the upload (the server-side partial
|
||||
// is then cleaned up by the DELETE in the catch below).
|
||||
// The AbortController stops this upload's in-flight chunk on Cancel.
|
||||
// The card-level beforeunload guard (added while activeUploads > 0)
|
||||
// warns on navigation; the server reclaims an abandoned .partial on
|
||||
// the next upload either way.
|
||||
const ac = new AbortController();
|
||||
let canceled = false;
|
||||
const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; };
|
||||
window.addEventListener('beforeunload', warnLeave);
|
||||
cancelUpload.style.display = '';
|
||||
cancelUpload.onclick = () => { canceled = true; ac.abort(); };
|
||||
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
||||
prog.classList.add('active');
|
||||
cancelBtn.onclick = () => { canceled = true; ac.abort(); };
|
||||
|
||||
activeUploads += 1;
|
||||
if (activeUploads === 1) addGuards();
|
||||
setStatus('Preparing ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
||||
bar.style.width = '1%';
|
||||
|
||||
try {
|
||||
@@ -675,6 +717,7 @@
|
||||
if (!begin.ok) throw new Error(await failText(begin));
|
||||
const session = await begin.json();
|
||||
uploadId = session.upload_id;
|
||||
activeIds.add(uploadId);
|
||||
const chunkSize = Math.max(1024 * 1024, Number(session.chunk_size || 8 * 1024 * 1024));
|
||||
|
||||
let offset = Number(session.offset || 0);
|
||||
@@ -699,25 +742,100 @@
|
||||
} while (!finished);
|
||||
|
||||
setStatus('Uploaded and analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok');
|
||||
render('storage');
|
||||
} catch (err) {
|
||||
if (uploadId) {
|
||||
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
|
||||
catch {}
|
||||
catch (_) {}
|
||||
}
|
||||
if (canceled || (err && err.name === 'AbortError')) {
|
||||
setStatus('Upload canceled — partial file discarded.', '');
|
||||
setStatus('Canceled — partial file discarded.', '');
|
||||
} else {
|
||||
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
||||
}
|
||||
} finally {
|
||||
window.removeEventListener('beforeunload', warnLeave);
|
||||
cancelUpload.style.display = 'none';
|
||||
cancelUpload.onclick = null;
|
||||
if (uploadId) activeIds.delete(uploadId);
|
||||
cancelBtn.style.display = 'none';
|
||||
prog.classList.remove('active');
|
||||
if (!upMsg.className.includes('ok')) bar.style.width = '0';
|
||||
activeUploads -= 1;
|
||||
if (activeUploads === 0) {
|
||||
removeGuards();
|
||||
// Refresh the table to show the new image(s) — but only if the
|
||||
// operator is still on Storage. isConnected goes false once
|
||||
// render() swapped the view, so a mid-upload tab change won't
|
||||
// yank them back here.
|
||||
if (uploadsList.isConnected) render('storage');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v0.8.1: add ISO by URL. Paste a link and the server streams it
|
||||
// straight into the store and auto-detects it — no download-then-
|
||||
// reupload. Progress polls /api/isos/fetch and shows rows below,
|
||||
// mirroring uploads. On an air-gapped network, use the drop zone.
|
||||
const urlInput = el('input', {type:'url', id:'iso-url', style:'flex:1',
|
||||
placeholder:'https://example.com/systemrescue.iso'});
|
||||
const fetchBtn = el('button', {class:'ghost', type:'button', style:'margin-left:8px'}, 'Fetch');
|
||||
const fetchMsg = el('div', {class:'msg', style:'margin-top:6px'});
|
||||
const fetchList = el('div', {id:'fetches', style:'display:grid;gap:12px;margin-top:12px'});
|
||||
const urlRow = el('div', {style:'margin-top:14px'}, [
|
||||
el('label', {class:'field', style:'margin-bottom:0'}, [
|
||||
el('span', {class:'name'}, 'Or add by URL'),
|
||||
el('div', {style:'display:flex;align-items:center'}, [urlInput, fetchBtn]),
|
||||
el('span', {class:'hint'},
|
||||
'The server downloads the .iso into storage and auto-detects it — same result as a drag-drop. Any http(s) .iso link works.'),
|
||||
]),
|
||||
fetchMsg,
|
||||
]);
|
||||
|
||||
let fetchTimer = null;
|
||||
const renderFetchRows = (jobs) => {
|
||||
fetchList.replaceChildren(...jobs.map(j => {
|
||||
const pct = j.total > 0 ? Math.min(100, (j.downloaded / j.total) * 100)
|
||||
: (j.state === 'done' ? 100 : 0);
|
||||
let text, cls = '';
|
||||
if (j.state === 'downloading')
|
||||
text = 'Downloading ' + fmtBytes(j.downloaded) +
|
||||
(j.total ? ' of ' + fmtBytes(j.total) + ' (' + pct.toFixed(0) + '%)' : '');
|
||||
else if (j.state === 'done') { text = 'Downloaded and analyzed.'; cls = 'ok'; }
|
||||
else if (j.state === 'failed') { text = 'Failed: ' + (j.error || 'unknown error'); cls = 'err'; }
|
||||
else text = 'Canceled — partial discarded.';
|
||||
const btn = el('button', {class:'danger', type:'button', style:'margin-top:8px'},
|
||||
j.state === 'downloading' ? 'Cancel' : 'Dismiss');
|
||||
btn.onclick = async () => {
|
||||
try { await fetch('/api/isos/fetch/' + encodeURIComponent(j.id), {method:'DELETE'}); } catch (_) {}
|
||||
pollFetches();
|
||||
};
|
||||
return el('div', {}, [
|
||||
el('div', {style:'font-weight:600;font-size:13px;margin-bottom:6px;word-break:break-all'},
|
||||
j.filename + ' · ' + j.url),
|
||||
el('div', {class:'progress' + (j.state === 'downloading' ? ' active' : '')},
|
||||
el('div', {class:'bar', style:'width:' + pct.toFixed(1) + '%'})),
|
||||
el('div', {class:'msg ' + cls}, text),
|
||||
btn,
|
||||
]);
|
||||
}));
|
||||
};
|
||||
async function pollFetches() {
|
||||
if (fetchTimer) { clearTimeout(fetchTimer); fetchTimer = null; }
|
||||
let jobs = [];
|
||||
try { jobs = (await getJSON('/api/isos/fetch')).jobs || []; } catch (_) {}
|
||||
renderFetchRows(jobs);
|
||||
// A successful fetch is read-once on the server, so refreshing here
|
||||
// shows the new image and won't re-trigger on the next poll. Keep
|
||||
// polling only while a download is still in flight.
|
||||
if (jobs.some(j => j.state === 'done')) { render('storage'); return; }
|
||||
if (jobs.some(j => j.state === 'downloading')) fetchTimer = setTimeout(pollFetches, 1500);
|
||||
}
|
||||
async function startFetch() {
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) return;
|
||||
fetchMsg.textContent = 'Starting…'; fetchMsg.className = 'msg';
|
||||
const r = await postJSON('/api/isos/fetch', { url });
|
||||
if (r.ok) { urlInput.value = ''; fetchMsg.textContent = ''; pollFetches(); }
|
||||
else { fetchMsg.textContent = 'Could not start: ' + (await r.text()).slice(0, 160); fetchMsg.className = 'msg err'; }
|
||||
}
|
||||
fetchBtn.onclick = startFetch;
|
||||
urlInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); startFetch(); } });
|
||||
|
||||
// ── ISO table (mixed local + SMB) ──
|
||||
// Each row gets a "Password" cell that toggles a small inline
|
||||
@@ -893,20 +1011,38 @@
|
||||
rowsAndEditors.push(tr, editorRow);
|
||||
});
|
||||
|
||||
// v0.7.2: client-side filter over the image table. Rows travel in
|
||||
// (row, password-editor) pairs; filtering hides both, and an open
|
||||
// editor stays closed for filtered-out rows.
|
||||
const isoSearch = el('input', {type:'search', placeholder:'Filter images… (name, family, category, source)',
|
||||
spellcheck:'false', oninput: () => {
|
||||
// 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];
|
||||
const editor = rowsAndEditors[k + 1];
|
||||
const show = !q || (row.dataset.search || '').includes(q);
|
||||
row.style.display = show ? '' : 'none';
|
||||
if (!show) editor.style.display = 'none';
|
||||
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
|
||||
? el('table', {}, [
|
||||
el('thead', {}, el('tr', {}, [
|
||||
@@ -918,6 +1054,13 @@
|
||||
el('tbody', {}, rowsAndEditors),
|
||||
])
|
||||
: 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) ──
|
||||
// SMB + NFS unified into one "Remote shares" card with a protocol
|
||||
@@ -1266,6 +1409,36 @@
|
||||
]))
|
||||
: [el('div', {class:'empty'}, 'No unattended files yet.')];
|
||||
|
||||
// v0.7.2: filter for big answer-file libraries; v0.7.5: paged 5 at
|
||||
// a time, the same filter-then-page view the image table uses.
|
||||
const UNATT_PAGE_SIZE = 5;
|
||||
let unattPage = 0;
|
||||
const unattPagerInfo = el('span', {});
|
||||
const unattPrev = el('button', {class:'ghost', onclick: () => { unattPage -= 1; applyUnattListView(); }}, '‹ Prev');
|
||||
const unattNext = el('button', {class:'ghost', onclick: () => { unattPage += 1; applyUnattListView(); }}, 'Next ›');
|
||||
const unattSearch = el('input', {type:'search', placeholder:'Filter files by name or kind',
|
||||
spellcheck:'false', oninput: () => { unattPage = 0; applyUnattListView(); }});
|
||||
function applyUnattListView() {
|
||||
if (!unattendedFiles.length) return; // empty-state div carries no dataset
|
||||
const q = unattSearch.value.trim().toLowerCase();
|
||||
const visible = unattRows.filter(r => {
|
||||
r.style.display = 'none';
|
||||
return !q || (r.dataset.search || '').includes(q);
|
||||
});
|
||||
const pages = Math.max(1, Math.ceil(visible.length / UNATT_PAGE_SIZE));
|
||||
if (unattPage >= pages) unattPage = pages - 1;
|
||||
if (unattPage < 0) unattPage = 0;
|
||||
visible.slice(unattPage * UNATT_PAGE_SIZE, (unattPage + 1) * UNATT_PAGE_SIZE)
|
||||
.forEach(r => { r.style.display = ''; });
|
||||
unattPagerInfo.textContent = visible.length
|
||||
? 'Showing ' + (unattPage * UNATT_PAGE_SIZE + 1) + '–' +
|
||||
Math.min(visible.length, (unattPage + 1) * UNATT_PAGE_SIZE) + ' of ' + visible.length
|
||||
: 'No files match';
|
||||
unattPrev.disabled = unattPage === 0;
|
||||
unattNext.disabled = unattPage >= pages - 1;
|
||||
}
|
||||
applyUnattListView();
|
||||
|
||||
const unattendedAdvanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
|
||||
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
||||
el('div', {class:'card', style:'margin-top:14px'}, [
|
||||
@@ -1275,18 +1448,15 @@
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
unattDrop, unattFile, unattMsg,
|
||||
// v0.7.2: filter for big answer-file libraries.
|
||||
unattendedFiles.length > 1 ? (() => {
|
||||
const search = el('input', {type:'search', placeholder:'Filter files… (name, kind)',
|
||||
spellcheck:'false', style:'margin-top:14px', oninput: () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
unattRows.forEach(r => {
|
||||
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
|
||||
});
|
||||
}});
|
||||
return search;
|
||||
})() : null,
|
||||
unattendedFiles.length > 1
|
||||
? el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, unattSearch)
|
||||
: null,
|
||||
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
||||
unattendedFiles.length > UNATT_PAGE_SIZE
|
||||
? el('div', {class:'list-pager', style:'padding:12px 0 0'}, [
|
||||
unattPagerInfo, el('span', {class:'spacer'}), unattPrev, unattNext,
|
||||
])
|
||||
: null,
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'These answer files drive unattended installs. Attach one to a ' +
|
||||
'host pin (Hosts tab) or a queued device (Queue → Profile); on ' +
|
||||
@@ -1296,11 +1466,11 @@
|
||||
]),
|
||||
]);
|
||||
|
||||
return el('div', {}, [el('div', {class:'grid'}, [
|
||||
const root = el('div', {}, [el('div', {class:'grid'}, [
|
||||
diskCard,
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||
el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]),
|
||||
el('div', {class:'body'}, [drop, file, urlRow, uploadsList, fetchList]),
|
||||
]),
|
||||
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
||||
// protocol dropdown. Backend endpoints are unchanged; this is a
|
||||
@@ -1332,10 +1502,17 @@
|
||||
el('h2', {}, 'Available images'),
|
||||
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
||||
]),
|
||||
isos.length > 1 ? el('div', {class:'list-search'}, isoSearch) : null,
|
||||
isos.length > 1
|
||||
? el('div', {class:'list-search'}, el('label', {class:'field', style:'margin-bottom:0'}, isoSearch))
|
||||
: null,
|
||||
isoTable,
|
||||
isoPager,
|
||||
]),
|
||||
]), unattendedAdvanced]);
|
||||
// v0.8.1: resume/kick URL-fetch progress polling; stop it on view swap.
|
||||
root._cleanup = () => { if (fetchTimer) clearTimeout(fetchTimer); };
|
||||
pollFetches();
|
||||
return root;
|
||||
},
|
||||
|
||||
hosts: async () => {
|
||||
@@ -1358,7 +1535,7 @@
|
||||
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
||||
];
|
||||
|
||||
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or prefix aa:bb:cc', spellcheck:'false'});
|
||||
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or aa:bb:cc', spellcheck:'false'});
|
||||
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
|
||||
// v0.7.2: the former separate "Boot rules" card folded into this
|
||||
// form. A full MAC with no architecture saves a per-host pin
|
||||
@@ -1476,38 +1653,32 @@
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {class:'form-row cols-3'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'MAC address or prefix'),
|
||||
macInput,
|
||||
el('span', {class:'hint'}, 'Full MAC pins one machine; a prefix (OUI) makes a group rule.'),
|
||||
]),
|
||||
// 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('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'}, 'Architecture (optional)'),
|
||||
archSel,
|
||||
el('span', {class:'hint'}, 'Selecting one makes a group rule for that firmware.'),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Architecture (optional)'), archSel]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Boot binary (optional)'), binSel]),
|
||||
]),
|
||||
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
||||
el('label', {class:'field', style:'margin-top:14px'}, [
|
||||
el('span', {class:'name'}, 'Target'),
|
||||
targetSel,
|
||||
el('span', {class:'hint'},
|
||||
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Boot binary (optional)'),
|
||||
binSel,
|
||||
el('span', {class:'hint'}, 'Pin Secure Boot racks to "shim" — zero failed boot cycles.'),
|
||||
]),
|
||||
]),
|
||||
el('div', {style:'margin-top:16px'}, profileFields.wrap),
|
||||
upsertBtn, msg,
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'When a matching client requests boot.ipxe, OpenPXE short-circuits ' +
|
||||
'past the interactive menu and chains directly. Decision order: ' +
|
||||
'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.'),
|
||||
'A full MAC pins one machine; a MAC prefix (OUI) or an architecture ' +
|
||||
'saves a first-match group rule. Pin the boot binary to “shim” for ' +
|
||||
'Secure Boot racks — zero failed boot cycles. When a matching client ' +
|
||||
'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'}, [
|
||||
@@ -1680,7 +1851,7 @@
|
||||
},
|
||||
|
||||
settings: async () => {
|
||||
const [status, me, sso, notify, docs] = await Promise.all([
|
||||
const [status, me, sso, notify, docs, apiKey] = await Promise.all([
|
||||
getJSON('/api/status'),
|
||||
getJSON('/api/me').catch(() => ({})),
|
||||
getJSON('/api/sso').catch(() => ({
|
||||
@@ -1690,6 +1861,7 @@
|
||||
// fetches the notify config + API docs it needs too.
|
||||
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||
getJSON('/api/api-key').catch(() => ({ key:'', header:'x-api-key' })),
|
||||
]);
|
||||
|
||||
// ── Account card (Forms admin credentials, v0.4.5).
|
||||
@@ -2003,7 +2175,7 @@
|
||||
// into a collapsible disclosure beneath the core settings cards —
|
||||
// webhook/email notifications + the API reference. Keeps Settings
|
||||
// clean by default while leaving the knobs one click away.
|
||||
const [notifyCard, apiCard] = views._advancedCards(notify, docs);
|
||||
const [notifyCard, apiCard] = views._advancedCards(notify, docs, apiKey);
|
||||
const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
|
||||
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
||||
el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]),
|
||||
@@ -2018,7 +2190,7 @@
|
||||
// and the API reference. There is no longer an Advanced sidebar tab;
|
||||
// the Settings view folds these into a collapsible disclosure and
|
||||
// passes in the pre-fetched `notify` + `docs` payloads.
|
||||
_advancedCards: (notify, docs) => {
|
||||
_advancedCards: (notify, docs, apiKey) => {
|
||||
|
||||
// ── Notification config ──
|
||||
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
||||
@@ -2133,14 +2305,47 @@
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── API reference (relocated from Settings) ──
|
||||
// ── API key + reference (relocated from Settings) ──
|
||||
const groups = docs.groups || [];
|
||||
|
||||
// v0.8.0: operator API key. Paste into the `x-api-key` request
|
||||
// header to drive /api/* from Postman / scripts without a browser
|
||||
// session (full operator access). Read + rotate via /api/api-key.
|
||||
const keyHeader = (apiKey && apiKey.header) || 'x-api-key';
|
||||
const keyField = el('input', {type:'text', readonly:true,
|
||||
value: (apiKey && apiKey.key) || '(unavailable)',
|
||||
style:'width:100%;font-family:var(--mono)'});
|
||||
const keyMsg = el('span', {class:'hint', style:'margin-left:10px'});
|
||||
const copyKey = el('button', {class:'ghost', type:'button', onclick: async () => {
|
||||
try { await navigator.clipboard.writeText(keyField.value); keyMsg.textContent = 'Copied to clipboard.'; }
|
||||
catch { keyField.select(); keyMsg.textContent = 'Select the field and copy.'; }
|
||||
}}, 'Copy');
|
||||
const regenKey = el('button', {class:'danger', type:'button', style:'margin-left:8px',
|
||||
onclick: async () => {
|
||||
if (!confirm('Regenerate the API key? The current key stops working immediately and any client using it must be updated.')) return;
|
||||
const r = await postJSON('/api/api-key/regenerate', {});
|
||||
if (r.ok) { const j = await r.json(); keyField.value = j.key || ''; keyMsg.textContent = 'New key generated.'; }
|
||||
else { keyMsg.textContent = 'Regenerate failed: ' + (await r.text()).slice(0, 120); }
|
||||
}}, 'Regenerate');
|
||||
const apiKeyBlock = el('div', {style:'padding:16px;border-bottom:1px solid var(--border)'}, [
|
||||
el('label', {class:'field', style:'margin-bottom:10px'}, [
|
||||
el('span', {class:'name'}, 'API key'),
|
||||
keyField,
|
||||
el('span', {class:'hint'}, [
|
||||
'Send as the ', el('code', {}, keyHeader),
|
||||
' request header to call the API from Postman or scripts — full operator access, so keep it secret.',
|
||||
]),
|
||||
]),
|
||||
el('div', {}, [copyKey, regenKey, keyMsg]),
|
||||
]);
|
||||
|
||||
const apiCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'API reference'),
|
||||
el('h2', {}, 'API'),
|
||||
el('span', {class:'sub'},
|
||||
groups.reduce((n, g) => n + (g.endpoints || []).length, 0) + ' endpoints'),
|
||||
]),
|
||||
apiKeyBlock,
|
||||
el('div', {class:'api-ref'},
|
||||
groups.length
|
||||
? groups.map(g => el('div', {class:'group'}, [
|
||||
|
||||
+27
-51
@@ -1,66 +1,42 @@
|
||||
# docker-compose for local / homelab deployment.
|
||||
# OpenPXE — single-host / homelab deployment.
|
||||
#
|
||||
# Two usage patterns:
|
||||
# One container: DHCP proxy + TFTP + iPXE chainload + HTTP (web UI, boot
|
||||
# scripts, and ISO range streaming).
|
||||
#
|
||||
# 1. Local MVP test — host network, proxy-DHCP off (don't fight your
|
||||
# existing DHCP server on the LAN), TFTP + HTTP exposed on the host:
|
||||
# OPENPXE_PUBLIC_IP=192.168.1.49 docker compose up -d
|
||||
#
|
||||
# docker compose up openpxe-dev
|
||||
# (or put OPENPXE_PUBLIC_IP in a .env file beside this one). That's this
|
||||
# host's LAN IP, advertised to PXE clients so the iPXE URLs resolve —
|
||||
# OpenPXE refuses to start rather than advertise an address clients can't
|
||||
# reach, so compose errors out below if it's unset.
|
||||
#
|
||||
# 2. Real PXE deployment — host network, proxy-DHCP on, runs on a box
|
||||
# plugged into the PXE network:
|
||||
# Host networking is REQUIRED: DHCPDISCOVER is a broadcast, and Docker
|
||||
# bridges / CNI overlays don't forward it into containers. In host mode
|
||||
# the container binds these ports directly on the host:
|
||||
#
|
||||
# # First set OPENPXE_PUBLIC_IP to this host's LAN address in .env
|
||||
# docker compose up openpxe
|
||||
# udp/67 DHCP proxy udp/4011 PXE Boot Server
|
||||
# udp/69 TFTP tcp/4200 web UI + HTTP boot assets
|
||||
#
|
||||
# On Linux hosts, `network_mode: host` gives the container direct access to
|
||||
# the physical NIC — required for DHCP proxy because CNI overlays and Docker
|
||||
# bridges do not forward DHCPDISCOVER broadcasts into containers.
|
||||
#
|
||||
# On macOS / Windows hosts, `network_mode: host` is limited — the daemon
|
||||
# runs in a Linux VM (Colima/Docker Desktop) so the "host" network is the
|
||||
# VM, not your Mac. Proxy-DHCP is not feasible on macOS; use `openpxe-dev`
|
||||
# with published ports and set DHCP-MODE=disabled.
|
||||
# Web UI: http://<this-host>:4200/
|
||||
|
||||
services:
|
||||
# Real PXE deployment (Linux hosts).
|
||||
openpxe:
|
||||
image: openpxe:0.1.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/docker/Dockerfile
|
||||
image: gitea.milesward.dev/mward4/openpxe:latest
|
||||
container_name: openpxe
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# The binary carries cap_net_bind_service as a file capability, so it
|
||||
# binds the low DHCP/TFTP ports as a non-root user — no privileged mode.
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
environment:
|
||||
# REQUIRED on multi-homed hosts. Set to this machine's LAN IP so the
|
||||
# advertised iPXE URLs actually resolve from the PXE clients. Without
|
||||
# this, OpenPXE will refuse to start rather than advertise a
|
||||
# loopback address that can't be reached.
|
||||
OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:?set this to the host LAN IP}
|
||||
OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:?set this to the host LAN IP, e.g. 192.168.1.49}
|
||||
# Web UI + HTTP boot assets. 4200 keeps clear of anything on :80
|
||||
# (an Unraid webGUI, a reverse proxy, …).
|
||||
OPENPXE_HTTP_PORT: "4200"
|
||||
# proxy = coexist with the LAN's existing DHCP server (recommended).
|
||||
OPENPXE_DHCP_MODE: proxy
|
||||
OPENPXE_LOG: info
|
||||
volumes:
|
||||
- ./data/isos:/var/lib/openpxe/isos
|
||||
- ./data/work:/var/lib/openpxe/work
|
||||
|
||||
# Dev / MVP container: published ports, DHCP disabled, HTTP on 8080.
|
||||
# Use this on laptops where you want to curl the API or UI without
|
||||
# running an actual PXE chain.
|
||||
openpxe-dev:
|
||||
image: openpxe:0.1.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/docker/Dockerfile
|
||||
environment:
|
||||
OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:-127.0.0.1}
|
||||
OPENPXE_DHCP_MODE: disabled
|
||||
OPENPXE_HTTP_PORT: "8080"
|
||||
OPENPXE_TFTP_PORT: "6969"
|
||||
OPENPXE_DHCP_PORT: "6767"
|
||||
OPENPXE_LOG: info,openpxe=debug
|
||||
ports:
|
||||
- "8080:8080/tcp"
|
||||
- "6969:6969/udp"
|
||||
volumes:
|
||||
- ./data/isos:/var/lib/openpxe/isos
|
||||
- ./data/work:/var/lib/openpxe/work
|
||||
- ./data/isos:/var/lib/openpxe/isos # uploaded / seeded .iso files
|
||||
- ./data/work:/var/lib/openpxe/work # settings, share state, scratch
|
||||
|
||||
Reference in New Issue
Block a user