v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)
Field report: even with CAP_SYS_ADMIN and full --privileged, NFS mounts
inside the OpenPXE container fail on Unraid with the same
"failed to apply fstab options" error v0.4.64 added diagnostics for.
The root cause is the host kernel: Unraid's base kernel ships without
the nfs/nfsv4 client modules loaded. Capabilities are necessary but
not sufficient; the modules have to be present on the host kernel for
in-container mount(2) to do anything. No container-side change can
fix that.
This is exactly the case every other PXE/imaging tool sidesteps
(Bootimus uses SMB; iVentoy, FOG, MAAS, Cobbler all rely on the host
to mount network storage and bind-mount the path into the imaging
service). v0.4.65 brings OpenPXE in line with that pattern.
What's new:
* `IsoSource::LocalDir { dir_id, relative_path }` — third source kind
alongside `Local` (uploaded) and `Nfs` (in-container mount).
* `LocalDirManager` (crates/iso-store/src/local_dir.rs) — registers
bind-mounted directories, validates them (absolute path, exists, is
a directory, readable), scans for *.iso files, registers them with
IsoStore. Persisted to <work_dir>/local_dirs.json so the relationship
survives restarts.
* `NfsHostCaps::detect()` — pure read of /proc/filesystems on startup.
Surfaced via GET /api/nfs/capabilities and used by the Storage tab to
show a prominent red banner above the NFS form when in-container
mounts cannot possibly work, pointing the operator at the Local
Directories card as the recommended path.
* Four new API routes:
GET /api/nfs/capabilities
GET /api/local-dirs
POST /api/local-dirs { path, label? }
DELETE /api/local-dirs/:id
POST /api/local-dirs/:id/scan
UI changes (crates/webui/src/app.js):
* Storage tab: new "Local directories" card under the NFS card with
the bind-mount form, an explainer paragraph (with the Docker
`-v /mnt/user/isos:/mnt/external-isos` command), and the list of
registered directories with rescan + remove actions.
* When NFS host caps are unavailable, the NFS card sprouts a red
banner explaining what's wrong and pointing at the local-dir
workaround. The card sub-header also flips to "N registered ·
recommended on this host".
* ISO table: new "dir:<id>" source badge; on-disk ISOs show "on disk"
in the actions column instead of a delete button (same pattern as
NFS — OpenPXE doesn't own those bytes).
* API reference table picks up the four new endpoints + a hint about
the new `port` field on NFS add.
Tests (+12, total 162):
* iso-store: 7 local_dir unit tests covering relative-path rejection,
missing path, non-directory file, empty-directory success, default
label, idempotent re-add, remove + iso-path-resolution clear.
* iso-store: 1 nfs unit test confirming NfsHostCaps::detect() never
panics and the boolean accessors are consistent.
* http-api: 4 integration tests covering /api/nfs/capabilities,
/api/local-dirs list/add/remove + relative-path 400.
`cargo clippy --workspace --all-targets -- -D warnings` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0afbe860e8
commit
761489761c
Generated
+8
-8
@@ -1140,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe"
|
name = "openpxe"
|
||||||
version = "0.4.64"
|
version = "0.4.65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1162,7 +1162,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-core"
|
name = "openpxe-core"
|
||||||
version = "0.4.64"
|
version = "0.4.65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -1181,7 +1181,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-dhcp-proxy"
|
name = "openpxe-dhcp-proxy"
|
||||||
version = "0.4.64"
|
version = "0.4.65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1195,7 +1195,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-http-api"
|
name = "openpxe-http-api"
|
||||||
version = "0.4.64"
|
version = "0.4.65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1226,7 +1226,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-ipxe-assets"
|
name = "openpxe-ipxe-assets"
|
||||||
version = "0.4.64"
|
version = "0.4.65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
@@ -1236,7 +1236,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-iso-store"
|
name = "openpxe-iso-store"
|
||||||
version = "0.4.64"
|
version = "0.4.65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -1260,7 +1260,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-tftp"
|
name = "openpxe-tftp"
|
||||||
version = "0.4.64"
|
version = "0.4.65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1274,7 +1274,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-webui"
|
name = "openpxe-webui"
|
||||||
version = "0.4.64"
|
version = "0.4.65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.64"
|
version = "0.4.65"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ use openpxe_core::{
|
|||||||
MAX_LOGO_BYTES,
|
MAX_LOGO_BYTES,
|
||||||
};
|
};
|
||||||
use openpxe_ipxe_assets::asset_bytes;
|
use openpxe_ipxe_assets::asset_bytes;
|
||||||
use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest};
|
use openpxe_iso_store::{IsoCategory, IsoMeta, LocalDirAddRequest, NfsAddRequest};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -136,6 +136,13 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
|
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
|
||||||
.route("/api/nfs/:id", delete(api_nfs_remove))
|
.route("/api/nfs/:id", delete(api_nfs_remove))
|
||||||
.route("/api/nfs/:id/scan", post(api_nfs_scan))
|
.route("/api/nfs/:id/scan", post(api_nfs_scan))
|
||||||
|
// v0.4.65: host-kernel NFS capability probe + bind-mounted
|
||||||
|
// local-directory source (works on Unraid / restricted-SCC
|
||||||
|
// hosts that can't kernel-mount NFS inside the container).
|
||||||
|
.route("/api/nfs/capabilities", get(api_nfs_capabilities))
|
||||||
|
.route("/api/local-dirs", get(api_local_dirs_list).post(api_local_dirs_add))
|
||||||
|
.route("/api/local-dirs/:id", delete(api_local_dirs_remove))
|
||||||
|
.route("/api/local-dirs/:id/scan", post(api_local_dirs_scan))
|
||||||
// Phase 4: Network info (read-only) + DNS edit.
|
// Phase 4: Network info (read-only) + DNS edit.
|
||||||
.route("/api/network", get(api_network).put(api_network_put))
|
.route("/api/network", get(api_network).put(api_network_put))
|
||||||
// Phase 4: live-log stream + recent buffer for the Terminal tab.
|
// Phase 4: live-log stream + recent buffer for the Terminal tab.
|
||||||
@@ -1032,11 +1039,26 @@ async fn api_docs() -> Json<serde_json::Value> {
|
|||||||
{"method": "GET", "path": "/api/nfs",
|
{"method": "GET", "path": "/api/nfs",
|
||||||
"summary": "List configured NFS shares with mount state and iso counts."},
|
"summary": "List configured NFS shares with mount state and iso counts."},
|
||||||
{"method": "POST", "path": "/api/nfs",
|
{"method": "POST", "path": "/api/nfs",
|
||||||
"summary": "Mount an NFS share. Body: { server, export, version, read_only }."},
|
"summary": "Mount an NFS share. Body: { server, export, version, read_only, port? }."},
|
||||||
{"method": "DELETE", "path": "/api/nfs/:id",
|
{"method": "DELETE", "path": "/api/nfs/:id",
|
||||||
"summary": "Unmount a share and drop its entries from the ISO store."},
|
"summary": "Unmount a share and drop its entries from the ISO store."},
|
||||||
{"method": "POST", "path": "/api/nfs/:id/scan",
|
{"method": "POST", "path": "/api/nfs/:id/scan",
|
||||||
"summary": "Re-walk a mounted share for ISOs."},
|
"summary": "Re-walk a mounted share for ISOs."},
|
||||||
|
{"method": "GET", "path": "/api/nfs/capabilities",
|
||||||
|
"summary": "Host-kernel NFS client support snapshot from /proc/filesystems."},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Local directories (v0.4.65)",
|
||||||
|
"endpoints": [
|
||||||
|
{"method": "GET", "path": "/api/local-dirs",
|
||||||
|
"summary": "List bind-mounted host directories registered as ISO sources."},
|
||||||
|
{"method": "POST", "path": "/api/local-dirs",
|
||||||
|
"summary": "Register a bind-mounted directory. Body: { path, label? }."},
|
||||||
|
{"method": "DELETE", "path": "/api/local-dirs/:id",
|
||||||
|
"summary": "Unregister a directory and drop its entries from the ISO store."},
|
||||||
|
{"method": "POST", "path": "/api/local-dirs/:id/scan",
|
||||||
|
"summary": "Re-walk a registered directory for ISOs."},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1736,6 +1758,62 @@ async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<Stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── v0.4.65: host-kernel NFS capability probe ─────────────────────────────
|
||||||
|
|
||||||
|
async fn api_nfs_capabilities(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||||
|
// The host caps are snapshotted at startup (pure read of
|
||||||
|
// /proc/filesystems) so this handler is just a JSON projection.
|
||||||
|
// The Storage tab uses `available` to decide whether to show the
|
||||||
|
// "your kernel doesn't have NFS client support" banner above the
|
||||||
|
// NFS form.
|
||||||
|
let c = &state.nfs_host_caps;
|
||||||
|
Json(json!({
|
||||||
|
"available": c.available,
|
||||||
|
"has_nfs3": c.has_nfs3,
|
||||||
|
"has_nfs4": c.has_nfs4,
|
||||||
|
"detail": c.detail,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── v0.4.65: bind-mounted local directories ───────────────────────────────
|
||||||
|
|
||||||
|
async fn api_local_dirs_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||||
|
Json(json!({ "directories": state.local_dirs.list() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_local_dirs_add(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<LocalDirAddRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match state.local_dirs.add(req).await {
|
||||||
|
Ok(d) => (StatusCode::CREATED, Json(d)).into_response(),
|
||||||
|
// The manager's errors are always operator-actionable
|
||||||
|
// (relative path / missing path / not-a-directory), so we
|
||||||
|
// surface them verbatim as 400s.
|
||||||
|
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_local_dirs_remove(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<String>,
|
||||||
|
) -> Response {
|
||||||
|
match state.local_dirs.remove(&id).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_local_dirs_scan(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<String>,
|
||||||
|
) -> Response {
|
||||||
|
match state.local_dirs.rescan(&id).await {
|
||||||
|
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
||||||
|
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Network info API ──────────────────────────────────────────────────────
|
// ─── Network info API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use openpxe_core::{
|
|||||||
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
||||||
Metrics, SettingsStore, SsoStore,
|
Metrics, SettingsStore, SsoStore,
|
||||||
};
|
};
|
||||||
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
|
use openpxe_iso_store::{IsoStore, LocalDirManager, NfsHostCaps, NfsManager, SmbManager};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
@@ -49,6 +49,17 @@ pub struct AppState {
|
|||||||
/// available in the runtime image. Surfaces errors per-mount rather
|
/// available in the runtime image. Surfaces errors per-mount rather
|
||||||
/// than failing the global state.
|
/// than failing the global state.
|
||||||
pub nfs: NfsManager,
|
pub nfs: NfsManager,
|
||||||
|
/// v0.4.65: bind-mounted host directories surfaced as ISO sources.
|
||||||
|
/// The container-friendly workaround for hosts (Unraid is the
|
||||||
|
/// dominant case) whose kernel lacks NFS client modules. Always
|
||||||
|
/// present; the operator opts in by registering paths from the
|
||||||
|
/// Storage tab.
|
||||||
|
pub local_dirs: LocalDirManager,
|
||||||
|
/// v0.4.65: host-kernel capability snapshot (from
|
||||||
|
/// `/proc/filesystems`). Snapshotted once at startup; the Storage
|
||||||
|
/// tab uses `available` to decide whether to show a "your kernel
|
||||||
|
/// doesn't have NFS client support" banner above the NFS form.
|
||||||
|
pub nfs_host_caps: NfsHostCaps,
|
||||||
/// Browser chunked upload state. Multipart uploads still go straight
|
/// Browser chunked upload state. Multipart uploads still go straight
|
||||||
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
|
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
|
||||||
/// can show deterministic progress and leave visible partial files.
|
/// can show deterministic progress and leave visible partial files.
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ fn isos_text(s: &AppState) -> String {
|
|||||||
let src = match i.source {
|
let src = match i.source {
|
||||||
openpxe_iso_store::IsoSource::Local => "local".to_string(),
|
openpxe_iso_store::IsoSource::Local => "local".to_string(),
|
||||||
openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
|
openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
|
||||||
|
// v0.4.65: bind-mounted host directory.
|
||||||
|
openpxe_iso_store::IsoSource::LocalDir { dir_id, .. } => format!("dir:{dir_id}"),
|
||||||
};
|
};
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use axum::body::Body;
|
|||||||
use axum::http::{header, Request, StatusCode};
|
use axum::http::{header, Request, StatusCode};
|
||||||
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
|
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
|
||||||
use openpxe_http_api::{build_router, AppState};
|
use openpxe_http_api::{build_router, AppState};
|
||||||
use openpxe_iso_store::{IsoStore, NfsManager};
|
use openpxe_iso_store::{IsoStore, LocalDirManager, NfsHostCaps, NfsManager};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
@@ -96,6 +96,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
let settings = SettingsStore::load_or_default(dir.path());
|
let settings = SettingsStore::load_or_default(dir.path());
|
||||||
let nfs = NfsManager::new(dir.path(), iso_store.clone());
|
let nfs = NfsManager::new(dir.path(), iso_store.clone());
|
||||||
iso_store.set_nfs_root(nfs.mount_root());
|
iso_store.set_nfs_root(nfs.mount_root());
|
||||||
|
let local_dirs = LocalDirManager::new(dir.path(), iso_store.clone());
|
||||||
|
let nfs_host_caps = NfsHostCaps::detect();
|
||||||
let log_bus = LogBus::new(64);
|
let log_bus = LogBus::new(64);
|
||||||
let hosts = HostBindings::load_or_default(dir.path());
|
let hosts = HostBindings::load_or_default(dir.path());
|
||||||
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
|
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
|
||||||
@@ -118,6 +120,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
metrics,
|
metrics,
|
||||||
smb: None,
|
smb: None,
|
||||||
nfs,
|
nfs,
|
||||||
|
local_dirs,
|
||||||
|
nfs_host_caps,
|
||||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||||
log_bus,
|
log_bus,
|
||||||
started_at: time::OffsetDateTime::now_utc(),
|
started_at: time::OffsetDateTime::now_utc(),
|
||||||
@@ -495,6 +499,96 @@ async fn nfs_list_starts_empty() {
|
|||||||
assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
|
assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.4.65: host kernel caps + local directories ───────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn nfs_capabilities_endpoint_returns_a_snapshot() {
|
||||||
|
// The endpoint must always answer 200 with the shape the UI
|
||||||
|
// expects, regardless of what `/proc/filesystems` actually says on
|
||||||
|
// the test host. (CI runs both with and without NFS modules.)
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state);
|
||||||
|
let (s, b) = get(&app, "/api/nfs/capabilities").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||||
|
assert!(v["available"].is_boolean());
|
||||||
|
assert!(v["has_nfs3"].is_boolean());
|
||||||
|
assert!(v["has_nfs4"].is_boolean());
|
||||||
|
assert!(v["detail"].is_string());
|
||||||
|
// Consistency: availability is true iff at least one of the
|
||||||
|
// booleans is true.
|
||||||
|
let derived = v["has_nfs3"].as_bool().unwrap() || v["has_nfs4"].as_bool().unwrap();
|
||||||
|
assert_eq!(v["available"].as_bool().unwrap(), derived);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_dirs_list_starts_empty() {
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state);
|
||||||
|
let (s, b) = get(&app, "/api/local-dirs").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||||
|
assert_eq!(v["directories"].as_array().unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_dirs_add_rejects_relative_path() {
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state);
|
||||||
|
let (s, b) = post_json(
|
||||||
|
&app,
|
||||||
|
"/api/local-dirs",
|
||||||
|
r#"{"path":"relative/path"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
let msg = String::from_utf8_lossy(&b);
|
||||||
|
assert!(
|
||||||
|
msg.to_lowercase().contains("absolute"),
|
||||||
|
"expected absolute-path hint, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_dirs_add_then_remove_round_trip() {
|
||||||
|
let (state, work_dir) = build_state().await;
|
||||||
|
let app = build_router(state);
|
||||||
|
// Use a fresh subdirectory of the test's work_dir as the bind-
|
||||||
|
// mount stand-in. It exists, is readable, contains no ISOs.
|
||||||
|
let dir = work_dir.path().join("external-isos");
|
||||||
|
std::fs::create_dir(&dir).unwrap();
|
||||||
|
let body = format!(
|
||||||
|
r#"{{"path":"{}","label":"my-isos"}}"#,
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
let (s, b) = post_json(&app, "/api/local-dirs", &body).await;
|
||||||
|
assert_eq!(s, StatusCode::CREATED, "add: {}", String::from_utf8_lossy(&b));
|
||||||
|
let added: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||||
|
let id = added["id"].as_str().unwrap().to_string();
|
||||||
|
assert_eq!(added["label"], "my-isos");
|
||||||
|
assert_eq!(added["iso_count"], 0);
|
||||||
|
|
||||||
|
// List should now show one row.
|
||||||
|
let (s, b) = get(&app, "/api/local-dirs").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||||
|
assert_eq!(v["directories"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
// Remove (DELETE) clears the row.
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("DELETE")
|
||||||
|
.uri(format!("/api/local-dirs/{id}"))
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let (s, b) = get(&app, "/api/local-dirs").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||||
|
assert_eq!(v["directories"].as_array().unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn terminal_help_and_status_round_trip() {
|
async fn terminal_help_and_status_round_trip() {
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
pub mod entry;
|
pub mod entry;
|
||||||
pub mod introspect;
|
pub mod introspect;
|
||||||
|
pub mod local_dir;
|
||||||
pub mod nfs;
|
pub mod nfs;
|
||||||
pub mod pxe_logo;
|
pub mod pxe_logo;
|
||||||
pub mod smb;
|
pub mod smb;
|
||||||
@@ -26,7 +27,8 @@ pub mod windows;
|
|||||||
|
|
||||||
pub use entry::{BootEntry, BootKind, KernelArgs};
|
pub use entry::{BootEntry, BootKind, KernelArgs};
|
||||||
pub use introspect::{DistroFamily, IntrospectionReport};
|
pub use introspect::{DistroFamily, IntrospectionReport};
|
||||||
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion};
|
pub use local_dir::{LocalDirAddRequest, LocalDirManager, LocalDirSpec};
|
||||||
|
pub use nfs::{NfsAddRequest, NfsHostCaps, NfsManager, NfsMount, NfsVersion};
|
||||||
pub use smb::{extract_windows_iso, SmbManager, SmbState};
|
pub use smb::{extract_windows_iso, SmbManager, SmbState};
|
||||||
pub use store::{
|
pub use store::{
|
||||||
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
|
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
//! Local-directory ISO source.
|
||||||
|
//!
|
||||||
|
//! v0.4.65: operators on container hosts that lack kernel NFS client
|
||||||
|
//! modules (Unraid is the dominant case) can't successfully `mount -t
|
||||||
|
//! nfs ...` inside the container regardless of `CAP_SYS_ADMIN` —
|
||||||
|
//! mount(2) returns EOPNOTSUPP or EINVAL, and nfs-utils translates that
|
||||||
|
//! into the deeply unhelpful `mount.nfs: failed to apply fstab options`
|
||||||
|
//! message we wired diagnostics for in v0.4.64.
|
||||||
|
//!
|
||||||
|
//! The pragmatic workaround used by Bootimus, iVentoy, FOG, MAAS, and
|
||||||
|
//! pretty much every other PXE/imaging tool is: don't try to mount
|
||||||
|
//! network storage inside the PXE server. Instead the *host* mounts the
|
||||||
|
//! remote share (Unraid's Unassigned Devices plugin, Synology's File
|
||||||
|
//! Station, mount.nfs at the OS level, …) and *bind-mounts* the
|
||||||
|
//! resulting local path into the container. The PXE server then reads
|
||||||
|
//! ISOs from a regular directory on disk — no protocol work, no
|
||||||
|
//! capabilities, no kernel module dependency.
|
||||||
|
//!
|
||||||
|
//! `LocalDirManager` is the in-container side of that workflow. The
|
||||||
|
//! operator pastes the path of a bind-mounted directory; we validate it
|
||||||
|
//! exists and is readable, walk it for `*.iso` files, register each one
|
||||||
|
//! with the `IsoStore` as an `IsoSource::LocalDir` entry, and persist
|
||||||
|
//! the spec to `<work_dir>/local_dirs.json` so the relationship survives
|
||||||
|
//! restarts.
|
||||||
|
//!
|
||||||
|
//! The intentional differences from `NfsManager`:
|
||||||
|
//!
|
||||||
|
//! - **No mount step.** The path must already be a directory; we never
|
||||||
|
//! shell out to `mount`. Any failure mode is a clean operator error
|
||||||
|
//! surfaced in the UI.
|
||||||
|
//! - **Path is the identity.** We slugify the absolute path so the
|
||||||
|
//! operator can re-add the same directory idempotently. (Two
|
||||||
|
//! directories with the same trailing component get distinct ids
|
||||||
|
//! because the full path goes into the slug.)
|
||||||
|
//! - **No password complexity.** Bytes live outside OpenPXE's control;
|
||||||
|
//! we don't try to gate them with the per-ISO password feature.
|
||||||
|
//!
|
||||||
|
//! Re-scans on startup are best-effort: if the bind mount was removed
|
||||||
|
//! before the container came back up, the directory will fail
|
||||||
|
//! validation, we log a warning, and surface the error in the UI row.
|
||||||
|
//! The spec itself stays persisted so a re-introduced bind mount picks
|
||||||
|
//! up where it left off.
|
||||||
|
|
||||||
|
use crate::introspect::{introspect, IntrospectionReport};
|
||||||
|
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||||
|
use openpxe_core::{Error, Result};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
/// One operator-registered directory. The id is a slug of the absolute
|
||||||
|
/// path so an operator who pastes the same path twice gets idempotent
|
||||||
|
/// behaviour rather than a duplicate row.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LocalDirSpec {
|
||||||
|
pub id: String,
|
||||||
|
/// Absolute path inside the container. Validated to be a directory
|
||||||
|
/// on every scan; never traversed for shell-special characters
|
||||||
|
/// because we only ever read from it (no command construction).
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// Optional friendly name for the UI row. Defaults to the trailing
|
||||||
|
/// path component when not provided.
|
||||||
|
pub label: String,
|
||||||
|
/// Most recent error encountered scanning this directory, or `None`
|
||||||
|
/// on success. Mirrors the `last_error` / `last_hint` pair on
|
||||||
|
/// `NfsMount` so the UI can render local-dir issues the same way it
|
||||||
|
/// renders NFS-mount issues.
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub last_hint: Option<String>,
|
||||||
|
#[serde(with = "time::serde::rfc3339::option")]
|
||||||
|
pub last_scan: Option<OffsetDateTime>,
|
||||||
|
/// Number of `*.iso` files registered from this directory on the
|
||||||
|
/// most recent scan.
|
||||||
|
pub iso_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submission from the UI / API.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct LocalDirAddRequest {
|
||||||
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Inner {
|
||||||
|
dirs: HashMap<String, LocalDirSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages bind-mounted host directories and surfaces their ISOs.
|
||||||
|
///
|
||||||
|
/// Cheap to clone — internal state is behind `Arc<Mutex<...>>`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LocalDirManager {
|
||||||
|
state_path: Arc<PathBuf>,
|
||||||
|
inner: Arc<Mutex<Inner>>,
|
||||||
|
iso_store: IsoStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalDirManager {
|
||||||
|
/// Construct a manager that persists state to
|
||||||
|
/// `<work_dir>/local_dirs.json`. Mount points are not under our
|
||||||
|
/// control — the bind mount is supplied by the operator's container
|
||||||
|
/// runtime — so unlike `NfsManager` we don't take a `work_dir` for
|
||||||
|
/// our own filesystem state besides this single JSON file.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
|
||||||
|
Self {
|
||||||
|
state_path: Arc::new(work_dir.join("local_dirs.json")),
|
||||||
|
inner: Arc::new(Mutex::new(Inner::default())),
|
||||||
|
iso_store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load persisted state and rescan every directory. Errors per
|
||||||
|
/// directory are logged and surfaced on the spec; the call itself
|
||||||
|
/// never fails — startup must not block on a single missing
|
||||||
|
/// bind mount.
|
||||||
|
pub async fn load_and_rescan(&self) -> Result<()> {
|
||||||
|
let specs = match tokio::fs::read_to_string(self.state_path.as_path()).await {
|
||||||
|
Ok(text) => serde_json::from_str::<Vec<LocalDirSpec>>(&text).unwrap_or_default(),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
for mut spec in specs {
|
||||||
|
spec.last_error = None;
|
||||||
|
spec.last_hint = None;
|
||||||
|
self.inner.lock().dirs.insert(spec.id.clone(), spec.clone());
|
||||||
|
// Register the mapping early so even a failed rescan keeps
|
||||||
|
// any path resolution wired up correctly.
|
||||||
|
self.iso_store
|
||||||
|
.register_local_dir(spec.id.clone(), spec.path.clone());
|
||||||
|
if let Err(e) = self.rescan_inner(&spec.id).await {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "openpxe::local_dir",
|
||||||
|
id = %spec.id, path = %spec.path.display(),
|
||||||
|
"rescan on startup failed: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or refresh a directory. Validates the path is absolute,
|
||||||
|
/// exists, and is a directory. On success the directory's ISOs are
|
||||||
|
/// registered with the `IsoStore` and the spec is persisted.
|
||||||
|
pub async fn add(&self, req: LocalDirAddRequest) -> Result<LocalDirSpec> {
|
||||||
|
let path = PathBuf::from(req.path.trim());
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return Err(Error::Invalid("path is required".into()));
|
||||||
|
}
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err(Error::Invalid(format!(
|
||||||
|
"path must be absolute (got '{}'); bind-mount the source on \
|
||||||
|
the host then enter the path inside the container",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let meta = match tokio::fs::metadata(&path).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
return Err(Error::Invalid(format!(
|
||||||
|
"path '{}' is not accessible: {e}. Verify the bind mount \
|
||||||
|
exists inside the container",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !meta.is_dir() {
|
||||||
|
return Err(Error::Invalid(format!(
|
||||||
|
"path '{}' exists but is not a directory",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = slugify_str(&path.display().to_string());
|
||||||
|
let label = req
|
||||||
|
.label
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| id.clone());
|
||||||
|
|
||||||
|
let spec = LocalDirSpec {
|
||||||
|
id: id.clone(),
|
||||||
|
path: path.clone(),
|
||||||
|
label,
|
||||||
|
last_error: None,
|
||||||
|
last_hint: None,
|
||||||
|
last_scan: None,
|
||||||
|
iso_count: 0,
|
||||||
|
};
|
||||||
|
self.inner.lock().dirs.insert(id.clone(), spec);
|
||||||
|
self.iso_store.register_local_dir(id.clone(), path);
|
||||||
|
self.persist_locked();
|
||||||
|
self.rescan_inner(&id).await?;
|
||||||
|
Ok(self.get(&id).expect("just inserted"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a directory: drops every ISO sourced from it and forgets
|
||||||
|
/// the spec. Idempotent.
|
||||||
|
///
|
||||||
|
/// Kept `async` for symmetry with `NfsManager::remove` (which does
|
||||||
|
/// shell out to `umount`) so the HTTP handlers can treat both
|
||||||
|
/// managers identically. The body is sync today; a future audit
|
||||||
|
/// log or scrub-on-remove I/O step would plug in here without a
|
||||||
|
/// signature change.
|
||||||
|
#[allow(clippy::unused_async)]
|
||||||
|
pub async fn remove(&self, id: &str) -> Result<()> {
|
||||||
|
self.inner.lock().dirs.remove(id);
|
||||||
|
self.iso_store.unregister_local_dir(id);
|
||||||
|
self.iso_store.drop_local_dir_source(id);
|
||||||
|
self.persist_locked();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually rescan a directory — picks up newly-added ISOs without
|
||||||
|
/// removing & re-adding the directory.
|
||||||
|
pub async fn rescan(&self, id: &str) -> Result<u32> {
|
||||||
|
self.rescan_inner(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of every registered directory, sorted by id for stable
|
||||||
|
/// UI rendering.
|
||||||
|
#[must_use]
|
||||||
|
pub fn list(&self) -> Vec<LocalDirSpec> {
|
||||||
|
let g = self.inner.lock();
|
||||||
|
let mut v: Vec<_> = g.dirs.values().cloned().collect();
|
||||||
|
v.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up one spec by id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self, id: &str) -> Option<LocalDirSpec> {
|
||||||
|
self.inner.lock().dirs.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn rescan_inner(&self, id: &str) -> Result<u32> {
|
||||||
|
let spec = self
|
||||||
|
.get(id)
|
||||||
|
.ok_or_else(|| Error::Invalid(format!("no such directory '{id}'")))?;
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
|
||||||
|
// Drop prior entries first so a deleted file disappears from the
|
||||||
|
// store on the next rescan. Mirrors NfsManager::scan_and_register.
|
||||||
|
self.iso_store.drop_local_dir_source(id);
|
||||||
|
|
||||||
|
let mut walker = match tokio::fs::read_dir(&spec.path).await {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
let err = format!("cannot read directory: {e}");
|
||||||
|
let hint = Some(
|
||||||
|
"Verify the bind mount still exists inside the container \
|
||||||
|
and that the openpxe user can read it (uid 10001)."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
self.update_status(id, 0, Some(err.clone()), hint, now);
|
||||||
|
return Err(Error::Invalid(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut count = 0u32;
|
||||||
|
loop {
|
||||||
|
let entry = match walker.next_entry().await {
|
||||||
|
Ok(Some(e)) => e,
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(e) => {
|
||||||
|
let err = format!("read_dir iteration failed: {e}");
|
||||||
|
self.update_status(id, count, Some(err.clone()), None, now);
|
||||||
|
return Err(Error::Invalid(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let p = entry.path();
|
||||||
|
if p.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(str::to_ascii_lowercase)
|
||||||
|
.as_deref()
|
||||||
|
!= Some("iso")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let filename = match p.file_name().and_then(|s| s.to_str()) {
|
||||||
|
Some(f) => f.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let size = match tokio::fs::metadata(&p).await {
|
||||||
|
Ok(m) => m.len(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "openpxe::local_dir",
|
||||||
|
id = %id, file = %filename, "stat failed: {e}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let p_owned = p.clone();
|
||||||
|
let report: IntrospectionReport =
|
||||||
|
match tokio::task::spawn_blocking(move || introspect(&p_owned)).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "openpxe::local_dir",
|
||||||
|
id = %id, file = %filename,
|
||||||
|
"introspection task panicked: {e}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let iso_id = format!("local-{id}-{}", slugify_str(&filename));
|
||||||
|
let boot_entries = generate_boot_entries_for(&iso_id, &filename, &report);
|
||||||
|
let source = IsoSource::LocalDir {
|
||||||
|
dir_id: id.to_string(),
|
||||||
|
relative_path: filename.clone(),
|
||||||
|
};
|
||||||
|
self.iso_store.register_external(
|
||||||
|
iso_id,
|
||||||
|
filename,
|
||||||
|
size,
|
||||||
|
report,
|
||||||
|
boot_entries,
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_status(id, count, None, None, now);
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::local_dir",
|
||||||
|
id = %id, path = %spec.path.display(), iso_count = count,
|
||||||
|
"local directory rescanned"
|
||||||
|
);
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_status(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
iso_count: u32,
|
||||||
|
err: Option<String>,
|
||||||
|
hint: Option<String>,
|
||||||
|
ts: OffsetDateTime,
|
||||||
|
) {
|
||||||
|
if let Some(s) = self.inner.lock().dirs.get_mut(id) {
|
||||||
|
s.iso_count = iso_count;
|
||||||
|
s.last_error = err;
|
||||||
|
s.last_hint = hint;
|
||||||
|
s.last_scan = Some(ts);
|
||||||
|
}
|
||||||
|
self.persist_locked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically replace the on-disk JSON with the current state.
|
||||||
|
/// Errors are logged, never propagated — settings live in memory
|
||||||
|
/// authoritatively, matching every other store in this crate.
|
||||||
|
fn persist_locked(&self) {
|
||||||
|
let dirs: Vec<LocalDirSpec> = self.inner.lock().dirs.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(&dirs) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(target: "openpxe::local_dir", "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::local_dir", "write tmp: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||||
|
tracing::warn!(target: "openpxe::local_dir", "rename: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn make_manager() -> (LocalDirManager, IsoStore, tempfile::TempDir) {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let iso = IsoStore::new(tmp.path().join("isos"));
|
||||||
|
let m = LocalDirManager::new(tmp.path(), iso.clone());
|
||||||
|
(m, iso, tmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_relative_paths() {
|
||||||
|
let (m, _iso, _tmp) = make_manager();
|
||||||
|
let err = m
|
||||||
|
.add(LocalDirAddRequest {
|
||||||
|
path: "relative/path".into(),
|
||||||
|
label: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(format!("{err}").to_lowercase().contains("absolute"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_missing_paths() {
|
||||||
|
let (m, _iso, _tmp) = make_manager();
|
||||||
|
let err = m
|
||||||
|
.add(LocalDirAddRequest {
|
||||||
|
path: "/nonexistent/path/should/not/be/here".into(),
|
||||||
|
label: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
let s = format!("{err}").to_lowercase();
|
||||||
|
assert!(s.contains("not accessible") || s.contains("bind mount"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_files_that_are_not_directories() {
|
||||||
|
let (m, _iso, tmp) = make_manager();
|
||||||
|
let file = tmp.path().join("a_file.txt");
|
||||||
|
std::fs::write(&file, b"hi").unwrap();
|
||||||
|
let err = m
|
||||||
|
.add(LocalDirAddRequest {
|
||||||
|
path: file.to_string_lossy().into_owned(),
|
||||||
|
label: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(format!("{err}").to_lowercase().contains("not a directory"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_directory_registers_with_zero_isos() {
|
||||||
|
let (m, _iso, tmp) = make_manager();
|
||||||
|
let empty = tmp.path().join("empty");
|
||||||
|
std::fs::create_dir(&empty).unwrap();
|
||||||
|
let spec = m
|
||||||
|
.add(LocalDirAddRequest {
|
||||||
|
path: empty.to_string_lossy().into_owned(),
|
||||||
|
label: Some("My empty dir".into()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spec.iso_count, 0);
|
||||||
|
assert_eq!(spec.label, "My empty dir");
|
||||||
|
assert!(spec.last_error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn label_defaults_to_trailing_path_component() {
|
||||||
|
let (m, _iso, tmp) = make_manager();
|
||||||
|
let d = tmp.path().join("my-isos");
|
||||||
|
std::fs::create_dir(&d).unwrap();
|
||||||
|
let spec = m
|
||||||
|
.add(LocalDirAddRequest {
|
||||||
|
path: d.to_string_lossy().into_owned(),
|
||||||
|
label: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spec.label, "my-isos");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn re_adding_same_path_is_idempotent() {
|
||||||
|
let (mgr, _iso, tmp) = make_manager();
|
||||||
|
let dir = tmp.path().join("re-add");
|
||||||
|
std::fs::create_dir(&dir).unwrap();
|
||||||
|
let path = dir.to_string_lossy().into_owned();
|
||||||
|
let first = mgr
|
||||||
|
.add(LocalDirAddRequest {
|
||||||
|
path: path.clone(),
|
||||||
|
label: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let second = mgr
|
||||||
|
.add(LocalDirAddRequest { path, label: None })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.id, second.id);
|
||||||
|
assert_eq!(mgr.list().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remove_drops_the_directory_and_clears_iso_store_path() {
|
||||||
|
let (m, iso, tmp) = make_manager();
|
||||||
|
let d = tmp.path().join("to-remove");
|
||||||
|
std::fs::create_dir(&d).unwrap();
|
||||||
|
let spec = m
|
||||||
|
.add(LocalDirAddRequest {
|
||||||
|
path: d.to_string_lossy().into_owned(),
|
||||||
|
label: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
m.remove(&spec.id).await.unwrap();
|
||||||
|
assert!(m.get(&spec.id).is_none());
|
||||||
|
// After unregister, the IsoStore can no longer resolve a path
|
||||||
|
// through this dir_id — confirms the mapping was dropped.
|
||||||
|
assert!(iso
|
||||||
|
.iso_path_for(&format!("local-{}-anything", spec.id))
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,73 @@ use tokio::process::Command;
|
|||||||
/// pre-flight probe and the option string assembly use the same value.
|
/// pre-flight probe and the option string assembly use the same value.
|
||||||
const DEFAULT_NFS_PORT: u16 = 2049;
|
const DEFAULT_NFS_PORT: u16 = 2049;
|
||||||
|
|
||||||
|
/// What the host kernel can do, from the container's point of view.
|
||||||
|
///
|
||||||
|
/// v0.4.65: we read this once at startup so the Storage tab can show
|
||||||
|
/// a prominent banner when in-container NFS mounts simply cannot
|
||||||
|
/// succeed regardless of CAP_SYS_ADMIN — the dominant case being
|
||||||
|
/// Unraid, whose base kernel ships without the `nfs` / `nfsv4` client
|
||||||
|
/// modules loaded. There's nothing the operator can do from inside the
|
||||||
|
/// container in that scenario; the right answer is to mount the share
|
||||||
|
/// on the host and bind-mount the path into OpenPXE (see
|
||||||
|
/// `LocalDirManager`).
|
||||||
|
///
|
||||||
|
/// `available` is `true` if `/proc/filesystems` contains *either* `nfs`
|
||||||
|
/// or `nfs4`. `raw` is the matched lines (or a short marker), retained
|
||||||
|
/// so we can show "what we actually saw" in the UI for support.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct NfsHostCaps {
|
||||||
|
pub available: bool,
|
||||||
|
pub has_nfs3: bool,
|
||||||
|
pub has_nfs4: bool,
|
||||||
|
/// `/proc/filesystems` lines we matched, joined with `\n`. Empty
|
||||||
|
/// when nothing matched, `"unreadable"` if the probe itself failed.
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NfsHostCaps {
|
||||||
|
/// Probe `/proc/filesystems` for NFS client filesystem support.
|
||||||
|
/// Pure read, no side effects — safe to call repeatedly. Reads
|
||||||
|
/// synchronously because `/proc/filesystems` is virtual and never
|
||||||
|
/// blocks.
|
||||||
|
#[must_use]
|
||||||
|
pub fn detect() -> Self {
|
||||||
|
let Ok(raw) = std::fs::read_to_string("/proc/filesystems") else {
|
||||||
|
return Self {
|
||||||
|
available: false,
|
||||||
|
has_nfs3: false,
|
||||||
|
has_nfs4: false,
|
||||||
|
detail: "unreadable".into(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// Each line is "nodev\tname" or "\tname". We tokenize on
|
||||||
|
// whitespace and check the last token.
|
||||||
|
let mut has_nfs3 = false;
|
||||||
|
let mut has_nfs4 = false;
|
||||||
|
let mut matched: Vec<&str> = Vec::new();
|
||||||
|
for line in raw.lines() {
|
||||||
|
let name = line.split_whitespace().last().unwrap_or("");
|
||||||
|
match name {
|
||||||
|
"nfs" | "nfs3" => {
|
||||||
|
has_nfs3 = true;
|
||||||
|
matched.push(line.trim());
|
||||||
|
}
|
||||||
|
"nfs4" | "nfsv4" => {
|
||||||
|
has_nfs4 = true;
|
||||||
|
matched.push(line.trim());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
available: has_nfs3 || has_nfs4,
|
||||||
|
has_nfs3,
|
||||||
|
has_nfs4,
|
||||||
|
detail: matched.join("\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How long to wait for a TCP connection to the NFS server before
|
/// How long to wait for a TCP connection to the NFS server before
|
||||||
/// declaring it unreachable. Short enough that a wrong IP doesn't make
|
/// declaring it unreachable. Short enough that a wrong IP doesn't make
|
||||||
/// the UI hang for half a minute; long enough that a slow appliance
|
/// the UI hang for half a minute; long enough that a slow appliance
|
||||||
@@ -981,4 +1048,15 @@ mod tests {
|
|||||||
"mount.nfs: access denied"
|
"mount.nfs: access denied"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nfs_host_caps_detect_is_consistent() {
|
||||||
|
// The detect() probe should never panic and the boolean
|
||||||
|
// accessors should agree with each other.
|
||||||
|
let c = NfsHostCaps::detect();
|
||||||
|
assert_eq!(c.available, c.has_nfs3 || c.has_nfs4);
|
||||||
|
// Whatever the result, `detail` is always a string we can
|
||||||
|
// present in the UI (possibly empty, possibly "unreadable").
|
||||||
|
let _ = c.detail;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,15 @@ use tokio::io::AsyncWriteExt;
|
|||||||
///
|
///
|
||||||
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
|
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
|
||||||
/// `Nfs` entries point at a file inside a remote share that the
|
/// `Nfs` entries point at a file inside a remote share that the
|
||||||
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
|
/// `NfsManager` is keeping mounted. `LocalDir` (v0.4.65) entries point
|
||||||
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
|
/// at a file inside an operator-registered directory that's
|
||||||
|
/// **bind-mounted into the container** from the host — the workaround
|
||||||
|
/// for environments (Unraid, OpenShift restricted SCC) where the host
|
||||||
|
/// kernel doesn't have NFS client modules loaded and in-container
|
||||||
|
/// mounts can't succeed regardless of CAP_SYS_ADMIN.
|
||||||
|
///
|
||||||
|
/// We resolve every variant's on-disk path lazily in
|
||||||
|
/// [`IsoStore::iso_path_for`].
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum IsoSource {
|
pub enum IsoSource {
|
||||||
@@ -29,6 +36,13 @@ pub enum IsoSource {
|
|||||||
/// Path relative to the mount point — typically just the filename.
|
/// Path relative to the mount point — typically just the filename.
|
||||||
relative_path: String,
|
relative_path: String,
|
||||||
},
|
},
|
||||||
|
/// v0.4.65: operator-registered host directory bind-mounted into the
|
||||||
|
/// container. Path resolution looks up `dir_id` in the IsoStore's
|
||||||
|
/// `local_dir_roots` map, then joins `relative_path`.
|
||||||
|
LocalDir {
|
||||||
|
dir_id: String,
|
||||||
|
relative_path: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the ISO lands in the PXE menu hierarchy.
|
/// Where the ISO lands in the PXE menu hierarchy.
|
||||||
@@ -168,6 +182,10 @@ pub struct IsoStore {
|
|||||||
/// [`IsoStore::set_nfs_root`]; required for resolving any
|
/// [`IsoStore::set_nfs_root`]; required for resolving any
|
||||||
/// `IsoSource::Nfs` entry.
|
/// `IsoSource::Nfs` entry.
|
||||||
nfs_root: Arc<RwLock<Option<PathBuf>>>,
|
nfs_root: Arc<RwLock<Option<PathBuf>>>,
|
||||||
|
/// v0.4.65: map of operator-registered directory id → absolute path
|
||||||
|
/// inside the container. Used to resolve `IsoSource::LocalDir`
|
||||||
|
/// entries to a real on-disk path. Maintained by `LocalDirManager`.
|
||||||
|
local_dir_roots: Arc<RwLock<HashMap<String, PathBuf>>>,
|
||||||
inner: Arc<RwLock<Inner>>,
|
inner: Arc<RwLock<Inner>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,10 +194,26 @@ impl IsoStore {
|
|||||||
Self {
|
Self {
|
||||||
iso_dir: Arc::new(iso_dir),
|
iso_dir: Arc::new(iso_dir),
|
||||||
nfs_root: Arc::new(RwLock::new(None)),
|
nfs_root: Arc::new(RwLock::new(None)),
|
||||||
|
local_dir_roots: Arc::new(RwLock::new(HashMap::new())),
|
||||||
inner: Arc::new(RwLock::new(Inner::default())),
|
inner: Arc::new(RwLock::new(Inner::default())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.4.65: register the bind-mounted host path under `dir_id` so
|
||||||
|
/// `iso_path_for` can resolve `IsoSource::LocalDir` entries. Called
|
||||||
|
/// by `LocalDirManager` when a directory is added or reloaded from
|
||||||
|
/// persisted state.
|
||||||
|
pub fn register_local_dir(&self, dir_id: String, path: PathBuf) {
|
||||||
|
self.local_dir_roots.write().insert(dir_id, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v0.4.65: drop the path mapping for `dir_id`. Existing
|
||||||
|
/// `IsoSource::LocalDir` entries referencing this id will start
|
||||||
|
/// resolving to `None` from `iso_path_for`.
|
||||||
|
pub fn unregister_local_dir(&self, dir_id: &str) {
|
||||||
|
self.local_dir_roots.write().remove(dir_id);
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the store where NFS mounts live. Without this set,
|
/// Tell the store where NFS mounts live. Without this set,
|
||||||
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
|
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
|
||||||
pub fn set_nfs_root(&self, root: PathBuf) {
|
pub fn set_nfs_root(&self, root: PathBuf) {
|
||||||
@@ -297,6 +331,19 @@ impl IsoStore {
|
|||||||
let root = self.nfs_root.read().clone()?;
|
let root = self.nfs_root.read().clone()?;
|
||||||
root.join(mount_id).join(relative_path)
|
root.join(mount_id).join(relative_path)
|
||||||
}
|
}
|
||||||
|
// v0.4.65: bind-mounted host directory. We look the dir_id
|
||||||
|
// up in local_dir_roots which is maintained by
|
||||||
|
// LocalDirManager. If LocalDirManager hasn't loaded yet (or
|
||||||
|
// the operator removed the directory) the resolution
|
||||||
|
// returns None and the HTTP handler 404s, same as a missing
|
||||||
|
// NFS mount.
|
||||||
|
IsoSource::LocalDir {
|
||||||
|
dir_id,
|
||||||
|
relative_path,
|
||||||
|
} => {
|
||||||
|
let root = self.local_dir_roots.read().get(dir_id).cloned()?;
|
||||||
|
root.join(relative_path)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
Some(path)
|
Some(path)
|
||||||
@@ -362,6 +409,16 @@ impl IsoStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.4.65: drop every entry that belongs to a registered local
|
||||||
|
/// directory. Used by `LocalDirManager` when an operator removes a
|
||||||
|
/// directory or before re-scanning to clean out stale entries.
|
||||||
|
pub fn drop_local_dir_source(&self, dir_id: &str) {
|
||||||
|
let mut g = self.inner.write();
|
||||||
|
g.isos.retain(
|
||||||
|
|_, m| !matches!(&m.source, IsoSource::LocalDir { dir_id: did, .. } if did == dir_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Set or clear an ISO's boot password.
|
/// Set or clear an ISO's boot password.
|
||||||
///
|
///
|
||||||
/// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for
|
/// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use openpxe_core::{
|
|||||||
};
|
};
|
||||||
use openpxe_dhcp_proxy::DhcpProxyServer;
|
use openpxe_dhcp_proxy::DhcpProxyServer;
|
||||||
use openpxe_http_api::{build_router, AppState};
|
use openpxe_http_api::{build_router, AppState};
|
||||||
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
|
use openpxe_iso_store::{IsoStore, LocalDirManager, NfsHostCaps, NfsManager, SmbManager};
|
||||||
use openpxe_tftp::TftpServer;
|
use openpxe_tftp::TftpServer;
|
||||||
use std::net::{Ipv4Addr, SocketAddr};
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -128,6 +128,39 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}");
|
tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.4.65: snapshot host kernel capabilities so the Storage tab can
|
||||||
|
// warn the operator early when in-container NFS mounts cannot
|
||||||
|
// possibly succeed (Unraid is the dominant case — its base kernel
|
||||||
|
// ships without nfs/nfsv4 client modules loaded). Pure read of
|
||||||
|
// /proc/filesystems; no side effects.
|
||||||
|
let nfs_host_caps = NfsHostCaps::detect();
|
||||||
|
if nfs_host_caps.available {
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::nfs",
|
||||||
|
v3 = nfs_host_caps.has_nfs3, v4 = nfs_host_caps.has_nfs4,
|
||||||
|
"host kernel NFS client support"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "openpxe::nfs",
|
||||||
|
detail = %nfs_host_caps.detail,
|
||||||
|
"host kernel lacks NFS client modules — in-container `mount -t nfs` will fail \
|
||||||
|
regardless of CAP_SYS_ADMIN. Use Local Directories instead."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.4.65: bind-mounted host directory manager. Provides the
|
||||||
|
// container-friendly ISO source that doesn't depend on kernel NFS
|
||||||
|
// support — the operator mounts the share on the host, bind-mounts
|
||||||
|
// the path into the container, and registers it here.
|
||||||
|
let local_dirs = LocalDirManager::new(&config.paths.work_dir, iso_store.clone());
|
||||||
|
if let Err(e) = local_dirs.load_and_rescan().await {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "openpxe::local_dir",
|
||||||
|
"could not reload local directories: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Sniff network details for the Network tab. None of these are
|
// Sniff network details for the Network tab. None of these are
|
||||||
// required for PXE to work — they're informational, surfaced in the
|
// required for PXE to work — they're informational, surfaced in the
|
||||||
// UI so an operator doesn't have to drop to a shell to find their
|
// UI so an operator doesn't have to drop to a shell to find their
|
||||||
@@ -153,6 +186,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
metrics: metrics.clone(),
|
metrics: metrics.clone(),
|
||||||
smb: Some(smb.clone()),
|
smb: Some(smb.clone()),
|
||||||
nfs: nfs.clone(),
|
nfs: nfs.clone(),
|
||||||
|
local_dirs: local_dirs.clone(),
|
||||||
|
nfs_host_caps: nfs_host_caps.clone(),
|
||||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||||
log_bus: log_bus.clone(),
|
log_bus: log_bus.clone(),
|
||||||
started_at: time::OffsetDateTime::now_utc(),
|
started_at: time::OffsetDateTime::now_utc(),
|
||||||
|
|||||||
+138
-12
@@ -342,13 +342,21 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
storage: async () => {
|
storage: async () => {
|
||||||
const [isos, settings, nfsRes, disk] = await Promise.all([
|
// v0.4.65: also fetch host NFS caps and local-dir registrations.
|
||||||
|
// Caps tell us whether kernel mounts can possibly work; local
|
||||||
|
// dirs are the bind-mount workaround for hosts that can't.
|
||||||
|
const [isos, settings, nfsRes, disk, caps, localRes] = await Promise.all([
|
||||||
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
|
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
|
||||||
getJSON('/api/storage/disk').catch(() => ({
|
getJSON('/api/storage/disk').catch(() => ({
|
||||||
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
||||||
})),
|
})),
|
||||||
|
getJSON('/api/nfs/capabilities').catch(() => ({
|
||||||
|
available: true, has_nfs3: true, has_nfs4: true, detail: '',
|
||||||
|
})),
|
||||||
|
getJSON('/api/local-dirs').catch(() => ({ directories: [] })),
|
||||||
]);
|
]);
|
||||||
const mounts = nfsRes.mounts || [];
|
const mounts = nfsRes.mounts || [];
|
||||||
|
const localDirs = localRes.directories || [];
|
||||||
|
|
||||||
// ── Upload card ──
|
// ── Upload card ──
|
||||||
const drop = el('div', {class:'drop', id:'drop'}, [
|
const drop = el('div', {class:'drop', id:'drop'}, [
|
||||||
@@ -459,6 +467,8 @@
|
|||||||
isos.forEach(i => {
|
isos.forEach(i => {
|
||||||
const b = bootability(i, settings);
|
const b = bootability(i, settings);
|
||||||
const isNfs = i.source && i.source.kind === 'nfs';
|
const isNfs = i.source && i.source.kind === 'nfs';
|
||||||
|
// v0.4.65: third source kind — bind-mounted host directory.
|
||||||
|
const isLocalDir = i.source && i.source.kind === 'local_dir';
|
||||||
const protectedNow = !!i.password_hash;
|
const protectedNow = !!i.password_hash;
|
||||||
|
|
||||||
// The inline editor row is hidden by default; the Password
|
// The inline editor row is hidden by default; the Password
|
||||||
@@ -578,8 +588,9 @@
|
|||||||
]),
|
]),
|
||||||
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
|
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
|
||||||
el('td', {},
|
el('td', {},
|
||||||
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
|
el('span', {class:'src-badge' + (isNfs ? ' nfs' : (isLocalDir ? ' nfs' : ''))},
|
||||||
isNfs ? ('nfs:' + i.source.mount_id) : 'local')),
|
isNfs ? ('nfs:' + i.source.mount_id)
|
||||||
|
: (isLocalDir ? ('dir:' + i.source.dir_id) : 'local'))),
|
||||||
el('td', {},
|
el('td', {},
|
||||||
protectedNow
|
protectedNow
|
||||||
? el('span', {class:'tag accent'}, 'protected')
|
? el('span', {class:'tag accent'}, 'protected')
|
||||||
@@ -591,11 +602,17 @@
|
|||||||
}}, protectedNow ? 'Password ✎' : 'Set password'),
|
}}, protectedNow ? 'Password ✎' : 'Set password'),
|
||||||
isNfs
|
isNfs
|
||||||
? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS')
|
? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS')
|
||||||
: el('button', {class:'danger', onclick: async () => {
|
: (isLocalDir
|
||||||
if (!confirm('Remove this image?')) return;
|
// v0.4.65: bytes live in an operator-managed
|
||||||
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
|
// bind-mounted directory; OpenPXE shouldn't try to
|
||||||
render('storage');
|
// delete files it didn't create. The next directory
|
||||||
}}, 'Remove'),
|
// re-scan would re-register them anyway.
|
||||||
|
? el('span', {class:'tag', style:'opacity:.6'}, 'on disk')
|
||||||
|
: el('button', {class:'danger', onclick: async () => {
|
||||||
|
if (!confirm('Remove this image?')) return;
|
||||||
|
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
|
||||||
|
render('storage');
|
||||||
|
}}, 'Remove')),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
rowsAndEditors.push(tr, editorRow);
|
rowsAndEditors.push(tr, editorRow);
|
||||||
@@ -685,6 +702,95 @@
|
|||||||
el('span'),
|
el('span'),
|
||||||
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
|
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
|
||||||
|
|
||||||
|
// ── v0.4.65: Local directories (bind-mount workaround) ──
|
||||||
|
// For hosts (Unraid is the dominant case) where the kernel
|
||||||
|
// doesn't have NFS client modules loaded, in-container NFS
|
||||||
|
// mounts simply can't succeed. The pragmatic answer is the same
|
||||||
|
// one Bootimus / iVentoy / FOG use: mount the remote storage on
|
||||||
|
// the *host* then bind-mount the path into the container.
|
||||||
|
// OpenPXE reads from a regular directory — no protocol work, no
|
||||||
|
// capabilities, no kernel module dependency.
|
||||||
|
const ldPath = el('input', {type:'text', placeholder:'/mnt/external-isos'});
|
||||||
|
const ldLabel = el('input', {type:'text', placeholder:'optional friendly name'});
|
||||||
|
const ldMsg = el('div', {class:'msg'});
|
||||||
|
const addLocalDir = el('button', {onclick: async () => {
|
||||||
|
if (!ldPath.value) {
|
||||||
|
ldMsg.replaceChildren(document.createTextNode('Path is required.'));
|
||||||
|
ldMsg.className='msg err'; return;
|
||||||
|
}
|
||||||
|
ldMsg.replaceChildren(document.createTextNode('Adding…'));
|
||||||
|
ldMsg.className='msg';
|
||||||
|
const r = await postJSON('/api/local-dirs', {
|
||||||
|
path: ldPath.value, label: ldLabel.value || null,
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
ldMsg.replaceChildren(document.createTextNode('Added.'));
|
||||||
|
ldMsg.className='msg ok';
|
||||||
|
render('storage');
|
||||||
|
} else {
|
||||||
|
const t = await r.text();
|
||||||
|
ldMsg.replaceChildren(
|
||||||
|
el('div', {}, [
|
||||||
|
el('strong', {}, 'Add failed: '),
|
||||||
|
document.createTextNode(t),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
ldMsg.className='msg err';
|
||||||
|
}
|
||||||
|
}}, 'Add directory');
|
||||||
|
|
||||||
|
const ldRows = localDirs.length ? localDirs.map(d => el('div', {class: 'nfs-row' + (d.last_error ? ' down' : '')}, [
|
||||||
|
el('span', {class: 'dot ' + (d.last_error ? 'err' : 'ok')}),
|
||||||
|
el('div', {}, [
|
||||||
|
el('div', {class:'id'}, d.label + ' · ' + d.path),
|
||||||
|
el('div', {class:'meta'},
|
||||||
|
'local · ' +
|
||||||
|
(d.iso_count != null ? d.iso_count + ' isos' : 'never scanned')),
|
||||||
|
d.last_error ? el('div', {class:'err'}, '⚠ ' + d.last_error) : null,
|
||||||
|
d.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, d.last_hint) : null,
|
||||||
|
]),
|
||||||
|
el('button', {class:'ghost', onclick: async () => {
|
||||||
|
const r = await postJSON('/api/local-dirs/' + encodeURIComponent(d.id) + '/scan', {});
|
||||||
|
if (r.ok) render('storage');
|
||||||
|
}}, 'Re-scan'),
|
||||||
|
el('button', {class:'danger', onclick: async () => {
|
||||||
|
if (!confirm('Remove ' + d.path + '? ISOs from this directory will disappear from the menu.')) return;
|
||||||
|
await fetch('/api/local-dirs/' + encodeURIComponent(d.id), {method:'DELETE'});
|
||||||
|
render('storage');
|
||||||
|
}}, 'Remove'),
|
||||||
|
el('span'),
|
||||||
|
])) : [el('div', {class:'empty'}, 'No local directories registered.')];
|
||||||
|
|
||||||
|
const localDirCard = el('div', {class:'card'}, [
|
||||||
|
el('header', {}, [
|
||||||
|
el('h2', {}, 'Local directories'),
|
||||||
|
el('span', {class:'sub'},
|
||||||
|
localDirs.length + ' registered' +
|
||||||
|
(!caps.available ? ' · recommended on this host' : '')),
|
||||||
|
]),
|
||||||
|
el('div', {class:'body'}, [
|
||||||
|
el('p', {class:'msg', style:'margin-bottom:14px'},
|
||||||
|
'Bind-mount a host directory into the container (' +
|
||||||
|
'e.g. -v /mnt/user/isos:/mnt/external-isos), then enter the ' +
|
||||||
|
'in-container path here. OpenPXE will scan it for ISOs and ' +
|
||||||
|
'surface them next to uploaded and NFS-mounted images. No ' +
|
||||||
|
'CAP_SYS_ADMIN required — this is the container-friendly ' +
|
||||||
|
'workaround when kernel NFS mounts can\'t work.'),
|
||||||
|
el('div', {class:'form-row'}, [
|
||||||
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'Path (inside container)'),
|
||||||
|
ldPath,
|
||||||
|
]),
|
||||||
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'Label (optional)'),
|
||||||
|
ldLabel,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
addLocalDir, ldMsg,
|
||||||
|
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, ldRows),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
// Disk-space card. Free + used + total for the volume hosting the
|
// Disk-space card. Free + used + total for the volume hosting the
|
||||||
// ISO directory, with a coloured bar. Warns at 80% and goes red at
|
// ISO directory, with a coloured bar. Warns at 80% and goes red at
|
||||||
// 95% so the operator sees the runway shrinking before uploads
|
// 95% so the operator sees the runway shrinking before uploads
|
||||||
@@ -737,6 +843,24 @@
|
|||||||
el('span', {class:'sub'}, mounts.length + ' configured'),
|
el('span', {class:'sub'}, mounts.length + ' configured'),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
|
// v0.4.65: if the host kernel doesn't have NFS client
|
||||||
|
// modules loaded (Unraid is the dominant case), in-
|
||||||
|
// container mounts will fail regardless of capabilities or
|
||||||
|
// privileged mode — there's nothing the operator can do
|
||||||
|
// from inside the container. Surface this prominently and
|
||||||
|
// point at the bind-mount workaround below.
|
||||||
|
!caps.available
|
||||||
|
? el('div', {class:'msg err', style:'margin-bottom:14px'}, [
|
||||||
|
el('div', {}, [
|
||||||
|
el('strong', {}, 'Host kernel has no NFS client support.'),
|
||||||
|
document.createTextNode(' Mounting from this container will fail no matter what capabilities you grant it.'),
|
||||||
|
]),
|
||||||
|
el('div', {style:'margin-top:6px;opacity:.85;font-size:12px'},
|
||||||
|
'Unraid is the most common case — its base kernel ships without the nfs/nfsv4 modules. ' +
|
||||||
|
'Mount the share on the host (Unassigned Devices plugin, /etc/fstab, etc.), ' +
|
||||||
|
'then bind-mount the resulting path into this container and register it as a Local directory below.'),
|
||||||
|
])
|
||||||
|
: null,
|
||||||
el('div', {class:'form-row'}, [
|
el('div', {class:'form-row'}, [
|
||||||
el('label', {class:'field'}, [
|
el('label', {class:'field'}, [
|
||||||
el('span', {class:'name'}, 'NFS server'),
|
el('span', {class:'name'}, 'NFS server'),
|
||||||
@@ -757,12 +881,14 @@
|
|||||||
addNfs, nfsMsg,
|
addNfs, nfsMsg,
|
||||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
|
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
el('p', {class:'msg', style:'margin-top:14px'},
|
||||||
'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' +
|
'Mounting NFS inside a container requires CAP_SYS_ADMIN, the ' +
|
||||||
'mount.nfs binary (bundled in the default Docker image). On ' +
|
'mount.nfs binary (bundled in the default Docker image), and ' +
|
||||||
'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' +
|
'the host kernel having NFS client modules loaded. If any of ' +
|
||||||
'NFS mounts as a CSI driver outside the pod.'),
|
'these are missing, use Local directories below as a workaround.'),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
// v0.4.65: Local directories — the bind-mount workaround.
|
||||||
|
localDirCard,
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, [
|
el('header', {}, [
|
||||||
el('h2', {}, 'Available images'),
|
el('h2', {}, 'Available images'),
|
||||||
|
|||||||
Reference in New Issue
Block a user