Compare commits

...
4 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 44a2212abe v0.5.5: SFTP-over-SSH remote shares (russh, pure-Rust, ring backend)
Adds SFTP as a third remote ISO-library protocol alongside SMB and NFS.
Pure-Rust russh + russh-sftp on the ring crypto backend — no kernel
mount, no subprocess, no OpenSSL, no new C deps. Like NFS (and unlike
SMB), SFTP-sourced ISOs support HTTP Range requests because SFTP opens
a seekable file handle.

- iso-store: SftpShareManager (connect/auth/READDIR/seekable stream),
  IsoSource::Sftp, password OR SSH-key auth, trust-on-first-use host-key
  pinning, 0600 credential sidecar with a restart-safe derived path.
- http-api: /api/sftp-shares routes, Range-aware ISO dispatch arm,
  status/metrics counts, /api/docs entry, `sftp` terminal commands.
- webui: "SFTP (SSH)" protocol option with a password/key auth toggle,
  host-key fingerprint display, dashboard tile, updated copy.

SCP was deliberately rejected: sequential-only (no Range) and its crates
wrap libssh2 (C + OpenSSL), which would break the static-musl build.

russh is pinned to =0.55.0: russh 0.61 needs the stable RustCrypto
generation (pkcs8 0.11), which is API-incompatible with the release-
candidate crates bergshamra-crypto pins (pkcs8 =0.11.0-rc.11). 0.55 is
the newest russh on the prior generation (pkcs8 0.7) that coexists. Do
not bump past 0.55 until bergshamra adopts stable RustCrypto.

252 tests pass, clippy clean, static musl x86_64 binary (ring already
present via rustls + bergshamra, so no new crypto/C deps).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:49:18 -04:00
Miles WardandClaude Opus 4.8 674a69f93b v0.5.4: code-cleanup pass (AppError, figment config, encoding dedup, typed status, deps)
Final cleanup before hardware testing. No behaviour changes; 248 tests green,
clippy clean.

#1  AppError newtype (http-api/src/error.rs) with one IntoResponse mapping
    (NotFound→404, Invalid→400, _→500) + From<core::Error>/From<io::Error>.
    Converted the clearly-safe handlers (sso_put, unattended_upload,
    branding_clear) to `?`; intentionally left handlers with bespoke
    status semantics (Invalid→404 on category, 409 on duplicate share /
    open upload) explicit so no asserted status changes.
#2  figment-based Config::load (defaults → TOML → env). Keeps the historical
    flat OPENPXE_* names (Unraid/entrypoint compatible) AND adds the nested
    OPENPXE_SECTION__FIELD form; now covers every field (apply_env had
    silently skipped unattended_dir + bind addrs). 6 Jail tests prove
    backward-compat. Removed the hand-rolled apply_env.
#3  thiserror 1→2; dropped unused mime/mime_guess/once_cell deps.
#4  Re-evaluated: Duration::from_hours/from_mins are stable on the pinned
    1.95 toolchain and clippy prefers them — kept the readable form
    (the "unstable" premise didn't hold; MSRV is intentionally 1.95).
#5  insta snapshot of the rendered iPXE menu (version-filtered) + wiremock
    coverage of the SAML metadata-URL fetch (200 + non-2xx).
#6  api_status → typed StatusResponse struct (was a 25-key json! blob) with
    a full_flow guard test asserting every UI key + the started_at string
    shape. Deferred the /api/docs typed conversion (lowest value, highest
    churn, zero functional benefit).
#7  pct_encode/xml_escape de-duplicated into openpxe_core::encoding (were
    copied across app.rs + the SAML modules). No new crates.
#8  UploadSessions registry → parking_lot::RwLock (sync, never held across
    .await); per-session lock stays tokio::Mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 03:33:05 -04:00
Miles WardandClaude Opus 4.8 7358013093 v0.5.3: dark-mode branding preview + unified button spacing
UI polish:
- Settings → Branding: each logo swatch now previews on a background
  matching where the mark lands (light page / dark page / dark PXE screen)
  regardless of the current page theme, so the Dark slot reads as dark
  even while viewing Settings in light mode.
- Site-wide button spacing: add one rule (`.card .body > button`) giving
  every primary card action button the same gap above it, and drop the
  ad-hoc per-button inline margins (14/16/6px) so the look is uniform.
  Fixes the Hosts → "Bind MAC to target" button butting against the form.

(Boot-menu highlight intentionally unchanged — a rotating-RGB highlight
isn't possible in iPXE's static single-draw menu; deferred to a future
custom-renderer effort.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 02:50:51 -04:00
Miles WardandClaude Opus 4.8 a906c47f53 build: native arm64→x86_64-musl cross-compile (cargo-zigbuild), no QEMU
The Rust `build` stage previously ran the entire compiler under QEMU x86_64
emulation on the arm64 builder. That was ~15x slower (one crate took >20 min)
and the emulated gcc/linker intermittently SIGSEGV'd or hung mid-link
(observed again building v0.5.2).

Pin the stage to $BUILDPLATFORM (native arm64 on Apple Silicon, amd64 in CI)
and cross-compile to x86_64-unknown-linux-musl with cargo-zigbuild — zig cc
supplies the musl sysroot + linker. rustc runs natively; no emulation. Build
drops from ~30 min to a few minutes and is deterministic. Output is the same
fully static musl binary (verified: x86_64, not a dynamic executable, 0
OpenSSL strings).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-31 17:46:50 -04:00
27 changed files with 3094 additions and 299 deletions
Generated
+832 -43
View File
File diff suppressed because it is too large Load Diff
+35 -5
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.5.2" version = "0.5.5"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -36,25 +36,25 @@ tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] } tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.4" hyper = "1.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
mime = "0.3"
mime_guess = "2.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml = "0.8" toml = "0.8"
# v0.5.4: layered config (TOML file + env). Pure-Rust, no C deps; keeps the
# static-musl build OpenSSL-free. Replaces the hand-rolled apply_env mapping.
figment = { version = "0.10", features = ["toml", "env"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = "1.0" anyhow = "1.0"
thiserror = "1.0" thiserror = "2.0"
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
uuid = { version = "1.10", features = ["v4", "serde"] } uuid = { version = "1.10", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] } time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] }
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
bcrypt = "0.15" bcrypt = "0.15"
once_cell = "1.19"
parking_lot = "0.12" parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] } rust-embed = { version = "8.5", features = ["include-exclude"] }
@@ -85,6 +85,36 @@ x509-parser = "0.18"
flate2 = "1.1" flate2 = "1.1"
base64 = "0.22" base64 = "0.22"
# v0.5.5: pure-Rust SSH/SFTP client for reading remote ISO libraries
# over SFTP without a kernel mount.
#
# CRITICAL #1 — crypto backend: `default-features = false` +
# `features = ["ring"]`. russh's *default* backend is `aws-lc-rs`, which
# pulls `aws-lc-sys` (C code, fiddly under musl); the `ring` feature
# instead reuses `ring 0.17` — the exact crate+version already in the
# binary via rustls + bergshamra — so SFTP adds ZERO new C/crypto deps
# and the static-musl build stays OpenSSL-free.
#
# CRITICAL #2 — pinned to EXACTLY 0.55.0, the newest russh that
# coexists with bergshamra-crypto (our SAML core). The RustCrypto
# ecosystem is mid-transition: bergshamra-crypto pins a constellation of
# release-CANDIDATE crates (`pkcs8 =0.11.0-rc.11` and its matching
# pkcs5/spki RCs) that are API-incompatible with the STABLE versions of
# the same crates in the same semver bucket. russh 0.56+ pulls those
# stable crates (`pkcs5 0.8`), which silently replaces bergshamra's RC
# copies and breaks compilation. russh ≤0.55 stays on the previous stable
# generation (`pkcs5 0.7`, `ssh-key 0.6`), which unifies with bergshamra's
# *stable* deps and leaves the RC bucket untouched — verified to compile.
# 0.55 still has the merged `russh::keys` API (keys merged at 0.50).
# IMPORTANT: do NOT bump russh past 0.55 until bergshamra-crypto adopts
# the stable RustCrypto generation; 0.56+ will not compile in this tree.
#
# SCP was deliberately rejected: the protocol is sequential-only (no
# random access → no HTTP Range, unlike SFTP/NFS) and the mature SCP
# crates wrap libssh2 (C + OpenSSL), which would break this build.
russh = { version = "=0.55.0", default-features = false, features = ["ring"] }
russh-sftp = "2.3"
openpxe-core = { path = "crates/core" } openpxe-core = { path = "crates/core" }
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" } openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
openpxe-tftp = { path = "crates/tftp" } openpxe-tftp = { path = "crates/tftp" }
+4
View File
@@ -13,6 +13,7 @@ workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
toml.workspace = true toml.workspace = true
figment.workspace = true
thiserror.workspace = true thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
@@ -38,6 +39,9 @@ base64.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
# v0.5.4: figment's `Jail` (hermetic env/file sandbox) for the config
# loader tests lives behind the `test` feature.
figment = { workspace = true, features = ["test"] }
# v0.5.1: generate a throwaway self-signed signing cert/key so SAML # v0.5.1: generate a throwaway self-signed signing cert/key so SAML
# verification tests can produce genuinely signed SAMLResponses. # verification tests can produce genuinely signed SAMLResponses.
rcgen = "0.13" rcgen = "0.13"
+159 -40
View File
@@ -1,3 +1,5 @@
use figment::providers::{Env, Format, Serialized, Toml};
use figment::Figment;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -56,6 +58,9 @@ pub enum DhcpMode {
/// Disabled — rely on an external DHCP server that has been manually /// Disabled — rely on an external DHCP server that has been manually
/// configured with `next-server` / `filename`. OpenPXE only serves TFTP /// configured with `next-server` / `filename`. OpenPXE only serves TFTP
/// + HTTP in this mode. Useful for home routers that can be pre-set. /// + HTTP in this mode. Useful for home routers that can be pre-set.
// `off`/`none` are accepted as aliases for backward-compat with the old
// hand-rolled `apply_env`, which mapped them to Disabled.
#[serde(alias = "off", alias = "none")]
Disabled, Disabled,
} }
@@ -129,48 +134,162 @@ impl Config {
toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string())) toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string()))
} }
/// Apply environment variable overrides. Env var names follow the pattern /// Load configuration with layered precedence (v0.5.4, via `figment`):
/// `OPENPXE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored. /// built-in [`Default`] → optional TOML file → `OPENPXE_*` environment
/// Call this after loading the TOML file so env takes precedence. /// (highest). Replaces the old `from_toml_file` + `apply_env` two-step
pub fn apply_env(&mut self) { /// and now covers **every** field automatically (the previous hand-rolled
if let Ok(v) = std::env::var("OPENPXE_HTTP_PORT") { /// mapping silently skipped `unattended_dir`, the bind addresses, etc.).
if let Ok(p) = v.parse() { ///
self.server.http_port = p; /// The env layer preserves the historical flat names
/// (`OPENPXE_HTTP_PORT`, `OPENPXE_ISO_DIR`, …) so existing deployments
/// (the Unraid template, `entrypoint.sh`) keep working unchanged, and
/// additionally accepts the explicit nested form
/// `OPENPXE_<SECTION>__<FIELD>` (double underscore).
pub fn load(path: Option<&Path>) -> crate::Result<Self> {
let mut fig = Figment::from(Serialized::defaults(Config::default()));
if let Some(p) = path {
if p.exists() {
fig = fig.merge(Toml::file(p));
} }
} }
if let Ok(v) = std::env::var("OPENPXE_TFTP_PORT") { fig = fig.merge(env_provider());
if let Ok(p) = v.parse() { fig.extract()
self.server.tftp_port = p; .map_err(|e| crate::Error::Config(e.to_string()))
} }
} }
if let Ok(v) = std::env::var("OPENPXE_DHCP_PORT") {
if let Ok(p) = v.parse() { /// The `OPENPXE_*` environment provider. Maps the historical flat variable
self.network.dhcp_port = p; /// names onto the nested [`Config`] fields, and also accepts the explicit
} /// `OPENPXE_SECTION__FIELD` nested form. Keys that match nothing (e.g.
} /// `OPENPXE_CONFIG`, `OPENPXE_UID` from the entrypoint) become stray
if let Ok(v) = std::env::var("OPENPXE_PUBLIC_IP") { /// top-level keys that `Config` ignores on extract.
if let Ok(ip) = v.parse() { fn env_provider() -> Env {
self.server.public_ip = Some(ip); Env::prefixed("OPENPXE_")
} .map(|key| {
} // Lowercase so the match is robust regardless of how the OS
if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") { // reports the var's case.
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() { let k = key.as_str().to_ascii_lowercase();
"proxy" => DhcpMode::Proxy, let mapped = match k.as_str() {
"disabled" | "off" | "none" => DhcpMode::Disabled, "http_port" => "server.http_port",
_ => self.network.dhcp_mode, "http_bind" => "server.http_bind",
"tftp_port" => "server.tftp_port",
"tftp_bind" => "server.tftp_bind",
"public_ip" => "server.public_ip",
"dhcp_port" => "network.dhcp_port",
"dhcp_bind" => "network.dhcp_bind",
"dhcp_mode" => "network.dhcp_mode",
"pxe_port" => "network.pxe_port",
"iso_dir" => "paths.iso_dir",
"work_dir" => "paths.work_dir",
"ipxe_dir" => "paths.ipxe_dir",
"smb_dir" => "paths.smb_dir",
"wimboot_path" => "paths.wimboot_path",
"unattended_dir" => "paths.unattended_dir",
// Unknown: support the explicit nested form
// (OPENPXE_SERVER__HTTP_PORT). `replace` is a no-op for the
// already-handled flat names above.
other => return other.replace("__", ".").into(),
}; };
} mapped.into()
if let Ok(v) = std::env::var("OPENPXE_ISO_DIR") { })
self.paths.iso_dir = PathBuf::from(v); .split(".")
} }
if let Ok(v) = std::env::var("OPENPXE_WORK_DIR") {
self.paths.work_dir = PathBuf::from(v); #[cfg(test)]
} mod tests {
if let Ok(v) = std::env::var("OPENPXE_IPXE_DIR") { // figment's `Jail::expect_with` closure returns `Result<(), figment::Error>`
self.paths.ipxe_dir = PathBuf::from(v); // and `figment::Error` is large; that's the library's API, not ours.
} #![allow(clippy::result_large_err)]
if let Ok(v) = std::env::var("OPENPXE_SMB_DIR") { use super::*;
self.paths.smb_dir = PathBuf::from(v);
} #[test]
fn defaults_load_when_no_file_or_env() {
figment::Jail::expect_with(|_jail| {
let c = Config::load(None).expect("load defaults");
assert_eq!(c.server.http_port, 80);
assert_eq!(c.network.dhcp_mode, DhcpMode::Proxy);
assert_eq!(c.paths.iso_dir, PathBuf::from("/var/lib/openpxe/isos"));
Ok(())
});
}
#[test]
fn legacy_flat_env_vars_still_apply() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_HTTP_PORT", "8123");
jail.set_env("OPENPXE_TFTP_PORT", "6900");
jail.set_env("OPENPXE_DHCP_PORT", "6767");
jail.set_env("OPENPXE_PXE_PORT", "4444");
jail.set_env("OPENPXE_PUBLIC_IP", "10.20.30.40");
jail.set_env("OPENPXE_DHCP_MODE", "disabled");
jail.set_env("OPENPXE_ISO_DIR", "/data/isos");
jail.set_env("OPENPXE_WORK_DIR", "/data/work");
jail.set_env("OPENPXE_IPXE_DIR", "/data/ipxe");
jail.set_env("OPENPXE_SMB_DIR", "/data/smb");
// v0.5.4: a field the old apply_env never covered.
jail.set_env("OPENPXE_UNATTENDED_DIR", "/data/unattended");
let c = Config::load(None).expect("load with env");
assert_eq!(c.server.http_port, 8123);
assert_eq!(c.server.tftp_port, 6900);
assert_eq!(c.network.dhcp_port, 6767);
assert_eq!(c.network.pxe_port, 4444);
assert_eq!(c.server.public_ip, Some("10.20.30.40".parse().unwrap()));
assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
assert_eq!(c.paths.iso_dir, PathBuf::from("/data/isos"));
assert_eq!(c.paths.work_dir, PathBuf::from("/data/work"));
assert_eq!(c.paths.ipxe_dir, PathBuf::from("/data/ipxe"));
assert_eq!(c.paths.smb_dir, PathBuf::from("/data/smb"));
assert_eq!(c.paths.unattended_dir, PathBuf::from("/data/unattended"));
Ok(())
});
}
#[test]
fn dhcp_mode_off_alias_maps_to_disabled() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_DHCP_MODE", "off");
let c = Config::load(None).unwrap();
assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
Ok(())
});
}
#[test]
fn nested_double_underscore_form_also_works() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_SERVER__HTTP_PORT", "9001");
let c = Config::load(None).unwrap();
assert_eq!(c.server.http_port, 9001);
Ok(())
});
}
#[test]
fn env_overrides_toml_file() {
figment::Jail::expect_with(|jail| {
jail.create_file(
"openpxe.toml",
"[server]\nhttp_port = 8080\n[paths]\niso_dir = \"/from/toml\"\n",
)?;
jail.set_env("OPENPXE_HTTP_PORT", "8443");
let c = Config::load(Some(Path::new("openpxe.toml"))).unwrap();
// env wins over TOML…
assert_eq!(c.server.http_port, 8443);
// …but TOML-only values still apply.
assert_eq!(c.paths.iso_dir, PathBuf::from("/from/toml"));
Ok(())
});
}
#[test]
fn unrelated_openpxe_env_vars_are_ignored() {
figment::Jail::expect_with(|jail| {
// entrypoint.sh sets these; they must not break config load.
jail.set_env("OPENPXE_UID", "10001");
jail.set_env("OPENPXE_CONFIG", "/etc/openpxe.toml");
let c = Config::load(None).expect("stray vars ignored");
assert_eq!(c.server.http_port, 80);
Ok(())
});
} }
} }
+65
View File
@@ -0,0 +1,65 @@
//! Small, dependency-free encoding helpers shared across crates.
//!
//! v0.5.4: `pct_encode` and `xml_escape` were duplicated in the SAML
//! modules and the HTTP layer; they live here now. They're deliberately
//! hand-rolled rather than pulling in `percent-encoding` / `url`: the
//! unreserved set below is exactly the RFC 3986 set that iPXE's
//! `:uristring` modifier and the SAML HTTP-Redirect binding both expect,
//! and a general-purpose URL crate escapes a different set.
use std::fmt::Write as _;
/// Percent-encode `s` per RFC 3986: the unreserved set
/// (`A-Z` `a-z` `0-9` `-` `_` `.` `~`) passes through unchanged; every
/// other byte becomes `%XX` (uppercase hex).
#[must_use]
pub fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
/// Escape the five XML predefined entities so `s` is safe inside element
/// text or a double-quoted attribute value.
#[must_use]
pub fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pct_encode_unreserved_passthrough_else_hex() {
assert_eq!(pct_encode("node-7.lab_1~"), "node-7.lab_1~");
assert_eq!(pct_encode("aa:bb cc/?&="), "aa%3Abb%20cc%2F%3F%26%3D");
assert_eq!(pct_encode(""), "");
}
#[test]
fn xml_escape_all_five_entities() {
assert_eq!(xml_escape("a&b<c>\"d'e"), "a&amp;b&lt;c&gt;&quot;d&apos;e");
assert_eq!(xml_escape("plain text"), "plain text");
}
}
+1
View File
@@ -8,6 +8,7 @@ pub mod boot_log;
pub mod branding; pub mod branding;
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod encoding;
pub mod error; pub mod error;
pub mod host_bindings; pub mod host_bindings;
pub mod log_bus; pub mod log_bus;
+3 -32
View File
@@ -5,7 +5,6 @@
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent //! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
//! unsigned in this release (the IdP must not require client signatures). //! unsigned in this release (the IdP must not require client signatures).
use std::fmt::Write as _;
use std::io::Write as _; use std::io::Write as _;
use base64::Engine; use base64::Engine;
@@ -15,6 +14,7 @@ use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime; use time::OffsetDateTime;
use super::{SamlError, SpParams}; use super::{SamlError, SpParams};
use crate::encoding::{pct_encode, xml_escape};
const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol"; const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion"; const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
@@ -79,37 +79,8 @@ fn deflate_base64(xml: &str) -> Result<String, SamlError> {
Ok(base64::engine::general_purpose::STANDARD.encode(compressed)) Ok(base64::engine::general_purpose::STANDARD.encode(compressed))
} }
/// Percent-encode a query-string component (RFC 3986 unreserved set passes // `pct_encode` + `xml_escape` now live in `openpxe_core::encoding` (v0.5.4)
/// through; everything else is `%XX`). // — imported above.
fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+2 -15
View File
@@ -7,6 +7,7 @@
use base64::Engine; use base64::Engine;
use super::{SamlError, SpParams}; use super::{SamlError, SpParams};
use crate::encoding::xml_escape;
/// SAML 2.0 binding URIs. /// SAML 2.0 binding URIs.
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
@@ -148,21 +149,7 @@ fn node_text(n: &roxmltree::Node<'_, '_>) -> String {
.collect() .collect()
} }
/// Minimal XML attribute/text escaping for the values we interpolate. // `xml_escape` now lives in `openpxe_core::encoding` (v0.5.4) — imported above.
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+6 -2
View File
@@ -33,8 +33,6 @@ thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
bytes.workspace = true bytes.workspace = true
futures.workspace = true futures.workspace = true
mime.workspace = true
mime_guess.workspace = true
uuid.workspace = true uuid.workspace = true
# v0.4.5 Forms auth: lock-free session store and cookie helpers. # v0.4.5 Forms auth: lock-free session store and cookie helpers.
parking_lot.workspace = true parking_lot.workspace = true
@@ -61,3 +59,9 @@ image = { version = "0.25", default-features = false, features = ["png"] }
# replay, and IdP-initiated-gating flows exercise real signatures. # replay, and IdP-initiated-gating flows exercise real signatures.
rcgen = "0.13" rcgen = "0.13"
bergshamra = { workspace = true } bergshamra = { workspace = true }
# v0.5.4: snapshot the generated iPXE menu so any unintended drift (a
# dropped line, reordered item) is caught and reviewed, not silently shipped.
insta = "1.40"
# v0.5.4: stand up a mock HTTP server to exercise the SAML metadata-URL
# fetch path (previously untested because it did a real network GET).
wiremock = "0.6"
+236 -78
View File
@@ -14,6 +14,7 @@
//! | `/api/*` | JSON/HTML API for the web UI | //! | `/api/*` | JSON/HTML API for the web UI |
use crate::auth as auth_api; use crate::auth as auth_api;
use crate::error::AppError;
use crate::ipxe_script::{ use crate::ipxe_script::{
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info, render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
render_queue_entry, render_shell, render_tools_menu, render_util, render_queue_entry, render_shell, render_tools_menu, render_util,
@@ -31,15 +32,15 @@ use axum::{
Json, Router, Json, Router,
}; };
use openpxe_core::{ use openpxe_core::{
ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error, LogoSlot, NotifyConfig, encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES, LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
}; };
use openpxe_ipxe_assets::asset_bytes; use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{ use openpxe_iso_store::{
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, UnattendedKind, render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
UnattendedMeta, SmbState, UnattendedKind, UnattendedMeta,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::time::Duration; use std::time::Duration;
@@ -187,6 +188,15 @@ pub fn build_router(state: AppState) -> Router {
) )
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove)) .route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan)) .route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
// v0.5.5: SFTP-over-SSH share manager (pure-Rust russh client).
// Parallel to SMB/NFS so the UI reuses the same form/error/hint
// rendering. Like NFS, SFTP-sourced ISOs support Range requests.
.route(
"/api/sftp-shares",
get(api_sftp_shares_list).post(api_sftp_shares_add),
)
.route("/api/sftp-shares/:id", delete(api_sftp_shares_remove))
.route("/api/sftp-shares/:id/scan", post(api_sftp_shares_scan))
// Phase 4: Network info (read-only) + DNS edit. // Phase 4: Network info (read-only) + DNS edit.
.route("/api/network", get(api_network).put(api_network_put)) .route("/api/network", get(api_network).put(api_network_put))
// Phase 4: live-log stream + recent buffer for the Terminal tab. // Phase 4: live-log stream + recent buffer for the Terminal tab.
@@ -241,12 +251,12 @@ async fn api_sso_get(State(state): State<AppState>) -> Json<SsoConfig> {
Json(state.sso.snapshot()) Json(state.sso.snapshot())
} }
async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response { async fn api_sso_put(
match state.sso.replace(body) { State(state): State<AppState>,
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(), Json(body): Json<SsoConfig>,
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(), ) -> Result<Json<SsoConfig>, AppError> {
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), // v0.5.4: `?` + AppError centralizes Invalid→400 / _→500.
} Ok(Json(state.sso.replace(body)?))
} }
// ─── UI ──────────────────────────────────────────────────────────────────── // ─── UI ────────────────────────────────────────────────────────────────────
@@ -855,6 +865,57 @@ async fn iso_raw(
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(), Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(),
} }
} }
IsoSource::Sftp {
share_id,
relative_path,
} => {
// v0.5.5: SFTP sources support Range requests because SFTP
// opens a seekable file handle (seek to offset, then bounded
// reads). Identical handling to the NFS arm above.
let total = meta.size_bytes;
let range = match parse_range(headers.get(header::RANGE), total) {
Some(triple) => triple,
None if headers.get(header::RANGE).is_some() => {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(Body::empty())
.unwrap();
}
// No Range header — serve the whole file.
None => (0, total.saturating_sub(1), false),
};
let (start, end, partial) = range;
let len = if total == 0 { 0 } else { end - start + 1 };
let max_len = if total == 0 { None } else { Some(len) };
match state
.sftp_shares
.stream_iso(share_id, relative_path, start, max_len)
.await
{
Ok(stream) => {
let body = Body::from_stream(stream);
let status = if partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
};
let mut builder = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, len);
if partial {
builder = builder.header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{total}"),
);
}
builder.body(body).unwrap()
}
Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response(),
}
}
} }
} }
@@ -1198,14 +1259,12 @@ async fn api_branding_upload(
async fn api_branding_clear( async fn api_branding_clear(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(slot): AxumPath<String>, AxumPath(slot): AxumPath<String>,
) -> Response { ) -> Result<Response, AppError> {
let Some(slot) = LogoSlot::parse(&slot) else { let Some(slot) = LogoSlot::parse(&slot) else {
return (StatusCode::BAD_REQUEST, "unknown logo slot").into_response(); return Ok((StatusCode::BAD_REQUEST, "unknown logo slot").into_response());
}; };
match state.branding.clear_logo(slot) { state.branding.clear_logo(slot)?;
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(StatusCode::NO_CONTENT.into_response())
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
} }
// ─── Unattended answer files (v0.5.2) ────────────────────────────────────── // ─── Unattended answer files (v0.5.2) ──────────────────────────────────────
@@ -1221,7 +1280,9 @@ async fn api_unattended_list(State(state): State<AppState>) -> Json<serde_json::
async fn api_unattended_upload( async fn api_unattended_upload(
State(state): State<AppState>, State(state): State<AppState>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Response { ) -> Result<Response, AppError> {
// v0.5.4: the answer-file add() maps Invalid→400 / _→500 via `?`+AppError.
// The multipart-shape 400s (missing field/filename) stay explicit.
while let Ok(Some(field)) = multipart.next_field().await { while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string(); let name = field.name().unwrap_or("").to_string();
if name != "file" && name != "unattended" { if name != "file" && name != "unattended" {
@@ -1229,19 +1290,18 @@ async fn api_unattended_upload(
} }
let filename = field.file_name().map(str::to_string).unwrap_or_default(); let filename = field.file_name().map(str::to_string).unwrap_or_default();
if filename.trim().is_empty() { if filename.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "missing filename on upload").into_response(); return Ok((StatusCode::BAD_REQUEST, "missing filename on upload").into_response());
} }
let bytes = match field.bytes().await { let bytes = match field.bytes().await {
Ok(b) => b, Ok(b) => b,
Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(), Err(e) => {
}; return Ok((StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response())
return match state.unattended.add(&filename, &bytes).await { }
Ok(meta) => (StatusCode::CREATED, Json(meta)).into_response(),
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}; };
let meta = state.unattended.add(&filename, &bytes).await?;
return Ok((StatusCode::CREATED, Json(meta)).into_response());
} }
(StatusCode::BAD_REQUEST, "no 'file' part").into_response() Ok((StatusCode::BAD_REQUEST, "no 'file' part").into_response())
} }
async fn api_unattended_delete( async fn api_unattended_delete(
@@ -1379,23 +1439,7 @@ fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
out out
} }
/// Minimal RFC 3986 percent-encoding for query values (unreserved set // `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
/// passes through; everything else becomes `%XX`).
fn pct_encode(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
/// Encode `(hostname, ip, mac)` into a single base64url path segment for /// Encode `(hostname, ip, mac)` into a single base64url path segment for
/// the cloud-init seed directory. Empty values become empty fields. /// the cloud-init seed directory. Empty values become empty fields.
@@ -1506,6 +1550,19 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Re-list a share for new ISOs."}, "summary": "Re-list a share for new ISOs."},
], ],
}, },
{
"name": "SFTP shares",
"endpoints": [
{"method": "GET", "path": "/api/sftp-shares",
"summary": "List configured SFTP-over-SSH shares with connection state and iso counts."},
{"method": "POST", "path": "/api/sftp-shares",
"summary": "Register an SFTP share. Body: { server, export, username, port?, password? | private_key? + passphrase? }. The server's SSH host key is pinned trust-on-first-use."},
{"method": "DELETE", "path": "/api/sftp-shares/:id",
"summary": "Forget a share, drop its entries from the ISO store, and scrub its credentials file."},
{"method": "POST", "path": "/api/sftp-shares/:id/scan",
"summary": "Re-list a share for new ISOs."},
],
},
{ {
"name": "Network", "name": "Network",
"endpoints": [ "endpoints": [
@@ -1948,7 +2005,51 @@ async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Val
Json(json!({ "clients": state.clients.list() })) Json(json!({ "clients": state.clients.list() }))
} }
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> { /// Per-theme branding presence, nested under [`StatusResponse::branding`].
#[derive(Serialize)]
struct BrandingStatus {
light: bool,
dark: bool,
client: bool,
rev: u64,
}
/// Dashboard status payload. v0.5.4: this replaced a 25-key hand-built
/// `json!` blob — the typed struct makes the contract with the WebUI
/// compile-checked. Field names ARE the JSON keys; do not rename without
/// updating `crates/webui/src/app.js` (a `full_flow` test guards the set).
/// `settings` / `smb` / `started_at` embed their own `Serialize` impls so
/// the wire shape is byte-identical to the previous `json!` output.
#[derive(Serialize)]
struct StatusResponse {
version: &'static str,
public_base_url: String,
iso_count: usize,
client_count: usize,
queue_count: usize,
imaging_count: usize,
waiting_count: usize,
ipxe_assets: Vec<String>,
settings: Settings,
smb: Option<SmbState>,
smb_share_count: usize,
smb_share_reachable: usize,
nfs_share_count: usize,
nfs_share_reachable: usize,
sftp_share_count: usize,
sftp_share_reachable: usize,
host_bindings: usize,
custom_logo: bool,
branding: BrandingStatus,
unattended_count: usize,
uptime_secs: i64,
started_at: time::OffsetDateTime,
nic_name: String,
subnet_mask: String,
gateway: String,
}
async fn api_status(State(state): State<AppState>) -> Json<StatusResponse> {
let smb = state.smb.as_ref().map(|s| s.snapshot()); let smb = state.smb.as_ref().map(|s| s.snapshot());
// v0.4.65+v0.4.67: external storage shares — SMB (userspace // v0.4.65+v0.4.67: external storage shares — SMB (userspace
// smbclient) and NFS (in-process nfs3_client). Dashboard tile // smbclient) and NFS (in-process nfs3_client). Dashboard tile
@@ -1957,6 +2058,9 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count(); let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
let nfs_shares = state.nfs_shares.list(); let nfs_shares = state.nfs_shares.list();
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count(); let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
// v0.5.5: SFTP shares fold into the same "reachable shares" tile.
let sftp_shares = state.sftp_shares.list();
let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count();
let isos = state.iso_store.list(); let isos = state.iso_store.list();
let clients = state.clients.list(); let clients = state.clients.list();
let queue_entries = state.queue.list(); let queue_entries = state.queue.list();
@@ -1977,43 +2081,46 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
.set_queue_counts(queue_entries.len() as u64, imaging as u64); .set_queue_counts(queue_entries.len() as u64, imaging as u64);
state state
.metrics .metrics
.set_nfs_active((smb_reachable + nfs_reachable) as u64); .set_nfs_active((smb_reachable + nfs_reachable + sftp_reachable) as u64);
state.metrics.record_http(openpxe_core::HttpRoute::Api); state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0); let uptime_secs = (now - state.started_at).whole_seconds().max(0);
Json(json!({ Json(StatusResponse {
"version": env!("CARGO_PKG_VERSION"), version: env!("CARGO_PKG_VERSION"),
"public_base_url": state.public_base_url, public_base_url: state.public_base_url.clone(),
"iso_count": isos.len(), iso_count: isos.len(),
"client_count": clients.len(), client_count: clients.len(),
"queue_count": queue_entries.len(), queue_count: queue_entries.len(),
"imaging_count": imaging, imaging_count: imaging,
"waiting_count": waiting, waiting_count: waiting,
"ipxe_assets": openpxe_ipxe_assets::list_assets(), ipxe_assets: openpxe_ipxe_assets::list_assets(),
"settings": state.settings.snapshot(), settings: state.settings.snapshot(),
"smb": smb, smb,
"smb_share_count": smb_shares.len(), smb_share_count: smb_shares.len(),
"smb_share_reachable": smb_reachable, smb_share_reachable: smb_reachable,
// v0.4.67: NFSv3 share counts. The dashboard tile sums these // v0.4.67: NFSv3 share counts. The dashboard tile sums these with
// with the SMB counts above ("N shares reachable") so the // the SMB counts above ("N shares reachable") so the top-line
// top-line metric works regardless of protocol mix. // metric works regardless of protocol mix.
"nfs_share_count": nfs_shares.len(), nfs_share_count: nfs_shares.len(),
"nfs_share_reachable": nfs_reachable, nfs_share_reachable: nfs_reachable,
"host_bindings": state.hosts.len(), // v0.5.5: SFTP share counts, summed into the same dashboard tile.
"custom_logo": state.branding.has_any_web_logo(), sftp_share_count: sftp_shares.len(),
"branding": { sftp_share_reachable: sftp_reachable,
"light": state.branding.has_logo(LogoSlot::Light), host_bindings: state.hosts.len(),
"dark": state.branding.has_logo(LogoSlot::Dark), custom_logo: state.branding.has_any_web_logo(),
"client": state.branding.has_logo(LogoSlot::Client), branding: BrandingStatus {
"rev": state.branding.logo_rev(), light: state.branding.has_logo(LogoSlot::Light),
dark: state.branding.has_logo(LogoSlot::Dark),
client: state.branding.has_logo(LogoSlot::Client),
rev: state.branding.logo_rev(),
}, },
"unattended_count": state.unattended.len(), unattended_count: state.unattended.len(),
"uptime_secs": uptime_secs, uptime_secs,
"started_at": state.started_at, started_at: state.started_at,
"nic_name": state.nic_name, nic_name: state.nic_name.clone(),
"subnet_mask": state.subnet_mask, subnet_mask: state.subnet_mask.clone(),
"gateway": state.gateway, gateway: state.gateway.clone(),
})) })
} }
async fn api_get_settings(State(state): State<AppState>) -> Json<Settings> { async fn api_get_settings(State(state): State<AppState>) -> Json<Settings> {
@@ -2322,6 +2429,48 @@ async fn api_nfs_shares_scan(
} }
} }
// ─── SFTP share API (v0.5.5) ───────────────────────────────────────────────
//
// Parallel to the NFS shares API. The pure-Rust `russh` + `russh-sftp`
// client gives us in-process listing and streaming, no subprocess. Like
// NFS (and unlike SMB), SFTP-sourced ISOs support HTTP Range requests —
// SFTP opens a seekable file handle. Auth is password OR SSH private
// key; the server's host key is pinned trust-on-first-use.
async fn api_sftp_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "shares": state.sftp_shares.list() }))
}
async fn api_sftp_shares_add(
State(state): State<AppState>,
Json(req): Json<SftpAddRequest>,
) -> Response {
match state.sftp_shares.add(req).await {
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
}
}
async fn api_sftp_shares_remove(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.sftp_shares.remove(&id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn api_sftp_shares_scan(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.sftp_shares.rescan(&id).await {
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
}
}
// ─── Network info API ────────────────────────────────────────────────────── // ─── Network info API ──────────────────────────────────────────────────────
async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> { async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
@@ -2689,7 +2838,16 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
.iter() .iter()
.filter(|m| m.reachable) .filter(|m| m.reachable)
.count(); .count();
state.metrics.set_nfs_active((smb_ok + nfs_ok) as u64); // v0.5.5: SFTP shares fold into the same reachable-shares gauge.
let sftp_ok = state
.sftp_shares
.list()
.iter()
.filter(|m| m.reachable)
.count();
state
.metrics
.set_nfs_active((smb_ok + nfs_ok + sftp_ok) as u64);
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
let uptime = (now - state.started_at).whole_seconds().max(0) as u64; let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
+92
View File
@@ -0,0 +1,92 @@
//! Uniform HTTP error mapping for the API layer (v0.5.4).
//!
//! Before this, ~40 handlers in `app.rs` hand-wrote
//! `match … { Err(e) => (StatusCode::…, format!("{e}")).into_response() }`,
//! and the `openpxe_core::Error` → status mapping drifted between them
//! (e.g. `Invalid` → 400 in most places, 404 in one). [`AppError`] wraps
//! `openpxe_core::Error` so a handler can return `Result<T, AppError>` and
//! `?` its way out, getting one consistent status + body. The body stays
//! plain-text (matching the previous `(StatusCode, String)` responses) so
//! existing clients and tests see no shape change; 5xx detail is logged
//! and returned verbatim exactly as before.
//!
//! Handlers with *intentional* domain-specific statuses (e.g. a duplicate
//! share → 409, a still-open chunked upload → 409) keep their explicit
//! returns — `AppError` is for the common case, not a straitjacket.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use openpxe_core::Error as CoreError;
/// Newtype over [`openpxe_core::Error`] with a uniform [`IntoResponse`].
#[derive(Debug)]
pub struct AppError(pub CoreError);
impl From<CoreError> for AppError {
fn from(e: CoreError) -> Self {
AppError(e)
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError(CoreError::Io(e))
}
}
impl AppError {
/// The HTTP status this error maps to. Public so handlers (and tests)
/// can reason about the mapping in one place.
#[must_use]
pub fn status(&self) -> StatusCode {
match self.0 {
CoreError::NotFound(_) => StatusCode::NOT_FOUND,
CoreError::Invalid(_) => StatusCode::BAD_REQUEST,
CoreError::Config(_) | CoreError::Io(_) | CoreError::Other(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status();
// Match the prior hand-written responses: the 4xx arms returned the
// bare inner message (not the `Display` prefix), so a UI showing
// `await r.text()` reads "metadata too long", not "invalid input:
// metadata too long". 5xx keeps the full `Display` string.
let body = match &self.0 {
CoreError::Invalid(m) | CoreError::NotFound(m) => m.clone(),
other => other.to_string(),
};
if status.is_server_error() {
// Log the full detail server-side; the body still carries it
// (unchanged from the prior `format!("{e}")` behaviour), but the
// log line is what an operator greps for.
tracing::error!(target: "openpxe::http", error = %self.0, "request failed");
}
(status, body).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_mapping_is_consistent() {
assert_eq!(
AppError(CoreError::NotFound("x".into())).status(),
StatusCode::NOT_FOUND
);
assert_eq!(
AppError(CoreError::Invalid("x".into())).status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
AppError(CoreError::Config("x".into())).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
}
+16
View File
@@ -693,6 +693,22 @@ mod password_tests {
assert!(s.contains("arm64 UEFI"), "{s}"); assert!(s.contains("arm64 UEFI"), "{s}");
} }
// v0.5.4: a full snapshot of the rendered top menu. The fragment
// `assert!`s above check specific invariants; this catches *any* other
// drift (a reordered item, a dropped line, changed spacing) so it's
// reviewed deliberately. The OpenPXE version is filtered out so the
// snapshot doesn't churn on every release bump.
#[test]
fn render_menu_snapshot() {
// Normalize the compile-time version so the snapshot doesn't churn
// on every release bump (no insta `filters` feature needed).
let rendered = render_menu(&[], &Settings::default(), "http://10.0.0.5").replace(
concat!("OpenPXE v", env!("CARGO_PKG_VERSION")),
"OpenPXE vX.Y.Z",
);
insta::assert_snapshot!(rendered);
}
#[test] #[test]
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() { fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
let settings = Settings::default(); let settings = Settings::default();
+1
View File
@@ -15,6 +15,7 @@
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod error;
pub mod ipxe_script; pub mod ipxe_script;
pub mod iso_fs; pub mod iso_fs;
pub mod log_stream; pub mod log_stream;
+32
View File
@@ -336,3 +336,35 @@ fn redirect_with_session(location: &str, session: &str) -> Response {
IntoResponse::into_response, IntoResponse::into_response,
) )
} }
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
// v0.5.4: exercise the SAML metadata-URL fetch against a mock server —
// previously this path did a real network GET and had no coverage.
#[tokio::test]
async fn fetch_metadata_returns_body_on_200() {
let server = MockServer::start().await;
let xml = "<EntityDescriptor>idp</EntityDescriptor>";
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(xml))
.mount(&server)
.await;
let got = fetch_metadata(&server.uri()).await.expect("fetch ok");
assert_eq!(got, xml);
}
#[tokio::test]
async fn fetch_metadata_errors_on_non_2xx() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let err = fetch_metadata(&server.uri()).await.unwrap_err();
assert!(matches!(err, SamlError::Metadata(_)), "got {err:?}");
}
}
@@ -0,0 +1,36 @@
---
source: crates/http-api/src/ipxe_script.rs
expression: rendered
---
#!ipxe
# OpenPXE top-level menu - auto-generated, do not edit
set base-url http://10.0.0.5
set esc:hex 1b
set cls ${esc:string}[2J
console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
set arch-label ${buildarch} ${platform}
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
:menu
menu OpenPXE - network boot menu
item --gap
item --gap -- ------------------------- Default -------------------------
item local Boot from Local HDD
item --gap -- ----------------------- Installers -----------------------
item --gap -- (no Linux ISOs uploaded)
item --gap -- (Windows support disabled in Settings)
item --gap -- -------------------------- Tools --------------------------
item tools Tools >
item --gap -- ---------------------- Queued Deployment ---------------------
item queue Queued Deployment (join queue)
item --gap
item --key x exit Exit iPXE
item --gap
item --gap -- OpenPXE vX.Y.Z - ${arch-label}
choose --default queue --timeout 600000 target || goto menu
iseq ${target} local && chain http://10.0.0.5/boot/_local.ipxe || goto menu
iseq ${target} linux && chain http://10.0.0.5/boot/_linux_menu.ipxe || goto menu
iseq ${target} windows && chain http://10.0.0.5/boot/_windows_menu.ipxe || goto menu
iseq ${target} tools && chain http://10.0.0.5/boot/_tools_menu.ipxe || goto menu
iseq ${target} queue && chain http://10.0.0.5/boot/_queue.ipxe || goto menu
iseq ${target} exit && exit || goto menu
goto menu
+10 -1
View File
@@ -5,7 +5,9 @@ use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, NotifyStore, SettingsStore, SsoStore, Metrics, NotifyStore, SettingsStore, SsoStore,
}; };
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager, UnattendedStore}; use openpxe_iso_store::{
IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore,
};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -67,6 +69,13 @@ pub struct AppState {
/// In-process (no subprocess); supports HTTP Range requests on /// In-process (no subprocess); supports HTTP Range requests on
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset. /// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
pub nfs_shares: NfsShareManager, pub nfs_shares: NfsShareManager,
/// v0.5.5: SFTP-over-SSH share manager — pure-Rust userspace
/// consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL).
/// Ships alongside SMB/NFS as the third remote-library protocol.
/// In-process (no subprocess, no kernel mount); supports HTTP Range
/// requests because SFTP opens a seekable file handle. Authenticates
/// the server's SSH host key on a trust-on-first-use basis.
pub sftp_shares: SftpShareManager,
/// v0.5.2: uploaded unattended-install answer files (Kickstart / /// v0.5.2: uploaded unattended-install answer files (Kickstart /
/// Preseed / Autoinstall / Windows answer files). Served on demand to /// Preseed / Autoinstall / Windows answer files). Served on demand to
/// booting clients with per-host hostname/IP/MAC templating; lives in /// booting clients with per-host hostname/IP/MAC templating; lives in
+123 -12
View File
@@ -96,6 +96,8 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
"share" | "smb-share" => smb_share_command(state, tail).await, "share" | "smb-share" => smb_share_command(state, tail).await,
"smb" => smb_command(state, tail).await, "smb" => smb_command(state, tail).await,
"nfs" => nfs_share_command(state, tail).await, "nfs" => nfs_share_command(state, tail).await,
// v0.5.5: SFTP-over-SSH remote shares (in-process russh client).
"sftp" => sftp_share_command(state, tail).await,
"log" => log_command(state, tail), "log" => log_command(state, tail),
"whoami" => Ok("operator".to_string()), "whoami" => Ok("operator".to_string()),
"echo" => Ok(tail.join(" ")), "echo" => Ok(tail.join(" ")),
@@ -118,17 +120,21 @@ fn status_text(s: &AppState) -> String {
// v0.4.67: NFSv3 sources too. // v0.4.67: NFSv3 sources too.
let nfs_shares = s.nfs_shares.list(); let nfs_shares = s.nfs_shares.list();
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count(); let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
// v0.5.5: SFTP-over-SSH sources too.
let sftp_shares = s.sftp_shares.list();
let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count();
format!( format!(
"OpenPXE {ver}\n\ "OpenPXE {ver}\n\
base url: {base}\n\ base url: {base}\n\
interface: {nic}\n\ interface: {nic}\n\
uptime: {up}\n\ uptime: {up}\n\
isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs})\n\ isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs}, sftp: {n_sftp})\n\
clients: {n_clients}\n\ clients: {n_clients}\n\
queue: {n_entries}\n\ queue: {n_entries}\n\
smb server: {smb}\n\ smb server: {smb}\n\
smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\ smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\
nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n", nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n\
sftp shares: {n_sftp_total} configured ({n_sftp_active} reachable)\n",
ver = env!("CARGO_PKG_VERSION"), ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url, base = s.public_base_url,
nic = if s.nic_name.is_empty() { nic = if s.nic_name.is_empty() {
@@ -150,6 +156,10 @@ fn status_text(s: &AppState) -> String {
.iter() .iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. })) .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
.count(), .count(),
n_sftp = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Sftp { .. }))
.count(),
n_clients = clients.len(), n_clients = clients.len(),
n_entries = queue_entries.len(), n_entries = queue_entries.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")), smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
@@ -157,6 +167,8 @@ fn status_text(s: &AppState) -> String {
n_smb_active = smb_reachable, n_smb_active = smb_reachable,
n_nfs_total = nfs_shares.len(), n_nfs_total = nfs_shares.len(),
n_nfs_active = nfs_reachable, n_nfs_active = nfs_reachable,
n_sftp_total = sftp_shares.len(),
n_sftp_active = sftp_reachable,
) )
} }
@@ -177,6 +189,8 @@ fn isos_text(s: &AppState) -> String {
openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"), openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
// v0.4.67: NFSv3 via in-process nfs3_client. // v0.4.67: NFSv3 via in-process nfs3_client.
openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"), openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"),
// v0.5.5: SFTP-over-SSH via in-process russh.
openpxe_iso_store::IsoSource::Sftp { share_id, .. } => format!("sftp:{share_id}"),
}; };
let _ = writeln!( let _ = writeln!(
out, out,
@@ -321,11 +335,9 @@ async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
} }
Some("add") => { Some("add") => {
// share add //server/share [guest|user:password] // share add //server/share [guest|user:password]
let target = args let target = args.get(1).ok_or_else(|| {
.get(1) "usage: share add //server/share [guest|user:password]".to_string()
.ok_or_else(|| { })?;
"usage: share add //server/share [guest|user:password]".to_string()
})?;
// Accept either `//server/share` (UNC-style) or // Accept either `//server/share` (UNC-style) or
// `server:share` (shorter to type). // `server:share` (shorter to type).
let stripped = target.trim_start_matches('/').trim_start_matches('\\'); let stripped = target.trim_start_matches('/').trim_start_matches('\\');
@@ -401,11 +413,7 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
return Ok("(no NFS shares configured)".into()); return Ok("(no NFS shares configured)".into());
} }
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
out,
"{:<24} {:<7} {:<6} TARGET",
"ID", "STATUS", "ISOS"
);
for m in shares { for m in shares {
let status = if m.reachable { "ok" } else { "down" }; let status = if m.reachable { "ok" } else { "down" };
let _ = writeln!( let _ = writeln!(
@@ -476,6 +484,104 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
} }
} }
// ── sftp (v0.5.5) ────────────────────────────────────────────────────────
//
// Parallel to nfs_share_command. The terminal `add` only supports
// password auth — pasting a multiline PEM private key through the
// terminal is impractical, so key-based shares are added via the WebUI.
async fn sftp_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let shares = s.sftp_shares.list();
if shares.is_empty() {
return Ok("(no SFTP shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} {}@{}:{}",
truncate(&m.id, 24),
status,
m.iso_count,
m.username,
m.server,
m.export,
);
if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}");
}
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
}
Ok(out)
}
Some("add") => {
// sftp add <user>@<server>:<export> <password> [port]
let target = args.get(1).ok_or_else(|| {
"usage: sftp add <user>@<server>:<export> <password> [port] \
(key auth: use the WebUI)"
.to_string()
})?;
let password = args
.get(2)
.ok_or_else(|| "a password is required (key auth: use the WebUI)".to_string())?;
let (user, rest) = target
.split_once('@')
.ok_or_else(|| "target must be 'user@server:/export'".to_string())?;
let (server, export) = rest
.split_once(':')
.ok_or_else(|| "target must be 'user@server:/export'".to_string())?;
let port = args.get(3).and_then(|s| s.parse::<u16>().ok());
let req = openpxe_iso_store::SftpAddRequest {
server: server.to_string(),
export: export.to_string(),
username: Some(user.to_string()),
port,
password: Some(password.clone()),
private_key: None,
passphrase: None,
};
match s.sftp_shares.add(req).await {
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
}
}
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: sftp remove <id>".to_string())?;
match s.sftp_shares.remove(id).await {
Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("remove failed: {e}")),
}
}
Some("scan") => {
let id = args
.get(1)
.ok_or_else(|| "usage: sftp scan <id>".to_string())?;
match s.sftp_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown sftp subcommand: {other}\ntry: sftp [list|add|remove|scan]"
)),
}
}
// ── smb ──────────────────────────────────────────────────────────────── // ── smb ────────────────────────────────────────────────────────────────
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
@@ -622,6 +728,11 @@ OpenPXE terminal — available commands:
nfs remove <id> forget an NFS share nfs remove <id> forget an NFS share
nfs scan <id> re-list an NFS share for new ISOs nfs scan <id> re-list an NFS share for new ISOs
sftp list list configured SFTP-over-SSH shares
sftp add <user>@<srv>:<export> <pass> [port] add an SFTP share (key auth: WebUI)
sftp remove <id> forget an SFTP share
sftp scan <id> re-list an SFTP share for new ISOs
smb status outbound Samba state (Windows install media) smb status outbound Samba state (Windows install media)
smb start | stop | reload control the outbound smbd smb start | stop | reload control the outbound smbd
+12 -8
View File
@@ -9,6 +9,7 @@
use bytes::Bytes; use bytes::Bytes;
use openpxe_core::{Error, Result}; use openpxe_core::{Error, Result};
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle}; use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
use parking_lot::RwLock;
use serde::Serialize; use serde::Serialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -19,7 +20,11 @@ const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct UploadSessions { pub struct UploadSessions {
inner: Arc<Mutex<HashMap<String, Arc<Mutex<UploadSession>>>>>, // v0.5.4: the registry is a sync `parking_lot::RwLock` — it's only ever
// briefly read/inserted/removed to look up a session, never held across
// an `.await`. The per-session lock below stays a `tokio::sync::Mutex`
// because `write_chunk` / `finish` are awaited while it's held.
inner: Arc<RwLock<HashMap<String, Arc<Mutex<UploadSession>>>>>,
} }
struct UploadSession { struct UploadSession {
@@ -67,8 +72,7 @@ impl UploadSessions {
}; };
self.inner self.inner
.lock() .write()
.await
.insert(upload_id.clone(), Arc::new(Mutex::new(session))); .insert(upload_id.clone(), Arc::new(Mutex::new(session)));
Ok(UploadStarted { Ok(UploadStarted {
@@ -88,7 +92,7 @@ impl UploadSessions {
chunk: Bytes, chunk: Bytes,
complete: bool, complete: bool,
) -> Result<UploadAppend> { ) -> Result<UploadAppend> {
let Some(session_lock) = self.inner.lock().await.get(upload_id).cloned() else { let Some(session_lock) = self.inner.read().get(upload_id).cloned() else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'"))); return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
}; };
@@ -119,7 +123,7 @@ impl UploadSessions {
if let Err(e) = handle.write_chunk(&chunk).await { if let Err(e) = handle.write_chunk(&chunk).await {
let handle = session.handle.take(); let handle = session.handle.take();
drop(session); drop(session);
self.inner.lock().await.remove(upload_id); self.inner.write().remove(upload_id);
if let Some(handle) = handle { if let Some(handle) = handle {
let _ = handle.abort().await; let _ = handle.abort().await;
} }
@@ -156,11 +160,11 @@ impl UploadSessions {
let meta = match handle.finish(store).await { let meta = match handle.finish(store).await {
Ok(meta) => meta, Ok(meta) => meta,
Err(e) => { Err(e) => {
self.inner.lock().await.remove(upload_id); self.inner.write().remove(upload_id);
return Err(e); return Err(e);
} }
}; };
self.inner.lock().await.remove(upload_id); self.inner.write().remove(upload_id);
Ok(UploadAppend::Complete { Ok(UploadAppend::Complete {
offset: new_offset, offset: new_offset,
iso: Box::new(meta), iso: Box::new(meta),
@@ -168,7 +172,7 @@ impl UploadSessions {
} }
pub async fn abort(&self, upload_id: &str) -> Result<()> { pub async fn abort(&self, upload_id: &str) -> Result<()> {
let Some(session_lock) = self.inner.lock().await.remove(upload_id) else { let Some(session_lock) = self.inner.write().remove(upload_id) else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'"))); return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
}; };
let mut session = session_lock.lock().await; let mut session = session_lock.lock().await;
+57 -1
View File
@@ -14,7 +14,7 @@ use axum::body::Body;
use axum::http::{header, Request, StatusCode}; use axum::http::{header, Request, StatusCode};
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore}; use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use openpxe_http_api::{build_router, AppState}; use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbShareManager}; use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbShareManager};
use tempfile::tempdir; use tempfile::tempdir;
use tower::ServiceExt; use tower::ServiceExt;
@@ -96,6 +96,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let settings = SettingsStore::load_or_default(dir.path()); let settings = SettingsStore::load_or_default(dir.path());
let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone()); let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone()); let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone());
let sftp_shares = SftpShareManager::new(dir.path(), iso_store.clone());
let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended")); let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended"));
unattended.ensure_dir().await.unwrap(); unattended.ensure_dir().await.unwrap();
let log_bus = LogBus::new(64); let log_bus = LogBus::new(64);
@@ -124,6 +125,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
smb: None, smb: None,
smb_shares, smb_shares,
nfs_shares, nfs_shares,
sftp_shares,
unattended, unattended,
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus, log_bus,
@@ -1536,6 +1538,60 @@ async fn status_exposes_custom_logo_flag() {
); );
} }
/// v0.5.4 guard: the typed `StatusResponse` must keep every key the WebUI
/// (`crates/webui/src/app.js`) reads off `/api/status`. If a refactor drops
/// or renames one, the dashboard silently breaks — this catches it.
#[tokio::test]
async fn status_contract_has_all_ui_keys() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/status").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
for key in [
"version",
"public_base_url",
"iso_count",
"client_count",
"queue_count",
"imaging_count",
"waiting_count",
"ipxe_assets",
"settings",
"smb_share_count",
"smb_share_reachable",
"nfs_share_count",
"nfs_share_reachable",
"host_bindings",
"custom_logo",
"branding",
"unattended_count",
"uptime_secs",
"started_at",
"nic_name",
"subnet_mask",
"gateway",
] {
assert!(
v.get(key).is_some(),
"/api/status missing UI key '{key}': {v}"
);
}
// Nested branding presence the Settings tab reads.
for key in ["light", "dark", "client", "rev"] {
assert!(
v["branding"].get(key).is_some(),
"/api/status branding missing '{key}': {v}"
);
}
// started_at must remain an RFC3339 string (the UI does fmtUptime on
// uptime_secs but renders started_at as text), not a serialized struct.
assert!(
v["started_at"].is_string(),
"started_at should serialize as a string: {v}"
);
}
async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) { async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
let res = router let res = router
.clone() .clone()
+4
View File
@@ -39,6 +39,10 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
# kernel mount. See crates/iso-store/src/nfs_share.rs for usage. # kernel mount. See crates/iso-store/src/nfs_share.rs for usage.
nfs3_client = { workspace = true } nfs3_client = { workspace = true }
nfs3_types = { workspace = true } nfs3_types = { workspace = true }
# v0.5.5: pure-Rust SSH/SFTP client (ring backend) for the SFTP remote
# share path. See crates/iso-store/src/sftp_share.rs for usage.
russh = { workspace = true }
russh-sftp = { workspace = true }
# Needed for the Stream trait that wraps the mpsc receiver feeding # Needed for the Stream trait that wraps the mpsc receiver feeding
# NFS read-loop bytes into axum's Body::from_stream. # NFS read-loop bytes into axum's Body::from_stream.
futures = { workspace = true } futures = { workspace = true }
+8
View File
@@ -20,6 +20,7 @@ pub mod entry;
pub mod introspect; pub mod introspect;
pub mod nfs_share; pub mod nfs_share;
pub mod pxe_logo; pub mod pxe_logo;
pub mod sftp_share;
pub mod smb; pub mod smb;
pub mod smb_share; pub mod smb_share;
pub mod store; pub mod store;
@@ -40,6 +41,13 @@ pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, Smb
// "works in any container" property as SMB, plus support for HTTP // "works in any container" property as SMB, plus support for HTTP
// Range requests because NFSv3 READ3 takes an explicit offset. // Range requests because NFSv3 READ3 takes an explicit offset.
pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream}; pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream};
// v0.5.5: SFTP-over-SSH remote shares via the pure-Rust `russh` +
// `russh-sftp` crates (ring backend — no OpenSSL, no new C deps). Like
// NFS, supports HTTP Range requests because SFTP opens a seekable file
// handle. See crates/iso-store/src/sftp_share.rs.
pub use sftp_share::{
SftpAddRequest, SftpAuthKind, SftpShare, SftpShareError, SftpShareManager, SftpStream,
};
pub use store::{ pub use store::{
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle, generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle,
}; };
File diff suppressed because it is too large Load Diff
+20 -6
View File
@@ -24,6 +24,10 @@ use tokio::io::AsyncWriteExt;
/// `nfs3_client` crate (in-process, no subprocess). Same "works in /// `nfs3_client` crate (in-process, no subprocess). Same "works in
/// any container" property as SMB, plus Range requests work because /// any container" property as SMB, plus Range requests work because
/// NFSv3 READ3 takes an explicit offset. /// NFSv3 READ3 takes an explicit offset.
/// `Sftp` (v0.5.5) — remote SFTP-over-SSH share, streamed via the
/// pure-Rust `russh` + `russh-sftp` crates (in-process). Like NFS it
/// supports HTTP Range requests because SFTP opens a seekable file
/// handle (`SSH_FXP_READ` at offset).
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource { pub enum IsoSource {
@@ -42,6 +46,13 @@ pub enum IsoSource {
/// Filename at the export root. /// Filename at the export root.
relative_path: String, relative_path: String,
}, },
/// v0.5.5: SFTP-over-SSH via the in-process `russh` + `russh-sftp`
/// crates.
Sftp {
share_id: String,
/// Filename at the export root.
relative_path: String,
},
} }
/// Where the ISO lands in the PXE menu hierarchy. /// Where the ISO lands in the PXE menu hierarchy.
@@ -299,11 +310,11 @@ impl IsoStore {
None None
} }
} }
// SMB and NFS sources have no local path — they're // SMB, NFS, and SFTP sources have no local path — they're
// streamed in-process. Callers must inspect the source // streamed in-process. Callers must inspect the source
// kind first and dispatch to the appropriate share // kind first and dispatch to the appropriate share
// manager. // manager.
IsoSource::Smb { .. } | IsoSource::Nfs { .. } => None, IsoSource::Smb { .. } | IsoSource::Nfs { .. } | IsoSource::Sftp { .. } => None,
} }
} }
@@ -363,9 +374,9 @@ impl IsoStore {
pub fn drop_external_source(&self, share_id: &str) { pub fn drop_external_source(&self, share_id: &str) {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.isos.retain(|_, m| match &m.source { g.isos.retain(|_, m| match &m.source {
IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => { IsoSource::Smb { share_id: sid, .. }
sid != share_id | IsoSource::Nfs { share_id: sid, .. }
} | IsoSource::Sftp { share_id: sid, .. } => sid != share_id,
IsoSource::Local => true, IsoSource::Local => true,
}); });
} }
@@ -659,7 +670,10 @@ mod tests {
// good. // good.
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04"); let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
assert!(s.contains("boot=casper"), "{s}"); assert!(s.contains("boot=casper"), "{s}");
assert!(s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), "{s}"); assert!(
s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"),
"{s}"
);
assert!(s.contains("ds=nocloud"), "{s}"); assert!(s.contains("ds=nocloud"), "{s}");
assert!(s.contains("ip=dhcp"), "{s}"); assert!(s.contains("ip=dhcp"), "{s}");
assert!(!s.contains("netboot=url"), "legacy option leaked: {s}"); assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
+20 -8
View File
@@ -9,7 +9,7 @@ use openpxe_core::{
}; };
use openpxe_dhcp_proxy::DhcpProxyServer; use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState}; use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager}; use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager};
use openpxe_tftp::TftpServer; use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf; use std::path::PathBuf;
@@ -59,11 +59,10 @@ async fn main() -> anyhow::Result<()> {
init_tracing(log_bus.clone()); init_tracing(log_bus.clone());
let cli = Cli::parse(); let cli = Cli::parse();
let mut config = match &cli.config { // v0.5.4: layered load via figment — defaults → optional TOML → env.
Some(p) if p.exists() => Config::from_toml_file(p)?, // The OPENPXE_* env layer keeps the historical flat names (see
_ => Config::default(), // `Config::load`), so existing deployments are unaffected.
}; let config = Config::load(cli.config.as_deref())?;
config.apply_env();
// Dispatch subcommands before bringing up the server. // Dispatch subcommands before bringing up the server.
if let Some(cmd) = cli.command { if let Some(cmd) = cli.command {
@@ -101,8 +100,7 @@ async fn main() -> anyhow::Result<()> {
iso_store.load_from_disk().await?; iso_store.load_from_disk().await?;
// v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/ // v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/
// Windows answer files). Separate directory from the ISO store. // Windows answer files). Separate directory from the ISO store.
let unattended = let unattended = openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
if let Err(e) = unattended.load_from_disk().await { if let Err(e) = unattended.load_from_disk().await {
tracing::warn!( tracing::warn!(
target: "openpxe::unattended", target: "openpxe::unattended",
@@ -156,6 +154,19 @@ async fn main() -> anyhow::Result<()> {
); );
} }
// v0.5.5: SFTP-over-SSH share manager — pure-Rust in-process
// consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL).
// The third remote-library protocol alongside SMB/NFS; like NFS it
// works in any container (no subprocess, no kernel mount) and
// supports HTTP Range requests because SFTP file handles seek.
let sftp_shares = SftpShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = sftp_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::sftp",
"could not reload SFTP shares on startup: {e}"
);
}
// Sniff network details for the Network tab. None of these are // Sniff network details for the Network tab. None of these are
// required for PXE to work — they're informational, surfaced in the // required for PXE to work — they're informational, surfaced in the
// UI so an operator doesn't have to drop to a shell to find their // UI so an operator doesn't have to drop to a shell to find their
@@ -184,6 +195,7 @@ async fn main() -> anyhow::Result<()> {
smb: Some(smb.clone()), smb: Some(smb.clone()),
smb_shares: smb_shares.clone(), smb_shares: smb_shares.clone(),
nfs_shares: nfs_shares.clone(), nfs_shares: nfs_shares.clone(),
sftp_shares: sftp_shares.clone(),
unattended: unattended.clone(), unattended: unattended.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
+8
View File
@@ -304,6 +304,14 @@ button.ghost { background: transparent; color: var(--fg); border: 1px solid var(
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); } button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); } button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); } button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
/* v0.5.3: unified spacing for a card's primary action button(s). Any
button that sits as a direct child of a card body (Save, Bind, Add,
Launch, ) gets the same gap above it so it never butts against the
form. Inline buttons inside table rows / toolbars / logo slots /
modal action bars are nested deeper, so the `>` keeps them untouched.
Adjacent action buttons on one row (e.g. Save + Send test) share the
margin and stay aligned. */
.card .body > button { margin-top: 16px; }
label.field { label.field {
display: grid; gap: 4px; margin-bottom: 14px; display: grid; gap: 4px; margin-bottom: 14px;
+137 -22
View File
@@ -291,11 +291,11 @@
el('div', {class: 'trend'}, el('div', {class: 'trend'},
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' + isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' + isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
// v0.4.67: count both protocols. Label generically since // v0.4.67+v0.5.5: count all remote-share protocols. Label
// operators may be using one, the other, or both. // generically since operators may use any mix of SMB/NFS/SFTP.
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) + ((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) +
' remote share' + ' remote share' +
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')), (((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) === 1 ? '' : 's')),
])), ])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'), el('div', {class: 'label'}, 'Uptime'),
@@ -508,10 +508,11 @@
// v0.4.67: NFSv3 added back as an in-process Rust client // v0.4.67: NFSv3 added back as an in-process Rust client
// (nfs3_client crate). Both protocols available side-by-side; // (nfs3_client crate). Both protocols available side-by-side;
// operators pick whichever their NAS prefers. // operators pick whichever their NAS prefers.
const [isos, settings, smbRes, nfsRes, disk, unattRes] = await Promise.all([ const [isos, settings, smbRes, nfsRes, sftpRes, disk, unattRes] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'), getJSON('/api/smb-shares'),
getJSON('/api/nfs-shares'), getJSON('/api/nfs-shares'),
getJSON('/api/sftp-shares'),
getJSON('/api/storage/disk').catch(() => ({ getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})), })),
@@ -519,6 +520,7 @@
]); ]);
const shares = smbRes.shares || []; const shares = smbRes.shares || [];
const nfsShares = nfsRes.shares || []; const nfsShares = nfsRes.shares || [];
const sftpShares = sftpRes.shares || [];
const unattendedFiles = unattRes.files || []; const unattendedFiles = unattRes.files || [];
// ── Upload card ── // ── Upload card ──
@@ -829,6 +831,7 @@
const protoSelect = el('select', {}, [ const protoSelect = el('select', {}, [
el('option', {value:'nfs'}, 'NFS (NFSv3)'), el('option', {value:'nfs'}, 'NFS (NFSv3)'),
el('option', {value:'smb'}, 'SMB / CIFS'), el('option', {value:'smb'}, 'SMB / CIFS'),
el('option', {value:'sftp'}, 'SFTP (SSH)'),
]); ]);
// SMB inputs. // SMB inputs.
@@ -893,18 +896,65 @@
]), ]),
]); ]);
// SFTP inputs (v0.5.5). Pure-Rust russh client, in-process, so
// SFTP-sourced ISOs support HTTP Range like NFS. Auth is password
// OR an SSH private key (PEM, optional passphrase); the server's
// host key is pinned trust-on-first-use on the first connect.
const sftpServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
const sftpExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
const sftpUserIn = el('input', {type:'text', placeholder:'root'});
const sftpPortIn = el('input', {type:'number', placeholder:'22', min:'1', max:'65535'});
const sftpAuthMode = el('select', {}, [
el('option', {value:'password'}, 'Password'),
el('option', {value:'key'}, 'SSH private key'),
]);
const sftpPassIn = el('input', {type:'password', placeholder:'••••••••'});
const sftpKeyIn = el('textarea', {rows:'4',
placeholder:'-----BEGIN OPENSSH PRIVATE KEY-----',
style:'width:100%;font-family:ui-monospace,monospace;font-size:12px;resize:vertical'});
const sftpPassphraseIn = el('input', {type:'password',
placeholder:'(only if the private key is encrypted)'});
const sftpPassBlock = el('label', {class:'field'},
[el('span', {class:'name'}, 'Password'), sftpPassIn]);
const sftpKeyBlock = el('div', {}, [
el('label', {class:'field'},
[el('span', {class:'name'}, 'SSH private key (PEM)'), sftpKeyIn]),
el('label', {class:'field', style:'margin-top:10px'},
[el('span', {class:'name'}, 'Key passphrase (optional)'), sftpPassphraseIn]),
]);
const syncSftpAuth = () => {
const key = sftpAuthMode.value === 'key';
sftpPassBlock.style.display = key ? 'none' : '';
sftpKeyBlock.style.display = key ? '' : 'none';
};
sftpAuthMode.addEventListener('change', syncSftpAuth);
syncSftpAuth();
const sftpFields = el('div', {}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'SSH server'), sftpServerIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), sftpExportIn]),
]),
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), sftpUserIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sftpPortIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Auth'), sftpAuthMode]),
]),
el('div', {style:'margin-top:14px'}, [sftpPassBlock, sftpKeyBlock]),
]);
// Swap the visible field block + clear any stale message. // Swap the visible field block + clear any stale message.
const syncProto = () => { const syncProto = () => {
const nfs = protoSelect.value === 'nfs'; const p = protoSelect.value;
smbFields.style.display = nfs ? 'none' : ''; smbFields.style.display = p === 'smb' ? '' : 'none';
nfsFields.style.display = nfs ? '' : 'none'; nfsFields.style.display = p === 'nfs' ? '' : 'none';
sftpFields.style.display = p === 'sftp' ? '' : 'none';
shareMsg.replaceChildren(); shareMsg.replaceChildren();
shareMsg.className = 'msg'; shareMsg.className = 'msg';
}; };
protoSelect.addEventListener('change', syncProto); protoSelect.addEventListener('change', syncProto);
// One add button; dispatches to the selected protocol's endpoint. // One add button; dispatches to the selected protocol's endpoint.
const addShare = el('button', {style:'margin-top:14px', onclick: async () => { const addShare = el('button', {onclick: async () => {
if (protoSelect.value === 'smb') { if (protoSelect.value === 'smb') {
if (!smbServer.value || !smbShare.value) { if (!smbServer.value || !smbShare.value) {
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.')); shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
@@ -924,6 +974,40 @@
shareMsg.className = 'msg ok'; shareMsg.className = 'msg ok';
render('storage'); render('storage');
} else { await showShareError(r); } } else { await showShareError(r); }
} else if (protoSelect.value === 'sftp') {
if (!sftpServerIn.value || !sftpExportIn.value || !sftpUserIn.value) {
shareMsg.replaceChildren(document.createTextNode('Server, export, and username are required.'));
shareMsg.className = 'msg err'; return;
}
const useKey = sftpAuthMode.value === 'key';
if (useKey && !sftpKeyIn.value.trim()) {
shareMsg.replaceChildren(document.createTextNode('Paste the SSH private key, or switch Auth to Password.'));
shareMsg.className = 'msg err'; return;
}
if (!useKey && !sftpPassIn.value) {
shareMsg.replaceChildren(document.createTextNode('Password is required, or switch Auth to SSH private key.'));
shareMsg.className = 'msg err'; return;
}
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
shareMsg.className = 'msg';
const body = {
server: sftpServerIn.value,
export: sftpExportIn.value,
username: sftpUserIn.value,
};
if (sftpPortIn.value) { body.port = parseInt(sftpPortIn.value, 10); }
if (useKey) {
body.private_key = sftpKeyIn.value;
if (sftpPassphraseIn.value) { body.passphrase = sftpPassphraseIn.value; }
} else {
body.password = sftpPassIn.value;
}
const r = await postJSON('/api/sftp-shares', body);
if (r.ok) {
shareMsg.replaceChildren(document.createTextNode('Connected.'));
shareMsg.className = 'msg ok';
render('storage');
} else { await showShareError(r); }
} else { } else {
if (!nfsServerIn.value || !nfsExportIn.value) { if (!nfsServerIn.value || !nfsExportIn.value) {
shareMsg.replaceChildren(document.createTextNode('Server and export are required.')); shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
@@ -962,9 +1046,36 @@
el('span'), el('span'),
])); ]));
const totalShares = shares.length + nfsShares.length; const sftpRowEls = sftpShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SFTP'),
document.createTextNode(m.username + '@' + m.server + ':' + m.export)]),
el('div', {class:'meta'},
'SSH · ' + (m.auth === 'key' ? 'key' : 'password') + ' · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.host_key_fingerprint
? el('div', {style:'margin-top:4px;opacity:.65;font-size:11px;font-family:ui-monospace,monospace;word-break:break-all'},
'host key ' + m.host_key_fingerprint)
: null,
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/sftp-shares/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
await fetch('/api/sftp-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Remove'),
el('span'),
]));
const totalShares = shares.length + nfsShares.length + sftpShares.length;
const remoteRows = totalShares const remoteRows = totalShares
? [...smbRowEls, ...nfsRowEls] ? [...smbRowEls, ...nfsRowEls, ...sftpRowEls]
: [el('div', {class:'empty'}, 'No remote shares configured.')]; : [el('div', {class:'empty'}, 'No remote shares configured.')];
syncProto(); syncProto();
@@ -1071,13 +1182,13 @@
]), ]),
el('span'), el('span'),
]), ]),
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields]), el('div', {style:'margin-top:14px'}, [smbFields, nfsFields, sftpFields]),
addShare, shareMsg, addShare, shareMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows), el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'Remote .iso libraries are read on demand — no local cache to ' + 'Remote .iso libraries are read on demand — no local cache to ' +
'preserve disk usage. Support for NFS 3.0 and SMB. Ensure that ' + 'preserve disk usage. Support for NFS 3.0, SMB, and SFTP (SSH). ' +
'the hosts IP address is provisioned.'), 'Ensure that the hosts IP address is provisioned.'),
]), ]),
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
@@ -1411,7 +1522,7 @@
// beneath the input row instead of butting against the password // beneath the input row instead of butting against the password
// fields. Mirrors the `Save SSO settings` button below for visual // fields. Mirrors the `Save SSO settings` button below for visual
// parity between the two settings cards. // parity between the two settings cards.
const accountSave = el('button', {style:'margin-top:6px', onclick: async () => { const accountSave = el('button', {onclick: async () => {
accountMsg.textContent = ''; accountMsg.className = 'msg'; accountMsg.textContent = ''; accountMsg.className = 'msg';
if (!currentPw.value) { if (!currentPw.value) {
accountMsg.textContent = 'Current password is required.'; accountMsg.textContent = 'Current password is required.';
@@ -1549,7 +1660,7 @@
}; };
ssoMode.onchange = refreshSsoFields; ssoMode.onchange = refreshSsoFields;
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'}); const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => { const ssoSave = el('button', {onclick: async () => {
ssoMsg.textContent = ''; ssoMsg.className = 'msg'; ssoMsg.textContent = ''; ssoMsg.className = 'msg';
const payload = { const payload = {
enabled: ssoEnabled.checked, enabled: ssoEnabled.checked,
@@ -1630,13 +1741,17 @@
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'}); const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
const bust = '?v=' + Date.now(); // bust the preview cache after a change const bust = '?v=' + Date.now(); // bust the preview cache after a change
const brandingPresence = status.branding || { light:false, dark:false, client:false }; const brandingPresence = status.branding || { light:false, dark:false, client:false };
// Each swatch previews on a background matching where the mark
// lands (light page / dark page / dark PXE screen), independent of
// the operator's current page theme — so the Dark slot always reads
// as dark even while viewing Settings in light mode.
const slotDefs = [ const slotDefs = [
{ slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1), { slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1),
hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' }, swatchBg:'#f4f5f7', hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
{ slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1), { slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1),
hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' }, swatchBg:'#0e1014', hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
{ slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust, { slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust,
hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' }, swatchBg:'#0e1014', hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' },
]; ];
const slotCol = (def) => { const slotCol = (def) => {
const set = !!brandingPresence[def.slot]; const set = !!brandingPresence[def.slot];
@@ -1662,7 +1777,7 @@
el('span', {class:'name'}, def.title), el('span', {class:'name'}, def.title),
set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'), set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'),
]), ]),
el('div', {class:'swatch', style: def.slot === 'light' ? 'background:#f4f5f7' : ''}, el('div', {class:'swatch', style:'background:' + def.swatchBg},
el('img', {src: def.preview, alt: def.title + ' logo'})), el('img', {src: def.preview, alt: def.title + ' logo'})),
el('div', {class:'logo-slot-hint'}, def.hint), el('div', {class:'logo-slot-hint'}, def.hint),
el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [ el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [
@@ -1795,13 +1910,13 @@
smtp_implicit_tls: sTls.checked, smtp_implicit_tls: sTls.checked,
}); });
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => { const saveBtn = el('button', {onclick: async () => {
nMsg.textContent = 'Saving…'; nMsg.className = 'msg'; nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
const r = await putJSON('/api/notify', collectNotify()); const r = await putJSON('/api/notify', collectNotify());
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); } if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; } else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
}}, 'Save notification settings'); }}, 'Save notification settings');
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px', const testBtn = el('button', {class:'ghost', style:'margin-left:8px',
onclick: async () => { onclick: async () => {
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg'; nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
// Save first so the test uses exactly what's on screen. // Save first so the test uses exactly what's on screen.
+30 -26
View File
@@ -60,35 +60,41 @@ COPY deploy/ipxe/local/ deploy/ipxe/local/
RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe
########## build openpxe ########## ########## build openpxe ##########
FROM rust:${RUST_VERSION}-bookworm AS build # v0.5.2: cross-compile the Rust binary NATIVELY — no QEMU.
WORKDIR /src
# v0.4.5: build a fully static musl binary (matches Bootimus v0.1.70's
# move). The resulting `/openpxe` has no glibc dependency at all, which:
# - Lets the runtime stage be any Linux distro (we still ship Debian
# slim for the `samba` / `wimtools` / `nfs-common` shellouts, but a
# scratch/distroless variant becomes a one-line swap).
# - Cuts a class of "GLIBC_2.39 not found" surprises when running on
# older RHEL/Rocky hosts that don't match Debian 12's libc version.
# - Sidesteps cross-compilation snags (the binary is its own world).
# #
# x86_64-unknown-linux-musl is fully static by default (no extra # This stage is pinned to $BUILDPLATFORM (the builder's native arch — arm64
# RUSTFLAGS needed). musl-tools provides the linker. # on an Apple-Silicon Mac, amd64 in x86 CI), exactly like `ipxe-build`. The
# Rust compiler therefore runs at full native speed and emits an
# x86_64-unknown-linux-musl binary via `cargo-zigbuild`, which uses `zig cc`
# as the cross-linker (it bundles the musl sysroot for every target, so
# there's no fiddly cross-gcc toolchain to assemble).
#
# Why this replaced the old `FROM rust ... --platform=linux/amd64` build:
# that ran the *entire* compiler under QEMU x86_64 emulation on the arm64
# host. It was ~15x slower (a single crate took >20 min) and the emulated
# gcc/linker intermittently SIGSEGV'd or hung mid-link. Cross-compiling
# sidesteps emulation entirely — the build is minutes, not half an hour,
# and is deterministic.
#
# The output is still a fully static musl binary with no glibc dependency,
# so the runtime stage stays free to be any Linux distro.
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src
# zig (via the `ziglang` pip package — cargo-zigbuild auto-discovers it as
# `python3 -m ziglang`) supplies the x86_64 musl sysroot + linker.
# cargo-zigbuild is the thin cargo wrapper that wires zig in as the linker.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends musl-tools \ && apt-get install -y --no-install-recommends python3 python3-pip \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl && rustup target add x86_64-unknown-linux-musl \
&& pip3 install --no-cache-dir --break-system-packages ziglang \
&& cargo install --locked cargo-zigbuild
# Copy the whole workspace in one go. We used to do a two-pass "cache-prime
# with stubs, then real build" dance for dep-compile reuse; that turned out
# to silently serve stale stub binaries when cargo's fingerprint didn't
# notice the source swap. A single build is ~1.5 min longer on cold cache
# but guarantees the binary reflects the sources we copied.
# Do not copy rust-toolchain.toml into the image. The local workspace pins # Do not copy rust-toolchain.toml into the image. The local workspace pins
# developer tooling, but inside Docker we intentionally use the Rust version # developer tooling, but inside Docker we intentionally use the Rust version
# selected by the base image. Copying rust-toolchain.toml with # selected by the base image. Copying rust-toolchain.toml with
# `channel = "stable"` makes rustup download a second full toolchain during # `channel = "stable"` makes rustup download a second full toolchain during
# `cargo build`, which is slow and can exhaust small Colima/CI disks. # the build, which is slow and can exhaust small Colima/CI disks.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/ COPY crates/ crates/
# Baseline binaries (BIOS / i386 / wimboot), then overlay the # Baseline binaries (BIOS / i386 / wimboot), then overlay the
@@ -100,13 +106,11 @@ COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
COPY --from=ipxe-build /src/assets/ipxe/snponly.efi /src/assets/ipxe/snponly.efi COPY --from=ipxe-build /src/assets/ipxe/snponly.efi /src/assets/ipxe/snponly.efi
COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi
# Cache cargo registry + target across builds. The mtime touch is # Cache cargo registry + target across builds. `cargo zigbuild` runs the
# belt-and-suspenders: cargo occasionally misses mtime-only changes on # native rustc (fast) and links for x86_64-musl with zig — no emulation.
# networked FS; this forces a fingerprint check.
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target,sharing=locked \ --mount=type=cache,target=/src/target,sharing=locked \
find crates -name '*.rs' -exec touch {} + && \ cargo zigbuild --release --target x86_64-unknown-linux-musl --bin openpxe && \
cargo build --release --target x86_64-unknown-linux-musl --bin openpxe && \
cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \ cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \
ls -l /openpxe ls -l /openpxe