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:
co-authored by
Claude Opus 4.7
parent
07e7c18698
commit
900b65b3ec
+117
-79
@@ -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 Samba’s 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 that’s the case. ISOs are streamed on demand at PXE ' +
|
||||
'boot time — no local cache, no double disk usage.'),
|
||||
]),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
|
||||
Reference in New Issue
Block a user