//! On-disk ISO store with sidecar metadata files. use crate::entry::{BootEntry, BootKind, KernelArgs}; use crate::introspect::{introspect, DistroFamily, IntrospectionReport}; use bytes::Bytes; use parking_lot::RwLock; use openpxe_core::{Error, Result}; 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`. /// `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, }, } #[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, #[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, /// 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/.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, } 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 { 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, }; 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, } #[derive(Debug, Clone)] pub struct IsoStore { iso_dir: Arc, /// Where NFS mounts land on disk. Set at startup via /// [`IsoStore::set_nfs_root`]; required for resolving any /// `IsoSource::Nfs` entry. nfs_root: Arc>>, inner: Arc>, } 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::(&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 { 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")); 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 { 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 { self.inner.read().isos.get(id).cloned() } /// Resolve an ISO id to its on-disk path, if any. For local entries /// this is `/.iso`; for NFS entries it's /// `//`. 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 { 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, 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, }; 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/.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(()) } /// Verify a candidate password against the stored bcrypt hash. /// Returns: /// - `Ok(true)` — match (or the ISO has no password set; gate is open) /// - `Ok(false)` — mismatch /// - `Err(_)` — id not found, or bcrypt error pub fn verify_password(&self, id: &str, candidate: &str) -> Result { 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())) } } 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::() .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 { 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 { 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//` 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 { DistroFamily::DebianUbuntu => format!( "boot=casper netboot=url url={iso_url} ip=dhcp ---" ), 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"); } 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, } } #[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 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()); } }