v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)

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 <work_dir>/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:<id>" 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) <[email protected]>
This commit is contained in:
Miles Ward
2026-05-28 03:09:43 -04:00
co-authored by Claude Opus 4.7
parent 0afbe860e8
commit 761489761c
12 changed files with 1027 additions and 29 deletions
+80 -2
View File
@@ -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<serde_json::Value> {
{"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<AppState>, AxumPath(id): AxumPath<Stri
}
}
// ─── v0.4.65: host-kernel NFS capability probe ─────────────────────────────
async fn api_nfs_capabilities(State(state): State<AppState>) -> Json<serde_json::Value> {
// 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<AppState>) -> Json<serde_json::Value> {
Json(json!({ "directories": state.local_dirs.list() }))
}
async fn api_local_dirs_add(
State(state): State<AppState>,
Json(req): Json<LocalDirAddRequest>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<AppState>) -> Json<serde_json::Value> {
+12 -1
View File
@@ -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.
+2
View File
@@ -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,
+95 -1
View File
@@ -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;