Compare commits

..
5 Commits
Author SHA1 Message Date
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
Miles WardandClaude Opus 4.7 55f4765a20 v0.4.6: iVentoy-style PXE menu, top-right user menu, Settings touchups
PXE boot menu polish (iVentoy-inspired):
- render_menu now opens with a best-effort `console --picture
  <base>/branding/pxe-logo || console` line so iPXE builds with PNG
  support paint the operator's uploaded raster logo as the background.
- ASCII OpenPXE wordmark banner sits at the top of the menu in
  `item --gap` lines — always visible on every iPXE build, including
  the snponly/undionly variants without graphics console.
- New footer line above `choose`: "OpenPXE v0.4.6 - <arch label>",
  where <arch label> is mapped from iPXE's ${buildarch}/${platform}
  to "x86 BIOS", "x86_64 UEFI", or "arm64 UEFI". No URL, per brief.
- New GET /branding/pxe-logo route serves the operator's PNG / JPEG /
  WebP / GIF as-is for iPXE to consume. SVG uploads 404 here (iPXE
  can't rasterize SVG) — the always-visible ASCII wordmark stands in.
  Route stays public after admin setup so iPXE clients (no cookies)
  can fetch it.

UI:
- Removed the bottom-left "signed in as / Sign out" row.
- Added a person-icon button next to the theme toggle in the topbar.
  Click opens a small popover with: Name (display only), Edit account
  (jumps to Settings), Sign out. Esc + click-outside close it.
- Settings → Account card form chrome made consistent. The previous
  `label.field` selector only styled type=text/number, leaving
  password inputs with default browser chrome. Switched to a
  negation-list selector that covers every typed input we use, plus
  -webkit-appearance:none + a 1px focus ring. Light + dark mode both
  show the same border/padding/focus state across all four account
  fields.
- Settings → SSO card now renders display name, IdP logo URL (new),
  and metadata source on one 3-column row. The metadata <select>
  inherits the same chrome as the text inputs so it baseline-aligns
  with them. SsoConfig grew an idp_logo_url field, persisted to
  sso.json, length-capped and validated to http(s) only.

Quality:
- 138 tests passing (was 132 in v0.4.5). +1 IdP-logo-URL validation,
  +1 PXE menu polish regression guard, +4 /branding/pxe-logo
  integration tests covering missing-config / SVG-fallback / raster-
  serve / post-auth public-allowlist cases.
- cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 00:02:20 -04:00
19 changed files with 1731 additions and 194 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.5"
version = "0.4.64"
dependencies = [
"anyhow",
"axum",
@@ -1037,7 +1162,7 @@ dependencies = [
[[package]]
name = "openpxe-core"
version = "0.4.5"
version = "0.4.64"
dependencies = [
"anyhow",
"bcrypt",
@@ -1056,7 +1181,7 @@ dependencies = [
[[package]]
name = "openpxe-dhcp-proxy"
version = "0.4.5"
version = "0.4.64"
dependencies = [
"anyhow",
"bytes",
@@ -1070,13 +1195,14 @@ dependencies = [
[[package]]
name = "openpxe-http-api"
version = "0.4.5"
version = "0.4.64"
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.5"
version = "0.4.64"
dependencies = [
"openpxe-core",
"rust-embed",
@@ -1110,12 +1236,13 @@ dependencies = [
[[package]]
name = "openpxe-iso-store"
version = "0.4.5"
version = "0.4.64"
dependencies = [
"anyhow",
"bcrypt",
"bytes",
"hex",
"image",
"libc",
"openpxe-core",
"parking_lot",
@@ -1133,7 +1260,7 @@ dependencies = [
[[package]]
name = "openpxe-tftp"
version = "0.4.5"
version = "0.4.64"
dependencies = [
"anyhow",
"bytes",
@@ -1147,7 +1274,7 @@ dependencies = [
[[package]]
name = "openpxe-webui"
version = "0.4.5"
version = "0.4.64"
[[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.5"
version = "0.4.64"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
+51
View File
@@ -33,6 +33,11 @@ pub struct SsoConfig {
/// with X" button label. Empty/whitespace falls back to "SSO".
#[serde(default)]
pub idp_name: String,
/// Optional HTTPS URL pointing at the IdP's brand logo. Rendered
/// next to `idp_name` on the WebUI's login screen (FleetDM-style).
/// Length-capped at [`MAX_URL_LEN`]; empty is fine.
#[serde(default)]
pub idp_logo_url: String,
/// Raw SAML metadata XML pasted by the operator. Mutually exclusive
/// with `metadata_url`; if both are set, the URL wins at apply time
/// (operators typically forget about a stale XML paste).
@@ -100,6 +105,7 @@ impl SsoStore {
/// but the server enforces a hard ceiling regardless.
pub fn replace(&self, mut cfg: SsoConfig) -> Result<SsoConfig> {
cfg.idp_name = cfg.idp_name.trim().to_string();
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
cfg.metadata = cfg.metadata.trim().to_string();
cfg.metadata_url = cfg.metadata_url.trim().to_string();
if cfg.metadata.len() > MAX_METADATA_BYTES {
@@ -112,6 +118,11 @@ impl SsoStore {
"metadata_url exceeds {MAX_URL_LEN}-char cap"
)));
}
if cfg.idp_logo_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"idp_logo_url exceeds {MAX_URL_LEN}-char cap"
)));
}
if !cfg.metadata_url.is_empty()
&& !cfg.metadata_url.starts_with("http://")
&& !cfg.metadata_url.starts_with("https://")
@@ -120,6 +131,14 @@ impl SsoStore {
"metadata_url must start with http:// or https://".into(),
));
}
if !cfg.idp_logo_url.is_empty()
&& !cfg.idp_logo_url.starts_with("http://")
&& !cfg.idp_logo_url.starts_with("https://")
{
return Err(Error::Invalid(
"idp_logo_url must start with http:// or https://".into(),
));
}
// If they're trying to *enable* the integration but haven't
// supplied either source, reject — saves a "configured but
// unusable" surprise later.
@@ -197,6 +216,7 @@ mod tests {
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
})
.unwrap();
drop(s);
@@ -218,6 +238,7 @@ mod tests {
idp_name: "Test IdP".into(),
metadata: xml.into(),
metadata_url: String::new(),
idp_logo_url: String::new(),
})
.unwrap();
assert!(s.snapshot().is_usable());
@@ -232,6 +253,7 @@ mod tests {
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
// …and a disabled blank config is fine.
@@ -247,10 +269,38 @@ mod tests {
idp_name: String::new(),
metadata: String::new(),
metadata_url: "ftp://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn idp_logo_url_must_be_http_scheme() {
// v0.4.6: SSO settings learned an idp_logo_url so the login
// screen can render the FleetDM-style "Sign in with <IdP-logo>"
// affordance. Same scheme rule as metadata_url.
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "data:image/png;base64,...".into(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
// Real HTTPS URL is fine.
s.replace(SsoConfig {
enabled: false,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "https://idp.example.com/logo.png".into(),
})
.unwrap();
assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png");
}
#[test]
fn metadata_size_cap_enforced() {
let dir = tempdir().unwrap();
@@ -261,6 +311,7 @@ mod tests {
idp_name: String::new(),
metadata: oversize,
metadata_url: String::new(),
idp_logo_url: String::new(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
+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"] }
+140 -23
View File
@@ -51,6 +51,13 @@ pub fn build_router(state: AppState) -> Router {
.route("/assets/app.css", get(ui_css))
.route("/assets/logo.svg", get(ui_logo))
.route("/assets/loader.svg", get(ui_loader))
// v0.4.6: PXE menu logo — the raster form of the operator's
// uploaded mark, served so iPXE's `console --picture` can
// overlay it on the boot menu. SVG uploads 404 here (iPXE
// can't rasterize SVG); we deliberately don't bundle a
// pre-rendered PNG fallback because iPXE's ASCII wordmark
// banner already provides the always-visible branding.
.route("/branding/pxe-logo", get(ui_pxe_logo))
// iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu))
.route("/boot/:filename", get(boot_sub))
@@ -186,23 +193,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()
@@ -210,7 +241,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()
@@ -254,21 +288,101 @@ 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.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();
};
let Some(mime) = state.branding.logo_mime() else {
return (StatusCode::NOT_FOUND, "no mime recorded").into_response();
};
if mime == "image/svg+xml" {
return (
StatusCode::NOT_FOUND,
"operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)",
)
.into_response();
}
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(e) => {
return (
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()
@@ -945,6 +1059,8 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
{"method": "DELETE", "path": "/api/branding/logo",
"summary": "Remove the custom logo and revert to the bundled mark."},
{"method": "GET", "path": "/branding/pxe-logo",
"summary": "Raster form of the operator's logo for the iPXE menu's `console --picture`. SVG uploads 404 here."},
{"method": "GET", "path": "/api/sso",
"summary": "Current SAML SSO configuration."},
{"method": "PUT", "path": "/api/sso",
@@ -1597,11 +1713,12 @@ async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value>
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(),
// v0.4.64: the manager returns a structured `NfsMountError` with
// `error` + optional `hint` + the raw `stderr`, so the UI can
// show both — the raw message for completeness, the hint for
// "what to fix next". Previously this was a plain text body
// which collapsed both bits of information into one line.
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
}
}
+3
View File
@@ -440,6 +440,9 @@ mod tests {
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe",
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz",
"/metrics",
// v0.4.6: iPXE fetches this for `console --picture` before
// it can possibly have a session cookie.
"/branding/pxe-logo",
] {
assert!(is_public_path(p), "expected {p} to be public");
}
+101
View File
@@ -29,6 +29,15 @@ use std::fmt::Write as _;
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares.
///
/// v0.4.6: rendered with an iVentoy-style polished frame — centered
/// OpenPXE wordmark banner at the top (ASCII so every iPXE build can
/// paint it), a footer carrying version + arch + firmware kind, and an
/// optional `console --picture` directive that paints the operator's
/// uploaded raster logo on top when the iPXE binary on the wire was
/// built with PNG support. The ASCII banner is always rendered so
/// even when the picture call no-ops the screen still reads as
/// "OpenPXE — here is the menu" rather than a featureless box.
#[must_use]
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
let mut s = String::new();
@@ -47,8 +56,41 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, "set esc:hex 1b");
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
// v0.4.6: best-effort graphics console with the operator-uploaded
// raster logo. Falls back to plain text console on iPXE builds
// without PNG support — the `||` chain keeps a parse-clean
// single-statement form so even the strictest iPXE parsers accept
// it. The `console` reset at the end re-syncs the menu output.
let _ = writeln!(
s,
"console --picture {base}/branding/pxe-logo || console"
);
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
// iPXE evaluates `iseq` lazily, so we only set whichever line
// matches. Anything not on the allowlist falls through to a generic
// `<buildarch> <platform>` display.
let _ = writeln!(s, "set arch-label ${{buildarch}} ${{platform}}");
let _ = writeln!(
s,
"iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || true"
);
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu");
// 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 -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|");
let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|");
let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|");
let _ = writeln!(s, "item --gap -- |_|");
let _ = writeln!(s, "item --gap");
let _ = writeln!(
s,
"item --gap -- ------------------------- Default -------------------------"
@@ -82,6 +124,18 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let _ = writeln!(s, "item queue Queued Deployment (join queue)");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key x exit Exit iPXE");
// v0.4.6 footer line. Sits just above the `choose` line so it's
// always visible regardless of how the menu paginates. iPXE
// interpolates `${arch-label}` (set near the top of this script)
// and `${version}` is the binary-baked iPXE version — *not* the
// OpenPXE version — so we hard-code the OpenPXE version string
// here.
let openpxe_version = env!("CARGO_PKG_VERSION");
let _ = writeln!(s, "item --gap");
let _ = writeln!(
s,
"item --gap -- OpenPXE v{openpxe_version} - ${{arch-label}}"
);
if matches!(settings.timeout_action, TimeoutAction::Stay) {
let _ = writeln!(s, "choose --default {default_item} target || goto menu");
@@ -575,6 +629,53 @@ mod password_tests {
assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe"));
}
#[test]
fn top_menu_has_polished_branding_and_arch_footer() {
// 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!(
s.contains("console --picture http://10.0.0.5/branding/pxe-logo"),
"missing console --picture line:\n{s}"
);
// 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 — paints on every iPXE build regardless of
// PNG support.
assert!(
s.contains("___ ___ __ __ ___"),
"ASCII banner missing first row:\n{s}"
);
// Footer with version + arch interpolation. The version comes
// from CARGO_PKG_VERSION at compile time.
let version = env!("CARGO_PKG_VERSION");
assert!(
s.contains(&format!("OpenPXE v{version}")),
"footer missing OpenPXE version:\n{s}"
);
assert!(
s.contains("${arch-label}"),
"footer missing arch-label interpolation:\n{s}"
);
// No website URL — the design brief calls that out as tacky.
assert!(
!s.to_ascii_lowercase().contains("openpxe.com"),
"footer should not advertise the website:\n{s}"
);
// Arch-label mapping covers the three labels from the brief:
// "x86 BIOS", "x86_64 UEFI", "arm64 UEFI".
assert!(s.contains("x86 BIOS"), "{s}");
assert!(s.contains("x86_64 UEFI"), "{s}");
assert!(s.contains("arm64 UEFI"), "{s}");
}
#[test]
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
let settings = Settings::default();
+16 -1
View File
@@ -321,10 +321,25 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
export: export.to_string(),
version,
read_only,
// v0.4.64: terminal callers can't override the port yet
// — keep the default 2049. We could plumb a 4th arg
// later if anyone asks.
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}")),
// v0.4.64: `add` now returns a structured `NfsMountError`.
// We render the raw error plus the hint (if any) on
// separate lines so the terminal output mirrors what
// the Storage tab shows.
Err(e) => {
let mut out = format!("mount failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
}
}
Some("unmount") => {
+116
View File
@@ -1742,3 +1742,119 @@ async fn docs_lists_new_v0_4_5_endpoints() {
assert!(paths.iter().any(|p| p == needle), "{needle} missing");
}
}
// ─── v0.4.6: PXE logo endpoint ────────────────────────────────────────────
#[tokio::test]
async fn pxe_logo_404_when_no_custom_logo_configured() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::NOT_FOUND);
let text = std::str::from_utf8(&body).unwrap();
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 menu
// simply paints without a logo.
let (state, _dir) = build_state().await;
state
.branding
.set_logo(
"image/svg+xml",
"svg",
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#,
)
.unwrap();
let app = build_router(state);
let (s, body) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::NOT_FOUND);
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("SVG"), "got: {text}");
}
#[tokio::test]
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", &png)
.unwrap();
let app = build_router(state);
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/branding/pxe-logo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let ct = res
.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
assert_eq!(ct, "image/png");
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]
async fn pxe_logo_endpoint_is_public_after_admin_setup() {
// iPXE clients can't send a session cookie, so /branding/pxe-logo
// 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", &png)
.unwrap();
let app = build_router(state);
// Configure an admin so the middleware kicks in.
let (s, _, _) = post_collect(
&app,
"/api/setup",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
assert_eq!(s, StatusCode::CREATED);
// Still public without a cookie.
let (s, _) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::OK);
}
+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"
+1
View File
@@ -19,6 +19,7 @@
pub mod entry;
pub mod introspect;
pub mod nfs;
pub mod pxe_logo;
pub mod smb;
pub mod store;
pub mod windows;
+521 -95
View File
@@ -9,14 +9,15 @@
//! 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`.
//! to `mount.nfs -v -o vers=...,ro,nolock,proto=tcp 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.
//! 4. On failure we record `last_error` + `hint` on the spec and persist
//! anyway so the UI can show a row in red with an actionable hint
//! rather than silently dropping it.
//!
//! ## Operational notes
//!
@@ -28,6 +29,28 @@
//! - Mount commands are issued sequentially under a single mutex to avoid
//! `mount` racing on the same target dir.
//!
//! ## v0.4.64 diagnostics rework
//!
//! Field reports showed `mount.nfs: failed to apply fstab options` (exit
//! code 32) was the dominant failure surfaced through the UI — a deeply
//! unhelpful message from nfs-utils 2.6.x that has nothing to do with
//! `/etc/fstab`. It comes from `nfs_options2string()` and lights up when
//! the kernel can't accept the assembled options, when mtab can't be
//! written (container without `CAP_SYS_ADMIN`), or when an obscure option
//! triggers a transformation edge case. In v0.4.64 we:
//!
//! 1. Probe TCP reach to `server:port` before shelling out so a wrong
//! IP / closed firewall surfaces as a clear "cannot reach NFS port"
//! instead of `failed to apply fstab options`.
//! 2. Pass `proto=tcp` explicitly on NFSv3 (UDP is widely deprecated
//! and several NAS appliances don't bind it at all).
//! 3. On `failed to apply fstab options`, retry with a stripped-down
//! option set (`vers=N,ro/rw`) — that frequently succeeds and at
//! minimum produces a real kernel error.
//! 4. Translate well-known stderr patterns into operator-friendly hints
//! and persist them on the mount so the UI can show "what to fix
//! next" instead of the raw mount.nfs message.
//!
//! ## Persistence
//!
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
@@ -42,9 +65,20 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::process::Command;
/// Default port for NFS over TCP. We expose it as a constant so the
/// pre-flight probe and the option string assembly use the same value.
const DEFAULT_NFS_PORT: u16 = 2049;
/// How long to wait for a TCP connection to the NFS server before
/// declaring it unreachable. Short enough that a wrong IP doesn't make
/// the UI hang for half a minute; long enough that a slow appliance
/// can still answer.
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// 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.
@@ -65,6 +99,14 @@ impl NfsVersion {
Self::V41 => "vers=4.1",
}
}
/// Short label for UI surfaces and log lines.
fn label(self) -> &'static str {
match self {
Self::V3 => "NFSv3",
Self::V41 => "NFSv4.1",
}
}
}
/// One configured mount. The id is generated from server+export so the
@@ -78,6 +120,12 @@ pub struct NfsMount {
/// 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,
/// TCP port for the NFS service. Defaults to 2049; configurable for
/// the (rare) case where the appliance binds the service elsewhere.
/// v0.4.64: previously inferred at runtime; now persisted so the UI
/// can echo the value back to the operator.
#[serde(default = "default_port")]
pub port: u16,
/// Local mount point under `<work_dir>/nfs/`.
pub local_path: PathBuf,
/// Whether the mount is currently active.
@@ -85,6 +133,12 @@ pub struct NfsMount {
/// Last error encountered on a `mount` or `umount` attempt; cleared on
/// success.
pub last_error: Option<String>,
/// v0.4.64: operator-friendly translation of `last_error` — e.g. for
/// "failed to apply fstab options" we surface "CAP_SYS_ADMIN may be
/// missing on the container". `None` means we don't have a friendlier
/// rendition than the raw error.
#[serde(default)]
pub last_hint: 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).
@@ -100,6 +154,9 @@ pub struct NfsAddRequest {
pub version: NfsVersion,
#[serde(default = "default_ro")]
pub read_only: bool,
/// Optional TCP port — defaults to 2049 if omitted or zero.
#[serde(default)]
pub port: Option<u16>,
}
fn default_version() -> NfsVersion {
@@ -108,6 +165,35 @@ fn default_version() -> NfsVersion {
fn default_ro() -> bool {
true
}
fn default_port() -> u16 {
DEFAULT_NFS_PORT
}
/// Outcome of an `add` attempt. `Ok` carries the mount; `Err` from the
/// API layer is converted to this richer shape so the UI can render the
/// raw error and the actionable hint independently.
#[derive(Debug, Clone, Serialize)]
pub struct NfsMountError {
/// The first line / summary of what went wrong.
pub error: String,
/// Verbatim stderr from `mount.nfs` (trimmed). May be empty.
pub stderr: String,
/// Operator-friendly hint or `None` if we don't have one.
pub hint: Option<String>,
}
impl NfsMountError {
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 {
@@ -165,6 +251,7 @@ impl NfsManager {
// when the process died. We'll try to remount each one.
m.mounted = false;
m.last_error = None;
m.last_hint = None;
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
if let Err(e) = self.try_mount(&m.id).await {
tracing::warn!(
@@ -178,20 +265,42 @@ impl NfsManager {
}
/// 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();
/// reflecting reality) or a structured `NfsMountError` describing
/// what went wrong.
pub async fn add(
&self,
req: NfsAddRequest,
) -> std::result::Result<NfsMount, NfsMountError> {
let server = normalize_server(&req.server);
let export = req.export.trim().to_string();
if server.is_empty() {
return Err(Error::Invalid("server is required".into()));
return Err(NfsMountError::from_raw(
"server is required",
"",
));
}
if !export.starts_with('/') {
return Err(Error::Invalid("export path must start with '/'".into()));
return Err(NfsMountError::from_raw(
"export path must start with '/'",
"",
));
}
if export.contains('\0') || server.contains('\0') {
return Err(NfsMountError::from_raw(
"server / export must not contain NUL bytes",
"",
));
}
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_NFS_PORT);
let id = mount_id(&server, &export);
let local_path = self.work_root.join(&id);
tokio::fs::create_dir_all(&local_path).await?;
if let Err(e) = tokio::fs::create_dir_all(&local_path).await {
return Err(NfsMountError::from_raw(
format!("failed to create local mount point: {e}"),
"",
));
}
let mount = NfsMount {
id: id.clone(),
@@ -199,15 +308,28 @@ impl NfsManager {
export,
version: req.version,
read_only: req.read_only,
port,
local_path,
mounted: false,
last_error: None,
last_hint: None,
last_attempt: None,
iso_count: 0,
};
self.inner.lock().mounts.insert(id.clone(), mount);
self.persist_locked();
self.try_mount(&id).await?;
self.try_mount(&id).await.map_err(|e| {
// try_mount has already persisted last_error/last_hint. We
// refetch them so the API response reflects exactly what the
// UI will see when it lists mounts.
let m = self.get(&id);
NfsMountError {
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("mount just inserted"))
}
@@ -276,63 +398,113 @@ impl NfsManager {
// 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);
self.update_status(id, true, None, 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))
}
// v0.4.64: pre-flight TCP probe. Catches the dominant failure
// mode (wrong IP / firewall) before mount.nfs gets a chance to
// emit its unhelpful "failed to apply fstab options" message.
if let Err((err, hint)) = tcp_probe(&m.server, m.port).await {
tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), Some(hint), now);
return Err(Error::Invalid(err));
}
// First attempt: full option set.
let full_opts = mount_options(&m, /*minimal*/ false);
let target = format!("{}:{}", m.server, m.export);
let attempt = run_mount_nfs(&full_opts, &target, &m.local_path).await;
let (success, stderr, exit_code) = match attempt {
Ok((true, stderr, _)) => (true, stderr, 0),
Ok((false, stderr, code)) => (false, stderr, code),
Err(e) => {
let err = format!("could not exec mount(8): {e}");
let hint = Some(
"the runtime image is missing /bin/mount or nfs-common — \
verify the container hasn't been stripped down"
.to_string(),
);
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), hint, now);
return Err(Error::Invalid(err));
}
};
if success {
tracing::info!(
target: "openpxe::nfs",
id = %id, server = %m.server, export = %m.export,
version = %m.version.label(), port = m.port,
"NFS mount succeeded"
);
self.update_status(id, true, None, None, now);
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
return Ok(());
}
// Second attempt: if the first attempt failed with the
// "failed to apply fstab options" oddity, retry with a minimal
// option set. nfs-utils 2.6.x sometimes chokes on the assembled
// option string for reasons unrelated to the actual options
// being valid; the stripped form bypasses the transformation
// edge case.
let trigger_retry = looks_like_option_transform_failure(&stderr);
let (final_success, final_stderr, final_exit_code) = if trigger_retry {
tracing::info!(
target: "openpxe::nfs", id = %id,
"retrying with minimal options after option-transform failure"
);
let minimal = mount_options(&m, /*minimal*/ true);
match run_mount_nfs(&minimal, &target, &m.local_path).await {
Ok((true, s, _)) => (true, s, 0),
Ok((false, s, c)) => (false, s, c),
Err(e) => (false, format!("could not exec mount(8): {e}"), -1),
}
} else {
(false, stderr, exit_code)
};
if final_success {
tracing::info!(
target: "openpxe::nfs", id = %id,
"NFS mount succeeded on minimal-options retry"
);
self.update_status(id, true, None, None, now);
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
return Ok(());
}
// Failure path: persist a clear error and a hint, log both.
// `mount(8)` passes mount.nfs's stderr through verbatim, so the
// user-visible text reads like "mount.nfs: ..." — we prepend the
// exit code so the operator can tell at a glance that the helper
// ran but rejected the request, vs the helper not running at all.
let err = if final_stderr.is_empty() {
format!("mount exit {final_exit_code}")
} else {
format!("mount exit {final_exit_code}: {}", final_stderr.trim())
};
let hint = hint_for(&final_stderr);
tracing::warn!(
target: "openpxe::nfs", id = %id,
hint = ?hint, "{err}"
);
self.update_status(id, false, Some(err.clone()), hint, 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());
self.update_status(id, false, None, None, OffsetDateTime::now_utc());
return Ok(());
}
// -l = lazy: detach immediately, finish when no process has a
@@ -344,7 +516,7 @@ impl NfsManager {
.await;
match out {
Ok(o) if o.status.success() => {
self.update_status(id, false, None, OffsetDateTime::now_utc());
self.update_status(id, false, None, None, OffsetDateTime::now_utc());
Ok(())
}
Ok(o) => {
@@ -353,12 +525,24 @@ impl NfsManager {
o.status.code().unwrap_or(-1),
String::from_utf8_lossy(&o.stderr).trim()
);
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
self.update_status(
id,
false,
Some(e.clone()),
None,
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());
self.update_status(
id,
false,
Some(e.clone()),
None,
OffsetDateTime::now_utc(),
);
Err(Error::Invalid(e))
}
}
@@ -410,10 +594,18 @@ impl NfsManager {
Ok(count)
}
fn update_status(&self, id: &str, mounted: bool, err: Option<String>, ts: OffsetDateTime) {
fn update_status(
&self,
id: &str,
mounted: bool,
err: Option<String>,
hint: Option<String>,
ts: OffsetDateTime,
) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.mounted = mounted;
m.last_error = err;
m.last_hint = hint;
m.last_attempt = Some(ts);
}
self.persist_locked();
@@ -453,18 +645,34 @@ impl NfsManager {
}
}
fn mount_options(m: &NfsMount) -> String {
/// Build the `-o` option list. With `minimal=true` we strip everything
/// except the protocol version and ro/rw — used on the retry path when
/// the first attempt failed at option transformation, which historically
/// indicates one of the auxiliary options confused `nfs_options2string()`.
fn mount_options(m: &NfsMount, minimal: bool) -> String {
let mut opts = vec![m.version.vers_arg().to_string()];
if m.read_only {
opts.push("ro".into());
} else {
opts.push("rw".into());
}
if minimal {
return opts.join(",");
}
// Explicit TCP. NFSv4.x is TCP-only by spec, but stating it
// doesn't hurt and on NFSv3 it's necessary on appliances that
// don't bind UDP (which is most modern ones).
opts.push("proto=tcp".into());
// `nolock` for v3 — many storage appliances disable lockd; we don't
// need locking for read-only ISO access anyway.
// need locking for read-only ISO access anyway. nfs-utils still
// tries to contact rpc.statd without it which is a no-op overhead.
if matches!(m.version, NfsVersion::V3) {
opts.push("nolock".into());
}
// Non-standard port hint to the kernel.
if m.port != DEFAULT_NFS_PORT {
opts.push(format!("port={}", m.port));
}
// 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.
@@ -474,6 +682,158 @@ fn mount_options(m: &NfsMount) -> String {
opts.join(",")
}
/// Invoke `mount -t nfs`. Returns `(success, stderr_trimmed,
/// exit_code)`. `stderr` is captured separately from `stdout`;
/// `mount(8)` passes mount.nfs's stderr through verbatim, so we get the
/// same diagnostics ("mount.nfs: ...") whether we invoke `mount.nfs`
/// directly or go through the generic wrapper.
///
/// We deliberately stay on `mount` rather than `mount.nfs` directly
/// because `/bin/mount` is in every user's PATH; `mount.nfs` lives in
/// `/sbin` (or `/usr/sbin`) and is *not* in the default PATH for the
/// non-root `openpxe` user. The generic `mount` binary knows where its
/// NFS helper lives and dispatches accordingly.
async fn run_mount_nfs(
opts: &str,
target: &str,
local: &Path,
) -> std::io::Result<(bool, String, i32)> {
let output = Command::new("mount")
.arg("-t")
.arg("nfs")
.arg("-o")
.arg(opts)
.arg(target)
.arg(local)
.output()
.await?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let code = output.status.code().unwrap_or(-1);
Ok((output.status.success(), stderr, code))
}
/// Try to open a TCP connection to `server:port` within `PROBE_TIMEOUT`.
/// On failure returns `(error_text, hint_text)` — pre-formatted so the
/// caller can persist both.
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
use tokio::net::TcpStream;
let addr = format!("{server}:{port}");
let connect = TcpStream::connect(&addr);
match tokio::time::timeout(PROBE_TIMEOUT, connect).await {
Ok(Ok(_stream)) => Ok(()),
Ok(Err(e)) => Err((
format!("cannot reach NFS port: {addr}: {e}"),
format!(
"verify the NFS service is running on {server} and that port {port} is open"
),
)),
Err(_) => Err((
format!("cannot reach NFS 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()
),
)),
}
}
/// Detect mount.nfs's "failed to apply fstab options" / "internal option
/// parsing error" path. These messages come from
/// `nfs_options2string()` / `nfs_validate_options()` in nfs-utils and
/// are emitted *before* the mount(2) syscall, so retrying with a
/// stripped option set often succeeds.
fn looks_like_option_transform_failure(stderr: &str) -> bool {
let s = stderr.to_ascii_lowercase();
s.contains("failed to apply fstab options")
|| s.contains("internal option parsing error")
}
/// Translate a mount.nfs stderr blob into an operator-friendly hint.
/// Returns `None` if we don't have a translation — the caller will fall
/// back to surfacing the raw stderr.
#[allow(clippy::if_same_then_else)] // ordering matters; keep the patterns explicit
fn hint_for(stderr: &str) -> Option<String> {
let s = stderr.to_ascii_lowercase();
if s.contains("failed to apply fstab options") || s.contains("internal option parsing error") {
// The dominant report from the field: mount.nfs failed at the
// option-transform layer. Most common root cause is missing
// CAP_SYS_ADMIN in the container.
Some(
"mount.nfs couldn't finalize the mount. Most common cause: the container is \
missing CAP_SYS_ADMIN (run with --cap-add=SYS_ADMIN, or use a privileged SCC on \
OpenShift). Also check that /etc/mtab exists and the host kernel has NFS client \
support."
.into(),
)
} else if s.contains("operation not permitted") || s.contains("permission denied") {
Some(
"the container is missing CAP_SYS_ADMIN — mount(2) returns EPERM without it. Re-run \
with --cap-add=SYS_ADMIN, or grant the OpenShift pod a privileged SCC."
.into(),
)
} else if s.contains("access denied by server") {
Some(
"the server rejected this client. Check the export's allowed-hosts list includes \
this OpenPXE host's IP (or 0.0.0.0/0 for testing)."
.into(),
)
} else if s.contains("no route to host") || s.contains("network is unreachable") {
Some("the server is not reachable on this network. Check the IP, subnet, and routes.".into())
} else if s.contains("connection refused") {
Some(
"the NFS service isn't listening on this address/port. Verify NFS is running and \
that the export path is correct (e.g. UniFi UNAS Pro exports under \
/var/nfs/shared/<name>, not the share name on its own)."
.into(),
)
} else if s.contains("connection timed out") {
Some(
"no answer from the server within the connect timeout. Most likely a firewall is \
dropping the connection, or the server isn't running NFS on this port."
.into(),
)
} else if s.contains("no such file or directory")
|| s.contains("mount: bad option")
|| s.contains("does not exist")
{
Some(
"the export path doesn't exist on the server, or a mount option isn't recognized. \
Double-check the export — many NAS appliances bury it under a service root like \
/var/nfs/shared/<share>."
.into(),
)
} else if s.contains("rpc: program not registered") || s.contains("mount system call failed") {
Some(
"the server didn't respond on the expected RPC programs. NFSv4.1 needs nfsd on TCP \
2049; NFSv3 also needs portmap (111) and mountd. If the server only speaks one \
version, switch the dropdown to match."
.into(),
)
} else if s.contains("protocol not supported") || s.contains("invalid argument") {
Some(
"the server doesn't speak the requested NFS version. Try the other entry in the \
Version dropdown."
.into(),
)
} else {
None
}
}
/// Normalize a server input: trim, strip a `http(s)://` prefix that the
/// operator may have pasted by mistake, and drop a trailing slash. Port
/// suffixes (`host:1234`) are preserved so the kernel sees them; the
/// explicit `port=` option still wins if the operator set one.
fn normalize_server(raw: &str) -> String {
let s = raw.trim();
let s = s
.strip_prefix("http://")
.or_else(|| s.strip_prefix("https://"))
.or_else(|| s.strip_prefix("nfs://"))
.unwrap_or(s);
s.trim_end_matches('/').to_string()
}
fn mount_id(server: &str, export: &str) -> String {
let raw = format!("{server}{export}");
slugify_str(&raw)
@@ -500,6 +860,23 @@ async fn is_mountpoint(path: &Path) -> bool {
mod tests {
use super::*;
fn make_mount(version: NfsVersion, ro: bool, port: u16) -> NfsMount {
NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version,
read_only: ro,
port,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_hint: None,
last_attempt: None,
iso_count: 0,
}
}
#[test]
fn version_arg() {
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
@@ -507,44 +884,39 @@ mod tests {
}
#[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"));
fn mount_options_v3_includes_nolock_and_tcp() {
let m = make_mount(NfsVersion::V3, true, DEFAULT_NFS_PORT);
let opts = mount_options(&m, false);
assert!(opts.contains("vers=3"), "got: {opts}");
assert!(opts.contains("ro"), "got: {opts}");
assert!(opts.contains("nolock"), "got: {opts}");
assert!(opts.contains("proto=tcp"), "got: {opts}");
assert!(opts.contains("soft"), "got: {opts}");
assert!(!opts.contains("port="), "default port shouldn't appear: {opts}");
}
#[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"));
fn mount_options_v41_has_tcp_no_nolock() {
let m = make_mount(NfsVersion::V41, false, DEFAULT_NFS_PORT);
let opts = mount_options(&m, false);
assert!(opts.contains("vers=4.1"), "got: {opts}");
assert!(opts.contains("rw"), "got: {opts}");
assert!(opts.contains("proto=tcp"), "got: {opts}");
assert!(!opts.contains("nolock"), "got: {opts}");
}
#[test]
fn mount_options_minimal_drops_everything_except_vers_and_mode() {
let m = make_mount(NfsVersion::V3, true, DEFAULT_NFS_PORT);
let opts = mount_options(&m, true);
assert_eq!(opts, "vers=3,ro");
}
#[test]
fn mount_options_non_default_port_appears() {
let m = make_mount(NfsVersion::V41, true, 2050);
let opts = mount_options(&m, false);
assert!(opts.contains("port=2050"), "got: {opts}");
}
#[test]
@@ -555,4 +927,58 @@ mod tests {
assert!(!a.contains('/'));
assert!(!a.contains('.'));
}
#[test]
fn normalize_server_strips_url_schemes_and_slashes() {
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
assert_eq!(normalize_server("http://10.0.0.5/"), "10.0.0.5");
assert_eq!(normalize_server("https://nas.lan//"), "nas.lan");
assert_eq!(normalize_server("nfs://192.168.1.51"), "192.168.1.51");
assert_eq!(normalize_server("nas.lan:2049"), "nas.lan:2049");
}
#[test]
fn hint_for_fstab_options_calls_out_cap_sys_admin() {
let h = hint_for("mount.nfs: failed to apply fstab options").unwrap();
assert!(
h.contains("CAP_SYS_ADMIN"),
"expected CAP_SYS_ADMIN guidance, got: {h}"
);
}
#[test]
fn hint_for_access_denied_points_at_exports_table() {
let h = hint_for("mount.nfs: access denied by server while mounting").unwrap();
assert!(
h.to_lowercase().contains("allowed-hosts") || h.to_lowercase().contains("export"),
"expected exports hint, got: {h}"
);
}
#[test]
fn hint_for_connection_refused_mentions_export_path() {
let h = hint_for("mount.nfs: Connection refused").unwrap();
assert!(
h.to_lowercase().contains("export"),
"expected export-path hint, got: {h}"
);
}
#[test]
fn hint_for_unknown_message_is_none() {
assert!(hint_for("some completely unrelated text").is_none());
}
#[test]
fn looks_like_option_transform_failure_detects_both_variants() {
assert!(looks_like_option_transform_failure(
"mount.nfs: failed to apply fstab options"
));
assert!(looks_like_option_transform_failure(
"mount.nfs: internal option parsing error"
));
assert!(!looks_like_option_transform_failure(
"mount.nfs: access denied"
));
}
}
+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());
}
}
+132 -17
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. */
@@ -287,23 +296,83 @@ label.field {
}
label.field .name { color: var(--fg-dim); font-size: 12px; }
label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
label.field input[type="text"],
label.field input[type="number"],
/* All single-line inputs share one chrome rule. Pre-v0.4.6 we only
styled type=text/number, which left type=password fields rendering
with the default browser look — visibly off vs adjacent text fields
in the Account card. The negation list keeps `type=checkbox`,
`type=file`, and `type=range` (none of which we use inside
`label.field`) from picking up the padded-box look. */
label.field input:not([type="checkbox"]):not([type="file"]):not([type="range"]),
label.field select,
label.field textarea {
width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 7px 10px; font: inherit;
/* iOS/Safari shrinks password-field text by default; clamp it so
the password input matches the username input's metrics. */
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);
}
label.check {
display: flex; gap: 10px; align-items: center;
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 ────────────────────────────────────────────────────── */
@@ -421,9 +490,21 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.dot.err { background: var(--err); }
.dot.warn { background: var(--warn); }
/* Inline form rows. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
/* Inline form rows. The default is a 4-column grid sized for the
Account card's "Current / New username / New password / Confirm"
quartet; the `.cols-3` modifier swaps to a 3-column layout for the
SSO header strip (display name / logo URL / metadata source). All
`.form-row > label.field` children share the same baseline because
their inner inputs share metrics via the global rule above. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; align-items: end; }
.form-row.cols-3 { grid-template-columns: repeat(3, 1fr); }
.form-row.cols-2 { grid-template-columns: repeat(2, 1fr); }
.form-row label.field { margin-bottom: 0; }
@media (max-width: 900px) {
.form-row,
.form-row.cols-3,
.form-row.cols-2 { grid-template-columns: 1fr; }
}
/* ── Queued deployment visual ────────────────────────────────────── */
.queue-track {
@@ -567,22 +648,56 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
}
.auth-card .sso-btn .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; }
/* ── Logout chip (sidebar footer) ────────────────────────────── */
.sidebar .footer .logout-row {
margin-top: 8px; display: flex; align-items: center; justify-content: space-between;
gap: 8px;
/* ── Top-right user menu (v0.4.6) ────────────────────────────
The "signed in as X" identity + sign-out moved out of the sidebar
footer in v0.4.6 — the sidebar footer is now reserved for the
service-state trio (Service status / Advertised URL / Backend
version). The button matches the theme toggle's size + chrome so
the top-right reads as a tidy two-icon strip. */
.user-menu { position: relative; }
.user-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 32px;
background: transparent; color: var(--fg);
border: 1px solid var(--border); border-radius: 8px;
cursor: pointer; padding: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
.sidebar .footer .logout-row .who {
color: var(--fg); font-weight: 600; font-size: 11.5px;
.user-btn:hover { background: var(--bg-panel-2); border-color: var(--accent); }
.user-pop {
position: absolute; right: 0; top: 38px;
min-width: 200px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 6px;
z-index: 60;
display: flex; flex-direction: column; gap: 2px;
}
.user-pop[hidden] { display: none; }
.user-pop .user-pop-name {
padding: 8px 10px 6px;
border-bottom: 1px solid var(--border-soft);
margin-bottom: 4px;
color: var(--fg); font-weight: 600; font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sidebar .footer .logout-btn {
background: transparent; color: var(--fg-dim);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 2px 8px; font: inherit; font-size: 11px; font-weight: 500;
.user-pop .user-pop-item {
text-align: left; width: 100%;
background: transparent; color: var(--fg);
border: 0; border-radius: var(--radius);
padding: 7px 10px; font: inherit; font-size: 13px; font-weight: 500;
cursor: pointer;
}
.sidebar .footer .logout-btn:hover { color: var(--fg); background: var(--bg-panel-2); border-color: var(--accent); }
.user-pop .user-pop-item:hover {
background: var(--bg-panel-2); color: var(--fg);
}
.user-pop .user-pop-danger { color: var(--err); }
.user-pop .user-pop-danger:hover {
background: color-mix(in srgb, var(--err) 12%, transparent);
color: var(--err);
}
/* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; }
+138 -31
View File
@@ -623,19 +623,41 @@
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;
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
nfsMsg.className='msg err'; return;
}
nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg';
nfsMsg.replaceChildren(document.createTextNode('Mounting…'));
nfsMsg.className = 'msg';
const r = await postJSON('/api/nfs', {
server: nfsServer.value, export: nfsExport.value,
version: nfsVer.value, read_only: nfsRo.checked,
});
if (r.ok) {
nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok';
nfsMsg.replaceChildren(document.createTextNode('Mounted.'));
nfsMsg.className = 'msg ok';
render('storage');
} else {
const t = await r.text();
nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err';
// v0.4.64: the API now returns a structured
// {error, stderr, hint} JSON body so we can render the
// mount failure and an actionable hint as two distinct lines
// instead of one long unreadable string. The dominant field
// failure mode — "mount.nfs: failed to apply fstab options" —
// becomes useful when paired with its CAP_SYS_ADMIN hint.
let body = null;
let raw = null;
try { body = await r.clone().json(); }
catch (_) { raw = await r.text().catch(()=> 'mount failed'); }
const msg = body && body.error ? body.error : (raw || 'mount failed');
const hint = body && body.hint;
const parts = [el('div', {}, [
el('strong', {}, 'Mount failed: '),
document.createTextNode(msg),
])];
if (hint) {
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
}
nfsMsg.replaceChildren(...parts);
nfsMsg.className = 'msg err';
}
}}, 'Mount share');
@@ -648,6 +670,8 @@
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
// v0.4.64: actionable hint paired with the raw error.
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', {});
@@ -1025,7 +1049,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.';
@@ -1103,47 +1131,71 @@
ssoEnabled.checked = !!sso.enabled;
const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD',
value: sso.idp_name || ''});
// v0.4.6: optional FleetDM-style IdP logo URL. The login screen
// will render this as the brand mark on the "Sign in with X"
// button once the runtime SSO flow ships; for v0.4.6 we just
// persist it.
const ssoLogo = el('input', {type:'text',
placeholder:'https://idp.example.com/logo.svg',
value: sso.idp_logo_url || ''});
const ssoUrl = el('input', {type:'text', placeholder:'https://idp.example.com/metadata',
value: sso.metadata_url || ''});
const ssoXml = el('textarea', {rows:'6',
placeholder:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
style:'width:100%;font-family:var(--mono);font-size:12px;background:var(--bg);' +
'color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);' +
'padding:8px 10px;resize:vertical'},
// The textarea inherits the same chrome via the global
// `label.field textarea` rule, plus the monospace family for
// pasting raw XML. Children come after the attrs object — the
// initial value is the only "child".
const ssoXml = el('textarea',
{rows:'6',
spellcheck:'false', autocapitalize:'off',
placeholder:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
style:'font-family:var(--mono);font-size:12px;resize:vertical'},
sso.metadata || '');
const ssoMode = el('select', {style:'min-width:160px;background:var(--bg);color:var(--fg);' +
'border:1px solid var(--border);border-radius:var(--radius);padding:6px 8px;font:inherit'}, [
// The mode picker is a styled <select> so it aligns with text
// inputs in the same `.form-row` — the global `label.field
// select` rule takes care of the chrome.
const ssoMode = el('select', {}, [
el('option', {value:'url'}, 'Metadata URL'),
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.5 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,
idp_name: ssoName.value,
idp_logo_url: ssoLogo.value,
metadata: ssoMode.value === 'xml' ? ssoXml.value : '',
metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '',
};
@@ -1179,22 +1231,35 @@
ssoEnabled,
el('span', {}, 'Enable single sign-on'),
]),
// 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('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.
@@ -1610,25 +1675,67 @@
}
async function startDashboard() {
// Light up the sidebar's "signed in as X / Sign out" row. It was
// hidden in index.html because we don't know the identity until
// /api/me resolves.
// v0.4.6: light up the top-right user-menu chip. The button is
// hidden in index.html until /api/me confirms a signed-in session,
// so we don't show the icon (then hide it) when the user lands
// on /login. Clicking the icon opens a small popover with
// Name / Edit account / Sign out.
try {
const me = await fetch('/api/me').then(r => r.ok ? r.json() : null);
const row = $('[data-bind=logout_row]');
const who = $('[data-bind=signed_in_as]');
const btn = $('[data-bind=logout_btn]');
if (row && me && me.authenticated && me.user) {
who.textContent = me.user.username;
who.title = 'Signed in as ' + me.user.username;
row.style.display = '';
const wrap = $('[data-bind=user_menu_wrap]');
const pop = $('[data-bind=user_menu_pop]');
const name = $('[data-bind=user_pop_name]');
const edit = $('[data-bind=user_pop_edit]');
const out = $('[data-bind=user_pop_logout]');
const btn = $('#user-menu-btn');
if (wrap && me && me.authenticated && me.user) {
wrap.style.display = '';
if (name) name.textContent = me.user.username;
if (btn) btn.title = 'Signed in as ' + me.user.username;
if (btn && !btn._wired) {
btn._wired = true;
btn.addEventListener('click', async () => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const open = !pop.hidden;
pop.hidden = open;
btn.setAttribute('aria-expanded', String(!open));
});
}
if (edit && !edit._wired) {
edit._wired = true;
edit.addEventListener('click', () => {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
render('settings');
});
}
if (out && !out._wired) {
out._wired = true;
out.addEventListener('click', async () => {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
await fetch('/api/logout', {method:'POST'}).catch(() => {});
wrap.style.display = 'none';
showAuthScreen('login');
});
}
// Click-outside-to-close, wired once. Stored on document so we
// don't re-attach every render.
if (!document._userPopWired) {
document._userPopWired = true;
document.addEventListener('click', (e) => {
if (pop.hidden) return;
if (e.target.closest('.user-menu')) return;
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !pop.hidden) {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
});
}
}
} catch (e) { /* surfaces elsewhere */ }
render('dashboard');
+33 -13
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,15 +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.5</span></div>
<!-- v0.4.5: signed-in identity + one-click sign-out. The button
is populated by app.js after /api/me reports an authenticated
session — pre-auth states swap the whole shell for the
login/setup card so this row never gets shown there. -->
<div class="logout-row" data-bind="logout_row" style="display:none">
<span class="who" data-bind="signed_in_as" title=""></span>
<button type="button" class="logout-btn" data-bind="logout_btn">Sign out</button>
</div>
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.63</span></div>
</div>
</aside>
@@ -97,11 +96,32 @@
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
</svg>
</button>
<!-- v0.4.6: signed-in operator menu. Sits next to the theme toggle
in the top-right corner so the sidebar footer stays clean for
the "Service status / Advertised URL / Backend version" trio.
The whole block is hidden until /api/me confirms a session. -->
<div class="user-menu" data-bind="user_menu_wrap" style="display:none">
<button id="user-menu-btn" class="user-btn" type="button"
aria-label="Account menu" aria-haspopup="true" aria-expanded="false"
title="Account">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="3.6"/>
<path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
</svg>
</button>
<div id="user-menu-pop" class="user-pop" data-bind="user_menu_pop" hidden>
<div class="user-pop-name" data-bind="user_pop_name"></div>
<button type="button" class="user-pop-item" data-bind="user_pop_edit">Edit account</button>
<button type="button" class="user-pop-item user-pop-danger" data-bind="user_pop_logout">Sign out</button>
</div>
</div>
</header>
<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