Compare commits

...
8 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 2f12a2ae84 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]>
2026-05-28 21:34:41 -04:00
Miles WardandClaude Opus 4.7 3f9d8568f0 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]>
2026-05-28 12:56:46 -04:00
Miles WardandClaude Opus 4.7 9f66c269c4 v0.4.66: ship smbclient in the runtime image
v0.4.65 added the SmbShareManager but the Dockerfile only installed
the `samba` package — in Debian 12 that ships the SERVER (smbd) only,
not the `smbclient` CLI the new manager shells out to. Every "Add
share" attempt surfaced:

    could not exec smbclient: No such file or directory (os error 2)

Fix is two lines: add `smbclient` to the runtime apt install, drop
the leftover `nfs-common` (no kernel-mount NFS anymore so the helpers
aren't needed).

While in the area, harden the manager so future stripped-down runtime
images get a useful error instead of a bare exec failure:

- `list_isos` and `stream_iso` both detect `ErrorKind::NotFound` on
  spawn and emit "smbclient binary not found on $PATH".
- `hint_for` translates the missing-binary pattern into an actionable
  hint: "pull OpenPXE v0.4.66+ or add the Debian `smbclient` package
  to your runtime stage." So even on a custom build the UI still
  surfaces a clear remediation.

Tests: 150 passing (+1 for the new hint). clippy clean.

The image is still ~98 MB — `smbclient` adds <1 MB on top of the
already-installed samba server.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 11:58:51 -04:00
Miles WardandClaude Opus 4.7 900b65b3ec 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]>
2026-05-28 11:19:47 -04:00
Miles Ward 07e7c18698 Revert "v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)"
This reverts commit 72a2089c98.
2026-05-28 10:47:17 -04:00
Miles WardandClaude Opus 4.7 761489761c v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)
Field report: even with CAP_SYS_ADMIN and full --privileged, NFS mounts
inside the OpenPXE container fail on Unraid with the same
"failed to apply fstab options" error v0.4.64 added diagnostics for.
The root cause is the host kernel: Unraid's base kernel ships without
the nfs/nfsv4 client modules loaded. Capabilities are necessary but
not sufficient; the modules have to be present on the host kernel for
in-container mount(2) to do anything. No container-side change can
fix that.

This is exactly the case every other PXE/imaging tool sidesteps
(Bootimus uses SMB; iVentoy, FOG, MAAS, Cobbler all rely on the host
to mount network storage and bind-mount the path into the imaging
service). v0.4.65 brings OpenPXE in line with that pattern.

What's new:

* `IsoSource::LocalDir { dir_id, relative_path }` — third source kind
  alongside `Local` (uploaded) and `Nfs` (in-container mount).
* `LocalDirManager` (crates/iso-store/src/local_dir.rs) — registers
  bind-mounted directories, validates them (absolute path, exists, is
  a directory, readable), scans for *.iso files, registers them with
  IsoStore. Persisted to <work_dir>/local_dirs.json so the relationship
  survives restarts.
* `NfsHostCaps::detect()` — pure read of /proc/filesystems on startup.
  Surfaced via GET /api/nfs/capabilities and used by the Storage tab to
  show a prominent red banner above the NFS form when in-container
  mounts cannot possibly work, pointing the operator at the Local
  Directories card as the recommended path.
* Four new API routes:
    GET    /api/nfs/capabilities
    GET    /api/local-dirs
    POST   /api/local-dirs           { path, label? }
    DELETE /api/local-dirs/:id
    POST   /api/local-dirs/:id/scan

UI changes (crates/webui/src/app.js):
* Storage tab: new "Local directories" card under the NFS card with
  the bind-mount form, an explainer paragraph (with the Docker
  `-v /mnt/user/isos:/mnt/external-isos` command), and the list of
  registered directories with rescan + remove actions.
* When NFS host caps are unavailable, the NFS card sprouts a red
  banner explaining what's wrong and pointing at the local-dir
  workaround. The card sub-header also flips to "N registered ·
  recommended on this host".
* ISO table: new "dir:<id>" source badge; on-disk ISOs show "on disk"
  in the actions column instead of a delete button (same pattern as
  NFS — OpenPXE doesn't own those bytes).
* API reference table picks up the four new endpoints + a hint about
  the new `port` field on NFS add.

Tests (+12, total 162):
* iso-store: 7 local_dir unit tests covering relative-path rejection,
  missing path, non-directory file, empty-directory success, default
  label, idempotent re-add, remove + iso-path-resolution clear.
* iso-store: 1 nfs unit test confirming NfsHostCaps::detect() never
  panics and the boolean accessors are consistent.
* http-api: 4 integration tests covering /api/nfs/capabilities,
  /api/local-dirs list/add/remove + relative-path 400.

`cargo clippy --workspace --all-targets -- -D warnings` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 03:09:43 -04:00
Miles WardandClaude Opus 4.7 0afbe860e8 v0.4.64: NFS mount diagnostics — pre-flight probe, retry, hint translation
The dominant field failure from v0.4.63 was "mount.nfs: failed to apply
fstab options" (exit 32), surfaced verbatim by the Storage tab. The
message is misleading — it has nothing to do with /etc/fstab; it comes
from nfs-utils 2.6.x's nfs_options2string() and most commonly indicates
the container is missing CAP_SYS_ADMIN, /etc/mtab is unwritable, or an
auxiliary option triggered an option-transform edge case.

Backend (crates/iso-store/src/nfs.rs):
- TCP pre-flight probe to server:port (4s timeout) before shelling out.
  Catches wrong-IP / firewall cases as "cannot reach NFS port" instead
  of letting mount.nfs spit out an unhelpful message.
- proto=tcp explicit on NFSv3 (UDP is widely deprecated, modern NAS
  appliances often don't bind UDP at all).
- Optional `port` field on NfsAddRequest (defaults to 2049), persisted
  on NfsMount.
- On "failed to apply fstab options" / "internal option parsing error"
  retry with a minimal option set (vers=N,ro/rw only) — bypasses the
  nfs-utils transformation bug; if it still fails we get a real kernel
  error to translate.
- hint_for() translates well-known stderr patterns into actionable
  guidance — CAP_SYS_ADMIN for option-transform failures, exports-table
  for access-denied, export-path hint for "no such file or directory"
  (calling out the UniFi UNAS Pro /var/nfs/shared/<name> convention),
  etc.
- normalize_server() strips http://, https://, nfs:// schemes the
  operator may have pasted by mistake, plus trailing slashes.

API (crates/http-api/src/app.rs):
- api_nfs_add now returns a structured {error, stderr, hint} JSON body
  on failure instead of plain text. UI renders the error in bold with
  the hint as a dimmer second line.

UI (crates/webui/src/app.js):
- Storage tab's "Mount failed" banner now shows the raw error + hint on
  two lines. Each persisted mount row also surfaces last_hint under
  last_error.

Terminal (crates/http-api/src/terminal.rs):
- `nfs mount` command prints "hint: ..." on a follow-up line when the
  manager returns one.

Tests:
- 8 new tests covering option string (incl. proto=tcp on v3, port=N for
  non-default), minimal-options stripping, server normalization, and
  hint translation for each well-known stderr pattern.
- All 150 tests pass; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 13:53:15 -04:00
Miles WardandClaude Opus 4.7 9f694f7c79 v0.4.63: SSO row alignment, themed checkbox, dropdown affordance
Three UI nits the operator caught on v0.4.62, plus the queued PXE-theme
research note for the next release.

- SSO header grid is now a 4-column form-row matching the Administrator
  account card column-for-column (display name / logo URL / metadata
  source / metadata URL). Switching to XML mode collapses column 4 and
  drops the multi-line textarea on its own full-width row below.
- Native form chrome (checkboxes, scroll bars) follows the active
  OpenPXE theme via CSS `color-scheme`; the inline meta tag was forcing
  dark form controls in light mode, which is why the "Enable single
  sign-on" checkbox rendered as an opaque black square against the
  light panel.
- Checkbox itself is now custom-styled (16x16 rounded square, accent
  fill + tick on :checked) so the chrome reads identically across both
  palettes and browsers, not just on whichever WebKit happens to honor
  `accent-color`.
- <select> dropdowns get a hand-drawn chevron via background-image SVG;
  with `-webkit-appearance: none` the native arrow had disappeared,
  making "Metadata source" look squished next to the inputs beside it.
- Update credentials + Save SSO settings buttons get explicit top
  margins so they sit clearly under their input rows instead of butting
  against the field beneath.
- `docs/queued/ipxe-pxe-menu-theme-research.md` captures findings on
  how iVentoy paints its boot menu (iPXE `console --picture` with
  baked-in per-resolution PNGs, no EDID auto-detect) and the
  recommended Rust architecture for the follow-up release.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 02:32:38 -04:00
20 changed files with 3247 additions and 869 deletions
Generated
+42 -8
View File
@@ -1102,6 +1102,37 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "nfs3_client"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ac227029a6127f3474b4fb61cbf2d3dddbe9424ad50c24fada68b633687a532"
dependencies = [
"fastrand",
"nfs3_types",
"tokio",
]
[[package]]
name = "nfs3_macros"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06d8fb377e6efeb91911a8ed962a69615535e167e675025a2be8fa109751426a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "nfs3_types"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5d5ab1a9fdfeab2f03b36b6300dea4ea3806e6a057cb479628b41d65e356b94"
dependencies = [
"nfs3_macros",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1140,7 +1171,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "openpxe" name = "openpxe"
version = "0.4.62" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -1162,7 +1193,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-core" name = "openpxe-core"
version = "0.4.62" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bcrypt", "bcrypt",
@@ -1181,7 +1212,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-dhcp-proxy" name = "openpxe-dhcp-proxy"
version = "0.4.62" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -1195,7 +1226,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-http-api" name = "openpxe-http-api"
version = "0.4.62" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -1226,7 +1257,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-ipxe-assets" name = "openpxe-ipxe-assets"
version = "0.4.62" version = "0.4.68"
dependencies = [ dependencies = [
"openpxe-core", "openpxe-core",
"rust-embed", "rust-embed",
@@ -1236,14 +1267,17 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-iso-store" name = "openpxe-iso-store"
version = "0.4.62" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bcrypt", "bcrypt",
"bytes", "bytes",
"futures",
"hex", "hex",
"image", "image",
"libc", "libc",
"nfs3_client",
"nfs3_types",
"openpxe-core", "openpxe-core",
"parking_lot", "parking_lot",
"serde", "serde",
@@ -1260,7 +1294,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-tftp" name = "openpxe-tftp"
version = "0.4.62" version = "0.4.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -1274,7 +1308,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-webui" name = "openpxe-webui"
version = "0.4.62" version = "0.4.68"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
+9 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.4.62" version = "0.4.68"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -58,6 +58,14 @@ once_cell = "1.19"
parking_lot = "0.12" parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] } rust-embed = { version = "8.5", features = ["include-exclude"] }
# v0.4.67: pure-Rust NFSv3 client. Replaces the (deleted-in-v0.4.65)
# kernel-mount NFS path with an in-process implementation that works
# in any container — no kernel modules, no CAP_SYS_ADMIN, no
# subprocess. Ships alongside the userspace SMB consumer; operators
# pick whichever protocol their NAS prefers.
nfs3_client = { version = "0.9", features = ["tokio"] }
nfs3_types = "0.5"
openpxe-core = { path = "crates/core" } openpxe-core = { path = "crates/core" }
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" } openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
openpxe-tftp = { path = "crates/tftp" } openpxe-tftp = { path = "crates/tftp" }
+36
View File
@@ -44,6 +44,14 @@ struct Inner {
/// MIME of the active logo, mirroring `logo_filename`. Cached here /// MIME of the active logo, mirroring `logo_filename`. Cached here
/// so the HTTP layer can set Content-Type without re-sniffing. /// so the HTTP layer can set Content-Type without re-sniffing.
logo_mime: Option<String>, 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 /// In-memory + on-disk override registry. Cheap to clone; locks are
@@ -150,6 +158,7 @@ impl BrandingStore {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.logo_filename = Some(filename.clone()); g.logo_filename = Some(filename.clone());
g.logo_mime = Some(mime.to_string()); g.logo_mime = Some(mime.to_string());
g.rev = g.rev.wrapping_add(1);
} }
self.persist(); self.persist();
tracing::info!( tracing::info!(
@@ -166,6 +175,7 @@ impl BrandingStore {
let mut g = self.inner.write(); let mut g = self.inner.write();
let removed = g.logo_filename.take(); let removed = g.logo_filename.take();
g.logo_mime = None; g.logo_mime = None;
g.rev = g.rev.wrapping_add(1);
removed removed
}; };
if let Some(name) = removed { if let Some(name) = removed {
@@ -185,6 +195,14 @@ impl BrandingStore {
self.inner.read().logo_filename.is_some() 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) { fn persist(&self) {
let snap = self.inner.read().clone(); let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) { 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:?}"); 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] #[test]
fn sanitize_ext_strips_separators_and_path_chars() { fn sanitize_ext_strips_separators_and_path_chars() {
assert_eq!(sanitize_ext("svg"), "svg"); assert_eq!(sanitize_ext("svg"), "svg");
@@ -319,6 +354,7 @@ mod tests {
let inner = Inner { let inner = Inner {
logo_filename: Some("logo.png".into()), logo_filename: Some("logo.png".into()),
logo_mime: Some("image/png".into()), logo_mime: Some("image/png".into()),
rev: 0,
}; };
std::fs::write( std::fs::write(
brand_dir.join("branding.json"), brand_dir.join("branding.json"),
+250 -41
View File
@@ -35,7 +35,7 @@ use openpxe_core::{
MAX_LOGO_BYTES, MAX_LOGO_BYTES,
}; };
use openpxe_ipxe_assets::asset_bytes; use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest}; use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -132,10 +132,21 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/queue/poll/:entry_id", get(api_queue_poll)) .route("/api/queue/poll/:entry_id", get(api_queue_poll))
.route("/api/queue/assign", post(api_queue_assign)) .route("/api/queue/assign", post(api_queue_assign))
.route("/api/queue/:entry_id", delete(api_queue_release)) .route("/api/queue/:entry_id", delete(api_queue_release))
// Phase 4: NFS share manager. // v0.4.65: SMB share manager (userspace via smbclient). The
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add)) // kernel-mount NFS routes that v0.4.64 shipped are gone — they
.route("/api/nfs/:id", delete(api_nfs_remove)) // didn't work on hosts whose kernel lacked the nfs client
.route("/api/nfs/:id/scan", post(api_nfs_scan)) // modules (Unraid), and no container-side configuration could
// load a host kernel module. `smbclient` speaks SMB over a
// plain TCP socket in userspace, works in every container.
.route("/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add))
.route("/api/smb-shares/:id", delete(api_smb_shares_remove))
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
// Ships alongside SMB. Routes are parallel so the UI can
// reuse the same form/error/hint rendering for both.
.route("/api/nfs-shares", get(api_nfs_shares_list).post(api_nfs_shares_add))
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
// Phase 4: Network info (read-only) + DNS edit. // Phase 4: Network info (read-only) + DNS edit.
.route("/api/network", get(api_network).put(api_network_put)) .route("/api/network", get(api_network).put(api_network_put))
// Phase 4: live-log stream + recent buffer for the Terminal tab. // Phase 4: live-log stream + recent buffer for the Terminal tab.
@@ -197,7 +208,14 @@ async fn index(State(state): State<AppState>) -> Response {
// browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE // browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE
// binary version — every release ships a new value, every release // binary version — every release ships a new value, every release
// forces a fresh URL on each asset. // forces a fresh URL on each asset.
let html = openpxe_webui::index_html(&state.public_base_url, env!("CARGO_PKG_VERSION")); // logo_rev cache-busts the brand mark / favicon independently of
// the release version, so an operator who swaps the custom logo
// sees it update on the next reload without waiting for an upgrade.
let html = openpxe_webui::index_html(
&state.public_base_url,
env!("CARGO_PKG_VERSION"),
state.branding.logo_rev(),
);
( (
[ [
( (
@@ -638,12 +656,111 @@ async fn iso_raw(
headers: HeaderMap, headers: HeaderMap,
) -> Response { ) -> Response {
let id = filename.strip_suffix(".iso").unwrap_or(&filename); let id = filename.strip_suffix(".iso").unwrap_or(&filename);
let Some(path) = state.iso_store.iso_path_for(id) else { // v0.4.65: SMB-sourced ISOs have no on-disk path — they're
// streamed live from the remote share via `smbclient`. We look
// up the meta first to decide whether to take the path-based
// local route or the subprocess-based SMB route.
let Some(meta) = state.iso_store.get(id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response(); return (StatusCode::NOT_FOUND, "no such iso").into_response();
}; };
match stream_file_range(&path, headers.get(header::RANGE)).await { match &meta.source {
Ok(r) => r, IsoSource::Local => {
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), let Some(path) = state.iso_store.iso_path_for(id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
match stream_file_range(&path, headers.get(header::RANGE)).await {
Ok(r) => r,
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
}
}
}
IsoSource::Smb {
share_id,
relative_path,
} => {
// Range requests aren't supported for SMB sources in
// v0.4.65 — smbclient's CLI can't seek mid-stream. iPXE
// chain loading and ISO sanboot do whole-file sequential
// reads, so this works in practice. A 416 here lets the
// client fall back to a full GET if it tried a range.
if headers.get(header::RANGE).is_some() {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", meta.size_bytes))
.body(Body::empty())
.unwrap();
}
match state.smb_shares.stream_iso(share_id, relative_path).await {
Ok(stream) => {
let reader = stream.stdout;
let body_stream = tokio_util::io::ReaderStream::new(reader);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_LENGTH, meta.size_bytes)
// Tell intermediaries we don't support
// ranges on this resource; saves them from
// even trying.
.header(header::ACCEPT_RANGES, "none")
.body(Body::from_stream(body_stream))
.unwrap()
}
Err(e) => (StatusCode::BAD_GATEWAY, format!("smb stream: {e}")).into_response(),
}
}
IsoSource::Nfs {
share_id,
relative_path,
} => {
// v0.4.67: NFS sources support Range requests because
// NFSv3 READ3 takes an explicit offset. We resolve the
// requested byte range here and pass start/len down to
// the streamer which seeks into the file via READ3.
let total = meta.size_bytes;
let range = match parse_range(headers.get(header::RANGE), total) {
Some(triple) => triple,
None if headers.get(header::RANGE).is_some() => {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(Body::empty())
.unwrap();
}
// No Range header — serve the whole file.
None => (0, total.saturating_sub(1), false),
};
let (start, end, partial) = range;
let len = if total == 0 { 0 } else { end - start + 1 };
let max_len = if total == 0 { None } else { Some(len) };
match state
.nfs_shares
.stream_iso(share_id, relative_path, start, max_len)
.await
{
Ok(stream) => {
let body = Body::from_stream(stream);
let status = if partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
};
let mut builder = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, len);
if partial {
builder = builder.header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{total}"),
);
}
builder.body(body).unwrap()
}
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(),
}
}
} }
} }
@@ -651,6 +768,10 @@ async fn iso_file(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath((id, path)): AxumPath<(String, String)>, AxumPath((id, path)): AxumPath<(String, String)>,
) -> Response { ) -> Response {
// In-ISO file extraction is only supported for local ISOs — it
// needs random-access reads into the ISO9660 directory tree, which
// smbclient's whole-file streaming can't do efficiently. SMB-
// sourced ISOs use the raw streaming endpoint above instead.
let Some(iso_path) = state.iso_store.iso_path_for(&id) else { let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response(); return (StatusCode::NOT_FOUND, "no such iso").into_response();
}; };
@@ -1026,17 +1147,30 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Abort a chunked upload session and remove the .partial file."}, "summary": "Abort a chunked upload session and remove the .partial file."},
], ],
}, },
{
"name": "SMB shares",
"endpoints": [
{"method": "GET", "path": "/api/smb-shares",
"summary": "List configured SMB shares with connection state and iso counts."},
{"method": "POST", "path": "/api/smb-shares",
"summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."},
{"method": "DELETE", "path": "/api/smb-shares/:id",
"summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/smb-shares/:id/scan",
"summary": "Re-list a share for new ISOs."},
],
},
{ {
"name": "NFS shares", "name": "NFS shares",
"endpoints": [ "endpoints": [
{"method": "GET", "path": "/api/nfs", {"method": "GET", "path": "/api/nfs-shares",
"summary": "List configured NFS shares with mount state and iso counts."}, "summary": "List configured NFSv3 shares with connection state and iso counts."},
{"method": "POST", "path": "/api/nfs", {"method": "POST", "path": "/api/nfs-shares",
"summary": "Mount an NFS share. Body: { server, export, version, read_only }."}, "summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."},
{"method": "DELETE", "path": "/api/nfs/:id", {"method": "DELETE", "path": "/api/nfs-shares/:id",
"summary": "Unmount a share and drop its entries from the ISO store."}, "summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/nfs/:id/scan", {"method": "POST", "path": "/api/nfs-shares/:id/scan",
"summary": "Re-walk a mounted share for ISOs."}, "summary": "Re-list a share for new ISOs."},
], ],
}, },
{ {
@@ -1453,8 +1587,13 @@ async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Val
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> { async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let smb = state.smb.as_ref().map(|s| s.snapshot()); let smb = state.smb.as_ref().map(|s| s.snapshot());
let nfs = state.nfs.list(); // v0.4.65+v0.4.67: external storage shares — SMB (userspace
let nfs_active = nfs.iter().filter(|m| m.mounted).count(); // smbclient) and NFS (in-process nfs3_client). Dashboard tile
// sums both so operators see a single "reachable shares" number.
let smb_shares = state.smb_shares.list();
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
let nfs_shares = state.nfs_shares.list();
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
let isos = state.iso_store.list(); let isos = state.iso_store.list();
let clients = state.clients.list(); let clients = state.clients.list();
let queue_entries = state.queue.list(); let queue_entries = state.queue.list();
@@ -1473,7 +1612,9 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
state state
.metrics .metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64); .set_queue_counts(queue_entries.len() as u64, imaging as u64);
state.metrics.set_nfs_active(nfs_active as u64); state
.metrics
.set_nfs_active((smb_reachable + nfs_reachable) as u64);
state.metrics.record_http(openpxe_core::HttpRoute::Api); state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0); let uptime_secs = (now - state.started_at).whole_seconds().max(0);
@@ -1488,8 +1629,13 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"ipxe_assets": openpxe_ipxe_assets::list_assets(), "ipxe_assets": openpxe_ipxe_assets::list_assets(),
"settings": state.settings.snapshot(), "settings": state.settings.snapshot(),
"smb": smb, "smb": smb,
"nfs_count": nfs.len(), "smb_share_count": smb_shares.len(),
"nfs_active": nfs_active, "smb_share_reachable": smb_reachable,
// v0.4.67: NFSv3 share counts. The dashboard tile sums these
// with the SMB counts above ("N shares reachable") so the
// top-line metric works regardless of protocol mix.
"nfs_share_count": nfs_shares.len(),
"nfs_share_reachable": nfs_reachable,
"host_bindings": state.hosts.len(), "host_bindings": state.hosts.len(),
"custom_logo": state.branding.has_logo(), "custom_logo": state.branding.has_logo(),
"uptime_secs": uptime_secs, "uptime_secs": uptime_secs,
@@ -1704,32 +1850,83 @@ async fn api_queue_release(
} }
} }
// ─── NFS share API ───────────────────────────────────────────────────────── // ─── SMB share API (v0.4.65) ───────────────────────────────────────────────
//
// Replaces the NFS share manager from v0.4.64. The wire shape is similar
// — a {shares: [...]} list, a POST that returns either the share or a
// structured {error, stderr, hint} body — so the UI can render both the
// same way.
async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value> { async fn api_smb_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "mounts": state.nfs.list() })) Json(json!({ "shares": state.smb_shares.list() }))
} }
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response { async fn api_smb_shares_add(
match state.nfs.add(req).await { State(state): State<AppState>,
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(), Json(req): Json<SmbAddRequest>,
// Anything from the manager surfaces as a user-fixable validation ) -> Response {
// error — bad host, kernel without NFS support, missing match state.smb_shares.add(req).await {
// `mount.nfs`, dead server. We pass the message through verbatim Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
// so the UI can show it to the operator. Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
} }
} }
async fn api_nfs_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response { async fn api_smb_shares_remove(
match state.nfs.remove(&id).await { State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.smb_shares.remove(&id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
} }
} }
async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response { async fn api_smb_shares_scan(
match state.nfs.rescan(&id).await { State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.smb_shares.rescan(&id).await {
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
}
}
// ─── NFS share API (v0.4.67) ───────────────────────────────────────────────
//
// Parallel to the SMB shares API. The pure-Rust NFSv3 client
// (`nfs3_client`) gives us in-process listing and streaming, no
// subprocess. Unlike SMB, NFS-sourced ISOs support HTTP Range
// requests — NFSv3 READ3 takes an explicit offset.
async fn api_nfs_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "shares": state.nfs_shares.list() }))
}
async fn api_nfs_shares_add(
State(state): State<AppState>,
Json(req): Json<NfsAddRequest>,
) -> Response {
match state.nfs_shares.add(req).await {
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
}
}
async fn api_nfs_shares_remove(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.nfs_shares.remove(&id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn api_nfs_shares_scan(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.nfs_shares.rescan(&id).await {
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(), Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
} }
@@ -1849,9 +2046,21 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
state state
.metrics .metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64); .set_queue_counts(queue_entries.len() as u64, imaging as u64);
state // v0.4.67: gauge tracks reachable external-storage shares
.metrics // across both protocols (SMB userspace + NFSv3 in-process).
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64); let smb_ok = state
.smb_shares
.list()
.iter()
.filter(|m| m.reachable)
.count();
let nfs_ok = state
.nfs_shares
.list()
.iter()
.filter(|m| m.reachable)
.count();
state.metrics.set_nfs_active((smb_ok + nfs_ok) as u64);
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
let uptime = (now - state.started_at).whole_seconds().max(0) as u64; let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
+15 -6
View File
@@ -4,7 +4,7 @@ use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, SettingsStore, SsoStore, Metrics, SettingsStore, SsoStore,
}; };
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -44,11 +44,20 @@ pub struct AppState {
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where /// `smb_dir` at startup; `None` in pure-Linux-only deployments where
/// Windows support is not wired in. Settings toggle drives start/stop. /// Windows support is not wired in. Settings toggle drives start/stop.
pub smb: Option<Arc<SmbManager>>, pub smb: Option<Arc<SmbManager>>,
/// NFS share manager. Always present (mounting is opt-in by the /// v0.4.65: SMB share manager — userspace consumer of remote SMB
/// operator from the Storage tab); `add()` requires `mount.nfs` to be /// shares via Samba's `smbclient` CLI. Replaces the kernel-mount
/// available in the runtime image. Surfaces errors per-mount rather /// NFS path that v0.4.64 shipped; that path didn't work on hosts
/// than failing the global state. /// (Unraid, etc.) whose kernel ships without the nfs/cifs client
pub nfs: NfsManager, /// modules, and no container-side configuration could fix it.
/// `smbclient` does the SMB protocol over a plain TCP socket in
/// userspace — works in any container, no special caps required.
pub smb_shares: SmbShareManager,
/// v0.4.67: NFSv3 share manager — pure-Rust userspace consumer
/// via the `nfs3_client` crate. Ships alongside the SMB manager
/// so operators pick whichever protocol their NAS prefers.
/// In-process (no subprocess); supports HTTP Range requests on
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
pub nfs_shares: NfsShareManager,
/// Browser chunked upload state. Multipart uploads still go straight /// Browser chunked upload state. Multipart uploads still go straight
/// through `IsoStore`, but the UI uses sessions so large ISO transfers /// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files. /// can show deterministic progress and leave visible partial files.
+186 -56
View File
@@ -88,8 +88,14 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
"isos" | "images" => Ok(isos_text(state)), "isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)), "clients" => Ok(clients_text(state)),
"queue" => queue_command(state, tail).await, "queue" => queue_command(state, tail).await,
"nfs" => nfs_command(state, tail).await, // `smb` controls the outbound Samba server for Windows
// install media. `share` lists/manages remote SMB shares
// OpenPXE pulls ISOs from (v0.4.65). `nfs` is the parallel
// command for remote NFSv3 shares (v0.4.67, in-process via
// nfs3_client — not the v0.4.64 kernel-mount path).
"share" | "smb-share" => smb_share_command(state, tail).await,
"smb" => smb_command(state, tail).await, "smb" => smb_command(state, tail).await,
"nfs" => nfs_share_command(state, tail).await,
"log" => log_command(state, tail), "log" => log_command(state, tail),
"whoami" => Ok("operator".to_string()), "whoami" => Ok("operator".to_string()),
"echo" => Ok(tail.join(" ")), "echo" => Ok(tail.join(" ")),
@@ -107,18 +113,22 @@ fn status_text(s: &AppState) -> String {
let clients = s.clients.list(); let clients = s.clients.list();
let queue_entries = s.queue.list(); let queue_entries = s.queue.list();
let smb = s.smb.as_ref().map(|m| m.snapshot()); let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list(); let smb_shares = s.smb_shares.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count(); let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
// v0.4.67: NFSv3 sources too.
let nfs_shares = s.nfs_shares.list();
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
format!( format!(
"OpenPXE {ver}\n\ "OpenPXE {ver}\n\
base url: {base}\n\ base url: {base}\n\
interface: {nic}\n\ interface: {nic}\n\
uptime: {up}\n\ uptime: {up}\n\
isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\ isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs})\n\
clients: {n_clients}\n\ clients: {n_clients}\n\
queue: {n_entries}\n\ queue: {n_entries}\n\
smb: {smb}\n\ smb server: {smb}\n\
nfs mounts: {n_total} configured ({n_active} active)\n", smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\
nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n",
ver = env!("CARGO_PKG_VERSION"), ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url, base = s.public_base_url,
nic = if s.nic_name.is_empty() { nic = if s.nic_name.is_empty() {
@@ -132,15 +142,21 @@ fn status_text(s: &AppState) -> String {
.iter() .iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local)) .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local))
.count(), .count(),
n_smb = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. }))
.count(),
n_nfs = isos n_nfs = isos
.iter() .iter()
.filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local)) .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
.count(), .count(),
n_clients = clients.len(), n_clients = clients.len(),
n_entries = queue_entries.len(), n_entries = queue_entries.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")), smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = nfs.len(), n_smb_total = smb_shares.len(),
n_active = nfs_active, n_smb_active = smb_reachable,
n_nfs_total = nfs_shares.len(),
n_nfs_active = nfs_reachable,
) )
} }
@@ -158,7 +174,9 @@ fn isos_text(s: &AppState) -> String {
for i in isos { for i in isos {
let src = match i.source { let src = match i.source {
openpxe_iso_store::IsoSource::Local => "local".to_string(), openpxe_iso_store::IsoSource::Local => "local".to_string(),
openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"), openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
// v0.4.67: NFSv3 via in-process nfs3_client.
openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"),
}; };
let _ = writeln!( let _ = writeln!(
out, out,
@@ -264,31 +282,136 @@ async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String>
} }
} }
// ── nfs ──────────────────────────────────────────────────────────────── // ── share (v0.4.65: SMB shares) ─────────────────────────────────────────
async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> { async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) { match args.first().map(String::as_str) {
None | Some("list") => { None | Some("list") => {
let mounts = s.nfs.list(); let shares = s.smb_shares.list();
if mounts.is_empty() { if shares.is_empty() {
return Ok("(no NFS mounts configured)".into()); return Ok("(no SMB shares configured)".into());
} }
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(
out, out,
"{:<24} {:<6} {:<7} {:<6} TARGET", "{:<24} {:<7} {:<6} {:<6} TARGET",
"ID", "VER", "STATUS", "ISOS" "ID", "STATUS", "AUTH", "ISOS"
); );
for m in mounts { for m in shares {
let status = if m.mounted { "ok" } else { "down" }; let status = if m.reachable { "ok" } else { "down" };
let auth = if m.guest { "guest" } else { "user" };
let _ = writeln!( let _ = writeln!(
out, out,
"{:<24} {:<6} {:<7} {:<6} {}:{}", "{:<24} {:<7} {:<6} {:<6} //{}/{}",
truncate(&m.id, 24),
status,
auth,
m.iso_count,
m.server,
m.share,
);
if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}");
}
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
}
Ok(out)
}
Some("add") => {
// share add //server/share [guest|user:password]
let target = args
.get(1)
.ok_or_else(|| {
"usage: share add //server/share [guest|user:password]".to_string()
})?;
// Accept either `//server/share` (UNC-style) or
// `server:share` (shorter to type).
let stripped = target.trim_start_matches('/').trim_start_matches('\\');
let (server, share) = if let Some((s, p)) = stripped.split_once('/') {
(s, p)
} else if let Some((s, p)) = stripped.split_once(':') {
(s, p)
} else {
return Err("target must be '//server/share' or 'server:share'".into());
};
// Auth spec: "guest" or "user:password". Default: guest.
let auth = args.get(2).cloned().unwrap_or_else(|| "guest".into());
let (guest, username, password) = if auth == "guest" {
(true, None, None)
} else if let Some((u, p)) = auth.split_once(':') {
(false, Some(u.to_string()), Some(p.to_string()))
} else {
return Err("auth must be 'guest' or 'user:password'".into());
};
let req = openpxe_iso_store::SmbAddRequest {
server: server.to_string(),
share: share.to_string(),
username,
password,
guest,
port: None,
};
match s.smb_shares.add(req).await {
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
}
}
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: share remove <id>".to_string())?;
match s.smb_shares.remove(id).await {
Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("remove failed: {e}")),
}
}
Some("scan") => {
let id = args
.get(1)
.ok_or_else(|| "usage: share scan <id>".to_string())?;
match s.smb_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown share subcommand: {other}\ntry: share [list|add|remove|scan]"
)),
}
}
// ── nfs (v0.4.67: in-process NFSv3 via nfs3_client) ────────────────────
async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let shares = s.nfs_shares.list();
if shares.is_empty() {
return Ok("(no NFS shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} TARGET",
"ID", "STATUS", "ISOS"
);
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} {}:{}",
truncate(&m.id, 24), truncate(&m.id, 24),
match m.version {
openpxe_iso_store::NfsVersion::V3 => "v3",
openpxe_iso_store::NfsVersion::V41 => "v4.1",
},
status, status,
m.iso_count, m.iso_count,
m.server, m.server,
@@ -297,56 +420,58 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
if let Some(e) = m.last_error { if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}"); let _ = writeln!(out, " error: {e}");
} }
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
} }
Ok(out) Ok(out)
} }
Some("mount") => { Some("add") => {
// nfs mount <server>:<export> [v3|v41] [ro|rw] // nfs add <server>:<export> [port]
let target = args let target = args
.get(1) .get(1)
.ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?; .ok_or_else(|| "usage: nfs add <server>:<export> [port]".to_string())?;
let (server, export) = target let (server, export) = target
.split_once(':') .split_once(':')
.ok_or_else(|| "target must be 'server:/export'".to_string())?; .ok_or_else(|| "target must be 'server:/export'".to_string())?;
let version = match args.get(2).map(String::as_str) { let port = args.get(2).and_then(|s| s.parse::<u16>().ok());
Some("v3") => openpxe_iso_store::NfsVersion::V3,
Some("v41") | None => openpxe_iso_store::NfsVersion::V41,
Some(other) => {
return Err(format!("unknown nfs version: {other} (expect v3 or v41)"))
}
};
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let req = openpxe_iso_store::NfsAddRequest { let req = openpxe_iso_store::NfsAddRequest {
server: server.to_string(), server: server.to_string(),
export: export.to_string(), export: export.to_string(),
version, port,
read_only,
}; };
match s.nfs.add(req).await { match s.nfs_shares.add(req).await {
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)), Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => Err(format!("mount failed: {e}")), Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
} }
} }
Some("unmount") => { Some("remove") => {
let id = args let id = args
.get(1) .get(1)
.ok_or_else(|| "usage: nfs unmount <id>".to_string())?; .ok_or_else(|| "usage: nfs remove <id>".to_string())?;
match s.nfs.remove(id).await { match s.nfs_shares.remove(id).await {
Ok(()) => Ok(format!("unmounted {id}")), Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("unmount failed: {e}")), Err(e) => Err(format!("remove failed: {e}")),
} }
} }
Some("scan") => { Some("scan") => {
let id = args let id = args
.get(1) .get(1)
.ok_or_else(|| "usage: nfs scan <id>".to_string())?; .ok_or_else(|| "usage: nfs scan <id>".to_string())?;
match s.nfs.rescan(id).await { match s.nfs_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")), Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")), Err(e) => Err(format!("scan failed: {e}")),
} }
} }
Some(other) => Err(format!( Some(other) => Err(format!(
"unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]" "unknown nfs subcommand: {other}\ntry: nfs [list|add|remove|scan]"
)), )),
} }
} }
@@ -487,13 +612,18 @@ OpenPXE terminal — available commands:
queue assign-all <target> assign every waiting client queue assign-all <target> assign every waiting client
queue release <entry_id> release one queued client queue release <entry_id> release one queued client
nfs list list NFS mounts share list list configured SMB shares
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share share add //srv/share [auth] add an SMB share; auth = 'guest' or 'user:pass'
nfs unmount <id> unmount and forget a share share remove <id> forget an SMB share
nfs scan <id> re-scan a share for new ISOs share scan <id> re-list a share for new ISOs
smb status SMB (Samba) state nfs list list configured NFSv3 shares
smb start | stop | reload control smbd nfs add <srv>:<export> [port] add an NFSv3 share
nfs remove <id> forget an NFS share
nfs scan <id> re-list an NFS share for new ISOs
smb status outbound Samba state (Windows install media)
smb start | stop | reload control the outbound smbd
log clear drop the in-memory log ring buffer log clear drop the in-memory log ring buffer
log tail [n] show the last n buffered lines (default 20) log tail [n] show the last n buffered lines (default 20)
@@ -508,10 +638,10 @@ mod tests {
#[test] #[test]
fn shell_split_basic() { fn shell_split_basic() {
assert_eq!(shell_split(""), Vec::<String>::new()); assert_eq!(shell_split(""), Vec::<String>::new());
assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]); assert_eq!(shell_split("share list"), vec!["share", "list"]);
assert_eq!( assert_eq!(
shell_split("nfs mount 10.0.0.5:/srv v41 ro"), shell_split("share add //nas/isos guest"),
vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"] vec!["share", "add", "//nas/isos", "guest"]
); );
} }
+100 -14
View File
@@ -14,7 +14,7 @@ use axum::body::Body;
use axum::http::{header, Request, StatusCode}; use axum::http::{header, Request, StatusCode};
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore}; use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use openpxe_http_api::{build_router, AppState}; use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager}; use openpxe_iso_store::{IsoStore, NfsShareManager, SmbShareManager};
use tempfile::tempdir; use tempfile::tempdir;
use tower::ServiceExt; use tower::ServiceExt;
@@ -94,8 +94,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let clients = ClientRegistry::new(); let clients = ClientRegistry::new();
let queue = DeploymentQueue::new(); let queue = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(dir.path()); let settings = SettingsStore::load_or_default(dir.path());
let nfs = NfsManager::new(dir.path(), iso_store.clone()); let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root()); let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone());
let log_bus = LogBus::new(64); let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path()); let hosts = HostBindings::load_or_default(dir.path());
let boot_log = openpxe_core::BootLog::load_or_default(dir.path()); let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
@@ -117,7 +117,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
sso, sso,
metrics, metrics,
smb: None, smb: None,
nfs, smb_shares,
nfs_shares,
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus, log_bus,
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
@@ -464,35 +465,120 @@ async fn no_external_urls_in_generated_ipxe() {
// ── Phase 4 integration tests ──────────────────────────────────────────── // ── Phase 4 integration tests ────────────────────────────────────────────
// v0.4.65: kernel-mount NFS replaced with userspace SMB via smbclient.
#[tokio::test] #[tokio::test]
async fn nfs_add_with_bad_export_is_rejected() { async fn smb_share_add_with_missing_server_is_rejected() {
// Validation must happen before we shell out to /bin/mount — // Validation must run before we shell out to smbclient — otherwise
// otherwise the operator sees opaque kernel errors instead of a // operators see opaque NT_STATUS codes for what's really a typo.
// clear "your export must start with /" hint.
let (state, _dir) = build_state().await; let (state, _dir) = build_state().await;
let app = build_router(state); let app = build_router(state);
let (s, b) = post_json( let (s, b) = post_json(
&app, &app,
"/api/nfs", "/api/smb-shares",
r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#, r#"{"server":"","share":"isos","guest":true}"#,
) )
.await; .await;
assert_eq!(s, StatusCode::BAD_REQUEST); assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b); let msg = String::from_utf8_lossy(&b);
assert!( assert!(
msg.contains("export"), msg.to_lowercase().contains("server"),
"expected validation hint, got: {msg}" "expected validation hint, got: {msg}"
); );
} }
#[tokio::test] #[tokio::test]
async fn nfs_list_starts_empty() { async fn smb_share_add_requires_username_when_not_guest() {
let (state, _dir) = build_state().await; let (state, _dir) = build_state().await;
let app = build_router(state); let app = build_router(state);
let (s, b) = get(&app, "/api/nfs").await; let (s, b) = post_json(
&app,
"/api/smb-shares",
r#"{"server":"10.0.0.5","share":"isos","guest":false}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.to_lowercase().contains("username"),
"expected username hint, got: {msg}"
);
}
#[tokio::test]
async fn smb_share_add_rejects_paths_in_share_name() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/smb-shares",
r#"{"server":"10.0.0.5","share":"isos/subdir","guest":true}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.to_lowercase().contains("share name"),
"expected share name hint, got: {msg}"
);
}
#[tokio::test]
async fn smb_shares_list_starts_empty() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/api/smb-shares").await;
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["mounts"].as_array().unwrap().len(), 0); assert_eq!(v["shares"].as_array().unwrap().len(), 0);
}
// v0.4.67: NFSv3 share manager (parallel to SMB).
#[tokio::test]
async fn nfs_shares_list_starts_empty() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/api/nfs-shares").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["shares"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn nfs_shares_add_rejects_missing_server() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/nfs-shares",
r#"{"server":"","export":"/srv/isos"}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.to_lowercase().contains("server"),
"expected server hint, got: {msg}"
);
}
#[tokio::test]
async fn nfs_shares_add_rejects_export_without_leading_slash() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/nfs-shares",
r#"{"server":"10.0.0.5","export":"srv/isos"}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.to_lowercase().contains("export"),
"expected export-path hint, got: {msg}"
);
} }
#[tokio::test] #[tokio::test]
+8
View File
@@ -35,5 +35,13 @@ libc = "0.2"
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc. # encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
# v0.4.67: pure-Rust NFSv3 client for reading remote ISOs without a
# kernel mount. See crates/iso-store/src/nfs_share.rs for usage.
nfs3_client = { workspace = true }
nfs3_types = { workspace = true }
# Needed for the Stream trait that wraps the mpsc receiver feeding
# NFS read-loop bytes into axum's Body::from_stream.
futures = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
+13 -2
View File
@@ -18,16 +18,27 @@
pub mod entry; pub mod entry;
pub mod introspect; pub mod introspect;
pub mod nfs; pub mod nfs_share;
pub mod pxe_logo; pub mod pxe_logo;
pub mod smb; pub mod smb;
pub mod smb_share;
pub mod store; pub mod store;
pub mod windows; pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs}; pub use entry::{BootEntry, BootKind, KernelArgs};
pub use introspect::{DistroFamily, IntrospectionReport}; pub use introspect::{DistroFamily, IntrospectionReport};
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion}; // v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace
// `smbclient` CLI replaced it — works in any container (no
// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and
// every other PXE/imaging tool that supports network storage handles
// it.
pub use smb::{extract_windows_iso, SmbManager, SmbState}; pub use smb::{extract_windows_iso, SmbManager, SmbState};
pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, SmbStream};
// v0.4.67: NFS is back — this time as an in-process userspace NFSv3
// client (the `nfs3_client` crate) rather than a kernel mount. Same
// "works in any container" property as SMB, plus support for HTTP
// Range requests because NFSv3 READ3 takes an explicit offset.
pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream};
pub use store::{ pub use store::{
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
UploadHandle, UploadHandle,
-558
View File
@@ -1,558 +0,0 @@
//! NFS share manager.
//!
//! Lets an operator mount a remote NFS export as an ISO source instead of
//! uploading every ISO into the container's PVC. Supports NFSv3 and
//! NFSv4.1 — the two versions the user explicitly asked for.
//!
//! ## How it works
//!
//! 1. Operator submits a mount spec via the Storage tab:
//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`.
//! 2. We slugify a stable id, mkdir `<work_dir>/nfs/<id>/`, then shell out
//! to `/bin/mount -t nfs -o vers=...,ro,nolock server:export local`.
//! 3. On success we walk the mount point looking for `*.iso` files and
//! register each one with the `IsoStore` as an external source — same
//! introspection pipeline as a web upload, but no sha256 (the bytes
//! live on a remote machine; hashing them would suck them through the
//! network on every restart).
//! 4. On failure we record `last_error` on the spec and persist anyway
//! so the UI can show a row in red rather than silently dropping it.
//!
//! ## Operational notes
//!
//! - Mounting NFS inside a container needs `CAP_SYS_ADMIN` and the
//! `nfs-common` package. The default image ships these (see Dockerfile).
//! - On OpenShift, the SCC must allow `CAP_SYS_ADMIN`. The bundled SCC
//! doesn't — operators have to opt in by switching to a more privileged
//! SCC or running NFS mounts as a CSI driver outside the pod.
//! - Mount commands are issued sequentially under a single mutex to avoid
//! `mount` racing on the same target dir.
//!
//! ## Persistence
//!
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
//! re-mounted on startup. Mounts that fail to come back online keep their
//! spec and their `last_error` so the operator sees what happened.
use crate::introspect::{introspect, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::process::Command;
/// Wire-protocol versions we support. Keep this enum closed — silently
/// accepting "auto" or letting the kernel negotiate would mean operators
/// could never confirm which version is in use.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NfsVersion {
/// NFSv3 — UDP/TCP, separate `mountd` protocol. Required for many
/// older NAS appliances.
V3,
/// NFSv4.1 — single TCP port (2049), session-based. Modern default.
V41,
}
impl NfsVersion {
fn vers_arg(self) -> &'static str {
match self {
Self::V3 => "vers=3",
Self::V41 => "vers=4.1",
}
}
}
/// One configured mount. The id is generated from server+export so the
/// operator can re-add the same export idempotently.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NfsMount {
pub id: String,
pub server: String,
pub export: String,
pub version: NfsVersion,
/// Read-only by default — most ISO libraries are. Operators that need
/// write can flip this off but OpenPXE itself never writes.
pub read_only: bool,
/// Local mount point under `<work_dir>/nfs/`.
pub local_path: PathBuf,
/// Whether the mount is currently active.
pub mounted: bool,
/// Last error encountered on a `mount` or `umount` attempt; cleared on
/// success.
pub last_error: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_attempt: Option<OffsetDateTime>,
/// Number of `.iso` files found on the share (re-counted on each scan).
pub iso_count: u32,
}
/// Spec submitted by the UI. Server and export are normalized before use.
#[derive(Debug, Clone, Deserialize)]
pub struct NfsAddRequest {
pub server: String,
pub export: String,
#[serde(default = "default_version")]
pub version: NfsVersion,
#[serde(default = "default_ro")]
pub read_only: bool,
}
fn default_version() -> NfsVersion {
NfsVersion::V41
}
fn default_ro() -> bool {
true
}
#[derive(Debug, Default)]
struct Inner {
mounts: HashMap<String, NfsMount>,
}
/// Manages NFS mounts and surfaces them as ISO sources.
///
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct NfsManager {
work_root: Arc<PathBuf>,
state_path: Arc<PathBuf>,
inner: Arc<Mutex<Inner>>,
iso_store: IsoStore,
/// Single-writer lock around the actual `mount`/`umount` shell-outs;
/// avoids racing on the same target directory.
mount_lock: Arc<tokio::sync::Mutex<()>>,
}
impl NfsManager {
/// Construct a manager rooted at `work_dir`. Mount points live under
/// `<work_dir>/nfs/<id>/`. State persists to `<work_dir>/nfs.json`.
#[must_use]
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
let work_root = work_dir.join("nfs");
let state_path = work_dir.join("nfs.json");
Self {
work_root: Arc::new(work_root),
state_path: Arc::new(state_path),
inner: Arc::new(Mutex::new(Inner::default())),
iso_store,
mount_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
/// Where this manager mounts shares. Used by `IsoStore` to resolve
/// NFS-backed `IsoMeta`s to their on-disk path.
#[must_use]
pub fn mount_root(&self) -> PathBuf {
self.work_root.as_ref().clone()
}
/// Load persisted state and re-attempt every mount. Errors are logged
/// per-mount but never fail the call — startup must not block on a
/// remote NFS server being slow.
pub async fn load_and_remount(&self) -> Result<()> {
tokio::fs::create_dir_all(self.work_root.as_path()).await?;
let mounts = match tokio::fs::read_to_string(self.state_path.as_path()).await {
Ok(text) => serde_json::from_str::<Vec<NfsMount>>(&text).unwrap_or_default(),
Err(_) => Vec::new(),
};
for mut m in mounts {
// Always start from "not mounted" — the kernel state was lost
// when the process died. We'll try to remount each one.
m.mounted = false;
m.last_error = None;
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
if let Err(e) = self.try_mount(&m.id).await {
tracing::warn!(
target: "openpxe::nfs",
id = %m.id, error = %e,
"could not remount NFS share on startup"
);
}
}
Ok(())
}
/// Add a new mount. Returns the resulting `NfsMount` (with `mounted`
/// reflecting reality) or an error if the spec was invalid.
pub async fn add(&self, req: NfsAddRequest) -> Result<NfsMount> {
let server = req.server.trim().to_string();
let export = req.export.trim().to_string();
if server.is_empty() {
return Err(Error::Invalid("server is required".into()));
}
if !export.starts_with('/') {
return Err(Error::Invalid("export path must start with '/'".into()));
}
let id = mount_id(&server, &export);
let local_path = self.work_root.join(&id);
tokio::fs::create_dir_all(&local_path).await?;
let mount = NfsMount {
id: id.clone(),
server,
export,
version: req.version,
read_only: req.read_only,
local_path,
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
self.inner.lock().mounts.insert(id.clone(), mount);
self.persist_locked();
self.try_mount(&id).await?;
Ok(self.get(&id).expect("mount just inserted"))
}
/// Unmount and forget a share. Removes any ISOs it contributed from
/// the IsoStore and deletes the local mount point. Idempotent.
pub async fn remove(&self, id: &str) -> Result<()> {
// Best-effort umount; even if it fails (e.g. server unreachable)
// we still want to drop the in-memory record.
let _ = self.umount_one(id).await;
let local_path = {
let mut g = self.inner.lock();
g.mounts.remove(id).map(|m| m.local_path)
};
self.persist_locked();
self.iso_store.drop_external_source(id);
if let Some(p) = local_path {
// rmdir only — never recurse, the mount could still be live
// on some kernel error path and we don't want to nuke a
// remote filesystem.
let _ = tokio::fs::remove_dir(&p).await;
}
Ok(())
}
/// Re-scan a mounted share for ISOs, refreshing the IsoStore.
pub async fn rescan(&self, id: &str) -> Result<u32> {
let mount = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
if !mount.mounted {
return Err(Error::Invalid(format!("mount '{id}' is not active")));
}
let count = self.scan_and_register(&mount).await?;
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.iso_count = count;
}
self.persist_locked();
Ok(count)
}
/// Snapshot of every configured mount.
#[must_use]
pub fn list(&self) -> Vec<NfsMount> {
let g = self.inner.lock();
let mut v: Vec<_> = g.mounts.values().cloned().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
/// Look up a single mount by id.
#[must_use]
pub fn get(&self, id: &str) -> Option<NfsMount> {
self.inner.lock().mounts.get(id).cloned()
}
// ── internals ─────────────────────────────────────────────────────
async fn try_mount(&self, id: &str) -> Result<()> {
let _g = self.mount_lock.lock().await;
let m = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
let now = OffsetDateTime::now_utc();
// Already mounted? Skip — `mount` would error on a busy target
// and confuse the operator's UI status.
if is_mountpoint(&m.local_path).await {
self.update_status(id, true, None, now);
// Even though already mounted, we still want a fresh ISO count.
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
return Ok(());
}
let opts = mount_options(&m);
let target = format!("{}:{}", m.server, m.export);
let output = Command::new("mount")
.arg("-t")
.arg("nfs")
.arg("-o")
.arg(&opts)
.arg(&target)
.arg(&m.local_path)
.output()
.await;
match output {
Ok(out) if out.status.success() => {
tracing::info!(
target: "openpxe::nfs",
id = %id, server = %m.server, export = %m.export,
version = ?m.version,
"NFS mount succeeded"
);
self.update_status(id, true, None, now);
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
Ok(())
}
Ok(out) => {
let err = format!(
"mount exit {}: {}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim()
);
tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
Err(e) => {
let err = format!("could not exec /bin/mount: {e}");
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
}
}
async fn umount_one(&self, id: &str) -> Result<()> {
let _g = self.mount_lock.lock().await;
let Some(m) = self.get(id) else { return Ok(()) };
if !is_mountpoint(&m.local_path).await {
self.update_status(id, false, None, OffsetDateTime::now_utc());
return Ok(());
}
// -l = lazy: detach immediately, finish when no process has a
// handle. Important if a stale ISO read is still in flight.
let out = Command::new("umount")
.arg("-l")
.arg(&m.local_path)
.output()
.await;
match out {
Ok(o) if o.status.success() => {
self.update_status(id, false, None, OffsetDateTime::now_utc());
Ok(())
}
Ok(o) => {
let e = format!(
"umount exit {}: {}",
o.status.code().unwrap_or(-1),
String::from_utf8_lossy(&o.stderr).trim()
);
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
Err(Error::Invalid(e))
}
Err(e) => {
let e = format!("could not exec /bin/umount: {e}");
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
Err(Error::Invalid(e))
}
}
}
/// Walk the mount point for `*.iso` files, introspect each one, and
/// register it with the IsoStore as an NFS-sourced entry. Returns the
/// count of ISOs registered.
async fn scan_and_register(&self, m: &NfsMount) -> Result<u32> {
// Drop any prior entries from this mount before re-registering, so
// a removed file disappears from the store.
self.iso_store.drop_external_source(&m.id);
let mut walker = tokio::fs::read_dir(&m.local_path).await?;
let mut count = 0u32;
while let Some(entry) = walker.next_entry().await? {
let p = entry.path();
if p.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
!= Some("iso")
{
continue;
}
let filename = match p.file_name().and_then(|s| s.to_str()) {
Some(f) => f.to_string(),
None => continue,
};
let size = tokio::fs::metadata(&p).await?.len();
// Introspection is sync + IO-bound (reads ISO9660 PVD). Push
// it to a blocking thread so the runtime stays responsive on
// a slow share.
let p_owned = p.clone();
let report: IntrospectionReport =
tokio::task::spawn_blocking(move || introspect(&p_owned))
.await
.map_err(|e| Error::Other(e.into()))?;
let id = format!("nfs-{}-{}", m.id, slugify_str(&filename));
let boot_entries = generate_boot_entries_for(&id, &filename, &report);
let source = IsoSource::Nfs {
mount_id: m.id.clone(),
relative_path: filename.clone(),
};
self.iso_store
.register_external(id, filename, size, report, boot_entries, source);
count += 1;
}
Ok(count)
}
fn update_status(&self, id: &str, mounted: bool, err: Option<String>, ts: OffsetDateTime) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.mounted = mounted;
m.last_error = err;
m.last_attempt = Some(ts);
}
self.persist_locked();
}
fn update_iso_count(&self, id: &str, count: u32) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.iso_count = count;
}
self.persist_locked();
}
/// Atomically replace the on-disk JSON with the current state.
/// Persistence errors are logged, never propagated — settings live in
/// memory authoritatively, matching the SettingsStore policy.
fn persist_locked(&self) {
let mounts: Vec<NfsMount> = self.inner.lock().mounts.values().cloned().collect();
let path = self.state_path.as_path();
let tmp = path.with_extension("json.tmp");
let body = match serde_json::to_vec_pretty(&mounts) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::nfs", "serialize NFS state: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::nfs", "write NFS state tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "openpxe::nfs", "rename NFS state: {e}");
}
}
}
fn mount_options(m: &NfsMount) -> String {
let mut opts = vec![m.version.vers_arg().to_string()];
if m.read_only {
opts.push("ro".into());
} else {
opts.push("rw".into());
}
// `nolock` for v3 — many storage appliances disable lockd; we don't
// need locking for read-only ISO access anyway.
if matches!(m.version, NfsVersion::V3) {
opts.push("nolock".into());
}
// Soft mount with a generous timeout — better to surface a hung share
// as a user-visible error than to wedge the iPXE client forever on a
// dead NFS server.
opts.push("soft".into());
opts.push("timeo=100".into());
opts.push("retrans=3".into());
opts.join(",")
}
fn mount_id(server: &str, export: &str) -> String {
let raw = format!("{server}{export}");
slugify_str(&raw)
}
/// Detect whether `path` is currently a mount point. We don't have
/// `is_mountpoint(2)`, so compare the parent's device id to the dir's;
/// if they differ the dir is a mount.
async fn is_mountpoint(path: &Path) -> bool {
let Some(parent) = path.parent() else {
return false;
};
let Ok(m1) = tokio::fs::metadata(path).await else {
return false;
};
let Ok(m2) = tokio::fs::metadata(parent).await else {
return false;
};
use std::os::unix::fs::MetadataExt;
m1.dev() != m2.dev()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_arg() {
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
assert_eq!(NfsVersion::V41.vers_arg(), "vers=4.1");
}
#[test]
fn mount_options_v3_includes_nolock() {
let m = NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version: NfsVersion::V3,
read_only: true,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=3"));
assert!(opts.contains("ro"));
assert!(opts.contains("nolock"));
assert!(opts.contains("soft"));
}
#[test]
fn mount_options_v41_no_nolock() {
let m = NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version: NfsVersion::V41,
read_only: false,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=4.1"));
assert!(opts.contains("rw"));
assert!(!opts.contains("nolock"));
}
#[test]
fn mount_id_is_stable_and_safe() {
let a = mount_id("10.0.0.5", "/srv/isos");
let b = mount_id("10.0.0.5", "/srv/isos");
assert_eq!(a, b);
assert!(!a.contains('/'));
assert!(!a.contains('.'));
}
}
+992
View File
@@ -0,0 +1,992 @@
//! NFSv3 share consumer — in-process userspace client.
//!
//! v0.4.67 brings NFS back, this time *the right way*: a pure-Rust
//! NFSv3 client (`nfs3_client` from the xetdata family of crates)
//! that speaks the NFS protocol entirely in userspace over TCP. No
//! `mount.nfs`, no kernel modules, no `CAP_SYS_ADMIN`. Works in every
//! container the SMB path (v0.4.65) works in, including Unraid and
//! restricted-SCC OpenShift.
//!
//! ## How it differs from the v0.4.64 NFS path
//!
//! v0.4.64 shelled out to `mount(8)` and asked the kernel to attach
//! the remote share to the local filesystem. That required nfs client
//! modules on the **host** kernel. Containers that ran on hosts
//! without those modules — Unraid being the dominant case — failed
//! with `mount.nfs: failed to apply fstab options` no matter what
//! capabilities they were granted.
//!
//! v0.4.67 doesn't touch the kernel. The client opens a TCP socket to
//! the NFS server, performs the MOUNT3 RPC to get a root file handle,
//! and uses NFSv3 READDIR3 / LOOKUP3 / READ3 / GETATTR3 to walk the
//! share and stream files. The kernel sees plain TCP traffic, nothing
//! else.
//!
//! ## How it differs from the v0.4.65 SMB path
//!
//! SMB ships in v0.4.65 via the `smbclient` CLI subprocess. NFS ships
//! here via a Rust crate — no subprocess, no PATH lookup, no parsing
//! of human-formatted output. That also means:
//!
//! - **Range requests work** for NFS-sourced ISOs. NFSv3 READ3
//! takes an explicit offset, so the HTTP handler can seek into
//! the middle of a 5 GB ISO without downloading what comes
//! before. SMB-sourced ISOs still return 416 for ranges because
//! `smbclient -c 'get file -'` is a sequential stream.
//! - **Introspection could work** (we could LOOKUP the ISO9660 PVD
//! at offset 0x8000 and parse the volume label). For v0.4.67 we
//! don't yet — same as SMB, NFS-sourced ISOs register with an
//! `Unknown` family and fall back to generic sanboot. A follow-up
//! can add bounded read-based detection.
//!
//! ## How it's the same as SMB
//!
//! The public surface is deliberately parallel: `NfsShareManager`
//! mirrors `SmbShareManager`, `NfsShare` mirrors `SmbShare`, the API
//! responses use the same `{error, stderr, hint}` shape so the UI's
//! storage tab renders both protocols through one code path. The
//! state file (`<work_dir>/nfs_shares.json`) sits next to
//! `smb_shares.json`.
//!
//! ## No auth
//!
//! NFSv3 uses AUTH_SYS by default — the client tells the server "I'm
//! uid X, gid Y" and the server decides whether to trust that. Most
//! NAS appliances exposing ISO libraries via NFS gate access by
//! **client IP address** rather than uid, so there's nothing for the
//! UI to ask for. (If a future server needs Kerberos or non-default
//! uid mapping we can add those, but for ISO read access nobody does.)
use crate::introspect::{DistroFamily, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use bytes::Bytes;
use nfs3_client::tokio::TokioConnector;
use nfs3_client::Nfs3ConnectionBuilder;
use nfs3_types::nfs3::{
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args,
Nfs3Result, READ3args, READDIR3args,
};
use nfs3_types::xdr_codec::Opaque;
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
/// Default TCP port for the NFSv3 protocol. The classic split (111
/// portmapper + dynamic mountd) is supported by the underlying crate;
/// for direct connection-mode NAS appliances 2049 is what answers.
const DEFAULT_NFS_PORT: u16 = 2049;
/// How long we wait for the initial MOUNT3 + portmap handshake before
/// giving up. Mirrors the SMB pre-flight probe shape so the UI's
/// timeout banner reads consistently across protocols.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
/// NFSv3 READ3 chunk size. The protocol's hard cap is 1 MiB per
/// reply; 64 KiB is a polite default that nearly every server hands
/// back without fragmentation and keeps in-flight memory bounded
/// during a streaming response.
const READ_CHUNK_BYTES: u32 = 64 * 1024;
/// Bound on the in-flight queue between the read-loop task and the
/// HTTP body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream —
/// enough to keep the network pipe full without letting a slow
/// client park gigabytes of decoded ISO in RAM.
const STREAM_BUFFER_DEPTH: usize = 16;
/// One configured NFS share. The id is derived from server+export so
/// re-adding the same coordinates is idempotent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NfsShare {
pub id: String,
pub server: String,
/// Export path on the server (e.g. "/srv/isos"). Must start with
/// "/" to match the NFS server's view; we validate on add.
pub export: String,
/// TCP port — 2049 unless the operator overrode it.
#[serde(default = "default_port")]
pub port: u16,
/// Most recent error talking to the share, or `None` on success.
pub last_error: Option<String>,
/// Operator-friendly translation of `last_error`. None when we
/// don't have a friendlier rendition.
pub last_hint: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_scan: Option<OffsetDateTime>,
pub iso_count: u32,
/// True after a successful scan, false on failure. Drives the
/// UI dot.
pub reachable: bool,
}
/// Submission from the UI / API.
#[derive(Debug, Clone, Deserialize)]
pub struct NfsAddRequest {
pub server: String,
pub export: String,
#[serde(default)]
pub port: Option<u16>,
}
fn default_port() -> u16 {
DEFAULT_NFS_PORT
}
/// Structured error surfaced to the API and rendered in the UI. Same
/// shape as `SmbShareError` so the storage tab uses one rendering
/// path for both protocols.
#[derive(Debug, Clone, Serialize)]
pub struct NfsShareError {
pub error: String,
pub stderr: String,
pub hint: Option<String>,
}
impl NfsShareError {
fn from_raw(error: impl Into<String>, stderr: impl Into<String>) -> Self {
let stderr = stderr.into();
let error = error.into();
let hint = hint_for(&stderr).or_else(|| hint_for(&error));
Self {
error,
stderr,
hint,
}
}
}
#[derive(Debug, Default)]
struct Inner {
shares: HashMap<String, NfsShare>,
}
/// Manages NFS shares. Cheap to clone — internal state is
/// `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct NfsShareManager {
state_path: Arc<PathBuf>,
inner: Arc<Mutex<Inner>>,
iso_store: IsoStore,
/// Serializes scan operations on the same manager. Each scan
/// opens its own NFS connection so concurrency isn't a hard
/// requirement, but serializing keeps log output predictable.
op_lock: Arc<tokio::sync::Mutex<()>>,
}
impl NfsShareManager {
/// Construct a manager. State persists to
/// `<work_dir>/nfs_shares.json`.
#[must_use]
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
let state_path = work_dir.join("nfs_shares.json");
Self {
state_path: Arc::new(state_path),
inner: Arc::new(Mutex::new(Inner::default())),
iso_store,
op_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
/// Load persisted state and re-scan every share. Per-share
/// failures are logged but never propagated — startup must not
/// block on a single offline server.
pub async fn load_and_rescan(&self) -> Result<()> {
let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await {
Ok(text) => serde_json::from_str::<Vec<NfsShare>>(&text).unwrap_or_default(),
Err(_) => Vec::new(),
};
for mut s in shares {
s.last_error = None;
s.last_hint = None;
s.reachable = false;
self.inner.lock().shares.insert(s.id.clone(), s.clone());
if let Err(e) = self.rescan_inner(&s.id).await {
tracing::warn!(
target: "openpxe::nfs",
id = %s.id, server = %s.server, export = %s.export,
"rescan on startup failed: {e}"
);
}
}
Ok(())
}
/// Register an NFS share. Validates, probes connectivity by
/// performing a real MOUNT3 + READDIR3, and registers the
/// resulting ISOs with the store.
pub async fn add(
&self,
req: NfsAddRequest,
) -> std::result::Result<NfsShare, NfsShareError> {
let server = normalize_server(&req.server);
let export = req.export.trim().to_string();
if server.is_empty() {
return Err(NfsShareError::from_raw("server is required", ""));
}
if !export.starts_with('/') {
return Err(NfsShareError::from_raw(
"export path must start with '/' (e.g. /srv/isos)",
"",
));
}
if server.contains('\0') || export.contains('\0') {
return Err(NfsShareError::from_raw("NUL bytes are not allowed", ""));
}
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_NFS_PORT);
let id = share_id(&server, &export);
let spec = NfsShare {
id: id.clone(),
server,
export,
port,
last_error: None,
last_hint: None,
last_scan: None,
iso_count: 0,
reachable: false,
};
self.inner.lock().shares.insert(id.clone(), spec);
self.persist_locked();
if let Err(e) = self.rescan_inner(&id).await {
let m = self.get(&id);
return Err(NfsShareError {
error: m.as_ref().and_then(|m| m.last_error.clone())
.unwrap_or_else(|| e.to_string()),
stderr: String::new(),
hint: m.and_then(|m| m.last_hint),
});
}
Ok(self.get(&id).expect("just inserted"))
}
/// Remove a share. Drops every ISO sourced from it. Idempotent.
/// Async for symmetry with [`SmbShareManager::remove`] — the
/// SMB version is async because it tears down a credentials
/// file; NFS has nothing to clean up but we keep the signature
/// uniform so the call sites in app.rs / terminal.rs match.
#[allow(clippy::unused_async)]
pub async fn remove(&self, id: &str) -> Result<()> {
let removed = self.inner.lock().shares.remove(id).is_some();
if removed {
self.iso_store.drop_external_source(id);
self.persist_locked();
}
Ok(())
}
/// Re-walk a share for new / removed ISOs.
pub async fn rescan(&self, id: &str) -> Result<u32> {
self.rescan_inner(id).await
}
#[must_use]
pub fn list(&self) -> Vec<NfsShare> {
let g = self.inner.lock();
let mut v: Vec<_> = g.shares.values().cloned().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
#[must_use]
pub fn get(&self, id: &str) -> Option<NfsShare> {
self.inner.lock().shares.get(id).cloned()
}
/// Open a byte stream reading `filename` out of share `share_id`,
/// starting at `start_offset` and reading at most `max_len`
/// bytes. The returned stream yields `Bytes` chunks (≤
/// `READ_CHUNK_BYTES`) and ends at EOF, after `max_len` bytes, or
/// on the first transport error.
///
/// Used by the HTTP ISO download handler. Supports HTTP Range
/// requests because NFSv3 READ3 takes an explicit offset — this
/// is the protocol-level advantage NFS has over the SMB
/// userspace path.
///
/// Kept `async` for symmetry with [`SmbShareManager::stream_iso`]
/// even though the body doesn't await today — a future
/// refinement (e.g. throttling, connection pooling) will need to
/// await without changing call sites.
#[allow(clippy::unused_async)]
pub async fn stream_iso(
&self,
share_id: &str,
filename: &str,
start_offset: u64,
max_len: Option<u64>,
) -> Result<NfsStream> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such NFS share '{share_id}'")))?;
// Defensive: NFSv3 LOOKUP3 takes a single name relative to
// the export root, not a path. We don't support nested
// directories in v0.4.67 — ISOs live at the top of the share.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
}
let (tx, rx) =
tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
let server = share.server.clone();
let export = share.export.clone();
let port = share.port;
let fname = filename.to_string();
// Spawn a task that owns the NFS connection. Owning the
// connection inside the spawn means we don't have to worry
// about borrowing across awaits or sharing the connection
// between scan and stream — each stream gets its own.
let task = tokio::spawn(async move {
let result = stream_loop(
&server,
&export,
port,
&fname,
start_offset,
max_len,
tx.clone(),
)
.await;
if let Err(e) = result {
// Best-effort signal of the error to the consumer.
// If the receiver has already dropped we just exit.
let _ = tx
.send(Err(std::io::Error::other(e.to_string())))
.await;
}
});
Ok(NfsStream {
rx,
_task: task,
})
}
// ── internals ─────────────────────────────────────────────────────
async fn rescan_inner(&self, id: &str) -> Result<u32> {
let _g = self.op_lock.lock().await;
let share = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such share '{id}'")))?;
let now = OffsetDateTime::now_utc();
// Drop prior entries so a deleted file disappears from the
// store on the next scan.
self.iso_store.drop_external_source(id);
let listing = match list_isos(&share.server, &share.export, share.port).await {
Ok(l) => l,
Err(err) => {
let stderr = err.to_string();
let hint = hint_for(&stderr);
self.update_status(id, 0, false, Some(stderr.clone()), hint, now);
return Err(Error::Invalid(stderr));
}
};
let mut count = 0u32;
for entry in listing {
let iso_id = format!("nfs-{}-{}", share.id, slugify_str(&entry.filename));
// Same approach as SMB: no real introspection over the
// network in v0.4.67. The boot-entry generator falls back
// to filename-based sanboot detection.
let report = IntrospectionReport {
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: Vec::new(),
has_boot_wim: false,
};
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
let source = IsoSource::Nfs {
share_id: share.id.clone(),
relative_path: entry.filename.clone(),
};
self.iso_store.register_external(
iso_id,
entry.filename,
entry.size,
report,
boot_entries,
source,
);
count += 1;
}
self.update_status(id, count, true, None, None, now);
tracing::info!(
target: "openpxe::nfs",
id = %id, server = %share.server, export = %share.export,
iso_count = count,
"NFS share scanned"
);
Ok(count)
}
fn update_status(
&self,
id: &str,
iso_count: u32,
reachable: bool,
err: Option<String>,
hint: Option<String>,
ts: OffsetDateTime,
) {
if let Some(s) = self.inner.lock().shares.get_mut(id) {
s.iso_count = iso_count;
s.reachable = reachable;
s.last_error = err;
s.last_hint = hint;
s.last_scan = Some(ts);
}
self.persist_locked();
}
fn persist_locked(&self) {
let shares: Vec<NfsShare> = self.inner.lock().shares.values().cloned().collect();
let path = self.state_path.as_path();
let tmp = path.with_extension("json.tmp");
let body = match serde_json::to_vec_pretty(&shares) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::nfs", "serialize: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::nfs", "write tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "openpxe::nfs", "rename: {e}");
}
}
}
/// HTTP body stream for an NFS-sourced ISO read.
///
/// Implements `Stream<Item = io::Result<Bytes>>` so axum can convert
/// it into a response body via `Body::from_stream`.
#[derive(Debug)]
pub struct NfsStream {
rx: tokio::sync::mpsc::Receiver<std::io::Result<Bytes>>,
/// Kept alive so the read-loop task isn't dropped while the HTTP
/// client is still consuming bytes. Dropping the stream cancels
/// the task, which is the right behavior on client disconnect.
_task: tokio::task::JoinHandle<()>,
}
impl futures::Stream for NfsStream {
type Item = std::io::Result<Bytes>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.rx.poll_recv(cx)
}
}
#[derive(Debug, Clone)]
struct NfsListEntry {
filename: String,
size: u64,
}
/// Connect, READDIR the export root, look up each `*.iso` to get its
/// size + file handle. Returns a flat list. Errors are returned with
/// a human-readable message; the caller decides how to surface them.
async fn list_isos(
server: &str,
export: &str,
port: u16,
) -> std::result::Result<Vec<NfsListEntry>, NfsClientError> {
// 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();
let mut cookie: u64 = 0;
let mut cookieverf = nfs3::cookieverf3::default();
loop {
let res = conn
.readdir(&READDIR3args {
dir: root.clone(),
cookie,
cookieverf,
count: 32 * 1024,
})
.await
.map_err(NfsClientError::Rpc)?;
let ok = match res {
Nfs3Result::Ok(ok) => ok,
Nfs3Result::Err((status, _)) => {
return Err(NfsClientError::Nfsstat(status_label(status)));
}
};
cookieverf = ok.cookieverf;
let eof = ok.reply.eof;
let dir_entries: Vec<entry3<'_>> = ok.reply.entries.0;
let next_cookie = dir_entries.last().map(|e| e.cookie);
for entry in dir_entries {
// entry.name is `filename3<'a>(Opaque<'a>)` — XDR opaque
// bytes. Decode to UTF-8 best-effort.
let name_bytes = entry.name.0.as_ref();
let Ok(name) = std::str::from_utf8(name_bytes) else {
continue;
};
if name == "." || name == ".." {
continue;
}
if !name.to_ascii_lowercase().ends_with(".iso") {
continue;
}
// Look up the file to get its size + verify it's a
// regular file (not a symlink / directory matching the
// .iso pattern).
let lookup = conn
.lookup(&LOOKUP3args {
what: diropargs3 {
dir: root.clone(),
name: filename3(Opaque::borrowed(name_bytes)),
},
})
.await
.map_err(NfsClientError::Rpc)?;
let lookup_ok = match lookup {
Nfs3Result::Ok(o) => o,
Nfs3Result::Err(_) => continue,
};
let getattr = conn
.getattr(&GETATTR3args {
object: lookup_ok.object,
})
.await
.map_err(NfsClientError::Rpc)?;
let attrs = match getattr {
Nfs3Result::Ok(o) => o.obj_attributes,
Nfs3Result::Err(_) => continue,
};
// ftype3::NF3REG == 1 (regular file). Skip everything
// else — directories, symlinks, devices.
if attrs.type_ as u32 != nfs3::ftype3::NF3REG as u32 {
continue;
}
entries.push(NfsListEntry {
filename: name.to_string(),
size: attrs.size,
});
}
if eof {
break;
}
match next_cookie {
Some(c) if c != 0 => cookie = c,
_ => break,
}
}
let _ = conn.unmount().await;
Ok(entries)
}
async fn stream_loop(
server: &str,
export: &str,
port: u16,
filename: &str,
start_offset: u64,
max_len: Option<u64>,
tx: tokio::sync::mpsc::Sender<std::io::Result<Bytes>>,
) -> std::result::Result<(), NfsClientError> {
let mut conn = build_connection(server, export, port).await?;
let root = conn.root_nfs_fh3();
// Look up the file to get its handle.
let name_bytes = filename.as_bytes();
let lookup = conn
.lookup(&LOOKUP3args {
what: diropargs3 {
dir: root,
name: filename3(Opaque::borrowed(name_bytes)),
},
})
.await
.map_err(NfsClientError::Rpc)?;
let lookup_ok = match lookup {
Nfs3Result::Ok(o) => o,
Nfs3Result::Err((status, _)) => {
return Err(NfsClientError::Nfsstat(status_label(status)));
}
};
let file_handle: nfs_fh3 = lookup_ok.object;
let mut offset = start_offset;
let mut remaining = max_len;
loop {
if let Some(r) = remaining {
if r == 0 {
break;
}
}
// Cap the chunk at READ_CHUNK_BYTES and at the remaining budget.
let chunk = match remaining {
Some(r) if r < u64::from(READ_CHUNK_BYTES) => r as u32,
_ => READ_CHUNK_BYTES,
};
let res = conn
.read(&READ3args {
file: file_handle.clone(),
offset,
count: chunk,
})
.await
.map_err(NfsClientError::Rpc)?;
let ok = match res {
Nfs3Result::Ok(o) => o,
Nfs3Result::Err((status, _)) => {
return Err(NfsClientError::Nfsstat(status_label(status)));
}
};
let bytes = Bytes::copy_from_slice(ok.data.as_ref());
let bytes_len = bytes.len() as u64;
if tx.send(Ok(bytes)).await.is_err() {
// HTTP client dropped — abort gracefully.
break;
}
offset += bytes_len;
if let Some(r) = remaining.as_mut() {
*r = r.saturating_sub(bytes_len);
}
if ok.eof {
break;
}
// Defensive: a server that returns 0 bytes without EOF
// would have us busy-looping. Bail out instead.
if bytes_len == 0 {
break;
}
}
let _ = conn.unmount().await;
Ok(())
}
/// 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,
port: u16,
) -> std::result::Result<
nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>,
NfsClientError,
> {
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();
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)]
enum NfsClientError {
Connect(String),
Timeout(String, u16),
Rpc(nfs3_client::RpcError),
Nfsstat(String),
}
impl std::fmt::Display for NfsClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Connect(msg) => write!(f, "connect failed: {msg}"),
Self::Timeout(host, port) => write!(
f,
"connect timed out after {}s talking to {host}:{port}",
CONNECT_TIMEOUT.as_secs()
),
Self::Rpc(e) => write!(f, "RPC failure: {e}"),
Self::Nfsstat(s) => write!(f, "NFS server returned {s}"),
}
}
}
impl std::error::Error for NfsClientError {}
/// Best-effort label for an `nfsstat3` so the UI shows a readable
/// name like `NFS3ERR_ACCES` instead of a magic number.
fn status_label(status: nfs3::nfsstat3) -> String {
use nfs3::nfsstat3 as S;
let name = match status {
S::NFS3_OK => "NFS3_OK",
S::NFS3ERR_PERM => "NFS3ERR_PERM",
S::NFS3ERR_NOENT => "NFS3ERR_NOENT",
S::NFS3ERR_IO => "NFS3ERR_IO",
S::NFS3ERR_NXIO => "NFS3ERR_NXIO",
S::NFS3ERR_ACCES => "NFS3ERR_ACCES",
S::NFS3ERR_EXIST => "NFS3ERR_EXIST",
S::NFS3ERR_XDEV => "NFS3ERR_XDEV",
S::NFS3ERR_NODEV => "NFS3ERR_NODEV",
S::NFS3ERR_NOTDIR => "NFS3ERR_NOTDIR",
S::NFS3ERR_ISDIR => "NFS3ERR_ISDIR",
S::NFS3ERR_INVAL => "NFS3ERR_INVAL",
S::NFS3ERR_FBIG => "NFS3ERR_FBIG",
S::NFS3ERR_NOSPC => "NFS3ERR_NOSPC",
S::NFS3ERR_ROFS => "NFS3ERR_ROFS",
S::NFS3ERR_MLINK => "NFS3ERR_MLINK",
S::NFS3ERR_NAMETOOLONG => "NFS3ERR_NAMETOOLONG",
S::NFS3ERR_NOTEMPTY => "NFS3ERR_NOTEMPTY",
S::NFS3ERR_DQUOT => "NFS3ERR_DQUOT",
S::NFS3ERR_STALE => "NFS3ERR_STALE",
S::NFS3ERR_REMOTE => "NFS3ERR_REMOTE",
S::NFS3ERR_BADHANDLE => "NFS3ERR_BADHANDLE",
S::NFS3ERR_NOT_SYNC => "NFS3ERR_NOT_SYNC",
S::NFS3ERR_BAD_COOKIE => "NFS3ERR_BAD_COOKIE",
S::NFS3ERR_NOTSUPP => "NFS3ERR_NOTSUPP",
S::NFS3ERR_TOOSMALL => "NFS3ERR_TOOSMALL",
S::NFS3ERR_SERVERFAULT => "NFS3ERR_SERVERFAULT",
S::NFS3ERR_BADTYPE => "NFS3ERR_BADTYPE",
S::NFS3ERR_JUKEBOX => "NFS3ERR_JUKEBOX",
};
name.to_string()
}
/// Translate well-known NFS error strings into actionable hints for
/// the UI. Mirrors the SMB hint table in spirit; the patterns are
/// different because NFS errors travel via NFS3ERR_* codes plus
/// transport-level messages from the Rust crate.
fn hint_for(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
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")
|| 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 — \
UniFi UNAS Pro exposes shares under /var/nfs/shared/<name>)."
.into(),
)
} else if s.contains("nfs3err_stale") || s.contains("nfs3err_badhandle") {
Some(
"the server's view of the share changed under us. Re-scan; \
if that doesn't help, remove and re-add the share."
.into(),
)
} else if s.contains("connect timed out")
|| s.contains("timed out")
|| s.contains("connection timed out")
{
Some(
"no answer from the server within the connect timeout. \
Verify the IP, the port (default 2049), and any firewall \
between OpenPXE and the NAS."
.into(),
)
} else if s.contains("connection refused") {
Some(
"the NFS service isn't accepting connections on this port. \
Make sure nfsd is running on the server and (for v3) the \
portmapper on port 111 is reachable."
.into(),
)
} else if s.contains("no route to host") || s.contains("network is unreachable") {
Some(
"the server isn't reachable on this network. Check the IP \
and routes."
.into(),
)
} else if s.contains("mount") && s.contains("denied") {
Some(
"MOUNT3 was denied. The classic cause is that the export's \
`rw=<host>` / `ro=<host>` list doesn't include this client. \
Some servers also require `insecure` in /etc/exports for \
non-privileged source ports — which is what this client \
uses (we run as uid 10001, no privileged-port capability)."
.into(),
)
} else {
None
}
}
fn share_id(server: &str, export: &str) -> String {
slugify_str(&format!("{server}{export}"))
}
/// Normalize a server input: trim, strip schemes, drop trailing
/// slashes. Matches the SMB normalizer so paste-from-anywhere works.
fn normalize_server(raw: &str) -> String {
let s = raw.trim();
let s = s
.strip_prefix("nfs://")
.or_else(|| s.strip_prefix("http://"))
.or_else(|| s.strip_prefix("https://"))
.unwrap_or(s);
s.trim_end_matches('/').to_string()
}
// Silence the unused-import warning on `Cow` — we use it implicitly
// via `Opaque::borrowed` constructions in `list_isos`.
#[allow(dead_code)]
fn _unused() -> Cow<'static, str> {
Cow::Borrowed("")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn share_id_is_stable_and_safe() {
let a = share_id("10.0.0.5", "/srv/isos");
let b = share_id("10.0.0.5", "/srv/isos");
assert_eq!(a, b);
assert!(!a.contains('/'));
assert!(!a.contains('.'));
}
#[test]
fn normalize_server_strips_schemes() {
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
assert_eq!(normalize_server("nfs://nas.lan/"), "nas.lan");
assert_eq!(normalize_server("http://192.168.1.51"), "192.168.1.51");
assert_eq!(normalize_server("nas.lan"), "nas.lan");
}
#[test]
fn hint_for_acces_points_to_exports_table() {
let h = hint_for("NFS server returned NFS3ERR_ACCES").unwrap();
assert!(
h.to_lowercase().contains("export"),
"expected exports guidance, got: {h}"
);
}
#[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();
assert!(
h.to_lowercase().contains("path") || h.to_lowercase().contains("directory"),
"expected export-path guidance, got: {h}"
);
}
#[test]
fn hint_for_mount_denied_calls_out_insecure_option() {
let h = hint_for("mount denied").unwrap();
assert!(h.to_lowercase().contains("insecure"));
}
#[test]
fn hint_for_unknown_is_none() {
assert!(hint_for("some entirely unrelated string").is_none());
}
#[test]
fn status_label_covers_common_codes() {
assert_eq!(status_label(nfs3::nfsstat3::NFS3_OK), "NFS3_OK");
assert_eq!(status_label(nfs3::nfsstat3::NFS3ERR_ACCES), "NFS3ERR_ACCES");
assert_eq!(status_label(nfs3::nfsstat3::NFS3ERR_NOENT), "NFS3ERR_NOENT");
}
}
File diff suppressed because it is too large Load Diff
+57 -49
View File
@@ -15,18 +15,31 @@ use tokio::io::AsyncWriteExt;
/// Where the bytes for an ISO actually live. /// Where the bytes for an ISO actually live.
/// ///
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`. /// `Local` — uploaded ISO, sits at `<iso_dir>/<id>.iso`.
/// `Nfs` entries point at a file inside a remote share that the /// `Smb` (v0.4.65) — remote SMB share, streamed via Samba's
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily /// userspace `smbclient` CLI subprocess. No kernel mount, no local
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup. /// cache. Sequential whole-file streaming; HTTP Range requests
/// return 416.
/// `Nfs` (v0.4.67) — remote NFSv3 share, streamed via the pure-Rust
/// `nfs3_client` crate (in-process, no subprocess). Same "works in
/// any container" property as SMB, plus Range requests work because
/// NFSv3 READ3 takes an explicit offset.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource { pub enum IsoSource {
#[default] #[default]
Local, Local,
/// v0.4.65: SMB via userspace `smbclient` works in any container.
Smb {
share_id: String,
/// Filename at the share root. We don't support nested paths
/// in v0.4.65; ISOs live at the top of the share.
relative_path: String,
},
/// v0.4.67: NFSv3 via the in-process `nfs3_client` crate.
Nfs { Nfs {
mount_id: String, share_id: String,
/// Path relative to the mount point — typically just the filename. /// Filename at the export root.
relative_path: String, relative_path: String,
}, },
} }
@@ -164,10 +177,6 @@ struct Inner {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct IsoStore { pub struct IsoStore {
iso_dir: Arc<PathBuf>, iso_dir: Arc<PathBuf>,
/// Where NFS mounts land on disk. Set at startup via
/// [`IsoStore::set_nfs_root`]; required for resolving any
/// `IsoSource::Nfs` entry.
nfs_root: Arc<RwLock<Option<PathBuf>>>,
inner: Arc<RwLock<Inner>>, inner: Arc<RwLock<Inner>>,
} }
@@ -175,17 +184,10 @@ impl IsoStore {
pub fn new(iso_dir: PathBuf) -> Self { pub fn new(iso_dir: PathBuf) -> Self {
Self { Self {
iso_dir: Arc::new(iso_dir), iso_dir: Arc::new(iso_dir),
nfs_root: Arc::new(RwLock::new(None)),
inner: Arc::new(RwLock::new(Inner::default())), inner: Arc::new(RwLock::new(Inner::default())),
} }
} }
/// Tell the store where NFS mounts live. Without this set,
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
pub fn set_nfs_root(&self, root: PathBuf) {
*self.nfs_root.write() = Some(root);
}
pub async fn ensure_dirs(&self) -> Result<()> { pub async fn ensure_dirs(&self) -> Result<()> {
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?; tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
Ok(()) Ok(())
@@ -281,33 +283,33 @@ impl IsoStore {
self.inner.read().isos.get(id).cloned() self.inner.read().isos.get(id).cloned()
} }
/// Resolve an ISO id to its on-disk path, if any. For local entries /// Resolve an ISO id to its on-disk path, if any. For local
/// this is `<iso_dir>/<id>.iso`; for NFS entries it's /// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced
/// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file /// ISOs there is no on-disk path — the HTTP handler must stream
/// is missing or the source isn't resolvable (e.g. NFS share /// via `SmbShareManager::stream_iso` instead. Returns `None` for
/// unmounted). /// SMB sources or when the file is missing.
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> { pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
let meta = self.get(id)?; let meta = self.get(id)?;
let path = match &meta.source { match &meta.source {
IsoSource::Local => self.iso_path(id), IsoSource::Local => {
IsoSource::Nfs { let path = self.iso_path(id);
mount_id, if path.exists() {
relative_path, Some(path)
} => { } else {
let root = self.nfs_root.read().clone()?; None
root.join(mount_id).join(relative_path) }
} }
}; // SMB and NFS sources have no local path — they're
if path.exists() { // streamed in-process. Callers must inspect the source
Some(path) // kind first and dispatch to the appropriate share
} else { // manager.
None IsoSource::Smb { .. } | IsoSource::Nfs { .. } => None,
} }
} }
/// Delete an ISO and its sidecar metadata. Only acts on local ISOs; /// Delete an ISO and its sidecar metadata. Only acts on local
/// for NFS-backed ISOs the operator must remove the file from the /// (uploaded) ISOs; for SMB-backed ISOs the operator must remove
/// share or unmount the NFS share entirely. /// the file from the share or unregister the share entirely.
pub async fn delete(&self, id: &str) -> Result<()> { pub async fn delete(&self, id: &str) -> Result<()> {
let meta = self.get(id); let meta = self.get(id);
let is_local = matches!( let is_local = matches!(
@@ -324,10 +326,10 @@ impl IsoStore {
Ok(()) Ok(())
} }
/// Register an externally-sourced ISO (e.g. NFS-mounted). Used by /// Register an externally-sourced ISO (SMB share, etc.). Used by
/// `NfsManager` after walking a freshly-mounted share. We do **not** /// `SmbShareManager` after listing a share. We do **not** persist
/// persist a `meta.json` on disk for these — the source of truth is /// a `meta.json` on disk for these — the source of truth is the
/// the share itself, and the NFS manager re-scans on startup. /// share itself, and the manager re-scans on startup.
pub fn register_external( pub fn register_external(
&self, &self,
id: String, id: String,
@@ -352,14 +354,20 @@ impl IsoStore {
self.inner.write().isos.insert(id, meta); self.inner.write().isos.insert(id, meta);
} }
/// Drop every entry that belongs to `mount_id`. Used by the NFS /// Drop every entry that belongs to `share_id`. Used by the SMB
/// manager when an operator removes a share, or before re-scanning /// and NFS share managers when an operator removes a share, or
/// to clean out stale entries. /// before re-scanning to clean out stale entries. The same id
pub fn drop_external_source(&self, mount_id: &str) { /// space serves both protocols — share ids are slugified from
/// `server+share` (SMB) or `server+export` (NFS) and the
/// protocol-specific prefix prevents collisions.
pub fn drop_external_source(&self, share_id: &str) {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.isos.retain( g.isos.retain(|_, m| match &m.source {
|_, m| !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id), IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => {
); sid != share_id
}
IsoSource::Local => true,
});
} }
/// Set or clear an ISO's boot password. /// Set or clear an ISO's boot password.
+27 -9
View File
@@ -9,7 +9,7 @@ use openpxe_core::{
}; };
use openpxe_dhcp_proxy::DhcpProxyServer; use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState}; use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
use openpxe_tftp::TftpServer; use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf; use std::path::PathBuf;
@@ -119,13 +119,30 @@ async fn main() -> anyhow::Result<()> {
let _ = smb.start(); let _ = smb.start();
} }
// NFS manager. The mount root has to be set on the IsoStore *before* // v0.4.65: SMB share manager — Samba `smbclient` userspace
// we replay any persisted mounts, otherwise an in-memory IsoMeta // consumer. Replaces the kernel-mount NFS path that v0.4.64
// pointing at an NFS source can't resolve to a path. // shipped; that didn't work on hosts whose kernel lacked the nfs
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone()); // client modules (Unraid is the dominant case). `smbclient` does
iso_store.set_nfs_root(nfs.mount_root()); // the SMB protocol entirely in userspace over TCP and works in
if let Err(e) = nfs.load_and_remount().await { // any container regardless of capabilities or kernel modules.
tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}"); let smb_shares = SmbShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = smb_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::smb",
"could not reload SMB shares on startup: {e}"
);
}
// v0.4.67: NFSv3 share manager — pure-Rust in-process consumer
// via the `nfs3_client` crate. Sits alongside the SMB manager;
// operators pick whichever protocol their NAS prefers, or use
// both. No subprocess, no kernel mount, works in any container.
let nfs_shares = NfsShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = nfs_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::nfs",
"could not reload NFS shares on startup: {e}"
);
} }
// Sniff network details for the Network tab. None of these are // Sniff network details for the Network tab. None of these are
@@ -152,7 +169,8 @@ async fn main() -> anyhow::Result<()> {
sso: sso.clone(), sso: sso.clone(),
metrics: metrics.clone(), metrics: metrics.clone(),
smb: Some(smb.clone()), smb: Some(smb.clone()),
nfs: nfs.clone(), smb_shares: smb_shares.clone(),
nfs_shares: nfs_shares.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
+60 -1
View File
@@ -34,9 +34,18 @@
--topbar-h: 56px; --topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
/* v0.4.63: tie native form-control rendering (checkboxes, scroll bars,
date pickers) to the active OpenPXE theme. Without this, the inline
`<meta name="color-scheme" content="dark light">` in index.html forces
dark form chrome in *both* themes — so the SSO "Enable single sign-on"
checkbox renders as an opaque black square against the light-mode
panel, ignoring our accent-color hint. CSS `color-scheme` overrides
the meta and tracks `data-theme` correctly. */
color-scheme: dark;
} }
:root[data-theme="light"] { :root[data-theme="light"] {
color-scheme: light;
/* Light palette — high-contrast neutral, accent unchanged for brand /* Light palette — high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot: consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */ near-white surfaces, soft grey dividers, dark text. */
@@ -304,6 +313,23 @@ label.field textarea {
font-size: 14px; line-height: 1.4; font-size: 14px; line-height: 1.4;
box-shadow: none; -webkit-appearance: none; appearance: none; box-shadow: none; -webkit-appearance: none; appearance: none;
} }
/* v0.4.63: with `appearance: none`, the native <select> dropdown arrow
disappears, which makes the "Metadata source" pick-list look like a
plain (and slightly squished) text input. Paint our own chevron via
background-image so the control still reads as a dropdown, and reserve
right-padding for it. The data-URI SVG inherits currentColor via the
`stroke` attribute so the arrow follows light/dark theme without a
second declaration. */
label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%239aa0a6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 11px 7px;
padding-right: 30px;
}
:root[data-theme="light"] label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%235a6377' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
}
label.field input:focus, label.field select:focus, label.field textarea:focus { label.field input:focus, label.field select:focus, label.field textarea:focus {
outline: none; border-color: var(--accent); outline: none; border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
@@ -313,7 +339,40 @@ label.check {
padding: 8px 10px; margin-bottom: 6px; padding: 8px 10px; margin-bottom: 6px;
border: 1px solid var(--border-soft); border-radius: var(--radius); border: 1px solid var(--border-soft); border-radius: var(--radius);
} }
label.check input { accent-color: var(--accent); } /* v0.4.63: native checkboxes used to render as opaque black squares in
light mode because the page meta declares `color-scheme: dark light`
and `accent-color` alone only repaints the *check mark* (not the
container). Take full control of the chrome so the box reads cleanly
on both palettes and the checked state lights up in our accent. */
label.check input[type="checkbox"] {
appearance: none; -webkit-appearance: none;
width: 16px; height: 16px; flex: none;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
display: inline-grid; place-content: center;
cursor: pointer; margin: 0;
transition: background 0.1s ease, border-color 0.1s ease;
}
label.check input[type="checkbox"]:hover { border-color: var(--accent); }
label.check input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent);
}
label.check input[type="checkbox"]:checked::after {
/* Classic ✓ glyph built from a rotated rectangle border. Colour is
#002923 (the same near-black we use on solid-accent buttons) so the
tick stays legible against the teal fill in both themes. */
content: '';
width: 4px; height: 8px;
border: solid #002923;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
}
label.check input[type="checkbox"]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
/* ── Drop zone ────────────────────────────────────────────────────── */ /* ── Drop zone ────────────────────────────────────────────────────── */
+312 -112
View File
@@ -116,6 +116,46 @@
return root; return root;
} }
// 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 start failing
// with ENOSPC. Shared by the Storage tab and the Dashboard (v0.4.68).
function diskSpaceCard(disk) {
const total = Number(disk.total_bytes || 0);
const avail = Number(disk.available_bytes || 0);
const used = Number(disk.used_bytes || 0);
const pctUsed = total > 0 ? (used / total) * 100 : 0;
let barClass = 'diskbar';
if (pctUsed >= 95) barClass += ' full';
else if (pctUsed >= 80) barClass += ' warn';
return el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Disk space'),
el('span', {class:'sub'},
total > 0 ? (pctUsed.toFixed(1) + '% used') : 'unavailable'),
]),
el('div', {class:'body'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
el('div', {class: barClass},
el('div', {class:'fill',
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
el('div', {class:'disk-meta'}, [
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
]),
pctUsed >= 95
? el('p', {class:'msg err', style:'margin-top:10px'},
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
: (pctUsed >= 80
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
'Volume is getting full. Consider pruning old ISOs.')
: null),
]),
]);
}
// Categorize an ISO row's "bootable now" status — drives the amber // Categorize an ISO row's "bootable now" status — drives the amber
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}. // tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
function bootability(iso, settings) { function bootability(iso, settings) {
@@ -142,6 +182,13 @@
const isos = await getJSON('/api/isos'); const isos = await getJSON('/api/isos');
const clients = (await getJSON('/api/clients')).clients || []; const clients = (await getJSON('/api/clients')).clients || [];
const entries = (await getJSON('/api/queue')).entries || []; const entries = (await getJSON('/api/queue')).entries || [];
// v0.4.68: surface the same disk-space card the Storage tab shows,
// so operators see capacity at a glance from the landing page.
// Tolerate the endpoint being unavailable (e.g. statvfs failure)
// the same way the Storage tab does.
const disk = await getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
}));
const ipxeOk = (status.ipxe_assets || []).length > 0; const ipxeOk = (status.ipxe_assets || []).length > 0;
const stats = el('div', {class: 'statstrip'}, [ const stats = el('div', {class: 'statstrip'}, [
@@ -167,7 +214,11 @@
el('div', {class: 'trend'}, el('div', {class: 'trend'},
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' + isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' + isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
(status.nfs_active || 0) + ' NFS active'), // 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: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'), el('div', {class: 'label'}, 'Uptime'),
@@ -218,7 +269,7 @@
'⚠ ' + i.filename + ' — ' + b.reason))) '⚠ ' + i.filename + ' — ' + b.reason)))
]) : null; ]) : null;
return el('div', {class:'grid'}, [stats, recentBlock, problemsBlock].filter(Boolean)); return el('div', {class:'grid'}, [stats, diskSpaceCard(disk), recentBlock, problemsBlock].filter(Boolean));
}, },
network: async () => { network: async () => {
@@ -342,13 +393,22 @@
}, },
storage: async () => { storage: async () => {
const [isos, settings, nfsRes, disk] = await Promise.all([ // v0.4.65: kernel-mount NFS replaced with userspace SMB via
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'), // smbclient — works in any container regardless of host kernel
// 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(() => ({ getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})), })),
]); ]);
const mounts = nfsRes.mounts || []; const shares = smbRes.shares || [];
const nfsShares = nfsRes.shares || [];
// ── Upload card ── // ── Upload card ──
const drop = el('div', {class:'drop', id:'drop'}, [ const drop = el('div', {class:'drop', id:'drop'}, [
@@ -450,7 +510,7 @@
} }
} }
// ── ISO table (mixed local + NFS) ── // ── ISO table (mixed local + SMB) ──
// Each row gets a "Password" cell that toggles a small inline // Each row gets a "Password" cell that toggles a small inline
// editor (a checkbox + a password field + Save button) inside the // editor (a checkbox + a password field + Save button) inside the
// *next* row of the table. Keeps the markup flat and avoids the // *next* row of the table. Keeps the markup flat and avoids the
@@ -458,7 +518,14 @@
const rowsAndEditors = []; const rowsAndEditors = [];
isos.forEach(i => { isos.forEach(i => {
const b = bootability(i, settings); const b = bootability(i, settings);
// 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 isNfs = i.source && i.source.kind === 'nfs';
const isRemote = isSmb || isNfs;
const protectedNow = !!i.password_hash; const protectedNow = !!i.password_hash;
// The inline editor row is hidden by default; the Password // The inline editor row is hidden by default; the Password
@@ -578,8 +645,9 @@
]), ]),
el('td', {class:'num'}, fmtBytes(i.size_bytes)), el('td', {class:'num'}, fmtBytes(i.size_bytes)),
el('td', {}, el('td', {},
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')}, el('span', {class:'src-badge' + (isRemote ? ' nfs' : '')},
isNfs ? ('nfs:' + i.source.mount_id) : 'local')), isSmb ? ('smb:' + i.source.share_id)
: isNfs ? ('nfs:' + i.source.share_id) : 'local')),
el('td', {}, el('td', {},
protectedNow protectedNow
? el('span', {class:'tag accent'}, 'protected') ? el('span', {class:'tag accent'}, 'protected')
@@ -589,8 +657,12 @@
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => { el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none'; editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
}}, protectedNow ? 'Password ✎' : 'Set password'), }}, protectedNow ? 'Password ✎' : 'Set password'),
isNfs isRemote
? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS') // 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 () => { : el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this image?')) return; if (!confirm('Remove this image?')) return;
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'}); await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
@@ -610,96 +682,166 @@
])), ])),
el('tbody', {}, rowsAndEditors), 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 ── // ── SMB shares section (v0.4.65) ──
const nfsMsg = el('div', {class:'msg'}); // Replaces the kernel-mount NFS card. SMB shares are consumed
const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'}); // in userspace via Samba's `smbclient` CLI — no kernel modules,
const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'}); // no CAP_SYS_ADMIN, works in any container. This is the same
const nfsVer = el('select', {}, [ // approach Bootimus uses.
el('option', {value:'v41'}, 'NFSv4.1 (default)'), const smbMsg = el('div', {class:'msg'});
el('option', {value:'v3'}, 'NFSv3'), const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
]); const smbShare = el('input', {type:'text', placeholder:'isos'});
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true; const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
const addNfs = el('button', {onclick: async () => { const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
if (!nfsServer.value || !nfsExport.value) { const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return; // 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.textContent = 'Mounting…'; nfsMsg.className = 'msg'; if (!smbGuest.checked && !smbUser.value) {
const r = await postJSON('/api/nfs', { smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
server: nfsServer.value, export: nfsExport.value, smbMsg.className='msg err'; return;
version: nfsVer.value, read_only: nfsRo.checked, }
}); 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) { if (r.ok) {
nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok'; smbMsg.replaceChildren(document.createTextNode('Connected.'));
smbMsg.className = 'msg ok';
render('storage'); render('storage');
} else { } else {
const t = await r.text(); // The API returns a structured {error, stderr, hint} JSON
nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err'; // body on failure so the raw smbclient error and the
// actionable hint render as two distinct lines.
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));
}
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')}, [ const smbRows = shares.length ? shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}), el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [ el('div', {}, [
el('div', {class:'id'}, m.server + ':' + m.export), el('div', {class:'id'}, '//' + m.server + '/' + m.share),
el('div', {class:'meta'}, el('div', {class:'meta'},
(m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' + (m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
(m.read_only ? 'read-only' : 'read-write') + ' · ' + (m.reachable ? m.iso_count + ' isos' : 'not reachable')),
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null, 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 () => { 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'); if (r.ok) render('storage');
}}, 'Re-scan'), }}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => { el('button', {class:'danger', onclick: async () => {
if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return; if (!confirm('Forget //' + m.server + '/' + m.share + '?')) return;
await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'}); await fetch('/api/smb-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage'); render('storage');
}}, 'Unmount'), }}, 'Remove'),
el('span'), 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 // ── NFS shares section (v0.4.67) ──
// ISO directory, with a coloured bar. Warns at 80% and goes red at // Parallel to SMB shares above. The NFSv3 client is in-process
// 95% so the operator sees the runway shrinking before uploads // (nfs3_client crate) so NFS-sourced ISOs support HTTP Range
// start failing with ENOSPC. // requests — SMB-sourced ones don't (smbclient CLI can't seek
const total = Number(disk.total_bytes || 0); // mid-stream). Otherwise the UX is identical: server + export,
const avail = Number(disk.available_bytes || 0); // submit, scan, remove.
const used = Number(disk.used_bytes || 0); const nfsMsg = el('div', {class:'msg'});
const pctUsed = total > 0 ? (used / total) * 100 : 0; const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
let barClass = 'diskbar'; const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
if (pctUsed >= 95) barClass += ' full'; const addNfs = el('button', {style:'margin-top:14px', onclick: async () => {
else if (pctUsed >= 80) barClass += ' warn'; if (!nfsServerIn.value || !nfsExportIn.value) {
const diskCard = el('div', {class:'card'}, [ nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
el('header', {}, [ nfsMsg.className = 'msg err'; return;
el('h2', {}, 'Disk space'), }
el('span', {class:'sub'}, nfsMsg.replaceChildren(document.createTextNode('Connecting…'));
total > 0 nfsMsg.className = 'msg';
? (pctUsed.toFixed(1) + '% used') const r = await postJSON('/api/nfs-shares', {
: 'unavailable'), 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('div', {class:'body'}, [ el('button', {class:'ghost', onclick: async () => {
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'}, const r = await postJSON('/api/nfs-shares/' + encodeURIComponent(m.id) + '/scan', {});
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'), if (r.ok) render('storage');
el('div', {class: barClass}, }}, 'Re-scan'),
el('div', {class:'fill', el('button', {class:'danger', onclick: async () => {
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})), if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
el('div', {class:'disk-meta'}, [ await fetch('/api/nfs-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]), render('storage');
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]), }}, 'Remove'),
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]), el('span'),
]), ])) : [el('div', {class:'empty'}, 'No NFS shares configured.')];
pctUsed >= 95
? el('p', {class:'msg err', style:'margin-top:10px'}, const diskCard = diskSpaceCard(disk);
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
: (pctUsed >= 80
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
'Volume is getting full. Consider pruning old ISOs.')
: null),
]),
]);
return el('div', {class:'grid'}, [ return el('div', {class:'grid'}, [
diskCard, diskCard,
@@ -709,34 +851,75 @@
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, [ el('header', {}, [
el('h2', {}, 'NFS shares'), el('h2', {}, 'SMB shares'),
el('span', {class:'sub'}, mounts.length + ' configured'), el('span', {class:'sub'}, shares.length + ' configured'),
]), ]),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
el('div', {class:'form-row'}, [ el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'SMB server'),
smbServer,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Share name'),
smbShare,
]),
]),
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'},
'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.'),
]),
]),
// 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('label', {class:'field'}, [
el('span', {class:'name'}, 'NFS server'), el('span', {class:'name'}, 'NFS server'),
nfsServer, nfsServerIn,
]), ]),
el('label', {class:'field'}, [ el('label', {class:'field'}, [
el('span', {class:'name'}, 'Export path'), el('span', {class:'name'}, 'Export path'),
nfsExport, nfsExportIn,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Version'),
nfsVer,
]),
el('label', {class:'check', style:'margin-top:18px'}, [
nfsRo, el('span', {}, 'Read-only'),
]), ]),
]), ]),
addNfs, nfsMsg, addNfs, nfsMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows), el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' + 'NFSv3 shares are read in-process via a pure-Rust client — ' +
'mount.nfs binary (bundled in the default Docker image). On ' + 'no kernel modules, no mount.nfs, no CAP_SYS_ADMIN. Works in ' +
'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' + 'every container the SMB path works in (Unraid included). ' +
'NFS mounts as a CSI driver outside the pod.'), 'NFSv3 auth is AUTH_SYS only; gate access on the server side ' +
'by allowing this OpenPXE hosts 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('div', {class:'card'}, [
@@ -1025,7 +1208,11 @@
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password', const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
placeholder: 'confirm new password'}); placeholder: 'confirm new password'});
const accountMsg = el('div', {class:'msg', style:'margin-top:8px'}); const accountMsg = el('div', {class:'msg', style:'margin-top:8px'});
const accountSave = el('button', {onclick: async () => { // v0.4.63: explicit top margin so the action button sits clearly
// beneath the input row instead of butting against the password
// fields. Mirrors the `Save SSO settings` button below for visual
// parity between the two settings cards.
const accountSave = el('button', {style:'margin-top:6px', onclick: async () => {
accountMsg.textContent = ''; accountMsg.className = 'msg'; accountMsg.textContent = ''; accountMsg.className = 'msg';
if (!currentPw.value) { if (!currentPw.value) {
accountMsg.textContent = 'Current password is required.'; accountMsg.textContent = 'Current password is required.';
@@ -1130,29 +1317,39 @@
el('option', {value:'xml'}, 'Metadata XML'), el('option', {value:'xml'}, 'Metadata XML'),
]); ]);
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url'; ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
// v0.4.63: the IdP metadata URL now sits inside the 4-col header
// grid as column 4, so the SSO row is column-for-column aligned with
// the Administrator account row above. When the operator switches
// to XML mode, column 4 collapses (display:none) and the multi-line
// XML textarea takes its own full-width row below — there's no way
// to fit a 6-row textarea into a single grid cell without making
// the rest of the row look stretched.
const urlWrap = el('label', {class:'field'}, [ const urlWrap = el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP metadata URL'), el('span', {class:'name'}, 'IdP metadata URL'),
ssoUrl, ssoUrl,
el('span', {class:'hint'},
'OpenPXE will fetch this URL once SSO sign-in lands; v0.4.6 just stores it.'),
]); ]);
const xmlWrap = el('label', {class:'field'}, [ const xmlWrap = el('label', {class:'field', style:'margin-top:14px'}, [
el('span', {class:'name'}, 'IdP metadata XML'), el('span', {class:'name'}, 'IdP metadata XML'),
ssoXml, ssoXml,
el('span', {class:'hint'}, el('span', {class:'hint'},
'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'), 'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
]); ]);
// Hint that used to live under the URL field; surfaced once below
// the whole row so it doesn't compete with the in-grid layout.
const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
'OpenPXE will fetch the metadata URL once SSO sign-in lands; v0.4.63 stores it.');
const refreshSsoFields = () => { const refreshSsoFields = () => {
if (ssoMode.value === 'url') { if (ssoMode.value === 'url') {
urlWrap.style.display = ''; xmlWrap.style.display = 'none'; urlWrap.style.display = ''; xmlWrap.style.display = 'none';
urlHint.style.display = '';
} else { } else {
urlWrap.style.display = 'none'; xmlWrap.style.display = ''; urlWrap.style.display = 'none'; xmlWrap.style.display = '';
urlHint.style.display = 'none';
} }
}; };
ssoMode.onchange = refreshSsoFields; ssoMode.onchange = refreshSsoFields;
refreshSsoFields();
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'}); const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
const ssoSave = el('button', {onclick: async () => { const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => {
ssoMsg.textContent = ''; ssoMsg.className = 'msg'; ssoMsg.textContent = ''; ssoMsg.className = 'msg';
const payload = { const payload = {
enabled: ssoEnabled.checked, enabled: ssoEnabled.checked,
@@ -1193,32 +1390,35 @@
ssoEnabled, ssoEnabled,
el('span', {}, 'Enable single sign-on'), el('span', {}, 'Enable single sign-on'),
]), ]),
// 3-column header strip: display name, logo URL, metadata // v0.4.63: 4-column form-row that matches the Administrator
// source. All three controls inherit the same border/padding/ // account card above column-for-column — display name / logo
// focus chrome from the global `label.field input/select` // URL / metadata source / metadata URL. All four controls share
// rule, so they line up cleanly. Below: the active source // the same `label.field` chrome so they line up cleanly. When
// field (URL or XML) spans the full width. // the operator picks "Metadata XML" the URL column collapses
el('div', {class:'form-row cols-3'}, [ // and the multi-line textarea drops below the row.
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [ el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP display name'), el('span', {class:'name'}, 'IdP display name'),
ssoName, ssoName,
el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'),
]), ]),
el('label', {class:'field'}, [ el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP logo URL'), el('span', {class:'name'}, 'IdP logo URL'),
ssoLogo, ssoLogo,
el('span', {class:'hint'}, 'Optional. Shown next to the IdP name on the login button.'),
]), ]),
el('label', {class:'field'}, [ el('label', {class:'field'}, [
el('span', {class:'name'}, 'Metadata source'), el('span', {class:'name'}, 'Metadata source'),
ssoMode, ssoMode,
]), ]),
urlWrap,
]), ]),
urlWrap,
xmlWrap, xmlWrap,
urlHint,
ssoSave, ssoMsg, ssoSave, ssoMsg,
]), ]),
]); ]);
// Wire up + paint the initial visibility now that all elements
// referenced by `refreshSsoFields` are attached.
refreshSsoFields();
// ── Custom logo upload. // ── Custom logo upload.
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB. // Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
+3 -3
View File
@@ -13,7 +13,7 @@
on the asset handlers, the practical caching window is one on the asset handlers, the practical caching window is one
version. --> version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" /> <link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" /> <link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" />
<!-- Theme is read from localStorage *before* paint to avoid the <!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. --> preference and finally to dark. -->
@@ -33,7 +33,7 @@
<div class="shell"> <div class="shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand">
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" /> <img src="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" alt="OpenPXE" />
<strong>OpenPXE</strong> <strong>OpenPXE</strong>
</div> </div>
<nav> <nav>
@@ -66,7 +66,7 @@
<!-- The brand badge at the top can be overridden by operator-uploaded <!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. --> identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.61</span></div> <div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.63</span></div>
</div> </div>
</aside> </aside>
+9 -1
View File
@@ -17,11 +17,19 @@
/// when we know the new one is incompatible. Combined with /// when we know the new one is incompatible. Combined with
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers, /// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
/// the worst-case caching window is one version. /// the worst-case caching window is one version.
/// * `logo_rev` is appended to the brand-mark and favicon URLs as an
/// extra `&r=…` token. Unlike `asset_version` it changes every time
/// the operator swaps the custom logo, so the top-left mark updates
/// immediately on the next page load instead of being pinned to the
/// release version (which only changes on upgrade). `index.html`
/// itself is served `no-cache`, so the fresh token lands as soon as
/// the operator reloads after an upload.
#[must_use] #[must_use]
pub fn index_html(base_url: &str, asset_version: &str) -> String { pub fn index_html(base_url: &str, asset_version: &str, logo_rev: u64) -> String {
INDEX_HTML INDEX_HTML
.replace("{{BASE_URL}}", base_url) .replace("{{BASE_URL}}", base_url)
.replace("{{ASSET_VERSION}}", asset_version) .replace("{{ASSET_VERSION}}", asset_version)
.replace("{{LOGO_REV}}", &logo_rev.to_string())
} }
#[must_use] #[must_use]
+20 -8
View File
@@ -81,23 +81,30 @@ FROM debian:12-slim AS runtime
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
ca-certificates libcap2-bin tini gosu iproute2 \ ca-certificates libcap2-bin tini gosu iproute2 \
wimtools samba nfs-common \ wimtools samba smbclient \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \ && useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \
&& mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \ && mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \
&& chown -R openpxe:openpxe /var/lib/openpxe && chown -R openpxe:openpxe /var/lib/openpxe
# v0.4.5: the openpxe binary itself is now built against musl and is # v0.4.5: the openpxe binary itself is now built against musl and is
# fully static — no glibc dependency. The runtime stage still ships # fully static — no glibc dependency. The runtime stage still ships
# Debian slim because OpenPXE shells out to the four packages below for # Debian slim because OpenPXE shells out to the packages below for
# functionality we deliberately don't reimplement in-process: # functionality we deliberately don't reimplement in-process:
#
# wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim. # wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# samba - `smbd` serves extracted Windows install media on :445 so # samba - `smbd` serves extracted Windows install media on :445 so
# WinPE can `net use`. Guest read-only, scoped to # WinPE can `net use`. Guest read-only, scoped to
# /var/lib/openpxe/smb. # /var/lib/openpxe/smb. This package provides the SERVER
# nfs-common - `mount.nfs` / `mount.nfs4` for the Storage tab's NFS # side only; the client CLI is a separate package below.
# share manager. Mount requires CAP_SYS_ADMIN; without it # smbclient - v0.4.66: Samba's `smbclient` userspace CLI, used by
# mount(2) returns EPERM and the manager surfaces a clear # the Storage tab's SMB shares manager to list and stream
# error in the UI. # ISOs from remote SMB servers without ever mounting them
# in the kernel. In Debian 12 `smbclient` is NOT pulled
# in by the `samba` package — they're siblings, not
# parent/child. v0.4.65 shipped without this line and
# every "Add share" attempt surfaced
# `could not exec smbclient: No such file or directory`
# until this landed.
# iproute2 - `ip addr` / `ip route` for the auto-detected Network # iproute2 - `ip addr` / `ip route` for the auto-detected Network
# tab fields (NIC name, subnet mask, default gateway). # tab fields (NIC name, subnet mask, default gateway).
# Tiny, always available; we don't pull in netlink crates # Tiny, always available; we don't pull in netlink crates
@@ -105,8 +112,13 @@ RUN apt-get update \
# gosu - drops privileges cleanly from root after the entrypoint # gosu - drops privileges cleanly from root after the entrypoint
# fixes bind-mount ownership (common OpenShift/Docker UX # fixes bind-mount ownership (common OpenShift/Docker UX
# issue). # issue).
#
# v0.4.65 dropped `nfs-common` — kernel-mount NFS is gone. The SMB
# shares replacement uses userspace `smbclient` and needs no kernel
# helpers.
#
# A future "openpxe-static" variant could drop everything except the # A future "openpxe-static" variant could drop everything except the
# binary onto distroless once we move the Windows + NFS legs to # binary onto distroless once we move the Windows + SMB legs to
# in-process Rust crates. # in-process Rust crates.
COPY --from=build /openpxe /usr/local/bin/openpxe COPY --from=build /openpxe /usr/local/bin/openpxe
+106
View File
@@ -0,0 +1,106 @@
# PXE menu theme — research for next-release follow-up
Status: queued. v0.4.63 keeps the ASCII-banner fallback + `console --picture`
compositor wired; this note captures the design for the menu-theming work
that lands once iPXE rebuilt with `IMAGE_PNG` is published.
## How iVentoy actually does it
iVentoy is closed-source for its menu, but the supporting bits are
public at https://github.com/ventoy/PXE — a vanilla iPXE snapshot
(`iPXE/ipxe-bd13697`) used to produce the loader binaries iVentoy
serves over TFTP (`pxeboot.efi`, `iventoy_loader_16000`,
`iventoy_loader_16000_uefi`).
The graphical menu itself is rendered by iPXE's framebuffer console
with a baked-in PNG background via `console --picture` — same
primitive OpenPXE already uses in `crates/http-api/src/ipxe_script.rs`.
Evidence:
- The iPXE build in `ventoy/PXE` is configured with `CONSOLE_FRAMEBUFFER`
+ `IMAGE_PNG` + `CONSOLE_CMD` (the three flags `console --picture`
needs).
- iVentoy issue #11 confirms "iventoy using default 1024x768"; users
report 800x600 / 1024x768 / 1280x720 / 1280x1024 / 1920x1080 as
selectable resolutions from the iVentoy web UI **Configuration tab**,
not via EDID auto-detect. iPXE has no EDID parsing; the daemon writes
a resolution-tagged script per boot and serves the matching PNG.
- iVentoy docs explicitly state both Free and Pro editions **do not
support** modifying the boot background/title — it's baked into the
shipped PNG assets.
- Chrome is iPXE's native `menu` / `item` / `choose` widgets (single
highlight bar, no borders) painted on top of the PNG, with margins
set via `console --left/--right/--top/--bottom` to keep the text off
the logo. Not GRUB, not syslinux — UEFI iVentoy uses iPXE's
`snponly.efi` / `pxeboot.efi`, and `--picture` does work under UEFI
GOP despite older folklore.
Do not conflate this with Ventoy-USB, which is a separate codebase and
uses GRUB2 themes (`theme.txt`, `background_ventoy.png`, `select_c.png`).
## Rust ingredients to replicate / surpass
Most of these already exist in the workspace.
1. **Compositor (extend, don't replace)** — extend
`crates/iso-store/src/pxe_logo.rs` to emit per-resolution PNGs
(1024x768, 1280x1024, 1920x1080 as the v1 set). `image` +
`imageproc` crates handle scaling; `ab_glyph` / `fontdue` for raster
text (subtitle, hostname, version). One source SVG/logo, three to
five rendered PNGs cached on disk.
2. **Script generator**`ipxe_script.rs` already emits
`console --picture … || console`. Add a `?res=` query param (or
per-MAC client hint persisted in `hosts.json`) and serve the matching
PNG plus matching `console --x --y` line. Keep the text-console
fallback already in place.
3. **Resolution selection** — iPXE exposes `${vesa-x}` / `${vesa-y}` on
BIOS; UEFI side we can probe firmware vars at chain-time. The simpler
v1 is a "low-res / hi-res" toggle in Settings plus a per-host
override — mirrors iVentoy's UX, no kernel helper needed. True EDID
parsing is overkill for the first cut.
4. **Chrome upgrades over iVentoy** — iPXE menus are limited (single
highlight, no borders). To look distinctly cooler without leaving
iPXE: paint border / title / footer **into the PNG**, leave a window
in the middle, then `console --left/--right/--top/--bottom` to inset
the iPXE menu exactly into that window. ASCII box-drawing inside the
menu remains fragile (iPXE mangles non-ASCII on some builds — already
noted in `ipxe_script.rs`).
## Recommended architecture for the next OpenPXE release
- Build a `pxe_theme` module beside `pxe_logo.rs`: takes operator logo
+ theme tokens (accent colour, title, footer) and renders a layered
PNG (background gradient → framing chrome → logo → title bar → footer
with `${hostname}` / `${version}` / `${ip}`) at the three target
resolutions. Cache by hash of inputs.
- Serve at `/branding/pxe-menu-{w}x{h}.png`. Default 1024x768; expose a
Settings dropdown.
- In `ipxe_script.rs`, emit
`console --picture …/pxe-menu-1024x768.png --left 80 --right 80 --top 180 --bottom 60 || console`,
then the existing `menu` / `item` / `choose` block — text now lands
inside the framed window.
- Compile iPXE with `CONSOLE_FRAMEBUFFER`, `IMAGE_PNG`, `CONSOLE_CMD`,
`CONSOLE_VESAFB` (BIOS) and `CONSOLE_EFIFB` (UEFI). The v0.4.61 image
attempted this in-Docker via QEMU emulation and hit `cc1` segfaults.
The follow-up will use a Gitea Actions runner pinned to native
`linux/amd64` (an Unraid host already exists for this).
- Stretch goal: a second "theme pack" that ships a layered PNG with
subtle scanlines / grid — iPXE can't animate, but a well-designed
static composite beats iVentoy's plain centered logo handily.
## Source URLs
- https://github.com/ventoy/PXE
- https://github.com/ventoy/PXE/tree/master/iPXE
- https://github.com/ventoy/PXE/issues/11 — 1024x768 default
- https://github.com/ventoy/PXE/issues/59 — iVentoy iPXE EFI loader
- https://ipxe.org/cmd/console — `--picture` and compile flags
- https://github.com/ipxe/ipxe/discussions/945 — background image how-to
- https://github.com/ipxe/ipxe/discussions/802 — `CONSOLE_FRAMEBUFFER`
requirement
- https://github.com/ipxe/ipxe/discussions/1006 — picture resolution
behaviour
- https://www.iventoy.com/en/doc_edition.html — background / title not
user-customisable
- https://kingtam.win/archives/iventoy.html — third-party iPXE-based
iVentoy alternative