Compare commits

..
4 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 5f98e6e03f v0.8.1: add ISO by URL, zero-touch admin bootstrap
Ease-of-use pass inspired by Bootimus (Dnsmasq-PXE is a manual dnsmasq
setup guide — nothing to adopt; OpenPXE already replaces that stack).

Add ISO by URL:
- New http-api `fetch` module: a small FetchJobs registry + a background
  streaming download (reqwest) that pipes a remote .iso through the same
  UploadHandle + introspection path as an upload, so a URL-fetched image
  classifies and gains boot entries identically. Progress is polled by the
  Storage view and rendered as rows, mirroring uploads.
- Routes POST/GET/DELETE /api/isos/fetch. http/https only; .iso-only
  filename derived from Content-Disposition / URL basename with path
  traversal stripped; 16 GiB cap; cancel; credential-stripped URL display.
  Operator-gated, no boot-time outbound — offline boot is untouched.
- Storage upload card gains an "Or add by URL" field with progress + cancel.

Zero-touch admin bootstrap:
- OPENPXE_ADMIN_USERNAME + OPENPXE_ADMIN_PASSWORD (or _PASSWORD_FILE for
  Docker/K8s secrets) auto-create the admin on first run, so a fresh
  container is usable with no setup wizard. Seeds the first run only — a
  lingering env var can't reset a rotated password.

Tests: URL parse / filename / Content-Disposition unit tests + a wiremock
end-to-end fetch-into-store integration test. clippy/fmt/node clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-16 15:56:27 -04:00
Miles WardandClaude Opus 4.8 1c262a6d61 v0.8.0: dep prune, memtest introspection fix, concurrent uploads, x-api-key
Dependency cleanup (ponytail audit):
- Drop 14 unused dependency declarations across 7 crates; quick-xml and
  x509-parser leave the tree entirely (SAML cert/XML work is handled by
  bergshamra + roxmltree).

Fixes:
- introspect: drop the over-broad "microsoft" UTF-16 bulk-scan marker that
  mislabeled Secure-Boot-signed non-Windows bootables (memtest86, signed
  BSDs, firmware tools) as Windows — the string lives in their MS-signed
  EFI loader's FAT long-filename entries. INTROSPECT_REV 3 -> 4 re-probes
  existing local ISOs on startup so the bogus label clears on upgrade.
- upload: begin_upload now reclaims an abandoned <id>.partial instead of
  rejecting the re-upload with "already uploading". Robust against browser
  refresh, tab close, and dropped connections (the chunked protocol can't
  resume a dead session anyway).

Features:
- Storage upload: multi-file + concurrent. Each dropped/selected .iso gets
  its own progress row and uploads independently; a single page-leave guard
  plus a pagehide keepalive-abort replace the old shared singletons.
- Operator API key (x-api-key): a persisted key authenticates /api/* exactly
  like an operator session, for Postman/scripts. New core ApiKeyStore
  (generated on first run, regenerable), accepted in require_auth alongside
  the session cookie, surfaced in Settings -> Advanced with copy + regenerate
  and a usage reference. GET /api/api-key + POST /api/api-key/regenerate.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-21 23:35:42 -04:00
Miles WardandClaude Opus 4.8 27703c437a docs(compose): simplify root docker-compose to one pull-based service
The root compose was scratch-built and carried both a prod and a dev
service plus a local build stanza. Replace it with a single
deployment-focused service that pulls
gitea.milesward.dev/mward4/openpxe:latest (matching the Unraid
template and the homelab flow):

- drop the openpxe-dev service and the build: context (deploy, not build)
- HTTP on 4200 so it clears an Unraid webGUI / reverse proxy on :80
- host networking (DHCPDISCOVER is broadcast — bridges don't forward it)
- cap_add NET_BIND_SERVICE instead of privileged; the binary already
  carries cap_net_bind_service as a file capability
- OPENPXE_PUBLIC_IP stays a required, fail-fast variable
- isos + work bind mounts (SMB dir omitted — Windows boots via HTTP
  sanboot since v0.5.8, no SMB server needed)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-14 08:15:43 -04:00
Miles WardandClaude Opus 4.8 1ded291c7b v0.7.5: Joliet namespace fallback, gap-tolerant El Torito walk, Unattended pagination
Follow-up to the v0.7.4 dashboard triage: VCSA/ultravnc data ISOs are
*correctly* flagged non-bootable (no boot catalog exists to find), but
two real false-negative holes could mislabel genuinely bootable
appliance ISOs — both closed here.

iso_fs:
- Joliet fallback (the big one): lookup() now tries the primary
  ISO9660 namespace first and falls back to the Joliet SVD (UCS-2
  big-endian identifiers, escape-sequence detected). Windows-oriented
  mastering tools — common for vendor/appliance ISOs — write a minimal
  or mangled primary tree and keep the real filenames only in Joliet;
  those images probed as "no installer files" and their in-ISO fetches
  404'd. Applies everywhere the walker is used: introspection probes
  (local + NFS/SFTP) and /iso/{id}/{*path} serving.
- find_descriptor(): the PVD/SVD search scans the whole descriptor
  area (LBA 16..32), skipping non-CD001 filler sectors instead of
  requiring a pristine sector 16.
- TestIsoBuilder grows a joliet_only mode (bare primary tree, real
  names only in the SVD) modeling the mastering worst case.

introspect:
- detect_el_torito() no longer aborts at the first non-CD001 sector or
  stops at a Set Terminator — sloppy mastering leaves zeroed filler
  sectors that used to hide a real boot record and flag a bootable
  image as a data ISO. All 16 descriptor sectors are examined; the
  25-byte exact signature can't false-positive on what follows the set.
- Volume-label read now uses the same tolerant descriptor scan, and
  label + El Torito + namespace probes all share one CachingReadAt, so
  remote probes spend fewer round-trips than before despite scanning
  more sectors.
- INTROSPECT_REV bumped to 3 so everything probed by the rev-2 logic
  re-probes with the Joliet fallback: local ISOs on first startup, and
  remote ISOs via the rev-gated cache self-invalidating.

webui:
- Unattended files: the same 5-per-page pager as Available images
  (Showing X–Y of N · Prev/Next), composed with the existing filter,
  page resets on input.

Validation: clippy pedantic clean, fmt clean, 319 workspace tests
green (+3: joliet fallback lookup, joliet-only classification, filler-
sector boot record), webui syntax-checked.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-12 17:04:01 -04:00
24 changed files with 1546 additions and 351 deletions
Generated
+9 -136
View File
@@ -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.1"
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.1"
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.1"
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.1"
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.1"
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.1"
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.1"
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.1"
[[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
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.7.4" version = "0.8.1"
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"
+9 -1
View File
@@ -65,7 +65,10 @@ OpenPXE collapses that whole stack into **one statically-linked binary in one co
hierarchy — generated fresh on every request from current settings. hierarchy — generated fresh on every request from current settings.
#### ISO management & remote libraries #### ISO management & remote libraries
- **Drag-and-drop chunked uploads** that don't 502 on multi-GB images. - **Drag-and-drop chunked uploads** that don't 502 on multi-GB images — drop several at once
and they upload concurrently.
- **Add by URL** — paste an ISO link and the server streams it straight into storage and
auto-detects it, with progress; no download-then-reupload.
- **Automatic introspection** — detects the distro family and generates the right - **Automatic introspection** — detects the distro family and generates the right
kernel+initrd or Windows `wimboot` chain. No manual config. kernel+initrd or Windows `wimboot` chain. No manual config.
- **Remote ISO libraries, streamed on demand** (no local cache) over **SMB, NFS, or SFTP** - **Remote ISO libraries, streamed on demand** (no local cache) over **SMB, NFS, or SFTP**
@@ -88,6 +91,9 @@ OpenPXE collapses that whole stack into **one statically-linked binary in one co
- **Prometheus `/metrics`**, a built-in operator **terminal**, live tracing log, and - **Prometheus `/metrics`**, a built-in operator **terminal**, live tracing log, and
`/healthz` · `/readyz` probes. `/healthz` · `/readyz` probes.
- **Layered config** — defaults → TOML file → `OPENPXE_*` env, in that order. - **Layered config** — defaults → TOML file → `OPENPXE_*` env, in that order.
- **Zero-touch first run** — set `OPENPXE_ADMIN_USERNAME` + `OPENPXE_ADMIN_PASSWORD` and a
fresh container comes up with the admin already created, no setup wizard. Automate the rest
from scripts/Postman with a per-install **API key** (`x-api-key`), shown in Settings → Advanced.
## Built in Rust ## Built in Rust
@@ -236,6 +242,8 @@ All settings have defaults and layer **defaults → TOML (`--config` / `OPENPXE_
| `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Uploaded ISOs | | `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Uploaded ISOs |
| `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch, settings, share + branding state | | `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch, settings, share + branding state |
| `OPENPXE_LOG` | `info,openpxe=info` | `tracing` filter | | `OPENPXE_LOG` | `info,openpxe=info` | `tracing` filter |
| `OPENPXE_ADMIN_USERNAME` | — | First-run only: with `OPENPXE_ADMIN_PASSWORD`, auto-creates the admin so no setup wizard is needed. Ignored once an admin exists. |
| `OPENPXE_ADMIN_PASSWORD` | — | First-run admin password. Use `OPENPXE_ADMIN_PASSWORD_FILE` to read it from a file (Docker/K8s secret). |
## OpenShift ## OpenShift
+3 -5
View File
@@ -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
+158
View File
@@ -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
View File
@@ -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};
-2
View File
@@ -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
-2
View File
@@ -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
+29
View File
@@ -94,6 +94,17 @@ pub fn build_router(state: AppState) -> Router {
// JSON API. // JSON API.
.route("/api/isos", get(api_list_isos).post(api_upload_iso)) .route("/api/isos", get(api_list_isos).post(api_upload_iso))
.route("/api/isos/{id}", delete(api_delete_iso)) .route("/api/isos/{id}", delete(api_delete_iso))
// v0.8.1: add ISO by URL — server-side streaming download + progress
// polling. Static `fetch` coexists with `{id}` above (matchit
// prioritizes the literal), same as `/api/queue/join` vs `{entry_id}`.
.route(
"/api/isos/fetch",
get(crate::fetch::api_iso_fetch_list).post(crate::fetch::api_iso_fetch_start),
)
.route(
"/api/isos/fetch/{id}",
delete(crate::fetch::api_iso_fetch_cancel),
)
.route("/api/uploads", post(api_upload_begin)) .route("/api/uploads", post(api_upload_begin))
.route( .route(
"/api/uploads/{upload_id}", "/api/uploads/{upload_id}",
@@ -147,6 +158,14 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/logout", post(auth_api::api_logout)) .route("/api/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))
@@ -1849,6 +1868,12 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Clear an ISO's boot password."}, "summary": "Clear an ISO's boot password."},
{"method": "PUT", "path": "/api/isos/{id}/category", {"method": "PUT", "path": "/api/isos/{id}/category",
"summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."}, "summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."},
{"method": "POST", "path": "/api/isos/fetch",
"summary": "Add an ISO by URL. Body: { \"url\", \"filename\"? }. The server streams the .iso into storage and introspects it. Returns { fetch_id }."},
{"method": "GET", "path": "/api/isos/fetch",
"summary": "Poll URL-fetch progress — [{ id, filename, url, downloaded, total, state }]. Completed jobs are returned once."},
{"method": "DELETE", "path": "/api/isos/fetch/{id}",
"summary": "Cancel an in-flight URL fetch, or dismiss a finished/failed one."},
], ],
}, },
{ {
@@ -1944,6 +1969,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."},
], ],
}, },
{ {
+39 -6
View File
@@ -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,
+485
View File
@@ -0,0 +1,485 @@
//! Server-side "add ISO by URL" — stream a remote `.iso` straight into the
//! store, reusing the chunked-upload handle + introspection pipeline so a
//! URL-fetched image classifies and gains boot entries exactly like an
//! uploaded one. A small in-memory job registry tracks progress; the web UI
//! polls it and renders rows just like browser uploads.
//!
//! This is operator-initiated and auth-gated (`/api/*`), never runs at boot,
//! and adds no CDN assets — so it doesn't touch OpenPXE's offline-boot
//! guarantee. On an air-gapped network it simply goes unused (upload
//! instead). It's the same class of optional outbound the server already
//! makes for webhooks and the update check.
use crate::state::AppState;
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use futures::StreamExt;
use openpxe_core::{Error, Result};
use openpxe_iso_store::IsoStore;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use uuid::Uuid;
/// Hard ceiling on a URL-fetched image — matches the HTTP upload body cap
/// (`DefaultBodyLimit` in `app.rs`).
const MAX_ISO_BYTES: u64 = 16 * 1024 * 1024 * 1024;
/// How long a finished-with-error job lingers so the operator can read the
/// failure before it's swept. Successful jobs are read-once (see
/// [`FetchJobs::snapshot`]).
const FAILED_TTL: Duration = Duration::from_mins(10);
#[derive(Clone)]
enum Phase {
Downloading,
Done { iso_id: String },
Failed { error: String },
Canceled,
}
struct Job {
/// Best-known target filename (provisional from the URL, refined once
/// the response headers arrive).
filename: String,
/// Display-safe source URL — any `user:pass@` userinfo is stripped so
/// the UI/logs never echo embedded credentials.
url: String,
downloaded: u64,
/// Total bytes from `Content-Length`, or `0` when the server didn't
/// send one (progress then shows bytes-so-far without a percentage).
total: u64,
phase: Phase,
cancel: bool,
finished_at: Option<Instant>,
}
/// One row in the fetch-progress list the UI polls.
#[derive(Serialize)]
pub struct JobDto {
id: String,
filename: String,
url: String,
downloaded: u64,
total: u64,
/// `downloading` | `done` | `failed` | `canceled`.
state: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
iso_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
/// In-memory registry of in-flight and recently-finished URL fetches.
/// Cheap to clone (Arc-shared); contention is nil (a handful of jobs, brief
/// per-chunk progress bumps).
#[derive(Clone, Default)]
pub struct FetchJobs {
inner: Arc<Mutex<HashMap<String, Arc<Mutex<Job>>>>>,
}
impl FetchJobs {
/// Validate the URL, register a job, and spawn the background download.
/// Returns the job id. Fails fast on a bad/unsupported URL so the POST
/// gets an immediate error instead of a job that dies a moment later.
pub fn start(
&self,
store: IsoStore,
raw_url: &str,
filename_hint: Option<&str>,
) -> Result<String> {
let (url, display) = parse_and_sanitize(raw_url)?;
// Provisional filename for the first render; the task refines it
// from Content-Disposition / the post-redirect URL.
let provisional = filename_hint
.map(sanitize_filename)
.or_else(|| basename(&url))
.unwrap_or_else(|| "download.iso".to_string());
let id = Uuid::new_v4().simple().to_string();
let job = Arc::new(Mutex::new(Job {
filename: provisional,
url: display,
downloaded: 0,
total: 0,
phase: Phase::Downloading,
cancel: false,
finished_at: None,
}));
self.inner.lock().insert(id.clone(), job.clone());
let hint = filename_hint.map(str::to_string);
tokio::spawn(async move {
if let Err(e) = download(&store, &job, url, hint).await {
let mut g = job.lock();
// A cancel flips the phase itself; don't overwrite it.
if !matches!(g.phase, Phase::Canceled) {
g.phase = Phase::Failed {
error: format!("{e}"),
};
}
g.finished_at = Some(Instant::now());
}
});
Ok(id)
}
/// Snapshot every job for the UI, then sweep the terminal ones:
/// `Done`/`Canceled` are **read-once** (removed after this call, so the
/// UI reacts to completion exactly once and never loops on a lingering
/// "done" row), while `Failed` is retained until [`FAILED_TTL`] so the
/// error stays visible.
pub fn snapshot(&self) -> Vec<JobDto> {
let mut g = self.inner.lock();
let mut out = Vec::with_capacity(g.len());
for (id, job) in g.iter() {
let j = job.lock();
let (state, iso_id, error) = match &j.phase {
Phase::Downloading => ("downloading", None, None),
Phase::Done { iso_id } => ("done", Some(iso_id.clone()), None),
Phase::Failed { error } => ("failed", None, Some(error.clone())),
Phase::Canceled => ("canceled", None, None),
};
out.push(JobDto {
id: id.clone(),
filename: j.filename.clone(),
url: j.url.clone(),
downloaded: j.downloaded,
total: j.total,
state,
iso_id,
error,
});
}
let now = Instant::now();
g.retain(|_, job| {
let j = job.lock();
match &j.phase {
Phase::Downloading => true,
Phase::Done { .. } | Phase::Canceled => false, // read-once
Phase::Failed { .. } => j
.finished_at
.is_none_or(|t| now.duration_since(t) < FAILED_TTL),
}
});
out
}
/// Cancel an in-flight download, or dismiss a terminal one. Returns
/// `true` if a job with that id existed.
pub fn cancel(&self, id: &str) -> bool {
let g = self.inner.lock();
let Some(job) = g.get(id) else { return false };
let mut j = job.lock();
if matches!(j.phase, Phase::Downloading) {
j.cancel = true; // the download loop checks this each chunk
} else {
drop(j);
drop(g);
self.inner.lock().remove(id);
}
true
}
}
/// The background download: GET the URL, derive + validate the filename,
/// then stream the body through an `UploadHandle` (which hashes, writes the
/// `.partial`, and on `finish` renames + introspects).
async fn download(
store: &IsoStore,
job: &Arc<Mutex<Job>>,
url: reqwest::Url,
filename_hint: Option<String>,
) -> Result<()> {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(15))
.user_agent(concat!("OpenPXE/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| Error::Other(e.into()))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| Error::Invalid(format!("request failed: {e}")))?;
if !resp.status().is_success() {
return Err(Error::Invalid(format!("server returned {}", resp.status())));
}
let cd = resp
.headers()
.get(header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok());
let filename = pick_filename(filename_hint.as_deref(), cd, resp.url())?;
let total = resp.content_length().unwrap_or(0);
if total > MAX_ISO_BYTES {
return Err(Error::Invalid(format!(
"declared size {total} exceeds the {MAX_ISO_BYTES}-byte cap"
)));
}
{
let mut g = job.lock();
g.filename.clone_from(&filename);
g.total = total;
}
let mut handle = store.begin_upload(&filename).await?;
let mut stream = resp.bytes_stream();
let mut received: u64 = 0;
while let Some(item) = stream.next().await {
if job.lock().cancel {
let _ = handle.abort().await;
job.lock().phase = Phase::Canceled;
job.lock().finished_at = Some(Instant::now());
return Ok(());
}
let chunk = item.map_err(|e| Error::Invalid(format!("transfer error: {e}")))?;
received += chunk.len() as u64;
if received > MAX_ISO_BYTES {
let _ = handle.abort().await;
return Err(Error::Invalid(format!(
"download exceeded the {MAX_ISO_BYTES}-byte cap"
)));
}
if let Err(e) = handle.write_chunk(&chunk).await {
let _ = handle.abort().await;
return Err(e);
}
job.lock().downloaded = received;
}
let meta = handle.finish(store).await?;
tracing::info!(
target: "openpxe::http::fetch",
filename = %filename, bytes = received, family = ?meta.introspection.family,
"fetched ISO from URL"
);
let mut g = job.lock();
g.downloaded = received;
g.phase = Phase::Done { iso_id: meta.id };
g.finished_at = Some(Instant::now());
Ok(())
}
/// Parse the URL, require an `http`/`https` scheme (no `file:`/`gopher:`/…),
/// and return it alongside a credential-stripped display form.
fn parse_and_sanitize(raw: &str) -> Result<(reqwest::Url, String)> {
let url = reqwest::Url::parse(raw.trim())
.map_err(|_| Error::Invalid("not a valid URL".to_string()))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(Error::Invalid(
"only http:// and https:// URLs are accepted".to_string(),
));
}
let mut display = url.clone();
let _ = display.set_username("");
let _ = display.set_password(None);
Ok((url, display.to_string()))
}
/// Choose the target filename: explicit hint > `Content-Disposition` >
/// post-redirect URL basename. Must end in `.iso` (case-insensitive).
fn pick_filename(
explicit: Option<&str>,
content_disposition: Option<&str>,
final_url: &reqwest::Url,
) -> Result<String> {
let candidate = explicit
.map(sanitize_filename)
.or_else(|| content_disposition.and_then(filename_from_disposition))
.or_else(|| basename(final_url))
.ok_or_else(|| Error::Invalid("could not determine a filename".to_string()))?;
if !candidate.to_ascii_lowercase().ends_with(".iso") {
return Err(Error::Invalid(format!(
"URL does not point at an .iso (got '{candidate}')"
)));
}
Ok(candidate)
}
/// Last path segment of a URL, percent-decoded and reduced to a bare
/// filename. `None` for a pathless URL.
fn basename(url: &reqwest::Url) -> Option<String> {
let seg = url.path_segments()?.next_back()?;
if seg.is_empty() {
return None;
}
let decoded = percent_decode(seg);
Some(sanitize_filename(&decoded))
}
/// Pull `filename="x.iso"` (or bare `filename=x.iso`) out of a
/// `Content-Disposition` header. RFC 5987 `filename*` is ignored — the
/// common case is enough, and the URL basename is the fallback.
fn filename_from_disposition(cd: &str) -> Option<String> {
let idx = cd.to_ascii_lowercase().find("filename=")?;
let rest = &cd[idx + "filename=".len()..];
let val = rest.trim_start().trim_start_matches('"');
let end = val.find(['"', ';']).unwrap_or(val.len());
let name = val[..end].trim();
if name.is_empty() {
None
} else {
Some(sanitize_filename(name))
}
}
/// Reduce any path-ish string to a safe bare filename: last component only,
/// no `/`, `\`, or NULs. Prevents a crafted `Content-Disposition`/URL from
/// escaping the ISO directory.
fn sanitize_filename(s: &str) -> String {
s.rsplit(['/', '\\'])
.next()
.unwrap_or(s)
.replace('\0', "")
.trim()
.to_string()
}
/// Minimal percent-decoding for a single path segment (enough for `%20`
/// spaces in an ISO name); leaves malformed escapes untouched.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (hexval(bytes[i + 1]), hexval(bytes[i + 2])) {
out.push(h << 4 | l);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hexval(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
// ── Handlers ──────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct FetchBody {
pub url: String,
#[serde(default)]
pub filename: Option<String>,
}
/// `POST /api/isos/fetch` — start a URL download.
pub async fn api_iso_fetch_start(
State(state): State<AppState>,
Json(body): Json<FetchBody>,
) -> Response {
match state
.fetch_jobs
.start(state.iso_store.clone(), &body.url, body.filename.as_deref())
{
Ok(id) => (StatusCode::ACCEPTED, Json(json!({ "fetch_id": id }))).into_response(),
Err(Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response(),
}
}
/// `GET /api/isos/fetch` — poll progress. Successful jobs appear once.
pub async fn api_iso_fetch_list(State(state): State<AppState>) -> Response {
(
StatusCode::OK,
Json(json!({ "jobs": state.fetch_jobs.snapshot() })),
)
.into_response()
}
/// `DELETE /api/isos/fetch/{id}` — cancel an in-flight download or dismiss a
/// finished/failed row.
pub async fn api_iso_fetch_cancel(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Response {
if state.fetch_jobs.cancel(&id) {
StatusCode::NO_CONTENT.into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "no such fetch job" })),
)
.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scheme_is_restricted_and_credentials_are_stripped() {
assert!(parse_and_sanitize("file:///etc/passwd").is_err());
assert!(parse_and_sanitize("gopher://x/1").is_err());
assert!(parse_and_sanitize("not a url").is_err());
let (_, display) = parse_and_sanitize("https://user:[email protected]/a.iso").unwrap();
assert!(
!display.contains("secret"),
"credentials must be stripped: {display}"
);
assert!(display.starts_with("https://example.com/"));
}
#[test]
fn filename_prefers_explicit_then_disposition_then_url() {
let u = reqwest::Url::parse("https://example.com/path/final.iso").unwrap();
// Explicit wins, and path traversal is stripped.
assert_eq!(
pick_filename(Some("../../evil/custom.iso"), None, &u).unwrap(),
"custom.iso"
);
// Content-Disposition next.
assert_eq!(
pick_filename(None, Some(r#"attachment; filename="rescue.iso""#), &u).unwrap(),
"rescue.iso"
);
// URL basename fallback (with percent-decoding).
let sp = reqwest::Url::parse("https://example.com/System%20Rescue.iso").unwrap();
assert_eq!(pick_filename(None, None, &sp).unwrap(), "System Rescue.iso");
// Non-.iso is rejected.
assert!(pick_filename(
None,
None,
&reqwest::Url::parse("https://x/y.tar.gz").unwrap()
)
.is_err());
}
#[test]
fn disposition_parsing_handles_quotes_and_bare() {
assert_eq!(
filename_from_disposition(r#"attachment; filename="a.iso"; size=1"#).as_deref(),
Some("a.iso")
);
assert_eq!(
filename_from_disposition("inline; filename=b.iso").as_deref(),
Some("b.iso")
);
assert_eq!(filename_from_disposition("attachment").as_deref(), None);
}
}
+1
View File
@@ -17,6 +17,7 @@
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod error; pub mod error;
pub mod fetch;
pub mod grub_script; pub mod grub_script;
pub mod ipxe_script; pub mod ipxe_script;
pub mod log_stream; pub mod log_stream;
+11 -1
View File
@@ -1,8 +1,9 @@
use crate::auth::SessionStore; use crate::auth::SessionStore;
use crate::fetch::FetchJobs;
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 +56,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
@@ -103,6 +109,10 @@ pub struct AppState {
/// through `IsoStore`, but the UI uses sessions so large ISO transfers /// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files. /// can show deterministic progress and leave visible partial files.
pub uploads: UploadSessions, pub uploads: UploadSessions,
/// v0.8.1: server-side "add ISO by URL" jobs. Background downloads
/// streamed straight into the store (reusing the upload handle +
/// introspection); the Storage view polls their progress.
pub fetch_jobs: FetchJobs,
/// Live log bus consumed by the Terminal tab via SSE. Operator-issued /// Live log bus consumed by the Terminal tab via SSE. Operator-issued
/// terminal commands also push synthetic lines onto it so the tail /// terminal commands also push synthetic lines onto it so the tail
/// shows them inline. /// shows them inline.
+106
View File
@@ -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,
@@ -124,6 +126,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
sftp_shares, sftp_shares,
unattended, unattended,
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
fetch_jobs: openpxe_http_api::fetch::FetchJobs::default(),
log_bus, log_bus,
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: "http://127.0.0.1".into(), public_base_url: "http://127.0.0.1".into(),
@@ -135,6 +138,109 @@ 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]
async fn fetch_iso_by_url_downloads_into_store() {
// v0.8.1: add-ISO-by-URL. Serve a real ISO over HTTP, POST its URL, poll
// the fetch registry until the background download finishes, then assert
// the image landed in the store (classified like an upload).
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let (state, _dir) = build_state().await;
let app = build_router(state);
let iso = fake_alpine_iso();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rescue.iso"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(iso))
.mount(&server)
.await;
let url = format!("{}/rescue.iso", server.uri());
let (code, _) = post_json(&app, "/api/isos/fetch", &format!(r#"{{"url":"{url}"}}"#)).await;
assert_eq!(code, StatusCode::ACCEPTED, "fetch should start");
// Bounded poll for completion (the download runs on a spawned task).
let mut done = false;
for _ in 0..100 {
let (_, body) = get(&app, "/api/isos/fetch").await;
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let jobs = v["jobs"].as_array().cloned().unwrap_or_default();
assert!(
!jobs.iter().any(|j| j["state"] == "failed"),
"fetch failed: {jobs:?}"
);
if jobs.iter().any(|j| j["state"] == "done") {
done = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(done, "fetch did not complete in time");
// The fetched ISO is now in the store under the URL basename.
let (_, body) = get(&app, "/api/isos").await;
assert!(
String::from_utf8_lossy(&body).contains("rescue.iso"),
"fetched ISO should appear in /api/isos"
);
}
#[tokio::test] #[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;
-1
View File
@@ -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
-3
View File
@@ -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
+117 -31
View File
@@ -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
View File
@@ -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 13).
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]
+40 -4
View File
@@ -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(_))));
} }
-2
View File
@@ -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
+35
View File
@@ -120,8 +120,41 @@ async fn main() -> anyhow::Result<()> {
let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir); let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir);
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir); let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir); let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
// v0.8.1: zero-touch first-run bootstrap. If no admin exists yet and the
// operator supplied OPENPXE_ADMIN_USERNAME + OPENPXE_ADMIN_PASSWORD (or
// …_PASSWORD_FILE, for Docker/K8s secrets), create the admin now so a
// fresh container is usable without the web setup wizard. Seeds the
// first run only — once an admin exists (including one made in the UI)
// this is a no-op, so a lingering env var can't reset a rotated password.
if !admin.is_configured() {
if let Ok(username) = std::env::var("OPENPXE_ADMIN_USERNAME") {
let password = std::env::var("OPENPXE_ADMIN_PASSWORD_FILE")
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| s.trim_end_matches(['\n', '\r']).to_string())
.or_else(|| std::env::var("OPENPXE_ADMIN_PASSWORD").ok());
if let Some(password) = password {
match admin.bootstrap(&username, &password) {
Ok(p) => tracing::info!(
target: "openpxe::auth", username = %p.username,
"admin bootstrapped from environment"
),
Err(e) => tracing::warn!(
target: "openpxe::auth",
"env admin bootstrap failed ({e}); use the web setup wizard"
),
}
} else {
tracing::warn!(
target: "openpxe::auth",
"OPENPXE_ADMIN_USERNAME set without OPENPXE_ADMIN_PASSWORD[_FILE]; skipping bootstrap"
);
}
}
}
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir); let 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 +234,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(),
@@ -211,6 +245,7 @@ async fn main() -> anyhow::Result<()> {
sftp_shares: sftp_shares.clone(), sftp_shares: sftp_shares.clone(),
unattended: unattended.clone(), unattended: unattended.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
fetch_jobs: openpxe_http_api::fetch::FetchJobs::default(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: public_base_url.clone(), public_base_url: public_base_url.clone(),
-2
View File
@@ -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
+252 -72
View File
@@ -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,25 +742,100 @@
} 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');
} }
} }
}
// v0.8.1: add ISO by URL. Paste a link and the server streams it
// straight into the store and auto-detects it — no download-then-
// reupload. Progress polls /api/isos/fetch and shows rows below,
// mirroring uploads. On an air-gapped network, use the drop zone.
const urlInput = el('input', {type:'url', id:'iso-url', style:'flex:1',
placeholder:'https://example.com/systemrescue.iso'});
const fetchBtn = el('button', {class:'ghost', type:'button', style:'margin-left:8px'}, 'Fetch');
const fetchMsg = el('div', {class:'msg', style:'margin-top:6px'});
const fetchList = el('div', {id:'fetches', style:'display:grid;gap:12px;margin-top:12px'});
const urlRow = el('div', {style:'margin-top:14px'}, [
el('label', {class:'field', style:'margin-bottom:0'}, [
el('span', {class:'name'}, 'Or add by URL'),
el('div', {style:'display:flex;align-items:center'}, [urlInput, fetchBtn]),
el('span', {class:'hint'},
'The server downloads the .iso into storage and auto-detects it — same result as a drag-drop. Any http(s) .iso link works.'),
]),
fetchMsg,
]);
let fetchTimer = null;
const renderFetchRows = (jobs) => {
fetchList.replaceChildren(...jobs.map(j => {
const pct = j.total > 0 ? Math.min(100, (j.downloaded / j.total) * 100)
: (j.state === 'done' ? 100 : 0);
let text, cls = '';
if (j.state === 'downloading')
text = 'Downloading ' + fmtBytes(j.downloaded) +
(j.total ? ' of ' + fmtBytes(j.total) + ' (' + pct.toFixed(0) + '%)' : '');
else if (j.state === 'done') { text = 'Downloaded and analyzed.'; cls = 'ok'; }
else if (j.state === 'failed') { text = 'Failed: ' + (j.error || 'unknown error'); cls = 'err'; }
else text = 'Canceled — partial discarded.';
const btn = el('button', {class:'danger', type:'button', style:'margin-top:8px'},
j.state === 'downloading' ? 'Cancel' : 'Dismiss');
btn.onclick = async () => {
try { await fetch('/api/isos/fetch/' + encodeURIComponent(j.id), {method:'DELETE'}); } catch (_) {}
pollFetches();
};
return el('div', {}, [
el('div', {style:'font-weight:600;font-size:13px;margin-bottom:6px;word-break:break-all'},
j.filename + ' · ' + j.url),
el('div', {class:'progress' + (j.state === 'downloading' ? ' active' : '')},
el('div', {class:'bar', style:'width:' + pct.toFixed(1) + '%'})),
el('div', {class:'msg ' + cls}, text),
btn,
]);
}));
};
async function pollFetches() {
if (fetchTimer) { clearTimeout(fetchTimer); fetchTimer = null; }
let jobs = [];
try { jobs = (await getJSON('/api/isos/fetch')).jobs || []; } catch (_) {}
renderFetchRows(jobs);
// A successful fetch is read-once on the server, so refreshing here
// shows the new image and won't re-trigger on the next poll. Keep
// polling only while a download is still in flight.
if (jobs.some(j => j.state === 'done')) { render('storage'); return; }
if (jobs.some(j => j.state === 'downloading')) fetchTimer = setTimeout(pollFetches, 1500);
}
async function startFetch() {
const url = urlInput.value.trim();
if (!url) return;
fetchMsg.textContent = 'Starting…'; fetchMsg.className = 'msg';
const r = await postJSON('/api/isos/fetch', { url });
if (r.ok) { urlInput.value = ''; fetchMsg.textContent = ''; pollFetches(); }
else { fetchMsg.textContent = 'Could not start: ' + (await r.text()).slice(0, 160); fetchMsg.className = 'msg err'; }
}
fetchBtn.onclick = startFetch;
urlInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); startFetch(); } });
// ── ISO table (mixed local + SMB) ── // ── ISO table (mixed local + SMB) ──
// Each row gets a "Password" cell that toggles a small inline // Each row gets a "Password" cell that toggles a small inline
@@ -1294,6 +1409,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 +1448,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 ' +
@@ -1324,11 +1466,11 @@
]), ]),
]); ]);
return el('div', {}, [el('div', {class:'grid'}, [ const root = el('div', {}, [el('div', {class:'grid'}, [
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, urlRow, uploadsList, fetchList]),
]), ]),
// 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
@@ -1367,6 +1509,10 @@
isoPager, isoPager,
]), ]),
]), unattendedAdvanced]); ]), unattendedAdvanced]);
// v0.8.1: resume/kick URL-fetch progress polling; stop it on view swap.
root._cleanup = () => { if (fetchTimer) clearTimeout(fetchTimer); };
pollFetches();
return root;
}, },
hosts: async () => { hosts: async () => {
@@ -1705,7 +1851,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 +1861,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 +2175,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 +2190,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 +2305,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
View File
@@ -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