From dcdf0b6fd08f10a10b18d7a8ae34f4bb228f9f1f Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Thu, 4 Jun 2026 21:24:15 -0400 Subject: [PATCH] v0.5.8: Windows ISOs just work (HTTP sanboot) + Storage UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows boot, the "less is more" way. Windows ISOs now boot via iPXE HTTP sanboot of the raw image — iPXE exposes the unmodified ISO as an emulated CD backed by on-demand HTTP range reads, and Windows Setup boots from it. This replaces the wimboot+SMB chain, which needed an SMB server the host often can't provide (:445 collisions), served in-ISO files via an ISO9660 lookup that failed on UDF-only Win11 ISOs, and was gated behind a Settings toggle the WebUI never even exposed (so Windows never booted). Now it needs only the HTTP port — works in any environment, SMB or not — and nothing is injected into Windows (no httpdisk.sys, no test certs, no trust-store changes; fully within the project's hard rules). - iso-store/store.rs: WindowsPe boot entry -> BootKind::SanBootIso of the raw iso/.iso (render_entry already emits `sanboot --no-describe`). - iso-store/introspect.rs: broaden Windows detection for UDF-only Win10/11 ISOs — UTF-16LE markers (boot.wim/bootmgr/install.wim/microsoft), extra ASCII markers, and a filename heuristic, since their volume labels are cryptic and filenames are UTF-16. + unit tests. - http-api/ipxe_script.rs: Windows installers submenu shows whenever a Windows ISO is present — no toggle, no "disabled in Settings". - webui: dashboard no longer flags Windows ISOs (they boot now); the generic large-ISO warning reworded to read sensibly for genuinely non-bootable images (e.g. VMware VCSA appliance bundles). Storage UX: - Available images listed alphabetically by filename. - Upload gains a Cancel button (aborts the chunk + discards the partial). - beforeunload warning while an upload is in flight. 263 tests pass, clippy clean. NOTE: actual Windows boot is validated on real hardware — code/script/range-serving are validated here. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 16 +-- Cargo.toml | 2 +- crates/http-api/src/ipxe_script.rs | 9 +- ..._password_tests__render_menu_snapshot.snap | 2 +- crates/http-api/tests/full_flow.rs | 43 ++++---- crates/iso-store/src/introspect.rs | 104 +++++++++++++++++- crates/iso-store/src/store.rs | 30 ++--- crates/webui/src/app.js | 48 ++++++-- 8 files changed, 189 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2179eac..90562ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2669,7 +2669,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "axum", @@ -2691,7 +2691,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "base64", @@ -2718,7 +2718,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "bytes", @@ -2732,7 +2732,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "axum", @@ -2768,7 +2768,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.5.7" +version = "0.5.8" dependencies = [ "openpxe-core", "rust-embed", @@ -2778,7 +2778,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "bcrypt", @@ -2807,7 +2807,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "bytes", @@ -2821,7 +2821,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.5.7" +version = "0.5.8" [[package]] name = "p256" diff --git a/Cargo.toml b/Cargo.toml index 9104191..b317df7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.5.7" +version = "0.5.8" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/http-api/src/ipxe_script.rs b/crates/http-api/src/ipxe_script.rs index 0927ac2..027e061 100644 --- a/crates/http-api/src/ipxe_script.rs +++ b/crates/http-api/src/ipxe_script.rs @@ -109,12 +109,13 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str } else { let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)"); } - if settings.windows_enabled && has_family(isos, is_windows_family) { + // v0.5.8: Windows just works — no Settings toggle. Show the Windows + // installers submenu whenever a Windows ISO is present; entries boot + // via HTTP sanboot of the raw ISO, so no SMB/extraction is required. + if has_family(isos, is_windows_family) { let _ = writeln!(s, "item windows Windows Installers >"); - } else if settings.windows_enabled { - let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)"); } else { - let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)"); + let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)"); } let _ = writeln!( s, diff --git a/crates/http-api/src/snapshots/openpxe_http_api__ipxe_script__password_tests__render_menu_snapshot.snap b/crates/http-api/src/snapshots/openpxe_http_api__ipxe_script__password_tests__render_menu_snapshot.snap index 37f9371..d4f90a1 100644 --- a/crates/http-api/src/snapshots/openpxe_http_api__ipxe_script__password_tests__render_menu_snapshot.snap +++ b/crates/http-api/src/snapshots/openpxe_http_api__ipxe_script__password_tests__render_menu_snapshot.snap @@ -17,7 +17,7 @@ item --gap -- ------------------------- Default ------------------------- item local Boot from Local HDD item --gap -- ----------------------- Installers ----------------------- item --gap -- (no Linux ISOs uploaded) -item --gap -- (Windows support disabled in Settings) +item --gap -- (no Windows ISOs uploaded) item --gap -- -------------------------- Tools -------------------------- item tools Tools > item --gap -- ---------------------- Queued Deployment --------------------- diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index d044058..df67616 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -693,7 +693,7 @@ async fn log_recent_returns_buffered_lines() { } #[tokio::test] -async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() { +async fn windows_iso_renders_clean_sanboot_script_with_no_trust_store_writes() { // Synthesize an ISO with a Windows volume label + the sources/boot.wim // sentinel so introspection labels it WindowsPe with has_boot_wim. let mut buf = vec![0u8; 32 * 2048]; @@ -763,38 +763,35 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() { "introspection should detect sources/boot.wim sentinel" ); - // The boot entry should be a wimboot kind with the canonical 5-file - // chain documented in the LinusTechTips iPXE-Windows guide. + // v0.5.8: Windows boots via iPXE HTTP sanboot of the raw ISO — no SMB, + // no extraction, no in-ISO file serving, no operator toggle. The boot + // entry is a `san_boot_iso` kind pointing at the raw image. let entry = &meta["boot_entries"][0]; - assert_eq!(entry["kind"]["kind"], "wimboot"); - let files = entry["kind"]["files"].as_array().unwrap(); - let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect(); - assert!(names.contains(&"bootmgr")); - assert!(names.contains(&"bootmgr.efi")); - assert!(names.contains(&"bcd")); - assert!(names.contains(&"boot.sdi")); - assert!(names.contains(&"boot.wim")); + assert_eq!(entry["kind"]["kind"], "san_boot_iso"); + let iso_url = entry["kind"]["iso_url"].as_str().unwrap(); + assert!( + std::path::Path::new(iso_url) + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("iso")), + "sanboot should target the raw ISO, got: {iso_url}" + ); // Render the entry script and verify: - // 1. It uses wimboot - // 2. All 5 files are referenced via `initrd --name` - // 3. NO trust-store / driver / testsigning operations slip in + // 1. It uses `sanboot` against the raw ISO over HTTP + // 2. NO trust-store / driver / testsigning operations slip in let entry_id = entry["id"].as_str().unwrap(); let url = format!("/boot/{entry_id}.ipxe"); let (s, body) = get(&app, &url).await; assert_eq!(s, StatusCode::OK); let script = String::from_utf8(body).unwrap(); - assert!(script.contains("kernel "), "missing kernel line:\n{script}"); assert!( - script.contains("ipxe/wimboot"), - "missing wimboot loader:\n{script}" + script.contains("sanboot"), + "missing sanboot line:\n{script}" + ); + assert!( + script.contains(&format!("/{iso_url}")), + "sanboot should reference the raw ISO url:\n{script}" ); - for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] { - assert!( - script.contains(&format!("initrd --name {tag}")), - "missing `initrd --name {tag}` line:\n{script}" - ); - } // Hard guarantees we never want to see in any client-facing script. let lower = script.to_lowercase(); for forbidden in [ diff --git a/crates/iso-store/src/introspect.rs b/crates/iso-store/src/introspect.rs index c8ee826..ade9373 100644 --- a/crates/iso-store/src/introspect.rs +++ b/crates/iso-store/src/introspect.rs @@ -85,12 +85,37 @@ pub fn introspect(path: &Path) -> IntrospectionReport { read_total += n; } + // `sources/boot.wim` is the definitive Windows-install-media marker + // when the ISO exposes ASCII (ISO9660/Joliet) names. `contains_ascii` + // is case-insensitive, so one form covers BOOT.WIM / boot.wim and the + // backslash variant. if contains_ascii(&haystack, b"sources/boot.wim") - || contains_ascii(&haystack, b"SOURCES/BOOT.WIM") - || contains_ascii(&haystack, b"SOURCES\\BOOT.WIM") + || contains_ascii(&haystack, b"sources\\boot.wim") { report.has_boot_wim = true; - if report.family == DistroFamily::Unknown { + report.family = DistroFamily::WindowsPe; + } + + // v0.5.8: broaden Windows detection. Modern Windows 10/11 ISOs are + // UDF — filenames are stored as UTF-16 (so the ASCII scan above misses + // them) and the volume label is a cryptic Microsoft string (so + // `family_from_label` misses it too). Booting is via HTTP sanboot of + // the raw ISO (no boot.wim extraction), so we only need the *family*. + // Catch the common cases: well-known Windows markers in either ASCII + // or UTF-16LE within the first 16 MiB, plus a filename hint. + if report.family == DistroFamily::Unknown { + let head = &haystack[..haystack.len().min(16 * 1024 * 1024)]; + let ascii_markers: [&[u8]; 4] = [ + b"bootmgr", + b"sources/install.wim", + b"sources/install.esd", + b"efi/microsoft", + ]; + let utf16_markers = ["bootmgr", "boot.wim", "install.wim", "microsoft"]; + let looks_windows = ascii_markers.iter().any(|m| contains_ascii(head, m)) + || utf16_markers.iter().any(|m| contains_utf16le_ci(head, m)) + || filename_looks_windows(path); + if looks_windows { report.family = DistroFamily::WindowsPe; } } @@ -158,6 +183,45 @@ fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool { .any(|w| w.eq_ignore_ascii_case(needle)) } +/// Case-insensitive search for an ASCII string encoded as UTF-16LE — the +/// way UDF (and thus modern Windows ISOs) store filenames. Each character +/// is two bytes: the ASCII low byte (compared case-insensitively) followed +/// by a 0 high byte. v0.5.8. +fn contains_utf16le_ci(haystack: &[u8], ascii: &str) -> bool { + let n = ascii.len(); + if n == 0 || haystack.len() < n * 2 { + return false; + } + let lower: Vec = ascii.bytes().map(|b| b.to_ascii_lowercase()).collect(); + haystack.windows(n * 2).any(|w| { + lower + .iter() + .enumerate() + .all(|(i, &c)| w[i * 2 + 1] == 0 && w[i * 2].to_ascii_lowercase() == c) + }) +} + +/// Filename heuristic: a stock Windows ISO almost always carries an obvious +/// token in its name (e.g. `..._windows_11_...`, `Win10`, `winserver`). +/// Used only as a last-resort family hint when the content scan and volume +/// label are inconclusive. v0.5.8. +fn filename_looks_windows(path: &Path) -> bool { + let name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + const TOKENS: [&str; 6] = [ + "windows", + "winpe", + "win10", + "win11", + "winserver", + "win-server", + ]; + TOKENS.iter().any(|t| name.contains(t)) +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +243,38 @@ mod tests { assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch); assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown); } + + #[test] + fn utf16le_marker_matches_case_insensitively() { + // "boot.wim" encoded UTF-16LE, mixed case — UDF stores Windows + // filenames this way, which the ASCII scan can't see. + let s = "BOOT.WIM"; + let utf16: Vec = s.bytes().flat_map(|b| [b, 0]).collect(); + let mut hay = vec![0u8; 8]; + hay.extend_from_slice(&utf16); + hay.extend_from_slice(&[1, 2, 3]); + assert!(contains_utf16le_ci(&hay, "boot.wim")); + assert!(contains_utf16le_ci(&hay, "Boot.Wim")); + assert!(!contains_utf16le_ci(&hay, "install.wim")); + // An ASCII (not UTF-16) occurrence must NOT match the UTF-16 scan. + assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim")); + } + + #[test] + fn filename_hint_catches_windows_isos() { + use std::path::Path; + assert!(filename_looks_windows(Path::new( + "en-us_windows_11_iot_enterprise_ltsc_2024_x64_dvd.iso" + ))); + assert!(filename_looks_windows(Path::new( + "Win10_22H2_English_x64.iso" + ))); + assert!(filename_looks_windows(Path::new("winserver2022.iso"))); + assert!(!filename_looks_windows(Path::new( + "ubuntu-24.04-desktop.iso" + ))); + assert!(!filename_looks_windows(Path::new( + "Rocky-9.4-x86_64-dvd.iso" + ))); + } } diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index e5ba75c..80cb4f6 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -557,22 +557,22 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V .clone() .unwrap_or_else(|| filename.to_string()); match r.family { - DistroFamily::WindowsPe if r.has_boot_wim => { - // Standard wimboot chain. Paths are in-ISO; the HTTP layer maps - // `iso//` to on-disk extraction via ISO9660 lookup. - let base = format!("iso/{id}"); + DistroFamily::WindowsPe => { + // v0.5.8: boot Windows directly via iPXE HTTP sanboot. iPXE + // exposes the raw ISO as an emulated CD backed by on-demand + // HTTP range reads, and Windows Setup boots from it. This + // replaces the old wimboot+SMB chain, which (a) needed an SMB + // server the host often can't provide (port 445 collisions), + // (b) served in-ISO files via an ISO9660 lookup that failed on + // UDF-only Windows 11 ISOs, and (c) required an operator + // toggle. sanboot needs none of that — just the HTTP port, + // which works in any environment. The unmodified, stock ISO is + // served at iso/.iso; nothing is injected into Windows. vec![BootEntry { - id: format!("{id}-winpe"), - title: format!("{title} (Windows / wimboot)"), - kind: BootKind::Wimboot { - wimboot_url: "ipxe/wimboot".to_string(), - files: vec![ - ("bootmgr".into(), format!("{base}/bootmgr")), - ("bootmgr.efi".into(), format!("{base}/bootmgr.efi")), - ("bcd".into(), format!("{base}/boot/bcd")), - ("boot.sdi".into(), format!("{base}/boot/boot.sdi")), - ("boot.wim".into(), format!("{base}/sources/boot.wim")), - ], + id: format!("{id}-windows"), + title: format!("{title} (Windows)"), + kind: BootKind::SanBootIso { + iso_url: format!("iso/{id}.iso"), }, }] } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 518b69f..f990211 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -160,15 +160,18 @@ // tint borrowed from Bootimus v0.1.62. Returns {ok, reason}. function bootability(iso, settings) { const fam = iso.introspection.family; - const isWin = fam === 'windows_pe'; - if (isWin && !settings.windows_enabled) { - return { ok: false, reason: 'Windows boot disabled in Settings' }; + // v0.5.8: Windows ISOs boot via iPXE HTTP sanboot of the raw image — + // no Settings toggle, no SMB, no size limit. Always bootable. + if (fam === 'windows_pe') { + return { ok: true }; } - if (!isWin && !iso.introspection.kernel_path && fam !== 'windows_pe') { - // Linux without a detected kernel falls through to sanboot which - // rarely works for >1 GiB ISOs. + if (!iso.introspection.kernel_path) { + // Not Windows and no Linux kernel/initrd detected. Small images can + // still try the sanboot fallback; large ones almost certainly aren't + // network-bootable installers (e.g. appliance bundles like VMware + // VCSA) — flag them clearly instead of with a Linux-centric message. if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) { - return { ok: false, reason: 'no kernel/initrd detected; ISO too large for sanboot fallback' }; + return { ok: false, reason: "not a recognized network-bootable installer (no Windows or Linux boot files found) — this image can't be PXE-booted" }; } return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' }; } @@ -533,6 +536,9 @@ style:'display:none', id:'file'}); const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'})); const upMsg = el('div', {class:'msg', id:'upmsg'}); + // v0.5.8: cancel button — shown only while an upload is in flight. + const cancelUpload = el('button', {class:'danger', type:'button', + style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload'); drop.onclick = () => file.click(); drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); }); @@ -575,6 +581,16 @@ }; let uploadId = null; + // v0.5.8: cancel + leave-page guard. The AbortController stops the + // in-flight chunk; the beforeunload listener warns the operator + // that navigating away aborts the upload (the server-side partial + // is then cleaned up by the DELETE in the catch below). + const ac = new AbortController(); + let canceled = false; + const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; }; + window.addEventListener('beforeunload', warnLeave); + cancelUpload.style.display = ''; + cancelUpload.onclick = () => { canceled = true; ac.abort(); }; setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')'); prog.classList.add('active'); bar.style.width = '1%'; @@ -601,6 +617,7 @@ 'x-openpxe-upload-complete': complete ? 'true' : 'false', }, body: f.slice(offset, end), + signal: ac.signal, }); if (!r.ok) throw new Error(await failText(r)); const j = await r.json(); @@ -616,8 +633,15 @@ try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); } catch {} } - setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err'); + if (canceled || (err && err.name === 'AbortError')) { + setStatus('Upload canceled — partial file discarded.', ''); + } else { + setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err'); + } } finally { + window.removeEventListener('beforeunload', warnLeave); + cancelUpload.style.display = 'none'; + cancelUpload.onclick = null; prog.classList.remove('active'); if (!upMsg.className.includes('ok')) bar.style.width = '0'; } @@ -629,7 +653,11 @@ // *next* row of the table. Keeps the markup flat and avoids the // overhead of a real modal. const rowsAndEditors = []; - isos.forEach(i => { + // v0.5.8: list Available images alphabetically by filename + // (case-insensitive, natural numeric order) instead of newest-first. + const sortedIsos = [...isos].sort((a, b) => + (a.filename || '').localeCompare(b.filename || '', undefined, { sensitivity: 'base', numeric: true })); + sortedIsos.forEach(i => { const b = bootability(i, settings); // v0.4.65: SMB userspace consumer (smbclient). // v0.4.67: NFS back as in-process Rust client (nfs3_client). @@ -1164,7 +1192,7 @@ diskCard, el('div', {class:'card'}, [ el('header', {}, el('h2', {}, 'Upload ISO')), - el('div', {class:'body'}, [drop, file, prog, upMsg]), + el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]), ]), // v0.5.1: SMB + NFS unified into one "Remote shares" card with a // protocol dropdown. Backend endpoints are unchanged; this is a