Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f12a2ae84 | ||
|
|
3f9d8568f0 | ||
|
|
9f66c269c4 |
Generated
+42
-8
@@ -1102,6 +1102,37 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nfs3_client"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ac227029a6127f3474b4fb61cbf2d3dddbe9424ad50c24fada68b633687a532"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"nfs3_types",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nfs3_macros"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06d8fb377e6efeb91911a8ed962a69615535e167e675025a2be8fa109751426a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nfs3_types"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5d5ab1a9fdfeab2f03b36b6300dea4ea3806e6a057cb479628b41d65e356b94"
|
||||
dependencies = [
|
||||
"nfs3_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1140,7 +1171,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openpxe"
|
||||
version = "0.4.65"
|
||||
version = "0.4.68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1162,7 +1193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-core"
|
||||
version = "0.4.65"
|
||||
version = "0.4.68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
@@ -1181,7 +1212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-dhcp-proxy"
|
||||
version = "0.4.65"
|
||||
version = "0.4.68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -1195,7 +1226,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-http-api"
|
||||
version = "0.4.65"
|
||||
version = "0.4.68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1226,7 +1257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-ipxe-assets"
|
||||
version = "0.4.65"
|
||||
version = "0.4.68"
|
||||
dependencies = [
|
||||
"openpxe-core",
|
||||
"rust-embed",
|
||||
@@ -1236,14 +1267,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-iso-store"
|
||||
version = "0.4.65"
|
||||
version = "0.4.68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
"bytes",
|
||||
"futures",
|
||||
"hex",
|
||||
"image",
|
||||
"libc",
|
||||
"nfs3_client",
|
||||
"nfs3_types",
|
||||
"openpxe-core",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
@@ -1260,7 +1294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-tftp"
|
||||
version = "0.4.65"
|
||||
version = "0.4.68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -1274,7 +1308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-webui"
|
||||
version = "0.4.65"
|
||||
version = "0.4.68"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
|
||||
+9
-1
@@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.65"
|
||||
version = "0.4.68"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -58,6 +58,14 @@ once_cell = "1.19"
|
||||
parking_lot = "0.12"
|
||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||
|
||||
# v0.4.67: pure-Rust NFSv3 client. Replaces the (deleted-in-v0.4.65)
|
||||
# kernel-mount NFS path with an in-process implementation that works
|
||||
# in any container — no kernel modules, no CAP_SYS_ADMIN, no
|
||||
# subprocess. Ships alongside the userspace SMB consumer; operators
|
||||
# pick whichever protocol their NAS prefers.
|
||||
nfs3_client = { version = "0.9", features = ["tokio"] }
|
||||
nfs3_types = "0.5"
|
||||
|
||||
openpxe-core = { path = "crates/core" }
|
||||
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
|
||||
openpxe-tftp = { path = "crates/tftp" }
|
||||
|
||||
@@ -44,6 +44,14 @@ struct Inner {
|
||||
/// MIME of the active logo, mirroring `logo_filename`. Cached here
|
||||
/// so the HTTP layer can set Content-Type without re-sniffing.
|
||||
logo_mime: Option<String>,
|
||||
/// Monotonic counter bumped on every set/clear. Surfaces as a
|
||||
/// cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser
|
||||
/// fetches the new bytes the moment the operator swaps the logo —
|
||||
/// the app version alone can't do this since it doesn't change on
|
||||
/// upload. Persisted so the token stays stable across restarts and
|
||||
/// keeps climbing across multiple swaps.
|
||||
#[serde(default)]
|
||||
rev: u64,
|
||||
}
|
||||
|
||||
/// In-memory + on-disk override registry. Cheap to clone; locks are
|
||||
@@ -150,6 +158,7 @@ impl BrandingStore {
|
||||
let mut g = self.inner.write();
|
||||
g.logo_filename = Some(filename.clone());
|
||||
g.logo_mime = Some(mime.to_string());
|
||||
g.rev = g.rev.wrapping_add(1);
|
||||
}
|
||||
self.persist();
|
||||
tracing::info!(
|
||||
@@ -166,6 +175,7 @@ impl BrandingStore {
|
||||
let mut g = self.inner.write();
|
||||
let removed = g.logo_filename.take();
|
||||
g.logo_mime = None;
|
||||
g.rev = g.rev.wrapping_add(1);
|
||||
removed
|
||||
};
|
||||
if let Some(name) = removed {
|
||||
@@ -185,6 +195,14 @@ impl BrandingStore {
|
||||
self.inner.read().logo_filename.is_some()
|
||||
}
|
||||
|
||||
/// Cache-bust token for the logo asset URL. Changes on every
|
||||
/// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL
|
||||
/// whenever the operator swaps the brand mark. Stable otherwise.
|
||||
#[must_use]
|
||||
pub fn logo_rev(&self) -> u64 {
|
||||
self.inner.read().rev
|
||||
}
|
||||
|
||||
fn persist(&self) {
|
||||
let snap = self.inner.read().clone();
|
||||
let body = match serde_json::to_vec_pretty(&snap) {
|
||||
@@ -292,6 +310,23 @@ mod tests {
|
||||
assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logo_rev_bumps_on_each_set_and_clear() {
|
||||
let dir = tempdir().unwrap();
|
||||
let b = BrandingStore::load_or_default(dir.path());
|
||||
assert_eq!(b.logo_rev(), 0);
|
||||
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
|
||||
assert_eq!(b.logo_rev(), 1);
|
||||
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake2").unwrap();
|
||||
assert_eq!(b.logo_rev(), 2);
|
||||
b.clear_logo().unwrap();
|
||||
assert_eq!(b.logo_rev(), 3);
|
||||
// Survives a restart.
|
||||
drop(b);
|
||||
let b2 = BrandingStore::load_or_default(dir.path());
|
||||
assert_eq!(b2.logo_rev(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ext_strips_separators_and_path_chars() {
|
||||
assert_eq!(sanitize_ext("svg"), "svg");
|
||||
@@ -319,6 +354,7 @@ mod tests {
|
||||
let inner = Inner {
|
||||
logo_filename: Some("logo.png".into()),
|
||||
logo_mime: Some("image/png".into()),
|
||||
rev: 0,
|
||||
};
|
||||
std::fs::write(
|
||||
brand_dir.join("branding.json"),
|
||||
|
||||
+149
-15
@@ -35,7 +35,7 @@ use openpxe_core::{
|
||||
MAX_LOGO_BYTES,
|
||||
};
|
||||
use openpxe_ipxe_assets::asset_bytes;
|
||||
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, SmbAddRequest};
|
||||
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
@@ -141,6 +141,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add))
|
||||
.route("/api/smb-shares/:id", delete(api_smb_shares_remove))
|
||||
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
|
||||
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
|
||||
// Ships alongside SMB. Routes are parallel so the UI can
|
||||
// reuse the same form/error/hint rendering for both.
|
||||
.route("/api/nfs-shares", get(api_nfs_shares_list).post(api_nfs_shares_add))
|
||||
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
|
||||
.route("/api/nfs-shares/:id/scan", post(api_nfs_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.
|
||||
@@ -202,7 +208,14 @@ async fn index(State(state): State<AppState>) -> Response {
|
||||
// browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE
|
||||
// binary version — every release ships a new value, every release
|
||||
// forces a fresh URL on each asset.
|
||||
let html = openpxe_webui::index_html(&state.public_base_url, env!("CARGO_PKG_VERSION"));
|
||||
// logo_rev cache-busts the brand mark / favicon independently of
|
||||
// the release version, so an operator who swaps the custom logo
|
||||
// sees it update on the next reload without waiting for an upgrade.
|
||||
let html = openpxe_webui::index_html(
|
||||
&state.public_base_url,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
state.branding.logo_rev(),
|
||||
);
|
||||
(
|
||||
[
|
||||
(
|
||||
@@ -696,6 +709,58 @@ async fn iso_raw(
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, format!("smb stream: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
IsoSource::Nfs {
|
||||
share_id,
|
||||
relative_path,
|
||||
} => {
|
||||
// v0.4.67: NFS sources support Range requests because
|
||||
// NFSv3 READ3 takes an explicit offset. We resolve the
|
||||
// requested byte range here and pass start/len down to
|
||||
// the streamer which seeks into the file via READ3.
|
||||
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
|
||||
.nfs_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!("nfs stream: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1095,6 +1160,19 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"summary": "Re-list a share for new ISOs."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "NFS shares",
|
||||
"endpoints": [
|
||||
{"method": "GET", "path": "/api/nfs-shares",
|
||||
"summary": "List configured NFSv3 shares with connection state and iso counts."},
|
||||
{"method": "POST", "path": "/api/nfs-shares",
|
||||
"summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."},
|
||||
{"method": "DELETE", "path": "/api/nfs-shares/:id",
|
||||
"summary": "Forget a share and drop its entries from the ISO store."},
|
||||
{"method": "POST", "path": "/api/nfs-shares/:id/scan",
|
||||
"summary": "Re-list a share for new ISOs."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Network",
|
||||
"endpoints": [
|
||||
@@ -1509,11 +1587,13 @@ async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Val
|
||||
|
||||
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let smb = state.smb.as_ref().map(|s| s.snapshot());
|
||||
// v0.4.65: NFS replaced with SMB shares. The dashboard metric
|
||||
// shape stays similar (count + reachable) so the UI doesn't have
|
||||
// to change its top-line tiles.
|
||||
// v0.4.65+v0.4.67: external storage shares — SMB (userspace
|
||||
// smbclient) and NFS (in-process nfs3_client). Dashboard tile
|
||||
// sums both so operators see a single "reachable shares" number.
|
||||
let smb_shares = state.smb_shares.list();
|
||||
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();
|
||||
let isos = state.iso_store.list();
|
||||
let clients = state.clients.list();
|
||||
let queue_entries = state.queue.list();
|
||||
@@ -1532,7 +1612,9 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
state
|
||||
.metrics
|
||||
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
||||
state.metrics.set_nfs_active(smb_reachable as u64);
|
||||
state
|
||||
.metrics
|
||||
.set_nfs_active((smb_reachable + nfs_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);
|
||||
@@ -1547,12 +1629,13 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
|
||||
"settings": state.settings.snapshot(),
|
||||
"smb": smb,
|
||||
// Keep the field names for now so existing UI bindings on
|
||||
// `iso_count2` / `client_count2` / sidebar counters keep
|
||||
// working. They cover "external storage shares" generically;
|
||||
// v0.4.65 the source is SMB instead of NFS.
|
||||
"smb_share_count": smb_shares.len(),
|
||||
"smb_share_reachable": smb_reachable,
|
||||
// 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_logo(),
|
||||
"uptime_secs": uptime_secs,
|
||||
@@ -1808,6 +1891,47 @@ async fn api_smb_shares_scan(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NFS share API (v0.4.67) ───────────────────────────────────────────────
|
||||
//
|
||||
// Parallel to the SMB shares API. The pure-Rust NFSv3 client
|
||||
// (`nfs3_client`) gives us in-process listing and streaming, no
|
||||
// subprocess. Unlike SMB, NFS-sourced ISOs support HTTP Range
|
||||
// requests — NFSv3 READ3 takes an explicit offset.
|
||||
|
||||
async fn api_nfs_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({ "shares": state.nfs_shares.list() }))
|
||||
}
|
||||
|
||||
async fn api_nfs_shares_add(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<NfsAddRequest>,
|
||||
) -> Response {
|
||||
match state.nfs_shares.add(req).await {
|
||||
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
|
||||
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_nfs_shares_remove(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.nfs_shares.remove(&id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_nfs_shares_scan(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.nfs_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> {
|
||||
@@ -1922,11 +2046,21 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
|
||||
state
|
||||
.metrics
|
||||
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
||||
// v0.4.65: gauge tracks reachable external-storage shares. With
|
||||
// NFS removed it now reflects SMB share reachability instead.
|
||||
state
|
||||
.metrics
|
||||
.set_nfs_active(state.smb_shares.list().iter().filter(|m| m.reachable).count() as u64);
|
||||
// v0.4.67: gauge tracks reachable external-storage shares
|
||||
// across both protocols (SMB userspace + NFSv3 in-process).
|
||||
let smb_ok = state
|
||||
.smb_shares
|
||||
.list()
|
||||
.iter()
|
||||
.filter(|m| m.reachable)
|
||||
.count();
|
||||
let nfs_ok = state
|
||||
.nfs_shares
|
||||
.list()
|
||||
.iter()
|
||||
.filter(|m| m.reachable)
|
||||
.count();
|
||||
state.metrics.set_nfs_active((smb_ok + nfs_ok) as u64);
|
||||
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
|
||||
|
||||
@@ -4,7 +4,7 @@ use openpxe_core::{
|
||||
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
||||
Metrics, SettingsStore, SsoStore,
|
||||
};
|
||||
use openpxe_iso_store::{IsoStore, SmbManager, SmbShareManager};
|
||||
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -52,6 +52,12 @@ pub struct AppState {
|
||||
/// `smbclient` does the SMB protocol over a plain TCP socket in
|
||||
/// userspace — works in any container, no special caps required.
|
||||
pub smb_shares: SmbShareManager,
|
||||
/// v0.4.67: NFSv3 share manager — pure-Rust userspace consumer
|
||||
/// via the `nfs3_client` crate. Ships alongside the SMB manager
|
||||
/// so operators pick whichever protocol their NAS prefers.
|
||||
/// In-process (no subprocess); supports HTTP Range requests on
|
||||
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
|
||||
pub nfs_shares: NfsShareManager,
|
||||
/// Browser chunked upload state. Multipart uploads still go straight
|
||||
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
|
||||
/// can show deterministic progress and leave visible partial files.
|
||||
|
||||
@@ -88,12 +88,14 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
|
||||
"isos" | "images" => Ok(isos_text(state)),
|
||||
"clients" => Ok(clients_text(state)),
|
||||
"queue" => queue_command(state, tail).await,
|
||||
// v0.4.65: `nfs` is gone — replaced with userspace SMB share
|
||||
// consumer. `smb` still controls the outbound Samba server
|
||||
// for Windows install media; `share` lists/manages remote SMB
|
||||
// shares OpenPXE pulls ISOs from.
|
||||
// `smb` controls the outbound Samba server for Windows
|
||||
// install media. `share` lists/manages remote SMB shares
|
||||
// OpenPXE pulls ISOs from (v0.4.65). `nfs` is the parallel
|
||||
// command for remote NFSv3 shares (v0.4.67, in-process via
|
||||
// nfs3_client — not the v0.4.64 kernel-mount path).
|
||||
"share" | "smb-share" => smb_share_command(state, tail).await,
|
||||
"smb" => smb_command(state, tail).await,
|
||||
"nfs" => nfs_share_command(state, tail).await,
|
||||
"log" => log_command(state, tail),
|
||||
"whoami" => Ok("operator".to_string()),
|
||||
"echo" => Ok(tail.join(" ")),
|
||||
@@ -113,16 +115,20 @@ fn status_text(s: &AppState) -> String {
|
||||
let smb = s.smb.as_ref().map(|m| m.snapshot());
|
||||
let smb_shares = s.smb_shares.list();
|
||||
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
|
||||
// v0.4.67: NFSv3 sources too.
|
||||
let nfs_shares = s.nfs_shares.list();
|
||||
let nfs_reachable = nfs_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})\n\
|
||||
isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs})\n\
|
||||
clients: {n_clients}\n\
|
||||
queue: {n_entries}\n\
|
||||
smb server: {smb}\n\
|
||||
smb shares: {n_total} configured ({n_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",
|
||||
ver = env!("CARGO_PKG_VERSION"),
|
||||
base = s.public_base_url,
|
||||
nic = if s.nic_name.is_empty() {
|
||||
@@ -140,11 +146,17 @@ fn status_text(s: &AppState) -> String {
|
||||
.iter()
|
||||
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. }))
|
||||
.count(),
|
||||
n_nfs = isos
|
||||
.iter()
|
||||
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
|
||||
.count(),
|
||||
n_clients = clients.len(),
|
||||
n_entries = queue_entries.len(),
|
||||
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
|
||||
n_total = smb_shares.len(),
|
||||
n_active = smb_reachable,
|
||||
n_smb_total = smb_shares.len(),
|
||||
n_smb_active = smb_reachable,
|
||||
n_nfs_total = nfs_shares.len(),
|
||||
n_nfs_active = nfs_reachable,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,8 +174,9 @@ fn isos_text(s: &AppState) -> String {
|
||||
for i in isos {
|
||||
let src = match i.source {
|
||||
openpxe_iso_store::IsoSource::Local => "local".to_string(),
|
||||
// v0.4.65: SMB userspace consumer replaced kernel-mount NFS.
|
||||
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}"),
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
@@ -378,6 +391,91 @@ async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
// ── nfs (v0.4.67: in-process NFSv3 via nfs3_client) ────────────────────
|
||||
|
||||
async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let shares = s.nfs_shares.list();
|
||||
if shares.is_empty() {
|
||||
return Ok("(no NFS 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.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") => {
|
||||
// nfs add <server>:<export> [port]
|
||||
let target = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs add <server>:<export> [port]".to_string())?;
|
||||
let (server, export) = target
|
||||
.split_once(':')
|
||||
.ok_or_else(|| "target must be 'server:/export'".to_string())?;
|
||||
let port = args.get(2).and_then(|s| s.parse::<u16>().ok());
|
||||
let req = openpxe_iso_store::NfsAddRequest {
|
||||
server: server.to_string(),
|
||||
export: export.to_string(),
|
||||
port,
|
||||
};
|
||||
match s.nfs_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: nfs remove <id>".to_string())?;
|
||||
match s.nfs_shares.remove(id).await {
|
||||
Ok(()) => Ok(format!("removed {id}")),
|
||||
Err(e) => Err(format!("remove failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some("scan") => {
|
||||
let id = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs scan <id>".to_string())?;
|
||||
match s.nfs_shares.rescan(id).await {
|
||||
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
|
||||
Err(e) => Err(format!("scan failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some(other) => Err(format!(
|
||||
"unknown nfs subcommand: {other}\ntry: nfs [list|add|remove|scan]"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── smb ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
@@ -519,6 +617,11 @@ OpenPXE terminal — available commands:
|
||||
share remove <id> forget an SMB share
|
||||
share scan <id> re-list a share for new ISOs
|
||||
|
||||
nfs list list configured NFSv3 shares
|
||||
nfs add <srv>:<export> [port] add an NFSv3 share
|
||||
nfs remove <id> forget an NFS share
|
||||
nfs scan <id> re-list an NFS share for new ISOs
|
||||
|
||||
smb status outbound Samba state (Windows install media)
|
||||
smb start | stop | reload control the outbound smbd
|
||||
|
||||
|
||||
@@ -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, SmbShareManager};
|
||||
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbShareManager};
|
||||
use tempfile::tempdir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -95,6 +95,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
let queue = DeploymentQueue::new();
|
||||
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 log_bus = LogBus::new(64);
|
||||
let hosts = HostBindings::load_or_default(dir.path());
|
||||
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
|
||||
@@ -117,6 +118,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
metrics,
|
||||
smb: None,
|
||||
smb_shares,
|
||||
nfs_shares,
|
||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||
log_bus,
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
@@ -531,6 +533,54 @@ async fn smb_shares_list_starts_empty() {
|
||||
assert_eq!(v["shares"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// v0.4.67: NFSv3 share manager (parallel to SMB).
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_shares_list_starts_empty() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = get(&app, "/api/nfs-shares").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["shares"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_shares_add_rejects_missing_server() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = post_json(
|
||||
&app,
|
||||
"/api/nfs-shares",
|
||||
r#"{"server":"","export":"/srv/isos"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let msg = String::from_utf8_lossy(&b);
|
||||
assert!(
|
||||
msg.to_lowercase().contains("server"),
|
||||
"expected server hint, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_shares_add_rejects_export_without_leading_slash() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = post_json(
|
||||
&app,
|
||||
"/api/nfs-shares",
|
||||
r#"{"server":"10.0.0.5","export":"srv/isos"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let msg = String::from_utf8_lossy(&b);
|
||||
assert!(
|
||||
msg.to_lowercase().contains("export"),
|
||||
"expected export-path hint, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_help_and_status_round_trip() {
|
||||
let (state, _dir) = build_state().await;
|
||||
|
||||
@@ -35,5 +35,13 @@ libc = "0.2"
|
||||
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
|
||||
|
||||
# v0.4.67: pure-Rust NFSv3 client for reading remote ISOs without a
|
||||
# kernel mount. See crates/iso-store/src/nfs_share.rs for usage.
|
||||
nfs3_client = { workspace = true }
|
||||
nfs3_types = { 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 }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.12"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
pub mod entry;
|
||||
pub mod introspect;
|
||||
pub mod nfs_share;
|
||||
pub mod pxe_logo;
|
||||
pub mod smb;
|
||||
pub mod smb_share;
|
||||
@@ -33,6 +34,11 @@ pub use introspect::{DistroFamily, IntrospectionReport};
|
||||
// it.
|
||||
pub use smb::{extract_windows_iso, SmbManager, SmbState};
|
||||
pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, SmbStream};
|
||||
// v0.4.67: NFS is back — this time as an in-process userspace NFSv3
|
||||
// client (the `nfs3_client` crate) rather than a kernel mount. Same
|
||||
// "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};
|
||||
pub use store::{
|
||||
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
|
||||
UploadHandle,
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
//! NFSv3 share consumer — in-process userspace client.
|
||||
//!
|
||||
//! v0.4.67 brings NFS back, this time *the right way*: a pure-Rust
|
||||
//! NFSv3 client (`nfs3_client` from the xetdata family of crates)
|
||||
//! that speaks the NFS protocol entirely in userspace over TCP. No
|
||||
//! `mount.nfs`, no kernel modules, no `CAP_SYS_ADMIN`. Works in every
|
||||
//! container the SMB path (v0.4.65) works in, including Unraid and
|
||||
//! restricted-SCC OpenShift.
|
||||
//!
|
||||
//! ## How it differs from the v0.4.64 NFS path
|
||||
//!
|
||||
//! v0.4.64 shelled out to `mount(8)` and asked the kernel to attach
|
||||
//! the remote share to the local filesystem. That required nfs client
|
||||
//! modules on the **host** kernel. Containers that ran on hosts
|
||||
//! without those modules — Unraid being the dominant case — failed
|
||||
//! with `mount.nfs: failed to apply fstab options` no matter what
|
||||
//! capabilities they were granted.
|
||||
//!
|
||||
//! v0.4.67 doesn't touch the kernel. The client opens a TCP socket to
|
||||
//! the NFS server, performs the MOUNT3 RPC to get a root file handle,
|
||||
//! and uses NFSv3 READDIR3 / LOOKUP3 / READ3 / GETATTR3 to walk the
|
||||
//! share and stream files. The kernel sees plain TCP traffic, nothing
|
||||
//! else.
|
||||
//!
|
||||
//! ## How it differs from the v0.4.65 SMB path
|
||||
//!
|
||||
//! SMB ships in v0.4.65 via the `smbclient` CLI subprocess. NFS ships
|
||||
//! here via a Rust crate — no subprocess, no PATH lookup, no parsing
|
||||
//! of human-formatted output. That also means:
|
||||
//!
|
||||
//! - **Range requests work** for NFS-sourced ISOs. NFSv3 READ3
|
||||
//! takes an explicit offset, so the HTTP handler can seek into
|
||||
//! the middle of a 5 GB ISO without downloading what comes
|
||||
//! before. SMB-sourced ISOs still return 416 for ranges because
|
||||
//! `smbclient -c 'get file -'` is a sequential stream.
|
||||
//! - **Introspection could work** (we could LOOKUP the ISO9660 PVD
|
||||
//! at offset 0x8000 and parse the volume label). For v0.4.67 we
|
||||
//! don't yet — same as SMB, NFS-sourced ISOs register with an
|
||||
//! `Unknown` family and fall back to generic sanboot. A follow-up
|
||||
//! can add bounded read-based detection.
|
||||
//!
|
||||
//! ## How it's the same as SMB
|
||||
//!
|
||||
//! The public surface is deliberately parallel: `NfsShareManager`
|
||||
//! mirrors `SmbShareManager`, `NfsShare` mirrors `SmbShare`, the API
|
||||
//! responses use the same `{error, stderr, hint}` shape so the UI's
|
||||
//! storage tab renders both protocols through one code path. The
|
||||
//! state file (`<work_dir>/nfs_shares.json`) sits next to
|
||||
//! `smb_shares.json`.
|
||||
//!
|
||||
//! ## No auth
|
||||
//!
|
||||
//! NFSv3 uses AUTH_SYS by default — the client tells the server "I'm
|
||||
//! uid X, gid Y" and the server decides whether to trust that. Most
|
||||
//! NAS appliances exposing ISO libraries via NFS gate access by
|
||||
//! **client IP address** rather than uid, so there's nothing for the
|
||||
//! UI to ask for. (If a future server needs Kerberos or non-default
|
||||
//! uid mapping we can add those, but for ISO read access nobody does.)
|
||||
|
||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||
use bytes::Bytes;
|
||||
use nfs3_client::tokio::TokioConnector;
|
||||
use nfs3_client::Nfs3ConnectionBuilder;
|
||||
use nfs3_types::nfs3::{
|
||||
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args,
|
||||
Nfs3Result, READ3args, READDIR3args,
|
||||
};
|
||||
use nfs3_types::xdr_codec::Opaque;
|
||||
use openpxe_core::{Error, Result};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// Default TCP port for the NFSv3 protocol. The classic split (111
|
||||
/// portmapper + dynamic mountd) is supported by the underlying crate;
|
||||
/// for direct connection-mode NAS appliances 2049 is what answers.
|
||||
const DEFAULT_NFS_PORT: u16 = 2049;
|
||||
|
||||
/// How long we wait for the initial MOUNT3 + portmap handshake before
|
||||
/// giving up. Mirrors the SMB pre-flight probe shape so the UI's
|
||||
/// timeout banner reads consistently across protocols.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
/// NFSv3 READ3 chunk size. The protocol's hard cap is 1 MiB per
|
||||
/// reply; 64 KiB is a polite default that nearly every server hands
|
||||
/// back without fragmentation and keeps in-flight memory bounded
|
||||
/// during a streaming response.
|
||||
const READ_CHUNK_BYTES: u32 = 64 * 1024;
|
||||
|
||||
/// Bound on the in-flight queue between the read-loop task and the
|
||||
/// HTTP body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream —
|
||||
/// enough to keep the network pipe full without letting a slow
|
||||
/// client park gigabytes of decoded ISO in RAM.
|
||||
const STREAM_BUFFER_DEPTH: usize = 16;
|
||||
|
||||
/// One configured NFS share. The id is derived from server+export so
|
||||
/// re-adding the same coordinates is idempotent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NfsShare {
|
||||
pub id: String,
|
||||
pub server: String,
|
||||
/// Export path on the server (e.g. "/srv/isos"). Must start with
|
||||
/// "/" to match the NFS server's view; we validate on add.
|
||||
pub export: String,
|
||||
/// TCP port — 2049 unless the operator overrode it.
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
/// Most recent error talking to the share, or `None` on success.
|
||||
pub last_error: Option<String>,
|
||||
/// Operator-friendly translation of `last_error`. None when we
|
||||
/// don't have a friendlier rendition.
|
||||
pub last_hint: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_scan: Option<OffsetDateTime>,
|
||||
pub iso_count: u32,
|
||||
/// True after a successful scan, false on failure. Drives the
|
||||
/// UI dot.
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
/// Submission from the UI / API.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NfsAddRequest {
|
||||
pub server: String,
|
||||
pub export: String,
|
||||
#[serde(default)]
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
DEFAULT_NFS_PORT
|
||||
}
|
||||
|
||||
/// Structured error surfaced to the API and rendered in the UI. Same
|
||||
/// shape as `SmbShareError` so the storage tab uses one rendering
|
||||
/// path for both protocols.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NfsShareError {
|
||||
pub error: String,
|
||||
pub stderr: String,
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
impl NfsShareError {
|
||||
fn from_raw(error: impl Into<String>, stderr: impl Into<String>) -> Self {
|
||||
let stderr = stderr.into();
|
||||
let error = error.into();
|
||||
let hint = hint_for(&stderr).or_else(|| hint_for(&error));
|
||||
Self {
|
||||
error,
|
||||
stderr,
|
||||
hint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
shares: HashMap<String, NfsShare>,
|
||||
}
|
||||
|
||||
/// Manages NFS shares. Cheap to clone — internal state is
|
||||
/// `Arc<Mutex<...>>`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NfsShareManager {
|
||||
state_path: Arc<PathBuf>,
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
iso_store: IsoStore,
|
||||
/// Serializes scan operations on the same manager. Each scan
|
||||
/// opens its own NFS connection so concurrency isn't a hard
|
||||
/// requirement, but serializing keeps log output predictable.
|
||||
op_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl NfsShareManager {
|
||||
/// Construct a manager. State persists to
|
||||
/// `<work_dir>/nfs_shares.json`.
|
||||
#[must_use]
|
||||
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
|
||||
let state_path = work_dir.join("nfs_shares.json");
|
||||
Self {
|
||||
state_path: Arc::new(state_path),
|
||||
inner: Arc::new(Mutex::new(Inner::default())),
|
||||
iso_store,
|
||||
op_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load persisted state and re-scan every share. Per-share
|
||||
/// failures are logged but never propagated — startup must not
|
||||
/// block on a single offline server.
|
||||
pub async fn load_and_rescan(&self) -> Result<()> {
|
||||
let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await {
|
||||
Ok(text) => serde_json::from_str::<Vec<NfsShare>>(&text).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
for mut s in shares {
|
||||
s.last_error = None;
|
||||
s.last_hint = None;
|
||||
s.reachable = false;
|
||||
self.inner.lock().shares.insert(s.id.clone(), s.clone());
|
||||
if let Err(e) = self.rescan_inner(&s.id).await {
|
||||
tracing::warn!(
|
||||
target: "openpxe::nfs",
|
||||
id = %s.id, server = %s.server, export = %s.export,
|
||||
"rescan on startup failed: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register an NFS share. Validates, probes connectivity by
|
||||
/// performing a real MOUNT3 + READDIR3, and registers the
|
||||
/// resulting ISOs with the store.
|
||||
pub async fn add(
|
||||
&self,
|
||||
req: NfsAddRequest,
|
||||
) -> std::result::Result<NfsShare, NfsShareError> {
|
||||
let server = normalize_server(&req.server);
|
||||
let export = req.export.trim().to_string();
|
||||
if server.is_empty() {
|
||||
return Err(NfsShareError::from_raw("server is required", ""));
|
||||
}
|
||||
if !export.starts_with('/') {
|
||||
return Err(NfsShareError::from_raw(
|
||||
"export path must start with '/' (e.g. /srv/isos)",
|
||||
"",
|
||||
));
|
||||
}
|
||||
if server.contains('\0') || export.contains('\0') {
|
||||
return Err(NfsShareError::from_raw("NUL bytes are not allowed", ""));
|
||||
}
|
||||
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_NFS_PORT);
|
||||
|
||||
let id = share_id(&server, &export);
|
||||
let spec = NfsShare {
|
||||
id: id.clone(),
|
||||
server,
|
||||
export,
|
||||
port,
|
||||
last_error: None,
|
||||
last_hint: None,
|
||||
last_scan: None,
|
||||
iso_count: 0,
|
||||
reachable: false,
|
||||
};
|
||||
self.inner.lock().shares.insert(id.clone(), spec);
|
||||
self.persist_locked();
|
||||
|
||||
if let Err(e) = self.rescan_inner(&id).await {
|
||||
let m = self.get(&id);
|
||||
return Err(NfsShareError {
|
||||
error: m.as_ref().and_then(|m| m.last_error.clone())
|
||||
.unwrap_or_else(|| e.to_string()),
|
||||
stderr: String::new(),
|
||||
hint: m.and_then(|m| m.last_hint),
|
||||
});
|
||||
}
|
||||
Ok(self.get(&id).expect("just inserted"))
|
||||
}
|
||||
|
||||
/// Remove a share. Drops every ISO sourced from it. Idempotent.
|
||||
/// Async for symmetry with [`SmbShareManager::remove`] — the
|
||||
/// SMB version is async because it tears down a credentials
|
||||
/// file; NFS has nothing to clean up but we keep the signature
|
||||
/// uniform so the call sites in app.rs / terminal.rs match.
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn remove(&self, id: &str) -> Result<()> {
|
||||
let removed = self.inner.lock().shares.remove(id).is_some();
|
||||
if removed {
|
||||
self.iso_store.drop_external_source(id);
|
||||
self.persist_locked();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-walk a share for new / removed ISOs.
|
||||
pub async fn rescan(&self, id: &str) -> Result<u32> {
|
||||
self.rescan_inner(id).await
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<NfsShare> {
|
||||
let g = self.inner.lock();
|
||||
let mut v: Vec<_> = g.shares.values().cloned().collect();
|
||||
v.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
v
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &str) -> Option<NfsShare> {
|
||||
self.inner.lock().shares.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Open a byte stream reading `filename` out of share `share_id`,
|
||||
/// starting at `start_offset` and reading at most `max_len`
|
||||
/// bytes. The returned stream yields `Bytes` chunks (≤
|
||||
/// `READ_CHUNK_BYTES`) and ends at EOF, after `max_len` bytes, or
|
||||
/// on the first transport error.
|
||||
///
|
||||
/// Used by the HTTP ISO download handler. Supports HTTP Range
|
||||
/// requests because NFSv3 READ3 takes an explicit offset — this
|
||||
/// is the protocol-level advantage NFS has over the SMB
|
||||
/// userspace path.
|
||||
///
|
||||
/// Kept `async` for symmetry with [`SmbShareManager::stream_iso`]
|
||||
/// even though the body doesn't await today — a future
|
||||
/// refinement (e.g. throttling, connection pooling) will need to
|
||||
/// await without changing call sites.
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn stream_iso(
|
||||
&self,
|
||||
share_id: &str,
|
||||
filename: &str,
|
||||
start_offset: u64,
|
||||
max_len: Option<u64>,
|
||||
) -> Result<NfsStream> {
|
||||
let share = self
|
||||
.get(share_id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such NFS share '{share_id}'")))?;
|
||||
// Defensive: NFSv3 LOOKUP3 takes a single name relative to
|
||||
// the export root, not a path. We don't support nested
|
||||
// directories in v0.4.67 — ISOs live at the top of the share.
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
||||
}
|
||||
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
|
||||
let server = share.server.clone();
|
||||
let export = share.export.clone();
|
||||
let port = share.port;
|
||||
let fname = filename.to_string();
|
||||
|
||||
// Spawn a task that owns the NFS connection. Owning the
|
||||
// connection inside the spawn means we don't have to worry
|
||||
// about borrowing across awaits or sharing the connection
|
||||
// between scan and stream — each stream gets its own.
|
||||
let task = tokio::spawn(async move {
|
||||
let result = stream_loop(
|
||||
&server,
|
||||
&export,
|
||||
port,
|
||||
&fname,
|
||||
start_offset,
|
||||
max_len,
|
||||
tx.clone(),
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
// Best-effort signal of the error to the consumer.
|
||||
// If the receiver has already dropped we just exit.
|
||||
let _ = tx
|
||||
.send(Err(std::io::Error::other(e.to_string())))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(NfsStream {
|
||||
rx,
|
||||
_task: task,
|
||||
})
|
||||
}
|
||||
|
||||
// ── internals ─────────────────────────────────────────────────────
|
||||
|
||||
async fn rescan_inner(&self, id: &str) -> Result<u32> {
|
||||
let _g = self.op_lock.lock().await;
|
||||
let share = self
|
||||
.get(id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such share '{id}'")))?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
// Drop prior entries so a deleted file disappears from the
|
||||
// store on the next scan.
|
||||
self.iso_store.drop_external_source(id);
|
||||
|
||||
let listing = match list_isos(&share.server, &share.export, share.port).await {
|
||||
Ok(l) => l,
|
||||
Err(err) => {
|
||||
let stderr = err.to_string();
|
||||
let hint = hint_for(&stderr);
|
||||
self.update_status(id, 0, false, Some(stderr.clone()), hint, now);
|
||||
return Err(Error::Invalid(stderr));
|
||||
}
|
||||
};
|
||||
|
||||
let mut count = 0u32;
|
||||
for entry in listing {
|
||||
let iso_id = format!("nfs-{}-{}", share.id, slugify_str(&entry.filename));
|
||||
// Same approach as SMB: no real introspection over the
|
||||
// network in v0.4.67. The boot-entry generator falls back
|
||||
// to filename-based sanboot detection.
|
||||
let report = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: Vec::new(),
|
||||
has_boot_wim: false,
|
||||
};
|
||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||
let source = IsoSource::Nfs {
|
||||
share_id: share.id.clone(),
|
||||
relative_path: entry.filename.clone(),
|
||||
};
|
||||
self.iso_store.register_external(
|
||||
iso_id,
|
||||
entry.filename,
|
||||
entry.size,
|
||||
report,
|
||||
boot_entries,
|
||||
source,
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
self.update_status(id, count, true, None, None, now);
|
||||
tracing::info!(
|
||||
target: "openpxe::nfs",
|
||||
id = %id, server = %share.server, export = %share.export,
|
||||
iso_count = count,
|
||||
"NFS share scanned"
|
||||
);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn update_status(
|
||||
&self,
|
||||
id: &str,
|
||||
iso_count: u32,
|
||||
reachable: bool,
|
||||
err: Option<String>,
|
||||
hint: Option<String>,
|
||||
ts: OffsetDateTime,
|
||||
) {
|
||||
if let Some(s) = self.inner.lock().shares.get_mut(id) {
|
||||
s.iso_count = iso_count;
|
||||
s.reachable = reachable;
|
||||
s.last_error = err;
|
||||
s.last_hint = hint;
|
||||
s.last_scan = Some(ts);
|
||||
}
|
||||
self.persist_locked();
|
||||
}
|
||||
|
||||
fn persist_locked(&self) {
|
||||
let shares: Vec<NfsShare> = self.inner.lock().shares.values().cloned().collect();
|
||||
let path = self.state_path.as_path();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let body = match serde_json::to_vec_pretty(&shares) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "openpxe::nfs", "serialize: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(e) = std::fs::write(&tmp, body) {
|
||||
tracing::warn!(target: "openpxe::nfs", "write tmp: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
tracing::warn!(target: "openpxe::nfs", "rename: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP body stream for an NFS-sourced ISO read.
|
||||
///
|
||||
/// Implements `Stream<Item = io::Result<Bytes>>` so axum can convert
|
||||
/// it into a response body via `Body::from_stream`.
|
||||
#[derive(Debug)]
|
||||
pub struct NfsStream {
|
||||
rx: tokio::sync::mpsc::Receiver<std::io::Result<Bytes>>,
|
||||
/// Kept alive so the read-loop task isn't dropped while the HTTP
|
||||
/// client is still consuming bytes. Dropping the stream cancels
|
||||
/// the task, which is the right behavior on client disconnect.
|
||||
_task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl futures::Stream for NfsStream {
|
||||
type Item = std::io::Result<Bytes>;
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
self.rx.poll_recv(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NfsListEntry {
|
||||
filename: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// Connect, READDIR the export root, look up each `*.iso` to get its
|
||||
/// size + file handle. Returns a flat list. Errors are returned with
|
||||
/// a human-readable message; the caller decides how to surface them.
|
||||
async fn list_isos(
|
||||
server: &str,
|
||||
export: &str,
|
||||
port: u16,
|
||||
) -> std::result::Result<Vec<NfsListEntry>, NfsClientError> {
|
||||
// build_connection applies its own per-attempt timeout (privileged
|
||||
// source port first, then a non-privileged fallback).
|
||||
let mut conn = build_connection(server, export, port).await?;
|
||||
|
||||
let root = conn.root_nfs_fh3();
|
||||
let mut entries = Vec::new();
|
||||
let mut cookie: u64 = 0;
|
||||
let mut cookieverf = nfs3::cookieverf3::default();
|
||||
loop {
|
||||
let res = conn
|
||||
.readdir(&READDIR3args {
|
||||
dir: root.clone(),
|
||||
cookie,
|
||||
cookieverf,
|
||||
count: 32 * 1024,
|
||||
})
|
||||
.await
|
||||
.map_err(NfsClientError::Rpc)?;
|
||||
let ok = match res {
|
||||
Nfs3Result::Ok(ok) => ok,
|
||||
Nfs3Result::Err((status, _)) => {
|
||||
return Err(NfsClientError::Nfsstat(status_label(status)));
|
||||
}
|
||||
};
|
||||
cookieverf = ok.cookieverf;
|
||||
let eof = ok.reply.eof;
|
||||
let dir_entries: Vec<entry3<'_>> = ok.reply.entries.0;
|
||||
let next_cookie = dir_entries.last().map(|e| e.cookie);
|
||||
for entry in dir_entries {
|
||||
// entry.name is `filename3<'a>(Opaque<'a>)` — XDR opaque
|
||||
// bytes. Decode to UTF-8 best-effort.
|
||||
let name_bytes = entry.name.0.as_ref();
|
||||
let Ok(name) = std::str::from_utf8(name_bytes) else {
|
||||
continue;
|
||||
};
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
if !name.to_ascii_lowercase().ends_with(".iso") {
|
||||
continue;
|
||||
}
|
||||
// Look up the file to get its size + verify it's a
|
||||
// regular file (not a symlink / directory matching the
|
||||
// .iso pattern).
|
||||
let lookup = conn
|
||||
.lookup(&LOOKUP3args {
|
||||
what: diropargs3 {
|
||||
dir: root.clone(),
|
||||
name: filename3(Opaque::borrowed(name_bytes)),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.map_err(NfsClientError::Rpc)?;
|
||||
let lookup_ok = match lookup {
|
||||
Nfs3Result::Ok(o) => o,
|
||||
Nfs3Result::Err(_) => continue,
|
||||
};
|
||||
let getattr = conn
|
||||
.getattr(&GETATTR3args {
|
||||
object: lookup_ok.object,
|
||||
})
|
||||
.await
|
||||
.map_err(NfsClientError::Rpc)?;
|
||||
let attrs = match getattr {
|
||||
Nfs3Result::Ok(o) => o.obj_attributes,
|
||||
Nfs3Result::Err(_) => continue,
|
||||
};
|
||||
// ftype3::NF3REG == 1 (regular file). Skip everything
|
||||
// else — directories, symlinks, devices.
|
||||
if attrs.type_ as u32 != nfs3::ftype3::NF3REG as u32 {
|
||||
continue;
|
||||
}
|
||||
entries.push(NfsListEntry {
|
||||
filename: name.to_string(),
|
||||
size: attrs.size,
|
||||
});
|
||||
}
|
||||
if eof {
|
||||
break;
|
||||
}
|
||||
match next_cookie {
|
||||
Some(c) if c != 0 => cookie = c,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = conn.unmount().await;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn stream_loop(
|
||||
server: &str,
|
||||
export: &str,
|
||||
port: u16,
|
||||
filename: &str,
|
||||
start_offset: u64,
|
||||
max_len: Option<u64>,
|
||||
tx: tokio::sync::mpsc::Sender<std::io::Result<Bytes>>,
|
||||
) -> std::result::Result<(), NfsClientError> {
|
||||
let mut conn = build_connection(server, export, port).await?;
|
||||
|
||||
let root = conn.root_nfs_fh3();
|
||||
// Look up the file to get its handle.
|
||||
let name_bytes = filename.as_bytes();
|
||||
let lookup = conn
|
||||
.lookup(&LOOKUP3args {
|
||||
what: diropargs3 {
|
||||
dir: root,
|
||||
name: filename3(Opaque::borrowed(name_bytes)),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.map_err(NfsClientError::Rpc)?;
|
||||
let lookup_ok = match lookup {
|
||||
Nfs3Result::Ok(o) => o,
|
||||
Nfs3Result::Err((status, _)) => {
|
||||
return Err(NfsClientError::Nfsstat(status_label(status)));
|
||||
}
|
||||
};
|
||||
let file_handle: nfs_fh3 = lookup_ok.object;
|
||||
|
||||
let mut offset = start_offset;
|
||||
let mut remaining = max_len;
|
||||
loop {
|
||||
if let Some(r) = remaining {
|
||||
if r == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Cap the chunk at READ_CHUNK_BYTES and at the remaining budget.
|
||||
let chunk = match remaining {
|
||||
Some(r) if r < u64::from(READ_CHUNK_BYTES) => r as u32,
|
||||
_ => READ_CHUNK_BYTES,
|
||||
};
|
||||
let res = conn
|
||||
.read(&READ3args {
|
||||
file: file_handle.clone(),
|
||||
offset,
|
||||
count: chunk,
|
||||
})
|
||||
.await
|
||||
.map_err(NfsClientError::Rpc)?;
|
||||
let ok = match res {
|
||||
Nfs3Result::Ok(o) => o,
|
||||
Nfs3Result::Err((status, _)) => {
|
||||
return Err(NfsClientError::Nfsstat(status_label(status)));
|
||||
}
|
||||
};
|
||||
let bytes = Bytes::copy_from_slice(ok.data.as_ref());
|
||||
let bytes_len = bytes.len() as u64;
|
||||
if tx.send(Ok(bytes)).await.is_err() {
|
||||
// HTTP client dropped — abort gracefully.
|
||||
break;
|
||||
}
|
||||
offset += bytes_len;
|
||||
if let Some(r) = remaining.as_mut() {
|
||||
*r = r.saturating_sub(bytes_len);
|
||||
}
|
||||
if ok.eof {
|
||||
break;
|
||||
}
|
||||
// Defensive: a server that returns 0 bytes without EOF
|
||||
// would have us busy-looping. Bail out instead.
|
||||
if bytes_len == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = conn.unmount().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mount the export and return a live connection.
|
||||
///
|
||||
/// ## Privileged source port (the v0.4.68 fix)
|
||||
///
|
||||
/// Linux kernel `nfsd` — which is what UniFi UNAS, Synology, TrueNAS,
|
||||
/// and essentially every appliance NAS runs underneath — exports with
|
||||
/// the `secure` option **by default**. `secure` means the server only
|
||||
/// accepts MOUNT3 / NFS3 requests whose TCP **source** port is in the
|
||||
/// privileged range (< 1024). A client connecting from an ephemeral
|
||||
/// high port gets `MNT3ERR_ACCES` at mount time — which is exactly the
|
||||
/// error operators hit in v0.4.67 even with their host IP correctly in
|
||||
/// the export's allow-list.
|
||||
///
|
||||
/// v0.4.67 disabled privileged source ports because the openpxe
|
||||
/// process runs as uid 10001 and "can't bind sub-1024 ports". That
|
||||
/// reasoning was wrong: the binary carries `CAP_NET_BIND_SERVICE`
|
||||
/// (granted via `setcap` in the Dockerfile so it can bind the DHCP /
|
||||
/// TFTP / HTTP low ports as non-root), and that capability also lets
|
||||
/// it bind a privileged *source* port for an outbound connection.
|
||||
///
|
||||
/// So we now try a privileged source port first — the common case for
|
||||
/// real NAS appliances — and fall back to a non-privileged port for
|
||||
/// servers exported `insecure` (or environments where we genuinely
|
||||
/// can't grab a low port). Each attempt gets its own connect timeout.
|
||||
async fn build_connection(
|
||||
server: &str,
|
||||
export: &str,
|
||||
port: u16,
|
||||
) -> std::result::Result<
|
||||
nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>,
|
||||
NfsClientError,
|
||||
> {
|
||||
match connect_once(server, export, port, true).await {
|
||||
Ok(conn) => Ok(conn),
|
||||
// A timeout means the server didn't answer at all — a
|
||||
// non-privileged retry would just time out again and double
|
||||
// the operator's wait. Surface the timeout immediately.
|
||||
Err(primary @ NfsClientError::Timeout(..)) => Err(primary),
|
||||
Err(primary) => match connect_once(server, export, port, false).await {
|
||||
Ok(conn) => Ok(conn),
|
||||
// Surface the privileged-attempt error: for the dominant
|
||||
// `secure`-export case it's the one whose hint points at
|
||||
// the real fix.
|
||||
Err(_) => Err(primary),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// One mount attempt with a specific source-port policy, bounded by
|
||||
/// [`CONNECT_TIMEOUT`].
|
||||
async fn connect_once(
|
||||
server: &str,
|
||||
export: &str,
|
||||
port: u16,
|
||||
privileged: bool,
|
||||
) -> std::result::Result<
|
||||
nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>,
|
||||
NfsClientError,
|
||||
> {
|
||||
let fut = Nfs3ConnectionBuilder::new(TokioConnector, server, export)
|
||||
.connect_from_privileged_port(privileged)
|
||||
.nfs3_port(port)
|
||||
.mount();
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, fut).await {
|
||||
Ok(Ok(conn)) => Ok(conn),
|
||||
Ok(Err(e)) => Err(NfsClientError::Connect(e.to_string())),
|
||||
Err(_) => Err(NfsClientError::Timeout(server.to_string(), port)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum NfsClientError {
|
||||
Connect(String),
|
||||
Timeout(String, u16),
|
||||
Rpc(nfs3_client::RpcError),
|
||||
Nfsstat(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NfsClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Connect(msg) => write!(f, "connect failed: {msg}"),
|
||||
Self::Timeout(host, port) => write!(
|
||||
f,
|
||||
"connect timed out after {}s talking to {host}:{port}",
|
||||
CONNECT_TIMEOUT.as_secs()
|
||||
),
|
||||
Self::Rpc(e) => write!(f, "RPC failure: {e}"),
|
||||
Self::Nfsstat(s) => write!(f, "NFS server returned {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NfsClientError {}
|
||||
|
||||
/// Best-effort label for an `nfsstat3` so the UI shows a readable
|
||||
/// name like `NFS3ERR_ACCES` instead of a magic number.
|
||||
fn status_label(status: nfs3::nfsstat3) -> String {
|
||||
use nfs3::nfsstat3 as S;
|
||||
let name = match status {
|
||||
S::NFS3_OK => "NFS3_OK",
|
||||
S::NFS3ERR_PERM => "NFS3ERR_PERM",
|
||||
S::NFS3ERR_NOENT => "NFS3ERR_NOENT",
|
||||
S::NFS3ERR_IO => "NFS3ERR_IO",
|
||||
S::NFS3ERR_NXIO => "NFS3ERR_NXIO",
|
||||
S::NFS3ERR_ACCES => "NFS3ERR_ACCES",
|
||||
S::NFS3ERR_EXIST => "NFS3ERR_EXIST",
|
||||
S::NFS3ERR_XDEV => "NFS3ERR_XDEV",
|
||||
S::NFS3ERR_NODEV => "NFS3ERR_NODEV",
|
||||
S::NFS3ERR_NOTDIR => "NFS3ERR_NOTDIR",
|
||||
S::NFS3ERR_ISDIR => "NFS3ERR_ISDIR",
|
||||
S::NFS3ERR_INVAL => "NFS3ERR_INVAL",
|
||||
S::NFS3ERR_FBIG => "NFS3ERR_FBIG",
|
||||
S::NFS3ERR_NOSPC => "NFS3ERR_NOSPC",
|
||||
S::NFS3ERR_ROFS => "NFS3ERR_ROFS",
|
||||
S::NFS3ERR_MLINK => "NFS3ERR_MLINK",
|
||||
S::NFS3ERR_NAMETOOLONG => "NFS3ERR_NAMETOOLONG",
|
||||
S::NFS3ERR_NOTEMPTY => "NFS3ERR_NOTEMPTY",
|
||||
S::NFS3ERR_DQUOT => "NFS3ERR_DQUOT",
|
||||
S::NFS3ERR_STALE => "NFS3ERR_STALE",
|
||||
S::NFS3ERR_REMOTE => "NFS3ERR_REMOTE",
|
||||
S::NFS3ERR_BADHANDLE => "NFS3ERR_BADHANDLE",
|
||||
S::NFS3ERR_NOT_SYNC => "NFS3ERR_NOT_SYNC",
|
||||
S::NFS3ERR_BAD_COOKIE => "NFS3ERR_BAD_COOKIE",
|
||||
S::NFS3ERR_NOTSUPP => "NFS3ERR_NOTSUPP",
|
||||
S::NFS3ERR_TOOSMALL => "NFS3ERR_TOOSMALL",
|
||||
S::NFS3ERR_SERVERFAULT => "NFS3ERR_SERVERFAULT",
|
||||
S::NFS3ERR_BADTYPE => "NFS3ERR_BADTYPE",
|
||||
S::NFS3ERR_JUKEBOX => "NFS3ERR_JUKEBOX",
|
||||
};
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// Translate well-known NFS error strings into actionable hints for
|
||||
/// the UI. Mirrors the SMB hint table in spirit; the patterns are
|
||||
/// different because NFS errors travel via NFS3ERR_* codes plus
|
||||
/// transport-level messages from the Rust crate.
|
||||
fn hint_for(text: &str) -> Option<String> {
|
||||
let s = text.to_ascii_lowercase();
|
||||
if s.contains("mnt3err_acces") || s.contains("mount") && s.contains("acces") {
|
||||
// Mount-protocol access denial. Two common causes, in order of
|
||||
// likelihood for an appliance NAS: (1) the export requires a
|
||||
// privileged source port (`secure`, the Linux default) — we
|
||||
// already retry with one, so reaching here means even that was
|
||||
// refused; (2) the client IP isn't in the allow-list.
|
||||
Some(
|
||||
"the NFS server denied the mount (MNT3ERR_ACCES). Two things to \
|
||||
check on the server: (1) this OpenPXE host's IP is in the \
|
||||
export's allowed-clients list, and (2) if your export uses the \
|
||||
default `secure` option, OpenPXE already connects from a \
|
||||
privileged port — but if the server still refuses, add \
|
||||
`insecure` to the export. On UniFi UNAS, confirm the host IP is \
|
||||
listed under the share's NFS permissions and the export path is \
|
||||
/var/nfs/shared/<share> (not just /<share>)."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("nfs3err_acces") || s.contains("permission denied") {
|
||||
Some(
|
||||
"the NFS server rejected this client. Most likely your export \
|
||||
is restricted by client IP — add this OpenPXE host (or its \
|
||||
subnet) to the export's allowed-clients list on the server."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("nfs3err_noent")
|
||||
|| s.contains("nfs3err_notdir")
|
||||
|| s.contains("mnt3err_noent")
|
||||
{
|
||||
Some(
|
||||
"the export path doesn't exist on the server, or it isn't a \
|
||||
directory. Double-check the path (e.g. /srv/isos vs /isos — \
|
||||
UniFi UNAS Pro exposes shares under /var/nfs/shared/<name>)."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("nfs3err_stale") || s.contains("nfs3err_badhandle") {
|
||||
Some(
|
||||
"the server's view of the share changed under us. Re-scan; \
|
||||
if that doesn't help, remove and re-add the share."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("connect timed out")
|
||||
|| s.contains("timed out")
|
||||
|| s.contains("connection timed out")
|
||||
{
|
||||
Some(
|
||||
"no answer from the server within the connect timeout. \
|
||||
Verify the IP, the port (default 2049), and any firewall \
|
||||
between OpenPXE and the NAS."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("connection refused") {
|
||||
Some(
|
||||
"the NFS service isn't accepting connections on this port. \
|
||||
Make sure nfsd is running on the server and (for v3) the \
|
||||
portmapper on port 111 is reachable."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("no route to host") || s.contains("network is unreachable") {
|
||||
Some(
|
||||
"the server isn't reachable on this network. Check the IP \
|
||||
and routes."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("mount") && s.contains("denied") {
|
||||
Some(
|
||||
"MOUNT3 was denied. The classic cause is that the export's \
|
||||
`rw=<host>` / `ro=<host>` list doesn't include this client. \
|
||||
Some servers also require `insecure` in /etc/exports for \
|
||||
non-privileged source ports — which is what this client \
|
||||
uses (we run as uid 10001, no privileged-port capability)."
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn share_id(server: &str, export: &str) -> String {
|
||||
slugify_str(&format!("{server}{export}"))
|
||||
}
|
||||
|
||||
/// Normalize a server input: trim, strip schemes, drop trailing
|
||||
/// slashes. Matches the SMB normalizer so paste-from-anywhere works.
|
||||
fn normalize_server(raw: &str) -> String {
|
||||
let s = raw.trim();
|
||||
let s = s
|
||||
.strip_prefix("nfs://")
|
||||
.or_else(|| s.strip_prefix("http://"))
|
||||
.or_else(|| s.strip_prefix("https://"))
|
||||
.unwrap_or(s);
|
||||
s.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
// Silence the unused-import warning on `Cow` — we use it implicitly
|
||||
// via `Opaque::borrowed` constructions in `list_isos`.
|
||||
#[allow(dead_code)]
|
||||
fn _unused() -> Cow<'static, str> {
|
||||
Cow::Borrowed("")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn share_id_is_stable_and_safe() {
|
||||
let a = share_id("10.0.0.5", "/srv/isos");
|
||||
let b = share_id("10.0.0.5", "/srv/isos");
|
||||
assert_eq!(a, b);
|
||||
assert!(!a.contains('/'));
|
||||
assert!(!a.contains('.'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_server_strips_schemes() {
|
||||
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
|
||||
assert_eq!(normalize_server("nfs://nas.lan/"), "nas.lan");
|
||||
assert_eq!(normalize_server("http://192.168.1.51"), "192.168.1.51");
|
||||
assert_eq!(normalize_server("nas.lan"), "nas.lan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_for_acces_points_to_exports_table() {
|
||||
let h = hint_for("NFS server returned NFS3ERR_ACCES").unwrap();
|
||||
assert!(
|
||||
h.to_lowercase().contains("export"),
|
||||
"expected exports guidance, got: {h}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_for_mount_acces_calls_out_privileged_port_and_allowlist() {
|
||||
// The dominant v0.4.67 field failure: mount denied even with the
|
||||
// host IP allow-listed, because the export is `secure` and the
|
||||
// client used a high source port. The hint should mention both
|
||||
// the allow-list and the secure/insecure angle.
|
||||
let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap();
|
||||
let lc = h.to_lowercase();
|
||||
assert!(lc.contains("insecure") || lc.contains("privileged"), "got: {h}");
|
||||
assert!(lc.contains("allow") || lc.contains("permission"), "got: {h}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_for_noent_points_to_export_path() {
|
||||
let h = hint_for("NFS server returned NFS3ERR_NOENT").unwrap();
|
||||
assert!(
|
||||
h.to_lowercase().contains("path") || h.to_lowercase().contains("directory"),
|
||||
"expected export-path guidance, got: {h}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_for_mount_denied_calls_out_insecure_option() {
|
||||
let h = hint_for("mount denied").unwrap();
|
||||
assert!(h.to_lowercase().contains("insecure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_for_unknown_is_none() {
|
||||
assert!(hint_for("some entirely unrelated string").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_label_covers_common_codes() {
|
||||
assert_eq!(status_label(nfs3::nfsstat3::NFS3_OK), "NFS3_OK");
|
||||
assert_eq!(status_label(nfs3::nfsstat3::NFS3ERR_ACCES), "NFS3ERR_ACCES");
|
||||
assert_eq!(status_label(nfs3::nfsstat3::NFS3ERR_NOENT), "NFS3ERR_NOENT");
|
||||
}
|
||||
}
|
||||
@@ -400,7 +400,20 @@ impl SmbShareManager {
|
||||
if share.guest {
|
||||
cmd.arg("-N");
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(|e| Error::Other(e.into()))?;
|
||||
// v0.4.66: surface a useful error if smbclient isn't on the
|
||||
// PATH. Shouldn't happen on the stock image but custom
|
||||
// builds may strip it.
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
Error::Invalid(
|
||||
"smbclient binary not found on $PATH — install the \
|
||||
Debian `smbclient` package or pull OpenPXE v0.4.66+"
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
Error::Other(e.into())
|
||||
}
|
||||
})?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
@@ -517,9 +530,22 @@ impl SmbShareManager {
|
||||
let output = match cmd.output().await {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
// v0.4.66: distinguish ENOENT (missing binary) from
|
||||
// other exec failures and pre-fill the hint so the
|
||||
// UI shows a clear remediation instead of the bare
|
||||
// "No such file or directory (os error 2)". This
|
||||
// shouldn't fire on the stock image — the Dockerfile
|
||||
// installs the `smbclient` package — but is a useful
|
||||
// breadcrumb for anyone running OpenPXE in a stripped
|
||||
// base image.
|
||||
let stderr = if e.kind() == std::io::ErrorKind::NotFound {
|
||||
"smbclient binary not found on $PATH".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
return Err((
|
||||
format!("could not exec smbclient: {e}"),
|
||||
String::new(),
|
||||
stderr,
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -762,7 +788,25 @@ async fn tcp_probe(
|
||||
/// hints. Returns `None` when we don't have a translation.
|
||||
fn hint_for(text: &str) -> Option<String> {
|
||||
let s = text.to_ascii_lowercase();
|
||||
if s.contains("nt_status_logon_failure") || s.contains("logon_failure") {
|
||||
if s.contains("smbclient binary not found")
|
||||
|| s.contains("smbclient: no such file")
|
||||
|| (s.contains("could not exec smbclient") && s.contains("os error 2"))
|
||||
{
|
||||
// v0.4.66: this only fires on a stripped / custom runtime
|
||||
// image — the stock OpenPXE container ships `smbclient` from
|
||||
// the Debian `smbclient` package. The error surfaced on
|
||||
// v0.4.65 specifically because that release's Dockerfile
|
||||
// installed `samba` (the server) but not `smbclient` (the
|
||||
// client CLI). Operators on the stock image should never see
|
||||
// this; if they do, the fix is to upgrade.
|
||||
Some(
|
||||
"smbclient isn't installed in this container. Pull the \
|
||||
official OpenPXE image v0.4.66 or newer — the stock image \
|
||||
ships smbclient. If you're running a custom build, add the \
|
||||
Debian `smbclient` package to your runtime stage."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("nt_status_logon_failure") || s.contains("logon_failure") {
|
||||
Some(
|
||||
"the server rejected the credentials. Double-check the username \
|
||||
and password — many NAS appliances use a separate SMB account \
|
||||
@@ -880,6 +924,24 @@ mod tests {
|
||||
assert!(hint_for("some unrelated error text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_for_missing_smbclient_calls_out_upgrade() {
|
||||
// The exec-side path sets `stderr` to "smbclient binary not
|
||||
// found on $PATH" when ENOENT lands.
|
||||
let h = hint_for("smbclient binary not found on $PATH").unwrap();
|
||||
assert!(
|
||||
h.contains("smbclient") && h.to_lowercase().contains("install"),
|
||||
"expected upgrade/install guidance, got: {h}"
|
||||
);
|
||||
// The raw error path on the API side passes the verbatim
|
||||
// exec error in `error` plus an empty `stderr`. The
|
||||
// SmbShareError constructor's hint_for fallback checks error
|
||||
// too, so this pattern needs to translate as well.
|
||||
let h2 = hint_for("could not exec smbclient: No such file or directory (os error 2)")
|
||||
.unwrap();
|
||||
assert!(h2.contains("smbclient"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_iso_finds_one_iso_and_skips_directories() {
|
||||
let out = "\
|
||||
|
||||
@@ -15,27 +15,33 @@ use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// Where the bytes for an ISO actually live.
|
||||
///
|
||||
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
|
||||
/// `Smb` entries (v0.4.65) point at a file inside a remote SMB share
|
||||
/// that the `SmbShareManager` knows how to stream via Samba's
|
||||
/// userspace `smbclient` CLI. The HTTP handler resolves the share by
|
||||
/// id at request time and pipes `smbclient -c 'get file -'` straight
|
||||
/// into the response body — no kernel mount, no local cache.
|
||||
/// `Local` — uploaded ISO, sits at `<iso_dir>/<id>.iso`.
|
||||
/// `Smb` (v0.4.65) — remote SMB share, streamed via Samba's
|
||||
/// userspace `smbclient` CLI subprocess. No kernel mount, no local
|
||||
/// cache. Sequential whole-file streaming; HTTP Range requests
|
||||
/// return 416.
|
||||
/// `Nfs` (v0.4.67) — remote NFSv3 share, streamed via the pure-Rust
|
||||
/// `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.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum IsoSource {
|
||||
#[default]
|
||||
Local,
|
||||
/// v0.4.65: kernel-mount NFS is gone (it didn't work on Unraid
|
||||
/// regardless of capabilities — the host kernel needs the nfs
|
||||
/// client modules loaded). SMB via userspace `smbclient` works in
|
||||
/// any container.
|
||||
/// v0.4.65: SMB via userspace `smbclient` works in any container.
|
||||
Smb {
|
||||
share_id: String,
|
||||
/// Filename at the share root. We don't support nested paths
|
||||
/// in v0.4.65; ISOs live at the top of the share.
|
||||
relative_path: String,
|
||||
},
|
||||
/// v0.4.67: NFSv3 via the in-process `nfs3_client` crate.
|
||||
Nfs {
|
||||
share_id: String,
|
||||
/// Filename at the export root.
|
||||
relative_path: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Where the ISO lands in the PXE menu hierarchy.
|
||||
@@ -293,10 +299,11 @@ impl IsoStore {
|
||||
None
|
||||
}
|
||||
}
|
||||
// SMB sources have no local path — they're streamed via
|
||||
// smbclient subprocess. Callers should check the source
|
||||
// kind first and dispatch accordingly.
|
||||
IsoSource::Smb { .. } => None,
|
||||
// SMB and NFS 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,13 +355,19 @@ impl IsoStore {
|
||||
}
|
||||
|
||||
/// Drop every entry that belongs to `share_id`. Used by the SMB
|
||||
/// share manager when an operator removes a share, or before
|
||||
/// re-scanning to clean out stale entries.
|
||||
/// and NFS share managers when an operator removes a share, or
|
||||
/// before re-scanning to clean out stale entries. The same id
|
||||
/// space serves both protocols — share ids are slugified from
|
||||
/// `server+share` (SMB) or `server+export` (NFS) and the
|
||||
/// protocol-specific prefix prevents collisions.
|
||||
pub fn drop_external_source(&self, share_id: &str) {
|
||||
let mut g = self.inner.write();
|
||||
g.isos.retain(
|
||||
|_, m| !matches!(&m.source, IsoSource::Smb { share_id: sid, .. } if sid == share_id),
|
||||
);
|
||||
g.isos.retain(|_, m| match &m.source {
|
||||
IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => {
|
||||
sid != share_id
|
||||
}
|
||||
IsoSource::Local => true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set or clear an ISO's boot password.
|
||||
|
||||
@@ -9,7 +9,7 @@ use openpxe_core::{
|
||||
};
|
||||
use openpxe_dhcp_proxy::DhcpProxyServer;
|
||||
use openpxe_http_api::{build_router, AppState};
|
||||
use openpxe_iso_store::{IsoStore, SmbManager, SmbShareManager};
|
||||
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
|
||||
use openpxe_tftp::TftpServer;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
@@ -133,6 +133,18 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// v0.4.67: NFSv3 share manager — pure-Rust in-process consumer
|
||||
// via the `nfs3_client` crate. Sits alongside the SMB manager;
|
||||
// operators pick whichever protocol their NAS prefers, or use
|
||||
// both. No subprocess, no kernel mount, works in any container.
|
||||
let nfs_shares = NfsShareManager::new(&config.paths.work_dir, iso_store.clone());
|
||||
if let Err(e) = nfs_shares.load_and_rescan().await {
|
||||
tracing::warn!(
|
||||
target: "openpxe::nfs",
|
||||
"could not reload NFS 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
|
||||
@@ -158,6 +170,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
metrics: metrics.clone(),
|
||||
smb: Some(smb.clone()),
|
||||
smb_shares: smb_shares.clone(),
|
||||
nfs_shares: nfs_shares.clone(),
|
||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||
log_bus: log_bus.clone(),
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
|
||||
+175
-54
@@ -116,6 +116,46 @@
|
||||
return root;
|
||||
}
|
||||
|
||||
// Disk-space card. Free + used + total for the volume hosting the ISO
|
||||
// directory, with a coloured bar. Warns at 80% and goes red at 95% so
|
||||
// the operator sees the runway shrinking before uploads start failing
|
||||
// with ENOSPC. Shared by the Storage tab and the Dashboard (v0.4.68).
|
||||
function diskSpaceCard(disk) {
|
||||
const total = Number(disk.total_bytes || 0);
|
||||
const avail = Number(disk.available_bytes || 0);
|
||||
const used = Number(disk.used_bytes || 0);
|
||||
const pctUsed = total > 0 ? (used / total) * 100 : 0;
|
||||
let barClass = 'diskbar';
|
||||
if (pctUsed >= 95) barClass += ' full';
|
||||
else if (pctUsed >= 80) barClass += ' warn';
|
||||
return el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Disk space'),
|
||||
el('span', {class:'sub'},
|
||||
total > 0 ? (pctUsed.toFixed(1) + '% used') : 'unavailable'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
|
||||
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
|
||||
el('div', {class: barClass},
|
||||
el('div', {class:'fill',
|
||||
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
|
||||
el('div', {class:'disk-meta'}, [
|
||||
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
|
||||
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
|
||||
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
|
||||
]),
|
||||
pctUsed >= 95
|
||||
? el('p', {class:'msg err', style:'margin-top:10px'},
|
||||
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
|
||||
: (pctUsed >= 80
|
||||
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
|
||||
'Volume is getting full. Consider pruning old ISOs.')
|
||||
: null),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// Categorize an ISO row's "bootable now" status — drives the amber
|
||||
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
||||
function bootability(iso, settings) {
|
||||
@@ -142,6 +182,13 @@
|
||||
const isos = await getJSON('/api/isos');
|
||||
const clients = (await getJSON('/api/clients')).clients || [];
|
||||
const entries = (await getJSON('/api/queue')).entries || [];
|
||||
// v0.4.68: surface the same disk-space card the Storage tab shows,
|
||||
// so operators see capacity at a glance from the landing page.
|
||||
// Tolerate the endpoint being unavailable (e.g. statvfs failure)
|
||||
// the same way the Storage tab does.
|
||||
const disk = await getJSON('/api/storage/disk').catch(() => ({
|
||||
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
||||
}));
|
||||
|
||||
const ipxeOk = (status.ipxe_assets || []).length > 0;
|
||||
const stats = el('div', {class: 'statstrip'}, [
|
||||
@@ -167,8 +214,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 · ' +
|
||||
(status.smb_share_reachable || 0) + ' SMB share' +
|
||||
((status.smb_share_reachable || 0) === 1 ? '' : 's')),
|
||||
// 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)) +
|
||||
' remote share' +
|
||||
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')),
|
||||
])),
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Uptime'),
|
||||
@@ -219,7 +269,7 @@
|
||||
'⚠ ' + i.filename + ' — ' + b.reason)))
|
||||
]) : null;
|
||||
|
||||
return el('div', {class:'grid'}, [stats, recentBlock, problemsBlock].filter(Boolean));
|
||||
return el('div', {class:'grid'}, [stats, diskSpaceCard(disk), recentBlock, problemsBlock].filter(Boolean));
|
||||
},
|
||||
|
||||
network: async () => {
|
||||
@@ -345,16 +395,20 @@
|
||||
storage: async () => {
|
||||
// v0.4.65: kernel-mount NFS replaced with userspace SMB via
|
||||
// smbclient — works in any container regardless of host kernel
|
||||
// modules or capabilities. The /api/nfs endpoint is gone;
|
||||
// /api/smb-shares is the replacement.
|
||||
const [isos, settings, smbRes, disk] = await Promise.all([
|
||||
// modules or capabilities.
|
||||
// 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] = await Promise.all([
|
||||
getJSON('/api/isos'), getJSON('/api/settings'),
|
||||
getJSON('/api/smb-shares'),
|
||||
getJSON('/api/nfs-shares'),
|
||||
getJSON('/api/storage/disk').catch(() => ({
|
||||
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
||||
})),
|
||||
]);
|
||||
const shares = smbRes.shares || [];
|
||||
const nfsShares = nfsRes.shares || [];
|
||||
|
||||
// ── Upload card ──
|
||||
const drop = el('div', {class:'drop', id:'drop'}, [
|
||||
@@ -464,10 +518,14 @@
|
||||
const rowsAndEditors = [];
|
||||
isos.forEach(i => {
|
||||
const b = bootability(i, settings);
|
||||
// v0.4.65: SMB userspace consumer replaced NFS. The badge
|
||||
// colours stay the same so the table looks unchanged for
|
||||
// existing operators.
|
||||
// v0.4.65: SMB userspace consumer (smbclient).
|
||||
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
|
||||
// Both render with the same "remote" badge colour — they
|
||||
// share the same "on a remote share, can't be deleted from
|
||||
// here" semantics in the UI.
|
||||
const isSmb = i.source && i.source.kind === 'smb';
|
||||
const isNfs = i.source && i.source.kind === 'nfs';
|
||||
const isRemote = isSmb || isNfs;
|
||||
const protectedNow = !!i.password_hash;
|
||||
|
||||
// The inline editor row is hidden by default; the Password
|
||||
@@ -587,8 +645,9 @@
|
||||
]),
|
||||
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
|
||||
el('td', {},
|
||||
el('span', {class:'src-badge' + (isSmb ? ' nfs' : '')},
|
||||
isSmb ? ('smb:' + i.source.share_id) : 'local')),
|
||||
el('span', {class:'src-badge' + (isRemote ? ' nfs' : '')},
|
||||
isSmb ? ('smb:' + i.source.share_id)
|
||||
: isNfs ? ('nfs:' + i.source.share_id) : 'local')),
|
||||
el('td', {},
|
||||
protectedNow
|
||||
? el('span', {class:'tag accent'}, 'protected')
|
||||
@@ -598,11 +657,12 @@
|
||||
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
|
||||
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
|
||||
}}, protectedNow ? 'Password ✎' : 'Set password'),
|
||||
isSmb
|
||||
// v0.4.65: SMB-sourced ISOs live on the remote share —
|
||||
// OpenPXE doesn't own those bytes. Same pattern as NFS
|
||||
// had: surface a tag instead of a destructive button.
|
||||
? el('span', {class:'tag', style:'opacity:.6'}, 'on SMB')
|
||||
isRemote
|
||||
// v0.4.65/v0.4.67: remote-sourced ISOs live on the
|
||||
// share — OpenPXE doesn't own those bytes. Surface a
|
||||
// tag instead of a destructive button.
|
||||
? el('span', {class:'tag', style:'opacity:.6'},
|
||||
isSmb ? 'on SMB' : 'on NFS')
|
||||
: el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Remove this image?')) return;
|
||||
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
|
||||
@@ -715,45 +775,73 @@
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No SMB shares configured.')];
|
||||
|
||||
// Disk-space card. Free + used + total for the volume hosting the
|
||||
// ISO directory, with a coloured bar. Warns at 80% and goes red at
|
||||
// 95% so the operator sees the runway shrinking before uploads
|
||||
// start failing with ENOSPC.
|
||||
const total = Number(disk.total_bytes || 0);
|
||||
const avail = Number(disk.available_bytes || 0);
|
||||
const used = Number(disk.used_bytes || 0);
|
||||
const pctUsed = total > 0 ? (used / total) * 100 : 0;
|
||||
let barClass = 'diskbar';
|
||||
if (pctUsed >= 95) barClass += ' full';
|
||||
else if (pctUsed >= 80) barClass += ' warn';
|
||||
const diskCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Disk space'),
|
||||
el('span', {class:'sub'},
|
||||
total > 0
|
||||
? (pctUsed.toFixed(1) + '% used')
|
||||
: 'unavailable'),
|
||||
// ── NFS shares section (v0.4.67) ──
|
||||
// Parallel to SMB shares above. The NFSv3 client is in-process
|
||||
// (nfs3_client crate) so NFS-sourced ISOs support HTTP Range
|
||||
// requests — SMB-sourced ones don't (smbclient CLI can't seek
|
||||
// mid-stream). Otherwise the UX is identical: server + export,
|
||||
// submit, scan, remove.
|
||||
const nfsMsg = el('div', {class:'msg'});
|
||||
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
||||
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
|
||||
const addNfs = el('button', {style:'margin-top:14px', onclick: async () => {
|
||||
if (!nfsServerIn.value || !nfsExportIn.value) {
|
||||
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||
nfsMsg.className = 'msg err'; return;
|
||||
}
|
||||
nfsMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||
nfsMsg.className = 'msg';
|
||||
const r = await postJSON('/api/nfs-shares', {
|
||||
server: nfsServerIn.value,
|
||||
export: nfsExportIn.value,
|
||||
});
|
||||
if (r.ok) {
|
||||
nfsMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||
nfsMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else {
|
||||
// Structured {error, stderr, hint} same as SMB.
|
||||
let bodyJson = null;
|
||||
let raw = null;
|
||||
try { bodyJson = await r.clone().json(); }
|
||||
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
|
||||
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
|
||||
const hint = bodyJson && bodyJson.hint;
|
||||
const parts = [el('div', {}, [
|
||||
el('strong', {}, 'Connect failed: '),
|
||||
document.createTextNode(msg),
|
||||
])];
|
||||
if (hint) {
|
||||
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
||||
}
|
||||
nfsMsg.replaceChildren(...parts);
|
||||
nfsMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Add share');
|
||||
|
||||
const nfsRows = nfsShares.length ? nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||||
el('div', {}, [
|
||||
el('div', {class:'id'}, m.server + ':' + m.export),
|
||||
el('div', {class:'meta'},
|
||||
'NFSv3 · ' +
|
||||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
|
||||
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
|
||||
el('div', {class: barClass},
|
||||
el('div', {class:'fill',
|
||||
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
|
||||
el('div', {class:'disk-meta'}, [
|
||||
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
|
||||
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
|
||||
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
|
||||
]),
|
||||
pctUsed >= 95
|
||||
? el('p', {class:'msg err', style:'margin-top:10px'},
|
||||
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
|
||||
: (pctUsed >= 80
|
||||
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
|
||||
'Volume is getting full. Consider pruning old ISOs.')
|
||||
: null),
|
||||
]),
|
||||
]);
|
||||
el('button', {class:'ghost', onclick: async () => {
|
||||
const r = await postJSON('/api/nfs-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/nfs-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||||
render('storage');
|
||||
}}, 'Remove'),
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No NFS shares configured.')];
|
||||
|
||||
const diskCard = diskSpaceCard(disk);
|
||||
|
||||
return el('div', {class:'grid'}, [
|
||||
diskCard,
|
||||
@@ -801,6 +889,39 @@
|
||||
'boot time — no local cache, no double disk usage.'),
|
||||
]),
|
||||
]),
|
||||
// v0.4.67: NFS shares card sits right below SMB so operators
|
||||
// can see both protocols at a glance. The form is simpler
|
||||
// (no auth) because NFSv3 access control is by client IP on
|
||||
// the server side, not by client-supplied credentials.
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'NFS shares'),
|
||||
el('span', {class:'sub'}, nfsShares.length + ' configured'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'NFS server'),
|
||||
nfsServerIn,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Export path'),
|
||||
nfsExportIn,
|
||||
]),
|
||||
]),
|
||||
addNfs, nfsMsg,
|
||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'NFSv3 shares are read in-process via a pure-Rust client — ' +
|
||||
'no kernel modules, no mount.nfs, no CAP_SYS_ADMIN. Works in ' +
|
||||
'every container the SMB path works in (Unraid included). ' +
|
||||
'NFSv3 auth is AUTH_SYS only; gate access on the server side ' +
|
||||
'by allowing this OpenPXE host’s IP in the export list. ' +
|
||||
'ISOs are streamed on demand and HTTP Range requests work — ' +
|
||||
'NFSv3 READ3 takes an explicit offset, so clients can seek ' +
|
||||
'into a 5 GB ISO without reading what comes before.'),
|
||||
]),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Available images'),
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
on the asset handlers, the practical caching window is one
|
||||
version. -->
|
||||
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" />
|
||||
<!-- Theme is read from localStorage *before* paint to avoid the
|
||||
dark→light flash on every navigation. Falls back to the OS
|
||||
preference and finally to dark. -->
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" />
|
||||
<img src="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" alt="OpenPXE" />
|
||||
<strong>OpenPXE</strong>
|
||||
</div>
|
||||
<nav>
|
||||
|
||||
@@ -17,11 +17,19 @@
|
||||
/// when we know the new one is incompatible. Combined with
|
||||
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
|
||||
/// the worst-case caching window is one version.
|
||||
/// * `logo_rev` is appended to the brand-mark and favicon URLs as an
|
||||
/// extra `&r=…` token. Unlike `asset_version` it changes every time
|
||||
/// the operator swaps the custom logo, so the top-left mark updates
|
||||
/// immediately on the next page load instead of being pinned to the
|
||||
/// release version (which only changes on upgrade). `index.html`
|
||||
/// itself is served `no-cache`, so the fresh token lands as soon as
|
||||
/// the operator reloads after an upload.
|
||||
#[must_use]
|
||||
pub fn index_html(base_url: &str, asset_version: &str) -> String {
|
||||
pub fn index_html(base_url: &str, asset_version: &str, logo_rev: u64) -> String {
|
||||
INDEX_HTML
|
||||
.replace("{{BASE_URL}}", base_url)
|
||||
.replace("{{ASSET_VERSION}}", asset_version)
|
||||
.replace("{{LOGO_REV}}", &logo_rev.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -81,23 +81,30 @@ FROM debian:12-slim AS runtime
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates libcap2-bin tini gosu iproute2 \
|
||||
wimtools samba nfs-common \
|
||||
wimtools samba smbclient \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \
|
||||
&& mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \
|
||||
&& chown -R openpxe:openpxe /var/lib/openpxe
|
||||
# v0.4.5: the openpxe binary itself is now built against musl and is
|
||||
# fully static — no glibc dependency. The runtime stage still ships
|
||||
# Debian slim because OpenPXE shells out to the four packages below for
|
||||
# Debian slim because OpenPXE shells out to the packages below for
|
||||
# functionality we deliberately don't reimplement in-process:
|
||||
#
|
||||
# wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
|
||||
# samba - `smbd` serves extracted Windows install media on :445 so
|
||||
# WinPE can `net use`. Guest read-only, scoped to
|
||||
# /var/lib/openpxe/smb.
|
||||
# nfs-common - `mount.nfs` / `mount.nfs4` for the Storage tab's NFS
|
||||
# share manager. Mount requires CAP_SYS_ADMIN; without it
|
||||
# mount(2) returns EPERM and the manager surfaces a clear
|
||||
# error in the UI.
|
||||
# /var/lib/openpxe/smb. This package provides the SERVER
|
||||
# side only; the client CLI is a separate package below.
|
||||
# smbclient - v0.4.66: Samba's `smbclient` userspace CLI, used by
|
||||
# the Storage tab's SMB shares manager to list and stream
|
||||
# ISOs from remote SMB servers without ever mounting them
|
||||
# in the kernel. In Debian 12 `smbclient` is NOT pulled
|
||||
# in by the `samba` package — they're siblings, not
|
||||
# parent/child. v0.4.65 shipped without this line and
|
||||
# every "Add share" attempt surfaced
|
||||
# `could not exec smbclient: No such file or directory`
|
||||
# until this landed.
|
||||
# iproute2 - `ip addr` / `ip route` for the auto-detected Network
|
||||
# tab fields (NIC name, subnet mask, default gateway).
|
||||
# Tiny, always available; we don't pull in netlink crates
|
||||
@@ -105,8 +112,13 @@ RUN apt-get update \
|
||||
# gosu - drops privileges cleanly from root after the entrypoint
|
||||
# fixes bind-mount ownership (common OpenShift/Docker UX
|
||||
# issue).
|
||||
#
|
||||
# v0.4.65 dropped `nfs-common` — kernel-mount NFS is gone. The SMB
|
||||
# shares replacement uses userspace `smbclient` and needs no kernel
|
||||
# helpers.
|
||||
#
|
||||
# A future "openpxe-static" variant could drop everything except the
|
||||
# binary onto distroless once we move the Windows + NFS legs to
|
||||
# binary onto distroless once we move the Windows + SMB legs to
|
||||
# in-process Rust crates.
|
||||
|
||||
COPY --from=build /openpxe /usr/local/bin/openpxe
|
||||
|
||||
Reference in New Issue
Block a user