Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c262a6d61 | ||
|
|
27703c437a | ||
|
|
1ded291c7b |
Generated
+9
-136
@@ -175,45 +175,6 @@ dependencies = [
|
|||||||
"password-hash",
|
"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]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -1144,20 +1105,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "der_derive"
|
name = "der_derive"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -2445,7 +2392,7 @@ dependencies = [
|
|||||||
"httpdate",
|
"httpdate",
|
||||||
"idna",
|
"idna",
|
||||||
"mime",
|
"mime",
|
||||||
"nom 8.0.0",
|
"nom",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"quoted_printable",
|
"quoted_printable",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -2564,12 +2511,6 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "minimal-lexical"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2702,16 +2643,6 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "8.0.0"
|
version = "8.0.0"
|
||||||
@@ -2803,15 +2734,6 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -2836,7 +2758,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe"
|
name = "openpxe"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2848,17 +2770,15 @@ dependencies = [
|
|||||||
"openpxe-ipxe-assets",
|
"openpxe-ipxe-assets",
|
||||||
"openpxe-iso-store",
|
"openpxe-iso-store",
|
||||||
"openpxe-tftp",
|
"openpxe-tftp",
|
||||||
"serde",
|
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-core"
|
name = "openpxe-core"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -2867,7 +2787,6 @@ dependencies = [
|
|||||||
"figment",
|
"figment",
|
||||||
"flate2",
|
"flate2",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"quick-xml",
|
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"roxmltree",
|
"roxmltree",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2880,29 +2799,26 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
"x509-parser",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-dhcp-proxy"
|
name = "openpxe-dhcp-proxy"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
|
||||||
"dhcproto",
|
"dhcproto",
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-http-api"
|
name = "openpxe-http-api"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2910,7 +2826,6 @@ dependencies = [
|
|||||||
"bergshamra",
|
"bergshamra",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
"hyper",
|
|
||||||
"image",
|
"image",
|
||||||
"insta",
|
"insta",
|
||||||
"lettre",
|
"lettre",
|
||||||
@@ -2924,7 +2839,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
@@ -2938,17 +2852,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-ipxe-assets"
|
name = "openpxe-ipxe-assets"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"thiserror",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-iso-store"
|
name = "openpxe-iso-store"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -2967,31 +2880,26 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-tftp"
|
name = "openpxe-tftp"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"openpxe-ipxe-assets",
|
"openpxe-ipxe-assets",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-webui"
|
name = "openpxe-webui"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "p256"
|
name = "p256"
|
||||||
@@ -3438,15 +3346,6 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
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]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -3933,15 +3832,6 @@ dependencies = [
|
|||||||
"semver",
|
"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]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -5644,23 +5534,6 @@ dependencies = [
|
|||||||
"tls_codec",
|
"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]]
|
[[package]]
|
||||||
name = "yansi"
|
name = "yansi"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|||||||
+1
-3
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
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.
|
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
|
||||||
bergshamra = "0.5"
|
bergshamra = "0.5"
|
||||||
roxmltree = "0.21"
|
roxmltree = "0.21"
|
||||||
quick-xml = "0.40"
|
|
||||||
x509-parser = "0.18"
|
|
||||||
# flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the
|
# flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the
|
||||||
# zlib/zlib-ng C backends, which would break the musl-static build.
|
# zlib/zlib-ng C backends, which would break the musl-static build.
|
||||||
flate2 = "1.1"
|
flate2 = "1.1"
|
||||||
|
|||||||
@@ -27,13 +27,11 @@ tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
|
|||||||
bcrypt.workspace = true
|
bcrypt.workspace = true
|
||||||
|
|
||||||
# v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive
|
# 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;
|
# c14n (no OpenSSL/C), plus IdP signing-cert extraction from metadata.
|
||||||
# x509-parser pulls the IdP signing cert out of metadata; flate2+base64
|
# roxmltree parses the SAML/metadata XML; flate2+base64 encode the
|
||||||
# encode the HTTP-Redirect binding's SAMLRequest.
|
# HTTP-Redirect binding's SAMLRequest.
|
||||||
bergshamra.workspace = true
|
bergshamra.workspace = true
|
||||||
roxmltree.workspace = true
|
roxmltree.workspace = true
|
||||||
quick-xml.workspace = true
|
|
||||||
x509-parser.workspace = true
|
|
||||||
flate2.workspace = true
|
flate2.workspace = true
|
||||||
base64.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.
|
//! runtime settings, and the Queued Deployment queue.
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod api_key;
|
||||||
pub mod arch;
|
pub mod arch;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod boot_log;
|
pub mod boot_log;
|
||||||
@@ -23,6 +24,7 @@ pub mod settings;
|
|||||||
pub mod sso;
|
pub mod sso;
|
||||||
pub mod wol;
|
pub mod wol;
|
||||||
|
|
||||||
|
pub use api_key::ApiKeyStore;
|
||||||
pub use arch::{ClientArch, DriverMode, FirmwareClass};
|
pub use arch::{ClientArch, DriverMode, FirmwareClass};
|
||||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||||
pub use boot_log::{BootEvent, BootLog};
|
pub use boot_log::{BootEvent, BootLog};
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ tokio.workspace = true
|
|||||||
socket2.workspace = true
|
socket2.workspace = true
|
||||||
dhcproto.workspace = true
|
dhcproto.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
thiserror.workspace = true
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
bytes.workspace = true
|
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
# v0.7.1: learned driver modes persist to <work_dir>/driver_modes.json.
|
# v0.7.1: learned driver modes persist to <work_dir>/driver_modes.json.
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ time.workspace = true
|
|||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
tower.workspace = true
|
tower.workspace = true
|
||||||
tower-http.workspace = true
|
tower-http.workspace = true
|
||||||
hyper.workspace = true
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
thiserror.workspace = true
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
|||||||
@@ -147,6 +147,14 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/logout", post(auth_api::api_logout))
|
.route("/api/logout", post(auth_api::api_logout))
|
||||||
.route("/api/me", get(auth_api::api_me))
|
.route("/api/me", get(auth_api::api_me))
|
||||||
.route("/api/me/credentials", put(auth_api::api_update_credentials))
|
.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
|
// SAML SSO configuration (FleetDM-shaped). Gated behind auth — the
|
||||||
// operator pastes their IdP metadata, Entity ID, and toggles here.
|
// operator pastes their IdP metadata, Entity ID, and toggles here.
|
||||||
.route("/api/sso", get(api_sso_get).put(api_sso_put))
|
.route("/api/sso", get(api_sso_get).put(api_sso_put))
|
||||||
@@ -1944,6 +1952,10 @@ async fn api_docs() -> Json<serde_json::Value> {
|
|||||||
"summary": "Auth status — { setup_required, authenticated, user }. Always 200."},
|
"summary": "Auth status — { setup_required, authenticated, user }. Always 200."},
|
||||||
{"method": "PUT", "path": "/api/me/credentials",
|
{"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."},
|
"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.
|
/// to avoid collisions with anything else sharing the host.
|
||||||
pub const SESSION_COOKIE: &str = "openpxe_session";
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
struct Session {
|
struct Session {
|
||||||
username: String,
|
username: String,
|
||||||
@@ -223,14 +229,19 @@ pub async fn require_auth(
|
|||||||
if is_public_path(path) {
|
if is_public_path(path) {
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
// Authenticated path. The cookie must be present, map to a live
|
// Authenticated path: either a live operator session cookie (the
|
||||||
// session, and the TTL refresh happens as a side-effect.
|
// browser) or the x-api-key header (scripts / Postman). Touching the
|
||||||
let token = parse_cookie(req.headers());
|
// cookie refreshes its idle TTL as a side-effect.
|
||||||
if let Some(t) = token {
|
let session_ok =
|
||||||
if state.sessions.touch(&t).is_some() {
|
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;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
(
|
(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
Json(json!({ "error": "authentication required" })),
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct LoginPayload<'a> {
|
struct LoginPayload<'a> {
|
||||||
user: &'a AdminPublic,
|
user: &'a AdminPublic,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::auth::SessionStore;
|
|||||||
use crate::saml_routes::SamlRuntime;
|
use crate::saml_routes::SamlRuntime;
|
||||||
use crate::uploads::UploadSessions;
|
use crate::uploads::UploadSessions;
|
||||||
use openpxe_core::{
|
use openpxe_core::{
|
||||||
AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
|
AdminStore, ApiKeyStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
|
||||||
DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore,
|
DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore,
|
||||||
};
|
};
|
||||||
use openpxe_iso_store::{
|
use openpxe_iso_store::{
|
||||||
@@ -55,6 +55,11 @@ pub struct AppState {
|
|||||||
/// process restart (sessions are tied to UI state, not persisted —
|
/// process restart (sessions are tied to UI state, not persisted —
|
||||||
/// matches Sonarr/Radarr behaviour).
|
/// matches Sonarr/Radarr behaviour).
|
||||||
pub sessions: SessionStore,
|
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).
|
/// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles).
|
||||||
pub sso: SsoStore,
|
pub sso: SsoStore,
|
||||||
/// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs
|
/// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
|
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
|
||||||
let sso = openpxe_core::SsoStore::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 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 sessions = openpxe_http_api::auth::SessionStore::default();
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -114,6 +115,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||||
admin,
|
admin,
|
||||||
sessions,
|
sessions,
|
||||||
|
api_key,
|
||||||
sso,
|
sso,
|
||||||
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
|
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
|
||||||
notify,
|
notify,
|
||||||
@@ -135,6 +137,60 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
(state, dir)
|
(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]
|
#[tokio::test]
|
||||||
async fn health_and_ready_endpoints() {
|
async fn health_and_ready_endpoints() {
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
|
|||||||
@@ -13,4 +13,3 @@ workspace = true
|
|||||||
openpxe-core.workspace = true
|
openpxe-core.workspace = true
|
||||||
rust-embed.workspace = true
|
rust-embed.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
thiserror.workspace = true
|
|
||||||
|
|||||||
@@ -12,16 +12,13 @@ workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
openpxe-core.workspace = true
|
openpxe-core.workspace = true
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tokio-util = { workspace = true }
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
thiserror.workspace = true
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
bcrypt.workspace = true
|
bcrypt.workspace = true
|
||||||
uuid.workspace = true
|
|
||||||
time.workspace = true
|
time.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
|
|||||||
@@ -56,7 +56,16 @@ pub enum DistroFamily {
|
|||||||
/// kernel/initrd paths exist before emitting them, and adds the Debian
|
/// kernel/initrd paths exist before emitting them, and adds the Debian
|
||||||
/// live / netinst / CoreOS shapes. Remote (NFS/SFTP) introspection
|
/// live / netinst / CoreOS shapes. Remote (NFS/SFTP) introspection
|
||||||
/// caches key off this rev too, so the cache self-invalidates.
|
/// caches key off this rev too, so the cache self-invalidates.
|
||||||
pub const INTROSPECT_REV: u32 = 2;
|
/// 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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct IntrospectionReport {
|
pub struct IntrospectionReport {
|
||||||
@@ -210,25 +219,28 @@ pub async fn introspect_reader<R: IsoReadAt + Send>(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ISO9660 Primary Volume Descriptor at LBA 16. Bytes 40..72 are the
|
// Everything sector-shaped goes through one caching wrapper: the
|
||||||
// volume identifier (space-padded).
|
// descriptor-set sectors are read once and shared between the label
|
||||||
if let Ok(pvd) = r.read_at(16 * SECTOR, 2048).await {
|
// scan, the El Torito walk, and the namespace-root lookups; the
|
||||||
if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" {
|
// 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();
|
let label = String::from_utf8_lossy(&pvd[40..72]).trim().to_string();
|
||||||
if !label.is_empty() {
|
if !label.is_empty() {
|
||||||
report.family = family_from_label(&label);
|
report.family = family_from_label(&label);
|
||||||
report.volume_label = Some(label);
|
report.volume_label = Some(label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
report.el_torito = detect_el_torito(r).await;
|
report.el_torito = detect_el_torito(&mut cr).await;
|
||||||
|
|
||||||
// Directory-tree probes. The caching wrapper collapses the repeated
|
|
||||||
// root/subdirectory reads the probe table would otherwise issue —
|
|
||||||
// over NFS/SFTP that's the difference between ~6 and ~60 round-trips.
|
|
||||||
{
|
|
||||||
let mut cr = CachingReadAt::new(r);
|
|
||||||
|
|
||||||
if iso_fs::exists(&mut cr, "/sources/boot.wim").await {
|
if iso_fs::exists(&mut cr, "/sources/boot.wim").await {
|
||||||
report.has_boot_wim = true;
|
report.has_boot_wim = true;
|
||||||
@@ -407,7 +419,13 @@ async fn bulk_windows_scan<R: IsoReadAt + Send>(r: &mut R, total_len: u64) -> Op
|
|||||||
return Some(true);
|
return Some(true);
|
||||||
}
|
}
|
||||||
let ascii_markers: [&[u8]; 3] = [b"bootmgr", b"sources/install.wim", b"sources/install.esd"];
|
let ascii_markers: [&[u8]; 3] = [b"bootmgr", b"sources/install.wim", b"sources/install.esd"];
|
||||||
let utf16_markers = ["bootmgr", "install.wim", "microsoft"];
|
// 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))
|
let hit = ascii_markers.iter().any(|m| contains_ascii(&haystack, m))
|
||||||
|| utf16_markers
|
|| utf16_markers
|
||||||
.iter()
|
.iter()
|
||||||
@@ -471,30 +489,31 @@ const EL_TORITO_ID: &[u8] = b"EL TORITO SPECIFICATION";
|
|||||||
/// by BIOS/UEFI firmware (and thus by iPXE `sanboot`).
|
/// by BIOS/UEFI firmware (and thus by iPXE `sanboot`).
|
||||||
///
|
///
|
||||||
/// The ISO9660 Volume Descriptor Set starts at LBA 16 and runs one
|
/// The ISO9660 Volume Descriptor Set starts at LBA 16 and runs one
|
||||||
/// 2048-byte descriptor per sector until a Set Terminator (type 0xFF).
|
/// 2048-byte descriptor per sector; a Boot Record descriptor (type 0x00)
|
||||||
/// A Boot Record descriptor (type 0x00) whose 32-byte boot system
|
/// whose 32-byte boot system identifier reads "EL TORITO SPECIFICATION"
|
||||||
/// identifier reads "EL TORITO SPECIFICATION" means the image declares a
|
/// means the image declares a boot catalog. We only confirm its presence
|
||||||
/// boot catalog. We only confirm its presence — we don't parse the
|
/// — we don't parse the catalog (sanboot/the firmware does that).
|
||||||
/// catalog (sanboot/the firmware does that). The walk is capped so a
|
///
|
||||||
/// malformed image can't spin us. v0.5.9; reader-generic since v0.7.4.
|
/// 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 {
|
async fn detect_el_torito<R: IsoReadAt + Send>(r: &mut R) -> bool {
|
||||||
for lba in 16u64..32 {
|
for lba in 16u64..32 {
|
||||||
let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else {
|
let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else {
|
||||||
|
// Past end of a tiny image — nothing more to examine.
|
||||||
return false;
|
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" {
|
if &vd[1..6] != b"CD001" {
|
||||||
return false;
|
continue; // filler/garbage sector — keep walking
|
||||||
}
|
}
|
||||||
match vd[0] {
|
if vd[0] == 0x00 && vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID {
|
||||||
// Boot Record descriptor carrying the El Torito signature.
|
return true;
|
||||||
0x00 if vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID => return true,
|
|
||||||
// Volume Descriptor Set Terminator — nothing bootable found.
|
|
||||||
0xFF => return false,
|
|
||||||
// Any other descriptor (incl. a non-El-Torito boot record) —
|
|
||||||
// keep walking the set.
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
@@ -700,6 +719,51 @@ mod tests {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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!(
|
||||||
|
futures::executor::block_on(detect_el_torito(&mut MemReadAt(img))),
|
||||||
|
"boot record behind a zeroed filler sector must still be found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn filename_hint_catches_windows_isos() {
|
fn filename_hint_catches_windows_isos() {
|
||||||
assert!(filename_looks_windows(
|
assert!(filename_looks_windows(
|
||||||
@@ -722,4 +786,26 @@ mod tests {
|
|||||||
assert_eq!(r.family, DistroFamily::WindowsPe);
|
assert_eq!(r.family, DistroFamily::WindowsPe);
|
||||||
assert!(!r.has_boot_wim);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+230
-37
@@ -14,11 +14,13 @@
|
|||||||
//! SFTP seek-read connection, which is what finally classifies
|
//! SFTP seek-read connection, which is what finally classifies
|
||||||
//! share-sourced ISOs instead of registering them all as `Unknown`.
|
//! share-sourced ISOs instead of registering them all as `Unknown`.
|
||||||
//!
|
//!
|
||||||
//! We parse only the Primary Volume Descriptor namespace. Joliet and Rock
|
//! Namespaces: the primary ISO9660 tree is tried first; on a miss the
|
||||||
//! Ridge are deliberately ignored — matching is case-insensitive against
|
//! walk falls back to the **Joliet** supplementary namespace (v0.7.5) —
|
||||||
//! plain ISO9660 identifiers (`;1` version suffix and the trailing dot of
|
//! Windows-oriented mastering tools often write a minimal/mangled
|
||||||
//! extension-less strict-mastered names stripped), which is how the local
|
//! primary tree with the real names only in Joliet. Rock Ridge stays
|
||||||
//! serving path has always behaved in production.
|
//! 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::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
@@ -118,14 +120,14 @@ impl<R: IsoReadAt + Send> IsoReadAt for CachingReadAt<'_, R> {
|
|||||||
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in
|
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in
|
||||||
/// the image behind `r`. Returns `None` on any parsing or IO failure —
|
/// 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.
|
/// "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> {
|
pub async fn lookup<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> Option<FileLocation> {
|
||||||
let pvd = r.read_at(16 * SECTOR, 2048).await.ok()?;
|
|
||||||
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Root directory record at PVD offset 156, 34 bytes.
|
|
||||||
let (mut lba, mut len) = parse_dir_record_ext(&pvd[156..156 + 34])?;
|
|
||||||
|
|
||||||
let components: Vec<&str> = in_iso_path
|
let components: Vec<&str> = in_iso_path
|
||||||
.trim_start_matches('/')
|
.trim_start_matches('/')
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -134,15 +136,78 @@ pub async fn lookup<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> Option
|
|||||||
if components.is_empty() {
|
if components.is_empty() {
|
||||||
return None;
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// The original walk was tail-recursive; iterate instead so the future
|
/// Find the namespace root: the Primary Volume Descriptor (`joliet =
|
||||||
// stays a plain (non-boxed) state machine.
|
/// 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() {
|
for (idx, comp) in components.iter().enumerate() {
|
||||||
if len == 0 || len > MAX_DIR_BYTES {
|
if len == 0 || len > MAX_DIR_BYTES {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let dir = r.read_at(lba * SECTOR, len as u32).await.ok()?;
|
let dir = r.read_at(lba * SECTOR, len as u32).await.ok()?;
|
||||||
let hit = scan_dir(&dir, comp)?;
|
let hit = scan_dir(&dir, comp, joliet)?;
|
||||||
let last = idx + 1 == components.len();
|
let last = idx + 1 == components.len();
|
||||||
match (last, hit.is_dir) {
|
match (last, hit.is_dir) {
|
||||||
(true, false) => {
|
(true, false) => {
|
||||||
@@ -184,7 +249,9 @@ struct DirHit {
|
|||||||
|
|
||||||
/// Scan one directory extent for an identifier. Pure function over the
|
/// Scan one directory extent for an identifier. Pure function over the
|
||||||
/// buffered extent — all protocol/IO concerns live in the caller.
|
/// buffered extent — all protocol/IO concerns live in the caller.
|
||||||
fn scan_dir(dir: &[u8], target: &str) -> Option<DirHit> {
|
/// `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;
|
let mut i = 0;
|
||||||
while i < dir.len() {
|
while i < dir.len() {
|
||||||
let len = dir[i] as usize;
|
let len = dir[i] as usize;
|
||||||
@@ -202,7 +269,7 @@ fn scan_dir(dir: &[u8], target: &str) -> Option<DirHit> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let rec = &dir[i..i + len];
|
let rec = &dir[i..i + len];
|
||||||
let name = dir_record_name(rec);
|
let name = dir_record_name(rec, joliet);
|
||||||
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
|
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
|
||||||
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
|
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
|
||||||
let is_pseudo =
|
let is_pseudo =
|
||||||
@@ -236,7 +303,27 @@ fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
|
|||||||
/// quirks: the `;N` version suffix and the trailing dot that strict
|
/// quirks: the `;N` version suffix and the trailing dot that strict
|
||||||
/// mastering appends to extension-less names (`VMLINUZ.;1`). Without the
|
/// mastering appends to extension-less names (`VMLINUZ.;1`). Without the
|
||||||
/// dot strip, level-1 images' kernels never matched `/casper/vmlinuz`.
|
/// dot strip, level-1 images' kernels never matched `/casper/vmlinuz`.
|
||||||
fn dir_record_name(rec: &[u8]) -> String {
|
/// 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;
|
let name_len = *rec.get(32).unwrap_or(&0) as usize;
|
||||||
if name_len == 0 || rec.len() < 33 + name_len {
|
if name_len == 0 || rec.len() < 33 + name_len {
|
||||||
return String::new();
|
return String::new();
|
||||||
@@ -271,6 +358,7 @@ pub mod testiso {
|
|||||||
root: Node,
|
root: Node,
|
||||||
volume_label: String,
|
volume_label: String,
|
||||||
el_torito: bool,
|
el_torito: bool,
|
||||||
|
joliet_only: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestIsoBuilder {
|
impl TestIsoBuilder {
|
||||||
@@ -279,6 +367,7 @@ pub mod testiso {
|
|||||||
root: Node::default(),
|
root: Node::default(),
|
||||||
volume_label: volume_label.to_string(),
|
volume_label: volume_label.to_string(),
|
||||||
el_torito: false,
|
el_torito: false,
|
||||||
|
joliet_only: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +377,16 @@ pub mod testiso {
|
|||||||
self
|
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`.
|
/// Add a file at `path` (e.g. "/casper/vmlinuz") with `content`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn file(mut self, path: &str, content: &[u8]) -> Self {
|
pub fn file(mut self, path: &str, content: &[u8]) -> Self {
|
||||||
@@ -307,8 +406,10 @@ pub mod testiso {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(self) -> Vec<u8> {
|
pub fn build(self) -> Vec<u8> {
|
||||||
// Pass 1: allocate extents. Directories first (1 sector each),
|
// Pass 1: allocate extents. Primary directories first (1
|
||||||
// then file contents.
|
// 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 next_lba: u64 = 20;
|
||||||
let mut dirs: Vec<(*const Node, u64)> = Vec::new();
|
let mut dirs: Vec<(*const Node, u64)> = Vec::new();
|
||||||
fn alloc_dirs(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64)>) {
|
fn alloc_dirs(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64)>) {
|
||||||
@@ -327,6 +428,17 @@ pub mod testiso {
|
|||||||
.map(|(_, l)| *l)
|
.map(|(_, l)| *l)
|
||||||
.expect("dir allocated")
|
.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();
|
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)>) {
|
fn alloc_files(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64, usize)>) {
|
||||||
for child in n.children.values() {
|
for child in n.children.values() {
|
||||||
@@ -371,14 +483,19 @@ pub mod testiso {
|
|||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2: write each directory extent.
|
// 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(
|
fn write_dir(
|
||||||
img: &mut [u8],
|
img: &mut [u8],
|
||||||
n: &Node,
|
n: &Node,
|
||||||
self_lba: u64,
|
self_lba: u64,
|
||||||
parent_lba: u64,
|
parent_lba: u64,
|
||||||
lba_of: &dyn Fn(&Node) -> u64,
|
dir_lba_of: &dyn Fn(&Node) -> u64,
|
||||||
file_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 base = self_lba as usize * SECTOR as usize;
|
||||||
let mut off = 0usize;
|
let mut off = 0usize;
|
||||||
@@ -388,32 +505,61 @@ pub mod testiso {
|
|||||||
};
|
};
|
||||||
put(record(&[0x00], self_lba, SECTOR, true), &mut off);
|
put(record(&[0x00], self_lba, SECTOR, true), &mut off);
|
||||||
put(record(&[0x01], parent_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 {
|
for (name, child) in &n.children {
|
||||||
if let Some(c) = &child.content {
|
if let Some(c) = &child.content {
|
||||||
// Files get the ISO9660 uppercase `;1` treatment so
|
|
||||||
// the case-insensitive + version-strip matching is
|
|
||||||
// what the tests actually exercise.
|
|
||||||
let stored = format!("{};1", name.to_ascii_uppercase());
|
|
||||||
put(
|
put(
|
||||||
record(stored.as_bytes(), file_lba_of(child), c.len() as u64, false),
|
record(
|
||||||
|
&encode(name, true),
|
||||||
|
file_lba_of(child),
|
||||||
|
c.len() as u64,
|
||||||
|
false,
|
||||||
|
),
|
||||||
&mut off,
|
&mut off,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let stored = name.to_ascii_uppercase();
|
|
||||||
put(
|
put(
|
||||||
record(stored.as_bytes(), lba_of(child), SECTOR, true),
|
record(&encode(name, false), dir_lba_of(child), SECTOR, true),
|
||||||
&mut off,
|
&mut off,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (name, child) in &n.children {
|
for child in n.children.values() {
|
||||||
if child.content.is_none() {
|
if child.content.is_none() {
|
||||||
write_dir(img, child, lba_of(child), self_lba, lba_of, file_lba_of);
|
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 {
|
} else if let Some(c) = &child.content {
|
||||||
let b = file_lba_of(child) as usize * SECTOR as usize;
|
let b = file_lba_of(child) as usize * SECTOR as usize;
|
||||||
img[b..b + c.len()].copy_from_slice(c);
|
img[b..b + c.len()].copy_from_slice(c);
|
||||||
}
|
}
|
||||||
let _ = name;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let root_lba = lba_of(&self.root);
|
let root_lba = lba_of(&self.root);
|
||||||
@@ -424,8 +570,25 @@ pub mod testiso {
|
|||||||
root_lba,
|
root_lba,
|
||||||
&lba_of,
|
&lba_of,
|
||||||
&file_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.
|
// PVD @ 16.
|
||||||
let pvd = 16 * SECTOR as usize;
|
let pvd = 16 * SECTOR as usize;
|
||||||
img[pvd] = 0x01;
|
img[pvd] = 0x01;
|
||||||
@@ -437,7 +600,8 @@ pub mod testiso {
|
|||||||
let root_rec = record(&[0x00], root_lba, SECTOR, true);
|
let root_rec = record(&[0x00], root_lba, SECTOR, true);
|
||||||
img[pvd + 156..pvd + 156 + 34].copy_from_slice(&root_rec[..34]);
|
img[pvd + 156..pvd + 156 + 34].copy_from_slice(&root_rec[..34]);
|
||||||
|
|
||||||
// Optional El Torito boot record @ 17, terminator after.
|
// Optional El Torito boot record @ 17, then the optional
|
||||||
|
// Joliet SVD, then the set terminator.
|
||||||
let mut vd = 17 * SECTOR as usize;
|
let mut vd = 17 * SECTOR as usize;
|
||||||
if self.el_torito {
|
if self.el_torito {
|
||||||
img[vd] = 0x00;
|
img[vd] = 0x00;
|
||||||
@@ -446,6 +610,15 @@ pub mod testiso {
|
|||||||
img[vd + 7..vd + 7 + id.len()].copy_from_slice(id);
|
img[vd + 7..vd + 7 + id.len()].copy_from_slice(id);
|
||||||
vd += SECTOR as usize;
|
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] = 0xFF;
|
||||||
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
|
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
|
||||||
|
|
||||||
@@ -548,9 +721,29 @@ mod tests {
|
|||||||
assert!(block_on(exists(&mut cr, "/a/two")));
|
assert!(block_on(exists(&mut cr, "/a/two")));
|
||||||
assert!(!block_on(exists(&mut cr, "/a/three")));
|
assert!(!block_on(exists(&mut cr, "/a/three")));
|
||||||
drop(cr);
|
drop(cr);
|
||||||
// 3 probes × (PVD + root dir + subdir) = 9 uncached; the cache
|
// 3 probes × (PVD + root dir + subdir) collapse to the 3 distinct
|
||||||
// collapses the repeats to the 3 distinct extents.
|
// extents, plus one: the "/a/three" miss falls back to the Joliet
|
||||||
assert_eq!(counting.calls, 3, "all repeat reads must hit the cache");
|
// 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]
|
#[test]
|
||||||
|
|||||||
@@ -300,7 +300,19 @@ impl IsoStore {
|
|||||||
}
|
}
|
||||||
let partial_path = self.iso_dir.join(format!("{id}.partial"));
|
let partial_path = self.iso_dir.join(format!("{id}.partial"));
|
||||||
if partial_path.exists() {
|
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?;
|
let file = tokio::fs::File::create(&partial_path).await?;
|
||||||
Ok(UploadHandle {
|
Ok(UploadHandle {
|
||||||
@@ -872,15 +884,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 dir = tempdir().unwrap();
|
||||||
let store = IsoStore::new(dir.path().to_path_buf());
|
let store = IsoStore::new(dir.path().to_path_buf());
|
||||||
store.ensure_dirs().await.unwrap();
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let r = store.begin_upload("ubuntu.iso").await;
|
let r = store.begin_upload("rocky.iso").await;
|
||||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,5 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
serde.workspace = true
|
|
||||||
toml.workspace = true
|
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
time.workspace = true
|
time.workspace = true
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
|
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
|
||||||
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
|
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 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 sessions = openpxe_http_api::auth::SessionStore::default();
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
|
|
||||||
@@ -201,6 +202,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||||
admin: admin.clone(),
|
admin: admin.clone(),
|
||||||
sessions: sessions.clone(),
|
sessions: sessions.clone(),
|
||||||
|
api_key,
|
||||||
sso: sso.clone(),
|
sso: sso.clone(),
|
||||||
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
|
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
|
||||||
notify: notify.clone(),
|
notify: notify.clone(),
|
||||||
|
|||||||
@@ -15,6 +15,4 @@ openpxe-ipxe-assets.workspace = true
|
|||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
socket2.workspace = true
|
socket2.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
thiserror.workspace = true
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
bytes.workspace = true
|
|
||||||
|
|||||||
+178
-71
@@ -603,48 +603,40 @@
|
|||||||
|
|
||||||
// ── Upload card ──
|
// ── Upload card ──
|
||||||
const drop = el('div', {class:'drop', id:'drop'}, [
|
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'},
|
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',
|
const file = el('input', {type:'file', accept:'.iso,application/octet-stream',
|
||||||
style:'display:none', id:'file'});
|
multiple:true, style:'display:none', id:'file'});
|
||||||
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
|
// v0.8.0: one progress row per file, appended here. Replaces the
|
||||||
const upMsg = el('div', {class:'msg', id:'upmsg'});
|
// single shared bar/msg/cancel that a second concurrent upload used
|
||||||
// v0.5.8: cancel button — shown only while an upload is in flight.
|
// to clobber.
|
||||||
const cancelUpload = el('button', {class:'danger', type:'button',
|
const uploadsList = el('div', {id:'uploads', style:'display:grid;gap:12px'});
|
||||||
style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload');
|
|
||||||
|
|
||||||
drop.onclick = () => file.click();
|
// One page-leave guard + one tab-hide cleanup for the whole card,
|
||||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
|
// registered only while ≥1 upload is in flight (added on 0→1, removed
|
||||||
drop.addEventListener('dragleave', () => drop.classList.remove('hover'));
|
// on 1→0) so listeners never pile up across re-renders.
|
||||||
drop.addEventListener('drop', e => {
|
let activeUploads = 0;
|
||||||
e.preventDefault(); drop.classList.remove('hover');
|
const activeIds = new Set();
|
||||||
if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]);
|
const warnLeave = (e) => { if (activeUploads > 0) { e.preventDefault(); e.returnValue = ''; return ''; } };
|
||||||
});
|
const abortOnHide = () => {
|
||||||
file.onchange = () => { if (file.files[0]) upload(file.files[0]); };
|
// keepalive lets these DELETEs outlive the unload; the server also
|
||||||
|
// reclaims an orphaned .partial on the next upload, so best-effort
|
||||||
// Chunked upload telemetry. The old browser path posted one huge
|
// is fine here.
|
||||||
// multipart body, which left operators staring at 0% when a reverse
|
for (const id of activeIds) {
|
||||||
// proxy buffered or rejected the request before OpenPXE saw it. This
|
try { fetch('/api/uploads/' + encodeURIComponent(id), {method:'DELETE', keepalive:true}); } catch (_) {}
|
||||||
// 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' : '') + ')');
|
|
||||||
};
|
};
|
||||||
|
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 failText = async (r) => {
|
||||||
const text = (await r.text()).slice(0, 240);
|
const text = (await r.text()).slice(0, 240);
|
||||||
let hint = '';
|
let hint = '';
|
||||||
@@ -655,19 +647,66 @@
|
|||||||
return 'HTTP ' + r.status + ' ' + text + hint;
|
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;
|
let uploadId = null;
|
||||||
// v0.5.8: cancel + leave-page guard. The AbortController stops the
|
// The AbortController stops this upload's in-flight chunk on Cancel.
|
||||||
// in-flight chunk; the beforeunload listener warns the operator
|
// The card-level beforeunload guard (added while activeUploads > 0)
|
||||||
// that navigating away aborts the upload (the server-side partial
|
// warns on navigation; the server reclaims an abandoned .partial on
|
||||||
// is then cleaned up by the DELETE in the catch below).
|
// the next upload either way.
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; };
|
cancelBtn.onclick = () => { canceled = true; ac.abort(); };
|
||||||
window.addEventListener('beforeunload', warnLeave);
|
|
||||||
cancelUpload.style.display = '';
|
activeUploads += 1;
|
||||||
cancelUpload.onclick = () => { canceled = true; ac.abort(); };
|
if (activeUploads === 1) addGuards();
|
||||||
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
setStatus('Preparing ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
||||||
prog.classList.add('active');
|
|
||||||
bar.style.width = '1%';
|
bar.style.width = '1%';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -678,6 +717,7 @@
|
|||||||
if (!begin.ok) throw new Error(await failText(begin));
|
if (!begin.ok) throw new Error(await failText(begin));
|
||||||
const session = await begin.json();
|
const session = await begin.json();
|
||||||
uploadId = session.upload_id;
|
uploadId = session.upload_id;
|
||||||
|
activeIds.add(uploadId);
|
||||||
const chunkSize = Math.max(1024 * 1024, Number(session.chunk_size || 8 * 1024 * 1024));
|
const chunkSize = Math.max(1024 * 1024, Number(session.chunk_size || 8 * 1024 * 1024));
|
||||||
|
|
||||||
let offset = Number(session.offset || 0);
|
let offset = Number(session.offset || 0);
|
||||||
@@ -702,23 +742,29 @@
|
|||||||
} while (!finished);
|
} while (!finished);
|
||||||
|
|
||||||
setStatus('Uploaded and analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok');
|
setStatus('Uploaded and analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok');
|
||||||
render('storage');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (uploadId) {
|
if (uploadId) {
|
||||||
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
|
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
|
||||||
catch {}
|
catch (_) {}
|
||||||
}
|
}
|
||||||
if (canceled || (err && err.name === 'AbortError')) {
|
if (canceled || (err && err.name === 'AbortError')) {
|
||||||
setStatus('Upload canceled — partial file discarded.', '');
|
setStatus('Canceled — partial file discarded.', '');
|
||||||
} else {
|
} else {
|
||||||
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
window.removeEventListener('beforeunload', warnLeave);
|
if (uploadId) activeIds.delete(uploadId);
|
||||||
cancelUpload.style.display = 'none';
|
cancelBtn.style.display = 'none';
|
||||||
cancelUpload.onclick = null;
|
|
||||||
prog.classList.remove('active');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1294,6 +1340,36 @@
|
|||||||
]))
|
]))
|
||||||
: [el('div', {class:'empty'}, 'No unattended files yet.')];
|
: [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'}, [
|
const unattendedAdvanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
|
||||||
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
||||||
el('div', {class:'card', style:'margin-top:14px'}, [
|
el('div', {class:'card', style:'margin-top:14px'}, [
|
||||||
@@ -1303,18 +1379,15 @@
|
|||||||
]),
|
]),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
unattDrop, unattFile, unattMsg,
|
unattDrop, unattFile, unattMsg,
|
||||||
// v0.7.2: filter for big answer-file libraries.
|
unattendedFiles.length > 1
|
||||||
unattendedFiles.length > 1 ? (() => {
|
? el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, unattSearch)
|
||||||
const search = el('input', {type:'search', placeholder:'Filter files by name or kind',
|
: null,
|
||||||
spellcheck:'false', oninput: () => {
|
|
||||||
const q = search.value.trim().toLowerCase();
|
|
||||||
unattRows.forEach(r => {
|
|
||||||
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
|
|
||||||
});
|
|
||||||
}});
|
|
||||||
return el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, search);
|
|
||||||
})() : null,
|
|
||||||
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
||||||
|
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'},
|
el('p', {class:'msg', style:'margin-top:14px'},
|
||||||
'These answer files drive unattended installs. Attach one to a ' +
|
'These answer files drive unattended installs. Attach one to a ' +
|
||||||
'host pin (Hosts tab) or a queued device (Queue → Profile); on ' +
|
'host pin (Hosts tab) or a queued device (Queue → Profile); on ' +
|
||||||
@@ -1328,7 +1401,7 @@
|
|||||||
diskCard,
|
diskCard,
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||||
el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]),
|
el('div', {class:'body'}, [drop, file, uploadsList]),
|
||||||
]),
|
]),
|
||||||
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
||||||
// protocol dropdown. Backend endpoints are unchanged; this is a
|
// protocol dropdown. Backend endpoints are unchanged; this is a
|
||||||
@@ -1705,7 +1778,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
settings: async () => {
|
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/status'),
|
||||||
getJSON('/api/me').catch(() => ({})),
|
getJSON('/api/me').catch(() => ({})),
|
||||||
getJSON('/api/sso').catch(() => ({
|
getJSON('/api/sso').catch(() => ({
|
||||||
@@ -1715,6 +1788,7 @@
|
|||||||
// fetches the notify config + API docs it needs too.
|
// fetches the notify config + API docs it needs too.
|
||||||
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
||||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||||
|
getJSON('/api/api-key').catch(() => ({ key:'', header:'x-api-key' })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Account card (Forms admin credentials, v0.4.5).
|
// ── Account card (Forms admin credentials, v0.4.5).
|
||||||
@@ -2028,7 +2102,7 @@
|
|||||||
// into a collapsible disclosure beneath the core settings cards —
|
// into a collapsible disclosure beneath the core settings cards —
|
||||||
// webhook/email notifications + the API reference. Keeps Settings
|
// webhook/email notifications + the API reference. Keeps Settings
|
||||||
// clean by default while leaving the knobs one click away.
|
// 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'}, [
|
const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
|
||||||
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
||||||
el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]),
|
el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]),
|
||||||
@@ -2043,7 +2117,7 @@
|
|||||||
// and the API reference. There is no longer an Advanced sidebar tab;
|
// and the API reference. There is no longer an Advanced sidebar tab;
|
||||||
// the Settings view folds these into a collapsible disclosure and
|
// the Settings view folds these into a collapsible disclosure and
|
||||||
// passes in the pre-fetched `notify` + `docs` payloads.
|
// passes in the pre-fetched `notify` + `docs` payloads.
|
||||||
_advancedCards: (notify, docs) => {
|
_advancedCards: (notify, docs, apiKey) => {
|
||||||
|
|
||||||
// ── Notification config ──
|
// ── Notification config ──
|
||||||
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
||||||
@@ -2158,14 +2232,47 @@
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── API reference (relocated from Settings) ──
|
// ── API key + reference (relocated from Settings) ──
|
||||||
const groups = docs.groups || [];
|
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'}, [
|
const apiCard = el('div', {class:'card'}, [
|
||||||
el('header', {}, [
|
el('header', {}, [
|
||||||
el('h2', {}, 'API reference'),
|
el('h2', {}, 'API'),
|
||||||
el('span', {class:'sub'},
|
el('span', {class:'sub'},
|
||||||
groups.reduce((n, g) => n + (g.endpoints || []).length, 0) + ' endpoints'),
|
groups.reduce((n, g) => n + (g.endpoints || []).length, 0) + ' endpoints'),
|
||||||
]),
|
]),
|
||||||
|
apiKeyBlock,
|
||||||
el('div', {class:'api-ref'},
|
el('div', {class:'api-ref'},
|
||||||
groups.length
|
groups.length
|
||||||
? groups.map(g => el('div', {class:'group'}, [
|
? 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
|
# OPENPXE_PUBLIC_IP=192.168.1.49 docker compose up -d
|
||||||
# existing DHCP server on the LAN), TFTP + HTTP exposed on the host:
|
|
||||||
#
|
#
|
||||||
# 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
|
# Host networking is REQUIRED: DHCPDISCOVER is a broadcast, and Docker
|
||||||
# plugged into the PXE network:
|
# 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
|
# udp/67 DHCP proxy udp/4011 PXE Boot Server
|
||||||
# docker compose up openpxe
|
# udp/69 TFTP tcp/4200 web UI + HTTP boot assets
|
||||||
#
|
#
|
||||||
# On Linux hosts, `network_mode: host` gives the container direct access to
|
# Web UI: http://<this-host>:4200/
|
||||||
# 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.
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Real PXE deployment (Linux hosts).
|
|
||||||
openpxe:
|
openpxe:
|
||||||
image: openpxe:0.1.0
|
image: gitea.milesward.dev/mward4/openpxe:latest
|
||||||
build:
|
container_name: openpxe
|
||||||
context: .
|
|
||||||
dockerfile: deploy/docker/Dockerfile
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
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:
|
environment:
|
||||||
# REQUIRED on multi-homed hosts. Set to this machine's LAN IP so the
|
OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:?set this to the host LAN IP, e.g. 192.168.1.49}
|
||||||
# advertised iPXE URLs actually resolve from the PXE clients. Without
|
# Web UI + HTTP boot assets. 4200 keeps clear of anything on :80
|
||||||
# this, OpenPXE will refuse to start rather than advertise a
|
# (an Unraid webGUI, a reverse proxy, …).
|
||||||
# loopback address that can't be reached.
|
OPENPXE_HTTP_PORT: "4200"
|
||||||
OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:?set this to the host LAN IP}
|
# proxy = coexist with the LAN's existing DHCP server (recommended).
|
||||||
OPENPXE_DHCP_MODE: proxy
|
OPENPXE_DHCP_MODE: proxy
|
||||||
OPENPXE_LOG: info
|
OPENPXE_LOG: info
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/isos:/var/lib/openpxe/isos
|
- ./data/isos:/var/lib/openpxe/isos # uploaded / seeded .iso files
|
||||||
- ./data/work:/var/lib/openpxe/work
|
- ./data/work:/var/lib/openpxe/work # settings, share state, scratch
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user