Compare commits

...
3 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
11 changed files with 817 additions and 225 deletions
Generated
+8 -8
View File
@@ -1140,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openpxe"
version = "0.4.61"
version = "0.4.64"
dependencies = [
"anyhow",
"axum",
@@ -1162,7 +1162,7 @@ dependencies = [
[[package]]
name = "openpxe-core"
version = "0.4.61"
version = "0.4.64"
dependencies = [
"anyhow",
"bcrypt",
@@ -1181,7 +1181,7 @@ dependencies = [
[[package]]
name = "openpxe-dhcp-proxy"
version = "0.4.61"
version = "0.4.64"
dependencies = [
"anyhow",
"bytes",
@@ -1195,7 +1195,7 @@ dependencies = [
[[package]]
name = "openpxe-http-api"
version = "0.4.61"
version = "0.4.64"
dependencies = [
"anyhow",
"axum",
@@ -1226,7 +1226,7 @@ dependencies = [
[[package]]
name = "openpxe-ipxe-assets"
version = "0.4.61"
version = "0.4.64"
dependencies = [
"openpxe-core",
"rust-embed",
@@ -1236,7 +1236,7 @@ dependencies = [
[[package]]
name = "openpxe-iso-store"
version = "0.4.61"
version = "0.4.64"
dependencies = [
"anyhow",
"bcrypt",
@@ -1260,7 +1260,7 @@ dependencies = [
[[package]]
name = "openpxe-tftp"
version = "0.4.61"
version = "0.4.64"
dependencies = [
"anyhow",
"bytes",
@@ -1274,7 +1274,7 @@ dependencies = [
[[package]]
name = "openpxe-webui"
version = "0.4.61"
version = "0.4.64"
[[package]]
name = "parking_lot"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.4.61"
version = "0.4.64"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
+6 -5
View File
@@ -1713,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(),
}
}
+25 -19
View File
@@ -77,15 +77,20 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
);
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu");
// v0.4.61: we used to draw an ASCII OpenPXE wordmark here. Now
// that the bundled iPXE is built with `IMAGE_PNG`, the
// `console --picture` line at the top of this script paints the
// operator's actual logo (composed server-side into a 1024×768
// canvas with the logo centered at the top) — the ASCII banner
// became visual noise *on top* of the real image. Old iPXE
// builds without PNG fall through the `|| console` clause and
// simply show the menu without a logo, which is the correct
// graceful-degradation outcome.
// 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 -------------------------"
@@ -626,12 +631,13 @@ mod password_tests {
#[test]
fn top_menu_has_polished_branding_and_arch_footer() {
// v0.4.6 polish + v0.4.61 image upgrade: the menu emits a
// `console --picture` line that the bundled iPXE (built with
// `IMAGE_PNG`) honours, plus an arch-resolved footer carrying
// the current OpenPXE version. The ASCII wordmark that used
// to live here was dropped in v0.4.61 — it duplicated the now-
// working image.
// v0.4.6 polish + v0.4.62 stability fixes: the menu emits a
// `console --picture` line that PNG-capable iPXE builds will
// honour (queued for a follow-up release once we can rebuild
// iPXE from source on native x86_64 hardware), an ASCII
// OpenPXE wordmark that works on every iPXE build (including
// the boot.ipxe.org pre-builds we currently ship), and a
// single-line footer carrying the OpenPXE version + arch.
let settings = Settings::default();
let s = render_menu(&[], &settings, "http://10.0.0.5");
assert!(
@@ -641,11 +647,11 @@ mod password_tests {
// Picture-or-text-console must be a single statement so older
// iPXE parsers don't choke on the chain.
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
// No ASCII wordmark — once the real PNG paints, the ASCII
// banner would duplicate the operator's logo visually.
// ASCII wordmark — paints on every iPXE build regardless of
// PNG support.
assert!(
!s.contains("___ ___ __ __ ___"),
"ASCII banner shouldn't be emitted in v0.4.61+:\n{s}"
s.contains("___ ___ __ __ ___"),
"ASCII banner missing first row:\n{s}"
);
// Footer with version + arch interpolation. The version comes
// from CARGO_PKG_VERSION at compile time.
+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") => {
+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"
));
}
}
+60 -1
View File
@@ -34,9 +34,18 @@
--topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
/* v0.4.63: tie native form-control rendering (checkboxes, scroll bars,
date pickers) to the active OpenPXE theme. Without this, the inline
`<meta name="color-scheme" content="dark light">` in index.html forces
dark form chrome in *both* themes — so the SSO "Enable single sign-on"
checkbox renders as an opaque black square against the light-mode
panel, ignoring our accent-color hint. CSS `color-scheme` overrides
the meta and tracks `data-theme` correctly. */
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
/* Light palette — high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */
@@ -304,6 +313,23 @@ label.field textarea {
font-size: 14px; line-height: 1.4;
box-shadow: none; -webkit-appearance: none; appearance: none;
}
/* v0.4.63: with `appearance: none`, the native <select> dropdown arrow
disappears, which makes the "Metadata source" pick-list look like a
plain (and slightly squished) text input. Paint our own chevron via
background-image so the control still reads as a dropdown, and reserve
right-padding for it. The data-URI SVG inherits currentColor via the
`stroke` attribute so the arrow follows light/dark theme without a
second declaration. */
label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%239aa0a6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 11px 7px;
padding-right: 30px;
}
:root[data-theme="light"] label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%235a6377' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
}
label.field input:focus, label.field select:focus, label.field textarea:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
@@ -313,7 +339,40 @@ label.check {
padding: 8px 10px; margin-bottom: 6px;
border: 1px solid var(--border-soft); border-radius: var(--radius);
}
label.check input { accent-color: var(--accent); }
/* v0.4.63: native checkboxes used to render as opaque black squares in
light mode because the page meta declares `color-scheme: dark light`
and `accent-color` alone only repaints the *check mark* (not the
container). Take full control of the chrome so the box reads cleanly
on both palettes and the checked state lights up in our accent. */
label.check input[type="checkbox"] {
appearance: none; -webkit-appearance: none;
width: 16px; height: 16px; flex: none;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
display: inline-grid; place-content: center;
cursor: pointer; margin: 0;
transition: background 0.1s ease, border-color 0.1s ease;
}
label.check input[type="checkbox"]:hover { border-color: var(--accent); }
label.check input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent);
}
label.check input[type="checkbox"]:checked::after {
/* Classic ✓ glyph built from a rotated rectangle border. Colour is
#002923 (the same near-black we use on solid-accent buttons) so the
tick stays legible against the teal fill in both themes. */
content: '';
width: 4px; height: 8px;
border: solid #002923;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
}
label.check input[type="checkbox"]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
/* ── Drop zone ────────────────────────────────────────────────────── */
+61 -20
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.';
@@ -1130,29 +1158,39 @@
el('option', {value:'xml'}, 'Metadata XML'),
]);
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
// v0.4.63: the IdP metadata URL now sits inside the 4-col header
// grid as column 4, so the SSO row is column-for-column aligned with
// the Administrator account row above. When the operator switches
// to XML mode, column 4 collapses (display:none) and the multi-line
// XML textarea takes its own full-width row below — there's no way
// to fit a 6-row textarea into a single grid cell without making
// the rest of the row look stretched.
const urlWrap = el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP metadata URL'),
ssoUrl,
el('span', {class:'hint'},
'OpenPXE will fetch this URL once SSO sign-in lands; v0.4.6 just stores it.'),
]);
const xmlWrap = el('label', {class:'field'}, [
const xmlWrap = el('label', {class:'field', style:'margin-top:14px'}, [
el('span', {class:'name'}, 'IdP metadata XML'),
ssoXml,
el('span', {class:'hint'},
'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
]);
// Hint that used to live under the URL field; surfaced once below
// the whole row so it doesn't compete with the in-grid layout.
const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
'OpenPXE will fetch the metadata URL once SSO sign-in lands; v0.4.63 stores it.');
const refreshSsoFields = () => {
if (ssoMode.value === 'url') {
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
urlHint.style.display = '';
} else {
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
urlHint.style.display = 'none';
}
};
ssoMode.onchange = refreshSsoFields;
refreshSsoFields();
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
const ssoSave = el('button', {onclick: async () => {
const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => {
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
const payload = {
enabled: ssoEnabled.checked,
@@ -1193,32 +1231,35 @@
ssoEnabled,
el('span', {}, 'Enable single sign-on'),
]),
// 3-column header strip: display name, logo URL, metadata
// source. All three controls inherit the same border/padding/
// focus chrome from the global `label.field input/select`
// rule, so they line up cleanly. Below: the active source
// field (URL or XML) spans the full width.
el('div', {class:'form-row cols-3'}, [
// v0.4.63: 4-column form-row that matches the Administrator
// account card above column-for-column — display name / logo
// URL / metadata source / metadata URL. All four controls share
// the same `label.field` chrome so they line up cleanly. When
// the operator picks "Metadata XML" the URL column collapses
// and the multi-line textarea drops below the row.
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP display name'),
ssoName,
el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP logo URL'),
ssoLogo,
el('span', {class:'hint'}, 'Optional. Shown next to the IdP name on the login button.'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Metadata source'),
ssoMode,
]),
urlWrap,
]),
urlWrap,
xmlWrap,
urlHint,
ssoSave, ssoMsg,
]),
]);
// Wire up + paint the initial visibility now that all elements
// referenced by `refreshSsoFields` are attached.
refreshSsoFields();
// ── Custom logo upload.
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
+1 -1
View File
@@ -66,7 +66,7 @@
<!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.61</span></div>
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.63</span></div>
</div>
</aside>
+12 -74
View File
@@ -16,79 +16,21 @@
ARG RUST_VERSION=1.95
########## fetch wimboot (and a sanity-check fetch of upstream iPXE) ##########
# v0.4.61: we no longer ship the boot.ipxe.org iPXE binaries directly;
# instead we build iPXE from source with IMAGE_PNG enabled (see the
# ipxe-build stage below). The fetch stage still pulls wimboot (a
# pre-signed binary from ipxe/wimboot's GitHub release) since that's
# unrelated to the PNG concern.
########## 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/*
WORKDIR /src
RUN mkdir -p assets/ipxe && \
curl --fail --silent --show-error --location \
-o assets/ipxe/wimboot \
https://github.com/ipxe/wimboot/releases/latest/download/wimboot \
|| echo "wimboot fetch failed; Windows toggle will stay disabled"
########## build iPXE from source with IMAGE_PNG enabled ##########
# This stage replaces the old "grab pre-built binaries from
# boot.ipxe.org" path. The shipped binaries there are built with the
# default config which omits `IMAGE_PNG`, so the `console --picture`
# call in render_menu silently no-ops — operator logos never paint.
# Building from source lets us flip the one flag we need.
#
# Cross-compilation: x86_64 + i386 use the native toolchain that ships
# in the rust:bookworm base; arm64 uses gcc-aarch64-linux-gnu. The four
# output binaries match the names openpxe-ipxe-assets expects in
# assets/ipxe/.
FROM rust:${RUST_VERSION}-bookworm AS ipxe-build
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git build-essential liblzma-dev mtools genisoimage syslinux \
gcc-aarch64-linux-gnu \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Pin to a recent iPXE master tip via shallow clone. iPXE doesn't tag
# releases; pinning the SHA in source would be a periodic chore. The
# tradeoff is that "rebuild the container" silently picks up upstream
# patches — for a boot loader this is the right side of the
# pin-vs-fresh tradeoff (we want CVE fixes ASAP and the PXE chain is
# the trusted base).
RUN git clone --depth=1 https://github.com/ipxe/ipxe.git ipxe
WORKDIR /build/ipxe/src
# Feature flags landed via the `config/local/` override files iPXE's
# config system reads after `config/general.h`. We enable just the
# image format + framebuffer console plumbing — everything else stays
# at the upstream default. `keep-debug` is off; `parserrors` is off; we
# pin a small set of useful tweaks.
RUN mkdir -p config/local \
&& printf '%s\n' \
'#define IMAGE_PNG' \
'#define CONSOLE_FRAMEBUFFER' \
'#define CONSOLE_VESAFB' \
'#define DOWNLOAD_PROTO_HTTPS' \
'#define NSLOOKUP_CMD' \
'#define NTP_CMD' \
> config/local/general.h
# Each arch builds to its own `bin-*` directory. We copy the four
# output binaries into /out/ with the names openpxe-ipxe-assets
# expects. Stripping the binaries saves ~30% — they go into the rust
# binary via include_bytes! so the savings ripple through the final
# image.
RUN mkdir -p /out && \
make -j"$(nproc)" bin/undionly.kpxe && \
cp bin/undionly.kpxe /out/undionly.kpxe && \
make -j"$(nproc)" bin-x86_64-efi/snponly.efi && \
cp bin-x86_64-efi/snponly.efi /out/snponly.efi && \
make -j"$(nproc)" bin-x86_64-efi/ipxe.efi && \
cp bin-x86_64-efi/ipxe.efi /out/ipxe.efi && \
make -j"$(nproc)" bin-i386-efi/snponly.efi && \
cp bin-i386-efi/snponly.efi /out/snponly-i386.efi && \
make -j"$(nproc)" CROSS_COMPILE=aarch64-linux-gnu- bin-arm64-efi/snponly.efi && \
cp bin-arm64-efi/snponly.efi /out/snponly-arm64.efi && \
ls -lh /out/
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
########## build openpxe ##########
FROM rust:${RUST_VERSION}-bookworm AS build
@@ -122,11 +64,7 @@ RUN apt-get update \
# `cargo build`, which is slow and can exhaust small Colima/CI disks.
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
# v0.4.61: iPXE binaries come from our own source-built stage with
# IMAGE_PNG enabled. wimboot still comes from the fetch stage (it's
# from ipxe/wimboot's GitHub release, separately signed).
COPY --from=ipxe-build /out/ /src/assets/ipxe/
COPY --from=fetch /src/assets/ipxe/wimboot /src/assets/ipxe/wimboot
COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
# Cache cargo registry + target across builds. The mtime touch is
# belt-and-suspenders: cargo occasionally misses mtime-only changes on
+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