From 761489761cdaecd59f69f398830d42a891a565d5 Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Thu, 28 May 2026 03:09:43 -0400 Subject: [PATCH] v0.4.65: Local directory ISO source (bind-mount workaround for Unraid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report: even with CAP_SYS_ADMIN and full --privileged, NFS mounts inside the OpenPXE container fail on Unraid with the same "failed to apply fstab options" error v0.4.64 added diagnostics for. The root cause is the host kernel: Unraid's base kernel ships without the nfs/nfsv4 client modules loaded. Capabilities are necessary but not sufficient; the modules have to be present on the host kernel for in-container mount(2) to do anything. No container-side change can fix that. This is exactly the case every other PXE/imaging tool sidesteps (Bootimus uses SMB; iVentoy, FOG, MAAS, Cobbler all rely on the host to mount network storage and bind-mount the path into the imaging service). v0.4.65 brings OpenPXE in line with that pattern. What's new: * `IsoSource::LocalDir { dir_id, relative_path }` — third source kind alongside `Local` (uploaded) and `Nfs` (in-container mount). * `LocalDirManager` (crates/iso-store/src/local_dir.rs) — registers bind-mounted directories, validates them (absolute path, exists, is a directory, readable), scans for *.iso files, registers them with IsoStore. Persisted to /local_dirs.json so the relationship survives restarts. * `NfsHostCaps::detect()` — pure read of /proc/filesystems on startup. Surfaced via GET /api/nfs/capabilities and used by the Storage tab to show a prominent red banner above the NFS form when in-container mounts cannot possibly work, pointing the operator at the Local Directories card as the recommended path. * Four new API routes: GET /api/nfs/capabilities GET /api/local-dirs POST /api/local-dirs { path, label? } DELETE /api/local-dirs/:id POST /api/local-dirs/:id/scan UI changes (crates/webui/src/app.js): * Storage tab: new "Local directories" card under the NFS card with the bind-mount form, an explainer paragraph (with the Docker `-v /mnt/user/isos:/mnt/external-isos` command), and the list of registered directories with rescan + remove actions. * When NFS host caps are unavailable, the NFS card sprouts a red banner explaining what's wrong and pointing at the local-dir workaround. The card sub-header also flips to "N registered · recommended on this host". * ISO table: new "dir:" source badge; on-disk ISOs show "on disk" in the actions column instead of a delete button (same pattern as NFS — OpenPXE doesn't own those bytes). * API reference table picks up the four new endpoints + a hint about the new `port` field on NFS add. Tests (+12, total 162): * iso-store: 7 local_dir unit tests covering relative-path rejection, missing path, non-directory file, empty-directory success, default label, idempotent re-add, remove + iso-path-resolution clear. * iso-store: 1 nfs unit test confirming NfsHostCaps::detect() never panics and the boolean accessors are consistent. * http-api: 4 integration tests covering /api/nfs/capabilities, /api/local-dirs list/add/remove + relative-path 400. `cargo clippy --workspace --all-targets -- -D warnings` clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/http-api/src/app.rs | 82 ++++- crates/http-api/src/state.rs | 13 +- crates/http-api/src/terminal.rs | 2 + crates/http-api/tests/full_flow.rs | 96 +++++- crates/iso-store/src/lib.rs | 4 +- crates/iso-store/src/local_dir.rs | 515 +++++++++++++++++++++++++++++ crates/iso-store/src/nfs.rs | 78 +++++ crates/iso-store/src/store.rs | 61 +++- crates/openpxe/src/main.rs | 37 ++- crates/webui/src/app.js | 150 ++++++++- 12 files changed, 1027 insertions(+), 29 deletions(-) create mode 100644 crates/iso-store/src/local_dir.rs diff --git a/Cargo.lock b/Cargo.lock index 9139e2c..1dfb95e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1140,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openpxe" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "axum", @@ -1162,7 +1162,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "bcrypt", @@ -1181,7 +1181,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "bytes", @@ -1195,7 +1195,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "axum", @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.4.64" +version = "0.4.65" dependencies = [ "openpxe-core", "rust-embed", @@ -1236,7 +1236,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "bcrypt", @@ -1260,7 +1260,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "bytes", @@ -1274,7 +1274,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.4.64" +version = "0.4.65" [[package]] name = "parking_lot" diff --git a/Cargo.toml b/Cargo.toml index 3538ceb..c9d2c00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.64" +version = "0.4.65" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 98044d3..aafef4c 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -35,7 +35,7 @@ use openpxe_core::{ MAX_LOGO_BYTES, }; use openpxe_ipxe_assets::asset_bytes; -use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest}; +use openpxe_iso_store::{IsoCategory, IsoMeta, LocalDirAddRequest, NfsAddRequest}; use serde::Deserialize; use serde_json::json; use std::net::SocketAddr; @@ -136,6 +136,13 @@ pub fn build_router(state: AppState) -> Router { .route("/api/nfs", get(api_nfs_list).post(api_nfs_add)) .route("/api/nfs/:id", delete(api_nfs_remove)) .route("/api/nfs/:id/scan", post(api_nfs_scan)) + // v0.4.65: host-kernel NFS capability probe + bind-mounted + // local-directory source (works on Unraid / restricted-SCC + // hosts that can't kernel-mount NFS inside the container). + .route("/api/nfs/capabilities", get(api_nfs_capabilities)) + .route("/api/local-dirs", get(api_local_dirs_list).post(api_local_dirs_add)) + .route("/api/local-dirs/:id", delete(api_local_dirs_remove)) + .route("/api/local-dirs/:id/scan", post(api_local_dirs_scan)) // Phase 4: Network info (read-only) + DNS edit. .route("/api/network", get(api_network).put(api_network_put)) // Phase 4: live-log stream + recent buffer for the Terminal tab. @@ -1032,11 +1039,26 @@ async fn api_docs() -> Json { {"method": "GET", "path": "/api/nfs", "summary": "List configured NFS shares with mount state and iso counts."}, {"method": "POST", "path": "/api/nfs", - "summary": "Mount an NFS share. Body: { server, export, version, read_only }."}, + "summary": "Mount an NFS share. Body: { server, export, version, read_only, port? }."}, {"method": "DELETE", "path": "/api/nfs/:id", "summary": "Unmount a share and drop its entries from the ISO store."}, {"method": "POST", "path": "/api/nfs/:id/scan", "summary": "Re-walk a mounted share for ISOs."}, + {"method": "GET", "path": "/api/nfs/capabilities", + "summary": "Host-kernel NFS client support snapshot from /proc/filesystems."}, + ], + }, + { + "name": "Local directories (v0.4.65)", + "endpoints": [ + {"method": "GET", "path": "/api/local-dirs", + "summary": "List bind-mounted host directories registered as ISO sources."}, + {"method": "POST", "path": "/api/local-dirs", + "summary": "Register a bind-mounted directory. Body: { path, label? }."}, + {"method": "DELETE", "path": "/api/local-dirs/:id", + "summary": "Unregister a directory and drop its entries from the ISO store."}, + {"method": "POST", "path": "/api/local-dirs/:id/scan", + "summary": "Re-walk a registered directory for ISOs."}, ], }, { @@ -1736,6 +1758,62 @@ async fn api_nfs_scan(State(state): State, AxumPath(id): AxumPath) -> Json { + // The host caps are snapshotted at startup (pure read of + // /proc/filesystems) so this handler is just a JSON projection. + // The Storage tab uses `available` to decide whether to show the + // "your kernel doesn't have NFS client support" banner above the + // NFS form. + let c = &state.nfs_host_caps; + Json(json!({ + "available": c.available, + "has_nfs3": c.has_nfs3, + "has_nfs4": c.has_nfs4, + "detail": c.detail, + })) +} + +// ─── v0.4.65: bind-mounted local directories ─────────────────────────────── + +async fn api_local_dirs_list(State(state): State) -> Json { + Json(json!({ "directories": state.local_dirs.list() })) +} + +async fn api_local_dirs_add( + State(state): State, + Json(req): Json, +) -> Response { + match state.local_dirs.add(req).await { + Ok(d) => (StatusCode::CREATED, Json(d)).into_response(), + // The manager's errors are always operator-actionable + // (relative path / missing path / not-a-directory), so we + // surface them verbatim as 400s. + Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), + } +} + +async fn api_local_dirs_remove( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.local_dirs.remove(&id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + } +} + +async fn api_local_dirs_scan( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.local_dirs.rescan(&id).await { + Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), + } +} + // ─── Network info API ────────────────────────────────────────────────────── async fn api_network(State(state): State) -> Json { diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index aeb0da8..a2cd889 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -4,7 +4,7 @@ use openpxe_core::{ AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore, SsoStore, }; -use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; +use openpxe_iso_store::{IsoStore, LocalDirManager, NfsHostCaps, NfsManager, SmbManager}; use std::sync::Arc; use time::OffsetDateTime; @@ -49,6 +49,17 @@ pub struct AppState { /// available in the runtime image. Surfaces errors per-mount rather /// than failing the global state. pub nfs: NfsManager, + /// v0.4.65: bind-mounted host directories surfaced as ISO sources. + /// The container-friendly workaround for hosts (Unraid is the + /// dominant case) whose kernel lacks NFS client modules. Always + /// present; the operator opts in by registering paths from the + /// Storage tab. + pub local_dirs: LocalDirManager, + /// v0.4.65: host-kernel capability snapshot (from + /// `/proc/filesystems`). Snapshotted once at startup; the Storage + /// tab uses `available` to decide whether to show a "your kernel + /// doesn't have NFS client support" banner above the NFS form. + pub nfs_host_caps: NfsHostCaps, /// Browser chunked upload state. Multipart uploads still go straight /// through `IsoStore`, but the UI uses sessions so large ISO transfers /// can show deterministic progress and leave visible partial files. diff --git a/crates/http-api/src/terminal.rs b/crates/http-api/src/terminal.rs index 8cd9aeb..849a6b8 100644 --- a/crates/http-api/src/terminal.rs +++ b/crates/http-api/src/terminal.rs @@ -159,6 +159,8 @@ fn isos_text(s: &AppState) -> String { let src = match i.source { openpxe_iso_store::IsoSource::Local => "local".to_string(), openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"), + // v0.4.65: bind-mounted host directory. + openpxe_iso_store::IsoSource::LocalDir { dir_id, .. } => format!("dir:{dir_id}"), }; let _ = writeln!( out, diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 5780f6c..e79314a 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -14,7 +14,7 @@ use axum::body::Body; use axum::http::{header, Request, StatusCode}; use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore}; use openpxe_http_api::{build_router, AppState}; -use openpxe_iso_store::{IsoStore, NfsManager}; +use openpxe_iso_store::{IsoStore, LocalDirManager, NfsHostCaps, NfsManager}; use tempfile::tempdir; use tower::ServiceExt; @@ -96,6 +96,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) { let settings = SettingsStore::load_or_default(dir.path()); let nfs = NfsManager::new(dir.path(), iso_store.clone()); iso_store.set_nfs_root(nfs.mount_root()); + let local_dirs = LocalDirManager::new(dir.path(), iso_store.clone()); + let nfs_host_caps = NfsHostCaps::detect(); let log_bus = LogBus::new(64); let hosts = HostBindings::load_or_default(dir.path()); let boot_log = openpxe_core::BootLog::load_or_default(dir.path()); @@ -118,6 +120,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) { metrics, smb: None, nfs, + local_dirs, + nfs_host_caps, uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus, started_at: time::OffsetDateTime::now_utc(), @@ -495,6 +499,96 @@ async fn nfs_list_starts_empty() { assert_eq!(v["mounts"].as_array().unwrap().len(), 0); } +// ── v0.4.65: host kernel caps + local directories ─────────────────────── + +#[tokio::test] +async fn nfs_capabilities_endpoint_returns_a_snapshot() { + // The endpoint must always answer 200 with the shape the UI + // expects, regardless of what `/proc/filesystems` actually says on + // the test host. (CI runs both with and without NFS modules.) + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, b) = get(&app, "/api/nfs/capabilities").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); + assert!(v["available"].is_boolean()); + assert!(v["has_nfs3"].is_boolean()); + assert!(v["has_nfs4"].is_boolean()); + assert!(v["detail"].is_string()); + // Consistency: availability is true iff at least one of the + // booleans is true. + let derived = v["has_nfs3"].as_bool().unwrap() || v["has_nfs4"].as_bool().unwrap(); + assert_eq!(v["available"].as_bool().unwrap(), derived); +} + +#[tokio::test] +async fn local_dirs_list_starts_empty() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, b) = get(&app, "/api/local-dirs").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["directories"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn local_dirs_add_rejects_relative_path() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, b) = post_json( + &app, + "/api/local-dirs", + r#"{"path":"relative/path"}"#, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let msg = String::from_utf8_lossy(&b); + assert!( + msg.to_lowercase().contains("absolute"), + "expected absolute-path hint, got: {msg}" + ); +} + +#[tokio::test] +async fn local_dirs_add_then_remove_round_trip() { + let (state, work_dir) = build_state().await; + let app = build_router(state); + // Use a fresh subdirectory of the test's work_dir as the bind- + // mount stand-in. It exists, is readable, contains no ISOs. + let dir = work_dir.path().join("external-isos"); + std::fs::create_dir(&dir).unwrap(); + let body = format!( + r#"{{"path":"{}","label":"my-isos"}}"#, + dir.display() + ); + let (s, b) = post_json(&app, "/api/local-dirs", &body).await; + assert_eq!(s, StatusCode::CREATED, "add: {}", String::from_utf8_lossy(&b)); + let added: serde_json::Value = serde_json::from_slice(&b).unwrap(); + let id = added["id"].as_str().unwrap().to_string(); + assert_eq!(added["label"], "my-isos"); + assert_eq!(added["iso_count"], 0); + + // List should now show one row. + let (s, b) = get(&app, "/api/local-dirs").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["directories"].as_array().unwrap().len(), 1); + + // Remove (DELETE) clears the row. + let req = Request::builder() + .method("DELETE") + .uri(format!("/api/local-dirs/{id}")) + .body(axum::body::Body::empty()) + .unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let (s, b) = get(&app, "/api/local-dirs").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["directories"].as_array().unwrap().len(), 0); +} + #[tokio::test] async fn terminal_help_and_status_round_trip() { let (state, _dir) = build_state().await; diff --git a/crates/iso-store/src/lib.rs b/crates/iso-store/src/lib.rs index ebaabe6..cdd113b 100644 --- a/crates/iso-store/src/lib.rs +++ b/crates/iso-store/src/lib.rs @@ -18,6 +18,7 @@ pub mod entry; pub mod introspect; +pub mod local_dir; pub mod nfs; pub mod pxe_logo; pub mod smb; @@ -26,7 +27,8 @@ pub mod windows; pub use entry::{BootEntry, BootKind, KernelArgs}; pub use introspect::{DistroFamily, IntrospectionReport}; -pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion}; +pub use local_dir::{LocalDirAddRequest, LocalDirManager, LocalDirSpec}; +pub use nfs::{NfsAddRequest, NfsHostCaps, NfsManager, NfsMount, NfsVersion}; pub use smb::{extract_windows_iso, SmbManager, SmbState}; pub use store::{ generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, diff --git a/crates/iso-store/src/local_dir.rs b/crates/iso-store/src/local_dir.rs new file mode 100644 index 0000000..37b6b9f --- /dev/null +++ b/crates/iso-store/src/local_dir.rs @@ -0,0 +1,515 @@ +//! Local-directory ISO source. +//! +//! v0.4.65: operators on container hosts that lack kernel NFS client +//! modules (Unraid is the dominant case) can't successfully `mount -t +//! nfs ...` inside the container regardless of `CAP_SYS_ADMIN` — +//! mount(2) returns EOPNOTSUPP or EINVAL, and nfs-utils translates that +//! into the deeply unhelpful `mount.nfs: failed to apply fstab options` +//! message we wired diagnostics for in v0.4.64. +//! +//! The pragmatic workaround used by Bootimus, iVentoy, FOG, MAAS, and +//! pretty much every other PXE/imaging tool is: don't try to mount +//! network storage inside the PXE server. Instead the *host* mounts the +//! remote share (Unraid's Unassigned Devices plugin, Synology's File +//! Station, mount.nfs at the OS level, …) and *bind-mounts* the +//! resulting local path into the container. The PXE server then reads +//! ISOs from a regular directory on disk — no protocol work, no +//! capabilities, no kernel module dependency. +//! +//! `LocalDirManager` is the in-container side of that workflow. The +//! operator pastes the path of a bind-mounted directory; we validate it +//! exists and is readable, walk it for `*.iso` files, register each one +//! with the `IsoStore` as an `IsoSource::LocalDir` entry, and persist +//! the spec to `/local_dirs.json` so the relationship survives +//! restarts. +//! +//! The intentional differences from `NfsManager`: +//! +//! - **No mount step.** The path must already be a directory; we never +//! shell out to `mount`. Any failure mode is a clean operator error +//! surfaced in the UI. +//! - **Path is the identity.** We slugify the absolute path so the +//! operator can re-add the same directory idempotently. (Two +//! directories with the same trailing component get distinct ids +//! because the full path goes into the slug.) +//! - **No password complexity.** Bytes live outside OpenPXE's control; +//! we don't try to gate them with the per-ISO password feature. +//! +//! Re-scans on startup are best-effort: if the bind mount was removed +//! before the container came back up, the directory will fail +//! validation, we log a warning, and surface the error in the UI row. +//! The spec itself stays persisted so a re-introduced bind mount picks +//! up where it left off. + +use crate::introspect::{introspect, IntrospectionReport}; +use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; +use openpxe_core::{Error, Result}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use time::OffsetDateTime; + +/// One operator-registered directory. The id is a slug of the absolute +/// path so an operator who pastes the same path twice gets idempotent +/// behaviour rather than a duplicate row. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalDirSpec { + pub id: String, + /// Absolute path inside the container. Validated to be a directory + /// on every scan; never traversed for shell-special characters + /// because we only ever read from it (no command construction). + pub path: PathBuf, + /// Optional friendly name for the UI row. Defaults to the trailing + /// path component when not provided. + pub label: String, + /// Most recent error encountered scanning this directory, or `None` + /// on success. Mirrors the `last_error` / `last_hint` pair on + /// `NfsMount` so the UI can render local-dir issues the same way it + /// renders NFS-mount issues. + pub last_error: Option, + pub last_hint: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub last_scan: Option, + /// Number of `*.iso` files registered from this directory on the + /// most recent scan. + pub iso_count: u32, +} + +/// Submission from the UI / API. +#[derive(Debug, Clone, Deserialize)] +pub struct LocalDirAddRequest { + pub path: String, + #[serde(default)] + pub label: Option, +} + +#[derive(Debug, Default)] +struct Inner { + dirs: HashMap, +} + +/// Manages bind-mounted host directories and surfaces their ISOs. +/// +/// Cheap to clone — internal state is behind `Arc>`. +#[derive(Debug, Clone)] +pub struct LocalDirManager { + state_path: Arc, + inner: Arc>, + iso_store: IsoStore, +} + +impl LocalDirManager { + /// Construct a manager that persists state to + /// `/local_dirs.json`. Mount points are not under our + /// control — the bind mount is supplied by the operator's container + /// runtime — so unlike `NfsManager` we don't take a `work_dir` for + /// our own filesystem state besides this single JSON file. + #[must_use] + pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self { + Self { + state_path: Arc::new(work_dir.join("local_dirs.json")), + inner: Arc::new(Mutex::new(Inner::default())), + iso_store, + } + } + + /// Load persisted state and rescan every directory. Errors per + /// directory are logged and surfaced on the spec; the call itself + /// never fails — startup must not block on a single missing + /// bind mount. + pub async fn load_and_rescan(&self) -> Result<()> { + let specs = match tokio::fs::read_to_string(self.state_path.as_path()).await { + Ok(text) => serde_json::from_str::>(&text).unwrap_or_default(), + Err(_) => Vec::new(), + }; + for mut spec in specs { + spec.last_error = None; + spec.last_hint = None; + self.inner.lock().dirs.insert(spec.id.clone(), spec.clone()); + // Register the mapping early so even a failed rescan keeps + // any path resolution wired up correctly. + self.iso_store + .register_local_dir(spec.id.clone(), spec.path.clone()); + if let Err(e) = self.rescan_inner(&spec.id).await { + tracing::warn!( + target: "openpxe::local_dir", + id = %spec.id, path = %spec.path.display(), + "rescan on startup failed: {e}" + ); + } + } + Ok(()) + } + + /// Add or refresh a directory. Validates the path is absolute, + /// exists, and is a directory. On success the directory's ISOs are + /// registered with the `IsoStore` and the spec is persisted. + pub async fn add(&self, req: LocalDirAddRequest) -> Result { + let path = PathBuf::from(req.path.trim()); + if path.as_os_str().is_empty() { + return Err(Error::Invalid("path is required".into())); + } + if !path.is_absolute() { + return Err(Error::Invalid(format!( + "path must be absolute (got '{}'); bind-mount the source on \ + the host then enter the path inside the container", + path.display() + ))); + } + let meta = match tokio::fs::metadata(&path).await { + Ok(m) => m, + Err(e) => { + return Err(Error::Invalid(format!( + "path '{}' is not accessible: {e}. Verify the bind mount \ + exists inside the container", + path.display() + ))); + } + }; + if !meta.is_dir() { + return Err(Error::Invalid(format!( + "path '{}' exists but is not a directory", + path.display() + ))); + } + + let id = slugify_str(&path.display().to_string()); + let label = req + .label + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| { + path.file_name() + .and_then(|s| s.to_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| id.clone()); + + let spec = LocalDirSpec { + id: id.clone(), + path: path.clone(), + label, + last_error: None, + last_hint: None, + last_scan: None, + iso_count: 0, + }; + self.inner.lock().dirs.insert(id.clone(), spec); + self.iso_store.register_local_dir(id.clone(), path); + self.persist_locked(); + self.rescan_inner(&id).await?; + Ok(self.get(&id).expect("just inserted")) + } + + /// Remove a directory: drops every ISO sourced from it and forgets + /// the spec. Idempotent. + /// + /// Kept `async` for symmetry with `NfsManager::remove` (which does + /// shell out to `umount`) so the HTTP handlers can treat both + /// managers identically. The body is sync today; a future audit + /// log or scrub-on-remove I/O step would plug in here without a + /// signature change. + #[allow(clippy::unused_async)] + pub async fn remove(&self, id: &str) -> Result<()> { + self.inner.lock().dirs.remove(id); + self.iso_store.unregister_local_dir(id); + self.iso_store.drop_local_dir_source(id); + self.persist_locked(); + Ok(()) + } + + /// Manually rescan a directory — picks up newly-added ISOs without + /// removing & re-adding the directory. + pub async fn rescan(&self, id: &str) -> Result { + self.rescan_inner(id).await + } + + /// Snapshot of every registered directory, sorted by id for stable + /// UI rendering. + #[must_use] + pub fn list(&self) -> Vec { + let g = self.inner.lock(); + let mut v: Vec<_> = g.dirs.values().cloned().collect(); + v.sort_by(|a, b| a.id.cmp(&b.id)); + v + } + + /// Look up one spec by id. + #[must_use] + pub fn get(&self, id: &str) -> Option { + self.inner.lock().dirs.get(id).cloned() + } + + // ── internals ───────────────────────────────────────────────────── + + async fn rescan_inner(&self, id: &str) -> Result { + let spec = self + .get(id) + .ok_or_else(|| Error::Invalid(format!("no such directory '{id}'")))?; + let now = OffsetDateTime::now_utc(); + + // Drop prior entries first so a deleted file disappears from the + // store on the next rescan. Mirrors NfsManager::scan_and_register. + self.iso_store.drop_local_dir_source(id); + + let mut walker = match tokio::fs::read_dir(&spec.path).await { + Ok(w) => w, + Err(e) => { + let err = format!("cannot read directory: {e}"); + let hint = Some( + "Verify the bind mount still exists inside the container \ + and that the openpxe user can read it (uid 10001)." + .to_string(), + ); + self.update_status(id, 0, Some(err.clone()), hint, now); + return Err(Error::Invalid(err)); + } + }; + + let mut count = 0u32; + loop { + let entry = match walker.next_entry().await { + Ok(Some(e)) => e, + Ok(None) => break, + Err(e) => { + let err = format!("read_dir iteration failed: {e}"); + self.update_status(id, count, Some(err.clone()), None, now); + return Err(Error::Invalid(err)); + } + }; + let p = entry.path(); + if p.extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + != Some("iso") + { + continue; + } + let filename = match p.file_name().and_then(|s| s.to_str()) { + Some(f) => f.to_string(), + None => continue, + }; + let size = match tokio::fs::metadata(&p).await { + Ok(m) => m.len(), + Err(e) => { + tracing::warn!( + target: "openpxe::local_dir", + id = %id, file = %filename, "stat failed: {e}" + ); + continue; + } + }; + let p_owned = p.clone(); + let report: IntrospectionReport = + match tokio::task::spawn_blocking(move || introspect(&p_owned)).await { + Ok(r) => r, + Err(e) => { + tracing::warn!( + target: "openpxe::local_dir", + id = %id, file = %filename, + "introspection task panicked: {e}" + ); + continue; + } + }; + let iso_id = format!("local-{id}-{}", slugify_str(&filename)); + let boot_entries = generate_boot_entries_for(&iso_id, &filename, &report); + let source = IsoSource::LocalDir { + dir_id: id.to_string(), + relative_path: filename.clone(), + }; + self.iso_store.register_external( + iso_id, + filename, + size, + report, + boot_entries, + source, + ); + count += 1; + } + + self.update_status(id, count, None, None, now); + tracing::info!( + target: "openpxe::local_dir", + id = %id, path = %spec.path.display(), iso_count = count, + "local directory rescanned" + ); + Ok(count) + } + + fn update_status( + &self, + id: &str, + iso_count: u32, + err: Option, + hint: Option, + ts: OffsetDateTime, + ) { + if let Some(s) = self.inner.lock().dirs.get_mut(id) { + s.iso_count = iso_count; + s.last_error = err; + s.last_hint = hint; + s.last_scan = Some(ts); + } + self.persist_locked(); + } + + /// Atomically replace the on-disk JSON with the current state. + /// Errors are logged, never propagated — settings live in memory + /// authoritatively, matching every other store in this crate. + fn persist_locked(&self) { + let dirs: Vec = self.inner.lock().dirs.values().cloned().collect(); + let path = self.state_path.as_path(); + let tmp = path.with_extension("json.tmp"); + let body = match serde_json::to_vec_pretty(&dirs) { + Ok(b) => b, + Err(e) => { + tracing::warn!(target: "openpxe::local_dir", "serialize: {e}"); + return; + } + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(&tmp, body) { + tracing::warn!(target: "openpxe::local_dir", "write tmp: {e}"); + return; + } + if let Err(e) = std::fs::rename(&tmp, path) { + tracing::warn!(target: "openpxe::local_dir", "rename: {e}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn make_manager() -> (LocalDirManager, IsoStore, tempfile::TempDir) { + let tmp = tempdir().unwrap(); + let iso = IsoStore::new(tmp.path().join("isos")); + let m = LocalDirManager::new(tmp.path(), iso.clone()); + (m, iso, tmp) + } + + #[tokio::test] + async fn rejects_relative_paths() { + let (m, _iso, _tmp) = make_manager(); + let err = m + .add(LocalDirAddRequest { + path: "relative/path".into(), + label: None, + }) + .await + .unwrap_err(); + assert!(format!("{err}").to_lowercase().contains("absolute")); + } + + #[tokio::test] + async fn rejects_missing_paths() { + let (m, _iso, _tmp) = make_manager(); + let err = m + .add(LocalDirAddRequest { + path: "/nonexistent/path/should/not/be/here".into(), + label: None, + }) + .await + .unwrap_err(); + let s = format!("{err}").to_lowercase(); + assert!(s.contains("not accessible") || s.contains("bind mount")); + } + + #[tokio::test] + async fn rejects_files_that_are_not_directories() { + let (m, _iso, tmp) = make_manager(); + let file = tmp.path().join("a_file.txt"); + std::fs::write(&file, b"hi").unwrap(); + let err = m + .add(LocalDirAddRequest { + path: file.to_string_lossy().into_owned(), + label: None, + }) + .await + .unwrap_err(); + assert!(format!("{err}").to_lowercase().contains("not a directory")); + } + + #[tokio::test] + async fn empty_directory_registers_with_zero_isos() { + let (m, _iso, tmp) = make_manager(); + let empty = tmp.path().join("empty"); + std::fs::create_dir(&empty).unwrap(); + let spec = m + .add(LocalDirAddRequest { + path: empty.to_string_lossy().into_owned(), + label: Some("My empty dir".into()), + }) + .await + .unwrap(); + assert_eq!(spec.iso_count, 0); + assert_eq!(spec.label, "My empty dir"); + assert!(spec.last_error.is_none()); + } + + #[tokio::test] + async fn label_defaults_to_trailing_path_component() { + let (m, _iso, tmp) = make_manager(); + let d = tmp.path().join("my-isos"); + std::fs::create_dir(&d).unwrap(); + let spec = m + .add(LocalDirAddRequest { + path: d.to_string_lossy().into_owned(), + label: None, + }) + .await + .unwrap(); + assert_eq!(spec.label, "my-isos"); + } + + #[tokio::test] + async fn re_adding_same_path_is_idempotent() { + let (mgr, _iso, tmp) = make_manager(); + let dir = tmp.path().join("re-add"); + std::fs::create_dir(&dir).unwrap(); + let path = dir.to_string_lossy().into_owned(); + let first = mgr + .add(LocalDirAddRequest { + path: path.clone(), + label: None, + }) + .await + .unwrap(); + let second = mgr + .add(LocalDirAddRequest { path, label: None }) + .await + .unwrap(); + assert_eq!(first.id, second.id); + assert_eq!(mgr.list().len(), 1); + } + + #[tokio::test] + async fn remove_drops_the_directory_and_clears_iso_store_path() { + let (m, iso, tmp) = make_manager(); + let d = tmp.path().join("to-remove"); + std::fs::create_dir(&d).unwrap(); + let spec = m + .add(LocalDirAddRequest { + path: d.to_string_lossy().into_owned(), + label: None, + }) + .await + .unwrap(); + m.remove(&spec.id).await.unwrap(); + assert!(m.get(&spec.id).is_none()); + // After unregister, the IsoStore can no longer resolve a path + // through this dir_id — confirms the mapping was dropped. + assert!(iso + .iso_path_for(&format!("local-{}-anything", spec.id)) + .is_none()); + } +} diff --git a/crates/iso-store/src/nfs.rs b/crates/iso-store/src/nfs.rs index eecf004..5e7d9dc 100644 --- a/crates/iso-store/src/nfs.rs +++ b/crates/iso-store/src/nfs.rs @@ -73,6 +73,73 @@ use tokio::process::Command; /// pre-flight probe and the option string assembly use the same value. const DEFAULT_NFS_PORT: u16 = 2049; +/// What the host kernel can do, from the container's point of view. +/// +/// v0.4.65: we read this once at startup so the Storage tab can show +/// a prominent banner when in-container NFS mounts simply cannot +/// succeed regardless of CAP_SYS_ADMIN — the dominant case being +/// Unraid, whose base kernel ships without the `nfs` / `nfsv4` client +/// modules loaded. There's nothing the operator can do from inside the +/// container in that scenario; the right answer is to mount the share +/// on the host and bind-mount the path into OpenPXE (see +/// `LocalDirManager`). +/// +/// `available` is `true` if `/proc/filesystems` contains *either* `nfs` +/// or `nfs4`. `raw` is the matched lines (or a short marker), retained +/// so we can show "what we actually saw" in the UI for support. +#[derive(Debug, Clone, Serialize)] +pub struct NfsHostCaps { + pub available: bool, + pub has_nfs3: bool, + pub has_nfs4: bool, + /// `/proc/filesystems` lines we matched, joined with `\n`. Empty + /// when nothing matched, `"unreadable"` if the probe itself failed. + pub detail: String, +} + +impl NfsHostCaps { + /// Probe `/proc/filesystems` for NFS client filesystem support. + /// Pure read, no side effects — safe to call repeatedly. Reads + /// synchronously because `/proc/filesystems` is virtual and never + /// blocks. + #[must_use] + pub fn detect() -> Self { + let Ok(raw) = std::fs::read_to_string("/proc/filesystems") else { + return Self { + available: false, + has_nfs3: false, + has_nfs4: false, + detail: "unreadable".into(), + }; + }; + // Each line is "nodev\tname" or "\tname". We tokenize on + // whitespace and check the last token. + let mut has_nfs3 = false; + let mut has_nfs4 = false; + let mut matched: Vec<&str> = Vec::new(); + for line in raw.lines() { + let name = line.split_whitespace().last().unwrap_or(""); + match name { + "nfs" | "nfs3" => { + has_nfs3 = true; + matched.push(line.trim()); + } + "nfs4" | "nfsv4" => { + has_nfs4 = true; + matched.push(line.trim()); + } + _ => {} + } + } + Self { + available: has_nfs3 || has_nfs4, + has_nfs3, + has_nfs4, + detail: matched.join("\n"), + } + } +} + /// How long to wait for a TCP connection to the NFS server before /// declaring it unreachable. Short enough that a wrong IP doesn't make /// the UI hang for half a minute; long enough that a slow appliance @@ -981,4 +1048,15 @@ mod tests { "mount.nfs: access denied" )); } + + #[test] + fn nfs_host_caps_detect_is_consistent() { + // The detect() probe should never panic and the boolean + // accessors should agree with each other. + let c = NfsHostCaps::detect(); + assert_eq!(c.available, c.has_nfs3 || c.has_nfs4); + // Whatever the result, `detail` is always a string we can + // present in the UI (possibly empty, possibly "unreadable"). + let _ = c.detail; + } } diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index 20b2c3e..2c6c39f 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -17,8 +17,15 @@ use tokio::io::AsyncWriteExt; /// /// The default is `Local` — uploaded ISOs sit in `/.iso`. /// `Nfs` entries point at a file inside a remote share that the -/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily -/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup. +/// `NfsManager` is keeping mounted. `LocalDir` (v0.4.65) entries point +/// at a file inside an operator-registered directory that's +/// **bind-mounted into the container** from the host — the workaround +/// for environments (Unraid, OpenShift restricted SCC) where the host +/// kernel doesn't have NFS client modules loaded and in-container +/// mounts can't succeed regardless of CAP_SYS_ADMIN. +/// +/// We resolve every variant's on-disk path lazily in +/// [`IsoStore::iso_path_for`]. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum IsoSource { @@ -29,6 +36,13 @@ pub enum IsoSource { /// Path relative to the mount point — typically just the filename. relative_path: String, }, + /// v0.4.65: operator-registered host directory bind-mounted into the + /// container. Path resolution looks up `dir_id` in the IsoStore's + /// `local_dir_roots` map, then joins `relative_path`. + LocalDir { + dir_id: String, + relative_path: String, + }, } /// Where the ISO lands in the PXE menu hierarchy. @@ -168,6 +182,10 @@ pub struct IsoStore { /// [`IsoStore::set_nfs_root`]; required for resolving any /// `IsoSource::Nfs` entry. nfs_root: Arc>>, + /// v0.4.65: map of operator-registered directory id → absolute path + /// inside the container. Used to resolve `IsoSource::LocalDir` + /// entries to a real on-disk path. Maintained by `LocalDirManager`. + local_dir_roots: Arc>>, inner: Arc>, } @@ -176,10 +194,26 @@ impl IsoStore { Self { iso_dir: Arc::new(iso_dir), nfs_root: Arc::new(RwLock::new(None)), + local_dir_roots: Arc::new(RwLock::new(HashMap::new())), inner: Arc::new(RwLock::new(Inner::default())), } } + /// v0.4.65: register the bind-mounted host path under `dir_id` so + /// `iso_path_for` can resolve `IsoSource::LocalDir` entries. Called + /// by `LocalDirManager` when a directory is added or reloaded from + /// persisted state. + pub fn register_local_dir(&self, dir_id: String, path: PathBuf) { + self.local_dir_roots.write().insert(dir_id, path); + } + + /// v0.4.65: drop the path mapping for `dir_id`. Existing + /// `IsoSource::LocalDir` entries referencing this id will start + /// resolving to `None` from `iso_path_for`. + pub fn unregister_local_dir(&self, dir_id: &str) { + self.local_dir_roots.write().remove(dir_id); + } + /// Tell the store where NFS mounts live. Without this set, /// `IsoSource::Nfs` entries cannot be resolved to a file path. pub fn set_nfs_root(&self, root: PathBuf) { @@ -297,6 +331,19 @@ impl IsoStore { let root = self.nfs_root.read().clone()?; root.join(mount_id).join(relative_path) } + // v0.4.65: bind-mounted host directory. We look the dir_id + // up in local_dir_roots which is maintained by + // LocalDirManager. If LocalDirManager hasn't loaded yet (or + // the operator removed the directory) the resolution + // returns None and the HTTP handler 404s, same as a missing + // NFS mount. + IsoSource::LocalDir { + dir_id, + relative_path, + } => { + let root = self.local_dir_roots.read().get(dir_id).cloned()?; + root.join(relative_path) + } }; if path.exists() { Some(path) @@ -362,6 +409,16 @@ impl IsoStore { ); } + /// v0.4.65: drop every entry that belongs to a registered local + /// directory. Used by `LocalDirManager` when an operator removes a + /// directory or before re-scanning to clean out stale entries. + pub fn drop_local_dir_source(&self, dir_id: &str) { + let mut g = self.inner.write(); + g.isos.retain( + |_, m| !matches!(&m.source, IsoSource::LocalDir { dir_id: did, .. } if did == dir_id), + ); + } + /// Set or clear an ISO's boot password. /// /// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index a721973..654eb2f 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -9,7 +9,7 @@ use openpxe_core::{ }; use openpxe_dhcp_proxy::DhcpProxyServer; use openpxe_http_api::{build_router, AppState}; -use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; +use openpxe_iso_store::{IsoStore, LocalDirManager, NfsHostCaps, NfsManager, SmbManager}; use openpxe_tftp::TftpServer; use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; @@ -128,6 +128,39 @@ async fn main() -> anyhow::Result<()> { tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}"); } + // v0.4.65: snapshot host kernel capabilities so the Storage tab can + // warn the operator early when in-container NFS mounts cannot + // possibly succeed (Unraid is the dominant case — its base kernel + // ships without nfs/nfsv4 client modules loaded). Pure read of + // /proc/filesystems; no side effects. + let nfs_host_caps = NfsHostCaps::detect(); + if nfs_host_caps.available { + tracing::info!( + target: "openpxe::nfs", + v3 = nfs_host_caps.has_nfs3, v4 = nfs_host_caps.has_nfs4, + "host kernel NFS client support" + ); + } else { + tracing::warn!( + target: "openpxe::nfs", + detail = %nfs_host_caps.detail, + "host kernel lacks NFS client modules — in-container `mount -t nfs` will fail \ + regardless of CAP_SYS_ADMIN. Use Local Directories instead." + ); + } + + // v0.4.65: bind-mounted host directory manager. Provides the + // container-friendly ISO source that doesn't depend on kernel NFS + // support — the operator mounts the share on the host, bind-mounts + // the path into the container, and registers it here. + let local_dirs = LocalDirManager::new(&config.paths.work_dir, iso_store.clone()); + if let Err(e) = local_dirs.load_and_rescan().await { + tracing::warn!( + target: "openpxe::local_dir", + "could not reload local directories: {e}" + ); + } + // Sniff network details for the Network tab. None of these are // required for PXE to work — they're informational, surfaced in the // UI so an operator doesn't have to drop to a shell to find their @@ -153,6 +186,8 @@ async fn main() -> anyhow::Result<()> { metrics: metrics.clone(), smb: Some(smb.clone()), nfs: nfs.clone(), + local_dirs: local_dirs.clone(), + nfs_host_caps: nfs_host_caps.clone(), uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus: log_bus.clone(), started_at: time::OffsetDateTime::now_utc(), diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 3f0feea..e1b790c 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -342,13 +342,21 @@ }, storage: async () => { - const [isos, settings, nfsRes, disk] = await Promise.all([ + // v0.4.65: also fetch host NFS caps and local-dir registrations. + // Caps tell us whether kernel mounts can possibly work; local + // dirs are the bind-mount workaround for hosts that can't. + const [isos, settings, nfsRes, disk, caps, localRes] = await Promise.all([ getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'), getJSON('/api/storage/disk').catch(() => ({ total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', })), + getJSON('/api/nfs/capabilities').catch(() => ({ + available: true, has_nfs3: true, has_nfs4: true, detail: '', + })), + getJSON('/api/local-dirs').catch(() => ({ directories: [] })), ]); const mounts = nfsRes.mounts || []; + const localDirs = localRes.directories || []; // ── Upload card ── const drop = el('div', {class:'drop', id:'drop'}, [ @@ -459,6 +467,8 @@ isos.forEach(i => { const b = bootability(i, settings); const isNfs = i.source && i.source.kind === 'nfs'; + // v0.4.65: third source kind — bind-mounted host directory. + const isLocalDir = i.source && i.source.kind === 'local_dir'; const protectedNow = !!i.password_hash; // The inline editor row is hidden by default; the Password @@ -578,8 +588,9 @@ ]), el('td', {class:'num'}, fmtBytes(i.size_bytes)), el('td', {}, - el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')}, - isNfs ? ('nfs:' + i.source.mount_id) : 'local')), + el('span', {class:'src-badge' + (isNfs ? ' nfs' : (isLocalDir ? ' nfs' : ''))}, + isNfs ? ('nfs:' + i.source.mount_id) + : (isLocalDir ? ('dir:' + i.source.dir_id) : 'local'))), el('td', {}, protectedNow ? el('span', {class:'tag accent'}, 'protected') @@ -591,11 +602,17 @@ }}, protectedNow ? 'Password ✎' : 'Set password'), isNfs ? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS') - : el('button', {class:'danger', onclick: async () => { - if (!confirm('Remove this image?')) return; - await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'}); - render('storage'); - }}, 'Remove'), + : (isLocalDir + // v0.4.65: bytes live in an operator-managed + // bind-mounted directory; OpenPXE shouldn't try to + // delete files it didn't create. The next directory + // re-scan would re-register them anyway. + ? el('span', {class:'tag', style:'opacity:.6'}, 'on disk') + : el('button', {class:'danger', onclick: async () => { + if (!confirm('Remove this image?')) return; + await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'}); + render('storage'); + }}, 'Remove')), ]), ]); rowsAndEditors.push(tr, editorRow); @@ -685,6 +702,95 @@ el('span'), ])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')]; + // ── v0.4.65: Local directories (bind-mount workaround) ── + // For hosts (Unraid is the dominant case) where the kernel + // doesn't have NFS client modules loaded, in-container NFS + // mounts simply can't succeed. The pragmatic answer is the same + // one Bootimus / iVentoy / FOG use: mount the remote storage on + // the *host* then bind-mount the path into the container. + // OpenPXE reads from a regular directory — no protocol work, no + // capabilities, no kernel module dependency. + const ldPath = el('input', {type:'text', placeholder:'/mnt/external-isos'}); + const ldLabel = el('input', {type:'text', placeholder:'optional friendly name'}); + const ldMsg = el('div', {class:'msg'}); + const addLocalDir = el('button', {onclick: async () => { + if (!ldPath.value) { + ldMsg.replaceChildren(document.createTextNode('Path is required.')); + ldMsg.className='msg err'; return; + } + ldMsg.replaceChildren(document.createTextNode('Adding…')); + ldMsg.className='msg'; + const r = await postJSON('/api/local-dirs', { + path: ldPath.value, label: ldLabel.value || null, + }); + if (r.ok) { + ldMsg.replaceChildren(document.createTextNode('Added.')); + ldMsg.className='msg ok'; + render('storage'); + } else { + const t = await r.text(); + ldMsg.replaceChildren( + el('div', {}, [ + el('strong', {}, 'Add failed: '), + document.createTextNode(t), + ]), + ); + ldMsg.className='msg err'; + } + }}, 'Add directory'); + + const ldRows = localDirs.length ? localDirs.map(d => el('div', {class: 'nfs-row' + (d.last_error ? ' down' : '')}, [ + el('span', {class: 'dot ' + (d.last_error ? 'err' : 'ok')}), + el('div', {}, [ + el('div', {class:'id'}, d.label + ' · ' + d.path), + el('div', {class:'meta'}, + 'local · ' + + (d.iso_count != null ? d.iso_count + ' isos' : 'never scanned')), + d.last_error ? el('div', {class:'err'}, '⚠ ' + d.last_error) : null, + d.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, d.last_hint) : null, + ]), + el('button', {class:'ghost', onclick: async () => { + const r = await postJSON('/api/local-dirs/' + encodeURIComponent(d.id) + '/scan', {}); + if (r.ok) render('storage'); + }}, 'Re-scan'), + el('button', {class:'danger', onclick: async () => { + if (!confirm('Remove ' + d.path + '? ISOs from this directory will disappear from the menu.')) return; + await fetch('/api/local-dirs/' + encodeURIComponent(d.id), {method:'DELETE'}); + render('storage'); + }}, 'Remove'), + el('span'), + ])) : [el('div', {class:'empty'}, 'No local directories registered.')]; + + const localDirCard = el('div', {class:'card'}, [ + el('header', {}, [ + el('h2', {}, 'Local directories'), + el('span', {class:'sub'}, + localDirs.length + ' registered' + + (!caps.available ? ' · recommended on this host' : '')), + ]), + el('div', {class:'body'}, [ + el('p', {class:'msg', style:'margin-bottom:14px'}, + 'Bind-mount a host directory into the container (' + + 'e.g. -v /mnt/user/isos:/mnt/external-isos), then enter the ' + + 'in-container path here. OpenPXE will scan it for ISOs and ' + + 'surface them next to uploaded and NFS-mounted images. No ' + + 'CAP_SYS_ADMIN required — this is the container-friendly ' + + 'workaround when kernel NFS mounts can\'t work.'), + el('div', {class:'form-row'}, [ + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'Path (inside container)'), + ldPath, + ]), + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'Label (optional)'), + ldLabel, + ]), + ]), + addLocalDir, ldMsg, + el('div', {style:'margin-top:18px;display:grid;gap:8px'}, ldRows), + ]), + ]); + // Disk-space card. Free + used + total for the volume hosting the // ISO directory, with a coloured bar. Warns at 80% and goes red at // 95% so the operator sees the runway shrinking before uploads @@ -737,6 +843,24 @@ el('span', {class:'sub'}, mounts.length + ' configured'), ]), el('div', {class:'body'}, [ + // v0.4.65: if the host kernel doesn't have NFS client + // modules loaded (Unraid is the dominant case), in- + // container mounts will fail regardless of capabilities or + // privileged mode — there's nothing the operator can do + // from inside the container. Surface this prominently and + // point at the bind-mount workaround below. + !caps.available + ? el('div', {class:'msg err', style:'margin-bottom:14px'}, [ + el('div', {}, [ + el('strong', {}, 'Host kernel has no NFS client support.'), + document.createTextNode(' Mounting from this container will fail no matter what capabilities you grant it.'), + ]), + el('div', {style:'margin-top:6px;opacity:.85;font-size:12px'}, + 'Unraid is the most common case — its base kernel ships without the nfs/nfsv4 modules. ' + + 'Mount the share on the host (Unassigned Devices plugin, /etc/fstab, etc.), ' + + 'then bind-mount the resulting path into this container and register it as a Local directory below.'), + ]) + : null, el('div', {class:'form-row'}, [ el('label', {class:'field'}, [ el('span', {class:'name'}, 'NFS server'), @@ -757,12 +881,14 @@ addNfs, nfsMsg, el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows), el('p', {class:'msg', style:'margin-top:14px'}, - 'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' + - 'mount.nfs binary (bundled in the default Docker image). On ' + - 'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' + - 'NFS mounts as a CSI driver outside the pod.'), + 'Mounting NFS inside a container requires CAP_SYS_ADMIN, the ' + + 'mount.nfs binary (bundled in the default Docker image), and ' + + 'the host kernel having NFS client modules loaded. If any of ' + + 'these are missing, use Local directories below as a workaround.'), ]), ]), + // v0.4.65: Local directories — the bind-mount workaround. + localDirCard, el('div', {class:'card'}, [ el('header', {}, [ el('h2', {}, 'Available images'),