Compare commits

...
2 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
25 changed files with 3043 additions and 264 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]
version = "0.5.3"
version = "0.5.5"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
@@ -36,25 +36,25 @@ tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.4"
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_json = "1.0"
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-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = "1.0"
thiserror = "1.0"
thiserror = "2.0"
clap = { version = "4.5", features = ["derive", "env"] }
uuid = { version = "1.10", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] }
sha2 = "0.10"
hex = "0.4"
bcrypt = "0.15"
once_cell = "1.19"
parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] }
@@ -85,6 +85,36 @@ x509-parser = "0.18"
flate2 = "1.1"
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-dhcp-proxy = { path = "crates/dhcp-proxy" }
openpxe-tftp = { path = "crates/tftp" }
+4
View File
@@ -13,6 +13,7 @@ workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
figment.workspace = true
thiserror.workspace = true
anyhow.workspace = true
tracing.workspace = true
@@ -38,6 +39,9 @@ base64.workspace = true
[dev-dependencies]
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
# verification tests can produce genuinely signed SAMLResponses.
rcgen = "0.13"
+153 -34
View File
@@ -1,3 +1,5 @@
use figment::providers::{Env, Format, Serialized, Toml};
use figment::Figment;
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};
@@ -56,6 +58,9 @@ pub enum DhcpMode {
/// Disabled — rely on an external DHCP server that has been manually
/// configured with `next-server` / `filename`. OpenPXE only serves TFTP
/// + 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,
}
@@ -129,48 +134,162 @@ impl Config {
toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string()))
}
/// Apply environment variable overrides. Env var names follow the pattern
/// `OPENPXE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored.
/// Call this after loading the TOML file so env takes precedence.
pub fn apply_env(&mut self) {
if let Ok(v) = std::env::var("OPENPXE_HTTP_PORT") {
if let Ok(p) = v.parse() {
self.server.http_port = p;
/// Load configuration with layered precedence (v0.5.4, via `figment`):
/// built-in [`Default`] → optional TOML file → `OPENPXE_*` environment
/// (highest). Replaces the old `from_toml_file` + `apply_env` two-step
/// and now covers **every** field automatically (the previous hand-rolled
/// mapping silently skipped `unattended_dir`, the bind addresses, etc.).
///
/// 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") {
if let Ok(p) = v.parse() {
self.server.tftp_port = p;
fig = fig.merge(env_provider());
fig.extract()
.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() {
self.network.dhcp_port = p;
}
}
if let Ok(v) = std::env::var("OPENPXE_PUBLIC_IP") {
if let Ok(ip) = v.parse() {
self.server.public_ip = Some(ip);
}
}
if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") {
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() {
"proxy" => DhcpMode::Proxy,
"disabled" | "off" | "none" => DhcpMode::Disabled,
_ => self.network.dhcp_mode,
}
/// The `OPENPXE_*` environment provider. Maps the historical flat variable
/// 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
/// top-level keys that `Config` ignores on extract.
fn env_provider() -> Env {
Env::prefixed("OPENPXE_")
.map(|key| {
// Lowercase so the match is robust regardless of how the OS
// reports the var's case.
let k = key.as_str().to_ascii_lowercase();
let mapped = match k.as_str() {
"http_port" => "server.http_port",
"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()
})
.split(".")
}
#[cfg(test)]
mod tests {
// figment's `Jail::expect_with` closure returns `Result<(), figment::Error>`
// and `figment::Error` is large; that's the library's API, not ours.
#![allow(clippy::result_large_err)]
use super::*;
#[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(())
});
}
if let Ok(v) = std::env::var("OPENPXE_ISO_DIR") {
self.paths.iso_dir = PathBuf::from(v);
#[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(())
});
}
if let Ok(v) = std::env::var("OPENPXE_WORK_DIR") {
self.paths.work_dir = PathBuf::from(v);
#[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(())
});
}
if let Ok(v) = std::env::var("OPENPXE_IPXE_DIR") {
self.paths.ipxe_dir = PathBuf::from(v);
#[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(())
});
}
if let Ok(v) = std::env::var("OPENPXE_SMB_DIR") {
self.paths.smb_dir = PathBuf::from(v);
#[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 client;
pub mod config;
pub mod encoding;
pub mod error;
pub mod host_bindings;
pub mod log_bus;
+3 -32
View File
@@ -5,7 +5,6 @@
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
//! unsigned in this release (the IdP must not require client signatures).
use std::fmt::Write as _;
use std::io::Write as _;
use base64::Engine;
@@ -15,6 +14,7 @@ use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
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_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))
}
/// Percent-encode a query-string component (RFC 3986 unreserved set passes
/// through; everything else is `%XX`).
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
}
// `pct_encode` + `xml_escape` now live in `openpxe_core::encoding` (v0.5.4)
// — imported above.
#[cfg(test)]
mod tests {
+2 -15
View File
@@ -7,6 +7,7 @@
use base64::Engine;
use super::{SamlError, SpParams};
use crate::encoding::xml_escape;
/// SAML 2.0 binding URIs.
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()
}
/// Minimal XML attribute/text escaping for the values we interpolate.
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
}
// `xml_escape` now lives in `openpxe_core::encoding` (v0.5.4) — imported above.
#[cfg(test)]
mod tests {
+6 -2
View File
@@ -33,8 +33,6 @@ thiserror.workspace = true
anyhow.workspace = true
bytes.workspace = true
futures.workspace = true
mime.workspace = true
mime_guess.workspace = true
uuid.workspace = true
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
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.
rcgen = "0.13"
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"
+237 -79
View File
@@ -14,6 +14,7 @@
//! | `/api/*` | JSON/HTML API for the web UI |
use crate::auth as auth_api;
use crate::error::AppError;
use crate::ipxe_script::{
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
render_queue_entry, render_shell, render_tools_menu, render_util,
@@ -31,15 +32,15 @@ use axum::{
Json, Router,
};
use openpxe_core::{
ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error, LogoSlot, NotifyConfig,
Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, UnattendedKind,
UnattendedMeta,
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
SmbState, UnattendedKind, UnattendedMeta,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::net::SocketAddr;
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/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.
.route("/api/network", get(api_network).put(api_network_put))
// 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())
}
async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response {
match state.sso.replace(body) {
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
async fn api_sso_put(
State(state): State<AppState>,
Json(body): Json<SsoConfig>,
) -> Result<Json<SsoConfig>, AppError> {
// v0.5.4: `?` + AppError centralizes Invalid→400 / _→500.
Ok(Json(state.sso.replace(body)?))
}
// ─── UI ────────────────────────────────────────────────────────────────────
@@ -855,6 +865,57 @@ async fn iso_raw(
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(
State(state): State<AppState>,
AxumPath(slot): AxumPath<String>,
) -> Response {
) -> Result<Response, AppError> {
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) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
state.branding.clear_logo(slot)?;
Ok(StatusCode::NO_CONTENT.into_response())
}
// ─── 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(
State(state): State<AppState>,
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 {
let name = field.name().unwrap_or("").to_string();
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();
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 {
Ok(b) => b,
Err(e) => return (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(),
};
Err(e) => {
return Ok((StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response())
}
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
};
let meta = state.unattended.add(&filename, &bytes).await?;
return Ok((StatusCode::CREATED, Json(meta)).into_response());
}
Ok((StatusCode::BAD_REQUEST, "no 'file' part").into_response())
}
async fn api_unattended_delete(
@@ -1379,23 +1439,7 @@ fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
out
}
/// Minimal RFC 3986 percent-encoding for query values (unreserved set
/// 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
}
// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
/// 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."},
],
},
{
"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",
"endpoints": [
@@ -1948,7 +2005,51 @@ async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Val
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());
// v0.4.65+v0.4.67: external storage shares — SMB (userspace
// 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 nfs_shares = state.nfs_shares.list();
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 clients = state.clients.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);
state
.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);
let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"public_base_url": state.public_base_url,
"iso_count": isos.len(),
"client_count": clients.len(),
"queue_count": queue_entries.len(),
"imaging_count": imaging,
"waiting_count": waiting,
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
"settings": state.settings.snapshot(),
"smb": smb,
"smb_share_count": smb_shares.len(),
"smb_share_reachable": smb_reachable,
// v0.4.67: NFSv3 share counts. The dashboard tile sums these
// with the SMB counts above ("N shares reachable") so the
// top-line metric works regardless of protocol mix.
"nfs_share_count": nfs_shares.len(),
"nfs_share_reachable": nfs_reachable,
"host_bindings": state.hosts.len(),
"custom_logo": state.branding.has_any_web_logo(),
"branding": {
"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(),
Json(StatusResponse {
version: env!("CARGO_PKG_VERSION"),
public_base_url: state.public_base_url.clone(),
iso_count: isos.len(),
client_count: clients.len(),
queue_count: queue_entries.len(),
imaging_count: imaging,
waiting_count: waiting,
ipxe_assets: openpxe_ipxe_assets::list_assets(),
settings: state.settings.snapshot(),
smb,
smb_share_count: smb_shares.len(),
smb_share_reachable: smb_reachable,
// v0.4.67: NFSv3 share counts. The dashboard tile sums these with
// the SMB counts above ("N shares reachable") so the top-line
// metric works regardless of protocol mix.
nfs_share_count: nfs_shares.len(),
nfs_share_reachable: nfs_reachable,
// v0.5.5: SFTP share counts, summed into the same dashboard tile.
sftp_share_count: sftp_shares.len(),
sftp_share_reachable: sftp_reachable,
host_bindings: state.hosts.len(),
custom_logo: state.branding.has_any_web_logo(),
branding: BrandingStatus {
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(),
"uptime_secs": uptime_secs,
"started_at": state.started_at,
"nic_name": state.nic_name,
"subnet_mask": state.subnet_mask,
"gateway": state.gateway,
}))
unattended_count: state.unattended.len(),
uptime_secs,
started_at: state.started_at,
nic_name: state.nic_name.clone(),
subnet_mask: state.subnet_mask.clone(),
gateway: state.gateway.clone(),
})
}
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 ──────────────────────────────────────────────────────
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()
.filter(|m| m.reachable)
.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 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}");
}
// 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]
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
let settings = Settings::default();
+1
View File
@@ -15,6 +15,7 @@
pub mod app;
pub mod auth;
pub mod error;
pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream;
+32
View File
@@ -336,3 +336,35 @@ fn redirect_with_session(location: &str, session: &str) -> 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,
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 time::OffsetDateTime;
@@ -67,6 +69,13 @@ pub struct AppState {
/// In-process (no subprocess); supports HTTP Range requests on
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
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 /
/// Preseed / Autoinstall / Windows answer files). Served on demand to
/// booting clients with per-host hostname/IP/MAC templating; lives in
+121 -10
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,
"smb" => smb_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),
"whoami" => Ok("operator".to_string()),
"echo" => Ok(tail.join(" ")),
@@ -118,17 +120,21 @@ fn status_text(s: &AppState) -> String {
// v0.4.67: NFSv3 sources too.
let nfs_shares = s.nfs_shares.list();
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!(
"OpenPXE {ver}\n\
base url: {base}\n\
interface: {nic}\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\
queue: {n_entries}\n\
smb server: {smb}\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"),
base = s.public_base_url,
nic = if s.nic_name.is_empty() {
@@ -150,6 +156,10 @@ fn status_text(s: &AppState) -> String {
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
.count(),
n_sftp = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Sftp { .. }))
.count(),
n_clients = clients.len(),
n_entries = queue_entries.len(),
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_nfs_total = nfs_shares.len(),
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}"),
// v0.4.67: NFSv3 via in-process nfs3_client.
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!(
out,
@@ -321,9 +335,7 @@ async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
}
Some("add") => {
// share add //server/share [guest|user:password]
let target = args
.get(1)
.ok_or_else(|| {
let target = args.get(1).ok_or_else(|| {
"usage: share add //server/share [guest|user:password]".to_string()
})?;
// Accept either `//server/share` (UNC-style) or
@@ -401,11 +413,7 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
return Ok("(no NFS shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} TARGET",
"ID", "STATUS", "ISOS"
);
let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
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 ────────────────────────────────────────────────────────────────
#[allow(clippy::unused_async)]
@@ -622,6 +728,11 @@ OpenPXE terminal — available commands:
nfs remove <id> forget an NFS share
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 start | stop | reload control the outbound smbd
+12 -8
View File
@@ -9,6 +9,7 @@
use bytes::Bytes;
use openpxe_core::{Error, Result};
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
use parking_lot::RwLock;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
@@ -19,7 +20,11 @@ const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
#[derive(Clone, Default)]
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 {
@@ -67,8 +72,7 @@ impl UploadSessions {
};
self.inner
.lock()
.await
.write()
.insert(upload_id.clone(), Arc::new(Mutex::new(session)));
Ok(UploadStarted {
@@ -88,7 +92,7 @@ impl UploadSessions {
chunk: Bytes,
complete: bool,
) -> 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}'")));
};
@@ -119,7 +123,7 @@ impl UploadSessions {
if let Err(e) = handle.write_chunk(&chunk).await {
let handle = session.handle.take();
drop(session);
self.inner.lock().await.remove(upload_id);
self.inner.write().remove(upload_id);
if let Some(handle) = handle {
let _ = handle.abort().await;
}
@@ -156,11 +160,11 @@ impl UploadSessions {
let meta = match handle.finish(store).await {
Ok(meta) => meta,
Err(e) => {
self.inner.lock().await.remove(upload_id);
self.inner.write().remove(upload_id);
return Err(e);
}
};
self.inner.lock().await.remove(upload_id);
self.inner.write().remove(upload_id);
Ok(UploadAppend::Complete {
offset: new_offset,
iso: Box::new(meta),
@@ -168,7 +172,7 @@ impl UploadSessions {
}
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}'")));
};
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 openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
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 tower::ServiceExt;
@@ -96,6 +96,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let settings = SettingsStore::load_or_default(dir.path());
let smb_shares = SmbShareManager::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"));
unattended.ensure_dir().await.unwrap();
let log_bus = LogBus::new(64);
@@ -124,6 +125,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
smb: None,
smb_shares,
nfs_shares,
sftp_shares,
unattended,
uploads: openpxe_http_api::uploads::UploadSessions::default(),
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>) {
let res = router
.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.
nfs3_client = { 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
# NFS read-loop bytes into axum's Body::from_stream.
futures = { workspace = true }
+8
View File
@@ -20,6 +20,7 @@ pub mod entry;
pub mod introspect;
pub mod nfs_share;
pub mod pxe_logo;
pub mod sftp_share;
pub mod smb;
pub mod smb_share;
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
// Range requests because NFSv3 READ3 takes an explicit offset.
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::{
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
/// any container" property as SMB, plus Range requests work because
/// 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)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource {
@@ -42,6 +46,13 @@ pub enum IsoSource {
/// Filename at the export root.
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.
@@ -299,11 +310,11 @@ impl IsoStore {
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
// kind first and dispatch to the appropriate share
// 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) {
let mut g = self.inner.write();
g.isos.retain(|_, m| match &m.source {
IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => {
sid != share_id
}
IsoSource::Smb { share_id: sid, .. }
| IsoSource::Nfs { share_id: sid, .. }
| IsoSource::Sftp { share_id: sid, .. } => sid != share_id,
IsoSource::Local => true,
});
}
@@ -659,7 +670,10 @@ mod tests {
// good.
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
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("ip=dhcp"), "{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_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 std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
@@ -59,11 +59,10 @@ async fn main() -> anyhow::Result<()> {
init_tracing(log_bus.clone());
let cli = Cli::parse();
let mut config = match &cli.config {
Some(p) if p.exists() => Config::from_toml_file(p)?,
_ => Config::default(),
};
config.apply_env();
// v0.5.4: layered load via figment — defaults → optional TOML → env.
// The OPENPXE_* env layer keeps the historical flat names (see
// `Config::load`), so existing deployments are unaffected.
let config = Config::load(cli.config.as_deref())?;
// Dispatch subcommands before bringing up the server.
if let Some(cmd) = cli.command {
@@ -101,8 +100,7 @@ async fn main() -> anyhow::Result<()> {
iso_store.load_from_disk().await?;
// v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/
// Windows answer files). Separate directory from the ISO store.
let unattended =
openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
let unattended = openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
if let Err(e) = unattended.load_from_disk().await {
tracing::warn!(
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
// 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
@@ -184,6 +195,7 @@ async fn main() -> anyhow::Result<()> {
smb: Some(smb.clone()),
smb_shares: smb_shares.clone(),
nfs_shares: nfs_shares.clone(),
sftp_shares: sftp_shares.clone(),
unattended: unattended.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(),
+124 -13
View File
@@ -291,11 +291,11 @@
el('div', {class: 'trend'},
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
// v0.4.67: count both protocols. Label generically since
// operators may be using one, the other, or both.
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) +
// v0.4.67+v0.5.5: count all remote-share protocols. Label
// generically since operators may use any mix of SMB/NFS/SFTP.
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) +
' 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: 'label'}, 'Uptime'),
@@ -508,10 +508,11 @@
// v0.4.67: NFSv3 added back as an in-process Rust client
// (nfs3_client crate). Both protocols available side-by-side;
// operators pick whichever their NAS prefers.
const [isos, settings, smbRes, nfsRes, disk, unattRes] = await Promise.all([
const [isos, settings, smbRes, nfsRes, sftpRes, disk, unattRes] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'),
getJSON('/api/nfs-shares'),
getJSON('/api/sftp-shares'),
getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})),
@@ -519,6 +520,7 @@
]);
const shares = smbRes.shares || [];
const nfsShares = nfsRes.shares || [];
const sftpShares = sftpRes.shares || [];
const unattendedFiles = unattRes.files || [];
// ── Upload card ──
@@ -829,6 +831,7 @@
const protoSelect = el('select', {}, [
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
el('option', {value:'smb'}, 'SMB / CIFS'),
el('option', {value:'sftp'}, 'SFTP (SSH)'),
]);
// SMB inputs.
@@ -893,11 +896,58 @@
]),
]);
// 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.
const syncProto = () => {
const nfs = protoSelect.value === 'nfs';
smbFields.style.display = nfs ? 'none' : '';
nfsFields.style.display = nfs ? '' : 'none';
const p = protoSelect.value;
smbFields.style.display = p === 'smb' ? '' : 'none';
nfsFields.style.display = p === 'nfs' ? '' : 'none';
sftpFields.style.display = p === 'sftp' ? '' : 'none';
shareMsg.replaceChildren();
shareMsg.className = 'msg';
};
@@ -924,6 +974,40 @@
shareMsg.className = 'msg ok';
render('storage');
} 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 {
if (!nfsServerIn.value || !nfsExportIn.value) {
shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
@@ -962,9 +1046,36 @@
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
? [...smbRowEls, ...nfsRowEls]
? [...smbRowEls, ...nfsRowEls, ...sftpRowEls]
: [el('div', {class:'empty'}, 'No remote shares configured.')];
syncProto();
@@ -1071,13 +1182,13 @@
]),
el('span'),
]),
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields]),
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields, sftpFields]),
addShare, shareMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
el('p', {class:'msg', style:'margin-top:14px'},
'Remote .iso libraries are read on demand — no local cache to ' +
'preserve disk usage. Support for NFS 3.0 and SMB. Ensure that ' +
'the hosts IP address is provisioned.'),
'preserve disk usage. Support for NFS 3.0, SMB, and SFTP (SSH). ' +
'Ensure that the hosts IP address is provisioned.'),
]),
]),
el('div', {class:'card'}, [