Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc9a9a1af |
Generated
+8
-8
@@ -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"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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 ---------------------
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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<u8> = 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<u8> = 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"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<id>/<path>` 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/<id>.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"),
|
||||
},
|
||||
}]
|
||||
}
|
||||
|
||||
+38
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user