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
+138 -12
View File
@@ -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'),