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:
co-authored by
Claude Opus 4.8
parent
3f9d8568f0
commit
2f12a2ae84
@@ -44,6 +44,14 @@ struct Inner {
|
||||
/// MIME of the active logo, mirroring `logo_filename`. Cached here
|
||||
/// so the HTTP layer can set Content-Type without re-sniffing.
|
||||
logo_mime: Option<String>,
|
||||
/// Monotonic counter bumped on every set/clear. Surfaces as a
|
||||
/// cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser
|
||||
/// fetches the new bytes the moment the operator swaps the logo —
|
||||
/// the app version alone can't do this since it doesn't change on
|
||||
/// upload. Persisted so the token stays stable across restarts and
|
||||
/// keeps climbing across multiple swaps.
|
||||
#[serde(default)]
|
||||
rev: u64,
|
||||
}
|
||||
|
||||
/// In-memory + on-disk override registry. Cheap to clone; locks are
|
||||
@@ -150,6 +158,7 @@ impl BrandingStore {
|
||||
let mut g = self.inner.write();
|
||||
g.logo_filename = Some(filename.clone());
|
||||
g.logo_mime = Some(mime.to_string());
|
||||
g.rev = g.rev.wrapping_add(1);
|
||||
}
|
||||
self.persist();
|
||||
tracing::info!(
|
||||
@@ -166,6 +175,7 @@ impl BrandingStore {
|
||||
let mut g = self.inner.write();
|
||||
let removed = g.logo_filename.take();
|
||||
g.logo_mime = None;
|
||||
g.rev = g.rev.wrapping_add(1);
|
||||
removed
|
||||
};
|
||||
if let Some(name) = removed {
|
||||
@@ -185,6 +195,14 @@ impl BrandingStore {
|
||||
self.inner.read().logo_filename.is_some()
|
||||
}
|
||||
|
||||
/// Cache-bust token for the logo asset URL. Changes on every
|
||||
/// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL
|
||||
/// whenever the operator swaps the brand mark. Stable otherwise.
|
||||
#[must_use]
|
||||
pub fn logo_rev(&self) -> u64 {
|
||||
self.inner.read().rev
|
||||
}
|
||||
|
||||
fn persist(&self) {
|
||||
let snap = self.inner.read().clone();
|
||||
let body = match serde_json::to_vec_pretty(&snap) {
|
||||
@@ -292,6 +310,23 @@ mod tests {
|
||||
assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logo_rev_bumps_on_each_set_and_clear() {
|
||||
let dir = tempdir().unwrap();
|
||||
let b = BrandingStore::load_or_default(dir.path());
|
||||
assert_eq!(b.logo_rev(), 0);
|
||||
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
|
||||
assert_eq!(b.logo_rev(), 1);
|
||||
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake2").unwrap();
|
||||
assert_eq!(b.logo_rev(), 2);
|
||||
b.clear_logo().unwrap();
|
||||
assert_eq!(b.logo_rev(), 3);
|
||||
// Survives a restart.
|
||||
drop(b);
|
||||
let b2 = BrandingStore::load_or_default(dir.path());
|
||||
assert_eq!(b2.logo_rev(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ext_strips_separators_and_path_chars() {
|
||||
assert_eq!(sanitize_ext("svg"), "svg");
|
||||
@@ -319,6 +354,7 @@ mod tests {
|
||||
let inner = Inner {
|
||||
logo_filename: Some("logo.png".into()),
|
||||
logo_mime: Some("image/png".into()),
|
||||
rev: 0,
|
||||
};
|
||||
std::fs::write(
|
||||
brand_dir.join("branding.json"),
|
||||
|
||||
Reference in New Issue
Block a user