Codebase-wide review pass: finish or remove every loose end, take the safe performance wins on the serving hot paths, and refresh the dependency tree for reliability. No behavior changes for working clients; legacy clients get clearer protocol errors. Finalize / cleanup: - Remove mac_allowlist/subnet_allowlist config fields — parsed but never enforced since introduction; the operator wants line-of-sight serving, so the honest fix is deletion, not wiring. - Remove dead ClientRegistry API (get, set_selected_target, always-None selected_target field, never-emitted DhcpRequest/ HttpIsoAsset events). - TFTP: reject WRQ with ERR_ILLEGAL_OP and non-octet modes with a clear error instead of silent timeouts (legacy-client friendliness); fold plan_window into cfg(test); drop the unused-constant keep-alive hack. - rustfmt sweep over the six files with accumulated drift. Hot-path optimizations (all behavior-preserving): - Serve embedded iPXE binaries zero-copy (Cow over rodata) on both TFTP and HTTP — was a ~1 MiB heap copy per boot file request. - Cache the composited PXE boot-menu background PNG keyed on the branding logo revision — was ~50-200 ms of image work per booting client; now one compose per logo change. - Run bcrypt verify/hash on the blocking pool (boot password gate, login, setup, credential rotation) so CPU-heavy auth can't stall the workers streaming ISO ranges to imaging machines. - iso_raw: reuse the already-cloned IsoMeta for path resolution instead of a second registry lock + deep clone per range request. - DriverEscalation: amortize the TTL sweep (1-min interval + inline staleness check) instead of an O(map) retain per DHCP packet. - format_mac: one allocation instead of four per datagram. - Introspection haystack sized to min(scan cap, file size) — was guaranteed a 32 MiB realloc on every large-ISO probe. Robustness: - parse_range: malformed Range headers are now ignored per RFC 7233 (200 + full body) instead of answered with a bogus 206. Dependencies: - dhcproto 0.12 -> 0.15: drops the deprecated/unmaintained trust-dns-proto from the tree (hickory-proto), three releases of DHCP option coverage. Compiles + passes the full suite unchanged. - socket2 0.6 (dedupes tree), bcrypt 0.19, tower-http 0.6.11 (sheds iri-string), tokio 1.52.3 / hyper 1.10 lockfile refresh; dead nom workspace entry removed; requested versions synced to shipped reality. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
885 lines
34 KiB
Rust
885 lines
34 KiB
Rust
//! On-disk ISO store with sidecar metadata files.
|
|
|
|
use crate::entry::{BootEntry, BootKind, KernelArgs};
|
|
use crate::introspect::{introspect, DistroFamily, IntrospectionReport};
|
|
use bytes::Bytes;
|
|
use openpxe_core::{Error, Result};
|
|
use parking_lot::RwLock;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use time::OffsetDateTime;
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
/// Where the bytes for an ISO actually live.
|
|
///
|
|
/// `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.
|
|
/// `Sftp` (v0.5.5) — remote SFTP-over-SSH share, streamed via the
|
|
/// pure-Rust `russh` + `russh-sftp` crates (in-process). Like NFS it
|
|
/// supports HTTP Range requests because SFTP opens a seekable file
|
|
/// handle (`SSH_FXP_READ` at offset).
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum IsoSource {
|
|
#[default]
|
|
Local,
|
|
/// 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,
|
|
},
|
|
/// v0.5.5: SFTP-over-SSH via the in-process `russh` + `russh-sftp`
|
|
/// crates.
|
|
Sftp {
|
|
share_id: String,
|
|
/// Filename at the export root.
|
|
relative_path: String,
|
|
},
|
|
}
|
|
|
|
/// Where the ISO lands in the PXE menu hierarchy.
|
|
///
|
|
/// Auto-detected family (Debian, Windows, …) still drives BIOS/UEFI
|
|
/// behaviour and per-entry boot args, but the *menu placement* is
|
|
/// operator-controlled — an operator who's uploaded a TinyCore live ISO
|
|
/// to use as a recovery shim, or a SystemRescue image, can flip its
|
|
/// category to `Tools` so it lands next to memtest/shell instead of
|
|
/// under Linux Installers.
|
|
///
|
|
/// Old `meta.json` files without this field deserialize as `Os`, which
|
|
/// matches v0.4.1 behaviour (everything goes under OS Installers).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum IsoCategory {
|
|
/// "OS Installer" — routed via the auto-detected family into the
|
|
/// Linux / Windows installer submenus.
|
|
#[default]
|
|
Os,
|
|
/// "Tool" — surfaced under the Tools menu next to memtest, shell,
|
|
/// NIC info, etc. Family detection still decides BIOS/UEFI vs
|
|
/// wimboot vs sanboot at boot time.
|
|
Tools,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct IsoMeta {
|
|
/// Stable slug used in URLs (derived from the uploaded filename).
|
|
pub id: String,
|
|
pub filename: String,
|
|
pub size_bytes: u64,
|
|
pub sha256_hex: Option<String>,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
pub uploaded_at: OffsetDateTime,
|
|
pub introspection: IntrospectionReport,
|
|
/// Boot entries this ISO currently exposes in the PXE menu. Usually one,
|
|
/// occasionally two (BIOS + UEFI variant for some RHEL ISOs).
|
|
pub boot_entries: Vec<BootEntry>,
|
|
/// Source of the bytes — local upload (default) or NFS mount.
|
|
/// Old `meta.json` files without this field deserialize as `Local`.
|
|
#[serde(default)]
|
|
pub source: IsoSource,
|
|
/// Optional bcrypt hash of an operator-set password. When present,
|
|
/// `/boot/<entry>.ipxe` returns a `read --secret` prompt instead of
|
|
/// the boot script until the client chains back with the correct
|
|
/// `?token=...`. We never store, log, or transmit the plaintext.
|
|
/// Skipped on serialize when None to keep meta.json clean for
|
|
/// the common no-password case.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub password_hash: Option<String>,
|
|
/// Where the ISO sits in the PXE menu hierarchy — operator-controlled,
|
|
/// not driven by family detection. Defaults to [`IsoCategory::Os`].
|
|
#[serde(default)]
|
|
pub category: IsoCategory,
|
|
}
|
|
|
|
impl IsoMeta {
|
|
/// Convenience predicate the HTTP layer + UI can both use.
|
|
#[must_use]
|
|
pub fn is_password_protected(&self) -> bool {
|
|
self.password_hash
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.is_some_and(|h| !h.is_empty())
|
|
}
|
|
}
|
|
|
|
pub struct UploadHandle {
|
|
pub id: String,
|
|
pub partial_path: PathBuf,
|
|
final_path: PathBuf,
|
|
filename: String,
|
|
hasher: Sha256,
|
|
bytes_written: u64,
|
|
file: tokio::fs::File,
|
|
}
|
|
|
|
impl UploadHandle {
|
|
pub async fn write_chunk(&mut self, chunk: &Bytes) -> Result<()> {
|
|
self.file.write_all(chunk).await?;
|
|
self.hasher.update(chunk);
|
|
self.bytes_written += chunk.len() as u64;
|
|
Ok(())
|
|
}
|
|
|
|
/// Flush, close, and atomically rename to the final path. Returns the
|
|
/// final `IsoMeta` including introspection results.
|
|
pub async fn finish(mut self, store: &IsoStore) -> Result<IsoMeta> {
|
|
self.file.flush().await?;
|
|
self.file.sync_all().await?;
|
|
drop(self.file);
|
|
tokio::fs::rename(&self.partial_path, &self.final_path).await?;
|
|
|
|
let hash = hex::encode(self.hasher.finalize());
|
|
let introspection = {
|
|
let p = self.final_path.clone();
|
|
tokio::task::spawn_blocking(move || introspect(&p))
|
|
.await
|
|
.map_err(|e| Error::Other(e.into()))?
|
|
};
|
|
|
|
let boot_entries = generate_boot_entries(&self.id, &self.filename, &introspection);
|
|
let meta = IsoMeta {
|
|
id: self.id.clone(),
|
|
filename: self.filename,
|
|
size_bytes: self.bytes_written,
|
|
sha256_hex: Some(hash),
|
|
uploaded_at: OffsetDateTime::now_utc(),
|
|
introspection,
|
|
boot_entries,
|
|
source: IsoSource::Local,
|
|
password_hash: None,
|
|
category: IsoCategory::default(),
|
|
};
|
|
store.persist_meta(&meta).await?;
|
|
store.insert(meta.clone());
|
|
Ok(meta)
|
|
}
|
|
|
|
pub async fn abort(self) -> Result<()> {
|
|
drop(self.file);
|
|
let _ = tokio::fs::remove_file(&self.partial_path).await;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct Inner {
|
|
isos: HashMap<String, IsoMeta>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct IsoStore {
|
|
iso_dir: Arc<PathBuf>,
|
|
inner: Arc<RwLock<Inner>>,
|
|
}
|
|
|
|
impl IsoStore {
|
|
pub fn new(iso_dir: PathBuf) -> Self {
|
|
Self {
|
|
iso_dir: Arc::new(iso_dir),
|
|
inner: Arc::new(RwLock::new(Inner::default())),
|
|
}
|
|
}
|
|
|
|
pub async fn ensure_dirs(&self) -> Result<()> {
|
|
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Scan the ISO directory on startup and load any sidecar `.meta.json`
|
|
/// files. ISOs without metadata are introspected lazily — we don't block
|
|
/// startup on potentially many GB of scanning.
|
|
pub async fn load_from_disk(&self) -> Result<()> {
|
|
self.ensure_dirs().await?;
|
|
let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?;
|
|
while let Some(e) = entries.next_entry().await? {
|
|
let p = e.path();
|
|
if p.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
continue;
|
|
}
|
|
if !p
|
|
.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.is_some_and(|n| n.ends_with(".meta.json"))
|
|
{
|
|
continue;
|
|
}
|
|
if let Ok(text) = tokio::fs::read_to_string(&p).await {
|
|
if let Ok(mut meta) = serde_json::from_str::<IsoMeta>(&text) {
|
|
self.reintrospect_if_stale(&mut meta).await;
|
|
self.insert(meta);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// v0.5.9: re-run introspection on a *local* ISO whose persisted report
|
|
/// predates the current logic. ISOs uploaded by an older binary carry a
|
|
/// stale family/boot profile — most visibly a Windows 11 ISO tagged
|
|
/// `Unknown` before the UDF/El-Torito detection landed, which then shows
|
|
/// as "won't boot" forever. Re-probing on startup fixes them in place,
|
|
/// no delete-and-re-upload. Bounded: only `Local` sources (we have the
|
|
/// bytes locally) below [`introspect::INTROSPECT_REV`], so it runs at
|
|
/// most once per ISO per upgrade. The probe reads up to ~64 MiB, so we
|
|
/// push it onto the blocking pool to keep the async runtime responsive.
|
|
async fn reintrospect_if_stale(&self, meta: &mut IsoMeta) {
|
|
if !matches!(meta.source, IsoSource::Local)
|
|
|| meta.introspection.introspect_rev >= crate::introspect::INTROSPECT_REV
|
|
{
|
|
return;
|
|
}
|
|
let path = self.iso_path(&meta.id);
|
|
if !path.exists() {
|
|
return;
|
|
}
|
|
let Ok(fresh) = tokio::task::spawn_blocking(move || introspect(&path)).await else {
|
|
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect task failed");
|
|
return;
|
|
};
|
|
let before = meta.introspection.family;
|
|
meta.introspection = fresh;
|
|
meta.boot_entries = generate_boot_entries(&meta.id, &meta.filename, &meta.introspection);
|
|
if let Err(e) = self.persist_meta(meta).await {
|
|
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect persist: {e}");
|
|
return;
|
|
}
|
|
tracing::info!(
|
|
target: "openpxe::iso",
|
|
id = %meta.id,
|
|
from = ?before,
|
|
to = ?meta.introspection.family,
|
|
el_torito = meta.introspection.el_torito,
|
|
"re-introspected stale ISO metadata"
|
|
);
|
|
}
|
|
|
|
fn insert(&self, meta: IsoMeta) {
|
|
self.inner.write().isos.insert(meta.id.clone(), meta);
|
|
}
|
|
|
|
async fn persist_meta(&self, meta: &IsoMeta) -> Result<()> {
|
|
let path = self.meta_path(&meta.id);
|
|
let text = serde_json::to_string_pretty(meta).map_err(|e| Error::Other(e.into()))?;
|
|
tokio::fs::write(path, text).await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn meta_path(&self, id: &str) -> PathBuf {
|
|
self.iso_dir.join(format!("{id}.meta.json"))
|
|
}
|
|
|
|
fn iso_path(&self, id: &str) -> PathBuf {
|
|
self.iso_dir.join(format!("{id}.iso"))
|
|
}
|
|
|
|
pub async fn begin_upload(&self, filename: &str) -> Result<UploadHandle> {
|
|
self.ensure_dirs().await?;
|
|
let id = slugify(filename);
|
|
let final_path = self.iso_path(&id);
|
|
if final_path.exists() {
|
|
return Err(Error::Invalid(format!("iso '{id}' already exists")));
|
|
}
|
|
let partial_path = self.iso_dir.join(format!("{id}.partial"));
|
|
if partial_path.exists() {
|
|
return Err(Error::Invalid(format!("iso '{id}' is already uploading")));
|
|
}
|
|
let file = tokio::fs::File::create(&partial_path).await?;
|
|
Ok(UploadHandle {
|
|
id,
|
|
partial_path,
|
|
final_path,
|
|
filename: filename.to_string(),
|
|
hasher: Sha256::new(),
|
|
bytes_written: 0,
|
|
file,
|
|
})
|
|
}
|
|
|
|
/// Readiness probe — is the backing directory reachable? Distinct from
|
|
/// "is there content in it", to avoid an empty store failing health.
|
|
#[must_use]
|
|
pub fn list_ok(&self) -> bool {
|
|
std::fs::read_dir(self.iso_dir.as_path()).is_ok()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn list(&self) -> Vec<IsoMeta> {
|
|
let g = self.inner.read();
|
|
let mut v: Vec<_> = g.isos.values().cloned().collect();
|
|
// Newest-first by upload time.
|
|
v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
|
|
v
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get(&self, id: &str) -> Option<IsoMeta> {
|
|
self.inner.read().isos.get(id).cloned()
|
|
}
|
|
|
|
/// Resolve an ISO id to its on-disk path, if any. For local
|
|
/// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced
|
|
/// ISOs there is no on-disk path — the HTTP handler must stream
|
|
/// via `SmbShareManager::stream_iso` instead. Returns `None` for
|
|
/// SMB sources or when the file is missing.
|
|
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
|
|
let meta = self.get(id)?;
|
|
self.local_path(&meta)
|
|
}
|
|
|
|
/// Same resolution as [`Self::iso_path_for`], but for a meta the
|
|
/// caller already holds — skips the second registry lock + deep
|
|
/// clone, which matters on the per-range-request ISO serving path
|
|
/// (a sanboot install issues hundreds of those).
|
|
#[must_use]
|
|
pub fn local_path(&self, meta: &IsoMeta) -> Option<PathBuf> {
|
|
match &meta.source {
|
|
IsoSource::Local => {
|
|
let path = self.iso_path(&meta.id);
|
|
if path.exists() {
|
|
Some(path)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
// SMB, NFS, and SFTP sources have no local path — they're
|
|
// streamed in-process. Callers must inspect the source
|
|
// kind first and dispatch to the appropriate share
|
|
// manager.
|
|
IsoSource::Smb { .. } | IsoSource::Nfs { .. } | IsoSource::Sftp { .. } => None,
|
|
}
|
|
}
|
|
|
|
/// Delete an ISO and its sidecar metadata. Only acts on local
|
|
/// (uploaded) ISOs; for SMB-backed ISOs the operator must remove
|
|
/// the file from the share or unregister the share entirely.
|
|
pub async fn delete(&self, id: &str) -> Result<()> {
|
|
let meta = self.get(id);
|
|
let is_local = matches!(
|
|
meta.as_ref().map(|m| &m.source),
|
|
Some(IsoSource::Local) | None
|
|
);
|
|
if is_local {
|
|
let iso = self.iso_path(id);
|
|
let meta_path = self.meta_path(id);
|
|
let _ = tokio::fs::remove_file(&iso).await;
|
|
let _ = tokio::fs::remove_file(&meta_path).await;
|
|
}
|
|
self.inner.write().isos.remove(id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Register an externally-sourced ISO (SMB share, etc.). Used by
|
|
/// `SmbShareManager` after listing a share. We do **not** persist
|
|
/// a `meta.json` on disk for these — the source of truth is the
|
|
/// share itself, and the manager re-scans on startup.
|
|
pub fn register_external(
|
|
&self,
|
|
id: String,
|
|
filename: String,
|
|
size_bytes: u64,
|
|
introspection: IntrospectionReport,
|
|
boot_entries: Vec<BootEntry>,
|
|
source: IsoSource,
|
|
) {
|
|
let meta = IsoMeta {
|
|
id: id.clone(),
|
|
filename,
|
|
size_bytes,
|
|
sha256_hex: None,
|
|
uploaded_at: OffsetDateTime::now_utc(),
|
|
introspection,
|
|
boot_entries,
|
|
source,
|
|
password_hash: None,
|
|
category: IsoCategory::default(),
|
|
};
|
|
self.inner.write().isos.insert(id, meta);
|
|
}
|
|
|
|
/// Drop every entry that belongs to `share_id`. Used by the SMB
|
|
/// 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| match &m.source {
|
|
IsoSource::Smb { share_id: sid, .. }
|
|
| IsoSource::Nfs { share_id: sid, .. }
|
|
| IsoSource::Sftp { share_id: sid, .. } => sid != share_id,
|
|
IsoSource::Local => true,
|
|
});
|
|
}
|
|
|
|
/// Set or clear an ISO's boot password.
|
|
///
|
|
/// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for
|
|
/// an interactive iPXE prompt, slow enough to be hostile to brute
|
|
/// force on a leaked meta.json) and persists.
|
|
///
|
|
/// `None` removes the password — the next /boot/<id>.ipxe request
|
|
/// returns the script directly without a prompt.
|
|
///
|
|
/// We never store, log, or transmit the plaintext.
|
|
pub async fn set_password(&self, id: &str, password: Option<&str>) -> Result<()> {
|
|
let new_hash = match password {
|
|
None => None,
|
|
Some(pw) => {
|
|
let pw = pw.trim();
|
|
if pw.is_empty() {
|
|
None
|
|
} else {
|
|
let h = bcrypt::hash(pw, bcrypt::DEFAULT_COST)
|
|
.map_err(|e| Error::Other(e.into()))?;
|
|
Some(h)
|
|
}
|
|
}
|
|
};
|
|
|
|
// Update in-memory + grab a clone for persistence outside the lock.
|
|
let updated = {
|
|
let mut g = self.inner.write();
|
|
let m = g
|
|
.isos
|
|
.get_mut(id)
|
|
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
|
|
m.password_hash = new_hash;
|
|
m.clone()
|
|
};
|
|
// NFS-sourced ISOs have no on-disk meta.json — skip persistence
|
|
// for them (the password lives in memory until the manager
|
|
// re-scans the share, then it's gone). Document this in the API
|
|
// handler so the operator knows.
|
|
if matches!(updated.source, IsoSource::Local) {
|
|
self.persist_meta(&updated).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Flip an ISO's menu category. Persists to `meta.json` for local
|
|
/// ISOs; NFS-sourced ISOs keep the change in memory only (the next
|
|
/// re-scan would overwrite it anyway).
|
|
pub async fn set_category(&self, id: &str, category: IsoCategory) -> Result<IsoMeta> {
|
|
let updated = {
|
|
let mut g = self.inner.write();
|
|
let m = g
|
|
.isos
|
|
.get_mut(id)
|
|
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
|
|
m.category = category;
|
|
m.clone()
|
|
};
|
|
if matches!(updated.source, IsoSource::Local) {
|
|
self.persist_meta(&updated).await?;
|
|
}
|
|
Ok(updated)
|
|
}
|
|
|
|
/// Absolute path to the directory holding local ISO uploads. Used
|
|
/// by the HTTP layer for the disk-space endpoint — the volume that
|
|
/// hosts this directory is what runs out of room first.
|
|
#[must_use]
|
|
pub fn iso_dir(&self) -> PathBuf {
|
|
self.iso_dir.as_path().to_path_buf()
|
|
}
|
|
|
|
/// `(total_bytes, available_bytes)` for the filesystem hosting the
|
|
/// ISO directory. Returns `None` if `statvfs` fails (read-only
|
|
/// filesystem with no quota, mount disappeared, …) — callers
|
|
/// should treat that as "unknown" rather than zero.
|
|
///
|
|
/// Lives here rather than the HTTP crate because `http-api`'s
|
|
/// `#![forbid(unsafe_code)]` rules out the libc FFI directly, and
|
|
/// because this is naturally an `IsoStore` question — the volume
|
|
/// of interest is whatever's hosting the iso dir.
|
|
#[must_use]
|
|
pub fn disk_usage(&self) -> Option<(u64, u64)> {
|
|
disk_usage_for(self.iso_dir.as_path())
|
|
}
|
|
|
|
/// Verify a candidate password against the stored bcrypt hash.
|
|
/// Returns:
|
|
/// - `Ok(true)` — match (or the ISO has no password set; boot is open)
|
|
/// - `Ok(false)` — mismatch
|
|
/// - `Err(_)` — id not found, or bcrypt error
|
|
pub fn verify_password(&self, id: &str, candidate: &str) -> Result<bool> {
|
|
let meta = self
|
|
.get(id)
|
|
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
|
|
let Some(hash) = meta.password_hash else {
|
|
return Ok(true); // no password set — anyone can boot
|
|
};
|
|
bcrypt::verify(candidate, &hash).map_err(|e| Error::Other(e.into()))
|
|
}
|
|
}
|
|
|
|
/// Resolve `(total, available)` bytes for the filesystem hosting `path`.
|
|
/// Returns `None` if `statvfs` fails.
|
|
#[allow(unsafe_code)]
|
|
fn disk_usage_for(path: &std::path::Path) -> Option<(u64, u64)> {
|
|
use std::ffi::CString;
|
|
use std::os::unix::ffi::OsStrExt;
|
|
let c = CString::new(path.as_os_str().as_bytes()).ok()?;
|
|
// SAFETY: `statvfs` is repr(C); a zeroed value is a valid initial
|
|
// state per POSIX. The FFI call writes every field we then read.
|
|
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
|
// SAFETY: `c` is a NUL-terminated C string pointing into a stack
|
|
// CString that outlives this call; `&mut stat` is a unique aligned
|
|
// pointer to a stack-local `statvfs`. The kernel writes through
|
|
// it but does not retain the pointer past return.
|
|
let rc = unsafe { libc::statvfs(c.as_ptr(), &raw mut stat) };
|
|
if rc != 0 {
|
|
return None;
|
|
}
|
|
// Use f_frsize (fundamental block size). f_bsize is "preferred I/O
|
|
// block" and doesn't always match the unit f_blocks is denominated
|
|
// in — on some BSDs it would over-report by a factor of 8.
|
|
let frsize = stat.f_frsize as u64;
|
|
let total = stat.f_blocks as u64 * frsize;
|
|
let avail = stat.f_bavail as u64 * frsize;
|
|
Some((total, avail))
|
|
}
|
|
|
|
fn slugify(filename: &str) -> String {
|
|
let stem = Path::new(filename)
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("iso");
|
|
slugify_str(stem)
|
|
}
|
|
|
|
/// Slugify an arbitrary string to lowercase ASCII alphanumerics, hyphens,
|
|
/// and underscores. Public so the NFS manager can mint ids that follow the
|
|
/// same rules as upload-time ISO ids.
|
|
#[must_use]
|
|
pub fn slugify_str(input: &str) -> String {
|
|
input
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
|
c.to_ascii_lowercase()
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.collect::<String>()
|
|
.trim_matches('-')
|
|
.to_string()
|
|
}
|
|
|
|
/// Public wrapper around [`generate_boot_entries`] so the NFS manager can
|
|
/// build entries for shares it just scanned, using the same logic as the
|
|
/// upload pipeline. Re-exported via the crate root.
|
|
#[must_use]
|
|
pub fn generate_boot_entries_for(
|
|
id: &str,
|
|
filename: &str,
|
|
r: &IntrospectionReport,
|
|
) -> Vec<BootEntry> {
|
|
generate_boot_entries(id, filename, r)
|
|
}
|
|
|
|
/// Build `BootEntry`s from the introspection report. URLs are relative —
|
|
/// the HTTP layer rewrites them with the public base URL per request.
|
|
fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec<BootEntry> {
|
|
let title = r
|
|
.volume_label
|
|
.clone()
|
|
.unwrap_or_else(|| filename.to_string());
|
|
match r.family {
|
|
DistroFamily::WindowsPe => {
|
|
// v0.5.8: boot Windows directly via iPXE HTTP sanboot. iPXE
|
|
// exposes the raw ISO as an emulated CD backed by on-demand
|
|
// HTTP range reads, and Windows Setup boots from it. This
|
|
// replaces the old wimboot+SMB chain, which (a) needed an SMB
|
|
// server the host often can't provide (port 445 collisions),
|
|
// (b) served in-ISO files via an ISO9660 lookup that failed on
|
|
// UDF-only Windows 11 ISOs, and (c) required an operator
|
|
// toggle. sanboot needs none of that — just the HTTP port,
|
|
// which works in any environment. The unmodified, stock ISO is
|
|
// served at iso/<id>.iso; nothing is injected into Windows.
|
|
vec![BootEntry {
|
|
id: format!("{id}-windows"),
|
|
title: format!("{title} (Windows)"),
|
|
kind: BootKind::SanBootIso {
|
|
iso_url: format!("iso/{id}.iso"),
|
|
},
|
|
}]
|
|
}
|
|
fam if r.kernel_path.is_some() => {
|
|
let base = format!("iso/{id}");
|
|
let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or(""));
|
|
let initrd_urls = r
|
|
.initrd_paths
|
|
.iter()
|
|
.map(|p| format!("{base}{p}"))
|
|
.collect();
|
|
let args = KernelArgs {
|
|
cmdline: linux_cmdline(fam, id),
|
|
};
|
|
vec![BootEntry {
|
|
id: format!("{id}-linux"),
|
|
title,
|
|
kind: BootKind::LinuxKernel {
|
|
kernel_url,
|
|
initrd_urls,
|
|
args,
|
|
},
|
|
}]
|
|
}
|
|
_ => {
|
|
// No Windows-install media and no Linux kernel/initrd. Decide
|
|
// whether the ISO is bootable at all (v0.6.0):
|
|
// * `el_torito` — it carries a boot catalog, so iPXE sanboots
|
|
// the raw image as an emulated CD: BSDs, ESXi/VMvisor
|
|
// installers, firmware tools, custom spins. The emulated CD
|
|
// is backed by HTTP range reads, so ISO size is a non-issue
|
|
// (this is the same path Windows uses since v0.5.8) — hence
|
|
// no more "may fail for >1GiB ISOs" disclaimer.
|
|
// * `introspect_rev == 0` — a remote-share ISO we couldn't
|
|
// introspect (SMB/NFS/SFTP listings don't seek into the ISO).
|
|
// Offer sanboot optimistically rather than hide a
|
|
// likely-bootable installer.
|
|
// Otherwise it's a local image we *did* introspect and found to
|
|
// carry no boot catalog — a data/appliance ISO (e.g. a VMware
|
|
// vCenter Server Appliance bundle). It genuinely cannot boot, so
|
|
// we expose no menu entry; the dashboard flags it instead.
|
|
if r.el_torito || r.introspect_rev == 0 {
|
|
vec![BootEntry {
|
|
id: format!("{id}-sanboot"),
|
|
title,
|
|
kind: BootKind::SanBootIso {
|
|
iso_url: format!("iso/{id}.iso"),
|
|
},
|
|
}]
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn linux_cmdline(family: DistroFamily, id: &str) -> String {
|
|
// The HTTP layer resolves `${base-url}` at render time.
|
|
let iso_url = format!("${{base-url}}/iso/{id}.iso");
|
|
match family {
|
|
// VMware-UEFI fix (v0.4.5, matching Bootimus v0.1.67's Casper
|
|
// patch): drop `netboot=url url=… ---` in favour of the
|
|
// canonical Casper option `iso-url=` and add `ds=nocloud` so
|
|
// cloud-init / subiquity (live-server) doesn't stall waiting on
|
|
// a metadata datasource that doesn't exist in PXE. Without
|
|
// `ds=nocloud`, Ubuntu live-server / Mint / Pop!_OS / elementary
|
|
// ISOs would boot fine on bare-metal UEFI but hang at "cloud-init
|
|
// running" on VMware-UEFI guests because the vmxnet3 driver's
|
|
// late-init upsets cloud-init's network probe.
|
|
DistroFamily::DebianUbuntu => format!(
|
|
"boot=casper initrd=initrd ds=nocloud ip=dhcp iso-url={iso_url}"
|
|
),
|
|
DistroFamily::RhelFedora => format!(
|
|
"inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp"
|
|
),
|
|
DistroFamily::OpenSuse => format!(
|
|
"install={iso_url} netsetup=dhcp"
|
|
),
|
|
DistroFamily::Arch => {
|
|
"archiso_http_srv=${base-url}/iso/ archisobasedir=arch ip=dhcp copytoram".to_string()
|
|
}
|
|
DistroFamily::Alpine => format!(
|
|
"alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp"
|
|
),
|
|
_ => "ip=dhcp".into(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::introspect::{DistroFamily, IntrospectionReport};
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn slugify_basic() {
|
|
// Upload filenames come from multipart parts (no path components);
|
|
// file_stem drops the extension, then non-alphanumerics become `-`.
|
|
assert_eq!(slugify("Ubuntu 24.04 Desktop.iso"), "ubuntu-24-04-desktop");
|
|
assert_eq!(slugify("Rocky-9.4-x86_64-dvd.iso"), "rocky-9-4-x86_64-dvd");
|
|
assert_eq!(slugify("arch.iso"), "arch");
|
|
// If a path sneaks in, file_stem strips the directory — OK, not a hazard.
|
|
assert_eq!(slugify("/etc/passwd"), "passwd");
|
|
}
|
|
|
|
#[test]
|
|
fn casper_cmdline_vmware_uefi_safe() {
|
|
// v0.4.5 regression guard: the Debian/Ubuntu cmdline must use
|
|
// the canonical Casper `iso-url=` option and include
|
|
// `ds=nocloud` so VMware-UEFI guests don't hang at "cloud-init
|
|
// running" waiting on a metadata datasource that PXE can't
|
|
// provide. The legacy `netboot=url url=… ---` form is gone for
|
|
// good.
|
|
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
|
|
assert!(s.contains("boot=casper"), "{s}");
|
|
assert!(
|
|
s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"),
|
|
"{s}"
|
|
);
|
|
assert!(s.contains("ds=nocloud"), "{s}");
|
|
assert!(s.contains("ip=dhcp"), "{s}");
|
|
assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
|
|
assert!(!s.contains(" --- "), "stray ---: {s}");
|
|
}
|
|
|
|
#[test]
|
|
fn boot_entries_respect_el_torito_and_source() {
|
|
use crate::introspect::INTROSPECT_REV;
|
|
|
|
// ESXi / VMvisor installer shape: bootable (carries an El Torito
|
|
// catalog) but not classifiable as Windows or Linux. Must yield a
|
|
// single sanboot entry so it's selectable + boots via emulated CD.
|
|
let esxi = IntrospectionReport {
|
|
family: DistroFamily::Unknown,
|
|
volume_label: Some("ESXI-7.0U3".into()),
|
|
el_torito: true,
|
|
introspect_rev: INTROSPECT_REV,
|
|
..Default::default()
|
|
};
|
|
let e = generate_boot_entries("esxi", "VMware-VMvisor-Installer-7.0U3n.iso", &esxi);
|
|
assert_eq!(e.len(), 1, "ESXi should get exactly one boot entry");
|
|
assert!(matches!(e[0].kind, BootKind::SanBootIso { .. }));
|
|
// Clean title — no stale ">1GiB may fail" disclaimer.
|
|
assert!(!e[0].title.contains("may fail"), "title: {}", e[0].title);
|
|
|
|
// VCSA / data-appliance shape: locally introspected (rev set), no
|
|
// boot catalog, not Windows/Linux. Genuinely unbootable → no entry,
|
|
// so it stays out of the iPXE menu (the dashboard flags it instead).
|
|
let vcsa = IntrospectionReport {
|
|
family: DistroFamily::Unknown,
|
|
el_torito: false,
|
|
introspect_rev: INTROSPECT_REV,
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
generate_boot_entries("vcsa", "VMware-VCSA-all-8.0.iso", &vcsa).is_empty(),
|
|
"data/appliance ISO must produce no boot entry"
|
|
);
|
|
|
|
// Remote-share ISO: never introspected (rev 0, no random access over
|
|
// SMB/NFS/SFTP). Assume bootable and offer sanboot rather than hide a
|
|
// likely-bootable installer.
|
|
let remote = IntrospectionReport::default();
|
|
let r = generate_boot_entries("remote", "unknown-remote.iso", &remote);
|
|
assert_eq!(r.len(), 1, "remote (uninspected) ISO keeps a sanboot entry");
|
|
assert!(matches!(r[0].kind, BootKind::SanBootIso { .. }));
|
|
}
|
|
|
|
fn fake_meta(id: &str) -> IsoMeta {
|
|
IsoMeta {
|
|
id: id.into(),
|
|
filename: format!("{id}.iso"),
|
|
size_bytes: 0,
|
|
sha256_hex: None,
|
|
uploaded_at: OffsetDateTime::now_utc(),
|
|
introspection: IntrospectionReport::default(),
|
|
boot_entries: vec![],
|
|
source: IsoSource::Local,
|
|
password_hash: None,
|
|
category: IsoCategory::default(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn password_round_trip_set_verify_clear() {
|
|
let dir = tempdir().unwrap();
|
|
let store = IsoStore::new(dir.path().to_path_buf());
|
|
store.ensure_dirs().await.unwrap();
|
|
store
|
|
.inner
|
|
.write()
|
|
.isos
|
|
.insert("alpha".into(), fake_meta("alpha"));
|
|
|
|
// No password set — verify_password returns Ok(true) for any input.
|
|
assert!(store.verify_password("alpha", "anything").unwrap());
|
|
assert!(!store.get("alpha").unwrap().is_password_protected());
|
|
|
|
// Set a password.
|
|
store.set_password("alpha", Some("hunter2")).await.unwrap();
|
|
let m = store.get("alpha").unwrap();
|
|
assert!(m.is_password_protected());
|
|
assert!(m.password_hash.unwrap().starts_with("$2"));
|
|
|
|
// Verify correct + wrong.
|
|
assert!(store.verify_password("alpha", "hunter2").unwrap());
|
|
assert!(!store.verify_password("alpha", "wrong").unwrap());
|
|
assert!(!store.verify_password("alpha", "").unwrap());
|
|
|
|
// Clear by passing None or an empty string.
|
|
store.set_password("alpha", None).await.unwrap();
|
|
assert!(!store.get("alpha").unwrap().is_password_protected());
|
|
store.set_password("alpha", Some("again")).await.unwrap();
|
|
store.set_password("alpha", Some(" ")).await.unwrap();
|
|
assert!(!store.get("alpha").unwrap().is_password_protected());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_password_for_unknown_id_errors() {
|
|
let dir = tempdir().unwrap();
|
|
let store = IsoStore::new(dir.path().to_path_buf());
|
|
store.ensure_dirs().await.unwrap();
|
|
let r = store.set_password("does-not-exist", Some("pw")).await;
|
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn begin_upload_rejects_existing_partial_file() {
|
|
let dir = tempdir().unwrap();
|
|
let store = IsoStore::new(dir.path().to_path_buf());
|
|
store.ensure_dirs().await.unwrap();
|
|
tokio::fs::write(dir.path().join("ubuntu.partial"), b"in-flight")
|
|
.await
|
|
.unwrap();
|
|
|
|
let r = store.begin_upload("ubuntu.iso").await;
|
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn password_persists_via_meta_json_for_local_isos() {
|
|
// Hash makes it onto disk so it survives a restart.
|
|
let dir = tempdir().unwrap();
|
|
let store = IsoStore::new(dir.path().to_path_buf());
|
|
store.ensure_dirs().await.unwrap();
|
|
let meta = fake_meta("alpha");
|
|
store.persist_meta(&meta).await.unwrap();
|
|
store.insert(meta);
|
|
store.set_password("alpha", Some("s3cret")).await.unwrap();
|
|
|
|
// Re-load from disk and confirm the hash came back.
|
|
let store2 = IsoStore::new(dir.path().to_path_buf());
|
|
store2.load_from_disk().await.unwrap();
|
|
let reloaded = store2.get("alpha").expect("reloaded");
|
|
assert!(reloaded.is_password_protected());
|
|
assert!(store2.verify_password("alpha", "s3cret").unwrap());
|
|
assert!(!store2.verify_password("alpha", "wrong").unwrap());
|
|
}
|
|
}
|