VMware UEFI / Casper boot fix:
- Linux cmdline for Debian/Ubuntu/Mint/Pop!_OS/elementary now uses the
canonical Casper `iso-url=` option and `ds=nocloud`, matching the
fix Bootimus shipped in v0.1.67. The previous
`boot=casper netboot=url url=… ip=dhcp ---` form booted fine on
bare-metal UEFI but hung at "cloud-init running" on VMware guests
because subiquity / cloud-init can't reach a metadata datasource
through PXE.
Static binary (matches Bootimus v0.1.70):
- Dockerfile build stage now compiles against
x86_64-unknown-linux-musl. The resulting /openpxe has no glibc
dependency at all; the runtime stage still ships Debian slim for the
samba/wimtools/nfs-common shellouts, but a future scratch/distroless
variant is now a one-line swap. Cuts a class of "GLIBC_2.39 not
found" surprises on older RHEL/Rocky hosts.
Forms auth (Sonarr/Radarr-style):
- New AdminStore in openpxe-core: single admin record persisted to
<work_dir>/auth.json, bcrypt-hashed credentials, rotation requires
current password.
- New SessionStore in openpxe-http-api: in-memory UUID-keyed sessions
with 24h sliding TTL, openpxe_session HttpOnly cookie.
- Endpoints: POST /api/setup (first-run), POST /api/login, POST
/api/logout, GET /api/me, PUT /api/me/credentials (rotates and
revokes every other session).
- Auth middleware gates /api/* once the admin is configured;
passes through entirely until then (tests + fresh installs ride this
path). Allowlists PXE-essential paths (/boot.ipxe, /iso/*, /ipxe/*,
/api/queue/join, /api/queue/poll/*) so iPXE clients still work
without a cookie they can't send.
- WebUI: first-run setup card, login card, logout chip in the sidebar
footer, Account card in Settings for rotating creds. Auth screen is
fully styled (centered narrow card, matches Sonarr layout).
SSO config (FleetDM-shaped, storage-only):
- New SsoStore in openpxe-core: { enabled, idp_name, metadata,
metadata_url } persisted to <work_dir>/sso.json with size caps and
URL-scheme validation.
- Endpoints: GET /api/sso, PUT /api/sso. Validation: enabling SSO
without either metadata or metadata_url returns 400.
- WebUI: SSO card in Settings with a URL-vs-XML mode switch and an
inert "Sign in with X" button on the login screen while runtime
flow is pending. Per the brief: no Entity ID field (defaults to the
advertised public_base_url internally when SAML wiring lands).
Quality:
- 132 tests passing (was 106 in v0.4.4): +5 auth unit tests, +5 SSO
unit tests, +7 auth integration tests, +1 SSO integration test, +1
regression guard pinning the new Casper cmdline.
- cargo clippy --workspace --all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
758 lines
28 KiB
Rust
758 lines
28 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.
|
|
///
|
|
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
|
|
/// `Nfs` entries point at a file inside a remote share that the
|
|
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
|
|
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum IsoSource {
|
|
#[default]
|
|
Local,
|
|
Nfs {
|
|
mount_id: String,
|
|
/// Path relative to the mount point — typically just the filename.
|
|
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>,
|
|
/// Where NFS mounts land on disk. Set at startup via
|
|
/// [`IsoStore::set_nfs_root`]; required for resolving any
|
|
/// `IsoSource::Nfs` entry.
|
|
nfs_root: Arc<RwLock<Option<PathBuf>>>,
|
|
inner: Arc<RwLock<Inner>>,
|
|
}
|
|
|
|
impl IsoStore {
|
|
pub fn new(iso_dir: PathBuf) -> Self {
|
|
Self {
|
|
iso_dir: Arc::new(iso_dir),
|
|
nfs_root: Arc::new(RwLock::new(None)),
|
|
inner: Arc::new(RwLock::new(Inner::default())),
|
|
}
|
|
}
|
|
|
|
/// Tell the store where NFS mounts live. Without this set,
|
|
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
|
|
pub fn set_nfs_root(&self, root: PathBuf) {
|
|
*self.nfs_root.write() = Some(root);
|
|
}
|
|
|
|
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(meta) = serde_json::from_str::<IsoMeta>(&text) {
|
|
self.insert(meta);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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 entries
|
|
/// this is `<iso_dir>/<id>.iso`; for NFS entries it's
|
|
/// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file
|
|
/// is missing or the source isn't resolvable (e.g. NFS share
|
|
/// unmounted).
|
|
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
|
|
let meta = self.get(id)?;
|
|
let path = match &meta.source {
|
|
IsoSource::Local => self.iso_path(id),
|
|
IsoSource::Nfs {
|
|
mount_id,
|
|
relative_path,
|
|
} => {
|
|
let root = self.nfs_root.read().clone()?;
|
|
root.join(mount_id).join(relative_path)
|
|
}
|
|
};
|
|
if path.exists() {
|
|
Some(path)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Delete an ISO and its sidecar metadata. Only acts on local ISOs;
|
|
/// for NFS-backed ISOs the operator must remove the file from the
|
|
/// share or unmount the NFS 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 (e.g. NFS-mounted). Used by
|
|
/// `NfsManager` after walking a freshly-mounted share. We do **not**
|
|
/// persist a `meta.json` on disk for these — the source of truth is
|
|
/// the share itself, and the NFS 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 `mount_id`. Used by the NFS
|
|
/// manager when an operator removes a share, or before re-scanning
|
|
/// to clean out stale entries.
|
|
pub fn drop_external_source(&self, mount_id: &str) {
|
|
let mut g = self.inner.write();
|
|
g.isos.retain(
|
|
|_, m| !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id),
|
|
);
|
|
}
|
|
|
|
/// 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 if r.has_boot_wim => {
|
|
// Standard wimboot chain. Paths are in-ISO; the HTTP layer maps
|
|
// `iso/<id>/<path>` to on-disk extraction via ISO9660 lookup.
|
|
let base = format!("iso/{id}");
|
|
vec![BootEntry {
|
|
id: format!("{id}-winpe"),
|
|
title: format!("{title} (Windows / wimboot)"),
|
|
kind: BootKind::Wimboot {
|
|
wimboot_url: "ipxe/wimboot".to_string(),
|
|
files: vec![
|
|
("bootmgr".into(), format!("{base}/bootmgr")),
|
|
("bootmgr.efi".into(), format!("{base}/bootmgr.efi")),
|
|
("bcd".into(), format!("{base}/boot/bcd")),
|
|
("boot.sdi".into(), format!("{base}/boot/boot.sdi")),
|
|
("boot.wim".into(), format!("{base}/sources/boot.wim")),
|
|
],
|
|
},
|
|
}]
|
|
}
|
|
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,
|
|
},
|
|
}]
|
|
}
|
|
_ => {
|
|
// Last-resort SAN boot. Won't work for large modern ISOs, but
|
|
// lets the ISO at least appear in the menu.
|
|
vec![BootEntry {
|
|
id: format!("{id}-sanboot"),
|
|
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
|
|
kind: BootKind::SanBootIso {
|
|
iso_url: format!("iso/{id}.iso"),
|
|
},
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
|
|
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}");
|
|
}
|
|
|
|
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 {
|
|
family: DistroFamily::Unknown,
|
|
volume_label: None,
|
|
kernel_path: None,
|
|
initrd_paths: vec![],
|
|
has_boot_wim: false,
|
|
},
|
|
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());
|
|
}
|
|
}
|