v0.4.67: NFSv3 alongside SMB (in-process via nfs3_client crate)
NFS is back — done right this time. v0.4.67 ships a pure-Rust NFSv3
client (`nfs3_client` 0.9 from the xetdata/Vaiz crate family) running
in-process inside the openpxe binary. No `mount.nfs`, no kernel
modules, no `CAP_SYS_ADMIN`, no subprocess. Works in every container
that the v0.4.65 SMB path works in (Unraid included).
The v0.4.65 SMB path stays as-is. Operators get both protocols
side-by-side and pick whichever their NAS prefers — or use both
together. NFSv3 has one architectural advantage over the SMB
userspace path: HTTP Range requests work for NFS-sourced ISOs
because NFSv3 READ3 takes an explicit offset. SMB-sourced ISOs still
return 416 for ranges (smbclient CLI can't seek mid-stream).
## What's new
- `crates/iso-store/src/nfs_share.rs` — `NfsShareManager` mirroring
`SmbShareManager` structurally. Lists ISOs via READDIR3+LOOKUP3+
GETATTR3, streams files via READ3 in 64 KiB chunks piped to axum
body streams. Uses `connect_from_privileged_port(false)` because
the openpxe binary runs as uid 10001 — most modern NFS servers
allow that; a server that demands privileged ports needs
`insecure` in /etc/exports, and the hint translation calls that
out specifically.
- `IsoSource::Nfs { share_id, relative_path }` variant alongside the
existing `Smb`. `IsoStore::iso_path_for` returns None for both;
the HTTP handler dispatches to the right share manager.
- `/api/nfs-shares` CRUD + scan endpoints, parallel to
`/api/smb-shares`. `POST` body: `{ server, export, port? }`.
- `nfs` terminal command back (this time as in-process, not kernel
mount): `list | add <srv>:<export> [port] | remove | scan`. The
v0.4.64 `nfs` command name pointing at kernel mount is moot
history — same name, completely different mechanism.
- Storage tab: a new NFS shares card sits directly below the SMB
shares card. The form is simpler (no auth fields) since NFSv3
uses AUTH_SYS and access is gated server-side by client IP.
- Dashboard "Images available" tile sums SMB + NFS reachable shares
into a generic "N remote shares" line.
## What's the same
- The structured `{error, stderr, hint}` JSON shape on failures
matches the SMB API exactly, so the UI's error banner renders
identically.
- Hint translation: NFS3ERR_ACCES → "exports list", NFS3ERR_NOENT →
"export path doesn't exist", `mount denied` → "/etc/exports may
need `insecure`", timeouts → "check IP/port/firewall".
- Persistence: `<work_dir>/nfs_shares.json`. No conflict with the
long-dead v0.4.64 `nfs.json`.
## Why nfs3_client
User picked it: pure-Rust matches the architecture, NFSv3 covers the
real-world cases, AUTH_SYS keeps the UI simple. The crate is at
0.9.0, MIT/Unlicense, rust-version 1.88 (we're on 1.95). Tokio
feature flag enabled. Image size unchanged at compile time — single
musl static binary, no extra OS packages.
## Tests
160 passing (was 150 in v0.4.66, +10):
- nfs_share parser: stable share ids, server normalization (smb://,
cifs://, \\, // all stripped).
- hint_for(): NFS3ERR_ACCES, NFS3ERR_NOENT, mount denied, unknown.
- status_label() covers the common nfsstat3 codes.
- HTTP integration: nfs-shares list starts empty, missing server
rejected, export without leading slash rejected.
`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
9f66c269c4
commit
3f9d8568f0
+127
-15
@@ -167,8 +167,11 @@
|
||||
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.smb_share_reachable || 0) + ' SMB share' +
|
||||
((status.smb_share_reachable || 0) === 1 ? '' : 's')),
|
||||
// v0.4.67: count both protocols. Label generically since
|
||||
// operators may be using one, the other, or both.
|
||||
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) +
|
||||
' remote share' +
|
||||
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')),
|
||||
])),
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Uptime'),
|
||||
@@ -345,16 +348,20 @@
|
||||
storage: async () => {
|
||||
// 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([
|
||||
// modules or capabilities.
|
||||
// v0.4.67: NFSv3 added back as an in-process Rust client
|
||||
// (nfs3_client crate). Both protocols available side-by-side;
|
||||
// operators pick whichever their NAS prefers.
|
||||
const [isos, settings, smbRes, nfsRes, disk] = await Promise.all([
|
||||
getJSON('/api/isos'), getJSON('/api/settings'),
|
||||
getJSON('/api/smb-shares'),
|
||||
getJSON('/api/nfs-shares'),
|
||||
getJSON('/api/storage/disk').catch(() => ({
|
||||
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
||||
})),
|
||||
]);
|
||||
const shares = smbRes.shares || [];
|
||||
const nfsShares = nfsRes.shares || [];
|
||||
|
||||
// ── Upload card ──
|
||||
const drop = el('div', {class:'drop', id:'drop'}, [
|
||||
@@ -464,10 +471,14 @@
|
||||
const rowsAndEditors = [];
|
||||
isos.forEach(i => {
|
||||
const b = bootability(i, settings);
|
||||
// v0.4.65: SMB userspace consumer replaced NFS. The badge
|
||||
// colours stay the same so the table looks unchanged for
|
||||
// existing operators.
|
||||
// v0.4.65: SMB userspace consumer (smbclient).
|
||||
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
|
||||
// Both render with the same "remote" badge colour — they
|
||||
// share the same "on a remote share, can't be deleted from
|
||||
// here" semantics in the UI.
|
||||
const isSmb = i.source && i.source.kind === 'smb';
|
||||
const isNfs = i.source && i.source.kind === 'nfs';
|
||||
const isRemote = isSmb || isNfs;
|
||||
const protectedNow = !!i.password_hash;
|
||||
|
||||
// The inline editor row is hidden by default; the Password
|
||||
@@ -587,8 +598,9 @@
|
||||
]),
|
||||
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
|
||||
el('td', {},
|
||||
el('span', {class:'src-badge' + (isSmb ? ' nfs' : '')},
|
||||
isSmb ? ('smb:' + i.source.share_id) : 'local')),
|
||||
el('span', {class:'src-badge' + (isRemote ? ' nfs' : '')},
|
||||
isSmb ? ('smb:' + i.source.share_id)
|
||||
: isNfs ? ('nfs:' + i.source.share_id) : 'local')),
|
||||
el('td', {},
|
||||
protectedNow
|
||||
? el('span', {class:'tag accent'}, 'protected')
|
||||
@@ -598,11 +610,12 @@
|
||||
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
|
||||
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
|
||||
}}, protectedNow ? 'Password ✎' : 'Set password'),
|
||||
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')
|
||||
isRemote
|
||||
// v0.4.65/v0.4.67: remote-sourced ISOs live on the
|
||||
// share — OpenPXE doesn't own those bytes. Surface a
|
||||
// tag instead of a destructive button.
|
||||
? el('span', {class:'tag', style:'opacity:.6'},
|
||||
isSmb ? 'on SMB' : 'on NFS')
|
||||
: el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Remove this image?')) return;
|
||||
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
|
||||
@@ -715,6 +728,72 @@
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No SMB shares configured.')];
|
||||
|
||||
// ── NFS shares section (v0.4.67) ──
|
||||
// Parallel to SMB shares above. The NFSv3 client is in-process
|
||||
// (nfs3_client crate) so NFS-sourced ISOs support HTTP Range
|
||||
// requests — SMB-sourced ones don't (smbclient CLI can't seek
|
||||
// mid-stream). Otherwise the UX is identical: server + export,
|
||||
// submit, scan, remove.
|
||||
const nfsMsg = el('div', {class:'msg'});
|
||||
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
||||
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
|
||||
const addNfs = el('button', {onclick: async () => {
|
||||
if (!nfsServerIn.value || !nfsExportIn.value) {
|
||||
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||
nfsMsg.className = 'msg err'; return;
|
||||
}
|
||||
nfsMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||
nfsMsg.className = 'msg';
|
||||
const r = await postJSON('/api/nfs-shares', {
|
||||
server: nfsServerIn.value,
|
||||
export: nfsExportIn.value,
|
||||
});
|
||||
if (r.ok) {
|
||||
nfsMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||
nfsMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else {
|
||||
// Structured {error, stderr, hint} same as SMB.
|
||||
let bodyJson = null;
|
||||
let raw = null;
|
||||
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', {}, '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';
|
||||
}
|
||||
}}, 'Add share');
|
||||
|
||||
const nfsRows = nfsShares.length ? nfsShares.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:'meta'},
|
||||
'NFSv3 · ' +
|
||||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||
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-shares/' + encodeURIComponent(m.id) + '/scan', {});
|
||||
if (r.ok) render('storage');
|
||||
}}, 'Re-scan'),
|
||||
el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
|
||||
await fetch('/api/nfs-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||||
render('storage');
|
||||
}}, 'Remove'),
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No NFS 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
|
||||
// 95% so the operator sees the runway shrinking before uploads
|
||||
@@ -801,6 +880,39 @@
|
||||
'boot time — no local cache, no double disk usage.'),
|
||||
]),
|
||||
]),
|
||||
// v0.4.67: NFS shares card sits right below SMB so operators
|
||||
// can see both protocols at a glance. The form is simpler
|
||||
// (no auth) because NFSv3 access control is by client IP on
|
||||
// the server side, not by client-supplied credentials.
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'NFS shares'),
|
||||
el('span', {class:'sub'}, nfsShares.length + ' configured'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'NFS server'),
|
||||
nfsServerIn,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Export path'),
|
||||
nfsExportIn,
|
||||
]),
|
||||
]),
|
||||
addNfs, nfsMsg,
|
||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'NFSv3 shares are read in-process via a pure-Rust client — ' +
|
||||
'no kernel modules, no mount.nfs, no CAP_SYS_ADMIN. Works in ' +
|
||||
'every container the SMB path works in (Unraid included). ' +
|
||||
'NFSv3 auth is AUTH_SYS only; gate access on the server side ' +
|
||||
'by allowing this OpenPXE host’s IP in the export list. ' +
|
||||
'ISOs are streamed on demand and HTTP Range requests work — ' +
|
||||
'NFSv3 READ3 takes an explicit offset, so clients can seek ' +
|
||||
'into a 5 GB ISO without reading what comes before.'),
|
||||
]),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Available images'),
|
||||
|
||||
Reference in New Issue
Block a user