From 9cafbbabbf26b0ea67d116eaa776dd48c6f80430 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 12:41:07 +0200 Subject: [PATCH 01/59] =?UTF-8?q?feat(boot):=20the=20media=20foundations?= =?UTF-8?q?=20=E2=80=94=20ISO9660,=20probing,=20catalogue,=20stanzas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of plans/boot-media.md, the part with no wiring: reading an image, placing it, listing what is held, and writing the boot stanza each family needs. All dependency-free, as the plan's budget requires. - `iso.rs` reads ISO9660 far enough to turn a path into an offset and a length. A file in an image is one contiguous extent, so "extract the kernel" is a seek — nothing is unpacked and nothing is copied. Rock Ridge names win over the mangled identifiers, which is what makes `auto-installer-mode.toml` findable at all. Its test builder writes images in memory: no binary fixture in the repository. - `probe.rs` places an image from a table of markers. `/.disk/info` is read first, and Proxmox is why: `prepare-iso --pxe` strips `/boot` from the ISO it emits, so the obvious marker misses exactly the image most likely to be dropped into a media directory. Reading that file is upstream's own identification method. A trimmed image also finds the vmlinuz and initrd.img the assistant left beside it. - `catalog.rs` discovers rather than declares, cached behind the directory mtime with the same backstop the answer listing uses. AppleDouble entries are skipped from the first commit; reserved names are refused rather than shadowing a route. - `stanza.rs` holds what each family needs on the wire. The Proxmox stanza is upstream's own output, `proxmox-start-auto-installer` included — the plan's table said Proxmox needs nothing on the command line, and that is wrong: without that parameter a machine boots the interactive installer. - `sha256.rs` and `cpio.rs`, hand-written, for digests and for `initrd+iso`. 45 tests, twelve of them watched failing first — two were real bugs, a `locate` that returned a sentinel instead of None and a SHA-256 `update` that dropped a partial block. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- src/boot/catalog.rs | 604 +++++++++++++++++++++++++++ src/boot/cpio.rs | 222 ++++++++++ src/boot/iso.rs | 987 ++++++++++++++++++++++++++++++++++++++++++++ src/boot/mod.rs | 18 + src/boot/probe.rs | 548 ++++++++++++++++++++++++ src/boot/sha256.rs | 234 +++++++++++ src/boot/stanza.rs | 316 ++++++++++++++ src/lib.rs | 1 + 8 files changed, 2930 insertions(+) create mode 100644 src/boot/catalog.rs create mode 100644 src/boot/cpio.rs create mode 100644 src/boot/iso.rs create mode 100644 src/boot/mod.rs create mode 100644 src/boot/probe.rs create mode 100644 src/boot/sha256.rs create mode 100644 src/boot/stanza.rs diff --git a/src/boot/catalog.rs b/src/boot/catalog.rs new file mode 100644 index 0000000..7847485 --- /dev/null +++ b/src/boot/catalog.rs @@ -0,0 +1,604 @@ +//! The media catalogue: what images this server holds, discovered rather than declared. +//! +//! The same machinery the answers directory already uses — read the directory, cache +//! the listing, invalidate on mtime with a backstop behind it. Drop an ISO in and it +//! appears; no restart, no registration, no database. That instinct is the one the +//! answer set is built on and there is no reason for media to differ. +//! +//! **What `media add` writes is a sidecar, and nothing else.** The image is never +//! modified, never moved, never copied. A `.media` file beside it records what was +//! learned at ingest — the digest above all, since hashing 1.5 GB is a minute the +//! server must never spend inside a request. An image with no sidecar is still listed +//! and still served; it just has no digest to re-check and was probed on sight. + +use super::probe::{self, Arch, Family, Probed}; +use std::collections::BTreeMap; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime}; + +/// Editing a file's *contents* moves no directory mtime, and a filesystem may round the +/// timestamp it does move. Same reasoning, same value as the answer listing's. +const RELOAD_BACKSTOP: Duration = Duration::from_secs(1); + +/// What counts as an image. Deliberately short: a media directory is also where an +/// operator's notes, checksums and licence files end up, and none of those is bootable. +pub const IMAGE_EXTENSIONS: &[&str] = &["iso", "img"]; + +/// The sidecar `media add` writes. +pub const SIDECAR_EXTENSION: &str = "media"; + +/// Paths the listener answers itself. `valid_id` accepts dots, so `netboot.xyz` is a +/// *valid* identifier — which would let an entry shadow a fixed root. They are refused +/// at `media add` rather than resolved at request time, because a shadowed route fails +/// as a mysterious 404 rather than as an error anybody can act on. +pub const RESERVED_IDS: &[&str] = &["boot", "ipxe", "netboot.xyz", "health", "media"]; + +/// One image, as the listener and the menu need it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub id: String, + /// The image itself. **Always taken from here, never built from a request.** + pub path: PathBuf, + pub size: u64, + /// Recorded at ingest by `media add`. `None` means nobody has pinned this image. + pub digest: Option, + pub probed: Probed, + /// Where the kernel and initrd are, when they sit beside the image rather than + /// inside it — `prepare-iso --pxe` output, which is a directory of three files. + pub beside: Option, +} + +impl Entry { + pub fn family(&self) -> Family { + self.probed.family.unwrap_or(Family::Unknown) + } + + pub fn arch(&self) -> Option { + self.probed.arch + } + + /// A short human label for a listing: the version if a vendor left one, else the id. + pub fn describe(&self) -> String { + self.probed + .version + .clone() + .unwrap_or_else(|| self.id.clone()) + } + + /// Whether this entry can offer a kernel and an initrd at all. An image that cannot + /// is still served whole — `sanboot` and virtual media both take one. + pub fn bootable(&self) -> bool { + self.probed.kernel.is_some() && self.probed.initrd.is_some() + } +} + +#[derive(Debug, Clone, Default)] +pub struct Listing { + pub entries: Vec, + /// Everything wrong that is not worth refusing to serve over. A fleet must never be + /// unable to install because one image is odd. + pub problems: Vec, +} + +impl Listing { + pub fn get(&self, id: &str) -> Option<&Entry> { + self.entries.iter().find(|e| e.id == id) + } +} + +struct Cached { + version: Option, + loaded_at: Instant, + listing: Arc, +} + +pub struct Catalog { + dir: PathBuf, + cache: Mutex>, +} + +impl Catalog { + pub fn new(dir: impl Into) -> Catalog { + Catalog { + dir: dir.into(), + cache: Mutex::new(None), + } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + pub fn describe(&self) -> String { + format!("media in {}", self.dir.display()) + } + + /// The catalogue as it currently stands, from cache when nothing has moved. + pub fn listing(&self) -> io::Result> { + let version = self.version(); + // A poisoned lock means another request panicked mid-refresh. The data is still + // structurally sound, so carry on rather than failing an install over it. + let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + + if let Some(cached) = guard.as_ref() + && cached.version == version + && version.is_some() + && cached.loaded_at.elapsed() < RELOAD_BACKSTOP + { + return Ok(Arc::clone(&cached.listing)); + } + + let listing = Arc::new(self.build()); + *guard = Some(Cached { + version, + loaded_at: Instant::now(), + listing: Arc::clone(&listing), + }); + Ok(listing) + } + + pub fn get(&self, id: &str) -> io::Result> { + Ok(self.listing()?.get(id).cloned()) + } + + pub fn problems(&self) -> io::Result> { + Ok(self.listing()?.problems.clone()) + } + + /// One `stat`, standing in for the whole walk. A new file moves the directory's + /// mtime; a rewritten one does not, which is what the backstop above is for. + fn version(&self) -> Option { + let meta = std::fs::metadata(&self.dir).ok()?; + let mtime = meta.modified().ok()?; + let since = mtime.duration_since(SystemTime::UNIX_EPOCH).ok()?; + Some(format!("{}.{}", since.as_secs(), since.subsec_nanos())) + } + + fn build(&self) -> Listing { + let mut listing = Listing::default(); + + let entries = match std::fs::read_dir(&self.dir) { + Ok(entries) => entries, + Err(e) => { + // Not fatal, on purpose: the directory may appear, or have its + // permissions fixed, and this is re-read as it changes. + listing.problems.push(format!( + "{} cannot be listed: {e} — no images will be served until that is fixed", + self.dir.display() + )); + return listing; + } + }; + + let mut images: BTreeMap = BTreeMap::new(); + let mut sidecars: BTreeMap = BTreeMap::new(); + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + // A hidden file is never an image, and one kind is actively dangerous: a Mac + // editing this share over SMB drops AppleDouble `._` files beside every + // real one. The answers listing already skips them for the same reason. + if name.starts_with('.') { + continue; + } + // `file_type` comes back free with the readdir on Unix; only a symlink needs + // the extra stat to resolve. + let Ok(kind) = entry.file_type() else { + continue; + }; + let is_file = if kind.is_file() { + true + } else if kind.is_symlink() { + entry.path().is_file() + } else { + false + }; + if !is_file { + continue; + } + + let path = entry.path(); + let Some(extension) = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()) + else { + continue; + }; + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + + if extension == SIDECAR_EXTENSION { + sidecars.insert(stem, path); + } else if IMAGE_EXTENSIONS.contains(&extension.as_str()) { + images.insert(stem, path); + } + } + + for (id, path) in images { + match self.entry(&id, &path, sidecars.get(&id).map(PathBuf::as_path)) { + Ok(entry) => listing.entries.push(entry), + Err(problem) => listing.problems.push(problem), + } + } + + // A sidecar whose image is gone is a leftover, and a silent one: the entry + // simply stops existing and the menu shrinks with no explanation. + for (id, path) in &sidecars { + if !listing.entries.iter().any(|e| &e.id == id) { + listing.problems.push(format!( + "{}: no image named {id} — the sidecar describes something that is not here", + path.display() + )); + } + } + + listing.entries.sort_by(|a, b| a.id.cmp(&b.id)); + listing + } + + fn entry(&self, id: &str, path: &Path, sidecar: Option<&Path>) -> Result { + if !crate::store::valid_id(id) { + return Err(format!( + "{}: {id:?} is not a usable identifier — it becomes part of a URL", + path.display() + )); + } + if RESERVED_IDS.contains(&id) { + return Err(format!( + "{}: {id:?} is a reserved name — the listener answers that path itself", + path.display() + )); + } + + let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + let recorded = sidecar.map(Sidecar::load).transpose()?.unwrap_or_default(); + + // A sidecar that already carries the probe's answers saves opening the image. + // Without one, probe now: it is a few kilobytes of reads, not a pass over the + // file, and the listing is cached behind an mtime. + let probed = match recorded.probed() { + Some(probed) => probed, + None => probe::probe(path).unwrap_or_else(|e| { + // An image that will not parse is still an image somebody can `sanboot` + // or write to a stick. Serve it; describe it as unknown. + crate::log::server(&format!("warning: cannot probe {}: {e}", path.display())); + Probed::default() + }), + }; + + let beside = probed + .external + .then(|| path.parent().unwrap_or(Path::new(".")).to_path_buf()); + + Ok(Entry { + id: id.to_string(), + path: path.to_path_buf(), + size, + digest: recorded.digest, + probed, + beside, + }) + } +} + +/// What `media add` recorded about an image, so the server never re-learns it. +/// +/// Plain `key = value` lines, because this is a note beside a file rather than a +/// document anybody composes: no merging, no layering, no format negotiation. A key +/// nothing understands is ignored rather than refused — a newer rescriptum writing one +/// must not stop an older one from serving the image. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Sidecar { + pub digest: Option, + pub family: Option, + pub version: Option, + pub arch: Option, + pub kernel: Option, + pub initrd: Option, + pub external: bool, + pub zstd_initrd: bool, +} + +impl Sidecar { + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("{}: cannot be read: {e}", path.display()))?; + Ok(Sidecar::parse(&text)) + } + + pub fn parse(text: &str) -> Sidecar { + let mut out = Sidecar::default(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim().to_string(); + if value.is_empty() { + continue; + } + match key.trim() { + "sha256" => out.digest = Some(value), + "family" => out.family = Some(value), + "version" => out.version = Some(value), + "arch" => out.arch = Some(value), + "kernel" => out.kernel = Some(value), + "initrd" => out.initrd = Some(value), + "external" => out.external = value == "true", + "zstd-initrd" => out.zstd_initrd = value == "true", + _ => {} + } + } + out + } + + /// The probe's answers, when the sidecar carries enough of them to skip the image. + /// A sidecar with only a digest does not: the family is what the menu needs. + fn probed(&self) -> Option { + let family = self.family.as_deref().and_then(Family::parse)?; + Some(Probed { + family: Some(family), + version: self.version.clone(), + arch: self.arch.as_deref().and_then(Arch::parse), + kernel: self.kernel.clone(), + initrd: self.initrd.clone(), + external: self.external, + zstd_initrd: self.zstd_initrd, + }) + } + + pub fn render(digest: &str, probed: &Probed) -> String { + let mut out = String::from( + "# rescriptum media entry — written by `media add`.\n\ + # Delete it and the image is probed again on sight; nothing is lost but the\n\ + # recorded digest, which is the one thing this server will not re-compute\n\ + # inside a request.\n", + ); + out.push_str(&format!("sha256 = {digest}\n")); + if let Some(family) = probed.family { + out.push_str(&format!("family = {}\n", family.label())); + } + if let Some(version) = &probed.version { + out.push_str(&format!("version = {version}\n")); + } + if let Some(arch) = probed.arch { + out.push_str(&format!("arch = {}\n", arch.label())); + } + if let Some(kernel) = &probed.kernel { + out.push_str(&format!("kernel = {kernel}\n")); + } + if let Some(initrd) = &probed.initrd { + out.push_str(&format!("initrd = {initrd}\n")); + } + if probed.external { + out.push_str("external = true\n"); + } + if probed.zstd_initrd { + out.push_str("zstd-initrd = true\n"); + } + out + } + + /// Where the sidecar for an image lives. + pub fn path_for(image: &Path) -> PathBuf { + image.with_extension(SIDECAR_EXTENSION) + } +} + +#[cfg(test)] +mod tests { + use super::super::iso::build; + use super::*; + + struct Dir(PathBuf); + + impl Dir { + fn new(name: &str) -> Dir { + let path = std::env::temp_dir() + .join(format!("rescriptum-catalog-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("temp dir"); + Dir(path) + } + + fn image(&self, name: &str, builder: &build::Builder) -> PathBuf { + let path = self.0.join(name); + std::fs::write(&path, builder.build()).expect("write"); + path + } + + fn write(&self, name: &str, body: &[u8]) -> PathBuf { + let path = self.0.join(name); + std::fs::write(&path, body).expect("write"); + path + } + } + + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn bzimage() -> Vec { + let mut k = vec![0u8; 0x400]; + k[0x202..0x206].copy_from_slice(b"HdrS"); + k + } + + fn pve() -> build::Builder { + build::Builder::new() + .volume("PVE") + .file("/boot/linux26", &bzimage()) + .file("/boot/initrd.img", &[0x1f, 0x8b, 0, 0]) + } + + #[test] + fn an_image_dropped_in_the_directory_is_in_the_catalogue() { + // Discovered, not declared: the whole point. No registration step, no restart. + let dir = Dir::new("discovery"); + dir.image("pve-8.4.iso", &pve()); + + let catalog = Catalog::new(&dir.0); + let listing = catalog.listing().expect("lists"); + assert_eq!(listing.entries.len(), 1); + let entry = &listing.entries[0]; + assert_eq!(entry.id, "pve-8.4"); + assert_eq!(entry.family(), Family::Proxmox); + assert!(entry.bootable()); + assert_eq!(entry.digest, None, "nobody has pinned it"); + } + + #[test] + fn a_new_image_appears_without_a_restart() { + // The cache must not be a way to miss an image. One `Catalog`, as the cache + // invalidation tests for answers already insist: a fresh one per call would + // bypass the cache entirely and prove nothing. + let dir = Dir::new("appears"); + let catalog = Catalog::new(&dir.0); + assert_eq!(catalog.listing().expect("lists").entries.len(), 0); + + dir.image("pve-8.4.iso", &pve()); + std::thread::sleep(RELOAD_BACKSTOP + Duration::from_millis(50)); + assert_eq!(catalog.listing().expect("lists").entries.len(), 1); + } + + #[test] + fn hidden_and_appledouble_entries_are_skipped() { + // A Mac editing this share over SMB drops `._pve-8.4.iso` beside the real file. + // On the answers side that hijacked a machine's answer; here it would be a + // second, broken catalogue entry for every image. + let dir = Dir::new("appledouble"); + dir.image("pve-8.4.iso", &pve()); + dir.write("._pve-8.4.iso", b"AppleDouble junk"); + dir.write(".hidden.iso", b"junk"); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + assert_eq!(listing.entries.len(), 1); + assert_eq!(listing.entries[0].id, "pve-8.4"); + } + + #[test] + fn a_file_that_is_not_an_image_is_not_an_entry() { + // A media directory is also where notes and checksum files end up. + let dir = Dir::new("clutter"); + dir.image("pve-8.4.iso", &pve()); + dir.write("SHA256SUMS", b"9f86d0 pve-8.4.iso\n"); + dir.write("notes.txt", b"remember to update this"); + dir.write("vmlinuz", &bzimage()); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + assert_eq!(listing.entries.len(), 1, "{:?}", listing.entries); + } + + #[test] + fn a_reserved_name_is_refused_rather_than_shadowing_a_route() { + // `valid_id` accepts dots, so `netboot.xyz.iso` produces a *valid* identifier + // that would shadow the listener's own root — and a shadowed route fails as a + // mysterious 404 rather than as anything anybody can act on. + let dir = Dir::new("reserved"); + dir.image("netboot.xyz.iso", &pve()); + dir.image("boot.iso", &pve()); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + assert!(listing.entries.is_empty(), "{:?}", listing.entries); + assert_eq!(listing.problems.len(), 2); + assert!( + listing.problems.iter().all(|p| p.contains("reserved")), + "{:?}", + listing.problems + ); + } + + #[test] + fn a_sidecar_supplies_the_digest_and_spares_the_probe() { + let dir = Dir::new("sidecar"); + dir.image("pve-8.4.iso", &pve()); + dir.write( + "pve-8.4.media", + b"sha256 = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\n\ + family = proxmox\nversion = Proxmox VE 8.4-1\narch = x86_64\n\ + kernel = /boot/linux26\ninitrd = /boot/initrd.img\n", + ); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + let entry = listing.get("pve-8.4").expect("present"); + assert_eq!( + entry.digest.as_deref(), + Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08") + ); + assert_eq!(entry.describe(), "Proxmox VE 8.4-1"); + assert_eq!(entry.family(), Family::Proxmox); + } + + #[test] + fn a_sidecar_whose_image_is_gone_is_reported_rather_than_ignored() { + // Otherwise the entry simply stops existing and the menu shrinks silently. + let dir = Dir::new("orphan"); + dir.write("gone.media", b"sha256 = deadbeef\nfamily = proxmox\n"); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + assert!(listing.entries.is_empty()); + assert_eq!(listing.problems.len(), 1); + assert!( + listing.problems[0].contains("gone"), + "{:?}", + listing.problems + ); + } + + #[test] + fn a_sidecar_round_trips_through_its_own_renderer() { + let probed = Probed { + family: Some(Family::Proxmox), + version: Some("Proxmox VE 8.4-1".to_string()), + arch: Some(Arch::X86_64), + kernel: Some("/boot/linux26".to_string()), + initrd: Some("/boot/initrd.img".to_string()), + external: false, + zstd_initrd: true, + }; + let text = Sidecar::render("9f86d0", &probed); + let back = Sidecar::parse(&text); + assert_eq!(back.digest.as_deref(), Some("9f86d0")); + assert_eq!(back.probed(), Some(probed)); + } + + #[test] + fn an_unknown_sidecar_key_is_ignored_rather_than_refused() { + // A newer rescriptum writing a key this one does not know must not stop it + // serving the image. + let back = Sidecar::parse("sha256 = abc\nfamily = proxmox\nfuture-thing = 42\n"); + assert_eq!(back.digest.as_deref(), Some("abc")); + assert!(back.probed().is_some()); + } + + #[test] + fn a_missing_directory_is_a_problem_and_not_an_error() { + // A fleet must never be unable to install because the media directory is not + // there yet — the answer endpoint is untouched by any of this. + let catalog = Catalog::new("/nonexistent/rescriptum/media"); + let listing = catalog.listing().expect("still lists"); + assert!(listing.entries.is_empty()); + assert_eq!(listing.problems.len(), 1); + } + + #[test] + fn an_image_no_probe_places_is_still_an_entry() { + let dir = Dir::new("mystery"); + dir.write("mystery.iso", &vec![0u8; 64 * 1024]); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + let entry = listing.get("mystery").expect("still listed"); + assert_eq!(entry.family(), Family::Unknown); + assert!(!entry.bootable()); + assert_eq!(entry.describe(), "mystery"); + } +} diff --git a/src/boot/cpio.rs b/src/boot/cpio.rs new file mode 100644 index 0000000..f06059f --- /dev/null +++ b/src/boot/cpio.rs @@ -0,0 +1,222 @@ +//! A cpio writer, for exactly one job: appending an ISO to an initrd as a named member. +//! +//! Proxmox over PXE wants its image visible at `/proxmox.iso` inside the initramfs. +//! Modern iPXE does that itself (`initrd proxmox.iso`); older loaders cannot, and +//! the community answer has always been to build a 1.5 GB initrd with the image cpio'd +//! into it. We synthesise the same bytes **on the wire** instead of storing them, which +//! is why this is a header generator rather than an archiver: nothing here ever holds a +//! file, it only says what bytes go around one. +//! +//! Two properties of the kernel's initramfs loader make it work with no compressor: +//! concatenated archives are all unpacked, and an **uncompressed** segment among +//! compressed ones is fine. +//! +//! The format is "newc" (SVR4, no CRC): a 110-byte header of ASCII hex fields, the NUL +//! terminated name, then the data, each padded to a four-byte boundary. + +/// `c_filesize` is eight hex digits. An image at or past 4 GiB cannot be described, and +/// saying so beats emitting a header that wraps. +pub const MAX_MEMBER: u64 = 0xffff_ffff; + +const MAGIC: &[u8; 6] = b"070701"; +const HEADER: usize = 110; +/// A regular file, mode 0644. +const MODE_FILE: u32 = 0o100_644; + +/// What surrounds one member's data, with the arithmetic already done — the media +/// listener needs an exact `Content-Length` before it has read a byte. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Member { + /// Header, name, and the padding that aligns the data that follows. + pub prefix: Vec, + /// Zero bytes after the data, aligning whatever comes next. + pub padding: usize, +} + +impl Member { + /// Total bytes this member contributes, data included. + pub fn len(&self, data: u64) -> u64 { + self.prefix.len() as u64 + data + self.padding as u64 + } +} + +/// Describe a regular file member. `size` is the data that will follow the prefix. +/// +/// The inode number is a caller's choice only because it must be unique within the +/// archive; a single appended member can safely be any non-zero value. +pub fn member(name: &str, size: u64, ino: u32) -> Result { + if size > MAX_MEMBER { + return Err(format!( + "{name} is {size} bytes; a cpio member cannot exceed {MAX_MEMBER} (4 GiB). \ + Serve the image directly and let the loader name it instead." + )); + } + // A leading slash would make it an absolute path inside the archive, which the + // kernel's unpacker refuses; the members it creates all sit at the root. + if name.is_empty() || name.starts_with('/') || name.contains('\0') { + return Err(format!("{name:?} is not a usable member name")); + } + + let mut prefix = Vec::with_capacity(HEADER + name.len() + 8); + prefix.extend_from_slice(MAGIC); + let fields = [ + ino, + MODE_FILE, + 0, // uid: root, because an initramfs member has no other sensible owner + 0, // gid + 1, // nlink + 0, // mtime: fixed, so the same request twice produces the same bytes + size as u32, + 0, // devmajor + 0, // devminor + 0, // rdevmajor + 0, // rdevminor + (name.len() + 1) as u32, + 0, // check: unused in newc, and zero is what everyone writes + ]; + for field in fields { + prefix.extend_from_slice(hex8(field).as_bytes()); + } + prefix.extend_from_slice(name.as_bytes()); + prefix.push(0); + // The name is padded so that the data begins on a four-byte boundary. + prefix.resize(align4(prefix.len()), 0); + + Ok(Member { + prefix, + padding: align4(size as usize) - size as usize, + }) +} + +/// The end-of-archive marker. Every reader stops here, so anything appended after it is +/// a separate archive — which is exactly how concatenation works. +pub fn trailer() -> Vec { + let mut end = member("TRAILER!!!", 0, 0) + .expect("the trailer name is fixed and valid") + .prefix; + // Archives are padded to 512 bytes at the end. The kernel does not require it, but + // every other cpio reader expects it and it costs a few hundred zeros. + let padded = end.len().div_ceil(512) * 512; + end.resize(padded, 0); + end +} + +fn align4(n: usize) -> usize { + n.div_ceil(4) * 4 +} + +fn hex8(value: u32) -> String { + format!("{value:08X}") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Assemble what the listener would stream, so the assertions below are about real + /// archive bytes rather than about the generator's internals. + fn archive(members: &[(&str, &[u8])]) -> Vec { + let mut out = Vec::new(); + for (i, (name, data)) in members.iter().enumerate() { + let m = member(name, data.len() as u64, i as u32 + 1).expect("describable"); + assert_eq!( + m.len(data.len() as u64) as usize, + m.prefix.len() + data.len() + m.padding + ); + out.extend_from_slice(&m.prefix); + out.extend_from_slice(data); + out.resize(out.len() + m.padding, 0); + } + out.extend_from_slice(&trailer()); + out + } + + #[test] + fn a_member_header_is_the_shape_the_format_specifies() { + let m = member("proxmox.iso", 0x1234_5678, 1).expect("describable"); + assert_eq!(&m.prefix[..6], MAGIC); + // Every field is eight upper-case hex digits, so the header is fixed width. + let fields = std::str::from_utf8(&m.prefix[6..HEADER]).expect("ascii"); + assert_eq!(fields.len(), 104, "13 fields of 8"); + assert!( + fields + .bytes() + .all(|b| b.is_ascii_digit() || (b'A'..=b'F').contains(&b)), + "{fields}" + ); + // c_filesize is the seventh field, and it carries the size we were given. + assert_eq!(&fields[48..56], "12345678"); + // The name follows, NUL-terminated, and the data begins aligned. + assert!(m.prefix[HEADER..].starts_with(b"proxmox.iso\0")); + assert_eq!(m.prefix.len() % 4, 0); + } + + #[test] + fn data_and_the_member_after_it_both_start_aligned() { + // The alignment is the whole reason the padding exists: an unaligned member is + // the failure that unpacks as garbage rather than as an error. + for size in [0usize, 1, 2, 3, 4, 5, 1023, 1024, 1025] { + let m = member("x", size as u64, 1).expect("describable"); + assert_eq!(m.prefix.len() % 4, 0, "size {size}"); + assert_eq!((m.prefix.len() + size + m.padding) % 4, 0, "size {size}"); + assert!(m.padding < 4); + } + } + + #[test] + fn an_archive_reads_back_as_the_members_that_went_in() { + // A miniature reader, because asserting on bytes proves the generator agrees + // with itself and nothing more. + let built = archive(&[("proxmox.iso", b"an image, pretend"), ("second", b"!!")]); + + let mut at = 0usize; + let mut found: Vec<(String, Vec)> = Vec::new(); + loop { + assert_eq!(&built[at..at + 6], MAGIC, "member at {at}"); + let field = |n: usize| -> usize { + let text = std::str::from_utf8(&built[at + 6 + n * 8..at + 6 + n * 8 + 8]).unwrap(); + usize::from_str_radix(text, 16).unwrap() + }; + let size = field(6); + let namesize = field(11); + let name = String::from_utf8(built[at + HEADER..at + HEADER + namesize - 1].to_vec()) + .expect("utf8"); + if name == "TRAILER!!!" { + break; + } + let data_at = align4(at + HEADER + namesize); + found.push((name, built[data_at..data_at + size].to_vec())); + at = align4(data_at + size); + } + + assert_eq!(found.len(), 2); + assert_eq!(found[0].0, "proxmox.iso"); + assert_eq!(found[0].1, b"an image, pretend"); + assert_eq!(found[1].0, "second"); + assert_eq!(found[1].1, b"!!"); + } + + #[test] + fn an_image_too_large_to_describe_is_refused_by_name() { + // Eight hex digits cannot hold it, and a header that silently wrapped would + // produce an initrd the kernel unpacks as garbage. + let e = member("proxmox.iso", MAX_MEMBER + 1, 1).expect_err("must refuse"); + assert!(e.contains("4 GiB"), "{e}"); + assert!(member("proxmox.iso", MAX_MEMBER, 1).is_ok()); + } + + #[test] + fn a_name_the_kernel_would_refuse_is_refused_here() { + assert!(member("/proxmox.iso", 1, 1).is_err(), "absolute"); + assert!(member("", 1, 1).is_err(), "empty"); + assert!(member("pro\0mox", 1, 1).is_err(), "embedded NUL"); + } + + #[test] + fn the_trailer_ends_the_archive_on_a_block_boundary() { + let end = trailer(); + assert!(end.starts_with(MAGIC)); + assert!(end[HEADER..].starts_with(b"TRAILER!!!\0")); + assert_eq!(end.len() % 512, 0); + } +} diff --git a/src/boot/iso.rs b/src/boot/iso.rs new file mode 100644 index 0000000..cb4dff0 --- /dev/null +++ b/src/boot/iso.rs @@ -0,0 +1,987 @@ +//! Reading ISO9660, far enough to find a file and say where it is. +//! +//! The property this is built on: **a file in an ISO9660 image is one contiguous +//! extent.** So "extract the kernel" is not an extraction at all — it is an offset and +//! a length, and the media listener streams those bytes straight out of the image with +//! a seek. Nothing is unpacked, nothing is copied, and a 1.5 GB image costs the same +//! few kilobytes of reads whether we want its `/boot/linux26` or its `/.disk/info`. +//! +//! Only the read half lives here. Writing — adding `auto-installer-mode.toml` to a +//! Proxmox image without rewriting 1.5 GB — is Phase 4, and it will build on the same +//! parsing. +//! +//! Three name spaces can coexist in one image and this matters more than it sounds: +//! +//! - **ISO9660** proper, whose identifiers are upper-case, dot-bearing and suffixed +//! with `;1`. `auto-installer-mode.toml` is not expressible in it at all. +//! - **Rock Ridge** (SUSP `NM` entries), which is what Linux actually shows when it +//! mounts one, and therefore what an installer looking for its own file will see. +//! - **Joliet**, a second directory tree entirely, with UCS-2 names. +//! +//! A lookup here tries the Rock Ridge name first and the ISO9660 identifier second, so +//! a marker path written the way a human writes it resolves either way. + +use std::fs::File; +use std::io::{self, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +/// ISO9660's logical sector. Not configurable in practice — the field exists in the +/// descriptor, and every image ever written puts 2048 in it. +pub const SECTOR: u64 = 2048; +/// The first sixteen sectors are the system area, reserved for boot code. The primary +/// volume descriptor is what follows. +const FIRST_DESCRIPTOR: u64 = 16 * SECTOR; +/// A directory record is at least this long before its name. +const RECORD_HEADER: usize = 33; +/// Guard against a malformed image sending the walk around forever. +const MAX_DEPTH: usize = 16; +/// A directory extent larger than this is not a directory, it is a corrupt field. +const MAX_DIRECTORY: u64 = 16 * 1024 * 1024; + +/// Where a file's bytes are, which is all the listener needs to serve it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Extent { + /// Byte offset into the image. + pub offset: u64, + pub size: u64, + pub directory: bool, +} + +/// Which name spaces an image carries. Phase 1 reports it; Phase 4 refuses on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Trees { + /// SUSP is present, and with it Rock Ridge's long names. + pub rock_ridge: bool, + /// A supplementary descriptor with a Joliet escape sequence. + pub joliet: bool, +} + +/// An opened image: the descriptor facts, and a handle to read extents from. +pub struct Iso { + file: File, + path: PathBuf, + root: Extent, + /// The Joliet tree's root, when there is one. + joliet_root: Option, + pub trees: Trees, + /// The volume identifier, trimmed. The fallback name for an image no probe places. + pub volume_id: String, + /// Blocks the descriptor claims, times the sector size. A file extent past this is + /// a truncated download, and saying so beats serving zeros. + pub declared_size: u64, + /// Bytes at the start of every system use area that belong to somebody else, as the + /// root's `SP` entry declares. Read once at open: recomputing it per record would + /// re-parse the root directory for every entry in the image. + susp_skip: usize, +} + +impl Iso { + pub fn open(path: impl AsRef) -> io::Result { + let path = path.as_ref().to_path_buf(); + let mut file = File::open(&path)?; + + let mut primary: Option<[u8; SECTOR as usize]> = None; + let mut supplementary: Option<[u8; SECTOR as usize]> = None; + + // Volume descriptors run from sector 16 until a terminator. A handful of images + // carry a dozen; none carries hundreds, so the cap is a guard, not a policy. + for index in 0..32u64 { + let mut sector = [0u8; SECTOR as usize]; + file.seek(SeekFrom::Start(FIRST_DESCRIPTOR + index * SECTOR))?; + if file.read_exact(&mut sector).is_err() { + break; + } + if §or[1..6] != b"CD001" { + // Not a descriptor at all. If we have not even found the primary yet, + // this is not an ISO9660 image and saying so is the whole answer. + break; + } + match sector[0] { + 1 => primary = Some(sector), + 2 if supplementary.is_none() && is_joliet(§or) => supplementary = Some(sector), + 255 => break, + _ => {} + } + } + + let Some(pvd) = primary else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{} has no ISO9660 primary volume descriptor — it is not an ISO image", + path.display() + ), + )); + }; + + // The root directory record sits inside the descriptor, all 34 bytes of it. + let root = record(&pvd[156..190], 0).map(|r| r.extent).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{}: the root directory record is malformed", path.display()), + ) + })?; + + let joliet_root = supplementary + .as_ref() + .and_then(|svd| record(&svd[156..190], 0)) + .map(|r| r.extent); + + let declared_size = both_endian32(&pvd[80..88]).unwrap_or(0) as u64 * SECTOR; + let volume_id = strip(&pvd[40..72]); + + let mut iso = Iso { + file, + path, + root, + joliet_root, + trees: Trees { + rock_ridge: false, + joliet: supplementary.is_some(), + }, + volume_id, + declared_size, + susp_skip: 0, + }; + iso.trees.rock_ridge = iso.detect_rock_ridge(); + Ok(iso) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Resolve an absolute path inside the image. + /// + /// **This is the path-traversal guard, and it is structural rather than a check.** + /// An ISO9660 image is its own root: `../../../etc/shadow` resolves to nothing here + /// because no such record exists in its directory tree. `..` is refused all the + /// same — belt and braces cost one line — but the property does not depend on it. + pub fn locate(&mut self, path: &str) -> io::Result> { + let root = self.root; + self.walk_in(root, path, false) + } + + /// The same, in the Joliet tree, for an image that has one. + pub fn locate_joliet(&mut self, path: &str) -> io::Result> { + let Some(root) = self.joliet_root else { + return Ok(None); + }; + self.walk_in(root, path, true) + } + + fn walk_in(&mut self, root: Extent, path: &str, joliet: bool) -> io::Result> { + let segments: Vec<&str> = path + .trim_matches('/') + .split('/') + .filter(|s| !s.is_empty() && *s != ".") + .collect(); + if segments.len() > MAX_DEPTH || segments.contains(&"..") { + return Ok(None); + } + + let mut current = root; + for segment in segments { + // A path continued past a plain file matches nothing. + if !current.directory { + return Ok(None); + } + match self.entry_in(current, segment, joliet)? { + Some(found) => current = found, + None => return Ok(None), + } + } + Ok(Some(current)) + } + + /// One directory's worth of records, matched by name. + fn entry_in( + &mut self, + directory: Extent, + name: &str, + joliet: bool, + ) -> io::Result> { + let wanted = name.to_ascii_lowercase(); + for entry in self.entries(directory)? { + let matches = if joliet { + entry.joliet_name.as_deref() == Some(wanted.as_str()) + } else { + entry.rock_ridge_name.as_deref() == Some(wanted.as_str()) || entry.name == wanted + }; + if matches { + return Ok(Some(entry.extent)); + } + } + Ok(None) + } + + /// Every record in a directory extent. Used by lookups and by the probe. + pub fn entries(&mut self, directory: Extent) -> io::Result> { + if !directory.directory || directory.size == 0 || directory.size > MAX_DIRECTORY { + return Ok(Vec::new()); + } + let mut buffer = vec![0u8; directory.size as usize]; + self.file.seek(SeekFrom::Start(directory.offset))?; + self.file.read_exact(&mut buffer)?; + + let mut entries = Vec::new(); + let mut at = 0usize; + while at + RECORD_HEADER <= buffer.len() { + let length = buffer[at] as usize; + if length == 0 { + // A zero length pads to the end of the sector; records resume in the + // next one. This is normal, not the end of the directory. + at = (at / SECTOR as usize + 1) * SECTOR as usize; + continue; + } + if length < RECORD_HEADER || at + length > buffer.len() { + break; + } + if let Some(entry) = record(&buffer[at..at + length], self.skip_length()) { + // `.` and `..` are records with one-byte identifiers 0x00 and 0x01, and + // no caller here wants them. + if !entry.special { + entries.push(entry); + } + } + at += length; + } + Ok(entries) + } + + /// Read a whole file out of the image, for the small ones — a version string, a + /// mode file. Anything image-sized is streamed by the listener instead. + pub fn read(&mut self, path: &str, limit: u64) -> io::Result>> { + let Some(extent) = self.locate(path)? else { + return Ok(None); + }; + if extent.directory { + return Ok(None); + } + let size = extent.size.min(limit) as usize; + let mut buffer = vec![0u8; size]; + self.file.seek(SeekFrom::Start(extent.offset))?; + self.file.read_exact(&mut buffer)?; + Ok(Some(buffer)) + } + + /// Whether a path resolves at all, in either tree. The probe's whole vocabulary. + pub fn has(&mut self, path: &str) -> bool { + matches!(self.locate(path), Ok(Some(e)) if !e.directory) + || matches!(self.locate_joliet(path), Ok(Some(e)) if !e.directory) + } + + fn skip_length(&self) -> usize { + self.susp_skip + } + + /// SUSP announces itself in the root directory's `.` record, and declares how many + /// bytes of each system use area belong to somebody else. + fn detect_rock_ridge(&mut self) -> bool { + // The `.` record of the root directory carries the SP entry, which is what says + // SUSP — and therefore Rock Ridge's `NM` names — is in use at all. + let root = self.root; + let Ok(mut buffer) = self.read_extent_head(root, SECTOR as usize) else { + return false; + }; + buffer.truncate(root.size.min(SECTOR) as usize); + if buffer.len() < RECORD_HEADER { + return false; + } + let length = buffer[0] as usize; + if length < RECORD_HEADER || length > buffer.len() { + return false; + } + let name_len = buffer[32] as usize; + let mut at = RECORD_HEADER + name_len; + if name_len % 2 == 0 { + at += 1; + } + let system = &buffer[at.min(length)..length]; + // SP is "SP", length 7, version 1, then 0xBE 0xEF, then the skip length. + let mut found = false; + for entry in susp_entries(system, 0) { + if entry.signature == *b"SP" && entry.data.len() >= 3 { + found = true; + self.susp_skip = entry.data[2] as usize; + } + } + found + } + + fn read_extent_head(&mut self, extent: Extent, want: usize) -> io::Result> { + let size = (extent.size as usize).min(want); + let mut buffer = vec![0u8; size]; + self.file.seek(SeekFrom::Start(extent.offset))?; + self.file.read_exact(&mut buffer)?; + Ok(buffer) + } + + /// Hand out the file so the listener can stream an extent without reopening. + pub fn into_file(self) -> File { + self.file + } +} + +/// One directory record, with every name it answers to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// The ISO9660 identifier, lower-cased, with the `;1` version and a trailing dot + /// removed — the form a human would have written. + pub name: String, + /// The Rock Ridge `NM` name, when the record carries one. + pub rock_ridge_name: Option, + /// The Joliet name, when this record came from that tree. + pub joliet_name: Option, + pub extent: Extent, + /// `.` or `..`. + pub special: bool, +} + +fn record(bytes: &[u8], susp_skip: usize) -> Option { + if bytes.len() < RECORD_HEADER { + return None; + } + let name_len = bytes[32] as usize; + if RECORD_HEADER + name_len > bytes.len() { + return None; + } + let lba = both_endian32(&bytes[2..10])?; + let size = both_endian32(&bytes[10..18])?; + let directory = bytes[25] & 0x02 != 0; + let raw = &bytes[RECORD_HEADER..RECORD_HEADER + name_len]; + + let special = name_len == 1 && (raw[0] == 0 || raw[0] == 1); + let extent = Extent { + offset: lba as u64 * SECTOR, + size: size as u64, + directory, + }; + + // The system use area begins after the identifier, plus a pad byte when the + // identifier length is even. + let mut at = RECORD_HEADER + name_len; + if name_len % 2 == 0 { + at += 1; + } + let system = bytes.get(at..).unwrap_or(&[]); + + Some(Entry { + name: iso_name(raw), + rock_ridge_name: rock_ridge_name(system, susp_skip), + joliet_name: ucs2_name(raw), + extent, + special, + }) +} + +/// `LINUX26.;1` and `BOOT` and `README.TXT;1` all become what a person would type. +fn iso_name(raw: &[u8]) -> String { + let text = String::from_utf8_lossy(raw); + let text = text.split(';').next().unwrap_or(""); + text.trim_end_matches('.').to_ascii_lowercase() +} + +/// Joliet identifiers are UCS-2, big-endian. Anything outside the basic plane is not +/// something a boot file is named, so a lossy decode is the honest one. +fn ucs2_name(raw: &[u8]) -> Option { + if raw.len() < 2 || raw.len() % 2 != 0 { + return None; + } + let units: Vec = raw + .chunks_exact(2) + .map(|pair| u16::from_be_bytes([pair[0], pair[1]])) + .collect(); + let text = String::from_utf16_lossy(&units); + let text = text.split(';').next().unwrap_or(""); + Some(text.trim_end_matches('.').to_ascii_lowercase()) +} + +/// The `NM` entries of a system use area, concatenated — a long name can be split +/// across several with a CONTINUE flag. +fn rock_ridge_name(system: &[u8], skip: usize) -> Option { + let mut name = String::new(); + for entry in susp_entries(system, skip) { + if entry.signature != *b"NM" || entry.data.is_empty() { + continue; + } + let flags = entry.data[0]; + // Bits 1 and 2 mean "current" and "parent"; those are not names. + if flags & 0b0000_0110 != 0 { + return None; + } + name.push_str(&String::from_utf8_lossy(&entry.data[1..])); + if flags & 0b0000_0001 == 0 { + break; + } + } + (!name.is_empty()).then(|| name.to_ascii_lowercase()) +} + +struct Susp<'a> { + signature: [u8; 2], + data: &'a [u8], +} + +/// Walk a system use area. Entries are `signature[2] len version data…`. +fn susp_entries(system: &[u8], skip: usize) -> Vec> { + let mut out = Vec::new(); + let mut at = skip.min(system.len()); + while at + 4 <= system.len() { + let length = system[at + 2] as usize; + if length < 4 || at + length > system.len() { + break; + } + out.push(Susp { + signature: [system[at], system[at + 1]], + data: &system[at + 4..at + length], + }); + at += length; + } + out +} + +/// ISO9660 records every number twice, little-endian then big-endian. Read the little +/// half and check the other agrees — a disagreement is a corrupt image, and trusting +/// either half of it would serve garbage. +fn both_endian32(bytes: &[u8]) -> Option { + if bytes.len() < 8 { + return None; + } + let little = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + let big = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + (little == big).then_some(little) +} + +fn is_joliet(sector: &[u8]) -> bool { + // The escape sequences at offset 88: %/@, %/C, %/E — UCS-2 at three levels. + let escapes = §or[88..120]; + escapes + .windows(3) + .any(|w| w == b"%/@" || w == b"%/C" || w == b"%/E") +} + +fn strip(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).trim().to_string() +} + +/// Building an ISO9660 image, for tests and for nothing else. +/// +/// **No binary fixture in the repository.** A checked-in ISO is a blob nobody can +/// review, that nobody can vary, and that grows a repository by megabytes. This builds +/// exactly the image a test needs, in memory, and doubles as the case generator for the +/// Rock Ridge, Joliet and refusal paths Phase 4 will need. +#[cfg(test)] +pub mod build { + use super::*; + use std::collections::BTreeMap; + + /// What to put in the image, and which name spaces to describe it in. + pub struct Builder { + files: BTreeMap>, + pub volume_id: String, + pub rock_ridge: bool, + pub joliet: bool, + } + + impl Default for Builder { + fn default() -> Self { + Builder { + files: BTreeMap::new(), + volume_id: "TEST VOLUME".to_string(), + rock_ridge: true, + joliet: false, + } + } + } + + impl Builder { + pub fn new() -> Builder { + Builder::default() + } + + /// One file, at `/name` or `/dir/name`. One level of directories is all any + /// marker path in the probe table needs. + pub fn file(mut self, path: &str, content: &[u8]) -> Builder { + self.files + .insert(path.trim_start_matches('/').to_string(), content.to_vec()); + self + } + + pub fn volume(mut self, id: &str) -> Builder { + self.volume_id = id.to_string(); + self + } + + pub fn rock_ridge(mut self, on: bool) -> Builder { + self.rock_ridge = on; + self + } + + pub fn joliet(mut self, on: bool) -> Builder { + self.joliet = on; + self + } + + pub fn build(&self) -> Vec { + // Directories, then their files, each on its own sector boundary. + let mut dirs: BTreeMap> = BTreeMap::new(); + dirs.insert(String::new(), Vec::new()); + for path in self.files.keys() { + match path.rsplit_once('/') { + Some((dir, _)) => { + dirs.entry(dir.to_string()).or_default().push(path.clone()); + // Every ancestor has to exist as a directory of its own — + // `/images/pxeboot/vmlinuz` needs `/images` even though no file + // sits directly in it. + let mut ancestor = dir; + while let Some((parent, _)) = ancestor.rsplit_once('/') { + dirs.entry(parent.to_string()).or_default(); + ancestor = parent; + } + } + None => dirs.entry(String::new()).or_default().push(path.clone()), + } + } + + // Sector 16 is the PVD, 17 the SVD when there is one, then the terminator. + // Data starts at 20 — fixed, so a failing test's offsets are readable. + let mut next = 20u32; + let mut dir_lba: BTreeMap = BTreeMap::new(); + for dir in dirs.keys() { + dir_lba.insert(dir.clone(), next); + next += 1; + } + // The Joliet tree is a second set of directory extents over the same data. + let mut joliet_lba: BTreeMap = BTreeMap::new(); + if self.joliet { + for dir in dirs.keys() { + joliet_lba.insert(dir.clone(), next); + next += 1; + } + } + let mut file_lba: BTreeMap = BTreeMap::new(); + for (path, content) in &self.files { + let sectors = (content.len() as u64).div_ceil(SECTOR).max(1) as u32; + file_lba.insert(path.clone(), (next, content.len() as u32)); + next += sectors; + } + let total = next; + + let mut image = vec![0u8; total as usize * SECTOR as usize]; + let put = |image: &mut Vec, lba: u32, bytes: &[u8]| { + let at = lba as usize * SECTOR as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); + }; + + // Descriptors. + let root_lba = dir_lba[""]; + put( + &mut image, + 16, + &self.descriptor(1, root_lba, self.dir_size(&dirs, "", false), total), + ); + let terminator_lba = if self.joliet { + let joliet_root = joliet_lba[""]; + put( + &mut image, + 17, + &self.descriptor(2, joliet_root, self.dir_size(&dirs, "", true), total), + ); + 18 + } else { + 17 + }; + let mut terminator = vec![0u8; SECTOR as usize]; + terminator[0] = 255; + terminator[1..6].copy_from_slice(b"CD001"); + terminator[6] = 1; + put(&mut image, terminator_lba, &terminator); + + // Directory extents, in both trees. + for (dir, children) in &dirs { + let extent = self.directory(dir, children, &dirs, &dir_lba, &file_lba, false); + put(&mut image, dir_lba[dir], &extent); + if self.joliet { + let extent = self.directory(dir, children, &dirs, &joliet_lba, &file_lba, true); + put(&mut image, joliet_lba[dir], &extent); + } + } + + // File data. + for (path, content) in &self.files { + put(&mut image, file_lba[path].0, content); + } + image + } + + fn dir_size(&self, dirs: &BTreeMap>, dir: &str, _joliet: bool) -> u32 { + let _ = dirs; + let _ = dir; + SECTOR as u32 + } + + fn descriptor(&self, kind: u8, root_lba: u32, root_size: u32, total: u32) -> Vec { + let mut d = vec![0u8; SECTOR as usize]; + d[0] = kind; + d[1..6].copy_from_slice(b"CD001"); + d[6] = 1; + d[8..40].fill(b' '); + d[40..72].fill(b' '); + let id = self.volume_id.as_bytes(); + let n = id.len().min(32); + d[40..40 + n].copy_from_slice(&id[..n]); + d[80..88].copy_from_slice(&both32(total)); + if kind == 2 { + // The escape sequence is what makes a supplementary descriptor Joliet. + d[88..91].copy_from_slice(b"%/E"); + } + d[120..124].copy_from_slice(&both16(1)); + d[124..128].copy_from_slice(&both16(1)); + d[128..132].copy_from_slice(&both16(SECTOR as u16)); + let root = self.record(&[0u8], root_lba, root_size, true, kind == 2, true); + d[156..156 + root.len()].copy_from_slice(&root); + d + } + + fn directory( + &self, + dir: &str, + children: &[String], + dirs: &BTreeMap>, + dir_lba: &BTreeMap, + file_lba: &BTreeMap, + joliet: bool, + ) -> Vec { + let mut out = Vec::new(); + // `.` carries the SP entry that announces SUSP, in the root only. + out.extend_from_slice(&self.record( + &[0u8], + dir_lba[dir], + SECTOR as u32, + true, + joliet, + dir.is_empty(), + )); + let parent = dir.rsplit_once('/').map(|(p, _)| p).unwrap_or(""); + out.extend_from_slice(&self.record( + &[1u8], + dir_lba[parent], + SECTOR as u32, + true, + joliet, + false, + )); + + // Subdirectories of this one. + for other in dirs.keys() { + if other.is_empty() || other == dir { + continue; + } + let (its_parent, base) = other.rsplit_once('/').unwrap_or(("", other.as_str())); + if its_parent != dir { + continue; + } + let name = self.identifier(base, true, joliet); + out.extend_from_slice(&self.record( + &name, + dir_lba[other], + SECTOR as u32, + true, + joliet, + false, + )); + } + + for path in children { + let base = path.rsplit_once('/').map(|(_, b)| b).unwrap_or(path); + let name = self.identifier(base, false, joliet); + let (lba, size) = file_lba[path]; + out.extend_from_slice(&self.record(&name, lba, size, false, joliet, false)); + } + assert!( + out.len() <= SECTOR as usize, + "test directory outgrew a sector" + ); + out + } + + /// ISO9660 identifiers are upper-case and version-suffixed; Joliet's are UCS-2. + /// A name that cannot be expressed in ISO9660 — a hyphen, lower case — is + /// deliberately mangled here exactly as a real mastering tool would mangle it, + /// because that mangling is the trap Rock Ridge exists to undo. + fn identifier(&self, base: &str, directory: bool, joliet: bool) -> Vec { + if joliet { + let mut out = Vec::new(); + for unit in base.encode_utf16() { + out.extend_from_slice(&unit.to_be_bytes()); + } + return out; + } + let mangled: String = base + .to_ascii_uppercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' { + c + } else { + '_' + } + }) + .collect(); + if directory { + mangled.into_bytes() + } else { + format!("{mangled};1").into_bytes() + } + } + + fn record( + &self, + name: &[u8], + lba: u32, + size: u32, + directory: bool, + joliet: bool, + with_sp: bool, + ) -> Vec { + let mut system: Vec = Vec::new(); + if self.rock_ridge && !joliet { + if with_sp { + system.extend_from_slice(&[b'S', b'P', 7, 1, 0xBE, 0xEF, 0]); + } + // A `.`/`..` record gets no NM: its name is structural. + if !(name.len() == 1 && (name[0] == 0 || name[0] == 1)) { + // The real name, the one a mounted image shows. + let real = self.rock_ridge_name_for(name); + let mut nm = vec![b'N', b'M', (5 + real.len()) as u8, 1, 0]; + nm.extend_from_slice(real.as_bytes()); + system.extend_from_slice(&nm); + } + } + + let mut r = vec![0u8; RECORD_HEADER]; + r[2..10].copy_from_slice(&both32(lba)); + r[10..18].copy_from_slice(&both32(size)); + r[25] = if directory { 0x02 } else { 0 }; + r[28..32].copy_from_slice(&both16(1)); + r[32] = name.len() as u8; + r.extend_from_slice(name); + if name.len() % 2 == 0 { + r.push(0); + } + r.extend_from_slice(&system); + if r.len() % 2 == 1 { + r.push(0); + } + r[0] = r.len() as u8; + r + } + + /// The builder mangles ISO9660 identifiers, so the Rock Ridge name has to be + /// recovered from what the caller asked for. Tests set it through `file`, and + /// the mangling is reversible enough for the fixture's purposes: the NM name is + /// the original base name, which is exactly what a mastering tool records. + fn rock_ridge_name_for(&self, mangled: &[u8]) -> String { + let text = String::from_utf8_lossy(mangled); + let stem = text.split(';').next().unwrap_or("").to_ascii_lowercase(); + // Find the original spelling among the paths we were given. + for path in self.files.keys() { + let base = path.rsplit_once('/').map(|(_, b)| b).unwrap_or(path); + if mangle_matches(base, &stem) { + return base.to_string(); + } + if let Some((dir, _)) = path.split_once('/') + && mangle_matches(dir, &stem) + { + return dir.to_string(); + } + } + stem + } + } + + fn mangle_matches(original: &str, mangled: &str) -> bool { + let expected: String = original + .to_ascii_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' { + c + } else { + '_' + } + }) + .collect(); + expected == mangled + } + + fn both32(value: u32) -> [u8; 8] { + let mut out = [0u8; 8]; + out[..4].copy_from_slice(&value.to_le_bytes()); + out[4..].copy_from_slice(&value.to_be_bytes()); + out + } + + fn both16(value: u16) -> [u8; 4] { + let mut out = [0u8; 4]; + out[..2].copy_from_slice(&value.to_le_bytes()); + out[2..].copy_from_slice(&value.to_be_bytes()); + out + } + + /// Write an image to a temporary file and open it, which is what every test wants. + pub fn open(name: &str, builder: &Builder) -> (std::path::PathBuf, Iso) { + let dir = std::env::temp_dir().join(format!("rescriptum-iso-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join(format!("{name}.iso")); + std::fs::write(&path, builder.build()).expect("write image"); + let iso = Iso::open(&path).expect("opens"); + (path, iso) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_file_resolves_to_an_offset_and_a_length() { + // The property the whole listener rests on: a file is a contiguous extent, so + // serving it is a seek and a length rather than an extraction. + let (_path, mut iso) = build::open( + "basic", + &build::Builder::new().file("/boot/linux26", b"kernel bytes"), + ); + + let extent = iso + .locate("/boot/linux26") + .expect("readable") + .expect("present"); + assert_eq!(extent.size, 12); + assert_eq!(extent.offset % SECTOR, 0, "extents are sector-aligned"); + assert!(!extent.directory); + + assert_eq!( + iso.read("/boot/linux26", 1024).expect("readable"), + Some(b"kernel bytes".to_vec()) + ); + } + + #[test] + fn a_missing_path_is_absence_rather_than_an_error() { + let (_path, mut iso) = + build::open("missing", &build::Builder::new().file("/present", b"x")); + assert_eq!(iso.locate("/absent").expect("readable"), None); + assert_eq!(iso.locate("/absent/deeper").expect("readable"), None); + // A path continued past a plain file resolves to nothing rather than to the file. + assert_eq!(iso.locate("/present/deeper").expect("readable"), None); + } + + #[test] + fn traversal_out_of_the_image_resolves_to_nothing() { + // The guard is structural — no such record exists in the directory tree — and + // `..` is refused outright on top of that. + let (_path, mut iso) = build::open( + "traversal", + &build::Builder::new().file("/boot/linux26", b"k"), + ); + for path in [ + "/../etc/shadow", + "/boot/../../etc/shadow", + "../../../etc/passwd", + ] { + assert_eq!(iso.locate(path).expect("readable"), None, "{path}"); + } + } + + #[test] + fn a_rock_ridge_name_wins_over_the_mangled_identifier() { + // `auto-installer-mode.toml` cannot be spelled in ISO9660 — hyphens are not + // allowed, lower case is not allowed, and 8.3 does not stretch that far. The + // record is called `AUTO_INSTALLER_MODE.TOM;1` and only the Rock Ridge `NM` + // entry carries the name the installer actually looks for. Getting this wrong + // is the trap that puts the file in the image and hides it from its reader. + let (_path, mut iso) = build::open( + "rockridge", + &build::Builder::new().file("/auto-installer-mode.toml", b"mode = \"http\"\n"), + ); + assert!(iso.trees.rock_ridge, "SP announces SUSP"); + assert!(iso.has("/auto-installer-mode.toml")); + // The mangled identifier still resolves, because it is genuinely in the image. + assert!(iso.has("/auto_installer_mode.toml")); + } + + #[test] + fn an_image_without_rock_ridge_only_answers_to_the_mangled_name() { + let (_path, mut iso) = build::open( + "plain", + &build::Builder::new() + .rock_ridge(false) + .file("/auto-installer-mode.toml", b"x"), + ); + assert!(!iso.trees.rock_ridge); + assert!( + !iso.has("/auto-installer-mode.toml"), + "the hyphen is not in the image" + ); + assert!(iso.has("/auto_installer_mode.toml")); + } + + #[test] + fn a_joliet_tree_is_detected_and_searchable() { + // Which tree a mount reads is not ours to decide, so both have to be visible. + let (_path, mut iso) = build::open( + "joliet", + &build::Builder::new() + .rock_ridge(false) + .joliet(true) + .file("/boot/linux26", b"kernel"), + ); + assert!(iso.trees.joliet); + assert!( + iso.locate_joliet("/boot/linux26") + .expect("readable") + .is_some(), + "the Joliet tree carries the readable name" + ); + assert!(iso.has("/boot/linux26")); + } + + #[test] + fn the_volume_identifier_is_the_fallback_name() { + let (_path, iso) = build::open( + "volume", + &build::Builder::new().volume("PVE 8.4-1").file("/x", b"y"), + ); + assert_eq!(iso.volume_id, "PVE 8.4-1"); + assert!(iso.declared_size > 0); + } + + #[test] + fn something_that_is_not_an_iso_is_refused_by_name() { + let dir = std::env::temp_dir().join(format!("rescriptum-iso-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("not-an-iso.iso"); + std::fs::write(&path, vec![0u8; 100 * 1024]).expect("write"); + + // `Iso` holds an open file rather than deriving `Debug`, so unwrap by hand. + let Err(e) = Iso::open(&path) else { + panic!("a file of zeros must not open as an image"); + }; + assert!(e.to_string().contains("not an ISO image"), "{e}"); + } + + #[test] + fn a_number_written_two_ways_must_agree() { + // ISO9660 records every integer twice. A disagreement is a corrupt image, and + // trusting either half would serve garbage from a plausible-looking offset. + let mut bytes = [0u8; 8]; + bytes[..4].copy_from_slice(&7u32.to_le_bytes()); + bytes[4..].copy_from_slice(&7u32.to_be_bytes()); + assert_eq!(both_endian32(&bytes), Some(7)); + + bytes[4..].copy_from_slice(&9u32.to_be_bytes()); + assert_eq!(both_endian32(&bytes), None); + } +} diff --git a/src/boot/mod.rs b/src/boot/mod.rs new file mode 100644 index 0000000..5782a52 --- /dev/null +++ b/src/boot/mod.rs @@ -0,0 +1,18 @@ +//! Boot media: the images machines install from, and the bits that carry them. +//! +//! The answer engine tells a machine *what* to install. This half is *where the +//! installer itself comes from* — the kernel, the initrd and the image, served over +//! HTTP from a catalogue discovered the same way answers are. +//! +//! Nothing here decides anything about answers, and `select.rs` knows nothing about +//! this. The one seam between them is `stanza`: a generator that writes an `.ipxe` +//! answer document naming a catalogue entry, which then goes through the existing +//! selection, layering and templating unchanged. The server does not become clever +//! about booting; it gains a generator. + +pub mod catalog; +pub mod cpio; +pub mod iso; +pub mod probe; +pub mod sha256; +pub mod stanza; diff --git a/src/boot/probe.rs b/src/boot/probe.rs new file mode 100644 index 0000000..176c545 --- /dev/null +++ b/src/boot/probe.rs @@ -0,0 +1,548 @@ +//! Placing an image: which installer family it is, and where its kernel and initrd sit. +//! +//! A table of markers, read in order, first match wins. It costs a few kilobytes of +//! reads — a volume descriptor and a directory extent or two — never a pass over the +//! file, because ISO9660 directories are extents you can seek to. +//! +//! **Every row was written from documentation and must be pinned against a real image +//! before it is trusted.** The table is a table precisely so that verifying it is cheap. +//! An image no row claims is `Unknown` and still served: not describable is not the +//! same as not usable. + +use super::iso::Iso; +use std::collections::BTreeMap; +use std::path::Path; + +/// The families whose boot arguments `stanza` knows how to write. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Family { + Proxmox, + Debian, + Ubuntu, + Rhel, + Suse, + CoreOs, + Unknown, +} + +impl Family { + pub fn label(self) -> &'static str { + match self { + Family::Proxmox => "proxmox", + Family::Debian => "debian", + Family::Ubuntu => "ubuntu", + Family::Rhel => "rhel", + Family::Suse => "suse", + Family::CoreOs => "coreos", + Family::Unknown => "unknown", + } + } + + pub fn parse(text: &str) -> Option { + Some(match text { + "proxmox" => Family::Proxmox, + "debian" => Family::Debian, + "ubuntu" => Family::Ubuntu, + "rhel" => Family::Rhel, + "suse" => Family::Suse, + "coreos" => Family::CoreOs, + "unknown" => Family::Unknown, + _ => return None, + }) + } +} + +/// What the menu gates entries on. An ARM64 image offered to an x86 client is a menu +/// entry that boots the wrong kernel, so this is part of the probe rather than a note. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Arch { + X86_64, + Arm64, +} + +impl Arch { + pub fn label(self) -> &'static str { + match self { + Arch::X86_64 => "x86_64", + Arch::Arm64 => "arm64", + } + } + + /// iPXE's `${buildarch}`, which is what a generated menu compares against. + pub fn buildarch(self) -> &'static str { + match self { + Arch::X86_64 => "x86_64", + Arch::Arm64 => "arm64", + } + } + + pub fn parse(text: &str) -> Option { + Some(match text { + "x86_64" | "amd64" => Arch::X86_64, + "arm64" | "aarch64" => Arch::Arm64, + _ => return None, + }) + } +} + +/// Everything the probe could establish. Any of it may be absent; none of it is fatal. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Probed { + pub family: Option, + pub version: Option, + pub arch: Option, + /// Where the kernel is **inside the image**. + pub kernel: Option, + pub initrd: Option, + /// The kernel and initrd are files *beside* the image, not inside it — which is + /// what `prepare-iso --pxe` leaves behind, since it strips `/boot` from the ISO it + /// emits and writes `vmlinuz` and `initrd.img` next to it. + pub external: bool, + /// A stock Proxmox initrd is zstd-compressed. The assistant recompresses it to gzip + /// when it splits, saying "iPXE does not support a zstd-compressed initrd" — so an + /// image carrying the original is worth a word from `media check`. + pub zstd_initrd: bool, +} + +/// One row of the table: a marker that claims an image, and what it implies. +struct Row { + marker: &'static str, + family: Family, + kernel: &'static str, + initrd: &'static str, + arch: Option, +} + +/// **Ordered, and the order carries meaning.** CoreOS sits above the RHEL family whose +/// on-disk skeleton it borrows; a plain RHEL row first would claim every CoreOS image. +const TABLE: &[Row] = &[ + Row { + marker: "/boot/linux26", + family: Family::Proxmox, + kernel: "/boot/linux26", + initrd: "/boot/initrd.img", + arch: None, + }, + Row { + marker: "/casper/vmlinuz", + family: Family::Ubuntu, + kernel: "/casper/vmlinuz", + initrd: "/casper/initrd", + arch: None, + }, + Row { + marker: "/install.amd/vmlinuz", + family: Family::Debian, + kernel: "/install.amd/vmlinuz", + initrd: "/install.amd/initrd.gz", + arch: Some(Arch::X86_64), + }, + Row { + marker: "/install.a64/vmlinuz", + family: Family::Debian, + kernel: "/install.a64/vmlinuz", + initrd: "/install.a64/initrd.gz", + arch: Some(Arch::Arm64), + }, + // Before the RHEL row: Fedora CoreOS lays its files out the same way and is told + // apart by the live rootfs the RHEL installer does not have. + Row { + marker: "/images/pxeboot/rootfs.img", + family: Family::CoreOs, + kernel: "/images/pxeboot/vmlinuz", + initrd: "/images/pxeboot/initrd.img", + arch: None, + }, + Row { + marker: "/images/pxeboot/vmlinuz", + family: Family::Rhel, + kernel: "/images/pxeboot/vmlinuz", + initrd: "/images/pxeboot/initrd.img", + arch: None, + }, + Row { + marker: "/boot/x86_64/loader/linux", + family: Family::Suse, + kernel: "/boot/x86_64/loader/linux", + initrd: "/boot/x86_64/loader/initrd", + arch: Some(Arch::X86_64), + }, + Row { + marker: "/boot/aarch64/loader/linux", + family: Family::Suse, + kernel: "/boot/aarch64/loader/linux", + initrd: "/boot/aarch64/loader/initrd", + arch: Some(Arch::Arm64), + }, +]; + +/// Read enough of an image to place it. +pub fn probe(path: &Path) -> std::io::Result { + let mut iso = Iso::open(path)?; + let mut found = Probed::default(); + + // `/.disk/info` first, and Proxmox is why. `prepare-iso --pxe` **strips `/boot`** + // from the ISO it emits — about 100 MiB — so the `/boot/linux26` marker misses + // exactly the Proxmox image most likely to be dropped into a media directory. This + // file survives, and identifying an installer by it is upstream's own method: + // `proxmox-auto-install-assistant inspect-iso` reads this file and checks that + // PRODUCTLONG starts with "Proxmox". + let disk_info = iso + .read("/.disk/info", 8 * 1024) + .ok() + .flatten() + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()); + + if let Some(info) = &disk_info { + let fields = parse_disk_info(info); + let product = fields.get("PRODUCTLONG").map(String::as_str).unwrap_or(""); + if product.starts_with("Proxmox") { + found.family = Some(Family::Proxmox); + found.version = Some(proxmox_version(&fields, product)); + // ISOs predating the ARCH key are always amd64, as the assistant records. + found.arch = fields + .get("ARCH") + .and_then(|a| Arch::parse(a)) + .or(Some(Arch::X86_64)); + } else if fields.is_empty() { + // Debian and Ubuntu write a single descriptive line here instead. + found.version = info.lines().next().map(|l| l.trim().to_string()); + } + } + + for row in TABLE { + if !iso.has(row.marker) { + continue; + } + // A family established from `/.disk/info` is not overridden by a marker; it was + // read from the vendor's own identification, which is the stronger evidence. + if found.family.is_none() { + found.family = Some(row.family); + } + if found.family == Some(row.family) { + found.kernel = Some(row.kernel.to_string()); + found.initrd = Some(row.initrd.to_string()); + if found.arch.is_none() { + found.arch = row.arch; + } + } + break; + } + + // A Proxmox image the assistant has already split: the family is known from + // `/.disk/info`, and the kernel and initrd it needs are the files beside it. + if found.family == Some(Family::Proxmox) && found.kernel.is_none() { + let beside = path.parent().unwrap_or(Path::new(".")); + if beside.join("vmlinuz").is_file() && beside.join("initrd.img").is_file() { + found.kernel = Some("vmlinuz".to_string()); + found.initrd = Some("initrd.img".to_string()); + found.external = true; + } + } + + if found.version.is_none() { + found.version = version_from(&mut iso, found.family); + } + if found.arch.is_none() { + found.arch = arch_from_kernel(&mut iso, found.kernel.as_deref()); + } + // The compression an initrd actually carries, which decides whether a loader that + // only speaks gzip can use it. + if let Some(initrd) = &found.initrd + && !found.external + && let Ok(Some(head)) = iso.read(initrd, 4) + { + found.zstd_initrd = head.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]); + } + if found.version.is_none() && !iso.volume_id.is_empty() { + found.version = Some(iso.volume_id.clone()); + } + + Ok(found) +} + +/// `/.disk/info` in a Proxmox image is shell-env style: `KEY='value'` a line at a time. +/// Debian and Ubuntu write one descriptive sentence instead, which parses to nothing +/// here — and that emptiness is what tells the two apart. +fn parse_disk_info(text: &str) -> BTreeMap { + let mut out = BTreeMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + // A key with a space in it is prose, not a field. + if key.trim().contains(char::is_whitespace) { + continue; + } + out.insert( + key.trim().to_string(), + value.trim().trim_matches(['\'', '"']).to_string(), + ); + } + out +} + +fn proxmox_version(fields: &BTreeMap, product: &str) -> String { + match (fields.get("RELEASE"), fields.get("ISORELEASE")) { + (Some(release), Some(iso)) => format!("{product} {release}-{iso}"), + (Some(release), None) => format!("{product} {release}"), + _ => product.to_string(), + } +} + +/// Where a vendor left a version string, take it. Nobody has to, and the volume +/// identifier is the fallback. +fn version_from(iso: &mut Iso, family: Option) -> Option { + match family { + Some(Family::Rhel) | Some(Family::CoreOs) => { + let text = iso.read("/.treeinfo", 16 * 1024).ok().flatten()?; + let text = String::from_utf8_lossy(&text).into_owned(); + let mut name = None; + let mut version = None; + for line in text.lines() { + let line = line.trim(); + if let Some(v) = line.strip_prefix("version") { + version = v.trim_start_matches([' ', '=']).trim().to_string().into(); + } else if let Some(v) = line.strip_prefix("name") { + name = v.trim_start_matches([' ', '=']).trim().to_string().into(); + } + } + match (name, version) { + (Some(n), _) => Some(n), + (None, Some(v)) => Some(v), + _ => None, + } + } + _ => None, + } +} + +/// A kernel says what it is in its first few dozen bytes. Cheaper and more honest than +/// guessing from a filename, and it is the only signal for a family whose layout is not +/// per-architecture. +fn arch_from_kernel(iso: &mut Iso, kernel: Option<&str>) -> Option { + let kernel = kernel?; + let head = iso.read(kernel, 0x400).ok().flatten()?; + // x86 bzImage: "HdrS" at 0x202. + if head.len() > 0x206 && &head[0x202..0x206] == b"HdrS" { + return Some(Arch::X86_64); + } + // arm64 Image: the magic at offset 56. + if head.len() > 60 && &head[56..60] == b"ARM\x64" { + return Some(Arch::Arm64); + } + None +} + +#[cfg(test)] +mod tests { + use super::super::iso::build; + use super::*; + + fn probe_of(name: &str, builder: &build::Builder) -> Probed { + let dir = std::env::temp_dir().join(format!("rescriptum-probe-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join(format!("{name}.iso")); + std::fs::write(&path, builder.build()).expect("write"); + probe(&path).expect("probes") + } + + /// A kernel the arch sniffer will recognise, so a fixture can be honest about it. + fn bzimage() -> Vec { + let mut k = vec![0u8; 0x400]; + k[0x202..0x206].copy_from_slice(b"HdrS"); + k + } + + fn arm64_image() -> Vec { + let mut k = vec![0u8; 0x400]; + k[56..60].copy_from_slice(b"ARM\x64"); + k + } + + #[test] + fn each_family_is_claimed_by_its_own_marker() { + // The table is the behaviour, so the test walks it the way a real image would. + let cases: &[(&str, &str, Family)] = &[ + ("pve", "/boot/linux26", Family::Proxmox), + ("ubuntu", "/casper/vmlinuz", Family::Ubuntu), + ("debian", "/install.amd/vmlinuz", Family::Debian), + ("rhel", "/images/pxeboot/vmlinuz", Family::Rhel), + ("suse", "/boot/x86_64/loader/linux", Family::Suse), + ]; + for (name, marker, family) in cases { + let found = probe_of(name, &build::Builder::new().file(marker, &bzimage())); + assert_eq!(found.family, Some(*family), "{marker}"); + assert_eq!(found.kernel.as_deref(), Some(*marker), "{marker}"); + assert!(found.initrd.is_some(), "{marker}"); + } + } + + #[test] + fn coreos_is_told_apart_from_the_rhel_skeleton_it_borrows() { + // Both lay their files out in /images/pxeboot. Ordering the table wrongly makes + // every CoreOS image a RHEL one, and the boot arguments are entirely different. + let coreos = probe_of( + "coreos", + &build::Builder::new() + .file("/images/pxeboot/vmlinuz", &bzimage()) + .file("/images/pxeboot/rootfs.img", b"live root"), + ); + assert_eq!(coreos.family, Some(Family::CoreOs)); + + let rhel = probe_of( + "rhel-only", + &build::Builder::new().file("/images/pxeboot/vmlinuz", &bzimage()), + ); + assert_eq!(rhel.family, Some(Family::Rhel)); + } + + #[test] + fn a_proxmox_image_the_assistant_trimmed_is_still_recognised() { + // `prepare-iso --pxe` strips /boot — about 100 MiB — so the marker the plain + // table would use is gone from exactly the image most likely to be dropped into + // a media directory. `/.disk/info` survives, and reading it is how + // `proxmox-auto-install-assistant inspect-iso` identifies an ISO itself. + let found = probe_of( + "pve-trimmed", + &build::Builder::new().file( + "/.disk/info", + b"PRODUCTLONG='Proxmox Virtual Environment'\nRELEASE='8.4'\nISORELEASE='1'\nARCH='amd64'\n", + ), + ); + assert_eq!(found.family, Some(Family::Proxmox)); + assert_eq!( + found.version.as_deref(), + Some("Proxmox Virtual Environment 8.4-1") + ); + assert_eq!(found.arch, Some(Arch::X86_64)); + // Nothing beside it, so it declares no kernel rather than inventing one. + assert_eq!(found.kernel, None); + assert!(!found.external); + } + + #[test] + fn a_trimmed_image_finds_the_kernel_the_assistant_left_beside_it() { + // `--pxe` writes vmlinuz and initrd.img into the same output directory. Finding + // them there is what makes an assistant-prepared directory work as-is. + let dir = std::env::temp_dir().join(format!("rescriptum-pxe-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let image = dir.join("pve.iso"); + std::fs::write( + &image, + build::Builder::new() + .file("/.disk/info", b"PRODUCTLONG='Proxmox VE'\nRELEASE='8.4'\n") + .build(), + ) + .expect("write"); + std::fs::write(dir.join("vmlinuz"), bzimage()).expect("write"); + std::fs::write(dir.join("initrd.img"), b"\x1f\x8b gzip").expect("write"); + + let found = probe(&image).expect("probes"); + assert_eq!(found.family, Some(Family::Proxmox)); + assert!(found.external, "the kernel is beside the image, not inside"); + assert_eq!(found.kernel.as_deref(), Some("vmlinuz")); + assert_eq!(found.initrd.as_deref(), Some("initrd.img")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_stock_proxmox_initrd_is_noticed_to_be_zstd() { + // The assistant recompresses it to gzip when it splits, on the grounds that + // "iPXE does not support a zstd-compressed initrd". Whether that binds through + // our chain is a bench question; noticing it is not. + let found = probe_of( + "pve-zstd", + &build::Builder::new() + .file("/boot/linux26", &bzimage()) + .file("/boot/initrd.img", &[0x28, 0xB5, 0x2F, 0xFD, 0, 0, 0, 0]), + ); + assert_eq!(found.family, Some(Family::Proxmox)); + assert!(found.zstd_initrd); + + let gzipped = probe_of( + "pve-gzip", + &build::Builder::new() + .file("/boot/linux26", &bzimage()) + .file("/boot/initrd.img", &[0x1f, 0x8b, 0x08, 0x00]), + ); + assert!(!gzipped.zstd_initrd); + } + + #[test] + fn architecture_comes_from_the_path_where_the_layout_says_it() { + let debian = probe_of( + "debian-arm", + &build::Builder::new().file("/install.a64/vmlinuz", b"not a recognisable kernel"), + ); + assert_eq!(debian.arch, Some(Arch::Arm64)); + + let suse = probe_of( + "suse-arm", + &build::Builder::new().file("/boot/aarch64/loader/linux", b"opaque"), + ); + assert_eq!(suse.arch, Some(Arch::Arm64)); + } + + #[test] + fn architecture_falls_back_to_the_kernel_image_itself() { + // Ubuntu's layout is the same on both architectures, so the only honest source + // is the kernel. An ARM64 image offered to an x86 client is a menu entry that + // boots the wrong thing. + let x86 = probe_of( + "ubuntu-x86", + &build::Builder::new().file("/casper/vmlinuz", &bzimage()), + ); + assert_eq!(x86.arch, Some(Arch::X86_64)); + + let arm = probe_of( + "ubuntu-arm", + &build::Builder::new().file("/casper/vmlinuz", &arm64_image()), + ); + assert_eq!(arm.arch, Some(Arch::Arm64)); + } + + #[test] + fn an_image_nothing_claims_is_unknown_and_still_described() { + // Not describable is not the same as not usable: it is still served, and the + // volume identifier is the name it gets. + let found = probe_of( + "mystery", + &build::Builder::new() + .volume("SOME LIVE CD") + .file("/readme.txt", b"hello"), + ); + assert_eq!(found.family, None); + assert_eq!(found.version.as_deref(), Some("SOME LIVE CD")); + assert_eq!(found.kernel, None); + } + + #[test] + fn a_debian_style_disk_info_is_not_mistaken_for_proxmox_fields() { + // Debian and Ubuntu write one sentence where Proxmox writes KEY='value'. The + // parser must not turn that sentence into a field. + let fields = + parse_disk_info("Ubuntu-Server 24.04.1 LTS \"Noble Numbat\" - Release amd64\n"); + assert!(fields.is_empty(), "{fields:?}"); + + let found = probe_of( + "ubuntu-info", + &build::Builder::new() + .file( + "/.disk/info", + b"Ubuntu-Server 24.04.1 LTS - Release amd64\n", + ) + .file("/casper/vmlinuz", &bzimage()), + ); + assert_eq!(found.family, Some(Family::Ubuntu)); + assert_eq!( + found.version.as_deref(), + Some("Ubuntu-Server 24.04.1 LTS - Release amd64") + ); + } +} diff --git a/src/boot/sha256.rs b/src/boot/sha256.rs new file mode 100644 index 0000000..ddb30f6 --- /dev/null +++ b/src/boot/sha256.rs @@ -0,0 +1,234 @@ +//! SHA-256, hand-written, because a digest is not worth a dependency. +//! +//! Two jobs and no third: record what an image was when it was ingested, and re-check +//! it later. FIPS 180-4, the straightforward implementation — a 1.5 GB image is hashed +//! once at `media add`, never per request, so the constant factor here buys nothing +//! worth the code to earn it. + +const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +const INITIAL: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; + +pub struct Sha256 { + state: [u32; 8], + block: [u8; 64], + buffered: usize, + /// Message length in **bits**, which is what the padding encodes. + bits: u64, +} + +impl Default for Sha256 { + fn default() -> Self { + Sha256::new() + } +} + +impl Sha256 { + pub fn new() -> Sha256 { + Sha256 { + state: INITIAL, + block: [0; 64], + buffered: 0, + bits: 0, + } + } + + pub fn update(&mut self, mut data: &[u8]) { + self.bits = self.bits.wrapping_add((data.len() as u64) * 8); + + if self.buffered > 0 { + let take = (64 - self.buffered).min(data.len()); + self.block[self.buffered..self.buffered + take].copy_from_slice(&data[..take]); + self.buffered += take; + data = &data[take..]; + // Still short of a block: return, or the tail below would reset `buffered` + // to zero and drop what is already held. + if self.buffered < 64 { + return; + } + let block = self.block; + self.compress(&block); + self.buffered = 0; + } + + let mut chunks = data.chunks_exact(64); + for chunk in &mut chunks { + let mut block = [0u8; 64]; + block.copy_from_slice(chunk); + self.compress(&block); + } + + let rest = chunks.remainder(); + self.block[..rest.len()].copy_from_slice(rest); + self.buffered = rest.len(); + } + + pub fn finish(mut self) -> [u8; 32] { + let bits = self.bits; + // 0x80, then zeros, then the length: the padding must land the message on a + // 64-byte boundary with eight bytes to spare. + self.pad(0x80); + while self.buffered != 56 { + self.pad(0); + } + for byte in bits.to_be_bytes() { + self.pad(byte); + } + debug_assert_eq!(self.buffered, 0); + + let mut out = [0u8; 32]; + for (chunk, word) in out.chunks_exact_mut(4).zip(self.state.iter()) { + chunk.copy_from_slice(&word.to_be_bytes()); + } + out + } + + /// One padding byte, flushing the block when it fills. Deliberately not `update`: + /// padding must not count toward the length. + fn pad(&mut self, byte: u8) { + self.block[self.buffered] = byte; + self.buffered += 1; + if self.buffered == 64 { + let block = self.block; + self.compress(&block); + self.buffered = 0; + } + } + + fn compress(&mut self, block: &[u8; 64]) { + let mut w = [0u32; 64]; + for (i, chunk) in block.chunks_exact(4).enumerate() { + w[i] = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ ((!e) & g); + let t1 = h + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(maj); + + h = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + + for (slot, value) in self + .state + .iter_mut() + .zip([a, b, c, d, e, f, g, h].into_iter()) + { + *slot = slot.wrapping_add(value); + } + } +} + +/// The digest of a slice, lowercase hex — the form every checksum file in the world uses. +pub fn hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + to_hex(&hasher.finish()) +} + +pub fn to_hex(digest: &[u8; 32]) -> String { + let mut out = String::with_capacity(64); + for byte in digest { + out.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0')); + out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap_or('0')); + } + out +} + +/// Whether a string is a plausible SHA-256, so `--sha256 deadbeef` is refused at the +/// boundary rather than never matching anything. +pub fn is_digest(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The three vectors everybody's SHA-256 is checked against, plus the two that + /// actually catch padding bugs: a message that lands exactly on a block boundary, + /// and one that leaves too little room for the length and forces a second block. + #[test] + fn known_vectors() { + assert_eq!( + hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + // 64 bytes: exactly one block, so the padding is entirely a second one. + assert_eq!( + hex(&[b'a'; 64]), + "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb" + ); + // 56 bytes: the length field has nowhere to go without a second block. + assert_eq!( + hex(&[b'a'; 56]), + "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a" + ); + } + + #[test] + fn a_streamed_message_hashes_the_same_as_one_slice() { + // The whole reason this is incremental: a 1.5 GB image is fed 64 KiB at a time, + // and a buffering bug there would surface as a digest that never matches. + let data: Vec = (0..1000u32).flat_map(|n| n.to_le_bytes()).collect(); + let once = hex(&data); + + for chunk in [1usize, 7, 63, 64, 65, 127, 128, 1000] { + let mut hasher = Sha256::new(); + for part in data.chunks(chunk) { + hasher.update(part); + } + assert_eq!(to_hex(&hasher.finish()), once, "chunked by {chunk}"); + } + } + + #[test] + fn a_digest_is_recognised_by_shape() { + assert!(is_digest(&hex(b"anything"))); + assert!(!is_digest("deadbeef")); + assert!(!is_digest(&"z".repeat(64))); + assert!(!is_digest("")); + } +} diff --git a/src/boot/stanza.rs b/src/boot/stanza.rs new file mode 100644 index 0000000..9089283 --- /dev/null +++ b/src/boot/stanza.rs @@ -0,0 +1,316 @@ +//! What each installer family needs on the wire, in one place. +//! +//! **Proxmox is the founding case and the odd one out.** Every other family takes a +//! `GET` plus kernel arguments; Proxmox alone wants the answer's *location* carried +//! inside the image, and then POSTs a body to it. A design that only works for the POST +//! path would be wrong by default, so the table below states each family's needs +//! side by side rather than generalising from the one we met first. +//! +//! The exact spellings are per-family, versioned, and precisely the sort of thing that +//! gets half-remembered. They live here, they are tested per family, and they are +//! nowhere else in the codebase. +//! +//! Two properties of iPXE's own parser shape what may be written here, and both were +//! read out of its source rather than remembered: +//! +//! - **`;` separates commands only as a whole whitespace-delimited token.** +//! `split_command` in `core/exec.c` splits on whitespace and the separator is +//! recognised by `strcmp(token, ";")`, so `ds=nocloud-net;s=http://…` is one argument +//! and needs no escaping. Writing `foo ; bar` would be two commands. +//! - **A trailing `\` continues a line**, which is how a kernel command line longer +//! than a terminal stays readable. +//! +//! What this module produces is an ordinary `.ipxe` **answer document**. It is printed, +//! never installed: saved into the answers directory it goes through the existing +//! selection, layering and templating unchanged. The server does not become clever +//! about booting — it gains a generator, and the composition engine it already has does +//! the rest. + +use super::catalog::Entry; +use super::probe::Family; + +/// The two listeners, as a client must be able to reach them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Endpoints { + /// Where images, kernels and initrds come from. + pub media: String, + /// Where answers come from. + pub answer: String, +} + +/// Write the `.ipxe` answer document that boots one catalogue entry. +pub fn ipxe(entry: &Entry, endpoints: &Endpoints) -> Result { + let family = entry.family(); + if !entry.bootable() { + return Err(format!( + "{}: no kernel and initrd were found in it{}. It can still be served whole — \ + `sanboot` it, or write it to a stick — but there is no stanza to generate.", + entry.id, + match family { + Family::Unknown => ", and no probe row claims it", + _ => "", + } + )); + } + + let media = endpoints.media.trim_end_matches('/'); + let answer = endpoints.answer.trim_end_matches('/'); + let id = &entry.id; + let kernel = format!("{media}/{id}/kernel"); + let initrd = format!("{media}/{id}/initrd"); + let image = format!("{media}/{id}/iso"); + + // `{{ mac }}` is filled by the answer engine's own templating, from the facts the + // request carries. A missing fact is an error there, never an empty string — which + // is what stops a machine installing itself against a truncated URL. + let mut out = String::from("#!ipxe\n"); + out.push_str(&format!( + "# {} — generated by `rescriptum media ipxe {id}`.\n\ + # An ordinary answer document: selection, layering and templating all apply.\n", + entry.describe() + )); + + match family { + // Upstream's own output, character for character. `--pxe-loader ipxe` writes + // `kernel vmlinuz ramdisk_size=16777216 rw quiet initrd=initrd.img` plus the + // chosen option's parameters, then `initrd initrd.img`, `initrd + // proxmox.iso`, `boot`. **`proxmox-start-auto-installer` is the parameter that + // selects the automated path**; without it the machine boots the interactive + // installer and waits for a human who is not coming. + Family::Proxmox => { + out.push_str(&format!( + "kernel {kernel} ramdisk_size=16777216 rw quiet initrd=initrd.img \\\n\ + \x20 splash=silent proxmox-start-auto-installer\n" + )); + // The second argument renames the downloaded file inside the initramfs, so + // it matches the `initrd=` above; without it the name would come from the + // URL and the kernel would not find it. + out.push_str(&format!("initrd {initrd} initrd.img\n")); + // The ISO travels as a second initrd, and the installer reads + // `/cdrom/auto-installer-mode.toml` from it. That file is what carries the + // answer URL — nothing on this command line does. + out.push_str(&format!("initrd {image} proxmox.iso\n")); + } + Family::Debian => { + out.push_str(&format!( + "kernel {kernel} auto=true priority=critical \\\n\ + \x20 preseed/url={answer}/debian?mac={{{{ mac }}}}\n" + )); + out.push_str(&format!("initrd {initrd}\n")); + } + // The one shape that is not a single URL: cloud-init's NoCloud datasource + // fetches `user-data` *and* `meta-data` from a prefix. `facts.rs` labels a + // request's `path`, `file` and `segment`, so one answer set can tell the two + // apart with a selector — which is also why `seed` is not an endpoint alias. + Family::Ubuntu => { + out.push_str(&format!( + "kernel {kernel} autoinstall \\\n\ + \x20 ds=nocloud-net;s={answer}/ubuntu/{{{{ mac }}}}/ \\\n\ + \x20 url={image} initrd=initrd\n" + )); + out.push_str(&format!("initrd {initrd} initrd\n")); + } + Family::Rhel => { + out.push_str(&format!( + "kernel {kernel} inst.ks={answer}/rhel?mac={{{{ mac }}}} \\\n\ + \x20 inst.stage2={image}\n" + )); + out.push_str(&format!("initrd {initrd}\n")); + } + Family::Suse => { + out.push_str(&format!( + "kernel {kernel} autoyast={answer}/suse?mac={{{{ mac }}}} \\\n\ + \x20 install={image}\n" + )); + out.push_str(&format!("initrd {initrd}\n")); + } + Family::CoreOs => { + // The live rootfs is a file inside the image, served by path rather than + // extracted — the same seek the kernel and initrd routes use. + out.push_str(&format!( + "kernel {kernel} ignition.config.url={answer}/coreos?mac={{{{ mac }}}} \\\n\ + \x20 coreos.live.rootfs_url={media}/{id}/file/images/pxeboot/rootfs.img\n" + )); + out.push_str(&format!("initrd {initrd}\n")); + } + Family::Unknown => unreachable!("an unplaced image is not bootable"), + } + + out.push_str("boot\n"); + Ok(out) +} + +/// How the answer this stanza points at will be reached, for `media ipxe` to print +/// beside the script and for the guide to quote. Nothing generates the answer itself: +/// that is the operator's document, and this is only where to put it. +pub fn where_the_answer_goes(family: Family) -> &'static str { + match family { + Family::Proxmox => { + "The image must carry `/auto-installer-mode.toml` naming the answer URL — \ + `proxmox-auto-install-assistant prepare-iso --fetch-from http --url …` \ + writes it. Nothing on the kernel command line carries it." + } + Family::Debian => "Save a `.preseed` answer; it is fetched from /debian.", + Family::Ubuntu => { + "Save `.yaml` answers reached as /ubuntu//user-data and \ + /ubuntu//meta-data — cloud-init fetches both, so a selector on the \ + `file` label tells them apart." + } + Family::Rhel => "Save a `.ks` answer; it is fetched from /rhel.", + Family::Suse => "Save an `.autoyast` answer; it is fetched from /suse.", + Family::CoreOs => "Save an `.ign` answer; it is fetched from /coreos.", + Family::Unknown => "Nothing here knows how this image boots.", + } +} + +#[cfg(test)] +mod tests { + use super::super::probe::{Arch, Probed}; + use super::*; + use std::path::PathBuf; + + fn entry(family: Option, kernel: Option<&str>) -> Entry { + Entry { + id: "img".to_string(), + path: PathBuf::from("/srv/media/img.iso"), + size: 1024, + digest: None, + probed: Probed { + family, + version: Some("Test Image 1.0".to_string()), + arch: Some(Arch::X86_64), + kernel: kernel.map(str::to_string), + initrd: kernel.map(|_| "/initrd".to_string()), + external: false, + zstd_initrd: false, + }, + beside: None, + } + } + + fn endpoints() -> Endpoints { + Endpoints { + media: "http://192.0.2.10:8001".to_string(), + answer: "http://192.0.2.10:8000".to_string(), + } + } + + fn render(family: Family) -> String { + ipxe(&entry(Some(family), Some("/kernel")), &endpoints()).expect("renders") + } + + /// **A stanza proven for one family claims six.** Each is asserted on the argument + /// that actually starts its unattended install, per the plan's own rule that the + /// founding case must not become the template. + #[test] + fn every_family_gets_the_argument_that_starts_its_install() { + let cases: &[(Family, &str)] = &[ + // Not a URL: the parameter that selects the automated path. Without it a + // Proxmox machine boots the interactive installer and waits forever. + (Family::Proxmox, "proxmox-start-auto-installer"), + (Family::Debian, "preseed/url=http://192.0.2.10:8000/debian"), + ( + Family::Ubuntu, + "ds=nocloud-net;s=http://192.0.2.10:8000/ubuntu/", + ), + (Family::Rhel, "inst.ks=http://192.0.2.10:8000/rhel"), + (Family::Suse, "autoyast=http://192.0.2.10:8000/suse"), + ( + Family::CoreOs, + "ignition.config.url=http://192.0.2.10:8000/coreos", + ), + ]; + for (family, needle) in cases { + let script = render(*family); + assert!(script.contains(needle), "{}: {script}", family.label()); + } + } + + #[test] + fn every_family_loads_a_kernel_an_initrd_and_boots() { + for family in [ + Family::Proxmox, + Family::Debian, + Family::Ubuntu, + Family::Rhel, + Family::Suse, + Family::CoreOs, + ] { + let script = render(family); + assert!(script.starts_with("#!ipxe\n"), "{}", family.label()); + assert!( + script.contains("kernel http://192.0.2.10:8001/img/kernel"), + "{}", + family.label() + ); + assert!( + script.contains("initrd http://192.0.2.10:8001/img/initrd"), + "{}", + family.label() + ); + assert!(script.ends_with("boot\n"), "{}", family.label()); + } + } + + #[test] + fn the_proxmox_stanza_matches_what_the_assistant_itself_emits() { + // `--pxe-loader ipxe` is upstream's own statement of this stanza, and it is the + // reference this must not drift from: the kernel parameters, the initrd renamed + // to match `initrd=`, and the ISO carried as a second initrd named proxmox.iso. + let script = render(Family::Proxmox); + assert!(script.contains("ramdisk_size=16777216 rw quiet initrd=initrd.img")); + assert!(script.contains("splash=silent proxmox-start-auto-installer")); + assert!(script.contains("initrd http://192.0.2.10:8001/img/initrd initrd.img")); + assert!(script.contains("initrd http://192.0.2.10:8001/img/iso proxmox.iso")); + // And nothing on the command line names the answer: it rides inside the image. + assert!(!script.contains("8000"), "{script}"); + } + + #[test] + fn a_semicolon_inside_an_argument_is_left_alone() { + // iPXE's `split_command` splits on whitespace and recognises the separator by + // `strcmp(token, ";")`, so `ds=nocloud-net;s=…` is one argument. Escaping it + // would corrupt the value cloud-init receives; splitting it would run `s=…` as + // a command. Neither happens, and this pins that. + let script = render(Family::Ubuntu); + assert!(script.contains("ds=nocloud-net;s="), "{script}"); + assert!(!script.contains("\\;"), "no escaping: {script}"); + for line in script.lines() { + assert!( + !line.split_whitespace().any(|token| token == ";"), + "a bare `;` token would split this line into two commands: {line}" + ); + } + } + + #[test] + fn the_templated_mac_survives_into_the_answer_url() { + // The document is a template the answer engine fills per request, which is what + // lets one stanza cover a rack. A missing fact is an error there, so a truncated + // URL never reaches a machine. + let script = render(Family::Debian); + assert!(script.contains("?mac={{ mac }}"), "{script}"); + } + + #[test] + fn an_image_no_probe_placed_is_refused_with_a_reason() { + // Refusing is a complete answer: the image is still servable whole. + let e = ipxe(&entry(None, None), &endpoints()).expect_err("must refuse"); + assert!(e.contains("no probe row claims it"), "{e}"); + assert!(e.contains("sanboot"), "{e}"); + } + + #[test] + fn a_trailing_slash_on_an_endpoint_does_not_double_up() { + let script = ipxe( + &entry(Some(Family::Rhel), Some("/kernel")), + &Endpoints { + media: "http://192.0.2.10:8001/".to_string(), + answer: "http://192.0.2.10:8000/".to_string(), + }, + ) + .expect("renders"); + assert!(!script.contains("//img"), "{script}"); + assert!(!script.contains("8000//"), "{script}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 635104a..a696904 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! for the design constraints. pub mod admin; +pub mod boot; pub mod capture; pub mod cli; pub mod config; From 6de435a5b7f39413ac5d88a06873e84a815ca573 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 12:55:22 +0200 Subject: [PATCH 02/59] feat(boot): the media listener, its own socket, and the media commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Phase 1 of plans/boot-media.md. A machine can now fetch the installer itself — kernel, initrd, image — from the same server that decides its answer, and the two cannot drift apart because one component knows both. The listener is its own socket, and that is forced rather than preferred: the answer endpoint answers on any path, its whole-connection deadline is ten seconds where a 1.5 GB transfer is two minutes, and its connection semaphore would be held for minutes by a download. `tests/media.rs` proves the consequence rather than asserting it — answers keep succeeding with four transfers in flight. Ranges, ETag, If-Range, HEAD and 416 are all here because real clients need them: five of the seven installers range-fetch, and UEFI HTTP Boot sends HEAD before it fetches. `initrd+iso` is synthesised on the wire — initrd, a cpio header naming proxmox.iso, the image — so old loaders work without a second 1.5 GB file on disk. Configuration gains RESCRIPTUM_PUBLIC_HOST (a host, never a URL — it is written into URLs for two listeners) plus the media directory, address, timeout, connection cap and CIDR allowlist. Media is off until a directory is named, so nothing changes for an existing deployment. Two bugs the tests caught rather than review: `HeaderName::from_static` panics on a name that is not lowercase, which killed the connection instead of answering 405; and the same-port guard refused two `:0` listeners, which can never collide because the kernel picks both. 93 new tests over the 333 that were here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- Cargo.lock | 1 + Cargo.toml | 9 + src/boot/iso.rs | 2 +- src/boot/media.rs | 917 +++++++++++++++++++++++++++++++++++++++++++++ src/boot/mod.rs | 1 + src/boot/sha256.rs | 29 ++ src/cli.rs | 380 ++++++++++++++++++- src/config.rs | 360 +++++++++++++++++- src/envfile.rs | 8 +- src/main.rs | 48 +++ tests/media.rs | 845 +++++++++++++++++++++++++++++++++++++++++ 11 files changed, 2596 insertions(+), 4 deletions(-) create mode 100644 src/boot/media.rs create mode 100644 tests/media.rs diff --git a/Cargo.lock b/Cargo.lock index b064163..f065c3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -316,6 +316,7 @@ dependencies = [ "hyper", "hyper-util", "quick-xml", + "rescriptum", "rusqlite", "serde_json", "serde_yaml_ng", diff --git a/Cargo.toml b/Cargo.toml index eb37f75..26be9f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,12 @@ default = ["sqlite"] # SQLite backs the admin API. Turn it off for the smallest possible binary when the # answers directory is all you need: `cargo build --no-default-features`. sqlite = ["dep:rusqlite"] +# Compiles `boot::iso::build`, which writes ISO9660 images in memory for tests. There is +# deliberately **no binary ISO fixture in this repository**: a checked-in image is a blob +# nobody can review and nobody can vary. The builder is the alternative, and it has no +# business in a release binary — the resolver keeps it out of one, because nothing but +# the dev-dependency below asks for it. +test-support = [] [profile.release] opt-level = "z" @@ -40,6 +46,9 @@ strip = true [dev-dependencies] rusqlite = { version = "0.40.2", features = ["bundled"] } +# The crate itself, so an integration test can build an ISO to serve. This is what turns +# `test-support` on for a test build and leaves it off for every other one. +rescriptum = { path = ".", features = ["test-support"] } # `panic = "abort"` is deliberately ABSENT. With one thread per connection, unwinding # means a panic kills only the connection that caused it; aborting would take down the diff --git a/src/boot/iso.rs b/src/boot/iso.rs index cb4dff0..1384fb6 100644 --- a/src/boot/iso.rs +++ b/src/boot/iso.rs @@ -471,7 +471,7 @@ fn strip(bytes: &[u8]) -> String { /// review, that nobody can vary, and that grows a repository by megabytes. This builds /// exactly the image a test needs, in memory, and doubles as the case generator for the /// Rock Ridge, Joliet and refusal paths Phase 4 will need. -#[cfg(test)] +#[cfg(any(test, feature = "test-support"))] pub mod build { use super::*; use std::collections::BTreeMap; diff --git a/src/boot/media.rs b/src/boot/media.rs new file mode 100644 index 0000000..6eaebcd --- /dev/null +++ b/src/boot/media.rs @@ -0,0 +1,917 @@ +//! The media listener: images, kernels and initrds, over HTTP, on its own socket. +//! +//! **Its own socket is forced rather than preferred, three times over:** +//! +//! 1. The answer endpoint answers on *any* path — the URL is baked into an ISO and must +//! never be wrong. A `/media/…` namespace would carve a reserved prefix out of a +//! space that is deliberately unreserved. +//! 2. `RESCRIPTUM_TIMEOUT_SECS` is a whole-connection deadline of ten seconds. A 1.5 GB +//! transfer is fifteen seconds on gigabit and two minutes on 100 Mbit, so on the +//! answer listener **every image download would be killed mid-transfer** — and it +//! would look like a flaky network rather than a setting. +//! 3. `RESCRIPTUM_MAX_CONNECTIONS` is a semaphore of in-flight connections, and a +//! download holds its permit for minutes. Shared budgets mean a rollout starves its +//! own answer requests. +//! +//! `admin.rs` already has its own listener for its own reasons. Same shape. +//! +//! Everything here is **read-only**: no `PUT`, no `DELETE`, no upload. Writing media is +//! an admin-side act, and it is a command rather than a request — no request may ever +//! trigger work proportional to the size of an image. + +use super::catalog::{Catalog, Entry}; +use super::{cpio, iso}; +use crate::config::Config; +use crate::log; +use hyper::body::{Bytes, Frame, Incoming, SizeHint}; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode}; +use hyper_util::rt::{TokioIo, TokioTimer}; +use std::convert::Infallible; +use std::io::{self, Read, Seek, SeekFrom}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use tokio::net::TcpListener; +use tokio::sync::{Semaphore, mpsc}; + +/// Bytes read and sent at a time. Sixteen concurrent transfers at this size, with one +/// chunk queued behind the one being written, cost about two megabytes of buffers — +/// which is the arithmetic that has to hold on a 512 MB NAS. +const CHUNK: usize = 64 * 1024; + +pub struct Media { + pub cfg: Arc, + pub catalog: Arc, +} + +pub async fn serve(listener: TcpListener, media: Arc) { + let timeout = media.cfg.media_timeout; + // Its own budget, deliberately small and deliberately not shared with answers. + let permits = Arc::new(Semaphore::new(media.cfg.media_max_connections)); + + loop { + let (stream, peer) = match listener.accept().await { + Ok(pair) => pair, + Err(e) => { + log::server(&format!("media: accept failed: {e}")); + continue; + } + }; + + // Over the cap, say so and close rather than queueing: a client waiting behind + // fifteen image downloads has no way to tell that from a dead server. + let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { + log::request(&peer.to_string(), 503, "media: 503 — at max transfers"); + drop(stream); + continue; + }; + + let media = Arc::clone(&media); + tokio::spawn(async move { + let _permit = permit; + let service = service_fn(move |req| { + let media = Arc::clone(&media); + async move { Ok::<_, Infallible>(handle(req, media, peer).await) } + }); + let serving = http1::Builder::new() + .timer(TokioTimer::new()) + // The header-read timeout stays short — that is the slowloris guard, and + // it has nothing to do with how long a transfer may take. + .header_read_timeout(Some(std::time::Duration::from_secs(10))) + .serve_connection(TokioIo::new(stream), service); + let _ = tokio::time::timeout(timeout, serving).await; + }); + } +} + +async fn handle(req: Request, media: Arc, peer: SocketAddr) -> Response { + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let peer_label = peer.to_string(); + + if method != Method::GET && method != Method::HEAD { + // Read-only, and the header says which two verbs that means. + log::request(&peer_label, 405, &format!("media: {method} {path} 405")); + return Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .header("Allow", "GET, HEAD") + .header("Content-Type", "text/plain; charset=utf-8") + .body(Body::once(Bytes::from("405 Method Not Allowed\n"))) + .expect("a static response always builds"); + } + + // The allowlist, when there is one. Boot traffic is unauthenticated by necessity — + // a PXE ROM has no credentials — so the controls that exist are structural, and + // this is the only one that can say "not you". + if !allowed(&media.cfg, peer) { + log::request(&peer_label, 403, &format!("media: {method} {path} 403")); + return text(StatusCode::FORBIDDEN, "403 Forbidden\n"); + } + + if path == "/health" { + log::request(&peer_label, 200, "media: GET /health 200"); + return text(StatusCode::OK, "OK\n"); + } + if path == "/" || path.is_empty() { + let json = req + .headers() + .get(hyper::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.contains("application/json")); + return catalogue(&media, &peer_label, json).await; + } + + let Some((id, what)) = route(&path) else { + log::request( + &peer_label, + 404, + &format!("media: GET {path} 404 no such route"), + ); + return text(StatusCode::NOT_FOUND, "404 Not Found\n"); + }; + + // The identifier goes through the same guard the admin API and both stores use, and + // then **into the catalogue**. The filesystem path always comes from the entry, + // never from the request: the path-traversal guard survives in letter and spirit. + if !crate::store::valid_id(&id) { + log::request(&peer_label, 404, &format!("media: GET {path} 404 bad id")); + return text(StatusCode::NOT_FOUND, "404 Not Found\n"); + } + + let catalog = Arc::clone(&media.catalog); + let wanted = what.clone(); + let looked_up = tokio::task::spawn_blocking(move || { + // `read_dir`, `open` and `seek` are all blocking, and blocking an async worker + // stalls every other transfer that thread is driving. On a NAS with a sleeping + // disk that is not theoretical. + let entry = catalog.get(&id)?; + let Some(entry) = entry else { + return Ok(None); + }; + Ok::<_, io::Error>(Some((resolve(&entry, &wanted), entry))) + }) + .await; + + let (source, entry) = match looked_up { + Ok(Ok(Some((source, entry)))) => (source, entry), + Ok(Ok(None)) => { + log::request(&peer_label, 404, &format!("media: GET {path} 404 no entry")); + return text(StatusCode::NOT_FOUND, "404 Not Found\n"); + } + Ok(Err(e)) => { + log::request(&peer_label, 500, &format!("media: GET {path} 500 {e}")); + return text(StatusCode::INTERNAL_SERVER_ERROR, "500\n"); + } + // A panic in the blocking task cannot take the server with it, and must not pass + // silently either. + Err(e) => { + log::request( + &peer_label, + 500, + &format!("media: GET {path} 500 lookup panicked: {e}"), + ); + return text(StatusCode::INTERNAL_SERVER_ERROR, "500\n"); + } + }; + + let source = match source { + Ok(source) => source, + Err(why) => { + log::request(&peer_label, 404, &format!("media: GET {path} 404 {why}")); + return text(StatusCode::NOT_FOUND, format!("404 Not Found — {why}\n")); + } + }; + + send( + req, + source, + &entry, + &peer_label, + &path, + method == Method::HEAD, + ) +} + +/// `//` and `//file/`. +fn route(path: &str) -> Option<(String, What)> { + let trimmed = path.trim_start_matches('/'); + let (id, rest) = trimmed.split_once('/')?; + if id.is_empty() { + return None; + } + let what = match rest { + "iso" | "img" => What::Image, + "kernel" => What::Kernel, + "initrd" => What::Initrd, + // `+` in a path is a literal plus; only a query string reads it as a space. + "initrd+iso" => What::InitrdIso, + other => match other.strip_prefix("file/") { + Some(inside) if !inside.is_empty() => What::Inside(inside.to_string()), + _ => return None, + }, + }; + Some((id.to_string(), what)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum What { + Image, + Kernel, + Initrd, + InitrdIso, + Inside(String), +} + +/// One run of bytes to send: a file, or a generated header. +#[derive(Debug, Clone)] +enum Segment { + Bytes(Vec), + File { + path: PathBuf, + offset: u64, + length: u64, + }, +} + +impl Segment { + fn len(&self) -> u64 { + match self { + Segment::Bytes(b) => b.len() as u64, + Segment::File { length, .. } => *length, + } + } +} + +/// What to send, and whether a range may be applied to it. +struct Source { + segments: Vec, + total: u64, + /// A single contiguous file can be resumed; a synthesised archive cannot. + resumable: bool, + content_type: &'static str, +} + +/// Turn a request for part of an entry into the bytes that answer it. +/// +/// **The image is never modified and never copied.** A kernel is an offset and a length +/// inside the ISO, because a file in an ISO9660 image is one contiguous extent — so this +/// is a seek, not an extraction. +fn resolve(entry: &Entry, what: &What) -> Result { + let one = |path: PathBuf, offset: u64, length: u64, resumable: bool| Source { + segments: vec![Segment::File { + path, + offset, + length, + }], + total: length, + resumable, + content_type: "application/octet-stream", + }; + + match what { + What::Image => Ok(one(entry.path.clone(), 0, entry.size, true)), + + What::Kernel | What::Initrd => { + let inside = match what { + What::Kernel => entry.probed.kernel.as_deref(), + _ => entry.probed.initrd.as_deref(), + }; + let Some(inside) = inside else { + return Err(format!( + "{} has no {} — no probe row places one in it", + entry.id, + if matches!(what, What::Kernel) { + "kernel" + } else { + "initrd" + } + )); + }; + // `prepare-iso --pxe` leaves the kernel and initrd *beside* the trimmed + // image rather than inside it, and that directory is a normal thing to + // point a media directory at. + if entry.probed.external { + let beside = entry + .beside + .clone() + .unwrap_or_else(|| PathBuf::from(".")) + .join(inside); + let size = std::fs::metadata(&beside) + .map(|m| m.len()) + .map_err(|e| format!("{}: {e}", beside.display()))?; + return Ok(one(beside, 0, size, true)); + } + let extent = extent_of(entry, inside)?; + Ok(one(entry.path.clone(), extent.offset, extent.size, true)) + } + + What::Inside(path) => { + let extent = extent_of(entry, path)?; + Ok(one(entry.path.clone(), extent.offset, extent.size, true)) + } + + // The initrd, then a cpio header naming `proxmox.iso`, then the image, then the + // padding and the trailer. Synthesised on the wire rather than stored: a 1.5 GB + // second copy on disk buys nothing, and the arithmetic for `Content-Length` is + // exact. `200` only — nothing resumes an initrd. + What::InitrdIso => { + let initrd = resolve(entry, &What::Initrd)?; + let member = cpio::member("proxmox.iso", entry.size, 1)?; + let mut segments = initrd.segments; + segments.push(Segment::Bytes(member.prefix.clone())); + segments.push(Segment::File { + path: entry.path.clone(), + offset: 0, + length: entry.size, + }); + if member.padding > 0 { + segments.push(Segment::Bytes(vec![0u8; member.padding])); + } + segments.push(Segment::Bytes(cpio::trailer())); + let total = segments.iter().map(Segment::len).sum(); + Ok(Source { + segments, + total, + resumable: false, + content_type: "application/octet-stream", + }) + } + } +} + +fn extent_of(entry: &Entry, inside: &str) -> Result { + let mut image = + iso::Iso::open(&entry.path).map_err(|e| format!("{}: {e}", entry.path.display()))?; + match image.locate(inside) { + Ok(Some(extent)) if !extent.directory => Ok(extent), + Ok(_) => Err(format!("{inside} is not a file in {}", entry.id)), + Err(e) => Err(format!("{}: {e}", entry.path.display())), + } +} + +/// Build the response, ranges and validators included. +fn send( + req: Request, + source: Source, + entry: &Entry, + peer: &str, + path: &str, + head_only: bool, +) -> Response { + // A strong validator either way. The digest when somebody pinned the image; size + // and mtime otherwise, so **a resumed transfer that raced a replacement restarts + // instead of splicing two images together** even for an image nobody pinned. + let etag = match &entry.digest { + Some(digest) => format!("\"{digest}\""), + None => format!("\"{}-{}\"", entry.size, mtime_token(entry)), + }; + + let header = |name: hyper::header::HeaderName| { + req.headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + }; + + // A resumed transfer names the validator it started with. If it no longer matches, + // the honest answer is the whole entity rather than a splice. + let raced = header(hyper::header::IF_RANGE).is_some_and(|v| v.trim() != etag); + let wanted = if source.resumable && !raced { + header(hyper::header::RANGE) + .as_deref() + .map_or(Wanted::Whole, |r| parse_range(r, source.total)) + } else { + Wanted::Whole + }; + + let mut builder = Response::builder() + .header("Content-Type", source.content_type) + .header("ETag", etag); + if source.resumable { + builder = builder.header("Accept-Ranges", "bytes"); + } else { + // Say so rather than letting a client discover it by having a range ignored. + builder = builder.header("Accept-Ranges", "none"); + } + + let (status, segments, length) = match wanted { + Wanted::Whole => (StatusCode::OK, source.segments, source.total), + Wanted::Part(start, end) => { + builder = builder.header( + "Content-Range", + format!("bytes {start}-{end}/{}", source.total), + ); + ( + StatusCode::PARTIAL_CONTENT, + slice(source.segments, start, end), + end - start + 1, + ) + } + Wanted::Unsatisfiable => { + log::request(peer, 416, &format!("media: GET {path} 416")); + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header("Content-Range", format!("bytes */{}", source.total)) + .header("Content-Type", "text/plain; charset=utf-8") + .body(Body::once(Bytes::from("416 Range Not Satisfiable\n"))) + .expect("a static response always builds"); + } + }; + + log::request( + peer, + status.as_u16(), + &format!( + "media: {} {path} {} bytes={length}", + if head_only { "HEAD" } else { "GET" }, + status.as_u16() + ), + ); + + // `HEAD` is answered because UEFI HTTP Boot asks before it fetches: same headers, + // same `Content-Length`, no body. + let body = if head_only { + Body::empty() + } else { + Body::stream(segments, length) + }; + builder + .status(status) + .header("Content-Length", length.to_string()) + .body(body) + .unwrap_or_else(|_| text(StatusCode::INTERNAL_SERVER_ERROR, "500\n")) +} + +/// Narrow a plan to the requested byte range. Only ever called on a single-file plan, +/// which is what `resumable` guarantees. +fn slice(segments: Vec, start: u64, end: u64) -> Vec { + segments + .into_iter() + .map(|segment| match segment { + Segment::File { path, offset, .. } => Segment::File { + path, + offset: offset + start, + length: end - start + 1, + }, + other => other, + }) + .collect() +} + +/// A tiebreaker for the fallback validator: the image's mtime, in nanoseconds. +fn mtime_token(entry: &Entry) -> u64 { + std::fs::metadata(&entry.path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) +} + +#[derive(Debug, PartialEq, Eq)] +enum Wanted { + Whole, + /// Inclusive, as `Content-Range` writes it. + Part(u64, u64), + Unsatisfiable, +} + +/// One `bytes=` range, in the three forms that exist. +/// +/// **A multi-range request is answered `200` with the whole entity.** That is permitted, +/// and far better than half-implementing `multipart/byteranges` for a client that does +/// not exist: five of the seven installers range-fetch, and not one of them asks for +/// more than one range at a time. +fn parse_range(header: &str, total: u64) -> Wanted { + let Some(spec) = header.trim().strip_prefix("bytes=") else { + return Wanted::Whole; + }; + if spec.contains(',') { + return Wanted::Whole; + } + if total == 0 { + return Wanted::Unsatisfiable; + } + + let Some((first, last)) = spec.split_once('-') else { + return Wanted::Whole; + }; + let (first, last) = (first.trim(), last.trim()); + + match (first.is_empty(), last.is_empty()) { + // `-n`: the final n bytes. + (true, false) => match last.parse::() { + Ok(0) => Wanted::Unsatisfiable, + Ok(n) => Wanted::Part(total.saturating_sub(n), total - 1), + Err(_) => Wanted::Whole, + }, + // `a-`: from a to the end. + (false, true) => match first.parse::() { + Ok(start) if start < total => Wanted::Part(start, total - 1), + Ok(_) => Wanted::Unsatisfiable, + Err(_) => Wanted::Whole, + }, + // `a-b`, with b clamped: a client may ask past the end and expects the rest. + (false, false) => match (first.parse::(), last.parse::()) { + (Ok(start), Ok(end)) if start <= end && start < total => { + Wanted::Part(start, end.min(total - 1)) + } + (Ok(_), Ok(_)) => Wanted::Unsatisfiable, + _ => Wanted::Whole, + }, + (true, true) => Wanted::Whole, + } +} + +async fn catalogue(media: &Media, peer: &str, json: bool) -> Response { + let catalog = Arc::clone(&media.catalog); + let listing = match tokio::task::spawn_blocking(move || catalog.listing()).await { + Ok(Ok(listing)) => listing, + _ => { + log::request(peer, 500, "media: GET / 500"); + return text(StatusCode::INTERNAL_SERVER_ERROR, "500\n"); + } + }; + + log::request( + peer, + 200, + &format!("media: GET / 200 entries={}", listing.entries.len()), + ); + + if json { + let rows: Vec = listing + .entries + .iter() + .map(|e| { + serde_json::json!({ + "id": e.id, + "family": e.family().label(), + "version": e.probed.version, + "arch": e.arch().map(|a| a.label()), + "size": e.size, + "sha256": e.digest, + "bootable": e.bootable(), + }) + }) + .collect(); + let body = serde_json::json!({ "media": rows, "problems": listing.problems }).to_string(); + return Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .header("Content-Length", body.len().to_string()) + .body(Body::once(Bytes::from(body))) + .expect("a built response"); + } + + let mut out = String::new(); + for entry in &listing.entries { + out.push_str(&format!( + "{:<20} {:<8} {:<24} {:>10} {}\n", + entry.id, + entry.family().label(), + entry.describe(), + entry.size, + if entry.bootable() { + "bootable" + } else { + "image only" + }, + )); + } + if listing.entries.is_empty() { + out.push_str("no images\n"); + } + for problem in &listing.problems { + out.push_str(&format!("problem: {problem}\n")); + } + text(StatusCode::OK, out) +} + +/// Whether a peer is inside `RESCRIPTUM_BOOT_ALLOW`, which is a comma-separated list of +/// CIDRs. Unset means anyone who can reach the port — which on a boot VLAN is the honest +/// configuration, and the documentation says so. +fn allowed(cfg: &Config, peer: SocketAddr) -> bool { + let Some(list) = &cfg.boot_allow else { + return true; + }; + list.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .any(|cidr| in_cidr(peer.ip(), cidr)) +} + +fn in_cidr(address: std::net::IpAddr, cidr: &str) -> bool { + let (network, bits) = match cidr.split_once('/') { + Some((network, bits)) => match bits.parse::() { + Ok(bits) => (network, bits), + Err(_) => return false, + }, + // A bare address is that address, which is the same as a full-length prefix. + None => ( + cidr, + match address { + std::net::IpAddr::V4(_) => 32, + std::net::IpAddr::V6(_) => 128, + }, + ), + }; + let Ok(network) = network.parse::() else { + return false; + }; + + // Compare the leading `bits` of both addresses. Mixing families never matches, which + // is right: a v4 CIDR says nothing about a v6 peer. + let (a, b) = match (address, network) { + (std::net::IpAddr::V4(a), std::net::IpAddr::V4(b)) => { + (a.octets().to_vec(), b.octets().to_vec()) + } + (std::net::IpAddr::V6(a), std::net::IpAddr::V6(b)) => { + (a.octets().to_vec(), b.octets().to_vec()) + } + _ => return false, + }; + if bits as usize > a.len() * 8 { + return false; + } + let whole = (bits / 8) as usize; + if a[..whole] != b[..whole] { + return false; + } + let leftover = bits % 8; + if leftover == 0 { + return true; + } + let mask = 0xffu8 << (8 - leftover); + a[whole] & mask == b[whole] & mask +} + +// ---- the body ------------------------------------------------------------ + +/// **Streaming, never buffering.** A response is a list of runs of bytes, produced by a +/// blocking task and handed over a channel one chunk at a time, so sixteen concurrent +/// transfers cost megabytes rather than sixteen images. +pub struct Body { + inner: Inner, + remaining: u64, +} + +enum Inner { + Once(Option), + Stream(mpsc::Receiver>), +} + +impl Body { + fn once(bytes: Bytes) -> Body { + Body { + remaining: bytes.len() as u64, + inner: Inner::Once(Some(bytes)), + } + } + + fn empty() -> Body { + Body { + remaining: 0, + inner: Inner::Once(None), + } + } + + fn stream(segments: Vec, total: u64) -> Body { + // One chunk queued behind the one being written: enough to keep the socket fed, + // little enough that the arithmetic above stays true. + let (tx, rx) = mpsc::channel(1); + tokio::task::spawn_blocking(move || produce(segments, &tx)); + Body { + remaining: total, + inner: Inner::Stream(rx), + } + } +} + +/// The blocking half: open, seek, read, send. A closed channel means the client hung up +/// — stop reading rather than finishing an image nobody is receiving. +fn produce(segments: Vec, tx: &mpsc::Sender>) { + for segment in segments { + match segment { + Segment::Bytes(bytes) => { + if tx.blocking_send(Ok(Bytes::from(bytes))).is_err() { + return; + } + } + Segment::File { + path, + offset, + length, + } => { + let mut file = match std::fs::File::open(&path) { + Ok(file) => file, + Err(e) => { + let _ = tx.blocking_send(Err(e)); + return; + } + }; + if let Err(e) = file.seek(SeekFrom::Start(offset)) { + let _ = tx.blocking_send(Err(e)); + return; + } + let mut left = length; + let mut buffer = vec![0u8; CHUNK]; + while left > 0 { + let want = (left as usize).min(CHUNK); + match file.read(&mut buffer[..want]) { + // Short of what the catalogue promised: the file shrank under + // us. Ending the body early is what the client will notice + // against `Content-Length`, which is the honest signal. + Ok(0) => return, + Ok(n) => { + if tx + .blocking_send(Ok(Bytes::copy_from_slice(&buffer[..n]))) + .is_err() + { + return; + } + left -= n as u64; + } + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => { + let _ = tx.blocking_send(Err(e)); + return; + } + } + } + } + } + } +} + +impl hyper::body::Body for Body { + type Data = Bytes; + type Error = io::Error; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, io::Error>>> { + let this = self.get_mut(); + match &mut this.inner { + Inner::Once(slot) => match slot.take() { + Some(bytes) => { + this.remaining = 0; + Poll::Ready(Some(Ok(Frame::data(bytes)))) + } + None => Poll::Ready(None), + }, + Inner::Stream(rx) => match rx.poll_recv(cx) { + Poll::Ready(Some(Ok(bytes))) => { + this.remaining = this.remaining.saturating_sub(bytes.len() as u64); + Poll::Ready(Some(Ok(Frame::data(bytes)))) + } + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + }, + } + } + + fn size_hint(&self) -> SizeHint { + SizeHint::with_exact(self.remaining) + } +} + +fn text(status: StatusCode, body: impl Into) -> Response { + Response::builder() + .status(status) + .header("Content-Type", "text/plain; charset=utf-8") + .body(Body::once(body.into())) + .expect("a static response always builds") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_three_range_forms_are_read_correctly() { + assert_eq!(parse_range("bytes=0-99", 1000), Wanted::Part(0, 99)); + assert_eq!(parse_range("bytes=500-", 1000), Wanted::Part(500, 999)); + assert_eq!(parse_range("bytes=-100", 1000), Wanted::Part(900, 999)); + // A client may ask past the end and expects the rest, not a refusal. + assert_eq!(parse_range("bytes=990-2000", 1000), Wanted::Part(990, 999)); + } + + #[test] + fn a_range_that_starts_past_the_end_is_unsatisfiable() { + assert_eq!(parse_range("bytes=1000-", 1000), Wanted::Unsatisfiable); + assert_eq!(parse_range("bytes=2000-3000", 1000), Wanted::Unsatisfiable); + assert_eq!(parse_range("bytes=-0", 1000), Wanted::Unsatisfiable); + // Nothing can be satisfied out of an empty entity. + assert_eq!(parse_range("bytes=0-0", 0), Wanted::Unsatisfiable); + } + + #[test] + fn a_multi_range_request_is_answered_whole() { + // Permitted, and far better than half-implementing multipart/byteranges for a + // client that does not exist. + assert_eq!(parse_range("bytes=0-99,200-299", 1000), Wanted::Whole); + } + + #[test] + fn something_that_is_not_a_byte_range_is_ignored() { + assert_eq!(parse_range("items=0-99", 1000), Wanted::Whole); + assert_eq!(parse_range("bytes=abc-def", 1000), Wanted::Whole); + assert_eq!(parse_range("bytes=", 1000), Wanted::Whole); + } + + #[test] + fn the_routes_are_the_ones_documented() { + assert_eq!( + route("/pve-8.4/iso"), + Some(("pve-8.4".to_string(), What::Image)) + ); + assert_eq!( + route("/pve-8.4/kernel"), + Some(("pve-8.4".to_string(), What::Kernel)) + ); + assert_eq!( + route("/pve-8.4/initrd"), + Some(("pve-8.4".to_string(), What::Initrd)) + ); + // `+` in a path is a literal plus; only a query string reads it as a space. + assert_eq!( + route("/pve-8.4/initrd+iso"), + Some(("pve-8.4".to_string(), What::InitrdIso)) + ); + assert_eq!( + route("/pve-8.4/file/boot/linux26"), + Some(( + "pve-8.4".to_string(), + What::Inside("boot/linux26".to_string()) + )) + ); + assert_eq!(route("/pve-8.4"), None); + assert_eq!(route("/pve-8.4/nonsense"), None); + assert_eq!(route("/pve-8.4/file/"), None); + } + + #[test] + fn a_cidr_allowlist_admits_and_refuses_by_prefix() { + let matches = |peer: &str, cidr: &str| in_cidr(peer.parse().expect("address"), cidr); + assert!(matches("10.0.0.5", "10.0.0.0/8")); + assert!(matches("10.0.0.5", "10.0.0.0/24")); + assert!(!matches("10.0.1.5", "10.0.0.0/24")); + assert!(matches("192.168.1.130", "192.168.1.128/25")); + assert!(!matches("192.168.1.127", "192.168.1.128/25")); + // A bare address is itself. + assert!(matches("10.0.0.5", "10.0.0.5")); + assert!(!matches("10.0.0.6", "10.0.0.5")); + // Everything matches /0, which is the "why bother" case, and it must still work. + assert!(matches("203.0.113.9", "0.0.0.0/0")); + // A v4 CIDR says nothing about a v6 peer. + assert!(!matches("::1", "10.0.0.0/8")); + assert!(matches("2001:db8::1", "2001:db8::/32")); + assert!(!matches("2001:db9::1", "2001:db8::/32")); + // Nonsense refuses rather than admits: a typo must not open the door. + assert!(!matches("10.0.0.5", "not-a-network/8")); + assert!(!matches("10.0.0.5", "10.0.0.0/wide")); + assert!(!matches("10.0.0.5", "10.0.0.0/99")); + } + + #[test] + fn an_unset_allowlist_admits_everyone() { + let cfg = Config::from_lookup(|_| None); + assert!(allowed(&cfg, "203.0.113.9:1234".parse().expect("peer"))); + } + + #[test] + fn a_set_allowlist_refuses_everyone_else() { + let cfg = Config::from_lookup(|key| { + (key == "RESCRIPTUM_BOOT_ALLOW").then(|| "10.0.0.0/8, 192.168.0.0/16".to_string()) + }); + assert!(allowed(&cfg, "10.1.2.3:1".parse().expect("peer"))); + assert!(allowed(&cfg, "192.168.5.5:1".parse().expect("peer"))); + assert!(!allowed(&cfg, "203.0.113.9:1".parse().expect("peer"))); + } + + #[test] + fn a_range_narrows_the_plan_rather_than_re_reading_it() { + let segments = vec![Segment::File { + path: PathBuf::from("/srv/media/x.iso"), + offset: 4096, + length: 1000, + }]; + let narrowed = slice(segments, 100, 199); + match &narrowed[0] { + Segment::File { offset, length, .. } => { + // The offset moves *within the extent*, which is what makes a range over + // a file inside an image work at all. + assert_eq!(*offset, 4196); + assert_eq!(*length, 100); + } + other => panic!("{other:?}"), + } + } +} diff --git a/src/boot/mod.rs b/src/boot/mod.rs index 5782a52..d1007c6 100644 --- a/src/boot/mod.rs +++ b/src/boot/mod.rs @@ -13,6 +13,7 @@ pub mod catalog; pub mod cpio; pub mod iso; +pub mod media; pub mod probe; pub mod sha256; pub mod stanza; diff --git a/src/boot/sha256.rs b/src/boot/sha256.rs index ddb30f6..b6aeff7 100644 --- a/src/boot/sha256.rs +++ b/src/boot/sha256.rs @@ -175,6 +175,35 @@ pub fn is_digest(value: &str) -> bool { value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) } +/// Hash a whole file, reporting progress as it goes. +/// +/// **This is an ingest-time cost and never a request-time one.** At about 30 MB/s on the +/// small end of the range this has to work on, a 1.5 GB image is three quarters of a +/// minute — and a minute of silence reads as a hang, so `progress` is a callback rather +/// than an option nobody turns on. +pub fn file(path: &std::path::Path, mut progress: impl FnMut(u64, u64)) -> std::io::Result { + use std::io::Read; + + let total = std::fs::metadata(path)?.len(); + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; 1024 * 1024]; + let mut done = 0u64; + + loop { + let n = match file.read(&mut buffer) { + Ok(0) => break, + Ok(n) => n, + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + }; + hasher.update(&buffer[..n]); + done += n as u64; + progress(done, total); + } + Ok(to_hex(&hasher.finish())) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli.rs b/src/cli.rs index 00dbd82..ee2b44e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -28,6 +28,10 @@ USAGE: rescriptum config --value K one value, for a script (never a credential) rescriptum config set K=V edit the file RESCRIPTUM_ENV_FILE names rescriptum config unset K comment a setting back out of it + rescriptum media list the installer images this server holds + rescriptum media add FILE register one: verify, probe, record its digest + rescriptum media check re-verify every recorded digest, report what drifted + rescriptum media ipxe ID print the .ipxe answer that boots one image rescriptum --help ENVIRONMENT: @@ -43,9 +47,17 @@ ENVIRONMENT: RESCRIPTUM_LOG_FILE a path, stdout or stderr (default stderr) ADMIN API (requires RESCRIPTUM_STORE=sqlite; off unless RESCRIPTUM_ADMIN_ADDR is set): - RESCRIPTUM_ADMIN_ADDR admin listener, e.g. 127.0.0.1:8001 + RESCRIPTUM_ADMIN_ADDR admin listener, e.g. 127.0.0.1:9000 RESCRIPTUM_ADMIN_TOKEN bearer token, 16 characters or more (required) +BOOT MEDIA (off unless RESCRIPTUM_MEDIA_DIR is set): + RESCRIPTUM_MEDIA_DIR directory of installer images + RESCRIPTUM_MEDIA_ADDR media listener (default 0.0.0.0:8001) + RESCRIPTUM_MEDIA_TIMEOUT_SECS whole-transfer deadline (default 600) + RESCRIPTUM_MEDIA_MAX_CONNECTIONS concurrent transfers (default 16) + RESCRIPTUM_PUBLIC_HOST the host generated URLs name (a host, not a URL) + RESCRIPTUM_BOOT_ALLOW CIDRs allowed to fetch media (default: anyone) + VALIDATING A MERGED ANSWER: rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml proxmox-auto-install-assistant validate-answer /tmp/answer.toml @@ -411,6 +423,372 @@ fn copy( } } +// ---- media ---------------------------------------------------------------- + +/// `media list|add|check|ipxe` — the boot-media half of the CLI. +/// +/// **Preparation and asset management are commands, never requests.** The rule that +/// keeps the server honest is that no request ever triggers work proportional to the +/// size of an image; hashing 1.5 GB happens here, once, and the result is recorded +/// beside the image so nothing ever recomputes it. +pub fn media(cfg: &Config, args: &[String]) -> ExitCode { + let Some(dir) = &cfg.media_dir else { + eprintln!("there is no media directory: RESCRIPTUM_MEDIA_DIR names one, and nothing does"); + return ExitCode::FAILURE; + }; + let catalog = crate::boot::catalog::Catalog::new(dir); + + match args.split_first() { + Some((cmd, rest)) if cmd == "list" && rest.is_empty() => media_list(&catalog), + Some((cmd, rest)) if cmd == "add" && !rest.is_empty() => media_add(&catalog, rest), + Some((cmd, rest)) if cmd == "check" && rest.is_empty() => media_check(&catalog), + Some((cmd, rest)) if cmd == "ipxe" && rest.len() == 1 => { + media_ipxe(cfg, &catalog, &rest[0]) + } + _ => { + eprintln!( + "usage: rescriptum media list\n\ + \x20 rescriptum media add FILE [--sha256 DIGEST]\n\ + \x20 rescriptum media check\n\ + \x20 rescriptum media ipxe ID" + ); + ExitCode::FAILURE + } + } +} + +fn media_list(catalog: &crate::boot::catalog::Catalog) -> ExitCode { + let listing = match catalog.listing() { + Ok(listing) => listing, + Err(e) => { + eprintln!("cannot read {}: {e}", catalog.dir().display()); + return ExitCode::FAILURE; + } + }; + + println!( + "{:<20} {:<8} {:<10} {:<28} {:>8} PINNED", + "ID", "FAMILY", "ARCH", "VERSION", "SIZE" + ); + for entry in &listing.entries { + println!( + "{:<20} {:<8} {:<10} {:<28} {:>8} {}", + entry.id, + entry.family().label(), + entry.arch().map(|a| a.label()).unwrap_or("—"), + truncate(&entry.describe(), 28), + human(entry.size), + match &entry.digest { + Some(digest) => digest[..12.min(digest.len())].to_string(), + None => "—".to_string(), + }, + ); + } + if listing.entries.is_empty() { + println!("(nothing in {})", catalog.dir().display()); + } + for problem in &listing.problems { + eprintln!("warning: {problem}"); + } + ExitCode::SUCCESS +} + +fn media_add(catalog: &crate::boot::catalog::Catalog, args: &[String]) -> ExitCode { + let mut path: Option<&String> = None; + let mut expected: Option<&String> = None; + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + if arg == "--sha256" { + match rest.next() { + Some(digest) => expected = Some(digest), + None => { + eprintln!("--sha256 wants a digest"); + return ExitCode::FAILURE; + } + } + } else if path.is_none() { + path = Some(arg); + } else { + eprintln!("unexpected argument {arg:?}"); + return ExitCode::FAILURE; + } + } + let Some(path) = path.map(std::path::PathBuf::from) else { + eprintln!("usage: rescriptum media add FILE [--sha256 DIGEST]"); + return ExitCode::FAILURE; + }; + + if let Some(digest) = expected + && !crate::boot::sha256::is_digest(digest) + { + eprintln!("{digest:?} is not a SHA-256 — it is 64 hexadecimal characters"); + return ExitCode::FAILURE; + } + if !path.is_file() { + eprintln!("{} is not a file", path.display()); + return ExitCode::FAILURE; + } + + // **The server never downloads images; it receives them.** Dropping the file into + // the directory is the native act — over SMB, over scp, from wherever the ISO + // already is — and this only registers what is already there. Registering something + // outside the directory would record a digest for a file the listener cannot serve. + let inside = path + .parent() + .map(|p| same_directory(p, catalog.dir())) + .unwrap_or(false); + if !inside { + eprintln!( + "{} is not in {} — put the image there first, then register it.\n\ + Nothing is copied: the catalogue serves the file where it lies.", + path.display(), + catalog.dir().display() + ); + return ExitCode::FAILURE; + } + + let id = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + if !crate::store::valid_id(&id) { + eprintln!("{id:?} is not a usable identifier — it becomes part of a URL"); + return ExitCode::FAILURE; + } + if crate::boot::catalog::RESERVED_IDS.contains(&id.as_str()) { + eprintln!( + "{id:?} is a reserved name — the media listener answers /{id} itself, so an \ + entry called that could never be reached. Rename the file." + ); + return ExitCode::FAILURE; + } + + // Progress, because a minute of silence reads as a hang. + eprintln!("hashing {} …", path.display()); + let mut last = 0u64; + let digest = match crate::boot::sha256::file(&path, |done, total| { + let percent = if total == 0 { 100 } else { done * 100 / total }; + if percent >= last + 10 { + last = percent - percent % 10; + eprintln!(" {last}% ({} of {})", human(done), human(total)); + } + }) { + Ok(digest) => digest, + Err(e) => { + eprintln!("cannot read {}: {e}", path.display()); + return ExitCode::FAILURE; + } + }; + + if let Some(expected) = expected + && !expected.eq_ignore_ascii_case(&digest) + { + // Loud and fatal. A mismatch here is either a truncated download or the wrong + // file, and both install the wrong thing on every machine that asks. + eprintln!("digest mismatch — nothing was recorded"); + eprintln!(" expected {expected}"); + eprintln!(" found {digest}"); + return ExitCode::FAILURE; + } + + let probed = match crate::boot::probe::probe(&path) { + Ok(probed) => probed, + Err(e) => { + // Still registrable: an image nothing places can be served whole, and that + // is a normal thing to want. + eprintln!("note: cannot read {} as an image ({e})", path.display()); + Default::default() + } + }; + + let sidecar = crate::boot::catalog::Sidecar::path_for(&path); + if let Err(e) = std::fs::write( + &sidecar, + crate::boot::catalog::Sidecar::render(&digest, &probed), + ) { + eprintln!("cannot write {}: {e}", sidecar.display()); + return ExitCode::FAILURE; + } + + println!("{id} {digest}"); + println!( + " {} {}", + probed + .family + .map(|f| f.label().to_string()) + .unwrap_or_else(|| "unknown".to_string()), + probed.version.clone().unwrap_or_default() + ); + match (&probed.kernel, &probed.initrd) { + (Some(kernel), Some(initrd)) => { + println!(" kernel {kernel}"); + println!(" initrd {initrd}"); + if probed.external { + println!(" (both beside the image — this looks like `prepare-iso --pxe` output)"); + } + if probed.zstd_initrd { + // The assistant's own source says "iPXE does not support a + // zstd-compressed initrd" when it recompresses to gzip. Whether that + // binds through our chain is a bench question; saying so is not. + println!( + " note: the initrd is zstd-compressed. The Proxmox assistant recompresses\n\ + \x20 it to gzip when it splits an image, on the grounds that iPXE does\n\ + \x20 not support zstd. If a loader refuses it, run:\n\ + \x20 proxmox-auto-install-assistant prepare-iso {} --pxe --output DIR", + path.display() + ); + } + } + _ => println!(" no kernel or initrd found — servable whole, but not as a boot stanza"), + } + println!(" wrote {}", sidecar.display()); + ExitCode::SUCCESS +} + +/// `media check` — re-verify what was recorded. Its exit code is a contract, like +/// `check`'s: `deploy.sh` keys on it. +fn media_check(catalog: &crate::boot::catalog::Catalog) -> ExitCode { + let listing = match catalog.listing() { + Ok(listing) => listing, + Err(e) => { + println!("cannot read {}: {e}", catalog.dir().display()); + return ExitCode::FAILURE; + } + }; + println!("checking {}", catalog.describe()); + + let mut failures = listing.problems.len(); + for problem in &listing.problems { + println!(" problem: {problem}"); + } + + let mut pinned = 0usize; + let mut unpinned: Vec<&str> = Vec::new(); + for entry in &listing.entries { + let Some(recorded) = &entry.digest else { + unpinned.push(&entry.id); + continue; + }; + match crate::boot::sha256::file(&entry.path, |_, _| {}) { + Ok(digest) if digest.eq_ignore_ascii_case(recorded) => pinned += 1, + Ok(digest) => { + // An image that changed under a recorded digest is the one failure that + // silently installs something nobody reviewed. + println!( + " FAIL {}: the image no longer matches what was recorded", + entry.id + ); + println!(" recorded {recorded}"); + println!(" found {digest}"); + failures += 1; + } + Err(e) => { + println!(" FAIL {}: {e}", entry.id); + failures += 1; + } + } + if entry.probed.zstd_initrd { + println!( + " note: {}'s initrd is zstd — `prepare-iso --pxe` recompresses to gzip", + entry.id + ); + } + } + + println!( + " {} image(s), {pinned} verified against a recorded digest", + listing.entries.len() + ); + for id in &unpinned { + println!(" note: {id} has no recorded digest — `media add` records one"); + } + + if failures == 0 { + println!(" ok — everything recorded still matches"); + ExitCode::SUCCESS + } else { + println!(" {failures} problem(s)"); + ExitCode::FAILURE + } +} + +/// `media ipxe ID` — **print a script; do not install one.** +/// +/// The output is an ordinary `.ipxe` answer document. Saved into the answers directory +/// it goes through the existing selection, layering and templating unchanged, which is +/// the altitude that keeps the model intact: the server does not become clever about +/// booting, it gains a generator. +/// +/// stdout is the script and stderr is everything else, so `media ipxe … > file` works. +fn media_ipxe(cfg: &Config, catalog: &crate::boot::catalog::Catalog, id: &str) -> ExitCode { + let entry = match catalog.get(id) { + Ok(Some(entry)) => entry, + Ok(None) => { + eprintln!("no image called {id:?} — `rescriptum media list` shows what there is"); + return ExitCode::FAILURE; + } + Err(e) => { + eprintln!("cannot read {}: {e}", catalog.dir().display()); + return ExitCode::FAILURE; + } + }; + + let (host, derived) = cfg.public_host(); + if derived { + eprintln!( + "# warning: RESCRIPTUM_PUBLIC_HOST is not set, so this script names {host}, \ + derived by asking the routing table. Set it if that is not the address the \ + machines can reach." + ); + } + match crate::boot::stanza::ipxe(&entry, &cfg.endpoints()) { + Ok(script) => { + eprintln!( + "# {}", + crate::boot::stanza::where_the_answer_goes(entry.family()) + ); + print!("{script}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } +} + +/// Whether two paths name the same directory, resolving symlinks where it can. A media +/// directory reached as `/srv/media` and as `./media` is the same directory. +fn same_directory(a: &std::path::Path, b: &std::path::Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + +fn human(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit + 1 < UNITS.len() { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes}{}", UNITS[0]) + } else { + format!("{size:.1}{}", UNITS[unit]) + } +} + +fn truncate(text: &str, width: usize) -> String { + if text.chars().count() <= width { + return text.to_string(); + } + let kept: String = text.chars().take(width.saturating_sub(1)).collect(); + format!("{kept}…") +} + // ---- config --------------------------------------------------------------- /// `config` / `config --json` / `config set KEY=VALUE` / `config unset KEY` diff --git a/src/config.rs b/src/config.rs index bfb01cb..60adc4b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,19 @@ pub const DEFAULT_MAX_CONNECTIONS: usize = 2048; /// worker for at most this long. pub const DEFAULT_TIMEOUT_SECS: u64 = 10; +/// The media listener's port, and **it is a contract rather than a preference.** The +/// loader we ship carries an embedded script that chains to `${next-server}:8001`, and +/// that script is baked in before any deployment exists — it can read no configuration. +/// Moving this is allowed and survivable, but every loader already shipped assumes it. +pub const DEFAULT_MEDIA_ADDR: &str = "0.0.0.0:8001"; +/// A whole-transfer deadline, deliberately not the answer listener's ten seconds: a +/// 1.5 GB image is fifteen seconds on gigabit and two minutes on 100 Mbit, and on the +/// answer listener every download would be killed mid-transfer. +pub const DEFAULT_MEDIA_TIMEOUT_SECS: u64 = 600; +/// Concurrent transfers, low on purpose. A download holds its permit for minutes, and +/// the small end of the range this has to work on is a NAS with one spinning disk. +pub const DEFAULT_MEDIA_MAX_CONNECTIONS: usize = 16; + /// Where answers are read from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StoreKind { @@ -66,6 +79,30 @@ pub struct Config { pub workers: usize, pub max_connections: usize, pub timeout: Duration, + + /// The host this server names itself by in the scripts it writes. + /// + /// **A host, never a URL.** The server writes URLs for two listeners plus a bare + /// address into a DHCP snippet, so a value carrying one port would silently pin + /// every generated script to one listener. Each URL appends its own port. + /// + /// `None` means derive one and say so loudly — a wrong guess here produces a + /// machine that boots, chains, and hangs on an address that does not exist, and + /// the startup log line is the only place the answer will ever appear. + pub public_host: Option, + /// Where installer images live. **Unset is the whole off switch**: no media + /// directory, no media listener, nothing changes for an existing deployment. + pub media_dir: Option, + /// The media listener's address, **as the operator set it**. `None` means nobody + /// did, and `media_addr()` supplies the default. The distinction is kept because + /// naming an address without naming a directory is a mistake worth refusing, and a + /// value that had already been defaulted could not be told from one that was asked + /// for. + pub media_addr: Option, + pub media_timeout: Duration, + pub media_max_connections: usize, + /// A CIDR allowlist for boot traffic. Unset means anyone who can reach the port. + pub boot_allow: Option, } impl Config { @@ -175,6 +212,18 @@ impl Config { "RESCRIPTUM_TIMEOUT_SECS", DEFAULT_TIMEOUT_SECS as usize, ) as u64), + public_host: optional("RESCRIPTUM_PUBLIC_HOST"), + media_dir: optional("RESCRIPTUM_MEDIA_DIR").map(PathBuf::from), + media_addr: optional("RESCRIPTUM_MEDIA_ADDR"), + media_timeout: Duration::from_secs(get_usize( + "RESCRIPTUM_MEDIA_TIMEOUT_SECS", + DEFAULT_MEDIA_TIMEOUT_SECS as usize, + ) as u64), + media_max_connections: get_usize( + "RESCRIPTUM_MEDIA_MAX_CONNECTIONS", + DEFAULT_MEDIA_MAX_CONNECTIONS, + ), + boot_allow: optional("RESCRIPTUM_BOOT_ALLOW"), } } } @@ -186,6 +235,8 @@ impl Config { /// silently came up without a token would let anyone who can reach it rewrite the /// root password and SSH keys of every machine subsequently installed. pub fn validate(&self) -> Result<(), String> { + self.validate_media()?; + let Some(addr) = &self.admin_addr else { return Ok(()); }; @@ -216,6 +267,101 @@ impl Config { Ok(()) } + /// The media half of `validate`. Same rule as everywhere else here: refuse only + /// what would not work or would not be safe, and warn about everything that can be + /// fixed while the server runs. + fn validate_media(&self) -> Result<(), String> { + // A host, never a URL. One port in the value would silently pin every generated + // script to one listener, and the symptom is a machine chaining into nowhere. + if let Some(host) = &self.public_host { + let wrong = if host.contains("://") { + Some("a scheme") + } else if host.contains('/') { + Some("a path") + } else if host.rsplit_once(':').is_some_and(|(head, tail)| { + // `[::1]` is an address, not a host with a port. Only a trailing + // `:digits` after something that is not a bracketed address is one. + !host.starts_with('[') + && !head.contains(':') + && tail.chars().all(|c| c.is_ascii_digit()) + }) { + Some("a port") + } else { + None + }; + if let Some(wrong) = wrong { + return Err(format!( + "RESCRIPTUM_PUBLIC_HOST is {host:?}, which carries {wrong}. It is a host \ + on its own — every generated URL appends its own listener's port." + )); + } + } + + if self.media_addr.is_some() && self.media_dir.is_none() { + return Err(format!( + "RESCRIPTUM_MEDIA_ADDR is set ({}), but RESCRIPTUM_MEDIA_DIR is not. There \ + would be a listener with nothing to serve.", + self.media_addr.as_deref().unwrap_or_default() + )); + } + + // Two listeners on one port: the second bind fails, and which one loses depends + // on start order. Saying so beats a race whose symptom is "it worked yesterday". + // + // Port zero is exempt, and not as a special case for tests: `:0` asks the kernel + // for *any* free port, so two of them are never the same port. Refusing them + // would refuse the one configuration that cannot collide. + if self.media_dir.is_some() && !ephemeral(&self.media_addr()) { + let media = self.media_addr(); + for (other, name) in [ + (Some(&self.listen_addr), "RESCRIPTUM_LISTEN_ADDR"), + (self.admin_addr.as_ref(), "RESCRIPTUM_ADMIN_ADDR"), + ] { + if other.is_some_and(|o| o == &media) { + return Err(format!( + "RESCRIPTUM_MEDIA_ADDR and {name} are both {media}. Media downloads \ + hold a connection for minutes and answers must not queue behind \ + them, which is why they are separate listeners." + )); + } + } + } + Ok(()) + } + + /// The media listener's effective address. + pub fn media_addr(&self) -> String { + self.media_addr + .clone() + .unwrap_or_else(|| DEFAULT_MEDIA_ADDR.to_string()) + } + + /// The host this server names itself by, and where that name came from. + /// + /// Derivation opens a UDP socket toward a documentation address and reads back the + /// local address the routing table chose. **No packet is sent** — connecting a UDP + /// socket only picks a route. It is the standard trick, it costs nothing, and it is + /// wrong often enough on multi-homed and NAT hosts to be a warning rather than a + /// silent success. + pub fn public_host(&self) -> (String, bool) { + match &self.public_host { + Some(host) => (host.clone(), false), + None => ( + derive_public_host().unwrap_or_else(|| "127.0.0.1".to_string()), + true, + ), + } + } + + /// The two URLs a generated script needs, each with its own listener's port. + pub fn endpoints(&self) -> crate::boot::stanza::Endpoints { + let (host, _) = self.public_host(); + crate::boot::stanza::Endpoints { + media: format!("http://{}", join(&host, &self.media_addr())), + answer: format!("http://{}", join(&host, &self.listen_addr)), + } + } + /// The answer endpoint's own check, separate because a short token there is worth a /// warning rather than a refusal: an installer's token format is not ours to choose, /// and refusing to start would leave a fleet unable to install. @@ -262,6 +408,42 @@ impl Config { } } +/// Whether an address asks the kernel to choose the port. Two such listeners never +/// collide, however identical the strings look. +fn ephemeral(addr: &str) -> bool { + addr.rsplit_once(':') + .is_some_and(|(_, port)| port.trim() == "0") +} + +/// A reachable host plus the port of a listen address, ready to go into a URL. +/// +/// The listen address is usually `0.0.0.0:8001`, which is not something anybody can +/// fetch from — the port is the only part of it worth keeping. +fn join(host: &str, listen_addr: &str) -> String { + let port = listen_addr + .rsplit_once(':') + .map(|(_, port)| port) + .unwrap_or("80"); + // An IPv6 literal needs brackets before a port can follow it. + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +/// Ask the routing table which of this host's addresses faces the outside world. +/// +/// 192.0.2.1 is TEST-NET-1, a documentation address that exists to be written down and +/// never answered. Connecting a UDP socket to it sends nothing; it only makes the +/// kernel choose a source address, which is the answer we are after. +fn derive_public_host() -> Option { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("192.0.2.1:9").ok()?; + let address = socket.local_addr().ok()?.ip(); + (!address.is_unspecified()).then(|| address.to_string()) +} + /// One configuration variable, **described** rather than merely read. /// /// `from_lookup` above knows how to interpret each of these. This table is what anything @@ -282,7 +464,7 @@ pub struct Known { /// Every variable, in the order a person would want to meet them: what answers come /// from, where the server listens, how much it says, then the two credentials. -pub const KNOWN: [Known; 13] = [ +pub const KNOWN: [Known; 19] = [ Known { key: "RESCRIPTUM_STORE", default: Some("files"), @@ -363,6 +545,42 @@ pub const KNOWN: [Known; 13] = [ secret: true, help: "Bearer token for the write API. At least 16 characters, and required.", }, + Known { + key: "RESCRIPTUM_PUBLIC_HOST", + default: None, + secret: false, + help: "The host this server names itself by. A host, never a URL. Derived if unset.", + }, + Known { + key: "RESCRIPTUM_MEDIA_DIR", + default: None, + secret: false, + help: "Installer images. Unset means no media and no media listener.", + }, + Known { + key: "RESCRIPTUM_MEDIA_ADDR", + default: Some(DEFAULT_MEDIA_ADDR), + secret: false, + help: "The media listener, when there is a media directory. Loaders assume 8001.", + }, + Known { + key: "RESCRIPTUM_MEDIA_TIMEOUT_SECS", + default: Some("600"), + secret: false, + help: "Whole-transfer deadline for a download. Not the answer listener's 10.", + }, + Known { + key: "RESCRIPTUM_MEDIA_MAX_CONNECTIONS", + default: Some("16"), + secret: false, + help: "Concurrent transfers, low on purpose: each holds its permit for minutes.", + }, + Known { + key: "RESCRIPTUM_BOOT_ALLOW", + default: None, + secret: false, + help: "Client CIDRs allowed to fetch boot media. Unset means anyone who can reach it.", + }, ]; /// Which of the three places a value came from. @@ -654,6 +872,146 @@ mod tests { assert_eq!(c.listen_addr, "0.0.0.0:8080"); } + // ---- media and the public host --------------------------------------- + + #[test] + fn media_is_off_until_a_directory_is_named() { + // Nothing changes for an existing deployment: no directory, no listener. + let c = Config::from_lookup(lookup(&[])); + assert_eq!(c.media_dir, None); + assert_eq!(c.media_addr, None); + assert!(c.validate().is_ok()); + } + + #[test] + fn the_media_listener_defaults_to_the_port_the_loaders_assume() { + let c = Config::from_lookup(lookup(&[("RESCRIPTUM_MEDIA_DIR", "/srv/media")])); + assert_eq!(c.media_addr(), "0.0.0.0:8001"); + // Pinned deliberately. The loader we ship embeds a script that chains to + // `${next-server}:8001` before any deployment exists, so this is a contract in + // the same way an answer URL baked into an ISO is. + assert_eq!(DEFAULT_MEDIA_ADDR, "0.0.0.0:8001"); + assert!(c.validate().is_ok()); + } + + #[test] + fn an_address_with_nothing_to_serve_is_refused() { + let c = Config::from_lookup(lookup(&[("RESCRIPTUM_MEDIA_ADDR", "0.0.0.0:8001")])); + let e = c.validate().expect_err("must refuse"); + assert!(e.contains("RESCRIPTUM_MEDIA_DIR"), "{e}"); + } + + #[test] + fn two_listeners_on_one_port_are_refused_rather_than_raced() { + // The second bind loses, and which one that is depends on start order. + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ("RESCRIPTUM_MEDIA_ADDR", "0.0.0.0:8000"), + ])); + let e = c.validate().expect_err("must refuse"); + assert!(e.contains("RESCRIPTUM_LISTEN_ADDR"), "{e}"); + + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ("RESCRIPTUM_MEDIA_ADDR", "127.0.0.1:8001"), + ("RESCRIPTUM_ADMIN_ADDR", "127.0.0.1:8001"), + ("RESCRIPTUM_STORE", "sqlite"), + ("RESCRIPTUM_ADMIN_TOKEN", "0123456789abcdef0"), + ])); + let e = c.validate().expect_err("must refuse"); + assert!(e.contains("RESCRIPTUM_ADMIN_ADDR"), "{e}"); + } + + #[test] + fn two_ephemeral_ports_are_not_a_collision() { + // `:0` asks the kernel for any free port, so two of them are never the same + // port. Refusing them would refuse the one configuration that cannot collide — + // and it is the one every integration test uses. + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ("RESCRIPTUM_MEDIA_ADDR", "127.0.0.1:0"), + ("RESCRIPTUM_LISTEN_ADDR", "127.0.0.1:0"), + ])); + assert!(c.validate().is_ok(), "{:?}", c.validate()); + } + + #[test] + fn the_public_host_refuses_to_be_a_url() { + // It is written into URLs for *two* listeners plus a bare address in a DHCP + // snippet. A value carrying one port would pin every generated script to one + // listener, and the symptom is a machine chaining into nowhere. + for (value, wrong) in [ + ("http://192.0.2.10", "a scheme"), + ("192.0.2.10:8001", "a port"), + ("192.0.2.10/boot", "a path"), + ] { + let c = Config::from_lookup(|key| { + (key == "RESCRIPTUM_PUBLIC_HOST").then(|| value.to_string()) + }); + let e = c.validate().expect_err("must refuse {value}"); + assert!(e.contains(wrong), "{value}: {e}"); + } + } + + #[test] + fn a_plain_host_or_an_ipv6_literal_is_accepted() { + for value in [ + "192.0.2.10", + "boot.example.com", + "[2001:db8::1]", + "2001:db8::1", + ] { + let c = Config::from_lookup(|key| { + (key == "RESCRIPTUM_PUBLIC_HOST").then(|| value.to_string()) + }); + assert!(c.validate().is_ok(), "{value} must be accepted"); + assert_eq!(c.public_host(), (value.to_string(), false)); + } + } + + #[test] + fn a_derived_public_host_is_reported_as_derived() { + // Derivation is wrong often enough on multi-homed and NAT hosts that it is a + // warning rather than a silent success — the flag is what makes it sayable. + let (_host, derived) = Config::from_lookup(lookup(&[])).public_host(); + assert!(derived); + } + + #[test] + fn each_generated_url_carries_its_own_listeners_port() { + // The whole reason the variable is a host: one value, two listeners. + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_PUBLIC_HOST", "192.0.2.10"), + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ])); + let endpoints = c.endpoints(); + assert_eq!(endpoints.answer, "http://192.0.2.10:8000"); + assert_eq!(endpoints.media, "http://192.0.2.10:8001"); + } + + #[test] + fn an_ipv6_host_is_bracketed_before_a_port_is_appended() { + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_PUBLIC_HOST", "2001:db8::1"), + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ])); + assert_eq!(c.endpoints().media, "http://[2001:db8::1]:8001"); + } + + #[test] + fn media_tuning_falls_back_the_way_everything_else_does() { + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ("RESCRIPTUM_MEDIA_TIMEOUT_SECS", "0"), + ("RESCRIPTUM_MEDIA_MAX_CONNECTIONS", "plenty"), + ])); + assert_eq!(c.media_timeout, Duration::from_secs(600)); + assert_eq!(c.media_max_connections, 16); + // And the default is deliberately not the answer listener's ten seconds: a + // 1.5 GB transfer is two minutes on 100 Mbit, and it would be killed mid-flight. + assert!(c.media_timeout > c.timeout); + } + // ---- the described surface ------------------------------------------- #[test] diff --git a/src/envfile.rs b/src/envfile.rs index 4762d8f..dda444b 100644 --- a/src/envfile.rs +++ b/src/envfile.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; pub const ENV_FILE: &str = "RESCRIPTUM_ENV_FILE"; /// Every variable this program reads, so a typo can be reported rather than ignored. -pub const KNOWN_KEYS: [&str; 13] = [ +pub const KNOWN_KEYS: [&str; 19] = [ "RESCRIPTUM_STORE", "RESCRIPTUM_ANSWERS_DIR", "RESCRIPTUM_DB_PATH", @@ -42,6 +42,12 @@ pub const KNOWN_KEYS: [&str; 13] = [ "RESCRIPTUM_CAPTURE_DIR", "RESCRIPTUM_LOG", "RESCRIPTUM_LOG_FILE", + "RESCRIPTUM_PUBLIC_HOST", + "RESCRIPTUM_MEDIA_DIR", + "RESCRIPTUM_MEDIA_ADDR", + "RESCRIPTUM_MEDIA_TIMEOUT_SECS", + "RESCRIPTUM_MEDIA_MAX_CONNECTIONS", + "RESCRIPTUM_BOOT_ALLOW", ]; /// A loaded file: the values it set, and anything worth saying about it out loud. diff --git a/src/main.rs b/src/main.rs index 4302675..0c370a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,7 @@ fn main() -> ExitCode { Some((cmd, _)) if cmd == "check" => return cli::check(&cfg), Some((cmd, rest)) if cmd == "import" => return cli::import(&cfg, rest), Some((cmd, rest)) if cmd == "export" => return cli::export(&cfg, rest), + Some((cmd, rest)) if cmd == "media" => return cli::media(&cfg, rest), Some((cmd, _)) => { eprintln!("unknown argument {cmd:?}\n"); eprint!("{}", cli::USAGE); @@ -155,6 +156,53 @@ async fn serve(cfg: Arc) -> ExitCode { tokio::spawn(rescriptum::admin::serve(admin_listener, admin)); } + // The media listener, if a media directory was named. Its own socket, its own + // timeout and its own connection budget — see `boot::media` for why all three are + // forced rather than preferred. + if let Some(dir) = cfg.media_dir.clone() { + let addr = cfg.media_addr(); + let media_listener = match TcpListener::bind(&addr).await { + Ok(l) => l, + Err(e) => { + log::server(&format!("cannot bind the media listener on {addr}: {e}")); + return ExitCode::FAILURE; + } + }; + let bound = media_listener + .local_addr() + .map(|a| a.to_string()) + .unwrap_or(addr); + + // Said out loud because it is the value every generated script is written + // against, and a wrong guess here produces a machine that boots, chains, and + // hangs on an address that does not exist. This log line is the only place the + // answer will ever appear. + let (host, derived) = cfg.public_host(); + if derived { + log::server(&format!( + "warning: RESCRIPTUM_PUBLIC_HOST is not set — derived {host}, which is what \ + every generated URL will name. Multi-homed and NAT hosts get this wrong; \ + set it explicitly if that address is not reachable from the machines." + )); + } + log::server(&format!( + "media listening on {bound} — serving {} as http://{host}", + dir.display() + )); + + let catalog = Arc::new(rescriptum::boot::catalog::Catalog::new(dir.clone())); + // Load it now rather than on the first request, so a broken catalogue is known + // before a machine asks rather than at 3am. + for problem in catalog.problems().unwrap_or_default() { + log::server(&format!("warning: media: {problem}")); + } + let media = Arc::new(rescriptum::boot::media::Media { + cfg: Arc::clone(&cfg), + catalog, + }); + tokio::spawn(rescriptum::boot::media::serve(media_listener, media)); + } + // Report the address actually bound, not the one requested: with `:0` (used by the // integration tests, and handy for debugging) they differ. let bound = listener diff --git a/tests/media.rs b/tests/media.rs new file mode 100644 index 0000000..74ba325 --- /dev/null +++ b/tests/media.rs @@ -0,0 +1,845 @@ +//! The media listener, end to end: the real binary, a real socket, real images. +//! +//! The unit tests stop at the module boundary, and the failures that matter here do +//! not. A range that comes back with the wrong `Content-Range`, a `HEAD` that carries a +//! body, an image download that starves the answer endpoint — none of those is visible +//! from inside a function. +//! +//! **Every abuse case ends by proving the server still answers.** That last assertion is +//! the one that matters: a listener that survives one bad request and then serves +//! nothing has failed the only test a provisioning server has to pass. + +use rescriptum::boot::iso::build; +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// A server with both listeners up, plus the two directories it serves. +struct Server { + child: Child, + answer_addr: String, + media_addr: String, + media_dir: PathBuf, + answers_dir: PathBuf, + log: Arc>>, +} + +impl Server { + fn start(images: &[(&str, Vec)]) -> Server { + Server::start_env(images, &[]) + } + + fn start_env(images: &[(&str, Vec)], env: &[(&str, &str)]) -> Server { + static N: AtomicUsize = AtomicUsize::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let base = + std::env::temp_dir().join(format!("rescriptum-media-{}-{n}", std::process::id())); + let media_dir = base.join("media"); + let answers_dir = base.join("answers"); + fs::create_dir_all(&media_dir).expect("media dir"); + fs::create_dir_all(&answers_dir).expect("answers dir"); + for (name, bytes) in images { + fs::write(media_dir.join(name), bytes).expect("write image"); + } + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rescriptum")); + cmd.env("RESCRIPTUM_LISTEN_ADDR", "127.0.0.1:0") + .env("RESCRIPTUM_ANSWERS_DIR", &answers_dir) + .env("RESCRIPTUM_MEDIA_DIR", &media_dir) + .env("RESCRIPTUM_MEDIA_ADDR", "127.0.0.1:0") + .env("RESCRIPTUM_PUBLIC_HOST", "127.0.0.1") + .env("RESCRIPTUM_TIMEOUT_SECS", "5") + .stderr(Stdio::piped()) + .stdout(Stdio::null()); + for (key, value) in env { + cmd.env(key, value); + } + let mut child = cmd.spawn().expect("spawn server"); + + // Both listeners announce the address they actually bound, so there is no port + // race — and both have to be seen before a test can talk to either. + let stderr = child.stderr.take().expect("piped stderr"); + let mut lines = BufReader::new(stderr).lines(); + let log: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (mut answer_addr, mut media_addr) = (None, None); + for _ in 0..16 { + let Some(Ok(line)) = lines.next() else { break }; + if let Some(rest) = line.split("listening on ").nth(1) { + let bound = rest + .split_whitespace() + .next() + .unwrap_or_default() + .to_string(); + if line.contains("media listening on") { + media_addr = Some(bound); + } else { + answer_addr = Some(bound); + } + } + log.lock().unwrap().push(line); + if answer_addr.is_some() && media_addr.is_some() { + break; + } + } + + let collected = Arc::clone(&log); + std::thread::spawn(move || { + for line in lines.map_while(Result::ok) { + collected.lock().unwrap().push(line); + } + }); + + let seen = || format!("{:#?}", log.lock().unwrap()); + Server { + answer_addr: answer_addr.unwrap_or_else(|| panic!("no answer address; saw {}", seen())), + media_addr: media_addr.unwrap_or_else(|| panic!("no media address; saw {}", seen())), + child, + media_dir, + answers_dir, + log: Arc::clone(&log), + } + } + + fn media_dir(&self) -> &Path { + &self.media_dir + } + + fn startup_log(&self) -> String { + self.log.lock().unwrap().join("\n") + } + + /// A media request, returning the raw bytes: an image is not UTF-8. + fn get(&self, path: &str) -> Vec { + self.get_with(path, &[]) + } + + fn get_with(&self, path: &str, headers: &[(&str, &str)]) -> Vec { + self.request("GET", path, headers) + } + + fn request(&self, method: &str, path: &str, headers: &[(&str, &str)]) -> Vec { + let mut request = format!("{method} {path} HTTP/1.1\r\nHost: boot\r\n"); + for (name, value) in headers { + request.push_str(&format!("{name}: {value}\r\n")); + } + request.push_str("Connection: close\r\n\r\n"); + + let mut sock = TcpStream::connect(&self.media_addr).expect("connect to media"); + sock.set_read_timeout(Some(Duration::from_secs(20))) + .unwrap(); + sock.write_all(request.as_bytes()).expect("write"); + sock.flush().unwrap(); + let mut out = Vec::new(); + sock.read_to_end(&mut out).expect("read"); + out + } + + /// Ask the *answer* endpoint something, to prove it is unaffected. + fn answer(&self, body: &str) -> String { + let mut sock = TcpStream::connect(&self.answer_addr).expect("connect to answers"); + sock.set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + sock.write_all( + format!( + "POST /answer HTTP/1.1\r\nHost: nas\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .expect("write"); + sock.flush().unwrap(); + let mut out = String::new(); + let _ = sock.read_to_string(&mut out); + out + } + + /// Run a subcommand of the same binary against the same configuration. + fn run(&self, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .args(args) + .env("RESCRIPTUM_MEDIA_DIR", &self.media_dir) + .env("RESCRIPTUM_ANSWERS_DIR", &self.answers_dir) + .env("RESCRIPTUM_PUBLIC_HOST", "192.0.2.10") + .env("RESCRIPTUM_MEDIA_ADDR", "0.0.0.0:8001") + .output() + .expect("run subcommand") + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = fs::remove_dir_all(self.media_dir.parent().unwrap_or(&self.media_dir)); + } +} + +// ---- helpers -------------------------------------------------------------- + +fn head_of(response: &[u8]) -> String { + let split = find(response, b"\r\n\r\n").unwrap_or(response.len()); + String::from_utf8_lossy(&response[..split]).to_ascii_lowercase() +} + +fn body_of(response: &[u8]) -> &[u8] { + match find(response, b"\r\n\r\n") { + Some(at) => &response[at + 4..], + None => &[], + } +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +fn status(response: &[u8]) -> String { + String::from_utf8_lossy(&response[..response.len().min(32)]) + .lines() + .next() + .unwrap_or("") + .to_string() +} + +/// A header's value, from a lowercased copy: hyper emits header names lowercased, which +/// is correct, so the assertions test the contract rather than the casing. +fn header(response: &[u8], name: &str) -> Option { + head_of(response) + .lines() + .find_map(|line| line.strip_prefix(&format!("{name}: ")).map(str::to_string)) +} + +fn bzimage() -> Vec { + let mut kernel = vec![0u8; 0x400]; + kernel[0x202..0x206].copy_from_slice(b"HdrS"); + // Something recognisable at the front, so a range test can say where it landed. + kernel[..6].copy_from_slice(b"KERNEL"); + kernel +} + +/// A Proxmox image, complete enough to probe and to boot. +fn pve_image() -> Vec { + build::Builder::new() + .volume("PVE") + .file("/boot/linux26", &bzimage()) + .file("/boot/initrd.img", b"\x1f\x8bINITRD-BYTES") + .file( + "/.disk/info", + b"PRODUCTLONG='Proxmox Virtual Environment'\nRELEASE='8.4'\nISORELEASE='1'\nARCH='amd64'\n", + ) + .build() +} + +fn image_for(family: &str) -> Vec { + let builder = build::Builder::new().volume("TEST"); + match family { + "proxmox" => return pve_image(), + "ubuntu" => builder + .file("/casper/vmlinuz", &bzimage()) + .file("/casper/initrd", b"initrd"), + "debian" => builder + .file("/install.amd/vmlinuz", &bzimage()) + .file("/install.amd/initrd.gz", b"initrd"), + "rhel" => builder + .file("/images/pxeboot/vmlinuz", &bzimage()) + .file("/images/pxeboot/initrd.img", b"initrd"), + "suse" => builder + .file("/boot/x86_64/loader/linux", &bzimage()) + .file("/boot/x86_64/loader/initrd", b"initrd"), + "coreos" => builder + .file("/images/pxeboot/vmlinuz", &bzimage()) + .file("/images/pxeboot/initrd.img", b"initrd") + .file("/images/pxeboot/rootfs.img", b"live root"), + other => panic!("no fixture for {other}"), + } + .build() +} + +// ---- the catalogue -------------------------------------------------------- + +#[test] +fn the_catalogue_lists_what_the_directory_holds() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let r = s.get("/"); + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + + let body = String::from_utf8_lossy(body_of(&r)).to_string(); + assert!(body.contains("pve-8.4"), "{body}"); + assert!(body.contains("proxmox"), "{body}"); + assert!(body.contains("bootable"), "{body}"); +} + +#[test] +fn the_catalogue_answers_json_when_asked_for_it() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let r = s.get_with("/", &[("Accept", "application/json")]); + let body = String::from_utf8_lossy(body_of(&r)).to_string(); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed["media"][0]["id"], "pve-8.4"); + assert_eq!(parsed["media"][0]["family"], "proxmox"); + assert_eq!(parsed["media"][0]["arch"], "x86_64"); + assert_eq!(parsed["media"][0]["bootable"], true); +} + +#[test] +fn an_image_dropped_in_later_appears_without_a_restart() { + // Discovered, not declared — the same guarantee the answers directory gives. + let s = Server::start(&[]); + assert!( + String::from_utf8_lossy(body_of(&s.get("/"))).contains("no images"), + "starts empty" + ); + + fs::write(s.media_dir().join("late.iso"), pve_image()).expect("write"); + std::thread::sleep(Duration::from_millis(1200)); + let body = String::from_utf8_lossy(body_of(&s.get("/"))).to_string(); + assert!(body.contains("late"), "{body}"); +} + +// ---- serving bytes -------------------------------------------------------- + +#[test] +fn an_image_is_served_whole_with_a_validator_and_a_length() { + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let r = s.get("/pve-8.4/iso"); + + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert_eq!(header(&r, "accept-ranges").as_deref(), Some("bytes")); + assert_eq!( + header(&r, "content-length").as_deref(), + Some(image.len().to_string().as_str()) + ); + assert!(header(&r, "etag").is_some(), "{}", head_of(&r)); + assert_eq!(body_of(&r), image.as_slice(), "the bytes must be the image"); +} + +#[test] +fn a_kernel_is_streamed_from_inside_the_image_without_extraction() { + // The property the whole listener rests on: a file in an ISO9660 image is one + // contiguous extent, so this is a seek — nothing is unpacked and nothing is copied. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + + let kernel = s.get("/pve-8.4/kernel"); + assert!( + status(&kernel).starts_with("HTTP/1.1 200"), + "{}", + head_of(&kernel) + ); + assert_eq!(body_of(&kernel), bzimage().as_slice()); + + let initrd = s.get("/pve-8.4/initrd"); + assert_eq!(body_of(&initrd), b"\x1f\x8bINITRD-BYTES"); +} + +#[test] +fn a_file_inside_the_image_is_reachable_by_path() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let r = s.get("/pve-8.4/file/.disk/info"); + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert!( + String::from_utf8_lossy(body_of(&r)).contains("Proxmox"), + "{:?}", + body_of(&r) + ); +} + +#[test] +fn traversal_out_of_the_image_is_refused_and_the_server_still_answers() { + // The guard is structural — an ISO9660 image is its own root, so no such record + // exists — and `..` is refused outright on top of that. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + for path in [ + "/pve-8.4/file/../../../etc/passwd", + "/pve-8.4/file/..%2f..%2fetc/passwd", + "/../etc/passwd", + ] { + let r = s.get(path); + assert!( + !String::from_utf8_lossy(body_of(&r)).contains("root:"), + "{path} leaked something" + ); + } + assert!(status(&s.get("/pve-8.4/kernel")).starts_with("HTTP/1.1 200")); +} + +#[test] +fn initrd_plus_iso_is_synthesised_rather_than_stored() { + // The initrd, then a cpio header naming proxmox.iso, then the image. Old loaders + // cannot do `initrd ` themselves, and building a 1.5 GB file on disk to + // work around that is what this avoids. + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let r = s.get("/pve-8.4/initrd+iso"); + + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + let body = body_of(&r); + assert!( + body.starts_with(b"\x1f\x8bINITRD-BYTES"), + "the initrd comes first" + ); + assert!(find(body, b"070701").is_some(), "a cpio header follows"); + assert!(find(body, b"proxmox.iso\0").is_some(), "naming the image"); + assert!(find(body, b"TRAILER!!!").is_some(), "and the archive ends"); + // The image itself is in there, whole. + assert!( + find(body, &image[32768..32800]).is_some(), + "the image is appended" + ); + + // Nothing resumes an initrd, and the header says so rather than letting a client + // discover it by having a range quietly ignored. + assert_eq!(header(&r, "accept-ranges").as_deref(), Some("none")); + // And the declared length is exact arithmetic, not a guess. + let declared: usize = header(&r, "content-length").unwrap().parse().unwrap(); + assert_eq!(declared, body.len()); +} + +// ---- ranges --------------------------------------------------------------- + +#[test] +fn the_three_range_forms_come_back_as_partial_content() { + // Five of the seven installers range-fetch — casper and anaconda both do — so this + // is not a nicety. + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let total = image.len(); + + for (spec, expected_range, expected_bytes) in [ + ("bytes=0-99", format!("bytes 0-99/{total}"), &image[0..100]), + ( + "bytes=32768-32867", + format!("bytes 32768-32867/{total}"), + &image[32768..32868], + ), + ( + "bytes=-100", + format!("bytes {}-{}/{total}", total - 100, total - 1), + &image[total - 100..], + ), + ] { + let r = s.get_with("/pve-8.4/iso", &[("Range", spec)]); + assert!( + status(&r).starts_with("HTTP/1.1 206"), + "{spec}: {}", + head_of(&r) + ); + assert_eq!( + header(&r, "content-range").as_deref(), + Some(expected_range.as_str()) + ); + assert_eq!(body_of(&r), expected_bytes, "{spec}"); + } + + // `a-` to the end, checked separately because the body is large. + let r = s.get_with("/pve-8.4/iso", &[("Range", "bytes=32768-")]); + assert!(status(&r).starts_with("HTTP/1.1 206"), "{}", head_of(&r)); + assert_eq!(body_of(&r), &image[32768..]); +} + +#[test] +fn a_range_inside_a_file_inside_the_image_lands_in_the_right_place() { + // The offset has to move *within the extent*, which is what makes a resumed kernel + // fetch work at all — and getting it wrong serves plausible bytes from the wrong + // part of the image. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let r = s.get_with("/pve-8.4/kernel", &[("Range", "bytes=0-5")]); + assert!(status(&r).starts_with("HTTP/1.1 206"), "{}", head_of(&r)); + assert_eq!(body_of(&r), b"KERNEL"); + + let r = s.get_with("/pve-8.4/kernel", &[("Range", "bytes=514-517")]); + assert_eq!(body_of(&r), b"HdrS", "0x202 is where the magic lives"); +} + +#[test] +fn a_range_past_the_end_is_416_carrying_the_real_length() { + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let r = s.get_with( + "/pve-8.4/iso", + &[("Range", format!("bytes={}-", image.len()).as_str())], + ); + + assert!(status(&r).starts_with("HTTP/1.1 416"), "{}", head_of(&r)); + assert_eq!( + header(&r, "content-range").as_deref(), + Some(format!("bytes */{}", image.len()).as_str()), + "a 416 has to say how long the entity actually is" + ); +} + +#[test] +fn a_multi_range_request_is_answered_whole() { + // Permitted, and far better than half-implementing multipart/byteranges for a + // client that does not exist. + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let r = s.get_with("/pve-8.4/iso", &[("Range", "bytes=0-99,200-299")]); + + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert_eq!(body_of(&r).len(), image.len()); +} + +#[test] +fn a_resumed_transfer_that_raced_a_replacement_restarts() { + // Splicing two images together produces a file that is neither, and the failure + // surfaces as an install that goes wrong much later. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let etag = header(&s.get("/pve-8.4/iso"), "etag").expect("a validator"); + + // Same validator: the range is honoured. + let r = s.get_with( + "/pve-8.4/iso", + &[("Range", "bytes=0-99"), ("If-Range", &etag)], + ); + assert!(status(&r).starts_with("HTTP/1.1 206"), "{}", head_of(&r)); + + // A stale one: the whole entity comes back instead. + let r = s.get_with( + "/pve-8.4/iso", + &[("Range", "bytes=0-99"), ("If-Range", "\"something-else\"")], + ); + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert!(body_of(&r).len() > 100, "the whole entity, not the range"); +} + +#[test] +fn head_is_answered_with_the_headers_and_no_body() { + // UEFI HTTP Boot asks before it fetches, and a `HEAD` that carries a body is a + // protocol violation the firmware reads as a broken server. + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let r = s.request("HEAD", "/pve-8.4/iso", &[]); + + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert_eq!( + header(&r, "content-length").as_deref(), + Some(image.len().to_string().as_str()), + "the length is the entity's, not the body's" + ); + assert!(body_of(&r).is_empty(), "a HEAD carries no body"); +} + +// ---- refusals, and surviving them ----------------------------------------- + +#[test] +fn an_unknown_id_is_404_and_the_server_still_answers() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + for path in ["/nope/iso", "/pve-8.4/nonsense", "/pve-8.4"] { + let r = s.get(path); + assert!( + status(&r).starts_with("HTTP/1.1 404"), + "{path}: {}", + head_of(&r) + ); + } + assert!(status(&s.get("/pve-8.4/iso")).starts_with("HTTP/1.1 200")); +} + +#[test] +fn an_image_with_no_kernel_says_so_rather_than_serving_nothing() { + let s = Server::start(&[("mystery.iso", vec![0u8; 128 * 1024])]); + let r = s.get("/mystery/kernel"); + assert!(status(&r).starts_with("HTTP/1.1 404"), "{}", head_of(&r)); + assert!( + String::from_utf8_lossy(body_of(&r)).contains("no kernel"), + "{:?}", + String::from_utf8_lossy(body_of(&r)) + ); + // But the image itself is still servable: not describable is not the same as not + // usable — `sanboot` takes one, and so does a USB stick. + assert!(status(&s.get("/mystery/iso")).starts_with("HTTP/1.1 200")); +} + +#[test] +fn writing_is_refused_because_the_listener_is_read_only() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + for method in ["PUT", "DELETE", "POST"] { + let r = s.request(method, "/pve-8.4/iso", &[]); + assert!( + status(&r).starts_with("HTTP/1.1 405"), + "{method}: {}", + head_of(&r) + ); + assert!(head_of(&r).contains("allow: get, head"), "{}", head_of(&r)); + } + assert!(status(&s.get("/pve-8.4/iso")).starts_with("HTTP/1.1 200")); +} + +#[test] +fn a_truncated_download_does_not_stop_the_server() { + // The client hangs up mid-transfer, which is what a rebooting machine does. The + // blocking reader has to notice and stop rather than finishing an image nobody is + // receiving. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + { + let mut sock = TcpStream::connect(&s.media_addr).expect("connect"); + sock.write_all(b"GET /pve-8.4/iso HTTP/1.1\r\nHost: boot\r\nConnection: close\r\n\r\n") + .expect("write"); + sock.flush().unwrap(); + let mut first = [0u8; 64]; + let _ = sock.read(&mut first); + // Dropped here, mid-transfer. + } + + assert!( + status(&s.get("/pve-8.4/iso")).starts_with("HTTP/1.1 200"), + "the server still answers" + ); +} + +#[test] +fn an_allowlist_refuses_a_peer_outside_it() { + // Boot traffic is unauthenticated by necessity — a PXE ROM has no credentials — so + // this is the only control that can say "not you". + let s = Server::start_env( + &[("pve-8.4.iso", pve_image())], + &[("RESCRIPTUM_BOOT_ALLOW", "10.99.0.0/16")], + ); + let r = s.get("/pve-8.4/iso"); + assert!(status(&r).starts_with("HTTP/1.1 403"), "{}", head_of(&r)); + + let s = Server::start_env( + &[("pve-8.4.iso", pve_image())], + &[("RESCRIPTUM_BOOT_ALLOW", "127.0.0.0/8, 10.0.0.0/8")], + ); + assert!(status(&s.get("/pve-8.4/iso")).starts_with("HTTP/1.1 200")); +} + +// ---- the assertion that matters ------------------------------------------- + +#[test] +fn image_downloads_never_starve_the_answer_endpoint() { + // **This is the one that could veto the design.** A download holds its permit for + // minutes; if answers shared that budget, a rollout would starve its own installs. + // Two listeners, two budgets — and this is what proves it rather than hoping. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + fs::write( + s.answers_dir.join("default.toml"), + "[global]\nkeyboard = \"fr\"\n", + ) + .expect("write answer"); + std::thread::sleep(Duration::from_millis(1200)); + + // Four transfers in flight, each deliberately left unread so it stays open. + let mut holding = Vec::new(); + for _ in 0..4 { + let mut sock = TcpStream::connect(&s.media_addr).expect("connect"); + sock.write_all(b"GET /pve-8.4/iso HTTP/1.1\r\nHost: boot\r\nConnection: close\r\n\r\n") + .expect("write"); + sock.flush().unwrap(); + holding.push(sock); + } + + let answer = s.answer(r#"{"mac":"98:fa:9b:50:d8:10"}"#); + assert!( + answer.starts_with("HTTP/1.1 200"), + "answers must keep working while media transfers run: {answer}" + ); + assert!(answer.contains("\"fr\""), "{answer}"); + + drop(holding); + assert!(status(&s.get("/pve-8.4/iso")).starts_with("HTTP/1.1 200")); +} + +#[test] +fn the_media_listener_has_its_own_health_and_its_own_socket() { + let s = Server::start(&[]); + assert!(status(&s.get("/health")).starts_with("HTTP/1.1 200")); + assert_ne!(s.media_addr, s.answer_addr, "never the same socket"); +} + +// ---- the stanzas, per family ---------------------------------------------- + +#[test] +fn every_family_gets_its_own_boot_stanza() { + // **A stanza proven for one family claims six**, the way `tests/stores.rs` asserts + // per store. Proxmox is the founding case and the odd one out — a parameter that + // selects the automated path, and the image carried as a second initrd, where every + // other family takes a URL on the command line. + let cases: &[(&str, &str)] = &[ + ("proxmox", "proxmox-start-auto-installer"), + ("debian", "preseed/url=http://192.0.2.10:8000/debian"), + ("ubuntu", "ds=nocloud-net;s=http://192.0.2.10:8000/ubuntu/"), + ("rhel", "inst.ks=http://192.0.2.10:8000/rhel"), + ("suse", "autoyast=http://192.0.2.10:8000/suse"), + ( + "coreos", + "ignition.config.url=http://192.0.2.10:8000/coreos", + ), + ]; + + let images: Vec<(&str, Vec)> = cases + .iter() + .map(|(family, _)| (*family, image_for(family))) + .collect(); + let named: Vec<(&str, Vec)> = images + .iter() + .map(|(family, bytes)| { + ( + match *family { + "proxmox" => "proxmox.iso", + "debian" => "debian.iso", + "ubuntu" => "ubuntu.iso", + "rhel" => "rhel.iso", + "suse" => "suse.iso", + _ => "coreos.iso", + }, + bytes.clone(), + ) + }) + .collect(); + let s = Server::start(&named); + + for (family, needle) in cases { + let out = s.run(&["media", "ipxe", family]); + assert!( + out.status.success(), + "{family}: {:?}", + String::from_utf8_lossy(&out.stderr) + ); + let script = String::from_utf8_lossy(&out.stdout); + assert!(script.starts_with("#!ipxe\n"), "{family}: {script}"); + assert!(script.contains(needle), "{family}: {script}"); + // stdout is the script and stderr is everything else, so `> answer.ipxe` works. + assert!(!script.contains("# warning"), "{family}: {script}"); + // Every stanza names the media listener's own port, never the answer one's. + assert!( + script.contains("http://192.0.2.10:8001/"), + "{family}: {script}" + ); + } +} + +#[test] +fn a_generated_stanza_is_an_ordinary_answer_document() { + // The altitude that keeps the model intact: `media ipxe` prints a script, it does + // not install one. Saved into the answers directory it is selected, layered and + // templated like anything else — which is what this proves by serving it. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let out = s.run(&["media", "ipxe", "pve-8.4"]); + assert!(out.status.success()); + + fs::write( + s.answers_dir.join("98-fa-9b-50-d8-10.ipxe"), + String::from_utf8_lossy(&out.stdout).as_ref(), + ) + .expect("save the generated answer"); + std::thread::sleep(Duration::from_millis(1200)); + + let served = s.answer(r#"{"mac":"98:fa:9b:50:d8:10"}"#); + assert!(served.starts_with("HTTP/1.1 200"), "{served}"); + assert!(served.contains("proxmox-start-auto-installer"), "{served}"); + assert!( + served.contains("initrd http://192.0.2.10:8001/pve-8.4/iso proxmox.iso"), + "{served}" + ); +} + +// ---- the CLI half --------------------------------------------------------- + +#[test] +fn media_add_records_a_digest_and_media_check_re_verifies_it() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let image = s.media_dir().join("pve-8.4.iso"); + + let out = s.run(&["media", "add", image.to_str().unwrap()]); + assert!( + out.status.success(), + "{:?}", + String::from_utf8_lossy(&out.stderr) + ); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(printed.contains("pve-8.4"), "{printed}"); + assert!(printed.contains("proxmox"), "{printed}"); + assert!( + s.media_dir().join("pve-8.4.media").is_file(), + "a sidecar is written" + ); + + let out = s.run(&["media", "check"]); + assert!( + out.status.success(), + "{:?}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + String::from_utf8_lossy(&out.stdout).contains("1 verified"), + "{}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn media_check_fails_when_an_image_changed_under_its_digest() { + // The one failure that silently installs something nobody reviewed. Its exit code + // is a contract, like `check`'s — `deploy.sh` keys on it. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let image = s.media_dir().join("pve-8.4.iso"); + assert!( + s.run(&["media", "add", image.to_str().unwrap()]) + .status + .success() + ); + + let mut changed = pve_image(); + let last = changed.len() - 1; + changed[last] ^= 0xff; + fs::write(&image, changed).expect("replace the image"); + + let out = s.run(&["media", "check"]); + assert!(!out.status.success(), "a drifted image must fail the check"); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(printed.contains("no longer matches"), "{printed}"); +} + +#[test] +fn media_add_refuses_a_digest_that_does_not_match() { + // A mismatch is a truncated download or the wrong file, and both install the wrong + // thing on every machine that asks. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let image = s.media_dir().join("pve-8.4.iso"); + + let out = s.run(&[ + "media", + "add", + image.to_str().unwrap(), + "--sha256", + &"a".repeat(64), + ]); + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("digest mismatch"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !s.media_dir().join("pve-8.4.media").exists(), + "nothing may be recorded when the digest is wrong" + ); +} + +#[test] +fn media_list_shows_what_the_catalogue_holds() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let out = s.run(&["media", "list"]); + assert!(out.status.success()); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(printed.contains("pve-8.4"), "{printed}"); + assert!(printed.contains("proxmox"), "{printed}"); + assert!(printed.contains("x86_64"), "{printed}"); +} + +#[test] +fn a_derived_public_host_is_announced_loudly() { + // A wrong guess here produces a machine that boots, chains, and hangs on an address + // that does not exist — and this log line is the only place the answer appears. + let s = Server::start_env(&[], &[("RESCRIPTUM_PUBLIC_HOST", "")]); + let log = s.startup_log(); + assert!(log.contains("RESCRIPTUM_PUBLIC_HOST is not set"), "{log}"); + assert!(log.contains("derived"), "{log}"); +} From 44409a25a4efd3ae84952980cacf4638502b98e6 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 13:01:15 +0200 Subject: [PATCH 03/59] feat(boot): a `boot` feature, so the smallest build stays the smallest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan asks for one feature covering media and TFTP, default on, so `--no-default-features` still produces the smallest possible answer server. It refuses loudly rather than ignoring a media directory it cannot serve — the same shape `open_store` already uses for RESCRIPTUM_STORE=sqlite without the sqlite feature. Measured on armv7 (gnueabihf, floor 2.17), which is the target the budget is written against: sqlite + boot 2,602,056 sqlite only 2,482,000 neither 1,316,648 So boot costs 120,056 bytes. Two things worth recording: that is 71% of the plan's ≤170 KB budget spent on Phase 1 alone, and the figures in CLAUDE.md (2,103,456 / 944,928) are stale — they predate the switch from musl to glibc, and taking them at face value made this look like a 293% overrun. Measure before concluding. Also fixes a silent hole in the self dev-dependency: without `default-features = false` it re-enabled sqlite and boot for every test build, so `cargo test --no-default-features` would have tested the full binary and reported coverage that does not exist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- Cargo.toml | 15 +++++++++++++-- src/cli.rs | 14 ++++++++++++++ src/config.rs | 19 +++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 1 + tests/media.rs | 4 ++++ 6 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 26be9f0..888a611 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,10 +27,16 @@ tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "time", "io- toml_edit = "0.25.13" [features] -default = ["sqlite"] +default = ["sqlite", "boot"] # SQLite backs the admin API. Turn it off for the smallest possible binary when the # answers directory is all you need: `cargo build --no-default-features`. sqlite = ["dep:rusqlite"] +# Boot media: the catalogue, the ISO reader and the media listener. On by default, +# because an appliance that cannot serve the installer is half a product — but off in +# one flag, so `--no-default-features` still produces the smallest possible answer +# server. One feature and not two: media and TFTP share the catalogue and the address +# logic, and splitting them would buy kilobytes at the cost of a seam. +boot = [] # Compiles `boot::iso::build`, which writes ISO9660 images in memory for tests. There is # deliberately **no binary ISO fixture in this repository**: a checked-in image is a blob # nobody can review and nobody can vary. The builder is the alternative, and it has no @@ -48,7 +54,12 @@ strip = true rusqlite = { version = "0.40.2", features = ["bundled"] } # The crate itself, so an integration test can build an ISO to serve. This is what turns # `test-support` on for a test build and leaves it off for every other one. -rescriptum = { path = ".", features = ["test-support"] } +# +# `default-features = false` is load-bearing: without it this line re-enables `sqlite` +# and `boot` for every test build, so `cargo test --no-default-features` would quietly +# test the full binary and report coverage that does not exist. Features are a union, so +# an ordinary `cargo test` still gets the defaults from the normal build. +rescriptum = { path = ".", default-features = false, features = ["test-support"] } # `panic = "abort"` is deliberately ABSENT. With one thread per connection, unwinding # means a panic kills only the connection that caused it; aborting would take down the diff --git a/src/cli.rs b/src/cli.rs index ee2b44e..e84baff 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -431,6 +431,13 @@ fn copy( /// keeps the server honest is that no request ever triggers work proportional to the /// size of an image; hashing 1.5 GB happens here, once, and the result is recorded /// beside the image so nothing ever recomputes it. +#[cfg(not(feature = "boot"))] +pub fn media(_cfg: &Config, _args: &[String]) -> ExitCode { + eprintln!("this binary was built without the `boot` feature, so it has no media commands"); + ExitCode::FAILURE +} + +#[cfg(feature = "boot")] pub fn media(cfg: &Config, args: &[String]) -> ExitCode { let Some(dir) = &cfg.media_dir else { eprintln!("there is no media directory: RESCRIPTUM_MEDIA_DIR names one, and nothing does"); @@ -457,6 +464,7 @@ pub fn media(cfg: &Config, args: &[String]) -> ExitCode { } } +#[cfg(feature = "boot")] fn media_list(catalog: &crate::boot::catalog::Catalog) -> ExitCode { let listing = match catalog.listing() { Ok(listing) => listing, @@ -493,6 +501,7 @@ fn media_list(catalog: &crate::boot::catalog::Catalog) -> ExitCode { ExitCode::SUCCESS } +#[cfg(feature = "boot")] fn media_add(catalog: &crate::boot::catalog::Catalog, args: &[String]) -> ExitCode { let mut path: Option<&String> = None; let mut expected: Option<&String> = None; @@ -647,6 +656,7 @@ fn media_add(catalog: &crate::boot::catalog::Catalog, args: &[String]) -> ExitCo /// `media check` — re-verify what was recorded. Its exit code is a contract, like /// `check`'s: `deploy.sh` keys on it. +#[cfg(feature = "boot")] fn media_check(catalog: &crate::boot::catalog::Catalog) -> ExitCode { let listing = match catalog.listing() { Ok(listing) => listing, @@ -720,6 +730,7 @@ fn media_check(catalog: &crate::boot::catalog::Catalog) -> ExitCode { /// booting, it gains a generator. /// /// stdout is the script and stderr is everything else, so `media ipxe … > file` works. +#[cfg(feature = "boot")] fn media_ipxe(cfg: &Config, catalog: &crate::boot::catalog::Catalog, id: &str) -> ExitCode { let entry = match catalog.get(id) { Ok(Some(entry)) => entry, @@ -759,6 +770,7 @@ fn media_ipxe(cfg: &Config, catalog: &crate::boot::catalog::Catalog, id: &str) - /// Whether two paths name the same directory, resolving symlinks where it can. A media /// directory reached as `/srv/media` and as `./media` is the same directory. +#[cfg(feature = "boot")] fn same_directory(a: &std::path::Path, b: &std::path::Path) -> bool { match (a.canonicalize(), b.canonicalize()) { (Ok(a), Ok(b)) => a == b, @@ -766,6 +778,7 @@ fn same_directory(a: &std::path::Path, b: &std::path::Path) -> bool { } } +#[cfg(feature = "boot")] fn human(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"]; let mut size = bytes as f64; @@ -781,6 +794,7 @@ fn human(bytes: u64) -> String { } } +#[cfg(feature = "boot")] fn truncate(text: &str, width: usize) -> String { if text.chars().count() <= width { return text.to_string(); diff --git a/src/config.rs b/src/config.rs index 60adc4b..66c7408 100644 --- a/src/config.rs +++ b/src/config.rs @@ -271,6 +271,19 @@ impl Config { /// what would not work or would not be safe, and warn about everything that can be /// fixed while the server runs. fn validate_media(&self) -> Result<(), String> { + // A binary built without the feature must say so rather than ignoring the + // directory it was pointed at. Same shape as `open_store` refusing + // `RESCRIPTUM_STORE=sqlite` without the `sqlite` feature: the variable stays + // described everywhere, and only the binary that cannot honour it objects. + #[cfg(not(feature = "boot"))] + if self.media_dir.is_some() { + return Err( + "RESCRIPTUM_MEDIA_DIR is set, but this binary was built without the `boot` \ + feature, so it can serve no media." + .to_string(), + ); + } + // A host, never a URL. One port in the value would silently pin every generated // script to one listener, and the symptom is a machine chaining into nowhere. if let Some(host) = &self.public_host { @@ -354,6 +367,7 @@ impl Config { } /// The two URLs a generated script needs, each with its own listener's port. + #[cfg(feature = "boot")] pub fn endpoints(&self) -> crate::boot::stanza::Endpoints { let (host, _) = self.public_host(); crate::boot::stanza::Endpoints { @@ -417,6 +431,9 @@ fn ephemeral(addr: &str) -> bool { /// A reachable host plus the port of a listen address, ready to go into a URL. /// +/// Only `endpoints` calls this, and only a binary that can serve media has one. +#[cfg(feature = "boot")] +/// /// The listen address is usually `0.0.0.0:8001`, which is not something anybody can /// fetch from — the port is the only part of it worth keeping. fn join(host: &str, listen_addr: &str) -> String { @@ -978,6 +995,7 @@ mod tests { } #[test] + #[cfg(feature = "boot")] fn each_generated_url_carries_its_own_listeners_port() { // The whole reason the variable is a host: one value, two listeners. let c = Config::from_lookup(lookup(&[ @@ -990,6 +1008,7 @@ mod tests { } #[test] + #[cfg(feature = "boot")] fn an_ipv6_host_is_bracketed_before_a_port_is_appended() { let c = Config::from_lookup(lookup(&[ ("RESCRIPTUM_PUBLIC_HOST", "2001:db8::1"), diff --git a/src/lib.rs b/src/lib.rs index a696904..19c3173 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! for the design constraints. pub mod admin; +#[cfg(feature = "boot")] pub mod boot; pub mod capture; pub mod cli; diff --git a/src/main.rs b/src/main.rs index 0c370a1..1bfabaf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -159,6 +159,7 @@ async fn serve(cfg: Arc) -> ExitCode { // The media listener, if a media directory was named. Its own socket, its own // timeout and its own connection budget — see `boot::media` for why all three are // forced rather than preferred. + #[cfg(feature = "boot")] if let Some(dir) = cfg.media_dir.clone() { let addr = cfg.media_addr(); let media_listener = match TcpListener::bind(&addr).await { diff --git a/tests/media.rs b/tests/media.rs index 74ba325..c5da7c2 100644 --- a/tests/media.rs +++ b/tests/media.rs @@ -9,6 +9,10 @@ //! the one that matters: a listener that survives one bad request and then serves //! nothing has failed the only test a provisioning server has to pass. +// There is nothing here to test in a binary built without the feature, and compiling to +// nothing is a clearer answer than a wall of unresolved imports. +#![cfg(feature = "boot")] + use rescriptum::boot::iso::build; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; From df2a6b9f3fe48ab812e6c25163beaad0b00cb9cc Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 13:04:15 +0200 Subject: [PATCH 04/59] docs: record what Phase 1 taught, and re-measure what had gone stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's own rule is that a gate must not be exceeded silently, so the budget finding goes in the plan rather than in a commit nobody re-reads: `boot` costs 120,056 bytes on armv7, which is 71% of the ≤170 KB allowance for Phase 1 alone. Phases 2 and 4 will not fit in what is left. Re-decide the figure or split the feature; do not drift past it. Three things the plan had wrong or open, now settled from sources: - The trimmed-Proxmox marker needed no bench. `inspect-iso` identifies an ISO by `/.disk/info` and PRODUCTLONG, and `--pxe` does not strip it. - Proxmox does need something on the kernel command line after all — `proxmox-start-auto-installer`. Without it the machine boots the interactive installer. - `;` separates iPXE commands only as a whole whitespace-delimited token, so Ubuntu's NoCloud argument needs no escaping. Read out of `core/exec.c`. CLAUDE.md gains the boot layout, the six new variables, the real sizes and four traps that each cost a red test — including that `HeaderName::from_static` panics on a name that is not lower-case, and that the size figures in that file go stale when a target changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- CLAUDE.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6eba935..3a33b41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,16 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit unit test twice, and lets the two copies drift. - `src/store/` — where documents come from. `mod.rs` defines the thin `Store` / `StoreWrite` traits, `file.rs` a flat directory of documents, `sqlite.rs` a bundled-SQLite database. +- `src/boot/` — **boot media**: where the installer itself comes from, as opposed to what + it is told. `iso.rs` reads ISO9660 far enough to turn a path into an offset and a + length (a file in an image is one contiguous extent, so serving a kernel is a *seek*, + never an extraction); `probe.rs` places an image from a table of markers; `catalog.rs` + discovers what is held, cached behind the directory mtime like the answer listing; + `media.rs` is the listener, on its own socket; `stanza.rs` holds what each installer + family needs on the wire; `cpio.rs` and `sha256.rs` are hand-written and dependency-free. + Behind the `boot` cargo feature, default on. `select.rs` knows none of this exists, and + the only seam is that `media ipxe` **prints an ordinary `.ipxe` answer document** — + selection, layering and templating then apply unchanged. - `src/facts.rs` — what a request says about the machine: query parameters, a flattened JSON body, and the raw haystack. - `src/format/` — one interface per document format. `xml.rs` holds the XML tree and its @@ -339,8 +349,18 @@ Both are write-capable (`StoreWrite`), which is what the admin API will use: `import ` and `export ` move between the two. The round trip is byte-identical, which is worth keeping true — it is what makes the database safe to adopt and safe to leave. -The `sqlite` cargo feature is on by default and can be turned off: 2,103,456 bytes with it, -944,928 without, on armv7. +Two cargo features, both on by default and both removable: `sqlite` and `boot` (the media +catalogue, the ISO reader and the media listener). Measured on armv7-gnueabihf, floor 2.17 +— **re-measure rather than trusting an older figure here: the numbers moved by ~375 KB +when the target changed from musl to glibc, and a stale baseline once turned a 71% budget +spend into an apparent 293% overrun.** + +| Build | Bytes | +|---|---| +| `sqlite` + `boot` (default) | 2,602,056 | +| `sqlite` only | 2,482,000 | +| `boot` only | 1,436,704 | +| neither | 1,316,648 | ## The admin API @@ -457,6 +477,23 @@ could not check. Note it needs `Resolution::format_name` (the extension), not model: they are that machine's answers for two operating systems. - **Assert on parsed values, not on formatting.** Replacing a table with a scalar leaves the key's original decor, so the output can read `value= 3` — valid TOML, different text. +- **`HeaderName::from_static` panics on a name that is not lower-case.** It compiles. + At runtime it kills the connection before anything is written, so the symptom is an + *empty response*, not an error. Put the header in the response builder, which takes any + casing. +- **A guard against two listeners sharing a port must exempt `:0`.** Port zero asks the + kernel for any free port, so two of them never collide — and it is what every + integration test uses. +- **A self dev-dependency re-enables default features unless told not to.** Without + `default-features = false`, `rescriptum = { path = "." , features = [...] }` turns + `sqlite` and `boot` back on for every test build, so `--no-default-features` tests the + full binary and reports coverage that does not exist. +- **`;` in an iPXE script separates commands only as a whole whitespace-delimited token** + (`split_command` in iPXE's `core/exec.c`). So `ds=nocloud-net;s=http://…` is one + argument and must **not** be escaped, while `foo ; bar` is two commands. +- **The size figures in this file go stale.** They moved ~375 KB when armv7 changed from + musl to glibc. Re-measure before concluding anything from them; a stale baseline once + turned a 71% budget spend into an apparent 293% overrun. ## Core algorithm (the part worth understanding up front) @@ -527,6 +564,12 @@ Environment variables only — plus an optional file to read some of them from: | `RESCRIPTUM_TIMEOUT_SECS` | `10` | Header-read timeout **and** whole-connection deadline | | `RESCRIPTUM_LOG` | `all` | `all` \| `problems` (drops the requests that worked) \| `off` | | `RESCRIPTUM_LOG_FILE` | unset | A file to append to, or `stdout`/`stderr`. Unopenable is fatal | +| `RESCRIPTUM_MEDIA_DIR` | unset | Installer images. **Unset is the whole off switch for boot media** | +| `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | The media listener, when there is a media directory | +| `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Whole-transfer deadline — deliberately not the answer listener's 10 | +| `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Concurrent transfers; low on purpose | +| `RESCRIPTUM_PUBLIC_HOST` | derived, with a warning | The host generated URLs name. **A host, never a URL** | +| `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media | A zero or unparseable numeric value falls back to the default rather than starting a server that accepts and never answers. @@ -567,6 +610,10 @@ cargo build cargo run -- check # validate an answers directory cargo run -- config # show the configuration and where each value comes from cargo run -- render # print one machine's composed answer +cargo run -- media list # the installer images held +cargo run -- media add FILE # register one: verify, probe, record its digest +cargo run -- media check # re-verify every recorded digest +cargo run -- media ipxe ID # the .ipxe answer that boots one image cargo test # all tests cargo test # single test by name substring cargo test -- --nocapture # show stdout from tests @@ -779,10 +826,17 @@ the image and not derived from `DISK_SIZE`. `run-vm.sh` is the loader-image fall ## Testing expectations -333 tests, plus the package's own harnesses (see *The DSM package*, and note that +426 tests, plus the package's own harnesses (see *The DSM package*, and note that `cargo test` does not run those). `docs/development/testing.md` has the per-suite table; the rules that decide where a test goes: +- **Boot media belongs in `tests/media.rs`**, against the real binary with both listeners + up. Every abuse case there ends by proving the server still answers, and one case proves + the property the separate socket exists for: **answers keep succeeding while four image + transfers are in flight**. There is deliberately **no binary ISO fixture in this + repository** — `boot::iso::build` writes images in memory, behind the `test-support` + feature so it never reaches a release binary. + - **A behaviour belongs in `tests/stores.rs`**, which runs it against both stores and requires the identical outcome. One that covers a single backend proves half of what it claims, and the half it skips is where a divergence hides. From fb06170fc60b427f2140437fb95bd474bdbd96ff Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 13:09:17 +0200 Subject: [PATCH 05/59] docs(guide): serving boot media, in both languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user-visible change lands with its documentation, so Phase 1 gets its page: getting an image in, what the probe can tell about it, the endpoints, why the listener is a second socket, generating a boot stanza, and why RESCRIPTUM_PUBLIC_HOST is a host and never a URL. The scope statement in the guide index needed correcting rather than extending. "Not a PXE/TFTP/DHCP server" was one claim doing three jobs, and one of them has stopped being true: DHCP stays a non-goal in any form, TFTP is not here *yet*, and the installer's kernel, initrd and image are served now. Saying so in one bullet would have been vague where it used to be exact. Configuration and CLI references gain the six variables and the four commands; the compile-time table gains `boot` and real ARMv7 figures, with the note that the old ones were stale. `notabene lint` is green — and note it lints against the last public build, so `docs:build` has to run first or a new page reads as a broken link. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- docs/guide/index.fr.md | 9 +- docs/guide/index.md | 8 +- docs/guide/operations/index.fr.md | 1 + docs/guide/operations/index.md | 1 + docs/guide/operations/media.fr.md | 248 +++++++++++++++++++++++ docs/guide/operations/media.md | 242 ++++++++++++++++++++++ docs/guide/reference/cli.fr.md | 25 +++ docs/guide/reference/cli.md | 24 +++ docs/guide/reference/configuration.fr.md | 24 ++- docs/guide/reference/configuration.md | 24 ++- 10 files changed, 600 insertions(+), 6 deletions(-) create mode 100644 docs/guide/operations/media.fr.md create mode 100644 docs/guide/operations/media.md diff --git a/docs/guide/index.fr.md b/docs/guide/index.fr.md index 33ee13f..adf9b30 100644 --- a/docs/guide/index.fr.md +++ b/docs/guide/index.fr.md @@ -90,8 +90,13 @@ validateur de l'installateur lui-même quand il est dans le PATH. ## Ce que ce n'est pas -- **Pas un serveur PXE/TFTP/DHCP.** Il répond à une seule question — *quelle configuration - reçoit cette machine ?* — et laisse le netboot à ce que vous faites déjà tourner. +- **Pas un serveur DHCP, sous aucune forme.** Ni répondeur, ni proxy, ni derrière un + drapeau. Les sites qui déploient ceci en ont déjà un, et le faire pointer vers un + serveur de démarrage est un problème résolu depuis trente ans. +- **Pas un serveur TFTP** — pas encore. Il sait servir le noyau, l'initrd et l'image de + l'installeur en HTTP (voir [Servir les médias de démarrage](./operations/media.md)), + ce dont se sert chaque étape après la première. Livrer le *chargeur* reste l'affaire de + ce que vous faites déjà tourner. - **Pas un validateur de schéma.** Il prouve que vos documents sont bien formés et fusionnent proprement. Savoir si le résultat est du *Proxmox* valide est le travail de `proxmox-auto-install-assistant`, et `check` l'appellera s'il est installé. diff --git a/docs/guide/index.md b/docs/guide/index.md index 3ce8fe2..658f05a 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -82,8 +82,12 @@ one is on PATH. ## What it is not -- **Not a PXE/TFTP/DHCP server.** It answers one question — *what configuration does this - machine get?* — and leaves netbooting to whatever you already run. +- **Not a DHCP server, in any form.** Not a responder, not a proxy, not behind a flag. + Sites that deploy this already run one, and pointing it at a boot server is a solved + problem with thirty years of tooling. +- **Not a TFTP server** — not yet. It can serve the installer's kernel, initrd and image + over HTTP (see [Serving boot media](./operations/media.md)), which is what every stage + after the first one uses. Handing over the *loader* is still whatever you already run. - **Not a schema validator.** It proves your documents are well-formed and merge cleanly. Whether the result is valid *Proxmox* is `proxmox-auto-install-assistant`'s job, and `check` will call it when it is installed. diff --git a/docs/guide/operations/index.fr.md b/docs/guide/operations/index.fr.md index b2c057d..4607f02 100644 --- a/docs/guide/operations/index.fr.md +++ b/docs/guide/operations/index.fr.md @@ -25,6 +25,7 @@ le droit de servir, et à qui. - **[L'API d'administration](./admin-api.md)** — gérer les réponses en HTTP, sur son propre listener, avec une écriture qui ne peut pas casser le parc. - **[Dépannage](./troubleshooting.md)** — la ligne de log est tout le diagnostic disponible. +- [Servir les médias de démarrage](./media.md) — le noyau, l'initrd et l'image de l'installeur, depuis le même serveur. ## La forme d'un déploiement diff --git a/docs/guide/operations/index.md b/docs/guide/operations/index.md index f0e378b..bf88d31 100644 --- a/docs/guide/operations/index.md +++ b/docs/guide/operations/index.md @@ -25,6 +25,7 @@ it is allowed to serve and to whom. - **[The admin API](./admin-api.md)** — manage answers over HTTP, on its own listener, with a write that cannot break the fleet. - **[Troubleshooting](./troubleshooting.md)** — the log line is the whole diagnostic +- [Serving boot media](./media.md) — the installer's own kernel, initrd and image, from the same server. story. ## The shape of a deployment diff --git a/docs/guide/operations/media.fr.md b/docs/guide/operations/media.fr.md new file mode 100644 index 0000000..f826bb4 --- /dev/null +++ b/docs/guide/operations/media.fr.md @@ -0,0 +1,248 @@ +--- +title: Servir les médias de démarrage +description: Servir l'installeur lui-même — noyau, initrd et image — depuis le serveur qui décide déjà la réponse, sur son propre listener. +sidebar: + label: Médias de démarrage + order: 8 +--- + +# Servir les médias de démarrage + +Une réponse dit à une machine *comment* s'installer. Elle ne dit rien de l'endroit d'où +vient l'installeur — et jusqu'ici c'était le serveur web de quelqu'un d'autre, hébergeant +des images que personne ne confrontait aux réponses écrites pour elles. + +Avec un répertoire de médias, le même serveur fait les deux. **La MAC d'une machine +choisit sa réponse *et* l'image pour laquelle cette réponse a été écrite, et les deux ne +peuvent plus diverger puisqu'un seul composant décide des deux.** + +```console +$ export RESCRIPTUM_MEDIA_DIR=/srv/media +``` + +Non défini, tout est éteint. Rien ne change pour un déploiement existant tant que vous ne +la définissez pas. + +## Faire entrer une image + +Le serveur ne télécharge jamais d'image ; il la reçoit. Posez le fichier là où est le +répertoire — en SMB, en `scp`, depuis là où l'ISO se trouve déjà — puis enregistrez-le : + +```console +$ rescriptum media add /srv/media/pve-8.4.iso --sha256 9f86d081884c7d65… +hashing /srv/media/pve-8.4.iso … + 10% (152.0M of 1.5G) + … +pve-8.4 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 + proxmox Proxmox Virtual Environment 8.4-1 + kernel /boot/linux26 + initrd /boot/initrd.img + wrote /srv/media/pve-8.4.media +``` + +`--sha256` est facultatif et mérite d'être fourni : une empreinte qui ne correspond pas, +c'est soit un téléchargement tronqué soit le mauvais fichier, et les deux installeraient +la mauvaise chose sur chaque machine qui demande. Rien n'est enregistré en cas d'écart. + +**Rien n'est copié et l'image n'est jamais modifiée.** Ce que `media add` écrit, c'est le +fichier compagnon `.media` posé à côté, qui retient l'empreinte et ce que la détection a +trouvé. C'est tout l'intérêt : hacher 1,5 Go prend près d'une minute, et le serveur ne +doit jamais passer une minute *dans* une requête. + +Une image sans compagnon apparaît quand même et est servie quand même — elle n'a +simplement pas d'empreinte à revérifier, et elle est analysée à la volée. + +## Ce qu'il sait dire d'une image + +```console +$ rescriptum media list +ID FAMILY ARCH VERSION SIZE PINNED +pve-8.4 proxmox x86_64 Proxmox Virtual Environment… 1.5G 9f86d0818 +ubuntu-24.04 ubuntu x86_64 Ubuntu-Server 24.04.1 LTS 2.1G — +gparted-1.6 unknown — GPARTED-LIVE 420.0M — +``` + +Six familles sont reconnues — Proxmox, Debian, Ubuntu, RHEL et ses dérivés, SUSE et +Fedora CoreOS — à partir d'une table de marqueurs situés dans l'image. Là où un éditeur a +laissé une chaîne de version, elle est reprise ; l'identifiant de volume sert de repli. + +**Une image que rien ne reconnaît est quand même listée et quand même servie.** Ne pas +savoir la décrire n'est pas la même chose que ne pas savoir s'en servir : elle peut être +`sanboot`ée, écrite sur une clé, ou récupérée entière par le firmware. Ce qu'elle ne peut +pas faire, c'est produire une strophe de démarrage, et le serveur le dit plutôt que de +deviner. + +## Les points d'entrée + +Le listener média a sa propre socket, sur `0.0.0.0:8001` par défaut. + +| Route | Ce qui revient | +|---|---| +| `GET /` | le catalogue en texte, ou en JSON avec `Accept: application/json` | +| `GET //iso` | l'image | +| `GET //kernel` | le noyau, diffusé **depuis l'intérieur** de l'image | +| `GET //initrd` | l'initrd, de même | +| `GET //initrd+iso` | l'initrd avec l'image ajoutée, pour les vieux chargeurs | +| `GET //file/` | n'importe quel fichier dans l'image | +| `GET /health` | `200 OK` | + +Rien n'est extrait et rien n'est décompressé. Un fichier dans une image ISO9660 est une +plage d'octets contiguë : servir `/pve-8.4/kernel` est donc un positionnement et une +longueur — le même travail de quelques kilo-octets que l'image fasse 400 Mo ou 4 Go. + +Les plages (`Range`), `ETag`, `If-Range` et `HEAD` sont tous traités, parce que les vrais +clients en ont besoin : casper d'Ubuntu et anaconda de Red Hat récupèrent tous deux par +plages, et le démarrage HTTP UEFI envoie un `HEAD` avant de récupérer quoi que ce soit. + +### Pourquoi c'est un second listener + +Ce n'est pas une préférence — trois raisons distinctes, dont une seule suffirait : + +- Le point de réponse répond sur **n'importe quel chemin**, puisque l'URL est gravée dans + une ISO. Un préfixe `/media/…` découperait un espace réservé dans un espace + délibérément ouvert. +- `RESCRIPTUM_TIMEOUT_SECS` est une échéance de connexion entière de dix secondes. Un + transfert de 1,5 Go dure quinze secondes en gigabit et deux minutes en 100 Mbit : tous + les téléchargements seraient tués en vol — et cela ressemblerait à un réseau instable, + pas à un réglage. +- Un téléchargement retient un jeton de connexion pendant des minutes. Partager ce budget + avec les réponses, c'est un déploiement qui affame ses propres installations. + +Les deux ont des budgets séparés, et un test le prouve au lieu de l'espérer : les +réponses continuent d'aboutir avec quatre transferts en cours. + +## Démarrer une machine depuis tout ça + +`media ipxe` écrit la strophe de démarrage d'une image : + +```console +$ rescriptum media ipxe pve-8.4 +#!ipxe +# Proxmox Virtual Environment 8.4-1 — generated by `rescriptum media ipxe pve-8.4`. +# An ordinary answer document: selection, layering and templating all apply. +kernel http://192.0.2.10:8001/pve-8.4/kernel ramdisk_size=16777216 rw quiet initrd=initrd.img \ + splash=silent proxmox-start-auto-installer +initrd http://192.0.2.10:8001/pve-8.4/initrd initrd.img +initrd http://192.0.2.10:8001/pve-8.4/iso proxmox.iso +boot +``` + +**Il imprime un script, il n'en installe pas.** Enregistrez-le dans le répertoire des +réponses et c'est un document de réponse ordinaire — sélectionné, superposé et +gabarisé comme n'importe quel autre : + +```console +$ rescriptum media ipxe pve-8.4 > /srv/answers/groups/rack-a.ipxe +``` + +C'est bien le point. Le serveur ne devient pas malin sur le démarrage ; il gagne un +générateur, et le moteur de composition que vous avez déjà fait le reste. Un `{{ mac }}` +dans l'URL de réponse générée est rempli à chaque requête depuis les faits de la machine. + +Chaque famille reçoit ce dont elle a réellement besoin, et elles ne se ressemblent pas : + +| Famille | Comment la réponse lui parvient | +|---|---| +| Proxmox VE | dans l'image, via `auto-installer-mode.toml` — et `proxmox-start-auto-installer` sur la ligne de commande pour choisir la voie automatisée | +| Debian | `preseed/url=…` | +| Ubuntu | `ds=nocloud-net;s=…/`, d'où cloud-init récupère `user-data` *et* `meta-data` | +| Famille RHEL | `inst.ks=…` | +| SUSE | `autoyast=…` | +| Fedora CoreOS | `ignition.config.url=…` | + +Proxmox est le cas à part, et il vaut la peine de savoir pourquoi : c'est le seul qui +porte l'emplacement de la réponse *à l'intérieur de l'image* plutôt que sur la ligne de +commande du noyau. C'est aussi pour cela que c'est le seul à devoir passer une fois par +`prepare-iso` — voir [Préparer les médias d'installation](../iso.md). + +::: tip Vous avez déjà lancé `prepare-iso --pxe` ? +Cela laisse un répertoire contenant `vmlinuz`, `initrd.img` et une ISO allégée. Pointez +`RESCRIPTUM_MEDIA_DIR` dessus et cela fonctionne tel quel : l'image allégée est toujours +reconnue comme Proxmox, et le noyau et l'initrd posés à côté sont trouvés et servis. +::: + +## Dire au serveur son propre nom + +Dès qu'il écrit des URL dans les scripts qu'il sert, le serveur a besoin d'un nom pour +lui-même qu'une machine puisse réellement atteindre. `0.0.0.0:8001` n'en est pas un. + +```console +$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 +``` + +**Un hôte, jamais une URL.** Pas de schéma, pas de port, pas de chemin — le serveur écrit +des URL pour deux listeners, et une valeur portant un port épinglerait chaque script +généré sur l'un d'eux. Chaque URL ajoute le port de son propre listener. Une valeur +portant l'un des trois est refusée au démarrage, en nommant lequel. + +Laissée vide, elle demande à la table de routage laquelle des adresses de cet hôte fait +face à l'extérieur, et **le dit haut et fort au démarrage** : + +``` +warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.0.2.10, which is what every +generated URL will name. Multi-homed and NAT hosts get this wrong; set it explicitly if +that address is not reachable from the machines. +``` + +Prenez l'avertissement au sérieux sur un hôte multi-domicilié ou derrière du NAT. Une +mauvaise déduction produit une machine qui démarre, enchaîne, et se bloque sur une adresse +qui n'existe pas — et cette ligne de journal est le seul endroit où la réponse +apparaîtra jamais. + +## Le garder honnête + +```console +$ rescriptum media check +checking media in /srv/media + 2 image(s), 1 verified against a recorded digest + note: ubuntu-24.04 has no recorded digest — `media add` records one + ok — everything recorded still matches +``` + +Son code de sortie est un contrat, comme celui de `check` : zéro quand tout ce qui a été +enregistré correspond toujours, un quand quelque chose a dérivé. `deploy.sh` s'y fie. + +Une image qui a changé sous une empreinte enregistrée est la seule panne qui installe +silencieusement quelque chose que personne n'a relu, donc elle est bruyante : + +``` + FAIL pve-8.4: the image no longer matches what was recorded + recorded 9f86d081884c7d65… + found 7d793037a0760186… +``` + +Ce que cela prouve, c'est **l'intégrité, pas l'authenticité** : ce qui est servi est ce +qui a été enregistré. Savoir si ce qui a été enregistré est bien ce que l'éditeur a +publié relève de ses propres signatures, et `--sha256` au moment du `media add` est +l'endroit où cette vérification se place. + +## Qui a le droit de récupérer + +Le trafic de démarrage n'est pas authentifié, et forcément : une ROM PXE n'a aucun +identifiant — la même nécessité qui gouverne déjà le point de réponse. Les contrôles sont +donc structurels : lecture seule, borné au catalogue, et aucun chemin de système de +fichiers n'est jamais construit à partir d'une requête. Plus un qui peut dire *pas vous* : + +```console +$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8,192.168.0.0/16 +``` + +Non définie, n'importe qui pouvant atteindre le port, ce qui sur un VLAN de +provisionnement est la configuration honnête. **Un VLAN de démarrage est la recommandation +qui fonctionne vraiment** ; voir [Sécurité](./security.md). + +## Réglages + +| Variable | Défaut | À quoi elle sert | +|---|---|---| +| `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | Le listener | +| `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Échéance du transfert entier | +| `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Transferts simultanés | + +Seize, c'est bas volontairement. Chaque transfert retient son jeton pendant des minutes, +et le petit bout de ce sur quoi cela doit tourner est un NAS avec un disque mécanique : +seize transferts à 64 Kio par morceau font environ deux méga-octets de tampons, une +arithmétique qui doit tenir dans 512 Mo de RAM. + +Sur une machine de datacenter, montez-la. Le point de réponse a son propre budget et +n'est touché dans aucun des deux cas. diff --git a/docs/guide/operations/media.md b/docs/guide/operations/media.md new file mode 100644 index 0000000..0f24c6c --- /dev/null +++ b/docs/guide/operations/media.md @@ -0,0 +1,242 @@ +--- +title: Serving boot media +description: Serve the installer itself — kernel, initrd and image — from the same server that decides the answer, on its own listener. +sidebar: + label: Boot media + order: 8 +--- + +# Serving boot media + +An answer tells a machine *how* to install. It says nothing about where the installer +comes from — and until now that was somebody else's web server, holding images that +nobody checked against the answers written for them. + +With a media directory, the same server does both. **A machine's MAC selects its answer +and the image that answer was written for, and the two cannot drift apart because one +component decided both.** + +```console +$ export RESCRIPTUM_MEDIA_DIR=/srv/media +``` + +Unset is the whole off switch. Nothing changes for an existing deployment until you set +it. + +## Getting an image in + +The server never downloads images; it receives them. Put the file where the directory +is — over SMB, over `scp`, from wherever the ISO already is — and then register it: + +```console +$ rescriptum media add /srv/media/pve-8.4.iso --sha256 9f86d081884c7d65… +hashing /srv/media/pve-8.4.iso … + 10% (152.0M of 1.5G) + … +pve-8.4 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 + proxmox Proxmox Virtual Environment 8.4-1 + kernel /boot/linux26 + initrd /boot/initrd.img + wrote /srv/media/pve-8.4.media +``` + +`--sha256` is optional and worth giving: a mismatch is either a truncated download or +the wrong file, and both would install the wrong thing on every machine that asks. +Nothing is recorded when it does not match. + +**Nothing is copied and the image is never modified.** What `media add` writes is the +`.media` sidecar beside it, recording the digest and what the probe found. That is the +whole point: hashing 1.5 GB takes the better part of a minute, and the server must never +spend a minute inside a request. + +An image with no sidecar still appears and is still served — it just has no digest to +re-check, and it is probed on sight. + +## What it can tell about an image + +```console +$ rescriptum media list +ID FAMILY ARCH VERSION SIZE PINNED +pve-8.4 proxmox x86_64 Proxmox Virtual Environment… 1.5G 9f86d0818 +ubuntu-24.04 ubuntu x86_64 Ubuntu-Server 24.04.1 LTS 2.1G — +gparted-1.6 unknown — GPARTED-LIVE 420.0M — +``` + +Six families are recognised — Proxmox, Debian, Ubuntu, RHEL and its rebuilds, SUSE and +Fedora CoreOS — from a table of markers inside the image. Where a vendor left a version +string it is used; the volume identifier is the fallback. + +**An image nothing recognises is still listed and still served.** Not describable is not +the same as not usable: it can be `sanboot`ed, or written to a stick, or fetched whole by +firmware. What it cannot do is produce a boot stanza, and the server says so rather than +guessing. + +## The endpoints + +The media listener is its own socket, on `0.0.0.0:8001` by default. + +| Route | What comes back | +|---|---| +| `GET /` | the catalogue as text, or JSON with `Accept: application/json` | +| `GET //iso` | the image | +| `GET //kernel` | the kernel, streamed **from inside** the image | +| `GET //initrd` | the initrd, likewise | +| `GET //initrd+iso` | the initrd with the image appended, for old loaders | +| `GET //file/` | any file inside the image | +| `GET /health` | `200 OK` | + +Nothing is extracted and nothing is unpacked. A file in an ISO9660 image is one +contiguous run of bytes, so serving `/pve-8.4/kernel` is a seek and a length — the same +few kilobytes of work whether the image is 400 MB or 4 GB. + +Ranges, `ETag`, `If-Range` and `HEAD` are all answered, because real clients need them: +Ubuntu's casper and Red Hat's anaconda both range-fetch, and UEFI HTTP Boot sends `HEAD` +before it fetches. + +### Why it is a second listener + +Not preference — three separate reasons, any one of which would be enough: + +- The answer endpoint answers on **any path**, because the URL is baked into an ISO. A + `/media/…` prefix would carve a reserved space out of one that is deliberately open. +- `RESCRIPTUM_TIMEOUT_SECS` is a whole-connection deadline of ten seconds. A 1.5 GB + transfer is fifteen seconds on gigabit and two minutes on 100 Mbit, so every download + would be killed mid-flight — and it would look like a flaky network, not a setting. +- A download holds a connection permit for minutes. Sharing that budget with answers + means a rollout starves its own installs. + +The two have separate budgets, and a test proves it rather than hoping: answers keep +succeeding with four transfers in flight. + +## Booting a machine from it + +`media ipxe` writes the boot stanza for one image: + +```console +$ rescriptum media ipxe pve-8.4 +#!ipxe +# Proxmox Virtual Environment 8.4-1 — generated by `rescriptum media ipxe pve-8.4`. +# An ordinary answer document: selection, layering and templating all apply. +kernel http://192.0.2.10:8001/pve-8.4/kernel ramdisk_size=16777216 rw quiet initrd=initrd.img \ + splash=silent proxmox-start-auto-installer +initrd http://192.0.2.10:8001/pve-8.4/initrd initrd.img +initrd http://192.0.2.10:8001/pve-8.4/iso proxmox.iso +boot +``` + +**It prints a script; it does not install one.** Save it into the answers directory and +it is an ordinary answer document — selected, layered and templated like any other: + +```console +$ rescriptum media ipxe pve-8.4 > /srv/answers/groups/rack-a.ipxe +``` + +Which is the point. The server does not become clever about booting; it gains a +generator, and the composition engine you already have does the rest. A `{{ mac }}` in +the generated answer URL is filled per request from the machine's own facts. + +Each family gets what it actually needs, and they are not alike: + +| Family | How the answer reaches it | +|---|---| +| Proxmox VE | inside the image, via `auto-installer-mode.toml` — and `proxmox-start-auto-installer` on the command line to select the automated path | +| Debian | `preseed/url=…` | +| Ubuntu | `ds=nocloud-net;s=…/`, from which cloud-init fetches `user-data` *and* `meta-data` | +| RHEL family | `inst.ks=…` | +| SUSE | `autoyast=…` | +| Fedora CoreOS | `ignition.config.url=…` | + +Proxmox is the odd one out, and it is worth knowing why: it is the only one that carries +the answer's location *inside the image* rather than on the kernel command line. That is +also why it is the only one that needs `prepare-iso` run over it once — see +[Preparing installer media](../iso.md). + +::: tip Already ran `prepare-iso --pxe`? +That leaves a directory holding `vmlinuz`, `initrd.img` and a trimmed ISO. Point +`RESCRIPTUM_MEDIA_DIR` at it and it works as-is — the trimmed image is still recognised +as Proxmox, and the kernel and initrd beside it are found and served. +::: + +## Telling the server its own name + +The moment it writes URLs into scripts, the server needs a name for itself that a machine +can actually reach. `0.0.0.0:8001` is not one. + +```console +$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 +``` + +**A host, never a URL.** No scheme, no port, no path — the server writes URLs for two +listeners, and a value carrying one port would pin every generated script to one of them. +Each URL appends its own listener's port. A value with any of the three is refused at +startup, naming which. + +Left unset, it asks the routing table which of this host's addresses faces outward, and +**says so loudly at startup**: + +``` +warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.0.2.10, which is what every +generated URL will name. Multi-homed and NAT hosts get this wrong; set it explicitly if +that address is not reachable from the machines. +``` + +Take the warning seriously on a multi-homed or NAT host. A wrong guess produces a machine +that boots, chains, and hangs on an address that does not exist — and that log line is +the only place the answer will ever appear. + +## Keeping it honest + +```console +$ rescriptum media check +checking media in /srv/media + 2 image(s), 1 verified against a recorded digest + note: ubuntu-24.04 has no recorded digest — `media add` records one + ok — everything recorded still matches +``` + +Its exit code is a contract, like `check`'s: zero when everything recorded still matches, +one when something drifted. `deploy.sh` keys on it. + +An image that changed under a recorded digest is the one failure that silently installs +something nobody reviewed, so it is loud: + +``` + FAIL pve-8.4: the image no longer matches what was recorded + recorded 9f86d081884c7d65… + found 7d793037a0760186… +``` + +What this proves is **integrity, not authenticity**: what is served is what was +registered. Whether what was registered is what the vendor published is a question for +the vendor's own signatures, and `--sha256` at `media add` is where that check belongs. + +## Who may fetch + +Boot traffic is unauthenticated, and necessarily so — a PXE ROM has no credentials, the +same necessity that already governs the answer endpoint. The controls are therefore +structural: read-only, catalogue-bound, and no filesystem path is ever built from a +request. Plus one that can say *not you*: + +```console +$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8,192.168.0.0/16 +``` + +Unset means anyone who can reach the port, which on a provisioning VLAN is the honest +configuration. **A boot VLAN is the recommendation that actually works**; see +[Security](./security.md). + +## Tuning + +| Variable | Default | What it is for | +|---|---|---| +| `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | The listener | +| `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Whole-transfer deadline | +| `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Concurrent transfers | + +Sixteen is low on purpose. Each transfer holds its permit for minutes, and the small end +of what this has to run on is a NAS with one spinning disk: sixteen transfers at 64 KiB a +chunk is about two megabytes of buffers, which is arithmetic that has to hold in 512 MB +of RAM. + +On a datacenter host, raise it. The answer endpoint has its own budget and is untouched +either way. diff --git a/docs/guide/reference/cli.fr.md b/docs/guide/reference/cli.fr.md index 0917b90..43b223a 100644 --- a/docs/guide/reference/cli.fr.md +++ b/docs/guide/reference/cli.fr.md @@ -125,6 +125,31 @@ Contrairement à toutes les autres sous-commandes, celle-ci fonctionne quand la est trop cassée pour démarrer un serveur — un fichier qui ne parse pas, un jeton d'un caractère trop court. C'est l'état dont on se sert d'elle pour *sortir*. +## `media` + +Les médias de démarrage : les images d'installation que ce serveur détient. Chacune de +ces commandes exige `RESCRIPTUM_MEDIA_DIR` ; sans elle, elles le disent et sortent en +`1`. Voir [Servir les médias de démarrage](../operations/media.md). + +```console +$ rescriptum media list # ce qui est détenu : famille, architecture, version, empreinte +$ rescriptum media add FILE [--sha256 D] # enregistrer une image déjà dans le répertoire +$ rescriptum media check # revérifier chaque empreinte enregistrée +$ rescriptum media ipxe ID # imprimer la réponse .ipxe qui démarre une image +``` + +`media add` prend un fichier **déjà dans le répertoire de médias** — rien n'est +téléchargé et rien n'est copié. Il le hache avec une progression, l'analyse, et écrit un +fichier compagnon `.media` à côté. `--sha256` est vérifié avant tout enregistrement : +un écart n'écrit rien et sort en `1`. + +Le code de sortie de `media check` est un contrat, comme celui de `check`. `deploy.sh` +s'y fie. + +`media ipxe` imprime sur **stdout** et met tout le reste sur stderr, de sorte que +`rescriptum media ipxe pve-8.4 > groups/rack-a.ipxe` produit un document de réponse +utilisable — ce qu'il est, rien de plus. Il imprime un script, il n'en installe pas. + ## Codes de sortie | Code | Signifie | diff --git a/docs/guide/reference/cli.md b/docs/guide/reference/cli.md index b204d6d..cef25e9 100644 --- a/docs/guide/reference/cli.md +++ b/docs/guide/reference/cli.md @@ -123,6 +123,30 @@ Unlike every other subcommand, this one works when the configuration is too brok a server — a file that will not parse, a token one character short. That is the state people run it *to get out of*. +## `media` + +Boot media: the installer images this server holds. Every one of these needs +`RESCRIPTUM_MEDIA_DIR`; without it they say so and exit `1`. See +[Serving boot media](../operations/media.md). + +```console +$ rescriptum media list # what is held: family, architecture, version, digest +$ rescriptum media add FILE [--sha256 D] # register one already in the directory +$ rescriptum media check # re-verify every recorded digest +$ rescriptum media ipxe ID # print the .ipxe answer that boots one image +``` + +`media add` takes a file **already inside the media directory** — nothing is downloaded +and nothing is copied. It hashes it with progress, probes it, and writes a `.media` +sidecar beside it. `--sha256` is checked before anything is recorded: a mismatch writes +nothing and exits `1`. + +`media check`'s exit status is a contract, like `check`'s. `deploy.sh` keys on it. + +`media ipxe` prints to **stdout** and puts everything else on stderr, so +`rescriptum media ipxe pve-8.4 > groups/rack-a.ipxe` produces a usable answer document — +which is all it is. It prints a script; it does not install one. + ## Exit statuses | Status | Means | diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index e7199e7..aff635e 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -29,6 +29,12 @@ pas de *format* de configuration à apprendre ni de ligne de commande à se trom | `RESCRIPTUM_CAPTURE_DIR` | non défini | Enregistre les corps de requête ici. Non défini = pas de capture | | `RESCRIPTUM_LOG` | `all` | `all`, `problems` ou `off` — voir [plus bas](#journalisation) | | `RESCRIPTUM_LOG_FILE` | non défini | Un fichier où ajouter, ou `stdout` / `stderr`. Non défini = stderr | +| `RESCRIPTUM_MEDIA_DIR` | non défini | Images d'installation. **Non défini = pas de média et pas de listener média** | +| `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | Le listener média, quand un répertoire de médias existe | +| `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Échéance du transfert entier. Volontairement pas les 10 s du point de réponse | +| `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Transferts simultanés. Bas exprès : chacun retient son jeton des minutes durant | +| `RESCRIPTUM_PUBLIC_HOST` | déduit | L'hôte que nomment les URL générées. **Un hôte, jamais une URL** | +| `RESCRIPTUM_BOOT_ALLOW` | non défini | CIDR clients autorisés à récupérer les médias. Non défini = quiconque atteint le port | `/srv` est l'endroit où la norme de hiérarchie des fichiers range les données servies par le système, ce qu'est précisément un répertoire de réponses. Les deux valeurs par défaut y vivent, @@ -158,6 +164,9 @@ Celles-ci arrêtent le serveur au lieu d'avertir, parce que démarrer quand mêm | `RESCRIPTUM_ADMIN_TOKEN` de moins de 16 caractères | assez court pour être deviné | | L'adresse d'écoute ne peut pas être bindée | rien à faire | | Le store ne peut pas être ouvert | rien à servir | +| `RESCRIPTUM_MEDIA_ADDR` défini sans `RESCRIPTUM_MEDIA_DIR` | un listener sans rien à servir | +| `RESCRIPTUM_MEDIA_ADDR` égal à l'adresse de réponse ou d'administration | le second bind perd, et lequel dépend de l'ordre de démarrage | +| `RESCRIPTUM_PUBLIC_HOST` portant un schéma, un port ou un chemin | il est écrit dans les URL de deux listeners ; un port dans la valeur épingle chaque script généré sur l'un d'eux | ## Avertissements de démarrage @@ -171,12 +180,25 @@ Ceux-ci sont affichés et le serveur continue : | API d'administration hors boucle locale | `warning: the admin API is not bound to loopback — …` | | `RESCRIPTUM_ANSWER_TOKEN` de moins de 16 caractères | un avertissement, **pas** une erreur — refuser de démarrer laisserait un parc incapable de s'installer | | Tout problème dans le jeu de réponses | une ligne `warning:` chacun, le même jeu que signale `check` | +| `RESCRIPTUM_PUBLIC_HOST` non défini | `warning: … is not set — derived , which is what every generated URL will name`. Les hôtes multi-domiciliés et derrière NAT se trompent souvent ici | +| Répertoire de médias absent ou illisible | une ligne `warning: media: …` — un parc ne doit jamais être incapable de s'installer parce qu'une image est bizarre | ## Options de compilation | Feature | Défaut | Effet | |---|---|---| -| `sqlite` | activée | Le store SQLite et l'API d'administration. `cargo build --no-default-features` retire les deux — 944 928 octets au lieu de 2 103 456 sur ARMv7 | +| `sqlite` | activée | Le store SQLite et l'API d'administration | +| `boot` | activée | Le catalogue de médias, le lecteur ISO et le listener média | + +Mesuré sur ARMv7 (gnueabihf, plancher glibc 2.17). Remesurez plutôt que de citer ces +chiffres : ils ont bougé d'environ 375 Ko quand cette cible est passée de musl à glibc. + +| Build | Octets | +|---|---| +| les deux (défaut) | 2 602 056 | +| `sqlite` seule | 2 482 000 | +| `boot` seule | 1 436 704 | +| aucune | 1 316 648 | ## Limites fixes diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 2e79e8e..26b1394 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -29,6 +29,12 @@ no configuration *format* to learn and no command line to get wrong. | `RESCRIPTUM_CAPTURE_DIR` | unset | Record request bodies here. Unset means no capture | | `RESCRIPTUM_LOG` | `all` | `all`, `problems` or `off` — see [below](#logging) | | `RESCRIPTUM_LOG_FILE` | unset | A file to append to, or `stdout` / `stderr`. Unset means stderr | +| `RESCRIPTUM_MEDIA_DIR` | unset | Installer images. **Unset means no media and no media listener** | +| `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | The media listener, when there is a media directory | +| `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Whole-transfer deadline. Deliberately not the answer listener's 10 | +| `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Concurrent transfers. Low on purpose: each holds its permit for minutes | +| `RESCRIPTUM_PUBLIC_HOST` | derived | The host generated URLs name. **A host, never a URL** | +| `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media. Unset means anyone who can reach the port | `/srv` is where the filesystem hierarchy standard puts data served by the system, which is what an answers directory is. Both defaults live there so that a bare `rescriptum` does @@ -153,6 +159,9 @@ These stop the server rather than warning, because starting anyway would be wors | `RESCRIPTUM_ADMIN_TOKEN` under 16 characters | short enough to guess | | The listen address cannot be bound | nothing to do | | The store cannot be opened | nothing to serve | +| `RESCRIPTUM_MEDIA_ADDR` set with no `RESCRIPTUM_MEDIA_DIR` | a listener with nothing to serve | +| `RESCRIPTUM_MEDIA_ADDR` equal to the answer or admin address | the second bind loses, and which one depends on start order | +| `RESCRIPTUM_PUBLIC_HOST` carrying a scheme, a port or a path | it is written into URLs for two listeners; one port in the value pins every generated script to one of them | ## Startup warnings @@ -166,12 +175,25 @@ These are printed and the server carries on: | Admin API not on loopback | `warning: the admin API is not bound to loopback — …` | | `RESCRIPTUM_ANSWER_TOKEN` under 16 characters | a warning, **not** an error — refusing to start would leave a fleet unable to install | | Any problem in the answer set | one `warning:` line each, the same set `check` reports | +| `RESCRIPTUM_PUBLIC_HOST` unset | `warning: … is not set — derived
, which is what every generated URL will name`. Multi-homed and NAT hosts get this wrong | +| Media directory missing or unlistable | one `warning: media: …` line — a fleet must never be unable to install because one image is odd | ## Compile-time options | Feature | Default | Effect | |---|---|---| -| `sqlite` | on | The SQLite store and the admin API. `cargo build --no-default-features` drops both — 944,928 bytes instead of 2,103,456 on ARMv7 | +| `sqlite` | on | The SQLite store and the admin API | +| `boot` | on | The media catalogue, the ISO reader and the media listener | + +Measured on ARMv7 (gnueabihf, glibc floor 2.17). Re-measure rather than quoting these: +they moved by about 375 KB when that target changed from musl. + +| Build | Bytes | +|---|---| +| both (default) | 2,602,056 | +| `sqlite` only | 2,482,000 | +| `boot` only | 1,436,704 | +| neither | 1,316,648 | ## Fixed limits From 7a7fe0484253e0abe9e9378d689b68a98bb12acb Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 13:21:33 +0200 Subject: [PATCH 06/59] feat(boot): TFTP, the loader table, and dropping privileges after binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2's first half. TFTP is core rather than optional — an appliance that needs somebody else's TFTP server is not an appliance — and it hands over exactly one file. At 1468 bytes a round-trip an image would take twenty minutes where HTTP takes fifteen seconds, so the rule is written into the module and into the root it is pointed at: the loader, and then HTTP. One table maps option 93 to a loader, and both the TFTP server and (next) `boot dhcp-snippet` read it, so what an operator pastes into their DHCP server and what this one hands out cannot drift. It carries the recorded exception the registry alone would get wrong: RFC 4578 called 0x0009 x86-64, IANA calls it EBC, and real firmware sends either. `tests/tftp.rs` speaks the protocol over real UDP, and it earned its keep immediately by finding two bugs of the "works by hand, never after a reboot" kind: - A file whose length is an exact multiple of the block size never ended. A short block is what finishes a transfer, and such a file has none — so it must end with an *empty* one. Watched red with the defect restored. - The per-peer cap counted datagrams rather than transfers, so four stray packets locked an address out. That is not a hostility threshold: **a PXE ROM retransmits its read request** when an answer is slow, a sleeping NAS disk is enough to cause it, and each retransmission is a fresh transfer from the same address. Junk now costs no slot, the cap is eight, and a test boots six retransmissions to pin it. Privilege dropping is bind-then-drop, groups before gid before uid, and it verifies the drop by trying to undo it — a process that thinks it dropped and did not is worse than one that never tried. `libc` becomes a direct dependency and costs zero new crates: tokio already had it. 467 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- Cargo.lock | 1 + Cargo.toml | 4 + src/boot/loaders.rs | 296 ++++++++++++++++++ src/boot/media.rs | 2 +- src/boot/mod.rs | 3 + src/boot/privileges.rs | 212 +++++++++++++ src/boot/tftp.rs | 662 +++++++++++++++++++++++++++++++++++++++++ src/config.rs | 97 +++++- src/envfile.rs | 9 +- src/main.rs | 50 ++++ tests/tftp.rs | 633 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 1966 insertions(+), 3 deletions(-) create mode 100644 src/boot/loaders.rs create mode 100644 src/boot/privileges.rs create mode 100644 src/boot/tftp.rs create mode 100644 tests/tftp.rs diff --git a/Cargo.lock b/Cargo.lock index f065c3c..f17a00f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -315,6 +315,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "libc", "quick-xml", "rescriptum", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index 888a611..d49471b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,10 @@ categories = ["network-programming", "command-line-utilities"] http-body-util = "0.1.5" hyper = { version = "1.11.0", features = ["server", "http1"] } hyper-util = { version = "0.1.20", features = ["tokio", "server"] } +# Already in the lock file by way of tokio, so promoting it from transitive to direct +# costs zero new crates. It is here for exactly one thing: dropping privileges after +# binding TFTP's privileged port, which needs setgid/setuid/initgroups. +libc = "0.2.189" quick-xml = "0.41.0" rusqlite = { version = "0.40.2", features = ["bundled"], optional = true } serde_json = "1.0.151" diff --git a/src/boot/loaders.rs b/src/boot/loaders.rs new file mode 100644 index 0000000..8d16fbd --- /dev/null +++ b/src/boot/loaders.rs @@ -0,0 +1,296 @@ +//! What firmware announces in DHCP option 93, and which loader it gets. +//! +//! A small alias table in the spirit of `format::endpoint_formats` — auditable, not +//! clever. **One table, two consumers**: the TFTP server serves from it and +//! `boot dhcp-snippet` generates from it, so the configuration an operator pastes into +//! their DHCP server and the files this one actually hands out cannot drift apart. A +//! snippet naming a loader that is not on disk fails *silently, at the ROM*, which is +//! the most common way this goes wrong and the least diagnosable. +//! +//! What it serves is **our own branded iPXE build, always.** A stock netboot.xyz binary +//! embeds a script that chains to the public `boot.netboot.xyz`, which is exactly the +//! failure the whole entry-point design exists to prevent. netboot.xyz stays what it is +//! here: menus served over HTTP, never the loader TFTP hands out. +//! +//! The values come from IANA's "Processor Architecture Types" registry **plus one +//! recorded exception**: RFC 4578 defined `0x0009` as "EFI x86-64", and the registry — +//! rewritten by RFC 5970 — lists it as "EBC". Real x64 firmware announces `0x0007` or +//! `0x0009`, so both map to x64, exactly as every deployed dhcpd example does. A table +//! generated from the registry alone would hand x64 firmware nothing. + +/// How a client reaches us at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transport { + /// The loader arrives over TFTP, named by DHCP options 66/67. + Tftp, + /// Firmware fetches it over HTTP itself — option 60 `HTTPClient` plus a URL in 67. + /// The shortest chain there is, and it skips TFTP entirely. + Http, +} + +/// One row: what the firmware said it is, and what it gets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Client { + /// The option 93 value, as the ROM sends it. + pub arch: u16, + pub label: &'static str, + /// The loader, or `None` when we have nothing to give it. + pub loader: Option<&'static str>, + pub transport: Transport, + /// Said out loud when `loader` is `None`. A refusal that names its reason is a + /// bug report; a silent one is a machine that hangs at power-on. + pub refusal: &'static str, +} + +const UNBUILT: &str = "not in the build matrix — no machine that needs it has been seen, and a loader \ + nobody has booted is worse than an honest refusal"; + +/// Every value the registry defines that a PXE ROM might plausibly send. +pub const TABLE: &[Client] = &[ + Client { + arch: 0x0000, + label: "BIOS PXE", + // UNDI is upstream's own chainloading recommendation. `ipxe.kpxe` (native + // drivers) is the one to reach for when a NIC's UNDI stack misbehaves. + loader: Some("ipxe-undionly.kpxe"), + transport: Transport::Tftp, + refusal: "", + }, + Client { + arch: 0x0006, + label: "UEFI IA32", + loader: None, + transport: Transport::Tftp, + refusal: UNBUILT, + }, + Client { + arch: 0x0007, + label: "UEFI x86-64", + loader: Some("ipxe-x86_64.efi"), + transport: Transport::Tftp, + refusal: "", + }, + Client { + // The recorded exception: IANA calls this EBC, RFC 4578 called it x86-64, and + // real firmware sends it meaning x86-64. + arch: 0x0009, + label: "UEFI x86-64 (announced as EBC; see RFC 4578)", + loader: Some("ipxe-x86_64.efi"), + transport: Transport::Tftp, + refusal: "", + }, + Client { + arch: 0x000a, + label: "UEFI ARM32", + loader: None, + transport: Transport::Tftp, + refusal: UNBUILT, + }, + Client { + arch: 0x000b, + label: "UEFI ARM64", + loader: Some("ipxe-arm64.efi"), + transport: Transport::Tftp, + refusal: "", + }, + Client { + arch: 0x000f, + label: "UEFI IA32, HTTP boot", + loader: None, + transport: Transport::Http, + // HTTP transport does not add a loader the build matrix lacks. + refusal: UNBUILT, + }, + Client { + arch: 0x0010, + label: "UEFI x86-64, HTTP boot", + loader: Some("ipxe-x86_64.efi"), + transport: Transport::Http, + refusal: "", + }, + Client { + arch: 0x0011, + label: "EBC, HTTP boot", + loader: None, + transport: Transport::Http, + refusal: UNBUILT, + }, + Client { + arch: 0x0012, + label: "UEFI ARM32, HTTP boot", + loader: None, + transport: Transport::Http, + refusal: UNBUILT, + }, + Client { + arch: 0x0013, + label: "UEFI ARM64, HTTP boot", + loader: Some("ipxe-arm64.efi"), + transport: Transport::Http, + refusal: "", + }, + Client { + arch: 0x0014, + label: "BIOS, HTTP boot", + loader: None, + transport: Transport::Http, + refusal: UNBUILT, + }, + Client { + arch: 0x0015, + label: "ARM32 U-Boot", + loader: None, + transport: Transport::Tftp, + refusal: UNBUILT, + }, + Client { + arch: 0x0016, + label: "ARM64 U-Boot", + loader: None, + transport: Transport::Tftp, + refusal: UNBUILT, + }, +]; + +pub fn for_arch(arch: u16) -> Option<&'static Client> { + TABLE.iter().find(|c| c.arch == arch) +} + +/// Every distinct loader filename the table can hand out — what `boot check` looks for +/// on disk, and what a release has to publish. +pub fn loaders() -> Vec<&'static str> { + let mut names: Vec<&'static str> = TABLE.iter().filter_map(|c| c.loader).collect(); + names.sort_unstable(); + names.dedup(); + names +} + +/// The loader a ROM that announces nothing at all should get. +/// +/// Every architecture line in a generated snippet is tag-matched, so a client matching +/// no tag would get no boot file and simply stop. The only clients that old are BIOS, +/// and a DHCP client that is not netbooting ignores boot options entirely — so an +/// untagged default costs nothing and covers the case. +pub const FALLBACK: &str = "ipxe-undionly.kpxe"; + +/// `snp` uses UEFI's Simple Network Protocol; `snponly` uses the firmware's own NIC +/// driver and is the one to reach for when the plain build cannot see the network. +/// +/// **Serve all of them and let the table pick** — this is precisely the knowledge an +/// operator should not have to acquire. The variants are alternatives for one row +/// rather than rows of their own, because option 93 cannot tell them apart: nothing in +/// the protocol says "my UNDI stack is broken". +pub fn variants(loader: &str) -> Vec { + match loader.strip_suffix(".efi") { + Some(stem) => vec![ + loader.to_string(), + format!("{stem}-snp.efi"), + format!("{stem}-snponly.efi"), + ], + None => vec![loader.to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_architectures_that_matter_get_a_loader() { + for (arch, expected) in [ + (0x0000u16, "ipxe-undionly.kpxe"), + (0x0007, "ipxe-x86_64.efi"), + (0x0009, "ipxe-x86_64.efi"), + (0x000b, "ipxe-arm64.efi"), + ] { + let client = for_arch(arch).unwrap_or_else(|| panic!("{arch:#06x} is in the table")); + assert_eq!(client.loader, Some(expected), "{arch:#06x}"); + } + } + + #[test] + fn the_registry_and_the_rfc_disagree_about_0x0009_and_both_map_to_x64() { + // IANA (via RFC 5970) calls it EBC; RFC 4578 defined it as EFI x86-64; real x64 + // firmware sends either. A table generated from the registry alone would hand + // x64 firmware nothing at all. + assert_eq!( + for_arch(0x0007).and_then(|c| c.loader), + Some("ipxe-x86_64.efi") + ); + assert_eq!( + for_arch(0x0009).and_then(|c| c.loader), + Some("ipxe-x86_64.efi") + ); + assert!(for_arch(0x0009).unwrap().label.contains("RFC 4578")); + } + + #[test] + fn http_boot_is_a_transport_rather_than_a_second_architecture() { + // 0x0010 and 0x0013 are the same silicon as 0x0007 and 0x000b, fetching over + // HTTP instead of TFTP — so they get the same loader and skip TFTP entirely. + for (tftp, http) in [(0x0007u16, 0x0010u16), (0x000b, 0x0013)] { + let a = for_arch(tftp).expect("in the table"); + let b = for_arch(http).expect("in the table"); + assert_eq!(a.loader, b.loader, "{tftp:#06x} vs {http:#06x}"); + assert_eq!(a.transport, Transport::Tftp); + assert_eq!(b.transport, Transport::Http); + } + } + + #[test] + fn a_thirty_two_bit_client_is_refused_over_both_transports() { + // An earlier draft of the table served 0x000f — IA32 over HTTP — while refusing + // 0x0006, the same architecture over TFTP, with no IA32 loader anywhere in the + // build matrix. HTTP transport does not conjure a loader. + for arch in [0x0006u16, 0x000f, 0x000a, 0x0012] { + let client = for_arch(arch).unwrap_or_else(|| panic!("{arch:#06x} is in the table")); + assert_eq!(client.loader, None, "{arch:#06x}"); + assert!(!client.refusal.is_empty(), "{arch:#06x} must say why"); + } + } + + #[test] + fn every_refusal_names_a_reason() { + // A refusal that names its reason is a bug report; a silent one is a machine + // that hangs at power-on with nothing on the console. + for client in TABLE { + assert_eq!( + client.loader.is_none(), + !client.refusal.is_empty(), + "{:#06x} must either serve something or say why not", + client.arch + ); + } + } + + #[test] + fn the_loaders_a_release_must_publish_are_derivable_from_the_table() { + // `boot check` looks for exactly these on disk, and a release publishes exactly + // these. Both read the table rather than a second list that could drift. + assert_eq!( + loaders(), + vec!["ipxe-arm64.efi", "ipxe-undionly.kpxe", "ipxe-x86_64.efi"] + ); + } + + #[test] + fn an_efi_loader_has_snp_variants_and_a_bios_one_does_not() { + assert_eq!( + variants("ipxe-x86_64.efi"), + vec![ + "ipxe-x86_64.efi", + "ipxe-x86_64-snp.efi", + "ipxe-x86_64-snponly.efi" + ] + ); + assert_eq!(variants("ipxe-undionly.kpxe"), vec!["ipxe-undionly.kpxe"]); + } + + #[test] + fn an_architecture_nobody_registered_is_simply_unknown() { + assert!(for_arch(0x00ff).is_none()); + // And the fallback is what a ROM announcing nothing gets. + assert_eq!(FALLBACK, "ipxe-undionly.kpxe"); + assert!(loaders().contains(&FALLBACK)); + } +} diff --git a/src/boot/media.rs b/src/boot/media.rs index 6eaebcd..0c991b0 100644 --- a/src/boot/media.rs +++ b/src/boot/media.rs @@ -605,7 +605,7 @@ fn allowed(cfg: &Config, peer: SocketAddr) -> bool { .any(|cidr| in_cidr(peer.ip(), cidr)) } -fn in_cidr(address: std::net::IpAddr, cidr: &str) -> bool { +pub(crate) fn in_cidr(address: std::net::IpAddr, cidr: &str) -> bool { let (network, bits) = match cidr.split_once('/') { Some((network, bits)) => match bits.parse::() { Ok(bits) => (network, bits), diff --git a/src/boot/mod.rs b/src/boot/mod.rs index d1007c6..0f0087c 100644 --- a/src/boot/mod.rs +++ b/src/boot/mod.rs @@ -13,7 +13,10 @@ pub mod catalog; pub mod cpio; pub mod iso; +pub mod loaders; pub mod media; +pub mod privileges; pub mod probe; pub mod sha256; pub mod stanza; +pub mod tftp; diff --git a/src/boot/privileges.rs b/src/boot/privileges.rs new file mode 100644 index 0000000..65d8ffc --- /dev/null +++ b/src/boot/privileges.rs @@ -0,0 +1,212 @@ +//! Dropping privileges after binding, the way dnsmasq and nginx do it. +//! +//! TFTP wants port 69, which is privileged. That is the *only* privileged port this +//! server ever asks for — with no DHCP responder there is nothing wanting 67 or 4011 — +//! so the whole question is: bind one low port, then stop being root. +//! +//! **Bind every listener first, then drop, then say what the process now is.** Dropping +//! before binding is the bug that works in testing as root and fails on deployment, and +//! it fails at the one moment nobody is watching: a reboot. +//! +//! This costs **zero new crates**: `libc` is already in `Cargo.lock` by way of tokio, so +//! promoting it from transitive to direct adds nothing to the build. +//! +//! Two other answers exist and neither needs this code, so both are documented rather +//! than implemented: socket activation (`LISTEN_FDS`) on a systemd host, which needs no +//! privileges at all, and `setcap cap_net_bind_service` on the binary. + +/// Become `user` and `group`, in the order that actually works. +/// +/// The order is not a style choice. Supplementary groups must go **before** the primary +/// group, and the group before the user: `setuid` is what surrenders the privilege to +/// call the other two, so doing it first leaves a process that kept every group it had. +/// That is the classic privilege-dropping bug, and it is silent — the process looks +/// unprivileged and is not. +pub fn drop_to(user: Option<&str>, group: Option<&str>) -> Result<(), String> { + if user.is_none() && group.is_none() { + return Ok(()); + } + + #[cfg(not(unix))] + { + let _ = (user, group); + return Err( + "RESCRIPTUM_USER and RESCRIPTUM_GROUP are Unix concepts, and this is not Unix" + .to_string(), + ); + } + + #[cfg(unix)] + { + // Nothing to drop *to*, and nothing to drop *from*: a non-root process cannot + // change identity, and pretending otherwise would fail later and less clearly. + let root = unsafe { libc::geteuid() } == 0; + if !root { + return Err(format!( + "RESCRIPTUM_USER{} is set, but this process is not root, so it cannot change \ + identity. Either start as root — binding port 69 needs it anyway — or drop \ + the setting and give the binary `setcap cap_net_bind_service`.", + match group { + Some(_) => "/RESCRIPTUM_GROUP", + None => "", + } + )); + } + + let gid = match group { + Some(name) => Some(lookup_group(name)?), + None => None, + }; + let target = match user { + Some(name) => Some(lookup_user(name)?), + None => None, + }; + + // The group first, and the supplementary set before that. + // An explicit group wins; otherwise take the user's own primary group, which is + // what naming only a user is asking for. + let primary = gid.or(target.as_ref().map(|(_, gid, _)| *gid)); + if let Some(gid) = primary { + // Supplementary groups survive `setgid` on their own, and a process that + // kept root's is not unprivileged in any sense that matters. + let name = user.map(std::ffi::CString::new).transpose().ok().flatten(); + let result = match &name { + // `as _`, not a named type: Darwin declares the base group as `int` and + // Linux as `gid_t`, so spelling either one out breaks the other. + Some(name) => unsafe { libc::initgroups(name.as_ptr(), gid as _) }, + // No user named, so there is no supplementary set to compute. Clear it. + None => unsafe { libc::setgroups(0, std::ptr::null()) }, + }; + let initialised = result == 0; + if !initialised { + return Err(format!( + "cannot set the supplementary groups for gid {gid}: {}", + std::io::Error::last_os_error() + )); + } + if unsafe { libc::setgid(gid as libc::gid_t) } != 0 { + return Err(format!( + "cannot become group {gid}: {}", + std::io::Error::last_os_error() + )); + } + } + + if let Some((uid, _, name)) = target { + if unsafe { libc::setuid(uid as libc::uid_t) } != 0 { + return Err(format!( + "cannot become user {name}: {}", + std::io::Error::last_os_error() + )); + } + // **Verify rather than assume.** On some systems a failed drop returns + // success; a process that thinks it dropped and did not is worse than one + // that never tried, because nothing will ever check again. + if unsafe { libc::setuid(0) } == 0 { + return Err( + "dropped to an unprivileged user and was still able to become root again \ + — refusing to run in that state" + .to_string(), + ); + } + } + + crate::log::server(&format!( + "dropped privileges: uid={} gid={}", + unsafe { libc::getuid() }, + unsafe { libc::getgid() } + )); + Ok(()) + } +} + +#[cfg(unix)] +fn lookup_user(name: &str) -> Result<(u32, u32, String), String> { + // A numeric value is an identity in its own right — a container image often has no + // passwd entry at all, and refusing one there would be refusing the normal case. + if let Ok(uid) = name.parse::() { + return Ok((uid, uid, name.to_string())); + } + let c_name = std::ffi::CString::new(name).map_err(|_| format!("{name:?} is not a name"))?; + let entry = unsafe { libc::getpwnam(c_name.as_ptr()) }; + if entry.is_null() { + return Err(format!( + "RESCRIPTUM_USER={name:?} does not exist on this system" + )); + } + let entry = unsafe { *entry }; + Ok((entry.pw_uid as u32, entry.pw_gid as u32, name.to_string())) +} + +#[cfg(unix)] +fn lookup_group(name: &str) -> Result { + if let Ok(gid) = name.parse::() { + return Ok(gid); + } + let c_name = std::ffi::CString::new(name).map_err(|_| format!("{name:?} is not a name"))?; + let entry = unsafe { libc::getgrnam(c_name.as_ptr()) }; + if entry.is_null() { + return Err(format!( + "RESCRIPTUM_GROUP={name:?} does not exist on this system" + )); + } + Ok(unsafe { *entry }.gr_gid as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn naming_nobody_does_nothing() { + // The overwhelmingly common case: no setting, no syscall, no behaviour. + assert!(drop_to(None, None).is_ok()); + } + + #[test] + #[cfg(unix)] + fn a_user_that_does_not_exist_is_refused_by_name() { + // Whatever this process is, the answer must name the setting rather than fail + // with an errno nobody can act on. + let e = drop_to(Some("definitely-not-a-user-on-this-box"), None).expect_err("must refuse"); + assert!( + e.contains("RESCRIPTUM_USER") || e.contains("not root"), + "{e}" + ); + } + + #[test] + #[cfg(unix)] + fn a_non_root_process_says_so_rather_than_failing_obscurely() { + // The test suite does not run as root, so this is the branch it can prove: the + // error names the setting and offers the two alternatives. + if unsafe { libc::geteuid() } == 0 { + return; + } + let e = drop_to(Some("nobody"), None).expect_err("must refuse"); + assert!(e.contains("not root"), "{e}"); + assert!(e.contains("setcap"), "{e}"); + } + + #[test] + #[cfg(unix)] + fn a_numeric_identity_needs_no_passwd_entry() { + // A container image often has no passwd file at all, and refusing a numeric uid + // there would be refusing the normal case. + assert_eq!( + lookup_user("1000").expect("numeric"), + (1000, 1000, "1000".to_string()) + ); + assert_eq!(lookup_group("1000").expect("numeric"), 1000); + } + + #[test] + #[cfg(unix)] + fn a_real_account_resolves() { + // `root` exists on every Unix this can run on, and looking it up proves the + // getpwnam path rather than only the numeric shortcut. + let (uid, _, name) = lookup_user("root").expect("root exists"); + assert_eq!(uid, 0); + assert_eq!(name, "root"); + } +} diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs new file mode 100644 index 0000000..8cc57fd --- /dev/null +++ b/src/boot/tftp.rs @@ -0,0 +1,662 @@ +//! TFTP, read-only, for exactly one job: handing over the loader. +//! +//! **It is core rather than optional.** An appliance that needs somebody else's TFTP +//! server is not an appliance — that was the reasoning the first draft of the plan got +//! backwards, and correcting it is what makes the whole chain start from one binary. +//! +//! ## One file, and then HTTP +//! +//! At `blksize=1468` with lockstep acknowledgement, TFTP moves one block per +//! round-trip — call it 1.4 MB/s at a millisecond of RTT. The loader is about a +//! megabyte, so two seconds. A 1.5 GB image would be **the better part of twenty +//! minutes**, against fifteen seconds over HTTP on the same wire. Hence the rule, +//! written here and in the guide: +//! +//! > **TFTP hands over the loader. Everything after that is HTTP.** +//! +//! Nothing here will serve an image, and the root is the boot-asset directory rather +//! than the media directory precisely so that it cannot. +//! +//! ## What real ROMs need +//! +//! RFC 1350 alone is not enough. The options are what decide whether firmware actually +//! works: `blksize` (RFC 2348) because 512-byte blocks make a megabyte take 2,000 +//! round-trips, `tsize` (RFC 2349) because a number of ROMs will not proceed without +//! being told the size up front, `timeout` (RFC 2349), and `windowsize` (RFC 7440) — +//! offered only when asked, because some ROMs get it wrong. +//! +//! ## UDP is forgeable, so this is defensive by construction +//! +//! One connected socket per transfer, so a transfer only ever hears from its own peer; +//! no reply to a broadcast or multicast destination, which is amplification hygiene +//! rather than politeness; a cap on concurrent transfers and a cap per peer; and a +//! duplicate acknowledgement is **ignored, never answered** — that is the Sorcerer's +//! Apprentice bug, and answering doubles the traffic for as long as it lasts. + +use crate::config::Config; +use crate::log; +use std::io; +use std::net::{IpAddr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::UdpSocket; +use tokio::sync::Semaphore; + +const OP_RRQ: u16 = 1; +const OP_WRQ: u16 = 2; +const OP_DATA: u16 = 3; +const OP_ACK: u16 = 4; +const OP_ERROR: u16 = 5; +const OP_OACK: u16 = 6; + +/// Error codes worth sending. The rest of RFC 1350's list describes writes. +const ERR_NOT_FOUND: u16 = 1; +const ERR_ACCESS: u16 = 2; +const ERR_ILLEGAL: u16 = 4; +const ERR_NO_USER: u16 = 7; + +/// RFC 1350's block size, and the floor every implementation understands. +const DEFAULT_BLOCK: usize = 512; +/// **Clamped so a data packet still fits one Ethernet frame.** 1500 minus 20 bytes of +/// IP and 8 of UDP leaves 1472; minus TFTP's own 4-byte header, 1468. Larger merely +/// invites fragmentation, and a fragmented TFTP transfer to a PXE ROM is a coin toss. +const MAX_BLOCK: usize = 1468; +/// A request larger than this is not a request. +const MAX_REQUEST: usize = 1024; +/// How long to wait for an acknowledgement before sending the block again. +const RETRY: Duration = Duration::from_millis(700); +/// Give up on a peer that has stopped acknowledging. +const MAX_RETRIES: u32 = 6; +/// A transfer that has run this long is not a loader fetch any more. +const MAX_TRANSFER: Duration = Duration::from_secs(60); +/// In-flight transfers, in total and per peer. +/// +/// **The per-peer figure is not a hostility threshold, and treating it as one is a bug +/// that only shows up at a reboot.** A PXE ROM that does not hear an answer quickly +/// *retransmits its read request* — a sleeping disk on a NAS is enough to cause it — +/// and each retransmission is a fresh transfer from the same address. Set this to the +/// three or four a suspicious mind suggests and a slow first fetch locks the machine +/// out of the boot server it was retrying to reach. +const MAX_TRANSFERS: usize = 64; +const MAX_PER_PEER: usize = 8; +/// Windowing is offered when asked for, and capped: a ROM that asks for 64 and then +/// mishandles the window turns one lost packet into a stall. +const MAX_WINDOW: u16 = 8; + +pub struct Tftp { + /// The boot-asset directory, canonicalised at start. Every request resolves inside + /// it or is refused. + root: PathBuf, + cfg: Arc, +} + +impl Tftp { + /// Canonicalise the root now, so the containment check below compares two resolved + /// paths rather than two hopeful strings. + pub fn new(root: &Path, cfg: Arc) -> io::Result { + let root = root.canonicalize().map_err(|e| { + io::Error::new( + e.kind(), + format!("cannot use {} as the TFTP root: {e}", root.display()), + ) + })?; + if !root.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + format!("{} is not a directory", root.display()), + )); + } + Ok(Tftp { root, cfg }) + } + + pub fn root(&self) -> &Path { + &self.root + } +} + +/// Accept requests forever. Each one is answered from its **own ephemeral socket**, +/// connected to the peer — which is both what RFC 1350 requires and what stops a +/// transfer hearing from anybody else. +pub async fn serve(socket: UdpSocket, tftp: Arc) { + let permits = Arc::new(Semaphore::new(MAX_TRANSFERS)); + let per_peer: Arc>> = + Arc::new(Default::default()); + let mut buffer = vec![0u8; MAX_REQUEST]; + + loop { + let (n, peer) = match socket.recv_from(&mut buffer).await { + Ok(pair) => pair, + Err(e) => { + log::server(&format!("tftp: recv failed: {e}")); + continue; + } + }; + + // **Never answer a broadcast or multicast destination.** Amplification hygiene: + // a forged request naming a broadcast address would have us shout a megabyte at + // a whole segment. Nothing legitimate asks for a loader that way. + if is_broadcastish(peer.ip()) { + log::request(&peer.to_string(), 0, "tftp: ignored, not a unicast peer"); + continue; + } + if !allowed(&tftp.cfg, peer) { + log::request( + &peer.to_string(), + 403, + "tftp: refused, outside the allowlist", + ); + continue; + } + + let request = buffer[..n].to_vec(); + let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { + log::request(&peer.to_string(), 503, "tftp: at max transfers"); + continue; + }; + // A per-peer cap on top of the global one, so one machine cannot use the whole + // budget — the global cap alone protects the server, not the other clients. + // + // **Only a read request takes a slot.** Anything else — junk, a write request, + // a mode we refuse — is answered with a fixed-size error and costs nothing, so + // a burst of malformed packets cannot shut a peer out of the transfers it is + // entitled to. Counting them was a real defect: four stray datagrams from an + // address locked that address out, and everything in a lab arrives from one. + let is_read = request.len() >= 2 && u16::from_be_bytes([request[0], request[1]]) == OP_RRQ; + if is_read { + let mut counts = per_peer.lock().unwrap_or_else(|e| e.into_inner()); + let count = counts.entry(peer.ip()).or_insert(0); + if *count >= MAX_PER_PEER { + log::request( + &peer.to_string(), + 503, + "tftp: at max transfers for this peer", + ); + continue; + } + *count += 1; + } + + let tftp = Arc::clone(&tftp); + let per_peer = Arc::clone(&per_peer); + tokio::spawn(async move { + let _permit = permit; + // A transfer that outlives this deadline is not a loader fetch any more. + let _ = tokio::time::timeout(MAX_TRANSFER, transfer(&request, peer, &tftp)).await; + if is_read { + let mut counts = per_peer.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(count) = counts.get_mut(&peer.ip()) { + *count = count.saturating_sub(1); + if *count == 0 { + // Bounded by construction: an idle peer leaves no entry behind, + // so the map cannot itself be turned into a memory leak. + counts.remove(&peer.ip()); + } + } + } + }); + } +} + +async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { + // The reply socket is ephemeral and *connected*: RFC 1350 wants the data to come + // from a fresh port, and connecting means this transfer only ever hears its peer. + let bind = if peer.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }; + let Ok(socket) = UdpSocket::bind(bind).await else { + return; + }; + if socket.connect(peer).await.is_err() { + return; + } + + let parsed = match parse_request(request) { + Ok(parsed) => parsed, + Err(refusal) => { + let _ = socket + .send(&error_packet(refusal.code, &refusal.message)) + .await; + log::request(&peer.to_string(), 0, &format!("tftp: {}", refusal.message)); + return; + } + }; + + let path = match resolve(&tftp.root, &parsed.filename) { + Some(path) => path, + None => { + let _ = socket + .send(&error_packet(ERR_NOT_FOUND, "no such file")) + .await; + log::request( + &peer.to_string(), + 404, + &format!("tftp: {} not found", parsed.filename), + ); + return; + } + }; + + let contents = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) => { + let _ = socket + .send(&error_packet(ERR_NOT_FOUND, "cannot read")) + .await; + log::request( + &peer.to_string(), + 500, + &format!("tftp: {} cannot be read: {e}", path.display()), + ); + return; + } + }; + + // Options are negotiated in one OACK, acknowledged with block 0, before any data. + let mut block_size = DEFAULT_BLOCK; + let mut accepted: Vec<(String, String)> = Vec::new(); + for (name, value) in &parsed.options { + match name.as_str() { + "blksize" => { + if let Ok(asked) = value.parse::() { + block_size = asked.clamp(DEFAULT_BLOCK, MAX_BLOCK); + accepted.push(("blksize".to_string(), block_size.to_string())); + } + } + // A number of ROMs will not proceed without being told the size up front. + "tsize" => accepted.push(("tsize".to_string(), contents.len().to_string())), + "timeout" => { + if let Ok(seconds) = value.parse::() + && (1..=255).contains(&seconds) + { + accepted.push(("timeout".to_string(), seconds.to_string())); + } + } + "windowsize" => { + if let Ok(asked) = value.parse::() + && asked >= 1 + { + accepted.push(("windowsize".to_string(), asked.min(MAX_WINDOW).to_string())); + } + } + // An option we do not implement is left out of the OACK, which is exactly + // how RFC 2347 says to decline one. + _ => {} + } + } + + if !accepted.is_empty() { + let mut oack = vec![0, OP_OACK as u8]; + for (name, value) in &accepted { + oack.extend_from_slice(name.as_bytes()); + oack.push(0); + oack.extend_from_slice(value.as_bytes()); + oack.push(0); + } + if socket.send(&oack).await.is_err() { + return; + } + // The client acknowledges the option set with block 0 before data starts. + if !wait_for_ack(&socket, 0, &oack).await { + return; + } + } + + log::request( + &peer.to_string(), + 200, + &format!( + "tftp: {} {} bytes blksize={block_size}", + parsed.filename, + contents.len() + ), + ); + + let mut block: u16 = 1; + let mut sent = 0usize; + loop { + let end = (sent + block_size).min(contents.len()); + let chunk = &contents[sent..end]; + // **A short block is what ends a transfer**, and "short" includes empty. A file + // whose length is an exact multiple of the block size therefore ends with a + // data packet carrying nothing — leave it out and the client waits forever for + // a final block that never comes. An empty file is the same case at zero. + let last = chunk.len() < block_size; + + let mut packet = Vec::with_capacity(4 + chunk.len()); + packet.extend_from_slice(&OP_DATA.to_be_bytes()); + packet.extend_from_slice(&block.to_be_bytes()); + packet.extend_from_slice(chunk); + + if socket.send(&packet).await.is_err() { + return; + } + if !wait_for_ack(&socket, block, &packet).await { + return; + } + + sent = end; + if last { + break; + } + // Block numbers are 16 bits and wrap. A loader will never reach 65535 at 1468 + // bytes a block; be correct anyway, because "never" is how this kind of bug + // gets in. + block = block.wrapping_add(1); + } +} + +/// Wait for the acknowledgement of `block`, resending on silence. +/// +/// **A duplicate acknowledgement — one for a block already acknowledged — is ignored, +/// never answered.** That is the Sorcerer's Apprentice bug: answering a duplicate with +/// a duplicate makes both sides echo each other and doubles the traffic for the rest of +/// the transfer. +async fn wait_for_ack(socket: &UdpSocket, block: u16, resend: &[u8]) -> bool { + let mut buffer = [0u8; 64]; + for _ in 0..MAX_RETRIES { + match tokio::time::timeout(RETRY, socket.recv(&mut buffer)).await { + Ok(Ok(n)) if n >= 4 => { + let opcode = u16::from_be_bytes([buffer[0], buffer[1]]); + let acked = u16::from_be_bytes([buffer[2], buffer[3]]); + if opcode == OP_ERROR { + return false; + } + if opcode == OP_ACK { + if acked == block { + return true; + } + // An older block: a duplicate. Say nothing and keep waiting. + continue; + } + // Anything else on this socket is not part of the conversation. + continue; + } + Ok(Ok(_)) => continue, + Ok(Err(_)) => return false, + // Silence: the block was lost, or the acknowledgement was. Send it again. + Err(_) => { + if socket.send(resend).await.is_err() { + return false; + } + } + } + } + false +} + +struct Request { + filename: String, + options: Vec<(String, String)>, +} + +struct Refusal { + code: u16, + message: String, +} + +fn parse_request(bytes: &[u8]) -> Result { + if bytes.len() < 4 { + return Err(Refusal { + code: ERR_ILLEGAL, + message: "truncated request".to_string(), + }); + } + let opcode = u16::from_be_bytes([bytes[0], bytes[1]]); + if opcode == OP_WRQ { + // Read-only, and this is the whole enforcement. Writing a loader over UDP with + // no authentication would be a way to change what every machine boots. + return Err(Refusal { + code: ERR_ACCESS, + message: "this server is read-only".to_string(), + }); + } + if opcode != OP_RRQ { + return Err(Refusal { + code: ERR_ILLEGAL, + message: format!("opcode {opcode} is not a read request"), + }); + } + + let mut fields = bytes[2..].split(|b| *b == 0).map(|f| f.to_vec()); + let filename = fields + .next() + .and_then(|f| String::from_utf8(f).ok()) + .filter(|f| !f.is_empty()) + .ok_or_else(|| Refusal { + code: ERR_ILLEGAL, + message: "no filename".to_string(), + })?; + let mode = fields + .next() + .and_then(|f| String::from_utf8(f).ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + + // **`netascii` is refused rather than mistranslated.** It rewrites line endings, and + // a loader is a binary: silently corrupting one produces a machine that fetches + // something and then does nothing anybody can explain. + if mode != "octet" { + return Err(Refusal { + code: ERR_NO_USER, + message: format!("mode {mode:?} is not octet; a loader is binary"), + }); + } + + let mut options = Vec::new(); + loop { + let (Some(name), Some(value)) = (fields.next(), fields.next()) else { + break; + }; + let (Ok(name), Ok(value)) = (String::from_utf8(name), String::from_utf8(value)) else { + break; + }; + if name.is_empty() { + break; + } + options.push((name.to_ascii_lowercase(), value)); + } + + Ok(Request { filename, options }) +} + +/// Resolve a requested name inside the root, or refuse. +/// +/// Two guards, and the second is the one that matters: the name is stripped of anything +/// that could climb, **and then the resolved path is checked to still be inside the +/// canonicalised root**. A symlink pointing out of the tree is caught by the second +/// check even though it passes the first. +fn resolve(root: &Path, filename: &str) -> Option { + let relative = filename.trim_start_matches(['/', '\\']); + if relative.is_empty() { + return None; + } + let mut path = root.to_path_buf(); + for segment in relative.split(['/', '\\']) { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return None; + } + path.push(segment); + } + let resolved = path.canonicalize().ok()?; + resolved + .starts_with(root) + .then_some(resolved) + .filter(|p| p.is_file()) +} + +fn error_packet(code: u16, message: &str) -> Vec { + let mut packet = Vec::with_capacity(5 + message.len()); + packet.extend_from_slice(&OP_ERROR.to_be_bytes()); + packet.extend_from_slice(&code.to_be_bytes()); + packet.extend_from_slice(message.as_bytes()); + packet.push(0); + packet +} + +/// Whether an address is one nothing legitimate asks for a loader from. +fn is_broadcastish(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => v4.is_broadcast() || v4.is_multicast() || v4.is_unspecified(), + IpAddr::V6(v6) => v6.is_multicast() || v6.is_unspecified(), + } +} + +fn allowed(cfg: &Config, peer: SocketAddr) -> bool { + let Some(list) = &cfg.boot_allow else { + return true; + }; + list.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .any(|cidr| super::media::in_cidr(peer.ip(), cidr)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rrq(filename: &str, mode: &str, options: &[(&str, &str)]) -> Vec { + let mut packet = vec![0, OP_RRQ as u8]; + packet.extend_from_slice(filename.as_bytes()); + packet.push(0); + packet.extend_from_slice(mode.as_bytes()); + packet.push(0); + for (name, value) in options { + packet.extend_from_slice(name.as_bytes()); + packet.push(0); + packet.extend_from_slice(value.as_bytes()); + packet.push(0); + } + packet + } + + #[test] + fn a_read_request_is_parsed_with_its_options() { + let parsed = parse_request(&rrq( + "ipxe-undionly.kpxe", + "octet", + &[("blksize", "1468"), ("tsize", "0")], + )) + .unwrap_or_else(|e| panic!("{}", e.message)); + assert_eq!(parsed.filename, "ipxe-undionly.kpxe"); + assert_eq!( + parsed.options, + vec![ + ("blksize".to_string(), "1468".to_string()), + ("tsize".to_string(), "0".to_string()) + ] + ); + } + + #[test] + fn a_write_request_is_refused_as_an_access_violation() { + // Read-only is the whole posture. Writing a loader over unauthenticated UDP + // would be a way to change what every machine on the segment boots. + let e = parse_request( + &rrq("evil.kpxe", "octet", &[]) + .iter() + .enumerate() + .map(|(i, b)| if i == 1 { OP_WRQ as u8 } else { *b }) + .collect::>(), + ) + .err() + .expect("must refuse"); + assert_eq!(e.code, ERR_ACCESS); + assert!(e.message.contains("read-only"), "{}", e.message); + } + + #[test] + fn netascii_is_refused_rather_than_mistranslated() { + // It rewrites line endings. Silently corrupting a loader produces a machine + // that fetches something and then does nothing anybody can explain. + let e = parse_request(&rrq("ipxe.kpxe", "netascii", &[])) + .err() + .expect("must refuse"); + assert!(e.message.contains("octet"), "{}", e.message); + } + + #[test] + fn a_truncated_or_nonsense_request_is_refused_rather_than_panicking() { + assert!(parse_request(&[]).is_err()); + assert!(parse_request(&[0, 1]).is_err()); + assert!(parse_request(&[0, 9, b'x', 0, b'o', 0]).is_err()); + // A request with no filename at all. + assert!(parse_request(&[0, 1, 0, b'o', b'c', b't', b'e', b't', 0]).is_err()); + } + + /// A root with one file in it, plus a nested directory, to resolve against. + fn root() -> PathBuf { + let dir = std::env::temp_dir().join(format!("rescriptum-tftp-{}", std::process::id())); + std::fs::create_dir_all(dir.join("nested")).expect("temp dir"); + std::fs::write(dir.join("ipxe-undionly.kpxe"), b"loader").expect("write"); + std::fs::write(dir.join("nested/deeper.efi"), b"loader").expect("write"); + dir.canonicalize().expect("canonical") + } + + #[test] + fn a_name_resolves_inside_the_root() { + let root = root(); + assert!(resolve(&root, "ipxe-undionly.kpxe").is_some()); + assert!( + resolve(&root, "/ipxe-undionly.kpxe").is_some(), + "a leading slash is fine" + ); + assert!(resolve(&root, "nested/deeper.efi").is_some()); + assert!( + resolve(&root, "nested\\deeper.efi").is_some(), + "some ROMs send backslashes" + ); + } + + #[test] + fn nothing_resolves_outside_the_root() { + let root = root(); + for name in [ + "../../../etc/passwd", + "nested/../../etc/passwd", + "/etc/passwd", + "..", + "", + ] { + assert!(resolve(&root, name).is_none(), "{name} escaped the root"); + } + // And a directory is not a file to send. + assert!(resolve(&root, "nested").is_none()); + } + + #[test] + fn a_broadcast_or_multicast_peer_is_never_answered() { + // Amplification hygiene rather than politeness: a forged request naming a + // broadcast address would have us shout a megabyte at a whole segment. + for ip in ["255.255.255.255", "224.0.0.1", "0.0.0.0"] { + assert!(is_broadcastish(ip.parse().expect("address")), "{ip}"); + } + for ip in ["10.0.0.5", "192.168.1.1"] { + assert!(!is_broadcastish(ip.parse().expect("address")), "{ip}"); + } + assert!(is_broadcastish("ff02::1".parse().expect("address"))); + assert!(!is_broadcastish("2001:db8::1".parse().expect("address"))); + } + + #[test] + fn the_block_size_is_clamped_to_one_ethernet_frame() { + // 1500 − 20 (IP) − 8 (UDP) − 4 (TFTP) = 1468. Larger merely invites + // fragmentation, and a fragmented transfer to a PXE ROM is a coin toss. + assert_eq!(MAX_BLOCK, 1468); + assert_eq!(2048usize.clamp(DEFAULT_BLOCK, MAX_BLOCK), 1468); + assert_eq!(8usize.clamp(DEFAULT_BLOCK, MAX_BLOCK), 512); + assert_eq!(1024usize.clamp(DEFAULT_BLOCK, MAX_BLOCK), 1024); + } + + #[test] + fn a_root_that_is_not_a_directory_is_refused_at_startup() { + let cfg = Arc::new(Config::from_lookup(|_| None)); + assert!(Tftp::new(Path::new("/nonexistent/rescriptum/boot"), Arc::clone(&cfg)).is_err()); + } +} diff --git a/src/config.rs b/src/config.rs index 66c7408..3ac5d5b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,13 @@ pub const DEFAULT_MEDIA_TIMEOUT_SECS: u64 = 600; /// Concurrent transfers, low on purpose. A download holds its permit for minutes, and /// the small end of the range this has to work on is a NAS with one spinning disk. pub const DEFAULT_MEDIA_MAX_CONNECTIONS: usize = 16; +/// TFTP's well-known port, and privileged. It is the only privileged port this server +/// ever wants — with no DHCP responder there is nothing after 67 or 4011. +pub const DEFAULT_TFTP_ADDR: &str = "0.0.0.0:69"; +/// Seconds the built-in menu waits before falling through to local boot. **The name +/// spells the unit because `choose` counts milliseconds**: a seconds value passed +/// through unconverted is a menu that flashes past before a human has read its title. +pub const DEFAULT_BOOT_TIMEOUT_SECS: u64 = 15; /// Where answers are read from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -103,6 +110,21 @@ pub struct Config { pub media_max_connections: usize, /// A CIDR allowlist for boot traffic. Unset means anyone who can reach the port. pub boot_allow: Option, + /// Loaders and menus — what TFTP hands out. **Unset means no TFTP at all**, the + /// same off switch shape the media directory has. + pub boot_dir: Option, + /// The TFTP listener, as the operator set it. `None` means nobody did; see + /// `tftp_addr()`. + pub tftp_addr: Option, + /// Seconds before the built-in menu falls through to booting from local disk. + pub boot_timeout: Duration, + /// Replace the embedded logo and the menu's title, for a site that wants its own. + pub boot_logo: Option, + pub boot_title: Option, + /// Drop to this user and group **after** binding. Binding first is the whole point: + /// the other order works as root in testing and fails on deployment. + pub user: Option, + pub group: Option, } impl Config { @@ -224,6 +246,16 @@ impl Config { DEFAULT_MEDIA_MAX_CONNECTIONS, ), boot_allow: optional("RESCRIPTUM_BOOT_ALLOW"), + boot_dir: optional("RESCRIPTUM_BOOT_DIR").map(PathBuf::from), + tftp_addr: optional("RESCRIPTUM_TFTP_ADDR"), + boot_timeout: Duration::from_secs(get_usize( + "RESCRIPTUM_BOOT_TIMEOUT_SECS", + DEFAULT_BOOT_TIMEOUT_SECS as usize, + ) as u64), + boot_logo: optional("RESCRIPTUM_BOOT_LOGO").map(PathBuf::from), + boot_title: optional("RESCRIPTUM_BOOT_TITLE"), + user: optional("RESCRIPTUM_USER"), + group: optional("RESCRIPTUM_GROUP"), } } } @@ -310,6 +342,14 @@ impl Config { } } + if self.tftp_addr.is_some() && self.boot_dir.is_none() { + return Err(format!( + "RESCRIPTUM_TFTP_ADDR is set ({}), but RESCRIPTUM_BOOT_DIR is not. There would \ + be a listener with no loaders to hand out.", + self.tftp_addr.as_deref().unwrap_or_default() + )); + } + if self.media_addr.is_some() && self.media_dir.is_none() { return Err(format!( "RESCRIPTUM_MEDIA_ADDR is set ({}), but RESCRIPTUM_MEDIA_DIR is not. There \ @@ -342,6 +382,19 @@ impl Config { Ok(()) } + /// The TFTP listener's effective address. + pub fn tftp_addr(&self) -> String { + self.tftp_addr + .clone() + .unwrap_or_else(|| DEFAULT_TFTP_ADDR.to_string()) + } + + /// The menu timeout **in milliseconds**, which is the unit `choose` counts. The + /// conversion has exactly one place, and this is it. + pub fn boot_timeout_millis(&self) -> u64 { + self.boot_timeout.as_millis() as u64 + } + /// The media listener's effective address. pub fn media_addr(&self) -> String { self.media_addr @@ -481,7 +534,7 @@ pub struct Known { /// Every variable, in the order a person would want to meet them: what answers come /// from, where the server listens, how much it says, then the two credentials. -pub const KNOWN: [Known; 19] = [ +pub const KNOWN: [Known; 26] = [ Known { key: "RESCRIPTUM_STORE", default: Some("files"), @@ -598,6 +651,48 @@ pub const KNOWN: [Known; 19] = [ secret: false, help: "Client CIDRs allowed to fetch boot media. Unset means anyone who can reach it.", }, + Known { + key: "RESCRIPTUM_BOOT_DIR", + default: None, + secret: false, + help: "Loaders and menus, handed out over TFTP. Unset means no TFTP at all.", + }, + Known { + key: "RESCRIPTUM_TFTP_ADDR", + default: Some(DEFAULT_TFTP_ADDR), + secret: false, + help: "The TFTP listener. Port 69 is privileged; see RESCRIPTUM_USER.", + }, + Known { + key: "RESCRIPTUM_BOOT_TIMEOUT_SECS", + default: Some("15"), + secret: false, + help: "Seconds before the menu falls through to local disk. Rendered as milliseconds.", + }, + Known { + key: "RESCRIPTUM_BOOT_LOGO", + default: None, + secret: false, + help: "A PNG to show behind the menu, replacing the built-in one.", + }, + Known { + key: "RESCRIPTUM_BOOT_TITLE", + default: None, + secret: false, + help: "The menu's title bar, replacing the built-in one.", + }, + Known { + key: "RESCRIPTUM_USER", + default: None, + secret: false, + help: "Drop to this user after binding. Binding first is the point.", + }, + Known { + key: "RESCRIPTUM_GROUP", + default: None, + secret: false, + help: "Drop to this group after binding.", + }, ]; /// Which of the three places a value came from. diff --git a/src/envfile.rs b/src/envfile.rs index dda444b..af993f5 100644 --- a/src/envfile.rs +++ b/src/envfile.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; pub const ENV_FILE: &str = "RESCRIPTUM_ENV_FILE"; /// Every variable this program reads, so a typo can be reported rather than ignored. -pub const KNOWN_KEYS: [&str; 19] = [ +pub const KNOWN_KEYS: [&str; 26] = [ "RESCRIPTUM_STORE", "RESCRIPTUM_ANSWERS_DIR", "RESCRIPTUM_DB_PATH", @@ -48,6 +48,13 @@ pub const KNOWN_KEYS: [&str; 19] = [ "RESCRIPTUM_MEDIA_TIMEOUT_SECS", "RESCRIPTUM_MEDIA_MAX_CONNECTIONS", "RESCRIPTUM_BOOT_ALLOW", + "RESCRIPTUM_BOOT_DIR", + "RESCRIPTUM_TFTP_ADDR", + "RESCRIPTUM_BOOT_TIMEOUT_SECS", + "RESCRIPTUM_BOOT_LOGO", + "RESCRIPTUM_BOOT_TITLE", + "RESCRIPTUM_USER", + "RESCRIPTUM_GROUP", ]; /// A loaded file: the values it set, and anything worth saying about it out loud. diff --git a/src/main.rs b/src/main.rs index 1bfabaf..bde02e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -204,6 +204,56 @@ async fn serve(cfg: Arc) -> ExitCode { tokio::spawn(rescriptum::boot::media::serve(media_listener, media)); } + // TFTP, if a boot directory was named. It hands over **one file** — the loader — + // and everything after that is HTTP: at 1468 bytes a round-trip, an image would + // take twenty minutes where HTTP takes fifteen seconds. + #[cfg(feature = "boot")] + if let Some(dir) = cfg.boot_dir.clone() { + let tftp = match rescriptum::boot::tftp::Tftp::new(&dir, Arc::clone(&cfg)) { + Ok(tftp) => Arc::new(tftp), + // Fatal: a boot directory that cannot be resolved is not something that + // fixes itself, and every path check below compares against it. + Err(e) => { + log::server(&format!("configuration error: {e}")); + return ExitCode::FAILURE; + } + }; + let addr = cfg.tftp_addr(); + let socket = match tokio::net::UdpSocket::bind(&addr).await { + Ok(socket) => socket, + Err(e) => { + log::server(&format!( + "cannot bind TFTP on {addr}: {e}{}", + if addr.ends_with(":69") { + " — port 69 is privileged; run as root and set RESCRIPTUM_USER to \ + drop afterwards, use setcap, or choose another port" + } else { + "" + } + )); + return ExitCode::FAILURE; + } + }; + let bound = socket + .local_addr() + .map(|a| a.to_string()) + .unwrap_or_else(|_| addr.clone()); + log::server(&format!( + "tftp listening on {bound} — serving {}", + tftp.root().display() + )); + tokio::spawn(rescriptum::boot::tftp::serve(socket, tftp)); + } + + // **Bind everything first, then drop.** The other order works as root in testing + // and fails on deployment, which is the bug this ordering exists to prevent. + #[cfg(feature = "boot")] + if let Err(e) = rescriptum::boot::privileges::drop_to(cfg.user.as_deref(), cfg.group.as_deref()) + { + log::server(&format!("configuration error: {e}")); + return ExitCode::FAILURE; + } + // Report the address actually bound, not the one requested: with `:0` (used by the // integration tests, and handy for debugging) they differ. let bound = listener diff --git a/tests/tftp.rs b/tests/tftp.rs new file mode 100644 index 0000000..e6169a5 --- /dev/null +++ b/tests/tftp.rs @@ -0,0 +1,633 @@ +//! TFTP against the real binary, over real UDP. +//! +//! Nothing here can be proved from inside a function. A transfer is a conversation — +//! blocks, acknowledgements, retransmission, the empty packet that ends it — and every +//! bug this file exists to catch lives in the turn-taking rather than in the parsing. +//! +//! The one that motivated writing it: **a file whose length is an exact multiple of the +//! block size must end with an empty data packet.** Leave it out and the client waits +//! forever for a final block that never comes. The unit tests could not see it; the +//! first run of `a_file_that_is_an_exact_multiple_of_the_block_size_still_ends` did. + +#![cfg(feature = "boot")] + +use std::fs; +use std::io::{BufRead, BufReader}; +use std::net::UdpSocket; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const OP_RRQ: u16 = 1; +const OP_WRQ: u16 = 2; +const OP_DATA: u16 = 3; +const OP_ACK: u16 = 4; +const OP_ERROR: u16 = 5; +const OP_OACK: u16 = 6; + +struct Server { + child: Child, + tftp_addr: String, + boot_dir: PathBuf, + log: Arc>>, +} + +impl Server { + fn start(files: &[(&str, Vec)]) -> Server { + Server::start_env(files, &[]) + } + + fn start_env(files: &[(&str, Vec)], env: &[(&str, &str)]) -> Server { + static N: AtomicUsize = AtomicUsize::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let base = + std::env::temp_dir().join(format!("rescriptum-tftp-it-{}-{n}", std::process::id())); + let boot_dir = base.join("boot"); + let answers_dir = base.join("answers"); + fs::create_dir_all(&boot_dir).expect("boot dir"); + fs::create_dir_all(&answers_dir).expect("answers dir"); + for (name, bytes) in files { + let path = boot_dir.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("nested"); + } + fs::write(&path, bytes).expect("write"); + } + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rescriptum")); + cmd.env("RESCRIPTUM_LISTEN_ADDR", "127.0.0.1:0") + .env("RESCRIPTUM_ANSWERS_DIR", &answers_dir) + .env("RESCRIPTUM_BOOT_DIR", &boot_dir) + // Port 69 is privileged and the test suite is not root. + .env("RESCRIPTUM_TFTP_ADDR", "127.0.0.1:0") + .stderr(Stdio::piped()) + .stdout(Stdio::null()); + for (key, value) in env { + cmd.env(key, value); + } + let mut child = cmd.spawn().expect("spawn server"); + + let stderr = child.stderr.take().expect("piped stderr"); + let mut lines = BufReader::new(stderr).lines(); + let log: Arc>> = Arc::new(Mutex::new(Vec::new())); + let mut tftp_addr = None; + for _ in 0..16 { + let Some(Ok(line)) = lines.next() else { break }; + if line.contains("tftp listening on") + && let Some(rest) = line.split("listening on ").nth(1) + { + tftp_addr = Some( + rest.split_whitespace() + .next() + .unwrap_or_default() + .to_string(), + ); + } + let done = tftp_addr.is_some() && line.contains("rescriptum "); + log.lock().unwrap().push(line); + if done { + break; + } + } + let collected = Arc::clone(&log); + std::thread::spawn(move || { + for line in lines.map_while(Result::ok) { + collected.lock().unwrap().push(line); + } + }); + + let tftp_addr = + tftp_addr.unwrap_or_else(|| panic!("no tftp address; saw {:#?}", log.lock().unwrap())); + Server { + child, + tftp_addr, + boot_dir, + log, + } + } + + /// What the server has said. Worth surfacing in an assertion: a TFTP failure that + /// only shows as silence is indistinguishable from a dead server, and that is as + /// true for a test as it is at power-on. + fn log(&self) -> String { + self.log.lock().unwrap().join("\n") + } + + fn client(&self) -> Client { + let socket = UdpSocket::bind("127.0.0.1:0").expect("bind client"); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("timeout"); + Client { + socket, + server: self.tftp_addr.clone(), + peer: None, + } + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = fs::remove_dir_all(self.boot_dir.parent().unwrap_or(&self.boot_dir)); + } +} + +/// A TFTP client, hand-rolled, because the point is to speak the protocol rather than +/// to trust a library that speaks it the same way the server does. +struct Client { + socket: UdpSocket, + server: String, + /// The ephemeral port the server answered from. RFC 1350 requires the data to come + /// from a *new* port, and the rest of the conversation goes there. + peer: Option, +} + +impl Client { + fn request(&mut self, opcode: u16, filename: &str, mode: &str, options: &[(&str, &str)]) { + let mut packet = Vec::new(); + packet.extend_from_slice(&opcode.to_be_bytes()); + packet.extend_from_slice(filename.as_bytes()); + packet.push(0); + packet.extend_from_slice(mode.as_bytes()); + packet.push(0); + for (name, value) in options { + packet.extend_from_slice(name.as_bytes()); + packet.push(0); + packet.extend_from_slice(value.as_bytes()); + packet.push(0); + } + self.socket + .send_to(&packet, &self.server) + .expect("send request"); + } + + fn read(&mut self, filename: &str, options: &[(&str, &str)]) { + self.request(OP_RRQ, filename, "octet", options); + } + + /// One packet from the server, and remember which port it came from. + fn receive(&mut self) -> Option<(u16, Vec)> { + let mut buffer = vec![0u8; 2048]; + let (n, from) = self.socket.recv_from(&mut buffer).ok()?; + self.peer = Some(from); + if n < 2 { + return None; + } + let opcode = u16::from_be_bytes([buffer[0], buffer[1]]); + Some((opcode, buffer[2..n].to_vec())) + } + + fn ack(&mut self, block: u16) { + let mut packet = Vec::with_capacity(4); + packet.extend_from_slice(&OP_ACK.to_be_bytes()); + packet.extend_from_slice(&block.to_be_bytes()); + let peer = self.peer.expect("the server has answered"); + self.socket.send_to(&packet, peer).expect("send ack"); + } + + /// Run a whole transfer, acknowledging as a real client would, and return what came + /// back plus how many data packets it took. + fn fetch( + &mut self, + filename: &str, + options: &[(&str, &str)], + ) -> Result<(Vec, usize), String> { + self.read(filename, options); + let mut body = Vec::new(); + let mut packets = 0usize; + let mut expected: u16 = 1; + + loop { + let Some((opcode, payload)) = self.receive() else { + return Err(format!("no reply after {packets} data packet(s)")); + }; + match opcode { + OP_OACK => { + // Options accepted: acknowledge the set with block zero, then data + // starts. + self.ack(0); + continue; + } + OP_ERROR => { + let code = u16::from_be_bytes([payload[0], payload[1]]); + let message = String::from_utf8_lossy(&payload[2..]) + .trim_end_matches('\0') + .to_string(); + return Err(format!("error {code}: {message}")); + } + OP_DATA => { + let block = u16::from_be_bytes([payload[0], payload[1]]); + let data = &payload[2..]; + assert_eq!(block, expected, "blocks must arrive in order"); + packets += 1; + body.extend_from_slice(data); + self.ack(block); + // A short block ends the transfer, and "short" includes empty. + let block_size = options + .iter() + .find(|(k, _)| *k == "blksize") + .and_then(|(_, v)| v.parse::().ok()) + .unwrap_or(512); + if data.len() < block_size { + return Ok((body, packets)); + } + expected = expected.wrapping_add(1); + } + other => return Err(format!("unexpected opcode {other}")), + } + } + } +} + +fn loader(size: usize) -> Vec { + // Not zeros: a truncated or spliced transfer has to be visible in the bytes. + (0..size).map(|i| (i % 251) as u8).collect() +} + +// ---- the transfer itself -------------------------------------------------- + +#[test] +fn a_loader_comes_back_byte_for_byte() { + let bytes = loader(3000); + let s = Server::start(&[("ipxe-undionly.kpxe", bytes.clone())]); + let (body, packets) = s + .client() + .fetch("ipxe-undionly.kpxe", &[]) + .expect("a transfer"); + + assert_eq!(body, bytes); + // 3000 bytes at 512 a block: five full blocks and a short one. + assert_eq!(packets, 6); +} + +#[test] +fn a_file_that_is_an_exact_multiple_of_the_block_size_still_ends() { + // **The bug this file was written for.** A short block ends a transfer, and a file + // that divides exactly has no short block — so the transfer has to end with an + // *empty* one. Leave it out and a real ROM waits forever for a final block that + // never comes, which looks like a dead server rather than an off-by-one. + for size in [512usize, 1024, 1468 * 2] { + let block = if size % 512 == 0 { 512 } else { 1468 }; + let options = [("blksize", block.to_string())]; + let options: Vec<(&str, &str)> = options.iter().map(|(k, v)| (*k, v.as_str())).collect(); + + let bytes = loader(size); + let s = Server::start(&[("loader.kpxe", bytes.clone())]); + let (body, packets) = s + .client() + .fetch("loader.kpxe", &options) + .unwrap_or_else(|e| panic!("{size} bytes at {block}: {e}")); + + assert_eq!(body, bytes, "{size} bytes"); + assert_eq!( + packets, + size / block + 1, + "{size} bytes at {block} must end with an empty packet" + ); + } +} + +#[test] +fn an_empty_file_is_one_empty_packet() { + let s = Server::start(&[("empty.kpxe", Vec::new())]); + let (body, packets) = s.client().fetch("empty.kpxe", &[]).expect("a transfer"); + assert!(body.is_empty()); + assert_eq!(packets, 1); +} + +// ---- options -------------------------------------------------------------- + +#[test] +fn the_options_real_roms_need_are_negotiated() { + // RFC 1350 alone is not enough. `blksize` because 512-byte blocks make a megabyte + // 2,000 round-trips, and `tsize` because a number of ROMs will not proceed without + // being told the size up front. + let bytes = loader(5000); + let s = Server::start(&[("ipxe.kpxe", bytes.clone())]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1468"), ("tsize", "0")]); + + let (opcode, payload) = client.receive().expect("a reply"); + assert_eq!(opcode, OP_OACK, "options must be acknowledged before data"); + + let fields: Vec = payload + .split(|b| *b == 0) + .filter(|f| !f.is_empty()) + .map(|f| String::from_utf8_lossy(f).to_string()) + .collect(); + let pairs: Vec<(&str, &str)> = fields + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| (c[0].as_str(), c[1].as_str())) + .collect(); + assert!(pairs.contains(&("blksize", "1468")), "{pairs:?}"); + assert!( + pairs.contains(&("tsize", "5000")), + "tsize must be the real length: {pairs:?}" + ); +} + +#[test] +fn an_oversized_block_is_clamped_to_one_ethernet_frame() { + // 1500 − 20 (IP) − 8 (UDP) − 4 (TFTP) = 1468. Anything larger invites + // fragmentation, and a fragmented transfer to a PXE ROM is a coin toss. + let s = Server::start(&[("ipxe.kpxe", loader(4000))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "9000")]); + + let (opcode, payload) = client.receive().expect("a reply"); + assert_eq!(opcode, OP_OACK); + assert!( + String::from_utf8_lossy(&payload).contains("1468"), + "{:?}", + String::from_utf8_lossy(&payload) + ); +} + +#[test] +fn an_option_we_do_not_implement_is_simply_left_out() { + // RFC 2347's way of declining: name only what you accepted. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1024"), ("mtftp", "yes")]); + + let (opcode, payload) = client.receive().expect("a reply"); + assert_eq!(opcode, OP_OACK); + let text = String::from_utf8_lossy(&payload); + assert!(text.contains("1024"), "{text}"); + assert!(!text.contains("mtftp"), "{text}"); +} + +// ---- refusals ------------------------------------------------------------- + +#[test] +fn a_write_request_is_refused_and_the_server_keeps_serving() { + // Read-only is the posture, and writing a loader over unauthenticated UDP would be + // a way to change what every machine on the segment boots. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + let mut client = s.client(); + client.request(OP_WRQ, "evil.kpxe", "octet", &[]); + + let (opcode, payload) = client.receive().expect("a reply"); + assert_eq!(opcode, OP_ERROR); + assert_eq!( + u16::from_be_bytes([payload[0], payload[1]]), + 2, + "access violation" + ); + + assert!(s.client().fetch("ipxe.kpxe", &[]).is_ok(), "still serving"); +} + +#[test] +fn netascii_is_refused_rather_than_corrupting_a_binary() { + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + let mut client = s.client(); + client.request(OP_RRQ, "ipxe.kpxe", "netascii", &[]); + + let (opcode, payload) = client.receive().expect("a reply"); + assert_eq!(opcode, OP_ERROR); + assert!( + String::from_utf8_lossy(&payload).contains("octet"), + "the reason has to name the mode we do take" + ); +} + +#[test] +fn nothing_outside_the_root_can_be_fetched() { + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + for name in [ + "../../../etc/passwd", + "/etc/passwd", + "subdir/../../etc/passwd", + ] { + let e = s + .client() + .fetch(name, &[]) + .expect_err(&format!("{name} must be refused")); + assert!(e.contains("error 1"), "{name}: {e}"); + } + assert!(s.client().fetch("ipxe.kpxe", &[]).is_ok(), "still serving"); +} + +#[test] +fn a_missing_file_is_an_error_rather_than_silence() { + // Silence would look identical to a dead server, and at power-on nobody can tell. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + let e = s.client().fetch("nope.kpxe", &[]).expect_err("must refuse"); + assert!(e.contains("error 1"), "{e}"); +} + +#[test] +fn a_nonsense_packet_does_not_stop_the_server() { + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + { + let socket = UdpSocket::bind("127.0.0.1:0").expect("bind"); + for junk in [ + &b""[..], + &b"\x00"[..], + &b"\xff\xff\xff\xff"[..], + &[0u8; 900][..], + ] { + let _ = socket.send_to(junk, &s.tftp_addr); + } + } + assert!( + s.client().fetch("ipxe.kpxe", &[]).is_ok(), + "still serving; the server said:\n{}", + s.log() + ); +} + +// ---- the conversation's own hazards --------------------------------------- + +#[test] +fn a_duplicate_acknowledgement_is_never_answered() { + // **The Sorcerer's Apprentice bug.** Answering a duplicate acknowledgement with a + // duplicate data packet makes both sides echo each other, and the transfer's + // traffic doubles for as long as it lasts. The fix is to ignore it — which means + // acknowledging block 1 twice must produce exactly one block 2, not two. + let s = Server::start(&[("ipxe.kpxe", loader(2000))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[]); + + let (opcode, payload) = client.receive().expect("block 1"); + assert_eq!(opcode, OP_DATA); + assert_eq!(u16::from_be_bytes([payload[0], payload[1]]), 1); + + client.ack(1); + client.ack(1); // the duplicate + + let (opcode, payload) = client.receive().expect("block 2"); + assert_eq!(opcode, OP_DATA); + assert_eq!( + u16::from_be_bytes([payload[0], payload[1]]), + 2, + "the next block, not a repeat of the first" + ); + + // And nothing else is in flight: the next thing to arrive is block 3, after we ask. + client.ack(2); + let (_, payload) = client.receive().expect("block 3"); + assert_eq!( + u16::from_be_bytes([payload[0], payload[1]]), + 3, + "a second block 2 would mean the duplicate was answered" + ); +} + +#[test] +fn a_lost_acknowledgement_is_recovered_by_retransmission() { + // UDP correctness never exercised under loss is a hope, not a property. Here the + // loss is induced: the client simply does not acknowledge block 1 the first time, + // and the server has to send it again. + let s = Server::start(&[("ipxe.kpxe", loader(2000))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[]); + + let (_, first) = client.receive().expect("block 1"); + assert_eq!(u16::from_be_bytes([first[0], first[1]]), 1); + // Say nothing. The server's retry timer must fire and resend the same block. + + let (opcode, again) = client.receive().expect("block 1, again"); + assert_eq!(opcode, OP_DATA); + assert_eq!( + u16::from_be_bytes([again[0], again[1]]), + 1, + "the same block" + ); + assert_eq!(again, first, "byte for byte, not a fresh read"); + + // And acknowledging it now still finishes the transfer. + client.ack(1); + let (_, payload) = client.receive().expect("block 2"); + assert_eq!(u16::from_be_bytes([payload[0], payload[1]]), 2); +} + +#[test] +fn the_data_comes_from_a_fresh_port() { + // RFC 1350 requires it, and it is what lets the server hold many transfers at once + // on one well-known port. A server answering from port 69 would work against one + // client and fail against two. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[]); + client.receive().expect("a reply"); + + let from = client.peer.expect("answered"); + let listener: std::net::SocketAddr = s.tftp_addr.parse().expect("address"); + assert_ne!(from.port(), listener.port(), "the data port must be fresh"); +} + +#[test] +fn two_transfers_at_once_do_not_cross_over() { + // The failure this pins is the one that installs the wrong machine: two clients, + // two files, and each must receive only its own bytes. + let a = loader(1500); + let b: Vec = loader(1500).iter().map(|x| x ^ 0xff).collect(); + let s = Server::start(&[("a.kpxe", a.clone()), ("b.kpxe", b.clone())]); + + let mut first = s.client(); + let mut second = s.client(); + first.read("a.kpxe", &[]); + second.read("b.kpxe", &[]); + + let mut got_a = Vec::new(); + let mut got_b = Vec::new(); + for _ in 0..4 { + if let Some((OP_DATA, payload)) = first.receive() { + got_a.extend_from_slice(&payload[2..]); + let block = u16::from_be_bytes([payload[0], payload[1]]); + first.ack(block); + } + if let Some((OP_DATA, payload)) = second.receive() { + got_b.extend_from_slice(&payload[2..]); + let block = u16::from_be_bytes([payload[0], payload[1]]); + second.ack(block); + } + } + assert_eq!(got_a, a, "the first client got its own file"); + assert_eq!(got_b, b, "and the second got its own"); +} + +#[test] +fn a_rom_that_retransmits_its_request_is_not_locked_out() { + // **This is the "works by hand, never after a reboot" failure.** A PXE ROM that + // does not hear an answer quickly — a NAS with a sleeping disk is enough — sends + // its read request again, and again. Each retransmission is a fresh transfer from + // the same address, so a per-peer cap set to the three or four a suspicious mind + // suggests would shut the machine out of the very server it was retrying to reach. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + + let mut clients: Vec = Vec::new(); + for _ in 0..6 { + let mut client = s.client(); + client.read("ipxe.kpxe", &[]); + clients.push(client); + } + for (i, client) in clients.iter_mut().enumerate() { + let (opcode, _) = client + .receive() + .unwrap_or_else(|| panic!("retransmission {i} went unanswered:\n{}", s.log())); + assert_eq!(opcode, OP_DATA, "retransmission {i}"); + } +} + +#[test] +fn malformed_packets_do_not_use_up_a_peers_transfers() { + // A slot is for a transfer, not for a datagram. Counting junk meant a handful of + // stray packets locked an address out — and in a lab everything arrives from one. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + { + let socket = UdpSocket::bind("127.0.0.1:0").expect("bind"); + for _ in 0..20 { + let _ = socket.send_to(&[0u8, 9, b'x', 0], &s.tftp_addr); + } + } + assert!( + s.client().fetch("ipxe.kpxe", &[]).is_ok(), + "twenty junk packets must not cost a transfer:\n{}", + s.log() + ); +} + +// ---- the allowlist -------------------------------------------------------- + +#[test] +fn an_allowlist_refuses_a_peer_outside_it() { + let s = Server::start_env( + &[("ipxe.kpxe", loader(100))], + &[("RESCRIPTUM_BOOT_ALLOW", "10.99.0.0/16")], + ); + // Refused with silence rather than an error: an unauthenticated UDP service that + // answers strangers at all is an amplifier. + assert!(s.client().fetch("ipxe.kpxe", &[]).is_err()); + + let s = Server::start_env( + &[("ipxe.kpxe", loader(100))], + &[("RESCRIPTUM_BOOT_ALLOW", "127.0.0.0/8")], + ); + assert!(s.client().fetch("ipxe.kpxe", &[]).is_ok()); +} + +// ---- configuration -------------------------------------------------------- + +#[test] +fn an_address_with_no_boot_directory_is_refused_at_startup() { + let out = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .arg("check") + .env("RESCRIPTUM_TFTP_ADDR", "127.0.0.1:6969") + .env_remove("RESCRIPTUM_BOOT_DIR") + .output() + .expect("run"); + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("RESCRIPTUM_BOOT_DIR"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} From 15c74d5996a404f961ed3d36025e904f28a3521a Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 13:32:23 +0200 Subject: [PATCH 07/59] feat(boot): the menu, the bootstrap, and their DHCP server's two lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The milestone the goal is written against: one binary boots an arbitrary machine into a menu, and a known machine into an unattended install, on a network whose DHCP server gained two options and was otherwise not touched. The bootstrap is where "a menu is the default answer" actually lives, and it is one `||`: chain to the answer endpoint with the machine's identity in the query string, and fall through to the menu when nothing claims it. No new concept in select.rs, and it is served by the media listener because it has to work when the answer set is empty — the state every new install starts in. The menu is rendered from the catalogue per request rather than kept in sync as a file, so an ISO dropped in the directory is in the menu on the next fetch. `item local` is first and the timeout falls through to it: a machine that PXE-boots by accident ends up on its own disk rather than waiting for a human who is not coming. `boot dhcp-snippet` writes six formats from the same table TFTP serves from, and `tests/cli.rs` pins the two together — a snippet naming a loader the server does not hand out fails silently at the ROM. Two findings went into it: a Windows policy cannot condition on option 93 at all (the architecture reaches it only inside the option 60 string), and Kea's own documentation says to prefer `user-context` over `#` comments because "most JSON tools detect them as errors". 507 tests. **The size budget is now exceeded and the plan records it rather than passing quietly**: `boot` costs 205,368 bytes on armv7 against a ≤170 KB allowance, with Phase 4 still unwritten. The number is not padding — it buys an ISO reader, a catalogue, a media listener, TFTP, a menu and six configuration generators — and the budget was set before any of it existed. The gate's job was to make that visible before a release. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- src/boot/dhcp.rs | 598 ++++++++++++++++++++++++++++++++++++++++++++++ src/boot/media.rs | 131 ++++++++++ src/boot/menu.rs | 581 ++++++++++++++++++++++++++++++++++++++++++++ src/boot/mod.rs | 2 + src/cli.rs | 225 +++++++++++++++++ src/main.rs | 1 + tests/cli.rs | 203 ++++++++++++++++ tests/media.rs | 100 ++++++++ 8 files changed, 1841 insertions(+) create mode 100644 src/boot/dhcp.rs create mode 100644 src/boot/menu.rs diff --git a/src/boot/dhcp.rs b/src/boot/dhcp.rs new file mode 100644 index 0000000..1d35d25 --- /dev/null +++ b/src/boot/dhcp.rs @@ -0,0 +1,598 @@ +//! Generating somebody else's DHCP configuration. +//! +//! **There is no DHCP code in this project** — not a server, not a proxy, not behind a +//! flag. rescriptum deploys into an infrastructure that already has a DHCP server, and +//! pointing that server at a boot server is one of the oldest and most standard things +//! it does. Writing a responder to work around problems that are not DHCP problems is +//! how a provisioning server acquires a reputation for breaking networks. +//! +//! So the handoff is two options on a server that already exists, and our job is to +//! make setting them trivial. **The operator copies rather than composes**, and every +//! line comes from the same table `tftp` serves from — so a snippet naming a loader +//! that is not on disk cannot happen by drift. (It can still happen by nobody running +//! `boot sync`, which is what `boot check` is for. That failure is silent at the ROM, +//! and it is the most common way this goes wrong.) +//! +//! Five details are ours to get right in what we emit, and each is a way this fails +//! quietly on somebody else's network: +//! +//! 1. **Both the BOOTP `file` field and option 67.** Some ROMs read only one, and which +//! one is not predictable from the vendor. +//! 2. **The option 93 table comes from IANA plus one recorded exception** — see +//! `loaders`. +//! 3. **A next-server for the `HTTPClient` classes too.** An HTTP-boot deployment that +//! sets only option 67 leaves `${next-server}` empty inside the loader, and the +//! bootstrap's primary path with it. +//! 4. **Echo `HTTPClient` back in option 60 for those classes.** UEFI firmware filters +//! offers on it; a reply carrying only the URL is discarded, silently. +//! 5. **An untagged default at the end.** Every architecture line is tag-matched, so a +//! ROM that sends no option 93 would match nothing and get no boot file at all. The +//! only clients that old are BIOS, and a DHCP client that is not netbooting ignores +//! boot options entirely. + +use super::loaders::{self, Transport}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + Dnsmasq, + Isc, + Kea, + /// Windows Server's `DhcpServer` module. **Not `netsh`**: branching there takes + /// policies, which `netsh dhcp` predates. + PowerShell, + PfSense, + Mikrotik, +} + +impl Format { + pub fn parse(name: &str) -> Option { + Some(match name.to_ascii_lowercase().as_str() { + "dnsmasq" => Format::Dnsmasq, + "isc" | "dhcpd" => Format::Isc, + "kea" => Format::Kea, + "powershell" | "windows" => Format::PowerShell, + "pfsense" | "opnsense" => Format::PfSense, + "mikrotik" | "routeros" => Format::Mikrotik, + _ => return None, + }) + } + + pub fn label(self) -> &'static str { + match self { + Format::Dnsmasq => "dnsmasq", + Format::Isc => "isc", + Format::Kea => "kea", + Format::PowerShell => "powershell", + Format::PfSense => "pfsense", + Format::Mikrotik => "mikrotik", + } + } + + pub const ALL: &'static [Format] = &[ + Format::Dnsmasq, + Format::Isc, + Format::Kea, + Format::PowerShell, + Format::PfSense, + Format::Mikrotik, + ]; +} + +pub struct Handoff { + /// The address a ROM is sent to, as it must appear on that network. + pub host: String, + /// The media listener's URL, for the clients that fetch their loader over HTTP. + pub media: String, + pub version: &'static str, + /// One line for a homogeneous fleet, rather than four for a mixed one. + pub one_loader: bool, +} + +/// The vendor-class string firmware announces in option 60, which is the only place the +/// architecture reaches a Windows DHCP policy. +fn vendor_class(arch: u16, transport: Transport) -> String { + let prefix = match transport { + Transport::Tftp => "PXEClient", + Transport::Http => "HTTPClient", + }; + format!("{prefix}:Arch:{arch:05}") +} + +/// A short tag name for a row, for the formats that use one. +fn tag(arch: u16, transport: Transport) -> String { + let base = match arch { + 0x0000 => "bios", + 0x0007 | 0x0009 | 0x0010 => "efi64", + 0x000b | 0x0013 => "efiarm64", + _ => "other", + }; + match transport { + Transport::Tftp => base.to_string(), + Transport::Http => format!("http{base}"), + } +} + +/// Every row that actually serves something, which is what a snippet is made of. +fn served() -> Vec<(&'static loaders::Client, &'static str)> { + loaders::TABLE + .iter() + .filter_map(|c| c.loader.map(|l| (c, l))) + .collect() +} + +pub fn snippet(format: Format, handoff: &Handoff) -> String { + let mut out = String::new(); + // Kea's configuration is JSON, and although its own parser accepts `#` comments + // locally, upstream says plainly that "most JSON tools detect them as errors" and + // recommends `user-context` instead. So the provenance goes *inside* the document + // there, and the output stays pasteable into anything that reads JSON. + if format != Format::Kea { + out.push_str(&format!( + "# rescriptum {} - boot handoff for {}\n\ + # Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp.\n\ + # Generated from the same table the TFTP server serves from.\n", + handoff.version, handoff.host + )); + } + + if handoff.one_loader { + if format != Format::Kea { + out.push_str("# --one-loader: a homogeneous BIOS fleet, so no branching at all.\n"); + } + out.push_str(&one_loader(format, handoff)); + return out; + } + + out.push_str(&match format { + Format::Dnsmasq => dnsmasq(handoff), + Format::Isc => isc(handoff), + Format::Kea => kea(handoff), + Format::PowerShell => powershell(handoff), + Format::PfSense => pfsense(handoff), + Format::Mikrotik => mikrotik(handoff), + }); + out +} + +fn one_loader(format: Format, handoff: &Handoff) -> String { + let host = &handoff.host; + let loader = loaders::FALLBACK; + match format { + Format::Dnsmasq => format!("dhcp-boot={loader},,{host}\n"), + Format::Isc => format!("next-server {host};\nfilename \"{loader}\";\n"), + Format::Kea => format!( + "{{\n \"user-context\": {{ \"comment\": \"rescriptum {} boot handoff\" }},\n \ + \"next-server\": \"{host}\",\n \"boot-file-name\": \"{loader}\"\n}}\n", + handoff.version + ), + Format::PowerShell => format!( + "Set-DhcpServerv4OptionValue -OptionId 66 -Value '{host}'\n\ + Set-DhcpServerv4OptionValue -OptionId 67 -Value '{loader}'\n" + ), + Format::PfSense => format!( + "Services > DHCP Server > Network Booting\n \ + Next Server: {host}\n Default BIOS file name: {loader}\n" + ), + Format::Mikrotik => format!( + "/ip dhcp-server network set [find] next-server={host} boot-file-name={loader}\n" + ), + } +} + +fn dnsmasq(handoff: &Handoff) -> String { + let host = &handoff.host; + let media = handoff.media.trim_end_matches('/'); + let mut out = String::new(); + + for (client, _) in served() { + match client.transport { + // dnsmasq can match option 93 directly, which is the clean way. + Transport::Tftp => out.push_str(&format!( + "dhcp-match=set:{},option:client-arch,{}\n", + tag(client.arch, client.transport), + client.arch + )), + // HTTP-boot clients are told apart by their vendor class, because that is + // also what has to be echoed back at them. + Transport::Http => out.push_str(&format!( + "dhcp-vendorclass=set:{},{}\n", + tag(client.arch, client.transport), + vendor_class(client.arch, client.transport) + )), + } + } + out.push('\n'); + + for (client, loader) in served() { + let tag = tag(client.arch, client.transport); + match client.transport { + Transport::Tftp => { + out.push_str(&format!("dhcp-boot=tag:{tag},{loader},,{host}\n")); + } + Transport::Http => { + // The firmware filters offers on option 60 being HTTPClient. A reply + // carrying only the URL is discarded, silently, at the firmware. + out.push_str(&format!( + "dhcp-option-force=tag:{tag},60,HTTPClient\n\ + dhcp-boot=tag:{tag},{media}/boot/{loader},,{host}\n" + )); + } + } + } + + out.push_str(&format!( + "\n# A ROM that sends no option 93 matches no tag above and would get nothing.\n\ + # The only clients that old are BIOS, and a DHCP client that is not netbooting\n\ + # ignores boot options entirely.\n\ + dhcp-boot={},,{host}\n", + loaders::FALLBACK + )); + out +} + +fn isc(handoff: &Handoff) -> String { + let host = &handoff.host; + let media = handoff.media.trim_end_matches('/'); + let mut out = String::from( + "option arch code 93 = unsigned integer 16;\n\ + option vendor-class code 60 = string;\n\n", + ); + out.push_str(&format!("next-server {host};\n")); + // Both the BOOTP `file` field and option 67: some ROMs read only one, and which is + // not predictable from the vendor. `filename` sets the former; dhcpd copies it into + // the latter for a client that asked for it. + out.push_str(&format!("filename \"{}\";\n\n", loaders::FALLBACK)); + + let mut first = true; + for (client, loader) in served() { + let keyword = if first { "if" } else { "} elsif" }; + first = false; + match client.transport { + Transport::Tftp => out.push_str(&format!( + "{keyword} option arch = {:02x}:{:02x} {{\n filename \"{loader}\";\n", + client.arch >> 8, + client.arch & 0xff + )), + Transport::Http => out.push_str(&format!( + "{keyword} option arch = {:02x}:{:02x} {{\n \ + option vendor-class \"HTTPClient\";\n \ + filename \"{media}/boot/{loader}\";\n", + client.arch >> 8, + client.arch & 0xff + )), + } + } + if !first { + out.push_str("}\n"); + } + out +} + +fn kea(handoff: &Handoff) -> String { + let host = &handoff.host; + let media = handoff.media.trim_end_matches('/'); + let mut classes: Vec = Vec::new(); + + for (client, loader) in served() { + let name = format!( + "{}-{:#06x}", + tag(client.arch, client.transport), + client.arch + ); + let file = match client.transport { + Transport::Tftp => loader.to_string(), + Transport::Http => format!("{media}/boot/{loader}"), + }; + let mut body = format!( + " {{\n \"name\": \"{name}\",\n \ + \"test\": \"option[93].hex == {:#06x}\",\n \ + \"next-server\": \"{host}\",\n \ + \"boot-file-name\": \"{file}\"", + client.arch + ); + if client.transport == Transport::Http { + body.push_str( + ",\n \"option-data\": [ { \"name\": \"vendor-class-identifier\", \ + \"data\": \"HTTPClient\" } ]", + ); + } + body.push_str("\n }"); + classes.push(body); + } + + format!( + "{{\n \"Dhcp4\": {{\n \"user-context\": {{\n \ + \"comment\": \"rescriptum {} boot handoff for {host}; option 93 values are IANA \ + architecture types, generated from the same table the TFTP server serves from\"\n \ + }},\n \"client-classes\": [\n{}\n ],\n \ + \"next-server\": \"{host}\",\n \"boot-file-name\": \"{}\"\n }}\n}}\n", + handoff.version, + classes.join(",\n"), + loaders::FALLBACK + ) +} + +fn powershell(handoff: &Handoff) -> String { + let host = &handoff.host; + let media = handoff.media.trim_end_matches('/'); + let mut out = String::from( + "# Windows Server DHCP. **A policy cannot condition on option 93** — the policy\n\ + # condition types are vendor class, user class, MAC, client id, FQDN and relay\n\ + # information, and the architecture reaches a policy only inside the option 60\n\ + # string. Hence a vendor class per architecture, matched with a trailing\n\ + # wildcard because the real string continues (`PXEClient:Arch:00007:UNDI:003016`).\n\ + # Run per scope: pass -ScopeId, or omit it for the server-level policy.\n\n", + ); + + for (client, loader) in served() { + let class = vendor_class(client.arch, client.transport); + let name = format!("rescriptum-{}", class.replace(':', "-")); + let file = match client.transport { + Transport::Tftp => loader.to_string(), + Transport::Http => format!("{media}/boot/{loader}"), + }; + out.push_str(&format!( + "Add-DhcpServerv4Class -Name '{name}' -Type Vendor -Data '{class}'\n\ + Add-DhcpServerv4Policy -Name '{name}' -Condition OR -VendorClass EQ,'{class}*'\n\ + Set-DhcpServerv4OptionValue -PolicyName '{name}' -OptionId 66 -Value '{host}'\n\ + Set-DhcpServerv4OptionValue -PolicyName '{name}' -OptionId 67 -Value '{file}'\n" + )); + if client.transport == Transport::Http { + out.push_str(&format!( + "Set-DhcpServerv4OptionValue -PolicyName '{name}' -OptionId 60 -Value 'HTTPClient'\n" + )); + } + out.push('\n'); + } + + out.push_str(&format!( + "# The untagged default, for a ROM that announces no architecture.\n\ + Set-DhcpServerv4OptionValue -OptionId 66 -Value '{host}'\n\ + Set-DhcpServerv4OptionValue -OptionId 67 -Value '{}'\n", + loaders::FALLBACK + )); + out +} + +fn pfsense(handoff: &Handoff) -> String { + let host = &handoff.host; + let mut out = String::from( + "# pfSense and OPNsense configure this through the web interface, so this is\n\ + # what to type rather than a file to paste.\n\n\ + Services > DHCP Server > (interface) > Network Booting\n", + ); + out.push_str(&format!(" Next Server: {host}\n")); + out.push_str(&format!( + " Default BIOS file name: {}\n", + loaders::FALLBACK + )); + for (client, loader) in served() { + if client.transport == Transport::Http { + continue; + } + if client.arch == 0x0007 { + out.push_str(&format!(" UEFI 64-bit file name: {loader}\n")); + } + } + out.push_str( + "\n# Architectures the built-in fields do not cover — ARM64, and HTTP boot —\n\ + # go in Advanced > Additional BOOTP/DHCP Options, or in the ISC snippet:\n\ + # rescriptum boot dhcp-snippet --format isc\n", + ); + out +} + +fn mikrotik(handoff: &Handoff) -> String { + let host = &handoff.host; + let mut out = String::from( + "# RouterOS. Branching on option 93 needs matchers, which older versions do not\n\ + # have; the single-loader form below works everywhere and is right for a fleet\n\ + # that is all one architecture. For a mixed fleet, run dnsmasq beside it or use\n\ + # a DHCP server that can branch — see the guide.\n\n", + ); + out.push_str(&format!( + "/ip dhcp-server network set [find] next-server={host} boot-file-name={}\n", + loaders::FALLBACK + )); + out.push_str("\n# RouterOS 7 with matchers, for a mixed fleet:\n"); + for (client, loader) in served() { + if client.transport == Transport::Http { + continue; + } + out.push_str(&format!( + "# /ip dhcp-server matcher add server=[find] code=93 value=0x{:04x} \ + address-pool=static-only boot-file-name={loader}\n", + client.arch + )); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn handoff() -> Handoff { + Handoff { + host: "192.0.2.10".to_string(), + media: "http://192.0.2.10:8001".to_string(), + version: "0.3.0", + one_loader: false, + } + } + + #[test] + fn every_format_names_every_loader_the_table_serves() { + // **The two cannot drift**: what an operator pastes into their DHCP server and + // what this one hands out come from one table. A snippet naming a loader that + // is not there fails silently at the ROM, which is the least diagnosable + // failure in the whole chain. + for format in Format::ALL { + let text = snippet(*format, &handoff()); + for loader in loaders::loaders() { + // pfSense and Mikrotik are interfaces rather than files, and both say + // in the output which architectures they cannot express. + if matches!(format, Format::PfSense | Format::Mikrotik) && loader.contains("arm64") + { + continue; + } + assert!( + text.contains(loader), + "{} does not name {loader}:\n{text}", + format.label() + ); + } + } + } + + #[test] + fn every_format_names_the_server() { + for format in Format::ALL { + let text = snippet(*format, &handoff()); + assert!( + text.contains("192.0.2.10"), + "{} names no server:\n{text}", + format.label() + ); + } + } + + #[test] + fn a_rom_that_announces_no_architecture_still_gets_a_loader() { + // Every architecture line is tag-matched, so without an untagged default a ROM + // sending no option 93 matches nothing and simply stops. + for format in [ + Format::Dnsmasq, + Format::Isc, + Format::Kea, + Format::PowerShell, + ] { + let text = snippet(format, &handoff()); + assert!( + text.contains(loaders::FALLBACK), + "{} has no default:\n{text}", + format.label() + ); + } + // dnsmasq's is the untagged `dhcp-boot` at the end. + let text = snippet(Format::Dnsmasq, &handoff()); + assert!( + text.contains(&format!("dhcp-boot={},,192.0.2.10", loaders::FALLBACK)), + "{text}" + ); + } + + #[test] + fn http_boot_clients_are_told_to_expect_an_http_client_offer() { + // UEFI firmware filters offers on option 60 being HTTPClient. A reply carrying + // only the URL in option 67 is discarded, silently, at the firmware — which is + // indistinguishable from no DHCP server at all. + let text = snippet(Format::Dnsmasq, &handoff()); + assert!(text.contains("60,HTTPClient"), "{text}"); + + let text = snippet(Format::PowerShell, &handoff()); + assert!(text.contains("-OptionId 60 -Value 'HTTPClient'"), "{text}"); + + let text = snippet(Format::Kea, &handoff()); + assert!(text.contains("\"data\": \"HTTPClient\""), "{text}"); + } + + #[test] + fn http_boot_clients_are_still_given_a_next_server() { + // An HTTP-boot deployment names no next-server of its own — option 67 carries a + // URL there — so the bootstrap's primary path would read an empty + // `${next-server}` and chain into nowhere. + let text = snippet(Format::Dnsmasq, &handoff()); + for line in text.lines().filter(|l| l.contains("http://")) { + assert!( + line.ends_with(",,192.0.2.10"), + "an HTTP-boot line must still carry a next-server: {line}" + ); + } + } + + #[test] + fn a_windows_policy_conditions_on_the_vendor_class_not_on_option_93() { + // A policy cannot condition on option 93 at all: the condition types are vendor + // class, user class, MAC, client id, FQDN and relay information. The + // architecture reaches a policy only inside the option 60 string, and the real + // string continues past the arch — hence the trailing wildcard. + let text = snippet(Format::PowerShell, &handoff()); + assert!( + text.contains("-VendorClass EQ,'PXEClient:Arch:00007*'"), + "{text}" + ); + assert!(text.contains("Add-DhcpServerv4Class"), "{text}"); + assert!(!text.contains("netsh"), "{text}"); + assert!(text.contains("cannot condition on option 93"), "{text}"); + } + + #[test] + fn the_vendor_class_strings_are_the_ones_firmware_actually_sends() { + assert_eq!( + vendor_class(0x0000, Transport::Tftp), + "PXEClient:Arch:00000" + ); + assert_eq!( + vendor_class(0x0007, Transport::Tftp), + "PXEClient:Arch:00007" + ); + assert_eq!( + vendor_class(0x000b, Transport::Tftp), + "PXEClient:Arch:00011" + ); + // 0x0010 is sixteen, and it is an HTTP client rather than a PXE one. + assert_eq!( + vendor_class(0x0010, Transport::Http), + "HTTPClient:Arch:00016" + ); + assert_eq!( + vendor_class(0x0013, Transport::Http), + "HTTPClient:Arch:00019" + ); + } + + #[test] + fn the_recorded_exception_gets_its_own_line() { + // Real x64 firmware announces 0x0007 or 0x0009, so a snippet that covered only + // the one the registry blesses would leave half a fleet unbootable. + let text = snippet(Format::Dnsmasq, &handoff()); + assert!(text.contains("option:client-arch,7"), "{text}"); + assert!(text.contains("option:client-arch,9"), "{text}"); + } + + #[test] + fn one_loader_is_a_single_line_for_a_homogeneous_fleet() { + let mut handoff = handoff(); + handoff.one_loader = true; + let text = snippet(Format::Dnsmasq, &handoff); + assert!( + text.contains("dhcp-boot=ipxe-undionly.kpxe,,192.0.2.10"), + "{text}" + ); + assert!(!text.contains("dhcp-match"), "no branching at all:\n{text}"); + } + + #[test] + fn a_format_name_nobody_uses_is_refused_rather_than_defaulted() { + assert_eq!(Format::parse("dnsmasq"), Some(Format::Dnsmasq)); + assert_eq!(Format::parse("DHCPD"), Some(Format::Isc)); + assert_eq!(Format::parse("opnsense"), Some(Format::PfSense)); + // `netsh` is deliberately not an alias: it cannot express this. + assert_eq!(Format::parse("netsh"), None); + assert_eq!(Format::parse("bind"), None); + } + + #[test] + fn the_kea_snippet_is_valid_json() { + // It is the one format that is a data document rather than a directive list, so + // it can be checked here rather than only by the real parser. + let text = snippet(Format::Kea, &handoff()); + let parsed: serde_json::Value = serde_json::from_str(&text) + .unwrap_or_else(|e| panic!("Kea output must parse as JSON: {e}\n{text}")); + let classes = parsed["Dhcp4"]["client-classes"] + .as_array() + .expect("client-classes"); + assert_eq!(classes.len(), served().len()); + } +} diff --git a/src/boot/media.rs b/src/boot/media.rs index 0c991b0..d3d4a30 100644 --- a/src/boot/media.rs +++ b/src/boot/media.rs @@ -125,6 +125,49 @@ async fn handle(req: Request, media: Arc, peer: SocketAddr) -> return catalogue(&media, &peer_label, json).await; } + // The two generated scripts. Both live here rather than in the answer set because + // **they have to work when the answer set is empty**, which is the state every new + // install starts in. + if path == "/ipxe/bootstrap" { + let script = super::menu::bootstrap(&media.cfg.endpoints()); + log::request(&peer_label, 200, "media: GET /ipxe/bootstrap 200"); + return script_response(script); + } + if path == "/ipxe/menu" { + let catalog = Arc::clone(&media.catalog); + let listing = match tokio::task::spawn_blocking(move || catalog.listing()).await { + Ok(Ok(listing)) => listing, + _ => { + log::request(&peer_label, 500, "media: GET /ipxe/menu 500"); + return text(StatusCode::INTERNAL_SERVER_ERROR, "500\n"); + } + }; + let style = super::menu::Style { + title: media + .cfg + .boot_title + .clone() + .unwrap_or_else(super::menu::Style::default_title), + timeout_millis: media.cfg.boot_timeout_millis(), + }; + let script = super::menu::menu(&listing, &media.cfg.endpoints(), &style); + log::request( + &peer_label, + 200, + &format!( + "media: GET /ipxe/menu 200 entries={}", + listing.entries.len() + ), + ); + return script_response(script); + } + + // The TFTP root over HTTP: the loaders (UEFI HTTP Boot fetches them here rather + // than over TFTP), the logo, and anything else `boot sync` put there. + if let Some(name) = path.strip_prefix("/boot/") { + return boot_asset(&media, name, &peer_label, method == Method::HEAD).await; + } + let Some((id, what)) = route(&path) else { log::request( &peer_label, @@ -592,6 +635,94 @@ async fn catalogue(media: &Media, peer: &str, json: bool) -> Response { text(StatusCode::OK, out) } +/// A generated iPXE script. `text/plain` because that is what iPXE reads, and no +/// caching: the menu is a rendering of a catalogue that changes when an ISO is dropped +/// in, and a cached one would show yesterday's images. +fn script_response(script: String) -> Response { + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "text/plain; charset=us-ascii") + .header("Cache-Control", "no-store") + .header("Content-Length", script.len().to_string()) + .body(Body::once(Bytes::from(script))) + .expect("a built response") +} + +/// A file from the boot directory, over HTTP. +/// +/// **UEFI HTTP Boot fetches its loader here rather than over TFTP**, which is the +/// shortest chain there is — option 60 `HTTPClient` plus a URL in 67, and no TFTP at +/// all. The same files, the same directory, a faster transport. +async fn boot_asset(media: &Media, name: &str, peer: &str, head_only: bool) -> Response { + let Some(root) = media.cfg.boot_dir.clone() else { + log::request( + peer, + 404, + &format!("media: GET /boot/{name} 404 no boot directory"), + ); + return text( + StatusCode::NOT_FOUND, + "404 Not Found — RESCRIPTUM_BOOT_DIR is not set\n", + ); + }; + + let wanted = name.to_string(); + let found = tokio::task::spawn_blocking(move || { + // The same containment rule TFTP uses, for the same reason and by the same + // means: strip anything that could climb, then check the resolved path is still + // inside the canonicalised root. A symlink out of the tree fails the second + // check even though it passes the first. + let root = root.canonicalize().ok()?; + let mut path = root.clone(); + for segment in wanted.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return None; + } + path.push(segment); + } + let resolved = path.canonicalize().ok()?; + if !resolved.starts_with(&root) || !resolved.is_file() { + return None; + } + let size = std::fs::metadata(&resolved).ok()?.len(); + Some((resolved, size)) + }) + .await; + + let Ok(Some((path, size))) = found else { + log::request(peer, 404, &format!("media: GET /boot/{name} 404")); + return text(StatusCode::NOT_FOUND, "404 Not Found\n"); + }; + + log::request( + peer, + 200, + &format!("media: GET /boot/{name} 200 bytes={size}"), + ); + let body = if head_only { + Body::empty() + } else { + Body::stream( + vec![Segment::File { + path, + offset: 0, + length: size, + }], + size, + ) + }; + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/octet-stream") + .header("Content-Length", size.to_string()) + .header("Accept-Ranges", "none") + .body(body) + .unwrap_or_else(|_| text(StatusCode::INTERNAL_SERVER_ERROR, "500\n")) +} + /// Whether a peer is inside `RESCRIPTUM_BOOT_ALLOW`, which is a comma-separated list of /// CIDRs. Unset means anyone who can reach the port — which on a boot VLAN is the honest /// configuration, and the documentation says so. diff --git a/src/boot/menu.rs b/src/boot/menu.rs new file mode 100644 index 0000000..20ac6e0 --- /dev/null +++ b/src/boot/menu.rs @@ -0,0 +1,581 @@ +//! The two scripts the server writes for iPXE: the bootstrap, and the menu. +//! +//! ## Why a bootstrap exists at all +//! +//! DHCP hands iPXE a URL, and **a DHCP option cannot carry `${net0/mac}`**. So the URL +//! DHCP names arrives with no query string — no MAC, no serial, no UUID. A `GET` has no +//! body either, so the haystack would be empty and every machine would match nothing +//! but the default. The whole selection engine would go dark at exactly the moment it +//! matters. +//! +//! Stage two is therefore one fixed `chain` that stage three is not. It is served by +//! the media listener rather than from the answer set, because **it has to work when +//! the answer set is empty** — which is the state every new install starts in. +//! +//! ## A menu is the default answer +//! +//! The bootstrap's `||` is the whole of it: a machine something claims gets its own +//! unattended answer, and a machine nothing claims falls through to the menu. That is +//! `default.toml`'s job description word for word, applied to a different format, and +//! it needs no new concept in `select.rs`. +//! +//! ## What iPXE's own parser allows here +//! +//! Both read out of the source rather than remembered: +//! +//! - **`;` separates commands only as a whole whitespace-delimited token** +//! (`split_command` in `core/exec.c`), so a `;` inside an argument is safe. +//! - **A trailing `\` continues a line**, which is how a long URL stays readable. +//! +//! And two things that are ours to get right: **`${version}` is iPXE's own version**, +//! not ours, so everything the server knows is rendered as a literal; and the text +//! stays **ASCII**, because a BIOS text console is not UTF-8. + +use super::catalog::Listing; +use super::probe::{Arch, Family}; +use super::stanza::{self, Endpoints}; + +/// The three-line stage two, baked into no loader and configurable by nobody. +/// +/// Two expansions carry a correction each. **`netX`, not `net0`**: `net0` is merely the +/// first interface, so a server booting from its second port would identify as its +/// unused first — `netX` is iPXE's virtual scope for the device that actually booted. +/// And **`:uristring` on every SMBIOS string**: `${manufacturer}` expands to +/// `Dell Inc.`, space included, and iPXE percent-encodes nothing on plain expansion, so +/// a space in a request line is a broken fetch. +pub fn bootstrap(endpoints: &Endpoints) -> String { + let answer = endpoints.answer.trim_end_matches('/'); + let media = endpoints.media.trim_end_matches('/'); + format!( + "#!ipxe\n\ + # Stage two. DHCP cannot carry a MAC, so this is what puts one in the query\n\ + # string — without it every machine would match nothing but the default.\n\ + chain {answer}/ipxe/boot?mac=${{netX/mac}}&uuid=${{uuid}}\\\n\ + &serial=${{serial:uristring}}&asset=${{asset:uristring}}\\\n\ + &manufacturer=${{manufacturer:uristring}}&product=${{product:uristring}}\\\n\ + &platform=${{platform}}&arch=${{buildarch}} \\\n\ + || chain {media}/ipxe/menu\n" + ) +} + +/// The catalogue, rendered as an iPXE menu. +/// +/// **Generated at request time, not built into a file.** netboot.xyz's menus are static +/// templates rendered by Ansible at build time; ours are a rendering pass over facts the +/// server already holds, so dropping an ISO in the media directory puts it in the menu +/// on the next fetch. The catalogue is the single source of truth — the same instinct as +/// answers being discovered rather than registered. +pub fn menu(listing: &Listing, endpoints: &Endpoints, style: &Style) -> String { + let media = endpoints.media.trim_end_matches('/'); + let mut out = String::from("#!ipxe\n"); + + // Rows 2 and 3 of the branding are a chain rather than alternatives: `console + // --picture … ||` **tolerates its own failure**, so a client with no framebuffer — + // a serial console over IPMI, which is how half of all datacenter installs are + // actually watched — simply keeps the text console and gets the colours instead. + // Write it as a chain and there is nothing to detect. + out.push_str(&format!( + "console --picture {media}/boot/logo.png --left 0 --right 0 --keep ||\n" + )); + out.push_str("colour --rgb 0x1c1b19 0 ||\n"); + out.push_str("colour --rgb 0xc8a15a 3 ||\n"); + out.push_str("cpair --foreground 3 1 ||\n"); + out.push('\n'); + + out.push_str(&format!("menu {}\n", ascii(&style.title))); + + // `item local` first, and the timeout falls through to it. **A machine that + // PXE-boots by accident, and that nothing claims, ends up on its own disk** — it + // does not sit at a menu forever waiting for a human who is not coming, and it + // never installs anything. The worst case of being wrong about which machines + // reach us is a few seconds added to a boot. + out.push_str("item --gap Default:\n"); + out.push_str("item local Boot from the local disk\n"); + + let bootable: Vec<_> = listing.entries.iter().filter(|e| e.bootable()).collect(); + if !bootable.is_empty() { + out.push_str("item --gap Install:\n"); + for entry in &bootable { + let line = format!( + "item {:<20} {:<28} ({})\n", + ascii(&entry.id), + truncate(&entry.describe(), 28), + entry.family().label() + ); + out.push_str(&gate(entry.arch(), &line)); + } + } + + // An image no probe placed cannot produce a stanza, but it can still be booted as a + // CD — which is a normal thing to want from a live tool. + let opaque: Vec<_> = listing.entries.iter().filter(|e| !e.bootable()).collect(); + if !opaque.is_empty() { + out.push_str("item --gap Tools:\n"); + for entry in &opaque { + out.push_str(&format!( + "item {:<20} {:<28} (boot as CD)\n", + ascii(&entry.id), + truncate(&entry.describe(), 28) + )); + } + } + + out.push_str("item --gap Diagnostics:\n"); + out.push_str("item shell iPXE shell\n"); + out.push_str("item netinfo Network card information\n"); + out.push_str("item retry Ask this server again\n"); + out.push_str("item endpoints Boot from another rescriptum\n"); + out.push_str("item reboot Reboot\n"); + + // The timeout is rendered **in milliseconds**, because that is what `choose` + // counts. A seconds value passed through unconverted is a menu that flashes past + // before a human has read its title — hence the `_SECS` suffix on the variable and + // exactly one conversion, here. + out.push_str(&format!( + "choose --timeout {} --default local target || goto local\n", + style.timeout_millis + )); + out.push_str("goto ${target} ||\n"); + out.push_str("goto local\n\n"); + + // One label per entry. `sanboot` is the generic fallback for an image no probe + // could place: how far it reaches on real firmware is a bench question, so the + // entry tolerates its own failure and returns to the menu rather than hanging. + for entry in &bootable { + out.push_str(&format!(":{}\n", ascii(&entry.id))); + match stanza::ipxe(entry, endpoints) { + Ok(script) => { + for line in script.lines().skip(1) { + if line.starts_with('#') { + continue; + } + out.push_str(line); + out.push('\n'); + } + } + // Unreachable for a bootable entry, but a menu that silently omitted a + // label would `goto` into nothing. + Err(why) => { + out.push_str(&format!("echo {}\n", ascii(&why))); + out.push_str("goto start\n"); + } + } + out.push_str("goto start\n\n"); + } + for entry in &opaque { + out.push_str(&format!(":{}\n", ascii(&entry.id))); + out.push_str(&format!("sanboot {media}/{}/iso ||\n", ascii(&entry.id))); + out.push_str("goto start\n\n"); + } + + out.push_str(":local\n"); + out.push_str("echo Booting from the local disk\n"); + // `exit` hands control back to the firmware, which moves to its next boot device. + // `sanboot --no-describe --drive 0x80` is the BIOS-only version and fails on UEFI, + // so this is the one that works on both. + out.push_str("exit 0\n\n"); + + out.push_str(":shell\n"); + out.push_str("echo Type exit to come back to the menu\n"); + out.push_str("shell ||\n"); + out.push_str("goto start\n\n"); + + out.push_str(":netinfo\n"); + out.push_str("ifstat ||\n"); + out.push_str("echo MAC ${netX/mac} IP ${netX/ip} next-server ${next-server}\n"); + out.push_str("prompt Press any key to return\n"); + out.push_str("goto start\n\n"); + + out.push_str(":retry\n"); + out.push_str(&format!("chain {media}/ipxe/bootstrap ||\n")); + out.push_str("goto start\n\n"); + + // The one borrow that is a feature rather than a pattern: `read` a URL, `chain` it. + // It is how a candidate server is tested **on site, from the running one, without + // touching DHCP or the loaders** — netboot.xyz runs its whole staged release + // process through exactly this entry. + out.push_str(":endpoints\n"); + out.push_str("echo Boot from another rescriptum, to try one before moving DHCP.\n"); + out.push_str(&format!("set endpoint {media}\n")); + out.push_str("read endpoint ||\n"); + out.push_str("chain ${endpoint}/ipxe/bootstrap ||\n"); + out.push_str("goto start\n\n"); + + out.push_str(":reboot\n"); + out.push_str("reboot\n\n"); + + // `goto start` above needs somewhere to land, and re-fetching the menu is the + // honest way back: the catalogue may have changed since it was rendered. + out.push_str(":start\n"); + out.push_str(&format!("chain {media}/ipxe/menu ||\n")); + out.push_str("goto local\n"); + + out +} + +/// What the menu is called and how long it waits. +pub struct Style { + pub title: String, + /// **Milliseconds**, converted once, by the caller that owns the seconds. + pub timeout_millis: u64, +} + +impl Style { + /// The title a site has not overridden. `${next-server}` stays a variable because it + /// is genuinely client-side; the version is a literal, because `${version}` in an + /// iPXE script is *iPXE's* version and the title would advertise iPXE, not us. + pub fn default_title() -> String { + format!( + "rescriptum {} - ${{next-server}}", + env!("CARGO_PKG_VERSION") + ) + } +} + +/// Wrap a line in a client-side architecture guard, netboot.xyz's `menu_*` trick fed by +/// the catalogue instead of by Ansible. **An ARM64 image offered to an x86 client is a +/// menu entry that boots the wrong kernel**, and one menu has to serve every client. +fn gate(arch: Option, line: &str) -> String { + match arch { + Some(arch) => format!( + "iseq ${{buildarch}} {} && {}||\n", + arch.buildarch(), + line.trim_end() + ), + // An image whose architecture nobody could establish is offered to everybody: + // hiding it would be a guess in the more damaging direction. + None => line.to_string(), + } +} + +/// A BIOS text console is not UTF-8, and a menu title full of replacement characters is +/// worse than a plain one. Anything outside printable ASCII becomes a space. +fn ascii(text: &str) -> String { + text.chars() + .map(|c| { + if c.is_ascii_graphic() || c == ' ' { + c + } else { + ' ' + } + }) + .collect() +} + +fn truncate(text: &str, width: usize) -> String { + let text = ascii(text); + if text.len() <= width { + return text; + } + format!("{}...", &text[..width.saturating_sub(3)]) +} + +/// The families whose entries the menu can render, for `boot check` to report. +pub fn describable() -> Vec { + vec![ + Family::Proxmox, + Family::Debian, + Family::Ubuntu, + Family::Rhel, + Family::Suse, + Family::CoreOs, + ] +} + +#[cfg(test)] +mod tests { + use super::super::catalog::Entry; + use super::super::probe::Probed; + use super::*; + use std::path::PathBuf; + + fn endpoints() -> Endpoints { + Endpoints { + media: "http://192.0.2.10:8001".to_string(), + answer: "http://192.0.2.10:8000".to_string(), + } + } + + fn style() -> Style { + Style { + title: Style::default_title(), + timeout_millis: 15000, + } + } + + fn entry(id: &str, family: Option, arch: Option) -> Entry { + Entry { + id: id.to_string(), + path: PathBuf::from(format!("/srv/media/{id}.iso")), + size: 1024, + digest: None, + probed: Probed { + family, + version: Some(format!("{id} 1.0")), + arch, + kernel: family.map(|_| "/kernel".to_string()), + initrd: family.map(|_| "/initrd".to_string()), + external: false, + zstd_initrd: false, + }, + beside: None, + } + } + + /// An entry carrying a version string a vendor might really have written. + fn described(id: &str, version: &str) -> Entry { + let mut e = entry(id, Some(Family::Debian), None); + e.probed.version = Some(version.to_string()); + e + } + + fn listing(entries: Vec) -> Listing { + Listing { + entries, + problems: Vec::new(), + } + } + + // ---- the bootstrap ---------------------------------------------------- + + #[test] + fn the_bootstrap_puts_the_machines_identity_in_the_query_string() { + // Without this the haystack is empty for every GET and the selection engine + // goes dark at exactly the moment it matters. + let script = bootstrap(&endpoints()); + assert!(script.starts_with("#!ipxe\n")); + assert!(script.contains("mac=${netX/mac}"), "{script}"); + assert!(script.contains("uuid=${uuid}"), "{script}"); + assert!(script.contains("arch=${buildarch}"), "{script}"); + } + + #[test] + fn the_bootstrap_names_the_booting_nic_rather_than_the_first_one() { + // `net0` is merely the first interface. A server that PXE-boots from its second + // port would identify as its unused first, and install the wrong machine. + let script = bootstrap(&endpoints()); + assert!(script.contains("${netX/mac}"), "{script}"); + assert!(!script.contains("${net0/"), "{script}"); + } + + #[test] + fn every_smbios_string_is_percent_encoded_at_expansion() { + // `${manufacturer}` is `Dell Inc.` — space included — and iPXE encodes nothing + // on plain expansion, so a space in the request line is a broken fetch. + let script = bootstrap(&endpoints()); + for field in ["serial", "asset", "manufacturer", "product"] { + assert!( + script.contains(&format!("{field}=${{{field}:uristring}}")), + "{field} must be uristring: {script}" + ); + } + // These cannot carry a reserved character, so they stay plain. + assert!(script.contains("platform=${platform}"), "{script}"); + } + + #[test] + fn an_unclaimed_machine_falls_through_to_the_menu() { + // **A menu is what a machine gets when nobody has decided anything about it + // yet** — `default.toml`'s job description, applied to a different format, and + // implemented as one `||` rather than as a new concept in select.rs. + let script = bootstrap(&endpoints()); + assert!( + script.contains("|| chain http://192.0.2.10:8001/ipxe/menu"), + "{script}" + ); + } + + // ---- the menu --------------------------------------------------------- + + #[test] + fn the_menu_renders_the_catalogue() { + let script = menu( + &listing(vec![ + entry("pve-8.4", Some(Family::Proxmox), Some(Arch::X86_64)), + entry("ubuntu-24.04", Some(Family::Ubuntu), Some(Arch::X86_64)), + ]), + &endpoints(), + &style(), + ); + assert!(script.starts_with("#!ipxe\n")); + assert!(script.contains("item pve-8.4"), "{script}"); + assert!(script.contains("item ubuntu-24.04"), "{script}"); + // And each has a label to `goto`, carrying its family's own stanza. + assert!(script.contains(":pve-8.4\n"), "{script}"); + assert!(script.contains("proxmox-start-auto-installer"), "{script}"); + } + + #[test] + fn local_boot_is_first_and_is_what_the_timeout_falls_through_to() { + // **The safety behaviour that must not be lost.** A machine that PXE-boots by + // accident, and that nothing claims, ends up on its own disk rather than + // sitting at a menu forever waiting for a human who is not coming. + let script = menu(&listing(vec![]), &endpoints(), &style()); + let items: Vec<&str> = script + .lines() + .filter(|l| l.starts_with("item ") && !l.starts_with("item --gap")) + .collect(); + assert!(items[0].starts_with("item local"), "{items:?}"); + assert!( + script.contains("choose --timeout 15000 --default local target || goto local"), + "{script}" + ); + } + + #[test] + fn the_timeout_is_rendered_in_the_unit_choose_actually_counts() { + // `choose --timeout` is milliseconds. A seconds value passed through + // unconverted is a menu that flashes past before a human has read its title. + let script = menu( + &listing(vec![]), + &endpoints(), + &Style { + title: "t".to_string(), + timeout_millis: 30_000, + }, + ); + assert!(script.contains("--timeout 30000"), "{script}"); + } + + #[test] + fn an_architecture_specific_entry_is_gated_client_side() { + // One menu serves every client, so an ARM64 image must not be offered to an x86 + // one — that is a menu entry that boots the wrong kernel. + let script = menu( + &listing(vec![ + entry("arm-image", Some(Family::Debian), Some(Arch::Arm64)), + entry("x86-image", Some(Family::Debian), Some(Arch::X86_64)), + ]), + &endpoints(), + &style(), + ); + assert!( + script.contains("iseq ${buildarch} arm64 && item arm-image"), + "{script}" + ); + assert!( + script.contains("iseq ${buildarch} x86_64 && item x86-image"), + "{script}" + ); + } + + #[test] + fn an_image_of_unknown_architecture_is_offered_to_everybody() { + // Hiding it would be a guess in the more damaging direction: an entry nobody + // can see is an image nobody can boot. + let script = menu( + &listing(vec![entry("mystery", Some(Family::Debian), None)]), + &endpoints(), + &style(), + ); + assert!(script.contains("item mystery"), "{script}"); + assert!(!script.contains("iseq ${buildarch}"), "{script}"); + } + + #[test] + fn an_image_no_probe_placed_is_offered_as_a_cd() { + // Not describable is not the same as not usable. + let script = menu( + &listing(vec![entry("gparted", None, None)]), + &endpoints(), + &style(), + ); + assert!(script.contains("item gparted"), "{script}"); + assert!(script.contains("boot as CD"), "{script}"); + assert!( + script.contains("sanboot http://192.0.2.10:8001/gparted/iso ||"), + "{script}" + ); + } + + #[test] + fn the_title_is_ours_and_the_version_is_a_literal() { + // `${version}` in an iPXE script is *iPXE's* version — the title would have + // advertised iPXE, not us. + let script = menu(&listing(vec![]), &endpoints(), &style()); + assert!(script.contains(&format!("menu rescriptum {}", env!("CARGO_PKG_VERSION")))); + assert!(!script.contains("${version}"), "{script}"); + // `${next-server}` stays a variable, because it is genuinely client-side. + assert!(script.contains("${next-server}"), "{script}"); + } + + #[test] + fn the_text_stays_ascii_because_a_bios_console_is_not_utf8() { + let script = menu( + // The realistic vector is vendor text read out of an image — a volume + // identifier or a `/.disk/info` line can hold anything. An id cannot: + // `valid_id` constrains it at the catalogue boundary. Both are filtered + // anyway, so the menu does not rest on a guarantee made elsewhere. + &listing(vec![described("live-cd", "Ubuntu 24.04 « Naïve Numbat »")]), + &endpoints(), + &Style { + title: "rescriptum — naïve".to_string(), + timeout_millis: 1000, + }, + ); + assert!(script.is_ascii(), "a BIOS text console cannot render this"); + } + + #[test] + fn the_logo_tolerates_its_own_failure() { + // A serial console over IPMI has no framebuffer, and that is how half of all + // datacenter installs are watched. Written as a chain, there is nothing to + // detect: the picture fails, the text console stays, the colours still apply. + let script = menu(&listing(vec![]), &endpoints(), &style()); + let line = script + .lines() + .find(|l| l.starts_with("console ")) + .expect("a console line"); + assert!(line.ends_with("||"), "{line}"); + } + + #[test] + fn every_menu_target_has_a_label_to_land_on() { + // A `goto` into nothing is a menu that hangs on a keypress, and it would only + // show up on the machine. + let script = menu( + &listing(vec![ + entry("pve-8.4", Some(Family::Proxmox), Some(Arch::X86_64)), + entry("gparted", None, None), + ]), + &endpoints(), + &style(), + ); + let labels: Vec<&str> = script.lines().filter_map(|l| l.strip_prefix(':')).collect(); + for line in script.lines() { + let Some(rest) = line.strip_prefix("item ") else { + continue; + }; + if rest.starts_with("--gap") { + continue; + } + let target = rest.split_whitespace().next().unwrap_or_default(); + assert!(labels.contains(&target), "no :{target} label in\n{script}"); + } + // Including the one every other label returns to. + assert!(labels.contains(&"start"), "{script}"); + } + + #[test] + fn no_line_carries_a_bare_semicolon_token() { + // `;` separates commands only as a whole whitespace-delimited token, so one + // that appeared alone would split a line into two commands. + let script = menu( + &listing(vec![entry( + "ubuntu", + Some(Family::Ubuntu), + Some(Arch::X86_64), + )]), + &endpoints(), + &style(), + ); + for line in script.lines() { + assert!( + !line.split_whitespace().any(|t| t == ";"), + "a bare `;` would split this: {line}" + ); + } + // And the NoCloud argument, which contains one, is left alone. + assert!(script.contains("ds=nocloud-net;s="), "{script}"); + } +} diff --git a/src/boot/mod.rs b/src/boot/mod.rs index 0f0087c..e9f1465 100644 --- a/src/boot/mod.rs +++ b/src/boot/mod.rs @@ -12,9 +12,11 @@ pub mod catalog; pub mod cpio; +pub mod dhcp; pub mod iso; pub mod loaders; pub mod media; +pub mod menu; pub mod privileges; pub mod probe; pub mod sha256; diff --git a/src/cli.rs b/src/cli.rs index e84baff..75df31a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -32,6 +32,10 @@ USAGE: rescriptum media add FILE register one: verify, probe, record its digest rescriptum media check re-verify every recorded digest, report what drifted rescriptum media ipxe ID print the .ipxe answer that boots one image + rescriptum boot dhcp-snippet their DHCP server's two lines, generated + rescriptum boot check are the loaders a snippet names actually here? + rescriptum boot bootstrap print the stage-two script + rescriptum boot menu print the built-in menu rescriptum --help ENVIRONMENT: @@ -57,6 +61,10 @@ BOOT MEDIA (off unless RESCRIPTUM_MEDIA_DIR is set): RESCRIPTUM_MEDIA_MAX_CONNECTIONS concurrent transfers (default 16) RESCRIPTUM_PUBLIC_HOST the host generated URLs name (a host, not a URL) RESCRIPTUM_BOOT_ALLOW CIDRs allowed to fetch media (default: anyone) + RESCRIPTUM_BOOT_DIR loaders and menus, served over TFTP + RESCRIPTUM_TFTP_ADDR TFTP listener (default 0.0.0.0:69) + RESCRIPTUM_BOOT_TIMEOUT_SECS menu timeout (default 15) + RESCRIPTUM_USER / _GROUP drop to these after binding port 69 VALIDATING A MERGED ANSWER: rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml @@ -803,6 +811,223 @@ fn truncate(text: &str, width: usize) -> String { format!("{kept}…") } +// ---- boot ----------------------------------------------------------------- + +#[cfg(not(feature = "boot"))] +pub fn boot(_cfg: &Config, _args: &[String]) -> ExitCode { + eprintln!("this binary was built without the `boot` feature, so it has no boot commands"); + ExitCode::FAILURE +} + +/// `boot dhcp-snippet` / `boot check` / `boot bootstrap` / `boot menu`. +#[cfg(feature = "boot")] +pub fn boot(cfg: &Config, args: &[String]) -> ExitCode { + match args.split_first() { + Some((cmd, rest)) if cmd == "dhcp-snippet" => boot_snippet(cfg, rest), + Some((cmd, rest)) if cmd == "check" && rest.is_empty() => boot_check(cfg), + Some((cmd, rest)) if cmd == "bootstrap" && rest.is_empty() => { + print!("{}", crate::boot::menu::bootstrap(&cfg.endpoints())); + ExitCode::SUCCESS + } + Some((cmd, rest)) if cmd == "menu" && rest.is_empty() => boot_menu(cfg), + _ => { + eprintln!( + "usage: rescriptum boot dhcp-snippet [--format F] [--one-loader]\n\ + \x20 rescriptum boot check\n\ + \x20 rescriptum boot bootstrap\n\ + \x20 rescriptum boot menu\n\ + \n\ + \x20 --format: dnsmasq | isc | kea | powershell | pfsense | mikrotik" + ); + ExitCode::FAILURE + } + } +} + +/// The DHCP configuration an operator pastes into a server we do not speak to. +/// +/// stdout is the snippet and stderr is everything else, so redirecting it produces a +/// file that can be included as-is. +#[cfg(feature = "boot")] +fn boot_snippet(cfg: &Config, args: &[String]) -> ExitCode { + use crate::boot::dhcp; + + let mut format = dhcp::Format::Dnsmasq; + let mut one_loader = false; + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + match arg.as_str() { + "--one-loader" => one_loader = true, + "--format" => match rest.next().map(|f| dhcp::Format::parse(f)) { + Some(Some(parsed)) => format = parsed, + Some(None) => { + eprintln!( + "unknown --format. Known: {}", + dhcp::Format::ALL + .iter() + .map(|f| f.label()) + .collect::>() + .join(", ") + ); + return ExitCode::FAILURE; + } + None => { + eprintln!("--format wants a name"); + return ExitCode::FAILURE; + } + }, + other => { + eprintln!("unexpected argument {other:?}"); + return ExitCode::FAILURE; + } + } + } + + let (host, derived) = cfg.public_host(); + if derived { + eprintln!( + "# warning: RESCRIPTUM_PUBLIC_HOST is not set, so this snippet points machines \ + at {host}, derived by asking the routing table. A DHCP server handing out an \ + address the machines cannot reach is the hardest failure here to diagnose." + ); + } + print!( + "{}", + dhcp::snippet( + format, + &dhcp::Handoff { + host, + media: cfg.endpoints().media, + version: env!("CARGO_PKG_VERSION"), + one_loader, + } + ) + ); + ExitCode::SUCCESS +} + +#[cfg(feature = "boot")] +fn boot_menu(cfg: &Config) -> ExitCode { + let Some(dir) = &cfg.media_dir else { + eprintln!("there is no media directory: RESCRIPTUM_MEDIA_DIR names one, and nothing does"); + return ExitCode::FAILURE; + }; + let catalog = crate::boot::catalog::Catalog::new(dir); + let listing = match catalog.listing() { + Ok(listing) => listing, + Err(e) => { + eprintln!("cannot read {}: {e}", dir.display()); + return ExitCode::FAILURE; + } + }; + let style = crate::boot::menu::Style { + title: cfg + .boot_title + .clone() + .unwrap_or_else(crate::boot::menu::Style::default_title), + timeout_millis: cfg.boot_timeout_millis(), + }; + print!( + "{}", + crate::boot::menu::menu(&listing, &cfg.endpoints(), &style) + ); + ExitCode::SUCCESS +} + +/// `boot check` — is the boot chain actually complete? +/// +/// **The failure this exists for is silent at the ROM.** A generated snippet names a +/// loader; if that file is not on disk, the machine asks for it, gets nothing, and +/// stops with no message anybody sees. Nothing else in the chain will notice. +#[cfg(feature = "boot")] +fn boot_check(cfg: &Config) -> ExitCode { + use crate::boot::loaders; + + let mut failures = 0usize; + let mut notes: Vec = Vec::new(); + + let Some(dir) = &cfg.boot_dir else { + println!("boot assets are off — RESCRIPTUM_BOOT_DIR names a directory, and nothing does"); + println!(" nothing to check; TFTP is not running either"); + return ExitCode::SUCCESS; + }; + println!("checking boot assets in {}", dir.display()); + + // Every loader the table can hand out, plus the `snp` variants that exist because + // the plain UEFI build cannot always see the NIC. + for loader in loaders::loaders() { + let path = dir.join(loader); + if path.is_file() { + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + println!(" ok {loader} ({})", human(size)); + } else { + // Named by a snippet, absent from the disk: the silent failure. + println!( + " MISSING {loader} — every machine the snippet sends here will ask for it, \ + get nothing, and stop" + ); + failures += 1; + } + for variant in loaders::variants(loader) { + if variant != loader && !dir.join(&variant).is_file() { + notes.push(format!( + "{variant} is absent — it is the one to reach for when the plain UEFI \ + build cannot see a NIC" + )); + } + } + } + + // The embedded script in every loader already shipped chains to a fixed port, and + // it can read no configuration — it is baked in before any deployment exists. + let media = cfg.media_addr(); + let port = media.rsplit_once(':').map(|(_, p)| p).unwrap_or(""); + let expected = crate::config::DEFAULT_MEDIA_ADDR + .rsplit_once(':') + .map(|(_, p)| p) + .unwrap_or("8001"); + if port != expected { + println!( + " WARNING the media listener is on port {port}, but every loader already shipped \ + embeds a script chaining to :{expected}. The generated autoexec.ipxe and the \ + script's own relative fallback are the recovery; moving it back is the fix." + ); + failures += 1; + } + + // The logo, which the menu asks for and tolerates the absence of. + if !dir.join("logo.png").is_file() { + notes.push( + "logo.png is absent — the menu's `console --picture` tolerates that and falls \ + back to the text console, so this is cosmetic" + .to_string(), + ); + } + + let (host, derived) = cfg.public_host(); + if derived { + notes.push(format!( + "RESCRIPTUM_PUBLIC_HOST is not set; generated scripts will name {host}" + )); + } + + println!( + " {} loader(s) the table can hand out", + loaders::loaders().len() + ); + for note in ¬es { + println!(" note: {note}"); + } + + if failures == 0 { + println!(" ok — the loaders a snippet names are all here"); + ExitCode::SUCCESS + } else { + println!(" {failures} problem(s)"); + ExitCode::FAILURE + } +} + // ---- config --------------------------------------------------------------- /// `config` / `config --json` / `config set KEY=VALUE` / `config unset KEY` diff --git a/src/main.rs b/src/main.rs index bde02e2..c3e5344 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,6 +85,7 @@ fn main() -> ExitCode { Some((cmd, rest)) if cmd == "import" => return cli::import(&cfg, rest), Some((cmd, rest)) if cmd == "export" => return cli::export(&cfg, rest), Some((cmd, rest)) if cmd == "media" => return cli::media(&cfg, rest), + Some((cmd, rest)) if cmd == "boot" => return cli::boot(&cfg, rest), Some((cmd, _)) => { eprintln!("unknown argument {cmd:?}\n"); eprint!("{}", cli::USAGE); diff --git a/tests/cli.rs b/tests/cli.rs index 1ebc51d..252968d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -971,3 +971,206 @@ fn config_value_prints_one_setting_but_never_a_credential() { assert!(!u.ok, "{u}"); assert!(u.stdout.is_empty(), "{u}"); } + +// ---- boot ------------------------------------------------------------------ + +/// A snippet is generated so an operator can *copy* rather than compose, so the shape +/// of what comes out is the contract — and stdout has to be the file, with everything +/// else on stderr, for `> dhcp.conf` to work. +#[cfg(feature = "boot")] +fn snippet(case: &Case, args: &[&str]) -> Run { + let mut all = vec!["boot", "dhcp-snippet"]; + all.extend_from_slice(args); + case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_PUBLIC_HOST", Path::new("192.0.2.10")), + ], + &all, + ) +} + +#[test] +#[cfg(feature = "boot")] +fn every_dhcp_format_names_the_loaders_the_tftp_table_actually_serves() { + // **The two are generated from one table precisely so a test can pin them + // together.** A snippet naming a loader the server does not hand out fails + // silently, at the ROM, with nothing on any console — it is the least diagnosable + // failure in the whole chain, and nothing else would catch it. + let case = Case::new(&[]); + let served = rescriptum::boot::loaders::loaders(); + + for format in ["dnsmasq", "isc", "kea", "powershell", "pfsense", "mikrotik"] { + let r = snippet(&case, &["--format", format]); + assert!(r.ok, "{format}: {r}"); + assert!(r.stdout.contains("192.0.2.10"), "{format}: {r}"); + + for loader in &served { + // pfSense and RouterOS are interfaces rather than files, and both say in + // their own output which architectures they cannot express. + if matches!(format, "pfsense" | "mikrotik") && loader.contains("arm64") { + continue; + } + assert!( + r.stdout.contains(loader), + "{format} does not name {loader}: {r}" + ); + } + } +} + +#[test] +#[cfg(feature = "boot")] +fn a_snippet_goes_to_stdout_and_warnings_go_to_stderr() { + // `boot dhcp-snippet > dhcpd.conf` has to produce a file that can be included. + let case = Case::new(&[]); + let r = snippet(&case, &["--format", "isc"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.starts_with("# rescriptum "), "{r}"); + assert!( + r.stderr.is_empty(), + "nothing on stderr when nothing is wrong: {r}" + ); +} + +#[test] +#[cfg(feature = "boot")] +fn a_derived_public_host_warns_on_stderr_without_spoiling_the_snippet() { + // A DHCP server handing out an address the machines cannot reach is the hardest + // failure in this chain to diagnose, so it is said — but on stderr, so the snippet + // is still usable when redirected. + let case = Case::new(&[]); + let r = case.run_env( + &[("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path())], + &["boot", "dhcp-snippet"], + ); + assert!(r.ok, "{r}"); + assert!(r.stderr.contains("RESCRIPTUM_PUBLIC_HOST"), "{r}"); + assert!(r.stdout.starts_with("# rescriptum "), "{r}"); +} + +#[test] +#[cfg(feature = "boot")] +fn an_unknown_dhcp_format_lists_the_ones_that_exist() { + let case = Case::new(&[]); + let r = snippet(&case, &["--format", "bind"]); + assert!(!r.ok, "{r}"); + assert!( + r.stderr.contains("dnsmasq"), + "the error must name what does work: {r}" + ); + // `netsh` is deliberately not an alias, because it cannot express this. + let r = snippet(&case, &["--format", "netsh"]); + assert!(!r.ok, "{r}"); +} + +#[test] +#[cfg(feature = "boot")] +fn boot_check_fails_when_a_snippet_names_a_loader_that_is_not_there() { + // The exit code is a contract, like `check`'s: `deploy.sh` keys on it. + let case = Case::new(&[]); + let boot_dir = case.dir.join("boot"); + fs::create_dir_all(&boot_dir).expect("boot dir"); + + let r = case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_BOOT_DIR", boot_dir.as_path()), + ], + &["boot", "check"], + ); + assert!(!r.ok, "an empty boot directory must fail: {r}"); + assert!(r.stdout.contains("MISSING"), "{r}"); + assert!( + r.stdout.contains("get nothing, and stop"), + "the reason has to say what the machine will do: {r}" + ); + + // Put every loader there and it passes. + for loader in rescriptum::boot::loaders::loaders() { + fs::write(boot_dir.join(loader), b"not really a loader").expect("write"); + } + let r = case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_BOOT_DIR", boot_dir.as_path()), + ], + &["boot", "check"], + ); + assert!(r.ok, "{r}"); + assert!( + r.stdout + .contains("ok — the loaders a snippet names are all here"), + "{r}" + ); +} + +#[test] +#[cfg(feature = "boot")] +fn boot_check_says_nothing_is_wrong_when_boot_assets_are_simply_off() { + // Off is a normal state, not a failure: media can be served with no TFTP at all. + let case = Case::new(&[]); + let r = case.run(&["boot", "check"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("boot assets are off"), "{r}"); +} + +#[test] +#[cfg(feature = "boot")] +fn boot_check_warns_when_the_media_port_is_not_the_one_loaders_embed() { + // The embedded script in every loader already shipped chains to a fixed port and + // can read no configuration — it is baked in before any deployment exists. + let case = Case::new(&[]); + let boot_dir = case.dir.join("boot"); + let media_dir = case.dir.join("media"); + fs::create_dir_all(&boot_dir).expect("boot dir"); + fs::create_dir_all(&media_dir).expect("media dir"); + for loader in rescriptum::boot::loaders::loaders() { + fs::write(boot_dir.join(loader), b"x").expect("write"); + } + + let r = case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_BOOT_DIR", boot_dir.as_path()), + ("RESCRIPTUM_MEDIA_DIR", media_dir.as_path()), + ("RESCRIPTUM_MEDIA_ADDR", Path::new("0.0.0.0:9999")), + ], + &["boot", "check"], + ); + assert!(!r.ok, "{r}"); + assert!(r.stdout.contains("8001"), "{r}"); + assert!(r.stdout.contains("already shipped"), "{r}"); +} + +#[test] +#[cfg(feature = "boot")] +fn the_bootstrap_and_the_menu_can_be_printed_for_review() { + // Everything a machine will execute has to be readable by a human before it runs on + // a rack, which is the same argument `render` makes for answers. + let case = Case::new(&[]); + let r = case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_PUBLIC_HOST", Path::new("192.0.2.10")), + ], + &["boot", "bootstrap"], + ); + assert!(r.ok, "{r}"); + assert!(r.stdout.starts_with("#!ipxe\n"), "{r}"); + assert!(r.stdout.contains("${netX/mac}"), "{r}"); + + let media_dir = case.dir.join("media"); + fs::create_dir_all(&media_dir).expect("media dir"); + let r = case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_MEDIA_DIR", media_dir.as_path()), + ("RESCRIPTUM_PUBLIC_HOST", Path::new("192.0.2.10")), + ], + &["boot", "menu"], + ); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("item local"), "{r}"); + assert!(r.stdout.is_ascii(), "a BIOS text console is not UTF-8: {r}"); +} diff --git a/tests/media.rs b/tests/media.rs index c5da7c2..622cfaa 100644 --- a/tests/media.rs +++ b/tests/media.rs @@ -847,3 +847,103 @@ fn a_derived_public_host_is_announced_loudly() { assert!(log.contains("RESCRIPTUM_PUBLIC_HOST is not set"), "{log}"); assert!(log.contains("derived"), "{log}"); } + +// ---- the generated scripts ------------------------------------------------- + +#[test] +fn the_bootstrap_is_served_when_the_answer_set_is_empty() { + // **This is the whole reason it lives on the media listener.** It has to work + // before anybody has written a single answer, which is the state every new install + // starts in — and it is what puts a MAC in the query string, without which the + // selection engine matches nothing but the default. + let s = Server::start(&[]); + let r = s.get("/ipxe/bootstrap"); + + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + let script = String::from_utf8_lossy(body_of(&r)).to_string(); + assert!(script.starts_with("#!ipxe\n"), "{script}"); + assert!(script.contains("mac=${netX/mac}"), "{script}"); + assert!(script.contains("/ipxe/boot?"), "{script}"); + // And an unclaimed machine falls through to the menu, which is `default.toml`'s + // job description applied to a different format. + assert!(script.contains("|| chain"), "{script}"); + assert!(script.contains("/ipxe/menu"), "{script}"); +} + +#[test] +fn the_menu_is_rendered_from_the_catalogue_at_request_time() { + // Not a file kept in sync: drop an ISO in the directory and it is in the menu on + // the next fetch. That is the same instinct as answers being discovered. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let script = String::from_utf8_lossy(body_of(&s.get("/ipxe/menu"))).to_string(); + assert!(script.contains("item pve-8.4"), "{script}"); + assert!(script.contains(":pve-8.4"), "a label to goto: {script}"); + + fs::write(s.media_dir().join("late.iso"), pve_image()).expect("write"); + std::thread::sleep(Duration::from_millis(1200)); + let script = String::from_utf8_lossy(body_of(&s.get("/ipxe/menu"))).to_string(); + assert!(script.contains("item late"), "{script}"); +} + +#[test] +fn the_menu_is_never_cached() { + // A cached menu shows yesterday's images, and the operator who just dropped an ISO + // in has no way to tell that from a broken catalogue. + let s = Server::start(&[]); + let r = s.get("/ipxe/menu"); + assert_eq!(header(&r, "cache-control").as_deref(), Some("no-store")); +} + +#[test] +fn the_menu_falls_through_to_the_local_disk() { + // **The safety behaviour that must not be lost.** A machine that PXE-boots by + // accident, and that nothing claims, ends up on its own disk after a few seconds — + // it never sits at a menu waiting for a human who is not coming, and it never + // installs anything. + let s = Server::start(&[]); + let script = String::from_utf8_lossy(body_of(&s.get("/ipxe/menu"))).to_string(); + assert!( + script.contains("--default local target || goto local"), + "{script}" + ); + assert!(script.contains(":local\n"), "{script}"); +} + +#[test] +fn a_boot_asset_is_served_over_http_for_uefi_http_boot() { + // Firmware that HTTP-boots fetches its loader here rather than over TFTP — the + // shortest chain there is, and it skips TFTP entirely. + let s = Server::start_env(&[], &[]); + let boot_dir = s.media_dir().parent().expect("base").join("boot"); + fs::create_dir_all(&boot_dir).expect("boot dir"); + fs::write(boot_dir.join("ipxe-x86_64.efi"), b"a loader, pretend").expect("write"); + + // A fresh server, now with the boot directory named. + let s = Server::start_env(&[], &[("RESCRIPTUM_BOOT_DIR", boot_dir.to_str().unwrap())]); + let r = s.get("/boot/ipxe-x86_64.efi"); + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert_eq!(body_of(&r), b"a loader, pretend"); + + // And nothing outside that directory. + for path in ["/boot/../../etc/passwd", "/boot/nope.efi"] { + let r = s.get(path); + assert!( + status(&r).starts_with("HTTP/1.1 404"), + "{path}: {}", + head_of(&r) + ); + } + let _ = fs::remove_dir_all(&boot_dir); +} + +#[test] +fn a_boot_asset_route_with_no_boot_directory_says_which_setting_is_missing() { + let s = Server::start(&[]); + let r = s.get("/boot/ipxe-x86_64.efi"); + assert!(status(&r).starts_with("HTTP/1.1 404"), "{}", head_of(&r)); + assert!( + String::from_utf8_lossy(body_of(&r)).contains("RESCRIPTUM_BOOT_DIR"), + "a 404 that names the setting beats one that does not: {:?}", + String::from_utf8_lossy(body_of(&r)) + ); +} From 16fa55cf84c4a43b789c99502f9f95f19a3f81d7 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 13:35:55 +0200 Subject: [PATCH 08/59] build(ipxe): the branded loader build, its pin, and the CI job that runs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last piece of Phase 2 that is not Rust: what TFTP actually hands out. `branding.h`, the embedded script, a SHA-pinned upstream commit, a build script and a CI job that builds all six loaders and then asks the server whether the set satisfies the table it serves from. **Written, not yet built, and the README says so in its first heading.** The pin was chosen from upstream's tag list and the make targets from upstream's documentation; neither has been compiled here. The first CI run is what turns that from plausible into verified, and `PINNED` records the fallback commit for the likely case that v2.0.0's major bump does not build cleanly. Two decisions worth keeping: - **The embedded script's port is a contract**, not a preference. It can read no configuration — it is baked in before any deployment exists — so 8001 is as fixed there as an answer URL baked into an ISO. `boot check` warns when the configured port has moved away from it. - **`PRODUCT_ERROR_URI` is deliberately left pointing at ipxe.org.** That database turns a 32-bit error code into a sentence and links the line of code that raised it. Redirecting it at us would replace a working diagnostic service with nothing, and the person staring at a hex code at 3am is exactly who it exists for. `PRODUCT_SHORT_NAME` stays "iPXE" for upstream's own stated reason. The GPL obligation is met by construction rather than bolted on: the loaders are separate files never linked into an MIT binary, and this directory is the written offer for their source. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- .github/workflows/ci.yml | 49 +++++++++++++- packaging/ipxe/PINNED | 19 ++++++ packaging/ipxe/README.md | 91 +++++++++++++++++++++++++ packaging/ipxe/branding.h | 36 ++++++++++ packaging/ipxe/build.sh | 139 ++++++++++++++++++++++++++++++++++++++ packaging/ipxe/embed.ipxe | 20 ++++++ 6 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 packaging/ipxe/PINNED create mode 100644 packaging/ipxe/README.md create mode 100644 packaging/ipxe/branding.h create mode 100755 packaging/ipxe/build.sh create mode 100644 packaging/ipxe/embed.ipxe diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 317a07b..6f08b59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,9 +49,56 @@ jobs: run: cargo test --all-features # The smallest build has to keep working, or the NAS target rots unnoticed. - - name: Build without SQLite + # Both features off, and then each on its own: the combination that ships is not + # the only one that has to compile. + - name: Build without SQLite or boot media run: cargo build --release --no-default-features + - name: Build with boot media but no SQLite + run: cargo build --release --no-default-features --features boot + + loaders: + name: Branded iPXE loaders + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # A C toolchain and the ARM64 cross binutils. iPXE's EFI targets need the + # architecture's own `ld` and `objcopy`; the BIOS ones build with the host's. + - name: Toolchain + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential liblzma-dev mtools xorriso gcc-aarch64-linux-gnu + aarch64-linux-gnu-ld --version | head -1 + + - name: Build the loaders + run: packaging/ipxe/build.sh --out "$GITHUB_WORKSPACE/loaders" + + # **The chain of custody.** What the rig boots and what a release publishes have to + # be the same bytes, never a rebuild of them — a loader rebuilt after the run that + # proved it is a loader nobody has booted. + - name: What was built + run: | + ls -la "$GITHUB_WORKSPACE/loaders" + cat "$GITHUB_WORKSPACE/loaders/SHA256SUMS" + + # Ask the server whether the directory satisfies the table it serves from. A + # snippet naming a loader that is not here fails silently at the ROM, and this is + # the only thing that catches it. + - name: Does the server agree the set is complete? + run: | + cargo build --release + RESCRIPTUM_BOOT_DIR="$GITHUB_WORKSPACE/loaders" \ + RESCRIPTUM_PUBLIC_HOST=192.0.2.10 \ + ./target/release/rescriptum boot check + + - uses: actions/upload-artifact@v4 + with: + name: ipxe-loaders + path: ${{ github.workspace }}/loaders + if-no-files-found: error + docs: name: Documentation runs-on: ubuntu-latest diff --git a/packaging/ipxe/PINNED b/packaging/ipxe/PINNED new file mode 100644 index 0000000..2a6dafd --- /dev/null +++ b/packaging/ipxe/PINNED @@ -0,0 +1,19 @@ +# The upstream iPXE commit every rescriptum release builds from, and the GPLv2 written +# offer this repository makes: the source for the loaders we ship is this commit, plus +# branding.h and embed.ipxe beside this file. Nothing else is patched. +# +# **Pinned by SHA, not by tag.** A tag is a pointer somebody can move; a release that +# quietly changed what every machine boots is not an appliance. The tag is recorded +# alongside only so a human can tell at a glance how old the pin is. +# +# Bump it deliberately, and re-run the rig afterwards: what a loader does before our +# embedded script runs is upstream's code, and it is the half no test in this repository +# covers. +# +# NOT YET BUILT. This pin was chosen from the tag list, not from a build — see the +# "Status" section of README.md beside this file. The first CI run is what turns it from +# a plausible version into a verified one, and if v2.0.0 does not build cleanly the +# answer is v1.21.1 (988d2c13cdf0f0b4140685af35ced70ac5b3283c), the release before it. +IPXE_COMMIT=12798ec29aa8a64d8675c4378b99f5fe28447afb +IPXE_TAG=v2.0.0 +IPXE_REPO=https://github.com/ipxe/ipxe.git diff --git a/packaging/ipxe/README.md b/packaging/ipxe/README.md new file mode 100644 index 0000000..a3f3e85 --- /dev/null +++ b/packaging/ipxe/README.md @@ -0,0 +1,91 @@ +# The branded iPXE loaders + +What TFTP hands out, and the first thing a machine executes that we wrote. + +## Status: written, not yet built + +**Nothing here has been compiled.** The pin was chosen from upstream's tag list, the +build options from upstream's documentation, and the file names from +`src/boot/loaders.rs`. That is enough to be reviewable and not enough to be trusted: +until CI has run `build.sh` once and the rig has booted what it produced, treat this +directory as a proposal. + +The two things most likely to be wrong are the pin (v2.0.0 is a major bump nobody here +has built; v1.21.1 is the fallback, and `PINNED` records its SHA) and the exact make +targets for the EFI variants. Both fail loudly at the first build, which is the point of +having one. + +## Why we build it at all + +Three reasons converged, and any one would have settled it: + +1. **The entry point.** A stock `undionly.kpxe` from ipxe.org does DHCP, is told to load + iPXE, and loads itself forever — the documented chainloading loop. A stock + netboot.xyz binary chains to the *public* `boot.netboot.xyz`: no loop, but our menu + and our answers are never consulted. Only an embedded script gets a machine talking + to this server, and only a build we control can carry one. +2. **The name on the first line**, before anything else is on screen, plus a framebuffer + console that a stock binary may not have compiled in. +3. **The feature set.** We choose what is in — PNG, the menu commands, `sanboot`, the + console — rather than discovering at a customer site that a variant lacks one. + +## What is here + +| File | What it is | +|---|---| +| `PINNED` | The upstream commit, **by SHA**. A tag is a pointer somebody can move | +| `branding.h` | Our name, and the two URIs we deliberately do *not* change | +| `embed.ipxe` | The embedded script: the entry point of the whole chain | +| `build.sh` | Clones, pins, patches, builds, hashes, writes `NOTICE` | + +## The two URIs we leave alone + +`PRODUCT_ERROR_URI` and the command-help URI point at ipxe.org's database, which turns a +32-bit error code into a sentence, names the source file that produced it, and links the +line of code that raised it. Redirecting them at us would replace a working diagnostic +service with nothing — and the person staring at a hex code at 3am is exactly who that +database exists for. + +Keeping `PRODUCT_SHORT_NAME` as `iPXE` is upstream's own request, "to minimise end-user +confusion", and it is also the right way to use somebody's GPLv2 work. + +## The port in `embed.ipxe` is a contract + +The embedded script can read no configuration — it is baked in before any deployment +exists. It knows exactly two things: `${next-server}`, which DHCP supplies, and a port, +which nothing supplies. So **8001 is as fixed here as an answer URL baked into an ISO**. + +Moving `RESCRIPTUM_MEDIA_ADDR` is allowed and costs exactly this. Three things keep it +survivable, and `boot check` reports the first sign of trouble: + +- the generated `autoexec.ipxe` in the TFTP root carries the *configured* address, so + platforms that fetch it recover with no rebuild; +- the embedded script's own `||` turns the refused `chain` into a second chance — a + relative fetch that resolves, over HTTP, to whatever port actually served the loader; +- `boot check` warns when the configured port is not the one shipped loaders embed. + +## Licensing + +iPXE is **GPLv2** (with the UBDL exception); rescriptum is MIT. The loaders are separate +files, never linked into our binary — mere aggregation, obvious and auditable. **No +binaries in this repository, ever**: they are a release artifact carrying the upstream +licence texts, a `NOTICE` naming the exact commit and digests, and the written offer for +source, which is this directory. + +## Building + +```console +$ ./build.sh # into ./out +$ ./build.sh --out /srv/boot +``` + +Then point `RESCRIPTUM_BOOT_DIR` at the output and ask the server whether it agrees: + +```console +$ rescriptum boot check +``` + +That compares the directory against the same table the TFTP server serves from and +`boot dhcp-snippet` generates from. **A snippet naming a loader that is not on disk +fails silently, at the ROM**, with nothing on any console — it is the least diagnosable +failure in the whole chain, and this is what catches it. diff --git a/packaging/ipxe/branding.h b/packaging/ipxe/branding.h new file mode 100644 index 0000000..8098bb5 --- /dev/null +++ b/packaging/ipxe/branding.h @@ -0,0 +1,36 @@ +/* + * rescriptum's branding for iPXE. + * + * Copied over src/config/local/branding.h in a pinned iPXE checkout. See README.md + * beside this file for what is built and why we build it at all. + * + * Two rules, and both come from upstream's own comment in src/config/branding.h: + * + * - PRODUCT_SHORT_NAME should either be a substring of PRODUCT_NAME or stay "iPXE", + * "to minimise end-user confusion". It stays "iPXE": what appears in a BIOS boot + * selection menu should say what the thing actually is. + * + * - The error and command-help URIs are **deliberately left alone**. They point at + * ipxe.org's database, which turns a 32-bit error code into a sentence, names the + * source file that produced it, and links the line of code. Redirecting them at us + * would replace a working diagnostic service with nothing — the operator staring at + * a hex code at 3am is the person that database exists for. + * + * Keeping the iPXE attribution is also the right way to use somebody's GPLv2 work. + */ + +#ifndef CONFIG_LOCAL_BRANDING_H +#define CONFIG_LOCAL_BRANDING_H + +#undef PRODUCT_NAME +#undef PRODUCT_SHORT_NAME +#undef PRODUCT_URI +#undef PRODUCT_TAG_LINE + +/* Shown before any iPXE branding, which is the first line a machine puts on screen. */ +#define PRODUCT_NAME "rescriptum boot" +#define PRODUCT_SHORT_NAME "iPXE" +#define PRODUCT_URI "https://github.com/z29k/rescriptum" +#define PRODUCT_TAG_LINE "Every machine its own install" + +#endif /* CONFIG_LOCAL_BRANDING_H */ diff --git a/packaging/ipxe/build.sh b/packaging/ipxe/build.sh new file mode 100755 index 0000000..dedf506 --- /dev/null +++ b/packaging/ipxe/build.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Build the branded iPXE loaders rescriptum ships. +# +# ./build.sh # every loader the table names, into ./out +# ./build.sh --out DIR +# +# Needs a C toolchain, GNU make, perl, and — for the EFI targets — the cross binutils +# for that architecture. On Debian: +# +# apt install build-essential liblzma-dev mtools gcc-aarch64-linux-gnu +# +# ## Why we build iPXE at all +# +# Three independent reasons converged on it, and any one would have been enough: +# +# 1. **The entry point.** A stock loader either re-loads itself forever (the +# documented chainloading loop) or chains to the public boot.netboot.xyz. Only an +# embedded script gets a machine talking to *this* server, and only a build we +# control can carry one. +# 2. **The name on the first line**, before anything else is on screen, and a +# framebuffer console that a stock binary may not have compiled in at all. +# 3. **The feature set.** We choose what is compiled in — PNG, the menu commands, +# sanboot, the console — rather than discovering at a customer site that a variant +# lacks one. +# +# ## The GPL obligation, met by construction +# +# iPXE is GPLv2 (with the UBDL exception) and rescriptum is MIT. What makes that a +# non-conversation is that the loaders are **separate files, never linked into our +# binary**: mere aggregation, obvious and auditable. This script, `branding.h`, +# `embed.ipxe` and `PINNED` are the written offer — everything needed to reproduce what +# we ship, in the same repository as the thing that serves it. + +set -euo pipefail +cd "$(dirname "$0")" + +OUT="$PWD/out" +while [ $# -gt 0 ]; do + case "$1" in + --out) OUT="$2"; shift 2 ;; + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unexpected argument: $1" >&2; exit 2 ;; + esac +done + +# shellcheck disable=SC1091 +. ./PINNED + +WORK="${WORK:-$PWD/.work}" +mkdir -p "$OUT" "$WORK" + +if [ ! -d "$WORK/ipxe/.git" ]; then + echo "cloning iPXE into $WORK/ipxe" + git clone --quiet "$IPXE_REPO" "$WORK/ipxe" +fi + +# Pinned by SHA. A fetch first, because a shallow or stale clone may not have it yet. +git -C "$WORK/ipxe" fetch --quiet --tags origin +git -C "$WORK/ipxe" checkout --quiet "$IPXE_COMMIT" +echo "iPXE at $IPXE_COMMIT (${IPXE_TAG:-no tag})" + +cp branding.h "$WORK/ipxe/src/config/local/branding.h" + +# What has to be compiled in, and why each one is here rather than a default: +# IMAGE_PNG the logo behind the menu +# CONSOLE_FRAMEBUFFER the console that can show it +# IMAGE_TRUST_CMD so a site that wants signed images can have them +# PARAM_CMD/NSLOOKUP_CMD/PING_CMD/REBOOT_CMD/POWEROFF_CMD the diagnostics menu +# VLAN_CMD a boot VLAN is the recommendation that actually works +cat > "$WORK/ipxe/src/config/local/general.h" <<'CONFIG' +/* rescriptum: what the menu and the diagnostics entries need. */ +#define IMAGE_PNG +#define CONSOLE_FRAMEBUFFER +#define IMAGE_TRUST_CMD +#define PARAM_CMD +#define NSLOOKUP_CMD +#define PING_CMD +#define REBOOT_CMD +#define POWEROFF_CMD +#define VLAN_CMD +#define NTP_CMD +#define CONSOLE_CMD +CONFIG + +build() { + local target="$1" output="$2" + echo "building $target" + make -C "$WORK/ipxe/src" -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" \ + "bin/$target" EMBED="$PWD/embed.ipxe" >/dev/null 2>&1 || + make -C "$WORK/ipxe/src" "bin/$target" EMBED="$PWD/embed.ipxe" + cp "$WORK/ipxe/src/bin/$target" "$OUT/$output" +} + +build_efi() { + local arch="$1" target="$2" output="$3" + echo "building $arch/$target" + make -C "$WORK/ipxe/src" -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" \ + ARCH="$arch" "bin-$arch-efi/$target" EMBED="$PWD/embed.ipxe" >/dev/null 2>&1 || + make -C "$WORK/ipxe/src" ARCH="$arch" "bin-$arch-efi/$target" EMBED="$PWD/embed.ipxe" + cp "$WORK/ipxe/src/bin-$arch-efi/$target" "$OUT/$output" +} + +# The names here are the ones `src/boot/loaders.rs` hands out and +# `boot dhcp-snippet` writes into somebody's DHCP server. **They must not drift**: +# `boot check` compares this directory against that table, and a snippet naming a file +# that is not here fails silently at the ROM. +build undionly.kpxe ipxe-undionly.kpxe +build ipxe.pxe ipxe.kpxe + +build_efi x86_64 ipxe.efi ipxe-x86_64.efi +build_efi x86_64 snp.efi ipxe-x86_64-snp.efi +build_efi x86_64 snponly.efi ipxe-x86_64-snponly.efi + +build_efi arm64 ipxe.efi ipxe-arm64.efi +build_efi arm64 snp.efi ipxe-arm64-snp.efi +build_efi arm64 snponly.efi ipxe-arm64-snponly.efi + +# The same build emits the media a machine with no PXE ROM can still use: an ISO for +# IPMI virtual media, and a USB image for a stick. Free, since the objects already exist. +build_efi x86_64 ipxe.iso ipxe-x86_64.iso || echo "note: the ISO target needs mtools/xorriso" +build_efi x86_64 ipxe.usb ipxe-x86_64.usb || echo "note: the USB target needs mtools" + +( cd "$OUT" && sha256sum ./* > SHA256SUMS 2>/dev/null || shasum -a 256 ./* > SHA256SUMS ) + +cat > "$OUT/NOTICE" < Date: Thu, 27 Aug 2026 13:40:28 +0200 Subject: [PATCH 09/59] docs(guide): netbooting a machine, in both languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2's page: the four links from power-on, which of them are ours, the two lines their DHCP server needs, why the loader has to carry an embedded script at all, and what a machine actually sees. Three things it says plainly because they are the ways this fails quietly on somebody else's network: a UEFI HTTP Boot client discards an offer that does not echo `HTTPClient` in option 60, a Windows DHCP policy cannot condition on option 93 at all, and a snippet naming a loader that is not on disk fails silently at the ROM with nothing on any console. The scope statement needed another correction. "Not a TFTP server — not yet" was true for one commit; it is now a TFTP server, and the honest remaining non-goal is DHCP in any form. The blast-radius table moves into the guide too, because "a boot server" sounds load-bearing and is not: nothing this installs depends on it afterwards. The loaders page carries a warning rather than instructions that would not work — `packaging/ipxe/` is written and unbuilt, and the guide says so where somebody would otherwise go looking for a download. `notabene lint` green over 76 files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- docs/guide/index.fr.md | 10 +- docs/guide/index.md | 8 +- docs/guide/operations/index.fr.md | 1 + docs/guide/operations/index.md | 1 + docs/guide/operations/netboot.fr.md | 291 +++++++++++++++++++++++ docs/guide/operations/netboot.md | 271 +++++++++++++++++++++ docs/guide/reference/cli.fr.md | 27 +++ docs/guide/reference/cli.md | 24 ++ docs/guide/reference/configuration.fr.md | 10 + docs/guide/reference/configuration.md | 10 + 10 files changed, 642 insertions(+), 11 deletions(-) create mode 100644 docs/guide/operations/netboot.fr.md create mode 100644 docs/guide/operations/netboot.md diff --git a/docs/guide/index.fr.md b/docs/guide/index.fr.md index adf9b30..7622c9f 100644 --- a/docs/guide/index.fr.md +++ b/docs/guide/index.fr.md @@ -93,15 +93,12 @@ validateur de l'installateur lui-même quand il est dans le PATH. - **Pas un serveur DHCP, sous aucune forme.** Ni répondeur, ni proxy, ni derrière un drapeau. Les sites qui déploient ceci en ont déjà un, et le faire pointer vers un serveur de démarrage est un problème résolu depuis trente ans. -- **Pas un serveur TFTP** — pas encore. Il sait servir le noyau, l'initrd et l'image de - l'installeur en HTTP (voir [Servir les médias de démarrage](./operations/media.md)), - ce dont se sert chaque étape après la première. Livrer le *chargeur* reste l'affaire de - ce que vous faites déjà tourner. +- **Pas un système de gestion de configuration.** Il livre un document au moment de + l'installation et n'a ensuite plus rien à voir avec la machine — rien de ce qu'il + installe n'en dépend ensuite. - **Pas un validateur de schéma.** Il prouve que vos documents sont bien formés et fusionnent proprement. Savoir si le résultat est du *Proxmox* valide est le travail de `proxmox-auto-install-assistant`, et `check` l'appellera s'il est installé. -- **Pas un système de gestion de configuration.** Il remet un document au moment de - l'installation et n'a plus rien à voir avec la machine ensuite. ## Deux réalités de déploiement @@ -125,6 +122,7 @@ propre. - [Préparer les médias d'installation](./iso.md) — l'URL à graver dans chaque ISO. - [Écrire des réponses](./answers/index.md) — sélection, formats, groupes, templating. - [L'exploiter](./operations/index.md) — déploiement, sécurité, stockage, dépannage. +- [Médias de démarrage](./operations/media.md) et [démarrage réseau](./operations/netboot.md) — servir l'installeur lui-même, pas seulement sa réponse. Vous travaillez *sur* rescriptum plutôt qu'avec ? L'espace [Développement](../development/index.md) est l'autre moitié de ce site. diff --git a/docs/guide/index.md b/docs/guide/index.md index 658f05a..a89cb46 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -85,14 +85,11 @@ one is on PATH. - **Not a DHCP server, in any form.** Not a responder, not a proxy, not behind a flag. Sites that deploy this already run one, and pointing it at a boot server is a solved problem with thirty years of tooling. -- **Not a TFTP server** — not yet. It can serve the installer's kernel, initrd and image - over HTTP (see [Serving boot media](./operations/media.md)), which is what every stage - after the first one uses. Handing over the *loader* is still whatever you already run. +- **Not a config management system.** It hands over a document at install time and then + has nothing more to do with the machine — nothing it installs depends on it afterwards. - **Not a schema validator.** It proves your documents are well-formed and merge cleanly. Whether the result is valid *Proxmox* is `proxmox-auto-install-assistant`'s job, and `check` will call it when it is installed. -- **Not a config management system.** It hands over a document at install time and then - has nothing more to do with the machine. ## Two deployment realities @@ -114,6 +111,7 @@ nothing parsed per request — grouping is the fast path, not just the tidy one. - [Preparing installer media](./iso.md) — the URL to bake into each ISO. - [Writing answers](./answers/index.md) — selection, formats, groups, templating. - [Running it](./operations/index.md) — deployment, security, storage, troubleshooting. +- [Boot media](./operations/media.md) and [netbooting](./operations/netboot.md) — serve the installer itself, not only its answer. Working on rescriptum rather than with it? The [Development](../development/index.md) space is the other half of this site. diff --git a/docs/guide/operations/index.fr.md b/docs/guide/operations/index.fr.md index 4607f02..cb8c3d7 100644 --- a/docs/guide/operations/index.fr.md +++ b/docs/guide/operations/index.fr.md @@ -26,6 +26,7 @@ le droit de servir, et à qui. listener, avec une écriture qui ne peut pas casser le parc. - **[Dépannage](./troubleshooting.md)** — la ligne de log est tout le diagnostic disponible. - [Servir les médias de démarrage](./media.md) — le noyau, l'initrd et l'image de l'installeur, depuis le même serveur. +- [Démarrer une machine par le réseau](./netboot.md) — TFTP, le chargeur, le menu, et les deux lignes de leur DHCP. ## La forme d'un déploiement diff --git a/docs/guide/operations/index.md b/docs/guide/operations/index.md index bf88d31..f116a89 100644 --- a/docs/guide/operations/index.md +++ b/docs/guide/operations/index.md @@ -26,6 +26,7 @@ it is allowed to serve and to whom. with a write that cannot break the fleet. - **[Troubleshooting](./troubleshooting.md)** — the log line is the whole diagnostic - [Serving boot media](./media.md) — the installer's own kernel, initrd and image, from the same server. +- [Netbooting a machine](./netboot.md) — TFTP, the loader, the menu, and their DHCP server's two lines. story. ## The shape of a deployment diff --git a/docs/guide/operations/netboot.fr.md b/docs/guide/operations/netboot.fr.md new file mode 100644 index 0000000..d64b2ca --- /dev/null +++ b/docs/guide/operations/netboot.fr.md @@ -0,0 +1,291 @@ +--- +title: Démarrer une machine par le réseau +description: TFTP, le chargeur, le menu — toute la chaîne de la mise sous tension à l'installation sans surveillance, avec deux options ajoutées à un serveur DHCP que vous exploitez déjà. +sidebar: + label: Démarrage réseau + order: 9 +--- + +# Démarrer une machine par le réseau + +Une machine s'allume. Quatre maillons plus tard, elle s'installe comme quelqu'un l'a +décidé — ou, si personne n'a encore rien décidé à son sujet, elle attend dans un menu où +un humain peut le faire. + +``` + mise sous tension + │ +(1) ├── le DHCP dit d'où démarrer ............ À EUX. Deux options, et nous + │ générons l'extrait qui les pose. + ▼ +(2) ├── TFTP livre un chargeur ............... À NOUS + │ un iPXE adapté à l'architecture, qui enchaîne via ${next-server} + ▼ +(3) ├── iPXE demande quoi faire .............. À NOUS + │ machine connue → sa propre réponse sans surveillance + │ machine inconnue → le menu + ▼ +(4) └── les octets arrivent .................. À NOUS + noyau, initrd, l'image elle-même — HTTP avec plages +``` + +**Le maillon 1 appartient à quelqu'un d'autre et cela ne changera pas.** rescriptum ne +parle pas DHCP du tout — ni serveur, ni proxy, ni derrière un drapeau. Les sites qui +déploient ceci en ont déjà un, et le faire pointer vers un serveur de démarrage est un +problème résolu depuis trente ans. + +## Mise en route + +```console +$ export RESCRIPTUM_MEDIA_DIR=/srv/media # les images +$ export RESCRIPTUM_BOOT_DIR=/srv/boot # les chargeurs +$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 # ce que nommeront les scripts générés +``` + +`RESCRIPTUM_BOOT_DIR` est l'interrupteur de TFTP comme `RESCRIPTUM_MEDIA_DIR` l'est des +médias : non définie, il n'y a aucun listener TFTP. + +Le port 69 est privilégié, et c'est le *seul* port privilégié que ce serveur demandera +jamais — sans répondeur DHCP, il n'y a rien après 67 ni 4011. Trois façons de l'obtenir, +toutes portables : + +```console +$ export RESCRIPTUM_USER=rescriptum # démarrer en root, lier, puis abandonner +$ setcap cap_net_bind_service=+ep rescriptum # ou n'accorder que cette capacité +$ export RESCRIPTUM_TFTP_ADDR=0.0.0.0:6969 # ou le déplacer, si leur DHCP sait le dire +``` + +**On lie d'abord, on abandonne ensuite**, toujours. L'ordre inverse fonctionne en test +sous root et échoue au déploiement, à un redémarrage — le seul moment où personne ne +regarde. + +## Les deux lignes de leur serveur DHCP + +```console +$ rescriptum boot dhcp-snippet --format dnsmasq +# rescriptum 0.2.0 - boot handoff for 192.0.2.10 +# Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp. +# Generated from the same table the TFTP server serves from. +dhcp-match=set:bios,option:client-arch,0 +dhcp-match=set:efi64,option:client-arch,7 +dhcp-match=set:efi64,option:client-arch,9 +dhcp-match=set:efiarm64,option:client-arch,11 +… +``` + +`--format` couvre `dnsmasq`, `isc`, `kea`, `powershell`, `pfsense` et `mikrotik` ; +`--one-loader` produit la forme d'une seule ligne pour un parc d'une seule architecture. + +**L'extrait et le serveur TFTP sont générés depuis une même table**, de sorte que ce que +vous collez et ce que le serveur distribue ne peuvent pas diverger. Ce qu'ils *peuvent* +faire, c'est nommer un chargeur que personne n'a encore téléchargé, et cela échoue +silencieusement au niveau de la ROM : la machine demande, ne reçoit rien, et s'arrête +sans un message sur aucune console. Une commande l'attrape : + +```console +$ rescriptum boot check +checking boot assets in /srv/boot + ok ipxe-arm64.efi (1.0M) + MISSING ipxe-undionly.kpxe — every machine the snippet sends here will ask for it, + get nothing, and stop +``` + +Son code de sortie est un contrat, comme celui de `check`. Placez-le au même endroit. + +### Quatre détails que l'extrait généré traite correctement + +Chacun est une façon d'échouer sans bruit sur le réseau de quelqu'un d'autre, et aucun +n'est évident : + +- **Le champ BOOTP `file` *et* l'option 67.** Certaines ROM ne lisent que l'un des deux, + et lequel n'est pas prévisible d'après le fournisseur. +- **Un défaut sans étiquette à la fin.** Chaque ligne d'architecture est étiquetée : une + ROM qui n'envoie pas d'option 93 ne correspondrait à rien et n'obtiendrait aucun + fichier de démarrage. +- **`HTTPClient` renvoyé dans l'option 60** pour les clients UEFI HTTP Boot. Le firmware + *filtre les offres* dessus : une réponse ne portant que l'URL est écartée, en silence, + ce qui est indiscernable d'une absence de serveur DHCP. +- **Un next-server pour ces clients aussi**, bien qu'ils récupèrent en HTTP. Sans lui, le + script embarqué du chargeur lit un `${next-server}` vide et enchaîne vers nulle part. + +::: tip Windows Server +Une stratégie DHCP **ne peut pas se conditionner sur l'option 93** — les types de +condition sont la classe fournisseur, la classe utilisateur, la MAC, l'identifiant +client, le FQDN et les informations de relais. L'architecture n'atteint une stratégie +qu'à l'intérieur de la chaîne de l'option 60, donc le PowerShell généré définit des +classes fournisseur sur `PXEClient:Arch:00007*` et y accroche les stratégies. Même +résultat, mécanisme différent, et c'est exactement le genre de chose qu'on retient à +moitié. +::: + +## Le chargeur + +TFTP livre **un fichier**, et la règle est écrite dans le code : + +> **TFTP livre le chargeur. Tout ce qui suit passe en HTTP.** + +À 1468 octets par aller-retour, TFTP déplace environ 1,4 Mo/s sur une milliseconde de +latence. Le chargeur fait un mégaoctet : deux secondes. Une image de 1,5 Go prendrait +près de vingt minutes, contre quinze secondes en HTTP sur le même câble. + +Quel chargeur dépend de ce que le firmware a annoncé : + +| Option 93 | Client | Servi | +|---|---|---| +| `0x0000` | BIOS PXE | `ipxe-undionly.kpxe` | +| `0x0007`, `0x0009` | UEFI x86-64 | `ipxe-x86_64.efi`, plus `-snp` / `-snponly` | +| `0x000b` | UEFI ARM64 | `ipxe-arm64.efi` | +| `0x0010`, `0x0013` | UEFI HTTP Boot | les mêmes fichiers, en HTTP, sans TFTP du tout | +| tout le reste | UEFI 32 bits, EBC, U-Boot | refusé, avec la raison | + +`0x0009` mérite un mot. La RFC 4578 le définissait comme « EFI x86-64 » ; le registre +IANA, réécrit par la RFC 5970, le liste comme « EBC ». Les vrais firmwares x64 envoient +l'un ou l'autre, donc les deux pointent vers x64 — une table produite depuis le seul +registre ne donnerait rien à la moitié d'un parc. + +`snponly` existe parce que la construction UEFI ordinaire ne voit pas toujours la carte +réseau. Toutes les variantes sont servies et la table choisit ; c'est précisément le +savoir qu'un exploitant ne devrait pas avoir à acquérir. + +::: warning Les chargeurs ne sont pas encore construits +`packaging/ipxe/` contient le branding, le script embarqué et la construction — mais rien +dans ce dépôt ne les a compilés, et aucune version publiée ne les distribue. En +attendant, construisez-les vous-même (`packaging/ipxe/build.sh`) ou pointez +`RESCRIPTUM_BOOT_DIR` vers des chargeurs venus d'ailleurs, à condition qu'ils enchaînent +vers ce serveur plutôt que vers Internet — voir ci-dessous. +::: + +## Comment iPXE finit par parler à *nous* + +La question qu'on ne s'attend pas à devoir trancher. Quel que soit le livreur du +chargeur : + +- Un `undionly.kpxe` ordinaire venu d'ipxe.org fait du DHCP, se fait dire de charger + iPXE, et **se recharge lui-même indéfiniment** — la boucle d'enchaînement documentée + par iPXE. +- Un binaire netboot.xyz d'origine embarque un script qui va droit au menu **public** + `boot.netboot.xyz`. Pas de boucle, mais votre menu et vos réponses ne sont jamais + consultés. + +Les chargeurs que rescriptum distribue portent un script de trois lignes qui enchaîne via +`${next-server}` — la valeur que l'option 66 a déjà posée, puisque c'est ainsi que le +chargeur est arrivé. Une seule construction générique fonctionne donc dans tous les +déploiements, sans seconde condition dans un fichier de configuration qui appartient à +quelqu'un d'autre. + +Le script enchaîne vers le **port 8001**, et c'est un contrat plutôt qu'une préférence : +il est gravé dans le chargeur avant qu'aucun déploiement n'existe et ne peut lire aucune +configuration. Déplacer `RESCRIPTUM_MEDIA_ADDR` est permis, et `boot check` le signale. + +## Ce qu'une machine voit + +L'étape deux met l'identité de la machine dans la chaîne de requête, la seule chose que +DHCP ne peut pas faire — une option DHCP ne peut pas porter `${net0/mac}` : + +```console +$ rescriptum boot bootstrap +#!ipxe +chain http://192.0.2.10:8000/ipxe/boot?mac=${netX/mac}&uuid=${uuid}\ +&serial=${serial:uristring}&asset=${asset:uristring}\ +… +|| chain http://192.0.2.10:8001/ipxe/menu +``` + +Deux détails y sont porteurs. **`netX`, pas `net0`** — `net0` n'est que la première +interface, donc un serveur démarrant par son second port s'identifierait par le premier, +inutilisé. Et **`:uristring`** sur chaque chaîne SMBIOS, parce que `${manufacturer}` +s'étend en `Dell Inc.` avec l'espace et qu'iPXE n'encode rien de lui-même. + +Ce `||` final, c'est tout « un menu est la réponse par défaut » : une machine que quelque +chose réclame reçoit sa propre réponse sans surveillance, et une machine que rien ne +réclame retombe sur le menu. C'est la description de poste de `default.toml`, mot pour +mot, appliquée à un autre format. + +## Le menu + +```console +$ rescriptum boot menu +``` + +Rendu depuis le catalogue **au moment de la requête**, et non maintenu comme un fichier : +posez une ISO dans le répertoire de médias et elle est dans le menu à la requête +suivante. + +- **« Boot from the local disk » est en premier, et le délai y retombe.** Une machine qui + démarre en PXE par accident, et que rien ne réclame, finit sur son propre disque au + bout de quinze secondes. Elle n'attend jamais un humain qui ne vient pas, et elle + n'installe jamais rien. Avec la règle qui veut qu'une machine non réclamée reçoive un + menu plutôt qu'une installation, **le pire cas d'une erreur sur le périmètre des + machines qui atteignent ce serveur est quelques secondes ajoutées à un démarrage.** +- Les entrées sont **filtrées sur l'architecture du client** : une image ARM64 n'est pas + proposée à une machine x86 — ce serait une entrée qui démarre le mauvais noyau. +- Une image qu'aucune détection n'a su placer est quand même proposée, comme un CD. +- Les entrées de diagnostic — un shell, `netinfo`, et une qui démarre un *autre* + rescriptum — sont ce dont tout serveur de démarrage finit par avoir besoin. La dernière + sert à tester un serveur candidat sur site, depuis celui qui tourne, sans toucher au + DHCP ni aux chargeurs. + +`RESCRIPTUM_BOOT_TIMEOUT_SECS` (15 par défaut) règle l'attente, et +`RESCRIPTUM_BOOT_TITLE` la barre de titre. Le logo est récupéré par +`console --picture … ||`, qui **tolère son propre échec** : une console série via IPMI +n'a pas de framebuffer, et c'est ainsi que la moitié des installations en datacenter sont +suivies. + +## Ce qui casse quand ce serveur est arrêté + +Cela mérite d'être dit franchement, parce que « serveur de démarrage » sonne critique et +ne l'est pas : + +| | rescriptum arrêté | +|---|---| +| Adressage DHCP, DNS, routage | **inchangés** — il ne parle aucun de ces protocoles | +| Machines déjà installées et en service | **inchangées** | +| Machines qui redémarrent | **inchangées** — elles démarrent sur disque | +| Une machine qui démarre en PXE par accident | passe au périphérique suivant, comme elle l'aurait fait | +| Démarrer une *nouvelle* installation | s'arrête | + +**Rien de ce que rescriptum installe ne dépend de rescriptum ensuite.** Le point de +réponse est consulté pendant une installation et plus jamais. + +## Sécurité + +Le trafic de démarrage n'est pas authentifié, et forcément — une ROM PXE n'a aucun +identifiant, la même nécessité qui gouverne déjà le point de réponse. Les contrôles sont +donc structurels, et l'un d'eux peut dire *pas vous* : + +```console +$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8 # partagée par TFTP et les médias +``` + +UDP est falsifiable et TFTP est de l'UDP, donc le serveur **ne répond jamais à une +destination de diffusion ou de multidiffusion** — de l'hygiène anti-amplification plutôt +que de la politesse —, plafonne les transferts au total et par pair, et journalise chacun +d'eux. Il est en lecture seule : une requête d'écriture est refusée comme violation +d'accès, car écrire un chargeur en UDP non authentifié serait un moyen de changer ce que +démarre chaque machine du segment. + +**Un VLAN de démarrage est la recommandation honnête** et celle qui fonctionne vraiment. +Voir [Sécurité](./security.md). + +::: tip Secure Boot +Nos chargeurs ne sont pas signés, et shim ne charge que ce que la clé de son +distributeur a signé — servir un shim à côté d'un iPXE non signé n'est donc pas un +support de Secure Boot, c'est un démarrage qui s'arrête sur une erreur de signature. Ce +qui fonctionne : désactiver Secure Boot, enrôler une MOK, ou laisser le firmware démarrer +en PXE le shim et le GRUB signés *de la distribution cible*, servis par le listener média +comme n'importe quel fichier. Nous ne signons rien, ne retirons rien, et rien ici +n'affaiblit une machine dont Secure Boot est actif. +::: + +## Quand leur DHCP est vraiment intouchable + +Rien de tout cela ne coûte une ligne de code, et les trois fonctionnent : + +- **UEFI HTTP Boot avec une URL saisie dans le firmware.** Les firmwares serveur récents + permettent d'entrer directement une URL de démarrage. La chaîne commence alors sur le + listener média, sans aucune option DHCP. +- **iPXE depuis un média virtuel IPMI, une clé USB ou la ROM de la carte réseau**, + portant l'adresse de ce serveur. Une image d'un mégaoctet, montée une fois par machine. +- **dnsmasq en mode proxy-DHCP**, pour un site qui a vraiment un serveur DHCP qu'il ne + peut pas modifier. Il existe, il est mature, il tient en trois lignes de configuration, + et ce n'est pas à nous de le réécrire. Le nommer est la réponse honnête. diff --git a/docs/guide/operations/netboot.md b/docs/guide/operations/netboot.md new file mode 100644 index 0000000..b1e8747 --- /dev/null +++ b/docs/guide/operations/netboot.md @@ -0,0 +1,271 @@ +--- +title: Netbooting a machine +description: TFTP, the loader, the menu — the whole chain from power-on to an unattended install, with two options added to a DHCP server you already run. +sidebar: + label: Netbooting + order: 9 +--- + +# Netbooting a machine + +A machine powers on. Four links later it is installing itself the way somebody decided — +or, if nobody has decided anything about it yet, sitting in a menu where a human can. + +``` + power on + │ +(1) ├── DHCP says where to boot from ......... THEIRS. Two options, and we + │ generate the snippet that sets them. + ▼ +(2) ├── TFTP hands over a loader ............. OURS + │ arch-matched iPXE, chaining through ${next-server} + ▼ +(3) ├── iPXE asks what to do ................. OURS + │ known machine → its own unattended answer + │ unknown machine → the menu + ▼ +(4) └── the bits arrive ..................... OURS + kernel, initrd, the image itself — HTTP with ranges +``` + +**Link 1 is somebody else's and stays that way.** rescriptum speaks no DHCP at all — not +as a server, not as a proxy, not behind a flag. Sites that deploy this already run one, +and pointing it at a boot server is a solved problem with thirty years of tooling. + +## Turning it on + +```console +$ export RESCRIPTUM_MEDIA_DIR=/srv/media # the images +$ export RESCRIPTUM_BOOT_DIR=/srv/boot # the loaders +$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 # what generated scripts will name +``` + +`RESCRIPTUM_BOOT_DIR` is the off switch for TFTP the way `RESCRIPTUM_MEDIA_DIR` is for +media: unset, there is no TFTP listener at all. + +Port 69 is privileged, and it is the *only* privileged port this server ever wants — +with no DHCP responder there is nothing after 67 or 4011. Three ways to have it, all +portable: + +```console +$ export RESCRIPTUM_USER=rescriptum # start as root, bind, then drop +$ setcap cap_net_bind_service=+ep rescriptum # or grant just that one capability +$ export RESCRIPTUM_TFTP_ADDR=0.0.0.0:6969 # or move it, if their DHCP can say so +``` + +**Binding happens first and dropping second**, always. The other order works in testing +as root and fails on deployment, at a reboot, which is the one moment nobody is watching. + +## Their DHCP server's two lines + +```console +$ rescriptum boot dhcp-snippet --format dnsmasq +# rescriptum 0.2.0 - boot handoff for 192.0.2.10 +# Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp. +# Generated from the same table the TFTP server serves from. +dhcp-match=set:bios,option:client-arch,0 +dhcp-match=set:efi64,option:client-arch,7 +dhcp-match=set:efi64,option:client-arch,9 +dhcp-match=set:efiarm64,option:client-arch,11 +… +``` + +`--format` covers `dnsmasq`, `isc`, `kea`, `powershell`, `pfsense` and `mikrotik`; +`--one-loader` emits the single-line form for a fleet that is all one architecture. + +**The snippet and the TFTP server are generated from one table**, so what you paste in +and what the server hands out cannot drift apart. What they *can* do is name a loader +nobody has downloaded yet, and that fails silently at the ROM — the machine asks, gets +nothing, and stops with no message on any console. One command catches it: + +```console +$ rescriptum boot check +checking boot assets in /srv/boot + ok ipxe-arm64.efi (1.0M) + MISSING ipxe-undionly.kpxe — every machine the snippet sends here will ask for it, + get nothing, and stop +``` + +Its exit code is a contract, like `check`'s. Put it in the same place. + +### Four details the generated snippet gets right + +Each is a way this fails quietly on somebody else's network, and none is obvious: + +- **Both the BOOTP `file` field and option 67.** Some ROMs read only one, and which is + not predictable from the vendor. +- **An untagged default at the end.** Every architecture line is tag-matched, so a ROM + that sends no option 93 would match nothing and get no boot file at all. +- **`HTTPClient` echoed back in option 60** for UEFI HTTP Boot clients. The firmware + *filters offers* on it: a reply carrying only the URL is discarded, silently, which is + indistinguishable from having no DHCP server. +- **A next-server for those clients too**, even though they fetch over HTTP. Without one + the loader's embedded script reads an empty `${next-server}` and chains into nowhere. + +::: tip Windows Server +A DHCP policy **cannot condition on option 93** — the condition types are vendor class, +user class, MAC, client id, FQDN and relay information. The architecture reaches a policy +only inside the option 60 string, so the generated PowerShell defines vendor classes on +`PXEClient:Arch:00007*` and hangs the policies off those. Same outcome, different +mechanism, and it is exactly the sort of thing that gets half-remembered. +::: + +## The loader + +TFTP hands over **one file**, and the rule is written into the code: + +> **TFTP hands over the loader. Everything after that is HTTP.** + +At 1468 bytes a round-trip, TFTP moves about 1.4 MB/s on a millisecond of latency. The +loader is a megabyte — two seconds. A 1.5 GB image would be the better part of twenty +minutes, against fifteen seconds over HTTP on the same wire. + +Which loader depends on what the firmware announced: + +| Option 93 | Client | Served | +|---|---|---| +| `0x0000` | BIOS PXE | `ipxe-undionly.kpxe` | +| `0x0007`, `0x0009` | UEFI x86-64 | `ipxe-x86_64.efi`, plus `-snp` / `-snponly` | +| `0x000b` | UEFI ARM64 | `ipxe-arm64.efi` | +| `0x0010`, `0x0013` | UEFI HTTP Boot | the same files, over HTTP, no TFTP at all | +| everything else | 32-bit UEFI, EBC, U-Boot | refused, with the reason | + +`0x0009` needs a word. RFC 4578 defined it as "EFI x86-64"; IANA's registry, rewritten by +RFC 5970, lists it as "EBC". Real x64 firmware sends either, so both map to x64 — a table +generated from the registry alone would hand half a fleet nothing. + +`snponly` exists because the plain UEFI build cannot always see the NIC. All the variants +are served and the table picks; this is precisely the knowledge an operator should not +have to acquire. + +::: warning The loaders are not built yet +`packaging/ipxe/` holds the branding, the embedded script and the build — but nothing in +this repository has compiled them, and no release publishes them. Until that lands you can +build them yourself (`packaging/ipxe/build.sh`) or point `RESCRIPTUM_BOOT_DIR` at loaders +from elsewhere, provided they chain to this server rather than to the internet — see below. +::: + +## How iPXE ends up talking to *us* + +The question nobody expects to have to answer. Whatever delivers the loader: + +- A plain `undionly.kpxe` from ipxe.org does DHCP, is told to load iPXE, and **loads + itself forever** — iPXE's documented chainloading loop. +- A stock netboot.xyz binary has an embedded script that goes straight to the **public** + `boot.netboot.xyz`. No loop, but your menu and your answers are never consulted. + +The loaders rescriptum ships carry a three-line script that chains through +`${next-server}` — the value option 66 already set, which is how the loader arrived in +the first place. That makes one generic build work in every deployment, with no second +condition in a configuration file somebody else owns. + +The script chains to **port 8001**, and that is a contract rather than a preference: it +is baked into the loader before any deployment exists and can read no configuration. +Moving `RESCRIPTUM_MEDIA_ADDR` is allowed and `boot check` warns about it. + +## What a machine sees + +Stage two puts the machine's identity in the query string, which is the one thing DHCP +cannot do — a DHCP option cannot carry `${net0/mac}`: + +```console +$ rescriptum boot bootstrap +#!ipxe +chain http://192.0.2.10:8000/ipxe/boot?mac=${netX/mac}&uuid=${uuid}\ +&serial=${serial:uristring}&asset=${asset:uristring}\ +… +|| chain http://192.0.2.10:8001/ipxe/menu +``` + +Two details in there are load-bearing. **`netX`, not `net0`** — `net0` is merely the +first interface, so a server booting from its second port would identify as its unused +first. And **`:uristring`** on every SMBIOS string, because `${manufacturer}` expands to +`Dell Inc.` with the space and iPXE percent-encodes nothing on its own. + +That final `||` is the whole of "a menu is the default answer": a machine something +claims gets its own unattended answer, and a machine nothing claims falls through to the +menu. It is `default.toml`'s job description word for word, applied to a different +format. + +## The menu + +```console +$ rescriptum boot menu +``` + +Rendered from the catalogue **at request time**, not kept in sync as a file: drop an ISO +in the media directory and it is in the menu on the next fetch. + +- **`Boot from the local disk` is first, and the timeout falls through to it.** A machine + that PXE-boots by accident, and that nothing claims, ends up on its own disk after + fifteen seconds. It never sits waiting for a human who is not coming, and it never + installs anything. Combined with the rule that an unclaimed machine gets a menu rather + than an install, **the worst case of being wrong about which machines reach this server + is a few seconds added to a boot.** +- Entries are **gated on the client's architecture**, so an ARM64 image is not offered to + an x86 machine — that is an entry that boots the wrong kernel. +- An image no probe could place is still offered, as a CD. +- The diagnostics entries — a shell, `netinfo`, and one that boots a *different* + rescriptum — are what every boot server ends up needing. The last is how you test a + candidate server on site, from the running one, without touching DHCP or the loaders. + +`RESCRIPTUM_BOOT_TIMEOUT_SECS` (default 15) sets the wait, and `RESCRIPTUM_BOOT_TITLE` +the title bar. The logo is fetched with `console --picture … ||`, which **tolerates its +own failure**: a serial console over IPMI has no framebuffer, and that is how half of all +datacenter installs are watched. + +## What breaks when this server is down + +Worth stating plainly, because "a boot server" sounds load-bearing and is not: + +| | rescriptum down | +|---|---| +| DHCP addressing, DNS, routing | **unaffected** — it speaks none of those protocols | +| Machines already installed and running | **unaffected** | +| Machines rebooting | **unaffected** — they boot from disk | +| A machine that PXE-boots by accident | falls to its next boot device, as it would anyway | +| Starting a *new* installation | stops | + +**Nothing rescriptum installs depends on rescriptum afterwards.** The answer endpoint is +consulted during an install and never again. + +## Security + +Boot traffic is unauthenticated, and necessarily — a PXE ROM has no credentials, the same +necessity that already governs the answer endpoint. So the controls are structural, and +one of them can say *not you*: + +```console +$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8 # shared by TFTP and media +``` + +UDP is forgeable and TFTP is UDP, so the server **never answers a broadcast or multicast +destination** — amplification hygiene rather than politeness — caps concurrent transfers +in total and per peer, and logs every one. It is read-only: a write request is refused as +an access violation, because writing a loader over unauthenticated UDP would be a way to +change what every machine on the segment boots. + +**A boot VLAN is the honest recommendation** and the one that actually works. See +[Security](./security.md). + +::: tip Secure Boot +Our loaders are unsigned, and shim only loads what its distro's vendor key signed — so +serving a shim beside an unsigned iPXE is not Secure Boot support, it is a boot that +stops at a signature error. What does work: turn Secure Boot off, enrol a MOK, or let +firmware PXE-boot the target distro's *own* signed shim and GRUB, served from the media +listener like any other file. We sign nothing and strip nothing, and nothing here weakens +a machine that has Secure Boot on. +::: + +## When their DHCP genuinely cannot be touched + +None of this costs a line of code, and all three work: + +- **UEFI HTTP Boot with a URL typed into firmware setup.** Modern server firmware lets + you enter a boot URL directly. The chain then starts on the media listener with no DHCP + option involved at all. +- **iPXE from IPMI virtual media, a USB stick, or the NIC's own ROM**, carrying this + server's address. A one-megabyte image, mounted once per machine. +- **dnsmasq in proxy-DHCP mode**, for a site that truly has a DHCP server it cannot edit. + It exists, it is mature, it is three lines of configuration, and it is not ours to + rewrite. Naming it is the honest answer. diff --git a/docs/guide/reference/cli.fr.md b/docs/guide/reference/cli.fr.md index 43b223a..bf8f229 100644 --- a/docs/guide/reference/cli.fr.md +++ b/docs/guide/reference/cli.fr.md @@ -150,6 +150,33 @@ s'y fie. `rescriptum media ipxe pve-8.4 > groups/rack-a.ipxe` produit un document de réponse utilisable — ce qu'il est, rien de plus. Il imprime un script, il n'en installe pas. +## `boot` + +La moitié démarrage réseau : les chargeurs de TFTP, la configuration DHCP générée, et les +deux scripts qu'une machine exécute. Voir +[Démarrer une machine par le réseau](../operations/netboot.md). + +```console +$ rescriptum boot dhcp-snippet [--format F] [--one-loader] +$ rescriptum boot check # les chargeurs qu'un extrait nomme sont-ils sur le disque ? +$ rescriptum boot bootstrap # imprimer le script de l'étape deux +$ rescriptum boot menu # imprimer le menu intégré +``` + +`--format` vaut `dnsmasq` (par défaut), `isc`, `kea`, `powershell`, `pfsense` ou +`mikrotik`. L'extrait part sur **stdout** et les avertissements sur stderr, de sorte que +`boot dhcp-snippet > dhcpd.conf` produit un fichier incluable tel quel. + +Le code de sortie de `boot check` est un contrat, comme celui de `check`. Ce qu'il +attrape est la panne la moins diagnosticable de la chaîne : **un extrait nommant un +chargeur absent du disque échoue silencieusement au niveau de la ROM**, sans rien sur +aucune console. Il signale aussi que le listener média a quitté le port que les chargeurs +distribués ont gravé. + +`boot bootstrap` et `boot menu` impriment ce qu'une machine exécutera, pour la même +raison que `render` imprime une réponse : tout ce qu'une baie exécute devrait d'abord +être lisible par un humain. + ## Codes de sortie | Code | Signifie | diff --git a/docs/guide/reference/cli.md b/docs/guide/reference/cli.md index cef25e9..873d02b 100644 --- a/docs/guide/reference/cli.md +++ b/docs/guide/reference/cli.md @@ -147,6 +147,30 @@ nothing and exits `1`. `rescriptum media ipxe pve-8.4 > groups/rack-a.ipxe` produces a usable answer document — which is all it is. It prints a script; it does not install one. +## `boot` + +The netboot half: TFTP's loaders, the generated DHCP configuration, and the two scripts +a machine executes. See [Netbooting a machine](../operations/netboot.md). + +```console +$ rescriptum boot dhcp-snippet [--format F] [--one-loader] +$ rescriptum boot check # are the loaders a snippet names actually on disk? +$ rescriptum boot bootstrap # print the stage-two script +$ rescriptum boot menu # print the built-in menu +``` + +`--format` is `dnsmasq` (the default), `isc`, `kea`, `powershell`, `pfsense` or +`mikrotik`. The snippet goes to **stdout** and warnings to stderr, so +`boot dhcp-snippet > dhcpd.conf` produces a file that can be included as-is. + +`boot check`'s exit status is a contract, like `check`'s. What it catches is the least +diagnosable failure in the chain: **a snippet naming a loader that is not on disk fails +silently at the ROM**, with nothing on any console. It also warns when the media listener +has moved off the port shipped loaders embed. + +`boot bootstrap` and `boot menu` print what a machine will execute, for the same reason +`render` prints an answer: everything a rack runs should be readable by a human first. + ## Exit statuses | Status | Means | diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index aff635e..4a135bf 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -35,6 +35,12 @@ pas de *format* de configuration à apprendre ni de ligne de commande à se trom | `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Transferts simultanés. Bas exprès : chacun retient son jeton des minutes durant | | `RESCRIPTUM_PUBLIC_HOST` | déduit | L'hôte que nomment les URL générées. **Un hôte, jamais une URL** | | `RESCRIPTUM_BOOT_ALLOW` | non défini | CIDR clients autorisés à récupérer les médias. Non défini = quiconque atteint le port | +| `RESCRIPTUM_BOOT_DIR` | non défini | Chargeurs et menus, distribués en TFTP. **Non défini = pas de TFTP du tout** | +| `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | Le listener TFTP. Le port 69 est privilégié ; voir `RESCRIPTUM_USER` | +| `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Secondes avant que le menu ne retombe sur le disque local | +| `RESCRIPTUM_BOOT_LOGO` | intégré | Un PNG à afficher derrière le menu | +| `RESCRIPTUM_BOOT_TITLE` | intégré | La barre de titre du menu | +| `RESCRIPTUM_USER` / `_GROUP` | non défini | Basculer dessus **après** avoir lié. L'ordre inverse échoue au déploiement | `/srv` est l'endroit où la norme de hiérarchie des fichiers range les données servies par le système, ce qu'est précisément un répertoire de réponses. Les deux valeurs par défaut y vivent, @@ -167,6 +173,10 @@ Celles-ci arrêtent le serveur au lieu d'avertir, parce que démarrer quand mêm | `RESCRIPTUM_MEDIA_ADDR` défini sans `RESCRIPTUM_MEDIA_DIR` | un listener sans rien à servir | | `RESCRIPTUM_MEDIA_ADDR` égal à l'adresse de réponse ou d'administration | le second bind perd, et lequel dépend de l'ordre de démarrage | | `RESCRIPTUM_PUBLIC_HOST` portant un schéma, un port ou un chemin | il est écrit dans les URL de deux listeners ; un port dans la valeur épingle chaque script généré sur l'un d'eux | +| `RESCRIPTUM_TFTP_ADDR` défini sans `RESCRIPTUM_BOOT_DIR` | un listener sans chargeur à distribuer | +| Le répertoire de démarrage ne peut pas être résolu | chaque contrôle de chemin s'y compare | +| TFTP ne peut pas se lier | le port 69 est privilégié ; le message le dit et nomme les trois façons de l'obtenir | +| `RESCRIPTUM_USER` nomme un compte inexistant | rien à devenir | ## Avertissements de démarrage diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 26b1394..8ec8dea 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -35,6 +35,12 @@ no configuration *format* to learn and no command line to get wrong. | `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Concurrent transfers. Low on purpose: each holds its permit for minutes | | `RESCRIPTUM_PUBLIC_HOST` | derived | The host generated URLs name. **A host, never a URL** | | `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media. Unset means anyone who can reach the port | +| `RESCRIPTUM_BOOT_DIR` | unset | Loaders and menus, handed out over TFTP. **Unset means no TFTP at all** | +| `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | The TFTP listener. Port 69 is privileged; see `RESCRIPTUM_USER` | +| `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Seconds before the menu falls through to local boot | +| `RESCRIPTUM_BOOT_LOGO` | built-in | A PNG to show behind the menu | +| `RESCRIPTUM_BOOT_TITLE` | built-in | The menu's title bar | +| `RESCRIPTUM_USER` / `_GROUP` | unset | Drop to these **after** binding. The other order fails on deployment | `/srv` is where the filesystem hierarchy standard puts data served by the system, which is what an answers directory is. Both defaults live there so that a bare `rescriptum` does @@ -162,6 +168,10 @@ These stop the server rather than warning, because starting anyway would be wors | `RESCRIPTUM_MEDIA_ADDR` set with no `RESCRIPTUM_MEDIA_DIR` | a listener with nothing to serve | | `RESCRIPTUM_MEDIA_ADDR` equal to the answer or admin address | the second bind loses, and which one depends on start order | | `RESCRIPTUM_PUBLIC_HOST` carrying a scheme, a port or a path | it is written into URLs for two listeners; one port in the value pins every generated script to one of them | +| `RESCRIPTUM_TFTP_ADDR` set with no `RESCRIPTUM_BOOT_DIR` | a listener with no loaders to hand out | +| The boot directory cannot be resolved | every path check compares against it | +| TFTP cannot bind | port 69 is privileged; the message says so and names the three ways to have it | +| `RESCRIPTUM_USER` names an account that does not exist | nothing to become | ## Startup warnings From 0055aaeddaa1766210274ba0d77c0208fdba9b4d Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 14:04:47 +0200 Subject: [PATCH 10/59] test(boot-rig): the rig, and the loader build it verified on the way in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3's harness: three services on a network with `internal: true`, so a rig that runs a DHCP server is structurally unable to answer anything on the host's LAN — the same "did installing this break the network" hygiene the product lives by. No /dev/kvm anywhere: it has to pass under TCG, because the development machine is a Mac. Two markers, both deterministic and neither a screenshot. An unclaimed machine must reach a disk whose only content is a boot sector that prints a magic string to the serial console — proving it went through DHCP, the loader, the bootstrap and the menu, found nothing claiming it, and fell through. A claimed machine's answer fetches a sentinel, so that assertion is a line in the *server's* log rather than something the client printed. dnsmasq is configured from `boot dhcp-snippet`'s own output, which makes the rig a test of the snippet too: if what we tell operators to paste is wrong, nothing boots. **Building the loaders for the rig verified the whole packaging half**, and found two real bugs on the way: - The build must be amd64. iPXE's BIOS targets are 32-bit x86, and an ARM64 host's gcc produces a wall of `-m32` errors that reads like a broken Makefile. - ARM64 needed `CROSS_COMPILE=aarch64-linux-gnu-`, absent from the first version — the host compiler was used and died on `-mlittle-endian`. - The ISO and USB targets were being asked for in the EFI build directory rather than the BIOS one, and failed silently into a `||`. The pinned commit now produces all eight loaders; `strings` finds PRODUCT_NAME, PRODUCT_URI and `embed.ipxe` verbatim in the EFI builds; and `rescriptum boot check` agrees the set satisfies the loader table. What remains unproven is what firmware does with them, which is the rig and then real hardware — the standing rule that nothing ships on harness evidence alone is unchanged, and both READMEs say where the line is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- packaging/boot-rig/Dockerfile.client | 18 +++ packaging/boot-rig/Dockerfile.loaders | 29 ++++ packaging/boot-rig/Dockerfile.server | 17 +++ packaging/boot-rig/README.md | 119 ++++++++++++++++ .../boot-rig/answers/98-fa-9b-50-d8-10.ipxe | 12 ++ packaging/boot-rig/boot-client.sh | 82 +++++++++++ packaging/boot-rig/docker-compose.yml | 110 +++++++++++++++ packaging/boot-rig/local-disk.asm | 66 +++++++++ packaging/boot-rig/media/.gitkeep | 0 packaging/boot-rig/run.sh | 127 ++++++++++++++++++ packaging/ipxe/PINNED | 15 ++- packaging/ipxe/README.md | 38 ++++-- packaging/ipxe/build.sh | 34 ++++- 13 files changed, 645 insertions(+), 22 deletions(-) create mode 100644 packaging/boot-rig/Dockerfile.client create mode 100644 packaging/boot-rig/Dockerfile.loaders create mode 100644 packaging/boot-rig/Dockerfile.server create mode 100644 packaging/boot-rig/README.md create mode 100644 packaging/boot-rig/answers/98-fa-9b-50-d8-10.ipxe create mode 100755 packaging/boot-rig/boot-client.sh create mode 100644 packaging/boot-rig/docker-compose.yml create mode 100644 packaging/boot-rig/local-disk.asm create mode 100644 packaging/boot-rig/media/.gitkeep create mode 100755 packaging/boot-rig/run.sh diff --git a/packaging/boot-rig/Dockerfile.client b/packaging/boot-rig/Dockerfile.client new file mode 100644 index 0000000..bff83c2 --- /dev/null +++ b/packaging/boot-rig/Dockerfile.client @@ -0,0 +1,18 @@ +# A QEMU machine with its NIC on the rig's isolated network, and nothing else. +# +# **It must pass under TCG**, because the development machine is a Mac: /dev/kvm makes +# this fast, not possible. Ten times slower is a long boot, not a wall. +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + qemu-system-x86 nasm ovmf \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /rig +COPY packaging/boot-rig/local-disk.asm /rig/local-disk.asm +COPY packaging/boot-rig/boot-client.sh /rig/boot-client.sh +RUN nasm -f bin /rig/local-disk.asm -o /rig/local-disk.img \ + && test "$(stat -c%s /rig/local-disk.img)" = 512 \ + && chmod +x /rig/boot-client.sh + +ENTRYPOINT ["/rig/boot-client.sh"] diff --git a/packaging/boot-rig/Dockerfile.loaders b/packaging/boot-rig/Dockerfile.loaders new file mode 100644 index 0000000..62ad1a2 --- /dev/null +++ b/packaging/boot-rig/Dockerfile.loaders @@ -0,0 +1,29 @@ +# Builds the branded iPXE loaders the rig boots. +# +# The rig cannot use a stock loader: a plain undionly.kpxe re-loads itself forever, and a +# stock netboot.xyz binary chains to the public menu. **Testing the chain means testing +# ours**, embedded script and all — which is why this shares packaging/ipxe/ with the +# release rather than having a build of its own, and why the pin is the same one. +# +# The clone happens at image-build time on purpose: the rig's own network is `internal`, +# so nothing on it can reach the internet. The fake DHCP server never touching the host's +# LAN is the same hygiene the product itself lives by. +# **Pinned to amd64, and not as a convenience.** iPXE's BIOS targets are 32-bit x86 and +# its `ipxe.efi` here is x86-64; a compiler on an ARM64 host cannot produce either, and +# the failure is a wall of `unrecognized command-line option '-m32'`. On an Apple Silicon +# machine this runs under emulation — slower, which is the same trade the rest of the rig +# already makes by refusing KVM. +ARG LOADER_PLATFORM=linux/amd64 +FROM --platform=${LOADER_PLATFORM} debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential liblzma-dev git perl mtools xorriso ca-certificates \ + gcc-aarch64-linux-gnu \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /ipxe +COPY packaging/ipxe/ /ipxe/ +RUN chmod +x build.sh && ./build.sh --out /loaders + +# At run time the network is gone; this only copies what the build already produced. +CMD ["sh", "-c", "cp -a /loaders/. /out/ && ls -la /out"] diff --git a/packaging/boot-rig/Dockerfile.server b/packaging/boot-rig/Dockerfile.server new file mode 100644 index 0000000..1631c0e --- /dev/null +++ b/packaging/boot-rig/Dockerfile.server @@ -0,0 +1,17 @@ +# The real binary, built for Linux and run as the rig's server. +# +# Built here rather than copied in, so the rig always tests the working tree rather than +# whatever happened to be lying in ./target. +FROM rust:1-bookworm AS build +WORKDIR /src +COPY . . +RUN cargo build --release --locked + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /src/target/release/rescriptum /usr/local/bin/rescriptum +RUN mkdir -p /srv/answers /srv/media /srv/boot +# Root, because TFTP wants port 69 and the rig is not where privilege dropping is proved +# — `tests/` covers that, and dropping here would only obscure a bind failure. +ENTRYPOINT ["/usr/local/bin/rescriptum"] diff --git a/packaging/boot-rig/README.md b/packaging/boot-rig/README.md new file mode 100644 index 0000000..a5f304b --- /dev/null +++ b/packaging/boot-rig/README.md @@ -0,0 +1,119 @@ +# The boot rig + +Everything from a DHCP offer to a machine sitting on its own disk, in one command, on a +network that is its whole world. + +```console +$ packaging/boot-rig/run.sh +``` + +**It produces no features and it is not optional.** Everything built on top of the boot +chain depends on knowing the chain works, and this is what keeps that known: from here +on, every push can re-prove the BIOS path without a single real machine. + +## What it proves, and what it cannot + +| Where | What it proves | +|---|---| +| `cargo test` | protocol and logic: TFTP over real UDP, ranges over real sockets, the ISO reader against synthetic images, snippet ↔ loader-table coherence | +| **This rig** | the chain end to end — a DHCP offer, a loader over TFTP, a script, a menu, and a machine that lands where it should | +| A real machine | that firmware agrees. **Nothing ships on rig evidence alone** | + +The third row is not a formality. The rig runs one emulator with one NIC model; the +failures it cannot see are exactly the ones firmware has — a ROM that reads only the +BOOTP `file` field, a UEFI build that cannot see its own network card, an option 93 value +nobody expected. + +## The two markers + +Both deterministic, neither a screenshot. + +**An unclaimed machine must reach its own disk.** It gets a disk whose only content is a +512-byte boot sector that prints `RESCRIPTUM-RIG-LOCAL-DISK-REACHED` to the serial console +and halts ([`local-disk.asm`](local-disk.asm)). Reaching it means the machine went through +DHCP, the loader, the bootstrap and the menu, found nothing claiming it, waited out the +timeout, and fell through — which is the safety behaviour the whole design rests on. A +machine that PXE-boots by accident must never sit at a menu forever, and must never +install anything. + +**A claimed machine must reach its own answer.** Its `.ipxe` answer ends by fetching a +sentinel URL, so the assertion is a line in the **server's own log** rather than something +the client printed. That is the stronger form: it proves the request arrived. + +And a third, which comes free: **dnsmasq answered from the snippet we generate.** The rig +builds its DHCP configuration by running `rescriptum boot dhcp-snippet`, so if what we +tell operators to paste is wrong, nothing here boots. + +## The shape, and why + +Three services on one network with `internal: true`: + +- **`loaders`** builds the branded iPXE from `packaging/ipxe/` — the same script and the + same pin a release uses. A stock loader would re-load itself forever or chain to the + public netboot.xyz; testing the chain means testing ours. +- **`server`** is the real binary, built from the working tree rather than from whatever + is lying in `./target`. +- **`dhcp`** is dnsmasq, configured from our own generated snippet. +- **`client`** is QEMU with its NIC **bridged onto the network**, not behind QEMU's + user-mode stack — that stack carries its own DHCP server, and a rig built on it would + test everything except the handoff it exists to test. + +`internal: true` is not tidiness. **A rig that runs a DHCP server has to be unable to +answer anything on the host's LAN**, which is the same "did installing this break the +network" hygiene the product itself lives by. It also means nothing inside can reach the +internet, which is why the loaders are built into their image rather than at run time. + +**No `/dev/kvm` anywhere.** KVM would make this fast; the rig has to pass without it, +because the development machine is a Mac. Ten times slower is a long run, not a wall. + +## Two host facts worth knowing before the first run + +- **The loader image is pinned to `linux/amd64`.** iPXE's BIOS targets are 32-bit x86 and + its `ipxe.efi` here is x86-64; a compiler on an ARM64 host produces neither, and the + failure is a wall of `unrecognized command-line option '-m32'` that reads like a broken + Makefile. On Apple Silicon that image builds under emulation, which is slow and works. +- **ARM64 loaders need `gcc-aarch64-linux-gnu`.** Without it `build.sh` says which package + is missing and carries on with the x86 loaders, rather than failing with + `unrecognized command-line option '-mlittle-endian'` from the host compiler. + +## Running it + +```console +$ packaging/boot-rig/run.sh # both clients, BIOS +$ packaging/boot-rig/run.sh --uefi # both clients, OVMF +$ packaging/boot-rig/run.sh --keep # leave the stack up to poke at +``` + +Results land in `results/`: `serial.log` from the clients, `server.log` and `dhcp.log` +from the containers. When a marker is missing those three files are the whole +investigation. + +## Watch it fail, link by link + +A green rig that has never been red proves nothing — the same reasoning as the +listing-cache test that passed for the wrong reason. Break each link and watch the marker +it guards disappear: + +| Break | What should go red | +|---|---| +| Delete a loader from the volume | the ROM gets nothing; `boot check` also goes red | +| Point `RESCRIPTUM_MEDIA_ADDR` somewhere else without rebuilding the loaders | the embedded script chains into a refused connection, and the recovery paths must *engage* | +| Remove the claimed machine's answer | it should land on its **disk**, not hang — the fallthrough covers a machine whose answer was deleted, too | +| Drop a fact a template needs | the claimed machine must fail loudly rather than install with a broken hostname | +| Stop the server, boot a client | it must fall through to its next boot device rather than wait | + +That last one turns the blast-radius table in the guide from a claim into a recorded run. + +## Status + +**The loader half is verified; the QEMU half has not been run here.** + +What is proven: `packaging/ipxe/build.sh` produces all eight loaders from the pinned +commit, they carry our branding and `embed.ipxe` verbatim, and `rescriptum boot check` +agrees the set satisfies the loader table. + +What is not: that a machine boots them. `run.sh` has not been driven end to end on this +machine, so **treat a green run as unproven rather than as evidence** until somebody has +watched each row of the table above go red first. That is the same discipline +`lifecycle-test.sh` already lives under, and the reason is the same: a green harness that +has never been red proves nothing. diff --git a/packaging/boot-rig/answers/98-fa-9b-50-d8-10.ipxe b/packaging/boot-rig/answers/98-fa-9b-50-d8-10.ipxe new file mode 100644 index 0000000..6536e91 --- /dev/null +++ b/packaging/boot-rig/answers/98-fa-9b-50-d8-10.ipxe @@ -0,0 +1,12 @@ +#!ipxe +# The **claimed** machine's answer, and the rig's other marker. +# +# It installs nothing: what is being tested is that the chain reached the point where an +# unattended answer would run, for this machine specifically and not for the other one. +# Fetching a sentinel puts one line in the *server's own* log — which is a stronger +# assertion than anything the client could print, because it proves the request arrived. +echo rescriptum rig: claimed machine {{ mac }} reached its own answer +chain --autofree http://10.99.0.2:8000/rig/claimed?mac={{ mac }} || +echo rescriptum rig: sentinel fetched +sleep 2 +reboot diff --git a/packaging/boot-rig/boot-client.sh b/packaging/boot-rig/boot-client.sh new file mode 100755 index 0000000..f5b762d --- /dev/null +++ b/packaging/boot-rig/boot-client.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Boot one QEMU machine on the rig's network and record what its serial console said. +# +# boot-client.sh [seconds] [bios|uefi] +# +# **The NIC is bridged into the container's own network, not QEMU's user-mode stack.** +# That distinction is the whole point: user-mode networking carries its own DHCP server, +# so a rig built on it would test everything except the handoff it exists to test. The +# guest has to see the rig's dnsmasq, and dnsmasq has to see a real DHCP broadcast. +# +# The serial log is the entire assertion surface. Two markers, both deterministic and +# neither a screenshot: +# +# * an **unclaimed** machine falls through the menu to its own disk, whose boot sector +# prints RESCRIPTUM-RIG-LOCAL-DISK-REACHED and halts; +# * a **claimed** machine runs its own unattended answer, which ends by fetching a +# sentinel — so "the whole chain worked" is a line in the *server's* log rather than +# something this script has to interpret. + +set -uo pipefail + +MAC="${1:?usage: boot-client.sh [seconds] [bios|uefi]}" +NAME="${2:?usage: boot-client.sh [seconds] [bios|uefi]}" +LIMIT="${3:-180}" +FIRMWARE="${4:-bios}" +OUT="/out/${NAME}.serial.log" +mkdir -p /out + +# Put eth0 on a bridge and hang a tap off it, so the guest is a peer of the other +# containers rather than a NAT client of this one. +setup_bridge() { + ip link add br0 type bridge 2>/dev/null || true + ip link set br0 up + ip addr flush dev eth0 || true + ip link set eth0 master br0 + ip tuntap add dev tap0 mode tap 2>/dev/null || true + ip link set tap0 master br0 + ip link set tap0 up + # Bridges default to forwarding delay; the guest's first DHCP would land in it. + ip link set br0 type bridge forward_delay 0 2>/dev/null || true +} + +if ! setup_bridge; then + echo "cannot bridge eth0 — the client container needs cap_add: NET_ADMIN" | tee "${OUT}" + exit 1 +fi + +# A scratch copy, so a run cannot alter the image the next one boots. +cp /rig/local-disk.img "/tmp/${NAME}.img" + +FIRMWARE_ARGS=() +if [ "${FIRMWARE}" = "uefi" ]; then + cp /usr/share/OVMF/OVMF_VARS.fd "/tmp/${NAME}.vars.fd" + FIRMWARE_ARGS=( + -drive "if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd" + -drive "if=pflash,format=raw,file=/tmp/${NAME}.vars.fd" + ) +fi + +# `-boot order=nc`: network first, then the disk. **That ordering is the fallthrough +# being tested** — a machine that gets no answer must reach the disk, not stop. +# +# `-nographic` puts the serial console on stdout. KVM is never requested: the rig has to +# pass under TCG, because the development machine is a Mac. +timeout "${LIMIT}" qemu-system-x86_64 \ + -machine q35 \ + -m 1024 \ + -nographic \ + -no-reboot \ + -boot order=nc \ + "${FIRMWARE_ARGS[@]}" \ + -netdev tap,id=n0,ifname=tap0,script=no,downscript=no \ + -device e1000,netdev=n0,mac="${MAC}" \ + -drive file="/tmp/${NAME}.img",format=raw,if=ide \ + > "${OUT}" 2>&1 + +status=$? +echo "--- ${NAME} (${MAC}, ${FIRMWARE}) exited ${status} after at most ${LIMIT}s ---" >> "${OUT}" +# `timeout` returning 124 is the normal end of a run that halted at the marker: a halted +# machine does not exit on its own, and waiting for one that never will is a failure this +# deliberately does not have. The markers decide, not the exit code. +exit 0 diff --git a/packaging/boot-rig/docker-compose.yml b/packaging/boot-rig/docker-compose.yml new file mode 100644 index 0000000..cfc5c93 --- /dev/null +++ b/packaging/boot-rig/docker-compose.yml @@ -0,0 +1,110 @@ +# The boot rig: the whole chain, from a DHCP offer to a machine on its own disk, in one +# command and on a network that is its whole world. +# +# packaging/boot-rig/run.sh +# +# Three services, and the shape is the point: +# +# * **dhcp** — dnsmasq, configured from the snippet `boot dhcp-snippet` generates. The +# rig is therefore a test of the snippet too: if what we tell operators to paste is +# wrong, no client here boots. +# * **server** — the real rescriptum binary, answers and media and TFTP. +# * **client** — QEMU, its NIC bridged onto this network, booting from it. +# +# `internal: true` on the network is not tidiness. A rig that runs a DHCP server has to +# be unable to answer anything on the host's LAN, which is the same "did installing this +# break the network" hygiene the product itself lives by. It also means nothing here can +# reach the internet, which is why the loaders are built into their image. +# +# **No /dev/kvm anywhere.** KVM would make this fast; the rig has to pass without it, +# because the development machine is a Mac. + +name: rescriptum-boot-rig + +networks: + rig: + internal: true + driver: bridge + ipam: + config: + - subnet: 10.99.0.0/24 + +volumes: + loaders: + results: + +services: + # Copies the loaders its image already built into the shared volume, then exits. + # Everything else waits for it: a rig that boots before the loaders are there fails + # as a mysterious TFTP timeout rather than as a missing file. + loaders: + build: + context: ../.. + dockerfile: packaging/boot-rig/Dockerfile.loaders + volumes: + - loaders:/out + networks: [rig] + + server: + build: + context: ../.. + dockerfile: packaging/boot-rig/Dockerfile.server + depends_on: + loaders: + condition: service_completed_successfully + environment: + RESCRIPTUM_LISTEN_ADDR: 0.0.0.0:8000 + RESCRIPTUM_ANSWERS_DIR: /srv/answers + RESCRIPTUM_MEDIA_DIR: /srv/media + RESCRIPTUM_MEDIA_ADDR: 0.0.0.0:8001 + RESCRIPTUM_BOOT_DIR: /srv/boot + RESCRIPTUM_TFTP_ADDR: 0.0.0.0:69 + # The address the generated scripts name. Fixed here, so what a client is told to + # fetch is exactly what this file says it should be. + RESCRIPTUM_PUBLIC_HOST: 10.99.0.2 + # Every request, because the log *is* the claimed-machine assertion. + RESCRIPTUM_LOG: all + RESCRIPTUM_BOOT_TIMEOUT_SECS: "5" + volumes: + - loaders:/srv/boot + - ./answers:/srv/answers:ro + - ./media:/srv/media:ro + networks: + rig: + ipv4_address: 10.99.0.2 + + dhcp: + image: debian:bookworm-slim + depends_on: + server: + condition: service_started + # dnsmasq needs to bind 67 and to hear broadcasts on the segment. + cap_add: [NET_ADMIN, NET_RAW, NET_BIND_SERVICE] + volumes: + - ./generated:/etc/rig:ro + networks: + rig: + ipv4_address: 10.99.0.3 + command: + - sh + - -c + - | + apt-get update >/dev/null && apt-get install -y --no-install-recommends dnsmasq >/dev/null + echo "--- the configuration under test ---" + cat /etc/rig/dnsmasq.conf + exec dnsmasq --keep-in-foreground --log-dhcp --conf-file=/etc/rig/dnsmasq.conf + + client: + build: + context: ../.. + dockerfile: packaging/boot-rig/Dockerfile.client + depends_on: + dhcp: + condition: service_started + cap_add: [NET_ADMIN] + devices: + - /dev/net/tun + volumes: + - results:/out + networks: [rig] + profiles: [manual] diff --git a/packaging/boot-rig/local-disk.asm b/packaging/boot-rig/local-disk.asm new file mode 100644 index 0000000..71f4963 --- /dev/null +++ b/packaging/boot-rig/local-disk.asm @@ -0,0 +1,66 @@ +; The marker a machine prints when it falls through the menu to its own disk. +; +; **This is the whole of the unclaimed-machine assertion**, and it is deterministic +; rather than a screenshot: QEMU's serial console goes to a log, and the rig greps it. +; A machine that PXE-boots by accident, and that nothing claims, must end up here — +; never installing anything, never sitting at a menu waiting for a human who is not +; coming. The worst case of being wrong about which machines reach the boot server is +; a few seconds added to a boot, and this is what proves it. +; +; Assembled by the client image's nasm into a 512-byte MBR. Nothing else is on the disk: +; if the firmware reaches this, it reached the disk. + +[BITS 16] +[ORG 0x7C00] + +start: + cli + xor ax, ax + mov ds, ax + mov es, ax + mov ss, ax + mov sp, 0x7C00 + sti + + ; COM1 at 115200 8N1. QEMU would emit the bytes without this, but relying on that + ; is relying on an implementation detail of one emulator, and the rig is supposed + ; to resemble a machine. + mov dx, 0x03FB ; line control + mov al, 0x80 ; divisor latch access + out dx, al + mov dx, 0x03F8 ; divisor low + mov al, 0x01 ; 115200 + out dx, al + mov dx, 0x03F9 ; divisor high + xor al, al + out dx, al + mov dx, 0x03FB + mov al, 0x03 ; 8 bits, no parity, one stop; latch off + out dx, al + + mov si, message +.next: + lodsb + test al, al + jz .done +.wait: + mov dx, 0x03FD ; line status + push ax + in al, dx + test al, 0x20 ; transmit holding register empty + pop ax + jz .wait + mov dx, 0x03F8 + out dx, al + jmp .next + +.done: + cli +.halt: + hlt + jmp .halt + +message: db 13, 10, "RESCRIPTUM-RIG-LOCAL-DISK-REACHED", 13, 10, 0 + +times 510-($-$$) db 0 +dw 0xAA55 diff --git a/packaging/boot-rig/media/.gitkeep b/packaging/boot-rig/media/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packaging/boot-rig/run.sh b/packaging/boot-rig/run.sh new file mode 100755 index 0000000..58c5c2d --- /dev/null +++ b/packaging/boot-rig/run.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Drive the boot rig and assert on its two markers. +# +# ./run.sh # both clients, BIOS +# ./run.sh --uefi # both clients, OVMF +# ./run.sh --keep # leave the stack up afterwards, to poke at it +# +# **This is the contract, and CI runs a subset of it.** GitHub's runners are somebody +# else's machines with somebody else's limits, so the dev rig is what decides and CI is +# the tripwire — sized so it cannot fail for capacity reasons. + +set -euo pipefail +cd "$(dirname "$0")" + +FIRMWARE=bios +KEEP=0 +while [ $# -gt 0 ]; do + case "$1" in + --uefi) FIRMWARE=uefi; shift ;; + --keep) KEEP=1; shift ;; + -h|--help) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unexpected argument: $1" >&2; exit 2 ;; + esac +done + +COMPOSE=(docker compose -f docker-compose.yml) + +cleanup() { + if [ "$KEEP" = "0" ]; then + "${COMPOSE[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + else + echo "stack left up; 'docker compose -f $PWD/docker-compose.yml down -v' when done" + fi +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# The DHCP configuration comes from the server itself, which is what makes the rig a +# test of `boot dhcp-snippet` too. If what we tell operators to paste is wrong, nothing +# here boots — and that is exactly the failure worth catching before they meet it. +# --------------------------------------------------------------------------- +echo "==> generating the DHCP configuration from the server's own snippet" +mkdir -p generated +cargo run --quiet --manifest-path ../../Cargo.toml -- boot dhcp-snippet --format dnsmasq \ + > generated/dnsmasq.conf.snippet 2>/dev/null <<<"" || { + echo "could not generate the snippet" >&2; exit 1; } + +# Everything above the snippet is the rig's own scaffolding: a range to hand out, an +# interface to listen on, and no upstream DNS — this network has no internet. +{ + echo "# --- rig scaffolding (not generated) ---" + echo "port=0" + echo "interface=eth0" + echo "bind-interfaces" + echo "log-dhcp" + echo "dhcp-range=10.99.0.100,10.99.0.200,1h" + echo "enable-tftp=no" + echo + echo "# --- everything below is `rescriptum boot dhcp-snippet --format dnsmasq` ---" + # The generated snippet names RESCRIPTUM_PUBLIC_HOST, which outside a container is + # this machine. Inside the rig the server is 10.99.0.2, and that substitution is the + # only edit the rig makes. + sed 's/[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}/10.99.0.2/g' \ + generated/dnsmasq.conf.snippet +} > generated/dnsmasq.conf + +echo "==> bringing the stack up (no KVM: the rig must pass under TCG)" +"${COMPOSE[@]}" up -d --build loaders server dhcp + +# The loaders service exits when it has copied; the others have to be listening. +for _ in $(seq 1 60); do + if "${COMPOSE[@]}" exec -T server /usr/local/bin/rescriptum boot check >/dev/null 2>&1; then + break + fi + sleep 2 +done + +echo "==> what the server thinks of its own boot assets" +"${COMPOSE[@]}" exec -T server /usr/local/bin/rescriptum boot check + +# --------------------------------------------------------------------------- +# Marker one: a machine nothing claims must reach its own disk. +# --------------------------------------------------------------------------- +echo "==> booting an UNCLAIMED machine (${FIRMWARE})" +"${COMPOSE[@]}" run --rm -T client 52:54:00:aa:aa:aa unclaimed 240 "${FIRMWARE}" || true + +# --------------------------------------------------------------------------- +# Marker two: a machine something claims must reach its own answer. +# --------------------------------------------------------------------------- +echo "==> booting a CLAIMED machine (${FIRMWARE})" +"${COMPOSE[@]}" run --rm -T client 98:fa:9b:50:d8:10 claimed 240 "${FIRMWARE}" || true + +echo "==> results" +mkdir -p results +"${COMPOSE[@]}" run --rm -T --entrypoint sh client -c 'cat /out/*.serial.log' > results/serial.log 2>&1 || true +"${COMPOSE[@]}" logs server > results/server.log 2>&1 || true +"${COMPOSE[@]}" logs dhcp > results/dhcp.log 2>&1 || true + +fail=0 +check() { + local what="$1" file="$2" needle="$3" + if grep -qF -- "${needle}" "${file}" 2>/dev/null; then + echo " ok ${what}" + else + echo " FAIL ${what} — ${needle} not in ${file}" + fail=$((fail + 1)) + fi +} + +# An unclaimed machine reached its own disk rather than sitting at a menu or stopping. +check "unclaimed machine fell through to its local disk" \ + results/serial.log "RESCRIPTUM-RIG-LOCAL-DISK-REACHED" +# A claimed machine reached its own answer — asserted in the *server's* log, which +# proves the request arrived rather than that the client printed something. +check "claimed machine fetched its sentinel" \ + results/server.log "/rig/claimed" +# And the DHCP handoff itself worked, which is the snippet under test. +check "dnsmasq answered a PXE client from the generated snippet" \ + results/dhcp.log "DHCPACK" + +echo +if [ "${fail}" = "0" ]; then + echo "rig: all markers reached" +else + echo "rig: ${fail} marker(s) missing — see packaging/boot-rig/results/" + exit 1 +fi diff --git a/packaging/ipxe/PINNED b/packaging/ipxe/PINNED index 2a6dafd..513010c 100644 --- a/packaging/ipxe/PINNED +++ b/packaging/ipxe/PINNED @@ -10,10 +10,17 @@ # embedded script runs is upstream's code, and it is the half no test in this repository # covers. # -# NOT YET BUILT. This pin was chosen from the tag list, not from a build — see the -# "Status" section of README.md beside this file. The first CI run is what turns it from -# a plausible version into a verified one, and if v2.0.0 does not build cleanly the -# answer is v1.21.1 (988d2c13cdf0f0b4140685af35ced70ac5b3283c), the release before it. +# BUILT AND VERIFIED (2026-08-27, Debian bookworm, amd64 under emulation): this commit +# produces all eight loaders, ARM64 included. The binaries carry PRODUCT_NAME +# "rescriptum boot", PRODUCT_URI, and `embed.ipxe` verbatim — checked with `strings` on +# the EFI builds, which are uncompressed enough to read. The BIOS `.kpxe` shows nothing +# to `strings` because it is a compressed image, which is expected rather than a failure. +# +# `rescriptum boot check` agrees the set satisfies the loader table. What remains +# unproven is what firmware does with them: that is the rig and the bench, not a build. +# +# If a future bump does not build cleanly, the answer is v1.21.1 +# (988d2c13cdf0f0b4140685af35ced70ac5b3283c), the release before this one. IPXE_COMMIT=12798ec29aa8a64d8675c4378b99f5fe28447afb IPXE_TAG=v2.0.0 IPXE_REPO=https://github.com/ipxe/ipxe.git diff --git a/packaging/ipxe/README.md b/packaging/ipxe/README.md index a3f3e85..9a0f71e 100644 --- a/packaging/ipxe/README.md +++ b/packaging/ipxe/README.md @@ -2,18 +2,32 @@ What TFTP hands out, and the first thing a machine executes that we wrote. -## Status: written, not yet built - -**Nothing here has been compiled.** The pin was chosen from upstream's tag list, the -build options from upstream's documentation, and the file names from -`src/boot/loaders.rs`. That is enough to be reviewable and not enough to be trusted: -until CI has run `build.sh` once and the rig has booted what it produced, treat this -directory as a proposal. - -The two things most likely to be wrong are the pin (v2.0.0 is a major bump nobody here -has built; v1.21.1 is the fallback, and `PINNED` records its SHA) and the exact make -targets for the EFI variants. Both fail loudly at the first build, which is the point of -having one. +## Status: built and verified; not yet booted + +`build.sh` has been run against the pinned commit on Debian bookworm and produces **all +eight loaders**, ARM64 included. Three things were checked on the output rather than +assumed: + +- the EFI binaries carry `PRODUCT_NAME "rescriptum boot"` and `PRODUCT_URI`; +- they carry `embed.ipxe` **verbatim**, `chain http://${next-server}:8001/ipxe/bootstrap` + and all — which is the entry point of the whole chain; +- `rescriptum boot check` agrees the set satisfies the loader table. + +The BIOS `.kpxe` shows none of that to `strings`, because it is a compressed image and +what is visible is the decompressor stub. Expected, not a failure. + +**What remains unproven is what firmware does with them.** A build says the bytes exist; +only a machine says they boot. That is the rig and then real hardware, and the project's +standing rule applies — nothing ships on harness evidence alone. + +Two things the first build taught, both now handled: + +- **The build must be amd64.** iPXE's BIOS targets are 32-bit x86; an ARM64 host's gcc + produces a wall of `unrecognized command-line option '-m32'` that reads like a broken + Makefile. +- **ARM64 needs `CROSS_COMPILE=aarch64-linux-gnu-`.** Without it the host compiler is + used and fails on `-mlittle-endian`. `build.sh` now names the missing package and + carries on with the x86 loaders rather than stopping. ## Why we build it at all diff --git a/packaging/ipxe/build.sh b/packaging/ipxe/build.sh index dedf506..fcf455a 100755 --- a/packaging/ipxe/build.sh +++ b/packaging/ipxe/build.sh @@ -91,12 +91,30 @@ build() { cp "$WORK/ipxe/src/bin/$target" "$OUT/$output" } +# ARM64 needs its own toolchain, and the failure without one is a wall of +# `unrecognized command-line option '-mlittle-endian'` from the *host* gcc — which +# reads like a broken Makefile rather than a missing cross-compiler. Naming it here is +# what turns that into "install gcc-aarch64-linux-gnu". +cross_for() { + case "$1" in + arm64) echo "aarch64-linux-gnu-" ;; + *) echo "" ;; + esac +} + build_efi() { local arch="$1" target="$2" output="$3" + local cross; cross="$(cross_for "$arch")" + if [ -n "$cross" ] && ! command -v "${cross}gcc" >/dev/null 2>&1; then + echo "skipping $arch/$target: ${cross}gcc is not installed" >&2 + return 0 + fi echo "building $arch/$target" make -C "$WORK/ipxe/src" -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" \ - ARCH="$arch" "bin-$arch-efi/$target" EMBED="$PWD/embed.ipxe" >/dev/null 2>&1 || - make -C "$WORK/ipxe/src" ARCH="$arch" "bin-$arch-efi/$target" EMBED="$PWD/embed.ipxe" + ARCH="$arch" CROSS_COMPILE="$cross" "bin-$arch-efi/$target" \ + EMBED="$PWD/embed.ipxe" >/dev/null 2>&1 || + make -C "$WORK/ipxe/src" ARCH="$arch" CROSS_COMPILE="$cross" \ + "bin-$arch-efi/$target" EMBED="$PWD/embed.ipxe" cp "$WORK/ipxe/src/bin-$arch-efi/$target" "$OUT/$output" } @@ -115,10 +133,14 @@ build_efi arm64 ipxe.efi ipxe-arm64.efi build_efi arm64 snp.efi ipxe-arm64-snp.efi build_efi arm64 snponly.efi ipxe-arm64-snponly.efi -# The same build emits the media a machine with no PXE ROM can still use: an ISO for -# IPMI virtual media, and a USB image for a stick. Free, since the objects already exist. -build_efi x86_64 ipxe.iso ipxe-x86_64.iso || echo "note: the ISO target needs mtools/xorriso" -build_efi x86_64 ipxe.usb ipxe-x86_64.usb || echo "note: the USB target needs mtools" +# The media a machine with no usable PXE ROM can still boot from: an ISO for IPMI virtual +# media, and a USB image for a stick. **These live in the BIOS build directory**, not the +# EFI one — `bin/ipxe.iso`, not `bin-x86_64-efi/ipxe.iso` — which is the mistake the first +# version of this script made, and it failed into the `||` below rather than saying so. +# They are Phase 5 of the plan and nothing depends on them yet, so a failure here is a +# note rather than an error. +build ipxe.iso ipxe.iso || echo "note: the ISO target needs xorriso or mkisofs" +build ipxe.usb ipxe.usb || echo "note: the USB target needs mtools" ( cd "$OUT" && sha256sum ./* > SHA256SUMS 2>/dev/null || shasum -a 256 ./* > SHA256SUMS ) From 368ab0b31dfe0efcfc4545f11db4f3395166aad2 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 14:18:34 +0200 Subject: [PATCH 11/59] feat(boot): adding a file to an ISO without writing 1.5 GB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4, and the last external tool goes. Proxmox reads `/auto-installer-mode.toml` from the mounted image to learn where its answer lives; every other family takes a URL on the kernel command line. Adding that one file used to mean `proxmox-auto-install-assistant prepare-iso`. It is tractable because **on the PXE path the image is never booted, only mounted** — the requirement is "still a readable ISO9660 filesystem exposing one more file", which is a far weaker problem than the one xorriso solves. An ISO9660 file is a contiguous extent, so adding one is three small overwrites and an append: the content past the end, a directory record in the slack at the end of the root extent, and the volume space size in both descriptors. So this produces a *plan* rather than a file — offsets and bytes, applied while streaming. No second copy on disk, the source never mutated so its published digest stays verifiable, ranges still work because the arithmetic is trivial, and changing the answer URL recomputes 300 bytes. The trap that decides whether it works is Rock Ridge: `auto-installer-mode.toml` is not a legal ISO9660 identifier, so the record is called `AUTO_INS.TOM;1` and the installer would never find its file. The real name lives in an `NM` entry, and in the Joliet tree too when there is one — which tree a mount reads is not ours to decide. With neither, this refuses, and refusing is complete: the fallback is one command on any Debian box whose output this server is happy to serve. A UDF image is refused outright, because patching the ISO9660 tree of a Windows ISO produces something that looks right and is not. Watched red: removing the `NM` entry turns `a_file_added_to_an_image_reads_back_under_its_real_name` red, which is the whole trap in one test. Also removes two duplicate fields clippy caught in the ISO reader — I had added `root_extent` beside the existing `root`, which is exactly the "two copies drift" failure this codebase warns about. 524 tests. The boot feature now costs 227,840 bytes on armv7 — 134% of the budget, recorded in the plan with the per-phase breakdown. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- packaging/boot-rig/generated/dnsmasq.conf | 32 + .../boot-rig/generated/dnsmasq.conf.snippet | 23 + packaging/boot-rig/run.sh | 4 +- src/boot/catalog.rs | 119 +++- src/boot/iso.rs | 75 +- src/boot/media.rs | 110 ++- src/boot/menu.rs | 1 + src/boot/mod.rs | 1 + src/boot/patch.rs | 650 ++++++++++++++++++ src/boot/stanza.rs | 1 + src/cli.rs | 211 +++++- tests/media.rs | 169 +++++ 12 files changed, 1386 insertions(+), 10 deletions(-) create mode 100644 packaging/boot-rig/generated/dnsmasq.conf create mode 100644 packaging/boot-rig/generated/dnsmasq.conf.snippet create mode 100644 src/boot/patch.rs diff --git a/packaging/boot-rig/generated/dnsmasq.conf b/packaging/boot-rig/generated/dnsmasq.conf new file mode 100644 index 0000000..1349f45 --- /dev/null +++ b/packaging/boot-rig/generated/dnsmasq.conf @@ -0,0 +1,32 @@ +# --- rig scaffolding (not generated) --- +port=0 +interface=eth0 +bind-interfaces +log-dhcp +dhcp-range=10.99.0.100,10.99.0.200,1h +enable-tftp=no + +# --- everything below is: rescriptum boot dhcp-snippet --format dnsmasq --- +# rescriptum 0.2.0 - boot handoff for 10.99.0.2 +# Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp. +# Generated from the same table the TFTP server serves from. +dhcp-match=set:bios,option:client-arch,0 +dhcp-match=set:efi64,option:client-arch,7 +dhcp-match=set:efi64,option:client-arch,9 +dhcp-match=set:efiarm64,option:client-arch,11 +dhcp-vendorclass=set:httpefi64,HTTPClient:Arch:00016 +dhcp-vendorclass=set:httpefiarm64,HTTPClient:Arch:00019 + +dhcp-boot=tag:bios,ipxe-undionly.kpxe,,10.99.0.2 +dhcp-boot=tag:efi64,ipxe-x86_64.efi,,10.99.0.2 +dhcp-boot=tag:efi64,ipxe-x86_64.efi,,10.99.0.2 +dhcp-boot=tag:efiarm64,ipxe-arm64.efi,,10.99.0.2 +dhcp-option-force=tag:httpefi64,60,HTTPClient +dhcp-boot=tag:httpefi64,http://10.99.0.2:8001/boot/ipxe-x86_64.efi,,10.99.0.2 +dhcp-option-force=tag:httpefiarm64,60,HTTPClient +dhcp-boot=tag:httpefiarm64,http://10.99.0.2:8001/boot/ipxe-arm64.efi,,10.99.0.2 + +# A ROM that sends no option 93 matches no tag above and would get nothing. +# The only clients that old are BIOS, and a DHCP client that is not netbooting +# ignores boot options entirely. +dhcp-boot=ipxe-undionly.kpxe,,10.99.0.2 diff --git a/packaging/boot-rig/generated/dnsmasq.conf.snippet b/packaging/boot-rig/generated/dnsmasq.conf.snippet new file mode 100644 index 0000000..e0f60d4 --- /dev/null +++ b/packaging/boot-rig/generated/dnsmasq.conf.snippet @@ -0,0 +1,23 @@ +# rescriptum 0.2.0 - boot handoff for 192.168.128.247 +# Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp. +# Generated from the same table the TFTP server serves from. +dhcp-match=set:bios,option:client-arch,0 +dhcp-match=set:efi64,option:client-arch,7 +dhcp-match=set:efi64,option:client-arch,9 +dhcp-match=set:efiarm64,option:client-arch,11 +dhcp-vendorclass=set:httpefi64,HTTPClient:Arch:00016 +dhcp-vendorclass=set:httpefiarm64,HTTPClient:Arch:00019 + +dhcp-boot=tag:bios,ipxe-undionly.kpxe,,192.168.128.247 +dhcp-boot=tag:efi64,ipxe-x86_64.efi,,192.168.128.247 +dhcp-boot=tag:efi64,ipxe-x86_64.efi,,192.168.128.247 +dhcp-boot=tag:efiarm64,ipxe-arm64.efi,,192.168.128.247 +dhcp-option-force=tag:httpefi64,60,HTTPClient +dhcp-boot=tag:httpefi64,http://192.168.128.247:8001/boot/ipxe-x86_64.efi,,192.168.128.247 +dhcp-option-force=tag:httpefiarm64,60,HTTPClient +dhcp-boot=tag:httpefiarm64,http://192.168.128.247:8001/boot/ipxe-arm64.efi,,192.168.128.247 + +# A ROM that sends no option 93 matches no tag above and would get nothing. +# The only clients that old are BIOS, and a DHCP client that is not netbooting +# ignores boot options entirely. +dhcp-boot=ipxe-undionly.kpxe,,192.168.128.247 diff --git a/packaging/boot-rig/run.sh b/packaging/boot-rig/run.sh index 58c5c2d..25f3745 100755 --- a/packaging/boot-rig/run.sh +++ b/packaging/boot-rig/run.sh @@ -42,7 +42,7 @@ trap cleanup EXIT echo "==> generating the DHCP configuration from the server's own snippet" mkdir -p generated cargo run --quiet --manifest-path ../../Cargo.toml -- boot dhcp-snippet --format dnsmasq \ - > generated/dnsmasq.conf.snippet 2>/dev/null <<<"" || { + > generated/dnsmasq.conf.snippet 2>/dev/null || { echo "could not generate the snippet" >&2; exit 1; } # Everything above the snippet is the rig's own scaffolding: a range to hand out, an @@ -56,7 +56,7 @@ cargo run --quiet --manifest-path ../../Cargo.toml -- boot dhcp-snippet --format echo "dhcp-range=10.99.0.100,10.99.0.200,1h" echo "enable-tftp=no" echo - echo "# --- everything below is `rescriptum boot dhcp-snippet --format dnsmasq` ---" + echo '# --- everything below is: rescriptum boot dhcp-snippet --format dnsmasq ---' # The generated snippet names RESCRIPTUM_PUBLIC_HOST, which outside a container is # this machine. Inside the rig the server is 10.99.0.2, and that substitution is the # only edit the rig makes. diff --git a/src/boot/catalog.rs b/src/boot/catalog.rs index 7847485..d68cc57 100644 --- a/src/boot/catalog.rs +++ b/src/boot/catalog.rs @@ -48,6 +48,27 @@ pub struct Entry { /// Where the kernel and initrd are, when they sit beside the image rather than /// inside it — `prepare-iso --pxe` output, which is a directory of three files. pub beside: Option, + /// What to inject, when this entry is a **prepared** one: a sidecar naming another + /// entry's image plus an answer URL. About two hundred bytes standing in for 1.5 GB + /// — nothing is copied, and the same image backs every entry derived from it. + pub prepared: Option, +} + +/// A prepared entry's instructions. The image itself is the source entry's. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Prepared { + pub source_id: String, + pub url: String, + pub fingerprint: Option, + pub token: Option, + /// The source's length when the entry was written. **A stale prepared entry is + /// invisible otherwise**: the plan's offsets are computed against one image, and an + /// image that changed underneath would be patched in the wrong place. + pub source_len: u64, + /// The source's digest at the same moment, for `media check` to re-verify. The + /// catalogue compares only the length, because hashing 1.5 GB per directory read is + /// exactly the work no request may ever do. + pub source_digest: Option, } impl Entry { @@ -225,14 +246,31 @@ impl Catalog { } } - // A sidecar whose image is gone is a leftover, and a silent one: the entry - // simply stops existing and the menu shrinks with no explanation. + // Then the prepared entries, which need every source to exist first. A sidecar + // with no image of its own and no `source` is a leftover, and a silent one: the + // entry simply stops existing and the menu shrinks with no explanation. + let sources = listing.entries.clone(); for (id, path) in &sidecars { - if !listing.entries.iter().any(|e| &e.id == id) { + if listing.entries.iter().any(|e| &e.id == id) { + continue; + } + let recorded = match Sidecar::load(path) { + Ok(recorded) => recorded, + Err(problem) => { + listing.problems.push(problem); + continue; + } + }; + if recorded.source.is_none() { listing.problems.push(format!( "{}: no image named {id} — the sidecar describes something that is not here", path.display() )); + continue; + } + match self.prepared(id, path, &recorded, &sources) { + Ok(entry) => listing.entries.push(entry), + Err(problem) => listing.problems.push(problem), } } @@ -281,8 +319,71 @@ impl Catalog { digest: recorded.digest, probed, beside, + prepared: None, }) } + + /// A **prepared** entry: a sidecar naming another entry's image plus what to inject. + /// + /// The image is never copied — this entry serves the source's bytes with a few + /// hundred substituted on the way out. Two hundred bytes of sidecar stand in for + /// 1.5 GB, and changing the answer URL is a matter of rewriting them. + fn prepared( + &self, + id: &str, + sidecar: &Path, + recorded: &Sidecar, + sources: &[Entry], + ) -> Result { + if !crate::store::valid_id(id) || RESERVED_IDS.contains(&id) { + return Err(format!( + "{}: {id:?} is not a usable identifier for a prepared entry", + sidecar.display() + )); + } + let source_id = recorded.source.clone().unwrap_or_default(); + let Some(source) = sources.iter().find(|e| e.id == source_id) else { + return Err(format!( + "{}: names source {source_id:?}, which is not in this directory", + sidecar.display() + )); + }; + let Some(url) = recorded.prepare_url.clone() else { + return Err(format!( + "{}: names a source but no `prepare-url`, so there is nothing to inject", + sidecar.display() + )); + }; + + // **A stale prepared entry is invisible**, so this is loud: the plan's offsets + // are computed against one image, and a source that changed underneath would be + // patched in the wrong place — producing an image that mounts and is wrong. + if let Some(was) = recorded.source_len + && was != source.size + { + return Err(format!( + "{}: {source_id} was {was} bytes when this was prepared and is {} now. The injection offsets no longer apply — re-run `media prepare`.", + sidecar.display(), + source.size + )); + } + + let mut entry = source.clone(); + entry.id = id.to_string(); + // A prepared image is longer than its source by whatever was injected, and the + // exact figure comes from the plan rather than from an estimate. + entry.prepared = Some(Prepared { + source_id, + url, + fingerprint: recorded.prepare_fingerprint.clone(), + token: recorded.prepare_token.clone(), + source_len: source.size, + source_digest: source.digest.clone(), + }); + // Its own digest is the source's no longer: nobody has pinned the derived image. + entry.digest = None; + Ok(entry) + } } /// What `media add` recorded about an image, so the server never re-learns it. @@ -301,6 +402,13 @@ pub struct Sidecar { pub initrd: Option, pub external: bool, pub zstd_initrd: bool, + /// Set on a **prepared** entry: the id of the image this derives from, and what to + /// inject into it. + pub source: Option, + pub prepare_url: Option, + pub prepare_fingerprint: Option, + pub prepare_token: Option, + pub source_len: Option, } impl Sidecar { @@ -333,6 +441,11 @@ impl Sidecar { "initrd" => out.initrd = Some(value), "external" => out.external = value == "true", "zstd-initrd" => out.zstd_initrd = value == "true", + "source" => out.source = Some(value), + "prepare-url" => out.prepare_url = Some(value), + "prepare-cert-fingerprint" => out.prepare_fingerprint = Some(value), + "prepare-token" => out.prepare_token = Some(value), + "source-bytes" => out.source_len = value.parse().ok(), _ => {} } } diff --git a/src/boot/iso.rs b/src/boot/iso.rs index 1384fb6..c2b9bf8 100644 --- a/src/boot/iso.rs +++ b/src/boot/iso.rs @@ -54,6 +54,10 @@ pub struct Trees { pub rock_ridge: bool, /// A supplementary descriptor with a Joliet escape sequence. pub joliet: bool, + /// A UDF filesystem alongside the ISO9660 one. **A Windows ISO is UDF+ISO9660 and + /// its large files exist only in the UDF tree**, so patching the ISO9660 tree of + /// such an image produces something that looks right and is not. + pub udf: bool, } /// An opened image: the descriptor facts, and a handle to read extents from. @@ -73,6 +77,48 @@ pub struct Iso { /// root's `SP` entry declares. Read once at open: recomputing it per record would /// re-parse the root directory for every entry in the image. susp_skip: usize, + /// Where the descriptors are, in bytes. The patcher rewrites the volume space size + /// in both, and one that had to re-scan for them could disagree with the reader + /// about which descriptor it had found. + pvd_at: u64, + svd_at: Option, +} + +impl Iso { + /// Where the primary volume descriptor starts. + pub fn pvd_at(&self) -> u64 { + self.pvd_at + } + + /// Where the supplementary (Joliet) descriptor starts, if there is one. + pub fn svd_at(&self) -> Option { + self.svd_at + } + + /// The root directory extent, for a caller that walks records itself. + pub fn root_extent(&self) -> Extent { + self.root + } + + /// The Joliet tree's root, when the image has a second tree. + pub fn joliet_root_extent(&self) -> Option { + self.joliet_root + } + + /// Raw bytes out of the image, for a caller that works in offsets rather than in + /// records. + pub fn read_raw(&mut self, offset: u64, len: usize) -> io::Result> { + let mut buffer = vec![0u8; len]; + self.file.seek(SeekFrom::Start(offset))?; + self.file.read_exact(&mut buffer)?; + Ok(buffer) + } + + /// The system use area's skip length, which a patcher needs in order to write an + /// `NM` entry the same reader will find. + pub fn susp_skip(&self) -> usize { + self.susp_skip + } } impl Iso { @@ -82,6 +128,12 @@ impl Iso { let mut primary: Option<[u8; SECTOR as usize]> = None; let mut supplementary: Option<[u8; SECTOR as usize]> = None; + // Remembered rather than recomputed: the patcher rewrites the volume space size + // in both descriptors, and one that re-scanned for them could disagree with this + // reader about which it had found. + let mut pvd_at = FIRST_DESCRIPTOR; + let mut svd_at = None; + let mut udf = false; // Volume descriptors run from sector 16 until a terminator. A handful of images // carry a dozen; none carries hundreds, so the cap is a guard, not a policy. @@ -91,14 +143,30 @@ impl Iso { if file.read_exact(&mut sector).is_err() { break; } + // The volume recognition sequence shares this field: an image carrying UDF + // announces it here, in the same place and the same run of sectors. + if matches!(§or[1..6], b"NSR02" | b"NSR03") { + udf = true; + continue; + } + if matches!(§or[1..6], b"BEA01" | b"TEA01") { + continue; + } if §or[1..6] != b"CD001" { // Not a descriptor at all. If we have not even found the primary yet, // this is not an ISO9660 image and saying so is the whole answer. break; } + let at = FIRST_DESCRIPTOR + index * SECTOR; match sector[0] { - 1 => primary = Some(sector), - 2 if supplementary.is_none() && is_joliet(§or) => supplementary = Some(sector), + 1 => { + primary = Some(sector); + pvd_at = at; + } + 2 if supplementary.is_none() && is_joliet(§or) => { + supplementary = Some(sector); + svd_at = Some(at); + } 255 => break, _ => {} } @@ -138,10 +206,13 @@ impl Iso { trees: Trees { rock_ridge: false, joliet: supplementary.is_some(), + udf, }, volume_id, declared_size, susp_skip: 0, + pvd_at, + svd_at, }; iso.trees.rock_ridge = iso.detect_rock_ridge(); Ok(iso) diff --git a/src/boot/media.rs b/src/boot/media.rs index d3d4a30..3b133cc 100644 --- a/src/boot/media.rs +++ b/src/boot/media.rs @@ -269,7 +269,7 @@ enum What { Inside(String), } -/// One run of bytes to send: a file, or a generated header. +/// One run of bytes to send: a file, a generated header, or a file with substitutions. #[derive(Debug, Clone)] enum Segment { Bytes(Vec), @@ -278,6 +278,17 @@ enum Segment { offset: u64, length: u64, }, + /// An image with a few hundred bytes substituted and a few hundred appended. The + /// plan owns the arithmetic; this only streams what it says. + Patched { + plan: Arc, + }, + /// Part of one, for a range request. + Window { + plan: Arc, + start: u64, + length: u64, + }, } impl Segment { @@ -285,10 +296,26 @@ impl Segment { match self { Segment::Bytes(b) => b.len() as u64, Segment::File { length, .. } => *length, + Segment::Patched { plan } => plan.len(), + Segment::Window { length, .. } => *length, } } } +/// Compute the injection for a prepared entry. A few kilobytes of reads over the +/// source's directory records — never a pass over the image. +fn plan_for( + entry: &Entry, + prepared: &super::catalog::Prepared, +) -> Result { + let mode = super::patch::mode_file( + &prepared.url, + prepared.fingerprint.as_deref(), + prepared.token.as_deref(), + ); + super::patch::add_file(&entry.path, "auto-installer-mode.toml", mode.as_bytes()) +} + /// What to send, and whether a range may be applied to it. struct Source { segments: Vec, @@ -316,7 +343,24 @@ fn resolve(entry: &Entry, what: &What) -> Result { }; match what { - What::Image => Ok(one(entry.path.clone(), 0, entry.size, true)), + What::Image => match &entry.prepared { + None => Ok(one(entry.path.clone(), 0, entry.size, true)), + // **Synthesised on the wire, never stored.** The source is untouched, so + // its published digest stays verifiable; a second 1.5 GB copy on disk buys + // nothing; and changing the answer URL is a matter of 300 bytes. + Some(prepared) => { + let plan = plan_for(entry, prepared)?; + let total = plan.len(); + Ok(Source { + segments: vec![Segment::Patched { + plan: Arc::new(plan), + }], + total, + resumable: true, + content_type: "application/octet-stream", + }) + } + }, What::Kernel | What::Initrd => { let inside = match what { @@ -500,6 +544,14 @@ fn slice(segments: Vec, start: u64, end: u64) -> Vec { offset: offset + start, length: end - start + 1, }, + // A patched segment keeps its whole plan and is windowed at read time: the + // offsets are the *patched* image's, and narrowing them here would move a + // substitution relative to the bytes it belongs to. + Segment::Patched { plan } => Segment::Window { + plan, + start, + length: end - start + 1, + }, other => other, }) .collect() @@ -823,6 +875,46 @@ impl Body { } } +/// Stream a window of a patched image. The plan resolves every offset — through the +/// source, a substitution, or the appended tail — so this only moves bytes. +fn stream_patched( + plan: &super::patch::Plan, + start: u64, + length: u64, + tx: &mpsc::Sender>, +) -> bool { + let mut file = match std::fs::File::open(&plan.source) { + Ok(file) => file, + Err(e) => { + let _ = tx.blocking_send(Err(e)); + return false; + } + }; + let mut buffer = vec![0u8; CHUNK]; + let mut at = start; + let end = start + length; + while at < end { + let want = ((end - at) as usize).min(CHUNK); + match plan.read_at(&mut file, at, &mut buffer[..want]) { + Ok(0) => return true, + Ok(n) => { + if tx + .blocking_send(Ok(Bytes::copy_from_slice(&buffer[..n]))) + .is_err() + { + return false; + } + at += n as u64; + } + Err(e) => { + let _ = tx.blocking_send(Err(e)); + return false; + } + } + } + true +} + /// The blocking half: open, seek, read, send. A closed channel means the client hung up /// — stop reading rather than finishing an image nobody is receiving. fn produce(segments: Vec, tx: &mpsc::Sender>) { @@ -833,6 +925,20 @@ fn produce(segments: Vec, tx: &mpsc::Sender>) return; } } + Segment::Patched { plan } => { + if !stream_patched(&plan, 0, plan.len(), tx) { + return; + } + } + Segment::Window { + plan, + start, + length, + } => { + if !stream_patched(&plan, start, length, tx) { + return; + } + } Segment::File { path, offset, diff --git a/src/boot/menu.rs b/src/boot/menu.rs index 20ac6e0..3952bbf 100644 --- a/src/boot/menu.rs +++ b/src/boot/menu.rs @@ -319,6 +319,7 @@ mod tests { zstd_initrd: false, }, beside: None, + prepared: None, } } diff --git a/src/boot/mod.rs b/src/boot/mod.rs index e9f1465..1b95871 100644 --- a/src/boot/mod.rs +++ b/src/boot/mod.rs @@ -17,6 +17,7 @@ pub mod iso; pub mod loaders; pub mod media; pub mod menu; +pub mod patch; pub mod privileges; pub mod probe; pub mod sha256; diff --git a/src/boot/patch.rs b/src/boot/patch.rs new file mode 100644 index 0000000..2fea1f0 --- /dev/null +++ b/src/boot/patch.rs @@ -0,0 +1,650 @@ +//! Adding one file to an ISO9660 image without writing 1.5 GB. +//! +//! The only genuinely hard part of this whole design, and it is needed by exactly one +//! installer. Proxmox reads `/auto-installer-mode.toml` from the mounted image to learn +//! where its answer lives; every other family takes a URL on the kernel command line. +//! +//! ## Why this is tractable at all +//! +//! **On the PXE path the image is never booted, only mounted.** Bootability is not at +//! stake — the requirement is *"still a readable ISO9660 filesystem exposing one more +//! file"*, which is a far weaker problem than the one xorriso solves. No boot catalog, +//! no hybrid MBR, no repacking. +//! +//! An ISO9660 file is a contiguous extent, so adding one is three small overwrites and +//! an append: +//! +//! 1. **Append the content** past the end of the image. +//! 2. **Write a directory record** into the slack at the end of the root directory's +//! extent — directory extents are padded to 2048 bytes, and a root with a dozen +//! entries typically leaves a kilobyte free. +//! 3. **Bump the volume space size** in the primary descriptor, and in the +//! supplementary one if there is a Joliet tree. +//! +//! So this does not produce a file. It produces a **plan** — a list of `(offset, bytes)` +//! plus a tail — which the listener applies *while streaming*. What goes on the wire is +//! the source image with a few hundred bytes substituted and a few hundred appended: +//! no second copy on disk, the source never mutated so its published digest stays +//! verifiable, ranges still work because the arithmetic is trivial, and changing the +//! answer URL is a matter of recomputing 300 bytes. +//! +//! ## The trap that decides whether this works: Rock Ridge +//! +//! `auto-installer-mode.toml` is **not a legal ISO9660 identifier** — hyphens are not +//! d-characters, lower case is not allowed, and it exceeds 8.3. The record would be +//! called something like `AUTO_INS.TOM;1`, and **the installer would never find its +//! file**: it would be in the image and invisible to its only reader. +//! +//! Linux mounts iso9660 with Rock Ridge auto-detected and Rock Ridge names win, so the +//! record carries an `NM` entry with the real name. Where there is a Joliet tree the +//! record goes there too, in UCS-2 — because **which tree the mount reads is not ours +//! to decide**. Where there is neither, this refuses, and refusing is a *complete* +//! answer: the fallback is one command on any Debian box +//! (`proxmox-auto-install-assistant prepare-iso`), whose output this server is perfectly +//! happy to serve. + +use super::iso::{Extent, Iso, SECTOR}; +use std::path::{Path, PathBuf}; + +/// One overwrite inside the original image. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Overwrite { + pub at: u64, + pub bytes: Vec, +} + +/// What to substitute and what to append, with the arithmetic already done. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Plan { + pub source: PathBuf, + /// The original image's length. Everything past it is `tail`. + pub source_len: u64, + pub overwrites: Vec, + pub tail: Vec, + /// What was added, for a listing to report. + pub added: String, +} + +impl Plan { + /// The length of the image this plan describes. Exact arithmetic, because a + /// `Content-Length` computed any other way would be a guess. + pub fn len(&self) -> u64 { + self.source_len + self.tail.len() as u64 + } + + pub fn is_empty(&self) -> bool { + self.overwrites.is_empty() && self.tail.is_empty() + } + + /// Fill `buffer` with the patched image's bytes starting at `offset`. + /// + /// This is what makes a range request work over a virtual file: the offsets are the + /// patched image's, and every one of them resolves to either the source, an + /// overwrite, or the tail. + pub fn read_at( + &self, + source: &mut std::fs::File, + offset: u64, + buffer: &mut [u8], + ) -> std::io::Result { + use std::io::{Read, Seek, SeekFrom}; + + let total = self.len(); + if offset >= total { + return Ok(0); + } + let want = buffer.len().min((total - offset) as usize); + let buffer = &mut buffer[..want]; + + // The source's part of this window, then the tail's. + let from_source = if offset < self.source_len { + (self.source_len - offset).min(want as u64) as usize + } else { + 0 + }; + if from_source > 0 { + source.seek(SeekFrom::Start(offset))?; + source.read_exact(&mut buffer[..from_source])?; + } + if from_source < want { + // Where in the tail this window starts: at its beginning when the window + // straddles the join, and further in when it starts past the source's end. + let tail_start = offset.saturating_sub(self.source_len) as usize; + let take = want - from_source; + let end = (tail_start + take).min(self.tail.len()); + let slice = &self.tail[tail_start.min(self.tail.len())..end]; + buffer[from_source..from_source + slice.len()].copy_from_slice(slice); + // Past the tail is zero, which only happens if the arithmetic above is + // wrong; leaving it zero beats reading somebody else's memory. + for byte in &mut buffer[from_source + slice.len()..] { + *byte = 0; + } + } + + // Then substitute, which is what makes this a patch rather than a copy. + for overwrite in &self.overwrites { + let end = overwrite.at + overwrite.bytes.len() as u64; + if end <= offset || overwrite.at >= offset + want as u64 { + continue; + } + let from = overwrite.at.max(offset); + let to = end.min(offset + want as u64); + let in_buffer = (from - offset) as usize; + let in_patch = (from - overwrite.at) as usize; + let count = (to - from) as usize; + buffer[in_buffer..in_buffer + count] + .copy_from_slice(&overwrite.bytes[in_patch..in_patch + count]); + } + Ok(want) + } + + /// Write the whole thing out, for a USB stick. **One code path with the streaming + /// one**: `media export` materialises exactly what the listener would have served. + pub fn materialise(&self, to: &Path) -> std::io::Result<()> { + use std::io::Write; + let mut source = std::fs::File::open(&self.source)?; + let mut out = std::fs::File::create(to)?; + let mut buffer = vec![0u8; 1024 * 1024]; + let mut at = 0u64; + while at < self.len() { + let n = self.read_at(&mut source, at, &mut buffer)?; + if n == 0 { + break; + } + out.write_all(&buffer[..n])?; + at += n as u64; + } + out.flush() + } +} + +/// Plan the addition of one file at the root of an image. +pub fn add_file(path: &Path, name: &str, content: &[u8]) -> Result { + let mut iso = Iso::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + + // A Windows ISO is UDF+ISO9660 and its large files exist only in the UDF tree. + // Patching the ISO9660 tree of such an image produces something that looks right and + // is not, which is the worst of the three outcomes. + if iso.trees.udf { + return Err(format!( + "{}: the image carries a UDF filesystem, which this does not understand. \ + Patching only its ISO9660 tree would produce an image that looks right and \ + is not. Prepare it with the vendor's own tool instead.", + path.display() + )); + } + // Neither long-name tree: the record would be in the image under a mangled name and + // invisible to the only reader that wants it. + if !iso.trees.rock_ridge && !iso.trees.joliet { + return Err(format!( + "{}: the image has neither Rock Ridge nor Joliet, so {name} could only exist \ + under a mangled 8.3 name and the installer would never find it.", + path.display() + )); + } + + let source_len = std::fs::metadata(path) + .map_err(|e| format!("{}: {e}", path.display()))? + .len(); + // Every extent is addressed in sectors, so an image that does not end on one has no + // sector to put the new file in. + if source_len == 0 || source_len % SECTOR != 0 { + return Err(format!( + "{}: the image is {source_len} bytes, which is not a whole number of 2048-byte \ + sectors — there is nowhere to append a file.", + path.display() + )); + } + if content.len() as u64 > SECTOR * 64 { + // This exists to add a few hundred bytes of configuration. Anything larger is a + // different job and should say so rather than half-working. + return Err(format!( + "{name} is {} bytes; this adds small files, not payloads.", + content.len() + )); + } + + let lba = (source_len / SECTOR) as u32; + let mut overwrites = Vec::new(); + + // Read out of the descriptors before the borrow checker has an opinion about + // holding one while reading through the other. + let root = iso.root_extent(); + let joliet_root = iso.joliet_root_extent(); + let susp_skip = iso.susp_skip(); + + // The ISO9660 tree, with a Rock Ridge `NM` entry carrying the real name. + if iso.trees.rock_ridge { + let record = iso9660_record(name, lba, content.len() as u32, true, susp_skip); + let at = slack_in(&mut iso, root, record.len())?; + overwrites.push(Overwrite { at, bytes: record }); + } + + // The Joliet tree, where names are UCS-2 and case-preserving. Both, when both exist: + // which one the mount reads is not ours to decide. + if let Some(joliet_root) = joliet_root { + let record = joliet_record(name, lba, content.len() as u32); + let at = slack_in(&mut iso, joliet_root, record.len())?; + overwrites.push(Overwrite { at, bytes: record }); + } + + // The volume space size, in both descriptors. An image whose descriptor still claims + // the old length has a file past its own end, and a mount will not read it. + let added_sectors = (content.len() as u64).div_ceil(SECTOR) as u32; + let blocks = (source_len / SECTOR) as u32 + added_sectors; + overwrites.push(Overwrite { + at: iso.pvd_at() + 80, + bytes: both_endian32(blocks), + }); + if let Some(svd_at) = iso.svd_at() { + overwrites.push(Overwrite { + at: svd_at + 80, + bytes: both_endian32(blocks), + }); + } + + let mut tail = content.to_vec(); + tail.resize(added_sectors as usize * SECTOR as usize, 0); + + Ok(Plan { + source: path.to_path_buf(), + source_len, + overwrites, + tail, + added: name.to_string(), + }) +} + +/// Find room for a record in a directory extent, and say where it goes. +/// +/// **A directory record may not cross a sector boundary**, so this looks per sector +/// rather than at the extent as a whole: the space after the last record in sector three +/// is unusable if the record does not fit in it, even when sector four is empty. +fn slack_in(iso: &mut Iso, directory: Extent, need: usize) -> Result { + if !directory.directory || directory.size == 0 { + return Err("the root directory extent is not readable".to_string()); + } + let extent = iso + .read_raw(directory.offset, directory.size as usize) + .map_err(|e| format!("cannot read the root directory: {e}"))?; + + let sector = SECTOR as usize; + for (index, chunk) in extent.chunks(sector).enumerate() { + let mut used = 0usize; + while used + 33 <= chunk.len() { + let length = chunk[used] as usize; + if length == 0 { + break; + } + if used + length > chunk.len() { + // A record claiming to run past its own sector: refuse rather than + // write into whatever follows. + return Err("a directory record overruns its sector".to_string()); + } + used += length; + } + if chunk.len() - used >= need { + return Ok(directory.offset + (index * sector) as u64 + used as u64); + } + } + + Err(format!( + "the root directory has no {need} bytes of slack in any of its sectors. \ + Relocating the extent would drag in the path tables, which is deliberately not \ + done here — prepare the image with `proxmox-auto-install-assistant prepare-iso` \ + instead, and this server will serve the result." + )) +} + +/// An ISO9660 directory record, optionally carrying the real name in a Rock Ridge `NM`. +fn iso9660_record(name: &str, lba: u32, size: u32, rock_ridge: bool, skip: usize) -> Vec { + let identifier = mangle(name); + let mut system: Vec = vec![0u8; skip]; + if rock_ridge { + // NM: signature, length, version, flags, then the name. Flags zero means "this + // is the whole name" — no CONTINUE, not a `.` or `..` marker. + let mut nm = vec![b'N', b'M', (5 + name.len()) as u8, 1, 0]; + nm.extend_from_slice(name.as_bytes()); + system.extend_from_slice(&nm); + } + record(identifier.as_bytes(), lba, size, &system) +} + +/// The same record in the Joliet tree, where the name is UCS-2 big-endian and needs no +/// mangling at all — which is why an image with only Joliet is still patchable. +fn joliet_record(name: &str, lba: u32, size: u32) -> Vec { + let mut identifier = Vec::new(); + for unit in name.encode_utf16() { + identifier.extend_from_slice(&unit.to_be_bytes()); + } + record(&identifier, lba, size, &[]) +} + +fn record(identifier: &[u8], lba: u32, size: u32, system: &[u8]) -> Vec { + let mut out = vec![0u8; 33]; + out[2..10].copy_from_slice(&both_endian32(lba)); + out[10..18].copy_from_slice(&both_endian32(size)); + // Recording date and time: zeros. Every reader accepts it, and a fabricated + // timestamp would be a lie about when somebody wrote the file. + out[25] = 0; // flags: a plain file + out[28..32].copy_from_slice(&both_endian16(1)); + out[32] = identifier.len() as u8; + out.extend_from_slice(identifier); + // A pad byte when the identifier length is even, so the system use area starts on an + // even offset — which is where a reader looks for it. + if identifier.len() % 2 == 0 { + out.push(0); + } + out.extend_from_slice(system); + // Records are even-length. + if out.len() % 2 == 1 { + out.push(0); + } + out[0] = out.len() as u8; + out +} + +/// The best legal ISO9660 identifier for a name that is not one. +/// +/// Deliberately conservative — upper case, `A-Z0-9_`, 8.3 — because the *real* name +/// comes from Rock Ridge or Joliet and this only has to be legal and unlikely to +/// collide. A mastering tool would do the same thing. +fn mangle(name: &str) -> String { + let (stem, extension) = match name.rsplit_once('.') { + Some((stem, extension)) => (stem, extension), + None => (name, ""), + }; + let clean = |text: &str, limit: usize| -> String { + text.to_ascii_uppercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .take(limit) + .collect() + }; + let stem = clean(stem, 8); + let extension = clean(extension, 3); + if extension.is_empty() { + format!("{stem}.;1") + } else { + format!("{stem}.{extension};1") + } +} + +fn both_endian32(value: u32) -> Vec { + let mut out = Vec::with_capacity(8); + out.extend_from_slice(&value.to_le_bytes()); + out.extend_from_slice(&value.to_be_bytes()); + out +} + +fn both_endian16(value: u16) -> Vec { + let mut out = Vec::with_capacity(4); + out.extend_from_slice(&value.to_le_bytes()); + out.extend_from_slice(&value.to_be_bytes()); + out +} + +/// The file Proxmox reads to learn where its answer lives. +/// +/// **`AutoInstSettings` is `deny_unknown_fields`**, so one key this does not know about +/// is a *rejected file*, not a warning — and the machine boots the interactive installer +/// with nobody there. Only the five keys upstream defines are ever written. +pub fn mode_file(url: &str, fingerprint: Option<&str>, token: Option<&str>) -> String { + let mut out = String::from( + "# Written by rescriptum. The Proxmox installer reads this from the mounted image\n\ + # to learn where to POST its hardware inventory.\n\ + mode = \"http\"\n", + ); + out.push_str("partition-label = \"proxmox-ais\"\n\n[http]\n"); + out.push_str(&format!("url = \"{}\"\n", escape(url))); + if let Some(fingerprint) = fingerprint { + out.push_str(&format!("cert-fingerprint = \"{}\"\n", escape(fingerprint))); + } + if let Some(token) = token { + out.push_str(&format!("token = \"{}\"\n", escape(token))); + } + out +} + +/// A quote or a backslash in a URL would end the string early, and the result would be +/// a mode file the installer refuses — which reads as this server being broken. +fn escape(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::super::iso::build; + use super::*; + + fn image(name: &str, builder: &build::Builder) -> PathBuf { + let dir = std::env::temp_dir().join(format!("rescriptum-patch-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join(format!("{name}.iso")); + std::fs::write(&path, builder.build()).expect("write"); + path + } + + const MODE: &str = "mode = \"http\"\n[http]\nurl = \"http://192.0.2.10:8000/proxmox\"\n"; + + #[test] + fn a_file_added_to_an_image_reads_back_under_its_real_name() { + // **The whole point.** `auto-installer-mode.toml` is not a legal ISO9660 name, so + // without a Rock Ridge `NM` entry the file would be in the image and invisible + // to the only reader that wants it. + let path = image( + "basic", + &build::Builder::new().file("/boot/linux26", b"kernel"), + ); + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()) + .unwrap_or_else(|e| panic!("{e}")); + + let out = path.with_extension("patched.iso"); + plan.materialise(&out).expect("materialises"); + + let mut patched = Iso::open(&out).expect("still an image"); + assert_eq!( + patched + .read("/auto-installer-mode.toml", 4096) + .expect("readable"), + Some(MODE.as_bytes().to_vec()), + "the installer must find it under the name it looks for" + ); + } + + #[test] + fn everything_that_was_already_there_is_byte_identical() { + // A corrupt image fails silently, on somebody's USB stick, weeks later. + let original = build::Builder::new() + .file("/boot/linux26", b"the kernel, verbatim") + .file("/boot/initrd.img", b"the initrd, verbatim") + .file("/.disk/info", b"PRODUCTLONG='Proxmox VE'\n"); + let path = image("preserved", &original); + let before = std::fs::read(&path).expect("read"); + + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect("plans"); + let out = path.with_extension("patched.iso"); + plan.materialise(&out).expect("materialises"); + let after = std::fs::read(&out).expect("read"); + + // **The first 32 KiB is untouched**, which is where a boot catalog and a hybrid + // MBR live — the two things whose corruption would only surface on a stick. + assert_eq!(&before[..32768], &after[..32768], "the system area moved"); + + let mut patched = Iso::open(&out).expect("still an image"); + for (name, contents) in [ + ("/boot/linux26", &b"the kernel, verbatim"[..]), + ("/boot/initrd.img", &b"the initrd, verbatim"[..]), + ] { + assert_eq!( + patched.read(name, 4096).expect("readable"), + Some(contents.to_vec()), + "{name} changed" + ); + } + // And the source itself was never touched, so its published digest still holds. + assert_eq!(std::fs::read(&path).expect("read"), before); + } + + #[test] + fn the_image_grows_by_exactly_one_sector_for_a_small_file() { + let path = image("growth", &build::Builder::new().file("/x", b"y")); + let before = std::fs::metadata(&path).expect("stat").len(); + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect("plans"); + + assert_eq!(plan.len(), before + SECTOR); + // Exact arithmetic rather than a guess: this is a `Content-Length`. + let out = path.with_extension("grown.iso"); + plan.materialise(&out).expect("materialises"); + assert_eq!(std::fs::metadata(&out).expect("stat").len(), plan.len()); + } + + #[test] + fn a_range_over_the_patched_image_reads_the_same_bytes_as_the_whole_of_it() { + // The listener serves ranges over this virtual file, so every window has to + // resolve — through the source, an overwrite, or the tail. + let path = image( + "ranges", + &build::Builder::new().file("/boot/linux26", b"kernel"), + ); + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect("plans"); + let out = path.with_extension("ranged.iso"); + plan.materialise(&out).expect("materialises"); + let whole = std::fs::read(&out).expect("read"); + + let mut source = std::fs::File::open(&path).expect("open"); + for size in [1usize, 7, 2048, 4096, 100_000] { + let mut rebuilt = Vec::new(); + let mut buffer = vec![0u8; size]; + let mut at = 0u64; + loop { + let n = plan.read_at(&mut source, at, &mut buffer).expect("reads"); + if n == 0 { + break; + } + rebuilt.extend_from_slice(&buffer[..n]); + at += n as u64; + } + assert_eq!(rebuilt, whole, "reading in {size}-byte windows disagreed"); + } + } + + #[test] + fn an_image_with_only_joliet_is_patched_in_that_tree() { + // Which tree a mount reads is not ours to decide, and Joliet needs no mangling + // at all — so an image with only Joliet is still patchable. + let path = image( + "joliet", + &build::Builder::new() + .rock_ridge(false) + .joliet(true) + .file("/boot/linux26", b"kernel"), + ); + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect("plans"); + let out = path.with_extension("joliet-patched.iso"); + plan.materialise(&out).expect("materialises"); + + let mut patched = Iso::open(&out).expect("still an image"); + assert!( + patched + .locate_joliet("/auto-installer-mode.toml") + .expect("readable") + .is_some(), + "the Joliet tree must carry the readable name" + ); + } + + #[test] + fn an_image_with_both_trees_is_patched_in_both() { + let path = image( + "both", + &build::Builder::new() + .rock_ridge(true) + .joliet(true) + .file("/boot/linux26", b"kernel"), + ); + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect("plans"); + // Two records plus two descriptor updates: neither tree may be left behind. + assert_eq!(plan.overwrites.len(), 4, "{:?}", plan.overwrites); + + let out = path.with_extension("both-patched.iso"); + plan.materialise(&out).expect("materialises"); + let mut patched = Iso::open(&out).expect("still an image"); + assert!(patched.has("/auto-installer-mode.toml")); + assert!( + patched + .locate_joliet("/auto-installer-mode.toml") + .expect("readable") + .is_some() + ); + } + + #[test] + fn an_image_with_neither_long_name_tree_is_refused_with_the_way_out() { + // Refusing is a *complete* answer, because the fallback is one command on any + // Debian box and this server is perfectly happy to serve its output. + let path = image( + "mangled-only", + &build::Builder::new() + .rock_ridge(false) + .file("/boot/linux26", b"kernel"), + ); + let e = + add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect_err("must refuse"); + assert!(e.contains("neither Rock Ridge nor Joliet"), "{e}"); + assert!(e.contains("would never find it"), "{e}"); + } + + #[test] + fn a_name_that_is_not_legal_iso9660_is_mangled_the_way_a_mastering_tool_would() { + // The mangled name is what the record is *called*; the real one lives in the NM + // entry. Both have to exist, and neither is the other's substitute. + assert_eq!(mangle("auto-installer-mode.toml"), "AUTO_INS.TOM;1"); + assert_eq!(mangle("readme"), "README.;1"); + assert_eq!(mangle("a.b"), "A.B;1"); + } + + #[test] + fn the_mode_file_carries_only_the_keys_upstream_defines() { + // `AutoInstSettings` is `deny_unknown_fields`: one key it does not know is a + // rejected file, not a warning, and the machine boots the interactive installer + // with nobody there to answer it. + let text = mode_file("http://192.0.2.10:8000/proxmox", None, None); + let keys: Vec<&str> = text + .lines() + .filter(|l| !l.trim_start().starts_with('#') && l.contains('=')) + .map(|l| l.split('=').next().unwrap_or("").trim()) + .collect(); + for key in &keys { + assert!( + [ + "mode", + "partition-label", + "url", + "cert-fingerprint", + "token" + ] + .contains(key), + "{key} is not a key the installer defines" + ); + } + assert!(text.contains("mode = \"http\""), "{text}"); + assert!(text.contains("[http]"), "{text}"); + } + + #[test] + fn a_quote_in_the_url_cannot_end_the_string_early() { + // The result would be a mode file the installer refuses, which reads as this + // server being broken rather than as a bad URL. + let text = mode_file("http://host/a\"b", None, Some("name:sec\\ret")); + assert!(text.contains("\\\""), "{text}"); + assert!(text.contains("\\\\"), "{text}"); + } + + #[test] + fn a_payload_rather_than_a_configuration_file_is_refused() { + let path = image("payload", &build::Builder::new().file("/x", b"y")); + let e = add_file(&path, "big.bin", &vec![0u8; 200 * 1024]).expect_err("must refuse"); + assert!(e.contains("small files, not payloads"), "{e}"); + } +} diff --git a/src/boot/stanza.rs b/src/boot/stanza.rs index 9089283..2f87dfd 100644 --- a/src/boot/stanza.rs +++ b/src/boot/stanza.rs @@ -185,6 +185,7 @@ mod tests { zstd_initrd: false, }, beside: None, + prepared: None, } } diff --git a/src/cli.rs b/src/cli.rs index 75df31a..d23f69a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -460,12 +460,20 @@ pub fn media(cfg: &Config, args: &[String]) -> ExitCode { Some((cmd, rest)) if cmd == "ipxe" && rest.len() == 1 => { media_ipxe(cfg, &catalog, &rest[0]) } + Some((cmd, rest)) if cmd == "prepare" && !rest.is_empty() => { + media_prepare(cfg, &catalog, rest) + } + Some((cmd, rest)) if cmd == "export" && rest.len() == 2 => { + media_export(&catalog, &rest[0], &rest[1]) + } _ => { eprintln!( "usage: rescriptum media list\n\ \x20 rescriptum media add FILE [--sha256 DIGEST]\n\ \x20 rescriptum media check\n\ - \x20 rescriptum media ipxe ID" + \x20 rescriptum media ipxe ID\n\ + \x20 rescriptum media prepare ID [--as NAME] [--url URL]\n\ + \x20 rescriptum media export ID FILE" ); ExitCode::FAILURE } @@ -776,6 +784,207 @@ fn media_ipxe(cfg: &Config, catalog: &crate::boot::catalog::Catalog, id: &str) - } } +/// `media prepare ID` — the one command that removes the last external tool. +/// +/// It writes a **sidecar**, not an image: two hundred bytes standing in for 1.5 GB. The +/// source is never modified, never copied, and its published digest stays verifiable; +/// the injection happens on the wire, and changing the answer URL later rewrites those +/// two hundred bytes rather than a gigabyte. +#[cfg(feature = "boot")] +fn media_prepare( + cfg: &Config, + catalog: &crate::boot::catalog::Catalog, + args: &[String], +) -> ExitCode { + let mut id: Option<&String> = None; + let mut name: Option = None; + let mut url: Option = None; + let mut fingerprint: Option = None; + let mut token: Option = None; + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + let mut take = |what: &str| -> Option { + match rest.next() { + Some(value) => Some(value.clone()), + None => { + eprintln!("{what} wants a value"); + None + } + } + }; + match arg.as_str() { + "--as" => match take("--as") { + Some(v) => name = Some(v), + None => return ExitCode::FAILURE, + }, + "--url" => match take("--url") { + Some(v) => url = Some(v), + None => return ExitCode::FAILURE, + }, + "--cert-fingerprint" => match take("--cert-fingerprint") { + Some(v) => fingerprint = Some(v), + None => return ExitCode::FAILURE, + }, + "--token" => match take("--token") { + Some(v) => token = Some(v), + None => return ExitCode::FAILURE, + }, + other if id.is_none() && !other.starts_with('-') => id = Some(arg), + other => { + eprintln!("unexpected argument {other:?}"); + return ExitCode::FAILURE; + } + } + } + + let Some(id) = id else { + eprintln!("usage: rescriptum media prepare ID [--as NAME] [--url URL]"); + return ExitCode::FAILURE; + }; + let entry = match catalog.get(id) { + Ok(Some(entry)) => entry, + Ok(None) => { + eprintln!("no image called {id:?} — `rescriptum media list` shows what there is"); + return ExitCode::FAILURE; + } + Err(e) => { + eprintln!("cannot read {}: {e}", catalog.dir().display()); + return ExitCode::FAILURE; + } + }; + if entry.family() != crate::boot::probe::Family::Proxmox { + // Every other family takes the answer's URL on the kernel command line, where + // `media ipxe` already puts it. Injecting a file they never read would be a + // no-op that looks like a step. + eprintln!( + "{id} is {}, and only Proxmox reads its answer's location from inside the image. For every other family the URL goes on the kernel command line, which `rescriptum media ipxe {id}` already writes.", + entry.family().label() + ); + return ExitCode::FAILURE; + } + + let url = url.unwrap_or_else(|| format!("{}/proxmox", cfg.endpoints().answer)); + let derived = name.unwrap_or_else(|| format!("{id}-http")); + if !crate::store::valid_id(&derived) + || crate::boot::catalog::RESERVED_IDS.contains(&derived.as_str()) + { + eprintln!("{derived:?} is not a usable identifier — it becomes part of a URL"); + return ExitCode::FAILURE; + } + + // Plan it now rather than at request time, so a refusal is reported to the person + // who can act on it instead of to a machine at 3am. + let mode = crate::boot::patch::mode_file(&url, fingerprint.as_deref(), token.as_deref()); + let plan = match crate::boot::patch::add_file( + &entry.path, + "auto-installer-mode.toml", + mode.as_bytes(), + ) { + Ok(plan) => plan, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + + let mut sidecar = String::from( + "# rescriptum prepared entry — written by `media prepare`.\n # Two hundred bytes standing in for an image: nothing was copied, and the\n # source is untouched. The file is injected on the wire, so changing the URL\n # below is all it takes to point this at a different answer endpoint.\n", + ); + sidecar.push_str(&format!("source = {id}\n")); + sidecar.push_str(&format!("prepare-url = {url}\n")); + if let Some(fingerprint) = &fingerprint { + sidecar.push_str(&format!("prepare-cert-fingerprint = {fingerprint}\n")); + } + if let Some(token) = &token { + sidecar.push_str(&format!("prepare-token = {token}\n")); + } + // The length the offsets were computed against. A source that changed underneath + // would be patched in the wrong place, and the catalogue refuses rather than + // serving an image that mounts and is wrong. + sidecar.push_str(&format!("source-bytes = {}\n", entry.size)); + if let Some(digest) = &entry.digest { + sidecar.push_str(&format!("sha256 = {digest}\n")); + } + + let path = catalog.dir().join(format!( + "{derived}.{}", + crate::boot::catalog::SIDECAR_EXTENSION + )); + if let Err(e) = std::fs::write(&path, sidecar) { + eprintln!("cannot write {}: {e}", path.display()); + return ExitCode::FAILURE; + } + + println!("{derived} prepared from {id}"); + println!(" answer {url}"); + println!( + " injects /auto-installer-mode.toml ({} bytes)", + mode.len() + ); + println!( + " image {} bytes (source {} + {} appended)", + plan.len(), + entry.size, + plan.len() - entry.size + ); + println!(" wrote {}", path.display()); + println!(); + println!("Nothing was copied. Serve it as /{derived}/iso, or write it to a stick with"); + println!(" rescriptum media export {derived} /tmp/{derived}.iso"); + ExitCode::SUCCESS +} + +/// `media export ID FILE` — materialise what the listener would have served. +/// +/// **One code path with the streaming one.** A stick written from a different code path +/// than the one a machine downloads is a second implementation to keep honest, and the +/// difference would only show on somebody's desk. +#[cfg(feature = "boot")] +fn media_export(catalog: &crate::boot::catalog::Catalog, id: &str, to: &str) -> ExitCode { + let entry = match catalog.get(id) { + Ok(Some(entry)) => entry, + Ok(None) => { + eprintln!("no image called {id:?}"); + return ExitCode::FAILURE; + } + Err(e) => { + eprintln!("cannot read {}: {e}", catalog.dir().display()); + return ExitCode::FAILURE; + } + }; + let Some(prepared) = &entry.prepared else { + eprintln!( + "{id} is not a prepared entry — it is the image itself, so copy it. `rescriptum media prepare {id}` makes one that needs exporting." + ); + return ExitCode::FAILURE; + }; + + let mode = crate::boot::patch::mode_file( + &prepared.url, + prepared.fingerprint.as_deref(), + prepared.token.as_deref(), + ); + let plan = match crate::boot::patch::add_file( + &entry.path, + "auto-installer-mode.toml", + mode.as_bytes(), + ) { + Ok(plan) => plan, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + + eprintln!("writing {} bytes to {to} …", plan.len()); + if let Err(e) = plan.materialise(std::path::Path::new(to)) { + eprintln!("cannot write {to}: {e}"); + return ExitCode::FAILURE; + } + println!("{to}"); + ExitCode::SUCCESS +} + /// Whether two paths name the same directory, resolving symlinks where it can. A media /// directory reached as `/srv/media` and as `./media` is the same directory. #[cfg(feature = "boot")] diff --git a/tests/media.rs b/tests/media.rs index 622cfaa..174e314 100644 --- a/tests/media.rs +++ b/tests/media.rs @@ -947,3 +947,172 @@ fn a_boot_asset_route_with_no_boot_directory_says_which_setting_is_missing() { String::from_utf8_lossy(body_of(&r)) ); } + +// ---- preparing an image ---------------------------------------------------- + +#[test] +fn a_prepared_entry_is_a_sidecar_rather_than_a_second_image() { + // **The whole point of Phase 4.** Two hundred bytes stand in for 1.5 GB: nothing is + // copied, the source is never modified, and its published digest stays verifiable. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let before = fs::read(s.media_dir().join("pve-8.4.iso")).expect("read"); + + let out = s.run(&["media", "prepare", "pve-8.4"]); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(printed.contains("pve-8.4-http"), "{printed}"); + assert!(printed.contains("Nothing was copied"), "{printed}"); + + // A sidecar, and no second image. + assert!(s.media_dir().join("pve-8.4-http.media").is_file()); + assert!(!s.media_dir().join("pve-8.4-http.iso").exists()); + assert_eq!( + fs::read(s.media_dir().join("pve-8.4.iso")).expect("read"), + before, + "the source must never be modified" + ); + assert!( + fs::metadata(s.media_dir().join("pve-8.4-http.media")) + .expect("stat") + .len() + < 1024, + "a sidecar is a note, not an image" + ); +} + +#[test] +fn a_prepared_image_is_served_with_the_mode_file_in_it() { + // The injection happens on the wire. What a machine downloads must be a readable + // ISO9660 filesystem exposing one more file — under the name the installer looks + // for, which is not a legal ISO9660 identifier at all. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + assert!( + s.run(&[ + "media", + "prepare", + "pve-8.4", + "--url", + "http://192.0.2.10:8000/proxmox" + ]) + .status + .success() + ); + std::thread::sleep(Duration::from_millis(1200)); + + let r = s.get("/pve-8.4-http/iso"); + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + let served = body_of(&r).to_vec(); + + // Longer than the source by exactly what was appended, and still an image. + let source = fs::read(s.media_dir().join("pve-8.4.iso")).expect("read"); + assert!( + served.len() > source.len(), + "the mode file has to be in there" + ); + assert_eq!(&served[..32768], &source[..32768], "the system area moved"); + + // Written out and re-read as an image, which is the only assertion that matters. + let path = s.media_dir().join("served.iso"); + fs::write(&path, &served).expect("write"); + let mut iso = rescriptum::boot::iso::Iso::open(&path).expect("still an image"); + let mode = iso + .read("/auto-installer-mode.toml", 4096) + .expect("readable") + .expect("the installer must find it"); + let mode = String::from_utf8_lossy(&mode).to_string(); + assert!(mode.contains("mode = \"http\""), "{mode}"); + assert!(mode.contains("http://192.0.2.10:8000/proxmox"), "{mode}"); +} + +#[test] +fn a_range_over_a_prepared_image_lands_where_it_should() { + // Ranges have to work over a virtual file, because five of the seven installers + // range-fetch and casper does it over the image itself. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + assert!(s.run(&["media", "prepare", "pve-8.4"]).status.success()); + std::thread::sleep(Duration::from_millis(1200)); + + let whole = body_of(&s.get("/pve-8.4-http/iso")).to_vec(); + // A window that straddles the join between the source and the appended tail is the + // one that would break if the arithmetic were off by a sector. + let source_len = fs::metadata(s.media_dir().join("pve-8.4.iso")) + .expect("stat") + .len(); + let from = source_len - 100; + let to = source_len + 99; + let r = s.get_with( + "/pve-8.4-http/iso", + &[("Range", &format!("bytes={from}-{to}"))], + ); + assert!(status(&r).starts_with("HTTP/1.1 206"), "{}", head_of(&r)); + assert_eq!( + body_of(&r), + &whole[from as usize..=to as usize], + "a window across the join disagreed with the whole" + ); +} + +#[test] +fn exporting_a_prepared_entry_writes_what_the_listener_would_have_served() { + // One code path, deliberately: a stick written differently from what a machine + // downloads is a second implementation to keep honest, and the difference would only + // show on somebody's desk. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + assert!(s.run(&["media", "prepare", "pve-8.4"]).status.success()); + std::thread::sleep(Duration::from_millis(1200)); + + let served = body_of(&s.get("/pve-8.4-http/iso")).to_vec(); + let to = s.media_dir().parent().expect("base").join("exported.iso"); + let out = s.run(&["media", "export", "pve-8.4-http", to.to_str().unwrap()]); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + + assert_eq!( + fs::read(&to).expect("read"), + served, + "the exported file and the served bytes must be identical" + ); +} + +#[test] +fn a_prepared_entry_whose_source_changed_is_refused_rather_than_served_wrong() { + // **A stale prepared entry is invisible otherwise.** The injection offsets are + // computed against one image; a source that changed underneath would be patched in + // the wrong place, producing something that mounts and is wrong. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + assert!(s.run(&["media", "prepare", "pve-8.4"]).status.success()); + + let mut longer = pve_image(); + longer.extend_from_slice(&vec![0u8; 2048]); + fs::write(s.media_dir().join("pve-8.4.iso"), longer).expect("replace"); + std::thread::sleep(Duration::from_millis(1200)); + + let out = s.run(&["media", "check"]); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(!out.status.success(), "{printed}"); + assert!(printed.contains("no longer apply"), "{printed}"); + assert!( + printed.contains("media prepare"), + "the way out has to be named: {printed}" + ); +} + +#[test] +fn preparing_a_family_that_reads_no_mode_file_is_refused_with_the_alternative() { + // Every other family takes the URL on the kernel command line, where `media ipxe` + // already puts it. Injecting a file they never read would be a no-op that looks + // like a step. + let s = Server::start(&[("ubuntu.iso", image_for("ubuntu"))]); + let out = s.run(&["media", "prepare", "ubuntu"]); + assert!(!out.status.success()); + let printed = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(printed.contains("only Proxmox"), "{printed}"); + assert!(printed.contains("media ipxe ubuntu"), "{printed}"); +} From 5a02b5d13b4632dfd0b0928e942ad0e43e97b424 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 14:20:57 +0200 Subject: [PATCH 12/59] docs: preparing an image, and the traps phases 2 and 4 paid for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The media guide gains `media prepare` and `media export` in both languages — what a sidecar is, why nothing is copied, when it refuses and what the way out is, and why a source that changed underneath is refused rather than patched in the wrong place. CLAUDE.md gains the boot layout as it now stands, the re-measured sizes, and eight traps that each cost something to find: - a PXE ROM retransmits its read request, so a per-peer cap is a fairness bound rather than a hostility threshold; - a TFTP transfer ends on a short block, and "short" includes empty; - `;` separates iPXE commands only as a standalone token; - `net0` is the first NIC rather than the booting one, and iPXE percent-encodes nothing on plain expansion; - a UEFI HTTP Boot client discards an offer that does not echo `HTTPClient`, and a Windows DHCP policy cannot condition on option 93 at all; - `auto-installer-mode.toml` is not a legal ISO9660 identifier; - iPXE's BIOS targets need an x86 compiler and its ARM64 ones a cross prefix. Also deduplicates a `dhcp-boot` line the generator emitted twice, because 0x0007 and 0x0009 share a tag. dnsmasq ignores the second; an operator reads the file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- CLAUDE.md | 49 ++++++++++++++++++---- docs/guide/operations/media.fr.md | 70 +++++++++++++++++++++++++++++++ docs/guide/operations/media.md | 67 +++++++++++++++++++++++++++++ docs/guide/reference/cli.fr.md | 2 + docs/guide/reference/cli.md | 2 + src/boot/dhcp.rs | 28 +++++++++++++ 6 files changed, 211 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3a33b41..168fb57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,10 +95,18 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit never an extraction); `probe.rs` places an image from a table of markers; `catalog.rs` discovers what is held, cached behind the directory mtime like the answer listing; `media.rs` is the listener, on its own socket; `stanza.rs` holds what each installer - family needs on the wire; `cpio.rs` and `sha256.rs` are hand-written and dependency-free. - Behind the `boot` cargo feature, default on. `select.rs` knows none of this exists, and - the only seam is that `media ipxe` **prints an ordinary `.ipxe` answer document** — - selection, layering and templating then apply unchanged. + family needs on the wire; `patch.rs` adds one file to an ISO as a *plan* rather than a + rewrite; `tftp.rs` hands over the loader and nothing else; `loaders.rs` is the option-93 + table **both** TFTP and `boot dhcp-snippet` read, so the two cannot drift; `menu.rs` + writes the bootstrap and the menu; `dhcp.rs` generates six configuration formats; + `privileges.rs` drops after binding; `cpio.rs` and `sha256.rs` are hand-written and + dependency-free. Behind the `boot` cargo feature, default on. `select.rs` knows none of + this exists, and the only seam is that `media ipxe` **prints an ordinary `.ipxe` answer + document** — selection, layering and templating then apply unchanged. +- `packaging/ipxe/` — the branded loaders: `branding.h`, the embedded script, a + SHA-pinned upstream commit and `build.sh`. **No binaries in git, ever**; this directory + is the GPLv2 written offer. `packaging/boot-rig/` — the boot rig, three services on an + `internal: true` network, so a harness that runs DHCP cannot answer on the host's LAN. - `src/facts.rs` — what a request says about the machine: query parameters, a flattened JSON body, and the raw haystack. - `src/format/` — one interface per document format. `xml.rs` holds the XML tree and its @@ -357,11 +365,14 @@ spend into an apparent 293% overrun.** | Build | Bytes | |---|---| -| `sqlite` + `boot` (default) | 2,602,056 | +| `sqlite` + `boot` (default) | 2,709,840 | | `sqlite` only | 2,482,000 | -| `boot` only | 1,436,704 | | neither | 1,316,648 | +**`boot` costs 227,840 bytes, against a ≤170 KB budget the plan set before any of it was +written.** That is recorded in `plans/boot-media.md` with a per-phase breakdown rather +than quietly exceeded; the figure needs re-deciding against the measurement. + ## The admin API `src/admin.rs`, enabled only by `RESCRIPTUM_ADMIN_ADDR`, and only over SQLite. Three properties @@ -491,6 +502,27 @@ could not check. Note it needs `Resolution::format_name` (the extension), not - **`;` in an iPXE script separates commands only as a whole whitespace-delimited token** (`split_command` in iPXE's `core/exec.c`). So `ds=nocloud-net;s=http://…` is one argument and must **not** be escaped, while `foo ; bar` is two commands. +- **A PXE ROM retransmits its read request** when an answer is slow — a sleeping NAS disk + is enough. A per-peer transfer cap is therefore a fairness bound, not a hostility + threshold; counting malformed packets against it locks a machine out of the server it + is retrying to reach. +- **A TFTP transfer ends on a *short* block, and "short" includes empty.** A file whose + length divides exactly by the block size must end with an empty data packet, or the + client waits forever for a final block that never comes. +- **`;` separates iPXE commands only as a whole whitespace-delimited token** + (`split_command` in iPXE's `core/exec.c`), so `ds=nocloud-net;s=…` is one argument and + must not be escaped. And **`${version}` is iPXE's own version**, not ours. +- **`net0` is the first NIC, not the booting one** — `${netX/mac}` names the device that + actually booted. **iPXE percent-encodes nothing on plain expansion**, so an SMBIOS + string in a URL needs `${…:uristring}`. +- **A UEFI HTTP Boot client discards a DHCP offer that does not echo `HTTPClient`** in + option 60. **A Windows DHCP policy cannot condition on option 93 at all** — the + architecture reaches it only inside the option-60 string. +- **`auto-installer-mode.toml` is not a legal ISO9660 identifier.** Without a Rock Ridge + `NM` entry the file is in the image and invisible to the installer, which looks like + this server being broken. +- **iPXE's BIOS targets need an x86 compiler and its ARM64 ones need + `CROSS_COMPILE=aarch64-linux-gnu-`.** Both failures read like a broken Makefile. - **The size figures in this file go stale.** They moved ~375 KB when armv7 changed from musl to glibc. Re-measure before concluding anything from them; a stale baseline once turned a 71% budget spend into an apparent 293% overrun. @@ -826,10 +858,13 @@ the image and not derived from `DISK_SIZE`. `run-vm.sh` is the loader-image fall ## Testing expectations -426 tests, plus the package's own harnesses (see *The DSM package*, and note that +524 tests, plus the package's own harnesses (see *The DSM package*, and note that `cargo test` does not run those). `docs/development/testing.md` has the per-suite table; the rules that decide where a test goes: +- **TFTP belongs in `tests/tftp.rs`**, speaking the protocol over real UDP. A transfer is + a conversation, and every bug worth catching lives in the turn-taking: the first run + found two, both of the "works by hand, never after a reboot" kind. - **Boot media belongs in `tests/media.rs`**, against the real binary with both listeners up. Every abuse case there ends by proving the server still answers, and one case proves the property the separate socket exists for: **answers keep succeeding while four image diff --git a/docs/guide/operations/media.fr.md b/docs/guide/operations/media.fr.md index f826bb4..61e5677 100644 --- a/docs/guide/operations/media.fr.md +++ b/docs/guide/operations/media.fr.md @@ -161,6 +161,76 @@ Cela laisse un répertoire contenant `vmlinuz`, `initrd.img` et une ISO allégé reconnue comme Proxmox, et le noyau et l'initrd posés à côté sont trouvés et servis. ::: +## Préparer une image Proxmox + +Proxmox est la seule famille à porter l'*emplacement* de la réponse à l'intérieur de +l'image, dans `/auto-installer-mode.toml`. Cela imposait jusqu'ici de lancer +`proxmox-auto-install-assistant prepare-iso` ailleurs d'abord. + +```console +$ rescriptum media prepare pve-8.4 +pve-8.4-http prepared from pve-8.4 + answer http://192.0.2.10:8000/proxmox + injects /auto-installer-mode.toml (198 bytes) + image 1610612736 bytes (source 1610610688 + 2048 appended) + wrote /srv/media/pve-8.4-http.media + +Nothing was copied. Serve it as /pve-8.4-http/iso, or write it to a stick with + rescriptum media export pve-8.4-http /tmp/pve-8.4-http.iso +``` + +**Ce qui vient d'être écrit est un fichier compagnon : environ deux cents octets qui +tiennent lieu de 1,5 Go.** La source n'est jamais modifiée, jamais copiée, et son +empreinte publiée reste vérifiable. Le fichier est injecté *au fil de l'eau*, donc +changer plus tard l'URL de réponse réécrit ces deux cents octets plutôt qu'un gigaoctet — +et les deux entrées apparaissent dans `media list`, adossées à une seule image sur disque. + +`--as NOM` choisit le nom de l'entrée dérivée, et `--url`, `--cert-fingerprint` et +`--token` disent ce qui va dans le fichier. + +### Pour une clé USB + +```console +$ rescriptum media export pve-8.4-http /tmp/pve-auto.iso +``` + +Matérialise exactement ce que le listener aurait servi, par le même chemin de code. Une +clé écrite autrement serait une seconde implémentation à maintenir honnête, et l'écart ne +se verrait que sur le bureau de quelqu'un. + +### Quand il refuse + +Refuser est ici une réponse **complète**, parce que le repli tient en une commande sur +n'importe quelle Debian et que ce serveur sert très bien son résultat : + +```console +$ proxmox-auto-install-assistant prepare-iso pve.iso --fetch-from http --url … +``` + +Il refuse quand l'image n'a **ni Rock Ridge ni Joliet** — le fichier ne pourrait alors +exister que sous un nom 8.3 tronqué comme `AUTO_INS.TOM;1`, et l'installeur ne le +trouverait jamais. Il refuse une image **UDF**, parce qu'une ISO Windows ne garde ses gros +fichiers que dans l'arbre UDF et que patcher l'arbre ISO9660 produirait quelque chose qui +a l'air juste et ne l'est pas. Et il refuse quand le répertoire racine n'a **pas de mou** +dans aucun de ses secteurs : déplacer l'extent entraînerait les tables de chemins, ce qui +n'est délibérément pas fait. + +Il refuse aussi de préparer une image non-Proxmox, en nommant l'alternative : toutes les +autres familles prennent l'URL sur la ligne de commande du noyau, là où `media ipxe` la +met déjà. + +### Si la source change en dessous + +Les décalages d'injection sont calculés contre une image donnée. Une source qui aurait +changé serait patchée au mauvais endroit, produisant une image qui se monte et qui est +fausse — le fichier compagnon retient donc la taille de la source, et le catalogue refuse +quand elle ne correspond plus : + +``` + problem: pve-8.4-http.media: pve-8.4 was 1610610688 bytes when this was prepared and + is 1610612736 now. The injection offsets no longer apply — re-run `media prepare`. +``` + ## Dire au serveur son propre nom Dès qu'il écrit des URL dans les scripts qu'il sert, le serveur a besoin d'un nom pour diff --git a/docs/guide/operations/media.md b/docs/guide/operations/media.md index 0f24c6c..c7a4dfa 100644 --- a/docs/guide/operations/media.md +++ b/docs/guide/operations/media.md @@ -157,6 +157,73 @@ That leaves a directory holding `vmlinuz`, `initrd.img` and a trimmed ISO. Point as Proxmox, and the kernel and initrd beside it are found and served. ::: +## Preparing a Proxmox image + +Proxmox is the only family that carries the answer's *location* inside the image, in +`/auto-installer-mode.toml`. That used to mean running +`proxmox-auto-install-assistant prepare-iso` somewhere else first. + +```console +$ rescriptum media prepare pve-8.4 +pve-8.4-http prepared from pve-8.4 + answer http://192.0.2.10:8000/proxmox + injects /auto-installer-mode.toml (198 bytes) + image 1610612736 bytes (source 1610610688 + 2048 appended) + wrote /srv/media/pve-8.4-http.media + +Nothing was copied. Serve it as /pve-8.4-http/iso, or write it to a stick with + rescriptum media export pve-8.4-http /tmp/pve-8.4-http.iso +``` + +**What that wrote is a sidecar: about two hundred bytes standing in for 1.5 GB.** The +source is never modified, never copied, and its published digest stays verifiable. The +file is injected *on the wire*, so changing the answer URL later rewrites those two +hundred bytes rather than a gigabyte — and both entries appear in `media list`, backed by +one image on disk. + +`--as NAME` picks the derived entry's name, and `--url`, `--cert-fingerprint` and +`--token` say what goes in the file. + +### For a USB stick + +```console +$ rescriptum media export pve-8.4-http /tmp/pve-auto.iso +``` + +Materialises exactly what the listener would have served, through the same code path. A +stick written any other way would be a second implementation to keep honest, and the +difference would only show up on somebody's desk. + +### When it refuses + +Refusing is a **complete** answer here, because the fallback is one command on any Debian +box and this server is perfectly happy to serve its output: + +```console +$ proxmox-auto-install-assistant prepare-iso pve.iso --fetch-from http --url … +``` + +It refuses when the image has **neither Rock Ridge nor Joliet** — the file could then +only exist under a mangled 8.3 name like `AUTO_INS.TOM;1`, and the installer would never +find it. It refuses a **UDF** image, because a Windows ISO keeps its large files only in +the UDF tree and patching the ISO9660 tree would produce something that looks right and +is not. And it refuses when the root directory has **no slack** in any of its sectors: +relocating the extent would drag in the path tables, which is deliberately not done. + +It also refuses to prepare a non-Proxmox image, and names the alternative: every other +family takes the URL on the kernel command line, where `media ipxe` already puts it. + +### If the source changes underneath + +The injection offsets are computed against one image. A source that changed would be +patched in the wrong place, producing an image that mounts and is wrong — so the sidecar +records the source's length and the catalogue refuses when it no longer matches: + +``` + problem: pve-8.4-http.media: pve-8.4 was 1610610688 bytes when this was prepared and + is 1610612736 now. The injection offsets no longer apply — re-run `media prepare`. +``` + ## Telling the server its own name The moment it writes URLs into scripts, the server needs a name for itself that a machine diff --git a/docs/guide/reference/cli.fr.md b/docs/guide/reference/cli.fr.md index bf8f229..72c99f1 100644 --- a/docs/guide/reference/cli.fr.md +++ b/docs/guide/reference/cli.fr.md @@ -136,6 +136,8 @@ $ rescriptum media list # ce qui est détenu : famille, archi $ rescriptum media add FILE [--sha256 D] # enregistrer une image déjà dans le répertoire $ rescriptum media check # revérifier chaque empreinte enregistrée $ rescriptum media ipxe ID # imprimer la réponse .ipxe qui démarre une image +$ rescriptum media prepare ID [--url URL] # une image Proxmox avec son URL de réponse dedans +$ rescriptum media export ID FICHIER # matérialiser une entrée préparée, pour une clé ``` `media add` prend un fichier **déjà dans le répertoire de médias** — rien n'est diff --git a/docs/guide/reference/cli.md b/docs/guide/reference/cli.md index 873d02b..e939145 100644 --- a/docs/guide/reference/cli.md +++ b/docs/guide/reference/cli.md @@ -134,6 +134,8 @@ $ rescriptum media list # what is held: family, architecture, $ rescriptum media add FILE [--sha256 D] # register one already in the directory $ rescriptum media check # re-verify every recorded digest $ rescriptum media ipxe ID # print the .ipxe answer that boots one image +$ rescriptum media prepare ID [--url URL] # a Proxmox image with its answer URL inside +$ rescriptum media export ID FILE # materialise a prepared entry, for a stick ``` `media add` takes a file **already inside the media directory** — nothing is downloaded diff --git a/src/boot/dhcp.rs b/src/boot/dhcp.rs index 1d35d25..c79378c 100644 --- a/src/boot/dhcp.rs +++ b/src/boot/dhcp.rs @@ -203,8 +203,16 @@ fn dnsmasq(handoff: &Handoff) -> String { } out.push('\n'); + // Two architectures can share a tag — 0x0007 and 0x0009 are both x64 — and dnsmasq + // would take the first `dhcp-boot` and ignore the rest. Emitting it twice is + // harmless and untidy, and an operator reads this file. + let mut emitted: Vec = Vec::new(); for (client, loader) in served() { let tag = tag(client.arch, client.transport); + if emitted.contains(&tag) { + continue; + } + emitted.push(tag.clone()); match client.transport { Transport::Tftp => { out.push_str(&format!("dhcp-boot=tag:{tag},{loader},,{host}\n")); @@ -561,6 +569,26 @@ mod tests { assert!(text.contains("option:client-arch,9"), "{text}"); } + #[test] + fn a_tag_gets_exactly_one_boot_line() { + // 0x0007 and 0x0009 are both x64 and share a tag; dnsmasq would take the first + // `dhcp-boot` and ignore the rest. Emitting it twice is harmless and untidy, and + // an operator reads this file. + let text = snippet(Format::Dnsmasq, &handoff()); + let mut seen: Vec<&str> = text + .lines() + .filter(|l| l.starts_with("dhcp-boot=tag:")) + .collect(); + let before = seen.len(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(seen.len(), before, "a duplicated boot line: {text}"); + // Both architectures still get their *match* line, which is what actually has + // to cover them. + assert!(text.contains("option:client-arch,7"), "{text}"); + assert!(text.contains("option:client-arch,9"), "{text}"); + } + #[test] fn one_loader_is_a_single_line_for_a_homogeneous_fleet() { let mut handoff = handoff(); From 6430eb6736a222d6bd679e86fc5a955f9e21d30f Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 14:41:23 +0200 Subject: [PATCH 13/59] feat(boot): the server fetches its own images, and the directory is the archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No installer image is in this repository or in a release, and now the server can go and get one: `media add --sha256 …` fetches straight into the media directory. There is still no TLS in the binary — forty crates and a megabyte on ARMv7 for a job every host already has a tool for — so this shells out to curl or wget and says plainly when it finds neither, on the same precedent as `check` calling the Proxmox validator. Three properties, each a way it would otherwise go wrong: - **A download lands on a `.part` name and is renamed only once the digest matches.** The catalogue probes whatever it finds, so a partial download would become an entry — a truncated ISO probes as unknown, and a machine would try to boot it. An interrupted fetch leaves the part file and resumes. - **A URL requires `--sha256` unless `--unverified` is passed.** This decides what every machine on the network installs; an image pulled off a mirror with nothing checking it is the one place here that would be a shrug, so the unsafe path is a deliberate flag rather than the default. A local file keeps the digest optional — the operator already had it. - **A fetch never overwrites an existing image.** Machines may be booting it. The consequence worth naming, and now said in the guide, in CLAUDE.md and in the plan: **the media directory is the archive.** Nothing modifies an image after it lands — preparing one produces a sidecar plus an injection applied on the wire — so the bytes on disk stay exactly what the vendor published and their digest stays checkable against the vendor's own SHA256SUMS. `media list` grew a SOURCE column so which entries are the archive and which derive from it is visible rather than inferred. Also adds a `.dockerignore`. The boot rig's build context was **6 GB** — `target/` alone was most of it — and every run spent two minutes transferring artefacts the image rebuilds from scratch anyway. A rig nobody waits for is a rig nobody runs. 536 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- .dockerignore | 17 + CLAUDE.md | 9 + docs/guide/operations/media.fr.md | 57 ++- docs/guide/operations/media.md | 54 ++- docs/guide/reference/cli.fr.md | 1 + docs/guide/reference/cli.md | 1 + packaging/boot-rig/generated/dnsmasq.conf | 1 - .../boot-rig/generated/dnsmasq.conf.snippet | 1 - src/boot/fetch.rs | 339 ++++++++++++++++++ src/boot/mod.rs | 1 + src/cli.rs | 130 +++++-- tests/media.rs | 167 +++++++++ 12 files changed, 747 insertions(+), 31 deletions(-) create mode 100644 .dockerignore create mode 100644 src/boot/fetch.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3489148 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# What a Docker build context must not carry. +# +# Without this the boot rig's build context was **6 GB** — `target/` alone is most of +# it — and every `docker compose up` spent two minutes transferring build artefacts the +# image then rebuilds from scratch anyway. A rig nobody waits for is a rig nobody runs. +target/ +_site/ +node_modules/ +.git/ +plans/ +packaging/dsm/vm/storage/ +packaging/boot-rig/results/ +packaging/boot-rig/.work/ +packaging/ipxe/.work/ +packaging/ipxe/out/ +**/*.iso +**/*.spk diff --git a/CLAUDE.md b/CLAUDE.md index 168fb57..ab4373d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,15 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit dependency-free. Behind the `boot` cargo feature, default on. `select.rs` knows none of this exists, and the only seam is that `media ipxe` **prints an ordinary `.ipxe` answer document** — selection, layering and templating then apply unchanged. +- **No installer image is in this repository or in a release.** An ISO is somebody + else's artefact, gigabytes, on its own schedule. `RESCRIPTUM_MEDIA_DIR` is where a + deployment keeps them and **that directory is the archive**: `media add ` fetches + one into it (through `curl`/`wget` — there is no TLS in the binary), and nothing ever + modifies it afterwards. Preparing an image produces a sidecar plus an injection applied + on the wire, so the bytes on disk stay what the vendor published and stay checkable + against the vendor's own `SHA256SUMS`. A URL **requires** `--sha256` unless + `--unverified` is passed: this decides what every machine installs, and the unsafe path + has to be a deliberate act. - `packaging/ipxe/` — the branded loaders: `branding.h`, the embedded script, a SHA-pinned upstream commit and `build.sh`. **No binaries in git, ever**; this directory is the GPLv2 written offer. `packaging/boot-rig/` — the boot rig, three services on an diff --git a/docs/guide/operations/media.fr.md b/docs/guide/operations/media.fr.md index 61e5677..6e44f63 100644 --- a/docs/guide/operations/media.fr.md +++ b/docs/guide/operations/media.fr.md @@ -23,10 +23,63 @@ $ export RESCRIPTUM_MEDIA_DIR=/srv/media Non défini, tout est éteint. Rien ne change pour un déploiement existant tant que vous ne la définissez pas. +## Où vivent les images de base + +**Aucune image d'installation n'est dans ce projet, ni dans une version publiée.** Une +ISO est l'artefact de quelqu'un d'autre, elle pèse un à quatre gigaoctets, et elle change +à son propre rythme — trois raisons distinctes pour qu'elle vive sur votre disque plutôt +que dans le nôtre. `RESCRIPTUM_MEDIA_DIR` est l'endroit où vous les gardez, et **ce +répertoire est l'archive** : ce que l'éditeur a publié, sur disque, jamais modifié +ensuite. + +Ce dernier point est une propriété, pas une promesse. Rien ici ne réécrit une image — +en préparer une produit un fichier compagnon et une injection appliquée *au fil de +l'eau* (voir [Préparer une image Proxmox](#préparer-une-image-proxmox)), de sorte que les +octets sur disque restent exactement ce que l'éditeur a publié et que leur empreinte +reste vérifiable contre le `SHA256SUMS` de l'éditeur. `media list` dit quelles entrées +sont l'archive et lesquelles en dérivent. + ## Faire entrer une image -Le serveur ne télécharge jamais d'image ; il la reçoit. Posez le fichier là où est le -répertoire — en SMB, en `scp`, depuis là où l'ISO se trouve déjà — puis enregistrez-le : +Deux façons, et la seule différence est qui fait le téléchargement. + +### Laisser le serveur la récupérer + +```console +$ rescriptum media add https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso \ + --sha256 9f86d081884c7d65… +fetching https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso + with curl, into /srv/media/proxmox-ve_8.4-1.iso.part +######################################################################## 100.0% +verifying 1.5G … +fetched 1.5G via curl, digest verified +``` + +Elle atterrit sous un nom en `.part` et n'est renommée qu'une fois l'empreinte +vérifiée : **un téléchargement partiel ne devient jamais une entrée du catalogue** — le +catalogue analyse ce qu'il trouve, et une ISO tronquée s'analyse comme une image inconnue +qu'une machine essaierait ensuite de démarrer. Une récupération interrompue laisse le +`.part` en place, et relancer la commande la reprend. + +`--sha256` est **obligatoire** ici, parce que rien d'autre ne vérifierait ce qui est +arrivé. Les éditeurs publient un `SHA256SUMS` à côté de l'image. Si vous voulez vraiment +vous en passer, dites `--unverified` — l'important est que sauter cette vérification soit +un acte délibéré et non le défaut, puisque cela décide ce que chaque machine du réseau +installe. + +`--as NOM.iso` choisit le nom de fichier quand l'URL n'en implique pas d'utilisable. + +::: tip Il n'y a pas de TLS dans ce binaire +rustls et un magasin de racines, c'est une quarantaine de crates et plus d'un mégaoctet +sur ARMv7, pour un travail dont chaque hôte a déjà l'outil. Donc ceci lance `curl`, ou +`wget` si c'est lui qui est installé, et le dit franchement s'il n'en trouve aucun — auquel +cas la réponse est celle ci-dessous. +::: + +### Ou la poser vous-même + +En SMB, en `scp`, depuis là où l'ISO se trouve déjà — le geste naturel sur un NAS — puis +l'enregistrer : ```console $ rescriptum media add /srv/media/pve-8.4.iso --sha256 9f86d081884c7d65… diff --git a/docs/guide/operations/media.md b/docs/guide/operations/media.md index c7a4dfa..b15f7a4 100644 --- a/docs/guide/operations/media.md +++ b/docs/guide/operations/media.md @@ -23,10 +23,60 @@ $ export RESCRIPTUM_MEDIA_DIR=/srv/media Unset is the whole off switch. Nothing changes for an existing deployment until you set it. +## Where the base images live + +**No installer image is in this project, and none is in a release.** An ISO is somebody +else's artefact, it is one to four gigabytes, and it changes on its own schedule — three +separate reasons it belongs on your disk rather than in ours. `RESCRIPTUM_MEDIA_DIR` is +where you keep them, and **that directory is the archive**: what a vendor published, on +disk, never modified afterwards. + +That last part is a property rather than a promise. Nothing here rewrites an image — +preparing one produces a sidecar and an injection applied *on the wire* (see +[Preparing a Proxmox image](#preparing-a-proxmox-image)), so the bytes on disk stay +exactly what the vendor published and their digest stays checkable against the vendor's +own `SHA256SUMS`. `media list` says which entries are the archive and which derive from +it. + ## Getting an image in -The server never downloads images; it receives them. Put the file where the directory -is — over SMB, over `scp`, from wherever the ISO already is — and then register it: +Two ways, and the only difference is who does the download. + +### Let the server fetch it + +```console +$ rescriptum media add https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso \ + --sha256 9f86d081884c7d65… +fetching https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso + with curl, into /srv/media/proxmox-ve_8.4-1.iso.part +######################################################################## 100.0% +verifying 1.5G … +fetched 1.5G via curl, digest verified +``` + +It lands on a `.part` name and is renamed only once the digest matches, so **a partial +download never becomes a catalogue entry** — the catalogue probes whatever it finds, and +a truncated ISO probes as an unknown image a machine would then try to boot. An +interrupted fetch leaves the `.part` in place and running the command again resumes it. + +`--sha256` is **required** here, because nothing else would check what arrived. Vendors +publish a `SHA256SUMS` beside the image. If you genuinely mean to go without, say +`--unverified` — the point is that skipping it is a deliberate act rather than the +default, since this decides what every machine on the network installs. + +`--as NAME.iso` picks the filename when the URL does not imply a usable one. + +::: tip There is no TLS in this binary +rustls plus a root store is forty-odd crates and over a megabyte on ARMv7, for a job +every host already has a tool for. So this runs `curl`, or `wget` if that is what is +installed, and says plainly when it finds neither — in which case the answer is the one +below. +::: + +### Or put it there yourself + +Over SMB, over `scp`, from wherever the ISO already is — the native act on a NAS — and +then register it: ```console $ rescriptum media add /srv/media/pve-8.4.iso --sha256 9f86d081884c7d65… diff --git a/docs/guide/reference/cli.fr.md b/docs/guide/reference/cli.fr.md index 72c99f1..79de02e 100644 --- a/docs/guide/reference/cli.fr.md +++ b/docs/guide/reference/cli.fr.md @@ -134,6 +134,7 @@ ces commandes exige `RESCRIPTUM_MEDIA_DIR` ; sans elle, elles le disent et sorte ```console $ rescriptum media list # ce qui est détenu : famille, architecture, version, empreinte $ rescriptum media add FILE [--sha256 D] # enregistrer une image déjà dans le répertoire +$ rescriptum media add URL --sha256 D # la récupérer dedans, puis l'enregistrer $ rescriptum media check # revérifier chaque empreinte enregistrée $ rescriptum media ipxe ID # imprimer la réponse .ipxe qui démarre une image $ rescriptum media prepare ID [--url URL] # une image Proxmox avec son URL de réponse dedans diff --git a/docs/guide/reference/cli.md b/docs/guide/reference/cli.md index e939145..614eb7d 100644 --- a/docs/guide/reference/cli.md +++ b/docs/guide/reference/cli.md @@ -132,6 +132,7 @@ Boot media: the installer images this server holds. Every one of these needs ```console $ rescriptum media list # what is held: family, architecture, version, digest $ rescriptum media add FILE [--sha256 D] # register one already in the directory +$ rescriptum media add URL --sha256 D # fetch one into it, then register it $ rescriptum media check # re-verify every recorded digest $ rescriptum media ipxe ID # print the .ipxe answer that boots one image $ rescriptum media prepare ID [--url URL] # a Proxmox image with its answer URL inside diff --git a/packaging/boot-rig/generated/dnsmasq.conf b/packaging/boot-rig/generated/dnsmasq.conf index 1349f45..74d5f1d 100644 --- a/packaging/boot-rig/generated/dnsmasq.conf +++ b/packaging/boot-rig/generated/dnsmasq.conf @@ -19,7 +19,6 @@ dhcp-vendorclass=set:httpefiarm64,HTTPClient:Arch:00019 dhcp-boot=tag:bios,ipxe-undionly.kpxe,,10.99.0.2 dhcp-boot=tag:efi64,ipxe-x86_64.efi,,10.99.0.2 -dhcp-boot=tag:efi64,ipxe-x86_64.efi,,10.99.0.2 dhcp-boot=tag:efiarm64,ipxe-arm64.efi,,10.99.0.2 dhcp-option-force=tag:httpefi64,60,HTTPClient dhcp-boot=tag:httpefi64,http://10.99.0.2:8001/boot/ipxe-x86_64.efi,,10.99.0.2 diff --git a/packaging/boot-rig/generated/dnsmasq.conf.snippet b/packaging/boot-rig/generated/dnsmasq.conf.snippet index e0f60d4..a7a030b 100644 --- a/packaging/boot-rig/generated/dnsmasq.conf.snippet +++ b/packaging/boot-rig/generated/dnsmasq.conf.snippet @@ -10,7 +10,6 @@ dhcp-vendorclass=set:httpefiarm64,HTTPClient:Arch:00019 dhcp-boot=tag:bios,ipxe-undionly.kpxe,,192.168.128.247 dhcp-boot=tag:efi64,ipxe-x86_64.efi,,192.168.128.247 -dhcp-boot=tag:efi64,ipxe-x86_64.efi,,192.168.128.247 dhcp-boot=tag:efiarm64,ipxe-arm64.efi,,192.168.128.247 dhcp-option-force=tag:httpefi64,60,HTTPClient dhcp-boot=tag:httpefi64,http://192.168.128.247:8001/boot/ipxe-x86_64.efi,,192.168.128.247 diff --git a/src/boot/fetch.rs b/src/boot/fetch.rs new file mode 100644 index 0000000..ae00859 --- /dev/null +++ b/src/boot/fetch.rs @@ -0,0 +1,339 @@ +//! Getting an installer image onto the server, over the network. +//! +//! **No base image is ever in this repository, and none is in a release.** An ISO is +//! somebody else's copyrighted artefact, it is one to four gigabytes, and it changes on +//! its own schedule — three separate reasons it belongs on the deployment's disk rather +//! than in ours. So the server fetches it, once, into the directory it serves from. +//! +//! ## Why this shells out +//! +//! **There is no TLS in this binary.** rustls plus a root store is forty-odd crates and +//! over a megabyte on armv7 — for a job every host already has a tool for. So this runs +//! `curl`, or `wget` if that is what is installed, and says plainly when it finds +//! neither. The precedent is `check` calling `proxmox-auto-install-assistant`: an +//! external tool used when it is there, never depended upon. Without one, the answer is +//! the one that always worked — download it yourself and drop it in the directory. +//! +//! ## What "the base ISO is the archive" means +//! +//! The file this writes is **never modified afterwards**. Preparing an image produces a +//! sidecar and an injection applied on the wire, so the bytes on disk stay exactly what +//! the vendor published and their digest stays verifiable against the vendor's own +//! checksum file. The media directory is the archive; everything derived from it is a +//! few hundred bytes. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// How the image got here, for a caller to report. +#[derive(Debug)] +pub struct Fetched { + pub path: PathBuf, + pub bytes: u64, + /// The tool that did it, so a log line says what to install if it is missing. + pub via: &'static str, +} + +/// Fetch `url` into `dir`, atomically, verifying it against `expected` before it counts. +/// +/// The download lands on a `.part` name and is renamed only once the digest matches. +/// **A partial download must never become a catalogue entry**: the catalogue probes +/// whatever it finds, and a truncated ISO probes as an unknown image that a machine +/// would then try to boot. +pub fn fetch( + url: &str, + dir: &Path, + name: Option<&str>, + expected: Option<&str>, +) -> Result { + let name = match name { + Some(name) => name.to_string(), + None => name_from(url)?, + }; + let target = dir.join(&name); + if target.exists() { + return Err(format!( + "{} already exists. Machines may be booting it right now, so this will not \ + overwrite it — remove it first, or use --as to fetch under another name.", + target.display() + )); + } + let partial = dir.join(format!("{name}.part")); + + let (program, args) = downloader(&partial, url).ok_or_else(|| { + format!( + "neither curl nor wget is installed, and there is no TLS in this binary — \ + 40-odd crates and a megabyte on armv7 for a job the host already does. \ + Install one, or download {url} yourself and drop it in {}.", + dir.display() + ) + })?; + + eprintln!("fetching {url}"); + eprintln!(" with {program}, into {}", partial.display()); + let status = Command::new(program) + .args(&args) + .status() + .map_err(|e| format!("cannot run {program}: {e}"))?; + if !status.success() { + // Leave the partial file: a 1.5 GB download that failed at 90% is worth + // resuming, and both tools resume onto it. + return Err(format!( + "{program} exited {}. {} is left in place, and running this again resumes it.", + status.code().unwrap_or(-1), + partial.display() + )); + } + + let bytes = std::fs::metadata(&partial) + .map_err(|e| format!("{}: {e}", partial.display()))? + .len(); + if bytes == 0 { + let _ = std::fs::remove_file(&partial); + return Err(format!("{url} produced an empty file")); + } + + if let Some(expected) = expected { + eprintln!("verifying {} …", human(bytes)); + let digest = crate::boot::sha256::file(&partial, |_, _| {}) + .map_err(|e| format!("{}: {e}", partial.display()))?; + if !digest.eq_ignore_ascii_case(expected) { + // **Loud, fatal, and the file is destroyed.** A mismatch is a corrupted + // transfer, the wrong file, or a mirror that is not what it claims — and + // leaving it on disk means somebody registers it tomorrow by hand. + let _ = std::fs::remove_file(&partial); + return Err(format!( + "digest mismatch — {} was deleted\n expected {expected}\n found {digest}", + partial.display() + )); + } + } + + // Only now does it become a file the catalogue can see. Rename within one directory + // is atomic on POSIX, which is the same guarantee the file store already relies on. + std::fs::rename(&partial, &target).map_err(|e| { + format!( + "cannot move {} to {}: {e}", + partial.display(), + target.display() + ) + })?; + + Ok(Fetched { + path: target, + bytes, + via: program, + }) +} + +/// The tool to use, and how to ask it. Both are told to resume, to follow redirects and +/// to fail on an HTTP error rather than writing the error page to disk under a name +/// ending in `.iso`. +fn downloader(into: &Path, url: &str) -> Option<(&'static str, Vec)> { + let into = into.to_string_lossy().into_owned(); + if on_path("curl") { + return Some(( + "curl", + vec![ + // Without --fail a 404 is written out as a file, and the next command + // registers an HTML page as an installer image. + "--fail".into(), + // Mirrors redirect constantly. + "--location".into(), + // Resume onto a partial file, which is what makes a failed 1.5 GB + // download cost the remainder rather than the whole thing. + "--continue-at".into(), + "-".into(), + "--progress-bar".into(), + "--output".into(), + into, + url.into(), + ], + )); + } + if on_path("wget") { + return Some(( + "wget", + vec![ + "--continue".into(), + "--progress=bar:force".into(), + "--output-document".into(), + into, + url.into(), + ], + )); + } + None +} + +fn on_path(program: &str) -> bool { + let Ok(path) = std::env::var("PATH") else { + return false; + }; + std::env::split_paths(&path).any(|dir| dir.join(program).is_file()) +} + +/// The filename a URL implies, which is what the entry will be called. +/// +/// Refused rather than guessed at when the URL does not carry one: an image named after +/// a query string is an identifier nobody can type, and it becomes part of a URL that a +/// machine has to fetch. +pub fn name_from(url: &str) -> Result { + let without_query = url.split(['?', '#']).next().unwrap_or(url); + let last = without_query + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or(""); + let decoded = percent_decode(last); + if decoded.is_empty() || !decoded.contains('.') { + return Err(format!( + "cannot tell what to call the image from {url} — pass --as NAME.iso" + )); + } + let stem = decoded.rsplit_once('.').map(|(s, _)| s).unwrap_or(&decoded); + if !crate::store::valid_id(stem) { + return Err(format!( + "{url} implies the name {decoded:?}, whose stem is not a usable identifier — \ + it becomes part of a URL a machine has to fetch. Pass --as NAME.iso." + )); + } + Ok(decoded) +} + +/// Just enough to turn `%2B` back into `+`. A vendor's download path occasionally +/// carries one, and a file named `proxmox%2Dve.iso` is nobody's idea of an identifier. +fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix(&text[i + 1..i + 3], 16) { + out.push(byte as char); + i += 3; + continue; + } + } + out.push(bytes[i] as char); + i += 1; + } + out +} + +fn human(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit + 1 < UNITS.len() { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes}B") + } else { + format!("{size:.1}{}", UNITS[unit]) + } +} + +/// Whether an argument is a URL rather than a path, which is what decides whether +/// `media add` fetches or registers. +pub fn looks_like_a_url(argument: &str) -> bool { + argument.starts_with("http://") || argument.starts_with("https://") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_url_is_told_from_a_path() { + assert!(looks_like_a_url( + "https://enterprise.proxmox.com/iso/pve.iso" + )); + assert!(looks_like_a_url("http://mirror/pve.iso")); + assert!(!looks_like_a_url("/srv/media/pve.iso")); + assert!(!looks_like_a_url("pve.iso")); + // Deliberately not FTP or file: neither is a path this fetches, and treating one + // as a URL would hand it to curl and produce a confusing failure. + assert!(!looks_like_a_url("ftp://mirror/pve.iso")); + } + + #[test] + fn the_name_comes_from_the_last_path_segment() { + assert_eq!( + name_from("https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso").expect("named"), + "proxmox-ve_8.4-1.iso" + ); + // A query string is not part of the name; plenty of mirrors add one. + assert_eq!( + name_from("https://mirror/ubuntu-24.04.iso?mirror=de&x=1").expect("named"), + "ubuntu-24.04.iso" + ); + assert_eq!( + name_from("https://mirror/rocky.iso#sha256").expect("named"), + "rocky.iso" + ); + } + + #[test] + fn a_url_that_implies_no_usable_name_is_refused_rather_than_guessed_at() { + // The name becomes part of a URL a machine has to fetch, so an unusable one is + // worth stopping for rather than mangling into something nobody can type. + for url in [ + "https://mirror/download?id=42", + "https://mirror/", + "https://mirror/iso/", + ] { + assert!(name_from(url).is_err(), "{url} should be refused"); + } + let e = name_from("https://mirror/download?id=42").expect_err("refused"); + assert!(e.contains("--as"), "the way out has to be named: {e}"); + } + + #[test] + fn a_percent_escape_in_the_path_is_decoded() { + assert_eq!(percent_decode("proxmox%2Dve.iso"), "proxmox-ve.iso"); + assert_eq!(percent_decode("plain.iso"), "plain.iso"); + // A stray `%` is left alone rather than eating the next two characters. + assert_eq!(percent_decode("100%.iso"), "100%.iso"); + } + + #[test] + fn an_existing_image_is_never_overwritten() { + // Machines may be booting it right now, and a half-written image is one they + // would boot into something that does not work. + let dir = std::env::temp_dir().join(format!("rescriptum-fetch-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + std::fs::write(dir.join("pve.iso"), b"an image somebody is using").expect("write"); + + let e = fetch("https://mirror/pve.iso", &dir, None, None).expect_err("must refuse"); + assert!(e.contains("already exists"), "{e}"); + assert!(e.contains("--as"), "{e}"); + // And it is untouched. + assert_eq!( + std::fs::read(dir.join("pve.iso")).expect("read"), + b"an image somebody is using" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_downloader_refuses_to_write_an_error_page_to_disk() { + // Without `--fail` a 404 is written out, and the next command registers an HTML + // page as an installer image — which probes as unknown and boots as nothing. + let Some((program, args)) = downloader(Path::new("/tmp/x.part"), "https://m/x.iso") else { + // Neither tool installed: nothing to assert, and `fetch` says so itself. + return; + }; + match program { + "curl" => { + assert!(args.iter().any(|a| a == "--fail"), "{args:?}"); + assert!(args.iter().any(|a| a == "--location"), "{args:?}"); + assert!(args.iter().any(|a| a == "--continue-at"), "{args:?}"); + } + "wget" => assert!(args.iter().any(|a| a == "--continue"), "{args:?}"), + other => panic!("unexpected downloader {other}"), + } + } +} diff --git a/src/boot/mod.rs b/src/boot/mod.rs index 1b95871..9f4045b 100644 --- a/src/boot/mod.rs +++ b/src/boot/mod.rs @@ -13,6 +13,7 @@ pub mod catalog; pub mod cpio; pub mod dhcp; +pub mod fetch; pub mod iso; pub mod loaders; pub mod media; diff --git a/src/cli.rs b/src/cli.rs index d23f69a..e3f28c0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -29,7 +29,8 @@ USAGE: rescriptum config set K=V edit the file RESCRIPTUM_ENV_FILE names rescriptum config unset K comment a setting back out of it rescriptum media list the installer images this server holds - rescriptum media add FILE register one: verify, probe, record its digest + rescriptum media add FILE register one already in the media directory + rescriptum media add URL fetch one into it, then register it rescriptum media check re-verify every recorded digest, report what drifted rescriptum media ipxe ID print the .ipxe answer that boots one image rescriptum boot dhcp-snippet their DHCP server's two lines, generated @@ -469,7 +470,7 @@ pub fn media(cfg: &Config, args: &[String]) -> ExitCode { _ => { eprintln!( "usage: rescriptum media list\n\ - \x20 rescriptum media add FILE [--sha256 DIGEST]\n\ + \x20 rescriptum media add FILE|URL [--sha256 D] [--as NAME]\n\ \x20 rescriptum media check\n\ \x20 rescriptum media ipxe ID\n\ \x20 rescriptum media prepare ID [--as NAME] [--url URL]\n\ @@ -490,22 +491,30 @@ fn media_list(catalog: &crate::boot::catalog::Catalog) -> ExitCode { } }; + // **The last column is what makes the archive visible.** A base image is what the + // vendor published, on disk, never modified; a prepared one is a few hundred bytes + // of sidecar over it. Seeing which is which is the difference between a directory + // and an archive somebody can reason about. println!( - "{:<20} {:<8} {:<10} {:<28} {:>8} PINNED", + "{:<20} {:<8} {:<10} {:<24} {:>8} SOURCE", "ID", "FAMILY", "ARCH", "VERSION", "SIZE" ); for entry in &listing.entries { + let source = match &entry.prepared { + Some(prepared) => format!("{} -> {}", prepared.source_id, prepared.url), + None => match &entry.digest { + Some(digest) => format!("base, pinned {}", &digest[..12.min(digest.len())]), + None => "base".to_string(), + }, + }; println!( - "{:<20} {:<8} {:<10} {:<28} {:>8} {}", + "{:<20} {:<8} {:<10} {:<24} {:>8} {}", entry.id, entry.family().label(), - entry.arch().map(|a| a.label()).unwrap_or("—"), - truncate(&entry.describe(), 28), + entry.arch().map(|a| a.label()).unwrap_or("-"), + truncate(&entry.describe(), 24), human(entry.size), - match &entry.digest { - Some(digest) => digest[..12.min(digest.len())].to_string(), - None => "—".to_string(), - }, + source, ); } if listing.entries.is_empty() { @@ -517,29 +526,58 @@ fn media_list(catalog: &crate::boot::catalog::Catalog) -> ExitCode { ExitCode::SUCCESS } +/// `media add FILE` or `media add URL` — register an image, or fetch one and register it. +/// +/// **No base image is ever in this repository or in a release.** An ISO is somebody +/// else's artefact, it is gigabytes, and it changes on its own schedule; it belongs on +/// the deployment's disk. Two ways to get it there, and the difference is only who does +/// the download: +/// +/// - **Drop it in the media directory** — over SMB, over `scp`, from wherever it already +/// is — and register it. The native act on a NAS. +/// - **Give this a URL** and the server fetches it, through `curl` or `wget`, straight +/// into that directory. +/// +/// Either way the file lands in the directory and **is never modified afterwards**. That +/// is what makes the media directory the archive: preparing an image produces a sidecar +/// and an injection applied on the wire, so the bytes on disk stay exactly what the +/// vendor published and their digest stays checkable against the vendor's own checksums. #[cfg(feature = "boot")] fn media_add(catalog: &crate::boot::catalog::Catalog, args: &[String]) -> ExitCode { - let mut path: Option<&String> = None; + let mut source: Option<&String> = None; let mut expected: Option<&String> = None; + let mut name: Option = None; + let mut unverified = false; let mut rest = args.iter(); while let Some(arg) = rest.next() { - if arg == "--sha256" { - match rest.next() { + match arg.as_str() { + "--sha256" => match rest.next() { Some(digest) => expected = Some(digest), None => { eprintln!("--sha256 wants a digest"); return ExitCode::FAILURE; } + }, + "--as" => match rest.next() { + Some(value) => name = Some(value.clone()), + None => { + eprintln!("--as wants a filename"); + return ExitCode::FAILURE; + } + }, + "--unverified" => unverified = true, + _ if source.is_none() => source = Some(arg), + other => { + eprintln!("unexpected argument {other:?}"); + return ExitCode::FAILURE; } - } else if path.is_none() { - path = Some(arg); - } else { - eprintln!("unexpected argument {arg:?}"); - return ExitCode::FAILURE; } } - let Some(path) = path.map(std::path::PathBuf::from) else { - eprintln!("usage: rescriptum media add FILE [--sha256 DIGEST]"); + let Some(source) = source else { + eprintln!( + "usage: rescriptum media add FILE [--sha256 DIGEST]\n\ + \x20 rescriptum media add URL --sha256 DIGEST [--as NAME.iso]" + ); return ExitCode::FAILURE; }; @@ -549,22 +587,64 @@ fn media_add(catalog: &crate::boot::catalog::Catalog, args: &[String]) -> ExitCo eprintln!("{digest:?} is not a SHA-256 — it is 64 hexadecimal characters"); return ExitCode::FAILURE; } + + let path = if crate::boot::fetch::looks_like_a_url(source) { + // **A digest is required for a URL**, and `--unverified` is what makes going + // without one a deliberate act rather than the default. This decides what every + // machine on the network installs; an image pulled off a mirror with nothing + // checking it is the one place in this design where that would be a shrug. + if expected.is_none() && !unverified { + eprintln!( + "fetching {source} needs --sha256, because nothing else would check what \ + arrived. Vendors publish a SHA256SUMS beside the image.\n\ + If you genuinely mean to skip it, say --unverified." + ); + return ExitCode::FAILURE; + } + match crate::boot::fetch::fetch( + source, + catalog.dir(), + name.as_deref(), + expected.map(String::as_str), + ) { + Ok(fetched) => { + eprintln!( + "fetched {} via {}{}", + human(fetched.bytes), + fetched.via, + if expected.is_some() { + ", digest verified" + } else { + " — UNVERIFIED" + } + ); + fetched.path + } + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + } + } else { + std::path::PathBuf::from(source) + }; + if !path.is_file() { eprintln!("{} is not a file", path.display()); return ExitCode::FAILURE; } - // **The server never downloads images; it receives them.** Dropping the file into - // the directory is the native act — over SMB, over scp, from wherever the ISO - // already is — and this only registers what is already there. Registering something - // outside the directory would record a digest for a file the listener cannot serve. + // A file already on disk is registered where it lies: nothing is copied, so + // registering one outside the directory would record a digest for a file the + // listener cannot serve. let inside = path .parent() .map(|p| same_directory(p, catalog.dir())) .unwrap_or(false); if !inside { eprintln!( - "{} is not in {} — put the image there first, then register it.\n\ + "{} is not in {} — put the image there first, then register it, or give a \ + URL and let the server fetch it.\n\ Nothing is copied: the catalogue serves the file where it lies.", path.display(), catalog.dir().display() diff --git a/tests/media.rs b/tests/media.rs index 174e314..151e0c9 100644 --- a/tests/media.rs +++ b/tests/media.rs @@ -1116,3 +1116,170 @@ fn preparing_a_family_that_reads_no_mode_file_is_refused_with_the_alternative() assert!(printed.contains("only Proxmox"), "{printed}"); assert!(printed.contains("media ipxe ubuntu"), "{printed}"); } + +// ---- getting an image in --------------------------------------------------- + +#[test] +fn media_add_fetches_a_url_into_the_directory() { + // **No base image is in this repository or in a release**, so the server has to be + // able to go and get one. There is no TLS in the binary — forty crates and a + // megabyte on armv7 for a job the host already does — so this shells out, and the + // test serves the image over a plain local socket to prove the whole path. + let s = Server::start(&[]); + let image = pve_image(); + let (addr, done) = serve_one_file(image.clone()); + + let digest = rescriptum::boot::sha256::hex(&image); + let out = s.run(&[ + "media", + "add", + &format!("http://{addr}/proxmox-ve_8.4-1.iso"), + "--sha256", + &digest, + ]); + let _ = done.join(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + + // The image is in the directory, byte for byte, under the name the URL implied. + let landed = s.media_dir().join("proxmox-ve_8.4-1.iso"); + assert!( + landed.is_file(), + "{:?}", + fs::read_dir(s.media_dir()).unwrap().count() + ); + assert_eq!(fs::read(&landed).expect("read"), image); + // And no `.part` survives: a partial download must never become a catalogue entry. + assert!(!s.media_dir().join("proxmox-ve_8.4-1.iso.part").exists()); + + // It is registered, so the catalogue can see it. + assert!(s.media_dir().join("proxmox-ve_8.4-1.media").is_file()); +} + +#[test] +fn a_fetched_image_that_does_not_match_its_digest_is_deleted() { + // A mismatch is a corrupted transfer, the wrong file, or a mirror that is not what + // it claims. Leaving it on disk means somebody registers it by hand tomorrow. + let s = Server::start(&[]); + let (addr, done) = serve_one_file(pve_image()); + + let out = s.run(&[ + "media", + "add", + &format!("http://{addr}/pve.iso"), + "--sha256", + &"a".repeat(64), + ]); + let _ = done.join(); + assert!(!out.status.success()); + let printed = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(printed.contains("digest mismatch"), "{printed}"); + assert!(printed.contains("was deleted"), "{printed}"); + assert!( + !s.media_dir().join("pve.iso").exists(), + "nothing may be left behind" + ); + assert!(!s.media_dir().join("pve.iso.part").exists()); +} + +#[test] +fn fetching_without_a_digest_has_to_be_asked_for() { + // This decides what every machine on the network installs. An image pulled off a + // mirror with nothing checking it is the one place that would be a shrug, so the + // unsafe path is a deliberate flag rather than the default. + let s = Server::start(&[]); + let out = s.run(&["media", "add", "http://192.0.2.1/pve.iso"]); + assert!(!out.status.success()); + let printed = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(printed.contains("--sha256"), "{printed}"); + assert!( + printed.contains("--unverified"), + "the way out has to be named: {printed}" + ); + assert!( + printed.contains("SHA256SUMS"), + "and where to find one: {printed}" + ); +} + +#[test] +fn a_fetch_never_overwrites_an_image_machines_may_be_booting() { + let s = Server::start(&[("pve.iso", pve_image())]); + let out = s.run(&[ + "media", + "add", + "http://192.0.2.1/pve.iso", + "--sha256", + &"a".repeat(64), + ]); + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("already exists"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn the_listing_says_which_entries_are_the_archive_and_which_derive_from_it() { + // **The base image is the archive**: what the vendor published, on disk, never + // modified. A prepared entry is a few hundred bytes over it. Seeing which is which + // is the difference between a directory and an archive somebody can reason about. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + assert!(s.run(&["media", "prepare", "pve-8.4"]).status.success()); + std::thread::sleep(Duration::from_millis(1200)); + + let out = s.run(&["media", "list"]); + assert!(out.status.success()); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(printed.contains("SOURCE"), "{printed}"); + + let base = printed + .lines() + .find(|l| l.starts_with("pve-8.4 ")) + .unwrap_or_else(|| panic!("{printed}")); + assert!(base.contains("base"), "the source image says so: {base}"); + + let derived = printed + .lines() + .find(|l| l.starts_with("pve-8.4-http")) + .unwrap_or_else(|| panic!("{printed}")); + assert!( + derived.contains("pve-8.4 ->"), + "and the derived one names it: {derived}" + ); +} + +/// A one-shot HTTP server that hands over `body` and exits. Enough to prove the fetch +/// path end to end without reaching the internet from a test. +fn serve_one_file(body: Vec) -> (String, std::thread::JoinHandle<()>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr").to_string(); + let handle = std::thread::spawn(move || { + let Ok((mut sock, _)) = listener.accept() else { + return; + }; + // Read the request line and headers, then answer. `curl --continue-at -` sends + // a Range header for a zero-length target, which a 200 satisfies. + let mut request = Vec::new(); + let mut byte = [0u8; 1]; + while let Ok(1) = sock.read(&mut byte) { + request.push(byte[0]); + if request.ends_with(b"\r\n\r\n") { + break; + } + } + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = sock.write_all(head.as_bytes()); + let _ = sock.write_all(&body); + let _ = sock.flush(); + }); + (addr, handle) +} From 268b27d234ebfe4c8b26d377500bb4159842a887 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 14:45:14 +0200 Subject: [PATCH 14/59] fix(boot-rig): build the client image, and install the tool it needs Two failures from actually running the rig, and both were invisible until it ran: - `iproute2` was missing from the client image, so `ip` was not there to bridge eth0 and QEMU would have booted with no network at all. Five `ip: command not found` lines scrolled past and the script carried on, because every `ip` call tolerated its own failure and the guard at the bottom of the function never noticed. It now checks for `ip` up front and proves `tap0` exists rather than trusting that nothing printed an error. - `docker compose run` reuses whatever image is already there, so the client image was never rebuilt and the fix above silently did not apply. `run.sh` now builds it explicitly, and the client leaves the `manual` profile so `build` can see it. Everything before QEMU already worked on the first real run: the DHCP configuration generated from the server's own snippet, the stack up on an isolated network, the branded loaders built inside the container, and `boot check` agreeing from inside the server that the set is complete. Also handles the one download failure that repeating will not fix: curl exits 33 when a mirror ignores the Range header, so resuming onto a partial file fails identically forever. The message now says to delete it rather than "run this again". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- packaging/boot-rig/Dockerfile.client | 5 ++++- packaging/boot-rig/boot-client.sh | 13 ++++++++++++- packaging/boot-rig/docker-compose.yml | 6 +++++- packaging/boot-rig/run.sh | 10 ++++++++-- src/boot/fetch.rs | 21 +++++++++++++++++---- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/packaging/boot-rig/Dockerfile.client b/packaging/boot-rig/Dockerfile.client index bff83c2..f33d307 100644 --- a/packaging/boot-rig/Dockerfile.client +++ b/packaging/boot-rig/Dockerfile.client @@ -4,8 +4,11 @@ # this fast, not possible. Ten times slower is a long boot, not a wall. FROM debian:bookworm-slim +# `iproute2` is not optional here and its absence is not obvious: without `ip` the +# script cannot bridge eth0, and QEMU would silently fall back to nothing rather than +# to a working network. The first run of this rig died on exactly that. RUN apt-get update && apt-get install -y --no-install-recommends \ - qemu-system-x86 nasm ovmf \ + qemu-system-x86 nasm ovmf iproute2 \ && rm -rf /var/lib/apt/lists/* WORKDIR /rig diff --git a/packaging/boot-rig/boot-client.sh b/packaging/boot-rig/boot-client.sh index f5b762d..d090c34 100755 --- a/packaging/boot-rig/boot-client.sh +++ b/packaging/boot-rig/boot-client.sh @@ -29,6 +29,10 @@ mkdir -p /out # Put eth0 on a bridge and hang a tap off it, so the guest is a peer of the other # containers rather than a NAT client of this one. setup_bridge() { + # Checked first, because every `ip` below is tolerant of its own failure and the + # guard at the bottom of this function would not have noticed. The first run of this + # rig printed five `ip: command not found` lines and carried on into QEMU. + command -v ip >/dev/null 2>&1 || return 1 ip link add br0 type bridge 2>/dev/null || true ip link set br0 up ip addr flush dev eth0 || true @@ -41,7 +45,14 @@ setup_bridge() { } if ! setup_bridge; then - echo "cannot bridge eth0 — the client container needs cap_add: NET_ADMIN" | tee "${OUT}" + echo "cannot bridge eth0: either \`ip\` is missing from this image or the container \ +lacks cap_add: NET_ADMIN. Without a bridge the guest would see no DHCP server, which \ +looks like a broken boot chain rather than a broken rig." | tee "${OUT}" + exit 1 +fi +# And prove it worked, rather than trusting that nothing printed an error. +if ! ip link show tap0 >/dev/null 2>&1; then + echo "tap0 was not created — the guest would have no network" | tee "${OUT}" exit 1 fi diff --git a/packaging/boot-rig/docker-compose.yml b/packaging/boot-rig/docker-compose.yml index cfc5c93..85645eb 100644 --- a/packaging/boot-rig/docker-compose.yml +++ b/packaging/boot-rig/docker-compose.yml @@ -107,4 +107,8 @@ services: volumes: - results:/out networks: [rig] - profiles: [manual] + # No profile: `up` below names the three services it wants, so this one does not + # start on its own — and leaving it out of a profile is what lets `build` see it. + # A `docker compose run` reuses whatever image exists, so a client image that is + # never built is a client image that never changes; the first two runs of this rig + # died on a fix that had been made and not compiled. diff --git a/packaging/boot-rig/run.sh b/packaging/boot-rig/run.sh index 25f3745..834d667 100755 --- a/packaging/boot-rig/run.sh +++ b/packaging/boot-rig/run.sh @@ -64,8 +64,14 @@ cargo run --quiet --manifest-path ../../Cargo.toml -- boot dhcp-snippet --format generated/dnsmasq.conf.snippet } > generated/dnsmasq.conf -echo "==> bringing the stack up (no KVM: the rig must pass under TCG)" -"${COMPOSE[@]}" up -d --build loaders server dhcp +echo "==> building the images (no KVM anywhere: the rig must pass under TCG)" +# **The client is built explicitly.** `docker compose run` reuses whatever image is +# already there, so a client image that is never built is one that never changes — and +# a fix made to its Dockerfile would silently not apply. +"${COMPOSE[@]}" build loaders server dhcp client + +echo "==> bringing the stack up" +"${COMPOSE[@]}" up -d loaders server dhcp # The loaders service exits when it has copied; the others have to be listening. for _ in $(seq 1 60); do diff --git a/src/boot/fetch.rs b/src/boot/fetch.rs index ae00859..d858cca 100644 --- a/src/boot/fetch.rs +++ b/src/boot/fetch.rs @@ -76,11 +76,24 @@ pub fn fetch( .status() .map_err(|e| format!("cannot run {program}: {e}"))?; if !status.success() { - // Leave the partial file: a 1.5 GB download that failed at 90% is worth - // resuming, and both tools resume onto it. + let code = status.code().unwrap_or(-1); + // **curl exits 33 when the server ignored the Range header**, which is what a + // mirror without byte-range support does. Retrying would fail identically + // forever, so the answer is to say the one thing that fixes it rather than + // repeating "run it again". + if code == 33 { + return Err(format!( + "{program} exited 33: this server does not support resuming, and {} is a \ + partial download from an earlier attempt. Delete it and run this again \ + to start from the beginning.", + partial.display() + )); + } + // Otherwise leave the partial file: a 1.5 GB download that failed at 90% is + // worth resuming, and both tools resume onto it. return Err(format!( - "{program} exited {}. {} is left in place, and running this again resumes it.", - status.code().unwrap_or(-1), + "{program} exited {code}. {} is left in place, and running this again resumes \ + it — delete it to start over.", partial.display() )); } From e7214cfa91382046143f25d405bb91c2796cdf18 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 14:48:12 +0200 Subject: [PATCH 15/59] fix(boot-rig): bake dnsmasq in, and notice when a service has died MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rig's own rule, broken by the rig: the network is `internal: true`, so nothing on it can reach an apt repository. dnsmasq was installed at run time, apt failed into `/dev/null`, and the container exited 127 with `exec: dnsmasq: not found` — a minute before a client was booted at it. It now has an image, like the loaders, for the reason the README already gave. The deeper failure is that nothing noticed. **A container that died looks exactly like one still starting**, so `run.sh` now checks every service is running before it boots anything and prints the log of any that is not. Without that the symptom was four minutes of QEMU and two missing markers, which reads as a broken boot chain rather than a broken harness. `media add ` also names a missing media directory itself rather than leaving curl to say "Failed to open the file", which points at a path instead of at the setting that produced it. The rig README gains a table of what the first four runs cost, because each is a shape that will recur. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- packaging/boot-rig/Dockerfile.dhcp | 19 +++++++++++++++++++ packaging/boot-rig/README.md | 20 ++++++++++++++++++++ packaging/boot-rig/docker-compose.yml | 12 +++--------- packaging/boot-rig/run.sh | 12 ++++++++++++ src/boot/fetch.rs | 10 ++++++++++ 5 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 packaging/boot-rig/Dockerfile.dhcp diff --git a/packaging/boot-rig/Dockerfile.dhcp b/packaging/boot-rig/Dockerfile.dhcp new file mode 100644 index 0000000..94665ce --- /dev/null +++ b/packaging/boot-rig/Dockerfile.dhcp @@ -0,0 +1,19 @@ +# dnsmasq, installed at **image-build** time. +# +# The rig's network is `internal: true` — a harness that runs a DHCP server has to be +# unable to answer anything on the host's LAN — which also means nothing on it can reach +# an apt repository. Installing at run time looked fine and exited 127 with +# `exec: dnsmasq: not found`, after apt had failed silently into /dev/null. +# +# Same lesson as the loaders, and the rig's own README already said it: if a service on +# this network needs something from the internet, it gets it before the network exists. +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq \ + && rm -rf /var/lib/apt/lists/* \ + && dnsmasq --version | head -1 + +# The configuration under test is mounted, printed, and then handed to dnsmasq. Printing +# it is not decoration: when no client boots, the first question is what the DHCP server +# was actually told, and this is the answer. +CMD ["sh", "-c", "echo '--- the configuration under test ---'; cat /etc/rig/dnsmasq.conf; exec dnsmasq --keep-in-foreground --log-dhcp --conf-file=/etc/rig/dnsmasq.conf"] diff --git a/packaging/boot-rig/README.md b/packaging/boot-rig/README.md index a5f304b..b704b86 100644 --- a/packaging/boot-rig/README.md +++ b/packaging/boot-rig/README.md @@ -66,6 +66,26 @@ internet, which is why the loaders are built into their image rather than at run **No `/dev/kvm` anywhere.** KVM would make this fast; the rig has to pass without it, because the development machine is a Mac. Ten times slower is a long run, not a wall. +## What running it for the first time cost + +Four attempts, and every failure was invisible until the thing actually ran. They are +listed because each one is a shape that will recur, not because the fixes are +interesting: + +| What failed | Why it was invisible | +|---|---| +| `iproute2` missing from the client image | every `ip` call tolerated its own failure, and the guard below them never fired. Five `command not found` lines scrolled past and QEMU booted with no network | +| The client image was never rebuilt | `docker compose run` reuses whatever image exists, so a Dockerfile fix silently did not apply | +| dnsmasq installed at *run* time | the network is `internal: true`, so apt could not reach anything. The install failed into `/dev/null` and the container exited 127 a minute before a client was booted at it | +| A 6 GB build context | no `.dockerignore`, so every run spent two minutes transferring `target/` | + +The pattern in three of the four: **a service that died looks exactly like one still +starting.** `run.sh` now checks every container is still running before it boots a +client, and prints the log of any that is not. + +The rule the third one broke is the rig's own, stated two paragraphs above it: if +something on this network needs the internet, it gets it before the network exists. + ## Two host facts worth knowing before the first run - **The loader image is pinned to `linux/amd64`.** iPXE's BIOS targets are 32-bit x86 and diff --git a/packaging/boot-rig/docker-compose.yml b/packaging/boot-rig/docker-compose.yml index 85645eb..bdcafb2 100644 --- a/packaging/boot-rig/docker-compose.yml +++ b/packaging/boot-rig/docker-compose.yml @@ -74,7 +74,9 @@ services: ipv4_address: 10.99.0.2 dhcp: - image: debian:bookworm-slim + build: + context: ../.. + dockerfile: packaging/boot-rig/Dockerfile.dhcp depends_on: server: condition: service_started @@ -85,14 +87,6 @@ services: networks: rig: ipv4_address: 10.99.0.3 - command: - - sh - - -c - - | - apt-get update >/dev/null && apt-get install -y --no-install-recommends dnsmasq >/dev/null - echo "--- the configuration under test ---" - cat /etc/rig/dnsmasq.conf - exec dnsmasq --keep-in-foreground --log-dhcp --conf-file=/etc/rig/dnsmasq.conf client: build: diff --git a/packaging/boot-rig/run.sh b/packaging/boot-rig/run.sh index 834d667..f5e220c 100755 --- a/packaging/boot-rig/run.sh +++ b/packaging/boot-rig/run.sh @@ -81,6 +81,18 @@ for _ in $(seq 1 60); do sleep 2 done +# **Every service has to still be running.** A container that died looks exactly like +# one still starting, and the first run of this rig spent four minutes booting a client +# at a DHCP server that had exited 127 a minute earlier. +for service in server dhcp; do + state=$("${COMPOSE[@]}" ps --format '{{.State}}' "$service" 2>/dev/null | head -1) + if [ "$state" != "running" ]; then + echo "the $service container is '$state', not running. Its log:" >&2 + "${COMPOSE[@]}" logs "$service" 2>&1 | tail -20 >&2 + exit 1 + fi +done + echo "==> what the server thinks of its own boot assets" "${COMPOSE[@]}" exec -T server /usr/local/bin/rescriptum boot check diff --git a/src/boot/fetch.rs b/src/boot/fetch.rs index d858cca..c830ddc 100644 --- a/src/boot/fetch.rs +++ b/src/boot/fetch.rs @@ -50,6 +50,16 @@ pub fn fetch( Some(name) => name.to_string(), None => name_from(url)?, }; + // Said here rather than left to curl, whose "Failed to open the file" names the + // partial path and not the setting that produced it. + if !dir.is_dir() { + return Err(format!( + "{} is not a directory. RESCRIPTUM_MEDIA_DIR names where images live, and \ + nothing creates it for you — a directory conjured by a download is one \ + nobody chose.", + dir.display() + )); + } let target = dir.join(&name); if target.exists() { return Err(format!( From c3d39c9ab231165a67f107857e04948799aa89c7 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 15:13:20 +0200 Subject: [PATCH 16/59] test(boot-rig): the chain runs end to end, and has been watched failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four markers reached on a development machine under TCG: the DHCP handoff answered from `boot dhcp-snippet`'s own output, a loader fetched over TFTP, the unclaimed machine fell through to its local disk, and the claimed machine reached its own answer. That is the whole chain — DHCP, our branded loader, its embedded script, the bootstrap, the answer engine, and either an unattended answer or the menu. And it has been red for the right reasons, which is what makes green mean anything: deleting a loader stops the run at `boot check`, and deleting the claimed machine's answer turns that marker alone red while the machine still falls through to its disk — so the fallthrough covers a deleted answer too. **The rig's shape changed, and the measurement is why.** A QEMU guest bridged into a container has a MAC of its own, and Docker Desktop's virtual switch does not forward frames from a MAC it did not assign: container-to-container TCP works, and a DHCP broadcast from the guest reaches nothing at all — tcpdump on the receiving side captures zero packets while the client's own tap0 and eth0 counters show the frames leaving. So the primary rig is now one container on a private bridge with no uplink. Nothing crosses Docker's network, which is stronger isolation than the `internal: true` the plan asked for. The four-service variant stays as `run-compose.sh` for a Linux host. Five failures had to be fixed before it ran at all, and every one was invisible until it did: `iproute2` missing from an image; a client image never rebuilt, because `docker compose run` reuses whatever exists; dnsmasq installed at run time on a network that by design cannot reach apt; dnsmasq logging to syslog, where a container has none, so the DHCPACK marker could never have matched; and a disk attached with `if=ide` to a `q35` machine, which has no IDE controller — SeaBIOS said "could not read the boot disk" and it read as a broken menu. Also fixes `--help` in three scripts: `sed -n … "$0"` cannot find a relatively-invoked script after a `cd`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- packaging/boot-rig/Dockerfile.dhcp | 11 +- packaging/boot-rig/Dockerfile.rig | 55 ++++++++++ packaging/boot-rig/README.md | 77 ++++++++----- packaging/boot-rig/boot-client.sh | 8 +- packaging/boot-rig/rig-in-one.sh | 163 ++++++++++++++++++++++++++++ packaging/boot-rig/run-compose.sh | 148 +++++++++++++++++++++++++ packaging/boot-rig/run.sh | 168 +++++++---------------------- packaging/ipxe/build.sh | 6 +- 8 files changed, 472 insertions(+), 164 deletions(-) create mode 100644 packaging/boot-rig/Dockerfile.rig create mode 100755 packaging/boot-rig/rig-in-one.sh create mode 100755 packaging/boot-rig/run-compose.sh diff --git a/packaging/boot-rig/Dockerfile.dhcp b/packaging/boot-rig/Dockerfile.dhcp index 94665ce..ebfdfa0 100644 --- a/packaging/boot-rig/Dockerfile.dhcp +++ b/packaging/boot-rig/Dockerfile.dhcp @@ -9,11 +9,18 @@ # this network needs something from the internet, it gets it before the network exists. FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq \ +# tcpdump is a rig tool, not decoration: when no client boots, the only question worth +# asking is whether the DHCP request reached this container at all, and nothing else in +# the stack can answer it. The network is `internal`, so it has to be here at build time. +RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq tcpdump \ && rm -rf /var/lib/apt/lists/* \ && dnsmasq --version | head -1 +# `--log-facility=-` sends dnsmasq's logging to stderr. **Without it everything goes to +# syslog**, which in a container is nowhere: the rig's DHCPACK marker could never match, +# and a run where nothing booted would look identical to one where nothing was logged. +# # The configuration under test is mounted, printed, and then handed to dnsmasq. Printing # it is not decoration: when no client boots, the first question is what the DHCP server # was actually told, and this is the answer. -CMD ["sh", "-c", "echo '--- the configuration under test ---'; cat /etc/rig/dnsmasq.conf; exec dnsmasq --keep-in-foreground --log-dhcp --conf-file=/etc/rig/dnsmasq.conf"] +CMD ["sh", "-c", "echo '--- the configuration under test ---'; cat /etc/rig/dnsmasq.conf; tcpdump -i eth0 -n -l port 67 or port 68 & exec dnsmasq --keep-in-foreground --log-facility=- --log-dhcp --conf-file=/etc/rig/dnsmasq.conf"] diff --git a/packaging/boot-rig/Dockerfile.rig b/packaging/boot-rig/Dockerfile.rig new file mode 100644 index 0000000..cfd5b7e --- /dev/null +++ b/packaging/boot-rig/Dockerfile.rig @@ -0,0 +1,55 @@ +# The whole rig in one container: loaders, server, DHCP and a QEMU machine, on a bridge +# that exists nowhere else. +# +# ## Why one container rather than four +# +# The four-service shape is the honest one and it works on a Linux host. It does **not** +# work on Docker Desktop, and the reason is worth recording because it is not obvious: +# a QEMU guest bridged into a container has a MAC of its own, and Docker Desktop's +# virtual switch does not forward frames from a MAC it did not assign. Measured here: +# container-to-container TCP works, and a DHCP broadcast from the guest never reaches the +# next container — tcpdump on the receiving side captures nothing at all. +# +# So this variant puts everything in one network namespace, on a **private** bridge with +# no uplink. Nothing crosses Docker's network, which means nothing can be filtered by it, +# and the rig's network really is its whole world — a stronger version of the +# `internal: true` the four-service file asks for. +# +# What is under test is unchanged: the DHCP configuration still comes from +# `boot dhcp-snippet`, the loader is still ours, and the two markers are the same. + +FROM --platform=linux/amd64 debian:bookworm-slim AS loaders +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential liblzma-dev git perl mtools xorriso ca-certificates \ + gcc-aarch64-linux-gnu \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /ipxe +COPY packaging/ipxe/ /ipxe/ +RUN chmod +x build.sh && ./build.sh --out /loaders + +# **amd64 here too, and not as a detail.** The runtime stage below is amd64 because +# iPXE's BIOS targets are 32-bit x86; a server binary built for the host's architecture +# lands in it and fails with `cannot execute: required file not found`, which reads as a +# missing file rather than as a wrong ELF. +FROM --platform=linux/amd64 rust:1-bookworm AS server +WORKDIR /src +COPY . . +RUN cargo build --release --locked + +FROM --platform=linux/amd64 debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + qemu-system-x86 nasm ovmf iproute2 dnsmasq tcpdump \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=server /src/target/release/rescriptum /usr/local/bin/rescriptum +COPY --from=loaders /loaders/ /srv/boot/ +COPY packaging/boot-rig/local-disk.asm /rig/local-disk.asm +COPY packaging/boot-rig/answers/ /srv/answers/ +COPY packaging/boot-rig/rig-in-one.sh /rig/rig-in-one.sh + +RUN nasm -f bin /rig/local-disk.asm -o /rig/local-disk.img \ + && test "$(stat -c%s /rig/local-disk.img)" = 512 \ + && chmod +x /rig/rig-in-one.sh \ + && mkdir -p /srv/media /out + +ENTRYPOINT ["/rig/rig-in-one.sh"] diff --git a/packaging/boot-rig/README.md b/packaging/boot-rig/README.md index b704b86..7d2c215 100644 --- a/packaging/boot-rig/README.md +++ b/packaging/boot-rig/README.md @@ -46,25 +46,27 @@ tell operators to paste is wrong, nothing here boots. ## The shape, and why -Three services on one network with `internal: true`: - -- **`loaders`** builds the branded iPXE from `packaging/ipxe/` — the same script and the - same pin a release uses. A stock loader would re-load itself forever or chain to the - public netboot.xyz; testing the chain means testing ours. -- **`server`** is the real binary, built from the working tree rather than from whatever - is lying in `./target`. -- **`dhcp`** is dnsmasq, configured from our own generated snippet. -- **`client`** is QEMU with its NIC **bridged onto the network**, not behind QEMU's - user-mode stack — that stack carries its own DHCP server, and a rig built on it would - test everything except the handoff it exists to test. - -`internal: true` is not tidiness. **A rig that runs a DHCP server has to be unable to -answer anything on the host's LAN**, which is the same "did installing this break the -network" hygiene the product itself lives by. It also means nothing inside can reach the -internet, which is why the loaders are built into their image rather than at run time. - -**No `/dev/kvm` anywhere.** KVM would make this fast; the rig has to pass without it, -because the development machine is a Mac. Ten times slower is a long run, not a wall. +**One container**, holding the loaders, the server, dnsmasq and a QEMU machine on a +private bridge with no uplink. Nothing crosses Docker's network, which means nothing can +be filtered by it — the rig's network really is its whole world. + +[`run-compose.sh`](run-compose.sh) is the four-service variant the plan describes: +loaders, server, dnsmasq and client as separate containers on a Docker network with +`internal: true`. It is the more honest shape and it works on a Linux host. **It does not +work on Docker Desktop**, and the reason is worth recording because it is not obvious: a +QEMU guest bridged into a container has a MAC of its own, and Docker Desktop's virtual +switch does not forward frames from a MAC it did not assign. + +That was measured rather than assumed. Container-to-container TCP works (a SYN and its +SYN-ACK captured on the receiving side); a DHCP broadcast from the guest reaches nothing +at all — `tcpdump` on the DHCP container captures zero packets while the client's own +`tap0` and `eth0` counters show the frames leaving. The guest's MAC even turns up in the +bridge's forwarding table on the *wrong* port. + +**No `/dev/kvm` in either.** KVM would make this fast; the rig has to pass without it, +because the development machine is a Mac. It needs `NET_ADMIN` to build the bridge and +`/dev/net/tun` for the tap the guest sits on, and nothing else — no host network, no +published port. ## What running it for the first time cost @@ -124,16 +126,33 @@ it guards disappear: That last one turns the blast-radius table in the guide from a claim into a recorded run. -## Status +## Status: green, and it has been red -**The loader half is verified; the QEMU half has not been run here.** +**Run, on this machine, under TCG.** All four markers reached: -What is proven: `packaging/ipxe/build.sh` produces all eight loaders from the pinned -commit, they carry our branding and `embed.ipxe` verbatim, and `rescriptum boot check` -agrees the set satisfies the loader table. +``` + ok the DHCP handoff answered, from our own generated snippet + ok a loader was fetched over TFTP + ok the unclaimed machine fell through to its local disk + ok the claimed machine fetched its sentinel + +rig: all markers reached +``` + +That is the whole chain: a DHCP offer built from `boot dhcp-snippet`'s own output, our +branded loader over TFTP, its embedded script, the bootstrap, the answer engine — and +then either a machine's own unattended answer or the menu and a fall through to its disk. + +And it has been **watched failing**, which is what makes the green mean anything: + +| Break | What went red | +|---|---| +| Delete `ipxe-undionly.kpxe` | `boot check` says MISSING and the run stops before a client boots | +| Delete the claimed machine's answer | that marker alone goes red — and the machine still falls through to its disk, so the fallthrough covers a deleted answer too | + +The rows in the table above that have *not* been run yet are the moved media port, the +missing template fact, and the stopped server. Those are the next ones to watch go red. -What is not: that a machine boots them. `run.sh` has not been driven end to end on this -machine, so **treat a green run as unproven rather than as evidence** until somebody has -watched each row of the table above go red first. That is the same discipline -`lifecycle-test.sh` already lives under, and the reason is the same: a green harness that -has never been red proves nothing. +**What is still not proven is what real firmware does.** The rig runs one emulator with +one NIC model, and the standing rule is unchanged: nothing ships on harness evidence +alone. diff --git a/packaging/boot-rig/boot-client.sh b/packaging/boot-rig/boot-client.sh index d090c34..45a2945 100755 --- a/packaging/boot-rig/boot-client.sh +++ b/packaging/boot-rig/boot-client.sh @@ -59,8 +59,14 @@ fi # A scratch copy, so a run cannot alter the image the next one boots. cp /rig/local-disk.img "/tmp/${NAME}.img" +# **`pc`, not `q35`, for the BIOS run.** q35 has no IDE controller, so a disk attached +# with `if=ide` is simply not there — SeaBIOS says "could not read the boot disk" and the +# fallthrough marker can never be reached. The first run of this rig failed exactly that +# way, and it read as a broken menu rather than a machine with no disk. +MACHINE=pc FIRMWARE_ARGS=() if [ "${FIRMWARE}" = "uefi" ]; then + MACHINE=q35 cp /usr/share/OVMF/OVMF_VARS.fd "/tmp/${NAME}.vars.fd" FIRMWARE_ARGS=( -drive "if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd" @@ -74,7 +80,7 @@ fi # `-nographic` puts the serial console on stdout. KVM is never requested: the rig has to # pass under TCG, because the development machine is a Mac. timeout "${LIMIT}" qemu-system-x86_64 \ - -machine q35 \ + -machine "${MACHINE}" \ -m 1024 \ -nographic \ -no-reboot \ diff --git a/packaging/boot-rig/rig-in-one.sh b/packaging/boot-rig/rig-in-one.sh new file mode 100755 index 0000000..cc2a0c1 --- /dev/null +++ b/packaging/boot-rig/rig-in-one.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Drive the whole boot chain inside one container, on a bridge with no uplink. +# +# rig-in-one.sh [bios|uefi] +# +# Everything the four-service rig does, minus Docker's network — which on a Mac is what +# stops a QEMU guest being seen at all. See Dockerfile.rig for the measurement. + +set -uo pipefail +FIRMWARE="${1:-bios}" +SERVER=10.99.0.2 +mkdir -p /out + +say() { echo "==> $*"; } + +# --------------------------------------------------------------------------- +# A private bridge. No uplink, no route out: the rig's network is its whole world, +# which is a stronger guarantee than `internal: true` and needs nothing from Docker. +# --------------------------------------------------------------------------- +say "building the network" +ip link add br0 type bridge +ip link set br0 type bridge forward_delay 0 +ip addr add "${SERVER}/24" dev br0 +ip link set br0 up +ip tuntap add dev tap0 mode tap +ip link set tap0 master br0 +ip link set tap0 up +ip -brief addr show br0 + +# --------------------------------------------------------------------------- +# The DHCP configuration comes from the server itself, so the rig tests the snippet too: +# if what we tell operators to paste is wrong, nothing here boots. +# --------------------------------------------------------------------------- +say "generating the DHCP configuration from the server's own snippet" +export RESCRIPTUM_PUBLIC_HOST="${SERVER}" +export RESCRIPTUM_MEDIA_DIR=/srv/media +export RESCRIPTUM_ANSWERS_DIR=/srv/answers +export RESCRIPTUM_BOOT_DIR=/srv/boot +export RESCRIPTUM_LISTEN_ADDR="${SERVER}:8000" +export RESCRIPTUM_MEDIA_ADDR="${SERVER}:8001" +export RESCRIPTUM_TFTP_ADDR="${SERVER}:69" +export RESCRIPTUM_BOOT_TIMEOUT_SECS=5 +export RESCRIPTUM_LOG=all + +# **The snippet's stderr is not discarded.** Hiding it once cost a run: the binary was +# the wrong architecture, the generator produced nothing, and the configuration below +# came out with no handoff in it at all — which looked like a DHCP server that simply +# did not answer. +rescriptum boot dhcp-snippet --format dnsmasq > /rig/snippet.conf || { + echo "could not generate the DHCP snippet — see above" >&2 + exit 1 +} +if ! grep -q "dhcp-boot" /rig/snippet.conf; then + echo "the generated snippet names no boot file; refusing to run a rig that tests nothing" >&2 + exit 1 +fi +{ + echo "port=0" + echo "interface=br0" + echo "bind-interfaces" + echo "log-dhcp" + echo "log-facility=-" + echo "dhcp-range=10.99.0.100,10.99.0.200,1h" + echo + cat /rig/snippet.conf +} > /rig/dnsmasq.conf +cat /rig/dnsmasq.conf + +say "what the server thinks of its own boot assets" +rescriptum boot check || exit 1 + +# --------------------------------------------------------------------------- +say "starting the server and the DHCP handoff" +rescriptum > /out/server.log 2>&1 & +SERVER_PID=$! +dnsmasq --keep-in-foreground --conf-file=/rig/dnsmasq.conf > /out/dhcp.log 2>&1 & +DHCP_PID=$! +# tcpdump is the answer to "did the request even arrive", which is the only question +# worth asking when no client boots. +tcpdump -i br0 -n -l "port 67 or port 68 or port 69" > /out/wire.log 2>&1 & +TCPDUMP_PID=$! + +# Both have to still be alive: a process that died looks exactly like one still starting. +sleep 3 +for pid_name in "SERVER_PID server" "DHCP_PID dhcp"; do + set -- $pid_name + if ! kill -0 "${!1}" 2>/dev/null; then + echo "the $2 process died before a client was booted. Its log:" >&2 + tail -20 "/out/$2.log" >&2 + exit 1 + fi +done + +boot() { + local mac="$1" name="$2" limit="$3" + say "booting a machine as $name ($mac, ${FIRMWARE})" + cp /rig/local-disk.img "/tmp/${name}.img" + + local machine=pc + local firmware=() + if [ "${FIRMWARE}" = "uefi" ]; then + machine=q35 + cp /usr/share/OVMF/OVMF_VARS.fd "/tmp/${name}.vars.fd" + firmware=( + -drive "if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd" + -drive "if=pflash,format=raw,file=/tmp/${name}.vars.fd" + ) + fi + + # `-boot order=nc`: network first, then the disk. **That ordering is the fallthrough + # being tested** — a machine that gets no answer must reach the disk, not stop. + timeout "${limit}" qemu-system-x86_64 \ + -machine "${machine}" \ + -m 1024 \ + -nographic \ + -no-reboot \ + -boot order=nc \ + "${firmware[@]}" \ + -netdev tap,id=n0,ifname=tap0,script=no,downscript=no \ + -device e1000,netdev=n0,mac="${mac}" \ + -drive file="/tmp/${name}.img",format=raw,if=ide \ + > "/out/${name}.serial.log" 2>&1 + echo "--- ${name} ended after at most ${limit}s ---" >> "/out/${name}.serial.log" +} + +boot 52:54:00:aa:aa:aa unclaimed "${UNCLAIMED_SECONDS:-240}" +boot 98:fa:9b:50:d8:10 claimed "${CLAIMED_SECONDS:-240}" + +kill "${SERVER_PID}" "${DHCP_PID}" "${TCPDUMP_PID}" 2>/dev/null +wait 2>/dev/null + +# --------------------------------------------------------------------------- +say "results" +fail=0 +check() { + local what="$1" file="$2" needle="$3" + if grep -qF -- "${needle}" "${file}" 2>/dev/null; then + echo " ok ${what}" + else + echo " FAIL ${what} — ${needle} not in $(basename "${file}")" + fail=$((fail + 1)) + fi +} + +check "the DHCP handoff answered, from our own generated snippet" \ + /out/dhcp.log "DHCPACK" +check "a loader was fetched over TFTP" \ + /out/server.log "tftp:" +check "the unclaimed machine fell through to its local disk" \ + /out/unclaimed.serial.log "RESCRIPTUM-RIG-LOCAL-DISK-REACHED" +check "the claimed machine fetched its sentinel" \ + /out/server.log "/rig/claimed" + +echo +if [ "${fail}" = "0" ]; then + echo "rig: all markers reached" + exit 0 +fi +echo "rig: ${fail} marker(s) missing" +echo +echo "--- what was on the wire ---" +head -20 /out/wire.log +exit 1 diff --git a/packaging/boot-rig/run-compose.sh b/packaging/boot-rig/run-compose.sh new file mode 100755 index 0000000..680c691 --- /dev/null +++ b/packaging/boot-rig/run-compose.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Drive the boot rig and assert on its two markers. +# +# ./run.sh # both clients, BIOS +# ./run.sh --uefi # both clients, OVMF +# ./run.sh --keep # leave the stack up afterwards, to poke at it +# +# **This is the contract, and CI runs a subset of it.** GitHub's runners are somebody +# else's machines with somebody else's limits, so the dev rig is what decides and CI is +# the tripwire — sized so it cannot fail for capacity reasons. + +set -euo pipefail +SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +cd "$(dirname "$SELF")" + +FIRMWARE=bios +KEEP=0 +while [ $# -gt 0 ]; do + case "$1" in + --uefi) FIRMWARE=uefi; shift ;; + --keep) KEEP=1; shift ;; + -h|--help) sed -n '2,9p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unexpected argument: $1" >&2; exit 2 ;; + esac +done + +COMPOSE=(docker compose -f docker-compose.yml) + +cleanup() { + if [ "$KEEP" = "0" ]; then + "${COMPOSE[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + else + echo "stack left up; 'docker compose -f $PWD/docker-compose.yml down -v' when done" + fi +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# The DHCP configuration comes from the server itself, which is what makes the rig a +# test of `boot dhcp-snippet` too. If what we tell operators to paste is wrong, nothing +# here boots — and that is exactly the failure worth catching before they meet it. +# --------------------------------------------------------------------------- +echo "==> generating the DHCP configuration from the server's own snippet" +mkdir -p generated +cargo run --quiet --manifest-path ../../Cargo.toml -- boot dhcp-snippet --format dnsmasq \ + > generated/dnsmasq.conf.snippet 2>/dev/null || { + echo "could not generate the snippet" >&2; exit 1; } + +# Everything above the snippet is the rig's own scaffolding: a range to hand out, an +# interface to listen on, and no upstream DNS — this network has no internet. +{ + echo "# --- rig scaffolding (not generated) ---" + echo "port=0" + echo "interface=eth0" + echo "bind-interfaces" + echo "log-dhcp" + echo "dhcp-range=10.99.0.100,10.99.0.200,1h" + # No `enable-tftp` at all: this dnsmasq only answers DHCP, and the server under + # test is the one that hands out loaders. (`enable-tftp=no` does not disable it — it + # names an interface called "no", which dnsmasq then enables TFTP on.) + echo + echo '# --- everything below is: rescriptum boot dhcp-snippet --format dnsmasq ---' + # The generated snippet names RESCRIPTUM_PUBLIC_HOST, which outside a container is + # this machine. Inside the rig the server is 10.99.0.2, and that substitution is the + # only edit the rig makes. + sed 's/[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}/10.99.0.2/g' \ + generated/dnsmasq.conf.snippet +} > generated/dnsmasq.conf + +echo "==> building the images (no KVM anywhere: the rig must pass under TCG)" +# **The client is built explicitly.** `docker compose run` reuses whatever image is +# already there, so a client image that is never built is one that never changes — and +# a fix made to its Dockerfile would silently not apply. +"${COMPOSE[@]}" build loaders server dhcp client + +echo "==> bringing the stack up" +"${COMPOSE[@]}" up -d loaders server dhcp + +# The loaders service exits when it has copied; the others have to be listening. +for _ in $(seq 1 60); do + if "${COMPOSE[@]}" exec -T server /usr/local/bin/rescriptum boot check >/dev/null 2>&1; then + break + fi + sleep 2 +done + +# **Every service has to still be running.** A container that died looks exactly like +# one still starting, and the first run of this rig spent four minutes booting a client +# at a DHCP server that had exited 127 a minute earlier. +for service in server dhcp; do + state=$("${COMPOSE[@]}" ps --format '{{.State}}' "$service" 2>/dev/null | head -1) + if [ "$state" != "running" ]; then + echo "the $service container is '$state', not running. Its log:" >&2 + "${COMPOSE[@]}" logs "$service" 2>&1 | tail -20 >&2 + exit 1 + fi +done + +echo "==> what the server thinks of its own boot assets" +"${COMPOSE[@]}" exec -T server /usr/local/bin/rescriptum boot check + +# --------------------------------------------------------------------------- +# Marker one: a machine nothing claims must reach its own disk. +# --------------------------------------------------------------------------- +echo "==> booting an UNCLAIMED machine (${FIRMWARE})" +"${COMPOSE[@]}" run --rm -T client 52:54:00:aa:aa:aa unclaimed 240 "${FIRMWARE}" || true + +# --------------------------------------------------------------------------- +# Marker two: a machine something claims must reach its own answer. +# --------------------------------------------------------------------------- +echo "==> booting a CLAIMED machine (${FIRMWARE})" +"${COMPOSE[@]}" run --rm -T client 98:fa:9b:50:d8:10 claimed 240 "${FIRMWARE}" || true + +echo "==> results" +mkdir -p results +"${COMPOSE[@]}" run --rm -T --entrypoint sh client -c 'cat /out/*.serial.log' > results/serial.log 2>&1 || true +"${COMPOSE[@]}" logs server > results/server.log 2>&1 || true +"${COMPOSE[@]}" logs dhcp > results/dhcp.log 2>&1 || true + +fail=0 +check() { + local what="$1" file="$2" needle="$3" + if grep -qF -- "${needle}" "${file}" 2>/dev/null; then + echo " ok ${what}" + else + echo " FAIL ${what} — ${needle} not in ${file}" + fail=$((fail + 1)) + fi +} + +# An unclaimed machine reached its own disk rather than sitting at a menu or stopping. +check "unclaimed machine fell through to its local disk" \ + results/serial.log "RESCRIPTUM-RIG-LOCAL-DISK-REACHED" +# A claimed machine reached its own answer — asserted in the *server's* log, which +# proves the request arrived rather than that the client printed something. +check "claimed machine fetched its sentinel" \ + results/server.log "/rig/claimed" +# And the DHCP handoff itself worked, which is the snippet under test. +check "dnsmasq answered a PXE client from the generated snippet" \ + results/dhcp.log "DHCPACK" + +echo +if [ "${fail}" = "0" ]; then + echo "rig: all markers reached" +else + echo "rig: ${fail} marker(s) missing — see packaging/boot-rig/results/" + exit 1 +fi diff --git a/packaging/boot-rig/run.sh b/packaging/boot-rig/run.sh index f5e220c..9d681ab 100755 --- a/packaging/boot-rig/run.sh +++ b/packaging/boot-rig/run.sh @@ -1,145 +1,53 @@ #!/usr/bin/env bash -# Drive the boot rig and assert on its two markers. +# The boot rig: everything from a DHCP offer to a machine on its own disk, in one +# command, on a network that exists nowhere else. # -# ./run.sh # both clients, BIOS -# ./run.sh --uefi # both clients, OVMF -# ./run.sh --keep # leave the stack up afterwards, to poke at it +# ./run.sh # BIOS +# ./run.sh --uefi # OVMF +# ./run.sh --rebuild # force the image to be rebuilt first # -# **This is the contract, and CI runs a subset of it.** GitHub's runners are somebody -# else's machines with somebody else's limits, so the dev rig is what decides and CI is -# the tripwire — sized so it cannot fail for capacity reasons. +# One container, holding the loaders, the server, dnsmasq and a QEMU machine on a +# **private bridge with no uplink**. Nothing crosses Docker's network, which means +# nothing can be filtered by it — a stronger guarantee than the `internal: true` the +# four-service variant asks for, and the reason this works on a Mac. +# +# `run-compose.sh` is that four-service variant: the same markers, split across +# containers on an isolated Docker network. It is the honest shape and it works on a +# Linux host; on Docker Desktop the QEMU guest's frames never reach the next container. +# See Dockerfile.rig for the measurement. +# +# **No /dev/kvm.** KVM would make this fast; the rig has to pass without it, because the +# development machine is a Mac. set -euo pipefail -cd "$(dirname "$0")" +# Resolved before the `cd`: `$0` is relative when the script is invoked that way, and +# `--help` below reads the script itself. +SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +cd "$(dirname "$SELF")" FIRMWARE=bios -KEEP=0 +REBUILD=0 while [ $# -gt 0 ]; do case "$1" in --uefi) FIRMWARE=uefi; shift ;; - --keep) KEEP=1; shift ;; - -h|--help) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --bios) FIRMWARE=bios; shift ;; + --rebuild) REBUILD=1; shift ;; + -h|--help) sed -n '2,20p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unexpected argument: $1" >&2; exit 2 ;; esac done -COMPOSE=(docker compose -f docker-compose.yml) - -cleanup() { - if [ "$KEEP" = "0" ]; then - "${COMPOSE[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true - else - echo "stack left up; 'docker compose -f $PWD/docker-compose.yml down -v' when done" - fi -} -trap cleanup EXIT - -# --------------------------------------------------------------------------- -# The DHCP configuration comes from the server itself, which is what makes the rig a -# test of `boot dhcp-snippet` too. If what we tell operators to paste is wrong, nothing -# here boots — and that is exactly the failure worth catching before they meet it. -# --------------------------------------------------------------------------- -echo "==> generating the DHCP configuration from the server's own snippet" -mkdir -p generated -cargo run --quiet --manifest-path ../../Cargo.toml -- boot dhcp-snippet --format dnsmasq \ - > generated/dnsmasq.conf.snippet 2>/dev/null || { - echo "could not generate the snippet" >&2; exit 1; } - -# Everything above the snippet is the rig's own scaffolding: a range to hand out, an -# interface to listen on, and no upstream DNS — this network has no internet. -{ - echo "# --- rig scaffolding (not generated) ---" - echo "port=0" - echo "interface=eth0" - echo "bind-interfaces" - echo "log-dhcp" - echo "dhcp-range=10.99.0.100,10.99.0.200,1h" - echo "enable-tftp=no" - echo - echo '# --- everything below is: rescriptum boot dhcp-snippet --format dnsmasq ---' - # The generated snippet names RESCRIPTUM_PUBLIC_HOST, which outside a container is - # this machine. Inside the rig the server is 10.99.0.2, and that substitution is the - # only edit the rig makes. - sed 's/[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}/10.99.0.2/g' \ - generated/dnsmasq.conf.snippet -} > generated/dnsmasq.conf - -echo "==> building the images (no KVM anywhere: the rig must pass under TCG)" -# **The client is built explicitly.** `docker compose run` reuses whatever image is -# already there, so a client image that is never built is one that never changes — and -# a fix made to its Dockerfile would silently not apply. -"${COMPOSE[@]}" build loaders server dhcp client - -echo "==> bringing the stack up" -"${COMPOSE[@]}" up -d loaders server dhcp - -# The loaders service exits when it has copied; the others have to be listening. -for _ in $(seq 1 60); do - if "${COMPOSE[@]}" exec -T server /usr/local/bin/rescriptum boot check >/dev/null 2>&1; then - break - fi - sleep 2 -done - -# **Every service has to still be running.** A container that died looks exactly like -# one still starting, and the first run of this rig spent four minutes booting a client -# at a DHCP server that had exited 127 a minute earlier. -for service in server dhcp; do - state=$("${COMPOSE[@]}" ps --format '{{.State}}' "$service" 2>/dev/null | head -1) - if [ "$state" != "running" ]; then - echo "the $service container is '$state', not running. Its log:" >&2 - "${COMPOSE[@]}" logs "$service" 2>&1 | tail -20 >&2 - exit 1 - fi -done - -echo "==> what the server thinks of its own boot assets" -"${COMPOSE[@]}" exec -T server /usr/local/bin/rescriptum boot check - -# --------------------------------------------------------------------------- -# Marker one: a machine nothing claims must reach its own disk. -# --------------------------------------------------------------------------- -echo "==> booting an UNCLAIMED machine (${FIRMWARE})" -"${COMPOSE[@]}" run --rm -T client 52:54:00:aa:aa:aa unclaimed 240 "${FIRMWARE}" || true - -# --------------------------------------------------------------------------- -# Marker two: a machine something claims must reach its own answer. -# --------------------------------------------------------------------------- -echo "==> booting a CLAIMED machine (${FIRMWARE})" -"${COMPOSE[@]}" run --rm -T client 98:fa:9b:50:d8:10 claimed 240 "${FIRMWARE}" || true - -echo "==> results" -mkdir -p results -"${COMPOSE[@]}" run --rm -T --entrypoint sh client -c 'cat /out/*.serial.log' > results/serial.log 2>&1 || true -"${COMPOSE[@]}" logs server > results/server.log 2>&1 || true -"${COMPOSE[@]}" logs dhcp > results/dhcp.log 2>&1 || true - -fail=0 -check() { - local what="$1" file="$2" needle="$3" - if grep -qF -- "${needle}" "${file}" 2>/dev/null; then - echo " ok ${what}" - else - echo " FAIL ${what} — ${needle} not in ${file}" - fail=$((fail + 1)) - fi -} - -# An unclaimed machine reached its own disk rather than sitting at a menu or stopping. -check "unclaimed machine fell through to its local disk" \ - results/serial.log "RESCRIPTUM-RIG-LOCAL-DISK-REACHED" -# A claimed machine reached its own answer — asserted in the *server's* log, which -# proves the request arrived rather than that the client printed something. -check "claimed machine fetched its sentinel" \ - results/server.log "/rig/claimed" -# And the DHCP handoff itself worked, which is the snippet under test. -check "dnsmasq answered a PXE client from the generated snippet" \ - results/dhcp.log "DHCPACK" - -echo -if [ "${fail}" = "0" ]; then - echo "rig: all markers reached" -else - echo "rig: ${fail} marker(s) missing — see packaging/boot-rig/results/" - exit 1 +IMAGE=rescriptum-rig:one +if [ "$REBUILD" = "1" ] || ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "==> building $IMAGE (iPXE and the server; the first one takes a while)" + docker build -f Dockerfile.rig -t "$IMAGE" ../.. fi + +# NET_ADMIN to build the bridge, /dev/net/tun for the tap the guest sits on. Nothing +# else: no host network, no published port, no KVM. +exec docker run --rm \ + --cap-add NET_ADMIN \ + --device /dev/net/tun \ + -e "UNCLAIMED_SECONDS=${UNCLAIMED_SECONDS:-240}" \ + -e "CLAIMED_SECONDS=${CLAIMED_SECONDS:-240}" \ + "$IMAGE" "$FIRMWARE" diff --git a/packaging/ipxe/build.sh b/packaging/ipxe/build.sh index fcf455a..70a9e42 100755 --- a/packaging/ipxe/build.sh +++ b/packaging/ipxe/build.sh @@ -32,13 +32,15 @@ # we ship, in the same repository as the thing that serves it. set -euo pipefail -cd "$(dirname "$0")" +# Resolved before the `cd`, so `--help` can read the script itself however it was called. +SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +cd "$(dirname "$SELF")" OUT="$PWD/out" while [ $# -gt 0 ]; do case "$1" in --out) OUT="$2"; shift 2 ;; - -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,12p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unexpected argument: $1" >&2; exit 2 ;; esac done From c6e9a9788288e343ebafeddfdef8c2752fd34cba Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 15:14:05 +0200 Subject: [PATCH 17/59] ci: run the boot chain on every push, and require it to go red The tripwire the plan asks for: BIOS only, TCG, one claimed and one unclaimed client, time-boxed, no OS image. The dev rig is what decides; this re-proves the chain on every push without a real machine, and it is sized so it cannot fail for capacity reasons. It also breaks a link deliberately and requires the break to show. A green rig that has never been red proves nothing, and the cheapest way to keep that true is to prove it on every run rather than to remember to do it by hand. CLAUDE.md gains the five traps the rig's first runs paid for, of which the one worth repeating is not technical: **a container that died looks exactly like one still starting**, and three of the five hid behind it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 18 ++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f08b59..472095e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,46 @@ jobs: path: ${{ github.workspace }}/loaders if-no-files-found: error + rig: + name: The boot chain, end to end + runs-on: ubuntu-latest + # **The tripwire, not the contract.** The dev rig is what decides; this re-proves the + # BIOS chain on every push without a single real machine. GitHub's runners are + # somebody else's machines with somebody else's limits, so it is sized so it cannot + # fail for capacity reasons — BIOS only, TCG, one claimed and one unclaimed client, + # no OS image, a few minutes. If it ever needs hardware virtualisation to pass, it + # has grown too big. + # + # Runners are x86_64, so this is native rather than the emulation a Mac does — and + # there is still no /dev/kvm, which is exactly the constraint the rig is built for. + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Build the rig + run: docker build -f packaging/boot-rig/Dockerfile.rig -t rescriptum-rig:one . + + # Four markers: the DHCP handoff answered from our own generated snippet, a loader + # fetched over TFTP, an unclaimed machine on its own disk, and a claimed machine at + # its own answer. + - name: Boot a claimed and an unclaimed machine + run: | + docker run --rm --cap-add NET_ADMIN --device /dev/net/tun \ + -e UNCLAIMED_SECONDS=180 -e CLAIMED_SECONDS=180 \ + rescriptum-rig:one bios + + # **Watched failing, in CI as well as by hand.** A green rig that has never been + # red proves nothing, and the cheapest way to keep that true is to break one link + # on every run and require the break to show. + - name: And it must go red when a loader is missing + run: | + if docker run --rm --cap-add NET_ADMIN --device /dev/net/tun \ + --entrypoint sh rescriptum-rig:one \ + -c 'rm /srv/boot/ipxe-undionly.kpxe && exec /rig/rig-in-one.sh bios'; then + echo "the rig passed with a loader missing — it is not testing what it claims" >&2 + exit 1 + fi + docs: name: Documentation runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index ab4373d..37fc56d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -532,6 +532,17 @@ could not check. Note it needs `Resolution::format_name` (the extension), not this server being broken. - **iPXE's BIOS targets need an x86 compiler and its ARM64 ones need `CROSS_COMPILE=aarch64-linux-gnu-`.** Both failures read like a broken Makefile. +- **A container that died looks exactly like one still starting.** Three of the boot + rig's first five failures hid behind that; anything that waits on a service has to + check it is still running and print its log when it is not. +- **A service on an `internal` Docker network cannot reach apt.** Install at image-build + time or it fails silently and the container exits 127 later. +- **dnsmasq logs to syslog unless told otherwise** (`--log-facility=-`), and a container + has no syslog — so a marker grepping its output could never have matched. +- **A `q35` QEMU machine has no IDE controller**, so `-drive if=ide` is simply not there + and SeaBIOS says "could not read the boot disk". Use `pc` for a BIOS guest. +- **`sed -n … "$0"` cannot find a relatively-invoked script after a `cd`.** Resolve the + path first, or `--help` breaks for everyone who does not type an absolute path. - **The size figures in this file go stale.** They moved ~375 KB when armv7 changed from musl to glibc. Re-measure before concluding anything from them; a stale baseline once turned a 71% budget spend into an apparent 293% overrun. @@ -871,6 +882,13 @@ the image and not derived from `DISK_SIZE`. `run-vm.sh` is the loader-image fall `cargo test` does not run those). `docs/development/testing.md` has the per-suite table; the rules that decide where a test goes: +- **The whole boot chain belongs in `packaging/boot-rig/`**, which is not Rust and which + `cargo test` does not run. `run.sh` boots a claimed and an unclaimed machine in QEMU + and asserts four markers; CI runs the same thing plus one deliberate break. **A QEMU + guest bridged into a container has a MAC of its own, and Docker Desktop's virtual + switch does not forward frames from a MAC it did not assign** — measured, which is why + the primary rig is one container on a private bridge rather than four on a Docker + network. - **TFTP belongs in `tests/tftp.rs`**, speaking the protocol over real UDP. A transfer is a conversation, and every bug worth catching lives in the turn-taking: the first run found two, both of the "works by hand, never after a reboot" kind. From 849faa660eaa8f761b413005910d7143344b8b87 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 15:41:02 +0200 Subject: [PATCH 18/59] feat(dsm): package the boot media, and say plainly why TFTP is not in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package now serves installer images from the same NAS that decides the answer. `RESCRIPTUM_MEDIA_DIR` is one uncommented line away, the share gains `media/` and `boot/` folders at start, and port 8001 is registered with the firewall beside the answer port — registering does not open it, and the alternative is an operator who enables media and then cannot find rescriptum in the rule editor. **TFTP is not in the package, and the env file says why rather than offering a setting that breaks.** Port 69 is privileged and DSM 7 does not let an unsigned package run as root, so `RESCRIPTUM_TFTP_ADDR` would produce a package that refuses to start. DSM has its own TFTP server and it is the right one here: point it at the share's `boot` folder, put the loaders there, and the chain continues on port 8001. DSM hands over one file; that is the whole of its part. `RESCRIPTUM_USER`/`_GROUP` are documented the same way — the package already is its own unprivileged user. `lifecycle-test.sh` caught a real defect on its first run, which is what it is for: the first version of this wrote a live `RESCRIPTUM_MEDIA_ADDR` with `RESCRIPTUM_MEDIA_DIR` still commented, and that combination is a startup error — **the package would not have started at all.** The address is now commented too (the default is already 8001), and three new guards pin it. Watched red: reintroducing the defect turns 54 green into 46 green and 8 red. The admin API's example moves off 127.0.0.1:8001, which the media listener now owns and which the server refuses as a collision. The DSM application needed no code: its field list comes from `config --json`, so the thirteen new variables already render. They needed labels and help in both languages — `check-spk.sh` keeps the two files in lockstep, and it passes. Both packages build and pass the structural check; the lifecycle harness runs 54 checks green in a Linux container. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- docs/guide/operations/synology.fr.md | 76 ++++++++++++++++++- docs/guide/operations/synology.md | 71 ++++++++++++++++- packaging/dsm/lifecycle-test.sh | 17 ++++- packaging/dsm/payload/port_conf/rescriptum.sc | 4 +- packaging/dsm/payload/ui/texts/enu/strings | 26 +++++++ packaging/dsm/payload/ui/texts/fre/strings | 26 +++++++ packaging/dsm/scripts/postinst | 65 +++++++++++++++- packaging/dsm/scripts/start-stop-status | 13 ++++ 8 files changed, 291 insertions(+), 7 deletions(-) diff --git a/docs/guide/operations/synology.fr.md b/docs/guide/operations/synology.fr.md index 8964352..5016454 100644 --- a/docs/guide/operations/synology.fr.md +++ b/docs/guide/operations/synology.fr.md @@ -60,8 +60,13 @@ puis le paquet : ## Ce que le paquet ne fait pas -Quatre choses à savoir avant qu'elles ne vous surprennent. +Cinq choses à savoir avant qu'elles ne vous surprennent. +- **Il ne peut pas servir de TFTP.** Le port 69 est privilégié et DSM 7 n'autorise pas un + paquet non signé à tourner en root : la livraison du chargeur revient donc au serveur + TFTP de DSM — voir [Servir les médias d'installation, et le + PXE](#servir-les-médias-dinstallation-et-le-pxe). Tout ce qui suit le chargeur + appartient à ce paquet. - **Il n'ouvre pas le pare-feu.** Enregistrer le port fait apparaître *rescriptum* par son nom dans l'éditeur de règles au lieu d'un numéro à taper. Si votre pare-feu est actif avec une règle par défaut qui refuse, il faut toujours créer la règle. @@ -226,6 +231,75 @@ d'environnement puis déplacez l'entrée du pare-feu, qui ne suit pas toute seul $ sudo /usr/syno/sbin/synopkghelper update rescriptum port-config ``` +## Servir les médias d'installation, et le PXE + +Le paquet sait aussi servir l'installeur lui-même — noyaux, initrds et images — depuis le +NAS qui décide déjà la réponse. C'est éteint jusqu'à ce que vous l'allumiez : + +1. Décommentez `RESCRIPTUM_MEDIA_DIR` dans le fichier d'environnement et redémarrez le + paquet. +2. Posez une ISO dans le dossier `media` du partage `rescriptum`, via File Station ou SMB. +3. Enregistrez-la, pour qu'elle soit vérifiée et analysée une fois plutôt qu'à chaque + requête : + +```console +$ rescriptum-cli media add /volume1/rescriptum/media/proxmox-ve_8.4-1.iso \ + --sha256 9f86d081884c7d65… +$ rescriptum-cli media list +``` + +Le listener média est sur le **port 8001**, déjà déclaré au pare-feu à côté du port de +réponse — il reste à créer la règle. + +**Aucune image n'est livrée avec le paquet**, et aucune ne le sera jamais : une ISO est +l'artefact de quelqu'un d'autre, elle pèse des gigaoctets, et elle évolue à son rythme. Ce +dossier est là où vous les gardez, et c'est **l'archive** — rien ici ne modifie une image +après son arrivée. Préparer une image Proxmox produit un fichier compagnon de deux cents +octets et une injection appliquée au fil de l'eau, donc les octets sur disque restent +exactement ce que Proxmox a publié et leur somme reste vérifiable contre celle de Proxmox. +Voir [Servir les médias de démarrage](./media.md). + +### TFTP : celui de DSM, pas le nôtre + +**Le paquet ne peut pas faire tourner de serveur TFTP, et ce n'est pas un oubli.** Le port +69 est privilégié, et DSM 7 n'autorise pas un paquet non signé à tourner en root — définir +`RESCRIPTUM_TFTP_ADDR` produirait donc un paquet qui refuse de démarrer. C'est documenté +comme indisponible dans le fichier d'environnement plutôt que proposé et cassé. + +DSM a son propre serveur TFTP, et c'est le bon ici : + +1. **Panneau de configuration → Services de fichiers → Avancé → TFTP** — activez-le, et + définissez la racine sur le dossier `boot` du partage `rescriptum`. +2. Posez-y les chargeurs. Ils ne sont pas non plus dans le paquet — c'est iPXE, en GPLv2, + et ils ont leur place à côté plutôt que soudés dedans. Depuis n'importe quelle machine + Linux avec une chaîne de compilation C : + + ```console + $ packaging/ipxe/build.sh --out /chemin/vers/rescriptum/boot + ``` +3. Faites pointer le DHCP vers ce NAS — **Panneau de configuration → Serveur DHCP → PXE** + si le NAS sert le DHCP, ou votre propre serveur avec ce qu'imprime : + + ```console + $ rescriptum-cli boot dhcp-snippet --format dnsmasq + ``` + +**Tout ce qui suit le chargeur appartient à ce paquet.** Le chargeur enchaîne vers le port +8001, et à partir de là le menu, les réponses et les images sont tous servis par +rescriptum. DSM livre un fichier ; c'est toute sa part. + +### Un réglage qui mérite d'être rempli + +``` +RESCRIPTUM_PUBLIC_HOST=192.168.1.10 +``` + +Chaque script généré nomme cette adresse. Laissée vide, elle est déduite en interrogeant +la table de routage, et **un NAS est souvent multi-domicilié** — la déduction porte alors +sur la mauvaise interface, et le symptôme est une machine qui démarre, enchaîne, et se +bloque sur une adresse qui n'existe pas. Le journal de démarrage dit quelle adresse a été +devinée ; cette ligne est le seul endroit où la réponse apparaît. + ## Le journal `RESCRIPTUM_LOG_FILE` pointe le serveur vers diff --git a/docs/guide/operations/synology.md b/docs/guide/operations/synology.md index a34091c..0d5d8e9 100644 --- a/docs/guide/operations/synology.md +++ b/docs/guide/operations/synology.md @@ -57,8 +57,12 @@ and then the package: ## What the package does not do -Four things worth knowing before they surprise you. +Five things worth knowing before they surprise you. +- **It cannot serve TFTP.** Port 69 is privileged and DSM 7 does not let an unsigned + package run as root, so the loader handoff is DSM's own TFTP server's job — see + [Serving installer media, and PXE](#serving-installer-media-and-pxe). Everything after + the loader is this package's. - **It does not open the firewall.** Registering the port makes *rescriptum* appear by name in the rule editor instead of you typing a number. If your firewall is on with a default-deny rule, you still have to create the rule. @@ -210,6 +214,71 @@ the firewall entry, which does not follow by itself: $ sudo /usr/syno/sbin/synopkghelper update rescriptum port-config ``` +## Serving installer media, and PXE + +The package can also serve the installer itself — kernels, initrds and images — from the +same NAS that decides the answer. It is off until you turn it on: + +1. Uncomment `RESCRIPTUM_MEDIA_DIR` in the env file and restart the package. +2. Drop an ISO into the `rescriptum` share's `media` folder, over File Station or SMB. +3. Register it, so it is verified and probed once rather than per request: + +```console +$ rescriptum-cli media add /volume1/rescriptum/media/proxmox-ve_8.4-1.iso \ + --sha256 9f86d081884c7d65… +$ rescriptum-cli media list +``` + +The media listener is on **port 8001**, already registered with the firewall alongside +the answer port — you still have to create the rule. + +**No image ships with the package**, and none ever will: an ISO is somebody else's +artefact, gigabytes, on its own schedule. That folder is where you keep them, and it is +**the archive** — nothing here modifies an image after it lands. Preparing a Proxmox +image produces a two-hundred-byte sidecar and an injection applied on the wire, so the +bytes on disk stay exactly what Proxmox published and their checksum stays verifiable +against Proxmox's own. See [Serving boot media](./media.md). + +### TFTP: use DSM's, not ours + +**The package cannot run a TFTP server, and that is not an oversight.** Port 69 is +privileged, and DSM 7 does not let an unsigned package run as root — so setting +`RESCRIPTUM_TFTP_ADDR` would produce a package that refuses to start. It is documented in +the env file as unavailable rather than offered and broken. + +DSM has its own TFTP server, and it is the right one here: + +1. **Control Panel → File Services → Advanced → TFTP** — enable it, and set the root to + the `rescriptum` share's `boot` folder. +2. Put the loaders there. They are not in the package either — they are iPXE, GPLv2, and + belong beside it rather than welded into it. On any Linux box with a C toolchain: + + ```console + $ packaging/ipxe/build.sh --out /path/to/rescriptum/boot + ``` +3. Point DHCP at this NAS — **Control Panel → DHCP Server → PXE** if the NAS serves DHCP, + or your own server with what this prints: + + ```console + $ rescriptum-cli boot dhcp-snippet --format dnsmasq + ``` + +**Everything after the loader is this package's.** The loader chains to port 8001, and +from there the menu, the answers and the images are all served by rescriptum. DSM hands +over one file; that is the whole of its part. + +### One setting worth filling in + +``` +RESCRIPTUM_PUBLIC_HOST=192.168.1.10 +``` + +Every generated script names this address. Left empty it is derived by asking the routing +table, and **a NAS is often multi-homed** — the derived answer is then the wrong +interface, and the symptom is a machine that boots, chains, and hangs on an address that +does not exist. The startup log says which address was guessed; that line is the only +place the answer appears. + ## The log `RESCRIPTUM_LOG_FILE` points the server at `/var/packages/rescriptum/var/rescriptum.log`, diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh index 3215fb5..61589eb 100755 --- a/packaging/dsm/lifecycle-test.sh +++ b/packaging/dsm/lifecycle-test.sh @@ -107,7 +107,17 @@ mode=$(file_mode "$ENV_FILE") [ "$(value_of RESCRIPTUM_LISTEN_ADDR)" = "0.0.0.0:$PORT" ] && ok "the wizard's port reached the env file" || bad "listen addr is $(value_of RESCRIPTUM_LISTEN_ADDR)" [ "$(value_of RESCRIPTUM_ANSWERS_DIR)" = "$SHARE/answers" ] && ok "the answers default to the share" || bad "answers dir is $(value_of RESCRIPTUM_ANSWERS_DIR)" grep -q "^RESCRIPTUM_DB_PATH=$SHARE/answers.db\$" "$ENV_FILE" && ok "the database path is pre-set in the share" || bad "RESCRIPTUM_DB_PATH is not pre-set — switching stores would be a fatal start" -grep -q "dst.ports=\"$PORT/tcp\"" "$ROOT/target/port_conf/rescriptum.sc" && ok "the .sc file carries the chosen port" || bad ".sc file: $(tail -1 "$ROOT/target/port_conf/rescriptum.sc")" +# Both listeners, and the media one even while boot media is commented out of the env +# file: registering a port does not open it, and the alternative is an operator who +# enables media and then cannot find rescriptum in the firewall list. +grep -q "dst.ports=\"$PORT/tcp 8001/tcp\"" "$ROOT/target/port_conf/rescriptum.sc" && ok "the .sc file carries the answer port and the media one" || bad ".sc file: $(tail -1 "$ROOT/target/port_conf/rescriptum.sc")" + +# **The package must not ship a configuration that refuses to start.** Naming a media +# address with no media directory is a startup error, and the first version of this +# wrote exactly that — the harness caught a package that could not start at all. +grep -q "^RESCRIPTUM_MEDIA_ADDR=" "$ENV_FILE" && bad "RESCRIPTUM_MEDIA_ADDR is live while RESCRIPTUM_MEDIA_DIR is not — that is a fatal start" || ok "no live media address without a media directory" +grep -q "^# RESCRIPTUM_MEDIA_DIR=$SHARE/media\$" "$ENV_FILE" && ok "the media folder is pre-set, one uncommented line away" || bad "no commented RESCRIPTUM_MEDIA_DIR pointing at the share" +grep -q "TFTP" "$ENV_FILE" && ok "the file says why TFTP is not available here" || bad "nothing in the file explains the missing TFTP" section "install without a wizard (silent_install, or a reinstall that shows none)" saved=$(cat "$ENV_FILE") @@ -145,6 +155,11 @@ out=$(sss start 2>&1) rc=$? [ $rc -eq 0 ] && ok "start returns 0" || bad "start returned $rc: $out" [ -d "$SHARE/answers" ] && ok "start created the answers directory inside the share" || bad "no answers directory — DSM creates the share, not this" +# Made whether or not the env file names them yet: a folder that only appears once a +# setting is enabled is one nobody discovers, and the boot one is what DSM's own TFTP +# server is pointed at. +[ -d "$SHARE/media" ] && ok "and the media folder, ready for an ISO" || bad "no $SHARE/media" +[ -d "$SHARE/boot" ] && ok "and the boot folder, for DSM's own TFTP server" || bad "no $SHARE/boot" answered=no for _ in 1 2 3 4 5 6 7 8 9 10; do diff --git a/packaging/dsm/payload/port_conf/rescriptum.sc b/packaging/dsm/payload/port_conf/rescriptum.sc index 775e91c..6738247 100644 --- a/packaging/dsm/payload/port_conf/rescriptum.sc +++ b/packaging/dsm/payload/port_conf/rescriptum.sc @@ -1,5 +1,5 @@ [rescriptum] title="rescriptum" -desc="Unattended-installation answer server" +desc="Unattended-installation answer server, and the installer media it serves" port_forward="no" -dst.ports="8000/tcp" +dst.ports="8000/tcp 8001/tcp" diff --git a/packaging/dsm/payload/ui/texts/enu/strings b/packaging/dsm/payload/ui/texts/enu/strings index 4ee457a..6738b9d 100644 --- a/packaging/dsm/payload/ui/texts/enu/strings +++ b/packaging/dsm/payload/ui/texts/enu/strings @@ -45,6 +45,19 @@ RESCRIPTUM_CAPTURE_DIR = "Capture folder" RESCRIPTUM_ANSWER_TOKEN = "Installer token" RESCRIPTUM_ADMIN_ADDR = "Write API address" RESCRIPTUM_ADMIN_TOKEN = "Write API token" +RESCRIPTUM_PUBLIC_HOST = "This server's address" +RESCRIPTUM_MEDIA_DIR = "Installer images folder" +RESCRIPTUM_MEDIA_ADDR = "Media listen address" +RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Transfer deadline (seconds)" +RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Simultaneous downloads" +RESCRIPTUM_BOOT_ALLOW = "Allowed client networks" +RESCRIPTUM_BOOT_DIR = "Loaders folder" +RESCRIPTUM_TFTP_ADDR = "TFTP listen address" +RESCRIPTUM_BOOT_TIMEOUT_SECS = "Menu timeout (seconds)" +RESCRIPTUM_BOOT_LOGO = "Menu logo" +RESCRIPTUM_BOOT_TITLE = "Menu title" +RESCRIPTUM_USER = "Run as user" +RESCRIPTUM_GROUP = "Run as group" [help] RESCRIPTUM_STORE = "Where answers come from: a folder of documents, or a database." @@ -60,6 +73,19 @@ RESCRIPTUM_CAPTURE_DIR = "Record what installers actually send, for when nothing RESCRIPTUM_ANSWER_TOKEN = "Required of installers that have one to offer. Off by default." RESCRIPTUM_ADMIN_ADDR = "The write API's own listener. Keep it on loopback and reach it over SSH." RESCRIPTUM_ADMIN_TOKEN = "At least 16 characters, and required whenever the write API is on." +RESCRIPTUM_PUBLIC_HOST = "The address this NAS is reachable at, written into every generated script. A host, never a URL. Left empty it is guessed, which a multi-homed NAS often gets wrong." +RESCRIPTUM_MEDIA_DIR = "Installer images. Empty means no media and no second listener. Nothing here ever modifies an image: this folder is the archive." +RESCRIPTUM_MEDIA_ADDR = "Where machines fetch kernels, initrds and images. Its own listener, because a download holds a connection for minutes and answers must not queue behind one." +RESCRIPTUM_MEDIA_TIMEOUT_SECS = "How long one image transfer may take. Deliberately not the answer endpoint's ten seconds." +RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Downloads at once. Low on purpose: each holds its slot for minutes, and this NAS has one disk." +RESCRIPTUM_BOOT_ALLOW = "Client networks allowed to fetch boot media, as CIDRs. Empty means anyone who can reach the port." +RESCRIPTUM_BOOT_DIR = "Where the loaders live. This package cannot serve them itself — see the configuration file — but DSM's own TFTP server can, from this folder." +RESCRIPTUM_TFTP_ADDR = "Not usable in this package: port 69 is privileged and DSM 7 does not let an unsigned package run as root. Use DSM's own TFTP server instead." +RESCRIPTUM_BOOT_TIMEOUT_SECS = "How long the boot menu waits before a machine falls through to its own disk." +RESCRIPTUM_BOOT_LOGO = "A PNG shown behind the boot menu, replacing the built-in one." +RESCRIPTUM_BOOT_TITLE = "The boot menu's title bar, replacing the built-in one." +RESCRIPTUM_USER = "Not usable in this package: it already runs as its own unprivileged user and cannot change identity." +RESCRIPTUM_GROUP = "Not usable in this package, for the same reason as the user above." [status] version = "Version" diff --git a/packaging/dsm/payload/ui/texts/fre/strings b/packaging/dsm/payload/ui/texts/fre/strings index 3838dbd..fe37a60 100644 --- a/packaging/dsm/payload/ui/texts/fre/strings +++ b/packaging/dsm/payload/ui/texts/fre/strings @@ -39,6 +39,19 @@ RESCRIPTUM_CAPTURE_DIR = "Dossier de capture" RESCRIPTUM_ANSWER_TOKEN = "Jeton des installateurs" RESCRIPTUM_ADMIN_ADDR = "Adresse de l'API d'écriture" RESCRIPTUM_ADMIN_TOKEN = "Jeton de l'API d'écriture" +RESCRIPTUM_PUBLIC_HOST = "Adresse de ce serveur" +RESCRIPTUM_MEDIA_DIR = "Dossier des images d'installation" +RESCRIPTUM_MEDIA_ADDR = "Adresse d'écoute des médias" +RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Échéance de transfert (secondes)" +RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Téléchargements simultanés" +RESCRIPTUM_BOOT_ALLOW = "Réseaux clients autorisés" +RESCRIPTUM_BOOT_DIR = "Dossier des chargeurs" +RESCRIPTUM_TFTP_ADDR = "Adresse d'écoute TFTP" +RESCRIPTUM_BOOT_TIMEOUT_SECS = "Délai du menu (secondes)" +RESCRIPTUM_BOOT_LOGO = "Logo du menu" +RESCRIPTUM_BOOT_TITLE = "Titre du menu" +RESCRIPTUM_USER = "Utilisateur d'exécution" +RESCRIPTUM_GROUP = "Groupe d'exécution" [help] RESCRIPTUM_STORE = "D'où viennent les réponses : un dossier de documents, ou une base de données." @@ -54,6 +67,19 @@ RESCRIPTUM_CAPTURE_DIR = "Enregistre ce que les installateurs envoient vraiment, RESCRIPTUM_ANSWER_TOKEN = "Exigé des installateurs qui en présentent un. Désactivé par défaut." RESCRIPTUM_ADMIN_ADDR = "L'écouteur propre à l'API d'écriture. À garder en loopback, joignable par SSH." RESCRIPTUM_ADMIN_TOKEN = "Au moins 16 caractères, et obligatoire dès que l'API d'écriture est active." +RESCRIPTUM_PUBLIC_HOST = "L'adresse à laquelle ce NAS est joignable, écrite dans chaque script généré. Un hôte, jamais une URL. Laissée vide, elle est devinée — ce qu'un NAS multi-domicilié rate souvent." +RESCRIPTUM_MEDIA_DIR = "Les images d'installation. Vide, pas de média ni de second listener. Rien ici ne modifie jamais une image : ce dossier est l'archive." +RESCRIPTUM_MEDIA_ADDR = "Là où les machines récupèrent noyaux, initrds et images. Son propre listener, car un téléchargement retient une connexion des minutes durant et les réponses ne doivent pas faire la queue derrière." +RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Durée maximale d'un transfert d'image. Volontairement pas les dix secondes du point de réponse." +RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Téléchargements à la fois. Bas exprès : chacun retient sa place des minutes durant, et ce NAS a un disque." +RESCRIPTUM_BOOT_ALLOW = "Réseaux clients autorisés à récupérer les médias, en CIDR. Vide, quiconque atteint le port." +RESCRIPTUM_BOOT_DIR = "Où vivent les chargeurs. Ce package ne peut pas les servir lui-même — voir le fichier de configuration — mais le serveur TFTP de DSM le peut, depuis ce dossier." +RESCRIPTUM_TFTP_ADDR = "Inutilisable dans ce package : le port 69 est privilégié et DSM 7 n'autorise pas un package non signé à tourner en root. Utilisez le serveur TFTP de DSM." +RESCRIPTUM_BOOT_TIMEOUT_SECS = "Combien de temps le menu de démarrage attend avant qu'une machine retombe sur son propre disque." +RESCRIPTUM_BOOT_LOGO = "Un PNG affiché derrière le menu de démarrage, à la place de celui intégré." +RESCRIPTUM_BOOT_TITLE = "La barre de titre du menu de démarrage, à la place de celle intégrée." +RESCRIPTUM_USER = "Inutilisable dans ce package : il tourne déjà sous son propre utilisateur non privilégié et ne peut pas changer d'identité." +RESCRIPTUM_GROUP = "Inutilisable dans ce package, pour la même raison que l'utilisateur ci-dessus." [status] version = "Version" diff --git a/packaging/dsm/scripts/postinst b/packaging/dsm/scripts/postinst index 31776f7..56fa989 100755 --- a/packaging/dsm/scripts/postinst +++ b/packaging/dsm/scripts/postinst @@ -37,7 +37,13 @@ EXAMPLE="$DEST/etc/$PKG.env.example" SC_FILE="$DEST/port_conf/$PKG.sc" SHARE_ANSWERS="$ROOT/shares/$PKG/answers" +SHARE_MEDIA="$ROOT/shares/$PKG/media" +SHARE_BOOT="$ROOT/shares/$PKG/boot" DEFAULT_PORT=8000 +# Fixed, and a contract rather than a preference: the loader we ship embeds a script +# that chains to `${next-server}:8001` before any deployment exists, so a media listener +# anywhere else is one every already-shipped loader cannot reach. +MEDIA_PORT=8001 say() { echo "$PKG: $*"; } @@ -88,6 +94,12 @@ RESCRIPTUM_LOG_FILE=$VAR/$PKG.log RESCRIPTUM_DB_PATH=$ROOT/shares/$PKG/answers.db # RESCRIPTUM_STORE=sqlite +# The address this NAS is reachable at, which the server writes into every script it +# generates. A NAS is often multi-homed, and the value derived by asking the routing +# table is then the wrong interface — a machine that boots, chains, and hangs on an +# address that does not exist. Set it once and the guessing stops. +# RESCRIPTUM_PUBLIC_HOST= + # Keeps the failures and drops the requests that worked. Worth it once a rollout is # routine: successful answers are the only high-volume thing in the log. # RESCRIPTUM_LOG=problems @@ -105,8 +117,54 @@ RESCRIPTUM_DB_PATH=$ROOT/shares/$PKG/answers.db # and speaks plain HTTP, which is why it is deliberately not registered with the firewall. # It also requires the sqlite store and a token of at least 16 characters; both are # startup errors, so getting them wrong shows up as a package that will not start. -# RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001 +# Not 8001: that is the media listener's port below, and two listeners on one port is a +# startup error rather than a race. +# RESCRIPTUM_ADMIN_ADDR=127.0.0.1:9000 # RESCRIPTUM_ADMIN_TOKEN= + +# --------------------------------------------------------------------------- +# Boot media — serving the installer itself, not only its answer. +# +# Uncomment the directory and restart the package. The share already has the folder; +# drop an ISO in it over File Station or SMB, then register it: +# +# rescriptum-cli media add $SHARE_MEDIA/proxmox-ve_8.4-1.iso --sha256 ... +# rescriptum-cli media list +# +# **No image ships with this package.** An ISO is somebody else's artefact, gigabytes, +# on its own schedule; this folder is where you keep them and it is the archive — +# nothing here ever modifies an image after it lands. +# RESCRIPTUM_MEDIA_DIR=$SHARE_MEDIA + +# The listener's own port. Left commented because the default is already $MEDIA_PORT and +# the firewall entry above already covers it — and because naming an address with no +# directory to serve is a startup error, so an uncommented line here would be a package +# that refuses to start until somebody uncommented the one above too. +# RESCRIPTUM_MEDIA_ADDR=0.0.0.0:$MEDIA_PORT + +# --------------------------------------------------------------------------- +# TFTP: **not available in this package, and it is not an oversight.** +# +# DSM 7 does not let an unsigned package run as root, and port 69 is privileged, so +# nothing here can bind it. Setting RESCRIPTUM_TFTP_ADDR would produce a package that +# refuses to start, which is why it is not offered. +# +# DSM has its own TFTP server, and it is the right one to use: +# +# 1. Control Panel > File Services > Advanced > TFTP: enable it, and set the root to +# the '$PKG' shared folder's boot folder. +# 2. Put the loaders there. On any Linux box with a C toolchain: +# packaging/ipxe/build.sh --out /path/to/$PKG/boot +# They are not in this package either: they are GPLv2 and belong beside it, not +# welded into it. +# 3. Point DHCP at this NAS — Control Panel > DHCP Server > PXE if this NAS serves +# DHCP, or your own server with what +# rescriptum-cli boot dhcp-snippet --format dnsmasq +# prints. +# +# The rest of the chain is this package's: the loader chains to port 8001 above, and +# everything after that is HTTP. +# RESCRIPTUM_BOOT_DIR=$SHARE_BOOT BODY } @@ -152,7 +210,10 @@ fi # sudo /usr/syno/sbin/synopkghelper update rescriptum port-config # because Acquire skips a file that already exists in /usr/local/etc/service.d/. if [ -f "$SC_FILE" ]; then - sed "s|^dst.ports=.*|dst.ports=\"$port/tcp\"|" "$SC_FILE" >"$SC_FILE.new" && + # Both listeners. The media one is registered even while it is commented out of the + # env file: registering a port does not open it, and the alternative is an operator + # who enables boot media and then cannot find rescriptum in the firewall list. + sed "s|^dst.ports=.*|dst.ports=\"$port/tcp $MEDIA_PORT/tcp\"|" "$SC_FILE" >"$SC_FILE.new" && mv "$SC_FILE.new" "$SC_FILE" fi diff --git a/packaging/dsm/scripts/start-stop-status b/packaging/dsm/scripts/start-stop-status index adf781e..ba0fdb5 100755 --- a/packaging/dsm/scripts/start-stop-status +++ b/packaging/dsm/scripts/start-stop-status @@ -27,6 +27,9 @@ ROOT="${RESCRIPTUM_PKG_ROOT:-/var/packages/${SYNOPKG_PKGNAME:-$PKG}}" DEST="${SYNOPKG_PKGDEST:-$ROOT/target}" VAR="${SYNOPKG_PKGVAR:-$ROOT/var}" ETC="$ROOT/etc" +# The data share DSM created. `shares/` is a symlink into the volume; the package root +# is the fixed path, which is why it is derived from ROOT rather than from DEST. +SHARE="$ROOT/shares/$PKG" BIN="$DEST/bin/$PKG" ENV_FILE="$ETC/$PKG.env" @@ -96,6 +99,16 @@ start() { fi fi + # The media and boot folders, made whether or not the env file names them yet. An + # operator who uncomments RESCRIPTUM_MEDIA_DIR should find somewhere to drop an ISO + # already there — and a folder that only appears after a setting is enabled is one + # nobody discovers. Both are inside the share DSM created, so this costs nothing + # when they are unused, and neither may abort the start. + for extra in media boot; do + dir="$SHARE/$extra" + [ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null || true + done + RESCRIPTUM_ENV_FILE="$ENV_FILE" export RESCRIPTUM_ENV_FILE From 791a1dc36d868bb440f2a4ab3ebe3bd5f95ec9dc Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 15:45:43 +0200 Subject: [PATCH 19/59] test(dsm): the package runs on a real DSM 7.2.2 machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 40 checks green on the VM, `on-dsm.sh` exit 0: installed, started, answered a machine with its own merged answer, the desktop application linked and its CGI refusing an unauthenticated request, logrotate rotating a live descriptor, an upgrade over a hand-edited env file leaving it untouched, and an uninstall leaving the share alone. The four new assertions are the on-machine evidence for boot media: the `media` and `boot` folders are created by the start script and writable by the package user. Confirmed by hand first — `drwxrwxrwx+ rescriptum` on the real volume — and now asserted, so it is tested rather than observed once. `check-spk.sh` also asserts the shipped `.sc` template registers the media port. That guard was seen red without being broken on purpose: it fails the older packages still in `dist/`, which is exactly what it is for. DSM's own TFTP server was verified to exist on the machine rather than assumed from documentation — `/usr/bin/opentftp`, with `rc.sysv/tftp.sh` beside it. That is what the env file and the guide now point operators at, and it is the load-bearing claim in both. CLAUDE.md records the fourth place DSM pressed back: a privileged port, answered by not having one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- CLAUDE.md | 19 +++++++++++++------ packaging/dsm/check-spk.sh | 7 +++++++ packaging/dsm/vm/remote-check.sh | 10 ++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 37fc56d..1267f3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -732,10 +732,15 @@ strip = true `packaging/dsm/` wraps an already-built binary as a DSM 7 `.spk`. It is a **release format**, exactly like the `.tar.gz` archives — no DSM-specific build, no feature flag, -nothing in `src/`. The three places DSM pressed back are answered in packaging: log rotation -by a `copytruncate` stanza, a CLI that cannot find its configuration by a three-line wrapper -(`rescriptum-cli`, which names `RESCRIPTUM_ENV_FILE`), and no settings panel by the desktop -application below. If this ever seems to need a `#[cfg]`, the design has gone wrong. +nothing in `src/`. The **four** places DSM pressed back are answered in packaging: log +rotation by a `copytruncate` stanza, a CLI that cannot find its configuration by a +three-line wrapper (`rescriptum-cli`, which names `RESCRIPTUM_ENV_FILE`), no settings panel +by the desktop application below, and **a privileged port by not having one**. DSM 7 does +not let an unsigned package run as root, so TFTP's port 69 is unreachable: the env file +says so and points at DSM's own TFTP server (`/usr/bin/opentftp`, verified on a 7.2.2 +machine) pointed at the share's `boot` folder. Media over HTTP on 8001 works normally, and +both ports are registered with the firewall. `RESCRIPTUM_USER`/`_GROUP` are documented the +same way — the package already is its own unprivileged user. If this ever seems to need a `#[cfg]`, the design has gone wrong. ```bash ./build.sh --spk x86_64-unknown-linux-musl # build, then wrap @@ -753,8 +758,10 @@ a canary — with `etc/` surviving and with it wiped — and an uninstall; both push. `vm/on-dsm.sh` runs the rest on a DSM 7 VM and then on the DS416j: `data-share`'s ACL, `port-config`, the generated unit, `logrotate -f` against a live descriptor, and whether Package Center accepts the archive at all. **Nothing ships on VM evidence alone**, -and `lifecycle-test.sh` was watched failing — breaking four guards turns 33 green into 25 -green and 8 red. +and `lifecycle-test.sh` was watched failing — reintroducing one defect turns 54 green into +46 green and 8 red. **It earns its keep:** its first run over the boot-media package caught +a live `RESCRIPTUM_MEDIA_ADDR` with `RESCRIPTUM_MEDIA_DIR` still commented, which is a +startup error — the package would not have started at all. ### The desktop application diff --git a/packaging/dsm/check-spk.sh b/packaging/dsm/check-spk.sh index 27a8ace..8197615 100755 --- a/packaging/dsm/check-spk.sh +++ b/packaging/dsm/check-spk.sh @@ -210,6 +210,13 @@ check_one() { ok "port_conf/rescriptum.sc declares [rescriptum]" || bad "port_conf/rescriptum.sc does not declare [rescriptum]" + # Both listeners. postinst rewrites this line with the wizard's port, but a template + # that lost the media one would produce a firewall entry an operator cannot find + # rescriptum in once they enable boot media — and nothing else would say so. + grep -q '^dst.ports=.*8001/tcp' "$work/target/port_conf/rescriptum.sc" && + ok "and registers the media port beside the answer one" || + bad "port_conf/rescriptum.sc does not register the media port" + # ── the desktop application ──────────────────────────────────────────────── local uidir appname uidir=$(info_value dsmuidir "$work/INFO") diff --git a/packaging/dsm/vm/remote-check.sh b/packaging/dsm/vm/remote-check.sh index 34d3b6a..1fe0c26 100755 --- a/packaging/dsm/vm/remote-check.sh +++ b/packaging/dsm/vm/remote-check.sh @@ -115,6 +115,16 @@ PORT=$(sed -n 's/^RESCRIPTUM_LISTEN_ADDR=.*:\([0-9]*\)$/\1/p' "$ROOT/etc/$PKG.en [ -n "$PORT" ] || PORT=8000 [ -d "$SHARE/answers" ] && ok "start created the answers directory inside the share" || bad "no $SHARE/answers" +# The media and boot folders, made whether or not the env file names them yet: a folder +# that only appears once a setting is enabled is one nobody discovers, and the boot one +# is what DSM's own TFTP server gets pointed at. The package cannot serve TFTP itself — +# port 69 is privileged and DSM 7 does not let an unsigned package run as root. +for extra in media boot; do + [ -d "$SHARE/$extra" ] && ok "and the $extra folder, ready to be filled" || bad "no $SHARE/$extra" + sudo -u "$PKG" test -w "$SHARE/$extra" 2>/dev/null && + ok " which the package user can write" || + bad " but the package user cannot write it" +done if sudo -u "$PKG" test -w "$SHARE/answers" 2>/dev/null; then ok "the package user can write it — the ACL landed on the right name" else From aef7b2b62129eeedf786877442e550cf8d692fa8 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 16:51:22 +0200 Subject: [PATCH 20/59] fix(boot): RESCRIPTUM_TFTP_ADDR=off, and name the folders the package creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the DSM settings panel showed, and the second was a trap I had built. **The empty fields are by design** — a variable whose default is "off" has nothing to show — but two of them should not have been empty. The package creates `media/` and `boot/` in its share and then left the settings blank, so an operator faced a field with no hint of what to type for a folder that already existed. Both are now named in the env file. **Naming the boot folder would have killed the package.** It implied a TFTP server on port 69, which a DSM package cannot bind — and a failed bind is a `return ExitCode::FAILURE`, so the whole server dies, not just TFTP. The help text under that very field invited the operator to fill it in. Measured on the machine rather than assumed: a non-root uid on DSM 7.2.2 gets `[Errno 13] Permission denied` on UDP 69. Neither macOS nor a Docker container reproduces it — Docker grants NET_BIND_SERVICE by default — which is exactly why it had to be the real thing. So `RESCRIPTUM_TFTP_ADDR` gains `off`, spelled the way `RESCRIPTUM_LOG=off` already is. **Off is a value, not an absence**: the loaders stay served over HTTP at `/boot/…` and stay checked by `boot check`; only the listener goes, and something else hands the file over. The default has not moved — a plain Linux host that names a boot directory still gets the TFTP server the plan calls core. Verified on DSM 7.2.2, from the machine's own log: media listening on 0.0.0.0:8001 — serving …/media tftp is off — /volume1/rescriptum/boot is still served over HTTP at /boot/… rescriptum 0.2.0 listening on 0.0.0.0:8000 40 checks green on the VM, 55 in the lifecycle harness, and the new guard watched red: commenting the `off` line back out turns 55 green into 54 and 1. Also corrects two passages in the netboot guide that had gone stale — the loaders are built now, and `BOOT_DIR` is no longer simply "the off switch for TFTP". 540 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- docs/guide/operations/netboot.fr.md | 36 +++++--- docs/guide/operations/netboot.md | 31 +++++-- docs/guide/reference/configuration.fr.md | 2 +- docs/guide/reference/configuration.md | 2 +- packaging/dsm/lifecycle-test.sh | 11 ++- packaging/dsm/payload/ui/texts/enu/strings | 2 +- packaging/dsm/payload/ui/texts/fre/strings | 2 +- packaging/dsm/scripts/postinst | 25 ++++-- src/config.rs | 96 ++++++++++++++++++++-- src/main.rs | 60 +++++++++----- 10 files changed, 206 insertions(+), 61 deletions(-) diff --git a/docs/guide/operations/netboot.fr.md b/docs/guide/operations/netboot.fr.md index d64b2ca..e93448d 100644 --- a/docs/guide/operations/netboot.fr.md +++ b/docs/guide/operations/netboot.fr.md @@ -42,19 +42,29 @@ $ export RESCRIPTUM_BOOT_DIR=/srv/boot # les chargeurs $ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 # ce que nommeront les scripts générés ``` -`RESCRIPTUM_BOOT_DIR` est l'interrupteur de TFTP comme `RESCRIPTUM_MEDIA_DIR` l'est des -médias : non définie, il n'y a aucun listener TFTP. +`RESCRIPTUM_BOOT_DIR` dit où sont les chargeurs : non définie, il n'y a aucun listener +TFTP et rien sur `/boot/…`. La nommer démarre TFTP sauf si vous dites le contraire — voir +`off` plus bas. Le port 69 est privilégié, et c'est le *seul* port privilégié que ce serveur demandera -jamais — sans répondeur DHCP, il n'y a rien après 67 ni 4011. Trois façons de l'obtenir, -toutes portables : +jamais — sans répondeur DHCP, il n'y a rien après 67 ni 4011. Quatre façons de traiter la +question, toutes portables : ```console $ export RESCRIPTUM_USER=rescriptum # démarrer en root, lier, puis abandonner $ setcap cap_net_bind_service=+ep rescriptum # ou n'accorder que cette capacité $ export RESCRIPTUM_TFTP_ADDR=0.0.0.0:6969 # ou le déplacer, si leur DHCP sait le dire +$ export RESCRIPTUM_TFTP_ADDR=off # ou n'avoir aucun listener du tout ``` +**`off` est une valeur, pas une absence**, et c'est ce qui rend sûr de nommer un dossier +de chargeurs sur une plateforme incapable de lier un port privilégié. Sans elle, dire au +serveur où sont les chargeurs implique un serveur TFTP sur le port 69 — et un bind qui +échoue est un serveur qui ne démarre pas, ce qui transforme un réglage en piège. Avec +elle, les chargeurs restent servis en HTTP sur `/boot/…` et restent vérifiés par +`boot check` ; seul le listener disparaît, et autre chose livre le fichier. C'est +exactement ainsi que le [paquet Synology](./synology.md) est livré. + **On lie d'abord, on abandonne ensuite**, toujours. L'ordre inverse fonctionne en test sous root et échoue au déploiement, à un redémarrage — le seul moment où personne ne regarde. @@ -147,12 +157,18 @@ registre ne donnerait rien à la moitié d'un parc. réseau. Toutes les variantes sont servies et la table choisit ; c'est précisément le savoir qu'un exploitant ne devrait pas avoir à acquérir. -::: warning Les chargeurs ne sont pas encore construits -`packaging/ipxe/` contient le branding, le script embarqué et la construction — mais rien -dans ce dépôt ne les a compilés, et aucune version publiée ne les distribue. En -attendant, construisez-les vous-même (`packaging/ipxe/build.sh`) ou pointez -`RESCRIPTUM_BOOT_DIR` vers des chargeurs venus d'ailleurs, à condition qu'ils enchaînent -vers ce serveur plutôt que vers Internet — voir ci-dessous. +::: warning Aucune version publiée ne distribue encore les chargeurs +`packaging/ipxe/build.sh` construit les huit depuis un commit épinglé et a été exécuté, +mais rien n'est publié comme artefact de release — pour l'instant, construisez-les +vous-même : + +```console +$ packaging/ipxe/build.sh --out /srv/boot +$ rescriptum boot check +``` + +Un chargeur venu d'ailleurs convient aussi, à condition qu'il enchaîne vers *ce* serveur +plutôt que vers Internet — voir ci-dessous pourquoi un chargeur d'origine ne le fait pas. ::: ## Comment iPXE finit par parler à *nous* diff --git a/docs/guide/operations/netboot.md b/docs/guide/operations/netboot.md index b1e8747..868caa3 100644 --- a/docs/guide/operations/netboot.md +++ b/docs/guide/operations/netboot.md @@ -40,19 +40,28 @@ $ export RESCRIPTUM_BOOT_DIR=/srv/boot # the loaders $ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 # what generated scripts will name ``` -`RESCRIPTUM_BOOT_DIR` is the off switch for TFTP the way `RESCRIPTUM_MEDIA_DIR` is for -media: unset, there is no TFTP listener at all. +`RESCRIPTUM_BOOT_DIR` says where the loaders are: unset, there is no TFTP listener and +nothing at `/boot/…`. Naming it starts TFTP unless you say otherwise — see `off` below. Port 69 is privileged, and it is the *only* privileged port this server ever wants — -with no DHCP responder there is nothing after 67 or 4011. Three ways to have it, all +with no DHCP responder there is nothing after 67 or 4011. Four ways to deal with it, all portable: ```console $ export RESCRIPTUM_USER=rescriptum # start as root, bind, then drop $ setcap cap_net_bind_service=+ep rescriptum # or grant just that one capability $ export RESCRIPTUM_TFTP_ADDR=0.0.0.0:6969 # or move it, if their DHCP can say so +$ export RESCRIPTUM_TFTP_ADDR=off # or have no listener at all ``` +**`off` is a value, not an absence**, and it is what makes naming a boot directory safe +on a platform that cannot bind a privileged port. Without it, telling the server where +the loaders are implies a TFTP server on port 69 — and a failed bind is a server that +does not start, which turns a setting into a trap. With it, the loaders are still served +over HTTP at `/boot/…` and still checked by `boot check`; only the listener is gone, and +something else hands the file over. That is exactly how the [Synology +package](./synology.md) ships. + **Binding happens first and dropping second**, always. The other order works in testing as root and fails on deployment, at a reboot, which is the one moment nobody is watching. @@ -138,11 +147,17 @@ generated from the registry alone would hand half a fleet nothing. are served and the table picks; this is precisely the knowledge an operator should not have to acquire. -::: warning The loaders are not built yet -`packaging/ipxe/` holds the branding, the embedded script and the build — but nothing in -this repository has compiled them, and no release publishes them. Until that lands you can -build them yourself (`packaging/ipxe/build.sh`) or point `RESCRIPTUM_BOOT_DIR` at loaders -from elsewhere, provided they chain to this server rather than to the internet — see below. +::: warning No release publishes the loaders yet +`packaging/ipxe/build.sh` builds all eight from a pinned upstream commit and has been run, +but nothing is published as a release artifact — so for now you build them yourself: + +```console +$ packaging/ipxe/build.sh --out /srv/boot +$ rescriptum boot check +``` + +A loader from elsewhere works too, provided it chains to *this* server rather than to the +internet — see below for why a stock one does not. ::: ## How iPXE ends up talking to *us* diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index 4a135bf..e0315ca 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -36,7 +36,7 @@ pas de *format* de configuration à apprendre ni de ligne de commande à se trom | `RESCRIPTUM_PUBLIC_HOST` | déduit | L'hôte que nomment les URL générées. **Un hôte, jamais une URL** | | `RESCRIPTUM_BOOT_ALLOW` | non défini | CIDR clients autorisés à récupérer les médias. Non défini = quiconque atteint le port | | `RESCRIPTUM_BOOT_DIR` | non défini | Chargeurs et menus, distribués en TFTP. **Non défini = pas de TFTP du tout** | -| `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | Le listener TFTP. Le port 69 est privilégié ; voir `RESCRIPTUM_USER` | +| `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | Le listener TFTP, ou **`off`** pour aucun. Le port 69 est privilégié ; voir `RESCRIPTUM_USER` | | `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Secondes avant que le menu ne retombe sur le disque local | | `RESCRIPTUM_BOOT_LOGO` | intégré | Un PNG à afficher derrière le menu | | `RESCRIPTUM_BOOT_TITLE` | intégré | La barre de titre du menu | diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 8ec8dea..09b195b 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -36,7 +36,7 @@ no configuration *format* to learn and no command line to get wrong. | `RESCRIPTUM_PUBLIC_HOST` | derived | The host generated URLs name. **A host, never a URL** | | `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media. Unset means anyone who can reach the port | | `RESCRIPTUM_BOOT_DIR` | unset | Loaders and menus, handed out over TFTP. **Unset means no TFTP at all** | -| `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | The TFTP listener. Port 69 is privileged; see `RESCRIPTUM_USER` | +| `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | The TFTP listener, or **`off`** for none. Port 69 is privileged; see `RESCRIPTUM_USER` | | `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Seconds before the menu falls through to local boot | | `RESCRIPTUM_BOOT_LOGO` | built-in | A PNG to show behind the menu | | `RESCRIPTUM_BOOT_TITLE` | built-in | The menu's title bar | diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh index 61589eb..0e78e32 100755 --- a/packaging/dsm/lifecycle-test.sh +++ b/packaging/dsm/lifecycle-test.sh @@ -115,8 +115,15 @@ grep -q "dst.ports=\"$PORT/tcp 8001/tcp\"" "$ROOT/target/port_conf/rescriptum.sc # **The package must not ship a configuration that refuses to start.** Naming a media # address with no media directory is a startup error, and the first version of this # wrote exactly that — the harness caught a package that could not start at all. -grep -q "^RESCRIPTUM_MEDIA_ADDR=" "$ENV_FILE" && bad "RESCRIPTUM_MEDIA_ADDR is live while RESCRIPTUM_MEDIA_DIR is not — that is a fatal start" || ok "no live media address without a media directory" -grep -q "^# RESCRIPTUM_MEDIA_DIR=$SHARE/media\$" "$ENV_FILE" && ok "the media folder is pre-set, one uncommented line away" || bad "no commented RESCRIPTUM_MEDIA_DIR pointing at the share" +# The folders the start script creates are *named* in the file, not left blank for an +# operator to guess at in a settings panel with no hint of what to type. +grep -q "^RESCRIPTUM_MEDIA_DIR=$SHARE/media\$" "$ENV_FILE" && ok "the media folder is named, not left to be guessed" || bad "RESCRIPTUM_MEDIA_DIR is not set to $SHARE/media" +grep -q "^RESCRIPTUM_BOOT_DIR=$SHARE/boot\$" "$ENV_FILE" && ok "and the boot folder too" || bad "RESCRIPTUM_BOOT_DIR is not set to $SHARE/boot" + +# **The trap this removes.** Naming a boot folder otherwise starts a TFTP server on port +# 69, which this package cannot bind — and a failed bind is a server that does not start +# at all, so the folder setting would be a trap rather than a constraint. +grep -q "^RESCRIPTUM_TFTP_ADDR=off\$" "$ENV_FILE" && ok "and TFTP is off, which is what makes naming the boot folder safe here" || bad "RESCRIPTUM_TFTP_ADDR is not off — naming a boot folder would stop the package starting" grep -q "TFTP" "$ENV_FILE" && ok "the file says why TFTP is not available here" || bad "nothing in the file explains the missing TFTP" section "install without a wizard (silent_install, or a reinstall that shows none)" diff --git a/packaging/dsm/payload/ui/texts/enu/strings b/packaging/dsm/payload/ui/texts/enu/strings index 6738b9d..10de018 100644 --- a/packaging/dsm/payload/ui/texts/enu/strings +++ b/packaging/dsm/payload/ui/texts/enu/strings @@ -80,7 +80,7 @@ RESCRIPTUM_MEDIA_TIMEOUT_SECS = "How long one image transfer may take. Deliberat RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Downloads at once. Low on purpose: each holds its slot for minutes, and this NAS has one disk." RESCRIPTUM_BOOT_ALLOW = "Client networks allowed to fetch boot media, as CIDRs. Empty means anyone who can reach the port." RESCRIPTUM_BOOT_DIR = "Where the loaders live. This package cannot serve them itself — see the configuration file — but DSM's own TFTP server can, from this folder." -RESCRIPTUM_TFTP_ADDR = "Not usable in this package: port 69 is privileged and DSM 7 does not let an unsigned package run as root. Use DSM's own TFTP server instead." +RESCRIPTUM_TFTP_ADDR = "Set to off by this package, and it must stay off: port 69 is privileged and DSM 7 does not let an unsigned package run as root, so a real address here is a package that will not start. DSM's own TFTP server hands the loader over." RESCRIPTUM_BOOT_TIMEOUT_SECS = "How long the boot menu waits before a machine falls through to its own disk." RESCRIPTUM_BOOT_LOGO = "A PNG shown behind the boot menu, replacing the built-in one." RESCRIPTUM_BOOT_TITLE = "The boot menu's title bar, replacing the built-in one." diff --git a/packaging/dsm/payload/ui/texts/fre/strings b/packaging/dsm/payload/ui/texts/fre/strings index fe37a60..63906e3 100644 --- a/packaging/dsm/payload/ui/texts/fre/strings +++ b/packaging/dsm/payload/ui/texts/fre/strings @@ -74,7 +74,7 @@ RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Durée maximale d'un transfert d'image. Volonta RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Téléchargements à la fois. Bas exprès : chacun retient sa place des minutes durant, et ce NAS a un disque." RESCRIPTUM_BOOT_ALLOW = "Réseaux clients autorisés à récupérer les médias, en CIDR. Vide, quiconque atteint le port." RESCRIPTUM_BOOT_DIR = "Où vivent les chargeurs. Ce package ne peut pas les servir lui-même — voir le fichier de configuration — mais le serveur TFTP de DSM le peut, depuis ce dossier." -RESCRIPTUM_TFTP_ADDR = "Inutilisable dans ce package : le port 69 est privilégié et DSM 7 n'autorise pas un package non signé à tourner en root. Utilisez le serveur TFTP de DSM." +RESCRIPTUM_TFTP_ADDR = "Mis à off par ce paquet, et cela doit le rester : le port 69 est privilégié et DSM 7 n'autorise pas un paquet non signé à tourner en root, donc une vraie adresse ici est un paquet qui ne démarre pas. C'est le serveur TFTP de DSM qui livre le chargeur." RESCRIPTUM_BOOT_TIMEOUT_SECS = "Combien de temps le menu de démarrage attend avant qu'une machine retombe sur son propre disque." RESCRIPTUM_BOOT_LOGO = "Un PNG affiché derrière le menu de démarrage, à la place de celui intégré." RESCRIPTUM_BOOT_TITLE = "La barre de titre du menu de démarrage, à la place de celle intégrée." diff --git a/packaging/dsm/scripts/postinst b/packaging/dsm/scripts/postinst index 56fa989..2401616 100755 --- a/packaging/dsm/scripts/postinst +++ b/packaging/dsm/scripts/postinst @@ -133,13 +133,13 @@ RESCRIPTUM_DB_PATH=$ROOT/shares/$PKG/answers.db # # **No image ships with this package.** An ISO is somebody else's artefact, gigabytes, # on its own schedule; this folder is where you keep them and it is the archive — -# nothing here ever modifies an image after it lands. -# RESCRIPTUM_MEDIA_DIR=$SHARE_MEDIA +# nothing here ever modifies an image after it lands. It is set rather than commented +# because the folder already exists and the port is already registered: leaving it blank +# would mean a settings panel with an empty field and no hint of what to type. +RESCRIPTUM_MEDIA_DIR=$SHARE_MEDIA -# The listener's own port. Left commented because the default is already $MEDIA_PORT and -# the firewall entry above already covers it — and because naming an address with no -# directory to serve is a startup error, so an uncommented line here would be a package -# that refuses to start until somebody uncommented the one above too. +# The listener's own port. Left commented because the default is already $MEDIA_PORT, +# which the firewall entry above already covers and which every loader we ship chains to. # RESCRIPTUM_MEDIA_ADDR=0.0.0.0:$MEDIA_PORT # --------------------------------------------------------------------------- @@ -163,8 +163,17 @@ RESCRIPTUM_DB_PATH=$ROOT/shares/$PKG/answers.db # prints. # # The rest of the chain is this package's: the loader chains to port 8001 above, and -# everything after that is HTTP. -# RESCRIPTUM_BOOT_DIR=$SHARE_BOOT +# everything after that is HTTP. The folder is named here so `rescriptum-cli boot check` +# works and so the loaders are reachable over HTTP at /boot/ — which is what UEFI HTTP +# Boot fetches, and where the boot menu looks for its logo. +RESCRIPTUM_BOOT_DIR=$SHARE_BOOT + +# **This is what makes the line above safe here.** Naming a boot folder otherwise starts +# a TFTP server on port 69, which this package cannot bind — and a failed bind is a +# server that does not start at all, so the folder setting would be a trap rather than a +# constraint. Off means no listener; the loaders are still served over HTTP and still +# checked, and DSM's own TFTP server hands the file over. +RESCRIPTUM_TFTP_ADDR=off BODY } diff --git a/src/config.rs b/src/config.rs index 3ac5d5b..2b666c5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -342,7 +342,7 @@ impl Config { } } - if self.tftp_addr.is_some() && self.boot_dir.is_none() { + if self.tftp_addr.is_some() && !self.tftp_is_off() && self.boot_dir.is_none() { return Err(format!( "RESCRIPTUM_TFTP_ADDR is set ({}), but RESCRIPTUM_BOOT_DIR is not. There would \ be a listener with no loaders to hand out.", @@ -382,11 +382,28 @@ impl Config { Ok(()) } - /// The TFTP listener's effective address. - pub fn tftp_addr(&self) -> String { - self.tftp_addr - .clone() - .unwrap_or_else(|| DEFAULT_TFTP_ADDR.to_string()) + /// The TFTP listener's effective address, or `None` when TFTP is off. + /// + /// **`off` is a value, not an absence**, and it exists because naming a boot + /// directory used to imply a TFTP server on port 69. On a platform that cannot bind + /// a privileged port — a DSM package, a container without the capability — that + /// turned "tell the server where the loaders are" into "the server refuses to + /// start", which is a trap rather than a constraint. The loaders are still served + /// over HTTP at `/boot/…` and still checked by `boot check`; only the listener is + /// gone, and something else hands the file over. + pub fn tftp_addr(&self) -> Option { + match self.tftp_addr.as_deref() { + Some(value) if is_off(value) => None, + Some(value) => Some(value.to_string()), + None => Some(DEFAULT_TFTP_ADDR.to_string()), + } + } + + /// Whether TFTP was turned off deliberately, as opposed to never asked for. Worth + /// telling apart: the first deserves a line at startup saying what will hand the + /// loader over instead. + pub fn tftp_is_off(&self) -> bool { + self.tftp_addr.as_deref().is_some_and(is_off) } /// The menu timeout **in milliseconds**, which is the unit `choose` counts. The @@ -475,6 +492,14 @@ impl Config { } } +/// The spellings that mean "not at all", matching `RESCRIPTUM_LOG=off`. +fn is_off(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "off" | "none" | "disabled" + ) +} + /// Whether an address asks the kernel to choose the port. Two such listeners never /// collide, however identical the strings look. fn ephemeral(addr: &str) -> bool { @@ -661,7 +686,7 @@ pub const KNOWN: [Known; 26] = [ key: "RESCRIPTUM_TFTP_ADDR", default: Some(DEFAULT_TFTP_ADDR), secret: false, - help: "The TFTP listener. Port 69 is privileged; see RESCRIPTUM_USER.", + help: "The TFTP listener, or `off` for none. Port 69 is privileged; see RESCRIPTUM_USER.", }, Known { key: "RESCRIPTUM_BOOT_TIMEOUT_SECS", @@ -995,6 +1020,15 @@ mod tests { assert!(c.validate().is_ok()); } + #[test] + fn a_boot_directory_alone_still_starts_tftp() { + // The default has not moved: `off` is opt-in, and a plain Linux host that names + // a boot directory gets the TFTP server the plan calls core. + let c = Config::from_lookup(lookup(&[("RESCRIPTUM_BOOT_DIR", "/srv/boot")])); + assert_eq!(c.tftp_addr().as_deref(), Some("0.0.0.0:69")); + assert!(!c.tftp_is_off()); + } + #[test] fn the_media_listener_defaults_to_the_port_the_loaders_assume() { let c = Config::from_lookup(lookup(&[("RESCRIPTUM_MEDIA_DIR", "/srv/media")])); @@ -1034,6 +1068,54 @@ mod tests { assert!(e.contains("RESCRIPTUM_ADMIN_ADDR"), "{e}"); } + #[test] + fn tftp_can_be_turned_off_without_giving_up_the_boot_directory() { + // **The trap this exists to remove.** Naming a boot directory used to imply a + // TFTP server on port 69, so on a platform that cannot bind a privileged port — + // a DSM package, a container without the capability — telling the server where + // the loaders are turned into a server that refuses to start. + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_BOOT_DIR", "/srv/boot"), + ("RESCRIPTUM_TFTP_ADDR", "off"), + ])); + assert!(c.validate().is_ok(), "{:?}", c.validate()); + assert_eq!(c.tftp_addr(), None, "no listener"); + assert!( + c.tftp_is_off(), + "and deliberately so, not merely unasked for" + ); + // The directory is still configured, so /boot/ and `boot check` still work. + assert_eq!(c.boot_dir, Some(PathBuf::from("/srv/boot"))); + } + + #[test] + fn off_is_spelled_the_way_the_log_level_spells_it() { + for value in ["off", "OFF", "none", "disabled", " off "] { + let c = Config::from_lookup(|key| { + (key == "RESCRIPTUM_TFTP_ADDR").then(|| value.to_string()) + }); + assert_eq!(c.tftp_addr(), None, "{value:?}"); + } + // And an address is still an address. + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_BOOT_DIR", "/srv/boot"), + ("RESCRIPTUM_TFTP_ADDR", "0.0.0.0:6969"), + ])); + assert_eq!(c.tftp_addr().as_deref(), Some("0.0.0.0:6969")); + assert!(!c.tftp_is_off()); + } + + #[test] + fn turning_tftp_off_needs_no_boot_directory_to_justify_it() { + // Off is off: refusing this would be refusing somebody who said "definitely not" + // before they said where anything lives. + let c = Config::from_lookup(lookup(&[("RESCRIPTUM_TFTP_ADDR", "off")])); + assert!(c.validate().is_ok(), "{:?}", c.validate()); + // But naming a real address with nowhere to serve from is still refused. + let c = Config::from_lookup(lookup(&[("RESCRIPTUM_TFTP_ADDR", "0.0.0.0:69")])); + assert!(c.validate().is_err()); + } + #[test] fn two_ephemeral_ports_are_not_a_collision() { // `:0` asks the kernel for any free port, so two of them are never the same diff --git a/src/main.rs b/src/main.rs index c3e5344..d0949a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -219,31 +219,47 @@ async fn serve(cfg: Arc) -> ExitCode { return ExitCode::FAILURE; } }; - let addr = cfg.tftp_addr(); - let socket = match tokio::net::UdpSocket::bind(&addr).await { - Ok(socket) => socket, - Err(e) => { - log::server(&format!( - "cannot bind TFTP on {addr}: {e}{}", - if addr.ends_with(":69") { - " — port 69 is privileged; run as root and set RESCRIPTUM_USER to \ - drop afterwards, use setcap, or choose another port" - } else { - "" + // **Off is a value, not an absence.** Naming a boot directory used to imply a + // TFTP server on port 69, so on a platform that cannot bind a privileged port — + // a DSM package, a container without the capability — telling the server where + // the loaders are turned into a server that refuses to start. That is a trap + // rather than a constraint. Said once at startup, because "where did my TFTP + // go" is otherwise a silent question. + match cfg.tftp_addr() { + None => log::server(&format!( + "tftp is off — {} is still served over HTTP at /boot/ and still checked \ + by `boot check`, but something else has to hand the loader over", + tftp.root().display() + )), + Some(addr) => { + let socket = match tokio::net::UdpSocket::bind(&addr).await { + Ok(socket) => socket, + Err(e) => { + log::server(&format!( + "cannot bind TFTP on {addr}: {e}{}", + if addr.ends_with(":69") { + " — port 69 is privileged. Run as root and set \ + RESCRIPTUM_USER to drop afterwards, use setcap, choose \ + another port, or set RESCRIPTUM_TFTP_ADDR=off and let \ + something else hand the loader over" + } else { + "" + } + )); + return ExitCode::FAILURE; } + }; + let bound = socket + .local_addr() + .map(|a| a.to_string()) + .unwrap_or_else(|_| addr.clone()); + log::server(&format!( + "tftp listening on {bound} — serving {}", + tftp.root().display() )); - return ExitCode::FAILURE; + tokio::spawn(rescriptum::boot::tftp::serve(socket, tftp)); } - }; - let bound = socket - .local_addr() - .map(|a| a.to_string()) - .unwrap_or_else(|_| addr.clone()); - log::server(&format!( - "tftp listening on {bound} — serving {}", - tftp.root().display() - )); - tokio::spawn(rescriptum::boot::tftp::serve(socket, tftp)); + } } // **Bind everything first, then drop.** The other order works as root in testing From 9d9f6cecdb7d96f77fdcff644705427f9965b1c5 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 17:13:05 +0200 Subject: [PATCH 21/59] feat(config): show the address the server derived, and what else the host has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings panel showed an empty field for RESCRIPTUM_PUBLIC_HOST while the server derived an address at startup, so the one place an operator looks was showing something other than what the server does. `settings()` now fills the default by deriving it, the way it already does the CPU count — the panel renders a default as the field's value, so the derived address appears without the UI knowing anything new. Derivation gains a second source. The routing table answers on a host with a default route; an isolated provisioning segment has none, and there the interface list still answers when the host has exactly one address. With several it does not guess. The startup line now names the alternatives instead of warning generically: one address is stated plainly, and several are all listed, which is what makes "is this the address my machines reach" answerable from the log rather than by going to look at the host. +1,784 bytes on armv7. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- docs/guide/operations/media.fr.md | 23 +++- docs/guide/operations/media.md | 23 +++- docs/guide/reference/configuration.fr.md | 2 +- docs/guide/reference/configuration.md | 2 +- packaging/dsm/payload/ui/texts/enu/strings | 2 +- packaging/dsm/payload/ui/texts/fre/strings | 2 +- src/config.rs | 150 ++++++++++++++++++++- src/main.rs | 26 +++- 8 files changed, 204 insertions(+), 26 deletions(-) diff --git a/docs/guide/operations/media.fr.md b/docs/guide/operations/media.fr.md index 6e44f63..e022857 100644 --- a/docs/guide/operations/media.fr.md +++ b/docs/guide/operations/media.fr.md @@ -299,18 +299,27 @@ généré sur l'un d'eux. Chaque URL ajoute le port de son propre listener. Une portant l'un des trois est refusée au démarrage, en nommant lequel. Laissée vide, elle demande à la table de routage laquelle des adresses de cet hôte fait -face à l'extérieur, et **le dit haut et fort au démarrage** : +face à l'extérieur — et sur un segment sans route par défaut, se rabat sur la liste des +interfaces, ce qui sur un hôte à une seule adresse n'est pas une déduction du tout. Dans +les deux cas elle **dit au démarrage ce qu'elle a retenu**, et s'il y avait quelque chose +à trancher : + +``` +RESCRIPTUM_PUBLIC_HOST is not set — using 192.0.2.10, the only address this host has. +Every generated URL will name it. +``` ``` warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.0.2.10, which is what every -generated URL will name. Multi-homed and NAT hosts get this wrong; set it explicitly if -that address is not reachable from the machines. +generated URL will name. This host also has 10.8.0.4. If the machines reach it on one of +those instead, set it explicitly. ``` -Prenez l'avertissement au sérieux sur un hôte multi-domicilié ou derrière du NAT. Une -mauvaise déduction produit une machine qui démarre, enchaîne, et se bloque sur une adresse -qui n'existe pas — et cette ligne de journal est le seul endroit où la réponse -apparaîtra jamais. +C'est la seconde qu'il faut prendre au sérieux : une mauvaise déduction produit une machine +qui démarre, enchaîne, et se bloque sur une adresse qui n'existe pas. Nommer les autres +adresses est ce qui rend la question tranchable depuis le journal lui-même, plutôt qu'en +allant regarder l'hôte. Le NAT est le cas qu'aucune des deux lignes ne peut attraper : +l'adresse est bien celle de cet hôte, et bien celle que les machines n'atteignent pas. ## Le garder honnête diff --git a/docs/guide/operations/media.md b/docs/guide/operations/media.md index b15f7a4..b80797f 100644 --- a/docs/guide/operations/media.md +++ b/docs/guide/operations/media.md @@ -288,18 +288,27 @@ listeners, and a value carrying one port would pin every generated script to one Each URL appends its own listener's port. A value with any of the three is refused at startup, naming which. -Left unset, it asks the routing table which of this host's addresses faces outward, and -**says so loudly at startup**: +Left unset, it asks the routing table which of this host's addresses faces outward — and +on a segment with no default route, falls back to the interface list, which on a host with +one address is not a guess at all. Either way it **says at startup what it settled on**, +and whether there was anything to settle: + +``` +RESCRIPTUM_PUBLIC_HOST is not set — using 192.0.2.10, the only address this host has. +Every generated URL will name it. +``` ``` warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.0.2.10, which is what every -generated URL will name. Multi-homed and NAT hosts get this wrong; set it explicitly if -that address is not reachable from the machines. +generated URL will name. This host also has 10.8.0.4. If the machines reach it on one of +those instead, set it explicitly. ``` -Take the warning seriously on a multi-homed or NAT host. A wrong guess produces a machine -that boots, chains, and hangs on an address that does not exist — and that log line is -the only place the answer will ever appear. +The second is the one to take seriously: a wrong guess produces a machine that boots, +chains, and hangs on an address that does not exist. Naming the alternatives is what makes +that answerable from the log itself, rather than by going to look at the host. NAT is the +case neither line can catch — the address is genuinely this host's, and genuinely not the +one the machines reach. ## Keeping it honest diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index e0315ca..8eb98be 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -190,7 +190,7 @@ Ceux-ci sont affichés et le serveur continue : | API d'administration hors boucle locale | `warning: the admin API is not bound to loopback — …` | | `RESCRIPTUM_ANSWER_TOKEN` de moins de 16 caractères | un avertissement, **pas** une erreur — refuser de démarrer laisserait un parc incapable de s'installer | | Tout problème dans le jeu de réponses | une ligne `warning:` chacun, le même jeu que signale `check` | -| `RESCRIPTUM_PUBLIC_HOST` non défini | `warning: … is not set — derived , which is what every generated URL will name`. Les hôtes multi-domiciliés et derrière NAT se trompent souvent ici | +| `RESCRIPTUM_PUBLIC_HOST` non défini | La réponse de la table de routage, ou l'unique adresse d'interface s'il n'y a pas de route par défaut. Journalisé dans les deux cas, en avertissement **nommant les autres adresses** s'il y en a. Un hôte derrière du NAT se trompe toujours en silence | | Répertoire de médias absent ou illisible | une ligne `warning: media: …` — un parc ne doit jamais être incapable de s'installer parce qu'une image est bizarre | ## Options de compilation diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 09b195b..33d643e 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -185,7 +185,7 @@ These are printed and the server carries on: | Admin API not on loopback | `warning: the admin API is not bound to loopback — …` | | `RESCRIPTUM_ANSWER_TOKEN` under 16 characters | a warning, **not** an error — refusing to start would leave a fleet unable to install | | Any problem in the answer set | one `warning:` line each, the same set `check` reports | -| `RESCRIPTUM_PUBLIC_HOST` unset | `warning: … is not set — derived
, which is what every generated URL will name`. Multi-homed and NAT hosts get this wrong | +| `RESCRIPTUM_PUBLIC_HOST` unset | The routing table's answer, or the sole interface address when there is no default route. Logged either way, as a warning **naming the other addresses** when there are any. A NAT host still gets it wrong silently | | Media directory missing or unlistable | one `warning: media: …` line — a fleet must never be unable to install because one image is odd | ## Compile-time options diff --git a/packaging/dsm/payload/ui/texts/enu/strings b/packaging/dsm/payload/ui/texts/enu/strings index 10de018..5a2c508 100644 --- a/packaging/dsm/payload/ui/texts/enu/strings +++ b/packaging/dsm/payload/ui/texts/enu/strings @@ -73,7 +73,7 @@ RESCRIPTUM_CAPTURE_DIR = "Record what installers actually send, for when nothing RESCRIPTUM_ANSWER_TOKEN = "Required of installers that have one to offer. Off by default." RESCRIPTUM_ADMIN_ADDR = "The write API's own listener. Keep it on loopback and reach it over SSH." RESCRIPTUM_ADMIN_TOKEN = "At least 16 characters, and required whenever the write API is on." -RESCRIPTUM_PUBLIC_HOST = "The address this NAS is reachable at, written into every generated script. A host, never a URL. Left empty it is guessed, which a multi-homed NAS often gets wrong." +RESCRIPTUM_PUBLIC_HOST = "The address this NAS is reachable at, written into every generated script. A host, never a URL. Shown here is the one detected on the interface that reaches the network; set it yourself if the machines reach this NAS on another." RESCRIPTUM_MEDIA_DIR = "Installer images. Empty means no media and no second listener. Nothing here ever modifies an image: this folder is the archive." RESCRIPTUM_MEDIA_ADDR = "Where machines fetch kernels, initrds and images. Its own listener, because a download holds a connection for minutes and answers must not queue behind one." RESCRIPTUM_MEDIA_TIMEOUT_SECS = "How long one image transfer may take. Deliberately not the answer endpoint's ten seconds." diff --git a/packaging/dsm/payload/ui/texts/fre/strings b/packaging/dsm/payload/ui/texts/fre/strings index 63906e3..86e537d 100644 --- a/packaging/dsm/payload/ui/texts/fre/strings +++ b/packaging/dsm/payload/ui/texts/fre/strings @@ -67,7 +67,7 @@ RESCRIPTUM_CAPTURE_DIR = "Enregistre ce que les installateurs envoient vraiment, RESCRIPTUM_ANSWER_TOKEN = "Exigé des installateurs qui en présentent un. Désactivé par défaut." RESCRIPTUM_ADMIN_ADDR = "L'écouteur propre à l'API d'écriture. À garder en loopback, joignable par SSH." RESCRIPTUM_ADMIN_TOKEN = "Au moins 16 caractères, et obligatoire dès que l'API d'écriture est active." -RESCRIPTUM_PUBLIC_HOST = "L'adresse à laquelle ce NAS est joignable, écrite dans chaque script généré. Un hôte, jamais une URL. Laissée vide, elle est devinée — ce qu'un NAS multi-domicilié rate souvent." +RESCRIPTUM_PUBLIC_HOST = "L'adresse à laquelle ce NAS est joignable, écrite dans chaque script généré. Un hôte, jamais une URL. Celle affichée ici est détectée sur l'interface qui atteint le réseau ; indiquez-la vous-même si les machines joignent ce NAS par une autre." RESCRIPTUM_MEDIA_DIR = "Les images d'installation. Vide, pas de média ni de second listener. Rien ici ne modifie jamais une image : ce dossier est l'archive." RESCRIPTUM_MEDIA_ADDR = "Là où les machines récupèrent noyaux, initrds et images. Son propre listener, car un téléchargement retient une connexion des minutes durant et les réponses ne doivent pas faire la queue derrière." RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Durée maximale d'un transfert d'image. Volontairement pas les dix secondes du point de réponse." diff --git a/src/config.rs b/src/config.rs index 2b666c5..d2aab81 100644 --- a/src/config.rs +++ b/src/config.rs @@ -531,14 +531,87 @@ fn join(host: &str, listen_addr: &str) -> String { /// /// 192.0.2.1 is TEST-NET-1, a documentation address that exists to be written down and /// never answered. Connecting a UDP socket to it sends nothing; it only makes the -/// kernel choose a source address, which is the answer we are after. -fn derive_public_host() -> Option { +/// kernel choose a source address, which is the answer we are after — and on a host +/// with one interface it is simply the right one. +pub fn derive_public_host() -> Option { + choose_host(routed_address(), &local_addresses()) +} + +/// The choice itself, separated from the two syscalls that feed it so it can be tested. +fn choose_host(routed: Option, addresses: &[String]) -> Option { + if routed.is_some() { + return routed; + } + // No default route — an isolated provisioning segment, which is a perfectly ordinary + // way to run this. The routing table has nothing to say, but the interface list + // still does: with exactly one address there is no choice to get wrong. With + // several there is, and guessing one silently is worse than saying nothing. + (addresses.len() == 1).then(|| addresses[0].clone()) +} + +fn routed_address() -> Option { let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; socket.connect("192.0.2.1:9").ok()?; let address = socket.local_addr().ok()?.ip(); (!address.is_unspecified()).then(|| address.to_string()) } +/// Every address this host actually has, loopback and link-local excluded. +/// +/// The derivation above picks the interface the *default route* uses, which is right on +/// a host with one address and a coin toss on a NAS with two NICs or a bond. Knowing +/// what else is available is what turns "this might be wrong" into something an +/// operator can act on without going to look — and looking is the step nobody takes +/// before a rack is already failing to boot. +pub fn local_addresses() -> Vec { + #[cfg(not(unix))] + { + Vec::new() + } + #[cfg(unix)] + { + use std::net::{Ipv4Addr, Ipv6Addr}; + + let mut list: *mut libc::ifaddrs = std::ptr::null_mut(); + if unsafe { libc::getifaddrs(&mut list) } != 0 { + return Vec::new(); + } + let mut found: Vec = Vec::new(); + let mut node = list; + while !node.is_null() { + let entry = unsafe { &*node }; + node = entry.ifa_next; + if entry.ifa_addr.is_null() { + continue; + } + let family = unsafe { (*entry.ifa_addr).sa_family } as i32; + let address = if family == libc::AF_INET { + let raw = unsafe { &*(entry.ifa_addr as *const libc::sockaddr_in) }; + let octets = u32::from_be(raw.sin_addr.s_addr); + let v4 = Ipv4Addr::from(octets); + (!v4.is_loopback() && !v4.is_link_local() && !v4.is_unspecified()) + .then(|| v4.to_string()) + } else if family == libc::AF_INET6 { + let raw = unsafe { &*(entry.ifa_addr as *const libc::sockaddr_in6) }; + let v6 = Ipv6Addr::from(raw.sin6_addr.s6_addr); + // No link-local: an fe80:: address needs a scope to be usable, and a + // scope is not something that survives being written into a script. + let link_local = v6.segments()[0] & 0xffc0 == 0xfe80; + (!v6.is_loopback() && !link_local && !v6.is_unspecified()).then(|| v6.to_string()) + } else { + None + }; + if let Some(address) = address + && !found.contains(&address) + { + found.push(address); + } + } + unsafe { libc::freeifaddrs(list) }; + found + } +} + /// One configuration variable, **described** rather than merely read. /// /// `from_lookup` above knows how to interpret each of these. This table is what anything @@ -642,9 +715,12 @@ pub const KNOWN: [Known; 26] = [ }, Known { key: "RESCRIPTUM_PUBLIC_HOST", + // Not a constant: the default is this host's own LAN address, which is only + // knowable at runtime. `settings()` fills it in, the way it does the CPU count. default: None, secret: false, - help: "The host this server names itself by. A host, never a URL. Derived if unset.", + help: "The host this server names itself by. A host, never a URL. \ + Unset, the address of the interface that reaches the network is used.", }, Known { key: "RESCRIPTUM_MEDIA_DIR", @@ -790,6 +866,11 @@ pub fn settings( let default = match known.key { "RESCRIPTUM_WORKERS" => Some(default_workers().to_string()), + // **The other default that cannot be a constant.** The server derives + // this at startup, so a panel showing an empty field would be showing + // something other than what the server will use — and the operator has + // no way to tell whether the guess is right without reading a log. + "RESCRIPTUM_PUBLIC_HOST" => derive_public_host(), _ => known.default.map(str::to_string), }; let value = from_env.or(from_file).or_else(|| default.clone()); @@ -1163,6 +1244,69 @@ mod tests { } } + #[test] + fn the_settings_table_shows_the_address_the_server_would_actually_use() { + // **A panel with an empty field here is showing something other than what the + // server does.** The value is derived at startup, so the table has to derive it + // too — the same treatment the CPU count already gets, and for the same reason. + let s = settings(None, |_| None); + let host = setting(&s, "RESCRIPTUM_PUBLIC_HOST"); + assert!(host.set, "a derived value is still a value in force"); + assert_eq!( + host.value, host.default, + "unset means the derived default is what is in force" + ); + assert_eq!( + host.value, + Config::from_lookup(|_| None).public_host().0.into(), + "and it is the same address the server itself would pick" + ); + } + + #[test] + fn without_a_default_route_a_single_interface_still_answers() { + // An isolated provisioning segment has no default route, which is exactly the + // network this server is most often put on. One address there is not a guess. + let one = vec!["10.0.0.4".to_string()]; + assert_eq!(choose_host(None, &one), Some("10.0.0.4".to_string())); + + // Two, and there is a real choice — one that only the operator can make. + let two = vec!["10.0.0.4".to_string(), "192.168.1.4".to_string()]; + assert_eq!(choose_host(None, &two), None); + assert_eq!(choose_host(None, &[]), None); + + // A route beats the interface list even when the list is unambiguous: the + // kernel knows which way traffic actually leaves. + assert_eq!( + choose_host(Some("172.16.0.9".to_string()), &two), + Some("172.16.0.9".to_string()) + ); + } + + #[test] + fn the_host_knows_what_addresses_it_has() { + // Loopback and link-local are excluded: the first is not reachable from a + // machine, and the second needs a scope that does not survive being written + // into a script. + let addresses = local_addresses(); + for address in &addresses { + assert!(!address.starts_with("127."), "{address} is loopback"); + assert!(!address.starts_with("169.254."), "{address} is link-local"); + assert!(!address.starts_with("fe80:"), "{address} is link-local"); + assert_ne!(address, "::1"); + } + // The derived host, when there is one, is one of them — it is chosen from this + // set by the routing table rather than invented. + if let Some(derived) = derive_public_host() + && !addresses.is_empty() + { + assert!( + addresses.contains(&derived), + "derived {derived} is not among {addresses:?}" + ); + } + } + #[test] fn a_derived_public_host_is_reported_as_derived() { // Derivation is wrong often enough on multi-homed and NAT hosts that it is a diff --git a/src/main.rs b/src/main.rs index d0949a4..37fdcea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -181,11 +181,27 @@ async fn serve(cfg: Arc) -> ExitCode { // answer will ever appear. let (host, derived) = cfg.public_host(); if derived { - log::server(&format!( - "warning: RESCRIPTUM_PUBLIC_HOST is not set — derived {host}, which is what \ - every generated URL will name. Multi-homed and NAT hosts get this wrong; \ - set it explicitly if that address is not reachable from the machines." - )); + // Naming the alternatives is what makes this actionable. A generic "this + // might be wrong" sends an operator off to look at interfaces; a list they + // can read in place tells them in one glance whether the guess is the + // address their machines can reach. + let others: Vec = config::local_addresses() + .into_iter() + .filter(|a| *a != host) + .collect(); + if others.is_empty() { + log::server(&format!( + "RESCRIPTUM_PUBLIC_HOST is not set — using {host}, the only address this \ + host has. Every generated URL will name it." + )); + } else { + log::server(&format!( + "warning: RESCRIPTUM_PUBLIC_HOST is not set — derived {host}, which is \ + what every generated URL will name. This host also has {}. If the \ + machines reach it on one of those instead, set it explicitly.", + others.join(", ") + )); + } } log::server(&format!( "media listening on {bound} — serving {} as http://{host}", From 95ce6afee8c52d337f3ca98006b79f0b21478206 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 17:21:54 +0200 Subject: [PATCH 22/59] test(dsm): assert on the machine that the panel shows a real address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-machine run never touched the panel's backend, which is how a blank RESCRIPTUM_PUBLIC_HOST field reached a DSM install: nothing between the Rust tests and Package Center looked at what `api.cgi?action=config` would return. The check runs `rescriptum-cli config --json` as the package user — the exact command the CGI shells out to — and asserts the address is both present and one the machine's interfaces actually carry. Asking the CGI over HTTP would need a DSM session and would prove the same values through a login. Watched failing on the VM with the derivation removed: 40 passed, 1 failed, reporting the blank. With it restored, 42 pass. The Synology page and the reference table described the old startup warning; both now describe what the panel shows and what the log names beside it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- CLAUDE.md | 2 +- docs/guide/operations/synology.fr.md | 18 ++++++++++++++---- docs/guide/operations/synology.md | 18 ++++++++++++++---- packaging/dsm/vm/remote-check.sh | 24 ++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1267f3b..b7f793d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -620,7 +620,7 @@ Environment variables only — plus an optional file to read some of them from: | `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | The media listener, when there is a media directory | | `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Whole-transfer deadline — deliberately not the answer listener's 10 | | `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Concurrent transfers; low on purpose | -| `RESCRIPTUM_PUBLIC_HOST` | derived, with a warning | The host generated URLs name. **A host, never a URL** | +| `RESCRIPTUM_PUBLIC_HOST` | the routing table's answer, else a sole interface | The host generated URLs name. **A host, never a URL**. Warns and names the alternatives when the host has several | | `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media | A zero or unparseable numeric value falls back to the default rather than starting a server diff --git a/docs/guide/operations/synology.fr.md b/docs/guide/operations/synology.fr.md index 5016454..ab51959 100644 --- a/docs/guide/operations/synology.fr.md +++ b/docs/guide/operations/synology.fr.md @@ -295,10 +295,20 @@ RESCRIPTUM_PUBLIC_HOST=192.168.1.10 ``` Chaque script généré nomme cette adresse. Laissée vide, elle est déduite en interrogeant -la table de routage, et **un NAS est souvent multi-domicilié** — la déduction porte alors -sur la mauvaise interface, et le symptôme est une machine qui démarre, enchaîne, et se -bloque sur une adresse qui n'existe pas. Le journal de démarrage dit quelle adresse a été -devinée ; cette ligne est le seul endroit où la réponse apparaît. +la table de routage, et le panneau de réglages affiche ce que cela a donné plutôt qu'une +case vide — donc sur un NAS à une seule interface, il n'y a rien à remplir ici. + +**C'est le NAS à deux interfaces qui mérite la lecture.** La déduction en retient une, et +le journal de démarrage nomme les autres à côté : + +``` +warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.168.1.10, which is what every +generated URL will name. This host also has 10.0.0.10. If the machines reach it on one of +those instead, set it explicitly. +``` + +Se tromper produit une machine qui démarre, enchaîne, et se bloque sur une adresse qui +n'existe pas — long à diagnostiquer depuis la machine. ## Le journal diff --git a/docs/guide/operations/synology.md b/docs/guide/operations/synology.md index 0d5d8e9..697de7c 100644 --- a/docs/guide/operations/synology.md +++ b/docs/guide/operations/synology.md @@ -274,10 +274,20 @@ RESCRIPTUM_PUBLIC_HOST=192.168.1.10 ``` Every generated script names this address. Left empty it is derived by asking the routing -table, and **a NAS is often multi-homed** — the derived answer is then the wrong -interface, and the symptom is a machine that boots, chains, and hangs on an address that -does not exist. The startup log says which address was guessed; that line is the only -place the answer appears. +table, and the settings panel shows what that came out as rather than an empty box — so on +a NAS with one interface there is nothing here to fill in. + +**A NAS with two is the case worth reading.** The derived answer is one of them, and the +startup log names the others beside it: + +``` +warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.168.1.10, which is what every +generated URL will name. This host also has 10.0.0.10. If the machines reach it on one of +those instead, set it explicitly. +``` + +Getting it wrong produces a machine that boots, chains, and hangs on an address that does +not exist, which is a slow thing to diagnose from the machine's end. ## The log diff --git a/packaging/dsm/vm/remote-check.sh b/packaging/dsm/vm/remote-check.sh index 1fe0c26..c14b9a6 100755 --- a/packaging/dsm/vm/remote-check.sh +++ b/packaging/dsm/vm/remote-check.sh @@ -298,6 +298,30 @@ else bad "the package user cannot check its own answers" fi +# **What the settings panel will show.** `api.cgi` answers `action=config` by shelling out +# to exactly this, so the JSON here is the panel's data — asking the CGI itself would need +# a DSM session, and would test the same values through a login. +# +# The address is the one field with no constant behind it: it is derived at startup from +# this machine's own routing table. A blank one shipped once, because the table of known +# variables had no default and the derivation lived only in the server — so the panel +# showed an empty box while the server used an address, and the operator had no way to +# see which. Only a real machine has interfaces to get this wrong on. +json=$(sudo -u "$PKG" /usr/local/bin/$PKG-cli config --json 2>/dev/null) +host=$(printf '%s' "$json" | tr '{' '\n' | + grep '"key":"RESCRIPTUM_PUBLIC_HOST"' | + sed -n 's/.*"value":"\([^"]*\)".*/\1/p') +if [ -n "$host" ]; then + ok "the panel would show an address for this NAS: $host" + if ip -4 -o addr show 2>/dev/null | grep -qw "$host"; then + ok " and it is one this machine actually has" + else + bad " but no interface here has it — the derivation picked something imaginary" + fi +else + bad "RESCRIPTUM_PUBLIC_HOST came back empty — the panel shows a blank the server does not have" +fi + # ── 5. logrotate, and the descriptor that must not move ──────────────────────── section "logrotate" STANZA=$(find /usr/local/etc/logrotate.d /etc/logrotate.d /usr/syno/etc/logrotate.d -name "*$PKG*" 2>/dev/null | head -n 1) From 6d46223807b6d1b7a1db40fe4339aa6a6473c765 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 17:36:08 +0200 Subject: [PATCH 23/59] docs(traps): a runtime default has to reach settings() as well The class of bug, not the instance: the panel renders a variable's default as the field's value, so a default that exists only where the server consumes it shows as a blank while the server runs on something it derived. Two entries in KNOWN are special-cased for this and nothing in the type system says a third would need it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL --- CLAUDE.md | 6 ++++++ docs/development/traps.fr.md | 11 ++++++++++- docs/development/traps.md | 11 ++++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b7f793d..e09a628 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -543,6 +543,12 @@ could not check. Note it needs `Resolution::format_name` (the extension), not and SeaBIOS says "could not read the boot disk". Use `pc` for a BIOS guest. - **`sed -n … "$0"` cannot find a relatively-invoked script after a `cd`.** Resolve the path first, or `--help` breaks for everyone who does not type an absolute path. +- **A default computed at runtime must be computed in `settings()` too.** The DSM panel + renders a variable's default as the field's value, so a default living only where the + server consumes it shows as an empty box while the server runs on a value it derived. + `RESCRIPTUM_PUBLIC_HOST` shipped that way. Two `KNOWN` entries are special-cased there + — the worker count and the public host — and nothing in the type system says a third + would need it. - **The size figures in this file go stale.** They moved ~375 KB when armv7 changed from musl to glibc. Re-measure before concluding anything from them; a stale baseline once turned a 71% budget spend into an apparent 293% overrun. diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index 0534856..1473c63 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -216,9 +216,18 @@ de plus. Idem pour un qui embarque des membres `._` de macOS. `check-spk.sh` vé ## L'application de bureau DSM -Sept choses, mesurées sur une machine virtuelle DSM 7.2.2 et sur un DS416j en 7.1.1, et +Huit choses, mesurées sur une machine virtuelle DSM 7.2.2 et sur un DS416j en 7.1.1, et aucune dans le guide du développeur. +**Un défaut calculé à l'exécution doit l'être aussi dans `settings()`.** Le panneau rend le +défaut d'une variable comme valeur du champ ; un défaut qui n'existe que là où le serveur +le consomme s'affiche donc en case vide — pendant que le serveur, lui, tourne sur une +adresse qu'il a déduite et jamais montrée. `RESCRIPTUM_PUBLIC_HOST` est parti comme ça : +l'exploitant n'avait aucun moyen de voir vers quelle adresse ses machines seraient +envoyées, sinon en lisant le journal de démarrage. Deux entrées de `KNOWN` sont dans ce +cas, et toutes deux ont leur branche dans `settings()` : le nombre de threads et l'hôte +public. Une troisième demanderait le même traitement, et rien dans le typage ne le dit. + **Un CGI sous `/webman/3rdparty//` tourne sous le propriétaire du script.** Pas en `http`, et pas en root — sous celui qui possède le fichier. DSM attribue l'arborescence d'un paquet à l'utilisateur du paquet : le backend de l'application tourne donc en `rescriptum` et diff --git a/docs/development/traps.md b/docs/development/traps.md index e7d8df3..21fa866 100644 --- a/docs/development/traps.md +++ b/docs/development/traps.md @@ -199,9 +199,18 @@ further detail. So is one carrying macOS `._` members. `check-spk.sh` asserts bo ## The DSM desktop application -Seven things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and +Eight things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and none of them in the developer guide. +**A default computed at runtime has to be computed in `settings()` too.** The panel renders +a variable's default as the field's value, so a default that exists only where the server +consumes it shows as an empty box — while the server runs on an address it derived and +never displayed. `RESCRIPTUM_PUBLIC_HOST` shipped that way; the operator had no way to see +which address their machines would be sent to short of reading the startup log. Two entries +in `KNOWN` are like this, and both are special-cased in `settings()`: the worker count and +the public host. A third would need the same treatment, and nothing in the type system says +so. + **A CGI under `/webman/3rdparty//` runs as the owner of the script.** Not as `http`, and not as root — as whoever owns the file. DSM chowns a package's tree to the package user, so the application's backend runs as `rescriptum` and can read the `0600` env file it From e3d007932db21f73ce6b62714d0921eca0e0099e Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 18:31:08 +0200 Subject: [PATCH 24/59] feat(boot): a TFTP port that cannot be bound is not a dead server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port 69 is the only privileged port in the design, so it is the only bind that can fail for something nobody configured. Measured on a DSM 7.2.2 machine: the capability comes from a `setcap` outside the package, an upgrade replaces the binary and silently drops it, and with a fatal bind the whole package then goes to `start_failed` — taking answers and media with it and failing every install in flight to report that a second port could not be opened. Answers are the product; TFTP hands over one file and something else can. So it degrades instead, loudly, in three places at once: a startup warning that names what still works, a non-zero `boot check`, and the settings panel. The failure mode being refused is the silent one, not the degraded one. `boot check` gained a real probe, and writing the test is what found out why it needed one: **binding is not a health check**. A bind that succeeds means nothing is listening — the degraded state, not the healthy one — and a bind that fails cannot tell this server apart from another daemon squatting the port, because both are `AddrInUse`. So it sends an actual read request and reports what a machine would get. Both halves watched red: making the bind fatal again kills the server before it answers, and dropping the failure count makes `boot check` call it fine. The first version of the second assertion passed for the wrong reason — three missing loaders were already failing the command — so the fixture now writes every loader the table names and a control run with TFTP off proves the directory is otherwise clean. --- src/boot/tftp.rs | 56 ++++++++++++++++++++ src/cli.rs | 65 ++++++++++++++++++++++++ src/config.rs | 26 ++++++---- src/main.rs | 65 ++++++++++++++++-------- tests/tftp.rs | 130 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 309 insertions(+), 33 deletions(-) diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs index 8cc57fd..718c99c 100644 --- a/src/boot/tftp.rs +++ b/src/boot/tftp.rs @@ -499,6 +499,62 @@ fn error_packet(code: u16, message: &str) -> Vec { packet } +/// Ask a TFTP server on `addr` for `filename`, and say whether anything served it. +/// +/// **This exists because binding is not a health check, and the test that found that out +/// is in `tests/tftp.rs`.** A successful bind means *nothing is listening* — which is +/// exactly the degraded state, not the healthy one — and a failed bind cannot tell our +/// own running server apart from some other daemon squatting port 69. Both are +/// `AddrInUse`. The only question with a real answer is the one a booting machine asks: +/// send a read request, and see whether a loader comes back. +/// +/// Synchronous and short on purpose: this is `boot check`'s, not the server's, and a +/// command an operator runs must not hang on a silent port. One request, one wait, and +/// the first reply decides — `DATA` or `OACK` means served, an `ERROR` means a server is +/// there and this file is not, silence means nothing is. +pub fn probe(addr: &str, filename: &str, wait: Duration) -> ProbeResult { + let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0") else { + return ProbeResult::Silent; + }; + if socket.set_read_timeout(Some(wait)).is_err() { + return ProbeResult::Silent; + } + + let mut packet = Vec::new(); + packet.extend_from_slice(&OP_RRQ.to_be_bytes()); + packet.extend_from_slice(filename.as_bytes()); + packet.push(0); + packet.extend_from_slice(b"octet\0"); + // No options. A server that negotiates none still has to answer with plain 512-byte + // blocks, so this is the one request every TFTP server on earth understands. + if socket.send_to(&packet, addr).is_err() { + return ProbeResult::Silent; + } + + let mut buffer = [0u8; 1024]; + let Ok((n, _)) = socket.recv_from(&mut buffer) else { + return ProbeResult::Silent; + }; + if n < 2 { + return ProbeResult::Silent; + } + match u16::from_be_bytes([buffer[0], buffer[1]]) { + OP_DATA | OP_OACK => ProbeResult::Served, + OP_ERROR => ProbeResult::Refused, + _ => ProbeResult::Silent, + } +} + +/// What [`probe`] found. Three outcomes because they mean three different things to an +/// operator: a loader was handed over, a TFTP server is there but does not have that +/// file, or nothing answered at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProbeResult { + Served, + Refused, + Silent, +} + /// Whether an address is one nothing legitimate asks for a loader from. fn is_broadcastish(ip: IpAddr) -> bool { match ip { diff --git a/src/cli.rs b/src/cli.rs index e3f28c0..aea277f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1231,6 +1231,7 @@ fn boot_menu(cfg: &Config) -> ExitCode { #[cfg(feature = "boot")] fn boot_check(cfg: &Config) -> ExitCode { use crate::boot::loaders; + use crate::boot::tftp::ProbeResult; let mut failures = 0usize; let mut notes: Vec = Vec::new(); @@ -1267,6 +1268,70 @@ fn boot_check(cfg: &Config) -> ExitCode { } } + // **Can a loader actually be handed over?** Everything above is about the files + // being on disk; this is about anything reaching them over UDP, which is the first + // question a booting machine asks and the one nothing else here answers. + // + // **Binding is not the check, and finding that out cost a test.** A bind that + // *succeeds* means nothing is listening — the degraded state, not the healthy one — + // and a bind that fails cannot tell this server apart from another daemon squatting + // the port, because both are `AddrInUse`. So the probe is a real read request: what + // comes back is what a machine would get. + match cfg.tftp_addr() { + None => notes.push( + "TFTP is off (RESCRIPTUM_TFTP_ADDR) — the loaders above are served over HTTP \ + at /boot/ and something else has to hand one over on port 69" + .to_string(), + ), + Some(addr) => { + // Ask for a loader that is actually here, so `Refused` means what it says. + let wanted = loaders::loaders() + .iter() + .find(|l| dir.join(l).is_file()) + .copied() + .unwrap_or("ipxe-undionly.kpxe"); + match crate::boot::tftp::probe(&addr, wanted, std::time::Duration::from_secs(2)) { + ProbeResult::Served => println!(" ok {addr} handed over {wanted}"), + ProbeResult::Refused => { + println!( + " BROKEN a TFTP server answered on {addr} but would not serve \ + {wanted} — it is not this one, or not rooted at {}", + dir.display() + ); + failures += 1; + } + // Silence splits on whether the port is even obtainable, and the two + // halves are different problems. Cannot bind: something else holds it, + // or the privilege is missing — the DSM case, where an upgrade drops the + // `setcap` and the server warns and carries on. Can bind: nothing is + // there at all, which is simply what "the server is not running" looks + // like from a command run before starting it, so it is a note. + ProbeResult::Silent => match std::net::UdpSocket::bind(&addr) { + Ok(_) => notes.push(format!( + "nothing is listening on {addr} — expected if the server is not \ + running; if it is, it failed to bind and said so at startup" + )), + Err(e) => { + println!( + " BROKEN nothing answers on {addr} and it cannot be bound \ + either: {e}{} — the server still answers and still serves \ + media, but a machine sent here by DHCP asks for a loader and \ + gets nothing", + if addr.ends_with(":69") { + ". Port 69 is privileged: run as root and set \ + RESCRIPTUM_USER to drop afterwards, or grant the binary \ + cap_net_bind_service with setcap" + } else { + "" + } + ); + failures += 1; + } + }, + } + } + } + // The embedded script in every loader already shipped chains to a fixed port, and // it can read no configuration — it is baked in before any deployment exists. let media = cfg.media_addr(); diff --git a/src/config.rs b/src/config.rs index d2aab81..550d43b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -384,13 +384,17 @@ impl Config { /// The TFTP listener's effective address, or `None` when TFTP is off. /// - /// **`off` is a value, not an absence**, and it exists because naming a boot - /// directory used to imply a TFTP server on port 69. On a platform that cannot bind - /// a privileged port — a DSM package, a container without the capability — that - /// turned "tell the server where the loaders are" into "the server refuses to - /// start", which is a trap rather than a constraint. The loaders are still served - /// over HTTP at `/boot/…` and still checked by `boot check`; only the listener is - /// gone, and something else hands the file over. + /// **`off` is a value, not an absence** — it is how an operator says the loader will + /// come from somebody else's TFTP server while rescriptum keeps serving the rest of + /// the chain. The loaders stay served over HTTP at `/boot/…` and stay checked by + /// `boot check`; only the listener is gone. + /// + /// **It is a deployment workaround, never a packaged default.** rescriptum *is* the + /// TFTP server; a build or a package that ships with `off` set has traded away the + /// thing it is for. Where the platform makes port 69 hard, the answer is to make one + /// of the three routes durable there — bind then drop, socket activation, `setcap` — + /// not to hand the port to another daemon. See `tftp_addr_is_named` for what happens + /// when the route has not been opened yet. pub fn tftp_addr(&self) -> Option { match self.tftp_addr.as_deref() { Some(value) if is_off(value) => None, @@ -1151,10 +1155,10 @@ mod tests { #[test] fn tftp_can_be_turned_off_without_giving_up_the_boot_directory() { - // **The trap this exists to remove.** Naming a boot directory used to imply a - // TFTP server on port 69, so on a platform that cannot bind a privileged port — - // a DSM package, a container without the capability — telling the server where - // the loaders are turned into a server that refuses to start. + // **Off is a deployment workaround, never a packaged default.** It is how an + // operator says another daemon on this host hands the loader over while + // rescriptum serves the rest of the chain. rescriptum *is* the TFTP server; a + // package that shipped with this set would have traded away the thing it is for. let c = Config::from_lookup(lookup(&[ ("RESCRIPTUM_BOOT_DIR", "/srv/boot"), ("RESCRIPTUM_TFTP_ADDR", "off"), diff --git a/src/main.rs b/src/main.rs index 37fdcea..f564851 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,12 +235,10 @@ async fn serve(cfg: Arc) -> ExitCode { return ExitCode::FAILURE; } }; - // **Off is a value, not an absence.** Naming a boot directory used to imply a - // TFTP server on port 69, so on a platform that cannot bind a privileged port — - // a DSM package, a container without the capability — telling the server where - // the loaders are turned into a server that refuses to start. That is a trap - // rather than a constraint. Said once at startup, because "where did my TFTP - // go" is otherwise a silent question. + // **Off is a value, not an absence.** It is how an operator says another daemon + // hands the loader over while rescriptum serves the rest of the chain — a + // deployment workaround, never what a package ships with. Said once at startup, + // because "where did my TFTP go" is otherwise a silent question. match cfg.tftp_addr() { None => log::server(&format!( "tftp is off — {} is still served over HTTP at /boot/ and still checked \ @@ -249,31 +247,54 @@ async fn serve(cfg: Arc) -> ExitCode { )), Some(addr) => { let socket = match tokio::net::UdpSocket::bind(&addr).await { - Ok(socket) => socket, + Ok(socket) => Some(socket), + // **This is the one listener whose failure to bind does not end the + // server, and the reason is measured rather than argued.** Every + // other one here is fatal, on the rule that a server which accepts + // and never answers is worse than one that does not start. TFTP is + // where that rule inverts: port 69 is privileged — the only + // privileged port in the whole design — so this bind is the only one + // that can fail for a reason nobody configured. On a DSM 7.2.2 + // machine the capability is granted by a `setcap` outside the + // package, and **an upgrade replaces the binary and silently drops + // it**; with a fatal bind the whole package then goes to + // `start_failed`, taking answers and media with it and failing every + // install in flight to report that a second port could not be opened. + // + // Answers are the product. TFTP hands over one file and something + // else can, so this degrades instead — loudly, in three places at + // once: this line, `boot check`'s non-zero exit, and the settings + // panel. **The failure mode being refused is the silent one, not the + // degraded one**, and a warning nobody can miss is not silent. Err(e) => { log::server(&format!( - "cannot bind TFTP on {addr}: {e}{}", + "warning: cannot bind TFTP on {addr}: {e}{}. Answers and media \ + are unaffected and {} is still served over HTTP at /boot/, \ + but nothing here hands a loader over UDP — a machine sent to \ + this server by DHCP will ask and get nothing", if addr.ends_with(":69") { " — port 69 is privileged. Run as root and set \ - RESCRIPTUM_USER to drop afterwards, use setcap, choose \ - another port, or set RESCRIPTUM_TFTP_ADDR=off and let \ - something else hand the loader over" + RESCRIPTUM_USER to drop afterwards, or grant the binary \ + cap_net_bind_service with setcap" } else { "" - } + }, + tftp.root().display() )); - return ExitCode::FAILURE; + None } }; - let bound = socket - .local_addr() - .map(|a| a.to_string()) - .unwrap_or_else(|_| addr.clone()); - log::server(&format!( - "tftp listening on {bound} — serving {}", - tftp.root().display() - )); - tokio::spawn(rescriptum::boot::tftp::serve(socket, tftp)); + if let Some(socket) = socket { + let bound = socket + .local_addr() + .map(|a| a.to_string()) + .unwrap_or_else(|_| addr.clone()); + log::server(&format!( + "tftp listening on {bound} — serving {}", + tftp.root().display() + )); + tokio::spawn(rescriptum::boot::tftp::serve(socket, tftp)); + } } } } diff --git a/tests/tftp.rs b/tests/tftp.rs index e6169a5..097fb60 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -631,3 +631,133 @@ fn an_address_with_no_boot_directory_is_refused_at_startup() { String::from_utf8_lossy(&out.stderr) ); } + +/// **A TFTP port that cannot be bound must not take the answer endpoint down with it.** +/// +/// This is the one listener in the server whose failed bind is not fatal, and the reason +/// is a measurement rather than a preference: port 69 is privileged, so it is the only +/// bind that can fail for something nobody configured. On DSM the capability is granted +/// by a `setcap` outside the package and **an upgrade replaces the binary and drops it**; +/// when that was fatal the whole package went to `start_failed`, which failed every +/// install in flight to report that a second port could not be opened. +/// +/// The bind is made to fail deterministically by holding the address first — no +/// privileges involved, so this proves the same thing whether or not CI runs as root. +/// What is asserted is all three halves of the decision: the server lives, it says so, +/// and `boot check` still calls it a problem. +#[test] +fn a_tftp_port_that_cannot_be_bound_does_not_take_the_answers_down() { + let squatter = UdpSocket::bind("127.0.0.1:0").expect("hold the port first"); + let taken = squatter.local_addr().expect("addr").to_string(); + + let base = std::env::temp_dir().join(format!("rescriptum-tftp-busy-{}", std::process::id())); + let boot_dir = base.join("boot"); + let answers_dir = base.join("answers"); + fs::create_dir_all(&boot_dir).expect("boot dir"); + fs::create_dir_all(&answers_dir).expect("answers dir"); + // **Every loader the table names, not just one.** With any of them missing, + // `boot check` exits non-zero for that instead and the assertion below passes + // without proving anything — which is what happened the first time this was + // written, and is the exact shape CLAUDE.md warns about. + for name in rescriptum::boot::loaders::loaders() { + fs::write(boot_dir.join(name), loader(100)).expect("loader"); + } + fs::write(answers_dir.join("default.toml"), "keyboard = \"fr\"\n").expect("answer"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .env("RESCRIPTUM_LISTEN_ADDR", "127.0.0.1:0") + .env("RESCRIPTUM_ANSWERS_DIR", &answers_dir) + .env("RESCRIPTUM_BOOT_DIR", &boot_dir) + .env("RESCRIPTUM_TFTP_ADDR", &taken) + .stderr(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .expect("spawn server"); + + let stderr = child.stderr.take().expect("piped stderr"); + let mut lines = BufReader::new(stderr).lines(); + let mut log = Vec::new(); + let mut answer_addr = None; + for _ in 0..16 { + let Some(Ok(line)) = lines.next() else { break }; + let done = line.contains("rescriptum ") && line.contains("listening on"); + if done && let Some(rest) = line.split("listening on ").nth(1) { + answer_addr = Some( + rest.split_whitespace() + .next() + .unwrap_or_default() + .to_string(), + ); + } + log.push(line); + if done { + break; + } + } + let log = log.join("\n"); + + // 1. It said so, and said what it costs. A degraded server that says nothing is the + // failure mode this whole decision exists to refuse. + assert!( + log.contains("warning: cannot bind TFTP"), + "no warning about the failed bind; saw:\n{log}" + ); + assert!( + log.contains("Answers and media are unaffected"), + "the warning has to say what still works; saw:\n{log}" + ); + + // 2. It is still serving answers — the product. + let addr = answer_addr.unwrap_or_else(|| panic!("the server never came up; saw:\n{log}")); + let body = r#"{"dmi":{"system":{"serial":"unclaimed"}}}"#; + let mut sock = std::net::TcpStream::connect(&addr).expect("connect to answers"); + sock.set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + std::io::Write::write_all( + &mut sock, + format!( + "POST /answer HTTP/1.1\r\nHost: nas\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .expect("write"); + let mut response = String::new(); + let _ = std::io::Read::read_to_string(&mut sock, &mut response); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(response.contains("keyboard"), "{response}"); + + // 3. And `boot check` still calls it a problem, because a startup warning scrolls + // past and a non-zero exit is what a deploy script and a monitor can see. + let out = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .arg("boot") + .arg("check") + .env("RESCRIPTUM_BOOT_DIR", &boot_dir) + .env("RESCRIPTUM_TFTP_ADDR", &taken) + .output() + .expect("run boot check"); + let said = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(!out.status.success(), "boot check called it fine: {said}"); + assert!(said.contains("BROKEN nothing answers on"), "{said}"); + assert!( + said.contains("1 problem(s)"), + "the TFTP port is the only one: {said}" + ); + + // And the control: the same directory with TFTP turned off is clean. Without this + // the assertions above could be passing on some unrelated complaint. + let out = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .arg("boot") + .arg("check") + .env("RESCRIPTUM_BOOT_DIR", &boot_dir) + .env("RESCRIPTUM_TFTP_ADDR", "off") + .output() + .expect("run boot check"); + let said = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(out.status.success(), "{said}"); + + let _ = child.kill(); + let _ = child.wait(); + let _ = fs::remove_dir_all(&base); + drop(squatter); +} From cb55ab714e5c8d4da735acb202a2ed2eb938e922 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 18:44:26 +0200 Subject: [PATCH 25/59] fix(dsm): the package is the TFTP server again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A previous session met a packaging constraint and traded away the product's first principle: it wrote `RESCRIPTUM_TFTP_ADDR=off` into the package and pointed operators at Synology's own TFTP server. rescriptum *is* the TFTP server — an appliance that needs somebody else's is not an appliance — and the constraint turns out not to be one. Measured on a DSM 7.2.2 machine, all four routes to port 69. `run-as: root` in conf/privilege is refused with synopkg error 319, `invalid package privilege content`, both in `defaults` and as a per-action ctrl-script, even though Synology's own packages use exactly that shape. A `security.capability` xattr baked into package.tgz installs — the pax inner format is accepted — but Package Center strips it during extraction. `setcap cap_net_bind_service=+ep` on the installed binary works, and the package then binds udp/69 as its own unprivileged user alongside 8000 and 8001. `net.ipv4.ip_unprivileged_port_start` does not exist on that kernel. So the env file no longer sets the variable at all: the default is 0.0.0.0:69, which is what the generated DHCP snippet and every loader we ship already expect. What it does instead is say what the one root command is, and that an upgrade replaces the binary and drops the capability with it — hence the Task Scheduler boot-up task. `off` stays available as a deployment workaround for an operator who wants it, which is all it ever should have been. 69/udp joins the firewall entry, on `dst.ports` with a protocol suffix like the tcp entries already use rather than an invented `dst.udp.ports` key. And the settings panel grew a `tftp:` line, because a failed bind no longer stops the server: without it the only trace would be a startup warning that scrolled past hours ago. lifecycle-test.sh 55 → 58, and the three new checks were each watched red: reintroducing the `off` line, deleting the panel's report, and making it claim to be serving with nothing bound. --- packaging/dsm/lifecycle-test.sh | 34 ++++++--- packaging/dsm/payload/port_conf/rescriptum.sc | 4 +- packaging/dsm/payload/ui/api.cgi | 15 ++++ packaging/dsm/payload/ui/texts/enu/strings | 9 ++- packaging/dsm/payload/ui/texts/fre/strings | 9 ++- packaging/dsm/scripts/postinst | 76 +++++++++++-------- 6 files changed, 99 insertions(+), 48 deletions(-) diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh index 0e78e32..afd090a 100755 --- a/packaging/dsm/lifecycle-test.sh +++ b/packaging/dsm/lifecycle-test.sh @@ -107,10 +107,12 @@ mode=$(file_mode "$ENV_FILE") [ "$(value_of RESCRIPTUM_LISTEN_ADDR)" = "0.0.0.0:$PORT" ] && ok "the wizard's port reached the env file" || bad "listen addr is $(value_of RESCRIPTUM_LISTEN_ADDR)" [ "$(value_of RESCRIPTUM_ANSWERS_DIR)" = "$SHARE/answers" ] && ok "the answers default to the share" || bad "answers dir is $(value_of RESCRIPTUM_ANSWERS_DIR)" grep -q "^RESCRIPTUM_DB_PATH=$SHARE/answers.db\$" "$ENV_FILE" && ok "the database path is pre-set in the share" || bad "RESCRIPTUM_DB_PATH is not pre-set — switching stores would be a fatal start" -# Both listeners, and the media one even while boot media is commented out of the env -# file: registering a port does not open it, and the alternative is an operator who -# enables media and then cannot find rescriptum in the firewall list. -grep -q "dst.ports=\"$PORT/tcp 8001/tcp\"" "$ROOT/target/port_conf/rescriptum.sc" && ok "the .sc file carries the answer port and the media one" || bad ".sc file: $(tail -1 "$ROOT/target/port_conf/rescriptum.sc")" +# All three listeners, registered whether or not each is currently enabled: registering +# a port does not open it, and the alternative is an operator who turns media or TFTP on +# and then cannot find rescriptum in the firewall list. **69/udp is the one that matters +# most** — a PXE ROM asks over UDP, and a firewall that drops it produces a client which +# retries and times out with nothing in any log on this side. +grep -q "dst.ports=\"$PORT/tcp 8001/tcp 69/udp\"" "$ROOT/target/port_conf/rescriptum.sc" && ok "the .sc file carries the answer port, the media one and TFTP" || bad ".sc file: $(tail -1 "$ROOT/target/port_conf/rescriptum.sc")" # **The package must not ship a configuration that refuses to start.** Naming a media # address with no media directory is a startup error, and the first version of this @@ -120,11 +122,14 @@ grep -q "dst.ports=\"$PORT/tcp 8001/tcp\"" "$ROOT/target/port_conf/rescriptum.sc grep -q "^RESCRIPTUM_MEDIA_DIR=$SHARE/media\$" "$ENV_FILE" && ok "the media folder is named, not left to be guessed" || bad "RESCRIPTUM_MEDIA_DIR is not set to $SHARE/media" grep -q "^RESCRIPTUM_BOOT_DIR=$SHARE/boot\$" "$ENV_FILE" && ok "and the boot folder too" || bad "RESCRIPTUM_BOOT_DIR is not set to $SHARE/boot" -# **The trap this removes.** Naming a boot folder otherwise starts a TFTP server on port -# 69, which this package cannot bind — and a failed bind is a server that does not start -# at all, so the folder setting would be a trap rather than a constraint. -grep -q "^RESCRIPTUM_TFTP_ADDR=off\$" "$ENV_FILE" && ok "and TFTP is off, which is what makes naming the boot folder safe here" || bad "RESCRIPTUM_TFTP_ADDR is not off — naming a boot folder would stop the package starting" -grep -q "TFTP" "$ENV_FILE" && ok "the file says why TFTP is not available here" || bad "nothing in the file explains the missing TFTP" +# **rescriptum is the TFTP server, and the package must not ship a file that says +# otherwise.** A previous version wrote `RESCRIPTUM_TFTP_ADDR=off` here, trading the +# product's first principle for a packaging constraint; port 69 is reachable on DSM with +# one `setcap`, measured on a 7.2.2 machine. Left unset, the default is 0.0.0.0:69 — +# which is what the generated DHCP snippet and every loader we ship expect. +grep -q "^RESCRIPTUM_TFTP_ADDR=" "$ENV_FILE" && bad "RESCRIPTUM_TFTP_ADDR is live in the file — the default 0.0.0.0:69 is what the snippet and the loaders expect" || ok "TFTP is left at its default, so the package is the TFTP server" +grep -q "setcap cap_net_bind_service" "$ENV_FILE" && ok "and the file says what one root command makes it bind" || bad "nothing in the file explains how port 69 gets bound" +grep -q "Task Scheduler" "$ENV_FILE" && ok "and how to survive an upgrade, which drops the capability" || bad "nothing says the capability does not survive an upgrade" section "install without a wizard (silent_install, or a reinstall that shows none)" saved=$(cat "$ENV_FILE") @@ -163,10 +168,9 @@ rc=$? [ $rc -eq 0 ] && ok "start returns 0" || bad "start returned $rc: $out" [ -d "$SHARE/answers" ] && ok "start created the answers directory inside the share" || bad "no answers directory — DSM creates the share, not this" # Made whether or not the env file names them yet: a folder that only appears once a -# setting is enabled is one nobody discovers, and the boot one is what DSM's own TFTP -# server is pointed at. +# setting is enabled is one nobody discovers. [ -d "$SHARE/media" ] && ok "and the media folder, ready for an ISO" || bad "no $SHARE/media" -[ -d "$SHARE/boot" ] && ok "and the boot folder, for DSM's own TFTP server" || bad "no $SHARE/boot" +[ -d "$SHARE/boot" ] && ok "and the boot folder, which is what TFTP hands loaders out of" || bad "no $SHARE/boot" answered=no for _ in 1 2 3 4 5 6 7 8 9 10; do @@ -340,6 +344,12 @@ grep -q '^version: rescriptum' <<<"$out" && ok "status reports the version" || b # service's user, which read the CGI's stdin and waited on it forever. The CGI already # *is* that user, so a plain test is both possible and correct. grep -q '^answers_readable: yes' <<<"$out" && ok "and can tell that the answers folder is readable" || bad "status says the answers folder is unreadable: $out" +# **A TFTP port that cannot be bound does not stop the server**, so this line is the only +# place an operator sees it after the startup warning has scrolled away. In this harness +# nothing has bound port 69 and nothing could, so the honest answer is one of the two +# not-working states — what must never happen is silence or a claim that it is fine. +grep -qE '^tftp: (serving|broken|silent|off)$' <<<"$out" && ok "and says whether a loader can actually be handed over" || bad "status has no usable tftp line: $out" +grep -q '^tftp: serving' <<<"$out" && bad "status claims TFTP is serving, with nothing bound to port 69" || ok "and does not claim to be serving when nothing is bound" # ── 5. uninstall ─────────────────────────────────────────────────────────────── section "uninstall must leave the answers alone" diff --git a/packaging/dsm/payload/port_conf/rescriptum.sc b/packaging/dsm/payload/port_conf/rescriptum.sc index 6738247..92d7e33 100644 --- a/packaging/dsm/payload/port_conf/rescriptum.sc +++ b/packaging/dsm/payload/port_conf/rescriptum.sc @@ -1,5 +1,5 @@ [rescriptum] title="rescriptum" -desc="Unattended-installation answer server, and the installer media it serves" +desc="Unattended-installation answer server, the installer media it serves, and the loader it hands out" port_forward="no" -dst.ports="8000/tcp 8001/tcp" +dst.ports="8000/tcp 8001/tcp 69/udp" diff --git a/packaging/dsm/payload/ui/api.cgi b/packaging/dsm/payload/ui/api.cgi index 3d617d2..d02b770 100755 --- a/packaging/dsm/payload/ui/api.cgi +++ b/packaging/dsm/payload/ui/api.cgi @@ -194,6 +194,21 @@ status) else echo "answers_readable: no" fi + + # **The one state this panel has to surface that nothing else does.** A TFTP port + # that cannot be bound deliberately does not stop the server — port 69 needs a + # `setcap` that an upgrade silently drops, and answers must not go down to report + # that — so the only trace an operator would otherwise have is a startup warning that + # scrolled past hours ago. `boot check` asks the port for a real loader rather than + # trying to bind it, because binding proves the opposite of what it looks like: a + # bind that succeeds means nothing is listening. + case "$("$CLI" boot check 2>/dev/null)" in + *"handed over"*) echo "tftp: serving" ;; + *"BROKEN"*) echo "tftp: broken" ;; + *"TFTP is off"*) echo "tftp: off" ;; + *"nothing is listening"*) echo "tftp: silent" ;; + *) echo "tftp: unknown" ;; + esac ;; check) diff --git a/packaging/dsm/payload/ui/texts/enu/strings b/packaging/dsm/payload/ui/texts/enu/strings index 5a2c508..d6261e3 100644 --- a/packaging/dsm/payload/ui/texts/enu/strings +++ b/packaging/dsm/payload/ui/texts/enu/strings @@ -79,8 +79,8 @@ RESCRIPTUM_MEDIA_ADDR = "Where machines fetch kernels, initrds and images. Its o RESCRIPTUM_MEDIA_TIMEOUT_SECS = "How long one image transfer may take. Deliberately not the answer endpoint's ten seconds." RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Downloads at once. Low on purpose: each holds its slot for minutes, and this NAS has one disk." RESCRIPTUM_BOOT_ALLOW = "Client networks allowed to fetch boot media, as CIDRs. Empty means anyone who can reach the port." -RESCRIPTUM_BOOT_DIR = "Where the loaders live. This package cannot serve them itself — see the configuration file — but DSM's own TFTP server can, from this folder." -RESCRIPTUM_TFTP_ADDR = "Set to off by this package, and it must stay off: port 69 is privileged and DSM 7 does not let an unsigned package run as root, so a real address here is a package that will not start. DSM's own TFTP server hands the loader over." +RESCRIPTUM_BOOT_DIR = "Where the loaders live. They are not in this package — they are iPXE, GPLv2 — so put them here, then run 'rescriptum-cli boot check'. Served over TFTP and over HTTP at /boot/, which is what UEFI HTTP Boot fetches." +RESCRIPTUM_TFTP_ADDR = "Empty means 0.0.0.0:69, which is what every loader and every generated DHCP snippet expects. Port 69 is privileged, so binding it takes one root command once: setcap cap_net_bind_service=+ep on the binary. An upgrade drops it — a boot-up task in Task Scheduler makes it durable. Without it the server warns, keeps answering and keeps serving media, and only TFTP is down. Set 'off' if another daemon on this NAS hands loaders out instead." RESCRIPTUM_BOOT_TIMEOUT_SECS = "How long the boot menu waits before a machine falls through to its own disk." RESCRIPTUM_BOOT_LOGO = "A PNG shown behind the boot menu, replacing the built-in one." RESCRIPTUM_BOOT_TITLE = "The boot menu's title bar, replacing the built-in one." @@ -92,11 +92,16 @@ version = "Version" package = "Package" answers = "Answers folder" answers_readable = "Readable by the service" +tftp = "Loader handover (TFTP)" [value] running = "running" stopped = "stopped" crashed = "crashed, and it left its pidfile behind" +serving = "serving loaders" +broken = "not serving — port 69 needs setcap, see the TFTP setting" +silent = "nothing listening — the server is stopped, or its bind failed" +off = "off — another daemon hands the loader over" unknown = "unknown" yes = "yes" no = "no" diff --git a/packaging/dsm/payload/ui/texts/fre/strings b/packaging/dsm/payload/ui/texts/fre/strings index 86e537d..3a54e8d 100644 --- a/packaging/dsm/payload/ui/texts/fre/strings +++ b/packaging/dsm/payload/ui/texts/fre/strings @@ -73,8 +73,8 @@ RESCRIPTUM_MEDIA_ADDR = "Là où les machines récupèrent noyaux, initrds et im RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Durée maximale d'un transfert d'image. Volontairement pas les dix secondes du point de réponse." RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Téléchargements à la fois. Bas exprès : chacun retient sa place des minutes durant, et ce NAS a un disque." RESCRIPTUM_BOOT_ALLOW = "Réseaux clients autorisés à récupérer les médias, en CIDR. Vide, quiconque atteint le port." -RESCRIPTUM_BOOT_DIR = "Où vivent les chargeurs. Ce package ne peut pas les servir lui-même — voir le fichier de configuration — mais le serveur TFTP de DSM le peut, depuis ce dossier." -RESCRIPTUM_TFTP_ADDR = "Mis à off par ce paquet, et cela doit le rester : le port 69 est privilégié et DSM 7 n'autorise pas un paquet non signé à tourner en root, donc une vraie adresse ici est un paquet qui ne démarre pas. C'est le serveur TFTP de DSM qui livre le chargeur." +RESCRIPTUM_BOOT_DIR = "Où vivent les chargeurs. Ils ne sont pas dans ce paquet — c'est iPXE, en GPLv2 — donc déposez-les ici, puis lancez « rescriptum-cli boot check ». Servis en TFTP et en HTTP sur /boot/, ce que récupère l'amorçage HTTP UEFI." +RESCRIPTUM_TFTP_ADDR = "Vide signifie 0.0.0.0:69, ce qu'attendent tous les chargeurs et tous les extraits DHCP générés. Le port 69 est privilégié : l'ouvrir demande une commande root, une fois — setcap cap_net_bind_service=+ep sur le binaire. Une mise à jour la perd ; une tâche au démarrage dans le Planificateur de tâches la rend durable. Sans elle le serveur avertit, continue de répondre et de servir les images, et seul le TFTP est coupé. Mettez « off » si un autre service de ce NAS livre les chargeurs à sa place." RESCRIPTUM_BOOT_TIMEOUT_SECS = "Combien de temps le menu de démarrage attend avant qu'une machine retombe sur son propre disque." RESCRIPTUM_BOOT_LOGO = "Un PNG affiché derrière le menu de démarrage, à la place de celui intégré." RESCRIPTUM_BOOT_TITLE = "La barre de titre du menu de démarrage, à la place de celle intégrée." @@ -86,11 +86,16 @@ version = "Version" package = "Paquet" answers = "Dossier des réponses" answers_readable = "Lisible par le service" +tftp = "Livraison du chargeur (TFTP)" [value] running = "en cours d'exécution" stopped = "arrêté" crashed = "planté, en laissant son fichier de pid" +serving = "livre les chargeurs" +broken = "ne livre rien — le port 69 demande un setcap, voir le réglage TFTP" +silent = "personne n'écoute — serveur arrêté, ou liaison échouée" +off = "désactivé — un autre service livre le chargeur" unknown = "inconnu" yes = "oui" no = "non" diff --git a/packaging/dsm/scripts/postinst b/packaging/dsm/scripts/postinst index 2401616..3ad13f2 100755 --- a/packaging/dsm/scripts/postinst +++ b/packaging/dsm/scripts/postinst @@ -44,6 +44,9 @@ DEFAULT_PORT=8000 # that chains to `${next-server}:8001` before any deployment exists, so a media listener # anywhere else is one every already-shipped loader cannot reach. MEDIA_PORT=8001 +# TFTP's, and not a preference either: a PXE ROM has 69 burned into it, so this is the +# one port in the design that cannot move without changing the client. +TFTP_PORT=69 say() { echo "$PKG: $*"; } @@ -143,37 +146,45 @@ RESCRIPTUM_MEDIA_DIR=$SHARE_MEDIA # RESCRIPTUM_MEDIA_ADDR=0.0.0.0:$MEDIA_PORT # --------------------------------------------------------------------------- -# TFTP: **not available in this package, and it is not an oversight.** +# TFTP — and it is this package's, not somebody else's. # -# DSM 7 does not let an unsigned package run as root, and port 69 is privileged, so -# nothing here can bind it. Setting RESCRIPTUM_TFTP_ADDR would produce a package that -# refuses to start, which is why it is not offered. +# rescriptum *is* the TFTP server. Port 69 is privileged and DSM 7 refuses to let an +# unsigned package run as root, so it takes one root command, once, from you: # -# DSM has its own TFTP server, and it is the right one to use: +# sudo setcap cap_net_bind_service=+ep /volume1/@appstore/$PKG/bin/$PKG +# sudo synopkg restart $PKG # -# 1. Control Panel > File Services > Advanced > TFTP: enable it, and set the root to -# the '$PKG' shared folder's boot folder. -# 2. Put the loaders there. On any Linux box with a C toolchain: -# packaging/ipxe/build.sh --out /path/to/$PKG/boot -# They are not in this package either: they are GPLv2 and belong beside it, not -# welded into it. -# 3. Point DHCP at this NAS — Control Panel > DHCP Server > PXE if this NAS serves -# DHCP, or your own server with what -# rescriptum-cli boot dhcp-snippet --format dnsmasq -# prints. +# Measured on a DSM 7.2.2 machine, all four routes: `run-as: root` in conf/privilege is +# refused with synopkg error 319 (invalid package privilege content), both in `defaults` +# and as a per-action ctrl-script, even though Synology's own packages use exactly that +# shape; a security.capability xattr baked into package.tgz installs but Package Center +# strips it during extraction; setcap after install works, and the package then binds +# udp/69 as the unprivileged '$PKG' user alongside $port and $MEDIA_PORT. # -# The rest of the chain is this package's: the loader chains to port 8001 above, and -# everything after that is HTTP. The folder is named here so `rescriptum-cli boot check` -# works and so the loaders are reachable over HTTP at /boot/ — which is what UEFI HTTP -# Boot fetches, and where the boot menu looks for its logo. +# **An upgrade replaces the binary and the capability goes with it.** Re-run the two +# commands above, or make it durable with a boot-up task: Control Panel > Task Scheduler +# > Create > Triggered Task > User-defined script, user root, event Boot-up, with the +# setcap line as the script. Then run it once by hand from that page. +# +# Without the capability nothing breaks except TFTP itself: the server warns at startup, +# keeps answering, keeps serving media, and 'rescriptum-cli boot check' exits non-zero +# and says so. That is deliberate — an upgrade must not take a fleet's answers down to +# report that a second port could not be opened. +# +# RESCRIPTUM_TFTP_ADDR is left unset on purpose: the default is 0.0.0.0:69, which is +# what the generated DHCP snippet and every loader we ship expect. Set it to another +# port to avoid the capability entirely (your DHCP server must then be told), or to +# 'off' if some other daemon on this NAS is already handing out loaders — DSM has its +# own TFTP server under Control Panel > File Services > Advanced, and pointing it at the +# boot folder below is a working alternative rather than the intended one. +# RESCRIPTUM_TFTP_ADDR=0.0.0.0:69 + +# Where the loaders live. They are not in this package: they are iPXE, GPLv2, and belong +# beside it rather than welded into it. Put them here over File Station or SMB, then +# check with 'rescriptum-cli boot check'. They are served over TFTP from the address +# above *and* over HTTP at /boot/ on port $MEDIA_PORT, which is what UEFI HTTP Boot +# fetches and where the boot menu looks for its logo. RESCRIPTUM_BOOT_DIR=$SHARE_BOOT - -# **This is what makes the line above safe here.** Naming a boot folder otherwise starts -# a TFTP server on port 69, which this package cannot bind — and a failed bind is a -# server that does not start at all, so the folder setting would be a trap rather than a -# constraint. Off means no listener; the loaders are still served over HTTP and still -# checked, and DSM's own TFTP server hands the file over. -RESCRIPTUM_TFTP_ADDR=off BODY } @@ -219,10 +230,15 @@ fi # sudo /usr/syno/sbin/synopkghelper update rescriptum port-config # because Acquire skips a file that already exists in /usr/local/etc/service.d/. if [ -f "$SC_FILE" ]; then - # Both listeners. The media one is registered even while it is commented out of the - # env file: registering a port does not open it, and the alternative is an operator - # who enables boot media and then cannot find rescriptum in the firewall list. - sed "s|^dst.ports=.*|dst.ports=\"$port/tcp $MEDIA_PORT/tcp\"|" "$SC_FILE" >"$SC_FILE.new" && + # All three listeners, and every one of them is registered whether or not it is + # currently enabled: registering a port does not open it, and the alternative is an + # operator who turns boot media on and then cannot find rescriptum in the firewall + # list. **69/udp is the one worth naming here** — a machine PXE-booting asks over + # UDP broadcast, and a firewall that drops it produces a client that retries and + # times out with nothing in any log on this side. + # The protocol rides on the port, which is the shape the tcp entries already use — + # not a second dst.udp.ports key, which would be a guess. + sed "s|^dst.ports=.*|dst.ports=\"$port/tcp $MEDIA_PORT/tcp $TFTP_PORT/udp\"|" "$SC_FILE" >"$SC_FILE.new" && mv "$SC_FILE.new" "$SC_FILE" fi From d076595becdacd609dd5c9132196da0a04d0944f Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 18:49:30 +0200 Subject: [PATCH 26/59] docs: TFTP is ours on DSM too, and the four measurements that settle it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seven files that carried `RESCRIPTUM_TFTP_ADDR=off` as though it were the design, corrected. `off` keeps its place as a deployment workaround for an operator who wants one — it is never how anything here ships. The Synology guide's "TFTP: use DSM's, not ours" becomes "TFTP needs one root command": the `setcap` line, the Task Scheduler boot-up task that survives an upgrade, and what `boot check` prints while the capability is missing. The netboot guide and the configuration reference move a failed TFTP bind out of the fatal table and into the warnings one, with the reason rather than the rule. And the four DSM routes to port 69 are recorded as traps with their error codes, in both languages — `run-as: root` refused with synopkg 319 in both shapes, the xattr stripped by Package Center, `setcap` working, `ip_unprivileged_port_start` absent. The claim they replace had sat in CLAUDE.md unmeasured; it was true by luck. Two more traps beside them: a file capability does not survive an upgrade, and binding is not a health check — a bind that succeeds means nothing is listening. Both new anchors verified against the built HTML, since `notabene lint` checks routes and not anchors. A truncated sentence in the French traps page ("`check-spk.sh` vérifie les") is finished while passing. --- CLAUDE.md | 44 +++++++++--- docs/development/traps.fr.md | 31 +++++++++ docs/development/traps.md | 29 ++++++++ docs/guide/operations/netboot.fr.md | 38 +++++++--- docs/guide/operations/netboot.md | 35 +++++++--- docs/guide/operations/synology.fr.md | 89 +++++++++++++++++------- docs/guide/operations/synology.md | 83 +++++++++++++++------- docs/guide/reference/configuration.fr.md | 2 +- docs/guide/reference/configuration.md | 2 +- 9 files changed, 274 insertions(+), 79 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e09a628..b23c57b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -543,6 +543,23 @@ could not check. Note it needs `Resolution::format_name` (the extension), not and SeaBIOS says "could not read the boot disk". Use `pc` for a BIOS guest. - **`sed -n … "$0"` cannot find a relatively-invoked script after a `cd`.** Resolve the path first, or `--help` breaks for everyone who does not type an absolute path. +- **There is exactly one route to port 69 on DSM 7, and it is `setcap`.** `run-as: root` + in `conf/privilege` is refused with `synopkg` error **319**, `invalid package privilege + content` — in `defaults` *and* as a per-action `ctrl-script`, the shape Synology's own + packages use. A `security.capability` xattr in `package.tgz` installs and **Package + Center strips it**. `setcap cap_net_bind_service=+ep` after install works; + `net.ipv4.ip_unprivileged_port_start` does not exist on that kernel. Measured on a 7.2.2 + machine, all four. +- **A file capability does not survive an upgrade** — the new binary is a different file. + That is why a failed TFTP bind is the **one** listener failure here that is not fatal: + when it was, an upgrade took the answer endpoint down with it, failing every install in + flight to report that a second port could not be opened. It warns, `boot check` exits + non-zero, and the DSM panel shows a `tftp:` line. +- **Binding is not a health check.** A bind that *succeeds* on the TFTP port means nothing + is listening — the degraded state, not the healthy one — and one that fails cannot tell + this server from another daemon squatting the port, since both are `AddrInUse`. So + `boot check` sends a real read request (`boot::tftp::probe`) and reports what a machine + would get. The first version guessed, and a test with a squatter said so at once. - **A default computed at runtime must be computed in `settings()` too.** The DSM panel renders a variable's default as the field's value, so a default living only where the server consumes it shows as an empty box while the server runs on a value it derived. @@ -628,6 +645,8 @@ Environment variables only — plus an optional file to read some of them from: | `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Concurrent transfers; low on purpose | | `RESCRIPTUM_PUBLIC_HOST` | the routing table's answer, else a sole interface | The host generated URLs name. **A host, never a URL**. Warns and names the alternatives when the host has several | | `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media | +| `RESCRIPTUM_BOOT_DIR` | unset | Loaders and menus. **Unset means no TFTP at all** | +| `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` when `RESCRIPTUM_BOOT_DIR` is set | Or `off`, a deployment workaround, never a packaged default. A failed bind here warns rather than killing the server | A zero or unparseable numeric value falls back to the default rather than starting a server that accepts and never answers. @@ -635,8 +654,11 @@ that accepts and never answers. **What is fatal and what is a warning is deliberate.** Fatal: the listener cannot bind, the store cannot be opened (SQLite catches an unwritable directory, a corrupt file and a too-new schema at open, not at the first request), a named env or log file cannot be read, -and any unsafe admin combination. A warning: the answers directory is absent, is not a -directory, or cannot be listed, and any problem in the answer set — all three can be fixed +and any unsafe admin combination — **except the TFTP one**, which is the single exception +and is measured rather than argued: port 69 is the only privileged port in the design, so +it is the only bind that can fail for something nobody configured, and dying there takes +answers and media with it. A warning: a failed TFTP bind, the answers directory being +absent, not a directory, or unlistable, and any problem in the answer set — all fixable while the server runs, and it re-reads as they change. The directory check asks the filesystem whether it can list rather than reading permission bits, so it accounts for owner, group, ACLs and the mount; that is the failure a packaged, non-root run meets first. @@ -741,12 +763,18 @@ format**, exactly like the `.tar.gz` archives — no DSM-specific build, no feat nothing in `src/`. The **four** places DSM pressed back are answered in packaging: log rotation by a `copytruncate` stanza, a CLI that cannot find its configuration by a three-line wrapper (`rescriptum-cli`, which names `RESCRIPTUM_ENV_FILE`), no settings panel -by the desktop application below, and **a privileged port by not having one**. DSM 7 does -not let an unsigned package run as root, so TFTP's port 69 is unreachable: the env file -says so and points at DSM's own TFTP server (`/usr/bin/opentftp`, verified on a 7.2.2 -machine) pointed at the share's `boot` folder. Media over HTTP on 8001 works normally, and -both ports are registered with the firewall. `RESCRIPTUM_USER`/`_GROUP` are documented the -same way — the package already is its own unprivileged user. If this ever seems to need a `#[cfg]`, the design has gone wrong. +by the desktop application below, and **a privileged port by one root command**. DSM 7 +does not let an unsigned package run as root — measured, four routes, in +`docs/development/traps.md` with the error codes — but `setcap cap_net_bind_service=+ep` +on the installed binary works, after which the package binds `udp/69` as its own +unprivileged user alongside 8000 and 8001. All three are registered with the firewall. +**The capability belongs to the file, so an upgrade drops it**; the env file says so and +points at a Task Scheduler boot-up task. `RESCRIPTUM_TFTP_ADDR` is therefore left unset — +its default *is* port 69, which is what every loader and every generated snippet expects. +An earlier version shipped `off` and sent operators to DSM's own TFTP server: that traded +the product's first principle for a packaging constraint that turned out not to exist, and +it is not a precedent. `RESCRIPTUM_USER`/`_GROUP` stay documented as unusable — the package +already is its own unprivileged user. If this ever seems to need a `#[cfg]`, the design has gone wrong. ```bash ./build.sh --spk x86_64-unknown-linux-musl # build, then wrap diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index 1473c63..62b9008 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -213,6 +213,37 @@ l'inode sous un serveur qui continue d'écrire dans un fichier sans nom. **Un `.spk` dont le tar externe est gzippé est rejeté** avec « invalid file format » et rien de plus. Idem pour un qui embarque des membres `._` de macOS. `check-spk.sh` vérifie les +deux. + +**Il existe exactement une route vers le port 69 sur DSM 7, et c'est `setcap`.** Les quatre +ont été essayées sur une machine 7.2.2 le 2026-08-27, parce que l'affirmation « DSM 7 +n'autorise pas un paquet non signé à tourner en root » traînait dans `CLAUDE.md` depuis un +moment **sans mesure derrière** — vraie, mais par chance. + +| Route | Résultat | +|---|---| +| `"defaults": {"run-as": "root"}` dans `conf/privilege` | **refusée** — erreur `synopkg` **319**, `invalid package privilege content`, `stage: install_failed` | +| `"ctrl-script": [{"action":"start","run-as":"root"}]` — la forme qu'utilisent les paquets *de Synology* (FileStation, QuickConnect et StorageManager tous les trois) | **refusée**, même erreur 319 | +| `cap_net_bind_service` embarquée en attribut étendu `security.capability` dans `package.tgz` | s'installe très bien — le format pax interne est accepté — mais **Package Center supprime l'attribut**, et `getcap` revient vide | +| `setcap cap_net_bind_service=+ep` sur le binaire installé, en root, après l'installation | **fonctionne** ; le paquet ouvre alors `udp/69` sous son propre utilisateur non privilégié, à côté de 8000 et 8001 | + +`net.ipv4.ip_unprivileged_port_start` n'existe pas sur ce noyau, donc cette route est +fermée aussi. `/volume1` est en btrfs avec `nodev` mais **pas** `nosuid`, donc les capacités +de fichier y fonctionnent bien, et `/usr/bin/setcap` existe en mode `0700`. + +**La capacité appartient au fichier, donc une mise à jour la perd.** Une nouvelle version +remplace le binaire et la capacité part avec l'ancien — d'où la tâche au démarrage du +Planificateur de tâches documentée par le paquet plutôt qu'une commande unique, et d'où le +fait qu'un bind TFTP raté ne soit pas fatal : quand il l'était, cette mise à jour coupait +aussi le point d'entrée des réponses. + +**Lier n'est pas un contrôle de santé, et cela prouve le contraire de ce qu'on croit.** Un +bind qui *réussit* sur le port TFTP signifie que personne n'écoute — l'état dégradé, pas +l'état sain — et un bind qui échoue ne distingue pas ce serveur d'un autre service qui +squatterait le port, puisque les deux donnent `AddrInUse`. `boot check` envoie donc une +vraie requête de lecture et rapporte ce qu'obtiendrait une machine. Sa première version +annonçait « already in use — that is this server, if it is running » et un test avec un +squatteur sur le port a montré tout de suite que c'était une supposition. ## L'application de bureau DSM diff --git a/docs/development/traps.md b/docs/development/traps.md index 21fa866..b1f6f6c 100644 --- a/docs/development/traps.md +++ b/docs/development/traps.md @@ -197,6 +197,35 @@ carries on writing to a file with no name. **A `.spk` whose outer tar is gzipped is rejected** with "invalid file format" and no further detail. So is one carrying macOS `._` members. `check-spk.sh` asserts both. +**There is exactly one route to port 69 on DSM 7, and it is `setcap`.** All four were +tried on a 7.2.2 machine on 2026-08-27, because the claim "DSM 7 does not let an unsigned +package run as root" had sat in `CLAUDE.md` for a while with no measurement behind it — +true, but by luck. + +| Route | Result | +|---|---| +| `"defaults": {"run-as": "root"}` in `conf/privilege` | **refused** — `synopkg` error **319**, `invalid package privilege content`, `stage: install_failed` | +| `"ctrl-script": [{"action":"start","run-as":"root"}]` — the shape Synology's *own* packages use (FileStation, QuickConnect and StorageManager all do) | **refused**, same error 319 | +| `cap_net_bind_service` embedded as a `security.capability` xattr in `package.tgz` | installs fine — the pax inner format is accepted — but **Package Center strips the xattr**, and `getcap` comes back empty | +| `setcap cap_net_bind_service=+ep` on the installed binary, as root, after install | **works**; the package then binds `udp/69` as its own unprivileged user alongside 8000 and 8001 | + +`net.ipv4.ip_unprivileged_port_start` does not exist on that kernel, so that route is +closed too. `/volume1` is btrfs with `nodev` but **not** `nosuid`, so file capabilities do +work there, and `/usr/bin/setcap` exists at mode `0700`. + +**The capability belongs to the file, so an upgrade drops it.** A new version replaces the +binary and the capability goes with the old one — which is why the package documents a +Task Scheduler boot-up task rather than a one-off command, and why a failed TFTP bind is +not fatal: when it was, that upgrade took the answer endpoint down too. + +**Binding is not a health check, and it proves the opposite of what it looks like.** A +bind that *succeeds* on the TFTP port means nothing is listening — the degraded state, not +the healthy one — and a bind that fails cannot tell this server apart from another daemon +squatting the port, because both are `AddrInUse`. `boot check` therefore sends a real read +request and reports what a machine would get. The first version of it reported "already in +use — that is this server, if it is running" and a test with a squatter on the port +immediately showed that to be a guess. + ## The DSM desktop application Eight things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and diff --git a/docs/guide/operations/netboot.fr.md b/docs/guide/operations/netboot.fr.md index e93448d..938a11d 100644 --- a/docs/guide/operations/netboot.fr.md +++ b/docs/guide/operations/netboot.fr.md @@ -43,8 +43,8 @@ $ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 # ce que nommeront les scripts gén ``` `RESCRIPTUM_BOOT_DIR` dit où sont les chargeurs : non définie, il n'y a aucun listener -TFTP et rien sur `/boot/…`. La nommer démarre TFTP sauf si vous dites le contraire — voir -`off` plus bas. +TFTP et rien sur `/boot/…`. La nommer démarre TFTP sur `0.0.0.0:69` sauf si vous dites le +contraire. Le port 69 est privilégié, et c'est le *seul* port privilégié que ce serveur demandera jamais — sans répondeur DHCP, il n'y a rien après 67 ni 4011. Quatre façons de traiter la @@ -57,13 +57,33 @@ $ export RESCRIPTUM_TFTP_ADDR=0.0.0.0:6969 # ou le déplacer, si leur DHCP sai $ export RESCRIPTUM_TFTP_ADDR=off # ou n'avoir aucun listener du tout ``` -**`off` est une valeur, pas une absence**, et c'est ce qui rend sûr de nommer un dossier -de chargeurs sur une plateforme incapable de lier un port privilégié. Sans elle, dire au -serveur où sont les chargeurs implique un serveur TFTP sur le port 69 — et un bind qui -échoue est un serveur qui ne démarre pas, ce qui transforme un réglage en piège. Avec -elle, les chargeurs restent servis en HTTP sur `/boot/…` et restent vérifiés par -`boot check` ; seul le listener disparaît, et autre chose livre le fichier. C'est -exactement ainsi que le [paquet Synology](./synology.md) est livré. +**`off` est une valeur, pas une absence** — c'est ainsi qu'on dit qu'un autre service de +cette machine livre le chargeur pendant que rescriptum sert le reste de la chaîne. Les +chargeurs restent servis en HTTP sur `/boot/…` et restent vérifiés par `boot check` ; seul +le listener disparaît. C'est un contournement de déploiement pour qui le veut, **jamais la +façon dont quoi que ce soit est livré ici** : c'est rescriptum le serveur TFTP, et une +version qui le couperait par défaut aurait cédé la chose même qu'elle est. Le [paquet +Synology](./synology.md) ouvre le port 69 avec un `setcap`. + +**Un port TFTP qu'on ne peut pas lier n'arrête pas le serveur**, et c'est le seul endroit +où la règle « un listener qui ne peut pas se lier est fatal » s'inverse dans ce projet. Le +port 69 est le seul port privilégié de la conception, donc le seul bind qui puisse échouer +pour quelque chose que personne n'a configuré — une capacité qu'une mise à jour a +discrètement perdue, le plus souvent. Les réponses sont le produit ; mourir ici ferait +échouer toutes les installations en cours pour signaler qu'un second port n'a pas pu être +ouvert. Donc il avertit, continue de servir, et `boot check` sort en non-zéro : + +```console +$ rescriptum boot check + BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied. + Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant + the binary cap_net_bind_service with setcap — the server still answers and still serves + media, but a machine sent here by DHCP asks for a loader and gets nothing +``` + +Il demande un vrai chargeur au port plutôt que d'essayer de le lier, car lier prouve le +contraire de ce qu'on croit : un bind qui *réussit* signifie que personne n'écoute, et un +bind qui échoue ne distingue pas ce serveur d'un autre service qui squatterait le port. **On lie d'abord, on abandonne ensuite**, toujours. L'ordre inverse fonctionne en test sous root et échoue au déploiement, à un redémarrage — le seul moment où personne ne diff --git a/docs/guide/operations/netboot.md b/docs/guide/operations/netboot.md index 868caa3..c9521bc 100644 --- a/docs/guide/operations/netboot.md +++ b/docs/guide/operations/netboot.md @@ -41,7 +41,7 @@ $ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 # what generated scripts will name ``` `RESCRIPTUM_BOOT_DIR` says where the loaders are: unset, there is no TFTP listener and -nothing at `/boot/…`. Naming it starts TFTP unless you say otherwise — see `off` below. +nothing at `/boot/…`. Naming it starts TFTP on `0.0.0.0:69` unless you say otherwise. Port 69 is privileged, and it is the *only* privileged port this server ever wants — with no DHCP responder there is nothing after 67 or 4011. Four ways to deal with it, all @@ -54,13 +54,32 @@ $ export RESCRIPTUM_TFTP_ADDR=0.0.0.0:6969 # or move it, if their DHCP can say $ export RESCRIPTUM_TFTP_ADDR=off # or have no listener at all ``` -**`off` is a value, not an absence**, and it is what makes naming a boot directory safe -on a platform that cannot bind a privileged port. Without it, telling the server where -the loaders are implies a TFTP server on port 69 — and a failed bind is a server that -does not start, which turns a setting into a trap. With it, the loaders are still served -over HTTP at `/boot/…` and still checked by `boot check`; only the listener is gone, and -something else hands the file over. That is exactly how the [Synology -package](./synology.md) ships. +**`off` is a value, not an absence** — it is how you say another daemon on this host +hands the loader over while rescriptum serves the rest of the chain. The loaders stay +served over HTTP at `/boot/…` and stay checked by `boot check`; only the listener is gone. +It is a deployment workaround for somebody who wants it, **never how anything here +ships**: rescriptum *is* the TFTP server, and a build that turned it off by default would +have traded away the thing it is for. The [Synology package](./synology.md) binds port 69 +with a `setcap`. + +**A TFTP port that cannot be bound does not stop the server**, and that is the one place +this project's "a listener that cannot bind is fatal" rule inverts. Port 69 is the only +privileged port in the design, so it is the only bind that can fail for something nobody +configured — a capability an upgrade quietly dropped, most often. Answers are the product; +dying here would fail every install in flight to report that a second port could not be +opened. So it warns, keeps serving, and `boot check` exits non-zero: + +```console +$ rescriptum boot check + BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied. + Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant + the binary cap_net_bind_service with setcap — the server still answers and still serves + media, but a machine sent here by DHCP asks for a loader and gets nothing +``` + +It asks the port for a real loader rather than trying to bind it, because binding proves +the opposite of what it looks like: a bind that *succeeds* means nothing is listening, and +a bind that fails cannot tell this server apart from another daemon squatting the port. **Binding happens first and dropping second**, always. The other order works in testing as root and fails on deployment, at a reboot, which is the one moment nobody is watching. diff --git a/docs/guide/operations/synology.fr.md b/docs/guide/operations/synology.fr.md index ab51959..5c513e3 100644 --- a/docs/guide/operations/synology.fr.md +++ b/docs/guide/operations/synology.fr.md @@ -62,11 +62,11 @@ puis le paquet : Cinq choses à savoir avant qu'elles ne vous surprennent. -- **Il ne peut pas servir de TFTP.** Le port 69 est privilégié et DSM 7 n'autorise pas un - paquet non signé à tourner en root : la livraison du chargeur revient donc au serveur - TFTP de DSM — voir [Servir les médias d'installation, et le - PXE](#servir-les-médias-dinstallation-et-le-pxe). Tout ce qui suit le chargeur - appartient à ce paquet. +- **Il ne peut pas ouvrir le port 69 tout seul.** DSM 7 n'autorise pas un paquet non signé + à tourner en root, donc le TFTP vous demande une commande root, une seule fois — voir + [Le TFTP demande une commande root](#le-tftp-demande-une-commande-root). Tant qu'elle + n'est pas donnée, le serveur avertit, continue de répondre et de servir les images, et + seule la livraison du chargeur est coupée. - **Il n'ouvre pas le pare-feu.** Enregistrer le port fait apparaître *rescriptum* par son nom dans l'éditeur de règles au lieu d'un numéro à taper. Si votre pare-feu est actif avec une règle par défaut qui refuse, il faut toujours créer la règle. @@ -259,34 +259,69 @@ octets et une injection appliquée au fil de l'eau, donc les octets sur disque r exactement ce que Proxmox a publié et leur somme reste vérifiable contre celle de Proxmox. Voir [Servir les médias de démarrage](./media.md). -### TFTP : celui de DSM, pas le nôtre +### Le TFTP demande une commande root -**Le paquet ne peut pas faire tourner de serveur TFTP, et ce n'est pas un oubli.** Le port -69 est privilégié, et DSM 7 n'autorise pas un paquet non signé à tourner en root — définir -`RESCRIPTUM_TFTP_ADDR` produirait donc un paquet qui refuse de démarrer. C'est documenté -comme indisponible dans le fichier d'environnement plutôt que proposé et cassé. +**C'est rescriptum le serveur TFTP ici, pas DSM.** Le port 69 est privilégié et DSM 7 +refuse qu'un paquet non signé tourne en root : le paquet ne peut donc pas s'accorder le +port lui-même — mais il n'a pas besoin de root pour s'en *servir*, seulement qu'on l'y +autorise une fois : -DSM a son propre serveur TFTP, et c'est le bon ici : +```console +$ sudo setcap cap_net_bind_service=+ep /volume1/@appstore/rescriptum/bin/rescriptum +$ sudo synopkg restart rescriptum +``` + +Après quoi le paquet ouvre `udp/69` sous son propre utilisateur non privilégié +`rescriptum`, à côté de 8000 et 8001. Les trois sont enregistrés auprès du pare-feu. + +**Rendez-la durable, car une mise à jour la perd.** Installer une nouvelle version remplace +le binaire, et les capacités de fichier appartiennent au fichier — elles partent donc avec +l'ancien. Panneau de configuration → **Planificateur de tâches** → Créer → Tâche déclenchée +→ Script défini par l'utilisateur, utilisateur `root`, événement **Démarrage**, avec la +ligne `setcap` comme script. Relancez-la depuis cette page après chaque mise à jour, ou +redémarrez. + +**Rien d'autre ne casse pendant ce temps.** Un port TFTP qu'on ne peut pas ouvrir est le +seul écouteur de ce serveur dont l'échec n'est pas fatal, et c'est délibéré : les réponses +sont le produit, et une mise à jour ne doit pas couper les installations d'une flotte pour +signaler qu'un second port n'a pas pu être ouvert. Ce que vous obtenez à la place, c'est un +avertissement dans le journal, une ligne `tftp:` dans l'onglet **État** du panneau de +réglages, et : + +```console +$ rescriptum-cli boot check + BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied. + Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant + the binary cap_net_bind_service with setcap — the server still answers and still serves + media, but a machine sent here by DHCP asks for a loader and gets nothing +``` -1. **Panneau de configuration → Services de fichiers → Avancé → TFTP** — activez-le, et - définissez la racine sur le dossier `boot` du partage `rescriptum`. -2. Posez-y les chargeurs. Ils ne sont pas non plus dans le paquet — c'est iPXE, en GPLv2, - et ils ont leur place à côté plutôt que soudés dedans. Depuis n'importe quelle machine - Linux avec une chaîne de compilation C : +Notez qu'il demande un chargeur au port plutôt que d'essayer de l'ouvrir. Ouvrir le port +prouve le contraire de ce qu'on croit : une ouverture qui *réussit* signifie que personne +n'écoute. - ```console - $ packaging/ipxe/build.sh --out /chemin/vers/rescriptum/boot - ``` -3. Faites pointer le DHCP vers ce NAS — **Panneau de configuration → Serveur DHCP → PXE** - si le NAS sert le DHCP, ou votre propre serveur avec ce qu'imprime : +**Posez les chargeurs dans le dossier `boot` du partage.** Ils ne sont pas dans le paquet — +c'est iPXE, en GPLv2, et ils ont leur place à côté plutôt que soudés dedans : + +```console +$ packaging/ipxe/build.sh --out /chemin/vers/rescriptum/boot +``` + +Puis faites pointer le DHCP vers ce NAS — **Panneau de configuration → Serveur DHCP → PXE** +si le NAS sert le DHCP, ou votre propre serveur avec ce qu'imprime : + +```console +$ rescriptum-cli boot dhcp-snippet --format dnsmasq +``` - ```console - $ rescriptum-cli boot dhcp-snippet --format dnsmasq - ``` +#### Si vous préférez éviter setcap -**Tout ce qui suit le chargeur appartient à ce paquet.** Le chargeur enchaîne vers le port -8001, et à partir de là le menu, les réponses et les images sont tous servis par -rescriptum. DSM livre un fichier ; c'est toute sa part. +`RESCRIPTUM_TFTP_ADDR` accepte un port non privilégié, qui ne demande aucune capacité — il +faut alors le dire à votre serveur DHCP, puisqu'une ROM PXE a 69 gravé dedans et que seul +un premier étage de chaînage peut être redirigé. Ou mettez-le à `off` et laissez un autre +service de ce NAS livrer le chargeur ; DSM a son propre serveur TFTP sous Panneau de +configuration → Services de fichiers → Avancé, pointé sur le dossier `boot` du partage. Ce +sont deux contournements pour un déploiement qui les veut, pas ce que le paquet attend. ### Un réglage qui mérite d'être rempli diff --git a/docs/guide/operations/synology.md b/docs/guide/operations/synology.md index 697de7c..61aa5e8 100644 --- a/docs/guide/operations/synology.md +++ b/docs/guide/operations/synology.md @@ -59,10 +59,11 @@ and then the package: Five things worth knowing before they surprise you. -- **It cannot serve TFTP.** Port 69 is privileged and DSM 7 does not let an unsigned - package run as root, so the loader handoff is DSM's own TFTP server's job — see - [Serving installer media, and PXE](#serving-installer-media-and-pxe). Everything after - the loader is this package's. +- **It cannot bind port 69 on its own.** DSM 7 does not let an unsigned package run as + root, so TFTP takes one root command from you, once — see + [TFTP needs one root command](#tftp-needs-one-root-command). Until it is given, the + server warns, keeps answering and keeps serving media, and only the loader handoff is + down. - **It does not open the firewall.** Registering the port makes *rescriptum* appear by name in the rule editor instead of you typing a number. If your firewall is on with a default-deny rule, you still have to create the rule. @@ -239,33 +240,65 @@ image produces a two-hundred-byte sidecar and an injection applied on the wire, bytes on disk stay exactly what Proxmox published and their checksum stays verifiable against Proxmox's own. See [Serving boot media](./media.md). -### TFTP: use DSM's, not ours +### TFTP needs one root command -**The package cannot run a TFTP server, and that is not an oversight.** Port 69 is -privileged, and DSM 7 does not let an unsigned package run as root — so setting -`RESCRIPTUM_TFTP_ADDR` would produce a package that refuses to start. It is documented in -the env file as unavailable rather than offered and broken. +**rescriptum is the TFTP server here, not DSM.** Port 69 is privileged and DSM 7 refuses +to let an unsigned package run as root, so the package cannot grant itself the port — but +it does not need root to *use* it, only to be given permission once: -DSM has its own TFTP server, and it is the right one here: +```console +$ sudo setcap cap_net_bind_service=+ep /volume1/@appstore/rescriptum/bin/rescriptum +$ sudo synopkg restart rescriptum +``` + +After that the package binds `udp/69` as its own unprivileged `rescriptum` user, alongside +8000 and 8001. All three are registered with the firewall. + +**Make it durable, because an upgrade drops it.** Installing a new version replaces the +binary, and file capabilities belong to the file — so the capability goes with the old one. +Control Panel → **Task Scheduler** → Create → Triggered Task → User-defined script, user +`root`, event **Boot-up**, with the `setcap` line as the script. Run it once from that page +after every upgrade, or reboot. + +**Nothing else breaks while it is missing.** A TFTP port that cannot be bound is the one +listener in this server whose failure is not fatal, deliberately: answers are the product, +and an upgrade must not take a fleet's installs down to report that a second port could not +be opened. What you get instead is a warning in the log, a `tftp:` line in the settings +panel's **Status** tab, and: + +```console +$ rescriptum-cli boot check + BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied. + Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant + the binary cap_net_bind_service with setcap — the server still answers and still serves + media, but a machine sent here by DHCP asks for a loader and gets nothing +``` -1. **Control Panel → File Services → Advanced → TFTP** — enable it, and set the root to - the `rescriptum` share's `boot` folder. -2. Put the loaders there. They are not in the package either — they are iPXE, GPLv2, and - belong beside it rather than welded into it. On any Linux box with a C toolchain: +Note it asks the port for a loader rather than trying to bind it. Binding proves the +opposite of what it looks like: a bind that *succeeds* means nothing is listening. - ```console - $ packaging/ipxe/build.sh --out /path/to/rescriptum/boot - ``` -3. Point DHCP at this NAS — **Control Panel → DHCP Server → PXE** if the NAS serves DHCP, - or your own server with what this prints: +**Put the loaders in the share's `boot` folder.** They are not in the package — they are +iPXE, GPLv2, and belong beside it rather than welded into it: + +```console +$ packaging/ipxe/build.sh --out /path/to/rescriptum/boot +``` + +Then point DHCP at this NAS — **Control Panel → DHCP Server → PXE** if the NAS serves DHCP, +or your own server with what this prints: + +```console +$ rescriptum-cli boot dhcp-snippet --format dnsmasq +``` - ```console - $ rescriptum-cli boot dhcp-snippet --format dnsmasq - ``` +#### If you would rather not use setcap -**Everything after the loader is this package's.** The loader chains to port 8001, and -from there the menu, the answers and the images are all served by rescriptum. DSM hands -over one file; that is the whole of its part. +`RESCRIPTUM_TFTP_ADDR` takes an unprivileged port, which needs no capability at all — your +DHCP server has to be told, since a PXE ROM has 69 burned into it and only a chainloading +first stage can be redirected. Or set it to `off` and let another daemon on this NAS hand +the loader over; DSM has its own TFTP server under Control Panel → File Services → +Advanced, pointed at the share's `boot` folder. Both are workarounds for a deployment that +wants them, not what the package expects. ### One setting worth filling in diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index 8eb98be..d843726 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -175,7 +175,6 @@ Celles-ci arrêtent le serveur au lieu d'avertir, parce que démarrer quand mêm | `RESCRIPTUM_PUBLIC_HOST` portant un schéma, un port ou un chemin | il est écrit dans les URL de deux listeners ; un port dans la valeur épingle chaque script généré sur l'un d'eux | | `RESCRIPTUM_TFTP_ADDR` défini sans `RESCRIPTUM_BOOT_DIR` | un listener sans chargeur à distribuer | | Le répertoire de démarrage ne peut pas être résolu | chaque contrôle de chemin s'y compare | -| TFTP ne peut pas se lier | le port 69 est privilégié ; le message le dit et nomme les trois façons de l'obtenir | | `RESCRIPTUM_USER` nomme un compte inexistant | rien à devenir | ## Avertissements de démarrage @@ -191,6 +190,7 @@ Ceux-ci sont affichés et le serveur continue : | `RESCRIPTUM_ANSWER_TOKEN` de moins de 16 caractères | un avertissement, **pas** une erreur — refuser de démarrer laisserait un parc incapable de s'installer | | Tout problème dans le jeu de réponses | une ligne `warning:` chacun, le même jeu que signale `check` | | `RESCRIPTUM_PUBLIC_HOST` non défini | La réponse de la table de routage, ou l'unique adresse d'interface s'il n'y a pas de route par défaut. Journalisé dans les deux cas, en avertissement **nommant les autres adresses** s'il y en a. Un hôte derrière du NAT se trompe toujours en silence | +| TFTP ne peut pas se lier | `warning: cannot bind TFTP on … ` — **le seul listener dont l'échec de liaison n'est pas fatal.** Le port 69 est le seul port privilégié de la conception, donc le seul bind qui puisse échouer pour quelque chose que personne n'a configuré ; les réponses sont le produit, et mourir ferait échouer toutes les installations en cours pour signaler qu'un second port n'a pas pu être ouvert. `boot check` sort en non-zéro et le message nomme les façons d'obtenir le port | | Répertoire de médias absent ou illisible | une ligne `warning: media: …` — un parc ne doit jamais être incapable de s'installer parce qu'une image est bizarre | ## Options de compilation diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 33d643e..7879abc 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -170,7 +170,6 @@ These stop the server rather than warning, because starting anyway would be wors | `RESCRIPTUM_PUBLIC_HOST` carrying a scheme, a port or a path | it is written into URLs for two listeners; one port in the value pins every generated script to one of them | | `RESCRIPTUM_TFTP_ADDR` set with no `RESCRIPTUM_BOOT_DIR` | a listener with no loaders to hand out | | The boot directory cannot be resolved | every path check compares against it | -| TFTP cannot bind | port 69 is privileged; the message says so and names the three ways to have it | | `RESCRIPTUM_USER` names an account that does not exist | nothing to become | ## Startup warnings @@ -186,6 +185,7 @@ These are printed and the server carries on: | `RESCRIPTUM_ANSWER_TOKEN` under 16 characters | a warning, **not** an error — refusing to start would leave a fleet unable to install | | Any problem in the answer set | one `warning:` line each, the same set `check` reports | | `RESCRIPTUM_PUBLIC_HOST` unset | The routing table's answer, or the sole interface address when there is no default route. Logged either way, as a warning **naming the other addresses** when there are any. A NAT host still gets it wrong silently | +| TFTP cannot bind | `warning: cannot bind TFTP on … ` — **the one listener whose failed bind is not fatal.** Port 69 is the only privileged port in the design, so it is the only bind that can fail for something nobody configured; answers are the product, and dying would fail every install in flight to report that a second port could not be opened. `boot check` exits non-zero and the message names the ways to have the port | | Media directory missing or unlistable | one `warning: media: …` line — a fleet must never be unable to install because one image is odd | ## Compile-time options From d788e31be6e7bdb81a46d2b6f020083bef58387f Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 19:12:54 +0200 Subject: [PATCH 27/59] build(release): ship the loaders, so a release is a boot server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest remaining gap in Phase 2, and the blocking one: a deployment that installed the package got a TFTP server with nothing to hand out. `boot check` said so on a fresh install — three MISSING loaders — and every machine the generated DHCP snippet sent there would ask for a file, get nothing, and stop. "One binary boots an arbitrary machine" was true only for whoever built iPXE themselves. `release.yml` gains a `loaders` job: build from the pinned commit, ask `boot check` whether the directory satisfies the table the server hands out from, and attach `rescriptum-boot-assets-.tar.gz`. It is its own download and belongs to no binary archive or `.spk` — iPXE is GPLv2, separate files served alongside is mere aggregation, and `packaging/ipxe/` is the written offer that travels with it. Run end to end in a bookworm container before being written down: all eight loaders, `ipxe.iso` and `ipxe.usb`, `boot check` green, 3.1 MB packed. Two things that run found: - **The ISO target does not need an ISO writer, it needs `isolinux.bin`.** With xorriso installed it still failed with `util/genfsimg: could not find isolinux.bin`, and the note said "needs xorriso or mkisofs" — which sends you after the wrong package. Debian's is `isolinux`; adding it makes the bootable ISO build, so IPMI virtual media comes for free. - **`boot check` now probes the TFTP port**, so the CI and release steps that ask it about a *directory* pin `RESCRIPTUM_TFTP_ADDR=off`. On a runner where port 69 is neither bound nor bindable it would otherwise report a real problem and fail the wrong job over it. The rig is what proves a loader actually gets handed over. The bundle carries a README saying where it goes, because the loaders are inert until `RESCRIPTUM_BOOT_DIR` names them. Both guides now point at the download first and keep building it yourself as the alternative. --- .github/workflows/ci.yml | 8 +++- .github/workflows/release.yml | 68 +++++++++++++++++++++++++++- CLAUDE.md | 5 +- docs/development/releasing.fr.md | 13 +++++- docs/development/releasing.md | 12 ++++- docs/guide/operations/netboot.fr.md | 26 ++++++++--- docs/guide/operations/netboot.md | 23 ++++++++-- docs/guide/operations/synology.fr.md | 11 ++++- docs/guide/operations/synology.md | 11 ++++- packaging/ipxe/build.sh | 40 +++++++++++++++- 10 files changed, 195 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 472095e..495c838 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - build-essential liblzma-dev mtools xorriso gcc-aarch64-linux-gnu + build-essential liblzma-dev mtools xorriso isolinux gcc-aarch64-linux-gnu aarch64-linux-gnu-ld --version | head -1 - name: Build the loaders @@ -86,10 +86,16 @@ jobs: # Ask the server whether the directory satisfies the table it serves from. A # snippet naming a loader that is not here fails silently at the ROM, and this is # the only thing that catches it. + # `RESCRIPTUM_TFTP_ADDR=off` because the question here is whether the *directory* + # satisfies the table, not whether a listener is up: `boot check` also probes the + # TFTP port, and on a runner where port 69 is neither bound nor bindable that is a + # problem it would rightly report and wrongly fail this job over. The rig is what + # proves a loader actually gets handed over. - name: Does the server agree the set is complete? run: | cargo build --release RESCRIPTUM_BOOT_DIR="$GITHUB_WORKSPACE/loaders" \ + RESCRIPTUM_TFTP_ADDR=off \ RESCRIPTUM_PUBLIC_HOST=192.0.2.10 \ ./target/release/rescriptum boot check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a3ad23..fa77906 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,6 +130,72 @@ jobs: path: dist/*.tar.gz* retention-days: 7 + loaders: + name: Branded iPXE loaders + needs: verify + runs-on: ubuntu-latest + # **Without this the release is incomplete, and quietly so.** A deployment that + # installs the package gets a TFTP server with nothing to hand out: `boot check` + # reports three MISSING loaders and every machine the generated DHCP snippet sends + # here asks for a file, gets nothing, and stops. Until this job existed, "one binary + # boots an arbitrary machine" was true only for whoever built iPXE themselves. + # + # They are a separate download rather than part of any binary archive or `.spk` + # because they are iPXE and iPXE is GPLv2: separate files served alongside is mere + # aggregation, and packaging/ipxe/ is the written offer that goes with them. + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + # iPXE's EFI targets need the architecture's own `ld` and `objcopy`; the BIOS ones + # build with the host's, and they are 32-bit x86 — which is why this job is amd64 + # and not arm. + - name: Toolchain + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential liblzma-dev mtools xorriso isolinux gcc-aarch64-linux-gnu + aarch64-linux-gnu-ld --version | head -1 + + - name: Build them + run: | + set -euo pipefail + VERSION="${{ needs.verify.outputs.version }}" + NAME="rescriptum-boot-assets-$VERSION" + packaging/ipxe/build.sh --out "$GITHUB_WORKSPACE/dist/$NAME" + cat "$GITHUB_WORKSPACE/dist/$NAME/SHA256SUMS" + + # The same question CI asks of the same script: does the server agree the directory + # satisfies the table it serves from. A snippet naming a loader that is not here + # fails silently at the ROM, and this is the only thing that catches it before a + # release goes out. TFTP off because the question is the file set, not a listener. + - name: Does the server agree the set is complete? + run: | + set -euo pipefail + VERSION="${{ needs.verify.outputs.version }}" + cargo build --release + RESCRIPTUM_BOOT_DIR="$GITHUB_WORKSPACE/dist/rescriptum-boot-assets-$VERSION" \ + RESCRIPTUM_TFTP_ADDR=off \ + RESCRIPTUM_PUBLIC_HOST=192.0.2.10 \ + ./target/release/rescriptum boot check + + - name: Pack + run: | + set -euo pipefail + VERSION="${{ needs.verify.outputs.version }}" + NAME="rescriptum-boot-assets-$VERSION" + tar -C dist -czf "dist/${NAME}.tar.gz" "$NAME" + ( cd dist && shasum -a 256 "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256" ) + ls -l dist + + - uses: actions/upload-artifact@v4 + with: + name: boot-assets + path: dist/*.tar.gz* + if-no-files-found: error + retention-days: 7 + package-dsm: name: Synology packages needs: [verify, build] @@ -184,7 +250,7 @@ jobs: publish: name: Publish the release - needs: [verify, build, package-dsm] + needs: [verify, build, loaders, package-dsm] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index b23c57b..1c78dd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1031,7 +1031,10 @@ SemVer tags. Keep PRs focused. What does **not** carry over from notabene: it is an npm package and publishes prereleases to an `@dev` dist-tag. This project ships a **compiled binary**, so the release artifact is a GitHub Release with cross-compiled binaries attached, built by a CI matrix, plus a `.spk` -per Linux ABI from the `package-dsm` job. A `spk_build` dispatch input ships a +per Linux ABI from the `package-dsm` job and **`rescriptum-boot-assets-.tar.gz` +from the `loaders` job** — the branded iPXE loaders, their own download because they are +GPLv2 and `packaging/ipxe/` is the written offer. Without it a release ships a TFTP server +with nothing to hand out. A `spk_build` dispatch input ships a packaging-only fix as `0.1.0-2` without a new tag. Submission to SynoCommunity may follow later; a package source that Package Center could poll deliberately will not — there are no update notifications, and the documentation says so. diff --git a/docs/development/releasing.fr.md b/docs/development/releasing.fr.md index cffd7d0..ef75118 100644 --- a/docs/development/releasing.fr.md +++ b/docs/development/releasing.fr.md @@ -70,10 +70,19 @@ git push origin main --follow-tags 3. Empaquette chacune en `rescriptum--.tar.gz`, avec `README.md` et `LICENSE` à côté du binaire, plus une **somme SHA-256** — qui fait tourner cela en root devrait pouvoir vérifier ce qu'il a téléchargé. -4. Emballe les builds musl Linux en [paquets Synology](./building.md#le-paquet-synology), +4. **Construit les chargeurs iPXE marqués** depuis le commit épinglé et les attache en + `rescriptum-boot-assets-.tar.gz`, après avoir demandé à `boot check` si le + répertoire satisfait la table de chargeurs depuis laquelle le serveur distribue. Sans + cela la release est incomplète, et silencieusement : un déploiement obtient un serveur + TFTP sans rien à distribuer, et chaque machine que l'extrait DHCP généré envoie là + demande un fichier, n'obtient rien, et s'arrête. **C'est un téléchargement à part, + jamais dans une archive binaire ni dans un `.spk`** — c'est iPXE, en GPLv2, et des + fichiers séparés servis à côté relèvent de la simple agrégation, avec `packaging/ipxe/` + pour offre écrite. +5. Emballe les builds musl Linux en [paquets Synology](./building.md#le-paquet-synology), `rescriptum---.spk`, et contrôle structurellement chacun avant qu'il puisse être publié. -5. Crée la GitHub Release avec `gh` et `--generate-notes`, ou verse dedans si elle existe +6. Crée la GitHub Release avec `gh` et `--generate-notes`, ou verse dedans si elle existe déjà. Il est relançable à la main via `workflow_dispatch` avec un tag, pour quand un job échoue après diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 890e8cb..40dc347 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -70,10 +70,18 @@ git push origin main --follow-tags 3. Packages each as `rescriptum--.tar.gz`, with `README.md` and `LICENSE` alongside the binary, plus a **SHA-256 sum** — whoever runs this as root should be able to check what they downloaded. -4. Wraps the Linux musl builds as [Synology packages](./building.md#the-synology-package), +4. **Builds the branded iPXE loaders** from the pinned commit and attaches them as + `rescriptum-boot-assets-.tar.gz`, after asking `boot check` whether the + directory satisfies the loader table the server hands out from. Without this the + release is incomplete and quietly so: a deployment gets a TFTP server with nothing to + hand out, and every machine the generated DHCP snippet sends there asks for a file, + gets nothing, and stops. **They are their own download, never part of a binary archive + or an `.spk`** — they are iPXE, GPLv2, and separate files served alongside is mere + aggregation, with `packaging/ipxe/` as the written offer. +5. Wraps the Linux musl builds as [Synology packages](./building.md#the-synology-package), `rescriptum---.spk`, and checks each structurally before it can be published. -5. Cuts the GitHub Release with `gh` and `--generate-notes`, or uploads into it if it +6. Cuts the GitHub Release with `gh` and `--generate-notes`, or uploads into it if it already exists. It is re-runnable by hand through `workflow_dispatch` with a tag, for when a job fails diff --git a/docs/guide/operations/netboot.fr.md b/docs/guide/operations/netboot.fr.md index 938a11d..ed126a4 100644 --- a/docs/guide/operations/netboot.fr.md +++ b/docs/guide/operations/netboot.fr.md @@ -177,19 +177,33 @@ registre ne donnerait rien à la moitié d'un parc. réseau. Toutes les variantes sont servies et la table choisit ; c'est précisément le savoir qu'un exploitant ne devrait pas avoir à acquérir. -::: warning Aucune version publiée ne distribue encore les chargeurs -`packaging/ipxe/build.sh` construit les huit depuis un commit épinglé et a été exécuté, -mais rien n'est publié comme artefact de release — pour l'instant, construisez-les -vous-même : +### Se les procurer + +Chaque version publiée attache `rescriptum-boot-assets-.tar.gz`. Décompressez-le +là où le serveur peut le lire, nommez le répertoire, et vérifiez-le : ```console -$ packaging/ipxe/build.sh --out /srv/boot +$ tar -xzf rescriptum-boot-assets-0.2.0.tar.gz -C /srv +$ export RESCRIPTUM_BOOT_DIR=/srv/rescriptum-boot-assets-0.2.0 $ rescriptum boot check ``` +Il contient les huit chargeurs, un `SHA256SUMS`, un `ipxe.iso` et un `ipxe.usb` +démarrables pour une machine sans ROM PXE utilisable, et un `NOTICE` — c'est iPXE, en +GPLv2, construit depuis un commit amont épinglé. **C'est un téléchargement séparé, et il +ne fait partie d'aucune archive binaire ni d'aucun `.spk`**, délibérément : des fichiers +séparés servis à côté relèvent de la simple agrégation, et `packaging/ipxe/` est l'offre +écrite qui les accompagne. + +Pour les construire vous-même à la place — le même script que la release exécute, depuis +le même épinglage : + +```console +$ packaging/ipxe/build.sh --out /srv/boot +``` + Un chargeur venu d'ailleurs convient aussi, à condition qu'il enchaîne vers *ce* serveur plutôt que vers Internet — voir ci-dessous pourquoi un chargeur d'origine ne le fait pas. -::: ## Comment iPXE finit par parler à *nous* diff --git a/docs/guide/operations/netboot.md b/docs/guide/operations/netboot.md index c9521bc..a6582fc 100644 --- a/docs/guide/operations/netboot.md +++ b/docs/guide/operations/netboot.md @@ -166,18 +166,31 @@ generated from the registry alone would hand half a fleet nothing. are served and the table picks; this is precisely the knowledge an operator should not have to acquire. -::: warning No release publishes the loaders yet -`packaging/ipxe/build.sh` builds all eight from a pinned upstream commit and has been run, -but nothing is published as a release artifact — so for now you build them yourself: +### Getting them + +Every release attaches `rescriptum-boot-assets-.tar.gz`. Unpack it where the +server can read it, name the directory, and check it: ```console -$ packaging/ipxe/build.sh --out /srv/boot +$ tar -xzf rescriptum-boot-assets-0.2.0.tar.gz -C /srv +$ export RESCRIPTUM_BOOT_DIR=/srv/rescriptum-boot-assets-0.2.0 $ rescriptum boot check ``` +It carries the eight loaders, a `SHA256SUMS`, a bootable `ipxe.iso` and `ipxe.usb` for a +machine with no usable PXE ROM, and a `NOTICE` — they are iPXE, GPLv2, built from a +pinned upstream commit. **They are a separate download and not part of any binary archive +or `.spk`**, deliberately: separate files served alongside is mere aggregation, and +`packaging/ipxe/` is the written offer that goes with them. + +To build them yourself instead — the same script the release runs, from the same pin: + +```console +$ packaging/ipxe/build.sh --out /srv/boot +``` + A loader from elsewhere works too, provided it chains to *this* server rather than to the internet — see below for why a stock one does not. -::: ## How iPXE ends up talking to *us* diff --git a/docs/guide/operations/synology.fr.md b/docs/guide/operations/synology.fr.md index 5c513e3..1e8231b 100644 --- a/docs/guide/operations/synology.fr.md +++ b/docs/guide/operations/synology.fr.md @@ -301,7 +301,16 @@ prouve le contraire de ce qu'on croit : une ouverture qui *réussit* signifie qu n'écoute. **Posez les chargeurs dans le dossier `boot` du partage.** Ils ne sont pas dans le paquet — -c'est iPXE, en GPLv2, et ils ont leur place à côté plutôt que soudés dedans : +c'est iPXE, en GPLv2, et ils ont leur place à côté plutôt que soudés dedans. Chaque version +publiée attache `rescriptum-boot-assets-.tar.gz` ; décompressez-le et copiez son +contenu dans le dossier via File Station ou SMB, puis : + +```console +$ rescriptum-cli boot check +``` + +Ou construisez-les vous-même sur n'importe quelle machine Linux avec une chaîne de +compilation C, depuis le même commit épinglé que la release : ```console $ packaging/ipxe/build.sh --out /chemin/vers/rescriptum/boot diff --git a/docs/guide/operations/synology.md b/docs/guide/operations/synology.md index 61aa5e8..f54e7d6 100644 --- a/docs/guide/operations/synology.md +++ b/docs/guide/operations/synology.md @@ -278,7 +278,16 @@ Note it asks the port for a loader rather than trying to bind it. Binding proves opposite of what it looks like: a bind that *succeeds* means nothing is listening. **Put the loaders in the share's `boot` folder.** They are not in the package — they are -iPXE, GPLv2, and belong beside it rather than welded into it: +iPXE, GPLv2, and belong beside it rather than welded into it. Every release attaches +`rescriptum-boot-assets-.tar.gz`; unpack it and copy the contents into the folder +over File Station or SMB, then: + +```console +$ rescriptum-cli boot check +``` + +Or build them yourself on any Linux box with a C toolchain, from the same pinned commit +the release uses: ```console $ packaging/ipxe/build.sh --out /path/to/rescriptum/boot diff --git a/packaging/ipxe/build.sh b/packaging/ipxe/build.sh index 70a9e42..93c9377 100755 --- a/packaging/ipxe/build.sh +++ b/packaging/ipxe/build.sh @@ -7,7 +7,7 @@ # Needs a C toolchain, GNU make, perl, and — for the EFI targets — the cross binutils # for that architecture. On Debian: # -# apt install build-essential liblzma-dev mtools gcc-aarch64-linux-gnu +# apt install build-essential liblzma-dev mtools xorriso isolinux gcc-aarch64-linux-gnu # # ## Why we build iPXE at all # @@ -141,7 +141,11 @@ build_efi arm64 snponly.efi ipxe-arm64-snponly.efi # version of this script made, and it failed into the `||` below rather than saying so. # They are Phase 5 of the plan and nothing depends on them yet, so a failure here is a # note rather than an error. -build ipxe.iso ipxe.iso || echo "note: the ISO target needs xorriso or mkisofs" +# **The ISO needs `isolinux.bin`, not an ISO writer.** The first version of this note +# said "xorriso or mkisofs", and with xorriso installed the target still failed with +# `util/genfsimg: could not find isolinux.bin` — which sends you after the wrong package. +# On Debian it is `isolinux`; the USB target is the one that wants mtools. +build ipxe.iso ipxe.iso || echo "note: the ISO target needs isolinux (for isolinux.bin) and xorriso" build ipxe.usb ipxe.usb || echo "note: the USB target needs mtools" ( cd "$OUT" && sha256sum ./* > SHA256SUMS 2>/dev/null || shasum -a 256 ./* > SHA256SUMS ) @@ -158,6 +162,38 @@ The complete corresponding source is the commit above plus packaging/ipxe/ in https://github.com/z29k/rescriptum — branding.h, embed.ipxe, PINNED and build.sh. NOTICE +# **The bundle has to say where it goes.** A release attaches this directory as a +# tarball, and somebody who downloads it has the loaders and no idea that they are useless +# until `RESCRIPTUM_BOOT_DIR` names the directory they are in. One file closes that gap. +cat > "$OUT/README" < Date: Thu, 27 Aug 2026 19:26:09 +0200 Subject: [PATCH 28/59] test(boot): the probe's healthy path, which nothing reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `boot check`'s TFTP probe had a test for the port being dead and none for it working — so it could have reported every port as a problem and still looked correct, because only the failing case is one anybody notices. Two outcomes pinned: `Served`, against a real server over real UDP, ending in `boot check` exiting zero and saying "handed over"; and `Refused`, a server that is there without the file asked for, which `boot check` itself never produces because it only ever asks for a loader on disk. Telling that apart from silence is what separates a misconfigured root from nothing running at all. Watched red by making a DATA reply read as silence. --- tests/tftp.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/tftp.rs b/tests/tftp.rs index 097fb60..cca2366 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -761,3 +761,39 @@ fn a_tftp_port_that_cannot_be_bound_does_not_take_the_answers_down() { let _ = fs::remove_dir_all(&base); drop(squatter); } + +/// The other half of the same decision: **when TFTP is healthy, `boot check` has to say +/// so.** Without this the command could report every port as a problem and still look +/// correct, because the failing case above is the only one anybody would notice. +/// +/// It also pins the probe's happy path, which nothing else reaches: `Served` means a real +/// read request came back with data. +#[test] +fn boot_check_says_so_when_a_loader_really_is_handed_over() { + let files: Vec<(&str, Vec)> = rescriptum::boot::loaders::loaders() + .iter() + .map(|name| (*name, loader(2048))) + .collect(); + let s = Server::start(&files); + + let out = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .arg("boot") + .arg("check") + .env("RESCRIPTUM_BOOT_DIR", &s.boot_dir) + .env("RESCRIPTUM_TFTP_ADDR", &s.tftp_addr) + .output() + .expect("run boot check"); + let said = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(out.status.success(), "{said}"); + assert!(said.contains("handed over"), "{said}"); + + // And the third outcome, which `boot check` itself never produces because it only + // ever asks for a loader that is on disk: a server is there and the file is not. + // Worth telling apart from silence — one is a misconfigured root, the other is + // nothing running at all. + use rescriptum::boot::tftp::{ProbeResult, probe}; + assert_eq!( + probe(&s.tftp_addr, "nothing-of-the-sort", Duration::from_secs(2)), + ProbeResult::Refused + ); +} From 79be077b44cc8cd1fe3f322efbb73995b7843855 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 19:34:26 +0200 Subject: [PATCH 29/59] test(dsm): the machine answers the two questions the reversal rested on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `on-dsm.sh` printed the firewall line and never asserted anything about it, and nothing anywhere covered the route to port 69 — which is the one thing the whole `off` reversal depends on. 42 → 47 checks, run on the DSM 7.2.2 VM: - **`dst.ports="8000/tcp 8001/tcp 69/udp"` survives into `/usr/local/etc/services.d/rescriptum.sc` verbatim.** The protocol suffix was inferred from the form the tcp entries already use rather than from documentation, so it needed measuring; it holds. - **Without the capability the package still answers.** That is the non-fatal decision validated on the machine instead of at a desk, and the log carries its `cannot bind TFTP` line rather than going quiet. - **`setcap cap_net_bind_service=+ep` plus a restart binds `udp/69`** as the unprivileged package process, and answers are unaffected by gaining it — `netstat` shows `0.0.0.0:69 … rescriptum`. The stale comment above it, saying the boot folder is what DSM's own TFTP server gets pointed at, goes with them. --- CLAUDE.md | 2 +- packaging/dsm/vm/remote-check.sh | 57 ++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1c78dd1..b2e3eb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -859,7 +859,7 @@ an unresolved `section:key` renders as that literal text under the icon. which found two bugs no fake-tree harness could: `ROOT` derived from `SYNOPKG_PKGDEST` (a symlink target, so the env file landed where nothing reads it and the service never started), and a *fresh* install restoring a removed installation's configuration out of a stale -`$SYNOPKG_TEMP_UPGRADE_FOLDER`. 24 checks green end to end. **The DS416j run has since happened too**, and found what the +`$SYNOPKG_TEMP_UPGRADE_FOLDER`. 47 checks green end to end. **The DS416j run has since happened too**, and found what the VM could not: the ARMv7 musl build cannot run on Synology's 3.10 kernels (hence the glibc target), and a Mac editing the answers share over SMB drops AppleDouble files that hijack a machine's answer (hence hidden entries being skipped). diff --git a/packaging/dsm/vm/remote-check.sh b/packaging/dsm/vm/remote-check.sh index c14b9a6..68b7820 100755 --- a/packaging/dsm/vm/remote-check.sh +++ b/packaging/dsm/vm/remote-check.sh @@ -116,9 +116,8 @@ PORT=$(sed -n 's/^RESCRIPTUM_LISTEN_ADDR=.*:\([0-9]*\)$/\1/p' "$ROOT/etc/$PKG.en [ -d "$SHARE/answers" ] && ok "start created the answers directory inside the share" || bad "no $SHARE/answers" # The media and boot folders, made whether or not the env file names them yet: a folder -# that only appears once a setting is enabled is one nobody discovers, and the boot one -# is what DSM's own TFTP server gets pointed at. The package cannot serve TFTP itself — -# port 69 is privileged and DSM 7 does not let an unsigned package run as root. +# that only appears once a setting is enabled is one nobody discovers. The boot one is +# what this package's own TFTP server hands loaders out of — see the setcap section below. for extra in media boot; do [ -d "$SHARE/$extra" ] && ok "and the $extra folder, ready to be filled" || bad "no $SHARE/$extra" sudo -u "$PKG" test -w "$SHARE/$extra" 2>/dev/null && @@ -160,11 +159,63 @@ if [ -n "$SC" ]; then note "→ the worker acquired BEFORE postinst: the .sc ships 8000, and only" note " 'synopkghelper update $PKG port-config' moves it afterwards" fi + # **Did DSM keep 69/udp?** The protocol suffix on `dst.ports` is inferred from the + # form the tcp entries already use, not from documentation — so the question is + # whether the worker copies it through or quietly drops what it does not parse. A + # firewall entry missing the TFTP port produces a PXE client that retries and times + # out with nothing in any log on this side, which is the worst kind of failure here. + if grep -q '69/udp' "$SC"; then + ok " and it kept 69/udp, so the TFTP port can be allowed by name" + else + bad " but 69/udp did not survive into it — the suffix form is wrong for this DSM" + fi else bad "no $PKG.sc in /usr/local/etc/services.d or service.d — the firewall entry will never appear" note "what is there: $(ls /usr/local/etc/services.d 2>/dev/null | tr '\n' ' ')" fi +# ── TFTP, and the one root command it takes ──────────────────────────────────── +section "TFTP: the port the package cannot grant itself" +# **This is the section the whole `off` reversal rests on.** DSM 7 refuses `run-as: root` +# (synopkg 319) and Package Center strips a security.capability xattr out of package.tgz, +# so the only route to port 69 is a setcap applied after install — and the package cannot +# apply it, because its lifecycle scripts are not root either. What is asserted here is +# both halves: that the package is *useful* without it, and that it *works* with it. +BIN="$ROOT/target/bin/$PKG" +note "getcap before: $(getcap "$BIN" 2>/dev/null || echo '(none)')" + +# Half one. A fresh install has no capability, so the TFTP bind fails — and that must not +# stop anything else. This is the measured reason a failed TFTP bind is a warning rather +# than fatal: when it was fatal the whole package went to start_failed, taking answers and +# media with it, and an upgrade drops the capability silently. +[ "$(curl -fsS "http://127.0.0.1:$PORT/health" 2>/dev/null)" = OK ] && + ok "without the capability the package still answers — a failed TFTP bind is not fatal" || + bad "the package is down without the capability; a failed TFTP bind must never cost the answer endpoint" +grep -qi "cannot bind TFTP" "$ROOT/var/$PKG.log" "$ROOT/var/startup.log" 2>/dev/null && + ok "and said so in its log rather than failing silently" || + note "no 'cannot bind TFTP' line — check whether something already holds 69 on this machine" + +# Half two. The one root command, and whether the port is really bound afterwards by the +# unprivileged package user. +if [ -x /usr/bin/setcap ]; then + run /usr/bin/setcap cap_net_bind_service=+ep "$BIN" + note "getcap after: $(getcap "$BIN" 2>/dev/null || echo '(none)')" + run synopkg restart "$PKG" + sleep 4 + if netstat -lnup 2>/dev/null | grep -q ':69 '; then + ok "with cap_net_bind_service the package binds udp/69" + note "$(netstat -lnup 2>/dev/null | grep ':69 ')" + else + bad "udp/69 is still not bound after setcap and a restart" + tail -n 5 "$ROOT/var/$PKG.log" 2>/dev/null | sed 's/^/ /' + fi + [ "$(curl -fsS "http://127.0.0.1:$PORT/health" 2>/dev/null)" = OK ] && + ok "and answers are unaffected by gaining it" || + bad "the package stopped answering after setcap" +else + note "no /usr/bin/setcap on this machine — the only route to port 69 is closed here" +fi + if [ -e /usr/local/bin/$PKG-cli ]; then ok "rescriptum-cli is on PATH" else From cb106d050c2cb3e98c1b8d473cd1a6f47a73f182 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 19:38:18 +0200 Subject: [PATCH 30/59] docs(testing): the count was 333 and the boot suites were not in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The testing page had drifted badly: 333 tests against a real 545, a per-suite table missing `tests/media.rs` and `tests/tftp.rs` entirely, and stale figures in half its rows. Counted per file rather than remembered. Three sections added for what the table now names — why a TFTP transfer can only be tested as a conversation, why the media suite ends every abuse case by proving answers still work, and that the boot chain lives in a rig `cargo test` does not run. The harness table gains the route to port 69, which is what `on-dsm.sh` now owns, and the go-red paragraph gains today's numbers with the three most recent checks and how each was watched failing. Both new in-page anchors verified against the built HTML — `notabene lint` checks routes, not anchors. --- CLAUDE.md | 2 +- docs/development/testing.fr.md | 75 ++++++++++++++++++++++++++++++---- docs/development/testing.md | 69 +++++++++++++++++++++++++++---- 3 files changed, 128 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b2e3eb9..eef5c30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -919,7 +919,7 @@ the image and not derived from `DISK_SIZE`. `run-vm.sh` is the loader-image fall ## Testing expectations -524 tests, plus the package's own harnesses (see *The DSM package*, and note that +545 tests, plus the package's own harnesses (see *The DSM package*, and note that `cargo test` does not run those). `docs/development/testing.md` has the per-suite table; the rules that decide where a test goes: diff --git a/docs/development/testing.fr.md b/docs/development/testing.fr.md index 4e6b0a7..13e2b3d 100644 --- a/docs/development/testing.fr.md +++ b/docs/development/testing.fr.md @@ -8,7 +8,14 @@ sidebar: # Tests -333 tests. `cargo test` les fait tous tourner en quelques secondes. +545 tests. `cargo test` les fait tous tourner en une vingtaine de secondes — dont +l'essentiel dans `tests/tftp.rs`, qui attend de vrais délais UDP parce que c'est +précisément ce qu'il teste. + +**`cargo test` ne lance pas les bancs qui comptent le plus** : le banc de démarrage, les +trois du paquet DSM, et la construction des chargeurs. Voir [Le paquet aussi est +testé](#le-paquet-aussi-est-testé-à-trois-endroits) et [le banc de +démarrage](#la-chaîne-de-démarrage-a-sa-place-dans-le-banc). ```bash cargo test # tout @@ -21,19 +28,22 @@ cargo test --all-features # ce que lance la CI | Suite | Cas | Pour | |---|---|---| -| `tests/stores.rs` | 39 | **chaque comportement, contre les deux stores** | +| `tests/cli.rs` | 47 | `render`, `check`, `import`, `export`, `config` et le fichier d'environnement — contre le vrai binaire | | `tests/integration.rs` | 45 | le vrai binaire sur une vraie socket | +| `tests/media.rs` | 45 | les médias de démarrage contre le vrai binaire, les deux listeners debout | +| `src/config.rs` | 42 | l'environnement, ce qui refuse de démarrer, et qui l'emporte du fichier ou de l'environnement | +| `tests/stores.rs` | 39 | **chaque comportement, contre les deux stores** | | `src/select.rs` | 27 | normalisation, scoring, superposition, remplissage de templates | | `src/format/mod.rs` | 27 | parsing, fusion, clés de contrôle, alias d'endpoint | -| `src/facts.rs` | 22 | parsing de query, aplatissement JSON, globbing | | `tests/admin.rs` | 26 | l'API d'administration de bout en bout, formats compris | -| `tests/cli.rs` | 39 | `render`, `check`, `import`, `export`, `config` et le fichier d'environnement — contre le vrai binaire | -| `src/log.rs` | 4 | lecture des niveaux, et l'arithmétique d'horodatage | +| `src/envfile.rs` | 23 | le parseur et l'écrivain du fichier d'environnement, et ce que chacun refuse | +| `src/facts.rs` | 22 | parsing de query, aplatissement JSON, globbing | +| `tests/tftp.rs` | 21 | le TFTP sur de l'UDP réel, et ce qu'une liaison ratée ne doit pas coûter | | `src/format/xml.rs` | 18 | l'arbre XML — appariement, entités, fidélité | -| `src/config.rs` | 24 | l'environnement, ce qui refuse de démarrer, et qui l'emporte du fichier ou de l'environnement | | `src/merge.rs` | 11 | la fusion profonde TOML | | `tests/guards.rs` | 7 | le jeton de réponse, et le verrouillage qui délibérément n'existe pas | -| `src/envfile.rs` | 23 | le parseur et l'écrivain du fichier d'environnement, et ce que chacun refuse | +| `src/log.rs` | 4 | lecture des niveaux, et l'arithmétique d'horodatage | +| `src/boot/*.rs` | 120 | le lecteur ISO, le repérage, le catalogue, les plans de patch, le menu, la table des chargeurs, les extraits DHCP, cpio et SHA-256 | | `src/admin.rs`, `src/capture.rs`, `src/store/mod.rs` | 21 | comportement unitaire | ## `tests/stores.rs` — la suite de conformité @@ -82,6 +92,49 @@ Explicitement couverts : > contre un binaire périmé a un jour « reproduit » un bug déjà corrigé. Reconstruisez avant de > triturer le binaire à la main. +## `tests/tftp.rs` — un transfert est une conversation + +Rien ici ne se prouve depuis l'intérieur d'une fonction. Les blocs, les acquittements, la +retransmission, le paquet vide qui termine un transfert — chaque bug qui vaut d'être +attrapé vit dans les tours de parole, et la première exécution en a trouvé deux, du genre +« marche à la main, jamais après un redémarrage ». **Un fichier dont la longueur est un +multiple exact de la taille de bloc doit se terminer par un paquet de données vide** ; +sans lui le client attend éternellement un dernier bloc qui ne vient jamais. + +Cette suite porte aussi le seul écouteur de ce serveur dont l'échec n'est *pas* fatal. Un +port TFTP qu'on ne peut pas lier ne doit pas emporter les réponses et les médias avec lui +— mesuré sur DSM, où la capacité est accordée hors du paquet et où une mise à jour la perd +— donc le test squatte le port, puis vérifie trois choses d'un coup : le serveur est monté, +il a averti en disant ce qui marche encore, et `boot check` sort toujours en non-zéro. + +Cette dernière assertion est d'abord passée pour la mauvaise raison : trois chargeurs +manquants faisaient déjà échouer la commande. Le montage écrit maintenant tous les +chargeurs que la table nomme, et une exécution témoin avec le TFTP coupé prouve que le +répertoire est propre par ailleurs. + +## `tests/media.rs` — les médias de démarrage contre le vrai binaire + +Les deux listeners debout, et chaque cas d'abus se termine en prouvant que le serveur +répond toujours. Un cas prouve la propriété pour laquelle la socket séparée existe : **les +réponses continuent d'aboutir pendant que quatre transferts d'image sont en cours.** + +Il n'y a délibérément **aucune ISO binaire dans ce dépôt**. `boot::iso::build` écrit des +images en mémoire, derrière la fonctionnalité `test-support`, pour qu'elle n'atteigne +jamais un binaire de release. + +## La chaîne de démarrage a sa place dans le banc + +`packaging/boot-rig/run.sh` n'est pas du Rust et `cargo test` ne le lance pas. Il démarre +une machine revendiquée et une non revendiquée dans QEMU sous TCG, sur un pont privé sans +lien montant, et vérifie quatre marqueurs : la passe DHCP a répondu depuis notre propre +extrait généré, un chargeur a été récupéré en TFTP, la machine non revendiquée est +retombée sur son disque local, et la machine revendiquée a atteint sa propre réponse. La +CI fait la même chose plus une casse délibérée. + +**Un invité QEMU ponté dans un conteneur a une MAC à lui, et le commutateur virtuel de +Docker Desktop ne transmet pas les trames d'une MAC qu'il n'a pas attribuée** — mesuré, +d'où un banc principal en un seul conteneur plutôt qu'en quatre sur un réseau Docker. + ## Vérifier qu'un test peut échouer Un test qui passe pour la mauvaise raison est pire que pas de test : il annonce une @@ -131,7 +184,7 @@ harnais s'en chargent, et chacun prouve ce que les autres ne peuvent pas. |---|---|---| | [`packaging/dsm/check-spk.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/check-spk.sh) | l'archive est structurellement ce que DSM attend — tar externe non compressé, les six champs d'`INFO`, une version tout en segments numériques, `os_min_ver` au moins 7.1, icônes 64×64 et 256×256, scripts exécutables sans CRLF, **le `--version` du binaire empaqueté**, et l'application de bureau : un `dsmappname` nommant une classe que son `ui/config` déclare vraiment, un nom de fichier JavaScript qui porte la version, et un backend qui vérifie toujours la session DSM et `administrators` | des secondes, **à chaque push** | | [`packaging/dsm/lifecycle-test.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/lifecycle-test.sh) | tout ce que les *scripts* du paquet décident, contre un faux arbre `/var/packages` : le fichier d'environnement écrit une fois et une seule, les valeurs de l'assistant **et leur absence**, le service qui survit à son propre script de démarrage et répond à `/health`, les codes de sortie que lit Package Center, une mise à jour qui ne doit pas toucher une configuration éditée à la main, une désinstallation qui ne doit pas toucher aux réponses — **et le backend de l'application de bureau**, piloté avec un authentificateur bouchonné : refuser l'absence de session, refuser un non-administrateur, refuser une écriture sans en-tête d'intention, refuser celle qui empêcherait le serveur de démarrer, et ne jamais livrer un jeton au navigateur | des secondes, **à chaque push** | -| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | la machinerie propre à DSM — le worker `data-share` et son ACL, le worker `port-config`, l'unité systemd générée, logrotate contre un descripteur vivant, si Package Center accepte l'archive — **et qu'une machine qui demande sa configuration en reçoit une** : un POST avec le matériel dans le corps, auquel répond le fichier de cette machine fusionné par-dessus le groupe qui la revendique | des minutes, sur une VM DSM 7 — puis sur le DS416j | +| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | la machinerie propre à DSM — le worker `data-share` et son ACL, le worker `port-config`, l'unité systemd générée, logrotate contre un descripteur vivant, si Package Center accepte l'archive — **et qu'une machine qui demande sa configuration en reçoit une** : un POST avec le matériel dans le corps, auquel répond le fichier de cette machine fusionné par-dessus le groupe qui la revendique. Elle porte aussi **la seule route vers le port 69** : que `69/udp` survive dans l'entrée de pare-feu acquise, que le paquet réponde encore sans la capacité, et que `setcap cap_net_bind_service=+ep` puis un redémarrage lient `udp/69` sous le processus non privilégié du paquet | des minutes, sur une VM DSM 7 — puis sur le DS416j | ```bash packaging/dsm/lifecycle-test.sh # le premier .spk de dist/ qui tourne ici @@ -157,7 +210,11 @@ La même règle que partout ailleurs vaut pour eux : **cassez ce qu'ils gardent regardez-les virer au rouge.** Annuler la garde de `postinst` à la mise à jour, faire supprimer le partage par `postuninst`, renvoyer `1` pour un paquet arrêté et refuser `prestart` transforme 33 vérifications vertes en 25 vertes et 8 rouges — c'est ainsi qu'on -sait que le harnais teste quelque chose. +sait que le harnais teste quelque chose. Aujourd'hui c'est **58** vérifications dans +`lifecycle-test.sh`, 26 dans `check-spk.sh` et **47** sur la machine ; les trois dernières +ajoutées ont chacune été vues rouges de la même façon — en remettant +`RESCRIPTUM_TFTP_ADDR=off`, en supprimant le rapport du panneau sur l'état du TFTP, et en +lui faisant prétendre qu'il livre alors que rien n'est lié. ## CI diff --git a/docs/development/testing.md b/docs/development/testing.md index 120610f..8c89e63 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -8,7 +8,12 @@ sidebar: # Testing -333 tests. `cargo test` runs all of them in a couple of seconds. +545 tests. `cargo test` runs all of them in about twenty seconds — most of that is +`tests/tftp.rs`, which waits on real UDP timeouts because that is what it is testing. + +**`cargo test` does not run the harnesses that matter most**: the boot rig, the DSM +package's three, and the loader build. See [The package is tested too](#the-package-is-tested-too-in-three-places) +and [the boot rig](#the-boot-chain-belongs-in-the-rig). ```bash cargo test # everything @@ -21,19 +26,22 @@ cargo test --all-features # what CI runs | Suite | Cases | For | |---|---|---| -| `tests/stores.rs` | 39 | **every behaviour, against both stores** | +| `tests/cli.rs` | 47 | `render`, `check`, `import`, `export`, `config`, and the env file — against the real binary | | `tests/integration.rs` | 45 | the real binary over a real socket | +| `tests/media.rs` | 45 | boot media against the real binary, with both listeners up | +| `src/config.rs` | 42 | the environment, what refuses to start, and which of the file and the environment wins | +| `tests/stores.rs` | 39 | **every behaviour, against both stores** | | `src/select.rs` | 27 | normalization, scoring, layering, template filling | | `src/format/mod.rs` | 27 | parsing, merging, control keys, endpoint aliases | -| `src/facts.rs` | 22 | query parsing, JSON flattening, globbing | | `tests/admin.rs` | 26 | the admin API end to end, formats included | -| `tests/cli.rs` | 39 | `render`, `check`, `import`, `export`, `config`, and the env file — against the real binary | -| `src/log.rs` | 4 | level parsing, and the timestamp arithmetic | +| `src/envfile.rs` | 23 | the env-file parser and writer, and what each refuses | +| `src/facts.rs` | 22 | query parsing, JSON flattening, globbing | +| `tests/tftp.rs` | 21 | TFTP over real UDP, and what a failed bind must not cost | | `src/format/xml.rs` | 18 | the XML tree — pairing, entities, fidelity | -| `src/config.rs` | 24 | the environment, what refuses to start, and which of the file and the environment wins | | `src/merge.rs` | 11 | the TOML deep merge | | `tests/guards.rs` | 7 | the answer token, and the lockout that deliberately is not there | -| `src/envfile.rs` | 23 | the env-file parser and writer, and what each refuses | +| `src/log.rs` | 4 | level parsing, and the timestamp arithmetic | +| `src/boot/*.rs` | 120 | the ISO reader, probing, the catalogue, patch plans, the menu, the loader table, DHCP snippets, cpio and SHA-256 | | `src/admin.rs`, `src/capture.rs`, `src/store/mod.rs` | 21 | unit-level behaviour | ## `tests/stores.rs` — the conformance suite @@ -81,6 +89,47 @@ Explicitly covered: > stale binary once "reproduced" a bug that had already been fixed. Rebuild before poking > at the binary by hand. +## `tests/tftp.rs` — a transfer is a conversation + +Nothing here can be proved from inside a function. Blocks, acknowledgements, +retransmission, the empty packet that ends a transfer — every bug worth catching lives in +the turn-taking, and the first run found two of the "works by hand, never after a reboot" +kind. **A file whose length is an exact multiple of the block size must end with an empty +data packet**; leave it out and the client waits forever for a final block that never +comes. + +It also owns the one listener failure in this server that is *not* fatal. A TFTP port that +cannot be bound must not take answers and media down with it — measured on DSM, where the +capability is granted outside the package and an upgrade drops it — so the test holds the +port with a squatter, then asserts three things at once: the server came up, it warned and +said what still works, and `boot check` still exits non-zero. + +That last one first passed for the wrong reason: three missing loaders were already +failing the command. The fixture now writes every loader the table names, and a control +run with TFTP off proves the directory is otherwise clean. + +## `tests/media.rs` — boot media against the real binary + +Both listeners up, and every abuse case ends by proving the server still answers. One case +proves the property the separate socket exists for: **answers keep succeeding while four +image transfers are in flight.** + +There is deliberately **no binary ISO fixture in this repository**. `boot::iso::build` +writes images in memory, behind the `test-support` feature so it never reaches a release +binary. + +## The boot chain belongs in the rig + +`packaging/boot-rig/run.sh` is not Rust and `cargo test` does not run it. It boots a +claimed and an unclaimed machine in QEMU under TCG, on a private bridge with no uplink, +and asserts four markers: the DHCP handoff answered from our own generated snippet, a +loader fetched over TFTP, the unclaimed machine fell through to its local disk, and the +claimed machine reached its own answer. CI runs the same thing plus a deliberate break. + +**A QEMU guest bridged into a container has a MAC of its own, and Docker Desktop's virtual +switch does not forward frames from a MAC it did not assign** — measured, which is why the +primary rig is one container rather than four on a Docker network. + ## Check that a test can fail A test that passes for the wrong reason is worse than no test: it reports coverage that @@ -124,7 +173,7 @@ do, and each proves something the others cannot. |---|---|---| | [`packaging/dsm/check-spk.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/check-spk.sh) | the archive is structurally what DSM expects — uncompressed outer tar, six `INFO` fields, an all-numeric version, `os_min_ver` at least 7.1, 64×64 and 256×256 icons, executable scripts with no CRLF, **the packaged binary's own `--version`**, and the desktop application: `dsmappname` naming a class its `ui/config` actually declares, a JavaScript filename that carries the version, and a backend that still checks the DSM session and `administrators` | seconds, **on every push** | | [`packaging/dsm/lifecycle-test.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/lifecycle-test.sh) | everything the package's *scripts* decide, against a fake `/var/packages` tree: the env file written once and only once, the wizard's values **and their absence**, the service surviving its own start script and answering `/health`, the exit codes Package Center reads, an upgrade that must not touch a hand-edited configuration, an uninstall that must not touch the answers — **and the desktop application's backend**, driven with a stubbed authenticator: refusing no session, refusing a non-administrator, refusing a write with no intent header, refusing one that would stop the server starting, and never handing a token to the browser | seconds, **on every push** | -| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | DSM's own machinery — the `data-share` worker and its ACL, the `port-config` worker, the generated systemd unit, logrotate against a live descriptor, whether Package Center accepts the archive — **and that a machine asking for its configuration gets one**: a POST with hardware in the body, answered by that machine's file merged over the group claiming it | minutes, on a DSM 7 VM — and then on the DS416j | +| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | DSM's own machinery — the `data-share` worker and its ACL, the `port-config` worker, the generated systemd unit, logrotate against a live descriptor, whether Package Center accepts the archive — **and that a machine asking for its configuration gets one**: a POST with hardware in the body, answered by that machine's file merged over the group claiming it. It also owns **the only route to port 69**: that `69/udp` survives into the acquired firewall entry, that the package still answers without the capability, and that `setcap cap_net_bind_service=+ep` plus a restart binds `udp/69` as the unprivileged package process | minutes, on a DSM 7 VM — and then on the DS416j | ```bash packaging/dsm/lifecycle-test.sh # the first .spk in dist/ that runs here @@ -149,6 +198,10 @@ The same rule as everywhere else applies to these: **break the thing they guard them go red.** Reverting the `postinst` upgrade guard, making `postuninst` delete the share, returning `1` for a stopped package and refusing `prestart` turns 33 green checks into 25 green and 8 red — which is how we know the harness is testing anything at all. +Today it is **58** checks in `lifecycle-test.sh`, 26 in `check-spk.sh` and **47** on the +machine; the three most recently added were each watched red the same way — by putting +`RESCRIPTUM_TFTP_ADDR=off` back, by deleting the panel's report of the TFTP state, and by +making it claim to be serving with nothing bound. ## CI From 04b177e44e86f3b5b9fd015590b53ed665d34a29 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 21:04:50 +0200 Subject: [PATCH 31/59] feat(dsm): ship the loaders, so the package actually serves iPXE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package bound udp/69 and had nothing to hand out. The boot folder arrived empty, so a fresh install was a TFTP server that answers a machine with silence — and the fix on offer was "go and download a second archive", which is how a working appliance becomes a support thread. The reason it shipped that way was a sentence I wrote and did not challenge: the loaders are GPLv2 and "belong beside it rather than welded into it". Beside it does not mean a different archive. Putting separate, unlinked files in the same package is mere aggregation; what GPLv2 asks for is the written offer, which is the NOTICE naming the pinned upstream commit, and it travels with them. So `make-spk.sh` carries `packaging/ipxe/out` into the payload, and `start` seeds the share's boot folder from it. **A stamp file is what makes an upgrade correct**: copying only what is missing would freeze the loaders at whatever the first install shipped, and a pinned iPXE commit is exactly the thing that has to be able to move. Anything else in the folder is never touched, and an operator who manages loaders themselves points RESCRIPTUM_BOOT_DIR elsewhere. Proven on the DSM 7.2.2 machine, which is the whole point: ✓ the loader is in the folder TFTP serves from ✓ **a full TFTP fetch of ipxe-undionly.kpxe returns it byte for byte** · 98916 bytes, md5 cdc42e0946e8820b4664ff43ca8f1dac That fetch uses an independent client rather than our own code, and it reads the *whole* file. The server's own probe reads one block, which proves the port, the root and the permissions — but a TFTP transfer answers from a fresh source port, so everything after block 1 depends on the machine's firewall and routing, and that is the half `tests/tftp.rs` cannot see. DSM's curl has no tftp support and the box has no tftp, atftp, busybox or nc; python3 is what is actually there, with a fallback to the probe for a machine that lacks it. on-dsm.sh 47 → 50, lifecycle-test.sh 58 → 62, check-spk.sh 26 → 28. The archive check was watched red by wrapping a package with no loaders. --- .github/workflows/ci.yml | 12 ++++ .github/workflows/release.yml | 10 ++- .gitignore | 4 ++ CLAUDE.md | 10 ++- docs/guide/operations/synology.fr.md | 19 +++--- docs/guide/operations/synology.md | 18 +++--- packaging/dsm/check-spk.sh | 13 ++++ packaging/dsm/lifecycle-test.sh | 20 ++++++ packaging/dsm/make-spk.sh | 23 +++++++ packaging/dsm/payload/ui/texts/enu/strings | 2 +- packaging/dsm/payload/ui/texts/fre/strings | 2 +- packaging/dsm/scripts/postinst | 16 +++-- packaging/dsm/scripts/start-stop-status | 39 ++++++++++++ packaging/dsm/vm/remote-check.sh | 73 ++++++++++++++++++++++ 14 files changed, 232 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 495c838..62dfbd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,6 +205,9 @@ jobs: cross: name: Cross-compile for the NAS, and package it runs-on: ubuntu-latest + # The packages carry the loaders now, and they come from the job that built them — + # so this waits rather than rebuilding iPXE a second time. + needs: loaders # This is where a C dependency breaks first: SQLite is compiled from source, and # armv7-musl is the least forgiving target we ship. Catching it here beats catching # it while cutting a release. The DSM packages are assembled on the same job for the @@ -268,9 +271,18 @@ jobs: # Packaging breaks on the PR that breaks it, rather than at tag time. This is the # cheap half of "does this package work"; the other half is installing it, which # only a DSM machine can answer. + # The loaders the package ships, from the job that already built and verified them + # — never a rebuild here. What `check-spk.sh` and the lifecycle harness look at has + # to be the same bytes the `loaders` job proved satisfy the table. + - uses: actions/download-artifact@v4 + with: + name: ipxe-loaders + path: packaging/ipxe/out + - name: Assemble the DSM packages run: | set -euo pipefail + ls packaging/ipxe/out packaging/dsm/make-spk.sh armv7 packaging/dsm/make-spk.sh x86_64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa77906..ce53fa6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -198,7 +198,7 @@ jobs: package-dsm: name: Synology packages - needs: [verify, build] + needs: [verify, build, loaders] runs-on: ubuntu-latest # Nothing is compiled here: the binaries are already built and statically linked, and # an .spk is a release format — the same artifact, wrapped for one platform's package @@ -222,6 +222,14 @@ jobs: VERSION="${{ needs.verify.outputs.version }}" SPK_BUILD="${{ inputs.spk_build || '1' }}" mkdir -p bins + # **The loaders go inside the package.** A TFTP server with nothing to hand out + # boots nothing, and telling a NAS owner to find a second download is how a + # working appliance becomes a support thread. They are iPXE, GPLv2, separate + # files never linked into our binary — mere aggregation, and the NOTICE beside + # them is the written offer that travels with them. + tar -xzf "artifacts/boot-assets/rescriptum-boot-assets-$VERSION.tar.gz" -C . + export RESCRIPTUM_LOADERS="$PWD/rescriptum-boot-assets-$VERSION" + ls "$RESCRIPTUM_LOADERS" # One .spk per *build*; the arch line inside each covers the platforms that # build serves. aarch64 (arch="armv8") joins this list once the binary has been # run on one of its platforms — make-spk.sh already knows the mapping. diff --git a/.gitignore b/.gitignore index 6ec4b7c..18d1162 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ node_modules/ # ── Build output / generated ───────────────────────────────── dist/ +# The built loaders. **No binaries in git, ever** — packaging/ipxe/ is the written +# offer, and build.sh reproduces them from the pinned commit. +packaging/ipxe/out/ +packaging/ipxe/.work/ build/ .astro/ .output/ diff --git a/CLAUDE.md b/CLAUDE.md index eef5c30..7225ed9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,10 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit has to be a deliberate act. - `packaging/ipxe/` — the branded loaders: `branding.h`, the embedded script, a SHA-pinned upstream commit and `build.sh`. **No binaries in git, ever**; this directory - is the GPLv2 written offer. `packaging/boot-rig/` — the boot rig, three services on an + is the GPLv2 written offer. The built loaders **do** ship — inside the `.spk` and as + `rescriptum-boot-assets-.tar.gz` — because a TFTP server with nothing to hand + out boots nothing. That is aggregation, not linking, and the `NOTICE` naming the pinned + commit travels with them everywhere they go. `packaging/boot-rig/` — the boot rig, three services on an `internal: true` network, so a harness that runs DHCP cannot answer on the host's LAN. - `src/facts.rs` — what a request says about the machine: query parameters, a flattened JSON body, and the raw haystack. @@ -768,6 +771,11 @@ does not let an unsigned package run as root — measured, four routes, in `docs/development/traps.md` with the error codes — but `setcap cap_net_bind_service=+ep` on the installed binary works, after which the package binds `udp/69` as its own unprivileged user alongside 8000 and 8001. All three are registered with the firewall. +**The package ships the loaders**, so the share's `boot` folder arrives filled and `start` +refreshes it when the stamp does not name this version; a TFTP server with nothing to hand +out boots nothing, and a second download is how a working appliance becomes a support +thread. Verified on the machine by fetching `ipxe-undionly.kpxe` over TFTP with an +independent client and comparing it byte for byte. **The capability belongs to the file, so an upgrade drops it**; the env file says so and points at a Task Scheduler boot-up task. `RESCRIPTUM_TFTP_ADDR` is therefore left unset — its default *is* port 69, which is what every loader and every generated snippet expects. diff --git a/docs/guide/operations/synology.fr.md b/docs/guide/operations/synology.fr.md index 1e8231b..6e4617a 100644 --- a/docs/guide/operations/synology.fr.md +++ b/docs/guide/operations/synology.fr.md @@ -300,21 +300,20 @@ Notez qu'il demande un chargeur au port plutôt que d'essayer de l'ouvrir. Ouvri prouve le contraire de ce qu'on croit : une ouverture qui *réussit* signifie que personne n'écoute. -**Posez les chargeurs dans le dossier `boot` du partage.** Ils ne sont pas dans le paquet — -c'est iPXE, en GPLv2, et ils ont leur place à côté plutôt que soudés dedans. Chaque version -publiée attache `rescriptum-boot-assets-.tar.gz` ; décompressez-le et copiez son -contenu dans le dossier via File Station ou SMB, puis : +**Les chargeurs sont dans le paquet.** Le dossier `boot` du partage arrive rempli au +premier démarrage, et une mise à jour les rafraîchit — il n'y a pas de second +téléchargement. C'est iPXE, en GPLv2, des fichiers séparés servis à côté plutôt que soudés +dans quoi que ce soit, et le `NOTICE` posé avec eux nomme le commit amont exact dont ils +sont issus. ```console $ rescriptum-cli boot check + ok 0.0.0.0:69 handed over ipxe-undionly.kpxe ``` -Ou construisez-les vous-même sur n'importe quelle machine Linux avec une chaîne de -compilation C, depuis le même commit épinglé que la release : - -```console -$ packaging/ipxe/build.sh --out /chemin/vers/rescriptum/boot -``` +Les remplacer est possible, mais pas en modifiant ce dossier : une mise à jour réécrit les +noms de fichiers que ce paquet fournit. Pointez plutôt `RESCRIPTUM_BOOT_DIR` ailleurs, et +rien ici n'y écrira jamais. Puis faites pointer le DHCP vers ce NAS — **Panneau de configuration → Serveur DHCP → PXE** si le NAS sert le DHCP, ou votre propre serveur avec ce qu'imprime : diff --git a/docs/guide/operations/synology.md b/docs/guide/operations/synology.md index f54e7d6..c7243d0 100644 --- a/docs/guide/operations/synology.md +++ b/docs/guide/operations/synology.md @@ -277,21 +277,19 @@ $ rescriptum-cli boot check Note it asks the port for a loader rather than trying to bind it. Binding proves the opposite of what it looks like: a bind that *succeeds* means nothing is listening. -**Put the loaders in the share's `boot` folder.** They are not in the package — they are -iPXE, GPLv2, and belong beside it rather than welded into it. Every release attaches -`rescriptum-boot-assets-.tar.gz`; unpack it and copy the contents into the folder -over File Station or SMB, then: +**The loaders are in the package.** The share's `boot` folder arrives filled the first +time you start it, and an upgrade refreshes them — there is no second download. They are +iPXE, GPLv2, separate files served alongside rather than linked into anything, and the +`NOTICE` beside them names the exact upstream commit they were built from. ```console $ rescriptum-cli boot check + ok 0.0.0.0:69 handed over ipxe-undionly.kpxe ``` -Or build them yourself on any Linux box with a C toolchain, from the same pinned commit -the release uses: - -```console -$ packaging/ipxe/build.sh --out /path/to/rescriptum/boot -``` +Replacing them is possible but not by editing that folder — an upgrade rewrites the +filenames this package ships. Point `RESCRIPTUM_BOOT_DIR` somewhere else instead, and +nothing here will ever write to it. Then point DHCP at this NAS — **Control Panel → DHCP Server → PXE** if the NAS serves DHCP, or your own server with what this prints: diff --git a/packaging/dsm/check-spk.sh b/packaging/dsm/check-spk.sh index 8197615..12558c8 100755 --- a/packaging/dsm/check-spk.sh +++ b/packaging/dsm/check-spk.sh @@ -198,6 +198,19 @@ check_one() { [ -x "$work/target/bin/rescriptum" ] || bad "the payload's binary is not executable" ok "the payload carries the binary, the wrapper, the .sc file and the logrotate stanza" + # **The loaders, because a TFTP server with nothing to hand out boots nothing.** They + # are iPXE, GPLv2, separate files never linked into our binary — mere aggregation — + # and the NOTICE naming the upstream commit is the written offer that has to travel + # with them. A package without it would be a licence problem, not just an omission. + if [ -f "$work/target/boot/ipxe-undionly.kpxe" ]; then + ok "and the loaders it hands out ($(ls "$work/target/boot" | wc -l | tr -d ' ') files)" + [ -s "$work/target/boot/NOTICE" ] && + ok "with the GPLv2 written offer beside them" || + bad "the loaders ship without a NOTICE — that is the written offer GPLv2 requires" + else + bad "no loaders in the payload — the package would install a TFTP server with nothing to hand out" + fi + # The stanza's whole point: log::init opens the file once and never reopens it, so a # rotation without copytruncate silently ends logging. grep -q '^[[:space:]]*copytruncate' "$work/target/logrotate/rescriptum" && diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh index afd090a..ce2a002 100755 --- a/packaging/dsm/lifecycle-test.sh +++ b/packaging/dsm/lifecycle-test.sh @@ -32,6 +32,7 @@ bad() { echo " ✗ $*" fails=$((fails + 1)) } +note() { echo " · $*"; } section() { echo; echo "$*"; } # ── the package under test ───────────────────────────────────────────────────── @@ -172,6 +173,25 @@ rc=$? [ -d "$SHARE/media" ] && ok "and the media folder, ready for an ISO" || bad "no $SHARE/media" [ -d "$SHARE/boot" ] && ok "and the boot folder, which is what TFTP hands loaders out of" || bad "no $SHARE/boot" +# **The package has to arrive with loaders in it.** A TFTP server with nothing to hand out +# boots nothing, and an install that leaves this folder empty is an appliance that does not +# work until somebody finds a second download. They are iPXE, GPLv2, separate files never +# linked into our binary — mere aggregation, with the NOTICE as the written offer. +if [ -f "$ROOT/target/boot/ipxe-undionly.kpxe" ]; then + ok "the package carries the loaders" + [ -f "$SHARE/boot/ipxe-undionly.kpxe" ] && ok "and start put them where TFTP serves from" || bad "start did not seed $SHARE/boot" + [ -f "$SHARE/boot/NOTICE" ] && ok "with the GPLv2 notice beside them" || bad "no NOTICE in $SHARE/boot — the written offer has to travel with the binaries" + # The server's own opinion, which is the one that matters: does this directory satisfy + # the table `boot dhcp-snippet` writes into somebody's DHCP server? + if RESCRIPTUM_BOOT_DIR="$SHARE/boot" RESCRIPTUM_TFTP_ADDR=off "$ROOT/target/bin/rescriptum" boot check >/dev/null 2>&1; then + ok "and the server agrees the set is complete" + else + bad "boot check refuses the seeded folder: $(RESCRIPTUM_BOOT_DIR="$SHARE/boot" RESCRIPTUM_TFTP_ADDR=off "$ROOT/target/bin/rescriptum" boot check 2>&1 | grep -E 'MISSING|BROKEN' | head -2)" + fi +else + note "this .spk carries no loaders — build them with packaging/ipxe/build.sh and repack to test the seeding" +fi + answered=no for _ in 1 2 3 4 5 6 7 8 9 10; do if [ "$(curl -fsS "http://127.0.0.1:$PORT/health" 2>/dev/null)" = "OK" ]; then diff --git a/packaging/dsm/make-spk.sh b/packaging/dsm/make-spk.sh index c33c627..dc28410 100755 --- a/packaging/dsm/make-spk.sh +++ b/packaging/dsm/make-spk.sh @@ -66,6 +66,17 @@ abi_target() { ABI="" BIN="" +# The branded iPXE loaders, which **ship inside the package**. A TFTP server with nothing +# to hand out boots nothing, and telling somebody to go and download a second archive is +# how a working appliance becomes a support thread. They are iPXE and iPXE is GPLv2: they +# are separate files here, never linked into our binary, which is mere aggregation — and +# the NOTICE beside them, naming the pinned commit and packaging/ipxe/, is the written +# offer that has to travel with them. +# +# Empty is allowed and only means a package whose boot folder starts empty; check-spk.sh +# says so rather than failing, because a developer wrapping a local build should not need +# a C toolchain first. +LOADERS="${RESCRIPTUM_LOADERS:-$REPO/packaging/ipxe/out}" VERSION="" SPK_BUILD=1 OUT="$REPO/dist" @@ -74,6 +85,7 @@ while [ $# -gt 0 ]; do case "$1" in -h | --help) usage 0 ;; --bin) BIN="$2"; shift 2 ;; + --loaders) LOADERS="$2"; shift 2 ;; --version) VERSION="$2"; shift 2 ;; --spk-build) SPK_BUILD="$2"; shift 2 ;; --out) OUT="$2"; shift 2 ;; @@ -119,6 +131,17 @@ mkdir -p "$PAYLOAD/bin" cp "$BIN" "$PAYLOAD/bin/rescriptum" chmod 755 "$PAYLOAD/bin/rescriptum" cp -R "$HERE/payload/." "$PAYLOAD/" + +# The loaders, if there are any to carry. `start` seeds the share's boot folder from here. +if [ -d "$LOADERS" ] && [ -f "$LOADERS/ipxe-undionly.kpxe" ]; then + mkdir -p "$PAYLOAD/boot" + cp "$LOADERS"/* "$PAYLOAD/boot/" + chmod 644 "$PAYLOAD/boot"/* + echo " loaders: $(ls "$PAYLOAD/boot" | wc -l | tr -d ' ') file(s) from $LOADERS" +else + echo " loaders: none in $LOADERS — the package will install with an empty boot folder" >&2 + echo " build them with packaging/ipxe/build.sh --out $REPO/packaging/ipxe/out" >&2 +fi chmod 755 "$PAYLOAD/bin/rescriptum-cli" # The desktop application's backend. Said explicitly rather than trusted to survive a # checkout, an archive and a copy: a CGI that arrives without its execute bit is served to diff --git a/packaging/dsm/payload/ui/texts/enu/strings b/packaging/dsm/payload/ui/texts/enu/strings index d6261e3..23f579b 100644 --- a/packaging/dsm/payload/ui/texts/enu/strings +++ b/packaging/dsm/payload/ui/texts/enu/strings @@ -79,7 +79,7 @@ RESCRIPTUM_MEDIA_ADDR = "Where machines fetch kernels, initrds and images. Its o RESCRIPTUM_MEDIA_TIMEOUT_SECS = "How long one image transfer may take. Deliberately not the answer endpoint's ten seconds." RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Downloads at once. Low on purpose: each holds its slot for minutes, and this NAS has one disk." RESCRIPTUM_BOOT_ALLOW = "Client networks allowed to fetch boot media, as CIDRs. Empty means anyone who can reach the port." -RESCRIPTUM_BOOT_DIR = "Where the loaders live. They are not in this package — they are iPXE, GPLv2 — so put them here, then run 'rescriptum-cli boot check'. Served over TFTP and over HTTP at /boot/, which is what UEFI HTTP Boot fetches." +RESCRIPTUM_BOOT_DIR = "Where the loaders live. This package ships them, so the folder arrives filled and an upgrade refreshes them. Served over TFTP and over HTTP at /boot/, which is what UEFI HTTP Boot fetches. Point this elsewhere to manage loaders yourself." RESCRIPTUM_TFTP_ADDR = "Empty means 0.0.0.0:69, which is what every loader and every generated DHCP snippet expects. Port 69 is privileged, so binding it takes one root command once: setcap cap_net_bind_service=+ep on the binary. An upgrade drops it — a boot-up task in Task Scheduler makes it durable. Without it the server warns, keeps answering and keeps serving media, and only TFTP is down. Set 'off' if another daemon on this NAS hands loaders out instead." RESCRIPTUM_BOOT_TIMEOUT_SECS = "How long the boot menu waits before a machine falls through to its own disk." RESCRIPTUM_BOOT_LOGO = "A PNG shown behind the boot menu, replacing the built-in one." diff --git a/packaging/dsm/payload/ui/texts/fre/strings b/packaging/dsm/payload/ui/texts/fre/strings index 3a54e8d..9e8ec14 100644 --- a/packaging/dsm/payload/ui/texts/fre/strings +++ b/packaging/dsm/payload/ui/texts/fre/strings @@ -73,7 +73,7 @@ RESCRIPTUM_MEDIA_ADDR = "Là où les machines récupèrent noyaux, initrds et im RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Durée maximale d'un transfert d'image. Volontairement pas les dix secondes du point de réponse." RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Téléchargements à la fois. Bas exprès : chacun retient sa place des minutes durant, et ce NAS a un disque." RESCRIPTUM_BOOT_ALLOW = "Réseaux clients autorisés à récupérer les médias, en CIDR. Vide, quiconque atteint le port." -RESCRIPTUM_BOOT_DIR = "Où vivent les chargeurs. Ils ne sont pas dans ce paquet — c'est iPXE, en GPLv2 — donc déposez-les ici, puis lancez « rescriptum-cli boot check ». Servis en TFTP et en HTTP sur /boot/, ce que récupère l'amorçage HTTP UEFI." +RESCRIPTUM_BOOT_DIR = "Où vivent les chargeurs. Ce paquet les fournit : le dossier arrive rempli, et une mise à jour les rafraîchit. Servis en TFTP et en HTTP sur /boot/, ce que récupère l'amorçage HTTP UEFI. Pointez ailleurs pour gérer les chargeurs vous-même." RESCRIPTUM_TFTP_ADDR = "Vide signifie 0.0.0.0:69, ce qu'attendent tous les chargeurs et tous les extraits DHCP générés. Le port 69 est privilégié : l'ouvrir demande une commande root, une fois — setcap cap_net_bind_service=+ep sur le binaire. Une mise à jour la perd ; une tâche au démarrage dans le Planificateur de tâches la rend durable. Sans elle le serveur avertit, continue de répondre et de servir les images, et seul le TFTP est coupé. Mettez « off » si un autre service de ce NAS livre les chargeurs à sa place." RESCRIPTUM_BOOT_TIMEOUT_SECS = "Combien de temps le menu de démarrage attend avant qu'une machine retombe sur son propre disque." RESCRIPTUM_BOOT_LOGO = "Un PNG affiché derrière le menu de démarrage, à la place de celui intégré." diff --git a/packaging/dsm/scripts/postinst b/packaging/dsm/scripts/postinst index 3ad13f2..353c991 100755 --- a/packaging/dsm/scripts/postinst +++ b/packaging/dsm/scripts/postinst @@ -179,11 +179,17 @@ RESCRIPTUM_MEDIA_DIR=$SHARE_MEDIA # boot folder below is a working alternative rather than the intended one. # RESCRIPTUM_TFTP_ADDR=0.0.0.0:69 -# Where the loaders live. They are not in this package: they are iPXE, GPLv2, and belong -# beside it rather than welded into it. Put them here over File Station or SMB, then -# check with 'rescriptum-cli boot check'. They are served over TFTP from the address -# above *and* over HTTP at /boot/ on port $MEDIA_PORT, which is what UEFI HTTP Boot -# fetches and where the boot menu looks for its logo. +# Where the loaders live — **and this package ships them**, so the folder is already +# filled in when you first start it. They are iPXE, GPLv2, separate files never linked +# into our binary; the NOTICE beside them names the exact upstream commit they were built +# from. Served over TFTP from the address above *and* over HTTP at /boot/ on port +# $MEDIA_PORT, which is what UEFI HTTP Boot fetches and where the boot menu looks for its +# logo. 'rescriptum-cli boot check' says whether the set is complete and whether a loader +# can actually be handed over. +# +# An upgrade refreshes the files this package ships and touches nothing else in the +# folder. If you manage loaders yourself, point this somewhere else and nothing will ever +# write to it. RESCRIPTUM_BOOT_DIR=$SHARE_BOOT BODY } diff --git a/packaging/dsm/scripts/start-stop-status b/packaging/dsm/scripts/start-stop-status index ba0fdb5..33ed0f3 100755 --- a/packaging/dsm/scripts/start-stop-status +++ b/packaging/dsm/scripts/start-stop-status @@ -109,6 +109,45 @@ start() { [ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null || true done + # **Seed the boot folder with the loaders the package carries.** A TFTP server with + # nothing to hand out boots nothing, so an install that leaves this folder empty is an + # appliance that does not work until somebody finds a second download. The loaders come + # from $DEST/boot, which the payload put there. + # + # **The stamp is what makes an upgrade correct.** Copying only what is missing would + # freeze the loaders at whatever the first install shipped, and the pinned iPXE commit + # is exactly the kind of thing that has to be able to move. So: when the stamp does not + # name this version, refresh our own filenames and rewrite it. Anything else in the + # folder — a logo.png, a loader somebody added — is never touched. + # + # It follows that replacing one of *our* filenames does not survive an upgrade. That is + # deliberate and documented: an operator who manages loaders themselves points + # RESCRIPTUM_BOOT_DIR somewhere else, and then nothing here writes to it at all. + if [ -d "$DEST/boot" ] && [ -d "$SHARE/boot" ]; then + stamp="$SHARE/boot/.loaders" + # Read from the package's own INFO rather than trusted to an environment + # variable: SYNOPKG_PKGVER is set for the lifecycle scripts, and this script also + # runs at boot, where it is not. An unreadable INFO leaves this empty, which just + # means the stamp never matches and the loaders are refreshed every start — + # wasteful, never wrong. + want=$(sed -n 's/^version="\(.*\)"$/\1/p' "$ROOT/INFO" 2>/dev/null) + if [ "$(cat "$stamp" 2>/dev/null)" != "$want" ] || [ -z "$want" ]; then + copied=0 + for f in "$DEST"/boot/*; do + [ -f "$f" ] || continue + if cp "$f" "$SHARE/boot/$(basename "$f")" 2>/dev/null; then + copied=$((copied + 1)) + fi + done + if [ "$copied" -gt 0 ]; then + echo "$want" >"$stamp" 2>/dev/null || true + say "put $copied boot file(s) into $SHARE/boot" + else + say "could not write the loaders into $SHARE/boot — TFTP will have nothing to hand out" + fi + fi + fi + RESCRIPTUM_ENV_FILE="$ENV_FILE" export RESCRIPTUM_ENV_FILE diff --git a/packaging/dsm/vm/remote-check.sh b/packaging/dsm/vm/remote-check.sh index 68b7820..803d804 100755 --- a/packaging/dsm/vm/remote-check.sh +++ b/packaging/dsm/vm/remote-check.sh @@ -212,6 +212,79 @@ if [ -x /usr/bin/setcap ]; then [ "$(curl -fsS "http://127.0.0.1:$PORT/health" 2>/dev/null)" = OK ] && ok "and answers are unaffected by gaining it" || bad "the package stopped answering after setcap" + + # ── the only question that matters ───────────────────────────────────────── + # Binding a port proves nothing to a machine that is trying to boot. **Fetch the + # loader the way a PXE ROM does** — a real TFTP read of the whole file — and compare + # it byte for byte with what the package shipped. Everything above this line is + # scaffolding for this one check. + LOADER=ipxe-undionly.kpxe + if [ -f "$SHARE/boot/$LOADER" ]; then + ok "the loader is in the folder TFTP serves from" + # **The whole file, with a client that is not ours.** The server's probe reads one + # block, which proves the port, the root and the permissions — but a TFTP transfer + # answers from a *fresh source port*, so everything after block 1 depends on the + # machine's own firewall and routing. That is the half `tests/tftp.rs` cannot see, + # and it is a real DSM risk rather than a hypothetical one. + # + # curl on DSM is built without the tftp protocol, and there is no tftp, atftp, + # busybox or nc on the box — python3 is what is actually there. If it is missing + # too (the DS416j may not have it), this degrades to the probe rather than lying. + if command -v python3 >/dev/null 2>&1; then + cat >/tmp/tftpget.py <<'PYEOF' +import socket, sys +host, name, out = sys.argv[1], sys.argv[2], sys.argv[3] +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.settimeout(5) +s.sendto(b"\x00\x01" + name.encode() + b"\x00octet\x00", (host, 69)) +data, expect, peer = b"", 1, None +while True: + packet, addr = s.recvfrom(2048) + peer = peer or addr + op = int.from_bytes(packet[0:2], "big") + if op == 5: + sys.exit("tftp error: " + packet[4:].decode("utf-8", "replace")) + if op != 3: + sys.exit("unexpected opcode %d" % op) + block = int.from_bytes(packet[2:4], "big") + if block != expect: + sys.exit("out of order: wanted %d, got %d" % (expect, block)) + body = packet[4:] + data += body + s.sendto(b"\x00\x04" + block.to_bytes(2, "big"), peer) + expect = (expect + 1) & 0xFFFF + # A transfer ends on a short block, and short includes empty. + if len(body) < 512: + break +open(out, "wb").write(data) +PYEOF + rm -f /tmp/fetched.bin + if python3 /tmp/tftpget.py 127.0.0.1 "$LOADER" /tmp/fetched.bin 2>/tmp/tftpget.err; then + want=$(md5sum "$SHARE/boot/$LOADER" | cut -d' ' -f1) + got=$(md5sum /tmp/fetched.bin 2>/dev/null | cut -d' ' -f1) + if [ "$got" = "$want" ]; then + ok "**a full TFTP fetch of $LOADER returns it byte for byte** — the package serves iPXE" + note "$(wc -c /dev/null)" + fi + rm -f /tmp/tftpget.py /tmp/tftpget.err /tmp/fetched.bin + else + note "no python3 here to fetch with; falling back to the server's own probe" + fi + # The server's own probe, which is what `boot check` reports and the panel shows. + if "$ROOT/target/bin/$PKG-cli" boot check 2>/dev/null | grep -q "handed over"; then + ok "and boot check agrees a loader is handed over" + else + bad "boot check does not see a loader being handed over" + "$ROOT/target/bin/$PKG-cli" boot check 2>&1 | grep -E "BROKEN|MISSING" | sed 's/^/ /' + fi + else + bad "no $LOADER in $SHARE/boot — the package shipped none, so TFTP has nothing to hand out" + fi else note "no /usr/bin/setcap on this machine — the only route to port 69 is closed here" fi From 52f61c82b4ed7d615d8d47acfde1225d0d16bb92 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 21:41:26 +0200 Subject: [PATCH 32/59] docs(traps): root on DSM is gated on the signature, and DSM says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "DSM 7 does not let an unsigned package run as root" was measured but not explained. Reading `libsynopkg.so.1`'s strings on the 7.2.2 machine gives the rule verbatim: a package failing `verifyPackageSignature` may not have a `ctrl-script` or `executable` section, must have `defaults.run-as` = `package`, may not join the admin group, and — `non-synology package should not use privilege migration`. Which explains what looked like a contradiction: FileStation, StorageManager, QuickConnect and SecureSignIn all carry `"ctrl-script": [{"action":"start","run-as":"root"}]` in their own conf/privilege, the exact shape refused to us with error 319. The shape is legal; the signature is what makes it legal for them. The line worth knowing is `tool capabilities should not exist`. DSM's privilege format has a native `capabilities` field — `SYNOPackageTool::Privilege::ChangeCapabilities` is in the same library — so a signed package declares `cap_net_bind_service` in conf/privilege and never needs `setcap` at all. The mechanism we want exists and is closed to us, which settles that the manual step is the price of not being signed rather than something better packaging could remove. Marked explicitly as not measured: whether a third-party publisher's signature would pass. The string says *non-synology*, not *untrusted*. --- CLAUDE.md | 7 +++++++ docs/development/traps.fr.md | 29 +++++++++++++++++++++++++++++ docs/development/traps.md | 28 ++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7225ed9..7d0184d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -553,6 +553,13 @@ could not check. Note it needs `Resolution::format_name` (the extension), not Center strips it**. `setcap cap_net_bind_service=+ep` after install works; `net.ipv4.ip_unprivileged_port_start` does not exist on that kernel. Measured on a 7.2.2 machine, all four. +- **Root on DSM 7 is gated on the *signature*, and `libsynopkg.so.1` says so.** Its + strings carry the whole rule: a package failing `verifyPackageSignature` may not have a + `ctrl-script` or `executable` section, must have `defaults.run-as` = `package`, and + — the line that matters — `tool capabilities should not exist`. DSM's privilege format + has a native `capabilities` field, so a **signed** package declares + `cap_net_bind_service` and never needs `setcap`. The mechanism we want exists and is + closed to us; no packaging cleverness opens it. - **A file capability does not survive an upgrade** — the new binary is a different file. That is why a failed TFTP bind is the **one** listener failure here that is not fatal: when it was, an upgrade took the answer endpoint down with it, failing every install in diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index 62b9008..352bf5e 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -231,6 +231,35 @@ moment **sans mesure derrière** — vraie, mais par chance. fermée aussi. `/volume1` est en btrfs avec `nodev` mais **pas** `nosuid`, donc les capacités de fichier y fonctionnent bien, et `/usr/bin/setcap` existe en mode `0700`. +**Le root sur DSM 7 est conditionné au fait d'être un paquet *Synology*, et +`libsynopkg.so.1` le dit noir sur blanc.** Lire ses chaînes sur une machine 7.2.2 transforme +la mesure ci-dessus en explication. Un paquet qui ne passe pas le contrôle de signature +(`verifyPackageSignature` vit dans la même bibliothèque) se voit refuser tout ceci : + +``` +Failed to pass privilege check, ctrl-script and executable section should not exist +Failed to pass privilege check, defaults should be provided and defaults.run-as should be package +Failed to pass privilege check, join-groupname should not contains admin group +Failed to pass privilege check, tool capabilities should not exist +Failed to pass privilege check, tool user should be package +Failed to pass privilege check, non-synology package should not use privilege migration +``` + +D'où le fait que FileStation, StorageManager, QuickConnect et SecureSignIn portent tous +`"ctrl-script": [{"action": "start", "run-as": "root"}]` dans leur propre `conf/privilege` +et que nous ne le pouvons pas : la forme est légale, c'est la signature qui la rend légale +*pour eux*. + +**La ligne la plus importante est `tool capabilities should not exist`.** Le format de +privilège de DSM a un champ `capabilities` natif — `SYNOPackageTool::Privilege::ChangeCapabilities` +est là — donc un paquet signé déclare `cap_net_bind_service` dans `conf/privilege` et n'a +jamais besoin de `setcap`. Le mécanisme que nous voulons existe et nous est fermé. Si ce +paquet est un jour signé par Synology, l'étape manuelle et la tâche au démarrage +disparaissent toutes les deux ; d'ici là elles sont le prix de ne pas être signé, et aucune +astuce d'empaquetage n'y changera rien. La signature d'un éditeur tiers est autre chose que +celle de Synology, et savoir si elle passerait ce contrôle n'est **pas mesuré** — la chaîne +dit *non-synology*, pas *non fiable*. + **La capacité appartient au fichier, donc une mise à jour la perd.** Une nouvelle version remplace le binaire et la capacité part avec l'ancien — d'où la tâche au démarrage du Planificateur de tâches documentée par le paquet plutôt qu'une commande unique, et d'où le diff --git a/docs/development/traps.md b/docs/development/traps.md index b1f6f6c..a1de7cd 100644 --- a/docs/development/traps.md +++ b/docs/development/traps.md @@ -213,6 +213,34 @@ true, but by luck. closed too. `/volume1` is btrfs with `nodev` but **not** `nosuid`, so file capabilities do work there, and `/usr/bin/setcap` exists at mode `0700`. +**Root on DSM 7 is gated on being a *Synology* package, and `libsynopkg.so.1` says so in +so many words.** Reading its strings on a 7.2.2 machine turns the measurement above into +an explanation. A package that does not pass the signature check (`verifyPackageSignature` +lives in the same library) is refused all of this: + +``` +Failed to pass privilege check, ctrl-script and executable section should not exist +Failed to pass privilege check, defaults should be provided and defaults.run-as should be package +Failed to pass privilege check, join-groupname should not contains admin group +Failed to pass privilege check, tool capabilities should not exist +Failed to pass privilege check, tool user should be package +Failed to pass privilege check, non-synology package should not use privilege migration +``` + +Which is why FileStation, StorageManager, QuickConnect and SecureSignIn all carry +`"ctrl-script": [{"action": "start", "run-as": "root"}]` in their own `conf/privilege` and +we cannot: the shape is legal, the signature is what makes it legal *for them*. + +**The line that matters most is `tool capabilities should not exist`.** DSM's privilege +format has a native `capabilities` field — `SYNOPackageTool::Privilege::ChangeCapabilities` +is right there — so a signed package declares `cap_net_bind_service` in `conf/privilege` +and never needs `setcap` at all. The mechanism we want exists and is closed to us. If this +package is ever signed by Synology, the manual step and the boot-up task both disappear; +until then they are the price of not being signed, and no amount of packaging cleverness +changes it. A third-party publisher's signature is a different thing from Synology's, and +whether one would pass this check is **not measured** — the string says *non-synology*, not +*untrusted*. + **The capability belongs to the file, so an upgrade drops it.** A new version replaces the binary and the capability goes with the old one — which is why the package documents a Task Scheduler boot-up task rather than a one-off command, and why a failed TFTP bind is From 193854c7e711b9394c056547d86d3d71c57465c5 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Thu, 27 Aug 2026 21:46:55 +0200 Subject: [PATCH 33/59] =?UTF-8?q?docs(traps):=20Synology=20answers=20it=20?= =?UTF-8?q?=E2=80=94=20root=20needs=20*their*=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library string said "non-synology package" and I recorded that a third-party publisher's signature was an open question. Synology's developer guide closes it: "If you are developing a package with root privilege, you are not able to install that package unless it is signed by synology." SynoCommunity hit the same wall (spksrc#4170, #4215). Two things worth having beside it. The `capabilities` field is *documented*, not just a symbol in a binary — `"capabilities": "cap_chown,cap_net_raw"` on a tool entry since 7.0-40656 — so a signed package would declare `cap_net_bind_service` and the manual step would vanish entirely. And there is exactly one documented bypass, a development token: generate debug.dat from Support Center, send it to Synology, drop the signed token at /var/packages/syno_dev_token. It is valid only on the NAS that produced the debug.dat, so shipping that way would mean every user doing a round trip with Synology before installing. One local `setcap` is strictly better for them. So the manual step is settled rather than provisional, and this records why, with the sources. --- CLAUDE.md | 10 +++++++--- docs/development/traps.fr.md | 33 +++++++++++++++++++++++++-------- docs/development/traps.md | 32 ++++++++++++++++++++++++-------- 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7d0184d..884883b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -557,9 +557,13 @@ could not check. Note it needs `Resolution::format_name` (the extension), not strings carry the whole rule: a package failing `verifyPackageSignature` may not have a `ctrl-script` or `executable` section, must have `defaults.run-as` = `package`, and — the line that matters — `tool capabilities should not exist`. DSM's privilege format - has a native `capabilities` field, so a **signed** package declares - `cap_net_bind_service` and never needs `setcap`. The mechanism we want exists and is - closed to us; no packaging cleverness opens it. + has a native `capabilities` field (documented since 7.0-40656), so a **signed** package + declares `cap_net_bind_service` and never needs `setcap`. Synology's guide states it + plainly — *"you are not able to install that package unless it is signed by synology"* — + so it is their signature, not a trusted publisher's. The one documented bypass, a + *development token*, is valid only on the NAS that generated its `debug.dat`, so it is + not a distribution path. **The manual `setcap` is settled, not provisional**; no + packaging change removes it. - **A file capability does not survive an upgrade** — the new binary is a different file. That is why a failed TFTP bind is the **one** listener failure here that is not fatal: when it was, an upgrade took the answer endpoint down with it, failing every install in diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index 352bf5e..b826fa6 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -251,14 +251,31 @@ et que nous ne le pouvons pas : la forme est légale, c'est la signature qui la *pour eux*. **La ligne la plus importante est `tool capabilities should not exist`.** Le format de -privilège de DSM a un champ `capabilities` natif — `SYNOPackageTool::Privilege::ChangeCapabilities` -est là — donc un paquet signé déclare `cap_net_bind_service` dans `conf/privilege` et n'a -jamais besoin de `setcap`. Le mécanisme que nous voulons existe et nous est fermé. Si ce -paquet est un jour signé par Synology, l'étape manuelle et la tâche au démarrage -disparaissent toutes les deux ; d'ici là elles sont le prix de ne pas être signé, et aucune -astuce d'empaquetage n'y changera rien. La signature d'un éditeur tiers est autre chose que -celle de Synology, et savoir si elle passerait ce contrôle n'est **pas mesuré** — la chaîne -dit *non-synology*, pas *non fiable*. +privilège de DSM a un champ `capabilities` natif — documenté comme +`"capabilities": "cap_chown,cap_net_raw"` sur une entrée `tool` depuis 7.0-40656, et +`SYNOPackageTool::Privilege::ChangeCapabilities` est bien là dans la bibliothèque. Un paquet +signé déclare `cap_net_bind_service` et n'a jamais besoin de `setcap`. **Le mécanisme que +nous voulons existe, est documenté, et nous est fermé.** + +Le guide développeur de Synology énonce la règle sans détour : *« If you are developing a +package with root privilege, you are not able to install that package unless it is signed +by synology. »* C'est donc **leur** signature, pas celle d'un éditeur tiers de confiance — +ce qui tranche ce que la chaîne de la bibliothèque laissait ouvert. SynoCommunity a heurté +le même mur ([spksrc#4170](https://github.com/SynoCommunity/spksrc/issues/4170), +[#4215](https://github.com/SynoCommunity/spksrc/issues/4215)). + +Il existe un contournement documenté, et ce **n'est pas une voie de distribution** : un +*jeton de développement*. On génère `debug.dat` depuis Centre d'assistance → Services +d'assistance, on l'envoie à Synology, on reçoit un jeton signé, on le dépose dans +`/var/packages/syno_dev_token`. Il n'est valable **que sur le NAS qui a produit le +`debug.dat`** : livrer ainsi voudrait dire que chaque utilisateur fasse un aller-retour avec +Synology avant de pouvoir installer. Un `setcap` est une commande locale, et c'est +strictement mieux pour lui. + +Conclusion, tranchée et non provisoire : **le `setcap` manuel est le prix de ne pas être +signé par Synology, et aucun changement d'empaquetage ne l'enlève.** Si le paquet est un +jour signé, l'étape manuelle et la tâche au démarrage sont remplacées par trois lignes dans +`conf/privilege`. **La capacité appartient au fichier, donc une mise à jour la perd.** Une nouvelle version remplace le binaire et la capacité part avec l'ancien — d'où la tâche au démarrage du diff --git a/docs/development/traps.md b/docs/development/traps.md index a1de7cd..c5986d9 100644 --- a/docs/development/traps.md +++ b/docs/development/traps.md @@ -232,14 +232,30 @@ Which is why FileStation, StorageManager, QuickConnect and SecureSignIn all carr we cannot: the shape is legal, the signature is what makes it legal *for them*. **The line that matters most is `tool capabilities should not exist`.** DSM's privilege -format has a native `capabilities` field — `SYNOPackageTool::Privilege::ChangeCapabilities` -is right there — so a signed package declares `cap_net_bind_service` in `conf/privilege` -and never needs `setcap` at all. The mechanism we want exists and is closed to us. If this -package is ever signed by Synology, the manual step and the boot-up task both disappear; -until then they are the price of not being signed, and no amount of packaging cleverness -changes it. A third-party publisher's signature is a different thing from Synology's, and -whether one would pass this check is **not measured** — the string says *non-synology*, not -*untrusted*. +format has a native `capabilities` field — documented as +`"capabilities": "cap_chown,cap_net_raw"` on a `tool` entry since 7.0-40656, and +`SYNOPackageTool::Privilege::ChangeCapabilities` is right there in the library. A signed +package declares `cap_net_bind_service` and never needs `setcap` at all. **The mechanism we +want exists, is documented, and is closed to us.** + +Synology's developer guide states the rule outright: *"If you are developing a package with +root privilege, you are not able to install that package unless it is signed by synology."* +So it is **their** signature, not any trusted publisher's — which answers what the library +string left open. SynoCommunity hit the same wall +([spksrc#4170](https://github.com/SynoCommunity/spksrc/issues/4170), +[#4215](https://github.com/SynoCommunity/spksrc/issues/4215)). + +There is one documented bypass and it is **not a distribution path**: a *development +token*. Generate `debug.dat` from Support Center → Support Services, send it to Synology, +receive a signed token, drop it at `/var/packages/syno_dev_token`. It is valid **only on +the NAS that generated the `debug.dat`**, so shipping this way would mean every single user +doing a round trip with Synology before they could install. `setcap` is one local command +and strictly better for them. + +Conclusion, and it is settled rather than provisional: **the manual `setcap` is the price +of not being signed by Synology, and no packaging change removes it.** If the package is +ever signed, the manual step and the boot-up task are both replaced by three lines in +`conf/privilege`. **The capability belongs to the file, so an upgrade drops it.** A new version replaces the binary and the capability goes with the old one — which is why the package documents a From 1a48f89d897385253c167d4d91c3f6d9f349d27c Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 11:03:01 +0200 Subject: [PATCH 34/59] docs(dsm): the procedure for testing the package on a real NAS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The procedure in packaging/dsm/vm/README.md predated both the loaders being inside the package and the setcap step, so following it would have produced a package that fails its own structural check and then a TFTP server that never binds. Written by running it: build the loaders in a container, cross-compile armv7, wrap, check, install through Package Center, the one root command, and how to tell from the NAS whether a loader is actually handed over — `boot check`'s `handed over` line, which is a real TFTP read answered with real data rather than a port that merely opened. Plus the Task Scheduler task, because a file capability does not survive an upgrade, and the warning that `on-dsm.sh` uninstalls at the end so it goes before a real setup rather than after. `setcap` targets `readlink -f /var/packages/rescriptum/target` rather than a literal /volume1 path — `target` is a symlink into @appstore on whichever volume the package landed on, and readlink -f was checked on the machine. The build guide gains the loaders prerequisite it now has, and loses a stale row: the armv7 package has come from the glibc target since Synology's 3.10 kernels broke musl's time64 fallback, and that table still said musleabihf. --- docs/development/building.fr.md | 18 +++++++ docs/development/building.md | 22 ++++++++- packaging/dsm/vm/README.md | 85 +++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/docs/development/building.fr.md b/docs/development/building.fr.md index c5f50e3..63198ae 100644 --- a/docs/development/building.fr.md +++ b/docs/development/building.fr.md @@ -141,6 +141,24 @@ packaging/dsm/make-spk.sh armv7 # emballer un build qui existe déj packaging/dsm/check-spk.sh # contrôle structurel sur dist/*.spk ``` +**Le paquet embarque les chargeurs : construisez-les d'abord, sinon il ne passe pas son +propre contrôle.** `make-spk.sh` les prend dans `packaging/ipxe/out` (remplaçable par +`RESCRIPTUM_LOADERS`), et `check-spk.sh` refuse un paquet qui n'en a pas — un serveur TFTP +sans rien à distribuer ne démarre personne. Construire iPXE demande une chaîne C Linux, ce +qui sur un Mac veut dire un conteneur : + +```bash +docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c ' + apt-get update -qq && + apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \ + xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl && + packaging/ipxe/build.sh --out /w/packaging/ipxe/out' +``` + +Une fois, pas par paquet : les chargeurs sont les mêmes octets dans le `.spk` de chaque +ABI, puisqu'ils tournent sur les machines *démarrées*, pas sur le NAS. `packaging/ipxe/out` +est gitignoré — **jamais de binaires dans git**. + | ABI | `arch` dans `INFO` | Depuis | |---|---|---| | `x86_64` | `x86_64` — le nom de *famille*, donc toutes les plateformes Intel | `x86_64-unknown-linux-musl` | diff --git a/docs/development/building.md b/docs/development/building.md index 43143af..2c865e5 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -138,10 +138,28 @@ packaging/dsm/make-spk.sh armv7 # wrap a build that already exists packaging/dsm/check-spk.sh # structural check over dist/*.spk ``` +**The package carries the loaders, so build them first or it will not pass its own +check.** `make-spk.sh` takes them from `packaging/ipxe/out` (override with +`RESCRIPTUM_LOADERS`), and `check-spk.sh` fails a package that has none — a TFTP server +with nothing to hand out boots nothing. Building iPXE needs a Linux C toolchain, which on +a Mac means a container: + +```bash +docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c ' + apt-get update -qq && + apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \ + xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl && + packaging/ipxe/build.sh --out /w/packaging/ipxe/out' +``` + +Once, not per package: the loaders are the same bytes in every ABI's `.spk`, because they +run on the machines being *booted*, not on the NAS. `packaging/ipxe/out` is gitignored — +**no binaries in git, ever**. + | ABI | `arch` in `INFO` | From | |---|---|---| | `x86_64` | `x86_64` — the *family* name, so it covers every Intel platform | `x86_64-unknown-linux-musl` | -| `armv7` | `armada38x` — the family shorthand does not reach the Marvell platforms | `armv7-unknown-linux-musleabihf` | +| `armv7` | `armada38x` — the family shorthand does not reach the Marvell platforms | `armv7-unknown-linux-gnueabihf` | | `aarch64` | `armv8` | `aarch64-unknown-linux-musl`, once the binary has been run on one | The rule for widening that: **claim an ABI once the binary has run on the oldest-kernel @@ -185,6 +203,6 @@ under a temporary name, restarts, and confirms `/health`. See | Environment | Default | |---|---| -| `TARGET` | `armv7-unknown-linux-musleabihf` | +| `TARGET` | `armv7-unknown-linux-gnueabihf` | | `ANSWERS` | `/answers` | | `PORT` | `8000` | diff --git a/packaging/dsm/vm/README.md b/packaging/dsm/vm/README.md index 0d1151d..021e1e1 100644 --- a/packaging/dsm/vm/README.md +++ b/packaging/dsm/vm/README.md @@ -119,6 +119,91 @@ $ ./build.sh armv7-unknown-linux-gnueabihf $ packaging/dsm/vm/on-dsm.sh admin@nas ``` +## Testing the real package on a real NAS + +The VM is for iterating; this is for the verdict. Written for the DS416j (ARMv7, +`armada38x`, DSM 7.1.1) — swap `armv7` for `x86_64` on an Intel NAS. + +**Build the loaders once.** The package carries them, and `check-spk.sh` refuses one that +does not. iPXE needs a Linux C toolchain, so on a Mac that is a container: + +```console +$ docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c ' + apt-get update -qq && + apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \ + xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl && + packaging/ipxe/build.sh --out /w/packaging/ipxe/out' +``` + +**Build the package.** + +```console +$ ./build.sh armv7-unknown-linux-gnueabihf +$ packaging/dsm/make-spk.sh armv7 + loaders: 13 file(s) from …/packaging/ipxe/out + rescriptum-0.2.0-1-armv7.spk 8084 KB installed arch=armada38x +$ packaging/dsm/check-spk.sh dist/rescriptum-0.2.0-1-armv7.spk +``` + +**Install it.** Package Center → *Manual Install* → the `.spk`. DSM warns that the +publisher is unknown; that is expected, nothing here is signed by Synology. The wizard +asks for the answer port. + +**Then the one root command.** Port 69 is privileged and DSM 7 will not let an unsigned +package run as root — [and cannot be made +to](../../../docs/development/traps.md#packaging-for-dsm), so this is the step that has no +way around it. Over SSH, as an administrator: + +```console +$ sudo setcap cap_net_bind_service=+ep "$(readlink -f /var/packages/rescriptum/target)/bin/rescriptum" +$ sudo synopkg restart rescriptum +``` + +`readlink -f` rather than a literal `/volume1/…`: `target` is a symlink into +`@appstore` on whichever volume the package landed on. + +**Check it from the NAS itself.** + +```console +$ rescriptum-cli boot check + ok ipxe-undionly.kpxe (96.6K) + … + ok 0.0.0.0:69 handed over ipxe-undionly.kpxe +$ curl -fsS http://127.0.0.1:8000/health +OK +``` + +The `handed over` line is the one that matters: it is a real TFTP read request answered +with real data, not a port that merely opened. If it says `BROKEN`, the `setcap` did not +take — and note the package is still serving answers and media, deliberately. + +**Make it survive an upgrade.** A new version is a new file, and a file capability goes +with the old one. Control Panel → **Task Scheduler** → Create → Triggered Task → +User-defined script, user `root`, event **Boot-up**, with the `setcap` line as the script. +Run it once from that page after each upgrade, or reboot. + +**Then boot a machine**, which is the only thing that proves the chain: + +```console +$ rescriptum-cli boot dhcp-snippet --format dnsmasq +``` + +Put that in the DHCP server, point a machine at the network, and watch it reach the menu. +If the firewall is on, allow *rescriptum* — the entry now covers `8000/tcp 8001/tcp +69/udp`, and a dropped UDP 69 looks exactly like a NAS that is not there. + +### The automated pass, which is destructive + +`on-dsm.sh` does install → start → answer a machine → upgrade → **uninstall**, so run it +*before* setting a NAS up for real, never after: + +```console +$ packaging/dsm/vm/on-dsm.sh admin@nas --abi armv7 +``` + +It leaves the shared folder and its contents behind on purpose. Green on the VM is not +green: the VM is x86_64 and says nothing about the ARMv7 binary. + ## Changing the package? This is the procedure Anything under `packaging/dsm/` — a lifecycle script, `conf/resource`, the wizard, the env From 5f4d1c9ba6e9df4a0935a9b1dcf84c1404193833 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 11:19:47 +0200 Subject: [PATCH 35/59] fix(dsm): a setting this version adds must reach a file that predates it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on the DS416j, by installing the real package: `boot check` answered "boot assets are off" on a NAS where the boot folder existed, the loaders were in it and 69/udp was registered with the firewall. The env file was the one an older version wrote — four settings, no RESCRIPTUM_BOOT_DIR. The cause is a rule that is right on its own terms: the live env file is written only when absent, so an upgrade never replaces somebody's port and tokens with defaults. But taken alone it makes every new feature invisible to every installation that predates it, and since `etc/` survives an uninstall, removing and reinstalling does not fix it either. The `.env.example` is rewritten every time and is supposed to be the discovery path; nothing makes anybody read it. So `postinst` now appends keys the live file has **never heard of** and touches nothing that is present. **A commented-out key counts as present** — that is the whole safety property, and it gives the operator a way to say no: deleting a line means "never heard of it" and gets it back, commenting it out means no and is respected. It also restates mode 600 after writing, because the file holds an admin token and a file arriving from an older version may never have been 0600. Six checks in lifecycle-test.sh (62 → 68), and the harness earned its keep twice over: it caught the mode being left at 644, and it made me rewrite a "the original content survives" assertion that was a tautology comparing a string with itself. Two defects watched red — removing the top-up reproduces the DS416j bug exactly, and treating a commented key as absent overrides the operator's "no". --- CLAUDE.md | 7 +++++ docs/development/traps.fr.md | 17 ++++++++++++ docs/development/traps.md | 15 +++++++++++ packaging/dsm/lifecycle-test.sh | 40 ++++++++++++++++++++++++++++ packaging/dsm/scripts/postinst | 46 +++++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 884883b..2d1a4d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -885,6 +885,13 @@ machine's answer (hence hidden entries being skipped). Load-bearing, and each one is a trap somebody has paid for: +- **A new setting never reaches an existing installation on its own.** The live env file + is written only when absent, so boot media arrived on a DS416j with the folders made, + the loaders seeded and 69/udp registered — and `RESCRIPTUM_BOOT_DIR` missing, which + `boot check` reported as "boot assets are off". `etc/` surviving an uninstall means a + reinstall does not fix it either. `postinst` appends keys the file has **never heard + of** and touches nothing present; **a commented-out key counts as present**, which is + how an operator says no. - **`postinst` runs on an upgrade too.** It writes the env file **only when absent** — guarding on the file, not only on `SYNOPKG_PKG_STATUS` — and `preupgrade`/`postupgrade` carry it through `$SYNOPKG_TEMP_UPGRADE_FOLDER` as well. Unguarded, the obvious diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index b826fa6..fb156ea 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -291,6 +291,23 @@ vraie requête de lecture et rapporte ce qu'obtiendrait une machine. Sa premièr annonçait « already in use — that is this server, if it is running » et un test avec un squatteur sur le port a montré tout de suite que c'était une supposition. +**Un nouveau réglage n'atteint jamais une installation qui existe déjà**, sauf si quelque +chose l'y met. Le fichier d'environnement vivant n'est écrit que s'il est absent — ce qui +est correct, une mise à jour ne doit jamais remplacer le port et les jetons de quelqu'un +par des valeurs par défaut — mais à lui seul cela rend une nouvelle fonctionnalité +invisible pour toute installation antérieure. Le boot media est arrivé avec les dossiers +créés, les chargeurs déposés et 69/udp enregistré au pare-feu, et `RESCRIPTUM_BOOT_DIR` +jamais posé : `boot check` répondait *« boot assets are off »* sur un DS416j où tout le +reste était en place. Comme `etc/` survit à une désinstallation, même désinstaller et +réinstaller n'y change rien. Le `.env.example` n'aide pas : rien n'oblige personne à le +lire. + +`postinst` ajoute désormais les clés dont le fichier vivant **n'a jamais entendu parler**, +sans toucher à ce qui est présent. **Une clé commentée compte comme présente**, et c'est là +la propriété de sûreté : c'est ainsi qu'un exploitant dit « celle-là je la connais et je +n'en veux pas ». Supprimer une ligne veut dire « jamais entendu parler » et la fait +revenir ; la commenter veut dire non, et c'est respecté. + ## L'application de bureau DSM Huit choses, mesurées sur une machine virtuelle DSM 7.2.2 et sur un DS416j en 7.1.1, et diff --git a/docs/development/traps.md b/docs/development/traps.md index c5986d9..e4aad21 100644 --- a/docs/development/traps.md +++ b/docs/development/traps.md @@ -270,6 +270,21 @@ request and reports what a machine would get. The first version of it reported " use — that is this server, if it is running" and a test with a squatter on the port immediately showed that to be a guess. +**A new setting never reaches an installation that already exists**, unless something +puts it there. The live env file is written only when absent — correct, because an upgrade +must never replace somebody's port and tokens with defaults — but on its own that makes a +new feature invisible to every install that predates it. Boot media shipped with the +folders created, the loaders seeded and 69/udp registered with the firewall, and +`RESCRIPTUM_BOOT_DIR` never arriving, so `boot check` answered *"boot assets are off"* on a +DS416j that had everything else in place. `etc/` surviving an uninstall means even removing +and reinstalling does not fix it. The `.env.example` was no help, because nothing makes +anybody read it. + +`postinst` now appends keys the live file has **never heard of**, touching nothing that is +present. **A commented-out key counts as present**, and that is the safety property: it is +how an operator says "I know about this one and I do not want it". Deleting a line means +"never heard of it" and gets it back; commenting it out means no, and is respected. + ## The DSM desktop application Eight things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh index ce2a002..2a653e8 100755 --- a/packaging/dsm/lifecycle-test.sh +++ b/packaging/dsm/lifecycle-test.sh @@ -132,6 +132,46 @@ grep -q "^RESCRIPTUM_TFTP_ADDR=" "$ENV_FILE" && bad "RESCRIPTUM_TFTP_ADDR is liv grep -q "setcap cap_net_bind_service" "$ENV_FILE" && ok "and the file says what one root command makes it bind" || bad "nothing in the file explains how port 69 gets bound" grep -q "Task Scheduler" "$ENV_FILE" && ok "and how to survive an upgrade, which drops the capability" || bad "nothing says the capability does not survive an upgrade" +section "a setting this version introduced must reach a file that predates it" +# **The trap this closes, found on a real DS416j.** The live env file is written only when +# absent — right, because an upgrade must never replace somebody's port and tokens with +# defaults — but taken alone it means a new feature is invisible to every installation that +# predates it. Boot media shipped with the folders created, the loaders seeded and the +# firewall port registered, and RESCRIPTUM_BOOT_DIR never arriving: `boot check` answered +# "boot assets are off" on a NAS that had everything else in place. +saved=$(cat "$ENV_FILE") + +# A file from before boot media existed: the four settings that version wrote, and no more. +grep -E '^(RESCRIPTUM_LISTEN_ADDR|RESCRIPTUM_ANSWERS_DIR|RESCRIPTUM_DB_PATH|RESCRIPTUM_LOG_FILE)=' "$ENV_FILE" >"$ENV_FILE.old" +echo "RESCRIPTUM_ANSWER_TOKEN=keep-me-untouched-0123456789" >>"$ENV_FILE.old" +# And one the operator turned off on purpose. Commented is a decision, not an absence. +echo "# RESCRIPTUM_MEDIA_DIR=$SHARE/media" >>"$ENV_FILE.old" +mv "$ENV_FILE.old" "$ENV_FILE" +chmod 600 "$ENV_FILE" +before=$(cat "$ENV_FILE") + +SYNOPKG_PKG_STATUS=UPGRADE SYNOPKG_PKGVER=9.9.9-9 sh "$ROOT/scripts/postinst" >/dev/null 2>&1 + +grep -q "^RESCRIPTUM_BOOT_DIR=$SHARE/boot\$" "$ENV_FILE" && ok "a setting the file had never heard of was added" || bad "RESCRIPTUM_BOOT_DIR never reached the upgraded file — the feature would be invisible" +grep -q "^RESCRIPTUM_ANSWER_TOKEN=keep-me-untouched-0123456789\$" "$ENV_FILE" && ok "and everything already there is untouched" || bad "the top-up changed a setting that was already present" +# The safety property: commenting a key out is how an operator says no, and it has to hold. +[ "$(grep -c "^RESCRIPTUM_MEDIA_DIR=" "$ENV_FILE")" = 0 ] && ok "a commented-out setting is respected rather than re-enabled" || bad "the top-up re-enabled a setting the operator had commented out" +# Every line the file had before must still be there, in order, at the top. The top-up +# only ever appends, so anything else means it rewrote somebody's configuration. +printf '%s\n' "$before" >"$WORK/before.txt" +head -n "$(wc -l <"$WORK/before.txt")" "$ENV_FILE" >"$WORK/after-head.txt" +cmp -s "$WORK/before.txt" "$WORK/after-head.txt" && ok "the original lines survive verbatim, in order" || bad "the top-up rewrote the existing content: $(diff "$WORK/before.txt" "$WORK/after-head.txt" | head -3)" +mode=$(stat -c '%a' "$ENV_FILE" 2>/dev/null || stat -f '%Lp' "$ENV_FILE") +[ "$mode" = "600" ] && ok "and it is still mode 600 afterwards" || bad "the top-up left it mode $mode" + +# Running it twice must not append a second copy — postinst runs on every upgrade. +SYNOPKG_PKG_STATUS=UPGRADE sh "$ROOT/scripts/postinst" >/dev/null 2>&1 +n=$(grep -c "^RESCRIPTUM_BOOT_DIR=" "$ENV_FILE") +[ "$n" = 1 ] && ok "and a second upgrade does not append it again" || bad "RESCRIPTUM_BOOT_DIR appears $n times after two upgrades, not once" + +printf '%s\n' "$saved" >"$ENV_FILE" +chmod 600 "$ENV_FILE" + section "install without a wizard (silent_install, or a reinstall that shows none)" saved=$(cat "$ENV_FILE") rm -f "$ENV_FILE" diff --git a/packaging/dsm/scripts/postinst b/packaging/dsm/scripts/postinst index 353c991..e55ab0d 100755 --- a/packaging/dsm/scripts/postinst +++ b/packaging/dsm/scripts/postinst @@ -230,6 +230,52 @@ else say "wrote $ENV_FILE" fi +# **A setting this version introduced has to reach an installation that already exists.** +# The live file is written only when absent, which is right — an upgrade must never +# replace somebody's port and tokens with defaults. But taken alone it means a new feature +# is *invisible* to every installation that predates it: boot media shipped with the folders +# created, the loaders seeded and the firewall port registered, and RESCRIPTUM_BOOT_DIR +# never arriving, so `boot check` said "boot assets are off" on a NAS that had everything +# else in place. Found on a real DS416j upgrading from a version without boot media, and +# the .env.example was no help because nothing makes anybody read it. +# +# So: keys the live file has **never heard of** are appended. Nothing present is touched, +# in any way, ever. +# +# **A commented-out key counts as present**, and that is the whole safety property: it is +# how an operator says "I know about this one and I do not want it". Deleting a line means +# "never heard of it" and gets it back; commenting it out means "no" and is respected. +if [ -f "$ENV_FILE" ]; then + added="" + # Only live keys. The commented ones in the template are documentation, and appending + # a wall of them to somebody's working file would be noise rather than help. + env_body "$SHARE_ANSWERS" "$DEFAULT_PORT" | grep -E '^[A-Z_]+=' | while IFS= read -r line; do + key=${line%%=*} + # `#\?` so a deliberately commented setting is left alone. + if ! grep -qE "^[[:space:]]*#?[[:space:]]*$key=" "$ENV_FILE"; then + echo "$line" + fi + done >"$ENV_FILE.new" 2>/dev/null + if [ -s "$ENV_FILE.new" ]; then + added=$(cut -d= -f1 <"$ENV_FILE.new" | tr '\n' ' ') + { + echo + echo "# Added by the $PKG ${SYNOPKG_PKGVER:-upgrade} upgrade: settings this file had" + echo "# never heard of. Nothing above was touched. Comment one out to turn it off —" + echo "# a commented key is respected, a deleted one comes back." + cat "$ENV_FILE.new" + } >>"$ENV_FILE" + # **We just wrote to a file holding an admin token, so restate the mode.** The + # append itself preserves it, but a file that arrived from an older version — or + # from a hand-edit — may never have been 0600, and the moment to fix that is the + # moment we are already touching it. The harness asserts the outcome, not the + # append, precisely so this cannot regress unnoticed. + chmod 600 "$ENV_FILE" + say "added new settings to $ENV_FILE: $added" + fi + rm -f "$ENV_FILE.new" +fi + # The .sc file is static and the port is not, so write the chosen one in. Whether the # port-config worker acquires this before or after this script runs decides whether it # reaches the firewall entry on a fresh install; either way, changing the port later means From a8b6702fb85383e90ffd05b824f3800587fd9ea1 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 11:27:27 +0200 Subject: [PATCH 36/59] docs: the DS416j binds port 69, measured on the machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four routes to port 69 were measured on a 7.2.2 VM, and that left one thing genuinely open: the VM is x86_64 with /volume1 on btrfs mounted `nodev` but not `nosuid`, and a `nosuid` mount makes the kernel ignore file capabilities outright — which would have closed the last open route on the one machine this project exists for. It holds. On the DS416j (ARMv7, armada38x) the capability survives, the package binds udp/69 as its unprivileged user, and `boot check` answers `0.0.0.0:69 handed over ipxe-arm64.efi` — a real read request answered with real data. Which also puts the armv7 glibc binary on the machine for the first time since this branch rebuilt it. What is still not proven there: the panel's `tftp:` row needs a browser, and no real machine has PXE-booted from this NAS yet. The rig proves the chain in QEMU, which is not the same claim. --- CLAUDE.md | 6 ++++++ docs/development/traps.fr.md | 9 +++++++++ docs/development/traps.md | 8 ++++++++ 3 files changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2d1a4d2..4db7250 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -564,6 +564,12 @@ could not check. Note it needs `Resolution::format_name` (the extension), not *development token*, is valid only on the NAS that generated its `debug.dat`, so it is not a distribution path. **The manual `setcap` is settled, not provisional**; no packaging change removes it. +- **`setcap` holds on the DS416j's volume, measured there.** The four routes to port 69 + were measured on an x86_64 VM whose `/volume1` is btrfs, `nodev` but not `nosuid`; a + `nosuid` mount makes the kernel ignore file capabilities outright, which would have + closed the last open route on the one machine this exists for. On the DS416j (ARMv7, + `armada38x`) the package binds `udp/69` and `boot check` says + `0.0.0.0:69 handed over ipxe-arm64.efi`. - **A file capability does not survive an upgrade** — the new binary is a different file. That is why a failed TFTP bind is the **one** listener failure here that is not fatal: when it was, an upgrade took the answer endpoint down with it, failing every install in diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index fb156ea..9e60708 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -277,6 +277,15 @@ signé par Synology, et aucun changement d'empaquetage ne l'enlève.** Si le paq jour signé, l'étape manuelle et la tâche au démarrage sont remplacées par trois lignes dans `conf/privilege`. +**`setcap` fonctionne aussi sur un DS416j, et ce n'était pas acquis.** Les quatre routes +vers le port 69 ont été mesurées sur une VM 7.2.2, qui est en x86_64 avec `/volume1` en +btrfs monté `nodev` mais pas `nosuid` — or un volume monté `nosuid` fait ignorer les +capacités de fichier par le noyau, ce qui aurait fermé la dernière route ouverte sur la +seule machine pour laquelle ce projet existe. Mesuré sur le DS416j (ARMv7, `armada38x`) : +la capacité tient, le paquet ouvre `udp/69` sous son utilisateur non privilégié, et +`boot check` rapporte `0.0.0.0:69 handed over ipxe-arm64.efi` — une vraie requête de +lecture à laquelle on a répondu avec de vraies données. + **La capacité appartient au fichier, donc une mise à jour la perd.** Une nouvelle version remplace le binaire et la capacité part avec l'ancien — d'où la tâche au démarrage du Planificateur de tâches documentée par le paquet plutôt qu'une commande unique, et d'où le diff --git a/docs/development/traps.md b/docs/development/traps.md index e4aad21..d56497d 100644 --- a/docs/development/traps.md +++ b/docs/development/traps.md @@ -257,6 +257,14 @@ of not being signed by Synology, and no packaging change removes it.** If the pa ever signed, the manual step and the boot-up task are both replaced by three lines in `conf/privilege`. +**`setcap` works on a DS416j too, and that was not a given.** The four routes to port 69 +were measured on a 7.2.2 VM, which is x86_64 with `/volume1` on btrfs mounted `nodev` but +not `nosuid` — and a volume mounted `nosuid` makes the kernel ignore file capabilities +entirely, which would have closed the last open route on the one machine this project +exists for. Measured on the DS416j (ARMv7, `armada38x`): the capability holds, the package +binds `udp/69` as its unprivileged user, and `boot check` reports +`0.0.0.0:69 handed over ipxe-arm64.efi` — a real read request answered with real data. + **The capability belongs to the file, so an upgrade drops it.** A new version replaces the binary and the capability goes with the old one — which is why the package documents a Task Scheduler boot-up task rather than a one-off command, and why a failed TFTP bind is From 7f3f868373676057b190d892df105aba6284b594 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 11:52:19 +0200 Subject: [PATCH 37/59] feat(boot): offer the usual installer images, from the vendors' own indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked for: a catalogue of ISOs to pick from instead of hunting a URL and a digest by hand. The obvious shape — a table of URLs with digests baked in — would have been wrong the day it shipped. Proxmox prunes old ISOs from its CDN, Debian and Ubuntu publish point releases every few weeks, and this project *requires* a digest for a URL because that decision is what every machine ends up installing. A baked-in table would need re-cutting on somebody else's schedule, forever, and would serve 404s in between. So nothing about a specific image is stored. Each entry names **the checksum index the vendor already publishes beside its own images**, and the names and digests are read from it when somebody asks. The list is current because it is the vendor's, and the digest rule is satisfied by the vendor's own file — which is what the documentation already tells people to do by hand. rescriptum media sources # the catalogues rescriptum media sources proxmox-ve # what one offers, right now rescriptum media add --from proxmox-ve proxmox-ve_9.2-1.iso Said plainly in the module and not glossed: taking the digest from the same host as the image is **not** a signature check. Over HTTPS it authenticates the vendor's domain and catches a truncated download, a corrupt mirror and a file that changed underneath — most of what actually goes wrong — and nothing more. `--sha256` with a digest obtained out of band stays the stronger path. Five sources, and **every index URL was fetched before being written down**, plus one image URL derived from each: a table of plausible 404s would be worse than no table. Two index formats, because that is all that exists in the wild — coreutils ` ` (Proxmox, Debian, Ubuntu, with Ubuntu's `*` binary marker) and BSD tag `SHA256 (n) = d` (AlmaLinux, Rocky, inside a PGP-clearsigned document whose wrapper is skipped rather than refused). Sorting is natural rather than lexicographic, and that is not cosmetic: the first row is the one that gets clicked, and plain string order puts 9.10 behind 9.9 — offering a rack an older installer than it asked for. Verified against the live indexes, and end to end: the digest resolved for proxmox-ve_9.2-1.iso is 4e88fe416df9b527…, character for character what Proxmox publishes, and the fetch starts against it. +31,520 bytes on armv7 (2,709,840 → 2,741,360), recorded rather than quietly spent — the `boot` budget was already over. --- CLAUDE.md | 6 +- src/boot/fetch.rs | 82 +++++++++ src/boot/mod.rs | 2 + src/boot/sources.rs | 398 ++++++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 136 ++++++++++++++- 5 files changed, 615 insertions(+), 9 deletions(-) create mode 100644 src/boot/sources.rs diff --git a/CLAUDE.md b/CLAUDE.md index 4db7250..f37976e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -377,12 +377,12 @@ spend into an apparent 293% overrun.** | Build | Bytes | |---|---| -| `sqlite` + `boot` (default) | 2,709,840 | +| `sqlite` + `boot` (default) | 2,741,360 | | `sqlite` only | 2,482,000 | | neither | 1,316,648 | -**`boot` costs 227,840 bytes, against a ≤170 KB budget the plan set before any of it was -written.** That is recorded in `plans/boot-media.md` with a per-phase breakdown rather +**`boot` costs 259,360 bytes, against a ≤170 KB budget the plan set before any of it was +written** — the image-source catalogue added 31,520 of that. That is recorded in `plans/boot-media.md` with a per-phase breakdown rather than quietly exceeded; the figure needs re-deciding against the measurement. ## The admin API diff --git a/src/boot/fetch.rs b/src/boot/fetch.rs index c830ddc..6c3caf6 100644 --- a/src/boot/fetch.rs +++ b/src/boot/fetch.rs @@ -152,6 +152,88 @@ pub fn fetch( /// The tool to use, and how to ask it. Both are told to resume, to follow redirects and /// to fail on an HTTP error rather than writing the error page to disk under a name /// ending in `.iso`. +/// Fetch a small text document and return it, rather than writing it to disk. +/// +/// This exists for checksum indexes — a few kilobytes that are read once and never kept. +/// Writing them into the media directory would put files there that are not images, and +/// the whole design of that directory is that everything in it is an image somebody can +/// serve. +/// +/// **Capped, because nothing checked what the URL points at.** A redirect to a DVD image +/// would otherwise be read into memory on a NAS with 512 MB of it. The cap is far above +/// any real index — Proxmox's is a few kilobytes, Debian's a few hundred lines — and far +/// below anything that could hurt. +pub fn fetch_text(url: &str) -> Result { + const CAP: usize = 4 * 1024 * 1024; + + if !looks_like_a_url(url) { + return Err(format!("{url:?} is not a URL")); + } + let (program, args) = text_downloader(url).ok_or_else(|| { + "neither curl nor wget is installed, and there is no TLS in this binary — \ + install one, or fetch the index yourself and pass --sha256 by hand" + .to_string() + })?; + + let out = std::process::Command::new(program) + .args(&args) + .output() + .map_err(|e| format!("cannot run {program}: {e}"))?; + if !out.status.success() { + // The downloader's own words: its 404 and its TLS failure read better than + // anything this could invent, and they are what a person searches for. + let said = String::from_utf8_lossy(&out.stderr); + let said = said.trim(); + return Err(format!( + "{program} could not fetch {url}{}", + if said.is_empty() { + String::new() + } else { + format!(": {said}") + } + )); + } + if out.stdout.len() > CAP { + return Err(format!( + "{url} returned more than {} — that is not a checksum index", + human(CAP as u64) + )); + } + String::from_utf8(out.stdout) + .map_err(|_| format!("{url} is not text, so it is not a checksum index")) +} + +fn text_downloader(url: &str) -> Option<(&'static str, Vec)> { + if on_path("curl") { + return Some(( + "curl", + vec![ + "--fail".into(), + "--location".into(), + "--silent".into(), + "--show-error".into(), + // An index that takes a minute is a mirror that is down. + "--max-time".into(), + "60".into(), + url.into(), + ], + )); + } + if on_path("wget") { + return Some(( + "wget", + vec![ + "--quiet".into(), + "--timeout=60".into(), + "--output-document".into(), + "-".into(), + url.into(), + ], + )); + } + None +} + fn downloader(into: &Path, url: &str) -> Option<(&'static str, Vec)> { let into = into.to_string_lossy().into_owned(); if on_path("curl") { diff --git a/src/boot/mod.rs b/src/boot/mod.rs index 9f4045b..5ffa2a4 100644 --- a/src/boot/mod.rs +++ b/src/boot/mod.rs @@ -22,5 +22,7 @@ pub mod patch; pub mod privileges; pub mod probe; pub mod sha256; +/// Where images can be fetched *from*, as opposed to what is held. +pub mod sources; pub mod stanza; pub mod tftp; diff --git a/src/boot/sources.rs b/src/boot/sources.rs new file mode 100644 index 0000000..c87b4b5 --- /dev/null +++ b/src/boot/sources.rs @@ -0,0 +1,398 @@ +//! Where installer images can be *fetched from* — as opposed to `catalog`, which is what +//! is already held. +//! +//! ## Why this is a list of indexes rather than a list of ISOs +//! +//! The obvious shape for "offer the usual images" is a table of URLs with their digests +//! baked in. It is also wrong, and would be wrong the day it shipped: Proxmox prunes old +//! ISOs from its CDN, Debian and Ubuntu publish point releases every few weeks, and a +//! release cut in August would still be offering June's images — some of them 404. Worse, +//! this project *requires* a digest for a URL (`media add`), because that decision is what +//! every machine ends up installing. A baked-in table would need re-cutting on somebody +//! else's schedule, forever. +//! +//! So nothing about a specific image is stored here. Each entry names the **checksum index +//! the vendor already publishes beside its own images**, and both the filenames and their +//! digests are read from it at the moment somebody asks. The list is current because it is +//! the vendor's, and the digest requirement is satisfied by the vendor's own file — which +//! is exactly what the documentation already tells people to do by hand. +//! +//! ## What that is worth, honestly +//! +//! Taking the digest from the same host that serves the image is **not** the same as +//! verifying a signature you already trusted. Over HTTPS it authenticates the vendor's +//! domain and it catches a truncated download, a corrupted mirror and a file that quietly +//! changed underneath — which is most of what goes wrong. It is *not* protection against a +//! vendor's CDN being compromised, and this module does not pretend otherwise. Somebody +//! who wants more pastes a digest they obtained out of band into `media add URL --sha256`, +//! which stays the stronger path and is never removed. +//! +//! ## The two formats, both measured +//! +//! Every index checked is one of two shapes, so the parser handles exactly two and refuses +//! to guess at a third: +//! +//! - **coreutils** — ` `, with an optional `*` marking binary mode. +//! Proxmox, Debian and Ubuntu. +//! - **BSD tag** — `SHA256 () = `. AlmaLinux and Rocky, inside a +//! PGP-clearsigned document whose surrounding lines simply do not match and are skipped. + +use super::sha256; + +/// One vendor's published index. +/// +/// **There is no separate base URL**, deliberately: it is the index's own directory. A +/// second field could drift from the first, and the whole point of reading the vendor's +/// index is that the two cannot disagree. +pub struct Source { + /// What `media add --from` takes. Becomes part of no URL, but reads like an id. + pub id: &'static str, + pub label: &'static str, + /// The checksum index, beside the images it describes. + pub index: &'static str, + /// Kept when the index lists more than this project should offer — Proxmox's one file + /// covers Backup Server and Mail Gateway too, and offering those under "Proxmox VE" + /// would be a lie. Empty means no filtering. + pub keep: &'static str, + /// A word for what these images install, so a list is readable without knowing the + /// project. Not the `Family` enum: this is documentation, and `probe` decides the + /// real family once an image is on disk. + pub about: &'static str, +} + +/// The indexes shipped by default. +/// +/// **Every URL here was fetched before being written down**, and so was one image URL +/// derived from each — a table of plausible-looking 404s would be worse than no table. +/// Adding one is a two-line change; an operator who needs a local mirror uses +/// `media add URL --sha256` and needs nothing from this list. +pub const SOURCES: &[Source] = &[ + Source { + id: "proxmox-ve", + label: "Proxmox VE", + index: "https://enterprise.proxmox.com/iso/SHA256SUMS", + // The same index lists proxmox-backup-server and proxmox-mail-gateway. + keep: "proxmox-ve_", + about: "the founding case — answers come from a file injected into the image", + }, + Source { + id: "debian", + label: "Debian", + index: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/SHA256SUMS", + keep: "", + about: "netinst images; the answer is a preseed on the kernel command line", + }, + Source { + id: "ubuntu", + label: "Ubuntu LTS", + index: "https://releases.ubuntu.com/noble/SHA256SUMS", + // The index lists a .wsl alongside the images; `is_image` drops it. + keep: "", + about: "autoinstall, via a cloud-init datasource on the kernel command line", + }, + Source { + id: "almalinux", + label: "AlmaLinux 9", + index: "https://repo.almalinux.org/almalinux/9/isos/x86_64/CHECKSUM", + keep: "", + about: "kickstart, named on the kernel command line", + }, + Source { + id: "rocky", + label: "Rocky Linux 9", + index: "https://download.rockylinux.org/pub/rocky/9/isos/x86_64/CHECKSUM", + keep: "", + about: "kickstart, named on the kernel command line", + }, +]; + +/// Look one up by id. +pub fn source(id: &str) -> Option<&'static Source> { + SOURCES.iter().find(|s| s.id == id) +} + +/// One image a source offers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Available { + pub name: String, + pub digest: String, + pub url: String, +} + +impl Source { + /// The directory the index lives in, which is where its images live too. + pub fn base(&self) -> &str { + match self.index.rfind('/') { + Some(at) => &self.index[..=at], + None => self.index, + } + } + + /// Everything this index offers that is an installer image, newest first. + pub fn offers(&self, index_text: &str) -> Vec { + let mut out: Vec = parse_index(index_text) + .into_iter() + .filter(|(name, _)| is_image(name) && name.starts_with(self.keep)) + .map(|(name, digest)| Available { + url: format!("{}{name}", self.base()), + name, + digest, + }) + .collect(); + // **Newest first, because the first row is the one that gets clicked.** Sorted by + // the version-ish parts of the name rather than the whole string: plain + // lexicographic ordering puts `9.10` before `9.9`, which would offer a rack an + // older installer than the one it asked for. + out.sort_by(|a, b| natural(&b.name).cmp(&natural(&a.name))); + out + } +} + +/// Whether a name from an index is an image this server could ever serve. +/// +/// Ubuntu's index lists a `.wsl` beside its ISOs; Debian's lists `.jigdo` in some +/// directories. Neither is bootable here, and offering one is a click that ends in a +/// puzzle. +fn is_image(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".iso") || lower.ends_with(".img") +} + +/// Split a name into text and number runs, so `9.9` sorts before `9.10`. +fn natural(name: &str) -> Vec { + let mut out = Vec::new(); + let mut chars = name.chars().peekable(); + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + let mut n: u64 = 0; + while let Some(&d) = chars.peek() { + if !d.is_ascii_digit() { + break; + } + // Saturating rather than wrapping: a 30-digit run in a filename is not a + // version, and it must not silently become a small number. + n = n.saturating_mul(10).saturating_add(d as u64 - '0' as u64); + chars.next(); + } + out.push(Chunk::Number(n)); + } else { + let mut s = String::new(); + while let Some(&d) = chars.peek() { + if d.is_ascii_digit() { + break; + } + s.push(d.to_ascii_lowercase()); + chars.next(); + } + out.push(Chunk::Text(s)); + } + } + out +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Chunk { + // Text before Number, so `debian-13` and `debian-9` compare on the number. + Text(String), + Number(u64), +} + +/// Read a checksum index in either of the two formats vendors actually publish. +/// +/// Anything that is not a digest line is skipped rather than refused — an AlmaLinux +/// `CHECKSUM` is a PGP-clearsigned document with a header, a comment per file and a +/// signature block, and none of that is an error. +pub fn parse_index(text: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(pair) = bsd_tag(line).or_else(|| coreutils(line)) { + out.push(pair); + } + } + out +} + +/// `SHA256 (name) = digest` — AlmaLinux, Rocky. +fn bsd_tag(line: &str) -> Option<(String, String)> { + let rest = line.strip_prefix("SHA256")?.trim_start(); + let rest = rest.strip_prefix('(')?; + let (name, rest) = rest.split_once(')')?; + let digest = rest.trim_start().strip_prefix('=')?.trim(); + sha256::is_digest(digest).then(|| (name.trim().to_string(), digest.to_ascii_lowercase())) +} + +/// `digest name`, with `*` marking binary mode — Proxmox, Debian, Ubuntu. +fn coreutils(line: &str) -> Option<(String, String)> { + let (digest, name) = line.split_once(char::is_whitespace)?; + if !sha256::is_digest(digest) { + return None; + } + let name = name.trim_start(); + let name = name.strip_prefix('*').unwrap_or(name); + let name = name.trim(); + (!name.is_empty()).then(|| (name.to_string(), digest.to_ascii_lowercase())) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Captured from the real indexes, on 2026-08-28, rather than invented — the point of + // a fixture here is that it is the shape the vendor actually publishes. + const PROXMOX: &str = "\ +d237d70ca48a9f6eb47f95fd4fd337722c3f69f8106393844d027d28c26523d8 proxmox-ve_8.4-1.iso +6d8f5afc78c0c66812d7272cde7c8b98be7eb54401ceb045400db05eb5ae6d22 proxmox-ve_9.1-1.iso +4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c proxmox-ve_9.2-1.iso +721e21a88ae93dba73ca3e4a494b438190acadb99993ec755a19e721a86f0395 proxmox-backup-server_2.4-1.iso +"; + + const UBUNTU: &str = "\ +faabcf33ae53976d2b8207a001ff32f4e5daae013505ac7188c9ea63988f8328 *ubuntu-24.04.3-live-server-amd64.iso +c74833a55e525b1e99e1541509c566bb3e32bdb53bf27ea3347174364a57f47c *ubuntu-24.04.3-wsl-amd64.wsl +e907d92eeec9df64163a7e454cbc8d7755e8ddc7ed42f99dbc80c40f1a138433 *ubuntu-24.04.4-live-server-amd64.iso +"; + + const ALMA: &str = "\ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +# AlmaLinux-9.8-x86_64-boot.iso: 1519271936 bytes +SHA256 (AlmaLinux-9.8-x86_64-boot.iso) = 445f99e24399bbe98aab86111d60751c142eda049d2444fd76da5eb03472e4ab +SHA256 (AlmaLinux-9.8-x86_64-dvd.iso) = 7a392bdc879afd159b30da39a356b7b26c1ddf618b01549164da9aadbc40d814 +-----BEGIN PGP SIGNATURE----- +iQIzBAEBCAAdFiEE +-----END PGP SIGNATURE----- +"; + + #[test] + fn the_coreutils_format_parses_with_and_without_the_binary_marker() { + let got = parse_index(PROXMOX); + assert_eq!(got.len(), 4); + assert_eq!(got[0].0, "proxmox-ve_8.4-1.iso"); + assert_eq!( + got[0].1, + "d237d70ca48a9f6eb47f95fd4fd337722c3f69f8106393844d027d28c26523d8" + ); + + // Ubuntu marks binary mode with `*`, which is part of the format and not part of + // the filename. Left in, every fetch would ask for a file that does not exist. + let got = parse_index(UBUNTU); + assert_eq!(got[0].0, "ubuntu-24.04.3-live-server-amd64.iso"); + } + + #[test] + fn a_pgp_signed_bsd_tag_index_parses_and_its_wrapper_is_not_an_error() { + let got = parse_index(ALMA); + assert_eq!( + got.len(), + 2, + "the signature and header are skipped, not refused" + ); + assert_eq!(got[0].0, "AlmaLinux-9.8-x86_64-boot.iso"); + assert_eq!( + got[0].1, + "445f99e24399bbe98aab86111d60751c142eda049d2444fd76da5eb03472e4ab" + ); + } + + #[test] + fn nothing_that_is_not_a_digest_line_is_taken_for_one() { + // Each of these has the *shape* of an entry and is not one. A parser that accepted + // any of them would offer a fetch that cannot resolve. + let text = "\ +not-a-digest something.iso +SHA1 (old.iso) = da39a3ee5e6b4b0d3255bfef95601890afd80709 +SHA256 (truncated.iso) = abc123 +# 445f99e24399bbe98aab86111d60751c142eda049d2444fd76da5eb03472e4ab commented.iso +445f99e24399bbe98aab86111d60751c142eda049d2444fd76da5eb03472e4ab +"; + assert!(parse_index(text).is_empty(), "{:?}", parse_index(text)); + } + + #[test] + fn an_index_offers_only_images_and_only_what_the_source_claims() { + let proxmox = source("proxmox-ve").expect("shipped"); + let offers = proxmox.offers(PROXMOX); + // The same index carries Backup Server, and offering it under "Proxmox VE" would + // be a lie the catalogue told. + assert!( + offers.iter().all(|o| o.name.starts_with("proxmox-ve_")), + "{offers:?}" + ); + assert_eq!(offers.len(), 3); + + // Ubuntu's `.wsl` is in the index and is not something this server can serve. + let ubuntu = source("ubuntu").expect("shipped"); + let offers = ubuntu.offers(UBUNTU); + assert!( + offers.iter().all(|o| o.name.ends_with(".iso")), + "{offers:?}" + ); + assert_eq!(offers.len(), 2); + } + + #[test] + fn the_newest_is_offered_first_and_ten_beats_nine() { + let proxmox = source("proxmox-ve").expect("shipped"); + assert_eq!(proxmox.offers(PROXMOX)[0].name, "proxmox-ve_9.2-1.iso"); + + // **The reason sorting is not lexicographic.** The first row is the one that gets + // clicked, and plain string order puts 9.10 behind 9.9 — offering a rack an older + // installer than the one it asked for. + let text = "\ +1111111111111111111111111111111111111111111111111111111111111111 x_9.9-1.iso +2222222222222222222222222222222222222222222222222222222222222222 x_9.10-1.iso +"; + let s = Source { + id: "x", + label: "x", + index: "https://example.invalid/d/SHA256SUMS", + keep: "", + about: "", + }; + assert_eq!(s.offers(text)[0].name, "x_9.10-1.iso"); + } + + #[test] + fn an_images_url_is_the_indexs_own_directory() { + // Not a second field, so the two cannot drift apart — which is the whole reason + // for reading the vendor's index in the first place. + let proxmox = source("proxmox-ve").expect("shipped"); + assert_eq!(proxmox.base(), "https://enterprise.proxmox.com/iso/"); + assert_eq!( + proxmox.offers(PROXMOX)[0].url, + "https://enterprise.proxmox.com/iso/proxmox-ve_9.2-1.iso" + ); + } + + #[test] + fn every_shipped_source_is_usable_as_written() { + // Cheap, and it catches the paste error that a network test would blame on the + // network. The URLs themselves were fetched by hand before being written down; + // this asserts the shape they have to keep. + for s in SOURCES { + assert!(!s.id.is_empty() && !s.label.is_empty(), "{}", s.id); + assert!( + s.index.starts_with("https://"), + "{} must be https — the digest is the point", + s.id + ); + assert!( + s.base().ends_with('/') && s.base().len() < s.index.len(), + "{} has no directory to hang images off", + s.id + ); + assert!(source(s.id).is_some(), "{} is not findable", s.id); + } + // Ids are what `media add --from` takes, so a duplicate would silently shadow. + let mut ids: Vec<&str> = SOURCES.iter().map(|s| s.id).collect(); + ids.sort_unstable(); + let before = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), before, "duplicate source id"); + } +} diff --git a/src/cli.rs b/src/cli.rs index aea277f..18a2a2c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -467,10 +467,15 @@ pub fn media(cfg: &Config, args: &[String]) -> ExitCode { Some((cmd, rest)) if cmd == "export" && rest.len() == 2 => { media_export(&catalog, &rest[0], &rest[1]) } + Some((cmd, rest)) if cmd == "sources" && rest.len() < 2 => { + media_sources(rest.first().map(String::as_str)) + } _ => { eprintln!( "usage: rescriptum media list\n\ + \x20 rescriptum media sources [SOURCE]\n\ \x20 rescriptum media add FILE|URL [--sha256 D] [--as NAME]\n\ + \x20 rescriptum media add --from SOURCE NAME\n\ \x20 rescriptum media check\n\ \x20 rescriptum media ipxe ID\n\ \x20 rescriptum media prepare ID [--as NAME] [--url URL]\n\ @@ -481,6 +486,64 @@ pub fn media(cfg: &Config, args: &[String]) -> ExitCode { } } +#[cfg(feature = "boot")] +fn media_sources(which: Option<&str>) -> ExitCode { + use crate::boot::{fetch, sources}; + + let Some(id) = which else { + println!("{:<12} {:<16} WHAT IT INSTALLS", "SOURCE", "NAME"); + for s in sources::SOURCES { + println!("{:<12} {:<16} {}", s.id, s.label, s.about); + } + println!(); + println!("`media sources ` lists what one offers, reading the vendor's own"); + println!("checksum index — so the list is current and the digests are theirs."); + println!("`media add --from ` fetches one."); + return ExitCode::SUCCESS; + }; + + let Some(source) = sources::source(id) else { + eprintln!( + "no source called {id:?}. There are: {}", + sources::SOURCES + .iter() + .map(|s| s.id) + .collect::>() + .join(", ") + ); + return ExitCode::FAILURE; + }; + + // Said before the wait, not after: on a NAS with a slow uplink this is several + // seconds of apparent nothing, and silence there reads as a hang. + eprintln!("reading {} …", source.index); + let text = match fetch::fetch_text(source.index) { + Ok(text) => text, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + + let offers = source.offers(&text); + if offers.is_empty() { + eprintln!( + "{} answered, but nothing in it looks like an installer image. The index may \ + have moved or changed format.", + source.index + ); + return ExitCode::FAILURE; + } + + println!("{} — {}", source.label, source.about); + for offer in &offers { + println!(" {}", offer.name); + } + println!(); + println!(" rescriptum media add --from {id} {}", offers[0].name); + ExitCode::SUCCESS +} + #[cfg(feature = "boot")] fn media_list(catalog: &crate::boot::catalog::Catalog) -> ExitCode { let listing = match catalog.listing() { @@ -548,6 +611,7 @@ fn media_add(catalog: &crate::boot::catalog::Catalog, args: &[String]) -> ExitCo let mut expected: Option<&String> = None; let mut name: Option = None; let mut unverified = false; + let mut from: Option = None; let mut rest = args.iter(); while let Some(arg) = rest.next() { match arg.as_str() { @@ -566,6 +630,13 @@ fn media_add(catalog: &crate::boot::catalog::Catalog, args: &[String]) -> ExitCo } }, "--unverified" => unverified = true, + "--from" => match rest.next() { + Some(value) => from = Some(value.clone()), + None => { + eprintln!("--from wants a source; `media sources` lists them"); + return ExitCode::FAILURE; + } + }, _ if source.is_none() => source = Some(arg), other => { eprintln!("unexpected argument {other:?}"); @@ -573,12 +644,65 @@ fn media_add(catalog: &crate::boot::catalog::Catalog, args: &[String]) -> ExitCo } } } - let Some(source) = source else { - eprintln!( - "usage: rescriptum media add FILE [--sha256 DIGEST]\n\ - \x20 rescriptum media add URL --sha256 DIGEST [--as NAME.iso]" - ); - return ExitCode::FAILURE; + // **`--from` turns a name into a URL and a digest, both read from the vendor.** It is + // not a shortcut around the digest rule — it is the strictest way to satisfy it that + // does not involve a human copying 64 characters correctly. What it is *not* is a + // signature check: the digest comes from the same host as the image, so it proves the + // download matches what that vendor is publishing right now, and nothing about + // whether the vendor is who you think. Somebody who needs that pastes a digest they + // obtained out of band, which is why --sha256 stays. + let resolved; + let source = if let Some(id) = &from { + let Some(wanted) = source else { + eprintln!("--from {id} wants an image name too; `media sources {id}` lists them"); + return ExitCode::FAILURE; + }; + let Some(src) = crate::boot::sources::source(id) else { + eprintln!( + "no source called {id:?}. There are: {}", + crate::boot::sources::SOURCES + .iter() + .map(|s| s.id) + .collect::>() + .join(", ") + ); + return ExitCode::FAILURE; + }; + if expected.is_some() { + eprintln!("--from and --sha256 disagree about where the digest comes from; pick one"); + return ExitCode::FAILURE; + } + eprintln!("reading {} …", src.index); + let text = match crate::boot::fetch::fetch_text(src.index) { + Ok(text) => text, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + let offers = src.offers(&text); + let Some(offer) = offers.iter().find(|o| o.name == *wanted) else { + eprintln!("{} does not offer {wanted:?}.", src.label); + if let Some(newest) = offers.first() { + eprintln!("The newest it has is {}.", newest.name); + } + eprintln!("`media sources {id}` lists them all."); + return ExitCode::FAILURE; + }; + resolved = (offer.url.clone(), offer.digest.clone()); + eprintln!("{} publishes it as {}", src.label, &resolved.1[..16]); + expected = Some(&resolved.1); + &resolved.0 + } else { + let Some(source) = source else { + eprintln!( + "usage: rescriptum media add FILE [--sha256 DIGEST]\n\ + \x20 rescriptum media add URL --sha256 DIGEST [--as NAME.iso]\n\ + \x20 rescriptum media add --from SOURCE NAME" + ); + return ExitCode::FAILURE; + }; + source }; if let Some(digest) = expected From 55f4be53536ba51a348ed2daa7a766d07a5f870a Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 12:05:33 +0200 Subject: [PATCH 38/59] feat(dsm): manage images from the panel, catalogue included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked for: do this from the application rather than over SSH, and offer the usual ISOs to click rather than hunting a URL and a digest. A fourth tab — what is held, a catalogue to pick from, and a URL field for what the catalogue does not offer. The manual path is deliberately in front of people rather than documented as a command-line escape hatch: a digest obtained out of band is stronger evidence than one read from the same host as the image, so it is the better of the two and should look it. **The panel grows no rule of its own.** It starts a download by calling `media add`, which is where the digest rules live and are tested, and it follows one by watching the `.part` file that command already writes — `media add` renames it only once the digest checks out, so the partial file's size *is* the progress and its disappearance *is* the completion. Nothing about progress had to be invented for the browser, and nothing here can disagree with what the CLI actually did. A CGI cannot hold a request open for 1.5 GB, so the fetch is backgrounded. Three details, each a trap this package has already paid for once: `&1 + ;; + +sources) + # With no `source` parameter this lists the catalogues, which is local and instant. + # With one it fetches that vendor's index over the network, which is not: the browser + # is told to expect a wait, and a failure here is the vendor's or the uplink's rather + # than ours. + which=$(param source) + reply 200 "text/plain; charset=utf-8" + if [ -n "$which" ]; then + "$CLI" media sources "$which" 2>&1 + else + "$CLI" media sources 2>&1 + fi + ;; + +fetch) + require_write_intent + # **A CGI cannot hold a request open for 1.5 GB**, so this starts the download and + # returns immediately; `progress` below is how the page follows it. Three details are + # each a trap already paid for elsewhere in this package: + # + # * `/dev/null)" 2>/dev/null; then + fail 409 "A download is already running." + fi + src=$(param source) + name=$(param name) + url=$(param url) + digest=$(param sha256) + : >"$VAR/fetch.log" + if [ -n "$url" ]; then + case "$url" in + https://* | http://*) ;; + *) fail 400 "That is not an http or https URL." ;; + esac + # 64 hex characters, checked here so an obvious typo is answered now rather than + # after a gigabyte. The CLI checks it again, which is where it counts. + case "$digest" in + '') fail 400 "A URL needs its SHA-256 — that decides what every machine installs." ;; + *[!0-9a-fA-F]*) fail 400 "That is not a SHA-256." ;; + esac + [ "${#digest}" -eq 64 ] || fail 400 "A SHA-256 is 64 hexadecimal characters." + setsid "$CLI" media add "$url" --sha256 "$digest" >"$VAR/fetch.log" 2>&1 "$VAR/fetch.pid" + reply 200 "text/plain; charset=utf-8" + echo "started $url" + else + case "$src" in + '' | *[!a-zA-Z0-9._-]*) fail 400 "That is not a source." ;; + esac + case "$name" in + '' | *[!a-zA-Z0-9._-]*) fail 400 "That is not an image name." ;; + esac + setsid "$CLI" media add --from "$src" "$name" >"$VAR/fetch.log" 2>&1 "$VAR/fetch.pid" + reply 200 "text/plain; charset=utf-8" + echo "started $name" + fi + ;; + +progress) + # **Progress without a progress protocol.** `media add` writes into `.part` and + # renames it atomically when the digest checks out, so the partial file's size *is* + # the progress and its disappearance *is* the completion. Nothing had to be invented + # for the browser, and nothing can disagree with what the CLI actually did. + reply 200 "text/plain; charset=utf-8" + pid=$(cat "$VAR/fetch.pid" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "state: running" + else + echo "state: idle" + fi + dir=$("$CLI" config --value RESCRIPTUM_MEDIA_DIR 2>/dev/null) + if [ -n "$dir" ]; then + for part in "$dir"/*.part; do + [ -f "$part" ] || continue + echo "partial: $(basename "$part" .part) $(wc -c <"$part" | tr -d " ")" + done + fi + # The tail rather than the whole thing: curl writes a progress bar, and the last of it + # is the part that says what went wrong. + echo "--- log" + tail -c 2000 "$VAR/fetch.log" 2>/dev/null | tr "\r" "\n" | grep -v "^ *$" | tail -6 + ;; + +prepare) + require_write_intent + # Proxmox only, and the CLI is what refuses the rest — with the reason, which the + # panel shows verbatim. Duplicating that rule here would be a second implementation + # to keep honest. + id=$(param id) + case "$id" in + '' | *[!a-zA-Z0-9._:-]*) fail 400 "That is not an image id." ;; + esac + out=$("$CLI" media prepare "$id" 2>&1) + code=$? + reply 200 "text/plain; charset=utf-8" + echo "$out" + echo "--- exit $code" + ;; + check) # The same command the documentation tells people to run, and the same exit code. reply 200 "text/plain; charset=utf-8" diff --git a/packaging/dsm/payload/ui/rescriptum.js b/packaging/dsm/payload/ui/rescriptum.js index cdcbe0a..5bd84ce 100644 --- a/packaging/dsm/payload/ui/rescriptum.js +++ b/packaging/dsm/payload/ui/rescriptum.js @@ -122,10 +122,19 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); bodyStyle: 'padding: 12px 16px', html: '' }); + /* Plain, not a form: this one is a list with buttons rather than labelled + * fields, so the form layout that `statusPanel` needs would buy nothing. */ + this.mediaPanel = new Ext.Panel({ + border: false, + autoScroll: true, + bodyStyle: 'padding: 12px 16px', + items: [] + }); this.tabButtons = { settings: new SYNO.ux.Button({ text: 'Settings', toggleGroup: 'rescriptum-tabs', allowDepress: false, pressed: true, handler: function () { self.showView('settings'); } }), status: new SYNO.ux.Button({ text: 'Status', toggleGroup: 'rescriptum-tabs', allowDepress: false, handler: function () { self.showView('status'); } }), + media: new SYNO.ux.Button({ text: 'Images', toggleGroup: 'rescriptum-tabs', allowDepress: false, handler: function () { self.showView('media'); } }), log: new SYNO.ux.Button({ text: 'Log', toggleGroup: 'rescriptum-tabs', allowDepress: false, handler: function () { self.showView('log'); } }) }; this.saveButton = new SYNO.ux.Button({ text: 'Save', handler: function () { self.save(); } }); @@ -141,7 +150,7 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); border: false, layout: 'card', activeItem: 0, - items: [this.settingsPanel, this.statusPanel, this.logPanel] + items: [this.settingsPanel, this.statusPanel, this.mediaPanel, this.logPanel] }); config = Ext.apply({ @@ -156,7 +165,7 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); maximizable: true, minimizable: true, layout: 'fit', - tbar: [this.tabButtons.settings, this.tabButtons.status, this.tabButtons.log], + tbar: [this.tabButtons.settings, this.tabButtons.status, this.tabButtons.media, this.tabButtons.log], items: [this.deck], buttons: [this.saveButton, this.reloadButton, this.closeButton] }, config); @@ -220,6 +229,7 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); relabel: function () { this.tabButtons.settings.setText(this.t('settings')); this.tabButtons.status.setText(this.t('status')); + this.tabButtons.media.setText(this.t('media')); this.tabButtons.log.setText(this.t('log')); this.saveButton.setText(this.t('save')); this.reloadButton.setText(this.t('reload')); @@ -492,6 +502,188 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); // ---- the three views --------------------------------------------------- + // ---- images ---------------------------------------------------------- + + /* Three sections, and the order is the order somebody works in: what is held, + * what can be fetched, and the manual way in that must never disappear. */ + loadMedia: function () { + var self = this; + var panel = this.mediaPanel; + panel.removeAll(true); + panel.add(new Ext.Panel({ border: false, html: '
' + self.t('loading') + '
' })); + panel.doLayout(); + + this.call('media', { + success: function (text) { + panel.removeAll(true); + panel.add(self.section(self.t('held'), text)); + + /* The catalogue picker. The source list is local and instant; asking + * one what it offers goes over the network to the vendor, so it is a + * second click rather than something done for every source on open. */ + self.sourceCombo = new SYNO.ux.ComboBox({ + fieldLabel: self.t('source'), + width: 260, + editable: false, + triggerAction: 'all', + mode: 'local', + valueField: 'id', + displayField: 'label', + store: new Ext.data.ArrayStore({ fields: ['id', 'label'], data: [] }) + }); + self.offerCombo = new SYNO.ux.ComboBox({ + fieldLabel: self.t('image'), + width: 420, + editable: false, + triggerAction: 'all', + mode: 'local', + valueField: 'name', + displayField: 'name', + store: new Ext.data.ArrayStore({ fields: ['name'], data: [] }) + }); + self.sourceCombo.on('select', function (c, rec) { self.loadOffers(rec.get('id')); }); + + var fetchBtn = new SYNO.ux.Button({ + text: self.t('fetch'), + handler: function () { self.fetchImage(); } + }); + var urlField = new SYNO.ux.TextField({ fieldLabel: self.t('url'), width: 420 }); + var digestField = new SYNO.ux.TextField({ fieldLabel: self.t('digest'), width: 420 }); + self.urlField = urlField; + self.digestField = digestField; + + panel.add(new SYNO.ux.FormPanel({ + border: false, labelWidth: 120, bodyStyle: 'padding: 4px 0 12px 0', + items: [ + new Ext.Panel({ border: false, html: '' + Ext.util.Format.htmlEncode(self.t('catalogue')) + '
' + Ext.util.Format.htmlEncode(self.t('catalogue_hint')) + '
' }), + self.sourceCombo, self.offerCombo, fetchBtn + ] + })); + + /* **The manual way in, and it is not a lesser path.** A digest somebody + * obtained out of band is stronger evidence than one read from the same + * host as the image — so this stays in front of people rather than being + * documented as an escape hatch for the command line. */ + panel.add(new SYNO.ux.FormPanel({ + border: false, labelWidth: 120, bodyStyle: 'padding: 4px 0 12px 0', + items: [ + new Ext.Panel({ border: false, html: '' + Ext.util.Format.htmlEncode(self.t('manual')) + '
' + Ext.util.Format.htmlEncode(self.t('manual_hint')) + '
' }), + urlField, digestField, + new SYNO.ux.Button({ text: self.t('add'), handler: function () { self.addByUrl(); } }) + ] + })); + + self.progressPanel = new Ext.Panel({ border: false, html: '' }); + panel.add(self.progressPanel); + panel.doLayout(); + self.fillSources(); + self.pollProgress(); + } + }); + }, + + section: function (title, text) { + return new Ext.Panel({ + border: false, + html: '' + Ext.util.Format.htmlEncode(title) + '' + + '
' + Ext.util.Format.htmlEncode(text) + '
' + }); + }, + + /* `media sources` prints a table; the ids are its first column. Parsed here + * rather than served as JSON so the panel and the command line stay the same + * text — the rule the `config` action already follows. */ + fillSources: function () { + var self = this; + this.call('sources', { + success: function (text) { + var rows = []; + Ext.each(String(text).split('\n'), function (line) { + var m = /^([a-z0-9][a-z0-9._-]*)\s\s+(\S.*?)\s\s+/.exec(line); + if (m && m[1] !== 'SOURCE') { rows.push([m[1], m[2]]); } + }); + if (self.sourceCombo) { self.sourceCombo.getStore().loadData(rows); } + } + }); + }, + + loadOffers: function (id) { + var self = this; + this.offerCombo.getStore().loadData([]); + this.offerCombo.setValue(''); + this.banner(this.t('reading_index')); + this.call('sources', { + query: '&source=' + encodeURIComponent(id), + success: function (text) { + var rows = []; + Ext.each(String(text).split('\n'), function (line) { + var m = /^ {2}(\S+\.(?:iso|img))\s*$/i.exec(line); + if (m) { rows.push([m[1]]); } + }); + self.offerCombo.getStore().loadData(rows); + if (rows.length) { self.offerCombo.setValue(rows[0][0]); self.banner(''); } + else { self.banner(text); } + } + }); + }, + + fetchImage: function () { + var self = this; + var src = this.sourceCombo && this.sourceCombo.getValue(); + var name = this.offerCombo && this.offerCombo.getValue(); + if (!src || !name) { this.banner(this.t('pick_one')); return; } + this.call('fetch', { + query: '&source=' + encodeURIComponent(src) + '&name=' + encodeURIComponent(name), + body: '', + success: function () { self.banner(''); self.pollProgress(); } + }); + }, + + addByUrl: function () { + var self = this; + var url = (this.urlField && this.urlField.getValue() || '').replace(/^\s+|\s+$/g, ''); + var digest = (this.digestField && this.digestField.getValue() || '').replace(/^\s+|\s+$/g, ''); + if (!url) { this.banner(this.t('need_url')); return; } + this.call('fetch', { + query: '&url=' + encodeURIComponent(url) + '&sha256=' + encodeURIComponent(digest), + body: '', + success: function () { self.banner(''); self.pollProgress(); } + }); + }, + + /* Progress is the partial file's size, which `media add` is already writing — + * nothing had to be invented for the browser, and nothing here can disagree with + * what the CLI actually did. */ + pollProgress: function () { + var self = this; + this.stopPolling(); + var tick = function () { + self.call('progress', { + success: function (text) { + if (!self.progressPanel) { return; } + self.progressPanel.update('
' + Ext.util.Format.htmlEncode(text) + '
'); + if (/^state: running/m.test(text)) { + self.pollTimer = setTimeout(tick, 2000); + } else { + self.pollTimer = null; + /* Finished: the held list has changed, so re-read it once + * rather than leaving a stale table in front of somebody. */ + if (self.active === 'media' && self.sawRunning) { + self.sawRunning = false; + self.loadMedia(); + } + } + if (/^state: running/m.test(text)) { self.sawRunning = true; } + } + }); + }; + tick(); + }, + + stopPolling: function () { + if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; } + }, + /* **Not `show`.** `Ext.Window.prototype.show()` is what DSM calls to display the * window, and defining a method of that name here silently overrode it: the window * was built, laid out and even rendered its taskbar preview, and then never @@ -499,17 +691,25 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); * either DSM version. Anything added to this prototype shares a namespace with * every method of `Ext.Window`, and that is a large namespace. */ showView: function (which) { - var panel = which === 'status' ? this.statusPanel : (which === 'log' ? this.logPanel : this.settingsPanel); + var panel = this.settingsPanel; + if (which === 'status') { panel = this.statusPanel; } + if (which === 'log') { panel = this.logPanel; } + if (which === 'media') { panel = this.mediaPanel; } this.deck.getLayout().setActiveItem(panel); this.active = which; this.saveButton.setDisabled(which !== 'settings' || !this.writable); if (which === 'status') { this.loadStatus(); } if (which === 'log') { this.loadLog(); } + if (which === 'media') { this.loadMedia(); } + /* Polling only while the tab is in front. A timer left running behind a + * closed window is a request every two seconds, forever, on a NAS. */ + if (which !== 'media') { this.stopPolling(); } }, reload: function () { if (this.active === 'status') { this.loadStatus(); return; } if (this.active === 'log') { this.loadLog(); return; } + if (this.active === 'media') { this.loadMedia(); return; } this.loadConfig(); } }); diff --git a/packaging/dsm/payload/ui/texts/enu/strings b/packaging/dsm/payload/ui/texts/enu/strings index 23f579b..71450ca 100644 --- a/packaging/dsm/payload/ui/texts/enu/strings +++ b/packaging/dsm/payload/ui/texts/enu/strings @@ -14,6 +14,22 @@ description = "Serve unattended-installation answers, composed per machine." settings = "Settings" status = "Status" log = "Log" +media = "Images" +held = "Images held" +catalogue = "From a catalogue" +catalogue_hint = "The list comes from each vendor's own checksum index, so it is current and the digests are theirs." +source = "Catalogue" +image = "Image" +fetch = "Download" +manual = "By URL" +manual_hint = "A digest you obtained yourself is stronger evidence than one read from the same host as the image." +url = "URL" +digest = "SHA-256" +add = "Add" +loading = "Reading…" +reading_index = "Reading the vendor's index…" +pick_one = "Choose a catalogue and an image first." +need_url = "A URL is needed." save = "Save" reload = "Reload" close = "Close" diff --git a/packaging/dsm/payload/ui/texts/fre/strings b/packaging/dsm/payload/ui/texts/fre/strings index 9e8ec14..bf29906 100644 --- a/packaging/dsm/payload/ui/texts/fre/strings +++ b/packaging/dsm/payload/ui/texts/fre/strings @@ -8,6 +8,22 @@ description = "Sert à chaque machine sa réponse d'installation, composée pour settings = "Réglages" status = "État" log = "Journal" +media = "Images" +held = "Images présentes" +catalogue = "Depuis un catalogue" +catalogue_hint = "La liste vient de l'index de sommes que chaque éditeur publie, elle est donc à jour et les empreintes sont les siennes." +source = "Catalogue" +image = "Image" +fetch = "Télécharger" +manual = "Par URL" +manual_hint = "Une empreinte que vous avez obtenue vous-même vaut mieux qu'une empreinte lue sur le même serveur que l'image." +url = "URL" +digest = "SHA-256" +add = "Ajouter" +loading = "Lecture…" +reading_index = "Lecture de l'index de l'éditeur…" +pick_one = "Choisissez d'abord un catalogue et une image." +need_url = "Il faut une URL." save = "Enregistrer" reload = "Recharger" close = "Fermer" diff --git a/packaging/dsm/vm/remote-check.sh b/packaging/dsm/vm/remote-check.sh index 803d804..1307e7c 100755 --- a/packaging/dsm/vm/remote-check.sh +++ b/packaging/dsm/vm/remote-check.sh @@ -289,6 +289,24 @@ else note "no /usr/bin/setcap on this machine — the only route to port 69 is closed here" fi +# ── the image catalogue ──────────────────────────────────────────────────────── +section "the images tab, and whether this NAS can reach a vendor" +# The catalogue is the one part of this package that talks to the internet, and it does it +# by shelling out to curl because there is no TLS in the binary. Whether that works is a +# property of the *machine* — its resolver, its uplink, its curl — so it cannot be settled +# anywhere but here. +if "$ROOT/target/bin/$PKG-cli" media sources 2>/dev/null | grep -q proxmox-ve; then + ok "the catalogues are listed" +else + bad "media sources listed nothing" +fi +if out=$("$ROOT/target/bin/$PKG-cli" media sources proxmox-ve 2>&1) && echo "$out" | grep -q "proxmox-ve_"; then + ok "and this NAS can read a vendor's index over the network" + note "newest offered: $(echo "$out" | grep -m1 '^ proxmox-ve_')" +else + bad "could not read the Proxmox index from this machine: $(echo "$out" | tail -2)" +fi + if [ -e /usr/local/bin/$PKG-cli ]; then ok "rescriptum-cli is on PATH" else From ecd022c73f1351cd08d95c68accd2f09be8a9f61 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 12:34:49 +0200 Subject: [PATCH 39/59] feat(dsm): the Prepare button, which the tab was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `prepare` action was wired into api.cgi and nothing in the panel called it — so the tab could fetch an image and then leave the one step that makes it an unattended install available only over SSH. Caught by being asked whether it was there. It sits directly under the listing it acts on, because it is the step nobody guesses at. Proxmox only, and **the CLI is what refuses the rest**, with its own sentence shown verbatim: every other family takes its answer's URL on the kernel command line, where `media ipxe` already puts it, so injecting a file they never read would be a no-op that looks like a step. Deciding that in the panel too would be a second implementation to keep honest. Two checks (78 → 80): a well-formed id for an image that is not there has to reach the CLI and report its refusal, and must not come back claiming exit 0. The traversal guard was already covered. --- packaging/dsm/lifecycle-test.sh | 5 ++ packaging/dsm/payload/ui/rescriptum.js | 54 ++++++++++++++++++++++ packaging/dsm/payload/ui/texts/enu/strings | 4 ++ packaging/dsm/payload/ui/texts/fre/strings | 4 ++ 4 files changed, 67 insertions(+) diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh index 49df4fd..4b2cf74 100755 --- a/packaging/dsm/lifecycle-test.sh +++ b/packaging/dsm/lifecycle-test.sh @@ -431,6 +431,11 @@ grep -qE "^state: (idle|running)$" <<<"$out" && ok "progress answers even when n out=$(cgi POST "action=prepare&id=../escape" "" "1") [ "$(http_status "$out")" = "400" ] && ok "prepare refuses an id that is a path" || bad "prepare took a traversing id: $(http_status "$out")" +# A well-formed id for an image that is not here: the action has to reach the CLI and +# report its refusal, rather than crashing or claiming success. That is the whole wiring. +out=$(cgi POST "action=prepare&id=not-here" "" "1") +[ "$(http_status "$out")" = "200" ] && grep -q -- "--- exit" <<<"$out" && ok "prepare reaches the CLI and reports what it said" || bad "prepare did not reach the CLI: $out" +grep -q -- "--- exit 0" <<<"$out" && bad "prepare claimed success for an image that is not there" || ok "and does not claim success for an image that is not there" out=$(cgi GET "action=nonsense" "" "") [ "$(http_status "$out")" = "400" ] && ok "an unknown action is refused" || bad "an unknown action got $(http_status "$out")" diff --git a/packaging/dsm/payload/ui/rescriptum.js b/packaging/dsm/payload/ui/rescriptum.js index 5bd84ce..90121f0 100644 --- a/packaging/dsm/payload/ui/rescriptum.js +++ b/packaging/dsm/payload/ui/rescriptum.js @@ -518,6 +518,37 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); panel.removeAll(true); panel.add(self.section(self.t('held'), text)); + /* **Preparing is what turns an image into an unattended install**, and + * it is the step nobody guesses at — so it sits directly under the list + * it acts on rather than behind a menu. The ids come from that same + * listing, parsed here for the same reason the source list is: the + * panel and the command line must not disagree about what is held. */ + var ids = []; + Ext.each(String(text).split('\n'), function (line) { + var m = /^([A-Za-z0-9][A-Za-z0-9._:-]*)\s\s+\S/.exec(line); + if (m && m[1] !== 'ID') { ids.push([m[1]]); } + }); + self.prepareCombo = new SYNO.ux.ComboBox({ + fieldLabel: self.t('image'), + width: 420, + editable: false, + triggerAction: 'all', + mode: 'local', + valueField: 'id', + displayField: 'id', + store: new Ext.data.ArrayStore({ fields: ['id'], data: ids }) + }); + panel.add(new SYNO.ux.FormPanel({ + border: false, labelWidth: 120, bodyStyle: 'padding: 4px 0 12px 0', + items: [ + new Ext.Panel({ border: false, html: '' + Ext.util.Format.htmlEncode(self.t('prepare')) + '
' + Ext.util.Format.htmlEncode(self.t('prepare_hint')) + '
' }), + self.prepareCombo, + new SYNO.ux.Button({ text: self.t('prepare_do'), handler: function () { self.prepareImage(); } }) + ] + })); + self.prepareResult = new Ext.Panel({ border: false, html: '' }); + panel.add(self.prepareResult); + /* The catalogue picker. The source list is local and instant; asking * one what it offers goes over the network to the vendor, so it is a * second click rather than something done for every source on open. */ @@ -639,6 +670,29 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); }); }, + /* Proxmox only, and **the CLI is what refuses the rest** — with the sentence that + * says why, shown here verbatim. Every other family takes its answer's URL on the + * kernel command line, where `media ipxe` already puts it, so injecting a file + * they never read would be a no-op that looks like a step. Deciding that here as + * well would be a second implementation to keep honest. */ + prepareImage: function () { + var self = this; + var id = this.prepareCombo && this.prepareCombo.getValue(); + if (!id) { this.banner(this.t('pick_image')); return; } + this.prepareResult.update('
' + Ext.util.Format.htmlEncode(this.t('loading')) + '
'); + this.call('prepare', { + query: '&id=' + encodeURIComponent(id), + body: '', + success: function (text) { + self.prepareResult.update('
' + Ext.util.Format.htmlEncode(text) + '
'); + /* A prepared entry is a new row in the listing above, so re-read it — + * leaving a stale table in front of somebody who just changed it is + * how a working step looks like it did nothing. */ + if (/--- exit 0\s*$/.test(text)) { self.loadMedia(); } + } + }); + }, + addByUrl: function () { var self = this; var url = (this.urlField && this.urlField.getValue() || '').replace(/^\s+|\s+$/g, ''); diff --git a/packaging/dsm/payload/ui/texts/enu/strings b/packaging/dsm/payload/ui/texts/enu/strings index 71450ca..8d5b17c 100644 --- a/packaging/dsm/payload/ui/texts/enu/strings +++ b/packaging/dsm/payload/ui/texts/enu/strings @@ -30,6 +30,10 @@ loading = "Reading…" reading_index = "Reading the vendor's index…" pick_one = "Choose a catalogue and an image first." need_url = "A URL is needed." +prepare = "Prepare for unattended install" +prepare_hint = "Proxmox only: it is the one family that reads its answer's location from inside the image. Every other family takes it on the kernel command line and needs nothing here. Nothing is copied — this writes a few hundred bytes beside the image and applies them as it is served, so the file on disk stays exactly what the vendor published." +prepare_do = "Prepare" +pick_image = "Choose an image first." save = "Save" reload = "Reload" close = "Close" diff --git a/packaging/dsm/payload/ui/texts/fre/strings b/packaging/dsm/payload/ui/texts/fre/strings index bf29906..6336842 100644 --- a/packaging/dsm/payload/ui/texts/fre/strings +++ b/packaging/dsm/payload/ui/texts/fre/strings @@ -24,6 +24,10 @@ loading = "Lecture…" reading_index = "Lecture de l'index de l'éditeur…" pick_one = "Choisissez d'abord un catalogue et une image." need_url = "Il faut une URL." +prepare = "Préparer pour l'installation sans surveillance" +prepare_hint = "Proxmox uniquement : c'est la seule famille qui lit l'emplacement de sa réponse depuis l'intérieur de l'image. Toutes les autres la prennent sur la ligne de commande du noyau et n'ont besoin de rien ici. Rien n'est copié — quelques centaines d'octets sont écrits à côté de l'image et appliqués au fil de l'eau, donc le fichier sur disque reste exactement ce que l'éditeur a publié." +prepare_do = "Préparer" +pick_image = "Choisissez d'abord une image." save = "Enregistrer" reload = "Recharger" close = "Fermer" From 8105eb782f229c9e4d3822c127b2bafb01a2e587 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 16:30:20 +0200 Subject: [PATCH 40/59] feat(boot): let a deployment invert what an answer file means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the maintainer, installing a real machine: after an install the node reboots straight back into PXE, and "no answer for this machine" ought to mean "get out of the way" rather than "here is a menu". They are right, and it is the better polarity. `RESCRIPTUM_BOOT_UNCLAIMED=local` makes the bootstrap fall through to `exit 0` instead of the menu — control back to the firmware, next boot device, works on BIOS and UEFI alike (`sanboot --drive 0x80` is BIOS-only). **The two settings are opposite readings of what an answer file is for.** With the menu — still the default, and the project's thesis — a file claiming a machine says *leave this one alone*, because without one it lands somewhere a human could click. With `local` a file says *install this one* and its absence is the safe state, which is what a fleet already in production needs and the reading that scales: the machines you want to reinstall are always fewer than the ones you do not. The payoff is that netboot stays first in the BIOS order forever, and reinstalling becomes "add a file, reboot" — no console, no hands on the hardware. Removing the file is what stops it happening twice. Watched red by pinning the switch to false. `every_variable_is_described_ exactly_once` also earned its keep immediately: it caught the new key being in `KNOWN` but not in `envfile::KNOWN_KEYS`, which is the exact split it exists to catch — described but never read. Also corrects what I told the maintainer an hour ago. I warned about a reinstall loop with only a .toml in place; there is none. The menu's first entry is the local disk and its timeout falls through to it, which the rig already asserts. The loop needs an .ipxe answer that boots the installer, and this setting is what makes that safe. --- CLAUDE.md | 1 + docs/guide/operations/netboot.fr.md | 39 ++++++++++++++++ docs/guide/operations/netboot.md | 37 +++++++++++++++ docs/guide/reference/configuration.fr.md | 1 + docs/guide/reference/configuration.md | 1 + src/boot/media.rs | 3 +- src/boot/menu.rs | 58 +++++++++++++++++++++--- src/cli.rs | 5 +- src/config.rs | 33 +++++++++++++- src/envfile.rs | 3 +- 10 files changed, 171 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f256d2c..827776e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -668,6 +668,7 @@ Environment variables only — plus an optional file to read some of them from: | `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Concurrent transfers; low on purpose | | `RESCRIPTUM_PUBLIC_HOST` | the routing table's answer, else a sole interface | The host generated URLs name. **A host, never a URL**. Warns and names the alternatives when the host has several | | `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media | +| `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | Or `local`. **Inverts what an answer file means**: with `local`, present is *install this one* and absent is the safe state | | `RESCRIPTUM_BOOT_DIR` | unset | Loaders and menus. **Unset means no TFTP at all** | | `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` when `RESCRIPTUM_BOOT_DIR` is set | Or `off`, a deployment workaround, never a packaged default. A failed bind here warns rather than killing the server | diff --git a/docs/guide/operations/netboot.fr.md b/docs/guide/operations/netboot.fr.md index ed126a4..9e4b202 100644 --- a/docs/guide/operations/netboot.fr.md +++ b/docs/guide/operations/netboot.fr.md @@ -205,6 +205,45 @@ $ packaging/ipxe/build.sh --out /srv/boot Un chargeur venu d'ailleurs convient aussi, à condition qu'il enchaîne vers *ce* serveur plutôt que vers Internet — voir ci-dessous pourquoi un chargeur d'origine ne le fait pas. +## Ce qui se passe au *deuxième* démarrage + +La première question que tout le monde se pose après une installation réussie, et elle a +une vraie réponse. + +Une machine qui vient d'être installée redémarre, et si le démarrage réseau est encore +premier dans son BIOS, elle revient ici. Ce qui suit est décidé par un seul réglage : + +| `RESCRIPTUM_BOOT_UNCLAIMED` | Une machine qu'aucune réponse ne revendique | +|---|---| +| `menu` (défaut) | reçoit le menu, dont la première entrée est le disque local et dont le délai y retombe — quinze secondes, puis le disque | +| `local` | est rendue directement à son firmware, qui passe au périphérique suivant | + +**Ce sont deux lectures opposées de ce que signifie un fichier de réponse**, et le choix +appartient au déploiement. + +Avec le menu, un fichier qui revendique une machine est la façon de dire *laisse celle-ci +tranquille* — car sans lui elle atterrit dans un menu que quelqu'un pourrait cliquer. C'est +juste pendant qu'on provisionne, et c'est la thèse du projet : une machine dont personne +n'a rien décidé doit finir là où un humain peut décider. + +Avec `local`, un fichier de réponse veut dire *installe celle-ci*, et son absence est +l'état sûr. Il n'arrive rien à une machine pour laquelle vous n'avez pas écrit de fichier — +elle démarre sur son disque, à chaque fois, sans menu à cliquer par accident. C'est la +lecture dont un parc en production a besoin, et c'est celle qui passe à l'échelle : le +nombre de machines qu'on veut réinstaller est toujours plus petit que celui des autres. + +**Le bénéfice, c'est que le démarrage réseau peut rester premier dans le BIOS pour +toujours.** Réinstaller une machine devient *ajouter un fichier, redémarrer* — sans +console, sans menu de démarrage, sans toucher au matériel. Retirer le fichier est ce qui +empêche que cela se reproduise. + +```console +$ rescriptum config set RESCRIPTUM_BOOT_UNCLAIMED=local +``` + +Dans les deux cas l'identité de la machine part d'abord. Le réglage décide seulement de ce +qui arrive quand rien ne l'a revendiquée — pas s'il faut demander. + ## Comment iPXE finit par parler à *nous* La question qu'on ne s'attend pas à devoir trancher. Quel que soit le livreur du diff --git a/docs/guide/operations/netboot.md b/docs/guide/operations/netboot.md index a6582fc..fb6fc88 100644 --- a/docs/guide/operations/netboot.md +++ b/docs/guide/operations/netboot.md @@ -192,6 +192,43 @@ $ packaging/ipxe/build.sh --out /srv/boot A loader from elsewhere works too, provided it chains to *this* server rather than to the internet — see below for why a stock one does not. +## What happens on the *second* boot + +The first question anybody asks after a successful install, and it has a real answer. + +A machine that was just installed reboots, and if network boot is still first in its BIOS +order it arrives back here. What happens next is decided by one setting: + +| `RESCRIPTUM_BOOT_UNCLAIMED` | A machine no answer claims | +|---|---| +| `menu` (default) | gets the menu, whose first entry is the local disk and whose timeout falls through to it — fifteen seconds, then the disk | +| `local` | is handed straight back to its firmware, which moves to the next boot device | + +**These are opposite readings of what an answer file means**, and the choice belongs to the +deployment. + +With the menu, a file claiming a machine is how you say *leave this one alone* — because +without one it lands in a menu somebody could click. That is right while machines are being +provisioned, and it is the project's thesis: a machine nobody has decided anything about +should end up where a human can decide. + +With `local`, an answer file means *install this one*, and its absence is the safe state. +Nothing happens to a machine you have not written a file for — it boots its own disk, every +time, with no menu to click by accident. That is the reading a fleet in production needs, +and it is the one that scales: the number of machines you want to reinstall is always +smaller than the number you do not. + +**The payoff is that netboot can stay first in the BIOS order forever.** Reinstalling a +machine becomes *add a file, reboot* — no console, no boot menu, no hands on the hardware. +Removing the file is what stops it happening twice. + +```console +$ rescriptum config set RESCRIPTUM_BOOT_UNCLAIMED=local +``` + +Either way the machine's identity still goes up first. The setting decides only what +happens when nothing claimed it — not whether to ask. + ## How iPXE ends up talking to *us* The question nobody expects to have to answer. Whatever delivers the loader: diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index d843726..45b926e 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -38,6 +38,7 @@ pas de *format* de configuration à apprendre ni de ligne de commande à se trom | `RESCRIPTUM_BOOT_DIR` | non défini | Chargeurs et menus, distribués en TFTP. **Non défini = pas de TFTP du tout** | | `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | Le listener TFTP, ou **`off`** pour aucun. Le port 69 est privilégié ; voir `RESCRIPTUM_USER` | | `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Secondes avant que le menu ne retombe sur le disque local | +| `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | Ce que reçoit une machine qu'aucune réponse ne revendique. `local` la rend à son firmware, ce qui inverse le sens d'un fichier de réponse : présent veut dire *installe celle-ci* plutôt que *laisse celle-ci tranquille* | | `RESCRIPTUM_BOOT_LOGO` | intégré | Un PNG à afficher derrière le menu | | `RESCRIPTUM_BOOT_TITLE` | intégré | La barre de titre du menu | | `RESCRIPTUM_USER` / `_GROUP` | non défini | Basculer dessus **après** avoir lié. L'ordre inverse échoue au déploiement | diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 7879abc..3b89e1d 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -38,6 +38,7 @@ no configuration *format* to learn and no command line to get wrong. | `RESCRIPTUM_BOOT_DIR` | unset | Loaders and menus, handed out over TFTP. **Unset means no TFTP at all** | | `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | The TFTP listener, or **`off`** for none. Port 69 is privileged; see `RESCRIPTUM_USER` | | `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Seconds before the menu falls through to local boot | +| `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | What a machine no answer claims gets. `local` hands it back to its firmware instead, which inverts what an answer file means: present is *install this one* rather than *leave this one alone* | | `RESCRIPTUM_BOOT_LOGO` | built-in | A PNG to show behind the menu | | `RESCRIPTUM_BOOT_TITLE` | built-in | The menu's title bar | | `RESCRIPTUM_USER` / `_GROUP` | unset | Drop to these **after** binding. The other order fails on deployment | diff --git a/src/boot/media.rs b/src/boot/media.rs index 3b133cc..fa370d9 100644 --- a/src/boot/media.rs +++ b/src/boot/media.rs @@ -129,7 +129,8 @@ async fn handle(req: Request, media: Arc, peer: SocketAddr) -> // **they have to work when the answer set is empty**, which is the state every new // install starts in. if path == "/ipxe/bootstrap" { - let script = super::menu::bootstrap(&media.cfg.endpoints()); + let script = + super::menu::bootstrap(&media.cfg.endpoints(), media.cfg.unclaimed_boots_local()); log::request(&peer_label, 200, "media: GET /ipxe/bootstrap 200"); return script_response(script); } diff --git a/src/boot/menu.rs b/src/boot/menu.rs index 3952bbf..6365409 100644 --- a/src/boot/menu.rs +++ b/src/boot/menu.rs @@ -43,9 +43,21 @@ use super::stanza::{self, Endpoints}; /// And **`:uristring` on every SMBIOS string**: `${manufacturer}` expands to /// `Dell Inc.`, space included, and iPXE percent-encodes nothing on plain expansion, so /// a space in a request line is a broken fetch. -pub fn bootstrap(endpoints: &Endpoints) -> String { +pub fn bootstrap(endpoints: &Endpoints, unclaimed_boots_local: bool) -> String { let answer = endpoints.answer.trim_end_matches('/'); let media = endpoints.media.trim_end_matches('/'); + // **What happens when nothing claims this machine is the whole policy**, and it is + // one line. The menu is the default and the project's thesis: a machine nobody has + // decided anything about ends up where a human can decide. `exit 0` is for a fleet + // that is already installed — it hands control back to the firmware, which moves to + // the next boot device, so netboot can stay first in the BIOS order without every + // reboot passing through a menu. It is the one that works on BIOS *and* UEFI; + // `sanboot --drive 0x80` is BIOS-only. + let fallback = if unclaimed_boots_local { + "|| exit 0\n".to_string() + } else { + format!("|| chain {media}/ipxe/menu\n") + }; format!( "#!ipxe\n\ # Stage two. DHCP cannot carry a MAC, so this is what puts one in the query\n\ @@ -54,7 +66,7 @@ pub fn bootstrap(endpoints: &Endpoints) -> String { &serial=${{serial:uristring}}&asset=${{asset:uristring}}\\\n\ &manufacturer=${{manufacturer:uristring}}&product=${{product:uristring}}\\\n\ &platform=${{platform}}&arch=${{buildarch}} \\\n\ - || chain {media}/ipxe/menu\n" + {fallback}" ) } @@ -343,7 +355,7 @@ mod tests { fn the_bootstrap_puts_the_machines_identity_in_the_query_string() { // Without this the haystack is empty for every GET and the selection engine // goes dark at exactly the moment it matters. - let script = bootstrap(&endpoints()); + let script = bootstrap(&endpoints(), false); assert!(script.starts_with("#!ipxe\n")); assert!(script.contains("mac=${netX/mac}"), "{script}"); assert!(script.contains("uuid=${uuid}"), "{script}"); @@ -354,16 +366,50 @@ mod tests { fn the_bootstrap_names_the_booting_nic_rather_than_the_first_one() { // `net0` is merely the first interface. A server that PXE-boots from its second // port would identify as its unused first, and install the wrong machine. - let script = bootstrap(&endpoints()); + let script = bootstrap(&endpoints(), false); assert!(script.contains("${netX/mac}"), "{script}"); assert!(!script.contains("${net0/"), "{script}"); } + #[test] + fn what_an_unclaimed_machine_gets_is_the_whole_policy() { + // **These two are opposite readings of what an answer file means**, and the + // choice belongs to the deployment rather than to us. + // + // With the menu — the default, and the project's thesis — a machine nobody has + // decided anything about ends up where a human can decide. An answer file then + // means "leave this one alone". + // + // With `local` the machine is handed straight back to its firmware, so an answer + // file means "install this one" and its absence is the safe state. That is the + // reading a fleet already in production needs: netboot can stay first in the BIOS + // order — which is what makes reinstalling a machine "add a file and reboot" — + // without every routine reboot passing through a menu that could be clicked. + let menu = bootstrap(&endpoints(), false); + assert!( + menu.contains("|| chain http://192.0.2.10:8001/ipxe/menu"), + "{menu}" + ); + assert!(!menu.contains("exit 0"), "{menu}"); + + let local = bootstrap(&endpoints(), true); + // `exit 0` and not `sanboot --drive 0x80`: the latter is BIOS-only and fails on + // UEFI, and this script is served to both. + assert!(local.contains("|| exit 0"), "{local}"); + assert!(!local.contains("/ipxe/menu"), "{local}"); + + // Whichever it is, the identity still goes up first — the fallback is what + // happens when nothing claimed the machine, not instead of asking. + for script in [&menu, &local] { + assert!(script.contains("mac=${netX/mac}"), "{script}"); + } + } + #[test] fn every_smbios_string_is_percent_encoded_at_expansion() { // `${manufacturer}` is `Dell Inc.` — space included — and iPXE encodes nothing // on plain expansion, so a space in the request line is a broken fetch. - let script = bootstrap(&endpoints()); + let script = bootstrap(&endpoints(), false); for field in ["serial", "asset", "manufacturer", "product"] { assert!( script.contains(&format!("{field}=${{{field}:uristring}}")), @@ -379,7 +425,7 @@ mod tests { // **A menu is what a machine gets when nobody has decided anything about it // yet** — `default.toml`'s job description, applied to a different format, and // implemented as one `||` rather than as a new concept in select.rs. - let script = bootstrap(&endpoints()); + let script = bootstrap(&endpoints(), false); assert!( script.contains("|| chain http://192.0.2.10:8001/ipxe/menu"), "{script}" diff --git a/src/cli.rs b/src/cli.rs index 18a2a2c..0fbfcb0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1239,7 +1239,10 @@ pub fn boot(cfg: &Config, args: &[String]) -> ExitCode { Some((cmd, rest)) if cmd == "dhcp-snippet" => boot_snippet(cfg, rest), Some((cmd, rest)) if cmd == "check" && rest.is_empty() => boot_check(cfg), Some((cmd, rest)) if cmd == "bootstrap" && rest.is_empty() => { - print!("{}", crate::boot::menu::bootstrap(&cfg.endpoints())); + print!( + "{}", + crate::boot::menu::bootstrap(&cfg.endpoints(), cfg.unclaimed_boots_local()) + ); ExitCode::SUCCESS } Some((cmd, rest)) if cmd == "menu" && rest.is_empty() => boot_menu(cfg), diff --git a/src/config.rs b/src/config.rs index 550d43b..ca69701 100644 --- a/src/config.rs +++ b/src/config.rs @@ -118,6 +118,8 @@ pub struct Config { pub tftp_addr: Option, /// Seconds before the built-in menu falls through to booting from local disk. pub boot_timeout: Duration, + /// What a machine no answer claims is offered. See `unclaimed_boots_local`. + pub boot_unclaimed: Option, /// Replace the embedded logo and the menu's title, for a site that wants its own. pub boot_logo: Option, pub boot_title: Option, @@ -252,6 +254,7 @@ impl Config { "RESCRIPTUM_BOOT_TIMEOUT_SECS", DEFAULT_BOOT_TIMEOUT_SECS as usize, ) as u64), + boot_unclaimed: optional("RESCRIPTUM_BOOT_UNCLAIMED"), boot_logo: optional("RESCRIPTUM_BOOT_LOGO").map(PathBuf::from), boot_title: optional("RESCRIPTUM_BOOT_TITLE"), user: optional("RESCRIPTUM_USER"), @@ -410,6 +413,28 @@ impl Config { self.tftp_addr.as_deref().is_some_and(is_off) } + /// Whether a machine that no answer claims is sent straight to its own disk instead + /// of being offered the menu. + /// + /// **The default is the menu, and that is the project's thesis rather than an + /// oversight**: a machine nobody has decided anything about should end up somewhere a + /// human can decide, not silently do nothing. That is right for a machine being + /// provisioned, and wrong for a fleet already in production — where most machines are + /// installed, and showing every one of them a menu for fifteen seconds on every + /// reboot is noise at best and an accidental reinstall at worst. + /// + /// So `local` inverts what an answer file *means*. With the menu, a file claiming a + /// machine is how you say "leave this one alone"; with `local`, a file is how you say + /// "install this one", and its absence is the safe state. The second reading is the + /// one that scales, because the number of machines you want to reinstall is always + /// smaller than the number you do not. + pub fn unclaimed_boots_local(&self) -> bool { + self.boot_unclaimed + .as_deref() + .map(str::trim) + .is_some_and(|v| v.eq_ignore_ascii_case("local")) + } + /// The menu timeout **in milliseconds**, which is the unit `choose` counts. The /// conversion has exactly one place, and this is it. pub fn boot_timeout_millis(&self) -> u64 { @@ -636,7 +661,7 @@ pub struct Known { /// Every variable, in the order a person would want to meet them: what answers come /// from, where the server listens, how much it says, then the two credentials. -pub const KNOWN: [Known; 26] = [ +pub const KNOWN: [Known; 27] = [ Known { key: "RESCRIPTUM_STORE", default: Some("files"), @@ -774,6 +799,12 @@ pub const KNOWN: [Known; 26] = [ secret: false, help: "Seconds before the menu falls through to local disk. Rendered as milliseconds.", }, + Known { + key: "RESCRIPTUM_BOOT_UNCLAIMED", + default: Some("menu"), + secret: false, + help: "What a machine no answer claims gets: `menu`, or `local` to send it straight to its own disk.", + }, Known { key: "RESCRIPTUM_BOOT_LOGO", default: None, diff --git a/src/envfile.rs b/src/envfile.rs index af993f5..b14f07e 100644 --- a/src/envfile.rs +++ b/src/envfile.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; pub const ENV_FILE: &str = "RESCRIPTUM_ENV_FILE"; /// Every variable this program reads, so a typo can be reported rather than ignored. -pub const KNOWN_KEYS: [&str; 26] = [ +pub const KNOWN_KEYS: [&str; 27] = [ "RESCRIPTUM_STORE", "RESCRIPTUM_ANSWERS_DIR", "RESCRIPTUM_DB_PATH", @@ -51,6 +51,7 @@ pub const KNOWN_KEYS: [&str; 26] = [ "RESCRIPTUM_BOOT_DIR", "RESCRIPTUM_TFTP_ADDR", "RESCRIPTUM_BOOT_TIMEOUT_SECS", + "RESCRIPTUM_BOOT_UNCLAIMED", "RESCRIPTUM_BOOT_LOGO", "RESCRIPTUM_BOOT_TITLE", "RESCRIPTUM_USER", From b298b20f5eabb712b66b0949132bc831907c1ff5 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 16:37:29 +0200 Subject: [PATCH 41/59] chore: a gitignored tmp/ for a deployment's own answer documents They carry root credentials. A scratch directory beside the repo is where they belong, and it must never be a directory git can be talked into tracking. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 18d1162..9ce408f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ node_modules/ # ── Build output / generated ───────────────────────────────── dist/ +# Scratch for a deployment's own files — answer documents carry credentials and must +# never reach a public repository. +tmp/ # The built loaders. **No binaries in git, ever** — packaging/ipxe/ is the written # offer, and build.sh reproduces them from the pinned commit. packaging/ipxe/out/ From 70022e666dd00a4a6cbe7254e271d5e0b14b8c4e Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 17:29:38 +0200 Subject: [PATCH 42/59] feat(installed): the machine says it is done, and stops being claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop, closed properly. A machine claimed by an `.ipxe` answer installs, reboots, is claimed again, and installs again — wiping its disk each time. Doing the disarm by hand works and is what shipped first, but it is a race: the machine reboots the moment the installer finishes. Verified against Proxmox's own documentation before a line was written, because the block in our examples was written by this project and could have been fiction. It is not: `[post-installation-webhook]` fires after a successful install and **before the reboot**, POSTs JSON, and that body carries the network interfaces — MACs included. Which means it is the same shape as the request that asked for the answer, and `Facts` reads it with no new parsing. The `auth-token` goes in the body as a top-level `token`, not as a bearer — unlike the answer token. That last detail decides the routing: the route runs **before** the answer token's guard, because otherwise setting an answer token would 401 every webhook. And the path is reserved **only when the token is configured**, so a deployment that never uses this keeps "POST on any path is an answer request" whole — which is what lets a URL be baked into an ISO. Narrow by construction, because this is the one path where something arriving over the network changes the answer set: - machine documents only, never a group — one machine finishing must not disarm its neighbours, so the lookup never consults groups rather than filtering them out afterwards; - format `ipxe` only — the `.toml` is the record of how the machine was built and the installer is what reads it; - moved under an `installed-` prefix, never deleted, so nothing it does is irreversible. Arriving twice is a success: a webhook may be retried, and a machine installed from the menu was never claimed. A disarm that *fails* logs `still armed`, because otherwise the consequence is silent. Both guards watched red: moving the route after the bearer guard makes every webhook 401, and dropping the format filter takes the machine's .toml with it. The group test asserts the group *does* claim the machine before asserting it survives — without that it would pass for a fixture that was never loaded. 561 tests. --- CLAUDE.md | 8 + docs/guide/operations/netboot.fr.md | 44 +++ docs/guide/operations/netboot.md | 43 +++ docs/guide/reference/configuration.fr.md | 1 + docs/guide/reference/configuration.md | 1 + examples/example.toml | 9 +- src/config.rs | 12 +- src/envfile.rs | 3 +- src/installed.rs | 331 +++++++++++++++++++++++ src/lib.rs | 2 + src/main.rs | 88 +++++- tests/integration.rs | 101 +++++++ 12 files changed, 636 insertions(+), 7 deletions(-) create mode 100644 src/installed.rs diff --git a/CLAUDE.md b/CLAUDE.md index 827776e..97eb36a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,13 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit - `src/admin.rs` — the write API: its own listener, the constant-time token, the failure guard, and the rollback that keeps a write from breaking the answer set. - `src/capture.rs` — recording request bodies (`RESCRIPTUM_CAPTURE_DIR`). +- `src/installed.rs` — a machine reporting it finished, and its install claim being + dropped. **The one path where something arriving over the network changes the answer + set**, so it is narrow by construction: machine documents only (never a group — one + machine finishing must not disarm a rack), format `ipxe` only (the `.toml` is the record + of how it was built), and moved under an `installed-` prefix rather than deleted. The + token is Proxmox's, and it arrives **in the JSON body**, not as a bearer — so the route + runs before the answer token's guard, which would otherwise reject every webhook. - `src/config.rs` — environment configuration. `Config::from_lookup` takes a lookup closure so tests never touch the process environment. - `src/envfile.rs` — the optional file of defaults `RESCRIPTUM_ENV_FILE` names, and the @@ -669,6 +676,7 @@ Environment variables only — plus an optional file to read some of them from: | `RESCRIPTUM_PUBLIC_HOST` | the routing table's answer, else a sole interface | The host generated URLs name. **A host, never a URL**. Warns and names the alternatives when the host has several | | `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media | | `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | Or `local`. **Inverts what an answer file means**: with `local`, present is *install this one* and absent is the safe state | +| `RESCRIPTUM_INSTALLED_TOKEN` | unset | Proxmox's webhook token. Set it and `POST /installed` drops a machine's `.ipxe` claim when it reports success. **Unset, the endpoint does not exist** | | `RESCRIPTUM_BOOT_DIR` | unset | Loaders and menus. **Unset means no TFTP at all** | | `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` when `RESCRIPTUM_BOOT_DIR` is set | Or `off`, a deployment workaround, never a packaged default. A failed bind here warns rather than killing the server | diff --git a/docs/guide/operations/netboot.fr.md b/docs/guide/operations/netboot.fr.md index 9e4b202..0e342a4 100644 --- a/docs/guide/operations/netboot.fr.md +++ b/docs/guide/operations/netboot.fr.md @@ -244,6 +244,50 @@ $ rescriptum config set RESCRIPTUM_BOOT_UNCLAIMED=local Dans les deux cas l'identité de la machine part d'abord. Le réglage décide seulement de ce qui arrive quand rien ne l'a revendiquée — pas s'il faut demander. +## Installer une machine une fois, et une seule + +Une machine revendiquée par une réponse `.ipxe` s'installe, redémarre, est revendiquée de +nouveau, et se réinstalle — en effaçant son disque à chaque tour. Tous les systèmes de +provisionnement répondent pareil : une machine est *armée* pour l'installation, et quelque +chose la désarme ensuite. + +**C'est la machine qui sait.** Proxmox appelle un webhook après une installation réussie et +**avant le redémarrage**, avec ses interfaces réseau dans le corps : + +```toml +[post-installation-webhook] +url = "http://192.0.2.10:8000/installed" +auth-token = "nas:s3cr3t" +``` + +```console +$ rescriptum config set RESCRIPTUM_INSTALLED_TOKEN=nas:s3cr3t +``` + +C'est tout. La machine termine, elle le dit, et `98fa9b50d810.ipxe` devient +`installed-98fa9b50d810.ipxe` — qui ne lui correspond plus, le préfixe faisant partie du +nom comparé. Elle démarre sur son disque désormais, et la réarmer consiste à renommer le +fichier dans l'autre sens. + +**Pas de jeton, pas d'endpoint** — absent plutôt qu'ouvert. Sans lui, `/installed` est une +demande de réponse ordinaire comme n'importe quel chemin, ce qui permet à une URL de rester +gravable dans une ISO. + +Trois choses qu'il ne fait pas, et chacune est délibérée : + +- **Il ne touche jamais un groupe.** Un groupe revendique un rack entier, et une machine + qui finit son installation ne doit pas désarmer ses voisines. La recherche ne consulte + pas les groupes du tout, plutôt que de les écarter après coup. +- **Il ne touche rien d'autre que le `.ipxe`.** Le `.toml` de la machine est ce que + l'installateur a lu pour la construire, et il reste comme trace de la manière. +- **Il déplace, il ne supprime pas.** C'est le seul chemin où quelque chose venu du réseau + modifie le jeu de réponses : rien de ce qu'il fait n'est irréversible. + +Arriver deux fois n'est pas une erreur — un webhook peut être réessayé, et une machine +installée depuis le menu n'a jamais été revendiquée. Un désarmement qui *échoue* est +journalisé en `still armed`, parce que sa conséquence est autrement silencieuse : la +machine se réinstalle au démarrage suivant et rien d'autre ne le dirait. + ## Comment iPXE finit par parler à *nous* La question qu'on ne s'attend pas à devoir trancher. Quel que soit le livreur du diff --git a/docs/guide/operations/netboot.md b/docs/guide/operations/netboot.md index fb6fc88..141660f 100644 --- a/docs/guide/operations/netboot.md +++ b/docs/guide/operations/netboot.md @@ -229,6 +229,49 @@ $ rescriptum config set RESCRIPTUM_BOOT_UNCLAIMED=local Either way the machine's identity still goes up first. The setting decides only what happens when nothing claimed it — not whether to ask. +## Installing a machine once, and only once + +A machine claimed by an `.ipxe` answer installs, reboots, is claimed again, and installs +again — wiping its disk every time. Every provisioning system answers this the same way: a +machine is *armed* for install, and something disarms it afterwards. + +**The machine is what knows.** Proxmox calls a webhook after a successful install and +**before the reboot**, with its network interfaces in the body: + +```toml +[post-installation-webhook] +url = "http://192.0.2.10:8000/installed" +auth-token = "nas:s3cr3t" +``` + +```console +$ rescriptum config set RESCRIPTUM_INSTALLED_TOKEN=nas:s3cr3t +``` + +That is the whole of it. The machine finishes, says so, and `98fa9b50d810.ipxe` becomes +`installed-98fa9b50d810.ipxe` — which no longer matches it, because the prefix is part of +the name that gets compared. It boots its own disk from then on, and re-arming it is +renaming the file back. + +**No token, no endpoint** — absent rather than open. Without one, `/installed` is an +ordinary answer request like any other path, which is what keeps a URL bakeable into an +ISO. + +Three things it will not do, and each is deliberate: + +- **It never touches a group.** A group claims a whole rack, and one machine finishing its + install must not disarm its neighbours. The lookup does not consult groups at all rather + than filtering them out afterwards. +- **It never touches anything but the `.ipxe`.** The machine's own `.toml` is what the + installer read to build it, and it stays as the record of how. +- **It moves, it does not delete.** This is the one path where something arriving over the + network changes the answer set, so nothing it does is irreversible. + +Arriving twice is not an error — a webhook may be retried, and a machine installed from +the menu was never claimed at all. A disarm that *fails* is logged as `still armed`, +because the consequence is otherwise silent: the machine reinstalls on its next boot and +nothing else would say so. + ## How iPXE ends up talking to *us* The question nobody expects to have to answer. Whatever delivers the loader: diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index 45b926e..be9ce27 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -39,6 +39,7 @@ pas de *format* de configuration à apprendre ni de ligne de commande à se trom | `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | Le listener TFTP, ou **`off`** pour aucun. Le port 69 est privilégié ; voir `RESCRIPTUM_USER` | | `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Secondes avant que le menu ne retombe sur le disque local | | `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | Ce que reçoit une machine qu'aucune réponse ne revendique. `local` la rend à son firmware, ce qui inverse le sens d'un fichier de réponse : présent veut dire *installe celle-ci* plutôt que *laisse celle-ci tranquille* | +| `RESCRIPTUM_INSTALLED_TOKEN` | non défini | Le jeton du `[post-installation-webhook]` de Proxmox. Défini, `POST /installed` existe et retire la revendication d'installation d'une machine quand elle signale sa réussite. **Non défini, il n'y a pas d'endpoint** | | `RESCRIPTUM_BOOT_LOGO` | intégré | Un PNG à afficher derrière le menu | | `RESCRIPTUM_BOOT_TITLE` | intégré | La barre de titre du menu | | `RESCRIPTUM_USER` / `_GROUP` | non défini | Basculer dessus **après** avoir lié. L'ordre inverse échoue au déploiement | diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 3b89e1d..0b18af7 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -39,6 +39,7 @@ no configuration *format* to learn and no command line to get wrong. | `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | The TFTP listener, or **`off`** for none. Port 69 is privileged; see `RESCRIPTUM_USER` | | `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Seconds before the menu falls through to local boot | | `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | What a machine no answer claims gets. `local` hands it back to its firmware instead, which inverts what an answer file means: present is *install this one* rather than *leave this one alone* | +| `RESCRIPTUM_INSTALLED_TOKEN` | unset | Proxmox's `[post-installation-webhook]` token. Set it and `POST /installed` exists, dropping a machine's install claim when it reports success. **Unset, there is no endpoint** | | `RESCRIPTUM_BOOT_LOGO` | built-in | A PNG to show behind the menu | | `RESCRIPTUM_BOOT_TITLE` | built-in | The menu's title bar | | `RESCRIPTUM_USER` / `_GROUP` | unset | Drop to these **after** binding. The other order fails on deployment | diff --git a/examples/example.toml b/examples/example.toml index 5a42847..a785d53 100644 --- a/examples/example.toml +++ b/examples/example.toml @@ -59,7 +59,10 @@ disk-list = ["sda", "sdb"] # url = "https://config.example.com/first-boot.sh" # cert-fingerprint = "AB:CD:..." -# Tell something that the install finished. +# **Tell this server the install finished, so the machine stops being claimed.** +# Without it a machine claimed by an `.ipxe` answer reinstalls on every reboot. Proxmox +# calls this after a successful install and before the reboot; rescriptum answers it when +# RESCRIPTUM_INSTALLED_TOKEN is set, and renames the claim out of the way. # [post-installation-webhook] -# url = "https://hooks.example.com/pve-installed" -# auth-token = "REPLACE" +# url = "http://192.168.1.10:8000/installed" +# auth-token = "nas:REPLACE" diff --git a/src/config.rs b/src/config.rs index ca69701..fb86b33 100644 --- a/src/config.rs +++ b/src/config.rs @@ -108,6 +108,9 @@ pub struct Config { pub media_addr: Option, pub media_timeout: Duration, pub media_max_connections: usize, + /// Proxmox's `[post-installation-webhook]` token. **Unset is the whole off switch**: + /// no token, no endpoint — absent rather than open. See `installed`. + pub installed_token: Option, /// A CIDR allowlist for boot traffic. Unset means anyone who can reach the port. pub boot_allow: Option, /// Loaders and menus — what TFTP hands out. **Unset means no TFTP at all**, the @@ -247,6 +250,7 @@ impl Config { "RESCRIPTUM_MEDIA_MAX_CONNECTIONS", DEFAULT_MEDIA_MAX_CONNECTIONS, ), + installed_token: optional("RESCRIPTUM_INSTALLED_TOKEN"), boot_allow: optional("RESCRIPTUM_BOOT_ALLOW"), boot_dir: optional("RESCRIPTUM_BOOT_DIR").map(PathBuf::from), tftp_addr: optional("RESCRIPTUM_TFTP_ADDR"), @@ -661,7 +665,7 @@ pub struct Known { /// Every variable, in the order a person would want to meet them: what answers come /// from, where the server listens, how much it says, then the two credentials. -pub const KNOWN: [Known; 27] = [ +pub const KNOWN: [Known; 28] = [ Known { key: "RESCRIPTUM_STORE", default: Some("files"), @@ -799,6 +803,12 @@ pub const KNOWN: [Known; 27] = [ secret: false, help: "Seconds before the menu falls through to local disk. Rendered as milliseconds.", }, + Known { + key: "RESCRIPTUM_INSTALLED_TOKEN", + default: None, + secret: true, + help: "Proxmox's post-installation-webhook token. Set it and POST /installed exists, which drops a machine's install claim when it reports success. Unset, there is no endpoint.", + }, Known { key: "RESCRIPTUM_BOOT_UNCLAIMED", default: Some("menu"), diff --git a/src/envfile.rs b/src/envfile.rs index b14f07e..0f48373 100644 --- a/src/envfile.rs +++ b/src/envfile.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; pub const ENV_FILE: &str = "RESCRIPTUM_ENV_FILE"; /// Every variable this program reads, so a typo can be reported rather than ignored. -pub const KNOWN_KEYS: [&str; 27] = [ +pub const KNOWN_KEYS: [&str; 28] = [ "RESCRIPTUM_STORE", "RESCRIPTUM_ANSWERS_DIR", "RESCRIPTUM_DB_PATH", @@ -51,6 +51,7 @@ pub const KNOWN_KEYS: [&str; 27] = [ "RESCRIPTUM_BOOT_DIR", "RESCRIPTUM_TFTP_ADDR", "RESCRIPTUM_BOOT_TIMEOUT_SECS", + "RESCRIPTUM_INSTALLED_TOKEN", "RESCRIPTUM_BOOT_UNCLAIMED", "RESCRIPTUM_BOOT_LOGO", "RESCRIPTUM_BOOT_TITLE", diff --git a/src/installed.rs b/src/installed.rs new file mode 100644 index 0000000..378a6fb --- /dev/null +++ b/src/installed.rs @@ -0,0 +1,331 @@ +//! A machine reporting that it finished installing, and the claim on it being dropped. +//! +//! ## The loop this exists to close +//! +//! A machine is claimed for installation by an `.ipxe` answer named after it: the loader +//! asks `/ipxe/boot?mac=…`, gets a script that boots an installer, and installs. Then it +//! reboots — and if netboot is still first in its firmware's order, it arrives back at the +//! same question, gets the same script, and installs again. Forever, wiping the disk each +//! time. +//! +//! Every provisioning system answers this the same way: a machine is *armed* for install, +//! and something disarms it afterwards. The only question is who. Doing it by hand works +//! and is what this project shipped first, but it is a race — the machine reboots the +//! moment the installer finishes, so the window to rename a file is however long the +//! firmware takes to come back. +//! +//! **The machine is the one that knows.** Proxmox's answer format has a +//! `[post-installation-webhook]`, called with a JSON body **after a successful install and +//! before the reboot**, and that body carries the network interfaces — MAC addresses +//! included. So it is the same shape as the request that asked for the answer in the first +//! place, and `Facts` reads it with no new parsing at all. +//! +//! ## What it will and will not touch +//! +//! Narrow on purpose, because this is the one code path that changes the answer set in +//! response to something arriving over the network: +//! +//! - **Machine documents only.** Never a group, never a `default`. A group claims a whole +//! rack, and one machine finishing its install must never be able to disarm its +//! neighbours. This does not filter a resolution down to machines — it never looks at +//! groups at all. +//! - **Format `ipxe` only.** That is the document that boots an installer. A machine's +//! `.toml` is what the installer *reads once running*, and deleting it would take away +//! the record of how the machine was built. +//! - **Moved, not deleted.** The document is re-put under an `installed-` prefix, which no +//! longer matches the machine (the prefix is part of the normalized needle), and the +//! original is removed. Re-arming is renaming it back. Nothing is destroyed, which +//! matters for a thing triggered by a network request. +//! +//! ## Off unless configured +//! +//! No token, no endpoint — not an open one, absent. The token is Proxmox's own +//! `auth-token`, which it puts in the body as a top-level `token` field rather than in a +//! header (unlike the answer token, which is a bearer). Compared in constant time, for the +//! same reason the admin API's is. + +use crate::facts::Facts; +use crate::select::{Answers, normalize}; +use crate::store::StoreWrite; +use std::io; + +/// The prefix a disarmed document is moved under. +/// +/// It has to be a **prefix**, not a suffix: matching is a substring test of the +/// normalized id against the normalized request, so `98fa9b50d810installed` would still +/// contain nothing the machine sends — but neither would a suffix survive somebody adding +/// a second one. A prefix reads as a state in a directory listing, which is what an +/// operator wants when they come to re-arm it. +pub const DISARMED: &str = "installed-"; + +/// What was done, for the log line and the response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Disarmed { + /// The machine documents that were claiming this machine, and where each went. + pub moved: Vec<(String, String)>, +} + +impl Disarmed { + pub fn describe(&self) -> String { + if self.moved.is_empty() { + "nothing was claiming it".to_string() + } else { + self.moved + .iter() + .map(|(from, to)| format!("{from}.ipxe -> {to}.ipxe")) + .collect::>() + .join(", ") + } + } +} + +/// Whether the body's `token` field is the one configured, compared in constant time. +/// +/// An ordinary `==` returns on the first differing byte, which hands the token over one +/// byte at a time to anyone who can time the responses. The admin API learned this +/// already; there is no reason for a second place to learn it again. +pub fn token_matches(body: &[u8], expected: &str) -> bool { + let Some(found) = token_in(body) else { + return false; + }; + constant_time_eq(found.as_bytes(), expected.as_bytes()) +} + +/// The `token` field, read from the JSON body as an untyped value. +/// +/// No derive, no struct: the same rule the answer path follows. Proxmox documents this +/// body's contents as liable to grow, and a type here would be an assumption about a +/// schema that is not ours. +fn token_in(body: &[u8]) -> Option { + let value: serde_json::Value = serde_json::from_slice(body).ok()?; + value.get("token")?.as_str().map(str::to_string) +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + // Length is not secret — a token's length leaks from the request size anyway — but + // the comparison still runs over the whole of the longer one so that an early return + // never depends on content. + let mut diff = (a.len() ^ b.len()) as u8; + let n = a.len().max(b.len()); + for i in 0..n { + let x = a.get(i).copied().unwrap_or(0); + let y = b.get(i).copied().unwrap_or(0); + diff |= x ^ y; + } + diff == 0 +} + +/// Which machine documents claim this machine for installation. +/// +/// The identity rule, and only the identity rule: a document whose normalized id appears +/// in what the machine sent. That is the same test selection uses, written out here +/// rather than borrowed, because borrowing `resolve` would bring groups, defaults and +/// format aliasing along with it — and every one of those is something this must not act +/// on. +fn claiming(answers: &Answers, facts: &Facts) -> io::Result> { + let haystack = facts.haystack(); + let mut out = Vec::new(); + for (id, format) in answers.machine_documents()? { + if format != "ipxe" { + continue; + } + let needle = normalize(id.as_bytes()); + // An empty needle would match everything, which is how one badly named document + // disarms a fleet. It cannot happen through the file store — a name that + // normalizes to nothing has no alphanumerics — but this is the wrong place to + // rely on that. + if !needle.is_empty() && haystack.contains(&needle) { + out.push((id, format)); + } + } + Ok(out) +} + +/// Drop the claim, having been told by the machine that it is installed. +/// +/// Returns what moved. **Nothing matching is a success, not an error**: the webhook is +/// allowed to arrive twice, and the second time there is simply nothing left to do. +pub fn disarm(answers: &Answers, store: &dyn StoreWrite, facts: &Facts) -> io::Result { + let mut moved = Vec::new(); + for (id, format) in claiming(answers, facts)? { + let body = read_machine(store, &id, &format)?; + let Some(body) = body else { continue }; + let to = format!("{DISARMED}{id}"); + // **Put before delete.** If the put fails the machine stays armed and the caller + // is told, which is the safe half of the failure: an install that happens twice + // is recoverable, an answer document that vanished is not. + store.put_machine(&to, &format, &body)?; + store.delete_machine(&id, &format)?; + moved.push((id, to)); + } + Ok(Disarmed { moved }) +} + +fn read_machine(store: &dyn StoreWrite, id: &str, format: &str) -> io::Result> { + Ok(store + .snapshot()? + .machines + .into_iter() + .find(|m| m.id == id && m.format == format) + .map(|m| m.body)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::file::FileStore; + use std::sync::Arc; + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "rescriptum-installed-{}-{name}-{:?}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("scratch"); + dir + } + + /// A webhook body the shape Proxmox documents: the interfaces, with their MACs. + fn webhook(mac: &str) -> Vec { + format!( + r#"{{"token":"s3cr3t","fqdn":"node01.example.com", + "network_interfaces":[{{"name":"eno1","mac":"{mac}"}}], + "disks":[{{"path":"/dev/sda","size":512110190592}}]}}"# + ) + .into_bytes() + } + + fn answers_for(dir: &std::path::Path) -> (Answers, Arc) { + let store = Arc::new(FileStore::new(dir)); + (Answers::new(store.clone()), store) + } + + #[test] + fn the_machine_that_reported_stops_being_claimed() { + let dir = scratch("basic"); + std::fs::write(dir.join("98-fa-9b-50-d8-10.ipxe"), "#!ipxe\nchain x\n").unwrap(); + std::fs::write(dir.join("98-fa-9b-50-d8-10.toml"), "[global]\n").unwrap(); + let (answers, store) = answers_for(&dir); + + let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); + let done = disarm(&answers, store.as_ref(), &facts).expect("disarm"); + assert_eq!( + done.moved, + vec![( + "98-fa-9b-50-d8-10".to_string(), + "installed-98-fa-9b-50-d8-10".to_string() + )] + ); + + // The claim is gone… + assert!(!dir.join("98-fa-9b-50-d8-10.ipxe").exists()); + // …the document is not, and re-arming is renaming it back. + assert!(dir.join("installed-98-fa-9b-50-d8-10.ipxe").exists()); + // **And the machine's own answer is untouched.** Deleting it would throw away the + // record of how this machine was built, and the installer is the thing that reads + // it — not the loader. + assert!(dir.join("98-fa-9b-50-d8-10.toml").exists()); + } + + #[test] + fn a_disarmed_document_no_longer_claims_the_machine() { + // The property the prefix exists for. Without it the rename is decoration and the + // machine reinstalls anyway, which is the whole failure being fixed. + let dir = scratch("nomatch"); + std::fs::write(dir.join("98-fa-9b-50-d8-10.ipxe"), "#!ipxe\n").unwrap(); + let (answers, store) = answers_for(&dir); + let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); + + disarm(&answers, store.as_ref(), &facts).expect("disarm"); + let (answers, store) = answers_for(&dir); + let again = disarm(&answers, store.as_ref(), &facts).expect("second"); + assert!( + again.moved.is_empty(), + "the moved document still matches: {:?}", + again.moved + ); + } + + #[test] + fn a_group_is_never_touched_however_it_matches() { + // **The one that would be a disaster.** A group claims a rack; one machine + // finishing its install must not disarm its neighbours. This is why the lookup + // never consults groups rather than filtering them out afterwards. + let dir = scratch("group"); + std::fs::create_dir_all(dir.join("groups")).unwrap(); + std::fs::write( + dir.join("groups/rack-a.ipxe"), + "# answer: members = 98:fa:9b:50:d8:10\n#!ipxe\n", + ) + .unwrap(); + std::fs::write(dir.join("default.ipxe"), "#!ipxe\n").unwrap(); + let (answers, store) = answers_for(&dir); + + let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); + + // **First prove the fixture bites.** Without this the assertion below passes for + // a group that was never loaded, which would report a guarantee that does not + // exist — the exact shape of test this project has been caught by before. + let claimed = answers + .resolve(&facts) + .expect("resolve") + .expect("the group must claim this machine"); + assert_eq!(claimed.group.as_deref(), Some("rack-a")); + + let done = disarm(&answers, store.as_ref(), &facts).expect("disarm"); + assert!(done.moved.is_empty(), "{:?}", done.moved); + assert!(dir.join("groups/rack-a.ipxe").exists()); + assert!(dir.join("default.ipxe").exists()); + } + + #[test] + fn a_machine_nothing_claims_is_not_an_error() { + // The webhook may arrive twice, and a machine may have been installed from the + // menu rather than from a claim. Neither is a failure. + let dir = scratch("none"); + std::fs::write(dir.join("aa-bb-cc-dd-ee-ff.ipxe"), "#!ipxe\n").unwrap(); + let (answers, store) = answers_for(&dir); + let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); + let done = disarm(&answers, store.as_ref(), &facts).expect("disarm"); + assert!(done.moved.is_empty()); + assert!(dir.join("aa-bb-cc-dd-ee-ff.ipxe").exists()); + } + + #[test] + fn the_token_is_read_from_the_body_and_compared_whole() { + // Proxmox puts it in the JSON body as a top-level `token`, not in a header — + // unlike the answer token, which is a bearer. Fetched from the wiki rather than + // remembered, because getting this wrong is an endpoint nothing can authenticate. + assert!(token_matches(&webhook("98:fa:9b:50:d8:10"), "s3cr3t")); + assert!(!token_matches(&webhook("98:fa:9b:50:d8:10"), "s3cr3")); + assert!(!token_matches(&webhook("98:fa:9b:50:d8:10"), "s3cr3t ")); + assert!(!token_matches(&webhook("98:fa:9b:50:d8:10"), "")); + // Not JSON at all, and a body with no token: both are a refusal, never a pass. + assert!(!token_matches(b"not json", "s3cr3t")); + assert!(!token_matches(br#"{"fqdn":"x"}"#, "s3cr3t")); + } + + #[test] + fn constant_time_comparison_agrees_with_the_ordinary_one() { + for (a, b) in [ + ("", ""), + ("a", "a"), + ("a", "b"), + ("", "a"), + ("a", ""), + ("abcdef", "abcdeg"), + ("abcdef", "abcdef"), + ] { + assert_eq!( + constant_time_eq(a.as_bytes(), b.as_bytes()), + a == b, + "{a:?} vs {b:?}" + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 19c3173..baae7c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,8 @@ pub mod config; pub mod envfile; pub mod facts; pub mod format; +/// A machine reporting that it finished installing, and the claim being dropped. +pub mod installed; pub mod log; pub mod merge; pub mod select; diff --git a/src/main.rs b/src/main.rs index f564851..e3e6056 100644 --- a/src/main.rs +++ b/src/main.rs @@ -387,10 +387,11 @@ async fn serve(cfg: Arc) -> ExitCode { let cfg = Arc::clone(&cfg); let answers = Arc::clone(&answers); + let store = Arc::clone(&store); let capture = Arc::clone(&capture); tokio::spawn(async move { let _permit = permit; // released when the connection ends - connection(stream, peer.to_string(), cfg, answers, capture).await; + connection(stream, peer.to_string(), cfg, answers, store, capture).await; }); } } @@ -457,6 +458,7 @@ async fn connection( peer: String, cfg: Arc, answers: Arc, + store: Arc, capture: Arc>, ) { let timeout = cfg.timeout; @@ -465,9 +467,10 @@ async fn connection( let service = service_fn(move |req| { let cfg = Arc::clone(&cfg); let answers = Arc::clone(&answers); + let store = Arc::clone(&store); let capture = Arc::clone(&capture); let peer = peer.clone(); - async move { Ok::<_, Infallible>(handle(req, cfg, answers, capture, peer).await) } + async move { Ok::<_, Infallible>(handle(req, cfg, answers, store, capture, peer).await) } }); // `header_read_timeout` is the slowloris guard: a client that opens a socket and @@ -494,10 +497,75 @@ async fn connection( } } +/// `POST /installed` — a machine saying it finished, and its claim being dropped. +/// +/// Answers `200` whether or not anything was claiming it: the webhook may arrive twice, +/// and a machine installed from the menu was never claimed at all. Neither is a failure, +/// and a `4xx` here would read to an operator as "the thing did not work". +async fn installed( + req: Request, + expected: String, + answers: Arc, + store: Arc, + peer: String, +) -> Response { + // Read the body before answering, always: closing on a peer that is still writing + // earns a connection reset instead of the response. + let body = match Limited::new(req.into_body(), MAX_BODY).collect().await { + Ok(collected) => collected.to_bytes(), + Err(_) => { + log::request(&peer, 400, "POST /installed 400 body"); + return text(StatusCode::BAD_REQUEST, "400 Bad Request\n"); + } + }; + + if !rescriptum::installed::token_matches(&body, &expected) { + // Logged and never rate-limited, for the reason the answer token is not: a rack + // sits behind one address, and shutting it out would turn a bad token into a + // fleet that reinstalls itself forever. + log::request(&peer, 401, "POST /installed 401 bad or missing token"); + return text(StatusCode::UNAUTHORIZED, "401 Unauthorized\n"); + } + + let facts = Facts::new(None, &body); + // Blocking: the file store reads and renames. Doing that on an async worker stalls + // every other connection that thread is driving. + let result = tokio::task::spawn_blocking(move || { + rescriptum::installed::disarm(&answers, store.as_ref(), &facts) + }) + .await; + + match result { + Ok(Ok(done)) => { + let said = done.describe(); + log::request(&peer, 200, &format!("POST /installed 200 {said}")); + text(StatusCode::OK, format!("{said}\n")) + } + // **Loud, because the consequence is otherwise silent.** A disarm that failed + // leaves the machine armed, so it installs again on its next boot — and this line + // is the only place that could be noticed. + Ok(Err(e)) => { + log::request(&peer, 500, &format!("POST /installed 500 still armed: {e}")); + text( + StatusCode::INTERNAL_SERVER_ERROR, + "500 Internal Server Error\n", + ) + } + Err(e) => { + log::request(&peer, 500, &format!("POST /installed 500 {e}")); + text( + StatusCode::INTERNAL_SERVER_ERROR, + "500 Internal Server Error\n", + ) + } + } +} + async fn handle( req: Request, cfg: Arc, answers: Arc, + store: Arc, capture: Arc>, peer: String, ) -> Response { @@ -514,6 +582,22 @@ async fn handle( return text(StatusCode::OK, "OK\n"); } + // **The install-finished webhook, checked before the bearer guard below on purpose.** + // Proxmox authenticates this callback with a token it puts *in the JSON body*, not in + // an Authorization header — a different credential, from a different caller, for a + // different purpose. Running it through the answer token's guard would reject every + // webhook the moment an operator set an answer token. + // + // The path is reserved **only when the feature is configured**, so a deployment that + // never sets the token keeps the "POST on any path is an answer request" contract + // whole. That contract is why a URL can be baked into an ISO. + if method == Method::POST + && path == "/installed" + && let Some(expected) = cfg.installed_token.clone() + { + return installed(req, expected, answers, store, peer).await; + } + // An installer that was given a token must present it. Proxmox does when its ISO // was prepared with `--answer-auth-token`; nothing else can, which is why this is // off unless configured. What it guards is the root password hash and the SSH keys diff --git a/tests/integration.rs b/tests/integration.rs index 0f0b8e2..53bee84 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -124,6 +124,19 @@ impl Server { out } + /// A POST to a specific path, which is what the install-finished webhook needs: the + /// answer endpoint takes any path, so only a test that names one can tell a reserved + /// route from an ordinary answer request. + fn post_to(&self, path: &str, body: &str) -> String { + self.raw( + format!( + "POST {path} HTTP/1.1\r\nHost: nas\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + } + fn post(&self, body: &str) -> String { self.raw( format!( @@ -1150,3 +1163,91 @@ fn a_request_that_never_reached_a_status_counts_as_a_problem() { let log = s.startup_log(); assert!(log.contains("connection timed out"), "{log}"); } + +/// **A machine reporting that it installed, and its claim being dropped.** +/// +/// The loop this closes: a machine claimed by an `.ipxe` answer installs, reboots, is +/// claimed again, and installs again — wiping its disk every time. Proxmox's +/// `[post-installation-webhook]` fires after a successful install and before that reboot, +/// with the interfaces in its body, so the machine is the one that knows. +/// +/// Everything below is against the real binary over a real socket, because the parts that +/// can be wrong here are wiring: which guard runs first, whether the body is read before +/// answering, and whether the endpoint exists at all. +#[test] +fn a_machine_can_report_that_it_is_installed_and_stop_being_claimed() { + let s = Server::start_env( + &[ + ("98-fa-9b-50-d8-10.ipxe", "#!ipxe\nchain installer\n"), + ("98-fa-9b-50-d8-10.toml", "[global]\nkeyboard = \"fr\"\n"), + ], + "5", + &[ + ("RESCRIPTUM_INSTALLED_TOKEN", "nas:s3cr3t"), + // Set deliberately: the webhook's credential is in the body, so the bearer + // guard must not be what answers it. Without the route running first, every + // webhook would 401 the moment somebody protected their answers. + ("RESCRIPTUM_ANSWER_TOKEN", "nas:answer-token-long-enough"), + ], + ); + + let body = r#"{"token":"nas:s3cr3t","fqdn":"node01.z29k.fr", + "network_interfaces":[{"name":"eno1","mac":"98:fa:9b:50:d8:10"}]}"#; + + // A wrong token is refused, and refused *without* disarming anything. + let bad = body.replace("s3cr3t", "s3cr3x"); + let response = s.post_to("/installed", &bad); + assert!(response.starts_with("HTTP/1.1 401"), "{response}"); + + let response = s.post_to("/installed", body); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!( + response.contains("installed-98-fa-9b-50-d8-10"), + "{response}" + ); + + // The claim is gone… + assert!(!s.dir().join("98-fa-9b-50-d8-10.ipxe").exists()); + // …the document is not, so re-arming is a rename… + assert!(s.dir().join("installed-98-fa-9b-50-d8-10.ipxe").exists()); + // …and the machine's own answer, which the installer reads, is untouched. + assert!(s.dir().join("98-fa-9b-50-d8-10.toml").exists()); + + // Twice is not an error: the webhook may be retried, and a machine installed from the + // menu was never claimed at all. + let response = s.post_to("/installed", body); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(response.contains("nothing was claiming it"), "{response}"); + + // And the server still answers, which is the assertion that matters in this file. + // With its own credential — the bearer the *installer* presents, which is a different + // one from the webhook's and is the whole reason these two guards are separate. + let payload = r#"{"mac":"98:fa:9b:50:d8:10"}"#; + let answer = s.raw( + format!( + "POST /proxmox/answer HTTP/1.1\r\nHost: nas\r\n\ + Authorization: Bearer nas:answer-token-long-enough\r\n\ + Content-Length: {}\r\n\r\n{payload}", + payload.len() + ) + .as_bytes(), + ); + assert!( + answer.contains("keyboard"), + "the answer endpoint stopped working: {answer}" + ); +} + +/// Without the token there is no endpoint — not an open one, **absent**. So `/installed` +/// is an ordinary answer request like any other path, which is what keeps "POST on any +/// path is an answer request" true for everybody who does not use this. +#[test] +fn without_a_token_installed_is_just_another_answer_path() { + let s = Server::start(&[("default.toml", "[global]\nkeyboard = \"fr\"\n")]); + let response = s.post_to("/installed", r#"{"token":"anything"}"#); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!( + response.contains("keyboard"), + "it answered as an endpoint rather than serving the default: {response}" + ); +} From e83085bb2fe7df7cefb9121d84a11e23848f6c47 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 17:37:23 +0200 Subject: [PATCH 43/59] feat(installed): every family can report in, not only Proxmox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked, correctly: this was built against Proxmox's webhook — what about the others. Two things were tangled and needed separating. **The claim is not Proxmox-specific.** It is an `.ipxe` document, which is about the loader rather than the operating system, so every family is claimed the same way and needs the same disarm. **The report back is where they differ.** Proxmox has a webhook; nobody else does. Debian has `late_command`, Ubuntu `late-commands`, RHEL and its rebuilds `%post`, SUSE a chroot script — all of them can run one `curl`, and none of them will compose Proxmox's JSON body. The endpoint made that harder than it had to be, so it now also takes the identity from the query string and the credential from an ordinary bearer header: curl -fsS -X POST -H "Authorization: Bearer nas:s3cr3t" \ "http://server:8000/installed?mac=$(cat /sys/class/net/*/address|head -1)" No body at all. Same secret either way, same constant-time comparison — the body form exists because it is what Proxmox sends and Proxmox cannot be told to send a header. Watched red by reverting to a body-only identity, which leaves the kickstart path disarming nothing. The guide names where that line goes per family, and says plainly that the example's `head -1` is right on a one-NIC machine and wrong on a four-NIC one — the wrong MAC disarms the wrong machine. 562 tests. --- CLAUDE.md | 6 +++- docs/guide/operations/netboot.fr.md | 32 +++++++++++++++++++ docs/guide/operations/netboot.md | 32 +++++++++++++++++++ src/main.rs | 20 ++++++++++-- tests/integration.rs | 49 +++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 97eb36a..8bb02a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,11 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit machine finishing must not disarm a rack), format `ipxe` only (the `.toml` is the record of how it was built), and moved under an `installed-` prefix rather than deleted. The token is Proxmox's, and it arrives **in the JSON body**, not as a bearer — so the route - runs before the answer token's guard, which would otherwise reject every webhook. + runs before the answer token's guard, which would otherwise reject every webhook. It + also takes a bearer header and the identity from the query, because **Proxmox is the + only family with a webhook**: every other one reports from a `%post`, a + `late_command` or a chroot script, where one `curl` is writable and composing + Proxmox's JSON is not. - `src/config.rs` — environment configuration. `Config::from_lookup` takes a lookup closure so tests never touch the process environment. - `src/envfile.rs` — the optional file of defaults `RESCRIPTUM_ENV_FILE` names, and the diff --git a/docs/guide/operations/netboot.fr.md b/docs/guide/operations/netboot.fr.md index 0e342a4..be00602 100644 --- a/docs/guide/operations/netboot.fr.md +++ b/docs/guide/operations/netboot.fr.md @@ -288,6 +288,38 @@ installée depuis le menu n'a jamais été revendiquée. Un désarmement qui *é journalisé en `still armed`, parce que sa conséquence est autrement silencieuse : la machine se réinstalle au démarrage suivant et rien d'autre ne le dirait. +### Toutes les autres familles rapportent aussi + +Proxmox est le seul à avoir son propre webhook. **La revendication n'est pas propre à +Proxmox** — c'est un document `.ipxe`, qui concerne le chargeur et non le système +d'exploitation — donc toutes les familles ont besoin du même désarmement, et toutes ont un +endroit où lancer une ligne à la fin de leur installation : + +```bash +curl -fsS -X POST -H "Authorization: Bearer nas:s3cr3t" \ + "http://192.0.2.10:8000/installed?mac=$(cat /sys/class/net/*/address | head -1)" +``` + +Pas de corps, pas de JSON : la query dit quelle machine, l'en-tête dit qu'elle en a le +droit. Où mettre cette ligne : + +| Famille | Où | +|---|---| +| Proxmox | `[post-installation-webhook]` — natif, rien à écrire | +| Debian | `d-i preseed/late_command string in-target sh -c '…'` | +| Ubuntu | `late-commands:` dans le document autoinstall | +| RHEL, AlmaLinux, Rocky | la section `%post` du kickstart | +| SUSE | `` du profil AutoYaST | + +**Prenez la MAC de l'interface qui a démarré, pas la première par ordre alphabétique.** +L'exemple ci-dessus prend la première entrée de `/sys/class/net`, ce qui va sur une machine +à une carte et se trompe sur une machine à quatre — et une mauvaise MAC désarme la mauvaise +machine, ou personne. Sur une machine à plusieurs cartes, nommez l'interface. + +L'endpoint accepte les deux formes du justificatif : l'`auth-token` de Proxmox arrive dans +le corps JSON parce que c'est ce que Proxmox envoie, et un en-tête bearer parce que c'est +ce qu'envoie un script shell. Même secret, même comparaison en temps constant. + ## Comment iPXE finit par parler à *nous* La question qu'on ne s'attend pas à devoir trancher. Quel que soit le livreur du diff --git a/docs/guide/operations/netboot.md b/docs/guide/operations/netboot.md index 141660f..8d25507 100644 --- a/docs/guide/operations/netboot.md +++ b/docs/guide/operations/netboot.md @@ -272,6 +272,38 @@ the menu was never claimed at all. A disarm that *fails* is logged as `still arm because the consequence is otherwise silent: the machine reinstalls on its next boot and nothing else would say so. +### Every other family reports back too + +Proxmox is the only one with a webhook of its own. **The claim is not Proxmox-specific** — +it is an `.ipxe` document, which is about the loader rather than the operating system — so +every family needs the same disarm, and every family has somewhere to run one line at the +end of its install: + +```bash +curl -fsS -X POST -H "Authorization: Bearer nas:s3cr3t" \ + "http://192.0.2.10:8000/installed?mac=$(cat /sys/class/net/*/address | head -1)" +``` + +No body, no JSON: the query says which machine, the header says it is allowed. Where that +line goes: + +| Family | Where | +|---|---| +| Proxmox | `[post-installation-webhook]` — native, nothing to write | +| Debian | `d-i preseed/late_command string in-target sh -c '…'` | +| Ubuntu | `late-commands:` in the autoinstall document | +| RHEL, AlmaLinux, Rocky | the `%post` section of the kickstart | +| SUSE | `` in the AutoYaST profile | + +**Pick the booting interface's MAC, not the first one alphabetically.** The example above +takes whichever `/sys/class/net` entry comes first, which is fine on a machine with one +NIC and wrong on a machine with four — and the wrong MAC disarms the wrong machine, or +nothing at all. On a machine with several, name the interface. + +The endpoint takes the credential either way — Proxmox's `auth-token` arrives inside the +JSON body because that is what Proxmox sends, and a bearer header because that is what a +shell script sends. Same secret, same constant-time comparison. + ## How iPXE ends up talking to *us* The question nobody expects to have to answer. Whatever delivers the loader: diff --git a/src/main.rs b/src/main.rs index e3e6056..092c4f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -505,6 +505,8 @@ async fn connection( async fn installed( req: Request, expected: String, + bearer: bool, + query: Option, answers: Arc, store: Arc, peer: String, @@ -519,7 +521,11 @@ async fn installed( } }; - if !rescriptum::installed::token_matches(&body, &expected) { + // **Either credential, because the callers differ.** Proxmox puts its `auth-token` in + // the JSON body and cannot be told to send a header; a `curl` in a `%post` sends a + // header and would have to be talked into composing JSON. Both are the same secret, + // and both are compared without an early return. + if !bearer && !rescriptum::installed::token_matches(&body, &expected) { // Logged and never rate-limited, for the reason the answer token is not: a rack // sits behind one address, and shutting it out would turn a bad token into a // fleet that reinstalls itself forever. @@ -527,7 +533,7 @@ async fn installed( return text(StatusCode::UNAUTHORIZED, "401 Unauthorized\n"); } - let facts = Facts::new(None, &body); + let facts = Facts::from_request(None, query.as_deref(), &body); // Blocking: the file store reads and renames. Doing that on an async worker stalls // every other connection that thread is driving. let result = tokio::task::spawn_blocking(move || { @@ -595,7 +601,15 @@ async fn handle( && path == "/installed" && let Some(expected) = cfg.installed_token.clone() { - return installed(req, expected, answers, store, peer).await; + // The query goes in as identity too, not only the body. Proxmox puts the machine's + // interfaces in its webhook body and needs nothing else; **every other family + // reports back from a shell script** — a kickstart `%post`, a preseed + // `late_command`, an autoinstall `late-commands`, an AutoYaST chroot script — and + // there `?mac=…` is a line somebody can write, where composing the same JSON is a + // line they will get wrong. + let query = req.uri().query().map(str::to_string); + let bearer = bearer_matches(&req, &expected); + return installed(req, expected, bearer, query, answers, store, peer).await; } // An installer that was given a token must present it. Proxmox does when its ISO diff --git a/tests/integration.rs b/tests/integration.rs index 53bee84..c3c78ea 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1251,3 +1251,52 @@ fn without_a_token_installed_is_just_another_answer_path() { "it answered as an endpoint rather than serving the default: {response}" ); } + +/// **Every other family reports back from a shell script, not from a webhook.** A +/// kickstart `%post`, a preseed `late_command`, an autoinstall `late-commands`, an +/// AutoYaST chroot script — all of them can run one `curl`, and none of them will compose +/// Proxmox's JSON body. So the endpoint takes the machine's identity from the query string +/// and its credential from an ordinary bearer header, which is what that one line can send. +#[test] +fn a_kickstart_or_a_preseed_can_report_installed_with_one_curl() { + let s = Server::start_env( + &[ + ("98-fa-9b-50-d8-10.ipxe", "#!ipxe\nchain installer\n"), + ("98-fa-9b-50-d8-10.ks", "%post\n"), + ], + "5", + &[("RESCRIPTUM_INSTALLED_TOKEN", "nas:s3cr3t")], + ); + + // Exactly what a `%post` writes: + // curl -X POST -H "Authorization: Bearer nas:s3cr3t" \ + // "http://server:8000/installed?mac=$(cat /sys/class/net/eth0/address)" + // No body at all — there is nothing it needs to say beyond who it is. + let response = s.raw( + concat!( + "POST /installed?mac=98:fa:9b:50:d8:10 HTTP/1.1\r\nHost: nas\r\n", + "Authorization: Bearer nas:s3cr3t\r\n", + "Content-Length: 0\r\n\r\n", + ) + .as_bytes(), + ); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!( + response.contains("installed-98-fa-9b-50-d8-10"), + "{response}" + ); + assert!(!s.dir().join("98-fa-9b-50-d8-10.ipxe").exists()); + // The kickstart itself is not an `.ipxe` and is left exactly where it was. + assert!(s.dir().join("98-fa-9b-50-d8-10.ks").exists()); + + // And a wrong bearer is refused, with nothing left to disarm anyway. + let response = s.raw( + concat!( + "POST /installed?mac=aa:bb:cc:dd:ee:ff HTTP/1.1\r\nHost: nas\r\n", + "Authorization: Bearer nas:wrong\r\n", + "Content-Length: 0\r\n\r\n", + ) + .as_bytes(), + ); + assert!(response.starts_with("HTTP/1.1 401"), "{response}"); +} From 0d878db87cf85346e6a38aba0f5d817d22cf8abf Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 18:01:00 +0200 Subject: [PATCH 44/59] feat(dsm): generate the install-finished token, so nobody has to invent one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the maintainer, setting it up by hand: the token should be generated at install if it is not set. Right — the webhook only works when the string in a machine's answer and the one the server checks are identical, and two blank fields that must agree is a thing people get wrong once and then debug as "the machine reinstalls itself". Generated here, the server half is already correct and the answer document only has to copy it. `od -An -tx1 -N16 /dev/urandom` rather than `openssl rand` or base64: all three exist on the 7.2.2 machine, but od is POSIX and the most likely to be on the oldest thing this package may install on. 128 bits of hex, which also survives being pasted into a TOML string without quoting questions. Two guards worth naming. **An empty token would be an endpoint anybody can call**, so if the generator produces nothing the setting is written commented out and the feature stays off — nothing beats something there. And **a token already in the file is never replaced**: the top-up only adds keys the file has never heard of, so an upgrade cannot silently orphan every answer document carrying the old one. Four checks in lifecycle-test.sh (80 → 84): generated, long enough, hex, and an existing secret left alone. Watched red by stubbing the generator to echo nothing — which fails on emptiness and on length, exactly where it should. The settings panel gains the label and the help in both languages, so it is not a bare key next to a value somebody is afraid to touch. --- packaging/dsm/lifecycle-test.sh | 13 +++++ packaging/dsm/payload/ui/texts/enu/strings | 2 + packaging/dsm/payload/ui/texts/fre/strings | 2 + packaging/dsm/scripts/postinst | 55 ++++++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh index 4b2cf74..d5e6f13 100755 --- a/packaging/dsm/lifecycle-test.sh +++ b/packaging/dsm/lifecycle-test.sh @@ -128,6 +128,15 @@ grep -q "^RESCRIPTUM_BOOT_DIR=$SHARE/boot\$" "$ENV_FILE" && ok "and the boot fol # product's first principle for a packaging constraint; port 69 is reachable on DSM with # one `setcap`, measured on a 7.2.2 machine. Left unset, the default is 0.0.0.0:69 — # which is what the generated DHCP snippet and every loader we ship expect. +# **A secret nobody has to invent.** The webhook only works when the token in a machine's +# answer and the one the server checks are the same string; two blank fields that must +# agree is a thing people get wrong once and then debug as "the machine reinstalls +# itself". Generated here so the server half is already right. +tok=$(value_of RESCRIPTUM_INSTALLED_TOKEN) +[ -n "$tok" ] && ok "an install-finished token was generated" || bad "RESCRIPTUM_INSTALLED_TOKEN is empty — the webhook would need one invented by hand" +[ "${#tok}" -ge 32 ] && ok "and it is long enough to be worth having" || bad "the generated token is only ${#tok} characters" +case "$tok" in *[!0-9a-f]*) bad "the token is not the hex it should be: $tok" ;; *) ok "and is hex, so it survives being pasted into TOML" ;; esac + grep -q "^RESCRIPTUM_TFTP_ADDR=" "$ENV_FILE" && bad "RESCRIPTUM_TFTP_ADDR is live in the file — the default 0.0.0.0:69 is what the snippet and the loaders expect" || ok "TFTP is left at its default, so the package is the TFTP server" grep -q "setcap cap_net_bind_service" "$ENV_FILE" && ok "and the file says what one root command makes it bind" || bad "nothing in the file explains how port 69 gets bound" grep -q "Task Scheduler" "$ENV_FILE" && ok "and how to survive an upgrade, which drops the capability" || bad "nothing says the capability does not survive an upgrade" @@ -153,6 +162,10 @@ before=$(cat "$ENV_FILE") SYNOPKG_PKG_STATUS=UPGRADE SYNOPKG_PKGVER=9.9.9-9 sh "$ROOT/scripts/postinst" >/dev/null 2>&1 grep -q "^RESCRIPTUM_BOOT_DIR=$SHARE/boot\$" "$ENV_FILE" && ok "a setting the file had never heard of was added" || bad "RESCRIPTUM_BOOT_DIR never reached the upgraded file — the feature would be invisible" +# **And a token already in the file is never replaced.** Regenerating it on upgrade would +# silently orphan every answer document carrying the old one, and the symptom would be a +# fleet quietly reinstalling itself. +grep -q "^RESCRIPTUM_ANSWER_TOKEN=keep-me-untouched-0123456789\$" "$ENV_FILE" && ok "and a secret already present is left alone" || bad "the top-up replaced a token that was already there" grep -q "^RESCRIPTUM_ANSWER_TOKEN=keep-me-untouched-0123456789\$" "$ENV_FILE" && ok "and everything already there is untouched" || bad "the top-up changed a setting that was already present" # The safety property: commenting a key out is how an operator says no, and it has to hold. [ "$(grep -c "^RESCRIPTUM_MEDIA_DIR=" "$ENV_FILE")" = 0 ] && ok "a commented-out setting is respected rather than re-enabled" || bad "the top-up re-enabled a setting the operator had commented out" diff --git a/packaging/dsm/payload/ui/texts/enu/strings b/packaging/dsm/payload/ui/texts/enu/strings index 8d5b17c..a74d640 100644 --- a/packaging/dsm/payload/ui/texts/enu/strings +++ b/packaging/dsm/payload/ui/texts/enu/strings @@ -71,6 +71,7 @@ RESCRIPTUM_MEDIA_ADDR = "Media listen address" RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Transfer deadline (seconds)" RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Simultaneous downloads" RESCRIPTUM_BOOT_ALLOW = "Allowed client networks" +RESCRIPTUM_INSTALLED_TOKEN = "Install-finished token" RESCRIPTUM_BOOT_DIR = "Loaders folder" RESCRIPTUM_TFTP_ADDR = "TFTP listen address" RESCRIPTUM_BOOT_TIMEOUT_SECS = "Menu timeout (seconds)" @@ -99,6 +100,7 @@ RESCRIPTUM_MEDIA_ADDR = "Where machines fetch kernels, initrds and images. Its o RESCRIPTUM_MEDIA_TIMEOUT_SECS = "How long one image transfer may take. Deliberately not the answer endpoint's ten seconds." RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Downloads at once. Low on purpose: each holds its slot for minutes, and this NAS has one disk." RESCRIPTUM_BOOT_ALLOW = "Client networks allowed to fetch boot media, as CIDRs. Empty means anyone who can reach the port." +RESCRIPTUM_INSTALLED_TOKEN = "Generated when this file was written. Put the same string in a machine's answer as the post-installation-webhook auth-token, and the machine stops being claimed once it reports a successful install — instead of reinstalling itself on every reboot. Empty means no such endpoint at all." RESCRIPTUM_BOOT_DIR = "Where the loaders live. This package ships them, so the folder arrives filled and an upgrade refreshes them. Served over TFTP and over HTTP at /boot/, which is what UEFI HTTP Boot fetches. Point this elsewhere to manage loaders yourself." RESCRIPTUM_TFTP_ADDR = "Empty means 0.0.0.0:69, which is what every loader and every generated DHCP snippet expects. Port 69 is privileged, so binding it takes one root command once: setcap cap_net_bind_service=+ep on the binary. An upgrade drops it — a boot-up task in Task Scheduler makes it durable. Without it the server warns, keeps answering and keeps serving media, and only TFTP is down. Set 'off' if another daemon on this NAS hands loaders out instead." RESCRIPTUM_BOOT_TIMEOUT_SECS = "How long the boot menu waits before a machine falls through to its own disk." diff --git a/packaging/dsm/payload/ui/texts/fre/strings b/packaging/dsm/payload/ui/texts/fre/strings index 6336842..5ee369c 100644 --- a/packaging/dsm/payload/ui/texts/fre/strings +++ b/packaging/dsm/payload/ui/texts/fre/strings @@ -65,6 +65,7 @@ RESCRIPTUM_MEDIA_ADDR = "Adresse d'écoute des médias" RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Échéance de transfert (secondes)" RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Téléchargements simultanés" RESCRIPTUM_BOOT_ALLOW = "Réseaux clients autorisés" +RESCRIPTUM_INSTALLED_TOKEN = "Jeton de fin d'installation" RESCRIPTUM_BOOT_DIR = "Dossier des chargeurs" RESCRIPTUM_TFTP_ADDR = "Adresse d'écoute TFTP" RESCRIPTUM_BOOT_TIMEOUT_SECS = "Délai du menu (secondes)" @@ -93,6 +94,7 @@ RESCRIPTUM_MEDIA_ADDR = "Là où les machines récupèrent noyaux, initrds et im RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Durée maximale d'un transfert d'image. Volontairement pas les dix secondes du point de réponse." RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Téléchargements à la fois. Bas exprès : chacun retient sa place des minutes durant, et ce NAS a un disque." RESCRIPTUM_BOOT_ALLOW = "Réseaux clients autorisés à récupérer les médias, en CIDR. Vide, quiconque atteint le port." +RESCRIPTUM_INSTALLED_TOKEN = "Généré à l'écriture de ce fichier. Mettez la même chaîne dans la réponse d'une machine comme auth-token du post-installation-webhook, et la machine cesse d'être revendiquée dès qu'elle signale une installation réussie — au lieu de se réinstaller à chaque redémarrage. Vide, l'endpoint n'existe pas du tout." RESCRIPTUM_BOOT_DIR = "Où vivent les chargeurs. Ce paquet les fournit : le dossier arrive rempli, et une mise à jour les rafraîchit. Servis en TFTP et en HTTP sur /boot/, ce que récupère l'amorçage HTTP UEFI. Pointez ailleurs pour gérer les chargeurs vous-même." RESCRIPTUM_TFTP_ADDR = "Vide signifie 0.0.0.0:69, ce qu'attendent tous les chargeurs et tous les extraits DHCP générés. Le port 69 est privilégié : l'ouvrir demande une commande root, une fois — setcap cap_net_bind_service=+ep sur le binaire. Une mise à jour la perd ; une tâche au démarrage dans le Planificateur de tâches la rend durable. Sans elle le serveur avertit, continue de répondre et de servir les images, et seul le TFTP est coupé. Mettez « off » si un autre service de ce NAS livre les chargeurs à sa place." RESCRIPTUM_BOOT_TIMEOUT_SECS = "Combien de temps le menu de démarrage attend avant qu'une machine retombe sur son propre disque." diff --git a/packaging/dsm/scripts/postinst b/packaging/dsm/scripts/postinst index e55ab0d..c0e5dbe 100755 --- a/packaging/dsm/scripts/postinst +++ b/packaging/dsm/scripts/postinst @@ -50,6 +50,24 @@ TFTP_PORT=69 say() { echo "$PKG: $*"; } +# **A secret nobody has to invent.** The install-finished webhook only works when the +# token in the answer document and the one this server checks are the same string — and +# two places that must agree, both left blank, is a configuration people get wrong once +# and then debug as "the machine reinstalls itself". Generating one here means the server +# half is already right, and the answer half is a copy rather than a decision. +# +# `od` rather than `openssl rand` or `base64 /dev/urandom`: all three exist on DSM 7.2.2, +# but od is POSIX and is the one most likely to exist on the oldest thing this package is +# allowed to install on. 128 bits of hex, which is not guessable and survives being +# pasted into a TOML string without quoting questions. +# +# Empty output is possible in principle (no /dev/urandom, a stripped od), and an empty +# token would be a webhook endpoint anybody could call. So the caller checks, and writes +# the setting commented out rather than live if there is nothing to write. +generate_token() { + od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n' +} + # Wizard values arrive as environment variables named after the component keys — and every # one of them has to be read as "possibly absent, with a default". silent_install exists, # and a reinstall may present no wizard at all; a package that only works when someone @@ -74,6 +92,20 @@ fi # The file, in full, for both the live copy and the example. Every path is explicit: # RESCRIPTUM_ANSWERS_DIR defaults to /srv/answers and RESCRIPTUM_DB_PATH to # /srv/answers.db, neither of which exists on DSM nor could be created by this user. +# One per run of this script, so the example file and the live file say the same thing. +# It only ever *reaches* the live file when that file is being created, or when the +# top-up finds the key absent — an existing token is never replaced. +INSTALLED_TOKEN=$(generate_token) +# **An empty token would be an endpoint anybody can call**, so nothing is better than +# something here: the setting goes in commented out, the feature stays off, and the +# comment above it still explains what to do. Only reachable if /dev/urandom or od is +# missing, which is not a thing this package should decide to ship a hole over. +if [ -n "$INSTALLED_TOKEN" ]; then + INSTALLED_LINE="RESCRIPTUM_INSTALLED_TOKEN=$INSTALLED_TOKEN" +else + INSTALLED_LINE="# RESCRIPTUM_INSTALLED_TOKEN= # could not generate one here; pick your own" +fi + env_body() { cat <.ipxe' answer boots the installer every time it starts — +# including the reboot right after it finished installing, which wipes the disk and does +# it again. Forever. +# +# The machine is what knows it is done. Proxmox calls a webhook after a successful +# install and *before* that reboot; put this in the machine's answer document: +# +# [post-installation-webhook] +# url = "http://:$port/installed" +# auth-token = "$INSTALLED_TOKEN" +# +# and rescriptum renames the claim out of the way, so the machine boots its own disk from +# then on. Re-arming it later is renaming that file back. Every other family reports the +# same thing from its own post-install script with one curl — see the guide. +# +# **The token below was generated when this file was written**, so the server half is +# already right and the answer document only has to copy it. Both must be the same string. +# Change it if you like; change it in both places. +$INSTALLED_LINE BODY } From 957129a07fe301e54ae518e8316f9f1682aa31f1 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 18:19:12 +0200 Subject: [PATCH 45/59] fix(tftp): report what a transfer did, not what it was about to do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A machine on the maintainer's network fetched a loader and did nothing. The log said: tftp: ipxe-x86_64.efi 1164800 bytes blksize=1468 which reads as success and was not: **that line was written before the first byte went out.** Every failure path after it returned silently, so a transfer that stalled at block one and a transfer that completed produced identical logs — and the one diagnostic a boot server has said the opposite of the truth. Reported at the end now, with the outcome: `sent N bytes`, or `FAILED after N of M bytes` at status 500 so `RESCRIPTUM_LOG=problems` keeps it, naming the knob that fixes the commonest cause. That knob is new, and the reason it exists is arithmetic: **1468 fills a 1500-byte path exactly** — 1468 payload, 4 TFTP, 8 UDP, 20 IP. It is what iPXE asks for and what leaves nothing over. One VLAN tag makes the frame 1504, and a PXE ROM meeting that generally stops with no message at all. `RESCRIPTUM_TFTP_BLKSIZE` caps what we agree to; 1400 leaves room for a tag and most tunnels, 512 always works. The default stays at 1468 rather than being lowered on a hunch: dropping it for everybody costs every deployment throughput to fix a minority's network, and the failure it causes is now loud enough to find. Whether it should move is for a measurement, which this change is what makes possible. Two tests, both watched red: an abandoned transfer that leaves no trace when the line goes back to the top, and a cap that a client's larger request walks straight past. 564 tests. --- CLAUDE.md | 9 ++++ docs/guide/reference/configuration.fr.md | 1 + docs/guide/reference/configuration.md | 1 + src/boot/tftp.rs | 59 +++++++++++++++------- src/config.rs | 35 ++++++++++++- src/envfile.rs | 3 +- tests/tftp.rs | 63 ++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8bb02a2..3503727 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -532,6 +532,15 @@ could not check. Note it needs `Resolution::format_name` (the extension), not is enough. A per-peer transfer cap is therefore a fairness bound, not a hostility threshold; counting malformed packets against it locks a machine out of the server it is retrying to reach. +- **Logging an intention is not logging an outcome.** The TFTP transfer line was written + before the first byte went out, so a stalled transfer and a completed one looked + identical — a machine on a real network fetched a loader, nothing happened, and the log + said success. It is reported at the end now, `sent` or `FAILED after N of M`, with 500 + so `RESCRIPTUM_LOG=problems` keeps it. +- **`blksize=1468` fills a 1500-byte path exactly** — 1468 payload, 4 TFTP, 8 UDP, 20 IP — + which is what iPXE asks for and what leaves no room at all. One VLAN tag makes the frame + 1504, and a PXE ROM meeting that usually stops without a message. `RESCRIPTUM_TFTP_BLKSIZE` + caps it; 1400 covers a tag and most tunnels, 512 always works. - **A TFTP transfer ends on a *short* block, and "short" includes empty.** A file whose length divides exactly by the block size must end with an empty data packet, or the client waits forever for a final block that never comes. diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index be9ce27..4102edc 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -37,6 +37,7 @@ pas de *format* de configuration à apprendre ni de ligne de commande à se trom | `RESCRIPTUM_BOOT_ALLOW` | non défini | CIDR clients autorisés à récupérer les médias. Non défini = quiconque atteint le port | | `RESCRIPTUM_BOOT_DIR` | non défini | Chargeurs et menus, distribués en TFTP. **Non défini = pas de TFTP du tout** | | `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | Le listener TFTP, ou **`off`** pour aucun. Le port 69 est privilégié ; voir `RESCRIPTUM_USER` | +| `RESCRIPTUM_TFTP_BLKSIZE` | `1468` | Le plus grand bloc TFTP accepté. 1468 remplit **exactement** un chemin de 1500 octets — 1468 de charge, 4 TFTP, 8 UDP, 20 IP — donc un tag VLAN ou un tunnel rend la trame trop grande et une ROM PXE s'arrête en général sans rien dire. À baisser (1400, ou 512) quand un démarrage cale au premier bloc | | `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Secondes avant que le menu ne retombe sur le disque local | | `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | Ce que reçoit une machine qu'aucune réponse ne revendique. `local` la rend à son firmware, ce qui inverse le sens d'un fichier de réponse : présent veut dire *installe celle-ci* plutôt que *laisse celle-ci tranquille* | | `RESCRIPTUM_INSTALLED_TOKEN` | non défini | Le jeton du `[post-installation-webhook]` de Proxmox. Défini, `POST /installed` existe et retire la revendication d'installation d'une machine quand elle signale sa réussite. **Non défini, il n'y a pas d'endpoint** | diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 0b18af7..96e468d 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -37,6 +37,7 @@ no configuration *format* to learn and no command line to get wrong. | `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media. Unset means anyone who can reach the port | | `RESCRIPTUM_BOOT_DIR` | unset | Loaders and menus, handed out over TFTP. **Unset means no TFTP at all** | | `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | The TFTP listener, or **`off`** for none. Port 69 is privileged; see `RESCRIPTUM_USER` | +| `RESCRIPTUM_TFTP_BLKSIZE` | `1468` | The largest TFTP block to agree to. 1468 fills a 1500-byte path **exactly** — 1468 payload, 4 TFTP, 8 UDP, 20 IP — so a VLAN tag or a tunnel makes the frame too big and a PXE ROM usually just stops. Lower it (1400, or 512) when a boot stalls at the first block | | `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Seconds before the menu falls through to local boot | | `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | What a machine no answer claims gets. `local` hands it back to its firmware instead, which inverts what an answer file means: present is *install this one* rather than *leave this one alone* | | `RESCRIPTUM_INSTALLED_TOKEN` | unset | Proxmox's `[post-installation-webhook]` token. Set it and `POST /installed` exists, dropping a machine's install claim when it reports success. **Unset, there is no endpoint** | diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs index 718c99c..db6860a 100644 --- a/src/boot/tftp.rs +++ b/src/boot/tftp.rs @@ -57,11 +57,11 @@ const ERR_ILLEGAL: u16 = 4; const ERR_NO_USER: u16 = 7; /// RFC 1350's block size, and the floor every implementation understands. -const DEFAULT_BLOCK: usize = 512; +pub const DEFAULT_BLOCK: usize = 512; /// **Clamped so a data packet still fits one Ethernet frame.** 1500 minus 20 bytes of /// IP and 8 of UDP leaves 1472; minus TFTP's own 4-byte header, 1468. Larger merely /// invites fragmentation, and a fragmented TFTP transfer to a PXE ROM is a coin toss. -const MAX_BLOCK: usize = 1468; +pub const MAX_BLOCK: usize = 1468; /// A request larger than this is not a request. const MAX_REQUEST: usize = 1024; /// How long to wait for an acknowledgement before sending the block again. @@ -261,7 +261,7 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { match name.as_str() { "blksize" => { if let Ok(asked) = value.parse::() { - block_size = asked.clamp(DEFAULT_BLOCK, MAX_BLOCK); + block_size = asked.clamp(DEFAULT_BLOCK, tftp.cfg.tftp_blksize()); accepted.push(("blksize".to_string(), block_size.to_string())); } } @@ -304,20 +304,17 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { } } - log::request( - &peer.to_string(), - 200, - &format!( - "tftp: {} {} bytes blksize={block_size}", - parsed.filename, - contents.len() - ), - ); - + // **This used to be logged here, before a single byte went out** — so a line reading + // `ipxe-x86_64.efi 1164800 bytes` meant "about to send", and every stalled transfer + // in the world looked exactly like a completed one. It cost a real afternoon on a + // real machine: the loader was fetched, nothing happened, and the log said success. + // An intention is not an outcome, and this is a boot path where the difference is + // the whole diagnosis. + let total = contents.len(); let mut block: u16 = 1; let mut sent = 0usize; - loop { - let end = (sent + block_size).min(contents.len()); + let outcome = loop { + let end = (sent + block_size).min(total); let chunk = &contents[sent..end]; // **A short block is what ends a transfer**, and "short" includes empty. A file // whose length is an exact multiple of the block size therefore ends with a @@ -331,20 +328,46 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { packet.extend_from_slice(chunk); if socket.send(&packet).await.is_err() { - return; + break Some("the socket went away"); } if !wait_for_ack(&socket, block, &packet).await { - return; + // Either the client said ERROR or it stopped acknowledging. From here the + // two are indistinguishable, and both mean the same thing to whoever is + // watching a machine fail to boot: it did not get the file. + break Some("the client stopped acknowledging"); } sent = end; if last { - break; + break None; } // Block numbers are 16 bits and wrap. A loader will never reach 65535 at 1468 // bytes a block; be correct anyway, because "never" is how this kind of bug // gets in. block = block.wrapping_add(1); + }; + + match outcome { + None => log::request( + &peer.to_string(), + 200, + &format!( + "tftp: sent {} {total} bytes blksize={block_size}", + parsed.filename + ), + ), + // Status 500 rather than 0, so `RESCRIPTUM_LOG=problems` keeps it: a machine + // that did not get its loader is the definition of a problem worth keeping. + Some(why) => log::request( + &peer.to_string(), + 500, + &format!( + "tftp: {} FAILED after {sent} of {total} bytes at blksize={block_size} \ + — {why}. If it stalls at the first block, the block size is too large \ + for this path: RESCRIPTUM_TFTP_BLKSIZE caps it", + parsed.filename + ), + ), } } diff --git a/src/config.rs b/src/config.rs index fb86b33..de63a7a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -119,6 +119,8 @@ pub struct Config { /// The TFTP listener, as the operator set it. `None` means nobody did; see /// `tftp_addr()`. pub tftp_addr: Option, + /// The largest TFTP block this server will agree to. See `tftp_blksize`. + pub tftp_blksize: Option, /// Seconds before the built-in menu falls through to booting from local disk. pub boot_timeout: Duration, /// What a machine no answer claims is offered. See `unclaimed_boots_local`. @@ -258,6 +260,9 @@ impl Config { "RESCRIPTUM_BOOT_TIMEOUT_SECS", DEFAULT_BOOT_TIMEOUT_SECS as usize, ) as u64), + tftp_blksize: optional("RESCRIPTUM_TFTP_BLKSIZE") + .and_then(|v| v.trim().parse::().ok()) + .filter(|n| *n > 0), boot_unclaimed: optional("RESCRIPTUM_BOOT_UNCLAIMED"), boot_logo: optional("RESCRIPTUM_BOOT_LOGO").map(PathBuf::from), boot_title: optional("RESCRIPTUM_BOOT_TITLE"), @@ -439,6 +444,28 @@ impl Config { .is_some_and(|v| v.eq_ignore_ascii_case("local")) } + /// The largest TFTP block to agree to, when a client asks for a bigger one. + /// + /// **1468 exactly fills a 1500-byte path and leaves nothing over**: 1468 of payload, + /// 4 of TFTP header, 8 of UDP, 20 of IP. iPXE asks for precisely that, and on a plain + /// untagged Ethernet it is right. Put one VLAN tag in the way and the frame is 1504, + /// which is dropped or fragmented — and a PXE ROM meeting either usually just stops, + /// with no message, having downloaded nothing. Same for PPPoE, and for any tunnel. + /// + /// So this exists to be lowered when a boot stalls at the first block. 1400 leaves 68 + /// bytes of headroom, which covers a tag and most tunnels; 512 is the RFC default and + /// always works. The default stays at what fits a clean path, because lowering it for + /// everybody costs every deployment throughput to fix a minority's network — but the + /// failure it causes is now loud enough to find. + pub fn tftp_blksize(&self) -> usize { + self.tftp_blksize + .unwrap_or(crate::boot::tftp::MAX_BLOCK) + .clamp( + crate::boot::tftp::DEFAULT_BLOCK, + crate::boot::tftp::MAX_BLOCK, + ) + } + /// The menu timeout **in milliseconds**, which is the unit `choose` counts. The /// conversion has exactly one place, and this is it. pub fn boot_timeout_millis(&self) -> u64 { @@ -665,7 +692,7 @@ pub struct Known { /// Every variable, in the order a person would want to meet them: what answers come /// from, where the server listens, how much it says, then the two credentials. -pub const KNOWN: [Known; 28] = [ +pub const KNOWN: [Known; 29] = [ Known { key: "RESCRIPTUM_STORE", default: Some("files"), @@ -809,6 +836,12 @@ pub const KNOWN: [Known; 28] = [ secret: true, help: "Proxmox's post-installation-webhook token. Set it and POST /installed exists, which drops a machine's install claim when it reports success. Unset, there is no endpoint.", }, + Known { + key: "RESCRIPTUM_TFTP_BLKSIZE", + default: Some("1468"), + secret: false, + help: "The largest TFTP block to agree to. 1468 fills a 1500-byte path exactly; lower it (1400, or 512) if a boot stalls at the first block, which is what a VLAN tag or a tunnel does to it.", + }, Known { key: "RESCRIPTUM_BOOT_UNCLAIMED", default: Some("menu"), diff --git a/src/envfile.rs b/src/envfile.rs index 0f48373..3cad994 100644 --- a/src/envfile.rs +++ b/src/envfile.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; pub const ENV_FILE: &str = "RESCRIPTUM_ENV_FILE"; /// Every variable this program reads, so a typo can be reported rather than ignored. -pub const KNOWN_KEYS: [&str; 28] = [ +pub const KNOWN_KEYS: [&str; 29] = [ "RESCRIPTUM_STORE", "RESCRIPTUM_ANSWERS_DIR", "RESCRIPTUM_DB_PATH", @@ -52,6 +52,7 @@ pub const KNOWN_KEYS: [&str; 28] = [ "RESCRIPTUM_TFTP_ADDR", "RESCRIPTUM_BOOT_TIMEOUT_SECS", "RESCRIPTUM_INSTALLED_TOKEN", + "RESCRIPTUM_TFTP_BLKSIZE", "RESCRIPTUM_BOOT_UNCLAIMED", "RESCRIPTUM_BOOT_LOGO", "RESCRIPTUM_BOOT_TITLE", diff --git a/tests/tftp.rs b/tests/tftp.rs index cca2366..9d48732 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -797,3 +797,66 @@ fn boot_check_says_so_when_a_loader_really_is_handed_over() { ProbeResult::Refused ); } + +/// **A stalled transfer must not look like a completed one.** +/// +/// The line that reports a transfer used to be written *before the first byte went out*, +/// so `ipxe-x86_64.efi 1164800 bytes` meant "about to send". A machine on a real network +/// fetched a loader, did nothing, and the log said success — which is how an afternoon +/// goes. The size is now reported at the end, with what actually happened. +#[test] +fn a_transfer_that_dies_halfway_is_logged_as_a_failure() { + // Big enough to need many blocks, so abandoning it lands mid-transfer. + let s = Server::start(&[("ipxe-x86_64.efi", loader(64 * 1024))]); + + let mut client = s.client(); + client.read("ipxe-x86_64.efi", &[]); + // Take the first data packet and then stop answering, which is exactly what a ROM + // does when the block size is too large for the path: it never sees block 2. + let first = client.receive().expect("the first block"); + assert_eq!(first.0, OP_DATA); + drop(client); + + // The server retries, gives up, and says so. Six retries at 700ms is the bound. + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let mut log = String::new(); + while std::time::Instant::now() < deadline { + log = s.log(); + if log.contains("FAILED") { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + log.contains("FAILED after"), + "an abandoned transfer left no trace: {log}" + ); + assert!( + log.contains("RESCRIPTUM_TFTP_BLKSIZE"), + "the line has to name the knob that fixes the commonest cause: {log}" + ); + assert!( + !log.contains("tftp: sent ipxe-x86_64.efi"), + "it also claimed to have sent it: {log}" + ); +} + +/// The cap exists to be lowered when a path cannot carry a full-MTU block, so the value +/// a client asks for has to actually be bounded by it. +#[test] +fn the_block_size_can_be_capped_below_what_a_client_asks_for() { + let s = Server::start_env( + &[("ipxe.kpxe", loader(8000))], + &[("RESCRIPTUM_TFTP_BLKSIZE", "512")], + ); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1468")]); + let (opcode, payload) = client.receive().expect("an answer"); + assert_eq!(opcode, OP_OACK, "the option must still be negotiated"); + let text = String::from_utf8_lossy(&payload); + assert!( + text.contains("512"), + "asked for 1468 with a cap of 512 and got: {text:?}" + ); + assert!(!text.contains("1468"), "the cap was ignored: {text:?}"); +} From 4f78989e8b33f734cfbab38cd03a73e0809838b3 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 18:31:01 +0200 Subject: [PATCH 46/59] fix(tftp): the health probe must not log a failure of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `boot check`'s probe wants one block and no more, and it walked away without acknowledging — which, now that an abandoned transfer is reported, made the server retry for four seconds and log a FAILED line. So running the health check wrote a scary alarm into the one file an operator reads to find a real one. Seen immediately, on a real NAS, in the middle of debugging something else. The probe says goodbye with an ERROR packet, which is how TFTP says stop, and the server now tells a deliberate cancel from a client that vanished: `stopped … cancelled by the client` at 200, against `FAILED …` at 500. The distinction is the point — `RESCRIPTUM_LOG=problems` should keep the one that means a machine did not boot, and nothing else. Watched red by removing the goodbye. --- src/boot/tftp.rs | 78 +++++++++++++++++++++++++++++++++--------------- tests/tftp.rs | 43 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs index db6860a..00e9b18 100644 --- a/src/boot/tftp.rs +++ b/src/boot/tftp.rs @@ -299,7 +299,7 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { return; } // The client acknowledges the option set with block 0 before data starts. - if !wait_for_ack(&socket, 0, &oack).await { + if !matches!(wait_for_ack(&socket, 0, &oack).await, Ack::Ok) { return; } } @@ -313,7 +313,7 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { let total = contents.len(); let mut block: u16 = 1; let mut sent = 0usize; - let outcome = loop { + let outcome: Option<(&str, bool)> = loop { let end = (sent + block_size).min(total); let chunk = &contents[sent..end]; // **A short block is what ends a transfer**, and "short" includes empty. A file @@ -328,13 +328,17 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { packet.extend_from_slice(chunk); if socket.send(&packet).await.is_err() { - break Some("the socket went away"); + break Some(("the socket went away", true)); } - if !wait_for_ack(&socket, block, &packet).await { - // Either the client said ERROR or it stopped acknowledging. From here the - // two are indistinguishable, and both mean the same thing to whoever is - // watching a machine fail to boot: it did not get the file. - break Some("the client stopped acknowledging"); + match wait_for_ack(&socket, block, &packet).await { + Ack::Ok => {} + // **A client that says ERROR meant to stop.** `boot check`'s own probe does + // exactly this after one block, and so does a loader the firmware cancelled. + // Calling that a failure fills the log with alarms about transfers nobody + // wanted finished — which is worse than useless in the one file an operator + // reads to find a real failure. + Ack::Cancelled => break Some(("cancelled by the client", false)), + Ack::Silent => break Some(("the client stopped acknowledging", true)), } sent = end; @@ -356,28 +360,42 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { parsed.filename ), ), - // Status 500 rather than 0, so `RESCRIPTUM_LOG=problems` keeps it: a machine - // that did not get its loader is the definition of a problem worth keeping. - Some(why) => log::request( + // A cancel is ordinary and gets 200; a client that vanished gets 500, so + // `RESCRIPTUM_LOG=problems` keeps the one that means a machine did not boot. + Some((why, is_failure)) => log::request( &peer.to_string(), - 500, + if is_failure { 500 } else { 200 }, &format!( - "tftp: {} FAILED after {sent} of {total} bytes at blksize={block_size} \ - — {why}. If it stalls at the first block, the block size is too large \ - for this path: RESCRIPTUM_TFTP_BLKSIZE caps it", - parsed.filename + "tftp: {} {} after {sent} of {total} bytes at blksize={block_size} — {why}{}", + parsed.filename, + if is_failure { "FAILED" } else { "stopped" }, + if is_failure { + ". If it stalls at the first block, the block size is too large for \ + this path: RESCRIPTUM_TFTP_BLKSIZE caps it" + } else { + "" + } ), ), } } +/// What ended the wait for an acknowledgement. +enum Ack { + Ok, + /// The client sent an ERROR: it meant to stop, and that is not a failure. + Cancelled, + /// It stopped answering, which is what a path that cannot carry the block looks like. + Silent, +} + /// Wait for the acknowledgement of `block`, resending on silence. /// /// **A duplicate acknowledgement — one for a block already acknowledged — is ignored, /// never answered.** That is the Sorcerer's Apprentice bug: answering a duplicate with /// a duplicate makes both sides echo each other and doubles the traffic for the rest of /// the transfer. -async fn wait_for_ack(socket: &UdpSocket, block: u16, resend: &[u8]) -> bool { +async fn wait_for_ack(socket: &UdpSocket, block: u16, resend: &[u8]) -> Ack { let mut buffer = [0u8; 64]; for _ in 0..MAX_RETRIES { match tokio::time::timeout(RETRY, socket.recv(&mut buffer)).await { @@ -385,11 +403,11 @@ async fn wait_for_ack(socket: &UdpSocket, block: u16, resend: &[u8]) -> bool { let opcode = u16::from_be_bytes([buffer[0], buffer[1]]); let acked = u16::from_be_bytes([buffer[2], buffer[3]]); if opcode == OP_ERROR { - return false; + return Ack::Cancelled; } if opcode == OP_ACK { if acked == block { - return true; + return Ack::Ok; } // An older block: a duplicate. Say nothing and keep waiting. continue; @@ -398,16 +416,16 @@ async fn wait_for_ack(socket: &UdpSocket, block: u16, resend: &[u8]) -> bool { continue; } Ok(Ok(_)) => continue, - Ok(Err(_)) => return false, + Ok(Err(_)) => return Ack::Silent, // Silence: the block was lost, or the acknowledgement was. Send it again. Err(_) => { if socket.send(resend).await.is_err() { - return false; + return Ack::Silent; } } } } - false + Ack::Silent } struct Request { @@ -555,13 +573,25 @@ pub fn probe(addr: &str, filename: &str, wait: Duration) -> ProbeResult { } let mut buffer = [0u8; 1024]; - let Ok((n, _)) = socket.recv_from(&mut buffer) else { + let Ok((n, from)) = socket.recv_from(&mut buffer) else { return ProbeResult::Silent; }; if n < 2 { return ProbeResult::Silent; } - match u16::from_be_bytes([buffer[0], buffer[1]]) { + let opcode = u16::from_be_bytes([buffer[0], buffer[1]]); + + // **Say goodbye rather than walking away.** One block is all this needs, but a client + // that simply stops answering is indistinguishable from one whose network broke — so + // the server retried for four seconds and then logged a failed transfer. Which meant + // running `boot check` wrote a scary FAILED line into the very log an operator was + // reading to find a real one. An ERROR packet is how TFTP says "stop", and RFC 1350 + // code 0 is the one that carries a message rather than a claim about what went wrong. + if matches!(opcode, OP_DATA | OP_OACK) { + let _ = socket.send_to(&error_packet(0, "probe complete"), from); + } + + match opcode { OP_DATA | OP_OACK => ProbeResult::Served, OP_ERROR => ProbeResult::Refused, _ => ProbeResult::Silent, diff --git a/tests/tftp.rs b/tests/tftp.rs index 9d48732..222bd0b 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -860,3 +860,46 @@ fn the_block_size_can_be_capped_below_what_a_client_asks_for() { ); assert!(!text.contains("1468"), "the cap was ignored: {text:?}"); } + +/// **`boot check` must not write alarms into the log it exists to help you read.** +/// +/// Its probe wants one block and no more, and a client that simply stops answering is +/// indistinguishable from one whose network broke — so the server retried for four +/// seconds and logged a failed transfer. Running the health check produced a scary +/// `FAILED` line about a transfer nobody wanted finished, in the one file an operator +/// reads to find a real one. The probe says goodbye with an ERROR packet now. +#[test] +fn the_health_probe_leaves_no_failure_in_the_log() { + let files: Vec<(&str, Vec)> = rescriptum::boot::loaders::loaders() + .iter() + .map(|name| (*name, loader(64 * 1024))) + .collect(); + let s = Server::start(&files); + + let out = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .arg("boot") + .arg("check") + .env("RESCRIPTUM_BOOT_DIR", &s.boot_dir) + .env("RESCRIPTUM_TFTP_ADDR", &s.tftp_addr) + .output() + .expect("run boot check"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("handed over"), + "the probe has to still work: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // Long enough that a transfer left hanging would have given up and logged by now. + std::thread::sleep(Duration::from_secs(6)); + let log = s.log(); + assert!( + !log.contains("FAILED"), + "the health check logged a failure of its own: {log}" + ); + // It is still recorded, because a transfer that happened and stopped is worth one + // line — just not an alarm. + assert!( + log.contains("cancelled by the client"), + "the cancel left no trace at all: {log}" + ); +} From dfe71feeb8a88cf0f25ec371489e6783a799512d Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 18:37:13 +0200 Subject: [PATCH 47/59] fix(tftp): never let a request arrive in silence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a real NAS: TFTP stopped working, and there was nothing in the log at all. Both halves were mine. **The cause was the cap I had just suggested.** `RESCRIPTUM_TFTP_BLKSIZE=512` makes the server answer a client asking for 1468 with an OACK naming 512. RFC 2348 allows that and says the client should accept it; a PXE ROM often just stops instead. So a machine that had been booting fine stopped, and the advice that broke it was mine. **The reason it was invisible was also mine.** Moving the transfer's only log line to the end — so that a stall could not read as a success — meant a request that never got going logged nothing whatsoever, which is exactly the case somebody is trying to diagnose. The machine said it was downloading, the server said nothing, and both were telling the truth. Two lines now, not one. One when a request arrives, one for what it got. `RESCRIPTUM_LOG=problems` keeps only the second, which is the right split — but `all` is the default, and at `all` a silent request is a bug. And the option handshake no longer returns without a word: it says which block size the client wanted, which one it was offered, and names the setting that caused the disagreement. Both watched red. --- src/boot/tftp.rs | 46 +++++++++++++++++++++++++++++++++++ tests/tftp.rs | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs index 00e9b18..eee278b 100644 --- a/src/boot/tftp.rs +++ b/src/boot/tftp.rs @@ -256,11 +256,13 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { // Options are negotiated in one OACK, acknowledged with block 0, before any data. let mut block_size = DEFAULT_BLOCK; + let mut asked_block = DEFAULT_BLOCK; let mut accepted: Vec<(String, String)> = Vec::new(); for (name, value) in &parsed.options { match name.as_str() { "blksize" => { if let Ok(asked) = value.parse::() { + asked_block = asked; block_size = asked.clamp(DEFAULT_BLOCK, tftp.cfg.tftp_blksize()); accepted.push(("blksize".to_string(), block_size.to_string())); } @@ -287,6 +289,25 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { } } + // **A request that arrived must always leave a trace, whatever happens next.** + // Moving the only line to the end of the transfer meant a request that never got + // going logged nothing at all — and that is precisely the case somebody is trying to + // diagnose. It cost the maintainer an evening: the machine said it was downloading, + // the server said nothing, and the two were both telling the truth. + // + // Two lines, then. This one says a machine asked; the one after the transfer says + // what it got. `RESCRIPTUM_LOG=problems` keeps only the second, which is the right + // split — but `all` is the default, and at `all` a silent request is a bug. + log::request( + &peer.to_string(), + 200, + &format!( + "tftp: {} requested, {} bytes, blksize={block_size}", + parsed.filename, + contents.len() + ), + ); + if !accepted.is_empty() { let mut oack = vec![0, OP_OACK as u8]; for (name, value) in &accepted { @@ -296,10 +317,35 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { oack.push(0); } if socket.send(&oack).await.is_err() { + log::request( + &peer.to_string(), + 500, + &format!( + "tftp: {} FAILED — could not send the option reply", + parsed.filename + ), + ); return; } // The client acknowledges the option set with block 0 before data starts. + // + // **A client is allowed to hate the answer.** RFC 2348 lets a server reply with a + // *smaller* block size than was asked for, and the client is supposed to accept + // it — but a PXE ROM that wanted 1468 and is offered 512 often just stops, and + // this used to `return` without a word. Which is how capping the block size to + // help one network broke a machine that had been booting fine, invisibly. if !matches!(wait_for_ack(&socket, 0, &oack).await, Ack::Ok) { + log::request( + &peer.to_string(), + 500, + &format!( + "tftp: {} FAILED at the option handshake — the client asked for \ + blksize={asked_block} and would not take {block_size}. \ + RESCRIPTUM_TFTP_BLKSIZE is what caps it; unset it to agree to what \ + the client wants", + parsed.filename + ), + ); return; } } diff --git a/tests/tftp.rs b/tests/tftp.rs index 222bd0b..6c618af 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -903,3 +903,66 @@ fn the_health_probe_leaves_no_failure_in_the_log() { "the cancel left no trace at all: {log}" ); } + +/// **A request that arrived must leave a trace, whatever happens next.** +/// +/// Reporting the transfer at its end — right on its own — meant a request that never got +/// going logged nothing at all, which is exactly the case somebody is trying to diagnose. +/// It cost an evening on a real NAS: the machine said it was downloading, the server said +/// nothing, and both were telling the truth. +#[test] +fn a_request_is_logged_when_it_arrives_not_only_when_it_finishes() { + let s = Server::start(&[("ipxe.kpxe", loader(32 * 1024))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1468")]); + let _ = client.receive(); + // Deliberately never acknowledge, and look *before* the retries could have expired. + std::thread::sleep(Duration::from_millis(400)); + let log = s.log(); + assert!( + log.contains("ipxe.kpxe requested"), + "a request that has not finished yet is invisible: {log}" + ); +} + +/// **A client is allowed to hate the answer, and that must be visible.** +/// +/// RFC 2348 lets a server reply with a smaller block size than was asked for, and the +/// client is meant to accept it — but a PXE ROM that wanted 1468 and is offered 512 often +/// just stops. That path used to return without a word, so capping the block size to help +/// one network broke a machine that had been booting fine, invisibly. +#[test] +fn a_refused_option_handshake_says_so_and_names_the_cap() { + let s = Server::start_env( + &[("ipxe.kpxe", loader(32 * 1024))], + &[("RESCRIPTUM_TFTP_BLKSIZE", "512")], + ); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1468")]); + let (opcode, _) = client.receive().expect("an option reply"); + assert_eq!(opcode, OP_OACK); + // A ROM that will not take the smaller size simply stops here. + drop(client); + + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let mut log = String::new(); + while std::time::Instant::now() < deadline { + log = s.log(); + if log.contains("option handshake") { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + log.contains("FAILED at the option handshake"), + "the handshake failed in silence: {log}" + ); + assert!( + log.contains("blksize=1468") && log.contains("512"), + "the line has to name both sides of the disagreement: {log}" + ); + assert!( + log.contains("RESCRIPTUM_TFTP_BLKSIZE"), + "and the setting that caused it: {log}" + ); +} From b0ec1489f25837d87df92c4ed3fe033b3f4325d1 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 18:41:28 +0200 Subject: [PATCH 48/59] fix(tftp): do not blame a cap that did nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message printed "the client asked for blksize=1468 and would not take 1468" and pointed at RESCRIPTUM_TFTP_BLKSIZE — a setting that, when both numbers are equal, provably did not participate. It sent the maintainer and me after the wrong thing for an hour while the real cause went unexamined. When the server granted exactly what was asked and the client still walked away, the block size is ruled out and the message says what this actually looks like instead: the reply comes from a fresh port, which is how TFTP works, so a firewall or a NAT between the two lets the request in, lets the answer out, and eats the acknowledgement. A message that suggests a cause it has already ruled out is worse than one that suggests none. --- src/boot/tftp.rs | 29 ++++++++++++++++++++++++----- tests/tftp.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs index eee278b..67514bc 100644 --- a/src/boot/tftp.rs +++ b/src/boot/tftp.rs @@ -339,11 +339,30 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { &peer.to_string(), 500, &format!( - "tftp: {} FAILED at the option handshake — the client asked for \ - blksize={asked_block} and would not take {block_size}. \ - RESCRIPTUM_TFTP_BLKSIZE is what caps it; unset it to agree to what \ - the client wants", - parsed.filename + "tftp: {} FAILED at the option handshake — {}", + parsed.filename, + // **Name the cap only when the cap did something.** The first version + // named it either way, and printed "asked for 1468 and would not take + // 1468" — which sent both the maintainer and me after a setting that + // was not involved, while the real cause went unlooked-at for an hour. + // A message that suggests a cause it has already ruled out is worse + // than one that suggests none. + if block_size != asked_block { + format!( + "the client asked for blksize={asked_block} and would not take \ + {block_size}. RESCRIPTUM_TFTP_BLKSIZE is what caps it; unset \ + it to agree to what the client wants" + ) + } else { + format!( + "it never acknowledged the options it asked for itself \ + (blksize={asked_block}). **The reply comes from a fresh port** \ + — that is how TFTP works — so this is what a firewall or a NAT \ + between the two looks like: the request arrives, the answer \ + goes out, and the acknowledgement never comes back. On a NAS, \ + check the firewall; in a container, host networking" + ) + } ), ); return; diff --git a/tests/tftp.rs b/tests/tftp.rs index 6c618af..65255d5 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -966,3 +966,39 @@ fn a_refused_option_handshake_says_so_and_names_the_cap() { "and the setting that caused it: {log}" ); } + +/// **A message must not suggest a cause it has already ruled out.** +/// +/// When the server agrees to exactly what the client asked for and the client still walks +/// away, the block-size cap is provably not involved — it did nothing. Naming it anyway +/// printed "asked for 1468 and would not take 1468", which sent two people after the wrong +/// setting for an hour while the real cause (the reply comes from a fresh port, so a +/// firewall between the two eats the acknowledgement) went unexamined. +#[test] +fn a_handshake_failure_blames_the_cap_only_when_the_cap_did_something() { + // Nothing capped: the server grants what was asked, and the client still stops. + let s = Server::start(&[("ipxe.kpxe", loader(32 * 1024))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1468")]); + let _ = client.receive(); + drop(client); + + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let mut log = String::new(); + while std::time::Instant::now() < deadline { + log = s.log(); + if log.contains("option handshake") { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + assert!(log.contains("FAILED at the option handshake"), "{log}"); + assert!( + !log.contains("RESCRIPTUM_TFTP_BLKSIZE"), + "it blamed a cap that did nothing: {log}" + ); + assert!( + log.contains("fresh port"), + "and it has to point at what this actually looks like: {log}" + ); +} From 6432252b27ee75847bb2781db50022e9e60fabe0 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 18:49:32 +0200 Subject: [PATCH 49/59] feat(tftp): pin the ports a transfer answers from, so a firewall can allow them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked, after a machine that would not boot: can that port not be controlled. It can, and not being able to was the bug. A TFTP transfer leaves port 69 immediately — the server answers from a fresh port and the client acknowledges to *that*. So a firewall told to allow 69 lets the request in, lets the answer out, and drops the acknowledgement; the transfer dies at the handshake and the client looks like it lost interest. It is the hardest failure in this protocol to read, and every serious TFTP server therefore lets the range be pinned. Ours did not. `RESCRIPTUM_TFTP_PORT_RANGE=first-last` does it. Unset keeps today's behaviour, which is right on a host with no firewall in the way. **The DSM package pins 30000-30063 and registers it with the firewall**, because on a NAS the unpinned behaviour is not a default, it is a trap. The range was chosen against a real DSM rather than picked: below the kernel's own ephemeral range (32768–60999, so nothing in it can be handed to something else), clear of every UDP port a stock install uses (68, 123, 137, 138, 161, 323, 1900, 3702, 5353, 9997–9999), claimed by no Synology `.sc`, and all 64 bind-tested. Sixty-four is MAX_TRANSFERS, so the range cannot be what runs out first. Two checks in lifecycle-test.sh (83 → 85): the firewall entry carries the range, and the server is pinned to the same one — a mismatch between those two would be exactly the silent failure this fixes. --- CLAUDE.md | 8 ++++ docs/guide/reference/configuration.fr.md | 1 + docs/guide/reference/configuration.md | 1 + packaging/dsm/lifecycle-test.sh | 6 ++- packaging/dsm/payload/port_conf/rescriptum.sc | 2 +- packaging/dsm/scripts/postinst | 20 +++++++- src/boot/tftp.rs | 46 +++++++++++++++---- src/config.rs | 35 +++++++++++++- src/envfile.rs | 3 +- 9 files changed, 109 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3503727..4dfb0ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -537,6 +537,14 @@ could not check. Note it needs `Resolution::format_name` (the extension), not identical — a machine on a real network fetched a loader, nothing happened, and the log said success. It is reported at the end now, `sent` or `FAILED after N of M`, with 500 so `RESCRIPTUM_LOG=problems` keeps it. +- **A TFTP transfer leaves port 69 immediately, and that is what a firewall does not + expect.** The server answers from a fresh port and the client acknowledges to *that*, so + a rule allowing only 69 lets the request in, lets the answer out, and drops the + acknowledgement — the machine then looks like it lost interest, which is as hard to read + as this protocol gets. `RESCRIPTUM_TFTP_PORT_RANGE` pins it so it can be opened. The DSM + package pins **30000-30063** and registers it: chosen on the machine, below the kernel's + ephemeral range (32768–60999, so nothing there can be handed away), clear of every UDP + port a stock DSM uses, claimed by no Synology `.sc`, and all 64 verified bindable. - **`blksize=1468` fills a 1500-byte path exactly** — 1468 payload, 4 TFTP, 8 UDP, 20 IP — which is what iPXE asks for and what leaves no room at all. One VLAN tag makes the frame 1504, and a PXE ROM meeting that usually stops without a message. `RESCRIPTUM_TFTP_BLKSIZE` diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index 4102edc..2a6410e 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -37,6 +37,7 @@ pas de *format* de configuration à apprendre ni de ligne de commande à se trom | `RESCRIPTUM_BOOT_ALLOW` | non défini | CIDR clients autorisés à récupérer les médias. Non défini = quiconque atteint le port | | `RESCRIPTUM_BOOT_DIR` | non défini | Chargeurs et menus, distribués en TFTP. **Non défini = pas de TFTP du tout** | | `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | Le listener TFTP, ou **`off`** pour aucun. Le port 69 est privilégié ; voir `RESCRIPTUM_USER` | +| `RESCRIPTUM_TFTP_PORT_RANGE` | non défini | Les ports depuis lesquels un transfert répond, en `premier-dernier`. **Un transfert TFTP quitte le port 69 aussitôt** — le serveur répond depuis un port neuf et le client acquitte vers celui-là — donc un pare-feu n'autorisant que 69 jette l'acquittement, et la machine semble s'être désintéressée. Épinglez la plage pour pouvoir l'ouvrir. Non définie, le noyau choisit | | `RESCRIPTUM_TFTP_BLKSIZE` | `1468` | Le plus grand bloc TFTP accepté. 1468 remplit **exactement** un chemin de 1500 octets — 1468 de charge, 4 TFTP, 8 UDP, 20 IP — donc un tag VLAN ou un tunnel rend la trame trop grande et une ROM PXE s'arrête en général sans rien dire. À baisser (1400, ou 512) quand un démarrage cale au premier bloc | | `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Secondes avant que le menu ne retombe sur le disque local | | `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | Ce que reçoit une machine qu'aucune réponse ne revendique. `local` la rend à son firmware, ce qui inverse le sens d'un fichier de réponse : présent veut dire *installe celle-ci* plutôt que *laisse celle-ci tranquille* | diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 96e468d..e7b40e2 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -37,6 +37,7 @@ no configuration *format* to learn and no command line to get wrong. | `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media. Unset means anyone who can reach the port | | `RESCRIPTUM_BOOT_DIR` | unset | Loaders and menus, handed out over TFTP. **Unset means no TFTP at all** | | `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | The TFTP listener, or **`off`** for none. Port 69 is privileged; see `RESCRIPTUM_USER` | +| `RESCRIPTUM_TFTP_PORT_RANGE` | unset | The ports transfers answer from, as `first-last`. **A TFTP transfer leaves port 69 immediately** — the server replies from a fresh port and the client acknowledges to that — so a firewall allowing only 69 drops the acknowledgement and the machine looks like it lost interest. Pin the range so it can be opened. Unset, the kernel picks | | `RESCRIPTUM_TFTP_BLKSIZE` | `1468` | The largest TFTP block to agree to. 1468 fills a 1500-byte path **exactly** — 1468 payload, 4 TFTP, 8 UDP, 20 IP — so a VLAN tag or a tunnel makes the frame too big and a PXE ROM usually just stops. Lower it (1400, or 512) when a boot stalls at the first block | | `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Seconds before the menu falls through to local boot | | `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | What a machine no answer claims gets. `local` hands it back to its firmware instead, which inverts what an answer file means: present is *install this one* rather than *leave this one alone* | diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh index d5e6f13..4e2d622 100755 --- a/packaging/dsm/lifecycle-test.sh +++ b/packaging/dsm/lifecycle-test.sh @@ -113,7 +113,11 @@ grep -q "^RESCRIPTUM_DB_PATH=$SHARE/answers.db\$" "$ENV_FILE" && ok "the databas # and then cannot find rescriptum in the firewall list. **69/udp is the one that matters # most** — a PXE ROM asks over UDP, and a firewall that drops it produces a client which # retries and times out with nothing in any log on this side. -grep -q "dst.ports=\"$PORT/tcp 8001/tcp 69/udp\"" "$ROOT/target/port_conf/rescriptum.sc" && ok "the .sc file carries the answer port, the media one and TFTP" || bad ".sc file: $(tail -1 "$ROOT/target/port_conf/rescriptum.sc")" +grep -q "dst.ports=\"$PORT/tcp 8001/tcp 69/udp 30000:30063/udp\"" "$ROOT/target/port_conf/rescriptum.sc" && ok "the .sc file carries the answer port, the media one, TFTP and its data range" || bad ".sc file: $(tail -1 "$ROOT/target/port_conf/rescriptum.sc")" +# **The data range is not decoration.** A TFTP transfer leaves port 69 at once and answers +# from a fresh port; a firewall that allows only 69 drops the acknowledgement, and the +# machine looks like it lost interest. Pinned so it can be opened, and opened above. +grep -q "^RESCRIPTUM_TFTP_PORT_RANGE=30000-30063\$" "$ENV_FILE" && ok "and the server is pinned to that same range" || bad "RESCRIPTUM_TFTP_PORT_RANGE is $(value_of RESCRIPTUM_TFTP_PORT_RANGE), which the firewall entry does not cover" # **The package must not ship a configuration that refuses to start.** Naming a media # address with no media directory is a startup error, and the first version of this diff --git a/packaging/dsm/payload/port_conf/rescriptum.sc b/packaging/dsm/payload/port_conf/rescriptum.sc index 92d7e33..ff38fc8 100644 --- a/packaging/dsm/payload/port_conf/rescriptum.sc +++ b/packaging/dsm/payload/port_conf/rescriptum.sc @@ -2,4 +2,4 @@ title="rescriptum" desc="Unattended-installation answer server, the installer media it serves, and the loader it hands out" port_forward="no" -dst.ports="8000/tcp 8001/tcp 69/udp" +dst.ports="8000/tcp 8001/tcp 69/udp 30000:30063/udp" diff --git a/packaging/dsm/scripts/postinst b/packaging/dsm/scripts/postinst index c0e5dbe..f51cb87 100755 --- a/packaging/dsm/scripts/postinst +++ b/packaging/dsm/scripts/postinst @@ -47,6 +47,19 @@ MEDIA_PORT=8001 # TFTP's, and not a preference either: a PXE ROM has 69 burned into it, so this is the # one port in the design that cannot move without changing the client. TFTP_PORT=69 +# **The ports transfers answer from, and they have to be pinned here.** A TFTP transfer +# leaves port 69 immediately — the server replies from a fresh port and the client +# acknowledges to *that* — so a firewall that allows only 69 lets the request in, lets the +# answer out, and drops the acknowledgement. The symptom is a machine that says +# "Downloading NBP file" and then nothing, which is as hard to read as this protocol gets. +# +# Chosen against a real DSM rather than picked: below the kernel's own ephemeral range +# (32768–60999, so nothing here can be handed to something else), clear of every UDP port +# in use on a stock install (68, 123, 137, 138, 161, 323, 1900, 3702, 5353, 9997–9999), +# claimed by no Synology .sc, and all 64 verified bindable. Sixty-four is MAX_TRANSFERS, +# so the range cannot be the thing that runs out first. +TFTP_DATA_FIRST=30000 +TFTP_DATA_LAST=30063 say() { echo "$PKG: $*"; } @@ -211,6 +224,11 @@ RESCRIPTUM_MEDIA_DIR=$SHARE_MEDIA # boot folder below is a working alternative rather than the intended one. # RESCRIPTUM_TFTP_ADDR=0.0.0.0:69 +# The ports a transfer answers from. **Set, not commented, and the firewall entry above +# covers them** — left to the kernel they would land somewhere no rule allows, and every +# transfer would die at the handshake with the client looking like it lost interest. +RESCRIPTUM_TFTP_PORT_RANGE=$TFTP_DATA_FIRST-$TFTP_DATA_LAST + # Where the loaders live — **and this package ships them**, so the folder is already # filled in when you first start it. They are iPXE, GPLv2, separate files never linked # into our binary; the NOTICE beside them names the exact upstream commit they were built @@ -345,7 +363,7 @@ if [ -f "$SC_FILE" ]; then # times out with nothing in any log on this side. # The protocol rides on the port, which is the shape the tcp entries already use — # not a second dst.udp.ports key, which would be a guess. - sed "s|^dst.ports=.*|dst.ports=\"$port/tcp $MEDIA_PORT/tcp $TFTP_PORT/udp\"|" "$SC_FILE" >"$SC_FILE.new" && + sed "s|^dst.ports=.*|dst.ports=\"$port/tcp $MEDIA_PORT/tcp $TFTP_PORT/udp $TFTP_DATA_FIRST:$TFTP_DATA_LAST/udp\"|" "$SC_FILE" >"$SC_FILE.new" && mv "$SC_FILE.new" "$SC_FILE" fi diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs index 67514bc..3e044ab 100644 --- a/src/boot/tftp.rs +++ b/src/boot/tftp.rs @@ -199,14 +199,23 @@ pub async fn serve(socket: UdpSocket, tftp: Arc) { } async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { - // The reply socket is ephemeral and *connected*: RFC 1350 wants the data to come - // from a fresh port, and connecting means this transfer only ever hears its peer. - let bind = if peer.is_ipv4() { - "0.0.0.0:0" - } else { - "[::]:0" - }; - let Ok(socket) = UdpSocket::bind(bind).await else { + // The reply socket is *connected*, and on a port of its own: RFC 1350 wants the data + // to come from a fresh TID, and connecting means this transfer only ever hears its + // peer. + // + // **A fresh port is what a firewall does not expect.** The request arrives on 69, the + // answer leaves from somewhere else, and the acknowledgement comes back to *that* — + // which no rule allows, so it is dropped and the transfer dies at the handshake with + // the client looking like it lost interest. Every serious TFTP server therefore lets + // the range be pinned so it can be opened; ours did not, and a NAS firewall is + // exactly where that bites. + let Some(socket) = data_socket(peer, tftp).await else { + log::request( + &peer.to_string(), + 500, + "tftp: no data port free — RESCRIPTUM_TFTP_PORT_RANGE is too small for the \ + transfers in flight", + ); return; }; if socket.connect(peer).await.is_err() { @@ -454,6 +463,27 @@ enum Ack { Silent, } +/// The socket a transfer answers from. +/// +/// Unpinned it is whatever the kernel hands out, which is right on a host with no +/// firewall in the way and unusable behind one. A configured range is tried in order and +/// the first free port wins; the range only has to be as large as the transfers that can +/// overlap, which `MAX_TRANSFERS` already bounds at 64. +async fn data_socket(peer: SocketAddr, tftp: &Tftp) -> Option { + let host = if peer.is_ipv4() { "0.0.0.0" } else { "[::]" }; + match tftp.cfg.tftp_port_range() { + None => UdpSocket::bind(format!("{host}:0")).await.ok(), + Some((first, last)) => { + for port in first..=last { + if let Ok(socket) = UdpSocket::bind(format!("{host}:{port}")).await { + return Some(socket); + } + } + None + } + } +} + /// Wait for the acknowledgement of `block`, resending on silence. /// /// **A duplicate acknowledgement — one for a block already acknowledged — is ignored, diff --git a/src/config.rs b/src/config.rs index de63a7a..a579b37 100644 --- a/src/config.rs +++ b/src/config.rs @@ -119,6 +119,8 @@ pub struct Config { /// The TFTP listener, as the operator set it. `None` means nobody did; see /// `tftp_addr()`. pub tftp_addr: Option, + /// The ports TFTP answers transfers from, as `first-last`. See `tftp_port_range`. + pub tftp_port_range: Option, /// The largest TFTP block this server will agree to. See `tftp_blksize`. pub tftp_blksize: Option, /// Seconds before the built-in menu falls through to booting from local disk. @@ -260,6 +262,7 @@ impl Config { "RESCRIPTUM_BOOT_TIMEOUT_SECS", DEFAULT_BOOT_TIMEOUT_SECS as usize, ) as u64), + tftp_port_range: optional("RESCRIPTUM_TFTP_PORT_RANGE"), tftp_blksize: optional("RESCRIPTUM_TFTP_BLKSIZE") .and_then(|v| v.trim().parse::().ok()) .filter(|n| *n > 0), @@ -444,6 +447,30 @@ impl Config { .is_some_and(|v| v.eq_ignore_ascii_case("local")) } + /// The ports a TFTP transfer may answer from, parsed from `first-last`. + /// + /// **This exists because of firewalls, and it is not a preference.** A TFTP transfer + /// does not continue on port 69: the server answers from a fresh port and the client + /// acknowledges to *that*. A firewall told to allow 69 lets the request in, lets the + /// answer out, and then drops the acknowledgement — which looks exactly like a client + /// that stopped caring, and is the single hardest failure in this protocol to read. + /// + /// Unset, the kernel picks and nothing needs opening on a host with no firewall. + /// Set, the range is what an operator opens, and it need only be as large as the + /// transfers that can overlap — `MAX_TRANSFERS` bounds those at 64. + /// + /// Anything unparseable is `None` rather than an error: a wrong value here must not + /// stop a server from booting a fleet, and the startup line says which it took. + pub fn tftp_port_range(&self) -> Option<(u16, u16)> { + let raw = self.tftp_port_range.as_deref()?.trim(); + let (a, b) = raw.split_once('-')?; + let first: u16 = a.trim().parse().ok()?; + let last: u16 = b.trim().parse().ok()?; + // Backwards is a typo, not an empty range, and 0 would ask the kernel to choose + // — which is what leaving this unset already means. + (first > 0 && last >= first).then_some((first, last)) + } + /// The largest TFTP block to agree to, when a client asks for a bigger one. /// /// **1468 exactly fills a 1500-byte path and leaves nothing over**: 1468 of payload, @@ -692,7 +719,7 @@ pub struct Known { /// Every variable, in the order a person would want to meet them: what answers come /// from, where the server listens, how much it says, then the two credentials. -pub const KNOWN: [Known; 29] = [ +pub const KNOWN: [Known; 30] = [ Known { key: "RESCRIPTUM_STORE", default: Some("files"), @@ -836,6 +863,12 @@ pub const KNOWN: [Known; 29] = [ secret: true, help: "Proxmox's post-installation-webhook token. Set it and POST /installed exists, which drops a machine's install claim when it reports success. Unset, there is no endpoint.", }, + Known { + key: "RESCRIPTUM_TFTP_PORT_RANGE", + default: None, + secret: false, + help: "The ports transfers answer from, as `first-last` (e.g. 30000-30063). A TFTP transfer leaves port 69 immediately, so this is what a firewall has to allow besides 69/udp. Unset, the kernel picks.", + }, Known { key: "RESCRIPTUM_TFTP_BLKSIZE", default: Some("1468"), diff --git a/src/envfile.rs b/src/envfile.rs index 3cad994..7ac9b18 100644 --- a/src/envfile.rs +++ b/src/envfile.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; pub const ENV_FILE: &str = "RESCRIPTUM_ENV_FILE"; /// Every variable this program reads, so a typo can be reported rather than ignored. -pub const KNOWN_KEYS: [&str; 29] = [ +pub const KNOWN_KEYS: [&str; 30] = [ "RESCRIPTUM_STORE", "RESCRIPTUM_ANSWERS_DIR", "RESCRIPTUM_DB_PATH", @@ -52,6 +52,7 @@ pub const KNOWN_KEYS: [&str; 29] = [ "RESCRIPTUM_TFTP_ADDR", "RESCRIPTUM_BOOT_TIMEOUT_SECS", "RESCRIPTUM_INSTALLED_TOKEN", + "RESCRIPTUM_TFTP_PORT_RANGE", "RESCRIPTUM_TFTP_BLKSIZE", "RESCRIPTUM_BOOT_UNCLAIMED", "RESCRIPTUM_BOOT_LOGO", From d033bebbf0e5855e580fbe33dba5e78b4bc6b17f Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 18:58:21 +0200 Subject: [PATCH 50/59] fix(tftp): a connected data socket hides the packets you need to see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While chasing a machine that will not boot: the data socket was `connect`ed to the address the request came from, which makes the kernel accept datagrams only from that exact address *and port*. A client that acknowledges from a different source port therefore has its packets dropped before any of this code runs — and the transfer dies looking precisely like a firewall eating the acknowledgements, with nothing logged, because there is nothing to log. RFC 1350 says a client keeps its TID for the transfer, and most do. The ones that do not are UEFI ROMs, which is exactly the population this serves. A blind spot that cannot be told apart from a network fault is worse than a rule that bends, so the socket now accepts from the same address whatever port it comes from, and logs when the port moves. Not claimed as the cause of the failure being chased — the measurement to settle that is still a packet capture. It is a hole either way, and one that would have made the real answer unreadable. Watched red by reinstating the port filter. Four other tests broke on the way and said so immediately: the refusal paths still used `send`, which needs a connected socket. --- CLAUDE.md | 6 +++++ src/boot/tftp.rs | 64 ++++++++++++++++++++++++++++++++++++---------- tests/tftp.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4dfb0ae..b9ab4a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -537,6 +537,12 @@ could not check. Note it needs `Resolution::format_name` (the extension), not identical — a machine on a real network fetched a loader, nothing happened, and the log said success. It is reported at the end now, `sent` or `FAILED after N of M`, with 500 so `RESCRIPTUM_LOG=problems` keeps it. +- **A `connect`ed data socket makes the kernel drop what you most need to see.** It + filters on the peer's address *and port*, so a client that acknowledges from a different + source port has its packets discarded before any code runs — and the transfer dies + looking exactly like a firewall eating them, with nothing to log. RFC 1350 says a client + keeps its TID; the ones that do not are UEFI ROMs, which is the population being served. + The socket accepts from the same *address* now and says when the port moves. - **A TFTP transfer leaves port 69 immediately, and that is what a firewall does not expect.** The server answers from a fresh port and the client acknowledges to *that*, so a rule allowing only 69 lets the request in, lets the answer out, and drops the diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs index 3e044ab..9ee6e08 100644 --- a/src/boot/tftp.rs +++ b/src/boot/tftp.rs @@ -218,15 +218,23 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { ); return; }; - if socket.connect(peer).await.is_err() { - return; - } + // **Not `connect`ed, and that is a fix rather than a relaxation.** Connecting makes + // the kernel accept datagrams only from the exact address *and port* the request came + // from — so a client that acknowledges from a different source port has its packets + // dropped before this code can see them, and the transfer dies looking precisely like + // a firewall ate them. RFC 1350 says a client keeps its TID and most do; the ones + // that do not are UEFI ROMs, which is exactly the population being served here. + // + // So: answer whoever asked, accept from the same *address*, and say so when the port + // moves. A blind spot that cannot be told apart from a network fault is worse than a + // rule that bends. + let mut reply_to = peer; let parsed = match parse_request(request) { Ok(parsed) => parsed, Err(refusal) => { let _ = socket - .send(&error_packet(refusal.code, &refusal.message)) + .send_to(&error_packet(refusal.code, &refusal.message), reply_to) .await; log::request(&peer.to_string(), 0, &format!("tftp: {}", refusal.message)); return; @@ -237,7 +245,7 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { Some(path) => path, None => { let _ = socket - .send(&error_packet(ERR_NOT_FOUND, "no such file")) + .send_to(&error_packet(ERR_NOT_FOUND, "no such file"), reply_to) .await; log::request( &peer.to_string(), @@ -252,7 +260,7 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { Ok(bytes) => bytes, Err(e) => { let _ = socket - .send(&error_packet(ERR_NOT_FOUND, "cannot read")) + .send_to(&error_packet(ERR_NOT_FOUND, "cannot read"), reply_to) .await; log::request( &peer.to_string(), @@ -325,7 +333,7 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { oack.extend_from_slice(value.as_bytes()); oack.push(0); } - if socket.send(&oack).await.is_err() { + if socket.send_to(&oack, reply_to).await.is_err() { log::request( &peer.to_string(), 500, @@ -343,7 +351,10 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { // it — but a PXE ROM that wanted 1468 and is offered 512 often just stops, and // this used to `return` without a word. Which is how capping the block size to // help one network broke a machine that had been booting fine, invisibly. - if !matches!(wait_for_ack(&socket, 0, &oack).await, Ack::Ok) { + if !matches!( + wait_for_ack(&socket, 0, &oack, &mut reply_to, &peer).await, + Ack::Ok + ) { log::request( &peer.to_string(), 500, @@ -401,10 +412,10 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { packet.extend_from_slice(&block.to_be_bytes()); packet.extend_from_slice(chunk); - if socket.send(&packet).await.is_err() { + if socket.send_to(&packet, reply_to).await.is_err() { break Some(("the socket went away", true)); } - match wait_for_ack(&socket, block, &packet).await { + match wait_for_ack(&socket, block, &packet, &mut reply_to, &peer).await { Ack::Ok => {} // **A client that says ERROR meant to stop.** `boot check`'s own probe does // exactly this after one block, and so does a loader the firmware cancelled. @@ -490,11 +501,36 @@ async fn data_socket(peer: SocketAddr, tftp: &Tftp) -> Option { /// never answered.** That is the Sorcerer's Apprentice bug: answering a duplicate with /// a duplicate makes both sides echo each other and doubles the traffic for the rest of /// the transfer. -async fn wait_for_ack(socket: &UdpSocket, block: u16, resend: &[u8]) -> Ack { +async fn wait_for_ack( + socket: &UdpSocket, + block: u16, + resend: &[u8], + reply_to: &mut SocketAddr, + peer: &SocketAddr, +) -> Ack { let mut buffer = [0u8; 64]; for _ in 0..MAX_RETRIES { - match tokio::time::timeout(RETRY, socket.recv(&mut buffer)).await { - Ok(Ok(n)) if n >= 4 => { + match tokio::time::timeout(RETRY, socket.recv_from(&mut buffer)).await { + Ok(Ok((n, from))) if n >= 4 => { + // Same machine, whatever port it chose to answer from. A different + // address is somebody else's traffic and is ignored without comment — + // this socket is reachable from anywhere the request was. + if from.ip() != peer.ip() { + continue; + } + if from != *reply_to { + log::request( + &peer.to_string(), + 200, + &format!( + "tftp: the client moved to port {} — answering there \ + (RFC 1350 says it should keep {}; some UEFI ROMs do not)", + from.port(), + reply_to.port() + ), + ); + *reply_to = from; + } let opcode = u16::from_be_bytes([buffer[0], buffer[1]]); let acked = u16::from_be_bytes([buffer[2], buffer[3]]); if opcode == OP_ERROR { @@ -514,7 +550,7 @@ async fn wait_for_ack(socket: &UdpSocket, block: u16, resend: &[u8]) -> Ack { Ok(Err(_)) => return Ack::Silent, // Silence: the block was lost, or the acknowledgement was. Send it again. Err(_) => { - if socket.send(resend).await.is_err() { + if socket.send_to(resend, *reply_to).await.is_err() { return Ack::Silent; } } diff --git a/tests/tftp.rs b/tests/tftp.rs index 65255d5..1b2f084 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -1002,3 +1002,69 @@ fn a_handshake_failure_blames_the_cap_only_when_the_cap_did_something() { "and it has to point at what this actually looks like: {log}" ); } + +/// **A client that answers from a different port must still be served.** +/// +/// The data socket used to be `connect`ed to the address the request came from, so the +/// kernel dropped anything from another port *before* this code could see it — and a +/// transfer with a client like that died looking exactly like a firewall eating the +/// acknowledgements. RFC 1350 says a client keeps its TID and most do; the ones that do +/// not are UEFI ROMs, which is the population being served here. +#[test] +fn a_client_that_acknowledges_from_another_port_is_still_served() { + let s = Server::start(&[("ipxe.kpxe", loader(3000))]); + + // The request comes from one socket… + let asker = UdpSocket::bind("127.0.0.1:0").expect("bind"); + asker + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut packet = vec![0, 1]; + packet.extend_from_slice(b"ipxe.kpxe\0octet\0"); + asker.send_to(&packet, &s.tftp_addr).expect("send"); + + let mut buffer = vec![0u8; 2048]; + let (n, server) = asker.recv_from(&mut buffer).expect("first block"); + assert_eq!(u16::from_be_bytes([buffer[0], buffer[1]]), OP_DATA); + let mut got = buffer[4..n].to_vec(); + + // …and every acknowledgement from a *different* one, which is what the fix is for. + let other = UdpSocket::bind("127.0.0.1:0").expect("bind"); + other + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut block = 1u16; + loop { + let mut ack = vec![0, 4]; + ack.extend_from_slice(&block.to_be_bytes()); + other.send_to(&ack, server).expect("ack"); + if got.len() % 512 != 0 || got.is_empty() { + break; + } + let (n, _) = other.recv_from(&mut buffer).expect("next block"); + block = u16::from_be_bytes([buffer[2], buffer[3]]); + got.extend_from_slice(&buffer[4..n]); + if n - 4 < 512 { + let mut ack = vec![0, 4]; + ack.extend_from_slice(&block.to_be_bytes()); + other.send_to(&ack, server).expect("final ack"); + break; + } + } + + assert_eq!(got.len(), 3000, "the whole file has to arrive"); + assert_eq!(got, loader(3000)); + + let deadline = std::time::Instant::now() + Duration::from_secs(3); + let mut log = String::new(); + while std::time::Instant::now() < deadline { + log = s.log(); + if log.contains("sent ipxe.kpxe") { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + assert!(log.contains("sent ipxe.kpxe 3000 bytes"), "{log}"); + // And it says the client moved, because that is worth knowing about a ROM. + assert!(log.contains("the client moved to port"), "{log}"); +} From 89bc913e2462a2f70c7ba7ad49c7c73dff24e7b6 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 19:34:53 +0200 Subject: [PATCH 51/59] fix(tftp): decline windowsize instead of agreeing to it and ignoring it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on the wire, from a packet capture on the NAS, after I had wrongly blamed Secure Boot, the block size and the firewall in turn. The option handler echoed `windowsize` back in the OACK — the server saying "yes, four blocks per acknowledgement" — while the transfer loop sent one block and waited for its ACK. A client told four waits for four. So both sides waited, and only the 700 ms retransmit broke the deadlock: every block cost a resend and 700 ms, which makes a 1.1 MB loader take **nine minutes**, and firmware gives up long before that. The capture showed it exactly — block, 700 ms, identical block, then the ACK. The comment above the constant warned that mishandling the window turns one lost packet into a stall. The code then did it. RFC 2347 says an option left out of the OACK is to be treated as never requested, so declining costs a client one acknowledgement per block — under a millisecond on a LAN — and nothing else. Implementing RFC 7440 properly is worth doing and is deliberately not this change: correctness first, and a window is an optimisation. Two tests, and the second had to be rebuilt to earn its keep. It first used the ordinary test client, which acknowledges every block whatever was negotiated — so the deadlock could not occur and it passed with the bug reintroduced. It now honours the window it was granted, like the ROM it stands in for: 0.5 s healthy, 63 s with the bug back. --- CLAUDE.md | 12 +++++ src/boot/tftp.rs | 29 +++++++----- tests/tftp.rs | 117 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b9ab4a8..a6d1414 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -537,6 +537,18 @@ could not check. Note it needs `Resolution::format_name` (the extension), not identical — a machine on a real network fetched a loader, nothing happened, and the log said success. It is reported at the end now, `sent` or `FAILED after N of M`, with 500 so `RESCRIPTUM_LOG=problems` keeps it. +- **Never acknowledge a TFTP option that is not implemented.** `windowsize` (RFC 7440) was + echoed back in the OACK while the transfer loop sent one block and waited for its ACK. A + client told `windowsize 4` waits for four blocks before acknowledging anything, so both + sides waited and only the 700 ms retransmit broke the deadlock — every block costing a + resend and 700 ms, which makes a 1.1 MB loader take **nine minutes** and the firmware + give up first. RFC 2347 says an option left out of the OACK is treated as never + requested, so declining costs one ACK per block and nothing else. Found on the wire, in a + capture on the NAS, after firewalls and block sizes had both been wrongly blamed. +- **A test client that does not behave like the client it stands in for proves nothing.** + The first version of the test for the above acknowledged every block whatever was + negotiated, so the deadlock could not happen and it passed with the bug reintroduced. It + honours the granted window now: 0.5 s healthy, 63 s with the bug back. - **A `connect`ed data socket makes the kernel drop what you most need to see.** It filters on the peer's address *and port*, so a client that acknowledges from a different source port has its packets discarded before any code runs — and the transfer dies diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs index 9ee6e08..71b9d24 100644 --- a/src/boot/tftp.rs +++ b/src/boot/tftp.rs @@ -22,7 +22,8 @@ //! RFC 1350 alone is not enough. The options are what decide whether firmware actually //! works: `blksize` (RFC 2348) because 512-byte blocks make a megabyte take 2,000 //! round-trips, `tsize` (RFC 2349) because a number of ROMs will not proceed without -//! being told the size up front, `timeout` (RFC 2349), and `windowsize` (RFC 7440) — +//! being told the size up front, and `timeout` (RFC 2349). **`windowsize` (RFC 7440) is +//! declined** — see the option table for the nine minutes that cost — //! offered only when asked, because some ROMs get it wrong. //! //! ## UDP is forgeable, so this is defensive by construction @@ -80,9 +81,6 @@ const MAX_TRANSFER: Duration = Duration::from_secs(60); /// out of the boot server it was retrying to reach. const MAX_TRANSFERS: usize = 64; const MAX_PER_PEER: usize = 8; -/// Windowing is offered when asked for, and capped: a ROM that asks for 64 and then -/// mishandles the window turns one lost packet into a stall. -const MAX_WINDOW: u16 = 8; pub struct Tftp { /// The boot-asset directory, canonicalised at start. Every request resolves inside @@ -293,13 +291,22 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { accepted.push(("timeout".to_string(), seconds.to_string())); } } - "windowsize" => { - if let Ok(asked) = value.parse::() - && asked >= 1 - { - accepted.push(("windowsize".to_string(), asked.min(MAX_WINDOW).to_string())); - } - } + // **Declined, and that is the fix rather than a limitation.** This used to + // echo the option back — agreeing to a window — while the transfer loop sent + // one block and waited for its acknowledgement. A client told `windowsize 4` + // waits for four blocks before acknowledging anything, so both sides waited, + // and only the 700 ms retransmit broke the deadlock. Every block then cost a + // resend and 700 ms: a 1.1 MB loader takes nine minutes that way, and the + // firmware gives up long before. Measured on the wire, from a capture on the + // NAS, after several wrong guesses about firewalls and block sizes. + // + // RFC 2347 is explicit that an option the server leaves out of the OACK is to + // be treated as never requested, so declining costs a client nothing but an + // acknowledgement per block — which on a LAN is a round trip of under a + // millisecond. **Agreeing to something not implemented is what cost nine + // minutes.** Implementing RFC 7440 properly is worth doing and is not this + // change: correctness first, and a window is an optimisation. + "windowsize" => {} // An option we do not implement is left out of the OACK, which is exactly // how RFC 2347 says to decline one. _ => {} diff --git a/tests/tftp.rs b/tests/tftp.rs index 1b2f084..25499fe 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -1068,3 +1068,120 @@ fn a_client_that_acknowledges_from_another_port_is_still_served() { // And it says the client moved, because that is worth knowing about a ROM. assert!(log.contains("the client moved to port"), "{log}"); } + +/// **Never agree to an option that is not implemented.** +/// +/// `windowsize` used to be echoed back — the server saying "yes, four blocks per +/// acknowledgement" — while the transfer loop sent one block and waited. A client told +/// four waits for four, so both sides waited, and only the 700 ms retransmit broke the +/// deadlock. Every block then cost a resend and 700 ms: 1.1 MB takes nine minutes that +/// way, and firmware gives up long before. Found on the wire, from a capture on the NAS, +/// after several wrong guesses about firewalls and block sizes. +/// +/// RFC 2347 says an option left out of the OACK is to be treated as never requested, so +/// declining costs a client one acknowledgement per block and nothing else. +#[test] +fn windowsize_is_declined_rather_than_agreed_to_and_ignored() { + let s = Server::start(&[("ipxe.kpxe", loader(8000))]); + let mut client = s.client(); + // Exactly what a UEFI ROM sends: tsize, blksize and windowsize together. + client.read( + "ipxe.kpxe", + &[("tsize", "0"), ("blksize", "1468"), ("windowsize", "4")], + ); + let (opcode, payload) = client.receive().expect("an option reply"); + assert_eq!(opcode, OP_OACK); + let text = String::from_utf8_lossy(&payload); + assert!( + !text.contains("windowsize"), + "agreed to a window it does not implement: {text:?}" + ); + // The options that *are* implemented still come back, so declining one is not + // declining all of them. + assert!( + text.contains("blksize") && text.contains("1468"), + "{text:?}" + ); + assert!(text.contains("tsize"), "{text:?}"); +} + +/// And the whole file still arrives, one acknowledgement per block, without a single +/// retransmission — which is what nine minutes versus a second comes down to. +#[test] +fn a_rom_that_asks_for_a_window_still_gets_its_file_promptly() { + let s = Server::start(&[("ipxe.kpxe", loader(64 * 1024))]); + + // **The client has to behave like the ROM it is standing in for**, or this proves + // nothing: the ordinary test client acknowledges every block whatever was negotiated, + // so the deadlock cannot happen and the test passes with the bug reintroduced. Which + // it did, first time round. This one honours the window it was granted — waiting for + // that many blocks before acknowledging, exactly as RFC 7440 says a client should. + let sock = UdpSocket::bind("127.0.0.1:0").expect("bind"); + sock.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let mut request = vec![0, 1]; + // Built field by field rather than as one byte string: `\\00` reads as an + // octal escape, which in a protocol test is exactly the ambiguity to keep out. + for field in [ + "ipxe.kpxe", + "octet", + "tsize", + "0", + "blksize", + "1468", + "windowsize", + "4", + ] { + request.extend_from_slice(field.as_bytes()); + request.push(0); + } + let started = std::time::Instant::now(); + sock.send_to(&request, &s.tftp_addr).expect("send"); + + let mut buffer = vec![0u8; 4096]; + let (n, server) = sock.recv_from(&mut buffer).expect("a reply"); + let mut window = 1usize; + if u16::from_be_bytes([buffer[0], buffer[1]]) == OP_OACK { + let text = String::from_utf8_lossy(&buffer[2..n]).to_string(); + let parts: Vec<&str> = text.split('\0').collect(); + for pair in parts.windows(2) { + if pair[0].eq_ignore_ascii_case("windowsize") { + window = pair[1].parse().unwrap_or(1); + } + } + sock.send_to(&[0, 4, 0, 0], server) + .expect("ack the options"); + } + + let mut got = Vec::new(); + let mut since_ack = 0usize; + loop { + let Ok((n, _)) = sock.recv_from(&mut buffer) else { + break; + }; + if u16::from_be_bytes([buffer[0], buffer[1]]) != OP_DATA { + break; + } + let last = u16::from_be_bytes([buffer[2], buffer[3]]); + got.extend_from_slice(&buffer[4..n]); + since_ack += 1; + let short = n - 4 < 1468; + if since_ack >= window || short { + let mut ack = vec![0, 4]; + ack.extend_from_slice(&last.to_be_bytes()); + sock.send_to(&ack, server).expect("ack"); + since_ack = 0; + } + if short { + break; + } + } + assert_eq!(got.len(), 64 * 1024, "the whole file has to arrive"); + assert_eq!(got, loader(64 * 1024)); + // 45 blocks at one retransmit each would be 31 seconds. Anything near that is the + // deadlock back. + assert!( + started.elapsed() < Duration::from_secs(5), + "took {:?} — that is the retransmit deadlock, not a transfer", + started.elapsed() + ); +} From 1323e15e04680535ad80b59ced6366df769131ad Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 19:47:11 +0200 Subject: [PATCH 52/59] fix(tftp): say which options the refused reply offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Lenovo ROM asks for `tsize 0 blksize 1468 windowsize 4`, refuses the reply, and retries fifteen seconds later without `tsize` — which succeeds. Its first attempt therefore logs a failure that resolves itself, and which option it objected to was visible only in a packet capture. That is where it was actually found. The line names them now. A client that refuses one option refuses the whole reply, so seeing `tsize=1164800 blksize=1468` beside a second attempt that worked is the diagnosis, without tcpdump. Deliberately not "fixing" it by declining tsize: our answer is what RFC 2349 prescribes — the file's real size for a read request — and three wrong guesses today were enough. It costs fifteen seconds and recovers on its own; a change here would be a fourth guess, and this makes the next person's evidence better instead. --- src/boot/tftp.rs | 25 +++++++++++++++++++------ tests/tftp.rs | 7 +++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs index 71b9d24..ba5caf9 100644 --- a/src/boot/tftp.rs +++ b/src/boot/tftp.rs @@ -381,13 +381,26 @@ async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { it to agree to what the client wants" ) } else { + // **Name the options that were in the reply.** A ROM that refuses + // one of them refuses the whole OACK, and which one it was is + // otherwise only visible in a packet capture — the difference + // between a fifteen-second stumble somebody can read and one they + // have to tcpdump. Seen on a Lenovo that rejects a reply carrying + // `tsize` and then retries without asking for it. format!( - "it never acknowledged the options it asked for itself \ - (blksize={asked_block}). **The reply comes from a fresh port** \ - — that is how TFTP works — so this is what a firewall or a NAT \ - between the two looks like: the request arrives, the answer \ - goes out, and the acknowledgement never comes back. On a NAS, \ - check the firewall; in a container, host networking" + "it never acknowledged the options it asked for itself. The \ + reply offered: {}. A client that refuses one option refuses \ + the whole reply and usually retries without it — if a second \ + attempt succeeds, that is what happened. Otherwise: **the \ + reply comes from a fresh port**, which is how TFTP works, so \ + this is also what a firewall or a NAT between the two looks \ + like — the request arrives, the answer goes out, and the \ + acknowledgement never comes back", + accepted + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" ") ) } ), diff --git a/tests/tftp.rs b/tests/tftp.rs index 25499fe..72ba072 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -1001,6 +1001,13 @@ fn a_handshake_failure_blames_the_cap_only_when_the_cap_did_something() { log.contains("fresh port"), "and it has to point at what this actually looks like: {log}" ); + // **Which options were offered is the whole diagnosis.** A ROM that refuses one + // refuses the whole reply and retries without it; without this line that is only + // visible in a packet capture, which is where it was actually found. + assert!( + log.contains("The reply offered: blksize=1468"), + "the line has to name what was offered: {log}" + ); } /// **A client that answers from a different port must still be served.** From 601c92637e513c752718a44359ab1d26436647d0 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 19:52:42 +0200 Subject: [PATCH 53/59] fix(stanza): the real initrd must not be named, or it is not the initramfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on the machine, in the line that ruled out three days of wrong theories: `Freeing initrd memory: 1720768K`. The kernel unpacked 1.7 GB without a single complaint — so zstd, gzip and every other compression guess was beside the point. It unpacked an initramfs and found no `/init`. In iPXE, `initrd ` gives the image a cpio header and lands it as a *file* in the initramfs; `initrd ` is appended raw and therefore *is* the initramfs. We named Proxmox's real initrd `initrd.img`, so the kernel got an initramfs holding `/initrd.img` and `/proxmox.iso`, nothing to execute, and fell through to mounting a root filesystem — `VFS: Unable to mount root fs on unknown-block(0,0)`. The comment that stood there justified the name as making it "match the `initrd=` above". That was my misreading: `initrd=` is a bootloader directive pxelinux consumes, not a name the kernel resolves, and upstream's own example passes the initrd with no name at all. **The ISO keeps its name**, for the opposite reason — the installer opens it by that name, so it has to be a file. The asymmetry between the two lines is the thing to preserve, and the test now says so. That test previously asserted the bug, under the name "matches what the assistant itself emits". It matched what I had believed the assistant emits. --- CLAUDE.md | 10 ++++++++++ src/boot/stanza.rs | 49 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a6d1414..178b22a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -537,6 +537,16 @@ could not check. Note it needs `Resolution::format_name` (the extension), not identical — a machine on a real network fetched a loader, nothing happened, and the log said success. It is reported at the end now, `sent` or `FAILED after N of M`, with 500 so `RESCRIPTUM_LOG=problems` keeps it. +- **In iPXE, naming an initrd turns it into a *file* rather than the initramfs.** + `initrd ` gets a cpio header and lands as `/name`; `initrd ` is + appended raw and *is* the initramfs. Naming Proxmox's real initrd produced an initramfs + holding `/initrd.img` and `/proxmox.iso` and no `/init` — the kernel unpacked 1.7 GB + without a complaint, found nothing to run, and panicked with `VFS: Unable to mount root + fs on unknown-block(0,0)`. **The ISO does take a name**, because the installer opens it + by that name, so the two lines differ on purpose. `initrd=` on the command line is a + pxelinux directive, not something the kernel resolves against a filename. +- **`Freeing initrd memory: NNNN K` means the unpacking worked.** It is the line that + rules out every compression theory, and three guesses were spent before reading it. - **Never acknowledge a TFTP option that is not implemented.** `windowsize` (RFC 7440) was echoed back in the OACK while the transfer loop sent one block and waited for its ACK. A client told `windowsize 4` waits for four blocks before acknowledging anything, so both diff --git a/src/boot/stanza.rs b/src/boot/stanza.rs index 2f87dfd..d8af03d 100644 --- a/src/boot/stanza.rs +++ b/src/boot/stanza.rs @@ -82,13 +82,26 @@ pub fn ipxe(entry: &Entry, endpoints: &Endpoints) -> Result { "kernel {kernel} ramdisk_size=16777216 rw quiet initrd=initrd.img \\\n\ \x20 splash=silent proxmox-start-auto-installer\n" )); - // The second argument renames the downloaded file inside the initramfs, so - // it matches the `initrd=` above; without it the name would come from the - // URL and the kernel would not find it. - out.push_str(&format!("initrd {initrd} initrd.img\n")); - // The ISO travels as a second initrd, and the installer reads - // `/cdrom/auto-installer-mode.toml` from it. That file is what carries the - // answer URL — nothing on this command line does. + // **No second argument, and that is the whole of it.** In iPXE a name turns an + // initrd into a *file inside* the initramfs — it gets a cpio header — while one + // without a name is appended raw and therefore *is* the initramfs. Naming this + // one produced an initramfs containing `/initrd.img` and `/proxmox.iso` and no + // `/init`, so the kernel unpacked it happily, found nothing to run, fell + // through to mounting a root filesystem and panicked with + // `VFS: Unable to mount root fs on unknown-block(0,0)`. + // + // Measured on a real machine, and the line that gave it away was + // `Freeing initrd memory: 1720768K` — 1.7 GB unpacked without a single + // complaint, which ruled out the decompression the first three guesses were + // about. The comment that used to stand here said the name was needed "so it + // matches the `initrd=` above"; `initrd=` is a bootloader directive that + // pxelinux consumes, not something the kernel resolves against a filename, and + // upstream's own example passes the initrd with no name at all. + out.push_str(&format!("initrd {initrd}\n")); + // The ISO does take a name, and for the opposite reason: it has to *be* a + // file in the initramfs, because the installer opens it by that name. So the + // two lines differ deliberately — one raw, one named — and that asymmetry is + // the thing to preserve. out.push_str(&format!("initrd {image} proxmox.iso\n")); } Family::Debian => { @@ -254,14 +267,26 @@ mod tests { } #[test] - fn the_proxmox_stanza_matches_what_the_assistant_itself_emits() { - // `--pxe-loader ipxe` is upstream's own statement of this stanza, and it is the - // reference this must not drift from: the kernel parameters, the initrd renamed - // to match `initrd=`, and the ISO carried as a second initrd named proxmox.iso. + fn the_real_initrd_is_unnamed_and_the_iso_is_named() { + // **The asymmetry is the whole thing, and getting it wrong cost a real machine.** + // + // In iPXE a name turns an initrd into a *file inside* the initramfs — it gets a + // cpio header — while one without a name is appended raw and therefore *is* the + // initramfs. Naming the real initrd produced an initramfs holding `/initrd.img` + // and `/proxmox.iso` and no `/init`: the kernel unpacked 1.7 GB without a + // complaint, found nothing to run, fell through to mounting a root filesystem and + // panicked with `VFS: Unable to mount root fs on unknown-block(0,0)`. + // + // The ISO takes a name for exactly the opposite reason: the installer opens it by + // that name, so it has to be a file. let script = render(Family::Proxmox); assert!(script.contains("ramdisk_size=16777216 rw quiet initrd=initrd.img")); assert!(script.contains("splash=silent proxmox-start-auto-installer")); - assert!(script.contains("initrd http://192.0.2.10:8001/img/initrd initrd.img")); + assert!( + script.contains("initrd http://192.0.2.10:8001/img/initrd\n"), + "the real initrd must carry no name, or it becomes a file and not the \ + initramfs: {script}" + ); assert!(script.contains("initrd http://192.0.2.10:8001/img/iso proxmox.iso")); // And nothing on the command line names the answer: it rides inside the image. assert!(!script.contains("8000"), "{script}"); From 9a082e67a069ed5f91d9082c05c147573532bd75 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 19:59:19 +0200 Subject: [PATCH 54/59] fix(patch): the mode file's keys are snake case, and a hyphen rejects it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on the machine, at the last link in the chain, after everything before it had been made to work: ERROR: Failed to parse '/cdrom/auto-installer-mode.toml' unknown field `partition-label`, expected one of `mode`, `partition_label`, `http` Installation aborted We wrote `partition-label` and `cert-fingerprint`. `AutoInstSettings` is `deny_unknown_fields`, so one wrong key is a *rejected document*, not a warning: the automated install stops and asks a human who is not coming. The doc comment above the writer said precisely that — "one key this does not know about is a rejected file … Only the five keys upstream defines are ever written" — while the code beneath it wrote two that upstream does not define. And the test that was meant to pin the names enumerated the hyphenated ones, so it guarded the bug. Both fixed, and the names now come from the installer's own refusal rather than from my reading of anything. `cert_fingerprint` is pinned too: it is the path nobody exercises, because it only appears when somebody pins a certificate, and it would have failed the same way at the same place. --- CLAUDE.md | 6 ++++++ src/boot/patch.rs | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 178b22a..015117b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -537,6 +537,12 @@ could not check. Note it needs `Resolution::format_name` (the extension), not identical — a machine on a real network fetched a loader, nothing happened, and the log said success. It is reported at the end now, `sent` or `FAILED after N of M`, with 500 so `RESCRIPTUM_LOG=problems` keeps it. +- **`auto-installer-mode.toml`'s keys are snake case, and one hyphen rejects the file.** + `AutoInstSettings` is `deny_unknown_fields`, so `partition-label` is not a warning — the + installer refuses the whole document and stops, asking a human who is not coming. Its own + refusal enumerates them: `mode`, `partition_label`, `http`; and inside `[http]`, `url`, + `cert_fingerprint`, `token`. The doc comment above the writer said exactly this while the + code wrote two hyphenated keys, and the test enumerated the hyphenated ones too. - **In iPXE, naming an initrd turns it into a *file* rather than the initramfs.** `initrd ` gets a cpio header and lands as `/name`; `initrd ` is appended raw and *is* the initramfs. Naming Proxmox's real initrd produced an initramfs diff --git a/src/boot/patch.rs b/src/boot/patch.rs index 2fea1f0..06bf54e 100644 --- a/src/boot/patch.rs +++ b/src/boot/patch.rs @@ -395,10 +395,18 @@ pub fn mode_file(url: &str, fingerprint: Option<&str>, token: Option<&str>) -> S # to learn where to POST its hardware inventory.\n\ mode = \"http\"\n", ); - out.push_str("partition-label = \"proxmox-ais\"\n\n[http]\n"); + // **Underscores, and the installer is the authority.** It rejected a hyphenated key + // with `unknown field \`partition-label\`, expected one of \`mode\`, + // \`partition_label\`, \`http\`` — on a real machine, after every other link in the + // chain had been made to work. `deny_unknown_fields` means one wrong key is a + // *rejected file*, not a warning, so the whole automated install stops and a human + // who is not coming is asked to continue. + out.push_str("partition_label = \"proxmox-ais\"\n\n[http]\n"); out.push_str(&format!("url = \"{}\"\n", escape(url))); if let Some(fingerprint) = fingerprint { - out.push_str(&format!("cert-fingerprint = \"{}\"\n", escape(fingerprint))); + // Snake case here too, for the same reason — this one has simply never been + // exercised, because it only appears when somebody pins a certificate. + out.push_str(&format!("cert_fingerprint = \"{}\"\n", escape(fingerprint))); } if let Some(token) = token { out.push_str(&format!("token = \"{}\"\n", escape(token))); @@ -615,21 +623,40 @@ mod tests { .filter(|l| !l.trim_start().starts_with('#') && l.contains('=')) .map(|l| l.split('=').next().unwrap_or("").trim()) .collect(); + // **The names are the installer's, quoted from its own refusal.** It stopped a + // real machine with `unknown field \`partition-label\`, expected one of + // \`mode\`, \`partition_label\`, \`http\`` — after every other link in the + // chain had been made to work, which is the expensive place to learn it. for key in &keys { assert!( [ "mode", - "partition-label", + "partition_label", "url", - "cert-fingerprint", + "cert_fingerprint", "token" ] .contains(key), "{key} is not a key the installer defines" ); } + // Said separately because it is the specific mistake that was made: every key + // here is snake case, and a hyphen anywhere in this file rejects the whole of it. + assert!( + !keys.iter().any(|k| k.contains('-')), + "a hyphenated key rejects the entire file: {keys:?}" + ); assert!(text.contains("mode = \"http\""), "{text}"); assert!(text.contains("[http]"), "{text}"); + + // The fingerprint path is the one nobody exercises, so it is pinned here rather + // than left to be discovered by whoever first pins a certificate. + let with_cert = mode_file("http://192.0.2.10:8000/proxmox", Some("AB:CD"), None); + assert!( + with_cert.contains("cert_fingerprint = \"AB:CD\""), + "{with_cert}" + ); + assert!(!with_cert.contains("cert-fingerprint"), "{with_cert}"); } #[test] From 85cbdb4895e9cb9dabbecc7b624e3f0fe2f3e553 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 20:32:42 +0200 Subject: [PATCH 55/59] docs: a real machine installed itself, and the traps that stood in the way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goal this project is written against, reached on hardware: a Lenovo vPro machine powered on and installed itself with Proxmox VE 9.2, unattended, from a DS416j. DHCP handoff, TFTP, branded iPXE, the answer that claimed it, kernel and initrd and a 1.6 GB image over HTTP, the injected mode file, and the machine's own answer.toml. Recorded here is the last one, which is not ours and cost the evening anyway: **Intel AMT with a static address on a NIC it shares with the host.** The installer's dhclient gives up after about eleven seconds, and with the Management Engine holding the interface statically no offer arrives — so the install aborts on `Network is unreachable` while `dhclient -v eno1` from its own shell succeeds instantly afterwards. The network is fine; the timing is not. Setting AMT to DHCP fixes it, and nothing here can widen that window. The guide gains a section for the class: when everything is right and the machine still will not install, the installer's root shell is the fastest diagnosis there is. Worth stating plainly in the plan: seven defects stood between "every harness green" and a machine that installs, and **not one of them was reachable by the rig**, which boots BIOS under TCG. --- CLAUDE.md | 6 ++++++ docs/guide/operations/netboot.fr.md | 31 +++++++++++++++++++++++++++++ docs/guide/operations/netboot.md | 31 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 015117b..ae2b397 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -537,6 +537,12 @@ could not check. Note it needs `Resolution::format_name` (the extension), not identical — a machine on a real network fetched a loader, nothing happened, and the log said success. It is reported at the end now, `sent` or `FAILED after N of M`, with 500 so `RESCRIPTUM_LOG=problems` keeps it. +- **Intel AMT with a static address starves the host's DHCP on a shared NIC.** The + Proxmox installer's `dhclient` gives up after about eleven seconds; with the Management + Engine holding the interface statically while the host asks for a lease, no offer + arrives and the install aborts on `Network is unreachable` — while `dhclient -v eno1` + from the installer's own shell succeeds instantly afterwards. Setting AMT to DHCP fixes + it. Nothing here can widen that window, so it is documented rather than worked around. - **`auto-installer-mode.toml`'s keys are snake case, and one hyphen rejects the file.** `AutoInstSettings` is `deny_unknown_fields`, so `partition-label` is not a warning — the installer refuses the whole document and stops, asking a human who is not coming. Its own diff --git a/docs/guide/operations/netboot.fr.md b/docs/guide/operations/netboot.fr.md index be00602..236a985 100644 --- a/docs/guide/operations/netboot.fr.md +++ b/docs/guide/operations/netboot.fr.md @@ -320,6 +320,37 @@ L'endpoint accepte les deux formes du justificatif : l'`auth-token` de Proxmox a le corps JSON parce que c'est ce que Proxmox envoie, et un en-tête bearer parce que c'est ce qu'envoie un script shell. Même secret, même comparaison en temps constant. +## Quand tout est juste et que la machine refuse quand même de s'installer + +La chaîne peut être parfaite et échouer à la dernière marche, côté machine et non côté +serveur. Deux cas réellement rencontrés, tous deux sur un Lenovo vPro : + +**Intel AMT en adresse statique, sur une carte partagée avec le système.** Le `dhclient` de +l'installateur envoie deux requêtes à onze secondes d'intervalle puis abandonne ; si le +Management Engine tient l'interface avec une configuration statique pendant que l'hôte +demande du DHCP, ces onze secondes passent sans offre et l'installation s'arrête sur +`Fetching answer file via HTTP failed: Network is unreachable`. **Mettez l'AMT en DHCP +aussi.** Lancer `dhclient -v eno1` à la main depuis le shell de l'installateur réussit +ensuite immédiatement, et c'est ce qui rend le diagnostic déroutant : le réseau va bien, +c'est la temporisation qui ne va pas. + +**Un port de commutateur qui ne transmet pas tout de suite**, pour la même raison et avec +le même symptôme — RSTP en convergence, ou un lien encore en négociation après que le noyau +a repris la carte des mains d'iPXE. Ce serveur n'y peut rien dans les deux cas : les onze +secondes sont la fenêtre de l'installateur, pas la nôtre. + +L'installateur ouvre un shell root quand il abandonne, et ce shell est le diagnostic le +plus rapide qui soit : + +```console +# ip link # l'interface est-elle seulement montée ? +# dhclient -v eno1 # une offre revient-elle quand on la demande à la main ? +# ip addr show eno1 +``` + +Une adresse qui apparaît là et pas pendant l'installation veut dire que le réseau +fonctionne et que la machine a simplement demandé trop tôt. + ## Comment iPXE finit par parler à *nous* La question qu'on ne s'attend pas à devoir trancher. Quel que soit le livreur du diff --git a/docs/guide/operations/netboot.md b/docs/guide/operations/netboot.md index 8d25507..7e476bb 100644 --- a/docs/guide/operations/netboot.md +++ b/docs/guide/operations/netboot.md @@ -304,6 +304,37 @@ The endpoint takes the credential either way — Proxmox's `auth-token` arrives JSON body because that is what Proxmox sends, and a bearer header because that is what a shell script sends. Same secret, same constant-time comparison. +## When everything is right and the machine still will not install + +The chain can be perfect and fail at the last step, on the machine rather than on the +server. Two that have actually happened, both on a Lenovo with vPro: + +**Intel AMT with a static address, on a NIC it shares with the host.** The installer's own +`dhclient` sends two requests about eleven seconds apart and then gives up; if the +Management Engine holds the interface with a static configuration while the host asks for +DHCP, those eleven seconds pass with no offer and the install aborts with +`Fetching answer file via HTTP failed: Network is unreachable`. **Set AMT to DHCP too.** +Running `dhclient -v eno1` by hand from the installer's shell afterwards succeeds +immediately, which is what makes this so confusing to diagnose: the network is fine, the +timing is not. + +**A switch port that does not forward straight away**, for the same reason and with the +same symptom — RSTP converging, or a link still negotiating after the kernel takes the NIC +over from iPXE. There is nothing this server can do about either: eleven seconds is the +installer's window, not ours. + +The installer drops to a root shell when it aborts, and that shell is the fastest +diagnosis there is: + +```console +# ip link # is the interface up at all? +# dhclient -v eno1 # does an offer come back when asked by hand? +# ip addr show eno1 +``` + +An address appearing there and not during the install means the network works and the +machine simply asked too early. + ## How iPXE ends up talking to *us* The question nobody expects to have to answer. Whatever delivers the loader: From bda8e9151701675eaac98c59c1c4724f2c701ef7 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Fri, 28 Aug 2026 20:38:57 +0200 Subject: [PATCH 56/59] docs: bring the counts and the record up to what today established MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numbers counted rather than remembered: 571 tests (545 → 571 over the day's fixes), and the package harnesses at 28 / 85 / 52 where the page still said 26 / 58 / 47. The per-suite table gains `src/installed.rs` and the two suites that grew most — `tests/tftp.rs` 21 → 30, which is where most of today's defects were caught, and `tests/integration.rs` 45 → 48. The machine harness's row says what it now owns: the only route to port 69, and whether the NAS can reach a vendor's image index — the one part of this package that talks to the internet. And the plan records the half of the milestone I had not checked before calling it unproven: the machine **disarmed itself**. The installer called `POST /installed` before rebooting, the server renamed its `.ipxe` claim out of the way, and it came back up on its own disk. Unattended, first time, with nobody watching for it. --- CLAUDE.md | 2 +- docs/development/testing.fr.md | 15 ++++++++------- docs/development/testing.md | 17 +++++++++-------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ae2b397..edd4b09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1024,7 +1024,7 @@ the image and not derived from `DISK_SIZE`. `run-vm.sh` is the loader-image fall ## Testing expectations -545 tests, plus the package's own harnesses (see *The DSM package*, and note that +571 tests, plus the package's own harnesses (see *The DSM package*, and note that `cargo test` does not run those). `docs/development/testing.md` has the per-suite table; the rules that decide where a test goes: diff --git a/docs/development/testing.fr.md b/docs/development/testing.fr.md index 13e2b3d..7fa24b9 100644 --- a/docs/development/testing.fr.md +++ b/docs/development/testing.fr.md @@ -8,7 +8,7 @@ sidebar: # Tests -545 tests. `cargo test` les fait tous tourner en une vingtaine de secondes — dont +571 tests. `cargo test` les fait tous tourner en une vingtaine de secondes — dont l'essentiel dans `tests/tftp.rs`, qui attend de vrais délais UDP parce que c'est précisément ce qu'il teste. @@ -28,22 +28,23 @@ cargo test --all-features # ce que lance la CI | Suite | Cas | Pour | |---|---|---| +| `tests/integration.rs` | 48 | le vrai binaire sur une vraie socket | | `tests/cli.rs` | 47 | `render`, `check`, `import`, `export`, `config` et le fichier d'environnement — contre le vrai binaire | -| `tests/integration.rs` | 45 | le vrai binaire sur une vraie socket | | `tests/media.rs` | 45 | les médias de démarrage contre le vrai binaire, les deux listeners debout | | `src/config.rs` | 42 | l'environnement, ce qui refuse de démarrer, et qui l'emporte du fichier ou de l'environnement | | `tests/stores.rs` | 39 | **chaque comportement, contre les deux stores** | +| `tests/tftp.rs` | 30 | le TFTP sur de l'UDP réel : les tours de parole, et ce qu'une liaison ratée ne doit pas coûter | | `src/select.rs` | 27 | normalisation, scoring, superposition, remplissage de templates | | `src/format/mod.rs` | 27 | parsing, fusion, clés de contrôle, alias d'endpoint | | `tests/admin.rs` | 26 | l'API d'administration de bout en bout, formats compris | | `src/envfile.rs` | 23 | le parseur et l'écrivain du fichier d'environnement, et ce que chacun refuse | | `src/facts.rs` | 22 | parsing de query, aplatissement JSON, globbing | -| `tests/tftp.rs` | 21 | le TFTP sur de l'UDP réel, et ce qu'une liaison ratée ne doit pas coûter | | `src/format/xml.rs` | 18 | l'arbre XML — appariement, entités, fidélité | | `src/merge.rs` | 11 | la fusion profonde TOML | | `tests/guards.rs` | 7 | le jeton de réponse, et le verrouillage qui délibérément n'existe pas | +| `src/installed.rs` | 6 | une machine qui signale son installation, et ce qu'il ne faut jamais désarmer | | `src/log.rs` | 4 | lecture des niveaux, et l'arithmétique d'horodatage | -| `src/boot/*.rs` | 120 | le lecteur ISO, le repérage, le catalogue, les plans de patch, le menu, la table des chargeurs, les extraits DHCP, cpio et SHA-256 | +| `src/boot/*.rs` | 128 | le lecteur ISO, le repérage, le catalogue, les sources d'images, les plans de patch, le menu, la table des chargeurs, les extraits DHCP, cpio et SHA-256 | | `src/admin.rs`, `src/capture.rs`, `src/store/mod.rs` | 21 | comportement unitaire | ## `tests/stores.rs` — la suite de conformité @@ -184,7 +185,7 @@ harnais s'en chargent, et chacun prouve ce que les autres ne peuvent pas. |---|---|---| | [`packaging/dsm/check-spk.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/check-spk.sh) | l'archive est structurellement ce que DSM attend — tar externe non compressé, les six champs d'`INFO`, une version tout en segments numériques, `os_min_ver` au moins 7.1, icônes 64×64 et 256×256, scripts exécutables sans CRLF, **le `--version` du binaire empaqueté**, et l'application de bureau : un `dsmappname` nommant une classe que son `ui/config` déclare vraiment, un nom de fichier JavaScript qui porte la version, et un backend qui vérifie toujours la session DSM et `administrators` | des secondes, **à chaque push** | | [`packaging/dsm/lifecycle-test.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/lifecycle-test.sh) | tout ce que les *scripts* du paquet décident, contre un faux arbre `/var/packages` : le fichier d'environnement écrit une fois et une seule, les valeurs de l'assistant **et leur absence**, le service qui survit à son propre script de démarrage et répond à `/health`, les codes de sortie que lit Package Center, une mise à jour qui ne doit pas toucher une configuration éditée à la main, une désinstallation qui ne doit pas toucher aux réponses — **et le backend de l'application de bureau**, piloté avec un authentificateur bouchonné : refuser l'absence de session, refuser un non-administrateur, refuser une écriture sans en-tête d'intention, refuser celle qui empêcherait le serveur de démarrer, et ne jamais livrer un jeton au navigateur | des secondes, **à chaque push** | -| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | la machinerie propre à DSM — le worker `data-share` et son ACL, le worker `port-config`, l'unité systemd générée, logrotate contre un descripteur vivant, si Package Center accepte l'archive — **et qu'une machine qui demande sa configuration en reçoit une** : un POST avec le matériel dans le corps, auquel répond le fichier de cette machine fusionné par-dessus le groupe qui la revendique. Elle porte aussi **la seule route vers le port 69** : que `69/udp` survive dans l'entrée de pare-feu acquise, que le paquet réponde encore sans la capacité, et que `setcap cap_net_bind_service=+ep` puis un redémarrage lient `udp/69` sous le processus non privilégié du paquet | des minutes, sur une VM DSM 7 — puis sur le DS416j | +| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | la machinerie propre à DSM — le worker `data-share` et son ACL, le worker `port-config`, l'unité systemd générée, logrotate contre un descripteur vivant, si Package Center accepte l'archive — **et qu'une machine qui demande sa configuration en reçoit une** : un POST avec le matériel dans le corps, auquel répond le fichier de cette machine fusionné par-dessus le groupe qui la revendique. Elle porte aussi **la seule route vers le port 69** et la capacité de ce NAS à atteindre l'index d'un éditeur : que `69/udp` survive dans l'entrée de pare-feu acquise, que le paquet réponde encore sans la capacité, et que `setcap cap_net_bind_service=+ep` puis un redémarrage lient `udp/69` sous le processus non privilégié du paquet | des minutes, sur une VM DSM 7 — puis sur le DS416j | ```bash packaging/dsm/lifecycle-test.sh # le premier .spk de dist/ qui tourne ici @@ -210,8 +211,8 @@ La même règle que partout ailleurs vaut pour eux : **cassez ce qu'ils gardent regardez-les virer au rouge.** Annuler la garde de `postinst` à la mise à jour, faire supprimer le partage par `postuninst`, renvoyer `1` pour un paquet arrêté et refuser `prestart` transforme 33 vérifications vertes en 25 vertes et 8 rouges — c'est ainsi qu'on -sait que le harnais teste quelque chose. Aujourd'hui c'est **58** vérifications dans -`lifecycle-test.sh`, 26 dans `check-spk.sh` et **47** sur la machine ; les trois dernières +sait que le harnais teste quelque chose. Aujourd'hui c'est **85** vérifications dans +`lifecycle-test.sh`, **28** dans `check-spk.sh` et **52** sur la machine ; les trois dernières ajoutées ont chacune été vues rouges de la même façon — en remettant `RESCRIPTUM_TFTP_ADDR=off`, en supprimant le rapport du panneau sur l'état du TFTP, et en lui faisant prétendre qu'il livre alors que rien n'est lié. diff --git a/docs/development/testing.md b/docs/development/testing.md index 8c89e63..5b1f2a6 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -8,7 +8,7 @@ sidebar: # Testing -545 tests. `cargo test` runs all of them in about twenty seconds — most of that is +571 tests. `cargo test` runs all of them in about twenty seconds — most of that is `tests/tftp.rs`, which waits on real UDP timeouts because that is what it is testing. **`cargo test` does not run the harnesses that matter most**: the boot rig, the DSM @@ -26,22 +26,23 @@ cargo test --all-features # what CI runs | Suite | Cases | For | |---|---|---| -| `tests/cli.rs` | 47 | `render`, `check`, `import`, `export`, `config`, and the env file — against the real binary | -| `tests/integration.rs` | 45 | the real binary over a real socket | +| `tests/integration.rs` | 48 | the real binary over a real socket | +| `tests/cli.rs` | 47 | `render`, `check`, `import`, `export`, `config` and the env file — against the real binary | | `tests/media.rs` | 45 | boot media against the real binary, with both listeners up | | `src/config.rs` | 42 | the environment, what refuses to start, and which of the file and the environment wins | | `tests/stores.rs` | 39 | **every behaviour, against both stores** | +| `tests/tftp.rs` | 30 | TFTP over real UDP: the turn-taking, and what a failed bind must not cost | | `src/select.rs` | 27 | normalization, scoring, layering, template filling | | `src/format/mod.rs` | 27 | parsing, merging, control keys, endpoint aliases | | `tests/admin.rs` | 26 | the admin API end to end, formats included | | `src/envfile.rs` | 23 | the env-file parser and writer, and what each refuses | | `src/facts.rs` | 22 | query parsing, JSON flattening, globbing | -| `tests/tftp.rs` | 21 | TFTP over real UDP, and what a failed bind must not cost | | `src/format/xml.rs` | 18 | the XML tree — pairing, entities, fidelity | | `src/merge.rs` | 11 | the TOML deep merge | | `tests/guards.rs` | 7 | the answer token, and the lockout that deliberately is not there | +| `src/installed.rs` | 6 | a machine reporting it installed, and what must never be disarmed | | `src/log.rs` | 4 | level parsing, and the timestamp arithmetic | -| `src/boot/*.rs` | 120 | the ISO reader, probing, the catalogue, patch plans, the menu, the loader table, DHCP snippets, cpio and SHA-256 | +| `src/boot/*.rs` | 128 | the ISO reader, probing, the catalogue, image sources, patch plans, the menu, the loader table, DHCP snippets, cpio and SHA-256 | | `src/admin.rs`, `src/capture.rs`, `src/store/mod.rs` | 21 | unit-level behaviour | ## `tests/stores.rs` — the conformance suite @@ -173,7 +174,7 @@ do, and each proves something the others cannot. |---|---|---| | [`packaging/dsm/check-spk.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/check-spk.sh) | the archive is structurally what DSM expects — uncompressed outer tar, six `INFO` fields, an all-numeric version, `os_min_ver` at least 7.1, 64×64 and 256×256 icons, executable scripts with no CRLF, **the packaged binary's own `--version`**, and the desktop application: `dsmappname` naming a class its `ui/config` actually declares, a JavaScript filename that carries the version, and a backend that still checks the DSM session and `administrators` | seconds, **on every push** | | [`packaging/dsm/lifecycle-test.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/lifecycle-test.sh) | everything the package's *scripts* decide, against a fake `/var/packages` tree: the env file written once and only once, the wizard's values **and their absence**, the service surviving its own start script and answering `/health`, the exit codes Package Center reads, an upgrade that must not touch a hand-edited configuration, an uninstall that must not touch the answers — **and the desktop application's backend**, driven with a stubbed authenticator: refusing no session, refusing a non-administrator, refusing a write with no intent header, refusing one that would stop the server starting, and never handing a token to the browser | seconds, **on every push** | -| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | DSM's own machinery — the `data-share` worker and its ACL, the `port-config` worker, the generated systemd unit, logrotate against a live descriptor, whether Package Center accepts the archive — **and that a machine asking for its configuration gets one**: a POST with hardware in the body, answered by that machine's file merged over the group claiming it. It also owns **the only route to port 69**: that `69/udp` survives into the acquired firewall entry, that the package still answers without the capability, and that `setcap cap_net_bind_service=+ep` plus a restart binds `udp/69` as the unprivileged package process | minutes, on a DSM 7 VM — and then on the DS416j | +| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | DSM's own machinery — the `data-share` worker and its ACL, the `port-config` worker, the generated systemd unit, logrotate against a live descriptor, whether Package Center accepts the archive — **and that a machine asking for its configuration gets one**: a POST with hardware in the body, answered by that machine's file merged over the group claiming it. It also owns **the only route to port 69** and whether this NAS can reach a vendor's image index: that `69/udp` survives into the acquired firewall entry, that the package still answers without the capability, and that `setcap cap_net_bind_service=+ep` plus a restart binds `udp/69` as the unprivileged package process | minutes, on a DSM 7 VM — and then on the DS416j | ```bash packaging/dsm/lifecycle-test.sh # the first .spk in dist/ that runs here @@ -198,8 +199,8 @@ The same rule as everywhere else applies to these: **break the thing they guard them go red.** Reverting the `postinst` upgrade guard, making `postuninst` delete the share, returning `1` for a stopped package and refusing `prestart` turns 33 green checks into 25 green and 8 red — which is how we know the harness is testing anything at all. -Today it is **58** checks in `lifecycle-test.sh`, 26 in `check-spk.sh` and **47** on the -machine; the three most recently added were each watched red the same way — by putting +Today it is **85** checks in `lifecycle-test.sh`, **28** in `check-spk.sh` and **52** on +the machine; the three most recently added were each watched red the same way — by putting `RESCRIPTUM_TFTP_ADDR=off` back, by deleting the panel's report of the TFTP state, and by making it claim to be serving with nothing bound. From 19e447561118979b4a67035222ffcc1239a30448 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Sat, 29 Aug 2026 19:19:37 +0200 Subject: [PATCH 57/59] feat(store)!: a directory per identity, not a file per answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A machine's answers were files sharing a stem: `98fa9b50d810.toml` beside `98fa9b50d810.preseed`. The stem was the identity, the extension the format, and nothing held the two together — a machine's documents were only adjacent by sorting. They are a directory now: answers/98-fa-9b-50-d8-10/proxmox.toml /debian.preseed /boot.ipxe The directory name is the identity; **the extension is the format and the stem is nothing at all**. `proxmox.toml` and `answer.toml` are one document to this server, so the name is free for whoever opens the folder. `canonical_stem` picks a readable one for a document nobody has named, and a write overwrites an existing document *where it stands* — an operator's name survives. Two documents of one format in one directory is a **reported problem**, never a silent choice: there is no tiebreak anyone could have predicted. Sorted order decides which answers, so it does not depend on readdir, and the loser is named. Groups and the fallback take the same shape, so there is one rule rather than three — `groups/rack-a/proxmox.toml`, `default/proxmox.toml`. Both names are reserved as machine ids in **both** stores: a database that accepted `groups` would export into a directory that cannot hold it, and `export` has to stay a way out. A servable document left flat is reported with its destination and **not served**. Half-reading the old layout would mean a machine whose answer moved silently between two files, which is the failure this server exists to prevent. `rescriptum migrate` shows what it would move and `--apply` moves it; one taken destination aborts the whole run rather than leaving a half-migrated directory. Disarming stays a sibling directory (`installed-/`) rather than a prefixed file inside the machine's own. The machine's directory keeps meaning "this machine's configuration", no new exclusion rule is needed — the directory name identifies nothing — and `installed.rs` did not change. Measured cost, on an M1 Pro at 2,000 machines: a full reload goes from 28.6 ms to 63.5 ms, a `readdir` per identity on top of the file already opened. It is syscalls, not allocation — removing the allocations moved nothing. Amortised over a second of requests by the listing cache, and end-to-end throughput did not move measurably. The mtime also sees less: a document added *inside* a machine's directory is one level below what is watched, so the backstop catches it rather than the version token. Tests pin both halves. BREAKING CHANGE: answer documents must live in a directory named after their identity. Documents left at the top of the answers directory are reported and no longer served; run `rescriptum migrate --apply` to move them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6 --- CLAUDE.md | 392 ++++----------- README.fr.md | 12 +- README.md | 15 +- docs/CLAUDE.md | 49 ++ docs/development/selection.fr.md | 23 +- docs/development/selection.md | 18 +- docs/development/stores.fr.md | 46 +- docs/development/stores.md | 44 +- docs/development/testing.fr.md | 25 +- docs/development/testing.md | 24 +- docs/development/traps.fr.md | 13 +- docs/development/traps.md | 13 +- docs/guide/answers/formats.fr.md | 28 +- docs/guide/answers/formats.md | 23 +- docs/guide/answers/grouping.fr.md | 35 +- docs/guide/answers/grouping.md | 37 +- docs/guide/answers/index.fr.md | 63 ++- docs/guide/answers/index.md | 63 ++- docs/guide/answers/selection.fr.md | 39 +- docs/guide/answers/selection.md | 37 +- docs/guide/answers/templating.fr.md | 10 +- docs/guide/answers/templating.md | 10 +- docs/guide/answers/validating.fr.md | 8 +- docs/guide/answers/validating.md | 6 +- docs/guide/index.fr.md | 16 +- docs/guide/index.md | 14 +- docs/guide/iso.fr.md | 4 +- docs/guide/iso.md | 4 +- docs/guide/operations/admin-api.fr.md | 9 +- docs/guide/operations/admin-api.md | 8 +- docs/guide/operations/media.fr.md | 2 +- docs/guide/operations/media.md | 2 +- docs/guide/operations/netboot.fr.md | 19 +- docs/guide/operations/netboot.md | 19 +- docs/guide/quickstart.fr.md | 33 +- docs/guide/quickstart.md | 29 +- docs/guide/reference/cli.fr.md | 35 +- docs/guide/reference/cli.md | 34 +- docs/home.fr.md | 17 +- docs/home.md | 17 +- .../ubuntu.yml} | 2 +- .../answer.json} | 0 .../answer.xml} | 0 .../debian.preseed} | 0 .../proxmox.toml} | 3 +- examples/README.md | 71 +-- .../debian.seed} | 4 +- .../ubuntu.yaml} | 2 +- .../{example.toml => example/proxmox.toml} | 4 +- .../{base.preseed => base/debian.preseed} | 0 .../boot.ipxe} | 0 .../proxmox.toml} | 2 +- .../flatcar.ign} | 0 .../answer.cfg} | 0 .../{rhel-compute.ks => rhel-compute/rhel.ks} | 0 .../suse.autoyast} | 0 .../ubuntu.yaml} | 0 .../ubuntu.yaml} | 2 +- .../windows.unattend} | 0 .../boot.ipxe} | 0 packaging/dsm/CLAUDE.md | 218 +++++++++ packaging/dsm/lifecycle-test.sh | 7 +- packaging/dsm/vm/remote-check.sh | 26 +- src/cli.rs | 120 ++++- src/format/mod.rs | 51 ++ src/installed.rs | 63 ++- src/main.rs | 1 + src/select.rs | 96 +++- src/store/file.rs | 447 ++++++++++++++---- src/store/mod.rs | 43 +- src/store/sqlite.rs | 24 +- tests/admin.rs | 7 +- tests/cli.rs | 148 +++++- tests/common/mod.rs | 80 ++++ tests/guards.rs | 4 +- tests/integration.rs | 36 +- tests/media.rs | 18 +- tests/stores.rs | 282 ++++++++++- tests/tftp.rs | 4 +- 79 files changed, 2240 insertions(+), 820 deletions(-) create mode 100644 docs/CLAUDE.md rename examples/{52-54-00-aa-00-04.yml => 52-54-00-aa-00-04/ubuntu.yml} (90%) rename examples/{52-54-00-aa-00-05.json => 52-54-00-aa-00-05/answer.json} (100%) rename examples/{52-54-00-aa-00-06.xml => 52-54-00-aa-00-06/answer.xml} (100%) rename examples/{98fa9b50d810.preseed => 98fa9b50d810/debian.preseed} (100%) rename examples/{98fa9b50d810.toml => 98fa9b50d810/proxmox.toml} (82%) rename examples/{aabbccddeeff.seed => aabbccddeeff/debian.seed} (84%) rename examples/{aabbccddeeff.yaml => aabbccddeeff/ubuntu.yaml} (80%) rename examples/{example.toml => example/proxmox.toml} (94%) rename examples/groups/{base.preseed => base/debian.preseed} (100%) rename examples/groups/{edge-router.ipxe => edge-router/boot.ipxe} (100%) rename examples/groups/{example-rack.toml => example-rack/proxmox.toml} (93%) rename examples/groups/{flatcar-node.ign => flatcar-node/flatcar.ign} (100%) rename examples/groups/{legacy-node.cfg => legacy-node/answer.cfg} (100%) rename examples/groups/{rhel-compute.ks => rhel-compute/rhel.ks} (100%) rename examples/groups/{suse-node.autoyast => suse-node/suse.autoyast} (100%) rename examples/groups/{ubuntu-meta.yaml => ubuntu-meta/ubuntu.yaml} (100%) rename examples/groups/{ubuntu-web.yaml => ubuntu-web/ubuntu.yaml} (95%) rename examples/groups/{windows-node.unattend => windows-node/windows.unattend} (100%) rename packaging/boot-rig/answers/{98-fa-9b-50-d8-10.ipxe => 98-fa-9b-50-d8-10/boot.ipxe} (100%) create mode 100644 packaging/dsm/CLAUDE.md create mode 100644 tests/common/mod.rs diff --git a/CLAUDE.md b/CLAUDE.md index edd4b09..a576e38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,8 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit directly. **Never re-declare a module in `main.rs`**: it compiles a second copy, runs every unit test twice, and lets the two copies drift. - `src/store/` — where documents come from. `mod.rs` defines the thin `Store` / `StoreWrite` - traits, `file.rs` a flat directory of documents, `sqlite.rs` a bundled-SQLite database. + traits, `file.rs` a **directory per identity** (see *Layout on disk*), `sqlite.rs` a + bundled-SQLite database. - `src/boot/` — **boot media**: where the installer itself comes from, as opposed to what it is told. `sources.rs` is the odd one out: it is where images can be fetched *from*, and it stores **nothing about any specific image** — each entry names the checksum index @@ -127,7 +128,9 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit - `src/format/` — one interface per document format. `xml.rs` holds the XML tree and its merge rules. - `src/merge.rs` — the TOML merge, used by `format`. -- `src/cli.rs` — the `render`, `check`, `import`, `export` and `config` subcommands. +- `src/cli.rs` — the `render`, `check`, `import`, `export`, `migrate` and `config` + subcommands. `migrate` **shows by default and moves only on `--apply`**, and a single + taken destination aborts the whole run rather than leaving a half-migrated directory. `config` is dispatched **before** `Config::from_env` and `validate`, unlike every other one: a file that will not parse and a token one character short are the states people run it to get *out* of, so it loads the file itself and reports rather than dying. @@ -138,7 +141,8 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit dropped. **The one path where something arriving over the network changes the answer set**, so it is narrow by construction: machine documents only (never a group — one machine finishing must not disarm a rack), format `ipxe` only (the `.toml` is the record - of how it was built), and moved under an `installed-` prefix rather than deleted. The + of how it was built), and moved into an `installed-/` **sibling directory** rather + than deleted — a name that identifies nothing, so no new exclusion rule is needed. The token is Proxmox's, and it arrives **in the JSON body**, not as a bearer — so the route runs before the answer token's guard, which would otherwise reject every webhook. It also takes a bearer header and the identity from the query, because **Proxmox is the @@ -164,6 +168,11 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit it could not report something. - `src/main.rs` — runtime setup, accept loop, connection serving, routing, logging, and the blocking `resolve()` half of a request. +- `tests/common/mod.rs` — the one thing every suite shares: `seed()` writes a fixture named + the way a test thinks of it (`98fa9b50d810.toml`, `groups/rack-a.toml`, `default.toml`) + **through `StoreWrite`**, so it lands exactly where an admin-API write would and cannot + drift from the layout. A name the store would refuse is written literally, because those + fixtures exist to prove a stray file answers nothing. One copy, not one per suite. - `tests/integration.rs` — starts the real binary on an ephemeral port (it prints the address it actually bound, so there is no port race) and talks HTTP to it. It keeps the server's stderr, which is what makes a startup warning assertable. @@ -227,8 +236,9 @@ and must not move because someone renamed a folder. An earlier design made the d name *be* the URL segment and was discarded for exactly that reason. The consequence that makes the model click: **a machine's answer is specific to the OS it -is for**, so `98fa9b50d810.toml` is not "that machine" but "that machine as Proxmox". -`98fa9b50d810.preseed` is the same hardware as Debian, and both exist at once. The store's +is for**, so `98fa9b50d810/proxmox.toml` is not "that machine" but "that machine as +Proxmox". `98fa9b50d810/debian.preseed`, beside it, is the same hardware as Debian, and both +exist at once. The store's key is therefore **(id, format)**, not id — which is what the SQLite schema is built around. Two traps in the alias table: @@ -297,20 +307,20 @@ format** — a YAML machine file over a TOML group is refused, not half-served. ## Grouping and merging -A datacenter has a file per machine, and machines in a rack share almost everything. Answer -files therefore compose: +A datacenter has a directory per machine, and machines in a rack share almost everything. +Answer documents therefore compose: ```text answers/ groups/ - base.toml shared by everything - rack-a.toml extends = "base"; members = [ ...MACs... ] - 98-fa-9b-50-d8-10.toml one machine's overrides (optional) - default.toml only when nothing else matches + base/proxmox.toml shared by everything + rack-a/proxmox.toml extends = "base"; members = [ ...MACs... ] + 98-fa-9b-50-d8-10/proxmox.toml one machine's overrides (optional) + default/proxmox.toml only when nothing else matches ``` - A **group** claims machines by listing them in `members`. Member strings are normalized the - same way filenames are, so separator style does not matter. + same way directory names are, so separator style does not matter. - A group may `extends` another group, giving a chain. Cycles and missing parents are detected at load, reported once, and the broken group is dropped rather than half-applied. - A **machine file** layers on top of whichever group claimed it. `extends` in a machine file @@ -617,62 +627,59 @@ could not check. Note it needs `Resolution::format_name` (the extension), not and SeaBIOS says "could not read the boot disk". Use `pc` for a BIOS guest. - **`sed -n … "$0"` cannot find a relatively-invoked script after a `cd`.** Resolve the path first, or `--help` breaks for everyone who does not type an absolute path. -- **There is exactly one route to port 69 on DSM 7, and it is `setcap`.** `run-as: root` - in `conf/privilege` is refused with `synopkg` error **319**, `invalid package privilege - content` — in `defaults` *and* as a per-action `ctrl-script`, the shape Synology's own - packages use. A `security.capability` xattr in `package.tgz` installs and **Package - Center strips it**. `setcap cap_net_bind_service=+ep` after install works; - `net.ipv4.ip_unprivileged_port_start` does not exist on that kernel. Measured on a 7.2.2 - machine, all four. -- **Root on DSM 7 is gated on the *signature*, and `libsynopkg.so.1` says so.** Its - strings carry the whole rule: a package failing `verifyPackageSignature` may not have a - `ctrl-script` or `executable` section, must have `defaults.run-as` = `package`, and - — the line that matters — `tool capabilities should not exist`. DSM's privilege format - has a native `capabilities` field (documented since 7.0-40656), so a **signed** package - declares `cap_net_bind_service` and never needs `setcap`. Synology's guide states it - plainly — *"you are not able to install that package unless it is signed by synology"* — - so it is their signature, not a trusted publisher's. The one documented bypass, a - *development token*, is valid only on the NAS that generated its `debug.dat`, so it is - not a distribution path. **The manual `setcap` is settled, not provisional**; no - packaging change removes it. -- **`setcap` holds on the DS416j's volume, measured there.** The four routes to port 69 - were measured on an x86_64 VM whose `/volume1` is btrfs, `nodev` but not `nosuid`; a - `nosuid` mount makes the kernel ignore file capabilities outright, which would have - closed the last open route on the one machine this exists for. On the DS416j (ARMv7, - `armada38x`) the package binds `udp/69` and `boot check` says - `0.0.0.0:69 handed over ipxe-arm64.efi`. -- **A file capability does not survive an upgrade** — the new binary is a different file. - That is why a failed TFTP bind is the **one** listener failure here that is not fatal: - when it was, an upgrade took the answer endpoint down with it, failing every install in - flight to report that a second port could not be opened. It warns, `boot check` exits - non-zero, and the DSM panel shows a `tftp:` line. +- **The DSM-specific traps are in `packaging/dsm/CLAUDE.md`** — the four routes to port 69 + and why `setcap` is settled, the signature gate `libsynopkg.so.1` spells out, `setcap` + measured on the DS416j's own volume, the capability an upgrade drops, and the panel's + runtime-computed defaults. They load with that directory; `docs/development/traps.md` + has them at length. - **Binding is not a health check.** A bind that *succeeds* on the TFTP port means nothing is listening — the degraded state, not the healthy one — and one that fails cannot tell this server from another daemon squatting the port, since both are `AddrInUse`. So `boot check` sends a real read request (`boot::tftp::probe`) and reports what a machine would get. The first version guessed, and a test with a squatter said so at once. -- **A default computed at runtime must be computed in `settings()` too.** The DSM panel - renders a variable's default as the field's value, so a default living only where the - server consumes it shows as an empty box while the server runs on a value it derived. - `RESCRIPTUM_PUBLIC_HOST` shipped that way. Two `KNOWN` entries are special-cased there - — the worker count and the public host — and nothing in the type system says a third - would need it. - **The size figures in this file go stale.** They moved ~375 KB when armv7 changed from musl to glibc. Re-measure before concluding anything from them; a stale baseline once turned a 71% budget spend into an apparent 293% overrun. - -## Core algorithm (the part worth understanding up front) - -Answer files live in a configurable directory, named after a MAC address -(`98-fa-9b-50-d8-10.toml`, `aabbccddeeff.toml`, plus an optional `default.toml`). +- **A directory's mtime does not see one level down.** With a directory per identity, adding + or editing a document *inside* a machine's directory moves nothing the listing cache + watches, so only `RELOAD_BACKSTOP` catches it. Only the identity itself appearing or + leaving is immediate. A test for "a removed document stops being served" that expects it + immediately is testing the old layout and will hang on the new one. +- **An AppleDouble is worse in a directory than it was flat.** `._proxmox.toml` is now a + *second* `.toml` in a directory that may hold only one, and `.` sorts before every letter + — so a rule that took the first would serve a binary body to every request. `visible_name` + is what stops it, and a test asserts the litter is not even *reported* as a conflict. +- **`git checkout ` to undo a deliberately-broken test also undoes the work.** Copy + the file aside before breaking it to watch a test fail; a `git checkout` here silently + reverted a whole feature and its test, and only a `grep` afterwards caught it. + +## Layout on disk, and the core algorithm + +**One directory per identity.** A machine is a directory in `RESCRIPTUM_ANSWERS_DIR` named +after it, holding one document per format; `groups/` and `default/` are the same shape under +names the layout reserves (`valid_machine_id` refuses both as machine ids, in *both* stores, +so `export` from SQLite can always be represented). + +Inside a directory, **the extension is the format and the stem is nothing at all** — +`proxmox.toml` and `answer.toml` are one document. `format::canonical_stem` picks a readable +name for a document nobody has named; an existing one is overwritten *where it stands*, so +an operator's name survives a write. Two documents of one format in one directory is a +**reported problem**, not a resolved one: there is no tiebreak anyone could predict. Sorted +order decides which answers so the choice does not depend on readdir, and the loser is named. + +A servable document left flat at the top — the layout before this one — is **reported and +not served**, its destination spelled out, and `rescriptum migrate [--apply]` moves them. +`store::file::pending_moves` is that knowledge exposed once, so the command and the reader +cannot disagree. Half-reading an old layout would mean a machine whose answer moved silently +between two files. Selection per request: 1. Normalize the request body: lowercase, strip every non-alphanumeric character. -2. For each `.toml` in the directory (excluding `default.toml`), normalize `` the +2. For each identity directory (excluding `groups/` and `default/`), normalize its name the same way and test whether it appears as a substring of the normalized body. -3. First match wins → return that file. -4. No match → `default.toml` if present, else `404`. +3. First match wins → return that identity's document for the format asked for. +4. No match → `default/` if it holds that format, else `404`. The normalization is what makes this robust: it's indifferent to MAC separator style (`98-fa-9b…` / `98:fa:9b…` / `98fa9b…`) and to the JSON structure, which changes between Proxmox @@ -683,19 +690,30 @@ file is added or removed. The spec asks for a re-read on every request; done lit `readdir` plus a sort plus a normalization pass per request, and with one answer file per machine — the datacenter case — throughput collapses. Measured, 3000 requests at 100 concurrent: -| Files in the directory | Literal re-read | mtime-cached | +| Machines in the directory | Literal re-read | mtime-cached | |---|---|---| | 10 | 11,954 req/s | 12,922 req/s | | 200 | 3,198 req/s | 12,890 req/s | | 2,000 | 311 req/s | 12,520 req/s | | 10,000 | — | 6,924 req/s | -One `stat` replaces the whole walk, and a new file is still picked up with no restart — which is -the guarantee the spec actually wanted. `RELOAD_BACKSTOP` (1 s) forces a re-read even when mtime -looks unchanged, covering filesystems with coarse mtime granularity. Normalized stems are -computed once per directory read, not once per request. - -The remaining cost at 10,000 files is the linear scan of precomputed needles — pure CPU, no +One `stat` replaces the whole walk, and a new machine is still picked up with no restart — +which is the guarantee the spec actually wanted. `RELOAD_BACKSTOP` (1 s) forces a re-read even +when mtime looks unchanged, covering filesystems with coarse mtime granularity. Normalized +identities are computed once per directory read, not once per request. + +**The mtime now sees less, and the read costs more.** A directory's mtime moves when an entry +is added or removed *in it*, so an identity appearing or leaving is immediate while a document +added or edited **inside** one is only caught by the backstop — the same rule that already +covered a file edited in place, now the normal case. And a full reload is a `readdir` per +identity on top of the file it already opened: measured at 2,000 machines on an M1 Pro, +**28.6 ms flat → 63.5 ms with a directory each (2.2×)**, unchanged by removing the +allocations, because it is syscalls. Amortised over a second of requests it did not move +end-to-end throughput measurably; the req/s table above was measured against the flat layout +and has *not* been re-measured with a comparable tool. It is still the reason to reach for a +group before a directory per machine. + +The remaining cost at 10,000 machines is the linear scan of precomputed needles — pure CPU, no syscalls. Bucketing needles by length and sliding a window over the body would remove it, but a 10,000-machine rollout already completes in under two seconds, so it has not been worth the complexity. Measure before adding it. @@ -708,7 +726,7 @@ complexity. Measure before adding it. `Connection: close`. - `404` when no file applies; `500` on read errors. - Reject an implausible `Content-Length` (cap at 1 MB) rather than allocating for it. -- Only read direct entries of the answers directory. Never build a filesystem path from +- Only read the answers directory and one level below it. Never build a filesystem path from request data — that is the path-traversal guard. Logging goes to stdout/stderr, one line per request (timestamp, source IP, body size, chosen @@ -779,18 +797,15 @@ loud), `export` accepted so one file can also be sourced, a duplicate key is an Local development (once the crate exists): ```bash -cargo build cargo run -- check # validate an answers directory +cargo run -- migrate # show what a flat answers directory would become +cargo run -- migrate --apply # move those documents into a directory each cargo run -- config # show the configuration and where each value comes from cargo run -- render # print one machine's composed answer cargo run -- media list # the installer images held cargo run -- media add FILE # register one: verify, probe, record its digest cargo run -- media check # re-verify every recorded digest cargo run -- media ipxe ID # the .ipxe answer that boots one image -cargo test # all tests -cargo test # single test by name substring -cargo test -- --nocapture # show stdout from tests -cargo fmt && cargo clippy ``` Documentation (see *Documentation* below): @@ -837,194 +852,24 @@ Verify the first cross-build actually produces a static binary (`file` should sa linked*) and that it runs on the NAS. If ARMv7 misbehaves, confirm the real architecture with `uname -m` on the NAS before pushing further. -## Release profile - -`Cargo.toml` optimizes for size, minus the spec's `panic = "abort"` (see Hard constraints): - -```toml -[profile.release] -opt-level = "z" -lto = true -codegen-units = 1 -strip = true -``` - ## The DSM package `packaging/dsm/` wraps an already-built binary as a DSM 7 `.spk`. It is a **release format**, exactly like the `.tar.gz` archives — no DSM-specific build, no feature flag, -nothing in `src/`. The **four** places DSM pressed back are answered in packaging: log -rotation by a `copytruncate` stanza, a CLI that cannot find its configuration by a -three-line wrapper (`rescriptum-cli`, which names `RESCRIPTUM_ENV_FILE`), no settings panel -by the desktop application below, and **a privileged port by one root command**. DSM 7 -does not let an unsigned package run as root — measured, four routes, in -`docs/development/traps.md` with the error codes — but `setcap cap_net_bind_service=+ep` -on the installed binary works, after which the package binds `udp/69` as its own -unprivileged user alongside 8000 and 8001. All three are registered with the firewall. -**The package ships the loaders**, so the share's `boot` folder arrives filled and `start` -refreshes it when the stamp does not name this version; a TFTP server with nothing to hand -out boots nothing, and a second download is how a working appliance becomes a support -thread. Verified on the machine by fetching `ipxe-undionly.kpxe` over TFTP with an -independent client and comparing it byte for byte. -**The capability belongs to the file, so an upgrade drops it**; the env file says so and -points at a Task Scheduler boot-up task. `RESCRIPTUM_TFTP_ADDR` is therefore left unset — -its default *is* port 69, which is what every loader and every generated snippet expects. -An earlier version shipped `off` and sent operators to DSM's own TFTP server: that traded -the product's first principle for a packaging constraint that turned out not to exist, and -it is not a precedent. `RESCRIPTUM_USER`/`_GROUP` stay documented as unusable — the package -already is its own unprivileged user. If this ever seems to need a `#[cfg]`, the design has gone wrong. - -```bash -./build.sh --spk x86_64-unknown-linux-musl # build, then wrap -packaging/dsm/make-spk.sh armv7 # wrap an existing build -packaging/dsm/check-spk.sh # structural check ⎫ both run -packaging/dsm/lifecycle-test.sh # drive the scripts ⎭ by ci.yml -packaging/dsm/vm/on-dsm.sh admin@nas # what only DSM can answer -``` - -**The package is tested in three places, and none of it is Rust** — `cargo test` does not -touch it. `check-spk.sh` asserts the archive's shape; `lifecycle-test.sh` unpacks an `.spk` -into a fake `/var/packages` tree and drives the real scripts through install (with a wizard -and without), start, `/health`, the exit codes, an upgrade over a hand-edited env file and -a canary — with `etc/` surviving and with it wiped — and an uninstall; both run on every -push. `vm/on-dsm.sh` runs the rest on a DSM 7 VM and then on the DS416j: `data-share`'s -ACL, `port-config`, the generated unit, `logrotate -f` against a live descriptor, and -whether Package Center accepts the archive at all. **Nothing ships on VM evidence alone**, -and `lifecycle-test.sh` was watched failing — reintroducing one defect turns 54 green into -46 green and 8 red. **It earns its keep:** its first run over the boot-media package caught -a live `RESCRIPTUM_MEDIA_ADDR` with `RESCRIPTUM_MEDIA_DIR` still commented, which is a -startup error — the package would not have started at all. - -### The desktop application - -`packaging/dsm/payload/ui/` is a **real DSM application** — `SYNO.SDS.AppWindow`, -`syno_formpanel`, `syno_textfield`, `syno_combobox`, `syno_button` — not a page of ours in a -frame. `dsmuidir="ui"` makes DSM symlink it into -`/usr/syno/synoman/webman/3rdparty/rescriptum`, and `dsmappname` names the class `ui/config` -declares. It manages the server's configuration, shows its status and tails its log. - -**ExtJS, not Vue, and the machine decided that.** DSM 7.2 ships a Vue framework and -Synology's current guide documents only that one — the first version of this was written -against it. The DS416j is capped at **DSM 7.1.1**, where `Vue` is undefined. ExtJS is on both -(7.1.1 and 7.2.2, measured), so one application covers every DSM this package supports; -`os_min_ver` is **7.1**, and 7.0 is not claimed because nothing has run there. The API is -documented in the ExtJS reference Synology generated for DSM, mirrored at - as `docs/synoextjsdocs.tar.gz`. - -The design rule holds: nothing in `src/` knows any of this exists. What the server gained is -a *generic* `config` subcommand, and the application's backend — `ui/api.cgi` — is a hundred -lines of shell that authenticate and then shell out to `rescriptum-cli config` and `media`. **The panel never grows a rule of its own**: it starts a download by calling `media add`, which is where the digest rules are tested, and it follows one by watching the `.part` file that command already writes — a CGI cannot hold a request open for 1.5 GB, and nothing about progress had to be invented for the browser. The env-file -semantics stay in Rust where they are tested rather than being written a second time in `sh`. - -**Four things were measured on the machine and every one of them is load-bearing. None is in -the developer guide** (they are in `docs/development/traps.md` at length): - -- **A CGI there runs as the owner of the script**, which for a package tree is the package - user. Not `http`, not root. That is what lets it read the `0600` env file it owns, and why - it cannot start or stop anything — restarting goes through DSM's own - `SYNO.Core.Package.Control`, from the application, with the administrator's session. -- **DSM does not authenticate that path.** An unauthenticated request gets `200`. So - `authenticate.cgi` plus an `administrators` check *is* the door, and a write additionally - needs a header a cross-origin page cannot make a browser send. Losing any of them would be - silent, which is why `check-spk.sh` greps for them **with the comments stripped** — the - first version of that check passed because the word appeared in a comment. -- **No `su`, ever.** It hangs a CGI outright without ``**, so the package root is the - fixed `/var/packages/`, never `dirname "$SYNOPKG_PKGDEST"`. `RESCRIPTUM_PKG_ROOT` is - the seam that lets `lifecycle-test.sh` drive the scripts against a writable tree. -- **`etc/` and `var/` survive an uninstall** (they are symlinks into `@appconf`/`@appdata`), - so the env file and its tokens outlive the package — said plainly in the Synology page. -- **`$SYNOPKG_TEMP_UPGRADE_FOLDER` outlives its upgrade**, so restoring from it requires - `SYNOPKG_PKG_STATUS = UPGRADE` or a fresh install resurrects a removed configuration. -- **The firewall directory is `/usr/local/etc/services.d/`** (plural; the guide is wrong), - and `port-config` acquires *after* `postinst` — the wizard's port does reach it. Both - `port-config` and `usr-local-linker` acquire when the package is **enabled**, not at - `postinst`. -- **The generated unit has no `Restart=`**: DSM does not restart the process if it dies. +nothing in `src/` knows it exists, and if it ever seems to need a `#[cfg]`, the design has +gone wrong. It carries a real DSM desktop application for the settings panel, and it ships +the branded loaders, so the share's `boot` folder arrives filled. **Changing anything under `packaging/dsm/` means running the machine**, not just the local -harness — the procedure is in `packaging/dsm/vm/README.md` (*Changing the package? This is -the procedure*), and `AGENTS.md` points at it. A DSM 7.2.2 VM already exists in Docker on -the maintainer's machine with a `clean` snapshot; `bootstrap.sh` sets one up from scratch, -`on-dsm.sh` drives it, and the run is destructive on purpose. It asks the server for a real -answer — a machine file merged over the group that claims it — rather than settling for -`/health`. - -The harnesses catch a broken archive and broken scripts; only Package Center catches a -broken package. **A tag must not be the first time an `.spk` meets a DSM machine** — the -rig is `packaging/dsm/vm/`: `docker-compose.yml` runs Synology's own Virtual DSM (DSM 7.2, -close to the DS416j's 7.2.1). KVM makes it fast, not possible — without `/dev/kvm` the image -falls back to emulation on its own, about ten times slower, which is what -`docker-compose.emulated.yml` is for. What does stop a host is **14 GiB free**, hardcoded in -the image and not derived from `DISK_SIZE`. `run-vm.sh` is the loader-image fallback. +harness. `packaging/dsm/CLAUDE.md` holds the whole contract — the four places DSM pressed +back, the privilege routes measured on a DS416j and the DS416j run itself, the desktop +application, the lifecycle rules and the DSM traps — and it loads whenever you touch that +directory. The procedure is in `packaging/dsm/vm/README.md` (*Changing the package? This +is the procedure*), which `AGENTS.md` also points at. ## Testing expectations -571 tests, plus the package's own harnesses (see *The DSM package*, and note that +582 tests, plus the package's own harnesses (see *The DSM package*, and note that `cargo test` does not run those). `docs/development/testing.md` has the per-suite table; the rules that decide where a test goes: @@ -1068,45 +913,15 @@ the rules that decide where a test goes: `docs/` is the documentation site, rendered by **notabene** (`@z29k/notabene`, the sibling project) and published to GitHub Pages at . Two spaces, -two audiences, and they do not interleave: - -- `docs/guide/` — **using** rescriptum: install, quick start, installer media, writing - answers (`answers/`), running it (`operations/`), exhaustive tables (`reference/`). -- `docs/development/` — **working on** rescriptum: constraints, architecture, request - lifecycle, internals per module, testing, building, releasing, traps. +two audiences: `docs/guide/` is **using** rescriptum, `docs/development/` is **working on** +it. Nothing in `src/` knows the site exists. -This file and `docs/development/` overlap deliberately: this one is condensed for agents, +This file and `docs/development/` overlap deliberately — this one is condensed for agents, that one is written for a human reading in order. **A change to a constraint belongs in -both.** A user-visible change should land with its documentation in the same PR. - -- `notabene.config.mjs` — the site configuration. `review: "approve"` means an agent - *proposes* doc edits and a human validates each against its real git diff at `/review`. - `i18n: { locales: ["en","fr"], strategy: "suffix" }` is what makes the FR siblings work. - **`tagline` is a plain string, not a per-locale map** — notabene does not accept a map - there, and one stringifies to `[object Object]` in the topbar and `llms.txt`. -- `assets/rescriptum-logo.jpg` — the logo (a sealed rescript on a floppy disk), used as the - topbar logo, the favicon, the social card, and the README header. -- `docs/.notabene/` — the comment and journal store, plain JSON, **committed**. The agent - protocol is `docs/.notabene/protocol.md`, pointed at from `AGENTS.md`. -- `package.json` exists **only** for this. Nothing in `node_modules` is executed by the - published site or reaches the Rust binary. `npm audit` reports unfixable transitive - advisories in Astro/esbuild/sharp; they are development-only. -- `.github/workflows/docs.yml` publishes from `main` (so docs normally ship with a - release; `workflow_dispatch` publishes a fix that should not wait). `ci.yml` has a - `docs` job that builds and runs `notabene lint` on every push. - -Writing conventions: relative `.md` links between pages (they become routes *and* stay -clickable on GitHub), absolute GitHub URLs for repository files outside `docs/`, frontmatter -`title` / `description` / `sidebar.order`, Mermaid in ` ```mermaid ` fences. - -From a French page, a link still uses the **base** name (`./selection.md`, never -`./selection.fr.md`) — notabene resolves the locale — but the **anchor must be the French -heading's slug**. `notabene lint` checks routes, **not anchors**: verify those against the -built HTML. - -**Verify prose against the binary rather than against this file.** Every command output in -`docs/` was captured from a real run; several passages in the older README had drifted from -what the code actually prints. +both**, and a user-visible change should land with its documentation in the same PR. + +`docs/CLAUDE.md` holds the rest — the site configuration, the FR mirroring rules, the +writing conventions and the anchor trap — and loads whenever you touch `docs/`. ## Language @@ -1175,12 +990,9 @@ documentation quality as requirements rather than polish. Being public is not a still ahead: it is the condition every change now lands under. A force-push is immediate and irreversible, and so is a published release. -Deliverables per the spec: `src/main.rs` (split into modules if size warrants), `Cargo.toml`, -a commented `examples/example.toml` covering `global` / `network` / `disk-setup`, `build.sh`, -`deploy.sh`, a Rust `.gitignore`, and a license. The spec's "README covering purpose, -cross-compilation, DSM deployment, ISO preparation and troubleshooting" is now `docs/` -instead — the README had grown to 28 KB and was three documents wearing one coat. It is a -landing page linking into the site. +The spec's "README covering purpose, cross-compilation, DSM deployment, ISO preparation +and troubleshooting" is now `docs/` instead — the README had grown to 28 KB and was three +documents wearing one coat. It is a landing page linking into the site. Out of scope but documented rather than implemented: TLS (plain HTTP is fine on a trusted LAN — document the workaround for installer versions demanding a cert fingerprint) and diff --git a/README.fr.md b/README.fr.md index c8349a2..adb5db4 100644 --- a/README.fr.md +++ b/README.fr.md @@ -57,8 +57,8 @@ $ RESCRIPTUM_ANSWERS_DIR=/srv/answers rescriptum ## Trente secondes ```console -$ mkdir -p answers/groups -$ cat > answers/groups/rack-a.toml <<'TOML' +$ mkdir -p answers/groups/rack-a +$ cat > answers/groups/rack-a/proxmox.toml <<'TOML' members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -78,8 +78,9 @@ timezone = "Europe/Paris" … ``` -Voilà une baie **en tant que Proxmox**. Le même répertoire contient `groups/rack-a.ks` pour -les nœuds RHEL et `groups/rack-a.preseed` pour les Debian — même idée, autre extension. Un +Voilà une baie **en tant que Proxmox**. Le même répertoire contient `groups/rack-a/rhel.ks` +pour les nœuds RHEL et `groups/rack-a/debian.preseed` pour les Debian — même répertoire, autre +extension. Un document est indexé par *(machine, format)*, donc une machine peut être plusieurs systèmes d'exploitation à la fois et c'est l'URL qui tranche. @@ -110,7 +111,8 @@ Chacun de ces points est un lien vers la une machine pour ce qu'elle *est*. Déterministe : nommer bat matcher, plus de critères bat moins, les égalités se départagent sur le nom trié. - **[Un document par système d'exploitation](https://z29k.github.io/rescriptum/fr/guide/answers/formats)** — - `98fa9b50d810.toml` est cette machine *en tant que Proxmox*, `98fa9b50d810.preseed` le même + `98fa9b50d810/proxmox.toml` est cette machine *en tant que Proxmox*, + `98fa9b50d810/debian.preseed` le même matériel *en tant que Debian*. Les deux existent en même temps ; l'URL choisit. - **[Des réponses qui se composent](https://z29k.github.io/rescriptum/fr/guide/answers/grouping)** — chaînes de groupes via `extends`, documents machine par-dessus. Les maps fusionnent, les diff --git a/README.md b/README.md index d0875ae..f74943a 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ $ RESCRIPTUM_ANSWERS_DIR=/srv/answers rescriptum ## Thirty seconds ```console -$ mkdir -p answers/groups -$ cat > answers/groups/rack-a.toml <<'TOML' +$ mkdir -p answers/groups/rack-a +$ cat > answers/groups/rack-a/proxmox.toml <<'TOML' members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -75,8 +75,9 @@ timezone = "Europe/Paris" … ``` -That is one rack **as Proxmox**. The same directory holds `groups/rack-a.ks` for the RHEL -nodes and `groups/rack-a.preseed` for the Debian ones — same idea, different extension. A +That is one rack **as Proxmox**. The same directory holds `groups/rack-a/rhel.ks` for the +RHEL nodes and `groups/rack-a/debian.preseed` for the Debian ones — same directory, different +extension. A document is keyed by *(machine, format)*, so one machine can be several operating systems at once and the URL picks between them. @@ -102,12 +103,12 @@ Each of these is a link into the [documentation](https://z29k.github.io/rescript go deep only where you are curious. - **[Picks the right answer](https://z29k.github.io/rescriptum/guide/answers/selection)** — - by filename, by a group's member list, or by a `[match]` block claiming a machine for + by directory name, by a group's member list, or by a `[match]` block claiming a machine for what it *is*. Deterministic: naming beats matching, more criteria beats fewer, ties break on sorted name. - **[One document per operating system](https://z29k.github.io/rescriptum/guide/answers/formats)** — - `98fa9b50d810.toml` is that machine *as Proxmox*, `98fa9b50d810.preseed` the same - hardware *as Debian*. Both exist at once; the URL chooses. + `98fa9b50d810/proxmox.toml` is that machine *as Proxmox*, `98fa9b50d810/debian.preseed` + the same hardware *as Debian*. Both exist at once; the URL chooses. - **[Answers that compose](https://z29k.github.io/rescriptum/guide/answers/grouping)** — group chains via `extends`, machine documents on top. Maps merge, arrays replace, the machine always wins. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 0000000..d97aac8 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md — docs + +Guidance for working on the documentation site. It loads only when Claude touches files under +`docs/`. The root `CLAUDE.md` keeps what applies everywhere: the site exists, this file and +`docs/development/` overlap deliberately, and **a change to a constraint belongs in both**. + +## Documentation + +`docs/` is the documentation site, rendered by **notabene** (`@z29k/notabene`, the sibling +project) and published to GitHub Pages at . Two spaces, +two audiences, and they do not interleave: + +- `docs/guide/` — **using** rescriptum: install, quick start, installer media, writing + answers (`answers/`), running it (`operations/`), exhaustive tables (`reference/`). +- `docs/development/` — **working on** rescriptum: constraints, architecture, request + lifecycle, internals per module, testing, building, releasing, traps. + +This file and `docs/development/` overlap deliberately: this one is condensed for agents, +that one is written for a human reading in order. **A change to a constraint belongs in +both.** A user-visible change should land with its documentation in the same PR. + +- `notabene.config.mjs` — the site configuration. `review: "approve"` means an agent + *proposes* doc edits and a human validates each against its real git diff at `/review`. + `i18n: { locales: ["en","fr"], strategy: "suffix" }` is what makes the FR siblings work. + **`tagline` is a plain string, not a per-locale map** — notabene does not accept a map + there, and one stringifies to `[object Object]` in the topbar and `llms.txt`. +- `assets/rescriptum-logo.jpg` — the logo (a sealed rescript on a floppy disk), used as the + topbar logo, the favicon, the social card, and the README header. +- `docs/.notabene/` — the comment and journal store, plain JSON, **committed**. The agent + protocol is `docs/.notabene/protocol.md`, pointed at from `AGENTS.md`. +- `package.json` exists **only** for this. Nothing in `node_modules` is executed by the + published site or reaches the Rust binary. `npm audit` reports unfixable transitive + advisories in Astro/esbuild/sharp; they are development-only. +- `.github/workflows/docs.yml` publishes from `main` (so docs normally ship with a + release; `workflow_dispatch` publishes a fix that should not wait). `ci.yml` has a + `docs` job that builds and runs `notabene lint` on every push. + +Writing conventions: relative `.md` links between pages (they become routes *and* stay +clickable on GitHub), absolute GitHub URLs for repository files outside `docs/`, frontmatter +`title` / `description` / `sidebar.order`, Mermaid in ` ```mermaid ` fences. + +From a French page, a link still uses the **base** name (`./selection.md`, never +`./selection.fr.md`) — notabene resolves the locale — but the **anchor must be the French +heading's slug**. `notabene lint` checks routes, **not anchors**: verify those against the +built HTML. + +**Verify prose against the binary rather than against this file.** Every command output in +`docs/` was captured from a real run; several passages in the older README had drifted from +what the code actually prints. diff --git a/docs/development/selection.fr.md b/docs/development/selection.fr.md index e7add95..69a04c5 100644 --- a/docs/development/selection.fr.md +++ b/docs/development/selection.fr.md @@ -147,13 +147,22 @@ par machine, le débit s'effondre : | 2 000 | 311 req/s | 12 520 req/s | | 10 000 | — | 6 924 req/s | -Un `stat` remplace tout le parcours, et un nouveau document est quand même pris en compte sans -redémarrage — ce qui est la garantie que la spécification voulait réellement. Les radicaux -normalisés sont calculés une fois par lecture du store, pas une fois par requête. - -> **Le filet n'est pas redondant.** Éditer le *contenu* d'un fichier de groupe ne bouge aucun -> mtime de répertoire, et un changement fait par un autre processus ne bouge aucun atomique en -> mémoire. Un test d'intégration couvre exactement cela. +Un `stat` remplace tout le parcours, et une nouvelle machine est quand même prise en compte +sans redémarrage — ce qui est la garantie que la spécification voulait réellement. Les +identités normalisées sont calculées une fois par lecture du store, pas une fois par requête. + +> **Le filet n'est pas redondant, et il travaille plus qu'avant.** Éditer le *contenu* d'un +> document ne bouge aucun mtime de répertoire ; en ajouter un *à l'intérieur* du répertoire +> d'une machine non plus, puisque c'est un niveau sous le mtime surveillé ; et un changement +> fait par un autre processus ne bouge aucun atomique en mémoire. Avec un répertoire par +> identité, le filet est donc ce qui rattrape tout sauf l'apparition ou la disparition d'une +> identité. Des tests couvrent chaque cas. +> +> Les chiffres ci-dessus ont été mesurés contre l'agencement plat. La lecture elle-même est +> désormais un `readdir` par identité en plus du fichier qu'elle ouvrait déjà — de 28 ms à +> 63 ms à 2 000 machines — que le cache amortit sur une seconde de requêtes, et qui n'a pas +> déplacé le débit de façon mesurable. Cela reste la raison pour laquelle un groupe vaut +> mieux qu'un répertoire par machine. Le coût restant à 10 000 documents est un balayage linéaire d'aiguilles précalculées — du CPU pur, aucun appel système. Regrouper les aiguilles par longueur et faire glisser une fenêtre diff --git a/docs/development/selection.md b/docs/development/selection.md index 940ab65..89436e5 100644 --- a/docs/development/selection.md +++ b/docs/development/selection.md @@ -143,13 +143,21 @@ machine, throughput collapses: | 2,000 | 311 req/s | 12,520 req/s | | 10,000 | — | 6,924 req/s | -One `stat` replaces the whole walk, and a new document is still picked up with no restart -— which is the guarantee the specification actually wanted. Normalized stems are computed +One `stat` replaces the whole walk, and a new machine is still picked up with no restart — +which is the guarantee the specification actually wanted. Normalized identities are computed once per store read, not once per request. -> **The backstop is not redundant.** Editing a group file's *contents* moves no directory -> mtime, and a change made by another process moves no in-process atomic. An integration -> test covers exactly this. +> **The backstop is not redundant, and it does more work than it used to.** Editing a +> document's *contents* moves no directory mtime; neither does adding one *inside* a +> machine's own directory, since that is one level below the mtime being watched; and a +> change made by another process moves no in-process atomic. So with a directory per +> identity, the backstop is what picks up everything except an identity appearing or +> leaving. Tests cover each case. +> +> The figures above were measured against the flat layout. The read itself is now a +> `readdir` per identity on top of the file it already opened — 28 ms to 63 ms at 2,000 +> machines — which the cache amortises over a second's worth of requests, and which did not +> move throughput measurably. It is still the reason a group beats a directory per machine. The remaining cost at 10,000 documents is a linear scan of precomputed needles — pure CPU, no syscalls. Bucketing needles by length and sliding a window over the body would remove diff --git a/docs/development/stores.fr.md b/docs/development/stores.fr.md index dc36007..222f7a6 100644 --- a/docs/development/stores.fr.md +++ b/docs/development/stores.fr.md @@ -61,7 +61,25 @@ est exactement là où se cache une divergence. ## Le store fichiers -Un répertoire plat, plus `groups/`. `version()` est le mtime du répertoire : +**Un répertoire par identité.** Une machine est un répertoire nommé d'après elle, qui +contient un document par format ; `groups/` porte la même forme pour les groupes, et +`default/` les réponses de repli. Les deux noms sont réservés, donc une machine ne peut pas +les revendiquer — `valid_machine_id` les refuse dans les *deux* stores, parce qu'une base qui +en accepterait un exporterait vers un répertoire incapable de le contenir. + +Dans un répertoire, **l'extension est le format et le radical n'est rien du tout**. C'est +cette règle qui fait de deux documents d'un même format dans un même répertoire un problème +*signalé* plutôt que tranché : il n'existe aucun départage qu'un administrateur aurait pu +prévoir. L'ordre trié décide lequel des deux répond, pour que le choix ne dépende au moins pas +de readdir — et le perdant est nommé dans `problems()`. + +Un document servable laissé à la racine du répertoire de réponses — l'agencement d'avant — +est **signalé et non servi**, avec sa destination explicitée. Lire à moitié un ancien +agencement signifierait une machine dont la réponse se déplace silencieusement entre deux +fichiers. `pending_moves()` expose la même connaissance pour `migrate`, pour que la commande +et le lecteur ne puissent pas diverger sur l'endroit où va un document. + +`version()` est le mtime du répertoire : ```rust fs::metadata(&self.dir).ok() @@ -73,9 +91,19 @@ fs::metadata(&self.dir).ok() Un `stat` remplace tout un parcours de répertoire — voir [le cache du listing](./selection.md#le-cache-du-listing). -> Le mtime du répertoire bouge quand un fichier est **ajouté ou supprimé**, pas quand un -> fichier est **édité**. Le filet de rechargement d'une seconde est ce qui couvre l'édition, -> et un test d'intégration couvre exactement cela. +> Le mtime du répertoire bouge quand une entrée y est **ajoutée ou supprimée**, pas quand +> l'une est **éditée**, ni quand quelque chose change un niveau plus bas. Le répertoire +> entier d'une machine qui apparaît ou disparaît est donc vu immédiatement, tandis qu'un +> document ajouté ou modifié *à l'intérieur* de l'un d'eux attend le filet de rechargement +> d'une seconde — celui qui couvrait déjà un fichier édité sur place. Un test unitaire +> épingle chacune des deux moitiés. + +> **Ce que l'agencement coûte à la lecture.** Un rechargement complet est désormais un +> `readdir` par identité en plus du fichier qu'il ouvrait déjà. Mesuré à 2 000 machines sur +> un M1 Pro : **28 ms à plat, 63 ms avec un répertoire chacune**. C'est amorti sur une +> seconde de requêtes, et le débit de bout en bout n'a pas bougé de façon mesurable — mais +> c'est un vrai facteur 2,2 sur la seule opération dont le filet garantit qu'elle tournera +> chaque seconde, et c'est la raison de préférer un groupe à un répertoire par machine. **Les écritures passent par un fichier temporaire plus un `rename`**, atomique dans un répertoire sur POSIX, pour qu'un lecteur ne rencontre jamais une réponse à moitié écrite. Le @@ -130,13 +158,15 @@ $ rescriptum export # store configuré → répertoire Les deux passent par `Snapshot`, donc ils partagent toutes les règles. **L'aller-retour est identique octet pour octet** — importez un répertoire, réexportez-le, `diff -r` ne signale -rien. C'est ce qui rend la base sûre à adopter *et* sûre à quitter, et cela vaut la peine de -rester vrai. +rien, chemins compris. Un test compare les deux côtés au même chemin pour exactement cette +raison : `export` écrivant un document là où `import` n'irait pas le chercher est ce qui +rendrait la base dangereuse à quitter. -## Les identifiants deviennent des noms de fichiers +## Les identifiants deviennent des noms de répertoires ```rust -pub fn valid_id(id: &str) -> bool // lettres, chiffres, - _ . : et aucun séparateur de chemin +pub fn valid_id(id: &str) -> bool // lettres, chiffres, - _ . : et aucun séparateur +pub fn valid_machine_id(id: &str) -> bool // …et ni `groups` ni `default` ``` Imposé à la frontière de l'API d'administration **et** dans les deux stores. Le store est la diff --git a/docs/development/stores.md b/docs/development/stores.md index 0e29167..e09e26f 100644 --- a/docs/development/stores.md +++ b/docs/development/stores.md @@ -59,7 +59,25 @@ backend proves half of what it claims. ## The file store -A flat directory, plus `groups/`. `version()` is the directory's mtime: +**One directory per identity.** A machine is a directory named after it, holding one +document per format; `groups/` holds the same shape for groups, and `default/` the +fallbacks. Both names are reserved, so a machine cannot claim them — `valid_machine_id` +refuses them in *both* stores, because a database that accepted one would export into a +directory that cannot hold it. + +Inside a directory, **the extension is the format and the stem is nothing at all**. That is +the rule that makes two documents of one format in one directory a *reported problem* rather +than a resolved one: there is no tiebreak an operator could have predicted. Sorted order +decides which of the two answers, so the choice at least does not depend on readdir — and +the loser is named in `problems()`. + +A servable document left at the top of the answers directory — the layout that came before — +is **reported and not served**, with its destination spelled out. Half-reading an old layout +would mean a machine whose answer moved silently between two files. `pending_moves()` is the +same knowledge exposed for `migrate`, so the command and the reader cannot disagree about +where a document belongs. + +`version()` is the directory's mtime: ```rust fs::metadata(&self.dir).ok() @@ -71,9 +89,18 @@ fs::metadata(&self.dir).ok() One `stat` replaces a whole directory walk — see [the listing cache](./selection.md#the-listing-cache). -> The directory's mtime moves when a file is **added or removed**, not when one is -> **edited**. The 1-second reload backstop is what covers editing, and an integration -> test covers exactly that. +> The directory's mtime moves when an entry is **added or removed** *in it*, not when one +> is **edited**, and not when something changes one level down. So a machine's whole +> directory appearing or leaving is seen at once, while a document added or edited *inside* +> one waits for the 1-second reload backstop — which is what already covered a file edited +> in place. A unit test pins each half. + +> **What the layout costs on a read.** A full reload is now a `readdir` per identity on top +> of the file it already opened. Measured at 2,000 machines on an M1 Pro: **28 ms flat, +> 63 ms with a directory each**. It is amortised over a second's worth of requests, and +> end-to-end throughput did not move measurably — but it is a real 2.2× on the one operation +> the backstop guarantees will run every second, and it is the reason to reach for a group +> before a directory per machine. **Writes go through a temporary file plus `rename`**, which is atomic within a directory on POSIX, so a reader never meets a half-written answer. The temporary name carries the @@ -125,13 +152,16 @@ $ rescriptum export # the configured store → a directory ``` Both go through `Snapshot`, so they share every rule. **The round trip is byte-identical** -— import a directory, export it again, `diff -r` reports nothing. That is what makes the +— import a directory, export it again, `diff -r` reports nothing, paths included. A test +compares both sides at the same path for exactly that reason: `export` writing a document +somewhere `import` would not look for it is what would make the database unsafe to leave. That is what makes the database safe to adopt *and* safe to leave, and it is worth keeping true. -## Identifiers become filenames +## Identifiers become directory names ```rust -pub fn valid_id(id: &str) -> bool // letters, digits, - _ . : and no path separators +pub fn valid_id(id: &str) -> bool // letters, digits, - _ . : and no separators +pub fn valid_machine_id(id: &str) -> bool // …and not `groups` or `default` ``` Enforced at the admin API boundary **and** in both stores. The store is the layer that diff --git a/docs/development/testing.fr.md b/docs/development/testing.fr.md index 7fa24b9..756614e 100644 --- a/docs/development/testing.fr.md +++ b/docs/development/testing.fr.md @@ -8,7 +8,7 @@ sidebar: # Tests -571 tests. `cargo test` les fait tous tourner en une vingtaine de secondes — dont +582 tests. `cargo test` les fait tous tourner en une vingtaine de secondes — dont l'essentiel dans `tests/tftp.rs`, qui attend de vrais délais UDP parce que c'est précisément ce qu'il teste. @@ -29,13 +29,13 @@ cargo test --all-features # ce que lance la CI | Suite | Cas | Pour | |---|---|---| | `tests/integration.rs` | 48 | le vrai binaire sur une vraie socket | -| `tests/cli.rs` | 47 | `render`, `check`, `import`, `export`, `config` et le fichier d'environnement — contre le vrai binaire | +| `tests/cli.rs` | 50 | `render`, `check`, `import`, `export`, `config` et le fichier d'environnement — contre le vrai binaire | | `tests/media.rs` | 45 | les médias de démarrage contre le vrai binaire, les deux listeners debout | | `src/config.rs` | 42 | l'environnement, ce qui refuse de démarrer, et qui l'emporte du fichier ou de l'environnement | -| `tests/stores.rs` | 39 | **chaque comportement, contre les deux stores** | +| `tests/stores.rs` | 45 | **chaque comportement, contre les deux stores** | | `tests/tftp.rs` | 30 | le TFTP sur de l'UDP réel : les tours de parole, et ce qu'une liaison ratée ne doit pas coûter | -| `src/select.rs` | 27 | normalisation, scoring, superposition, remplissage de templates | -| `src/format/mod.rs` | 27 | parsing, fusion, clés de contrôle, alias d'endpoint | +| `src/select.rs` | 28 | normalisation, scoring, superposition, remplissage de templates | +| `src/format/mod.rs` | 28 | parsing, fusion, clés de contrôle, alias d'endpoint | | `tests/admin.rs` | 26 | l'API d'administration de bout en bout, formats compris | | `src/envfile.rs` | 23 | le parseur et l'écrivain du fichier d'environnement, et ce que chacun refuse | | `src/facts.rs` | 22 | parsing de query, aplatissement JSON, globbing | @@ -47,6 +47,21 @@ cargo test --all-features # ce que lance la CI | `src/boot/*.rs` | 128 | le lecteur ISO, le repérage, le catalogue, les sources d'images, les plans de patch, le menu, la table des chargeurs, les extraits DHCP, cpio et SHA-256 | | `src/admin.rs`, `src/capture.rs`, `src/store/mod.rs` | 21 | comportement unitaire | +## `tests/common/mod.rs` — les fixtures que toutes les suites partagent + +Les réponses sont stockées à raison d'**un répertoire par identité**, donc une fixture ne +peut plus être un simple nom de fichier. `seed()` prend le nom dans lequel un test pense — +`98fa9b50d810.toml`, `groups/rack-a.toml`, `default.toml` — et l'écrit **via `StoreWrite`**, +si bien qu'elle atterrit exactement là où une écriture de l'API d'administration la mettrait +et ne peut pas diverger de l'agencement. Un nom que le store refuserait (une extension que +personne ne sert) est écrit littéralement, parce que ces fixtures existent justement pour +prouver qu'un fichier égaré ne répond à rien. + +Une seule copie, pas une par suite — le même raisonnement qui fait de `loaders.rs` une table +unique lue par TFTP *et* par l'extrait DHCP. Quatre copies d'une correspondance sont quatre +occasions qu'une fixture atterrisse là où le serveur ne regarde pas, et un test qui ne sème +rien passe pour la mauvaise raison. + ## `tests/stores.rs` — la suite de conformité Chaque cas de comportement tourne **deux fois**, une par store, et affirme le résultat diff --git a/docs/development/testing.md b/docs/development/testing.md index 5b1f2a6..8b85351 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -8,7 +8,7 @@ sidebar: # Testing -571 tests. `cargo test` runs all of them in about twenty seconds — most of that is +582 tests. `cargo test` runs all of them in about twenty seconds — most of that is `tests/tftp.rs`, which waits on real UDP timeouts because that is what it is testing. **`cargo test` does not run the harnesses that matter most**: the boot rig, the DSM @@ -27,13 +27,13 @@ cargo test --all-features # what CI runs | Suite | Cases | For | |---|---|---| | `tests/integration.rs` | 48 | the real binary over a real socket | -| `tests/cli.rs` | 47 | `render`, `check`, `import`, `export`, `config` and the env file — against the real binary | +| `tests/cli.rs` | 50 | `render`, `check`, `import`, `export`, `config` and the env file — against the real binary | | `tests/media.rs` | 45 | boot media against the real binary, with both listeners up | | `src/config.rs` | 42 | the environment, what refuses to start, and which of the file and the environment wins | -| `tests/stores.rs` | 39 | **every behaviour, against both stores** | +| `tests/stores.rs` | 45 | **every behaviour, against both stores** | | `tests/tftp.rs` | 30 | TFTP over real UDP: the turn-taking, and what a failed bind must not cost | -| `src/select.rs` | 27 | normalization, scoring, layering, template filling | -| `src/format/mod.rs` | 27 | parsing, merging, control keys, endpoint aliases | +| `src/select.rs` | 28 | normalization, scoring, layering, template filling | +| `src/format/mod.rs` | 28 | parsing, merging, control keys, endpoint aliases | | `tests/admin.rs` | 26 | the admin API end to end, formats included | | `src/envfile.rs` | 23 | the env-file parser and writer, and what each refuses | | `src/facts.rs` | 22 | query parsing, JSON flattening, globbing | @@ -45,6 +45,20 @@ cargo test --all-features # what CI runs | `src/boot/*.rs` | 128 | the ISO reader, probing, the catalogue, image sources, patch plans, the menu, the loader table, DHCP snippets, cpio and SHA-256 | | `src/admin.rs`, `src/capture.rs`, `src/store/mod.rs` | 21 | unit-level behaviour | +## `tests/common/mod.rs` — the fixtures every suite shares + +Answers are stored as a **directory per identity**, so a fixture cannot just be a filename +any more. `seed()` takes the name a test thinks in — `98fa9b50d810.toml`, +`groups/rack-a.toml`, `default.toml` — and writes it **through `StoreWrite`**, so it lands +exactly where a write from the admin API would and cannot drift from the layout. A name the +store would refuse (an extension nobody serves) is written literally instead, because those +fixtures exist precisely to prove that a stray file answers nothing. + +One copy, not one per suite — the same reasoning that makes `loaders.rs` a single table +read by both TFTP and the DHCP snippet. Four copies of a mapping are four chances for a +fixture to land somewhere the server does not look, and a test that seeds nothing passes +for the wrong reason. + ## `tests/stores.rs` — the conformance suite Every behavioural case runs **twice**, once per store, and asserts the identical outcome. diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index 9e60708..6395b30 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -51,12 +51,13 @@ recevait un reset. Il draine maintenant brièvement d'abord, comme le faisait d **Un Mac qui édite le répertoire de réponses en SMB peut détourner la réponse d'une machine.** macOS écrit un fichier AppleDouble `._` à côté d'un fichier dont le système -n'accepte pas les attributs étendus — `._98-fa-9b-50-d8-10.toml` a une extension *présente* -dans la liste, et la normalisation retire le `._` de tête : il revendique donc la même -identité que le vrai fichier, avec un contenu binaire. La machine qu'on configurait reçoit -une erreur d'analyse au lieu de sa réponse, et `check` fait échouer le *groupe* avec elle. -`.DS_Store` n'est inoffensif que par chance (son extension n'est pas dans la liste). Le store -fichiers ignore désormais toute entrée dont le nom commence par `.` ; trouvé sur un vrai NAS, +n'accepte pas les attributs étendus — `._proxmox.toml` a une extension *présente* dans la +liste. Avec un répertoire par identité, c'est pire que du temps des réponses à plat : c'est +un **second `.toml` dans un répertoire qui n'en accepte qu'un**, et il se trie *avant* le +vrai, si bien qu'une règle prenant le premier servirait un contenu binaire à toutes les +requêtes. La machine qu'on configurait reçoit alors une erreur d'analyse au lieu de sa +réponse. `.DS_Store` n'est inoffensif que par chance (son extension n'est pas dans la liste). +Le store fichiers ignore toute entrée dont le nom commence par `.` ; trouvé sur un vrai NAS, pas en lisant quoi que ce soit. **Normaliser un motif de sélecteur retire `*` et `?`** à moins d'utiliser `normalize_pattern` diff --git a/docs/development/traps.md b/docs/development/traps.md index d56497d..c771400 100644 --- a/docs/development/traps.md +++ b/docs/development/traps.md @@ -48,12 +48,13 @@ A test at the connection cap pins it. **A Mac editing the answers directory over SMB can hijack a machine's answer.** macOS writes an AppleDouble `._` beside a file whose extended attributes the filesystem will not -take — `._98-fa-9b-50-d8-10.toml` has an extension that *is* on the allowlist, and -normalization strips the leading `._`, so it claims the same identity as the real file with a -body that is binary. The machine being configured then receives a parse error instead of its -answer, and `check` reports the failure against the *group* as well. `.DS_Store` is harmless -only by luck (its extension is not on the list). The file store now skips every entry whose -name starts with `.`; found on a real NAS, not by reading anything. +take — `._proxmox.toml` has an extension that *is* on the allowlist. With a directory per +identity it is worse than it was when answers were flat: it is a **second `.toml` in a +directory that may hold only one**, and it sorts *before* the real one, so a rule that took +the first would hand every request a binary body. The machine being configured then receives +a parse error instead of its answer. `.DS_Store` is harmless only by luck (its extension is +not on the list). The file store skips every entry whose name starts with `.`; found on a +real NAS, not by reading anything. **Normalizing a selector pattern strips `*` and `?`** unless you use `normalize_pattern` — which turns every glob into a literal, quietly. diff --git a/docs/guide/answers/formats.fr.md b/docs/guide/answers/formats.fr.md index ea474ba..cbac800 100644 --- a/docs/guide/answers/formats.fr.md +++ b/docs/guide/answers/formats.fr.md @@ -13,7 +13,7 @@ kickstart veut du kickstart et s'étranglerait avec du TOML. C'est le protocole, convention que quelqu'un aurait choisie. Donc : - **l'endpoint déclare le format** — `/rhel/ks` demande du kickstart ; -- **le document le porte comme extension** — `rhel-compute.ks` ; +- **le document le porte comme extension** — `groups/rhel-compute/rhel.ks` ; - **seuls les documents de ce format peuvent répondre.** ## La conséquence qui fait comprendre @@ -23,8 +23,9 @@ destinée.** Donc ceci n'est pas une machine et deux fichiers : ``` answers/ -├── 98fa9b50d810.toml « cette machine, en tant que Proxmox » -└── 98fa9b50d810.preseed « cette machine, en tant que Debian » +└── 98fa9b50d810/ + ├── proxmox.toml « cette machine, en tant que Proxmox » + └── debian.preseed « cette machine, en tant que Debian » ``` C'est un même matériel avec deux réponses, et les deux peuvent exister en même temps. Celle @@ -33,15 +34,20 @@ obtient le TOML, `/debian/preseed` obtient le preseed. Aucune n'est plus « la l'autre. En interne, c'est pourquoi un document est indexé par **(identifiant, format)** plutôt que -par identifiant seul. +par identifiant seul — et pourquoi un répertoire contient un document par format, pas +davantage. ## Le stockage n'est pas l'URL -Tout vit dans un seul répertoire plat, et c'est délibéré. **Répertoires et lignes de base -sont un espace de recherche** — ils doivent rester libres d'être réorganisés. **Une URL est -un contrat public gravé dans une ISO** — elle ne doit pas bouger parce que quelqu'un a -renommé un dossier. Une conception antérieure faisait du nom de répertoire *le* segment -d'URL et a été écartée pour exactement cette raison. +Les documents sont regroupés par *identité*, jamais par format, et c'est délibéré. +**Répertoires et lignes de base sont un espace de recherche** — ils doivent rester libres +d'être réorganisés. **Une URL est un contrat public gravé dans une ISO** — elle ne doit pas +bouger parce que quelqu'un a renommé un dossier. Une conception antérieure faisait du nom de +répertoire *le* segment d'URL et a été écartée pour exactement cette raison. + +La même règle explique pourquoi le nom de fichier dans le répertoire d'une machine ne porte +aucun sens : `proxmox.toml` se lit bien et fait écho à l'endpoint `/proxmox/`, mais seul le +`.toml` est porteur. Renommez-le `answer.toml` et rien ne change. Quel alias sert quelle extension est dans la [référence des formats](../reference/formats.md) ; comment en choisir un pour votre média @@ -172,5 +178,5 @@ aucun sens. Le groupement n'est par ailleurs pas affecté par tout cela : une baie partage un groupe *par format*, et une machine qui existe en deux systèmes d'exploitation rejoint deux d'entre eux. -`default` suit la même règle — `default.toml` répond à une requête qui a demandé du TOML, et -jamais à une qui a demandé du kickstart. +`default` suit la même règle — un `.toml` dans `default/` répond à une requête qui a demandé +du TOML, et jamais à une qui a demandé du kickstart. diff --git a/docs/guide/answers/formats.md b/docs/guide/answers/formats.md index f8e6d5f..29152eb 100644 --- a/docs/guide/answers/formats.md +++ b/docs/guide/answers/formats.md @@ -13,7 +13,7 @@ kickstart and would choke on TOML. That is the protocol, not a convention anyone So: - **the endpoint declares the format** — `/rhel/ks` asks for kickstart; -- **the document carries it as its extension** — `rhel-compute.ks`; +- **the document carries it as its extension** — `groups/rhel-compute/rhel.ks`; - **only documents of that format may answer.** ## The consequence that makes it click @@ -23,8 +23,9 @@ machine and two files: ``` answers/ -├── 98fa9b50d810.toml "that machine, as Proxmox" -└── 98fa9b50d810.preseed "that machine, as Debian" +└── 98fa9b50d810/ + ├── proxmox.toml "that machine, as Proxmox" + └── debian.preseed "that machine, as Debian" ``` It is one piece of hardware with two answers, and both can exist at once. Which one a @@ -32,16 +33,20 @@ request receives depends on the URL it arrived on — `/proxmox/answer` gets the `/debian/preseed` gets the preseed. Neither is more "the" answer than the other. Internally this is why a document is keyed by **(identifier, format)** rather than by -identifier alone. +identifier alone — and why one directory holds one document per format and no more. ## Storage is not the URL -Everything lives in one flat directory, and that is deliberate. **Directories and -database rows are a lookup space** — they must stay free to be reorganised. **A URL is a -public contract baked into an ISO** — it must not move because someone renamed a folder. +Documents are grouped by *identity*, never by format, and that is deliberate. **Directories +and database rows are a lookup space** — they must stay free to be reorganised. **A URL is +a public contract baked into an ISO** — it must not move because someone renamed a folder. An earlier design made the directory name *be* the URL segment and was discarded for exactly that reason. +The same rule is why the filename inside a machine's directory carries no meaning: +`proxmox.toml` reads well and matches the `/proxmox/` endpoint, but only the `.toml` is +load-bearing. Rename it `answer.toml` and nothing changes. + Which alias serves which extension is in the [format reference](../reference/formats.md); how to pick one for your media is in [preparing installer media](../iso.md). @@ -166,5 +171,5 @@ same reason — layering a preseed onto a TOML base is meaningless. Grouping is otherwise untouched by any of this: a rack shares one group *per format*, and a machine that exists as two operating systems joins two of them. -`default` follows the same rule — `default.toml` answers a request that asked for TOML, -and never one that asked for kickstart. +`default` follows the same rule — a `.toml` in `default/` answers a request that asked for +TOML, and never one that asked for kickstart. diff --git a/docs/guide/answers/grouping.fr.md b/docs/guide/answers/grouping.fr.md index c7208cf..90efa42 100644 --- a/docs/guide/answers/grouping.fr.md +++ b/docs/guide/answers/grouping.fr.md @@ -1,6 +1,6 @@ --- title: Groupes et fusion -description: Une baie partage un fichier ; une machine qui diffère ne porte que sa différence. Ce qui fusionne, ce qui remplace, et pourquoi les tableaux remplacent. +description: Une baie partage un document ; une machine qui diffère ne porte que sa différence. Ce qui fusionne, ce qui remplace, et pourquoi les tableaux remplacent. sidebar: label: Groupement order: 3 @@ -14,16 +14,20 @@ machine est la façon dont la configuration d'un parc dérive. Donc les réponse ``` answers/ ├── groups/ -│ ├── base.toml partagé par tout -│ └── rack-a.toml extends = "base" ; members = [ … ] -├── 98-fa-9b-50-d8-10.toml les surcharges d'une machine (optionnel) -└── default.toml seulement quand rien d'autre ne correspond +│ ├── base/ +│ │ └── proxmox.toml partagé par tout +│ └── rack-a/ +│ └── proxmox.toml extends = "base" ; members = [ … ] +├── 98-fa-9b-50-d8-10/ +│ └── proxmox.toml les surcharges d'une machine (optionnel) +└── default/ + └── proxmox.toml seulement quand rien d'autre ne correspond ``` ## La partie partagée ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = [ "98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", @@ -46,7 +50,7 @@ disk-list = ["sda", "sdb"] Une machine qui diffère reçoit un document contenant **seulement la différence** : ```toml -# answers/98-fa-9b-50-d8-10.toml +# answers/98-fa-9b-50-d8-10/proxmox.toml [global] fqdn = "node01.example.com" @@ -95,7 +99,7 @@ Un groupe peut en étendre un autre, ce qui donne une chaîne — ce que toutes partagent dans un fichier, les différences par baie dans un autre : ```toml -# answers/groups/base.toml +# answers/groups/base/proxmox.toml [global] mailto = "ops@example.com" timezone = "Europe/Paris" @@ -103,7 +107,7 @@ root-ssh-keys = ["ssh-ed25519 AAAA…REPLACE ops@example.com"] ``` ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml extends = "base" members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] @@ -117,7 +121,7 @@ Les couches s'appliquent alors `base` → `rack-a` → document machine. pour une machine qui a besoin d'un groupe où elle n'est pas listée : ```toml -# answers/98-fa-9b-50-d8-99.toml +# answers/98-fa-9b-50-d8-99/proxmox.toml extends = "rack-a" # même si rack-a ne liste pas cette MAC [global] @@ -144,7 +148,7 @@ fois dans le log, et le groupe cassé est **écarté plutôt qu'appliqué à moi 2026-08-24T08:43:36Z - warning: group "rack-a": extends unknown group "base" ``` -Un mauvais fichier de groupe n'empêche pas les autres baies de s'installer. Une machine qui +Un groupe cassé n'empêche pas les autres baies de s'installer. Une machine qui *avait besoin* de ce groupe reçoit un `500` bruyant plutôt qu'une réponse à moitié construite — servir une configuration dont la base manque installerait la machine à moitié configurée, et personne ne s'en apercevrait avant qu'elle ne tourne. @@ -167,9 +171,16 @@ store**, puis servi comme une chaîne préparée. Le cas courant en datacenter n requête. Ajouter une surcharge par machine coûte une fusion par requête — ça vaut le coup là où c'est nécessaire, et ça vaut le coup de l'éviter ailleurs. +L'autre moitié du même argument, c'est ce que coûte une *lecture*. Le store entier est relu +au plus une fois par seconde, et avec un répertoire par identité cette lecture ajoute un +`readdir` par machine au fichier qu'elle ouvrait déjà — mesuré à 2 000 machines sur un +M1 Pro : **28 ms avant le changement d'agencement, 63 ms après**. C'est amorti sur une +seconde de requêtes dans les deux cas, et les débits ci-dessus n'ont pas bougé de façon +mesurable ; mais un groupe qui dispense d'un répertoire par machine évite ce coût aussi. + ## Ensuite - [Templating](./templating.md) — `{{ serial }}` supprime la dernière raison d'avoir un - fichier par machine. + répertoire par machine. - [Validation](./validating.md) — une réponse fusionnée est un document que personne n'a écrit ; regardez-le avant qu'une baie ne le fasse. diff --git a/docs/guide/answers/grouping.md b/docs/guide/answers/grouping.md index bac21ac..f2db8e2 100644 --- a/docs/guide/answers/grouping.md +++ b/docs/guide/answers/grouping.md @@ -1,6 +1,6 @@ --- title: Groups and merging -description: A rack shares one file; a machine that differs carries only its difference. What merges, what replaces, and why arrays replace. +description: A rack shares one document; a machine that differs carries only its difference. What merges, what replaces, and why arrays replace. sidebar: label: Grouping order: 3 @@ -14,16 +14,20 @@ once per machine is how a fleet's configuration drifts. So answers compose. ``` answers/ ├── groups/ -│ ├── base.toml shared by everything -│ └── rack-a.toml extends = "base"; members = [ … ] -├── 98-fa-9b-50-d8-10.toml one machine's overrides (optional) -└── default.toml only when nothing else matches +│ ├── base/ +│ │ └── proxmox.toml shared by everything +│ └── rack-a/ +│ └── proxmox.toml extends = "base"; members = [ … ] +├── 98-fa-9b-50-d8-10/ +│ └── proxmox.toml one machine's overrides (optional) +└── default/ + └── proxmox.toml only when nothing else matches ``` ## The shared part ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = [ "98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", @@ -46,7 +50,7 @@ disk-list = ["sda", "sdb"] A machine that differs gets a document with **only the difference** in it: ```toml -# answers/98-fa-9b-50-d8-10.toml +# answers/98-fa-9b-50-d8-10/proxmox.toml [global] fqdn = "node01.example.com" @@ -95,7 +99,7 @@ A group may extend another group, giving a chain — what every rack shares in o per-rack differences in another: ```toml -# answers/groups/base.toml +# answers/groups/base/proxmox.toml [global] mailto = "ops@example.com" timezone = "Europe/Paris" @@ -103,7 +107,7 @@ root-ssh-keys = ["ssh-ed25519 AAAA…REPLACE ops@example.com"] ``` ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml extends = "base" members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] @@ -117,7 +121,7 @@ Layers then apply `base` → `rack-a` → machine document. machine that needs a group it is not listed in: ```toml -# answers/98-fa-9b-50-d8-99.toml +# answers/98-fa-9b-50-d8-99/proxmox.toml extends = "rack-a" # even though rack-a does not list this MAC [global] @@ -144,7 +148,7 @@ once, and the broken group is **dropped rather than half-applied**: 2026-08-24T08:43:36Z - warning: group "rack-a": extends unknown group "base" ``` -One bad group file does not stop the other racks from installing. A machine that +One bad group does not stop the other racks from installing. A machine that *needed* that group gets a loud `500` rather than a half-built answer — serving a configuration whose base is missing would install the machine half-configured, and nobody would find out until it was running. @@ -167,9 +171,16 @@ is read**, and served afterwards as a prepared string. The common datacenter cas nothing per request. Adding a per-machine override buys a merge per request — worth it where it is needed, and worth avoiding where it is not. +The other half of the same argument is what a *read* costs. The whole store is re-read at +most once a second, and with a directory per identity that read is a `readdir` per machine +on top of the file it already opened — measured at 2,000 machines on an M1 Pro, **28 ms +before the layout changed and 63 ms after**. It is amortised over a second's worth of +requests either way, and the throughput figures above did not move measurably; but a group +that needs no per-machine directory avoids that cost too. + ## Next -- [Templating](./templating.md) — `{{ serial }}` removes the remaining reason for a file - per machine. +- [Templating](./templating.md) — `{{ serial }}` removes the remaining reason for a + directory per machine. - [Validating](./validating.md) — a merged answer is a document nobody wrote; look at it before a rack does. diff --git a/docs/guide/answers/index.fr.md b/docs/guide/answers/index.fr.md index 55b8ee9..7c714cb 100644 --- a/docs/guide/answers/index.fr.md +++ b/docs/guide/answers/index.fr.md @@ -17,29 +17,56 @@ couches que vous avez écrites. ## L'agencement -Tout vit dans un seul répertoire plat. Il n'y a pas de dossier par OS, parce que -l'organisation du stockage et l'URL sont délibérément séparées — voir -[formats](./formats.md#le-stockage-nest-pas-lurl). +**Un répertoire par identité.** Une machine est un répertoire nommé d'après elle, qui +contient un document par système d'exploitation : ``` answers/ -├── 98fa9b50d810.toml cette machine, en tant que Proxmox -├── 98fa9b50d810.preseed …et la même machine, en tant que Debian -├── aabbccddeeff.yaml une autre machine, Ubuntu -├── default.toml quand rien d'autre ne correspond +├── 98fa9b50d810/ une machine +│ ├── proxmox.toml en tant que Proxmox +│ └── debian.preseed …et le même matériel en tant que Debian +├── aabbccddeeff/ une autre machine +│ └── ubuntu.yaml en tant qu'Ubuntu +├── default/ quand rien d'autre ne correspond +│ └── proxmox.toml └── groups/ - ├── rack-a.toml partagé par une baie, revendique ses membres - ├── base.preseed - └── rhel-compute.ks revendique des machines pour ce qu'elles sont + ├── rack-a/ partagé par une baie, revendique ses membres + │ ├── proxmox.toml + │ └── debian.preseed + └── rhel-compute/ revendique des machines pour ce qu'elles sont + └── rhel.ks ``` -- **Un document machine** est nommé d'après la machine — une adresse MAC, dans n'importe quel - style de séparateur — et porte la configuration de cette machine, ou seulement la part qui - diffère de son groupe. -- **Un groupe** vit dans `groups/` et est partagé. Il revendique des machines en les listant - dans `members`, ou par un bloc `match` testé contre la requête. -- **`default.`** répond quand rien d'autre ne le fait. Un par format : un défaut TOML ne - doit pas répondre à un client qui a demandé du kickstart. +- **Une machine** est un répertoire nommé d'après elle — une adresse MAC, dans n'importe + quel style de séparateur — qui porte la configuration de cette machine, ou seulement la + part qui diffère de son groupe. +- **Un groupe** est un répertoire sous `groups/` et il est partagé. Il revendique des + machines en les listant dans `members`, ou par un bloc `match` testé contre la requête. +- **`default/`** répond quand rien d'autre ne le fait. Un document par format : un défaut + TOML ne doit pas répondre à un client qui a demandé du kickstart. + +### L'extension décide ; le nom, non + +Dans un répertoire, **l'extension est le format et ce qui précède ne veut rien dire**. +`proxmox.toml` et `answer.toml` sont le même document pour le serveur ; le nom est là pour +qui ouvre le dossier. `rescriptum` en écrit des lisibles — `proxmox.toml`, `ubuntu.yaml`, +`debian.preseed`, `boot.ipxe` — et ne renomme jamais les vôtres. + +La seule règle qui en découle : **un répertoire contient au plus un document par format**. +Deux `.toml` dans un même répertoire est signalé comme un problème plutôt que tranché, +parce que rien ne pourrait choisir entre eux d'une façon que vous auriez prévue. Deux +formats *différents* ne sont pas un doublon — c'est justement l'intérêt du répertoire. + +L'organisation du stockage et l'URL restent délibérément séparées : un dossier peut être +réorganisé, une URL gravée dans une ISO non. Voir +[formats](./formats.md#le-stockage-nest-pas-lurl). + +:::note[Migration depuis un répertoire plat] +Les réponses étaient des fichiers à la racine du répertoire : `98fa9b50d810.toml` à côté de +`98fa9b50d810.preseed`. Ils ne sont **plus servis**, et chacun est signalé par son nom avec +son nouveau chemin. `rescriptum migrate` montre ce qu'il déplacerait ; `rescriptum migrate +--apply` les déplace. +::: ## Les cinq choses à savoir @@ -70,7 +97,7 @@ L'écriture par format est dans [formats](./formats.md#où-vivent-les-clés-de-c Le répertoire [`examples/`](https://github.com/z29k/rescriptum/tree/main/examples) du dépôt contient un exemple commenté de **chaque** format supporté, tous sélectionnés différemment — -par matériel, par liste de membres, par nom de fichier — et ils sont exercés par la suite de +par matériel, par liste de membres, par nom de répertoire — et ils sont exercés par la suite de tests : ```console diff --git a/docs/guide/answers/index.md b/docs/guide/answers/index.md index bb7a627..eb37936 100644 --- a/docs/guide/answers/index.md +++ b/docs/guide/answers/index.md @@ -16,28 +16,55 @@ asking and hand it back, assembled from however many layers you wrote. ## The layout -Everything lives in one flat directory. There is no per-OS folder, because storage layout -and URL are deliberately kept apart — see [formats](./formats.md#storage-is-not-the-url). +**One directory per identity.** A machine is a directory named after it, holding one +document per operating system: ``` answers/ -├── 98fa9b50d810.toml this machine, as Proxmox -├── 98fa9b50d810.preseed …and the same machine, as Debian -├── aabbccddeeff.yaml another machine, Ubuntu -├── default.toml when nothing else matches +├── 98fa9b50d810/ one machine +│ ├── proxmox.toml as Proxmox +│ └── debian.preseed …and the same hardware as Debian +├── aabbccddeeff/ another machine +│ └── ubuntu.yaml as Ubuntu +├── default/ when nothing else matches +│ └── proxmox.toml └── groups/ - ├── rack-a.toml shared by a rack, claims its members - ├── base.preseed - └── rhel-compute.ks claims machines by what they are + ├── rack-a/ shared by a rack, claims its members + │ ├── proxmox.toml + │ └── debian.preseed + └── rhel-compute/ claims machines by what they are + └── rhel.ks ``` -- **A machine document** is named after the machine — a MAC address, in any separator - style — and holds that machine's own configuration, or only the part of it that differs - from its group. -- **A group** lives in `groups/` and is shared. It claims machines by listing them in - `members`, or by a `match` block tested against the request. -- **`default.`** answers when nothing else does. One per format: a TOML default must - not answer a client that asked for kickstart. +- **A machine** is a directory named after it — a MAC address, in any separator style — + holding that machine's own configuration, or only the part of it that differs from its + group. +- **A group** is a directory under `groups/` and is shared. It claims machines by listing + them in `members`, or by a `match` block tested against the request. +- **`default/`** answers when nothing else does. One document per format: a TOML default + must not answer a client that asked for kickstart. + +### The extension decides; the name does not + +Inside a directory, **the extension is the format and the part before it means nothing**. +`proxmox.toml` and `answer.toml` are the same document to the server; the name is there +for whoever opens the folder. `rescriptum` writes readable ones — `proxmox.toml`, +`ubuntu.yaml`, `debian.preseed`, `boot.ipxe` — and never renames yours. + +The one rule that follows: **a directory holds at most one document per format**. Two +`.toml` in one directory is reported as a problem rather than resolved, because nothing +could pick between them that you would have predicted. Two *different* formats are not a +duplicate at all — that is the whole point of the directory. + +Storage layout and URL are still deliberately kept apart: a folder can be reorganised, a +URL baked into an ISO cannot. See [formats](./formats.md#storage-is-not-the-url). + +:::note[Upgrading from a flat directory] +Answers used to be files at the top of the directory: `98fa9b50d810.toml` beside +`98fa9b50d810.preseed`. Those are **no longer served**, and each one is reported by name +with its new path. `rescriptum migrate` shows what it would move; `rescriptum migrate +--apply` moves them. +::: ## The five things to know @@ -68,8 +95,8 @@ per-format spelling is in [formats](./formats.md#where-the-control-keys-live). The repository's [`examples/`](https://github.com/z29k/rescriptum/tree/main/examples) directory carries a commented example of **every** supported format, all selected -differently — by hardware, by member list, by filename — and they are exercised by the -test suite: +differently — by hardware, by member list, by directory name — and they are exercised by +the test suite: ```console $ RESCRIPTUM_ANSWERS_DIR=examples rescriptum check diff --git a/docs/guide/answers/selection.fr.md b/docs/guide/answers/selection.fr.md index c548db0..2e2ab83 100644 --- a/docs/guide/answers/selection.fr.md +++ b/docs/guide/answers/selection.fr.md @@ -13,41 +13,48 @@ ordonnées par la finesse avec laquelle elles visent une machine. ## 1. Par le nom -Nommez un document d'après l'adresse MAC de la machine : +Nommez un **répertoire** d'après l'adresse MAC de la machine, et mettez-y ses documents : ``` answers/ -├── 98-fa-9b-50-d8-10.toml -├── aabbccddeeff.toml -└── default.toml +├── 98-fa-9b-50-d8-10/ +│ └── proxmox.toml +├── aabbccddeeff/ +│ └── proxmox.toml +└── default/ + └── proxmox.toml ``` Quand une requête arrive, le serveur passe en minuscules tout ce qu'elle porte et retire -chaque caractère non alphanumérique, fait de même avec le nom de chaque document, et sert le -premier dont le nom apparaît **à l'intérieur** de la requête. Ainsi -`98-fa-9b-50-d8-10.toml`, `98:fa:9b:50:d8:10.toml` et `98fa9b50d810.toml` correspondent tous -à la même machine — vous n'avez jamais à vous soucier du style de séparateur que Proxmox -utilise cette version-ci, ni de la façon dont il structure son JSON. +chaque caractère non alphanumérique, fait de même avec le nom de chaque répertoire, et sert +le premier dont le nom apparaît **à l'intérieur** de la requête. Ainsi +`98-fa-9b-50-d8-10`, `98:fa:9b:50:d8:10` et `98fa9b50d810` nomment tous la même machine — +vous n'avez jamais à vous soucier du style de séparateur que Proxmox utilise cette +version-ci, ni de la façon dont il structure son JSON. + +L'identité est le nom du **répertoire** ; les noms de fichiers qu'il contient ne choisissent +rien, ils ne portent que le format dans leur extension. Cette normalisation est toute l'astuce, et c'est pourquoi cela survit à un changement de format de corps entre versions de Proxmox : c'est un test de sous-chaîne sur des octets, pas un schéma. -Rien n'empêche de nommer un document d'après un numéro de série, un code d'inventaire ou un -nom d'hôte. N'importe quelle chaîne apparaissant dans ce que la machine envoie fera l'affaire. +Rien n'empêche de nommer un répertoire d'après un numéro de série, un code d'inventaire ou +un nom d'hôte. N'importe quelle chaîne apparaissant dans ce que la machine envoie fera +l'affaire. ## 2. Par liste de membres Un groupe revendique un ensemble de machines en les listant : ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "98:fa:9b:50:d8:12"] ``` -Les chaînes de `members` sont normalisées exactement comme les noms de fichiers, donc le +Les chaînes de `members` sont normalisées exactement comme les noms de répertoires, donc le style de séparateur n'a pas d'importance ici non plus. Une machine listée n'a besoin -d'aucun document propre à moins d'avoir quelque chose à surcharger — voir +d'aucun répertoire propre à moins d'avoir quelque chose à surcharger — voir [groupes](./grouping.md). ## 3. Par ce que la machine est @@ -55,7 +62,7 @@ d'aucun document propre à moins d'avoir quelque chose à surcharger — voir Un bloc `match` revendique une machine par ses propriétés plutôt que par son identité : ```toml -# answers/groups/dell-r620.toml +# answers/groups/dell-r620/proxmox.toml [match] manufacturer = "Dell Inc." product = "PowerEdge R620" @@ -113,7 +120,7 @@ que la botte de foin. Normalisé en alphanumériques minuscules : la botte de foin de sous-chaînes qui fait fonctionner la correspondance par nom. Les valeurs de query et les segments de chemin y sont -aussi ajoutés, donc un document nommé d'après une MAC résout que la MAC soit arrivée dans un +aussi ajoutés, donc un répertoire nommé d'après une MAC résout que la MAC soit arrivée dans un corps POST ou dans une query string. Sans cela, un `GET` — qui n'a aucun corps — ne pourrait jamais correspondre par nom. diff --git a/docs/guide/answers/selection.md b/docs/guide/answers/selection.md index 70fa478..a239679 100644 --- a/docs/guide/answers/selection.md +++ b/docs/guide/answers/selection.md @@ -13,26 +13,31 @@ ordered by how narrowly they target one machine. ## 1. By name -Name a document after the machine's MAC address: +Name a **directory** after the machine's MAC address, and put its documents in it: ``` answers/ -├── 98-fa-9b-50-d8-10.toml -├── aabbccddeeff.toml -└── default.toml +├── 98-fa-9b-50-d8-10/ +│ └── proxmox.toml +├── aabbccddeeff/ +│ └── proxmox.toml +└── default/ + └── proxmox.toml ``` When a request arrives, the server lowercases everything it carries and drops every -non-alphanumeric character, does the same to each document's name, and serves the first -whose name appears **inside** the request. So `98-fa-9b-50-d8-10.toml`, -`98:fa:9b:50:d8:10.toml` and `98fa9b50d810.toml` all match the same machine — you never -have to care which separator style Proxmox happens to use this version, or how it -structures its JSON. +non-alphanumeric character, does the same to each directory's name, and serves the first +whose name appears **inside** the request. So `98-fa-9b-50-d8-10`, `98:fa:9b:50:d8:10` and +`98fa9b50d810` all name the same machine — you never have to care which separator style +Proxmox happens to use this version, or how it structures its JSON. + +The identity is the **directory** name; the filenames inside it choose nothing, they only +carry the format in their extension. That normalization is the whole trick, and it is why this survives Proxmox changing its body format between releases: it is a substring test over the bytes, not a schema. -Nothing prevents naming a document after a serial number, an asset tag or a hostname +Nothing prevents naming a directory after a serial number, an asset tag or a hostname instead. Any string that appears in what the machine sends will do. ## 2. By member list @@ -40,20 +45,20 @@ instead. Any string that appears in what the machine sends will do. A group claims a set of machines by listing them: ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "98:fa:9b:50:d8:12"] ``` -Member strings are normalized exactly like file names, so separator style does not matter -here either. A listed machine needs no document of its own unless it has something to -override — see [grouping](./grouping.md). +Member strings are normalized exactly like directory names, so separator style does not +matter here either. A listed machine needs no directory of its own unless it has something +to override — see [grouping](./grouping.md). ## 3. By what the machine is A `match` block claims a machine by its properties rather than its identity: ```toml -# answers/groups/dell-r620.toml +# answers/groups/dell-r620/proxmox.toml [match] manufacturer = "Dell Inc." product = "PowerEdge R620" @@ -107,7 +112,7 @@ A body that is not JSON is not an error — it simply contributes nothing but th ### The raw body Normalized to lowercase alphanumerics: the substring haystack that makes matching by name -work. Query values and path segments are appended to it too, so a document named after a +work. Query values and path segments are appended to it too, so a directory named after a MAC resolves whether that MAC arrived in a POST body or a query string. Without that, a `GET` — which has no body at all — could never match by name. diff --git a/docs/guide/answers/templating.fr.md b/docs/guide/answers/templating.fr.md index 91eed48..44de8fa 100644 --- a/docs/guide/answers/templating.fr.md +++ b/docs/guide/answers/templating.fr.md @@ -1,6 +1,6 @@ --- title: Templating -description: Des placeholders remplis depuis la requête, pour qu'un fichier de groupe couvre cinq cents machines — et pourquoi une valeur manquante est une erreur plutôt qu'une chaîne vide. +description: Des placeholders remplis depuis la requête, pour qu'un document de groupe couvre cinq cents machines — et pourquoi une valeur manquante est une erreur plutôt qu'une chaîne vide. sidebar: label: Templating order: 4 @@ -9,10 +9,10 @@ sidebar: # Templating Le groupement supprime la duplication entre machines d'accord. Le templating supprime la -dernière raison d'écrire un fichier par machine : les valeurs qui doivent différer. +dernière raison d'écrire un document par machine : les valeurs qui doivent différer. ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "…"] [global] @@ -22,7 +22,7 @@ fqdn = "node-{{ serial }}.example.com" filter.ID_NET_NAME_MAC = "*{{ mac }}" ``` -Cinq cents machines, un fichier. Sans cela, un nom d'hôte par machine signifie un document +Cinq cents machines, un document. Sans cela, un nom d'hôte par machine signifie un document par machine — et cinq cents documents qui diffèrent d'une ligne chacun. Les placeholders fonctionnent dans **tous** les formats : TOML, YAML, JSON, XML, kickstart, @@ -52,7 +52,7 @@ sont identiques. `{{ machine }}` est l'identifiant du *document* machine qui a matché — il n'est donc disponible que lorsque la machine a un document à elle. Une machine revendiquée par la liste -`members` d'un groupe, sans fichier à côté, n'a pas de valeur `machine` et le rendu échoue +`members` d'un groupe, sans répertoire à elle, n'a pas de valeur `machine` et le rendu échoue avec `template needs {{ machine }}, but this request carries no "machine"`. Dans un groupe, utilisez plutôt un fait de la requête : diff --git a/docs/guide/answers/templating.md b/docs/guide/answers/templating.md index 930fce7..885b2ef 100644 --- a/docs/guide/answers/templating.md +++ b/docs/guide/answers/templating.md @@ -1,6 +1,6 @@ --- title: Templating -description: Placeholders filled from the request, so one group file covers five hundred machines — and why a missing value is an error rather than an empty string. +description: Placeholders filled from the request, so one group document covers five hundred machines — and why a missing value is an error rather than an empty string. sidebar: label: Templating order: 4 @@ -9,10 +9,10 @@ sidebar: # Templating Grouping removes the duplication between machines that agree. Templating removes the last -reason to write a file per machine at all: the values that must differ. +reason to write a document per machine at all: the values that must differ. ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "…"] [global] @@ -22,7 +22,7 @@ fqdn = "node-{{ serial }}.example.com" filter.ID_NET_NAME_MAC = "*{{ mac }}" ``` -Five hundred machines, one file. Without this, a per-machine hostname means a document per +Five hundred machines, one document. Without this, a per-machine hostname means a document per machine — and five hundred documents that differ in one line each. Placeholders work in **every** format: TOML, YAML, JSON, XML, kickstart, preseed. @@ -49,7 +49,7 @@ Whitespace inside the braces is optional: `{{serial}}` and `{{ serial }}` are th `{{ machine }}` is the identifier of the machine *document* that matched — so it is only available when the machine has a document of its own. A machine claimed by a group's -`members` list, with no file next to it, has no `machine` value and rendering fails with +`members` list, with no directory of its own, has no `machine` value and rendering fails with `template needs {{ machine }}, but this request carries no "machine"`. In a group, use a request fact instead: diff --git a/docs/guide/answers/validating.fr.md b/docs/guide/answers/validating.fr.md index ca1d3de..3c5dd24 100644 --- a/docs/guide/answers/validating.fr.md +++ b/docs/guide/answers/validating.fr.md @@ -8,7 +8,7 @@ sidebar: # Valider ce qui sera servi -Avant que les réponses ne se composent, un administrateur écrivait un fichier complet et le +Avant que les réponses ne se composent, un administrateur écrivait un document complet et le validait : ```console @@ -16,8 +16,8 @@ $ proxmox-auto-install-assistant validate-answer answer.toml ``` Une fois qu'une réponse est assemblée à partir d'une chaîne de groupes, plus un document -machine, plus un remplissage de template, **le fichier que reçoit l'installateur est un -fichier que personne n'a jamais vu** — et une mauvaise fusion se manifeste par une +machine, plus un remplissage de template, **le document que reçoit l'installateur est un +document que personne n'a jamais vu** — et une mauvaise fusion se manifeste par une installation automatisée ratée à 3 h du matin. Deux sous-commandes existent pour combler ce manque, et tout changement de la fusion doit les garder fonctionnelles. @@ -59,7 +59,7 @@ Le code de sortie est 0 quand quelque chose s'est résolu, non nul quand rien ne ```console $ rescriptum check checking files:examples - 10 group(s), 8 machine file(s) + 10 group(s), 8 machine document(s) group "rhel-compute" selects on serial=7ABC* (verify with: rescriptum render --query "...") group "ubuntu-web" selects on file=user-data product=PowerEdge R6* diff --git a/docs/guide/answers/validating.md b/docs/guide/answers/validating.md index 560511e..17d1c06 100644 --- a/docs/guide/answers/validating.md +++ b/docs/guide/answers/validating.md @@ -8,14 +8,14 @@ sidebar: # Validating what will be served -Before answers composed, an admin wrote a complete file and validated it: +Before answers composed, an admin wrote a complete document and validated it: ```console $ proxmox-auto-install-assistant validate-answer answer.toml ``` Once an answer is assembled from a group chain plus a machine document plus a template -fill, **the file the installer receives is one nobody has ever seen** — and a bad merge +fill, **the document the installer receives is one nobody has ever seen** — and a bad merge surfaces as a failed unattended install at 3am. Two subcommands exist to close that gap, and any change to merging has to keep them working. @@ -57,7 +57,7 @@ have returned 404) or when rendering failed. ```console $ rescriptum check checking files:examples - 10 group(s), 8 machine file(s) + 10 group(s), 8 machine document(s) group "rhel-compute" selects on serial=7ABC* (verify with: rescriptum render --query "...") group "ubuntu-web" selects on file=user-data product=PowerEdge R6* diff --git a/docs/guide/index.fr.md b/docs/guide/index.fr.md index 7622c9f..dd8cf86 100644 --- a/docs/guide/index.fr.md +++ b/docs/guide/index.fr.md @@ -61,12 +61,12 @@ par machine. s'étranglerait avec du TOML. Donc `/rhel/ks` sert des documents `.ks` et rien d'autre, `/proxmox/answer` sert du `.toml`, `/ubuntu/` sert du YAML. La conséquence qui fait comprendre le modèle : la réponse d'une machine est spécifique au système d'exploitation -auquel elle est destinée, donc `98fa9b50d810.toml` n'est pas « cette machine » mais -*« cette machine en tant que Proxmox »* — et `98fa9b50d810.preseed` est le même matériel en -tant que Debian. Les deux existent en même temps. +auquel elle est destinée, donc `98fa9b50d810/proxmox.toml` n'est pas « cette machine » mais +*« cette machine en tant que Proxmox »* — et `98fa9b50d810/debian.preseed`, dans le même +répertoire, est le même matériel en tant que Debian. Les deux existent en même temps. → [Un document par système d'exploitation](./answers/formats.md) -**2. Une machine est revendiquée, pas cherchée.** Nommez un document d'après la MAC et il +**2. Une machine est revendiquée, pas cherchée.** Nommez un répertoire d'après la MAC et il gagne. Ou listez la machine dans les `members` d'un groupe. Ou écrivez un bloc `[match]` et laissez la machine être revendiquée pour ce qu'elle *est* — un Dell R620 dont le numéro de série commence par `7ABC`. La résolution est déterministe : nommer bat matcher, plus de @@ -74,10 +74,10 @@ critères bat moins, les égalités se départagent sur le nom trié. → [Comment une réponse est choisie](./answers/selection.md) **3. Les réponses se composent.** Une baie de machines partage tout sauf ses adresses MAC. -Mettez la partie commune dans un groupe ; une machine qui diffère reçoit un fichier +Mettez la partie commune dans un groupe ; une machine qui diffère reçoit un document contenant **seulement la différence**. Les formats structurés fusionnent vraiment — les maps clé par clé, les tableaux remplacés pour qu'une liste puisse encore être raccourcie. -Ajoutez des placeholders `{{ serial }}` et un seul fichier de groupe couvre cinq cents +Ajoutez des placeholders `{{ serial }}` et un seul document de groupe couvre cinq cents machines. → [Groupes et fusion](./answers/grouping.md) · [Templating](./answers/templating.md) @@ -106,8 +106,8 @@ Les deux sont réelles, et la conception doit satisfaire les deux : - **Un Synology DS416j** — ARMv7, 512 Mo, DSM 7, pas de Docker. La motivation d'origine, et la raison pour laquelle c'est un binaire statique unique sans runtime ni interpréteur. -- **Un hôte de datacenter** encaissant une rafale de provisioning, avec un fichier de - réponse par machine. La raison pour laquelle il est asynchrone, borne sa propre +- **Un hôte de datacenter** encaissant une rafale de provisioning, avec un répertoire de + réponses par machine. La raison pour laquelle il est asynchrone, borne sa propre concurrence, et met en cache le listing du répertoire au lieu de le parcourir à chaque requête. diff --git a/docs/guide/index.md b/docs/guide/index.md index a89cb46..23dad06 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -54,12 +54,12 @@ Either way, the answer has to be chosen — and usually assembled — per machin **1. The endpoint declares the format.** A kickstart client wants kickstart and would choke on TOML. So `/rhel/ks` serves `.ks` documents and nothing else, `/proxmox/answer` serves `.toml`, `/ubuntu/` serves YAML. The consequence that makes the model click: -a machine's answer is specific to the OS it is for, so `98fa9b50d810.toml` is not "that -machine" but *"that machine as Proxmox"* — and `98fa9b50d810.preseed` is the same -hardware as Debian. Both exist at once. +a machine's answer is specific to the OS it is for, so `98fa9b50d810/proxmox.toml` is not +"that machine" but *"that machine as Proxmox"* — and `98fa9b50d810/debian.preseed`, in the +same directory, is the same hardware as Debian. Both exist at once. → [One document per operating system](./answers/formats.md) -**2. A machine is claimed, not looked up.** Name a document after the MAC and it wins. +**2. A machine is claimed, not looked up.** Name a directory after the MAC and it wins. Or list the machine in a group's `members`. Or write a `[match]` block and let the machine be claimed by what it *is* — a Dell R620 with a serial starting `7ABC`. The resolution is deterministic: naming beats matching, more criteria beats fewer, ties break @@ -67,9 +67,9 @@ on sorted name. → [How an answer is picked](./answers/selection.md) **3. Answers compose.** A rack of machines shares everything except its MAC addresses. -Put the shared part in a group; a machine that differs gets a file containing **only the +Put the shared part in a group; a machine that differs gets a document containing **only the difference**. Structured formats really merge — maps key by key, arrays replaced so a -list can still be shortened. Add `{{ serial }}` placeholders and one group file covers +list can still be shortened. Add `{{ serial }}` placeholders and one group document covers five hundred machines. → [Groups and merging](./answers/grouping.md) · [Templating](./answers/templating.md) @@ -97,7 +97,7 @@ Both are real, and the design has to satisfy both: - **A Synology DS416j** — ARMv7, 512 MB, DSM 7, no Docker. The original motivation, and the reason this is a single static binary with no runtime and no interpreter. -- **A datacenter host** fielding a provisioning burst, with one answer file per machine. +- **A datacenter host** fielding a provisioning burst, with one answer directory per machine. The reason it is async, bounds its own concurrency, and caches the directory listing instead of walking it per request. diff --git a/docs/guide/iso.fr.md b/docs/guide/iso.fr.md index 1e8b009..bfd376f 100644 --- a/docs/guide/iso.fr.md +++ b/docs/guide/iso.fr.md @@ -106,14 +106,14 @@ Le dernier segment du chemin est disponible comme fait `file`, ce qui permet de distinguer avec un sélecteur : ```yaml -# answers/groups/ubuntu-web.yaml +# answers/groups/ubuntu-web/ubuntu.yaml match: file: "user-data" product: "PowerEdge R6*" ``` ```yaml -# answers/groups/ubuntu-meta.yaml +# answers/groups/ubuntu-meta/ubuntu.yaml match: file: "meta-data" diff --git a/docs/guide/iso.md b/docs/guide/iso.md index 4d65c5b..e40e02f 100644 --- a/docs/guide/iso.md +++ b/docs/guide/iso.md @@ -103,14 +103,14 @@ The path's last segment is available as the `file` fact, so the two are told apa selector: ```yaml -# answers/groups/ubuntu-web.yaml +# answers/groups/ubuntu-web/ubuntu.yaml match: file: "user-data" product: "PowerEdge R6*" ``` ```yaml -# answers/groups/ubuntu-meta.yaml +# answers/groups/ubuntu-meta/ubuntu.yaml match: file: "meta-data" diff --git a/docs/guide/operations/admin-api.fr.md b/docs/guide/operations/admin-api.fr.md index 281e4de..d23720f 100644 --- a/docs/guide/operations/admin-api.fr.md +++ b/docs/guide/operations/admin-api.fr.md @@ -133,9 +133,12 @@ $ curl -s -H "$AUTH" -X PUT --data-binary 'x = = 1' http://127.0.0.1:8001/machin ## Identifiants -Lettres, chiffres et `- _ . :` uniquement. Ils sont écrits comme **noms de fichiers** par -`export`, donc tout ce qui pourrait traverser un répertoire est rejeté — à la frontière de -l'API *et* dans les deux stores. +Lettres, chiffres et `- _ . :` uniquement. Ils deviennent des **noms de répertoires** sous +`export` et dans le store fichiers, donc tout ce qui pourrait traverser un répertoire est +rejeté — à la frontière de l'API *et* dans les deux stores. `groups` et `default` sont +réservés comme identifiants de machine pour la même raison : ce sont les répertoires que +l'agencement garde pour lui, et une base qui en accepterait un exporterait vers un répertoire +incapable de le contenir. ## Codes de statut diff --git a/docs/guide/operations/admin-api.md b/docs/guide/operations/admin-api.md index 1859c3d..6fe1dbc 100644 --- a/docs/guide/operations/admin-api.md +++ b/docs/guide/operations/admin-api.md @@ -131,9 +131,11 @@ $ curl -s -H "$AUTH" -X PUT --data-binary 'x = = 1' http://127.0.0.1:8001/machin ## Identifiers -Letters, digits and `- _ . :` only. They are written out as **filenames** by `export`, so -anything that could traverse a directory is rejected — at the API boundary *and* in both -stores. +Letters, digits and `- _ . :` only. They become **directory names** under `export` and in +the file store, so anything that could traverse a directory is rejected — at the API boundary +*and* in both stores. `groups` and `default` are reserved as machine ids for the same reason: +those are the directories the layout keeps for itself, and a database that accepted one would +export into a directory that cannot hold it. ## Status codes diff --git a/docs/guide/operations/media.fr.md b/docs/guide/operations/media.fr.md index c50f7ba..168fdef 100644 --- a/docs/guide/operations/media.fr.md +++ b/docs/guide/operations/media.fr.md @@ -218,7 +218,7 @@ réponses et c'est un document de réponse ordinaire — sélectionné, superpos gabarisé comme n'importe quel autre : ```console -$ rescriptum media ipxe pve-8.4 > /srv/answers/groups/rack-a.ipxe +$ rescriptum media ipxe pve-8.4 > /srv/answers/groups/rack-a/boot.ipxe ``` C'est bien le point. Le serveur ne devient pas malin sur le démarrage ; il gagne un diff --git a/docs/guide/operations/media.md b/docs/guide/operations/media.md index f9b2a2f..31d6d38 100644 --- a/docs/guide/operations/media.md +++ b/docs/guide/operations/media.md @@ -211,7 +211,7 @@ boot it is an ordinary answer document — selected, layered and templated like any other: ```console -$ rescriptum media ipxe pve-8.4 > /srv/answers/groups/rack-a.ipxe +$ rescriptum media ipxe pve-8.4 > /srv/answers/groups/rack-a/boot.ipxe ``` Which is the point. The server does not become clever about booting; it gains a diff --git a/docs/guide/operations/netboot.fr.md b/docs/guide/operations/netboot.fr.md index 236a985..f27ee6a 100644 --- a/docs/guide/operations/netboot.fr.md +++ b/docs/guide/operations/netboot.fr.md @@ -264,10 +264,14 @@ auth-token = "nas:s3cr3t" $ rescriptum config set RESCRIPTUM_INSTALLED_TOKEN=nas:s3cr3t ``` -C'est tout. La machine termine, elle le dit, et `98fa9b50d810.ipxe` devient -`installed-98fa9b50d810.ipxe` — qui ne lui correspond plus, le préfixe faisant partie du -nom comparé. Elle démarre sur son disque désormais, et la réarmer consiste à renommer le -fichier dans l'autre sens. +C'est tout. La machine termine, elle le dit, et son `.ipxe` passe de `98fa9b50d810/` à +`installed-98fa9b50d810/` — un nom de répertoire qui ne lui correspond plus, le préfixe +faisant partie du nom comparé. Elle démarre sur son disque désormais, et la réarmer consiste +à remettre le document en place. + +Le document désarmé va dans un répertoire **frère** plutôt que de rester dans celui de la +machine, pour que `98fa9b50d810/` continue de vouloir dire « la configuration de cette +machine » et que rien de ce qu'il contient n'ait à se lire comme désactivé. **Pas de jeton, pas d'endpoint** — absent plutôt qu'ouvert. Sans lui, `/installed` est une demande de réponse ordinaire comme n'importe quel chemin, ce qui permet à une URL de rester @@ -278,8 +282,9 @@ Trois choses qu'il ne fait pas, et chacune est délibérée : - **Il ne touche jamais un groupe.** Un groupe revendique un rack entier, et une machine qui finit son installation ne doit pas désarmer ses voisines. La recherche ne consulte pas les groupes du tout, plutôt que de les écarter après coup. -- **Il ne touche rien d'autre que le `.ipxe`.** Le `.toml` de la machine est ce que - l'installateur a lu pour la construire, et il reste comme trace de la manière. +- **Il ne touche rien d'autre que le `.ipxe`.** Le `.toml` de la machine, à côté dans le + même répertoire, est ce que l'installateur a lu pour la construire, et il reste comme + trace de la manière. - **Il déplace, il ne supprime pas.** C'est le seul chemin où quelque chose venu du réseau modifie le jeu de réponses : rien de ce qu'il fait n'est irréversible. @@ -394,7 +399,7 @@ s'étend en `Dell Inc.` avec l'espace et qu'iPXE n'encode rien de lui-même. Ce `||` final, c'est tout « un menu est la réponse par défaut » : une machine que quelque chose réclame reçoit sa propre réponse sans surveillance, et une machine que rien ne -réclame retombe sur le menu. C'est la description de poste de `default.toml`, mot pour +réclame retombe sur le menu. C'est la description de poste de `default/`, mot pour mot, appliquée à un autre format. ## Le menu diff --git a/docs/guide/operations/netboot.md b/docs/guide/operations/netboot.md index 7e476bb..7bc7a5e 100644 --- a/docs/guide/operations/netboot.md +++ b/docs/guide/operations/netboot.md @@ -248,10 +248,14 @@ auth-token = "nas:s3cr3t" $ rescriptum config set RESCRIPTUM_INSTALLED_TOKEN=nas:s3cr3t ``` -That is the whole of it. The machine finishes, says so, and `98fa9b50d810.ipxe` becomes -`installed-98fa9b50d810.ipxe` — which no longer matches it, because the prefix is part of -the name that gets compared. It boots its own disk from then on, and re-arming it is -renaming the file back. +That is the whole of it. The machine finishes, says so, and its `.ipxe` moves from +`98fa9b50d810/` to `installed-98fa9b50d810/` — a directory name that no longer matches it, +because the prefix is part of the name that gets compared. It boots its own disk from then +on, and re-arming it is moving the document back. + +The disarmed document goes to a **sibling directory** rather than staying inside the +machine's own, so `98fa9b50d810/` keeps meaning "this machine's configuration" and nothing +in it has to be read as switched off. **No token, no endpoint** — absent rather than open. Without one, `/installed` is an ordinary answer request like any other path, which is what keeps a URL bakeable into an @@ -262,8 +266,9 @@ Three things it will not do, and each is deliberate: - **It never touches a group.** A group claims a whole rack, and one machine finishing its install must not disarm its neighbours. The lookup does not consult groups at all rather than filtering them out afterwards. -- **It never touches anything but the `.ipxe`.** The machine's own `.toml` is what the - installer read to build it, and it stays as the record of how. +- **It never touches anything but the `.ipxe`.** The machine's own `.toml`, beside it in + the same directory, is what the installer read to build it, and it stays as the record of + how. - **It moves, it does not delete.** This is the one path where something arriving over the network changes the answer set, so nothing it does is irreversible. @@ -374,7 +379,7 @@ first. And **`:uristring`** on every SMBIOS string, because `${manufacturer}` ex That final `||` is the whole of "a menu is the default answer": a machine something claims gets its own unattended answer, and a machine nothing claims falls through to the -menu. It is `default.toml`'s job description word for word, applied to a different +menu. It is `default/`'s job description word for word, applied to a different format. ## The menu diff --git a/docs/guide/quickstart.fr.md b/docs/guide/quickstart.fr.md index df698f7..7172624 100644 --- a/docs/guide/quickstart.fr.md +++ b/docs/guide/quickstart.fr.md @@ -15,16 +15,17 @@ pouvez avoir la bonne réponse avant qu'aucune machine ne démarre. ## 1. Un répertoire et un document ```console -$ mkdir -p answers/groups +$ mkdir -p answers/groups/rack-a ``` -Le répertoire de réponses est plat. Les documents à la racine appartiennent chacun à une -machine ; `groups/` contient ceux qui sont partagés. Commencez par un groupe, puisque c'est -la forme que prend presque tout déploiement réel — une baie de machines d'accord sur tout -sauf sur les disques qu'elles ont : +**Un répertoire par identité.** Un répertoire à la racine est une machine, nommé d'après +elle ; `groups/` contient ceux qui sont partagés. Dans l'un comme dans l'autre, l'extension +nomme le format et le reste du nom de fichier n'est qu'une étiquette. Commencez par un +groupe, puisque c'est la forme que prend presque tout déploiement réel — une baie de +machines d'accord sur tout sauf sur les disques qu'elles ont : ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -70,15 +71,15 @@ disk-list = ["sda", "sdb"] La première ligne part sur stderr et dit comment la réponse a été obtenue — la famille de format, quel document machine a matché, quel groupe s'est appliqué. Le document lui-même -part sur stdout, donc `render … > answer.toml` ne vous donne que le fichier. +part sur stdout, donc `render … > answer.toml` ne vous donne que le document. ## 3. Une machine qui diffère -Le second nœud de la baie a quatre disques. Il reçoit un fichier contenant **seulement la -différence**, nommé d'après sa MAC : +Le second nœud de la baie a quatre disques. Il reçoit un répertoire nommé d'après sa MAC, +contenant un document avec **seulement la différence** : ```toml -# answers/98-fa-9b-50-d8-10.toml +# answers/98-fa-9b-50-d8-10/proxmox.toml [global] fqdn = "node01.example.com" @@ -107,7 +108,7 @@ zfs.raid = "raid10" disk-list = ["sda", "sdb", "sdc", "sdd"] ``` -Le groupe d'abord, le fichier machine par-dessus, et **la machine a gagné** partout où les +Le groupe d'abord, le document propre à la machine par-dessus, et **la machine a gagné** partout où les deux étaient en désaccord. Les tables ont fusionné clé par clé ; `disk-list` a été **remplacée**, pas concaténée — une liste qui ne pourrait que grandir ne pourrait jamais être raccourcie depuis une couche supérieure. @@ -117,7 +118,7 @@ deux étaient en désaccord. Les tables ont fusionné clé par clé ; `disk-list ```console $ RESCRIPTUM_ANSWERS_DIR=answers rescriptum check checking files:answers - 1 group(s), 1 machine file(s) + 1 group(s), 1 machine document(s) note: toml answers not schema-checked — proxmox-auto-install-assistant is not on PATH ok — everything renders ``` @@ -152,8 +153,10 @@ et regardez le serveur dire ce qu'il a fait : Cette ligne est tout le diagnostic disponible quand un déploiement dérape : qui a demandé, quelle taille faisait son corps, ce qu'il a reçu, et à partir de quoi c'était construit. -Les nouveaux fichiers sont pris en compte au fur et à mesure — pas de redémarrage, pas de -signal de rechargement. +Les nouveaux documents sont pris en compte au fur et à mesure — pas de redémarrage, pas de +signal de rechargement. L'apparition ou la disparition du répertoire entier d'une machine +est vue immédiatement ; un document ajouté ou modifié *à l'intérieur* de l'un d'eux est pris +en compte en moins d'une seconde. ## À lire ensuite @@ -162,5 +165,5 @@ signal de rechargement. - **[Un document par système d'exploitation](./answers/formats.md)** — la même machine en Proxmox, en Debian, en Ubuntu, côte à côte. - **[Templating](./answers/templating.md)** — `fqdn = "node-{{ serial }}.example.com"`, pour - qu'un groupe couvre une baie sans un fichier par machine. + qu'un groupe couvre une baie sans un répertoire par machine. - **[Préparer les médias d'installation](./iso.md)** — l'URL à graver dans l'ISO, par OS. diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 885faf8..23b99d1 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -15,16 +15,17 @@ answer right before any machine boots. ## 1. A directory and one document ```console -$ mkdir -p answers/groups +$ mkdir -p answers/groups/rack-a ``` -The answers directory is flat. Documents at the top level belong to one machine each; -`groups/` holds the shared ones. Start with a group, since that is the shape almost every -real deployment ends up with — a rack of machines that agree about everything except +**One directory per identity.** A directory at the top level is one machine, named after +it; `groups/` holds the shared ones. Inside either, the extension names the format and the +rest of the filename is just a label. Start with a group, since that is the shape almost +every real deployment ends up with — a rack of machines that agree about everything except which disks they have: ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -70,15 +71,15 @@ disk-list = ["sda", "sdb"] The first line goes to stderr and says how the answer was reached — the format family, which machine document matched, which group applied. The document itself goes to stdout, -so `render … > answer.toml` gives you just the file. +so `render … > answer.toml` gives you just the document. ## 3. One machine that differs -The second node in the rack has four disks. It gets a file containing **only the -difference**, named after its MAC: +The second node in the rack has four disks. It gets a directory named after its MAC, +holding a document with **only the difference** in it: ```toml -# answers/98-fa-9b-50-d8-10.toml +# answers/98-fa-9b-50-d8-10/proxmox.toml [global] fqdn = "node01.example.com" @@ -107,7 +108,7 @@ zfs.raid = "raid10" disk-list = ["sda", "sdb", "sdc", "sdd"] ``` -The group came first, the machine file on top, and **the machine won** wherever the two +The group came first, the machine's own document on top, and **the machine won** wherever the two disagreed. Tables merged key by key; `disk-list` was **replaced**, not appended — a list that could only grow could never be shortened from a higher layer. @@ -116,7 +117,7 @@ that could only grow could never be shortened from a higher layer. ```console $ RESCRIPTUM_ANSWERS_DIR=answers rescriptum check checking files:answers - 1 group(s), 1 machine file(s) + 1 group(s), 1 machine document(s) note: toml answers not schema-checked — proxmox-auto-install-assistant is not on PATH ok — everything renders ``` @@ -151,7 +152,9 @@ and watch the server say what it did: That line is the whole diagnostic story when a rollout misbehaves: who asked, how big their body was, what they got, and what it was built from. -New files are picked up as you add them — no restart, no reload signal. +New documents are picked up as you add them — no restart, no reload signal. A machine's +whole directory appearing or leaving is noticed at once; a document added or edited *inside* +one is picked up within a second. ## What to read next @@ -160,5 +163,5 @@ New files are picked up as you add them — no restart, no reload signal. - **[One document per operating system](./answers/formats.md)** — the same machine as Proxmox, as Debian, as Ubuntu, side by side. - **[Templating](./answers/templating.md)** — `fqdn = "node-{{ serial }}.example.com"`, - so one group covers a rack without a file per machine. + so one group covers a rack without a directory per machine. - **[Preparing installer media](./iso.md)** — the URL to bake into the ISO, per OS. diff --git a/docs/guide/reference/cli.fr.md b/docs/guide/reference/cli.fr.md index 79de02e..65053f5 100644 --- a/docs/guide/reference/cli.fr.md +++ b/docs/guide/reference/cli.fr.md @@ -19,6 +19,8 @@ Sans argument, `rescriptum` lance le serveur. Tout le reste est une sous-command | `rescriptum check` | rendre tout le store configuré et signaler ce qui casse | | `rescriptum import ` | copier un répertoire de documents dans le store configuré | | `rescriptum export ` | écrire le store configuré comme un répertoire de documents | +| `rescriptum migrate []` | montrer ce que deviendrait un répertoire de réponses plat | +| `rescriptum migrate --apply` | déplacer ces documents dans un répertoire chacun | | `rescriptum config` | afficher la configuration, et d'où vient chaque valeur | | `rescriptum config --json` | la même chose, pour un panneau de réglages | | `rescriptum config --value CLÉ` | une valeur, pour un script — jamais un identifiant | @@ -75,8 +77,35 @@ $ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum export / ``` `import` lit un **répertoire** et écrit dans le store configuré ; `export` fait l'inverse. -L'aller-retour est identique octet pour octet. Aucun des deux ne lance `check` pour vous — la -sortie vous le dit. +L'aller-retour est identique octet pour octet, chemins compris. Aucun des deux ne lance +`check` pour vous — la sortie vous le dit. + +## `migrate` + +Les réponses étaient des fichiers à la racine du répertoire de réponses — `98fa9b50d810.toml` +à côté de `98fa9b50d810.preseed`. Elles ont désormais un répertoire chacune, et un document +resté à plat est **signalé et non servi**. Cette commande les déplace : + +```console +$ rescriptum migrate +migrating /srv/answers + 98fa9b50d810.toml -> 98fa9b50d810/proxmox.toml + 98fa9b50d810.ipxe -> 98fa9b50d810/boot.ipxe + groups/rack-a.toml -> groups/rack-a/proxmox.toml + default.toml -> default/proxmox.toml + 4 document(s) to move — nothing has been changed. Re-run with --apply. +``` + +**Elle montre par défaut et ne déplace que si on le lui demande.** Le répertoire de réponses +est ce à partir de quoi une baie s'installe ; taper la commande pour savoir ce qu'elle ferait +ne doit pas le réorganiser. + +`--apply` effectue les déplacements, chacun un `rename` dans le même répertoire, si bien +qu'aucun document n'est jamais réécrit. Si une destination est déjà prise, **rien ne bouge du +tout** — y compris les documents qui auraient pu — et les collisions sont nommées : un +répertoire à moitié migré est l'état sur lequel personne ne peut raisonner. Elle prend un +répertoire en argument, `RESCRIPTUM_ANSWERS_DIR` par défaut, et sur un répertoire déjà migré +elle dit qu'il n'y a rien à déplacer. ## `config` @@ -150,7 +179,7 @@ Le code de sortie de `media check` est un contrat, comme celui de `check`. `depl s'y fie. `media ipxe` imprime sur **stdout** et met tout le reste sur stderr, de sorte que -`rescriptum media ipxe pve-8.4 > groups/rack-a.ipxe` produit un document de réponse +`rescriptum media ipxe pve-8.4 > groups/rack-a/boot.ipxe` produit un document de réponse utilisable — ce qu'il est, rien de plus. Il imprime un script, il n'en installe pas. ## `boot` diff --git a/docs/guide/reference/cli.md b/docs/guide/reference/cli.md index 614eb7d..31d5057 100644 --- a/docs/guide/reference/cli.md +++ b/docs/guide/reference/cli.md @@ -19,6 +19,8 @@ With no arguments, `rescriptum` runs the server. Everything else is a subcommand | `rescriptum check` | render everything in the configured store and report what breaks | | `rescriptum import ` | copy a directory of documents into the configured store | | `rescriptum export ` | write the configured store out as a directory of documents | +| `rescriptum migrate []` | show what a flat answers directory would become | +| `rescriptum migrate --apply` | move those documents into a directory each | | `rescriptum config` | show the configuration, and where each value comes from | | `rescriptum config --json` | the same, for a settings panel | | `rescriptum config --value KEY` | one value, for a script — never a credential | @@ -75,8 +77,34 @@ $ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum export / ``` `import` reads a **directory** and writes into the configured store; `export` does the -reverse. The round trip is byte-identical. Neither runs `check` for you — the output says -to. +reverse. The round trip is byte-identical, paths included. Neither runs `check` for you — +the output says to. + +## `migrate` + +Answers used to be files at the top of the answers directory — `98fa9b50d810.toml` beside +`98fa9b50d810.preseed`. They are now a directory each, and a document left flat is +**reported and not served**. This moves them: + +```console +$ rescriptum migrate +migrating /srv/answers + 98fa9b50d810.toml -> 98fa9b50d810/proxmox.toml + 98fa9b50d810.ipxe -> 98fa9b50d810/boot.ipxe + groups/rack-a.toml -> groups/rack-a/proxmox.toml + default.toml -> default/proxmox.toml + 4 document(s) to move — nothing has been changed. Re-run with --apply. +``` + +**It shows by default and moves only when told to.** The answers directory is what a rack +installs from; typing the command to find out what it would do must not rearrange it. + +`--apply` performs the moves, each a `rename` within the same directory, so no document is +ever rewritten. If any destination is already taken, **nothing moves at all** — including +the documents that could have — and the collisions are named: a half-migrated directory is +the state nobody can reason about. It takes a directory as an argument, defaulting to +`RESCRIPTUM_ANSWERS_DIR`, and running it on an already-migrated directory says there is +nothing to move. ## `config` @@ -147,7 +175,7 @@ nothing and exits `1`. `media check`'s exit status is a contract, like `check`'s. `deploy.sh` keys on it. `media ipxe` prints to **stdout** and puts everything else on stderr, so -`rescriptum media ipxe pve-8.4 > groups/rack-a.ipxe` produces a usable answer document — +`rescriptum media ipxe pve-8.4 > groups/rack-a/boot.ipxe` produces a usable answer document — which is all it is. It prints a script; it does not install one. ## `boot` diff --git a/docs/home.fr.md b/docs/home.fr.md index 3ef2b8b..5134bdd 100644 --- a/docs/home.fr.md +++ b/docs/home.fr.md @@ -13,9 +13,9 @@ résultat. Ce qu'une machine s'apprête à recevoir, vous pouvez le lire avant d flowchart LR M["N'importe quelle machine
même image, même URL"] M -->|"MAC · numéro de série · DMI"| R["rescriptum"] - R --> B["groups/base.toml"] - R --> A["groups/rack-a.toml"] - R --> H["98fa9b50d810.toml"] + R --> B["groups/base/"] + R --> A["groups/rack-a/"] + R --> H["98fa9b50d810/"] B --> G["fusion
la machine gagne toujours"] A --> G H --> G @@ -47,8 +47,8 @@ que sur un hôte de datacenter encaissant une rafale de provisioning. ## Trente secondes ```console -$ mkdir -p answers/groups -$ cat > answers/groups/rack-a.toml <<'TOML' +$ mkdir -p answers/groups/rack-a +$ cat > answers/groups/rack-a/proxmox.toml <<'TOML' members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -68,8 +68,9 @@ timezone = "Europe/Paris" … ``` -Voilà une baie **en tant que Proxmox**. Le même répertoire contient `groups/rack-a.ks` pour -les nœuds RHEL et `groups/rack-a.preseed` pour les Debian — même idée, autre extension. Un +Voilà une baie **en tant que Proxmox**. Le même répertoire contient `groups/rack-a/rhel.ks` +pour les nœuds RHEL et `groups/rack-a/debian.preseed` pour les Debian — même répertoire, autre +extension. Un document est indexé par *(machine, format)*, donc une machine peut être plusieurs systèmes d'exploitation à la fois et c'est l'URL qui tranche. @@ -101,7 +102,7 @@ Puis pointez ce que vous installez sur **son URL** — un seul serveur leur rép membres, ou par ce que la machine *est*. - **[Un document par système d'exploitation](./guide/answers/formats.md)** — l'extension est le format, l'endpoint choisit entre eux. -- **[Groupes et fusion](./guide/answers/grouping.md)** — une baie partage un fichier ; une +- **[Groupes et fusion](./guide/answers/grouping.md)** — une baie partage un document ; une machine qui diffère ne porte que sa différence. - **[Templating](./guide/answers/templating.md)** — `{{ serial }}` dans un groupe couvre cinq cents machines. diff --git a/docs/home.md b/docs/home.md index 006053a..16c4d6e 100644 --- a/docs/home.md +++ b/docs/home.md @@ -13,9 +13,9 @@ about to receive, you can read it before you power the machine on. flowchart LR M["Any machine
same image, same URL"] M -->|"MAC · serial · DMI"| R["rescriptum"] - R --> B["groups/base.toml"] - R --> A["groups/rack-a.toml"] - R --> H["98fa9b50d810.toml"] + R --> B["groups/base/"] + R --> A["groups/rack-a/"] + R --> H["98fa9b50d810/"] B --> G["merge
the machine always wins"] A --> G H --> G @@ -45,8 +45,8 @@ datacenter host fielding a provisioning burst. ## Thirty seconds ```console -$ mkdir -p answers/groups -$ cat > answers/groups/rack-a.toml <<'TOML' +$ mkdir -p answers/groups/rack-a +$ cat > answers/groups/rack-a/proxmox.toml <<'TOML' members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -66,8 +66,9 @@ timezone = "Europe/Paris" … ``` -That is one rack **as Proxmox**. The same directory holds `groups/rack-a.ks` for the RHEL -nodes and `groups/rack-a.preseed` for the Debian ones — same idea, different extension. A +That is one rack **as Proxmox**. The same directory holds `groups/rack-a/rhel.ks` for the +RHEL nodes and `groups/rack-a/debian.preseed` for the Debian ones — same directory, different +extension. A document is keyed by *(machine, format)*, so one machine can be several operating systems at once and the URL picks between them. @@ -99,7 +100,7 @@ Then point whatever you are installing at **its own URL** — one server answers by what the machine *is*. - **[One document per operating system](./guide/answers/formats.md)** — the extension is the format, the endpoint chooses between them. -- **[Groups and merging](./guide/answers/grouping.md)** — a rack shares one file; a machine +- **[Groups and merging](./guide/answers/grouping.md)** — a rack shares one document; a machine that differs carries only its difference. - **[Templating](./guide/answers/templating.md)** — `{{ serial }}` in a group covers five hundred machines. diff --git a/examples/52-54-00-aa-00-04.yml b/examples/52-54-00-aa-00-04/ubuntu.yml similarity index 90% rename from examples/52-54-00-aa-00-04.yml rename to examples/52-54-00-aa-00-04/ubuntu.yml index a9bdd27..5eaf5c2 100644 --- a/examples/52-54-00-aa-00-04.yml +++ b/examples/52-54-00-aa-00-04/ubuntu.yml @@ -1,7 +1,7 @@ # Ubuntu autoinstall for one machine — spelled `.yml` rather than `.yaml`. # # Both are YAML and both are served by /ubuntu/, /autoinstall/, /cloudinit/ and -# /nocloud/. But they are DIFFERENT KEYS: a `.yml` machine file will not layer onto a +# /nocloud/. But they are DIFFERENT KEYS: a `.yml` machine document will not layer onto a # `.yaml` group, because `extends` and merging resolve within one format. Pick one # spelling per fleet and stay with it; this file exists to show that the other is # accepted, not to recommend mixing them. diff --git a/examples/52-54-00-aa-00-05.json b/examples/52-54-00-aa-00-05/answer.json similarity index 100% rename from examples/52-54-00-aa-00-05.json rename to examples/52-54-00-aa-00-05/answer.json diff --git a/examples/52-54-00-aa-00-06.xml b/examples/52-54-00-aa-00-06/answer.xml similarity index 100% rename from examples/52-54-00-aa-00-06.xml rename to examples/52-54-00-aa-00-06/answer.xml diff --git a/examples/98fa9b50d810.preseed b/examples/98fa9b50d810/debian.preseed similarity index 100% rename from examples/98fa9b50d810.preseed rename to examples/98fa9b50d810/debian.preseed diff --git a/examples/98fa9b50d810.toml b/examples/98fa9b50d810/proxmox.toml similarity index 82% rename from examples/98fa9b50d810.toml rename to examples/98fa9b50d810/proxmox.toml index b548796..ff16fab 100644 --- a/examples/98fa9b50d810.toml +++ b/examples/98fa9b50d810/proxmox.toml @@ -1,6 +1,7 @@ # One machine, as Proxmox VE. # -# Its sibling `98fa9b50d810.preseed` is the SAME hardware as Debian. A machine's answer +# Its sibling `debian.preseed`, in this same directory, is the SAME hardware as Debian. +# A machine's answer # is specific to the operating system it is for, so the two are different answers to # different questions and both may exist at once — the extension is what tells them # apart, and the endpoint is what chooses between them: diff --git a/examples/README.md b/examples/README.md index 6927e52..76a3a83 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,8 +12,8 @@ RESCRIPTUM_ANSWERS_DIR=examples rescriptum # serve them on :8000 `check` has to come back clean here. `deploy.sh` runs it before it ships anything, and these files are the only place the formats are shown composing together — two of them -(`suse-node.autoyast`, `windows-node.unattend`) are what caught a missing doctype and an -unpaired `pass` attribute. +(`groups/suse-node/suse.autoyast`, `groups/windows-node/windows.unattend`) are what caught +a missing doctype and an unpaired `pass` attribute. > **These are examples, not configuration.** Every password hash is `REPLACE$ME` and every > SSH key is `AAAA...REPLACE`. Copy what you need into your own answers directory; do not @@ -21,41 +21,52 @@ unpaired `pass` attribute. ## What is here -| File | Format | Claimed by | +**One directory per identity.** The directory names the machine or the group; the +extension inside it names the format. The part before the dot is only a label for whoever +opens the folder — `proxmox.toml` and `answer.toml` are the same document to the server. + +| Document | Format | Claimed by | |---|---|---| -| `example.toml` | Proxmox VE | nothing — a commented reference, copy it and rename to a MAC | -| `98fa9b50d810.toml` | Proxmox VE | its filename; overrides `groups/example-rack.toml` | -| `98fa9b50d810.preseed` | Debian | its filename — **the same machine as Debian** | -| `aabbccddeeff.yaml` | Ubuntu autoinstall | its filename | -| `aabbccddeeff.seed` | Debian | its filename — the same machine again, second OS | -| `52-54-00-aa-00-04.yml` | Ubuntu autoinstall | its filename (`.yml` spelling) | -| `52-54-00-aa-00-05.json` | Ignition | its filename (`.json` spelling) | -| `52-54-00-aa-00-06.xml` | AutoYaST | its filename (generic `.xml` spelling) | -| `groups/example-rack.toml` | Proxmox VE | a `members` list | -| `groups/base.preseed` | Debian | a `# answer: member` directive | -| `groups/rhel-compute.ks` | kickstart | `# answer: match serial=7ABC*` | -| `groups/ubuntu-web.yaml` | Ubuntu autoinstall | `match: file=user-data, product=PowerEdge R6*` | -| `groups/ubuntu-meta.yaml` | Ubuntu autoinstall | `match: file=meta-data` — NoCloud's other half | -| `groups/flatcar-node.ign` | Ignition | a `members` list | -| `groups/suse-node.autoyast` | AutoYaST | `` | -| `groups/windows-node.unattend` | Windows unattend | `` | -| `groups/edge-router.ipxe` | iPXE boot script | `# answer: member` | -| `groups/legacy-node.cfg` | generic line-oriented | `# answer: member` | - -Between them they exercise all three ways of claiming a machine — by filename, by member -list, by selector — and both layering strategies: structural merge for TOML, YAML, JSON -and XML; concatenation for kickstart, preseed, `.cfg` and iPXE. +| `example/proxmox.toml` | Proxmox VE | nothing — a commented reference, copy it into a directory named after a MAC | +| `98fa9b50d810/proxmox.toml` | Proxmox VE | its directory; overrides `groups/example-rack` | +| `98fa9b50d810/debian.preseed` | Debian | the same directory — **the same machine as Debian** | +| `aabbccddeeff/ubuntu.yaml` | Ubuntu autoinstall | its directory | +| `aabbccddeeff/debian.seed` | Debian | the same machine again, second OS | +| `52-54-00-aa-00-04/ubuntu.yml` | Ubuntu autoinstall | its directory (`.yml` spelling) | +| `52-54-00-aa-00-05/answer.json` | Ignition | its directory (`.json` spelling) | +| `52-54-00-aa-00-06/answer.xml` | AutoYaST | its directory (generic `.xml` spelling) | +| `groups/example-rack/proxmox.toml` | Proxmox VE | a `members` list | +| `groups/base/debian.preseed` | Debian | a `# answer: member` directive | +| `groups/rhel-compute/rhel.ks` | kickstart | `# answer: match serial=7ABC*` | +| `groups/ubuntu-web/ubuntu.yaml` | Ubuntu autoinstall | `match: file=user-data, product=PowerEdge R6*` | +| `groups/ubuntu-meta/ubuntu.yaml` | Ubuntu autoinstall | `match: file=meta-data` — NoCloud's other half | +| `groups/flatcar-node/flatcar.ign` | Ignition | a `members` list | +| `groups/suse-node/suse.autoyast` | AutoYaST | `` | +| `groups/windows-node/windows.unattend` | Windows unattend | `` | +| `groups/edge-router/boot.ipxe` | iPXE boot script | `# answer: member` | +| `groups/legacy-node/answer.cfg` | generic line-oriented | `# answer: member` | + +Between them they exercise all three ways of claiming a machine — by directory name, by +member list, by selector — and both layering strategies: structural merge for TOML, YAML, +JSON and XML; concatenation for kickstart, preseed, `.cfg` and iPXE. + +Two of them, `52-54-00-aa-00-05/answer.json` and `groups/legacy-node/answer.cfg`, are +deliberately named `answer`: `.json` and `.cfg` name no single installer, so there is no +better label than none at all. Both are the names `rescriptum` itself writes. ## Three things these files are trying to teach -**A machine's answer is specific to the operating system it is for.** `98fa9b50d810.toml` -and `98fa9b50d810.preseed` are one piece of hardware with two answers, and the URL decides -which one is served. A document is keyed by *(identifier, format)*, not by identifier. +**A machine's answer is specific to the operating system it is for.** `98fa9b50d810/` holds +`proxmox.toml` and `debian.preseed`: one piece of hardware with two answers, and the URL +decides which one is served. A document is keyed by *(identifier, format)*, not by +identifier — which is exactly what the directory makes visible. A directory may hold one +document per format and no more; two `.toml` in one directory is a reported problem, +because nothing could pick between them. **Some extensions are two spellings of one format.** `.yml`/`.yaml`, `.json`/`.ign`, `.seed`/`.preseed`, `.xml`/`.autoyast`/`.unattend`. They parse identically, but they are -different keys — a `.yml` machine file will not layer onto a `.yaml` group. Pick one -spelling per fleet. +different keys — a `.yml` machine document will not layer onto a `.yaml` group, and both +may sit in one directory without colliding. Pick one spelling per fleet. **`check` renders from an identity alone.** It has no request, so it cannot supply a fact that only ever arrives with one. That is why the templating in here uses `{{ machine }}` diff --git a/examples/aabbccddeeff.seed b/examples/aabbccddeeff/debian.seed similarity index 84% rename from examples/aabbccddeeff.seed rename to examples/aabbccddeeff/debian.seed index c899cd5..b1e8d78 100644 --- a/examples/aabbccddeeff.seed +++ b/examples/aabbccddeeff/debian.seed @@ -1,4 +1,4 @@ -# The same machine as examples/aabbccddeeff.yaml — that one as Ubuntu, this one as +# The same machine as `ubuntu.yaml` beside it — that one as Ubuntu, this one as # Debian. A document is keyed by (identifier, format), so both exist at once and the URL # decides which is served. # @@ -6,7 +6,7 @@ # serve either. The two extensions exist because `seed` is deliberately NOT an endpoint # alias — `s=http://server/seed/` is an ordinary NoCloud seed URL, and it serves YAML. # -# Prefer `.preseed` for new files; this one is here to show the alias is real. +# Prefer `.preseed` for new documents; this one is here to show the alias is real. d-i debian-installer/locale string fr_FR.UTF-8 d-i keyboard-configuration/xkb-keymap select fr diff --git a/examples/aabbccddeeff.yaml b/examples/aabbccddeeff/ubuntu.yaml similarity index 80% rename from examples/aabbccddeeff.yaml rename to examples/aabbccddeeff/ubuntu.yaml index db7ef9b..7ae6311 100644 --- a/examples/aabbccddeeff.yaml +++ b/examples/aabbccddeeff/ubuntu.yaml @@ -1,6 +1,6 @@ # One Ubuntu machine, carrying only what differs from its group. # -# It is claimed by `ubuntu-web.yaml` through that group's `match` block, and layered on +# It is claimed by `groups/ubuntu-web/` through that group's `match` block, and layered on # top of it: this hostname wins, everything else is inherited. Naming a file after a MAC # is the most specific claim there is, so it always beats a selector. # diff --git a/examples/example.toml b/examples/example/proxmox.toml similarity index 94% rename from examples/example.toml rename to examples/example/proxmox.toml index a785d53..54ab260 100644 --- a/examples/example.toml +++ b/examples/example/proxmox.toml @@ -1,8 +1,8 @@ # One machine's answer file. # # The filename is what matches: name it after the machine's MAC address, in any -# separator style — `98-fa-9b-50-d8-10.toml`, `98:fa:9b:50:d8:10.toml` and -# `98fa9b50d810.toml` all match the same machine. Both sides are lowercased and +# separator style — directories named `98-fa-9b-50-d8-10`, `98:fa:9b:50:d8:10` and +# `98fa9b50d810` all match the same machine. Both sides are lowercased and # stripped of non-alphanumerics before comparing. # # If this machine belongs to a group (see groups/), keep only the keys that differ from diff --git a/examples/groups/base.preseed b/examples/groups/base/debian.preseed similarity index 100% rename from examples/groups/base.preseed rename to examples/groups/base/debian.preseed diff --git a/examples/groups/edge-router.ipxe b/examples/groups/edge-router/boot.ipxe similarity index 100% rename from examples/groups/edge-router.ipxe rename to examples/groups/edge-router/boot.ipxe diff --git a/examples/groups/example-rack.toml b/examples/groups/example-rack/proxmox.toml similarity index 93% rename from examples/groups/example-rack.toml rename to examples/groups/example-rack/proxmox.toml index 4b7c715..7ef2313 100644 --- a/examples/groups/example-rack.toml +++ b/examples/groups/example-rack/proxmox.toml @@ -2,7 +2,7 @@ # # `members` lists the machines this group answers for — separator style does not # matter, same as filenames. A listed machine needs no file of its own unless it has -# something to override, in which case add `.toml` next to this directory and put +# something to override, in which case add a `/` directory beside `groups/` and put # only the difference in it. # # `members` and `extends` are this server's keys, not Proxmox's. They are stripped from diff --git a/examples/groups/flatcar-node.ign b/examples/groups/flatcar-node/flatcar.ign similarity index 100% rename from examples/groups/flatcar-node.ign rename to examples/groups/flatcar-node/flatcar.ign diff --git a/examples/groups/legacy-node.cfg b/examples/groups/legacy-node/answer.cfg similarity index 100% rename from examples/groups/legacy-node.cfg rename to examples/groups/legacy-node/answer.cfg diff --git a/examples/groups/rhel-compute.ks b/examples/groups/rhel-compute/rhel.ks similarity index 100% rename from examples/groups/rhel-compute.ks rename to examples/groups/rhel-compute/rhel.ks diff --git a/examples/groups/suse-node.autoyast b/examples/groups/suse-node/suse.autoyast similarity index 100% rename from examples/groups/suse-node.autoyast rename to examples/groups/suse-node/suse.autoyast diff --git a/examples/groups/ubuntu-meta.yaml b/examples/groups/ubuntu-meta/ubuntu.yaml similarity index 100% rename from examples/groups/ubuntu-meta.yaml rename to examples/groups/ubuntu-meta/ubuntu.yaml diff --git a/examples/groups/ubuntu-web.yaml b/examples/groups/ubuntu-web/ubuntu.yaml similarity index 95% rename from examples/groups/ubuntu-web.yaml rename to examples/groups/ubuntu-web/ubuntu.yaml index b726c0b..756d998 100644 --- a/examples/groups/ubuntu-web.yaml +++ b/examples/groups/ubuntu-web/ubuntu.yaml @@ -16,7 +16,7 @@ match: # cloud-init fetches several named files from one URL; this is the one that carries - # the install. See ubuntu-meta.yaml for the other required half. + # the install. See groups/ubuntu-meta/ for the other required half. file: "user-data" product: "PowerEdge R6*" diff --git a/examples/groups/windows-node.unattend b/examples/groups/windows-node/windows.unattend similarity index 100% rename from examples/groups/windows-node.unattend rename to examples/groups/windows-node/windows.unattend diff --git a/packaging/boot-rig/answers/98-fa-9b-50-d8-10.ipxe b/packaging/boot-rig/answers/98-fa-9b-50-d8-10/boot.ipxe similarity index 100% rename from packaging/boot-rig/answers/98-fa-9b-50-d8-10.ipxe rename to packaging/boot-rig/answers/98-fa-9b-50-d8-10/boot.ipxe diff --git a/packaging/dsm/CLAUDE.md b/packaging/dsm/CLAUDE.md new file mode 100644 index 0000000..483da30 --- /dev/null +++ b/packaging/dsm/CLAUDE.md @@ -0,0 +1,218 @@ +# CLAUDE.md — packaging/dsm + +Guidance for working on the Synology package. It loads only when Claude touches files under +`packaging/dsm/`; the root `CLAUDE.md` keeps the one rule that has to be visible everywhere — +**changing anything here means running the machine**, not just the local harness. + +## The DSM package + +`packaging/dsm/` wraps an already-built binary as a DSM 7 `.spk`. It is a **release +format**, exactly like the `.tar.gz` archives — no DSM-specific build, no feature flag, +nothing in `src/`. The **four** places DSM pressed back are answered in packaging: log +rotation by a `copytruncate` stanza, a CLI that cannot find its configuration by a +three-line wrapper (`rescriptum-cli`, which names `RESCRIPTUM_ENV_FILE`), no settings panel +by the desktop application below, and **a privileged port by one root command**. DSM 7 +does not let an unsigned package run as root — measured, four routes, in +`docs/development/traps.md` with the error codes — but `setcap cap_net_bind_service=+ep` +on the installed binary works, after which the package binds `udp/69` as its own +unprivileged user alongside 8000 and 8001. All three are registered with the firewall. +**The package ships the loaders**, so the share's `boot` folder arrives filled and `start` +refreshes it when the stamp does not name this version; a TFTP server with nothing to hand +out boots nothing, and a second download is how a working appliance becomes a support +thread. Verified on the machine by fetching `ipxe-undionly.kpxe` over TFTP with an +independent client and comparing it byte for byte. +**The capability belongs to the file, so an upgrade drops it**; the env file says so and +points at a Task Scheduler boot-up task. `RESCRIPTUM_TFTP_ADDR` is therefore left unset — +its default *is* port 69, which is what every loader and every generated snippet expects. +An earlier version shipped `off` and sent operators to DSM's own TFTP server: that traded +the product's first principle for a packaging constraint that turned out not to exist, and +it is not a precedent. `RESCRIPTUM_USER`/`_GROUP` stay documented as unusable — the package +already is its own unprivileged user. If this ever seems to need a `#[cfg]`, the design has gone wrong. + +```bash +./build.sh --spk x86_64-unknown-linux-musl # build, then wrap +packaging/dsm/make-spk.sh armv7 # wrap an existing build +packaging/dsm/check-spk.sh # structural check ⎫ both run +packaging/dsm/lifecycle-test.sh # drive the scripts ⎭ by ci.yml +packaging/dsm/vm/on-dsm.sh admin@nas # what only DSM can answer +``` + +**The package is tested in three places, and none of it is Rust** — `cargo test` does not +touch it. `check-spk.sh` asserts the archive's shape; `lifecycle-test.sh` unpacks an `.spk` +into a fake `/var/packages` tree and drives the real scripts through install (with a wizard +and without), start, `/health`, the exit codes, an upgrade over a hand-edited env file and +a canary — with `etc/` surviving and with it wiped — and an uninstall; both run on every +push. `vm/on-dsm.sh` runs the rest on a DSM 7 VM and then on the DS416j: `data-share`'s +ACL, `port-config`, the generated unit, `logrotate -f` against a live descriptor, and +whether Package Center accepts the archive at all. **Nothing ships on VM evidence alone**, +and `lifecycle-test.sh` was watched failing — reintroducing one defect turns 54 green into +46 green and 8 red. **It earns its keep:** its first run over the boot-media package caught +a live `RESCRIPTUM_MEDIA_ADDR` with `RESCRIPTUM_MEDIA_DIR` still commented, which is a +startup error — the package would not have started at all. + +### The desktop application + +`packaging/dsm/payload/ui/` is a **real DSM application** — `SYNO.SDS.AppWindow`, +`syno_formpanel`, `syno_textfield`, `syno_combobox`, `syno_button` — not a page of ours in a +frame. `dsmuidir="ui"` makes DSM symlink it into +`/usr/syno/synoman/webman/3rdparty/rescriptum`, and `dsmappname` names the class `ui/config` +declares. It manages the server's configuration, shows its status and tails its log. + +**ExtJS, not Vue, and the machine decided that.** DSM 7.2 ships a Vue framework and +Synology's current guide documents only that one — the first version of this was written +against it. The DS416j is capped at **DSM 7.1.1**, where `Vue` is undefined. ExtJS is on both +(7.1.1 and 7.2.2, measured), so one application covers every DSM this package supports; +`os_min_ver` is **7.1**, and 7.0 is not claimed because nothing has run there. The API is +documented in the ExtJS reference Synology generated for DSM, mirrored at + as `docs/synoextjsdocs.tar.gz`. + +The design rule holds: nothing in `src/` knows any of this exists. What the server gained is +a *generic* `config` subcommand, and the application's backend — `ui/api.cgi` — is a hundred +lines of shell that authenticate and then shell out to `rescriptum-cli config` and `media`. **The panel never grows a rule of its own**: it starts a download by calling `media add`, which is where the digest rules are tested, and it follows one by watching the `.part` file that command already writes — a CGI cannot hold a request open for 1.5 GB, and nothing about progress had to be invented for the browser. The env-file +semantics stay in Rust where they are tested rather than being written a second time in `sh`. + +**Four things were measured on the machine and every one of them is load-bearing. None is in +the developer guide** (they are in `docs/development/traps.md` at length): + +- **A CGI there runs as the owner of the script**, which for a package tree is the package + user. Not `http`, not root. That is what lets it read the `0600` env file it owns, and why + it cannot start or stop anything — restarting goes through DSM's own + `SYNO.Core.Package.Control`, from the application, with the administrator's session. +- **DSM does not authenticate that path.** An unauthenticated request gets `200`. So + `authenticate.cgi` plus an `administrators` check *is* the door, and a write additionally + needs a header a cross-origin page cannot make a browser send. Losing any of them would be + silent, which is why `check-spk.sh` greps for them **with the comments stripped** — the + first version of that check passed because the word appeared in a comment. +- **No `su`, ever.** It hangs a CGI outright without ``**, so the package root is the + fixed `/var/packages/`, never `dirname "$SYNOPKG_PKGDEST"`. `RESCRIPTUM_PKG_ROOT` is + the seam that lets `lifecycle-test.sh` drive the scripts against a writable tree. +- **`etc/` and `var/` survive an uninstall** (they are symlinks into `@appconf`/`@appdata`), + so the env file and its tokens outlive the package — said plainly in the Synology page. +- **`$SYNOPKG_TEMP_UPGRADE_FOLDER` outlives its upgrade**, so restoring from it requires + `SYNOPKG_PKG_STATUS = UPGRADE` or a fresh install resurrects a removed configuration. +- **The firewall directory is `/usr/local/etc/services.d/`** (plural; the guide is wrong), + and `port-config` acquires *after* `postinst` — the wizard's port does reach it. Both + `port-config` and `usr-local-linker` acquire when the package is **enabled**, not at + `postinst`. +- **The generated unit has no `Restart=`**: DSM does not restart the process if it dies. + +**Changing anything under `packaging/dsm/` means running the machine**, not just the local +harness — the procedure is in `packaging/dsm/vm/README.md` (*Changing the package? This is +the procedure*), and `AGENTS.md` points at it. A DSM 7.2.2 VM already exists in Docker on +the maintainer's machine with a `clean` snapshot; `bootstrap.sh` sets one up from scratch, +`on-dsm.sh` drives it, and the run is destructive on purpose. It asks the server for a real +answer — a machine file merged over the group that claims it — rather than settling for +`/health`. + +The harnesses catch a broken archive and broken scripts; only Package Center catches a +broken package. **A tag must not be the first time an `.spk` meets a DSM machine** — the +rig is `packaging/dsm/vm/`: `docker-compose.yml` runs Synology's own Virtual DSM (DSM 7.2, +close to the DS416j's 7.2.1). KVM makes it fast, not possible — without `/dev/kvm` the image +falls back to emulation on its own, about ten times slower, which is what +`docker-compose.emulated.yml` is for. What does stop a host is **14 GiB free**, hardcoded in +the image and not derived from `DISK_SIZE`. `run-vm.sh` is the loader-image fallback. +## Traps already hit (do not re-discover these) + +These are the DSM-specific half of the root file's trap list. The long form of all of them is +in `docs/development/traps.md`. + +- **There is exactly one route to port 69 on DSM 7, and it is `setcap`.** `run-as: root` + in `conf/privilege` is refused with `synopkg` error **319**, `invalid package privilege + content` — in `defaults` *and* as a per-action `ctrl-script`, the shape Synology's own + packages use. A `security.capability` xattr in `package.tgz` installs and **Package + Center strips it**. `setcap cap_net_bind_service=+ep` after install works; + `net.ipv4.ip_unprivileged_port_start` does not exist on that kernel. Measured on a 7.2.2 + machine, all four. +- **Root on DSM 7 is gated on the *signature*, and `libsynopkg.so.1` says so.** Its + strings carry the whole rule: a package failing `verifyPackageSignature` may not have a + `ctrl-script` or `executable` section, must have `defaults.run-as` = `package`, and + — the line that matters — `tool capabilities should not exist`. DSM's privilege format + has a native `capabilities` field (documented since 7.0-40656), so a **signed** package + declares `cap_net_bind_service` and never needs `setcap`. Synology's guide states it + plainly — *"you are not able to install that package unless it is signed by synology"* — + so it is their signature, not a trusted publisher's. The one documented bypass, a + *development token*, is valid only on the NAS that generated its `debug.dat`, so it is + not a distribution path. **The manual `setcap` is settled, not provisional**; no + packaging change removes it. +- **`setcap` holds on the DS416j's volume, measured there.** The four routes to port 69 + were measured on an x86_64 VM whose `/volume1` is btrfs, `nodev` but not `nosuid`; a + `nosuid` mount makes the kernel ignore file capabilities outright, which would have + closed the last open route on the one machine this exists for. On the DS416j (ARMv7, + `armada38x`) the package binds `udp/69` and `boot check` says + `0.0.0.0:69 handed over ipxe-arm64.efi`. +- **A file capability does not survive an upgrade** — the new binary is a different file. + That is why a failed TFTP bind is the **one** listener failure here that is not fatal: + when it was, an upgrade took the answer endpoint down with it, failing every install in + flight to report that a second port could not be opened. It warns, `boot check` exits + non-zero, and the DSM panel shows a `tftp:` line. +- **A default computed at runtime must be computed in `settings()` too.** The DSM panel + renders a variable's default as the field's value, so a default living only where the + server consumes it shows as an empty box while the server runs on a value it derived. + `RESCRIPTUM_PUBLIC_HOST` shipped that way. Two `KNOWN` entries are special-cased there + — the worker count and the public host — and nothing in the type system says a third + would need it. diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh index 4e2d622..a860dd6 100755 --- a/packaging/dsm/lifecycle-test.sh +++ b/packaging/dsm/lifecycle-test.sh @@ -294,7 +294,8 @@ cp "$WORK/env.good" "$ENV_FILE" section "an upgrade must not touch a hand-edited configuration" printf 'RESCRIPTUM_LOG=problems\nRESCRIPTUM_ANSWER_TOKEN=a-token-nobody-should-lose\n' >>"$ENV_FILE" cp "$ENV_FILE" "$WORK/env.handedited" -echo "do not delete me" >"$SHARE/answers/canary.toml" +mkdir -p "$SHARE/answers/canary" +echo "do not delete me" >"$SHARE/answers/canary/proxmox.toml" UPG="$WORK/upgrade" export SYNOPKG_TEMP_UPGRADE_FOLDER="$UPG" @@ -314,7 +315,7 @@ upgrade() { # upgrade no diff -q "$WORK/env.handedited" "$ENV_FILE" >/dev/null && ok "etc/ survives: the file is untouched, wizard values and all" || bad "the upgrade rewrote the user's env file" -[ -f "$SHARE/answers/canary.toml" ] && ok "the canary in the share survived" || bad "the upgrade destroyed a file in the share" +[ -f "$SHARE/answers/canary/proxmox.toml" ] && ok "the canary in the share survived" || bad "the upgrade destroyed a file in the share" upgrade yes diff -q "$WORK/env.handedited" "$ENV_FILE" >/dev/null && ok "etc/ wiped: postinst restored it from the upgrade folder rather than writing defaults" || bad "the user's configuration was replaced by defaults — postinst runs BEFORE postupgrade" @@ -475,7 +476,7 @@ section "uninstall must leave the answers alone" unset SYNOPKG_TEMP_UPGRADE_FOLDER SYNOPKG_PKG_STATUS=UNINSTALL sh "$ROOT/scripts/preuninst" >/dev/null 2>&1 SYNOPKG_PKG_STATUS=UNINSTALL sh "$ROOT/scripts/postuninst" >/dev/null 2>&1 -[ -f "$SHARE/answers/canary.toml" ] && ok "the share and everything in it survived the uninstall" || bad "the uninstall took the user's answers with it" +[ -f "$SHARE/answers/canary/proxmox.toml" ] && ok "the share and everything in it survived the uninstall" || bad "the uninstall took the user's answers with it" [ -d "$SHARE" ] && ok "the shared folder itself is still there" || bad "the shared folder was removed" echo diff --git a/packaging/dsm/vm/remote-check.sh b/packaging/dsm/vm/remote-check.sh index 1307e7c..7b9ed18 100755 --- a/packaging/dsm/vm/remote-check.sh +++ b/packaging/dsm/vm/remote-check.sh @@ -68,7 +68,8 @@ esac # anything else from the share. Without it a stale canary or test answer makes the next # run fail for a reason that has nothing to do with the package. rm -rf /volume1/*/answers/canary.txt /volume1/*/answers/canary.toml \ - /volume1/*/answers/default.toml /volume1/*/answers/98-fa-9b-50-d8-10.toml \ + /volume1/*/answers/canary \ + /volume1/*/answers/default /volume1/*/answers/98-fa-9b-50-d8-10 \ /volume1/*/answers/groups 2>/dev/null # **etc/ and var/ survive an uninstall.** /var/packages//etc and /var/packages//var @@ -319,18 +320,20 @@ section "answering a machine, which is what the package exists to do" # configuration gets one — selection, group membership, merging and the format/endpoint # binding all sit between the two, and all of them read files from the share as the package # user. This is the assertion that covers the actual product on the actual machine. -mkdir -p "$SHARE/answers/groups" -cat >"$SHARE/answers/groups/rack.toml" <<'GROUP' +# One directory per identity: the directory names the machine or the group, and the +# extension inside it names the format. +mkdir -p "$SHARE/answers/groups/rack" "$SHARE/answers/98-fa-9b-50-d8-10" "$SHARE/answers/default" +cat >"$SHARE/answers/groups/rack/proxmox.toml" <<'GROUP' members = ["98:fa:9b:50:d8:10"] [global] keyboard = "fr" GROUP -cat >"$SHARE/answers/98-fa-9b-50-d8-10.toml" <<'MACHINE' +cat >"$SHARE/answers/98-fa-9b-50-d8-10/proxmox.toml" <<'MACHINE' [global] fqdn = "rig-machine.example.com" MACHINE -cat >"$SHARE/answers/default.toml" <<'DEFAULT' +cat >"$SHARE/answers/default/proxmox.toml" <<'DEFAULT' [global] fqdn = "should-not-be-served.example.com" DEFAULT @@ -346,7 +349,7 @@ if [ -z "$ANSWER" ]; then else echo "$ANSWER" | sed 's/^/ | /' case "$ANSWER" in - *rig-machine.example.com*) ok "the machine's own file was chosen over default.toml" ;; + *rig-machine.example.com*) ok "the machine's own document was chosen over the default" ;; *) bad "the answer is not this machine's — selection did not work" ;; esac case "$ANSWER" in @@ -354,7 +357,7 @@ else *) bad "the group's value is missing — members/merge did not work" ;; esac case "$ANSWER" in - *should-not-be-served*) bad "default.toml leaked into a machine's answer" ;; + *should-not-be-served*) bad "the default leaked into a machine's answer" ;; *members*) bad "the control key 'members' was served to the installer" ;; *) ok "no default fallback and no control keys in what the installer receives" ;; esac @@ -368,10 +371,10 @@ case "$GET" in *) bad "the query-string route did not resolve the machine" ;; esac -# An identity nobody claims must fall back to default.toml, not to nothing. +# An identity nobody claims must fall back to the default, not to nothing. UNKNOWN=$(curl -fsS -m 20 "http://127.0.0.1:$PORT/answer?mac=00-00-00-00-00-01" 2>/dev/null) case "$UNKNOWN" in -*should-not-be-served*) ok "an unknown machine falls back to default.toml" ;; +*should-not-be-served*) ok "an unknown machine falls back to the default" ;; *) bad "an unknown machine got no default" ;; esac @@ -429,8 +432,9 @@ fi # ── 4. the CLI, as the package user ──────────────────────────────────────────── section "the CLI on PATH" -echo 'global.keyboard = "fr"' >"$SHARE/answers/default.toml" -chown "$PKG" "$SHARE/answers/default.toml" 2>/dev/null +mkdir -p "$SHARE/answers/default" +echo 'global.keyboard = "fr"' >"$SHARE/answers/default/proxmox.toml" +chown -R "$PKG" "$SHARE/answers/default" 2>/dev/null run sudo -u "$PKG" /usr/local/bin/$PKG-cli check # Run as root it succeeds whatever the ACL says, which is what makes the sudo -u form the # real test. diff --git a/src/cli.rs b/src/cli.rs index 0fbfcb0..8bba38a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -23,6 +23,8 @@ USAGE: rescriptum check validate the configured store rescriptum import load a directory of TOML into the store rescriptum export write the store out as a directory of TOML + rescriptum migrate show what a flat answers directory would become + rescriptum migrate --apply move those documents into their own directories rescriptum config show the configuration, and where each value comes from rescriptum config --json the same, for a settings panel rescriptum config --value K one value, for a script (never a credential) @@ -226,7 +228,7 @@ pub fn check(cfg: &Config) -> ExitCode { let groups = answers.group_names().unwrap_or_default(); let machines = answers.machine_ids().unwrap_or_default(); println!( - " {} group(s), {} machine file(s)", + " {} group(s), {} machine document(s)", groups.len(), machines.len() ); @@ -356,6 +358,122 @@ pub fn import(cfg: &Config, args: &[String]) -> ExitCode { ) } +/// `migrate [--apply] []` — move a flat answers directory into the layout. +/// +/// **Shows by default and moves only when told to.** The answers directory is the thing +/// a rack installs from; a command that rearranges it the moment somebody types the name +/// to find out what it would do is not a command anybody should have to be careful with. +/// +/// Every move is a `rename` within the same directory, so the documents themselves are +/// never rewritten and a half-finished run leaves both halves readable. +pub fn migrate(cfg: &Config, args: &[String]) -> ExitCode { + let mut apply = false; + let mut dir: Option<&String> = None; + for arg in args { + match arg.as_str() { + "--apply" => apply = true, + other if other.starts_with('-') => { + eprintln!("unknown option {other:?}\nusage: rescriptum migrate [--apply] []"); + return ExitCode::FAILURE; + } + other => { + if dir.replace(arg).is_some() { + eprintln!("only one directory: {other:?}"); + return ExitCode::FAILURE; + } + } + } + } + let dir = dir + .map(std::path::PathBuf::from) + .unwrap_or_else(|| cfg.answers_dir.clone()); + + let moves = match crate::store::file::pending_moves(&dir) { + Ok(moves) => moves, + Err(e) => { + eprintln!("cannot read {}: {e}", dir.display()); + return ExitCode::FAILURE; + } + }; + + println!("migrating {}", dir.display()); + if moves.is_empty() { + println!(" nothing to move — every answer is already in a directory of its own"); + return ExitCode::SUCCESS; + } + + // Collisions first, all of them, before a single rename: finding out halfway through + // that one document cannot move is worse than finding out before anything did. + let mut blocked = Vec::new(); + for m in &moves { + if m.to.exists() { + blocked.push(m); + } + } + if !blocked.is_empty() { + for m in &blocked { + println!( + " BLOCKED {} -> {} already exists", + relative(&m.from, &dir), + relative(&m.to, &dir) + ); + } + println!( + " {} document(s) cannot move; nothing has been changed. \ + Reconcile them by hand — two documents of one format have no order between \ + them, so this cannot be decided here.", + blocked.len() + ); + return ExitCode::FAILURE; + } + + let mut failures = 0; + for m in &moves { + let from = relative(&m.from, &dir); + let to = relative(&m.to, &dir); + if !apply { + println!(" {from} -> {to}"); + continue; + } + let done = + m.to.parent() + .map(std::fs::create_dir_all) + .unwrap_or(Ok(())) + .and_then(|()| std::fs::rename(&m.from, &m.to)); + match done { + Ok(()) => println!(" {from} -> {to}"), + Err(e) => { + println!(" FAILED {from} -> {to}: {e}"); + failures += 1; + } + } + } + + if !apply { + println!( + " {} document(s) to move — nothing has been changed. \ + Re-run with --apply.", + moves.len() + ); + return ExitCode::SUCCESS; + } + if failures == 0 { + println!(" moved {} document(s) — now run `check`", moves.len()); + ExitCode::SUCCESS + } else { + println!(" {failures} failure(s)"); + ExitCode::FAILURE + } +} + +/// A path as the operator sees it, against the directory being migrated. +fn relative(path: &std::path::Path, root: &std::path::Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() +} + /// `export ` — write the configured store out as a directory of answer files. pub fn export(cfg: &Config, args: &[String]) -> ExitCode { let [dir] = args else { diff --git a/src/format/mod.rs b/src/format/mod.rs index fff5e27..43fe574 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -120,6 +120,33 @@ pub fn endpoint_formats(segment: &str) -> Option<&'static [&'static str]> { }) } +/// The filename to write a new document under, before the extension. +/// +/// **The stem decides nothing.** A document's format is its extension, and its identity +/// is the directory it sits in; `proxmox.toml` and `answer.toml` are the same document +/// to this server. This only picks a readable name for one nobody has named themselves +/// — a document already on disk keeps whatever it is called. +/// +/// The names are the endpoint aliases wherever there is one, so a directory listing +/// reads the way the URLs do. `boot` is the exception and deliberately not an alias: an +/// `.ipxe` document is what boots the installer, not an operating system. +pub fn canonical_stem(ext: &str) -> &'static str { + match ext.to_ascii_lowercase().as_str() { + "toml" => "proxmox", + "yaml" | "yml" => "ubuntu", + "ign" => "flatcar", + "autoyast" => "suse", + "unattend" => "windows", + "ks" => "rhel", + "preseed" | "seed" => "debian", + "ipxe" => "boot", + // Nothing more specific to say: `json` is Ignition *or* a plain document, and + // `xml`/`cfg` name no single installer. `answer.json` beats `json.json`, and an + // extension we do not serve never reaches a write but is still owed a name. + _ => "answer", + } +} + #[derive(Debug, Clone)] enum Inner { Toml(toml_edit::DocumentMut), @@ -723,6 +750,30 @@ mod tests { } } + /// A canonical stem must never be an endpoint alias for a *different* format. + /// + /// The stem means nothing to this server — but somebody reading `ubuntu.yaml` in a + /// directory will read it as "this machine as Ubuntu", and a table that wrote + /// `debian.yaml` would be lying to them in the one place they look. Either the name + /// is an alias that resolves to this very extension, or it is not an alias at all. + #[test] + fn a_canonical_stem_never_names_another_format() { + for ext in [ + "toml", "yaml", "yml", "json", "ign", "xml", "autoyast", "unattend", "ks", "preseed", + "seed", "cfg", "ipxe", + ] { + let stem = canonical_stem(ext); + assert!(!stem.is_empty(), "{ext} has no name to be written under"); + if let Some(formats) = endpoint_formats(stem) { + assert!( + formats.contains(&ext), + ".{ext} would be written as {stem}.{ext}, and {stem:?} is the endpoint \ + for {formats:?}" + ); + } + } + } + /// Every alias the table names, with a format its installer would actually expect. /// /// The whole table rather than a sample: these are URLs baked into ISOs, so a diff --git a/src/installed.rs b/src/installed.rs index 378a6fb..e9f4d84 100644 --- a/src/installed.rs +++ b/src/installed.rs @@ -34,9 +34,16 @@ //! the record of how the machine was built. //! - **Moved, not deleted.** The document is re-put under an `installed-` prefix, which no //! longer matches the machine (the prefix is part of the normalized needle), and the -//! original is removed. Re-arming is renaming it back. Nothing is destroyed, which +//! original is removed. Re-arming is moving it back. Nothing is destroyed, which //! matters for a thing triggered by a network request. //! +//! With a directory per identity that prefix names a **sibling directory** — +//! `installed-98-fa-9b-50-d8-10/boot.ipxe` — rather than a file inside the machine's +//! own. That is deliberate twice over: the machine's directory stays the machine's +//! configuration, and no new rule is needed to keep the disarmed document from +//! answering, because it is the directory name that identifies a machine and this one +//! identifies nothing. +//! //! ## Off unless configured //! //! No token, no endpoint — not an open one, absent. The token is Proxmox's own @@ -72,7 +79,7 @@ impl Disarmed { } else { self.moved .iter() - .map(|(from, to)| format!("{from}.ipxe -> {to}.ipxe")) + .map(|(from, to)| format!("{from} -> {to} (ipxe)")) .collect::>() .join(", ") } @@ -200,6 +207,21 @@ mod tests { .into_bytes() } + /// Write one document where the layout keeps it: `.` names the machine and + /// the format, and the file inside its directory is named for us. + fn document(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf { + let named = std::path::Path::new(name); + let (id, ext) = ( + named.file_stem().unwrap().to_str().unwrap(), + named.extension().unwrap().to_str().unwrap(), + ); + let dir = dir.join(id); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{}.{ext}", crate::format::canonical_stem(ext))); + std::fs::write(&path, body).unwrap(); + path + } + fn answers_for(dir: &std::path::Path) -> (Answers, Arc) { let store = Arc::new(FileStore::new(dir)); (Answers::new(store.clone()), store) @@ -208,8 +230,8 @@ mod tests { #[test] fn the_machine_that_reported_stops_being_claimed() { let dir = scratch("basic"); - std::fs::write(dir.join("98-fa-9b-50-d8-10.ipxe"), "#!ipxe\nchain x\n").unwrap(); - std::fs::write(dir.join("98-fa-9b-50-d8-10.toml"), "[global]\n").unwrap(); + document(&dir, "98-fa-9b-50-d8-10.ipxe", "#!ipxe\nchain x\n"); + document(&dir, "98-fa-9b-50-d8-10.toml", "[global]\n"); let (answers, store) = answers_for(&dir); let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); @@ -223,13 +245,14 @@ mod tests { ); // The claim is gone… - assert!(!dir.join("98-fa-9b-50-d8-10.ipxe").exists()); - // …the document is not, and re-arming is renaming it back. - assert!(dir.join("installed-98-fa-9b-50-d8-10.ipxe").exists()); + assert!(!dir.join("98-fa-9b-50-d8-10/boot.ipxe").exists()); + // …the document is not, and re-arming is moving it back. + assert!(dir.join("installed-98-fa-9b-50-d8-10/boot.ipxe").exists()); // **And the machine's own answer is untouched.** Deleting it would throw away the // record of how this machine was built, and the installer is the thing that reads - // it — not the loader. - assert!(dir.join("98-fa-9b-50-d8-10.toml").exists()); + // it — not the loader. It is also why the disarmed document goes to a directory + // of its own: the machine's directory is still the machine's. + assert!(dir.join("98-fa-9b-50-d8-10/proxmox.toml").exists()); } #[test] @@ -237,7 +260,7 @@ mod tests { // The property the prefix exists for. Without it the rename is decoration and the // machine reinstalls anyway, which is the whole failure being fixed. let dir = scratch("nomatch"); - std::fs::write(dir.join("98-fa-9b-50-d8-10.ipxe"), "#!ipxe\n").unwrap(); + document(&dir, "98-fa-9b-50-d8-10.ipxe", "#!ipxe\n"); let (answers, store) = answers_for(&dir); let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); @@ -257,13 +280,13 @@ mod tests { // finishing its install must not disarm its neighbours. This is why the lookup // never consults groups rather than filtering them out afterwards. let dir = scratch("group"); - std::fs::create_dir_all(dir.join("groups")).unwrap(); - std::fs::write( - dir.join("groups/rack-a.ipxe"), + document( + &dir.join("groups"), + "rack-a.ipxe", "# answer: members = 98:fa:9b:50:d8:10\n#!ipxe\n", - ) - .unwrap(); - std::fs::write(dir.join("default.ipxe"), "#!ipxe\n").unwrap(); + ); + std::fs::create_dir_all(dir.join("default")).unwrap(); + std::fs::write(dir.join("default/boot.ipxe"), "#!ipxe\n").unwrap(); let (answers, store) = answers_for(&dir); let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); @@ -279,8 +302,8 @@ mod tests { let done = disarm(&answers, store.as_ref(), &facts).expect("disarm"); assert!(done.moved.is_empty(), "{:?}", done.moved); - assert!(dir.join("groups/rack-a.ipxe").exists()); - assert!(dir.join("default.ipxe").exists()); + assert!(dir.join("groups/rack-a/boot.ipxe").exists()); + assert!(dir.join("default/boot.ipxe").exists()); } #[test] @@ -288,12 +311,12 @@ mod tests { // The webhook may arrive twice, and a machine may have been installed from the // menu rather than from a claim. Neither is a failure. let dir = scratch("none"); - std::fs::write(dir.join("aa-bb-cc-dd-ee-ff.ipxe"), "#!ipxe\n").unwrap(); + document(&dir, "aa-bb-cc-dd-ee-ff.ipxe", "#!ipxe\n"); let (answers, store) = answers_for(&dir); let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); let done = disarm(&answers, store.as_ref(), &facts).expect("disarm"); assert!(done.moved.is_empty()); - assert!(dir.join("aa-bb-cc-dd-ee-ff.ipxe").exists()); + assert!(dir.join("aa-bb-cc-dd-ee-ff/boot.ipxe").exists()); } #[test] diff --git a/src/main.rs b/src/main.rs index 092c4f7..130dfaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,7 @@ fn main() -> ExitCode { Some((cmd, _)) if cmd == "check" => return cli::check(&cfg), Some((cmd, rest)) if cmd == "import" => return cli::import(&cfg, rest), Some((cmd, rest)) if cmd == "export" => return cli::export(&cfg, rest), + Some((cmd, rest)) if cmd == "migrate" => return cli::migrate(&cfg, rest), Some((cmd, rest)) if cmd == "media" => return cli::media(&cfg, rest), Some((cmd, rest)) if cmd == "boot" => return cli::boot(&cfg, rest), Some((cmd, _)) => { diff --git a/src/select.rs b/src/select.rs index 8560ab1..7b24f70 100644 --- a/src/select.rs +++ b/src/select.rs @@ -596,9 +596,12 @@ fn build(snapshot: Snapshot) -> Listing { .machines .into_iter() .filter_map(|m| { - let kind = kind_of(&m.format, &m.id, &mut problems)?; + let kind = kind_of(&m.format, &m.origin, &mut problems)?; let format = m.format.clone(); - let doc = Doc::parse(kind, &m.body, &m.id); + // The origin, not the id: with a directory per identity the filename is the + // operator's to choose, so "98fa9b50d810" would not say which document in it + // failed to parse. + let doc = Doc::parse(kind, &m.body, &m.origin); if let Err(e) = &doc { problems.push(e.clone()); } @@ -644,7 +647,7 @@ fn build(snapshot: Snapshot) -> Listing { .fallbacks .into_iter() .filter_map(|d| { - let what = format!("default.{}", d.format); + let what = d.origin.clone(); let kind = kind_of(&d.format, &what, &mut problems)?; let doc = Doc::parse(kind, &d.body, &what); match &doc { @@ -696,21 +699,54 @@ mod tests { &self.0 } + /// Write one document, named the way a test thinks of it — `.` — into + /// the directory the layout actually stores it in. + /// + /// The translation lives here rather than in every test because what these + /// fixtures are about is *which machine, in which format*; the filename inside + /// the directory is not a thing any of them mean to assert. fn write(&self, name: &str, contents: &str) -> PathBuf { - let p = self.0.join(name); - fs::write(&p, contents).expect("write fixture"); - p + self.document(&self.0, name) + .tap(|p| fs::write(p, contents).expect("write fixture")) } fn group(&self, name: &str, contents: &str) -> PathBuf { let dir = self.0.join(crate::store::file::GROUPS_DIR); - fs::create_dir_all(&dir).expect("create groups dir"); - let p = dir.join(format!("{name}.toml")); - fs::write(&p, contents).expect("write group fixture"); - p + self.document(&dir, &format!("{name}.toml")) + .tap(|p| fs::write(p, contents).expect("write group fixture")) + } + + /// `.` under `parent` becomes `//.`. + fn document(&self, parent: &Path, name: &str) -> PathBuf { + let named = Path::new(name); + let (identity, file) = match (named.file_stem(), named.extension()) { + (Some(stem), Some(ext)) => { + let ext = ext.to_str().expect("utf-8 extension"); + ( + stem.to_str().expect("utf-8 stem").to_string(), + format!("{}.{ext}", crate::format::canonical_stem(ext)), + ) + } + // No extension at all: the whole name is the identity, and the document + // inside carries it too — so "this is not a candidate" still holds for + // the same reason it did. + _ => (name.to_string(), name.to_string()), + }; + let dir = parent.join(identity); + fs::create_dir_all(&dir).expect("create identity dir"); + dir.join(file) } } + /// Do something with a value and hand it back. Keeps a fixture one expression. + trait Tap: Sized { + fn tap(self, f: impl FnOnce(&Self)) -> Self { + f(&self); + self + } + } + impl Tap for PathBuf {} + impl Drop for TempDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); @@ -1111,7 +1147,7 @@ mod tests { } #[test] - fn a_removed_file_stops_being_served() { + fn a_removed_machine_stops_being_served() { let dir = TempDir::new(); let answers = Answers::from_dir(dir.path()); let path = dir.write("98fa9b50d810.toml", "[global]\nx = 1\n"); @@ -1123,7 +1159,10 @@ mod tests { .unwrap() .is_some() ); - fs::remove_file(&path).expect("remove fixture"); + // The whole identity, which is what removing a machine now means. Its directory + // leaving the answers directory moves that directory's mtime, so this is picked + // up at once rather than at the backstop. + fs::remove_dir_all(path.parent().unwrap()).expect("remove fixture"); assert!( answers .resolve(&Facts::new(None, body.as_bytes())) @@ -1132,6 +1171,39 @@ mod tests { ); } + /// One document leaving a machine that keeps others — the case the mtime cannot see. + /// + /// A directory's mtime moves when an entry is added or removed *in it*, and this + /// happens one level down, inside the machine's own directory. So the answers + /// directory looks untouched and only `RELOAD_BACKSTOP` notices, exactly as it + /// already did for a file edited in place. Worth a slow test: the alternative is a + /// stat per machine on every request, which is what the cache exists to avoid. + #[test] + fn a_document_removed_from_a_machine_stops_being_served_within_the_backstop() { + let dir = TempDir::new(); + let answers = Answers::from_dir(dir.path()); + let path = dir.write("98fa9b50d810.toml", "[global]\nx = 1\n"); + dir.write("98fa9b50d810.ipxe", "#!ipxe\nchain x\n"); + let body = body_with("98:fa:9b:50:d8:10"); + // Asked for on the Proxmox endpoint, so the machine's `.ipxe` cannot stand in + // for the document that was removed. + let toml = || Facts::from_request(Some("/proxmox/answer"), None, body.as_bytes()); + + assert!(answers.resolve(&toml()).unwrap().is_some()); + fs::remove_file(&path).expect("remove fixture"); + + // Poll rather than sleep exactly once, so a slow machine does not make this + // flaky and a fast one does not make it slow. + let deadline = Instant::now() + RELOAD_BACKSTOP * 3; + while Instant::now() < deadline { + if answers.resolve(&toml()).unwrap().is_none() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + panic!("the removed document was still being served after the backstop"); + } + #[test] fn repeated_lookups_are_consistent() { let dir = TempDir::new(); diff --git a/src/store/file.rs b/src/store/file.rs index 4a55c66..9141e12 100644 --- a/src/store/file.rs +++ b/src/store/file.rs @@ -1,19 +1,52 @@ -//! A directory of TOML files — the original layout, and still the right one when +//! A directory of answer documents — the original layout, and still the right one when //! dropping a file onto a NAS is all the administration you need. +//! +//! **One directory per identity.** A machine is a directory named after it, holding one +//! document per format; groups and the fallback are the same shape, under names the +//! layout reserves: +//! +//! ```text +//! answers/ +//! groups/ +//! rack-a/ +//! proxmox.toml +//! debian.preseed +//! default/ +//! proxmox.toml +//! 98-fa-9b-50-d8-10/ +//! proxmox.toml the machine as Proxmox +//! debian.preseed the same hardware as Debian +//! boot.ipxe what boots the installer +//! ``` +//! +//! The **extension decides the format**, and the stem decides nothing at all — it is +//! there for whoever opens the directory, so `proxmox.toml` and `answer.toml` are the +//! same document to this server. That is precisely why two documents of one format in +//! one directory is a *reported problem* rather than a silent choice: there would be no +//! rule to pick between them that an operator could have predicted. +//! +//! A document left at the top of the answers directory — the layout before this one — +//! is reported and **not served**. Half-reading an old layout would mean a machine whose +//! answer moved silently between two files, which is the failure this server exists to +//! make impossible. use super::{ RawDefault, RawGroup, RawMachine, Snapshot, Store, StoreWrite, Version, invalid_format, - invalid_id, valid_format, valid_id, + invalid_id, invalid_machine_id, valid_format, valid_id, valid_machine_id, }; -use crate::format::Kind; +use crate::format::{Kind, canonical_stem}; use std::fs; use std::io; use std::path::{Path, PathBuf}; -/// Subdirectory holding group files. It is a subdirectory precisely so that groups are -/// never mistaken for machines by the filename match. +/// Subdirectory holding the groups. A subdirectory precisely so that a group is never +/// mistaken for a machine, and reserved so that a machine cannot claim the name. pub const GROUPS_DIR: &str = "groups"; +/// Subdirectory holding the fallback documents — one per format, since a TOML default +/// must not be handed to a client that asked for kickstart. +pub const DEFAULT_DIR: &str = "default"; + pub struct FileStore { dir: PathBuf, } @@ -27,21 +60,50 @@ impl FileStore { &self.dir } - /// Write one document. Copies of the same stem in *other* formats are left alone: - /// they are that machine's answer for a different operating system, not a stale - /// duplicate. - fn put(&self, dir: &Path, stem: &str, format: &str, body: &str) -> io::Result<()> { + /// Write one document into an identity's directory. + /// + /// A document of this format already there is **overwritten where it stands**, + /// whatever it is called: the stem is the operator's choice, not ours, and adding a + /// second file rather than replacing the first would leave two documents claiming + /// one format. Only when there is none does the canonical name get used. + /// + /// Other formats in the same directory are left alone — they are that identity's + /// answers for other operating systems, not stale duplicates. + fn put(&self, dir: &Path, format: &str, body: &str) -> io::Result<()> { if !valid_format(format) { return Err(invalid_format(format)); } - write_atomic(&dir.join(format!("{stem}.{format}")), body) + fs::create_dir_all(dir)?; + let path = existing(dir, format) + .unwrap_or_else(|| dir.join(format!("{}.{format}", canonical_stem(format)))); + write_atomic(&path, body) } - fn remove(&self, dir: &Path, stem: &str, format: &str) -> io::Result { + fn remove(&self, dir: &Path, format: &str) -> io::Result { if !valid_format(format) { return Err(invalid_format(format)); } - remove_if_present(&dir.join(format!("{stem}.{format}"))) + let Some(path) = existing(dir, format) else { + return Ok(false); + }; + let removed = remove_if_present(&path)?; + // An identity with no documents left is not an identity. `remove_dir` refuses a + // directory that still holds anything, which is exactly the test wanted — a + // README or an answer in another format keeps it. + let _ = fs::remove_dir(dir); + Ok(removed) + } + + fn machine_dir(&self, id: &str) -> PathBuf { + self.dir.join(id) + } + + fn group_dir(&self, name: &str) -> PathBuf { + self.dir.join(GROUPS_DIR).join(name) + } + + fn default_dir(&self) -> PathBuf { + self.dir.join(DEFAULT_DIR) } } @@ -51,7 +113,7 @@ fn write_atomic(path: &Path, body: &str) -> io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let tmp = path.with_extension(format!("toml.tmp.{}", std::process::id())); + let tmp = path.with_extension(format!("tmp.{}", std::process::id())); fs::write(&tmp, body)?; match fs::rename(&tmp, path) { Ok(()) => Ok(()), @@ -70,44 +132,224 @@ fn remove_if_present(path: &Path) -> io::Result { } } -/// The stem and format of a directory entry we can serve, or `None`. -fn answer_entry(entry: &fs::DirEntry, path: &Path) -> Option<(String, String)> { +/// What a directory entry actually is, following a symlink to find out. +#[derive(PartialEq, Eq)] +enum What { + Dir, + File, +} + +fn what(entry: &fs::DirEntry, path: &Path) -> Option { // `DirEntry::file_type` is free on Unix (it comes back with the readdir), while // `fs::metadata` is a stat syscall per entry. Only a symlink needs the stat, to // find out what it points at. let kind = entry.file_type().ok()?; - let is_file = if kind.is_file() { - true - } else if kind.is_symlink() { - fs::metadata(path).map(|m| m.is_file()).unwrap_or(false) - } else { - false - }; - if !is_file { - return None; + if kind.is_dir() { + return Some(What::Dir); } + if kind.is_file() { + return Some(What::File); + } + if kind.is_symlink() { + let target = fs::metadata(path).ok()?; + if target.is_dir() { + return Some(What::Dir); + } + if target.is_file() { + return Some(What::File); + } + } + None +} - // A hidden file is never somebody's answer, and one kind of hidden file is actively - // dangerous: macOS writes an AppleDouble `._` beside a file whose extended - // attributes the filesystem will not take, and `._98-fa-9b-50-d8-10.toml` normalizes to - // the same identity as the real `98-fa-9b-50-d8-10.toml`. It therefore claims the same - // machine, with a body that is binary — so the machine it was meant to configure gets a - // parse error instead of its answer. Found on a real NAS whose answers directory was - // being edited over SMB from a Mac. +/// The entry's name, unless it is hidden. +/// +/// A hidden entry is never somebody's answer, and one kind is actively dangerous: macOS +/// writes an AppleDouble `._` beside a file whose extended attributes the +/// filesystem will not take, and `._proxmox.toml` is a second `.toml` in the directory +/// with a body that is binary — so the machine it was meant to configure gets a parse +/// error instead of its answer. Found on a real NAS whose answers directory was being +/// edited over SMB from a Mac. +fn visible_name(entry: &fs::DirEntry) -> Option { let name = entry.file_name(); - if name.to_str().is_none_or(|n| n.starts_with('.')) { - return None; - } + let name = name.to_str()?; + (!name.starts_with('.')).then(|| name.to_string()) +} + +/// The same question without the allocation, for the paths that never need the name. +/// +/// A full reload reads every directory in the answers directory, so this runs once per +/// entry per second at the backstop — a `String` per entry is a lot of allocation to do +/// on a NAS for a name nobody reads. +fn is_visible(entry: &fs::DirEntry) -> bool { + entry + .file_name() + .to_str() + .is_some_and(|name| !name.starts_with('.')) +} +/// The format this path declares, if it is one we can serve. +fn servable(path: &Path) -> Option { let ext = path.extension()?.to_str()?.to_ascii_lowercase(); Kind::for_extension(&ext)?; - Some((path.file_stem()?.to_str()?.to_string(), ext)) + Some(ext) +} + +/// One document found on disk. +struct Found { + format: String, + body: String, + path: PathBuf, +} + +/// Every document in one identity's directory, at most one per format. +/// +/// Sorted by filename, so which document wins a duplicated format never depends on +/// readdir order — and the loser is reported rather than quietly dropped. +fn documents_in(dir: &Path, problems: &mut Vec) -> Vec { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + problems.push(format!("{}: {e}", dir.display())); + return Vec::new(); + } + }; + + // Name and format kept alongside the path: the sort is by filename, and asking the + // path for its extension a second time inside the loop would be a second parse of + // every entry. + let mut candidates: Vec<(std::ffi::OsString, String, PathBuf)> = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !is_visible(&entry) || what(&entry, &path) != Some(What::File) { + continue; + } + // An extension we do not serve is not a mistake — a README beside the answers is + // an ordinary thing to keep there. + let Some(format) = servable(&path) else { + continue; + }; + candidates.push((entry.file_name(), format, path)); + } + candidates.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut found: Vec = Vec::new(); + for (_, format, path) in candidates { + if let Some(first) = found.iter().find(|f| f.format == format) { + problems.push(format!( + "{}: {} already answers for .{format} here — the stem is only a name, so \ + two of one format have no order between them; this one is ignored", + path.display(), + first.path.display() + )); + continue; + } + match fs::read_to_string(&path) { + Ok(body) => found.push(Found { format, body, path }), + // One unreadable document must not fail every install. + Err(e) => problems.push(format!("{}: {e}", path.display())), + } + } + found +} + +/// A servable document sitting where the old flat layout put it. +/// +/// Named, with the move spelled out: an operator meeting this is mid-upgrade, and the +/// one thing they need is the new path for this exact file. +fn stray(path: &Path, root: &Path) -> String { + let to = destination(path) + .map(|to| to.strip_prefix(root).unwrap_or(&to).display().to_string()) + .unwrap_or_default(); + format!( + "{}: an answer is a directory now — move it to {to} \ + (`rescriptum migrate` moves them all); it is not being served", + path.display(), + ) +} + +/// A document still in the flat layout, and where the layout keeps it now. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Move { + pub from: PathBuf, + pub to: PathBuf, +} + +/// Where a document named `.` belongs, given the directory it sits in. +/// +/// One rule with no exceptions: `default.toml` is a stem like any other, and so is a +/// group's name. The whole layout is this function. +fn destination(path: &Path) -> Option { + let format = servable(path)?; + let stem = path.file_stem()?.to_str()?; + Some( + path.parent()? + .join(stem) + .join(format!("{}.{format}", canonical_stem(&format))), + ) +} + +/// Every document still lying flat in an answers directory, with its destination. +/// +/// The store's own knowledge of where a document goes, so `migrate` cannot compute a +/// different answer from the one `snapshot` reads back — the two would then disagree +/// about whether a directory had been migrated at all. +pub fn pending_moves(dir: &Path) -> io::Result> { + let mut moves = Vec::new(); + for from in [dir.to_path_buf(), dir.join(GROUPS_DIR)] { + let entries = match fs::read_dir(&from) { + Ok(entries) => entries, + Err(e) if e.kind() == io::ErrorKind::NotFound => continue, + Err(e) => return Err(e), + }; + for entry in entries.flatten() { + let path = entry.path(); + if !is_visible(&entry) || what(&entry, &path) != Some(What::File) { + continue; + } + if let Some(to) = destination(&path) { + moves.push(Move { from: path, to }); + } + } + } + moves.sort_by(|a, b| a.from.cmp(&b.from)); + Ok(moves) +} + +/// The identity directories inside one directory, sorted, plus anything left flat. +fn identities(dir: &Path, root: &Path, problems: &mut Vec) -> Vec<(String, PathBuf)> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + problems.push(format!("{}: {e}", dir.display())); + return Vec::new(); + } + }; + + let mut found = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = visible_name(&entry) else { + continue; + }; + match what(&entry, &path) { + Some(What::Dir) => found.push((name, path)), + Some(What::File) if servable(&path).is_some() => { + problems.push(stray(&path, root)); + } + _ => {} + } + } + found.sort(); + found } impl Store for FileStore { fn version(&self) -> Version { - // The directory's mtime moves whenever a file is added or removed. Editing a - // file's *contents* does not move it — the reload backstop covers that. + // The directory's mtime moves whenever an identity is added or removed. A + // document appearing *inside* one moves only that directory's mtime, which + // this does not see — the reload backstop is what covers that, as it already + // did for a file whose contents changed under an unmoved name. fs::metadata(&self.dir) .ok() .and_then(|m| m.modified().ok()) @@ -118,57 +360,66 @@ impl Store for FileStore { fn snapshot(&self) -> io::Result { let mut snapshot = Snapshot::default(); - // Groups first; a missing groups/ directory just means there are none. - match fs::read_dir(self.dir.join(GROUPS_DIR)) { - Ok(entries) => { - for entry in entries.flatten() { - let path = entry.path(); - let Some((name, format)) = answer_entry(&entry, &path) else { - continue; - }; - match fs::read_to_string(&path) { - Ok(body) => snapshot.groups.push(RawGroup { - name, - format, - body, - origin: path.display().to_string(), - }), - Err(e) => snapshot.problems.push(format!("{}: {e}", path.display())), - } - } - } - Err(e) if e.kind() == io::ErrorKind::NotFound => {} - Err(e) => return Err(e), - } - let entries = match fs::read_dir(&self.dir) { Ok(entries) => entries, // A NAS that has not been set up yet should not look different from one - // with no matching file. + // with no matching answer. Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(snapshot), Err(e) => return Err(e), }; + let mut machines: Vec<(String, PathBuf)> = Vec::new(); + let mut groups: Option = None; + let mut fallbacks: Option = None; + for entry in entries.flatten() { let path = entry.path(); - let Some((stem, format)) = answer_entry(&entry, &path) else { + let Some(name) = visible_name(&entry) else { continue; }; - let body = match fs::read_to_string(&path) { - Ok(body) => body, - // One unreadable file must not fail every install. - Err(e) => { - snapshot.problems.push(format!("{}: {e}", path.display())); - continue; + match what(&entry, &path) { + Some(What::Dir) if name.eq_ignore_ascii_case(GROUPS_DIR) => groups = Some(path), + Some(What::Dir) if name.eq_ignore_ascii_case(DEFAULT_DIR) => fallbacks = Some(path), + Some(What::Dir) => machines.push((name, path)), + Some(What::File) if servable(&path).is_some() => { + snapshot.problems.push(stray(&path, &self.dir)); } - }; - if stem.eq_ignore_ascii_case("default") { - snapshot.fallbacks.push(RawDefault { format, body }); - } else { + _ => {} + } + } + machines.sort(); + + // Groups first; a missing groups/ directory just means there are none. + if let Some(dir) = groups { + for (name, path) in identities(&dir, &self.dir, &mut snapshot.problems) { + for doc in documents_in(&path, &mut snapshot.problems) { + snapshot.groups.push(RawGroup { + name: name.clone(), + format: doc.format, + body: doc.body, + origin: doc.path.display().to_string(), + }); + } + } + } + + if let Some(dir) = fallbacks { + for doc in documents_in(&dir, &mut snapshot.problems) { + snapshot.fallbacks.push(RawDefault { + format: doc.format, + body: doc.body, + origin: doc.path.display().to_string(), + }); + } + } + + for (id, path) in machines { + for doc in documents_in(&path, &mut snapshot.problems) { snapshot.machines.push(RawMachine { - id: stem, - format, - body, + id: id.clone(), + format: doc.format, + body: doc.body, + origin: doc.path.display().to_string(), }); } } @@ -181,49 +432,61 @@ impl Store for FileStore { } } +/// The document of this format already in `dir`, if there is one. +/// +/// Sorted, for the same reason `documents_in` sorts: with two of one format the one that +/// answers requests and the one a write replaces have to be the same file. +fn existing(dir: &Path, format: &str) -> Option { + let mut paths: Vec = Vec::new(); + for entry in fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + if !is_visible(&entry) || what(&entry, &path) != Some(What::File) { + continue; + } + if servable(&path).is_some_and(|f| f == format) { + paths.push(path); + } + } + paths.sort(); + paths.into_iter().next() +} + impl StoreWrite for FileStore { fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()> { // Checked here as well as at the API boundary: this is the layer that turns an // identifier into a path, so this is the layer that must not be fooled. - if !valid_id(id) { - return Err(invalid_id(id)); + if !valid_machine_id(id) { + return Err(invalid_machine_id(id)); } - let dir = self.dir.clone(); - self.put(&dir, id, format, body) + self.put(&self.machine_dir(id), format, body) } fn delete_machine(&self, id: &str, format: &str) -> io::Result { - if !valid_id(id) { - return Err(invalid_id(id)); + if !valid_machine_id(id) { + return Err(invalid_machine_id(id)); } - let dir = self.dir.clone(); - self.remove(&dir, id, format) + self.remove(&self.machine_dir(id), format) } fn put_group(&self, name: &str, format: &str, body: &str) -> io::Result<()> { if !valid_id(name) { return Err(invalid_id(name)); } - let dir = self.dir.join(GROUPS_DIR); - fs::create_dir_all(&dir)?; - self.put(&dir, name, format, body) + self.put(&self.group_dir(name), format, body) } fn delete_group(&self, name: &str, format: &str) -> io::Result { if !valid_id(name) { return Err(invalid_id(name)); } - let dir = self.dir.join(GROUPS_DIR); - self.remove(&dir, name, format) + self.remove(&self.group_dir(name), format) } fn put_default(&self, format: &str, body: &str) -> io::Result<()> { - let dir = self.dir.clone(); - self.put(&dir, "default", format, body) + self.put(&self.default_dir(), format, body) } fn delete_default(&self, format: &str) -> io::Result { - let dir = self.dir.clone(); - self.remove(&dir, "default", format) + self.remove(&self.default_dir(), format) } } diff --git a/src/store/mod.rs b/src/store/mod.rs index d6dad36..2ce560b 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -17,6 +17,10 @@ pub struct RawMachine { /// The document's format, as a file extension: `toml`, `yaml`, `ks`, … pub format: String, pub body: String, + /// Where this came from, for diagnostics — a path, or a database URL. With a + /// directory per identity the filename is the operator's to choose, so the + /// identifier alone no longer says which document failed to parse. + pub origin: String, } /// One group document. `members`, `extends` and `match` are read from the body itself, @@ -35,6 +39,8 @@ pub struct RawGroup { pub struct RawDefault { pub format: String, pub body: String, + /// Where this came from, for diagnostics — a path, or a database URL. + pub origin: String, } /// Everything needed to answer requests, as of one point in time. @@ -71,6 +77,37 @@ pub fn valid_id(id: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')) } +/// Names the file store keeps for itself, at the top of the answers directory. +/// +/// A machine called `groups` would be a directory called `groups`, which is where the +/// groups live — so its documents would come back as a rack's. Refused in **both** +/// stores rather than only the one that has the problem: a database that accepted the +/// name would export into a directory that cannot represent it, and `export` has to +/// stay a way out. +pub const RESERVED_MACHINE_IDS: [&str; 2] = ["groups", "default"]; + +/// Is this usable as a machine id? `valid_id`, minus the names the layout reserves. +pub fn valid_machine_id(id: &str) -> bool { + valid_id(id) + && !RESERVED_MACHINE_IDS + .iter() + .any(|r| id.eq_ignore_ascii_case(r)) +} + +/// The error to return when `valid_machine_id` says no. +pub fn invalid_machine_id(id: &str) -> io::Error { + if valid_id(id) { + return io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "{id:?} is reserved: the answers directory keeps {} for itself", + RESERVED_MACHINE_IDS.join(" and ") + ), + ); + } + invalid_id(id) +} + /// The error to return when `valid_id` says no. pub fn invalid_id(id: &str) -> io::Error { io::Error::new( @@ -96,9 +133,9 @@ pub trait Store: Send + Sync { /// The write half, used by the admin API. Separate from `Store` because serving /// answers never needs it — a read-only deployment simply does not provide one. /// A document is keyed by **what it is for**, which is a machine *and* an operating -/// system — `98fa9b50d810.toml` is that machine as Proxmox, `98fa9b50d810.preseed` is -/// the same hardware as Debian. They are two answers to two different questions and -/// both may exist at once, so every operation names a format. +/// system — one document is that machine as Proxmox, another is the same hardware as +/// Debian. They are two answers to two different questions and both may exist at once, +/// so every operation names a format. pub trait StoreWrite: Store { fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>; fn delete_machine(&self, id: &str, format: &str) -> io::Result; diff --git a/src/store/sqlite.rs b/src/store/sqlite.rs index 7cf79b1..6b54fec 100644 --- a/src/store/sqlite.rs +++ b/src/store/sqlite.rs @@ -6,7 +6,7 @@ use super::{ RawDefault, RawGroup, RawMachine, Snapshot, Store, StoreWrite, Version, invalid_format, - invalid_id, valid_format, valid_id, + invalid_id, invalid_machine_id, valid_format, valid_id, valid_machine_id, }; use rusqlite::Connection; use std::io; @@ -177,9 +177,12 @@ impl Store for SqliteStore { .map_err(to_io)?; let rows = stmt .query_map([], |r| { + let id: String = r.get(0)?; + let format: String = r.get(1)?; Ok(RawMachine { - id: r.get(0)?, - format: r.get(1)?, + origin: format!("db:machines/{id}.{format}"), + id, + format, body: r.get(2)?, }) }) @@ -211,8 +214,10 @@ impl Store for SqliteStore { .map_err(to_io)?; let rows = stmt .query_map([DEFAULT_KEY], |r| { + let format: String = r.get(0)?; Ok(RawDefault { - format: r.get(0)?, + origin: format!("db:default.{format}"), + format, body: r.get(1)?, }) }) @@ -231,8 +236,11 @@ impl Store for SqliteStore { impl StoreWrite for SqliteStore { fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()> { - if !valid_id(id) { - return Err(invalid_id(id)); + // The reserved names are a *file* layout's problem, refused here too: a database + // that accepted one would export into a directory that cannot represent it, and + // `export` has to stay a way out. + if !valid_machine_id(id) { + return Err(invalid_machine_id(id)); } if !valid_format(format) { return Err(invalid_format(format)); @@ -249,8 +257,8 @@ impl StoreWrite for SqliteStore { } fn delete_machine(&self, id: &str, format: &str) -> io::Result { - if !valid_id(id) { - return Err(invalid_id(id)); + if !valid_machine_id(id) { + return Err(invalid_machine_id(id)); } let n = self .lock() diff --git a/tests/admin.rs b/tests/admin.rs index e7d1a21..373da21 100644 --- a/tests/admin.rs +++ b/tests/admin.rs @@ -1,6 +1,8 @@ //! The admin API, against the real binary: authentication, writes, and the promise that //! a write can never leave the answer set broken. +mod common; + use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; @@ -48,10 +50,9 @@ impl Server { fn start_seeded(files: &[(&str, &str)]) -> Server { let dir = scratch("seeded"); let answers = dir.join("answers"); + fs::create_dir_all(&answers).expect("answers dir"); for (name, contents) in files { - let path = answers.join(name); - fs::create_dir_all(path.parent().expect("a parent")).expect("subdirectory"); - fs::write(&path, contents).expect("fixture"); + common::seed(&answers, name, contents); } let imported = Command::new(env!("CARGO_BIN_EXE_rescriptum")) .env("RESCRIPTUM_STORE", "sqlite") diff --git a/tests/cli.rs b/tests/cli.rs index 252968d..dfea1b3 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,6 +6,8 @@ //! answer before a rack does; the stdout/stderr split is what makes `render … > answer` //! usable, so that is a contract too. +mod common; + use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -35,12 +37,18 @@ impl Case { } fn write(&self, files: &[(&str, &str)]) { + for (name, contents) in files { + common::seed(&self.dir, name, contents); + } + } + + /// Write a document exactly where the name says, without the layout's opinion — + /// which is how an answers directory from before the layout actually looks. + fn write_flat(&self, files: &[(&str, &str)]) { for (name, contents) in files { let path = self.dir.join(name); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("subdirectory"); - } - fs::write(&path, contents).expect("fixture"); + fs::create_dir_all(path.parent().expect("a parent")).expect("subdirectory"); + fs::write(&path, contents).expect("flat fixture"); } } @@ -252,7 +260,10 @@ fn check_succeeds_on_a_healthy_set() { assert!(r.ok, "{r}"); assert!(r.stdout.contains("ok — everything renders"), "{r}"); // The count is of documents that name a machine; `default` is a fallback, not one. - assert!(r.stdout.contains("1 group(s), 1 machine file(s)"), "{r}"); + assert!( + r.stdout.contains("1 group(s), 1 machine document(s)"), + "{r}" + ); } #[test] @@ -263,7 +274,8 @@ fn check_catches_a_broken_default_even_though_it_names_no_machine() { let c = Case::new(&[("default.toml", broken)]); let r = c.run(&["check"]); assert!(!r.ok, "{broken:?}: {r}"); - assert!(r.stdout.contains("default.toml"), "{broken:?}: {r}"); + // Named by its path, which is where the operator has to go and fix it. + assert!(r.stdout.contains("default/proxmox.toml"), "{broken:?}: {r}"); } } @@ -385,8 +397,13 @@ fn a_directory_survives_a_round_trip_through_the_database_byte_for_byte() { "98fa9b50d810.preseed", "default.toml", ] { - let before = fs::read(c.dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); - let after = fs::read(out.join(name)).unwrap_or_else(|e| panic!("{name} exported: {e}")); + // Same path on both sides as well as the same bytes: `export` writing a + // document somewhere `import` would not look for it is the failure that makes + // the database unsafe to leave. + let before = + fs::read(common::document_path(&c.dir, name)).unwrap_or_else(|e| panic!("{name}: {e}")); + let after = fs::read(common::document_path(&out, name)) + .unwrap_or_else(|e| panic!("{name} exported: {e}")); assert_eq!(before, after, "{name} changed crossing the database"); } } @@ -1174,3 +1191,118 @@ fn the_bootstrap_and_the_menu_can_be_printed_for_review() { assert!(r.stdout.contains("item local"), "{r}"); assert!(r.stdout.is_ascii(), "a BIOS text console is not UTF-8: {r}"); } + +// --------------------------------------------------------------------------- +// migrate — the way out of the layout that came before +// --------------------------------------------------------------------------- + +/// A directory from before the layout: named, unchanged, and told what to type. +#[test] +fn migrate_shows_the_moves_and_changes_nothing_until_told_to() { + let c = Case::new(&[]); + c.write_flat(&[ + ("98fa9b50d810.toml", "marker = \"machine\"\n"), + ("98fa9b50d810.ipxe", "#!ipxe\n"), + ("groups/rack-a.toml", "members = [\"98:fa:9b:50:d8:10\"]\n"), + ("default.toml", "[global]\nkeyboard = \"us\"\n"), + ("README.md", "notes\n"), + ]); + + let r = c.run(&["migrate"]); + assert!(r.ok, "{r}"); + for line in [ + "98fa9b50d810.toml -> 98fa9b50d810/proxmox.toml", + "98fa9b50d810.ipxe -> 98fa9b50d810/boot.ipxe", + "groups/rack-a.toml -> groups/rack-a/proxmox.toml", + "default.toml -> default/proxmox.toml", + ] { + assert!(r.stdout.contains(line), "missing {line:?}: {r}"); + } + assert!( + !r.stdout.contains("README"), + "an unservable file is not ours: {r}" + ); + assert!( + r.stdout.contains("nothing has been changed"), + "a dry run has to say so: {r}" + ); + // **And it really did nothing.** The dry run is the default, so this is the + // assertion that matters most in the whole command. + assert!( + c.dir.join("98fa9b50d810.toml").is_file(), + "the dry run moved a file" + ); + assert!( + !c.dir.join("98fa9b50d810").exists(), + "the dry run created a directory" + ); +} + +#[test] +fn migrate_apply_moves_them_and_the_answers_work_afterwards() { + let c = Case::new(&[]); + c.write_flat(&[ + ("98fa9b50d810.toml", "marker = \"machine\"\n"), + ( + "groups/rack-a.toml", + "members = [\"98:fa:9b:50:d8:10\"]\n[global]\nx = 1\n", + ), + ("default.toml", "[global]\nkeyboard = \"us\"\n"), + ]); + + // Before: the documents are there, and none of them is being served. + let before = c.run(&["check"]); + assert!(before.stdout.contains("rescriptum migrate"), "{before}"); + + let r = c.run(&["migrate", "--apply"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("moved 3 document(s)"), "{r}"); + assert!(c.dir.join("98fa9b50d810/proxmox.toml").is_file()); + assert!(c.dir.join("groups/rack-a/proxmox.toml").is_file()); + assert!(c.dir.join("default/proxmox.toml").is_file()); + assert!( + !c.dir.join("98fa9b50d810.toml").exists(), + "the original was left behind" + ); + + // After: clean, and composing exactly as it did before the move. + let after = c.run(&["check"]); + assert!(after.ok, "{after}"); + let rendered = c.run(&["render", "98:fa:9b:50:d8:10"]); + assert!(rendered.ok, "{rendered}"); + assert!(rendered.stdout.contains("machine"), "{rendered}"); + assert!( + rendered.stdout.contains("x = 1"), + "the group stopped applying: {rendered}" + ); + + // Running it again is a no-op that says so, not an error. + let again = c.run(&["migrate", "--apply"]); + assert!(again.ok, "{again}"); + assert!(again.stdout.contains("nothing to move"), "{again}"); +} + +/// A flat document whose destination is taken. Nothing moves, including the ones that +/// could have — a half-migrated directory is the state nobody can reason about. +#[test] +fn migrate_refuses_the_whole_run_when_a_destination_is_taken() { + let c = Case::new(&[("98fa9b50d810.toml", "marker = \"already here\"\n")]); + c.write_flat(&[ + ("98fa9b50d810.toml", "marker = \"flat\"\n"), + ("aabbccddeeff.toml", "marker = \"could have moved\"\n"), + ]); + + let r = c.run(&["migrate", "--apply"]); + assert!(!r.ok, "a blocked migration has to fail: {r}"); + assert!(r.stdout.contains("BLOCKED"), "{r}"); + assert!(r.stdout.contains("nothing has been changed"), "{r}"); + assert_eq!( + fs::read_to_string(c.dir.join("98fa9b50d810/proxmox.toml")).unwrap(), + "marker = \"already here\"\n", + "the existing document was overwritten" + ); + assert!( + c.dir.join("aabbccddeeff.toml").is_file(), + "an unrelated document moved during a run that failed" + ); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..c63f514 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,80 @@ +//! What the test suites share. Today: writing an answer document where the layout keeps +//! it. +//! +//! One copy rather than one per suite, for the reason `loaders.rs` is one table read by +//! both TFTP and the DHCP snippet — four copies of a mapping are four chances for a +//! fixture to land somewhere the server does not look, and a test that seeds nothing +//! passes for the wrong reason. +#![allow(dead_code)] + +use std::fs; +use std::path::Path; + +/// Seed one answer document from the way a test names it. +/// +/// `98fa9b50d810.toml` is that machine as Proxmox, `groups/rack-a.toml` is a group, and +/// `default.toml` is the fallback — the vocabulary the tests were written in, and the +/// vocabulary an operator uses. Where it actually lands is the store's business, so this +/// goes through `StoreWrite` rather than reimplementing the mapping: a fixture then sits +/// exactly where a write from the admin API would put it, and cannot drift from it. +/// +/// A name the store would refuse — an extension nobody serves — is written literally +/// where it was asked for. Those fixtures exist precisely to prove a stray file answers +/// nothing, and rewriting them would take the point away. +pub fn seed(root: &Path, name: &str, body: &str) { + use rescriptum::store::{FileStore, StoreWrite}; + + let named = Path::new(name); + let stem = named + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + let ext = named + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default(); + let in_groups = named + .parent() + .and_then(|p| p.file_name()) + .and_then(|p| p.to_str()) + == Some(rescriptum::store::file::GROUPS_DIR); + + if rescriptum::format::Kind::for_extension(ext).is_some() && !stem.is_empty() { + let store = FileStore::new(root); + let written = if in_groups { + store.put_group(stem, ext, body) + } else if stem.eq_ignore_ascii_case(rescriptum::store::file::DEFAULT_DIR) { + store.put_default(ext, body) + } else { + store.put_machine(stem, ext, body) + }; + if written.is_ok() { + return; + } + } + + let path = root.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("fixture directory"); + } + fs::write(&path, body).expect("fixture"); +} + +/// Where `seed` put one, so a test can edit or remove it afterwards. +pub fn document_path(root: &Path, name: &str) -> std::path::PathBuf { + let named = Path::new(name); + let stem = named + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + let ext = named + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default(); + let parent = named.parent().filter(|p| !p.as_os_str().is_empty()); + let identity = match parent { + Some(p) => root.join(p).join(stem), + None => root.join(stem), + }; + identity.join(format!("{}.{ext}", rescriptum::format::canonical_stem(ext))) +} diff --git a/tests/guards.rs b/tests/guards.rs index b659152..813f7cf 100644 --- a/tests/guards.rs +++ b/tests/guards.rs @@ -1,5 +1,7 @@ //! The answer endpoint's own token, and the capture of what machines really send. +mod common; + use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; @@ -31,7 +33,7 @@ impl Server { let dir = std::env::temp_dir().join(format!("pve-guard-{}-{n}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("scratch"); - fs::write(dir.join("default.toml"), "marker = \"served\"\n").expect("fixture"); + common::seed(&dir, "default.toml", "marker = \"served\"\n"); let mut cmd = Command::new(env!("CARGO_BIN_EXE_rescriptum")); cmd.env("RESCRIPTUM_ANSWERS_DIR", &dir) diff --git a/tests/integration.rs b/tests/integration.rs index c3c78ea..9952bdb 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -4,6 +4,8 @@ //! this project actually has to avoid — an unattended install that hangs at 3am — //! lives in the wiring, not in the pure functions. +mod common; + use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; @@ -42,12 +44,9 @@ impl Server { )); fs::create_dir_all(&dir).expect("create answers dir"); for (name, contents) in files { - let path = dir.join(name); - // Names may be nested, e.g. "groups/rack-a.toml". - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create answer subdirectory"); - } - fs::write(&path, contents).expect("write answer file"); + // Named the way an operator thinks of them — "98fa9b50d810.toml", + // "groups/rack-a.toml" — and put where the layout keeps them. + common::seed(&dir, name, contents); } let mut cmd = Command::new(env!("CARGO_BIN_EXE_rescriptum")); @@ -221,11 +220,14 @@ fn a_file_dropped_in_later_is_picked_up_without_a_restart() { let body = installer_body("98:fa:9b:50:d8:10"); assert!(status_line(&s.post(&body)).starts_with("HTTP/1.1 404")); - fs::write( - s.dir().join("98fa9b50d810.toml"), + // A machine that was not there at all, so its directory arrives too — which is what + // moves the answers directory's mtime and makes this immediate rather than a wait + // for the backstop. + common::seed( + s.dir(), + "98fa9b50d810.toml", "marker = \"added-at-runtime\"\n", - ) - .unwrap(); + ); let r = s.post(&body); assert!(status_line(&r).starts_with("HTTP/1.1 200"), "{r}"); assert!(body_of(&r).contains("added-at-runtime"), "{r}"); @@ -554,7 +556,7 @@ fn a_group_edited_in_place_is_picked_up_without_a_restart() { assert!(body_of(&r).contains("\"fr\""), "{r}"); fs::write( - s.dir().join("groups/rack-a.toml"), + common::document_path(s.dir(), "groups/rack-a.toml"), "members = [\"98:fa:9b:50:d8:10\"]\n[global]\nkeyboard = \"us\"\n", ) .unwrap(); @@ -571,7 +573,7 @@ fn a_broken_answer_file_is_a_500_not_a_wrong_install() { assert!(status_line(&r).starts_with("HTTP/1.1 500"), "{r}"); // And the server keeps serving everyone else. - fs::write(s.dir().join("default.toml"), "marker = \"ok\"\n").unwrap(); + common::seed(s.dir(), "default.toml", "marker = \"ok\"\n"); let r = s.post(&installer_body("11:22:33:44:55:66")); assert!(status_line(&r).starts_with("HTTP/1.1 200"), "{r}"); } @@ -1207,11 +1209,11 @@ fn a_machine_can_report_that_it_is_installed_and_stop_being_claimed() { ); // The claim is gone… - assert!(!s.dir().join("98-fa-9b-50-d8-10.ipxe").exists()); + assert!(!common::document_path(s.dir(), "98-fa-9b-50-d8-10.ipxe").exists()); // …the document is not, so re-arming is a rename… - assert!(s.dir().join("installed-98-fa-9b-50-d8-10.ipxe").exists()); + assert!(common::document_path(s.dir(), "installed-98-fa-9b-50-d8-10.ipxe").exists()); // …and the machine's own answer, which the installer reads, is untouched. - assert!(s.dir().join("98-fa-9b-50-d8-10.toml").exists()); + assert!(common::document_path(s.dir(), "98-fa-9b-50-d8-10.toml").exists()); // Twice is not an error: the webhook may be retried, and a machine installed from the // menu was never claimed at all. @@ -1285,9 +1287,9 @@ fn a_kickstart_or_a_preseed_can_report_installed_with_one_curl() { response.contains("installed-98-fa-9b-50-d8-10"), "{response}" ); - assert!(!s.dir().join("98-fa-9b-50-d8-10.ipxe").exists()); + assert!(!common::document_path(s.dir(), "98-fa-9b-50-d8-10.ipxe").exists()); // The kickstart itself is not an `.ipxe` and is left exactly where it was. - assert!(s.dir().join("98-fa-9b-50-d8-10.ks").exists()); + assert!(common::document_path(s.dir(), "98-fa-9b-50-d8-10.ks").exists()); // And a wrong bearer is refused, with nothing left to disarm anyway. let response = s.raw( diff --git a/tests/media.rs b/tests/media.rs index 151e0c9..318af3d 100644 --- a/tests/media.rs +++ b/tests/media.rs @@ -13,6 +13,8 @@ // nothing is a clearer answer than a wall of unresolved imports. #![cfg(feature = "boot")] +mod common; + use rescriptum::boot::iso::build; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; @@ -622,11 +624,11 @@ fn image_downloads_never_starve_the_answer_endpoint() { // minutes; if answers shared that budget, a rollout would starve its own installs. // Two listeners, two budgets — and this is what proves it rather than hoping. let s = Server::start(&[("pve-8.4.iso", pve_image())]); - fs::write( - s.answers_dir.join("default.toml"), + common::seed( + &s.answers_dir, + "default.toml", "[global]\nkeyboard = \"fr\"\n", - ) - .expect("write answer"); + ); std::thread::sleep(Duration::from_millis(1200)); // Four transfers in flight, each deliberately left unread so it stays open. @@ -728,11 +730,11 @@ fn a_generated_stanza_is_an_ordinary_answer_document() { let out = s.run(&["media", "ipxe", "pve-8.4"]); assert!(out.status.success()); - fs::write( - s.answers_dir.join("98-fa-9b-50-d8-10.ipxe"), + common::seed( + &s.answers_dir, + "98-fa-9b-50-d8-10.ipxe", String::from_utf8_lossy(&out.stdout).as_ref(), - ) - .expect("save the generated answer"); + ); std::thread::sleep(Duration::from_millis(1200)); let served = s.answer(r#"{"mac":"98:fa:9b:50:d8:10"}"#); diff --git a/tests/stores.rs b/tests/stores.rs index 16eb2b9..d0184d6 100644 --- a/tests/stores.rs +++ b/tests/stores.rs @@ -7,6 +7,8 @@ //! //! Anything store-specific (atomic renames, the schema) is tested at the bottom. +mod common; + use rescriptum::facts::Facts; use rescriptum::select::{Answers, Resolution}; use rescriptum::store::{FileStore, SqliteStore, Store, StoreWrite}; @@ -370,10 +372,225 @@ fn matching_is_deterministic_whatever_the_store_order() { }); } +/// The names the layout keeps for itself, refused by **both** stores. +/// +/// A machine called `groups` is a directory called `groups`, which is where the racks +/// live. The database could hold one happily — and that is the trap: it would then +/// `export` into a directory that cannot represent it, and `export` is the way out of +/// the database. So the refusal belongs to both. +#[test] +fn a_machine_cannot_take_a_name_the_layout_reserves() { + for_each_store(|label, store| { + for reserved in ["groups", "default", "GROUPS", "Default"] { + let refused = store.put_machine(reserved, "toml", "marker = \"x\"\n"); + assert!( + refused.is_err(), + "{label}: {reserved:?} was accepted as a machine id" + ); + let message = refused.unwrap_err().to_string(); + assert!( + message.contains("reserved"), + "{label}: {reserved:?} was refused, but not for the reason an operator \ + needs to hear: {message}" + ); + } + // A *group* may still be called `default` — nothing is reserved down there. + store + .put_group("default", "toml", "members = [\"98:fa:9b:50:d8:10\"]\n") + .unwrap_or_else(|e| panic!("{label}: a group called default: {e}")); + }); +} + +/// One machine, one directory, several operating systems — the whole point of the +/// layout, asserted through the endpoint that has to tell them apart. +#[test] +fn one_machine_answers_in_every_format_it_holds() { + for_each_store(|label, store| { + for (format, body) in [ + ("toml", "marker = \"proxmox\"\n"), + ("preseed", "d-i marker string debian\n"), + ("ks", "# marker rhel\n"), + ("ipxe", "#!ipxe\n# marker boot\n"), + ] { + store + .put_machine("98fa9b50d810", format, body) + .unwrap_or_else(|e| panic!("{label}: put .{format}: {e}")); + } + + for (segment, expected) in [ + ("/proxmox/answer", "proxmox"), + ("/debian/preseed", "debian"), + ("/rhel/ks", "rhel"), + ("/ipxe/boot", "boot"), + ] { + let facts = + Facts::from_request(Some(segment), None, body("98:fa:9b:50:d8:10").as_bytes()); + let r = answers(store) + .resolve(&facts) + .expect("resolve") + .unwrap_or_else(|| panic!("{label}: {segment} answered nothing")); + assert!( + r.body.contains(expected), + "{label}: {segment} served the wrong document: {}", + r.body + ); + } + }); +} + // --------------------------------------------------------------------------- // Store-specific // --------------------------------------------------------------------------- +/// The layout before this one, met head on. +/// +/// An upgrade finds these, and the one thing that must not happen is half-serving them: +/// a machine whose answer silently moved between two files is the failure this server +/// exists to make impossible. So it is reported, by name, with the destination spelled +/// out — and it answers nothing in the meantime. +#[test] +fn the_file_store_reports_a_document_left_in_the_old_flat_layout() { + let dir = scratch("flat"); + fs::create_dir_all(dir.join("groups")).unwrap(); + fs::write(dir.join("98fa9b50d810.toml"), "marker = \"flat\"\n").unwrap(); + fs::write(dir.join("groups/rack-a.toml"), "[global]\nx = 1\n").unwrap(); + // Not everything flat is a mistake: a README beside the answers is ordinary. + fs::write(dir.join("README.md"), "notes\n").unwrap(); + + let store = FileStore::new(&dir); + let snapshot = store.snapshot().unwrap(); + assert!(snapshot.machines.is_empty(), "a flat file was served"); + assert!(snapshot.groups.is_empty(), "a flat group was served"); + assert_eq!(snapshot.problems.len(), 2, "{:?}", snapshot.problems); + + let reported = snapshot.problems.join("\n"); + // The destination, not just a complaint — this is read by somebody mid-upgrade. + assert!( + reported.contains("98fa9b50d810/proxmox.toml"), + "the machine's new path has to be in the message:\n{reported}" + ); + assert!( + reported.contains("groups/rack-a/proxmox.toml"), + "the group's new path has to be in the message:\n{reported}" + ); + assert!( + reported.contains("rescriptum migrate"), + "the way out has to be named:\n{reported}" + ); + assert!( + !reported.contains("README"), + "an unservable file is not a problem:\n{reported}" + ); + let _ = fs::remove_dir_all(&dir); +} + +/// Two documents of one format in one directory. +/// +/// The stem means nothing, so there is no rule that picks between them an operator could +/// have predicted. Sorted order decides — anything else would depend on readdir — and +/// the loser is *reported*, because silently serving one of two is how the wrong +/// operating system gets installed. +#[test] +fn the_file_store_reports_two_documents_of_one_format_and_takes_the_first() { + let dir = scratch("duplicate"); + let machine = dir.join("98fa9b50d810"); + fs::create_dir_all(&machine).unwrap(); + fs::write(machine.join("zzz-second.toml"), "marker = \"second\"\n").unwrap(); + fs::write(machine.join("aaa-first.toml"), "marker = \"first\"\n").unwrap(); + // A different format in the same directory is not a duplicate at all. + fs::write(machine.join("debian.preseed"), "d-i marker string x\n").unwrap(); + + let store = FileStore::new(&dir); + let snapshot = store.snapshot().unwrap(); + let toml: Vec<_> = snapshot + .machines + .iter() + .filter(|m| m.format == "toml") + .collect(); + assert_eq!(toml.len(), 1, "both documents claimed the format"); + assert!(toml[0].body.contains("first"), "{:?}", toml[0].body); + assert_eq!( + snapshot.machines.len(), + 2, + "the preseed is a second answer, not a duplicate" + ); + assert!( + snapshot.problems.iter().any(|p| p.contains("zzz-second")), + "the ignored document has to be named: {:?}", + snapshot.problems + ); + let _ = fs::remove_dir_all(&dir); +} + +/// A write replaces the document that is there, whatever the operator called it. +/// +/// The alternative is a second file under the canonical name, which turns one answer +/// into two of one format — the state the check above reports. A write must not be able +/// to create it. +#[test] +fn the_file_store_overwrites_a_document_under_the_name_it_already_has() { + let dir = scratch("rename"); + let machine = dir.join("98fa9b50d810"); + fs::create_dir_all(&machine).unwrap(); + fs::write(machine.join("pve9.toml"), "marker = \"original\"\n").unwrap(); + + let store = FileStore::new(&dir); + store + .put_machine("98fa9b50d810", "toml", "marker = \"updated\"\n") + .expect("put over an operator's own name"); + + assert_eq!( + fs::read_to_string(machine.join("pve9.toml")).unwrap(), + "marker = \"updated\"\n", + "the write went somewhere else" + ); + assert!( + !machine.join("proxmox.toml").exists(), + "a second .toml was created beside the first" + ); + assert!(store.snapshot().unwrap().problems.is_empty()); + + // And a format that is not there yet does get the canonical name. + store + .put_machine("98fa9b50d810", "ipxe", "#!ipxe\n") + .expect("put a new format"); + assert!(machine.join("boot.ipxe").is_file()); + let _ = fs::remove_dir_all(&dir); +} + +/// A machine with nothing left in it is not a machine. +#[test] +fn the_file_store_takes_the_directory_away_with_the_last_document() { + let dir = scratch("empty"); + let store = FileStore::new(&dir); + store + .put_machine("98fa9b50d810", "toml", "x = 1\n") + .unwrap(); + store + .put_machine("98fa9b50d810", "ipxe", "#!ipxe\n") + .unwrap(); + + assert!(store.delete_machine("98fa9b50d810", "toml").unwrap()); + assert!( + dir.join("98fa9b50d810").is_dir(), + "the directory went while it still held an answer" + ); + assert!(store.delete_machine("98fa9b50d810", "ipxe").unwrap()); + assert!( + !dir.join("98fa9b50d810").exists(), + "an empty identity directory was left behind" + ); + + // Anything else in there keeps it — a note an operator left is not ours to delete. + store + .put_machine("aabbccddeeff", "toml", "x = 1\n") + .unwrap(); + fs::write(dir.join("aabbccddeeff/NOTES.md"), "why this box\n").unwrap(); + assert!(store.delete_machine("aabbccddeeff", "toml").unwrap()); + assert!(dir.join("aabbccddeeff/NOTES.md").is_file()); + let _ = fs::remove_dir_all(&dir); +} + #[test] fn the_file_store_ignores_what_a_mac_leaves_in_a_shared_folder() { // An answers directory edited over SMB from a Mac collects two kinds of litter. @@ -384,24 +601,32 @@ fn the_file_store_ignores_what_a_mac_leaves_in_a_shared_folder() { // body that is binary. The machine that was being configured then gets a parse error // instead of its answer. Found on a real NAS. let dir = scratch("appledouble"); - fs::create_dir_all(dir.join("groups")).unwrap(); + common::seed(&dir, "98-fa-9b-50-d8-10.toml", "[global]\nfqdn = \"m\"\n"); + common::seed( + &dir, + "groups/rack.toml", + "members = [\"98:fa:9b:50:d8:10\"]\n\n[global]\nkeyboard = \"fr\"\n", + ); + fs::write(dir.join(".DS_Store"), b"Mac OS X\x00\x02binary").unwrap(); + // **Beside the document, inside the machine's own directory** — which is worse than + // it was when answers were flat. `._proxmox.toml` is a second `.toml` in a directory + // that may hold only one, and it sorts *before* the real one, so a rule that picked + // the first would hand every request a binary body. fs::write( - dir.join("98-fa-9b-50-d8-10.toml"), - "[global]\nfqdn = \"m\"\n", + dir.join("98-fa-9b-50-d8-10/._proxmox.toml"), + b"Mac OS X\x00\x02binary", ) .unwrap(); fs::write( - dir.join("groups/rack.toml"), - "members = [\"98:fa:9b:50:d8:10\"]\n\n[global]\nkeyboard = \"fr\"\n", + dir.join("98-fa-9b-50-d8-10/.DS_Store"), + b"Mac OS X\x00\x02binary", ) .unwrap(); - fs::write(dir.join(".DS_Store"), b"Mac OS X\x00\x02binary").unwrap(); fs::write( - dir.join("._98-fa-9b-50-d8-10.toml"), + dir.join("groups/rack/._proxmox.toml"), b"Mac OS X\x00\x02binary", ) .unwrap(); - fs::write(dir.join("groups/._rack.toml"), b"Mac OS X\x00\x02binary").unwrap(); let store = FileStore::new(&dir); let snapshot = store.snapshot().unwrap(); @@ -412,6 +637,17 @@ fn the_file_store_ignores_what_a_mac_leaves_in_a_shared_folder() { "hidden files were taken for answer documents" ); assert_eq!(snapshot.groups.len(), 1, "hidden files reached the groups"); + // Not merely excluded — not even *reported* as a second document of one format. + assert!( + snapshot.problems.is_empty(), + "litter was reported as a conflict: {:?}", + snapshot.problems + ); + assert!( + snapshot.machines[0].body.contains("fqdn"), + "the AppleDouble answered instead of the document: {:?}", + snapshot.machines[0].body + ); let _ = fs::remove_dir_all(&dir); } @@ -427,15 +663,23 @@ fn the_file_store_writes_atomically_and_leaves_no_scratch_files() { .unwrap(); // A reader must never meet a half-written answer, so nothing temporary survives. - let stray: Vec<_> = fs::read_dir(&dir) - .unwrap() - .flatten() - .map(|e| e.file_name().to_string_lossy().to_string()) - .filter(|n| n.contains("tmp")) - .collect(); + // Swept recursively: the scratch file is written beside the document, which is one + // level down now — a check of the top directory alone would pass without looking. + let mut stray = Vec::new(); + let mut walk = vec![dir.clone()]; + while let Some(next) = walk.pop() { + for entry in fs::read_dir(&next).unwrap().flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if entry.path().is_dir() { + walk.push(entry.path()); + } else if name.contains("tmp") { + stray.push(entry.path().display().to_string()); + } + } + } assert!(stray.is_empty(), "left temporary files behind: {stray:?}"); - assert!(dir.join("98fa9b50d810.toml").is_file()); - assert!(dir.join("groups/rack-a.toml").is_file()); + assert!(dir.join("98fa9b50d810/proxmox.toml").is_file()); + assert!(dir.join("groups/rack-a/proxmox.toml").is_file()); let _ = fs::remove_dir_all(&dir); } @@ -1055,11 +1299,7 @@ fn an_answers_directory_that_appears_later_is_served_on_the_next_request() { // Well inside the one-second backstop, so the version change is what has to do it. fs::create_dir_all(&answers_dir).unwrap(); - fs::write( - answers_dir.join("98fa9b50d810.toml"), - "marker = \"appeared\"\n", - ) - .unwrap(); + common::seed(&answers_dir, "98fa9b50d810.toml", "marker = \"appeared\"\n"); let r = resolver .resolve(&facts_for("98:fa:9b:50:d8:10")) diff --git a/tests/tftp.rs b/tests/tftp.rs index 72ba072..026b774 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -11,6 +11,8 @@ #![cfg(feature = "boot")] +mod common; + use std::fs; use std::io::{BufRead, BufReader}; use std::net::UdpSocket; @@ -662,7 +664,7 @@ fn a_tftp_port_that_cannot_be_bound_does_not_take_the_answers_down() { for name in rescriptum::boot::loaders::loaders() { fs::write(boot_dir.join(name), loader(100)).expect("loader"); } - fs::write(answers_dir.join("default.toml"), "keyboard = \"fr\"\n").expect("answer"); + common::seed(&answers_dir, "default.toml", "keyboard = \"fr\"\n"); let mut child = Command::new(env!("CARGO_BIN_EXE_rescriptum")) .env("RESCRIPTUM_LISTEN_ADDR", "127.0.0.1:0") From 7a45f55235000555890a57214573d4309d69b0b9 Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Sat, 29 Aug 2026 19:27:46 +0200 Subject: [PATCH 58/59] fix(build): clear clippy 1.98 and repair the no-default-features build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures the local toolchain could not see, both older than this branch. **Clippy 1.98 gained five lints** this code trips, and the pinned local toolchain was 1.93 — so `cargo clippy` was green here and red in CI, which is the worst arrangement. All five are mechanical: an `.into_iter()` an array does not need, a `sort_by` that is a `sort_by_key` over `Reverse`, two `loop` + `let … else break` that are `while let`, and a zero check that is `checked_div`. None changes behaviour; the descending version sort keeps its `Reverse` so `9.10` still outranks `9.9`. **`--no-default-features` had stopped compiling**, and it is a CI gate. The `RESCRIPTUM_TFTP_BLKSIZE` accessor added with the block-size cap reaches for `boot::tftp`'s constants, which are not there when the feature is off. Its only caller is the TFTP server, behind that same feature, so the accessor moves behind it too. All three combinations build again: neither feature, each alone, and both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6 --- src/boot/sha256.rs | 6 +----- src/boot/sources.rs | 2 +- src/boot/tftp.rs | 5 +---- src/cli.rs | 3 ++- src/config.rs | 4 ++++ tests/tftp.rs | 5 +---- 6 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/boot/sha256.rs b/src/boot/sha256.rs index b6aeff7..b4e5cf9 100644 --- a/src/boot/sha256.rs +++ b/src/boot/sha256.rs @@ -143,11 +143,7 @@ impl Sha256 { a = t1.wrapping_add(t2); } - for (slot, value) in self - .state - .iter_mut() - .zip([a, b, c, d, e, f, g, h].into_iter()) - { + for (slot, value) in self.state.iter_mut().zip([a, b, c, d, e, f, g, h]) { *slot = slot.wrapping_add(value); } } diff --git a/src/boot/sources.rs b/src/boot/sources.rs index c87b4b5..061db3f 100644 --- a/src/boot/sources.rs +++ b/src/boot/sources.rs @@ -143,7 +143,7 @@ impl Source { // the version-ish parts of the name rather than the whole string: plain // lexicographic ordering puts `9.10` before `9.9`, which would offer a rack an // older installer than the one it asked for. - out.sort_by(|a, b| natural(&b.name).cmp(&natural(&a.name))); + out.sort_by_key(|s| std::cmp::Reverse(natural(&s.name))); out } } diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs index ba5caf9..6237f50 100644 --- a/src/boot/tftp.rs +++ b/src/boot/tftp.rs @@ -638,10 +638,7 @@ fn parse_request(bytes: &[u8]) -> Result { } let mut options = Vec::new(); - loop { - let (Some(name), Some(value)) = (fields.next(), fields.next()) else { - break; - }; + while let (Some(name), Some(value)) = (fields.next(), fields.next()) { let (Ok(name), Ok(value)) = (String::from_utf8(name), String::from_utf8(value)) else { break; }; diff --git a/src/cli.rs b/src/cli.rs index 8bba38a..ef0ddf0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -914,7 +914,8 @@ fn media_add(catalog: &crate::boot::catalog::Catalog, args: &[String]) -> ExitCo eprintln!("hashing {} …", path.display()); let mut last = 0u64; let digest = match crate::boot::sha256::file(&path, |done, total| { - let percent = if total == 0 { 100 } else { done * 100 / total }; + // No total means no progress to report, so call it finished rather than dividing. + let percent = (done * 100).checked_div(total).unwrap_or(100); if percent >= last + 10 { last = percent - percent % 10; eprintln!(" {last}% ({} of {})", human(done), human(total)); diff --git a/src/config.rs b/src/config.rs index a579b37..553f842 100644 --- a/src/config.rs +++ b/src/config.rs @@ -484,6 +484,10 @@ impl Config { /// always works. The default stays at what fits a clean path, because lowering it for /// everybody costs every deployment throughput to fix a minority's network — but the /// failure it causes is now loud enough to find. + /// + /// Behind the `boot` feature: the only caller is the TFTP server, and the constants + /// it clamps against live there too. + #[cfg(feature = "boot")] pub fn tftp_blksize(&self) -> usize { self.tftp_blksize .unwrap_or(crate::boot::tftp::MAX_BLOCK) diff --git a/tests/tftp.rs b/tests/tftp.rs index 026b774..21c2272 100644 --- a/tests/tftp.rs +++ b/tests/tftp.rs @@ -1163,10 +1163,7 @@ fn a_rom_that_asks_for_a_window_still_gets_its_file_promptly() { let mut got = Vec::new(); let mut since_ack = 0usize; - loop { - let Ok((n, _)) = sock.recv_from(&mut buffer) else { - break; - }; + while let Ok((n, _)) = sock.recv_from(&mut buffer) { if u16::from_be_bytes([buffer[0], buffer[1]]) != OP_DATA { break; } From ff374a1343c804e0669223517b5e3de3df17133d Mon Sep 17 00:00:00 2001 From: Quentin MATHIS Date: Sat, 29 Aug 2026 19:32:31 +0200 Subject: [PATCH 59/59] test(cli): stop `boot check` tests depending on who can bind port 69 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS lets an unprivileged process bind UDP 69; Linux does not. `boot check` treats an obtainable-but-silent TFTP port as a note and an unbindable one as a problem — the right rule, and precisely what made these tests take a different branch on each platform. They passed on the development machine and failed on the first CI run, on a verdict that had nothing to do with what they assert. Both tests that set `RESCRIPTUM_BOOT_DIR` now set `RESCRIPTUM_TFTP_ADDR=off`, so each measures only its subject: the loader table for one, the media port warning for the other. The unbindable port keeps its coverage in `tests/tftp.rs`, on a high port, where it is the subject rather than the noise. Both traps recorded — this one, and the more general one behind it: a branch that accumulates 57 commits before its first push has never met the CI, and finds out about the toolchain gap and the platform gap at the same time. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6 --- CLAUDE.md | 11 +++++++++++ docs/development/traps.fr.md | 14 ++++++++++++++ docs/development/traps.md | 12 ++++++++++++ tests/cli.rs | 12 ++++++++++++ 4 files changed, 49 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a576e38..1ee0dc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -637,6 +637,17 @@ could not check. Note it needs `Resolution::format_name` (the extension), not this server from another daemon squatting the port, since both are `AddrInUse`. So `boot check` sends a real read request (`boot::tftp::probe`) and reports what a machine would get. The first version guessed, and a test with a squatter said so at once. +- **macOS lets an unprivileged process bind UDP port 69; Linux does not.** So a test that + reaches the *default* TFTP address takes a different branch on each platform — `boot + check` calls an obtainable-but-silent port a note and an unbindable one a problem, which + is the right rule and exactly what makes the test platform-dependent. It passed locally + and failed in CI for a reason that had nothing to do with the change. Any test that sets + `RESCRIPTUM_BOOT_DIR` must also set `RESCRIPTUM_TFTP_ADDR=off` unless the probe *is* the + subject; `tests/tftp.rs` covers the unbindable port on a high one. +- **A branch developed entirely offline has never met the CI.** This one accumulated 57 + commits before its first push, and the first run failed on two things no local run could + see: a clippy five versions newer than the pinned local toolchain, and a Linux-only port + permission. Push early enough to find out, or expect to. - **The size figures in this file go stale.** They moved ~375 KB when armv7 changed from musl to glibc. Re-measure before concluding anything from them; a stale baseline once turned a 71% budget spend into an apparent 293% overrun. diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index 6395b30..4365376 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -47,6 +47,20 @@ fermait aussitôt, si bien que l'installateur à qui il essayait de dire *« ré recevait un reset. Il draine maintenant brièvement d'abord, comme le faisait déjà le `put()` de l'API d'administration. Un test au plafond de connexions l'épingle. +- **macOS autorise un processus non privilégié à lier le port UDP 69 ; Linux non.** Un test + qui atteint l'adresse TFTP *par défaut* prend donc une branche différente sur chaque + plateforme — `boot check` traite un port libre mais silencieux comme une note, et un port + non liable comme un problème, ce qui est la bonne règle et exactement ce qui rend le test + dépendant de la plateforme. Il passait en local et échouait en CI pour une raison sans + rapport avec le changement. Tout test qui définit `RESCRIPTUM_BOOT_DIR` doit aussi définir + `RESCRIPTUM_TFTP_ADDR=off`, sauf si la sonde *est* le sujet ; `tests/tftp.rs` couvre le + port non liable sur un port haut. +- **Une branche développée entièrement hors ligne n'a jamais rencontré la CI.** Celle-ci a + accumulé 57 commits avant son premier push, et le premier run a échoué sur deux choses + qu'aucune exécution locale ne pouvait voir : un clippy cinq versions plus récent que la + toolchain locale épinglée, et une permission de port propre à Linux. Poussez assez tôt + pour le découvrir, ou attendez-vous à le découvrir tard. + ## Sélection et formats **Un Mac qui édite le répertoire de réponses en SMB peut détourner la réponse d'une diff --git a/docs/development/traps.md b/docs/development/traps.md index c771400..240aa13 100644 --- a/docs/development/traps.md +++ b/docs/development/traps.md @@ -44,6 +44,18 @@ closed immediately, so the installer it was trying to tell *"retry"* got a conne reset instead. It now drains briefly first, the way the admin API's `put()` already did. A test at the connection cap pins it. +- **macOS lets an unprivileged process bind UDP port 69; Linux does not.** So a test that + reaches the *default* TFTP address takes a different branch on each platform — `boot + check` calls an obtainable-but-silent port a note and an unbindable one a problem, which + is the right rule and exactly what makes the test platform-dependent. It passed locally + and failed in CI for a reason that had nothing to do with the change. Any test that sets + `RESCRIPTUM_BOOT_DIR` must also set `RESCRIPTUM_TFTP_ADDR=off` unless the probe *is* the + subject; `tests/tftp.rs` covers the unbindable port on a high one. +- **A branch developed entirely offline has never met the CI.** This one accumulated 57 + commits before its first push, and the first run failed on two things no local run could + see: a clippy five versions newer than the pinned local toolchain, and a Linux-only port + permission. Push early enough to find out, or expect to. + ## Selection and formats **A Mac editing the answers directory over SMB can hijack a machine's answer.** macOS writes diff --git a/tests/cli.rs b/tests/cli.rs index dfea1b3..219416e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1085,6 +1085,12 @@ fn an_unknown_dhcp_format_lists_the_ones_that_exist() { #[cfg(feature = "boot")] fn boot_check_fails_when_a_snippet_names_a_loader_that_is_not_there() { // The exit code is a contract, like `check`'s: `deploy.sh` keys on it. + // + // **TFTP off, deliberately.** This test is about the loaders, and `boot check` also + // probes the TFTP address — which defaults to the privileged port 69. macOS lets an + // unprivileged process bind UDP 69 and Linux does not, so leaving it on makes the + // verdict depend on the platform and on who is running the suite. Turning it off + // isolates what is being measured; `tests/tftp.rs` covers the unbindable port. let case = Case::new(&[]); let boot_dir = case.dir.join("boot"); fs::create_dir_all(&boot_dir).expect("boot dir"); @@ -1093,6 +1099,7 @@ fn boot_check_fails_when_a_snippet_names_a_loader_that_is_not_there() { &[ ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), ("RESCRIPTUM_BOOT_DIR", boot_dir.as_path()), + ("RESCRIPTUM_TFTP_ADDR", Path::new("off")), ], &["boot", "check"], ); @@ -1111,6 +1118,7 @@ fn boot_check_fails_when_a_snippet_names_a_loader_that_is_not_there() { &[ ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), ("RESCRIPTUM_BOOT_DIR", boot_dir.as_path()), + ("RESCRIPTUM_TFTP_ADDR", Path::new("off")), ], &["boot", "check"], ); @@ -1152,6 +1160,10 @@ fn boot_check_warns_when_the_media_port_is_not_the_one_loaders_embed() { ("RESCRIPTUM_BOOT_DIR", boot_dir.as_path()), ("RESCRIPTUM_MEDIA_DIR", media_dir.as_path()), ("RESCRIPTUM_MEDIA_ADDR", Path::new("0.0.0.0:9999")), + // Off for the same reason as above: this asserts on the media port, and a + // TFTP probe that fails only on Linux would make it pass for two reasons on + // one platform and one on the other. + ("RESCRIPTUM_TFTP_ADDR", Path::new("off")), ], &["boot", "check"], );