v0.4.68: fix NFS secure-export mount, logo cache-bust, dashboard disk card, NFS form spacing

Four operator-reported issues from v0.4.67 validation.

## 1. NFS MNT3ERR_ACCES even with the host IP allow-listed

Root cause: Linux kernel nfsd (what UniFi UNAS / Synology / TrueNAS all
run underneath) exports with the `secure` option by default, which only
accepts mount/NFS requests from a privileged source port (<1024). v0.4.67
explicitly connected from a non-privileged port on the mistaken assumption
that uid 10001 can't bind low ports — but the binary carries
CAP_NET_BIND_SERVICE (granted via setcap for the DHCP/TFTP/HTTP low-port
binds), which also covers privileged *source* ports for outbound connects.

Fix: build_connection now tries a privileged source port first (the common
case for every appliance NAS), then falls back to a non-privileged port
for `insecure` exports or capability-less environments. Each attempt has
its own connect timeout; a timeout on the first attempt skips the fallback
(the server isn't answering — a retry would just double the wait).

Also: hint_for now recognizes MNT3ERR_ACCES distinctly from NFS3ERR_ACCES
and explains both the allow-list and the secure/insecure angle, with the
UniFi /var/nfs/shared/<share> path convention called out.

## 2. Custom logo didn't update the top-left brand mark

The brand <img> and favicon were pinned to ?v=<app-version>, which only
changes on upgrade — so uploading a new logo left the cached bundled SVG
in place. Added a monotonic `rev` counter to BrandingStore that bumps on
every set/clear, persisted across restarts, surfaced through index_html as
an extra &r=<rev> cache-bust token on the brand mark + favicon URLs. Since
index.html is served no-cache, the fresh token lands on the next reload
after upload and the new logo appears immediately.

(Note: this updates the WebUI brand mark. The PXE *boot menu* still shows
the ASCII wordmark — painting the operator's PNG there needs the
IMAGE_PNG-enabled iPXE rebuild that remains queued for native x86_64
hardware. The /branding/pxe-logo compositor is ready for when it lands.)

## 3. Disk-space card on the Dashboard

Extracted the Storage tab's disk card into a shared diskSpaceCard(disk)
helper and added it to the Dashboard grid under the stat strip. Dashboard
fetches /api/storage/disk with the same graceful-degradation fallback the
Storage tab uses.

## 4. NFS "Add share" button touching the form field

The NFS card has a single form row (vs SMB's two), so the button butted
right against it. Added margin-top:14px to match SMB's effective spacing.

Tests: 162 passing (+2 — logo_rev bump, MNT3ERR_ACCES hint). clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-05-28 21:34:41 -04:00
co-authored by Claude Opus 4.8
parent 3f9d8568f0
commit 2f12a2ae84
8 changed files with 211 additions and 76 deletions
+97 -22
View File
@@ -510,10 +510,9 @@ async fn list_isos(
export: &str,
port: u16,
) -> std::result::Result<Vec<NfsListEntry>, NfsClientError> {
let mut conn =
tokio::time::timeout(CONNECT_TIMEOUT, build_connection(server, export, port))
.await
.map_err(|_| NfsClientError::Timeout(server.to_string(), port))??;
// build_connection applies its own per-attempt timeout (privileged
// source port first, then a non-privileged fallback).
let mut conn = build_connection(server, export, port).await?;
let root = conn.root_nfs_fh3();
let mut entries = Vec::new();
@@ -610,10 +609,7 @@ async fn stream_loop(
max_len: Option<u64>,
tx: tokio::sync::mpsc::Sender<std::io::Result<Bytes>>,
) -> std::result::Result<(), NfsClientError> {
let mut conn =
tokio::time::timeout(CONNECT_TIMEOUT, build_connection(server, export, port))
.await
.map_err(|_| NfsClientError::Timeout(server.to_string(), port))??;
let mut conn = build_connection(server, export, port).await?;
let root = conn.root_nfs_fh3();
// Look up the file to get its handle.
@@ -686,13 +682,30 @@ async fn stream_loop(
Ok(())
}
/// Hand the connection builder the user's settings. `mount_path` is
/// the server-side export (e.g. "/srv/isos"). We disable
/// `connect_from_privileged_port` because the openpxe process runs
/// as uid 10001 and can't bind sub-1024 source ports — and most
/// modern NFS servers no longer require them anyway. If a server
/// does demand it the operator's hint will guide them to the
/// `insecure` export option.
/// Mount the export and return a live connection.
///
/// ## Privileged source port (the v0.4.68 fix)
///
/// Linux kernel `nfsd` — which is what UniFi UNAS, Synology, TrueNAS,
/// and essentially every appliance NAS runs underneath — exports with
/// the `secure` option **by default**. `secure` means the server only
/// accepts MOUNT3 / NFS3 requests whose TCP **source** port is in the
/// privileged range (< 1024). A client connecting from an ephemeral
/// high port gets `MNT3ERR_ACCES` at mount time — which is exactly the
/// error operators hit in v0.4.67 even with their host IP correctly in
/// the export's allow-list.
///
/// v0.4.67 disabled privileged source ports because the openpxe
/// process runs as uid 10001 and "can't bind sub-1024 ports". That
/// reasoning was wrong: the binary carries `CAP_NET_BIND_SERVICE`
/// (granted via `setcap` in the Dockerfile so it can bind the DHCP /
/// TFTP / HTTP low ports as non-root), and that capability also lets
/// it bind a privileged *source* port for an outbound connection.
///
/// So we now try a privileged source port first — the common case for
/// real NAS appliances — and fall back to a non-privileged port for
/// servers exported `insecure` (or environments where we genuinely
/// can't grab a low port). Each attempt gets its own connect timeout.
async fn build_connection(
server: &str,
export: &str,
@@ -701,12 +714,42 @@ async fn build_connection(
nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>,
NfsClientError,
> {
Nfs3ConnectionBuilder::new(TokioConnector, server, export)
.connect_from_privileged_port(false)
match connect_once(server, export, port, true).await {
Ok(conn) => Ok(conn),
// A timeout means the server didn't answer at all — a
// non-privileged retry would just time out again and double
// the operator's wait. Surface the timeout immediately.
Err(primary @ NfsClientError::Timeout(..)) => Err(primary),
Err(primary) => match connect_once(server, export, port, false).await {
Ok(conn) => Ok(conn),
// Surface the privileged-attempt error: for the dominant
// `secure`-export case it's the one whose hint points at
// the real fix.
Err(_) => Err(primary),
},
}
}
/// One mount attempt with a specific source-port policy, bounded by
/// [`CONNECT_TIMEOUT`].
async fn connect_once(
server: &str,
export: &str,
port: u16,
privileged: bool,
) -> std::result::Result<
nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>,
NfsClientError,
> {
let fut = Nfs3ConnectionBuilder::new(TokioConnector, server, export)
.connect_from_privileged_port(privileged)
.nfs3_port(port)
.mount()
.await
.map_err(|e| NfsClientError::Connect(e.to_string()))
.mount();
match tokio::time::timeout(CONNECT_TIMEOUT, fut).await {
Ok(Ok(conn)) => Ok(conn),
Ok(Err(e)) => Err(NfsClientError::Connect(e.to_string())),
Err(_) => Err(NfsClientError::Timeout(server.to_string(), port)),
}
}
#[derive(Debug)]
@@ -778,14 +821,34 @@ fn status_label(status: nfs3::nfsstat3) -> String {
/// transport-level messages from the Rust crate.
fn hint_for(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
if s.contains("nfs3err_acces") || s.contains("permission denied") {
if s.contains("mnt3err_acces") || s.contains("mount") && s.contains("acces") {
// Mount-protocol access denial. Two common causes, in order of
// likelihood for an appliance NAS: (1) the export requires a
// privileged source port (`secure`, the Linux default) — we
// already retry with one, so reaching here means even that was
// refused; (2) the client IP isn't in the allow-list.
Some(
"the NFS server denied the mount (MNT3ERR_ACCES). Two things to \
check on the server: (1) this OpenPXE host's IP is in the \
export's allowed-clients list, and (2) if your export uses the \
default `secure` option, OpenPXE already connects from a \
privileged port — but if the server still refuses, add \
`insecure` to the export. On UniFi UNAS, confirm the host IP is \
listed under the share's NFS permissions and the export path is \
/var/nfs/shared/<share> (not just /<share>)."
.into(),
)
} else if s.contains("nfs3err_acces") || s.contains("permission denied") {
Some(
"the NFS server rejected this client. Most likely your export \
is restricted by client IP — add this OpenPXE host (or its \
subnet) to the export's allowed-clients list on the server."
.into(),
)
} else if s.contains("nfs3err_noent") || s.contains("nfs3err_notdir") {
} else if s.contains("nfs3err_noent")
|| s.contains("nfs3err_notdir")
|| s.contains("mnt3err_noent")
{
Some(
"the export path doesn't exist on the server, or it isn't a \
directory. Double-check the path (e.g. /srv/isos vs /isos — \
@@ -888,6 +951,18 @@ mod tests {
);
}
#[test]
fn hint_for_mount_acces_calls_out_privileged_port_and_allowlist() {
// The dominant v0.4.67 field failure: mount denied even with the
// host IP allow-listed, because the export is `secure` and the
// client used a high source port. The hint should mention both
// the allow-list and the secure/insecure angle.
let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap();
let lc = h.to_lowercase();
assert!(lc.contains("insecure") || lc.contains("privileged"), "got: {h}");
assert!(lc.contains("allow") || lc.contains("permission"), "got: {h}");
}
#[test]
fn hint_for_noent_points_to_export_path() {
let h = hint_for("NFS server returned NFS3ERR_NOENT").unwrap();