Compare commits

...
7 Commits
Author SHA1 Message Date
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
Miles WardandClaude Opus 4.7 d729a7ae2f v0.4.62: ship the v0.4.61 cache fix as a buildable image
v0.4.61 source landed in main with the cache fix and the
PXE-logo compositor, plus an aspirational Dockerfile stage that
rebuilds iPXE from source with IMAGE_PNG enabled. The Dockerfile
stage hits intermittent `cc1: internal compiler error: Segmentation
fault` when cross-emulating x86_64 gcc under QEMU on arm64 build
hosts, which is what the build host I was using does. No v0.4.61
image was ever published as a result.

v0.4.62 walks back the iPXE-from-source change and ships a working
image with the same cache fix and the same compositor code in place.
The iPXE rebuild is queued for a follow-up release, to be built and
validated on the actual x86_64 Unraid hardware where the QEMU
instability doesn't apply.

What's in v0.4.62 vs v0.4.6:

- Asset URL versioning: index.html now appends `?v=<openpxe-version>`
  to every asset URL (app.css, app.js, logo.svg). Combined with
  `Cache-Control: no-cache, must-revalidate` on the asset handlers,
  upgrades land in operators' browsers without a hard refresh. This
  is the fix for "I pulled v0.4.6 but the UI still looks like v0.4.5".
- New PXE-logo compositor in iso-store::pxe_logo: decodes any raster
  the operator uploads, scales-to-fit into a 600×200 bounding box,
  pastes it centered at the top of a 1024×768 PNG canvas, and serves
  the result at GET /branding/pxe-logo. Wired into render_menu's
  `console --picture` directive; takes effect when the shipped iPXE
  binaries grow PNG support.
- ASCII OpenPXE wordmark in render_menu retained for v0.4.62 — works
  on the boot.ipxe.org pre-builds we currently ship.

Quality:
- 142 tests passing.
- cargo clippy --workspace --all-targets clean.
- No image dependency change since v0.4.61 (the `image = "0.25"` dep
  added in v0.4.61 stays — it backs the compositor).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 00:53:30 -04:00
Miles WardandClaude Opus 4.7 1419309a2d v0.4.61: asset cache fix, PNG-enabled iPXE, composed PXE logo
Two real issues v0.4.6 left on the table:

Asset caching:
- index.html now interpolates the running OpenPXE version into every
  asset URL as `?v=<version>` (app.css, app.js, logo.svg). Combined
  with `Cache-Control: no-cache, must-revalidate` on the asset
  handlers, browsers and intermediary proxies are forced to fetch
  fresh on every upgrade. Without this, last release's bundled JS
  kept serving the old UI even after the operator pulled the new
  image — invisible to anyone who only checks the version chip in
  the footer (which is dynamic).
- The Cache-Control header is also applied to logo.svg and loader.svg
  so a logo upload reflects immediately rather than after a hard
  refresh.

Real-image PXE menu logo (matches iVentoy now):
- New Dockerfile stage `ipxe-build` clones the iPXE source and
  compiles all four binaries (undionly.kpxe, snponly.efi for
  x86_64/i386, snponly.efi for arm64 via gcc-aarch64-linux-gnu) with
  IMAGE_PNG + CONSOLE_FRAMEBUFFER + CONSOLE_VESAFB enabled. Replaces
  the boot.ipxe.org fetch — those binaries are built without PNG
  support, which is why v0.4.6's `console --picture` line silently
  no-op'd.
- `iso-store::pxe_logo::compose_pxe_logo` decodes any operator upload
  (PNG / JPEG / WebP / GIF), downscales-to-fit if larger than
  600×200, and pastes it onto a transparent 1024×768 canvas
  centered horizontally with a 64-pixel top margin. iPXE paints the
  result at 1:1 on the typical VESA framebuffer, giving the
  iVentoy-style centered-logo look regardless of the operator's
  source dimensions.
- GET /branding/pxe-logo now returns the composed PNG. wimboot still
  fetches from ipxe/wimboot's GitHub release (separately signed).
- Dropped the ASCII OpenPXE wordmark from render_menu — once the
  real image paints, the banner would duplicate it visually. iPXE
  builds without PNG (none of ours after this release, but a third-
  party undionly might) simply show the menu without a logo, which
  is the right graceful-degradation outcome.

Quality:
- 142 tests passing (was 138 in v0.4.6): +4 pxe_logo unit tests
  covering canvas dimensions, centered-top placement, oversize
  downscale, and unsupported-bytes error handling; existing
  integration tests updated to verify the 1024×768 IHDR header from
  the composed PNG instead of round-tripping the raw upload.
- cargo clippy --workspace --all-targets clean.
- Image dependency: `image = "0.25"` with only `png/jpeg/webp/gif`
  features enabled. No new transitive C deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 00:38:39 -04:00
21 changed files with 2132 additions and 896 deletions
Generated
+187 -8
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -84,6 +90,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.7.9"
@@ -212,12 +224,24 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -280,6 +304,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -295,6 +325,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -406,6 +445,25 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -549,6 +607,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "globset"
version = "0.4.18"
@@ -821,6 +889,34 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -958,6 +1054,16 @@ dependencies = [
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.0"
@@ -969,6 +1075,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -1001,6 +1117,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1015,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openpxe"
version = "0.4.6"
version = "0.4.65"
dependencies = [
"anyhow",
"axum",
@@ -1037,7 +1162,7 @@ dependencies = [
[[package]]
name = "openpxe-core"
version = "0.4.6"
version = "0.4.65"
dependencies = [
"anyhow",
"bcrypt",
@@ -1056,7 +1181,7 @@ dependencies = [
[[package]]
name = "openpxe-dhcp-proxy"
version = "0.4.6"
version = "0.4.65"
dependencies = [
"anyhow",
"bytes",
@@ -1070,13 +1195,14 @@ dependencies = [
[[package]]
name = "openpxe-http-api"
version = "0.4.6"
version = "0.4.65"
dependencies = [
"anyhow",
"axum",
"bytes",
"futures",
"hyper",
"image",
"mime",
"mime_guess",
"openpxe-core",
@@ -1100,7 +1226,7 @@ dependencies = [
[[package]]
name = "openpxe-ipxe-assets"
version = "0.4.6"
version = "0.4.65"
dependencies = [
"openpxe-core",
"rust-embed",
@@ -1110,12 +1236,13 @@ dependencies = [
[[package]]
name = "openpxe-iso-store"
version = "0.4.6"
version = "0.4.65"
dependencies = [
"anyhow",
"bcrypt",
"bytes",
"hex",
"image",
"libc",
"openpxe-core",
"parking_lot",
@@ -1133,7 +1260,7 @@ dependencies = [
[[package]]
name = "openpxe-tftp"
version = "0.4.6"
version = "0.4.65"
dependencies = [
"anyhow",
"bytes",
@@ -1147,7 +1274,7 @@ dependencies = [
[[package]]
name = "openpxe-webui"
version = "0.4.6"
version = "0.4.65"
[[package]]
name = "parking_lot"
@@ -1184,6 +1311,19 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -1227,6 +1367,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.45"
@@ -1484,6 +1636,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "slab"
version = "0.4.12"
@@ -2124,6 +2282,12 @@ dependencies = [
"semver",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -2438,3 +2602,18 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.4.6"
version = "0.4.65"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
+3
View File
@@ -41,3 +41,6 @@ tower = { workspace = true }
tempfile = "3.12"
serde_json = { workspace = true }
time = { workspace = true }
# v0.4.61: integration tests need to generate real PNG bytes for the
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
image = { version = "0.25", default-features = false, features = ["png"] }
+222 -83
View File
@@ -35,7 +35,7 @@ use openpxe_core::{
MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest};
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, SmbAddRequest};
use serde::Deserialize;
use serde_json::json;
use std::net::SocketAddr;
@@ -132,10 +132,15 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/queue/poll/:entry_id", get(api_queue_poll))
.route("/api/queue/assign", post(api_queue_assign))
.route("/api/queue/:entry_id", delete(api_queue_release))
// Phase 4: NFS share manager.
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
.route("/api/nfs/:id", delete(api_nfs_remove))
.route("/api/nfs/:id/scan", post(api_nfs_scan))
// v0.4.65: SMB share manager (userspace via smbclient). The
// kernel-mount NFS routes that v0.4.64 shipped are gone — they
// didn't work on hosts whose kernel lacked the nfs client
// 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))
// Phase 4: Network info (read-only) + DNS edit.
.route("/api/network", get(api_network).put(api_network_put))
// Phase 4: live-log stream + recent buffer for the Terminal tab.
@@ -193,23 +198,47 @@ async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>)
// ─── UI ────────────────────────────────────────────────────────────────────
async fn index(State(state): State<AppState>) -> Response {
let html = openpxe_webui::index_html(&state.public_base_url);
// The asset version pin in index.html (`?v=…`) is what makes
// browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE
// binary version — every release ships a new value, every release
// forces a fresh URL on each asset.
let html = openpxe_webui::index_html(&state.public_base_url, env!("CARGO_PKG_VERSION"));
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
)],
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
),
// index.html itself must never be cached — that's how the
// browser learns about a new `?v=…` value for the assets.
(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, must-revalidate"),
),
],
html,
)
.into_response()
}
/// Cache-Control header value used for the bundled JS/CSS/SVG assets.
/// We pin a 1-day TTL so a long-lived deployment doesn't re-fetch the
/// same bytes on every page-load, but require revalidation — combined
/// with the `?v=<version>` query string in index.html, the practical
/// upper bound on caching across an upgrade is "until the operator
/// reloads".
const ASSET_CACHE_CONTROL: HeaderValue =
HeaderValue::from_static("no-cache, must-revalidate");
async fn ui_js() -> Response {
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/javascript"),
)],
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/javascript"),
),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
],
openpxe_webui::app_js(),
)
.into_response()
@@ -217,7 +246,10 @@ async fn ui_js() -> Response {
async fn ui_css() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
[
(header::CONTENT_TYPE, HeaderValue::from_static("text/css")),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
],
openpxe_webui::app_css(),
)
.into_response()
@@ -261,20 +293,29 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
}
}
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
)],
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
],
openpxe_webui::logo_svg(),
)
.into_response()
}
/// v0.4.6: raster-only logo endpoint for the iPXE menu's
/// `console --picture`. iPXE can't rasterize SVG, so SVG uploads 404
/// here — the ASCII OpenPXE wordmark in `render_menu` already gives
/// the operator a polished default. No bundled PNG fallback by design:
/// either the operator's raster logo paints, or the text stands in.
/// v0.4.61: PXE menu logo composed for the iPXE `console --picture`
/// call. The operator can upload any raster image (PNG / JPEG / WebP /
/// GIF) of any aspect ratio; this handler decodes it, draws it
/// centered-top onto a fixed 1024×768 canvas, and returns PNG bytes.
/// That gives the same look as iVentoy regardless of what the operator
/// uploaded — a portrait logo, a wide wordmark, a square monogram all
/// land in the same place on the boot screen.
///
/// SVG uploads still 404 here — iPXE can't rasterize SVG, and rather
/// than haul in `resvg` we ask the operator to provide a raster when
/// they want a custom PXE-side logo. (The WebUI keeps using the SVG.)
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
let Some(path) = state.branding.logo_path() else {
return (StatusCode::NOT_FOUND, "no custom logo configured").into_response();
@@ -285,40 +326,68 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
if mime == "image/svg+xml" {
return (
StatusCode::NOT_FOUND,
"operator-uploaded logo is SVG; iPXE menu falls back to the bundled ASCII wordmark",
"operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)",
)
.into_response();
}
match tokio::fs::read(&path).await {
Ok(bytes) => {
let ct = HeaderValue::from_str(&mime)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
(
[
(header::CONTENT_TYPE, ct),
(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, max-age=0"),
),
],
bytes,
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(e) => {
return (
StatusCode::NOT_FOUND,
format!("custom logo unreadable: {e}"),
)
.into_response()
.into_response();
}
Err(e) => (
StatusCode::NOT_FOUND,
format!("custom logo unreadable: {e}"),
)
.into_response(),
}
};
// Compose to a fixed 1024×768 PNG so the PXE menu paints the logo
// centered-top regardless of the operator's source dimensions. The
// `image` crate is pure-Rust + sync; offload to a blocking task
// because Lanczos resampling on a 4K source can take tens of
// milliseconds and we don't want to block the executor.
let composed =
match tokio::task::spawn_blocking(move || openpxe_iso_store::pxe_logo::compose_pxe_logo(&bytes))
.await
{
Ok(Ok(png)) => png,
Ok(Err(e)) => {
tracing::warn!(
target: "openpxe::http::branding",
error = %e, "failed to compose PXE logo PNG"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to compose PXE logo: {e}"),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("pxe-logo task failed: {e}"),
)
.into_response();
}
};
(
[
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
],
composed,
)
.into_response()
}
async fn ui_loader() -> Response {
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
)],
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
],
openpxe_webui::loader_svg(),
)
.into_response()
@@ -574,12 +643,59 @@ async fn iso_raw(
headers: HeaderMap,
) -> Response {
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();
};
match stream_file_range(&path, headers.get(header::RANGE)).await {
Ok(r) => r,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
match &meta.source {
IsoSource::Local => {
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(),
}
}
}
}
@@ -587,6 +703,10 @@ async fn iso_file(
State(state): State<AppState>,
AxumPath((id, path)): AxumPath<(String, String)>,
) -> 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 {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
@@ -963,16 +1083,16 @@ async fn api_docs() -> Json<serde_json::Value> {
],
},
{
"name": "NFS shares",
"name": "SMB shares",
"endpoints": [
{"method": "GET", "path": "/api/nfs",
"summary": "List configured NFS shares with mount state and iso counts."},
{"method": "POST", "path": "/api/nfs",
"summary": "Mount an NFS share. Body: { server, export, version, read_only }."},
{"method": "DELETE", "path": "/api/nfs/:id",
"summary": "Unmount a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/nfs/:id/scan",
"summary": "Re-walk a mounted share for ISOs."},
{"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."},
],
},
{
@@ -1389,8 +1509,11 @@ 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> {
let smb = state.smb.as_ref().map(|s| s.snapshot());
let nfs = state.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
// v0.4.65: NFS replaced with SMB shares. The dashboard metric
// shape stays similar (count + reachable) so the UI doesn't have
// to change its top-line tiles.
let smb_shares = state.smb_shares.list();
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
let isos = state.iso_store.list();
let clients = state.clients.list();
let queue_entries = state.queue.list();
@@ -1409,7 +1532,7 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
state
.metrics
.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 as u64);
state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
@@ -1424,8 +1547,12 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
"settings": state.settings.snapshot(),
"smb": smb,
"nfs_count": nfs.len(),
"nfs_active": nfs_active,
// Keep the field names for now so existing UI bindings on
// `iso_count2` / `client_count2` / sidebar counters keep
// working. They cover "external storage shares" generically;
// v0.4.65 the source is SMB instead of NFS.
"smb_share_count": smb_shares.len(),
"smb_share_reachable": smb_reachable,
"host_bindings": state.hosts.len(),
"custom_logo": state.branding.has_logo(),
"uptime_secs": uptime_secs,
@@ -1640,32 +1767,42 @@ 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> {
Json(json!({ "mounts": state.nfs.list() }))
async fn api_smb_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "shares": state.smb_shares.list() }))
}
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
match state.nfs.add(req).await {
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
// Anything from the manager surfaces as a user-fixable validation
// error — bad host, kernel without NFS support, missing
// `mount.nfs`, dead server. We pass the message through verbatim
// so the UI can show it to the operator.
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
async fn api_smb_shares_add(
State(state): State<AppState>,
Json(req): Json<SmbAddRequest>,
) -> Response {
match state.smb_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_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
match state.nfs.remove(&id).await {
async fn api_smb_shares_remove(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.smb_shares.remove(&id).await {
Ok(()) => StatusCode::NO_CONTENT.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 {
match state.nfs.rescan(&id).await {
async fn api_smb_shares_scan(
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(),
}
@@ -1785,9 +1922,11 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
state
.metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
// v0.4.65: gauge tracks reachable external-storage shares. With
// NFS removed it now reflects SMB share reachability instead.
state
.metrics
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
.set_nfs_active(state.smb_shares.list().iter().filter(|m| m.reachable).count() as u64);
let now = time::OffsetDateTime::now_utc();
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
+17 -11
View File
@@ -77,10 +77,13 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
);
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu");
// Centered ASCII wordmark. iPXE menus are ~76 columns wide on the
// default VGA text console; the lines below are padded to sit
// approximately centered. `item --gap -- <text>` emits text without
// a selectable hotkey.
// ASCII OpenPXE wordmark. Works on every iPXE build, including
// the boot.ipxe.org pre-builds we ship (which omit `IMAGE_PNG`,
// so `console --picture` paints nothing). When the queued iPXE
// source-build lands and the operator's uploaded raster actually
// paints via `console --picture`, this banner can be retired in
// favour of the real image. The compositor at
// /branding/pxe-logo is already wired and waiting.
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___");
let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|");
@@ -628,10 +631,13 @@ mod password_tests {
#[test]
fn top_menu_has_polished_branding_and_arch_footer() {
// v0.4.6 polish: a `console --picture` line for operator
// logos, an ASCII OpenPXE wordmark visible across iPXE
// builds (graphics or not), and a single-line footer carrying
// the current OpenPXE version + the resolved arch label.
// v0.4.6 polish + v0.4.62 stability fixes: the menu emits a
// `console --picture` line that PNG-capable iPXE builds will
// honour (queued for a follow-up release once we can rebuild
// iPXE from source on native x86_64 hardware), an ASCII
// OpenPXE wordmark that works on every iPXE build (including
// the boot.ipxe.org pre-builds we currently ship), and a
// single-line footer carrying the OpenPXE version + arch.
let settings = Settings::default();
let s = render_menu(&[], &settings, "http://10.0.0.5");
assert!(
@@ -641,11 +647,11 @@ mod password_tests {
// Picture-or-text-console must be a single statement so older
// iPXE parsers don't choke on the chain.
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
// ASCII wordmark — at least one of the banner lines must
// contain the trailing pipe segment, plus the leading "_"s.
// ASCII wordmark — paints on every iPXE build regardless of
// PNG support.
assert!(
s.contains("___ ___ __ __ ___"),
"ascii banner missing first row:\n{s}"
"ASCII banner missing first row:\n{s}"
);
// Footer with version + arch interpolation. The version comes
// from CARGO_PKG_VERSION at compile time.
+9 -6
View File
@@ -4,7 +4,7 @@ use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, SettingsStore, SsoStore,
};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use openpxe_iso_store::{IsoStore, SmbManager, SmbShareManager};
use std::sync::Arc;
use time::OffsetDateTime;
@@ -44,11 +44,14 @@ pub struct AppState {
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where
/// Windows support is not wired in. Settings toggle drives start/stop.
pub smb: Option<Arc<SmbManager>>,
/// NFS share manager. Always present (mounting is opt-in by the
/// operator from the Storage tab); `add()` requires `mount.nfs` to be
/// available in the runtime image. Surfaces errors per-mount rather
/// than failing the global state.
pub nfs: NfsManager,
/// v0.4.65: SMB share manager — userspace consumer of remote SMB
/// shares via Samba's `smbclient` CLI. Replaces the kernel-mount
/// NFS path that v0.4.64 shipped; that path didn't work on hosts
/// (Unraid, etc.) whose kernel ships without the nfs/cifs client
/// 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,
/// Browser chunked upload state. Multipart uploads still go straight
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files.
+90 -63
View File
@@ -88,7 +88,11 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
"isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)),
"queue" => queue_command(state, tail).await,
"nfs" => nfs_command(state, tail).await,
// v0.4.65: `nfs` is gone — replaced with userspace SMB share
// consumer. `smb` still controls the outbound Samba server
// for Windows install media; `share` lists/manages remote SMB
// shares OpenPXE pulls ISOs from.
"share" | "smb-share" => smb_share_command(state, tail).await,
"smb" => smb_command(state, tail).await,
"log" => log_command(state, tail),
"whoami" => Ok("operator".to_string()),
@@ -107,18 +111,18 @@ fn status_text(s: &AppState) -> String {
let clients = s.clients.list();
let queue_entries = s.queue.list();
let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
let smb_shares = s.smb_shares.list();
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
format!(
"OpenPXE {ver}\n\
base url: {base}\n\
interface: {nic}\n\
uptime: {up}\n\
isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\
isos: {n_isos} (local: {n_local}, smb: {n_smb})\n\
clients: {n_clients}\n\
queue: {n_entries}\n\
smb: {smb}\n\
nfs mounts: {n_total} configured ({n_active} active)\n",
smb server: {smb}\n\
smb shares: {n_total} configured ({n_active} reachable)\n",
ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url,
nic = if s.nic_name.is_empty() {
@@ -132,15 +136,15 @@ fn status_text(s: &AppState) -> String {
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local))
.count(),
n_nfs = isos
n_smb = isos
.iter()
.filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local))
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. }))
.count(),
n_clients = clients.len(),
n_entries = queue_entries.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = nfs.len(),
n_active = nfs_active,
n_total = smb_shares.len(),
n_active = smb_reachable,
)
}
@@ -158,7 +162,8 @@ fn isos_text(s: &AppState) -> String {
for i in isos {
let src = match i.source {
openpxe_iso_store::IsoSource::Local => "local".to_string(),
openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
// v0.4.65: SMB userspace consumer replaced kernel-mount NFS.
openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
};
let _ = writeln!(
out,
@@ -264,89 +269,111 @@ 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) {
None | Some("list") => {
let mounts = s.nfs.list();
if mounts.is_empty() {
return Ok("(no NFS mounts configured)".into());
let shares = s.smb_shares.list();
if shares.is_empty() {
return Ok("(no SMB shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(
out,
"{:<24} {:<6} {:<7} {:<6} TARGET",
"ID", "VER", "STATUS", "ISOS"
"{:<24} {:<7} {:<6} {:<6} TARGET",
"ID", "STATUS", "AUTH", "ISOS"
);
for m in mounts {
let status = if m.mounted { "ok" } else { "down" };
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let auth = if m.guest { "guest" } else { "user" };
let _ = writeln!(
out,
"{:<24} {:<6} {:<7} {:<6} {}:{}",
"{:<24} {:<7} {:<6} {:<6} //{}/{}",
truncate(&m.id, 24),
match m.version {
openpxe_iso_store::NfsVersion::V3 => "v3",
openpxe_iso_store::NfsVersion::V41 => "v4.1",
},
status,
auth,
m.iso_count,
m.server,
m.export,
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("mount") => {
// nfs mount <server>:<export> [v3|v41] [ro|rw]
Some("add") => {
// share add //server/share [guest|user:password]
let target = args
.get(1)
.ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?;
let (server, export) = target
.split_once(':')
.ok_or_else(|| "target must be 'server:/export'".to_string())?;
let version = match args.get(2).map(String::as_str) {
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)"))
}
.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());
};
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let req = openpxe_iso_store::NfsAddRequest {
// 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(),
export: export.to_string(),
version,
read_only,
share: share.to_string(),
username,
password,
guest,
port: None,
};
match s.nfs.add(req).await {
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
Err(e) => Err(format!("mount failed: {e}")),
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("unmount") => {
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: nfs unmount <id>".to_string())?;
match s.nfs.remove(id).await {
Ok(()) => Ok(format!("unmounted {id}")),
Err(e) => Err(format!("unmount failed: {e}")),
.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: nfs scan <id>".to_string())?;
match s.nfs.rescan(id).await {
.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 nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]"
"unknown share subcommand: {other}\ntry: share [list|add|remove|scan]"
)),
}
}
@@ -487,13 +514,13 @@ OpenPXE terminal — available commands:
queue assign-all <target> assign every waiting client
queue release <entry_id> release one queued client
nfs list list NFS mounts
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
nfs unmount <id> unmount and forget a share
nfs scan <id> re-scan a share for new ISOs
share list list configured SMB shares
share add //srv/share [auth] add an SMB share; auth = 'guest' or 'user:pass'
share remove <id> forget an SMB share
share scan <id> re-list a share for new ISOs
smb status SMB (Samba) state
smb start | stop | reload control smbd
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 tail [n] show the last n buffered lines (default 20)
@@ -508,10 +535,10 @@ mod tests {
#[test]
fn shell_split_basic() {
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!(
shell_split("nfs mount 10.0.0.5:/srv v41 ro"),
vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"]
shell_split("share add //nas/isos guest"),
vec!["share", "add", "//nas/isos", "guest"]
);
}
+82 -19
View File
@@ -14,7 +14,7 @@ use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager};
use openpxe_iso_store::{IsoStore, SmbShareManager};
use tempfile::tempdir;
use tower::ServiceExt;
@@ -94,8 +94,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let clients = ClientRegistry::new();
let queue = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(dir.path());
let nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path());
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
@@ -117,7 +116,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
sso,
metrics,
smb: None,
nfs,
smb_shares,
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus,
started_at: time::OffsetDateTime::now_utc(),
@@ -464,35 +463,72 @@ async fn no_external_urls_in_generated_ipxe() {
// ── Phase 4 integration tests ────────────────────────────────────────────
// v0.4.65: kernel-mount NFS replaced with userspace SMB via smbclient.
#[tokio::test]
async fn nfs_add_with_bad_export_is_rejected() {
// Validation must happen before we shell out to /bin/mount —
// otherwise the operator sees opaque kernel errors instead of a
// clear "your export must start with /" hint.
async fn smb_share_add_with_missing_server_is_rejected() {
// Validation must run before we shell out to smbclient — otherwise
// operators see opaque NT_STATUS codes for what's really a typo.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/nfs",
r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#,
"/api/smb-shares",
r#"{"server":"","share":"isos","guest":true}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.contains("export"),
msg.to_lowercase().contains("server"),
"expected validation hint, got: {msg}"
);
}
#[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 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);
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);
}
#[tokio::test]
@@ -1755,11 +1791,26 @@ async fn pxe_logo_404_when_no_custom_logo_configured() {
assert!(text.contains("no custom logo"), "got: {text}");
}
/// Build a tiny valid PNG via the `image` crate. The v0.4.61 PXE-logo
/// compositor decodes whatever the operator uploaded — hand-rolled
/// PNGs with handwritten CRCs are too easy to break; let the encoder
/// produce something it can later decode.
fn tiny_png() -> Vec<u8> {
use image::{DynamicImage, ImageBuffer, ImageFormat, Rgb};
use std::io::Cursor;
let buf: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(8, 8, Rgb([0, 180, 220]));
let mut out = Vec::with_capacity(256);
DynamicImage::ImageRgb8(buf)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
#[tokio::test]
async fn pxe_logo_404_when_uploaded_logo_is_svg() {
// iPXE can't rasterize SVG, so an SVG upload deliberately doesn't
// light up the PXE menu's `console --picture` overlay — the ASCII
// wordmark in render_menu stands in instead.
// light up the PXE menu's `console --picture` overlay — the menu
// simply paints without a logo.
let (state, _dir) = build_state().await;
state
.branding
@@ -1777,11 +1828,15 @@ async fn pxe_logo_404_when_uploaded_logo_is_svg() {
}
#[tokio::test]
async fn pxe_logo_serves_raster_with_correct_mime() {
async fn pxe_logo_composes_to_1024x768_png() {
// v0.4.61: the endpoint no longer serves the raw upload — it
// composes the operator's logo into a fixed 1024×768 canvas so
// the iPXE menu always paints at consistent dimensions.
let (state, _dir) = build_state().await;
let png = tiny_png();
state
.branding
.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake-png-bytes")
.set_logo("image/png", "png", &png)
.unwrap();
let app = build_router(state);
let res = app
@@ -1805,7 +1860,14 @@ async fn pxe_logo_serves_raster_with_correct_mime() {
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
// PNG signature.
assert!(body.starts_with(b"\x89PNG"), "PNG header missing");
// IHDR chunk lives at bytes 8..29; width is bytes 16..20, height
// 20..24 in big-endian u32. The composed canvas should be 1024×768.
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
let height = u32::from_be_bytes([body[20], body[21], body[22], body[23]]);
assert_eq!(width, 1024, "compose should pin width to 1024");
assert_eq!(height, 768, "compose should pin height to 768");
}
#[tokio::test]
@@ -1814,9 +1876,10 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
// must stay reachable once the admin has been bootstrapped. The
// auth allowlist gates `/api/*` only.
let (state, _dir) = build_state().await;
let png = tiny_png();
state
.branding
.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake")
.set_logo("image/png", "png", &png)
.unwrap();
let app = build_router(state);
// Configure an admin so the middleware kicks in.
+7
View File
@@ -27,6 +27,13 @@ parking_lot.workspace = true
bytes.workspace = true
tempfile = "3.12"
libc = "0.2"
# v0.4.61: server-side compose of the operator's uploaded raster into a
# fixed 1024x768 canvas so the PXE menu always gets a consistently-sized
# PNG regardless of what the operator uploaded. We use the bare-bones
# `image` crate (no default features) and explicitly enable only the
# decoders we accept on upload (PNG/JPEG/WebP/GIF) plus the PNG
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
[dev-dependencies]
tempfile = "3.12"
+8 -2
View File
@@ -18,15 +18,21 @@
pub mod entry;
pub mod introspect;
pub mod nfs;
pub mod pxe_logo;
pub mod smb;
pub mod smb_share;
pub mod store;
pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs};
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_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, SmbStream};
pub use store::{
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
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('.'));
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Operator-logo compositor for the iPXE menu.
//!
//! The brief: match iVentoy's polished centered-logo PXE chrome with
//! whatever raster the operator drops onto Settings → Branding. A wide
//! wordmark, a portrait stack, a square monogram — all three should
//! land in roughly the same place on the boot screen.
//!
//! Approach: decode the operator's upload, fit it into a fixed
//! 1024×768 canvas with the logo horizontally centered and pinned a
//! short margin from the top, re-encode as PNG, return the bytes. iPXE
//! built with `IMAGE_PNG` paints the result via `console --picture`.
//!
//! The 1024×768 size matches the default VESA framebuffer iPXE picks
//! on most BIOS/UEFI consoles. Operators uploading 4K logos get
//! correctly downscaled; tiny icons get drawn at their native size,
//! centered, with transparent margins.
//!
//! We deliberately don't ship `resvg` for SVG support — keeping the
//! dependency surface narrow matters more than supporting SVG-only
//! brand assets. The WebUI's logo stays SVG-native (the browser
//! rasterizes it); the PXE menu wants a raster regardless.
use image::imageops::FilterType;
use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage};
use std::io::Cursor;
/// Canvas dimensions used for the composed PXE logo. Picked to match
/// the framebuffer dimensions iPXE picks on most BIOS/UEFI consoles —
/// gives a 1:1 paint with no scaling at the firmware layer.
pub const CANVAS_W: u32 = 1024;
pub const CANVAS_H: u32 = 768;
/// Maximum dimensions for the operator's logo inside the canvas. Any
/// upload larger than this in either axis is downscaled (preserving
/// aspect ratio) to fit. Smaller uploads paint at native size.
const LOGO_MAX_W: u32 = 600;
const LOGO_MAX_H: u32 = 200;
/// Top margin in pixels from the canvas's top edge to the logo's top
/// edge. Matches the visual rhythm of iVentoy's screen (logo at top,
/// menu below).
const LOGO_TOP_MARGIN: u32 = 64;
/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top
/// 1024×768 PNG and return the encoded bytes.
///
/// Errors when the source can't be decoded or the encoded buffer can't
/// be written (only really fires on out-of-memory; the encoder itself
/// is infallible for well-formed inputs).
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
let logo = image::load_from_memory(src_bytes)?;
// Resize-fit if the upload exceeds our bounding box. `Lanczos3`
// keeps the antialiasing crisp on the framebuffer console; it's a
// touch slower than `Triangle` but the operator hits this endpoint
// once per boot at most.
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
let logo_rgba = logo.to_rgba8();
// Transparent canvas. iPXE 1.21+ honours alpha-channel transparency
// on framebuffer consoles; older builds simply draw the alpha as
// black, which still gives a sensible look.
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0]));
let logo_w = logo_rgba.width();
let logo_h = logo_rgba.height();
// Horizontal center, top-margin from the top. Saturating math
// means a logo wider than CANVAS_W (shouldn't happen after the
// downscale above, but defensive) just sits flush-left.
let off_x = CANVAS_W.saturating_sub(logo_w) / 2;
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_h));
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
let mut out = Vec::with_capacity(64 * 1024);
DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
Ok(out)
}
fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage {
let (w, h) = (img.width(), img.height());
if w <= max_w && h <= max_h {
return img;
}
// Preserve aspect ratio. `resize` clamps to the smaller of the
// two scale factors so we never overshoot the bounding box.
img.resize(max_w, max_h, FilterType::Lanczos3)
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgb};
fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(w, h, Rgb(rgb));
let mut out = Vec::with_capacity(4096);
DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
#[test]
fn compose_emits_canvas_sized_png() {
let src = solid_png(120, 60, [200, 50, 50]);
let out = compose_pxe_logo(&src).unwrap();
// Round-trip the output and confirm dimensions.
let img = image::load_from_memory(&out).unwrap();
assert_eq!(img.width(), CANVAS_W);
assert_eq!(img.height(), CANVAS_H);
}
#[test]
fn small_logo_centered_at_top_margin() {
let src = solid_png(100, 40, [10, 200, 10]);
let out = compose_pxe_logo(&src).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
// Pixel just inside the logo box should match the source color
// (alpha=255). Pixel near a far corner of the canvas should be
// the transparent background.
let cx = (CANVAS_W - 100) / 2;
let cy = LOGO_TOP_MARGIN;
let inside = canvas.get_pixel(cx + 10, cy + 10);
assert_eq!(inside.0[3], 255, "logo pixel should be opaque");
assert!(inside.0[0] < 100 && inside.0[1] > 100 && inside.0[2] < 100, "color mismatch: {inside:?}");
let corner = canvas.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
assert_eq!(corner.0[3], 0, "canvas corner should be transparent");
}
#[test]
fn oversize_logo_is_downscaled_to_bounding_box() {
// 4000×800 image — bigger than LOGO_MAX_W and LOGO_MAX_H in
// both axes. After downscale the output must fit; we re-decode
// the canvas, count non-transparent pixels, and confirm none
// sit outside the expected band.
let src = solid_png(4000, 800, [50, 50, 200]);
let out = compose_pxe_logo(&src).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
// Span row at the top margin should have non-transparent
// pixels somewhere; rows past the LOGO_TOP_MARGIN + LOGO_MAX_H
// should be entirely transparent.
let bottom_band_y = LOGO_TOP_MARGIN + LOGO_MAX_H + 10;
for x in 0..CANVAS_W {
let p = canvas.get_pixel(x, bottom_band_y);
assert_eq!(p.0[3], 0, "row {bottom_band_y} should be transparent at x={x}");
}
}
#[test]
fn unsupported_bytes_returns_error_not_panic() {
let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image");
assert!(r.is_err());
}
}
+940
View File
@@ -0,0 +1,940 @@
//! SMB share consumer — replaces the kernel-mount NFS path that v0.4.64
//! shipped.
//!
//! ## Why SMB and not NFS
//!
//! v0.4.64 tried to make `mount -t nfs` work inside the OpenPXE
//! container. With `CAP_SYS_ADMIN` + `--privileged` we still hit the
//! same `mount.nfs: failed to apply fstab options` on Unraid because
//! Unraid's base kernel ships without the `nfs` / `nfsv4` client
//! modules loaded. No amount of container-side configuration can
//! load a kernel module on the host.
//!
//! SMB has the same kernel-side problem (`mount -t cifs` needs the
//! `cifs` kernel module) but unlike NFS it has a usable **userspace**
//! client: Samba's `smbclient` CLI. It speaks the SMB protocol over a
//! plain TCP socket, no kernel modules required. Bootimus uses the
//! same approach.
//!
//! ## How it works
//!
//! 1. Operator submits a share spec via the Storage tab:
//! `{ server: "192.168.1.51", share: "isos",
//! username, password, guest }`.
//! 2. We write credentials to a 0600-permission tempfile under
//! `<work_dir>/smb_creds/`. Passing them on the command line would
//! leak them through `ps` and the container's audit log.
//! 3. We test the connection by listing the share's root with
//! `smbclient //server/share -A creds_file -c 'ls *.iso'`. If the
//! server is unreachable, the share doesn't exist, or auth fails,
//! we get a clean error before persisting anything.
//! 4. We parse the `ls` output for `*.iso` filenames and sizes, and
//! register each one with the `IsoStore` as an
//! `IsoSource::Smb { share_id, relative_path }`.
//! 5. When a PXE client requests the bytes, the HTTP handler asks this
//! manager for an async reader. We spawn
//! `smbclient //server/share -A creds_file -c 'get file -'` and
//! pipe its stdout straight into the response body. No double
//! storage, no temp files.
//!
//! ## Why subprocess and not a Rust library
//!
//! The Debian runtime image already ships the `samba` package
//! (Dockerfile line 84) — `smbclient` is right there. Library options
//! like `pavao` wrap `libsmbclient` so they still pull in the same C
//! library at runtime. Subprocess is simpler, the API surface is
//! whatever the operator can verify with `smbclient` at a shell, and
//! debugging "what does smbclient see?" is trivial.
//!
//! ## Range request limitations (v0.4.65)
//!
//! `smbclient -c 'get file -'` is a sequential whole-file stream;
//! there's no native seek in the CLI. We honor full GETs and reject
//! HTTP Range requests with `416 Range Not Satisfiable` for
//! SMB-sourced ISOs. PXE clients in practice request the whole file:
//! iPXE chain loading, casper sanboot, wimboot all do sequential
//! streaming. A follow-up release can add libsmbclient-based seek if
//! a real workload needs it.
use crate::introspect::{DistroFamily, 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::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::process::Command;
/// Default TCP port for SMB / CIFS. The wire protocol moved to 445
/// years ago; 139 (NetBIOS) is legacy and we don't expose it as an
/// option.
const DEFAULT_SMB_PORT: u16 = 445;
/// Maximum time we wait for a TCP connection to the SMB server during
/// the pre-flight probe. Same shape as the v0.4.64 NFS probe — short
/// enough that a wrong IP doesn't make the UI hang for 30s, long
/// enough that a slow appliance can still answer.
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// One configured SMB share. The id is derived from server+share so an
/// operator pasting the same coordinates twice gets idempotent
/// behaviour rather than a duplicate row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmbShare {
pub id: String,
pub server: String,
pub share: String,
/// Username used for the SMB connection. Empty when `guest` is
/// true. Stored so the UI can echo it back; the password lives in
/// the separate credentials file (see `creds_path`).
pub username: String,
/// True when we're connecting with `-N` (anonymous / guest mode).
/// Most NAS appliances that expose ISO libraries do so as
/// guest-readable; this is the common case.
pub guest: bool,
/// TCP port — 445 unless the operator overrode it. Persisted so
/// the UI can echo it back.
#[serde(default = "default_port")]
pub port: u16,
/// Most recent error encountered talking to the share, or `None`
/// on success. Cleared every successful operation.
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>,
/// Number of `*.iso` files we know about on the share as of the
/// most recent scan.
pub iso_count: u32,
/// Whether the connection's currently working. `true` after a
/// successful scan, `false` after a failure. Drives the UI dot.
pub reachable: bool,
/// Path to the credentials file on disk. Internal — not surfaced
/// in the API JSON; we serialize it for restart-survival but the
/// UI doesn't render it.
#[serde(default)]
#[serde(skip_serializing)]
pub(crate) creds_path: Option<PathBuf>,
}
/// Submission from the UI / API.
#[derive(Debug, Clone, Deserialize)]
pub struct SmbAddRequest {
pub server: String,
pub share: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub guest: bool,
#[serde(default)]
pub port: Option<u16>,
}
fn default_port() -> u16 {
DEFAULT_SMB_PORT
}
/// Structured error surfaced to the API and rendered in the UI as two
/// lines: the raw `error` from smbclient + an actionable `hint`.
/// Mirrors the v0.4.64 NFS error shape so the storage tab can use a
/// single rendering path.
#[derive(Debug, Clone, Serialize)]
pub struct SmbShareError {
pub error: String,
pub stderr: String,
pub hint: Option<String>,
}
impl SmbShareError {
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, SmbShare>,
}
/// Manages SMB shares and surfaces their ISOs through the IsoStore.
///
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct SmbShareManager {
creds_root: Arc<PathBuf>,
state_path: Arc<PathBuf>,
inner: Arc<Mutex<Inner>>,
iso_store: IsoStore,
/// Serializes scan/list/get against the same share. smbclient
/// itself is fine concurrent across processes, but bundling
/// operations through a single lock makes test ordering and log
/// output predictable.
op_lock: Arc<tokio::sync::Mutex<()>>,
}
impl SmbShareManager {
/// Construct a manager rooted at `work_dir`. Credentials files
/// live under `<work_dir>/smb_creds/` with 0600 permissions; state
/// persists to `<work_dir>/smb_shares.json`.
#[must_use]
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
let creds_root = work_dir.join("smb_creds");
let state_path = work_dir.join("smb_shares.json");
Self {
creds_root: Arc::new(creds_root),
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. Errors per share
/// are logged and surfaced on the spec; the call itself never
/// fails — startup must not block on a single offline server.
pub async fn load_and_rescan(&self) -> Result<()> {
tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await {
Ok(text) => serde_json::from_str::<Vec<SmbShare>>(&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::smb",
id = %s.id, server = %s.server, share = %s.share,
"rescan on startup failed: {e}"
);
}
}
Ok(())
}
/// Add or refresh a share. Validates the input, writes a creds
/// file, probes connectivity, and scans for ISOs.
pub async fn add(
&self,
req: SmbAddRequest,
) -> std::result::Result<SmbShare, SmbShareError> {
let server = normalize_server(&req.server);
let share = req.share.trim().trim_start_matches('/').to_string();
if server.is_empty() {
return Err(SmbShareError::from_raw("server is required", ""));
}
if share.is_empty() {
return Err(SmbShareError::from_raw("share name is required", ""));
}
if share.contains('/') {
return Err(SmbShareError::from_raw(
"share name should be the top-level share (e.g. 'isos'), not a path",
"",
));
}
if server.contains('\0') || share.contains('\0') {
return Err(SmbShareError::from_raw("NUL bytes are not allowed", ""));
}
let guest = req.guest;
let username = req.username.unwrap_or_default().trim().to_string();
let password = req.password.unwrap_or_default();
if !guest && username.is_empty() {
return Err(SmbShareError::from_raw(
"username is required when 'guest' is unchecked",
"",
));
}
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_SMB_PORT);
let id = share_id(&server, &share);
// Pre-flight TCP probe so a wrong IP / firewall surfaces a
// clean error instead of one of smbclient's notoriously
// cryptic NT_STATUS codes.
if let Err((err, hint)) = tcp_probe(&server, port).await {
// No share is persisted yet; just return the error.
return Err(SmbShareError {
error: err,
stderr: String::new(),
hint: Some(hint),
});
}
// Write the creds file. Even guest mode gets a file (empty
// username/password) so the code path is uniform.
let creds_path = self.creds_root.join(format!("{id}.cred"));
if let Err(e) = self.write_creds(&creds_path, &username, &password).await {
return Err(SmbShareError::from_raw(
format!("could not write credentials file: {e}"),
"",
));
}
let spec = SmbShare {
id: id.clone(),
server,
share,
username,
guest,
port,
last_error: None,
last_hint: None,
last_scan: None,
iso_count: 0,
reachable: false,
creds_path: Some(creds_path),
};
self.inner.lock().shares.insert(id.clone(), spec);
self.persist_locked();
// Now actually talk to the server.
if let Err(e) = self.rescan_inner(&id).await {
let m = self.get(&id);
return Err(SmbShareError {
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, scrubs the
/// creds file, and forgets the spec. Idempotent.
pub async fn remove(&self, id: &str) -> Result<()> {
let creds_path = {
let mut g = self.inner.lock();
g.shares.remove(id).and_then(|s| s.creds_path)
};
self.iso_store.drop_external_source(id);
if let Some(p) = creds_path {
// Overwrite-then-unlink would be more thorough but the
// file is 0600 in a non-root-owned dir; rm is sufficient.
let _ = tokio::fs::remove_file(&p).await;
}
self.persist_locked();
Ok(())
}
/// Re-list the share and refresh the IsoStore entries.
pub async fn rescan(&self, id: &str) -> Result<u32> {
self.rescan_inner(id).await
}
/// Snapshot of every configured share, sorted by id for stable UI
/// rendering.
#[must_use]
pub fn list(&self) -> Vec<SmbShare> {
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
}
/// Look up a share by id.
#[must_use]
pub fn get(&self, id: &str) -> Option<SmbShare> {
self.inner.lock().shares.get(id).cloned()
}
/// Open an async reader streaming an ISO out of the share. Used
/// by the HTTP ISO download handler.
///
/// Kept `async` for symmetry with the other I/O entrypoints —
/// spawning the child is sync today (no `.await` inside) but a
/// future addition (e.g. probing the share before spawn or
/// throttling concurrent smbclients) would need to await without
/// changing the call sites.
#[allow(clippy::unused_async)]
pub async fn stream_iso(
&self,
share_id: &str,
filename: &str,
) -> Result<SmbStream> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?;
// Defensive: reject any filename that tries to escape the
// share root. smbclient itself accepts only filenames at the
// share root in our `get` form, but belt-and-suspenders.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!(
"invalid filename '{filename}'"
)));
}
let creds = share
.creds_path
.as_deref()
.ok_or_else(|| Error::Invalid("share has no credentials file".into()))?;
let target = format!("//{}/{}", share.server, share.share);
let mut cmd = Command::new("smbclient");
cmd.arg(&target)
.arg("-A")
.arg(creds)
.arg("-p")
.arg(share.port.to_string())
.arg("-c")
.arg(format!("get \"{filename}\" -"))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
if share.guest {
cmd.arg("-N");
}
let mut child = cmd.spawn().map_err(|e| Error::Other(e.into()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| Error::Invalid("smbclient stdout missing".into()))?;
Ok(SmbStream { child, stdout })
}
// ── 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 self.list_isos(&share).await {
Ok(l) => l,
Err((err, stderr)) => {
let combined = if stderr.is_empty() {
err.clone()
} else {
format!("{err}: {stderr}")
};
let hint = hint_for(&stderr).or_else(|| hint_for(&err));
self.update_status(id, 0, false, Some(combined.clone()), hint, now);
return Err(Error::Invalid(combined));
}
};
// For each ISO we found, we still need its size + a quick
// introspection pass. The introspection pass needs random
// access into the ISO9660 PVD which lives at offset 0x8000.
// For SMB sources we can't seek without downloading the file
// first, so we use a degenerate "unknown family" introspection
// report for the listing pass. Operators can rescan after the
// first PXE boot has touched the file if they want a real
// family detection. (Better: a follow-up release adds a tiny
// `smbclient -c 'get file -'` bounded read to do introspection
// without storing the whole ISO.)
let mut count = 0u32;
for entry in listing {
let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename));
// SMB sources don't get a real introspection pass — that
// would require seeking into the ISO9660 PVD over the
// network, and smbclient CLI doesn't seek. We register an
// `Unknown` family so the boot-entry generator falls back
// to generic sanboot/wimboot detection from the filename
// and the operator gets *something* bootable. A follow-up
// release can do a bounded `smbclient get` of the first
// 64 KiB for real 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::Smb {
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::smb",
id = %id, server = %share.server, share = %share.share,
iso_count = count,
"SMB share scanned"
);
Ok(count)
}
/// Spawn `smbclient //server/share -A creds -c "ls *.iso"` and
/// parse the output. Returns `(error_text, stderr_text)` on
/// failure so the caller can surface both.
async fn list_isos(
&self,
share: &SmbShare,
) -> std::result::Result<Vec<SmbListEntry>, (String, String)> {
let target = format!("//{}/{}", share.server, share.share);
let mut cmd = Command::new("smbclient");
cmd.arg(&target)
.arg("-A")
.arg(
share
.creds_path
.as_deref()
.ok_or_else(|| ("no credentials file".to_string(), String::new()))?,
)
.arg("-p")
.arg(share.port.to_string())
.arg("-c")
.arg("ls *.iso");
if share.guest {
cmd.arg("-N");
}
let output = match cmd.output().await {
Ok(o) => o,
Err(e) => {
return Err((
format!("could not exec smbclient: {e}"),
String::new(),
));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
// smbclient writes most diagnostics to stdout too; merge
// them so we don't lose context.
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let combined = if stderr.is_empty() { stdout } else { stderr };
return Err((
format!("smbclient exit {}", output.status.code().unwrap_or(-1)),
combined,
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_ls_iso(&stdout))
}
async fn write_creds(
&self,
path: &Path,
username: &str,
password: &str,
) -> std::io::Result<()> {
tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
// Write the file with 0600 perms. `smbclient -A` accepts the
// standard pam_mount-style:
// username = foo
// password = bar
let body = format!(
"username = {}\npassword = {}\n",
username.replace('\n', ""),
password.replace('\n', ""),
);
// Synchronous file write to set perms atomically with the
// create — there's no async equivalent of OpenOptions+mode
// shared with the tokio API in std stable.
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
f.write_all(body.as_bytes())?;
Ok(())
})
.await
.map_err(std::io::Error::other)??;
Ok(())
}
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();
}
/// Atomically replace the on-disk JSON. Persistence errors are
/// logged, never propagated.
fn persist_locked(&self) {
let shares: Vec<SmbShare> = 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::smb", "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::smb", "write tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "openpxe::smb", "rename: {e}");
}
}
}
/// Async-reader handle for an in-flight `smbclient get file -` stream.
/// Wraps the child process + its piped stdout; dropping it kills the
/// child.
#[derive(Debug)]
pub struct SmbStream {
/// Kept alive so the child isn't reaped while we're reading. The
/// `Drop` impl on `tokio::process::Child` sends SIGKILL on drop
/// when `kill_on_drop` is set; we leave that to the default
/// (no-kill) so a slow client doesn't tear down the pipe before
/// the OS finishes the read. The child exits naturally when its
/// stdout closes.
#[allow(dead_code)]
child: tokio::process::Child,
pub stdout: tokio::process::ChildStdout,
}
#[derive(Debug, Clone)]
struct SmbListEntry {
filename: String,
size: u64,
}
/// Parse `smbclient ls *.iso` output. The format is:
///
/// ```text
/// . D 0 Mon May 26 10:00:00 2026
/// .. D 0 Mon May 26 10:00:00 2026
/// ubuntu-22.04-desktop.iso A 3650912256 Mon May 26 11:00:00 2026
///
/// 4096 blocks of size 1048576. 1234 blocks available
/// ```
///
/// Each file line:
/// - starts with whitespace
/// - has the filename, then attribute flags (D=dir, A=archive, R=read-only,
/// H=hidden, S=system, N=normal), then size, then date.
///
/// We accept any line where the attributes column doesn't contain `D`
/// (i.e. not a directory) and the filename ends in `.iso` (case
/// insensitive).
fn parse_ls_iso(out: &str) -> Vec<SmbListEntry> {
let mut entries = Vec::new();
for raw in out.lines() {
let line = raw.trim();
// Skip blank lines, the connection-info banner, and the
// trailing "N blocks of size" summary. The actual filter for
// "is this a file listing?" is the attribute+size pattern
// detection below, which only matches real file rows.
if line.is_empty() || line.contains("blocks of size") {
continue;
}
// Find the attribute column: a short token of one or more of
// [DAHSRN] that follows a long-enough filename block.
// smbclient pads the filename to ~36 columns, so we can split
// on multiple consecutive spaces and then look for the
// attribute token.
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 3 {
continue;
}
// The last 5 tokens are typically: ATTR SIZE Day Mon DD HH:MM:SS YYYY
// (sometimes Day is missing depending on locale). Walk
// backwards to find ATTR + SIZE: ATTR is 1-6 chars of [DAHSRN],
// SIZE is digits.
let attr_idx = tokens.iter().enumerate().rev().find_map(|(i, t)| {
if i == 0 {
return None;
}
let next = tokens.get(i + 1)?;
let is_attr = !t.is_empty() && t.chars().all(|c| "DAHSRN".contains(c));
let is_size = next.chars().all(|c| c.is_ascii_digit()) && !next.is_empty();
if is_attr && is_size {
Some(i)
} else {
None
}
});
let Some(attr_idx) = attr_idx else { continue };
let attr = tokens[attr_idx];
// Directories aren't ISO files.
if attr.contains('D') {
continue;
}
let size_tok = tokens[attr_idx + 1];
let Ok(size) = size_tok.parse::<u64>() else {
continue;
};
// The filename is everything before the attribute token in
// the original (un-tokenized) line — we need the original
// because filenames can contain spaces.
// Locate the attribute token's start column by counting
// characters in the prior tokens + separators. Simpler: find
// the index of the attribute in the trimmed line by joining
// and trimming again.
let joined_before: String = tokens[..attr_idx].join(" ");
let name = joined_before.trim().to_string();
if name.is_empty() || name == "." || name == ".." {
continue;
}
if !name.to_ascii_lowercase().ends_with(".iso") {
continue;
}
entries.push(SmbListEntry {
filename: name,
size,
});
}
entries
}
/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
/// probe so the UI banner reads consistently.
async fn tcp_probe(
server: &str,
port: u16,
) -> std::result::Result<(), (String, String)> {
use tokio::net::TcpStream;
let addr = format!("{server}:{port}");
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err((
format!("cannot reach SMB port: {addr}: {e}"),
format!(
"verify the SMB service is running on {server} and that port {port} is open"
),
)),
Err(_) => Err((
format!(
"cannot reach SMB port: {addr}: timed out after {}s",
PROBE_TIMEOUT.as_secs()
),
format!(
"no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between",
PROBE_TIMEOUT.as_secs()
),
)),
}
}
/// Translate well-known smbclient stderr patterns into actionable
/// hints. Returns `None` when we don't have a translation.
fn hint_for(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
if s.contains("nt_status_logon_failure") || s.contains("logon_failure") {
Some(
"the server rejected the credentials. Double-check the username \
and password — many NAS appliances use a separate SMB account \
rather than the system login."
.into(),
)
} else if s.contains("nt_status_access_denied") || s.contains("access_denied") {
Some(
"the credentials worked but the account doesn't have read \
access to this share. Check the share's permissions on the \
server."
.into(),
)
} else if s.contains("nt_status_bad_network_name")
|| s.contains("nt_status_bad_network_path")
|| s.contains("bad_network_name")
{
Some(
"the share name doesn't exist on this server. Enter just the \
share name (e.g. 'isos'), not a path. Use `smbclient -L \
//server` to list shares manually."
.into(),
)
} else if s.contains("connection refused") {
Some(
"the SMB service isn't accepting connections on this port. \
Verify smbd / Samba is running on the server."
.into(),
)
} else if s.contains("connection timed out") || s.contains("no route to host") {
Some(
"the server isn't reachable on this network. Check the IP and \
any firewall in between."
.into(),
)
} else if s.contains("nt_status_network_unreachable") {
Some(
"the server's network is unreachable from this container — \
check the host networking setup."
.into(),
)
} else if s.contains("does not exist") || s.contains("not a directory") {
Some(
"the listed path doesn't exist on the share. Make sure the \
share name is the top-level share, not a sub-path."
.into(),
)
} else if s.contains("session setup failed") {
Some(
"session setup failed — usually a protocol / dialect mismatch. \
Most modern servers speak SMB2/3; very old shares (XP) may \
need legacy support enabled on the server."
.into(),
)
} else {
None
}
}
fn share_id(server: &str, share: &str) -> String {
slugify_str(&format!("{server}-{share}"))
}
/// Normalize a server input: trim, strip scheme prefix the operator
/// may have pasted, and drop trailing slashes. UNC-style `\\server`
/// and `//server` prefixes are also accepted.
fn normalize_server(raw: &str) -> String {
let s = raw.trim();
let s = s
.strip_prefix("smb://")
.or_else(|| s.strip_prefix("cifs://"))
.or_else(|| s.strip_prefix("\\\\"))
.or_else(|| s.strip_prefix("//"))
.unwrap_or(s);
s.trim_end_matches('/').trim_end_matches('\\').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn share_id_is_stable_and_safe() {
let a = share_id("10.0.0.5", "isos");
let b = share_id("10.0.0.5", "isos");
assert_eq!(a, b);
assert!(!a.contains('/'));
assert!(!a.contains('.'));
}
#[test]
fn normalize_server_strips_url_and_unc_prefixes() {
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
assert_eq!(normalize_server("smb://nas.lan/"), "nas.lan");
assert_eq!(normalize_server("cifs://192.168.1.51"), "192.168.1.51");
assert_eq!(normalize_server("\\\\192.168.1.51\\"), "192.168.1.51");
assert_eq!(normalize_server("//nas.lan//"), "nas.lan");
assert_eq!(normalize_server("nas.lan"), "nas.lan");
}
#[test]
fn hint_for_logon_failure_calls_out_credentials() {
let h = hint_for("session setup failed: NT_STATUS_LOGON_FAILURE").unwrap();
assert!(h.to_lowercase().contains("credentials"));
}
#[test]
fn hint_for_bad_share_name_calls_out_share_lookup() {
let h = hint_for("tree connect failed: NT_STATUS_BAD_NETWORK_NAME").unwrap();
assert!(h.to_lowercase().contains("share"));
}
#[test]
fn hint_for_unknown_is_none() {
assert!(hint_for("some unrelated error text").is_none());
}
#[test]
fn parse_ls_iso_finds_one_iso_and_skips_directories() {
let out = "\
\tDomain=[WORKGROUP] OS=[Windows] Server=[Samba]\n\
. D 0 Mon May 26 10:00:00 2026\n\
.. D 0 Mon May 26 10:00:00 2026\n\
ubuntu-22.04-desktop.iso A 3650912256 Mon May 26 11:00:00 2026\n\
\n\
\t\t4096 blocks of size 1048576. 1234 blocks available\n\
";
let entries = parse_ls_iso(out);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].filename, "ubuntu-22.04-desktop.iso");
assert_eq!(entries[0].size, 3_650_912_256);
}
#[test]
fn parse_ls_iso_handles_filenames_with_spaces() {
let out = "\
Windows Server 2025.iso A 5000000000 Tue May 27 09:00:00 2026\n\
";
let entries = parse_ls_iso(out);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].filename, "Windows Server 2025.iso");
assert_eq!(entries[0].size, 5_000_000_000);
}
#[test]
fn parse_ls_iso_skips_non_iso_files() {
let out = "\
readme.txt A 100 Tue May 27 09:00:00 2026\n\
archive.zip A 5000 Tue May 27 09:00:00 2026\n\
";
let entries = parse_ls_iso(out);
assert!(entries.is_empty());
}
#[test]
fn add_request_requires_username_when_not_guest() {
// We can't easily test the add() path against a real SMB
// server in unit tests, but we can confirm the validation
// logic at least serializes the request shape we expect. The
// actual auth check happens in add() itself which we cover in
// integration tests against a stub server.
let req = SmbAddRequest {
server: "10.0.0.5".into(),
share: "isos".into(),
username: None,
password: None,
guest: false,
port: None,
};
// No SmbShareManager here — we just check the field shape
// matches what UI submits.
assert!(!req.guest);
assert!(req.username.is_none());
}
}
+42 -47
View File
@@ -16,17 +16,24 @@ use tokio::io::AsyncWriteExt;
/// Where the bytes for an ISO actually live.
///
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
/// `Nfs` entries point at a file inside a remote share that the
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
/// `Smb` entries (v0.4.65) point at a file inside a remote SMB share
/// that the `SmbShareManager` knows how to stream via Samba's
/// userspace `smbclient` CLI. The HTTP handler resolves the share by
/// id at request time and pipes `smbclient -c 'get file -'` straight
/// into the response body — no kernel mount, no local cache.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource {
#[default]
Local,
Nfs {
mount_id: String,
/// Path relative to the mount point — typically just the filename.
/// v0.4.65: kernel-mount NFS is gone (it didn't work on Unraid
/// regardless of capabilities — the host kernel needs the nfs
/// client modules loaded). 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,
},
}
@@ -164,10 +171,6 @@ struct Inner {
#[derive(Debug, Clone)]
pub struct IsoStore {
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>>,
}
@@ -175,17 +178,10 @@ impl IsoStore {
pub fn new(iso_dir: PathBuf) -> Self {
Self {
iso_dir: Arc::new(iso_dir),
nfs_root: Arc::new(RwLock::new(None)),
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<()> {
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
Ok(())
@@ -281,33 +277,32 @@ impl IsoStore {
self.inner.read().isos.get(id).cloned()
}
/// Resolve an ISO id to its on-disk path, if any. For local entries
/// this is `<iso_dir>/<id>.iso`; for NFS entries it's
/// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file
/// is missing or the source isn't resolvable (e.g. NFS share
/// unmounted).
/// Resolve an ISO id to its on-disk path, if any. For local
/// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced
/// ISOs there is no on-disk path — the HTTP handler must stream
/// via `SmbShareManager::stream_iso` instead. Returns `None` for
/// SMB sources or when the file is missing.
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
let meta = self.get(id)?;
let path = match &meta.source {
IsoSource::Local => self.iso_path(id),
IsoSource::Nfs {
mount_id,
relative_path,
} => {
let root = self.nfs_root.read().clone()?;
root.join(mount_id).join(relative_path)
match &meta.source {
IsoSource::Local => {
let path = self.iso_path(id);
if path.exists() {
Some(path)
} else {
None
}
}
};
if path.exists() {
Some(path)
} else {
None
// SMB sources have no local path — they're streamed via
// smbclient subprocess. Callers should check the source
// kind first and dispatch accordingly.
IsoSource::Smb { .. } => None,
}
}
/// Delete an ISO and its sidecar metadata. Only acts on local ISOs;
/// for NFS-backed ISOs the operator must remove the file from the
/// share or unmount the NFS share entirely.
/// Delete an ISO and its sidecar metadata. Only acts on local
/// (uploaded) ISOs; for SMB-backed ISOs the operator must remove
/// the file from the share or unregister the share entirely.
pub async fn delete(&self, id: &str) -> Result<()> {
let meta = self.get(id);
let is_local = matches!(
@@ -324,10 +319,10 @@ impl IsoStore {
Ok(())
}
/// Register an externally-sourced ISO (e.g. NFS-mounted). Used by
/// `NfsManager` after walking a freshly-mounted share. We do **not**
/// persist a `meta.json` on disk for these — the source of truth is
/// the share itself, and the NFS manager re-scans on startup.
/// Register an externally-sourced ISO (SMB share, etc.). Used by
/// `SmbShareManager` after listing a share. We do **not** persist
/// a `meta.json` on disk for these — the source of truth is the
/// share itself, and the manager re-scans on startup.
pub fn register_external(
&self,
id: String,
@@ -352,13 +347,13 @@ impl IsoStore {
self.inner.write().isos.insert(id, meta);
}
/// Drop every entry that belongs to `mount_id`. Used by the NFS
/// manager when an operator removes a share, or before re-scanning
/// to clean out stale entries.
pub fn drop_external_source(&self, mount_id: &str) {
/// Drop every entry that belongs to `share_id`. Used by the SMB
/// share manager when an operator removes a share, or before
/// re-scanning to clean out stale entries.
pub fn drop_external_source(&self, share_id: &str) {
let mut g = self.inner.write();
g.isos.retain(
|_, m| !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id),
|_, m| !matches!(&m.source, IsoSource::Smb { share_id: sid, .. } if sid == share_id),
);
}
+14 -9
View File
@@ -9,7 +9,7 @@ use openpxe_core::{
};
use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use openpxe_iso_store::{IsoStore, SmbManager, SmbShareManager};
use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
@@ -119,13 +119,18 @@ async fn main() -> anyhow::Result<()> {
let _ = smb.start();
}
// NFS manager. The mount root has to be set on the IsoStore *before*
// we replay any persisted mounts, otherwise an in-memory IsoMeta
// pointing at an NFS source can't resolve to a path.
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
if let Err(e) = nfs.load_and_remount().await {
tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}");
// v0.4.65: SMB share manager — Samba `smbclient` userspace
// consumer. Replaces the kernel-mount NFS path that v0.4.64
// shipped; that didn't work on hosts whose kernel lacked the nfs
// client modules (Unraid is the dominant case). `smbclient` does
// the SMB protocol entirely in userspace over TCP and works in
// any container regardless of capabilities or kernel modules.
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}"
);
}
// Sniff network details for the Network tab. None of these are
@@ -152,7 +157,7 @@ async fn main() -> anyhow::Result<()> {
sso: sso.clone(),
metrics: metrics.clone(),
smb: Some(smb.clone()),
nfs: nfs.clone(),
smb_shares: smb_shares.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(),
+60 -1
View File
@@ -34,9 +34,18 @@
--topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--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"] {
color-scheme: light;
/* Light palette — high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */
@@ -304,6 +313,23 @@ label.field textarea {
font-size: 14px; line-height: 1.4;
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 {
outline: none; border-color: var(--accent);
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;
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 ────────────────────────────────────────────────────── */
+157 -78
View File
@@ -167,7 +167,8 @@
el('div', {class: 'trend'},
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
(status.nfs_active || 0) + ' NFS active'),
(status.smb_share_reachable || 0) + ' SMB share' +
((status.smb_share_reachable || 0) === 1 ? '' : 's')),
])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'),
@@ -342,13 +343,18 @@
},
storage: async () => {
const [isos, settings, nfsRes, disk] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
// v0.4.65: kernel-mount NFS replaced with userspace SMB via
// smbclient — works in any container regardless of host kernel
// modules or capabilities. The /api/nfs endpoint is gone;
// /api/smb-shares is the replacement.
const [isos, settings, smbRes, disk] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'),
getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})),
]);
const mounts = nfsRes.mounts || [];
const shares = smbRes.shares || [];
// ── Upload card ──
const drop = el('div', {class:'drop', id:'drop'}, [
@@ -450,7 +456,7 @@
}
}
// ── ISO table (mixed local + NFS) ──
// ── ISO table (mixed local + SMB) ──
// Each row gets a "Password" cell that toggles a small inline
// editor (a checkbox + a password field + Save button) inside the
// *next* row of the table. Keeps the markup flat and avoids the
@@ -458,7 +464,10 @@
const rowsAndEditors = [];
isos.forEach(i => {
const b = bootability(i, settings);
const isNfs = i.source && i.source.kind === 'nfs';
// v0.4.65: SMB userspace consumer replaced NFS. The badge
// colours stay the same so the table looks unchanged for
// existing operators.
const isSmb = i.source && i.source.kind === 'smb';
const protectedNow = !!i.password_hash;
// The inline editor row is hidden by default; the Password
@@ -578,8 +587,8 @@
]),
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
el('td', {},
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
isNfs ? ('nfs:' + i.source.mount_id) : 'local')),
el('span', {class:'src-badge' + (isSmb ? ' nfs' : '')},
isSmb ? ('smb:' + i.source.share_id) : 'local')),
el('td', {},
protectedNow
? el('span', {class:'tag accent'}, 'protected')
@@ -589,8 +598,11 @@
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
}}, protectedNow ? 'Password ✎' : 'Set password'),
isNfs
? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS')
isSmb
// v0.4.65: SMB-sourced ISOs live on the remote share —
// OpenPXE doesn't own those bytes. Same pattern as NFS
// had: surface a tag instead of a destructive button.
? el('span', {class:'tag', style:'opacity:.6'}, 'on SMB')
: el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this image?')) return;
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
@@ -610,56 +622,98 @@
])),
el('tbody', {}, rowsAndEditors),
])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
// ── NFS section ──
const nfsMsg = el('div', {class:'msg'});
const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'});
const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'});
const nfsVer = el('select', {}, [
el('option', {value:'v41'}, 'NFSv4.1 (default)'),
el('option', {value:'v3'}, 'NFSv3'),
]);
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
const addNfs = el('button', {onclick: async () => {
if (!nfsServer.value || !nfsExport.value) {
nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return;
// ── SMB shares section (v0.4.65) ──
// Replaces the kernel-mount NFS card. SMB shares are consumed
// in userspace via Samba's `smbclient` CLI — no kernel modules,
// no CAP_SYS_ADMIN, works in any container. This is the same
// approach Bootimus uses.
const smbMsg = el('div', {class:'msg'});
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
const smbShare = el('input', {type:'text', placeholder:'isos'});
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
// Toggle username/password fields based on the Guest checkbox so
// operators don't get confused about which fields matter.
const syncAuthDisabled = () => {
smbUser.disabled = smbGuest.checked;
smbPass.disabled = smbGuest.checked;
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
};
smbGuest.addEventListener('change', syncAuthDisabled);
syncAuthDisabled();
const addSmb = el('button', {onclick: async () => {
if (!smbServer.value || !smbShare.value) {
smbMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
smbMsg.className='msg err'; return;
}
nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg';
const r = await postJSON('/api/nfs', {
server: nfsServer.value, export: nfsExport.value,
version: nfsVer.value, read_only: nfsRo.checked,
});
if (!smbGuest.checked && !smbUser.value) {
smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
smbMsg.className='msg err'; return;
}
smbMsg.replaceChildren(document.createTextNode('Connecting…'));
smbMsg.className = 'msg';
const body = {
server: smbServer.value,
share: smbShare.value,
guest: smbGuest.checked,
};
if (!smbGuest.checked) {
body.username = smbUser.value;
body.password = smbPass.value;
}
const r = await postJSON('/api/smb-shares', body);
if (r.ok) {
nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok';
smbMsg.replaceChildren(document.createTextNode('Connected.'));
smbMsg.className = 'msg ok';
render('storage');
} else {
const t = await r.text();
nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err';
// The API returns a structured {error, stderr, hint} JSON
// body on failure so the raw smbclient error and the
// actionable hint render as two distinct lines.
let bodyJson = null;
let raw = null;
try { 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')}, [
el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}),
const smbRows = shares.length ? shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, m.server + ':' + m.export),
el('div', {class:'id'}, '//' + m.server + '/' + m.share),
el('div', {class:'meta'},
(m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' +
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
const r = await postJSON('/api/smb-shares/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return;
await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'});
if (!confirm('Forget //' + m.server + '/' + m.share + '?')) return;
await fetch('/api/smb-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Unmount'),
}}, 'Remove'),
el('span'),
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
])) : [el('div', {class:'empty'}, 'No SMB shares configured.')];
// Disk-space card. Free + used + total for the volume hosting the
// ISO directory, with a coloured bar. Warns at 80% and goes red at
@@ -709,34 +763,42 @@
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'NFS shares'),
el('span', {class:'sub'}, mounts.length + ' configured'),
el('h2', {}, 'SMB shares'),
el('span', {class:'sub'}, shares.length + ' configured'),
]),
el('div', {class:'body'}, [
el('div', {class:'form-row'}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'NFS server'),
nfsServer,
el('span', {class:'name'}, 'SMB server'),
smbServer,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Export path'),
nfsExport,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Version'),
nfsVer,
]),
el('label', {class:'check', style:'margin-top:18px'}, [
nfsRo, el('span', {}, 'Read-only'),
el('span', {class:'name'}, 'Share name'),
smbShare,
]),
]),
addNfs, nfsMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
el('label', {class:'check'}, [
smbGuest, el('span', {}, 'Guest (anonymous read)'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Username'),
smbUser,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Password'),
smbPass,
]),
]),
addSmb, smbMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, smbRows),
el('p', {class:'msg', style:'margin-top:14px'},
'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' +
'mount.nfs binary (bundled in the default Docker image). On ' +
'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' +
'NFS mounts as a CSI driver outside the pod.'),
'SMB shares are read in userspace via 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.'),
]),
]),
el('div', {class:'card'}, [
@@ -1025,7 +1087,11 @@
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
placeholder: 'confirm new password'});
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';
if (!currentPw.value) {
accountMsg.textContent = 'Current password is required.';
@@ -1130,29 +1196,39 @@
el('option', {value:'xml'}, 'Metadata XML'),
]);
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'}, [
el('span', {class:'name'}, 'IdP metadata URL'),
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'),
ssoXml,
el('span', {class:'hint'},
'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 = () => {
if (ssoMode.value === 'url') {
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
urlHint.style.display = '';
} else {
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
urlHint.style.display = 'none';
}
};
ssoMode.onchange = refreshSsoFields;
refreshSsoFields();
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';
const payload = {
enabled: ssoEnabled.checked,
@@ -1193,32 +1269,35 @@
ssoEnabled,
el('span', {}, 'Enable single sign-on'),
]),
// 3-column header strip: display name, logo URL, metadata
// source. All three controls inherit the same border/padding/
// focus chrome from the global `label.field input/select`
// rule, so they line up cleanly. Below: the active source
// field (URL or XML) spans the full width.
el('div', {class:'form-row cols-3'}, [
// v0.4.63: 4-column form-row that matches the Administrator
// account card above column-for-column — display name / logo
// URL / metadata source / metadata URL. All four controls share
// the same `label.field` chrome so they line up cleanly. When
// the operator picks "Metadata XML" the URL column collapses
// and the multi-line textarea drops below the row.
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP display name'),
ssoName,
el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP logo URL'),
ssoLogo,
el('span', {class:'hint'}, 'Optional. Shown next to the IdP name on the login button.'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Metadata source'),
ssoMode,
]),
urlWrap,
]),
urlWrap,
xmlWrap,
urlHint,
ssoSave, ssoMsg,
]),
]);
// Wire up + paint the initial visibility now that all elements
// referenced by `refreshSsoFields` are attached.
refreshSsoFields();
// ── Custom logo upload.
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
+12 -5
View File
@@ -5,8 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>OpenPXE</title>
<link rel="stylesheet" href="/assets/app.css" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
<!-- v0.4.61: the `?v=…` query string is replaced by the server at
request time with the running OpenPXE version. That guarantees a
fresh URL on every upgrade so browsers (and intermediary proxies)
can't keep serving stale JS / CSS / branding from before the
deploy. Combined with `Cache-Control: no-cache, must-revalidate`
on the asset handlers, the practical caching window is one
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}}" />
<!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. -->
@@ -26,7 +33,7 @@
<div class="shell">
<aside class="sidebar">
<div class="brand">
<img src="/assets/logo.svg" alt="OpenPXE" />
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" />
<strong>OpenPXE</strong>
</div>
<nav>
@@ -59,7 +66,7 @@
<!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.6</span></div>
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.63</span></div>
</div>
</aside>
@@ -115,6 +122,6 @@
<main class="main" id="view-root"></main>
</div>
<script src="/assets/app.js"></script>
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
</body>
</html>
+14 -4
View File
@@ -7,11 +7,21 @@
//! nav, top bar with secondary tabs, card-dense content panels.
#![forbid(unsafe_code)]
/// Render the top-level page. `base_url` is interpolated into the footer
/// so operators can see at a glance what URL clients are PXE-booting from.
/// Render the top-level page.
///
/// * `base_url` is interpolated into the footer so operators can see at
/// a glance what URL clients are PXE-booting from.
/// * `asset_version` is appended as `?v=…` to every asset URL so each
/// release ships with brand-new asset URLs — browsers (and any
/// intermediary proxy) can't keep serving last release's `app.js`
/// when we know the new one is incompatible. Combined with
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
/// the worst-case caching window is one version.
#[must_use]
pub fn index_html(base_url: &str) -> String {
INDEX_HTML.replace("{{BASE_URL}}", base_url)
pub fn index_html(base_url: &str, asset_version: &str) -> String {
INDEX_HTML
.replace("{{BASE_URL}}", base_url)
.replace("{{ASSET_VERSION}}", asset_version)
}
#[must_use]
+9 -1
View File
@@ -16,7 +16,15 @@
ARG RUST_VERSION=1.95
########## fetch iPXE binaries ##########
########## fetch iPXE binaries + wimboot ##########
# v0.4.62: kept on the boot.ipxe.org pre-builds for the moment. We
# want PNG support (so `console --picture` paints the operator's logo
# on the PXE menu) but the obvious path — adding a new `ipxe-build`
# stage that compiles iPXE from source with `IMAGE_PNG` enabled —
# runs into a QEMU/gcc instability when cross-emulating x86_64 on
# arm64 build hosts (intermittent `cc1` segfaults). The compositor
# at /branding/pxe-logo is already wired so when the iPXE rebuild
# lands (on native x86_64 hardware), no other code change is needed.
FROM debian:12-slim AS fetch
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
+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