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
+59 -2
View File
@@ -17,8 +17,15 @@ use tokio::io::AsyncWriteExt;
///
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.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<RwLock<Option<PathBuf>>>,
/// 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<RwLock<HashMap<String, PathBuf>>>,
inner: Arc<RwLock<Inner>>,
}
@@ -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