Full rename to match the openpxe.com brand. The product now reads as a
polished open-source project rather than a personal-tool nickname:
the anvil/forge metaphor is gone, replaced with the rainbow-horizon
brand mark from the marketing site.
## Naming changes
**PXEForge → OpenPXE** everywhere it's user-visible or developer-
facing:
- All 8 crate package names (`pxeforge-*` → `openpxe-*`).
- The bin crate dir + binary (`crates/pxeforge` → `crates/openpxe`,
`bin = "openpxe"`).
- Env vars: `PXEFORGE_*` → `OPENPXE_*` (no compat shim — pre-beta).
- Tracing targets: `pxeforge::*` → `openpxe::*`.
- Prometheus metrics: `pxeforge_*` → `openpxe_*` (pre-beta; nobody
has dashboards on these yet).
- Container image: `gitea.milesward.dev/mward4/openpxe:0.3.0`.
- All in-tree paths: `/var/lib/openpxe/{isos,work,smb}`,
`/usr/share/openpxe/ipxe`, `/etc/openpxe/...`.
- Unraid template renamed `pxeforge.xml` → `openpxe.xml`.
- README, NEXT_PHASE.md, architecture.md, comments, and the WebUI
brand string.
**Gated Deployment → Queued Deployment** as the user-facing concept:
- `Settings::TimeoutAction::GatedDeployment` →
`QueuedDeployment` (with `#[serde(alias = "gated_deployment")]`
so v0.2.0 settings.json files keep deserializing).
- Rust types: `Gate` → `QueueEntry`, `GateQueue` → `DeploymentQueue`,
`GateInner` → `QueueEntryInner`.
- File: `crates/core/src/gate.rs` → `crates/core/src/queue.rs`.
- HTTP routes: `/api/gate/*` → `/api/queue/*`. The JSON list key
flipped from `"gates"` to `"entries"` to match.
- iPXE shortcut: `/boot/_gate.ipxe` → `/boot/_queue.ipxe`. The
top-level menu's item id is now `queue` instead of `gate`.
- WebUI sidebar tab: "Forge Gate" → "Queue".
- Field on `AppState`: `gates` → `queue`.
## Brand assets
The anvil + forging-sparks logos are dropped:
- `logo.svg` is now a 24×24 medallion filled with the
`rainbow-horizon` gradient from openpxe.com (sliding hue rotation
via SMIL on the gradient stops, no JS needed).
- `anvil-forge.svg` renamed to `loader.svg` and rebuilt as a 64×64
louder version of the same disc — used for page-load transitions
and the imaging-progress widget. Adds a subtle scale pulse and a
white inner-glow so it has dimensionality on either theme.
## CSS rename
- `.forge-progress` → `.queue-progress`
- `.forge-progress .anvil` → `.queue-progress .mark`
- `@keyframes forge-sheen` → `queue-sheen`
- `.loader .anvil` → `.loader .mark`
- "Heating the forge…" loader text → "Loading…"
The rest of the layout is untouched. Light/dark theme tokens and the
sidebar/topbar structure carry over from v0.2.0 unchanged — the
brief was "keeping the UI similar."
## Validation
- `cargo build --workspace` — clean.
- `cargo clippy --workspace --all-targets` — no warnings.
- `cargo test --workspace` — **66 tests passing**, same as v0.2.0.
- Local smoke run against the rebuilt release binary verifies:
- `/boot.ipxe` emits `Queued Deployment` + `item queue` + chains
`/boot/_queue.ipxe`
- `/api/queue` returns `{count, entries}`
- `/metrics` emits `openpxe_queue_count` (renamed)
- `/assets/logo.svg` and `/assets/loader.svg` serve the new
rainbow brand SVGs
- `/api/status` reports version `0.3.0`
## Migration notes for operators on v0.2.0
- Container image path changed: pull
`gitea.milesward.dev/mward4/openpxe:0.3.0` (not `pxeforge:`).
- Bind mounts: `/var/lib/openpxe/{isos,work,smb}` (not `pxeforge`).
Move the host path or update the template.
- Env vars: replace `PXEFORGE_*` with `OPENPXE_*`. The Unraid
template at `deploy/unraid/openpxe.xml` is already updated.
- `settings.json` carries over transparently — the
`gated_deployment` value is accepted as an alias.
- HTTP API: any external scripts that hit `/api/gate/*` need to
switch to `/api/queue/*`. The JSON envelope key is `entries`
instead of `gates`.
156 lines
6.1 KiB
Rust
156 lines
6.1 KiB
Rust
//! ISO introspection — identify the distro family and locate kernel/initrd.
|
|
//!
|
|
//! We avoid a full ISO9660/Joliet/Rock-Ridge parser by reading a small number
|
|
//! of well-known files via `isoinfo` (from cdrtools/genisoimage) when it's on
|
|
//! the path. As a pure-Rust fallback we do a crude scan: read the volume
|
|
//! descriptor at offset 0x8000 to grab the volume label, and grep for known
|
|
//! filenames by scanning raw sectors — good enough to tell Debian from RHEL
|
|
//! most of the time, without shelling out.
|
|
//!
|
|
//! The returned `IntrospectionReport` is what `BootEntry`s get generated from.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
use std::path::Path;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DistroFamily {
|
|
DebianUbuntu,
|
|
RhelFedora,
|
|
OpenSuse,
|
|
Arch,
|
|
Alpine,
|
|
WindowsPe,
|
|
Unknown,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct IntrospectionReport {
|
|
pub family: DistroFamily,
|
|
pub volume_label: Option<String>,
|
|
/// Kernel path inside the ISO (e.g. `/casper/vmlinuz`, `/isolinux/vmlinuz`).
|
|
pub kernel_path: Option<String>,
|
|
/// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups.
|
|
pub initrd_paths: Vec<String>,
|
|
/// True if `sources/boot.wim` present — Windows install media.
|
|
pub has_boot_wim: bool,
|
|
}
|
|
|
|
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log
|
|
/// and return an `Unknown` family so the uploader still sees a record.
|
|
pub fn introspect(path: &Path) -> IntrospectionReport {
|
|
let mut report = IntrospectionReport {
|
|
family: DistroFamily::Unknown,
|
|
volume_label: None,
|
|
kernel_path: None,
|
|
initrd_paths: Vec::new(),
|
|
has_boot_wim: false,
|
|
};
|
|
|
|
let Ok(mut f) = std::fs::File::open(path) else {
|
|
tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display());
|
|
return report;
|
|
};
|
|
|
|
// ISO9660 Primary Volume Descriptor at LBA 16 (offset 0x8000), 2048 bytes.
|
|
// Bytes 40..72 are the Volume Identifier (space-padded, d-characters).
|
|
let mut pvd = [0u8; 2048];
|
|
if f.seek(SeekFrom::Start(0x8000)).is_ok() && f.read_exact(&mut pvd).is_ok() {
|
|
// Byte 0 must be 0x01 (primary descriptor), bytes 1..6 = "CD001".
|
|
if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" {
|
|
let label_raw = &pvd[40..72];
|
|
let label = String::from_utf8_lossy(label_raw).trim().to_string();
|
|
if !label.is_empty() {
|
|
report.volume_label = Some(label.clone());
|
|
report.family = family_from_label(&label);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cheap content scan: read the first ~64 MiB, look for signature filenames.
|
|
// This is enough to identify `sources/boot.wim` (Windows) and common
|
|
// kernel/initrd paths for the major Linux distros.
|
|
let _ = f.seek(SeekFrom::Start(0));
|
|
let scan_bytes = 64 * 1024 * 1024;
|
|
let mut buf = vec![0u8; 1024 * 1024];
|
|
let mut read_total = 0usize;
|
|
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
|
|
while read_total < scan_bytes {
|
|
let n = f.read(&mut buf).unwrap_or(0);
|
|
if n == 0 { break; }
|
|
haystack.extend_from_slice(&buf[..n]);
|
|
read_total += n;
|
|
}
|
|
|
|
if 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;
|
|
}
|
|
}
|
|
|
|
// Best-effort kernel/initrd path guess from family. These paths are what
|
|
// distro ISOs conventionally ship at — we don't verify extraction here;
|
|
// that happens in the store after introspection.
|
|
let (k, i) = guess_kernel_initrd(report.family);
|
|
report.kernel_path = k.map(str::to_string);
|
|
report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
|
|
|
|
report
|
|
}
|
|
|
|
fn family_from_label(label: &str) -> DistroFamily {
|
|
let l = label.to_ascii_lowercase();
|
|
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") {
|
|
DistroFamily::DebianUbuntu
|
|
} else if l.contains("rhel") || l.contains("centos") || l.contains("fedora")
|
|
|| l.contains("rocky") || l.contains("alma")
|
|
{
|
|
DistroFamily::RhelFedora
|
|
} else if l.contains("suse") || l.contains("opensuse") {
|
|
DistroFamily::OpenSuse
|
|
} else if l.contains("arch") {
|
|
DistroFamily::Arch
|
|
} else if l.contains("alpine") {
|
|
DistroFamily::Alpine
|
|
} else if l.contains("windows") || l.contains("winpe") {
|
|
DistroFamily::WindowsPe
|
|
} else {
|
|
DistroFamily::Unknown
|
|
}
|
|
}
|
|
|
|
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
|
|
match family {
|
|
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
|
|
DistroFamily::RhelFedora => (Some("/images/pxeboot/vmlinuz"), vec!["/images/pxeboot/initrd.img"]),
|
|
DistroFamily::OpenSuse => (Some("/boot/x86_64/loader/linux"), vec!["/boot/x86_64/loader/initrd"]),
|
|
DistroFamily::Arch => (Some("/arch/boot/x86_64/vmlinuz-linux"), vec!["/arch/boot/x86_64/initramfs-linux.img"]),
|
|
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]),
|
|
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
|
|
}
|
|
}
|
|
|
|
fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
|
|
if needle.is_empty() || haystack.len() < needle.len() { return false; }
|
|
haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn label_matching() {
|
|
assert_eq!(family_from_label("Ubuntu 24.04"), DistroFamily::DebianUbuntu);
|
|
assert_eq!(family_from_label("Rocky-9-x86_64-dvd"), DistroFamily::RhelFedora);
|
|
assert_eq!(family_from_label("openSUSE-Leap-15.6"), DistroFamily::OpenSuse);
|
|
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
|
|
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
|
|
}
|
|
}
|