v0.4.65: swap kernel-mount NFS for userspace SMB (smbclient)

v0.4.64's NFS path didn't work on Unraid even with --privileged
because Unraid's base kernel ships without the nfs/nfsv4 client
modules — and no container-side configuration can load a host kernel
module. SMB has the same kernel-mount problem (`mount -t cifs` needs
the cifs module) but it also has a usable *userspace* client: Samba's
`smbclient` CLI, which speaks the SMB protocol over a plain TCP socket
with no kernel involvement. This is the same approach Bootimus uses,
and works in every container regardless of host kernel modules or
container capabilities.

What's gone:

* `crates/iso-store/src/nfs.rs` (in entirety)
* `NfsManager`, `NfsMount`, `NfsAddRequest`, `NfsVersion` types
* `IsoSource::Nfs` variant
* `IsoStore::nfs_root` / `IsoStore::set_nfs_root`
* `/api/nfs`, `/api/nfs/:id`, `/api/nfs/:id/scan` routes
* `nfs` terminal command
* Storage tab's NFS shares card and the v0.4.64 fstab-options
  diagnostics work (the whole error path is moot now)

What's new:

* `crates/iso-store/src/smb_share.rs` — `SmbShareManager` that drives
  `smbclient` as a subprocess. Indexes shares via `smbclient -c "ls
  *.iso"` and streams files via `smbclient -c "get file -"` piped
  straight into HTTP response bodies. No local cache, no double disk
  usage.
* `IsoSource::Smb { share_id, relative_path }` variant.
* `IsoStore::iso_path_for` returns None for SMB sources — the HTTP
  ISO download handler dispatches on the source kind and streams via
  the SmbShareManager when it's SMB.
* `/api/smb-shares` + `/api/smb-shares/:id` + `/api/smb-shares/:id/scan`
  routes.
* `share` terminal command (`list | add //srv/share [auth] | remove |
  scan`). Auth spec is `guest` or `user:password`.
* Storage tab: SMB shares card replaces the NFS one. Two-column form
  for server + share name, three-column form for guest checkbox /
  username / password. Username and password fields auto-disable when
  Guest is checked.
* Credentials live under <work_dir>/smb_creds/<id>.cred at 0600
  permissions so they don't leak through `ps`. Persisted state at
  <work_dir>/smb_shares.json (sans password — re-entered on add /
  re-scan).

Why subprocess and not a Rust crate:

* The Debian runtime image already ships the `samba` package
  (Dockerfile line 84) — `smbclient` is right there.
* Library options (pavao, etc.) wrap libsmbclient so they still pull
  in the same C library at runtime.
* Subprocess gives operators a verifiable mental model — anything
  OpenPXE can do over SMB, they can reproduce by running `smbclient`
  manually at a shell.

Range-request limitation, called out in the smb_share.rs module docs
and the UI explainer: `smbclient -c 'get file -'` is a sequential
whole-file stream. HTTP range requests on SMB-sourced ISOs return
416. PXE workloads (iPXE chain, casper sanboot, wimboot) do
whole-file sequential reads, so this works in practice. A follow-up
release can add libsmbclient-based seek if a real workload needs it.

Stderr-to-hint translation patterns mirror v0.4.64's NFS work:
NT_STATUS_LOGON_FAILURE → "check credentials", BAD_NETWORK_NAME →
"check share name", connection refused / timeout → "verify
reachability + firewall", etc. UI renders the raw smbclient error
plus the hint as two lines.

Tests (149 total, was 142 in v0.4.64):
* smb_share parser tests covering ISO + skipped directory, filenames
  with spaces, non-ISO filtering.
* hint_for() translation tests for the dominant NT_STATUS codes.
* Server normalization (smb://, cifs://, \\, // prefixes all stripped).
* HTTP integration: shares list starts empty, invalid server / missing
  username / path in share name all rejected with actionable hints.

`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 11:19:47 -04:00
co-authored by Claude Opus 4.7
parent 07e7c18698
commit 900b65b3ec
12 changed files with 1383 additions and 1259 deletions
+117 -79
View File
@@ -167,7 +167,8 @@
el('div', {class: 'trend'},
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
(status.nfs_active || 0) + ' NFS active'),
(status.smb_share_reachable || 0) + ' SMB share' +
((status.smb_share_reachable || 0) === 1 ? '' : 's')),
])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'),
@@ -342,13 +343,18 @@
},
storage: async () => {
const [isos, settings, nfsRes, disk] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
// v0.4.65: kernel-mount NFS replaced with userspace SMB via
// smbclient — works in any container regardless of host kernel
// modules or capabilities. The /api/nfs endpoint is gone;
// /api/smb-shares is the replacement.
const [isos, settings, smbRes, disk] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'),
getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})),
]);
const mounts = nfsRes.mounts || [];
const shares = smbRes.shares || [];
// ── Upload card ──
const drop = el('div', {class:'drop', id:'drop'}, [
@@ -450,7 +456,7 @@
}
}
// ── ISO table (mixed local + NFS) ──
// ── ISO table (mixed local + SMB) ──
// Each row gets a "Password" cell that toggles a small inline
// editor (a checkbox + a password field + Save button) inside the
// *next* row of the table. Keeps the markup flat and avoids the
@@ -458,7 +464,10 @@
const rowsAndEditors = [];
isos.forEach(i => {
const b = bootability(i, settings);
const isNfs = i.source && i.source.kind === 'nfs';
// v0.4.65: SMB userspace consumer replaced NFS. The badge
// colours stay the same so the table looks unchanged for
// existing operators.
const isSmb = i.source && i.source.kind === 'smb';
const protectedNow = !!i.password_hash;
// The inline editor row is hidden by default; the Password
@@ -578,8 +587,8 @@
]),
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' + (isSmb ? ' nfs' : '')},
isSmb ? ('smb:' + i.source.share_id) : 'local')),
el('td', {},
protectedNow
? el('span', {class:'tag accent'}, 'protected')
@@ -589,8 +598,11 @@
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
}}, protectedNow ? 'Password ✎' : 'Set password'),
isNfs
? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS')
isSmb
// v0.4.65: SMB-sourced ISOs live on the remote share —
// OpenPXE doesn't own those bytes. Same pattern as NFS
// had: surface a tag instead of a destructive button.
? el('span', {class:'tag', style:'opacity:.6'}, 'on SMB')
: el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this image?')) return;
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
@@ -610,80 +622,98 @@
])),
el('tbody', {}, rowsAndEditors),
])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
// ── NFS section ──
const nfsMsg = el('div', {class:'msg'});
const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'});
const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'});
const nfsVer = el('select', {}, [
el('option', {value:'v41'}, 'NFSv4.1 (default)'),
el('option', {value:'v3'}, 'NFSv3'),
]);
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
const addNfs = el('button', {onclick: async () => {
if (!nfsServer.value || !nfsExport.value) {
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
nfsMsg.className='msg err'; return;
// ── SMB shares section (v0.4.65) ──
// Replaces the kernel-mount NFS card. SMB shares are consumed
// in userspace via Samba's `smbclient` CLI — no kernel modules,
// no CAP_SYS_ADMIN, works in any container. This is the same
// approach Bootimus uses.
const smbMsg = el('div', {class:'msg'});
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
const smbShare = el('input', {type:'text', placeholder:'isos'});
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
// Toggle username/password fields based on the Guest checkbox so
// operators don't get confused about which fields matter.
const syncAuthDisabled = () => {
smbUser.disabled = smbGuest.checked;
smbPass.disabled = smbGuest.checked;
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
};
smbGuest.addEventListener('change', syncAuthDisabled);
syncAuthDisabled();
const addSmb = el('button', {onclick: async () => {
if (!smbServer.value || !smbShare.value) {
smbMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
smbMsg.className='msg err'; return;
}
nfsMsg.replaceChildren(document.createTextNode('Mounting…'));
nfsMsg.className = 'msg';
const r = await postJSON('/api/nfs', {
server: nfsServer.value, export: nfsExport.value,
version: nfsVer.value, read_only: nfsRo.checked,
});
if (!smbGuest.checked && !smbUser.value) {
smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
smbMsg.className='msg err'; return;
}
smbMsg.replaceChildren(document.createTextNode('Connecting…'));
smbMsg.className = 'msg';
const body = {
server: smbServer.value,
share: smbShare.value,
guest: smbGuest.checked,
};
if (!smbGuest.checked) {
body.username = smbUser.value;
body.password = smbPass.value;
}
const r = await postJSON('/api/smb-shares', body);
if (r.ok) {
nfsMsg.replaceChildren(document.createTextNode('Mounted.'));
nfsMsg.className = 'msg ok';
smbMsg.replaceChildren(document.createTextNode('Connected.'));
smbMsg.className = 'msg ok';
render('storage');
} else {
// v0.4.64: the API now returns a structured
// {error, stderr, hint} JSON body so we can render the
// mount failure and an actionable hint as two distinct lines
// instead of one long unreadable string. The dominant field
// failure mode — "mount.nfs: failed to apply fstab options" —
// becomes useful when paired with its CAP_SYS_ADMIN hint.
let body = null;
// The API returns a structured {error, stderr, hint} JSON
// body on failure so the raw smbclient error and the
// actionable hint render as two distinct lines.
let bodyJson = null;
let raw = null;
try { body = await r.clone().json(); }
catch (_) { raw = await r.text().catch(()=> 'mount failed'); }
const msg = body && body.error ? body.error : (raw || 'mount failed');
const hint = body && body.hint;
try { bodyJson = await r.clone().json(); }
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
const hint = bodyJson && bodyJson.hint;
const parts = [el('div', {}, [
el('strong', {}, 'Mount failed: '),
el('strong', {}, 'Connect failed: '),
document.createTextNode(msg),
])];
if (hint) {
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
}
nfsMsg.replaceChildren(...parts);
nfsMsg.className = 'msg err';
smbMsg.replaceChildren(...parts);
smbMsg.className = 'msg err';
}
}}, 'Mount share');
}}, 'Add share');
const nfsRows = mounts.length ? mounts.map(m => el('div', {class: 'nfs-row' + (m.mounted ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}),
const smbRows = shares.length ? shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, m.server + ':' + m.export),
el('div', {class:'id'}, '//' + m.server + '/' + m.share),
el('div', {class:'meta'},
(m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' +
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
// v0.4.64: actionable hint paired with the raw error.
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
const r = await postJSON('/api/smb-shares/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return;
await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'});
if (!confirm('Forget //' + m.server + '/' + m.share + '?')) return;
await fetch('/api/smb-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Unmount'),
}}, 'Remove'),
el('span'),
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
])) : [el('div', {class:'empty'}, 'No SMB shares configured.')];
// Disk-space card. Free + used + total for the volume hosting the
// ISO directory, with a coloured bar. Warns at 80% and goes red at
@@ -733,34 +763,42 @@
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'NFS shares'),
el('span', {class:'sub'}, mounts.length + ' configured'),
el('h2', {}, 'SMB shares'),
el('span', {class:'sub'}, shares.length + ' configured'),
]),
el('div', {class:'body'}, [
el('div', {class:'form-row'}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'NFS server'),
nfsServer,
el('span', {class:'name'}, 'SMB server'),
smbServer,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Export path'),
nfsExport,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Version'),
nfsVer,
]),
el('label', {class:'check', style:'margin-top:18px'}, [
nfsRo, el('span', {}, 'Read-only'),
el('span', {class:'name'}, 'Share name'),
smbShare,
]),
]),
addNfs, nfsMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
el('label', {class:'check'}, [
smbGuest, el('span', {}, 'Guest (anonymous read)'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Username'),
smbUser,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Password'),
smbPass,
]),
]),
addSmb, smbMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, smbRows),
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.'),
'SMB shares are read in userspace via Sambas smbclient — ' +
'no kernel modules, no CAP_SYS_ADMIN, works in any container ' +
'(Unraid, OpenShift restricted SCC, plain Docker, etc.). Most NAS ' +
'appliances expose ISO libraries as guest-readable; check the box ' +
'above when thats the case. ISOs are streamed on demand at PXE ' +
'boot time — no local cache, no double disk usage.'),
]),
]),
el('div', {class:'card'}, [