Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06695c3d77 | ||
|
|
cb51b8db75 | ||
|
|
d6a9df85d7 |
@@ -11,3 +11,6 @@ data/work/
|
|||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.claude/scheduled_tasks.lock
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
|
# local editor / agent settings (not part of the project)
|
||||||
|
.claude/
|
||||||
|
|||||||
Generated
+8
-8
@@ -2669,7 +2669,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe"
|
name = "openpxe"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2691,7 +2691,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-core"
|
name = "openpxe-core"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -2718,7 +2718,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-dhcp-proxy"
|
name = "openpxe-dhcp-proxy"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2732,7 +2732,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-http-api"
|
name = "openpxe-http-api"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2768,7 +2768,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-ipxe-assets"
|
name = "openpxe-ipxe-assets"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
@@ -2778,7 +2778,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-iso-store"
|
name = "openpxe-iso-store"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -2807,7 +2807,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-tftp"
|
name = "openpxe-tftp"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2821,7 +2821,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-webui"
|
name = "openpxe-webui"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "p256"
|
name = "p256"
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="release" src="https://img.shields.io/badge/release-v0.5.5-2874d7" />
|
<img alt="release" src="https://img.shields.io/badge/release-v0.5.8-2874d7" />
|
||||||
<img alt="license" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-59824f" />
|
<img alt="license" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-59824f" />
|
||||||
<img alt="rust" src="https://img.shields.io/badge/built%20with-Rust-fb8841?logo=rust&logoColor=white" />
|
<img alt="rust" src="https://img.shields.io/badge/built%20with-Rust-fb8841?logo=rust&logoColor=white" />
|
||||||
<img alt="container" src="https://img.shields.io/badge/container--native-OCI%20%C2%B7%20OpenShift-2496ED?logo=docker&logoColor=white" />
|
<img alt="container" src="https://img.shields.io/badge/container--native-OCI%20%C2%B7%20OpenShift-2496ED?logo=docker&logoColor=white" />
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ pub use profile::DeployProfile;
|
|||||||
pub use queue::{DeploymentQueue, QueueEntry};
|
pub use queue::{DeploymentQueue, QueueEntry};
|
||||||
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
|
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
|
||||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||||
pub use sso::{SsoConfig, SsoStore};
|
pub use sso::{SsoConfig, SsoLoginInfo, SsoStore};
|
||||||
|
|||||||
@@ -73,6 +73,23 @@ impl SsoConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The minimal, non-sensitive slice of the SSO config that the **pre-auth**
|
||||||
|
/// login screen needs to render the "Sign in with …" button. Carries only
|
||||||
|
/// the display affordances — never the metadata XML/URL or entity ID, which
|
||||||
|
/// stay behind the auth-gated `/api/sso`. Served as part of the public
|
||||||
|
/// `/api/me` so the button renders reliably whether or not anyone is signed
|
||||||
|
/// in (v0.5.9: fixes the button vanishing because `/api/sso` 401s pre-auth).
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct SsoLoginInfo {
|
||||||
|
/// True only when SSO is *usable* (enabled AND a metadata source is
|
||||||
|
/// present) — i.e. clicking the button will actually reach an IdP.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Button label, e.g. "STC AD". Empty falls back to "SSO" in the UI.
|
||||||
|
pub idp_name: String,
|
||||||
|
/// Optional IdP logo rendered on the button. Empty = no image.
|
||||||
|
pub idp_logo_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// In-memory + on-disk SSO settings registry.
|
/// In-memory + on-disk SSO settings registry.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SsoStore {
|
pub struct SsoStore {
|
||||||
@@ -111,6 +128,19 @@ impl SsoStore {
|
|||||||
self.inner.read().clone()
|
self.inner.read().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public, non-sensitive descriptor for the login screen. Safe to
|
||||||
|
/// expose pre-auth — it's exactly what the "Sign in with …" button
|
||||||
|
/// keys off, with no metadata/entity-ID leakage. v0.5.9.
|
||||||
|
#[must_use]
|
||||||
|
pub fn login_info(&self) -> SsoLoginInfo {
|
||||||
|
let cfg = self.inner.read();
|
||||||
|
SsoLoginInfo {
|
||||||
|
enabled: cfg.is_usable(),
|
||||||
|
idp_name: cfg.idp_name.clone(),
|
||||||
|
idp_logo_url: cfg.idp_logo_url.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace the whole config in one shot. Light validation: metadata
|
/// Replace the whole config in one shot. Light validation: metadata
|
||||||
/// XML and URL are length-capped so an operator can't OOM us by
|
/// XML and URL are length-capped so an operator can't OOM us by
|
||||||
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
|
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
|
||||||
|
|||||||
@@ -319,6 +319,12 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
// and the logo asset is public, so this leaks nothing sensitive.
|
// and the logo asset is public, so this leaks nothing sensitive.
|
||||||
let has_custom_logo = state.branding.has_any_web_logo();
|
let has_custom_logo = state.branding.has_any_web_logo();
|
||||||
let logo_rev = state.branding.logo_rev();
|
let logo_rev = state.branding.logo_rev();
|
||||||
|
// v0.5.9: ship the non-sensitive SSO descriptor with every /api/me so
|
||||||
|
// the pre-auth login screen can render the "Sign in with …" button
|
||||||
|
// reliably. Previously the button keyed off the auth-gated /api/sso,
|
||||||
|
// which 401s when logged out — the button only survived on a stale
|
||||||
|
// in-memory config and vanished on any fresh login-page load.
|
||||||
|
let sso = state.sso.login_info();
|
||||||
if !state.admin.is_configured() {
|
if !state.admin.is_configured() {
|
||||||
return (
|
return (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -327,6 +333,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
"has_custom_logo": has_custom_logo,
|
"has_custom_logo": has_custom_logo,
|
||||||
"logo_rev": logo_rev,
|
"logo_rev": logo_rev,
|
||||||
|
"sso": sso,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
@@ -343,6 +350,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
"session_user": u,
|
"session_user": u,
|
||||||
"has_custom_logo": has_custom_logo,
|
"has_custom_logo": has_custom_logo,
|
||||||
"logo_rev": logo_rev,
|
"logo_rev": logo_rev,
|
||||||
|
"sso": sso,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
@@ -353,6 +361,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
"has_custom_logo": has_custom_logo,
|
"has_custom_logo": has_custom_logo,
|
||||||
"logo_rev": logo_rev,
|
"logo_rev": logo_rev,
|
||||||
|
"sso": sso,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum DistroFamily {
|
pub enum DistroFamily {
|
||||||
DebianUbuntu,
|
DebianUbuntu,
|
||||||
@@ -22,10 +22,22 @@ pub enum DistroFamily {
|
|||||||
Arch,
|
Arch,
|
||||||
Alpine,
|
Alpine,
|
||||||
WindowsPe,
|
WindowsPe,
|
||||||
|
#[default]
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
/// Bumped whenever the introspection logic changes in a way that should
|
||||||
|
/// re-classify already-uploaded ISOs. On startup the store re-runs
|
||||||
|
/// `introspect` on any *local* ISO whose persisted report predates this
|
||||||
|
/// revision (see `IsoStore::load_from_disk`), so an upgrade fixes stale
|
||||||
|
/// metadata — e.g. a Windows 11 ISO tagged `Unknown` by an older binary —
|
||||||
|
/// without the operator having to delete and re-upload it.
|
||||||
|
///
|
||||||
|
/// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened
|
||||||
|
/// Windows (UDF/UTF-16) detection becomes retroactive.
|
||||||
|
pub const INTROSPECT_REV: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct IntrospectionReport {
|
pub struct IntrospectionReport {
|
||||||
pub family: DistroFamily,
|
pub family: DistroFamily,
|
||||||
pub volume_label: Option<String>,
|
pub volume_label: Option<String>,
|
||||||
@@ -35,17 +47,28 @@ pub struct IntrospectionReport {
|
|||||||
pub initrd_paths: Vec<String>,
|
pub initrd_paths: Vec<String>,
|
||||||
/// True if `sources/boot.wim` present — Windows install media.
|
/// True if `sources/boot.wim` present — Windows install media.
|
||||||
pub has_boot_wim: bool,
|
pub has_boot_wim: bool,
|
||||||
|
/// True if the ISO carries an El Torito boot catalog — i.e. it is
|
||||||
|
/// bootable by BIOS/UEFI firmware and therefore by iPXE `sanboot`
|
||||||
|
/// (emulated CD). This is the authoritative "can this boot at all?"
|
||||||
|
/// signal for ISOs we can't classify as Linux or Windows (BSDs, ESXi,
|
||||||
|
/// firmware tools, custom spins). A *data* ISO (e.g. a VMware vCenter
|
||||||
|
/// appliance bundle) has no boot catalog and reports `false`. v0.5.9.
|
||||||
|
#[serde(default)]
|
||||||
|
pub el_torito: bool,
|
||||||
|
/// Revision of the introspection logic that produced this report. Old
|
||||||
|
/// `meta.json` files without the field deserialize as 0, which is
|
||||||
|
/// below [`INTROSPECT_REV`], triggering a one-time re-introspect on
|
||||||
|
/// the next startup. v0.5.9.
|
||||||
|
#[serde(default)]
|
||||||
|
pub introspect_rev: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log
|
/// 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.
|
/// and return an `Unknown` family so the uploader still sees a record.
|
||||||
pub fn introspect(path: &Path) -> IntrospectionReport {
|
pub fn introspect(path: &Path) -> IntrospectionReport {
|
||||||
let mut report = IntrospectionReport {
|
let mut report = IntrospectionReport {
|
||||||
family: DistroFamily::Unknown,
|
introspect_rev: INTROSPECT_REV,
|
||||||
volume_label: None,
|
..Default::default()
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: Vec::new(),
|
|
||||||
has_boot_wim: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(mut f) = std::fs::File::open(path) else {
|
let Ok(mut f) = std::fs::File::open(path) else {
|
||||||
@@ -68,6 +91,11 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Does the ISO have an El Torito boot catalog? This is what decides
|
||||||
|
// whether an ISO we *can't* otherwise classify is bootable at all —
|
||||||
|
// a bootable ISO sanboots; a data/appliance ISO (no catalog) can't.
|
||||||
|
report.el_torito = detect_el_torito(&mut f);
|
||||||
|
|
||||||
// Cheap content scan: read the first ~64 MiB, look for signature filenames.
|
// Cheap content scan: read the first ~64 MiB, look for signature filenames.
|
||||||
// This is enough to identify `sources/boot.wim` (Windows) and common
|
// This is enough to identify `sources/boot.wim` (Windows) and common
|
||||||
// kernel/initrd paths for the major Linux distros.
|
// kernel/initrd paths for the major Linux distros.
|
||||||
@@ -222,6 +250,44 @@ fn filename_looks_windows(path: &Path) -> bool {
|
|||||||
TOKENS.iter().any(|t| name.contains(t))
|
TOKENS.iter().any(|t| name.contains(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The boot-system identifier string in an El Torito Boot Record Volume
|
||||||
|
/// Descriptor (offset 7, NUL-padded to 32 bytes).
|
||||||
|
const EL_TORITO_ID: &[u8] = b"EL TORITO SPECIFICATION";
|
||||||
|
|
||||||
|
/// Detect an El Torito boot catalog — the marker that an ISO is bootable
|
||||||
|
/// by BIOS/UEFI firmware (and thus by iPXE `sanboot`).
|
||||||
|
///
|
||||||
|
/// The ISO9660 Volume Descriptor Set starts at LBA 16 (offset 0x8000) and
|
||||||
|
/// runs one 2048-byte descriptor per sector until a Set Terminator
|
||||||
|
/// (type 0xFF). A Boot Record descriptor (type 0x00) whose 32-byte boot
|
||||||
|
/// system identifier reads "EL TORITO SPECIFICATION" means the image
|
||||||
|
/// declares an El Torito boot catalog. We only confirm its presence — we
|
||||||
|
/// don't parse the catalog (sanboot/the firmware does that). The walk is
|
||||||
|
/// capped so a malformed/huge image can't spin us. v0.5.9.
|
||||||
|
fn detect_el_torito(f: &mut std::fs::File) -> bool {
|
||||||
|
let mut vd = [0u8; 2048];
|
||||||
|
for lba in 16u64..32 {
|
||||||
|
if f.seek(SeekFrom::Start(lba * 2048)).is_err() || f.read_exact(&mut vd).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Every descriptor in the set carries the "CD001" magic; once it's
|
||||||
|
// missing we've walked off the end of a valid set.
|
||||||
|
if &vd[1..6] != b"CD001" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match vd[0] {
|
||||||
|
// Boot Record descriptor carrying the El Torito signature.
|
||||||
|
0x00 if vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID => return true,
|
||||||
|
// Volume Descriptor Set Terminator — nothing bootable found.
|
||||||
|
0xFF => return false,
|
||||||
|
// Any other descriptor (incl. a non-El-Torito boot record) —
|
||||||
|
// keep walking the set.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -260,6 +326,40 @@ mod tests {
|
|||||||
assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim"));
|
assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn el_torito_boot_catalog_detected() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
// Helper: stamp a 2048-byte descriptor at `lba` with type + magic.
|
||||||
|
let stamp = |img: &mut [u8], lba: usize, ty: u8| {
|
||||||
|
let off = lba * 2048;
|
||||||
|
img[off] = ty;
|
||||||
|
img[off + 1..off + 6].copy_from_slice(b"CD001");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bootable image: PVD @16, El Torito Boot Record @17, terminator @18.
|
||||||
|
let mut boot = vec![0u8; 2048 * 19];
|
||||||
|
stamp(&mut boot, 16, 0x01);
|
||||||
|
stamp(&mut boot, 17, 0x00);
|
||||||
|
boot[17 * 2048 + 7..17 * 2048 + 7 + EL_TORITO_ID.len()].copy_from_slice(EL_TORITO_ID);
|
||||||
|
stamp(&mut boot, 18, 0xFF);
|
||||||
|
let bp = dir.path().join("boot.iso");
|
||||||
|
std::fs::write(&bp, &boot).unwrap();
|
||||||
|
let mut f = std::fs::File::open(&bp).unwrap();
|
||||||
|
assert!(
|
||||||
|
detect_el_torito(&mut f),
|
||||||
|
"El Torito boot record should match"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Data/appliance image: PVD @16, terminator @17, no boot record.
|
||||||
|
let mut data = vec![0u8; 2048 * 18];
|
||||||
|
stamp(&mut data, 16, 0x01);
|
||||||
|
stamp(&mut data, 17, 0xFF);
|
||||||
|
let dp = dir.path().join("data.iso");
|
||||||
|
std::fs::write(&dp, &data).unwrap();
|
||||||
|
let mut f2 = std::fs::File::open(&dp).unwrap();
|
||||||
|
assert!(!detect_el_torito(&mut f2), "data ISO has no boot catalog");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filename_hint_catches_windows_isos() {
|
fn filename_hint_catches_windows_isos() {
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
//! UI to ask for. (If a future server needs Kerberos or non-default
|
//! UI to ask for. (If a future server needs Kerberos or non-default
|
||||||
//! uid mapping we can add those, but for ISO read access nobody does.)
|
//! uid mapping we can add those, but for ISO read access nobody does.)
|
||||||
|
|
||||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
use crate::introspect::IntrospectionReport;
|
||||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use nfs3_client::tokio::TokioConnector;
|
use nfs3_client::tokio::TokioConnector;
|
||||||
@@ -399,13 +399,7 @@ impl NfsShareManager {
|
|||||||
// Same approach as SMB: no real introspection over the
|
// Same approach as SMB: no real introspection over the
|
||||||
// network in v0.4.67. The boot-entry generator falls back
|
// network in v0.4.67. The boot-entry generator falls back
|
||||||
// to filename-based sanboot detection.
|
// to filename-based sanboot detection.
|
||||||
let report = IntrospectionReport {
|
let report = IntrospectionReport::default();
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: Vec::new(),
|
|
||||||
has_boot_wim: false,
|
|
||||||
};
|
|
||||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||||
let source = IsoSource::Nfs {
|
let source = IsoSource::Nfs {
|
||||||
share_id: share.id.clone(),
|
share_id: share.id.clone(),
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
//! hint}` error shape is shared so the storage tab renders all three
|
//! hint}` error shape is shared so the storage tab renders all three
|
||||||
//! protocols through one code path.
|
//! protocols through one code path.
|
||||||
|
|
||||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
use crate::introspect::IntrospectionReport;
|
||||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use openpxe_core::{Error, Result};
|
use openpxe_core::{Error, Result};
|
||||||
@@ -480,13 +480,7 @@ impl SftpShareManager {
|
|||||||
// register `Unknown` and let the boot-entry generator fall
|
// register `Unknown` and let the boot-entry generator fall
|
||||||
// back to filename-based detection. SFTP *could* do bounded
|
// back to filename-based detection. SFTP *could* do bounded
|
||||||
// PVD reads (it has random access) — a follow-up can add it.
|
// PVD reads (it has random access) — a follow-up can add it.
|
||||||
let report = IntrospectionReport {
|
let report = IntrospectionReport::default();
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: Vec::new(),
|
|
||||||
has_boot_wim: false,
|
|
||||||
};
|
|
||||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||||
let source = IsoSource::Sftp {
|
let source = IsoSource::Sftp {
|
||||||
share_id: share.id.clone(),
|
share_id: share.id.clone(),
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
//! streaming. A follow-up release can add libsmbclient-based seek if
|
//! streaming. A follow-up release can add libsmbclient-based seek if
|
||||||
//! a real workload needs it.
|
//! a real workload needs it.
|
||||||
|
|
||||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
use crate::introspect::IntrospectionReport;
|
||||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||||
use openpxe_core::{Error, Result};
|
use openpxe_core::{Error, Result};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
@@ -470,13 +470,7 @@ impl SmbShareManager {
|
|||||||
// and the operator gets *something* bootable. A follow-up
|
// and the operator gets *something* bootable. A follow-up
|
||||||
// release can do a bounded `smbclient get` of the first
|
// release can do a bounded `smbclient get` of the first
|
||||||
// 64 KiB for real detection.
|
// 64 KiB for real detection.
|
||||||
let report = IntrospectionReport {
|
let report = IntrospectionReport::default();
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: Vec::new(),
|
|
||||||
has_boot_wim: false,
|
|
||||||
};
|
|
||||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||||
let source = IsoSource::Smb {
|
let source = IsoSource::Smb {
|
||||||
share_id: share.id.clone(),
|
share_id: share.id.clone(),
|
||||||
|
|||||||
@@ -223,7 +223,8 @@ impl IsoStore {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(text) = tokio::fs::read_to_string(&p).await {
|
if let Ok(text) = tokio::fs::read_to_string(&p).await {
|
||||||
if let Ok(meta) = serde_json::from_str::<IsoMeta>(&text) {
|
if let Ok(mut meta) = serde_json::from_str::<IsoMeta>(&text) {
|
||||||
|
self.reintrospect_if_stale(&mut meta).await;
|
||||||
self.insert(meta);
|
self.insert(meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +232,46 @@ impl IsoStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.5.9: re-run introspection on a *local* ISO whose persisted report
|
||||||
|
/// predates the current logic. ISOs uploaded by an older binary carry a
|
||||||
|
/// stale family/boot profile — most visibly a Windows 11 ISO tagged
|
||||||
|
/// `Unknown` before the UDF/El-Torito detection landed, which then shows
|
||||||
|
/// as "won't boot" forever. Re-probing on startup fixes them in place,
|
||||||
|
/// no delete-and-re-upload. Bounded: only `Local` sources (we have the
|
||||||
|
/// bytes locally) below [`introspect::INTROSPECT_REV`], so it runs at
|
||||||
|
/// most once per ISO per upgrade. The probe reads up to ~64 MiB, so we
|
||||||
|
/// push it onto the blocking pool to keep the async runtime responsive.
|
||||||
|
async fn reintrospect_if_stale(&self, meta: &mut IsoMeta) {
|
||||||
|
if !matches!(meta.source, IsoSource::Local)
|
||||||
|
|| meta.introspection.introspect_rev >= crate::introspect::INTROSPECT_REV
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = self.iso_path(&meta.id);
|
||||||
|
if !path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(fresh) = tokio::task::spawn_blocking(move || introspect(&path)).await else {
|
||||||
|
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect task failed");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let before = meta.introspection.family;
|
||||||
|
meta.introspection = fresh;
|
||||||
|
meta.boot_entries = generate_boot_entries(&meta.id, &meta.filename, &meta.introspection);
|
||||||
|
if let Err(e) = self.persist_meta(meta).await {
|
||||||
|
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect persist: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::iso",
|
||||||
|
id = %meta.id,
|
||||||
|
from = ?before,
|
||||||
|
to = ?meta.introspection.family,
|
||||||
|
el_torito = meta.introspection.el_torito,
|
||||||
|
"re-introspected stale ISO metadata"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn insert(&self, meta: IsoMeta) {
|
fn insert(&self, meta: IsoMeta) {
|
||||||
self.inner.write().isos.insert(meta.id.clone(), meta);
|
self.inner.write().isos.insert(meta.id.clone(), meta);
|
||||||
}
|
}
|
||||||
@@ -687,13 +728,7 @@ mod tests {
|
|||||||
size_bytes: 0,
|
size_bytes: 0,
|
||||||
sha256_hex: None,
|
sha256_hex: None,
|
||||||
uploaded_at: OffsetDateTime::now_utc(),
|
uploaded_at: OffsetDateTime::now_utc(),
|
||||||
introspection: IntrospectionReport {
|
introspection: IntrospectionReport::default(),
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: vec![],
|
|
||||||
has_boot_wim: false,
|
|
||||||
},
|
|
||||||
boot_entries: vec![],
|
boot_entries: vec![],
|
||||||
source: IsoSource::Local,
|
source: IsoSource::Local,
|
||||||
password_hash: None,
|
password_hash: None,
|
||||||
|
|||||||
+47
-21
@@ -165,18 +165,31 @@
|
|||||||
if (fam === 'windows_pe') {
|
if (fam === 'windows_pe') {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
if (!iso.introspection.kernel_path) {
|
// Linux with a detected kernel/initrd — direct kernel+initrd boot.
|
||||||
// Not Windows and no Linux kernel/initrd detected. Small images can
|
if (iso.introspection.kernel_path) {
|
||||||
// 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: "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' };
|
|
||||||
}
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
// v0.5.9: any other ISO that carries an El Torito boot catalog is
|
||||||
|
// bootable via iPXE sanboot (emulated CD) — BSDs, ESXi, firmware
|
||||||
|
// tools, custom Linux spins. This replaces the old "> 1.5 GB ⇒
|
||||||
|
// unbootable" size guess with the authoritative on-disk boot signal,
|
||||||
|
// so a large bootable ISO is no longer mislabeled and a Windows ISO
|
||||||
|
// re-introspected on upgrade lights up correctly.
|
||||||
|
if (iso.introspection.el_torito) {
|
||||||
|
return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' };
|
||||||
|
}
|
||||||
|
// Remote-share ISOs aren't introspected (no random access over the
|
||||||
|
// network), so el_torito is unknown — assume bootable and let sanboot
|
||||||
|
// try rather than cry wolf.
|
||||||
|
const remote = iso.source && iso.source.kind && iso.source.kind !== 'local';
|
||||||
|
if (remote) {
|
||||||
|
return { ok: true, warn: 'remote ISO — not introspected; sanboot is attempted at boot' };
|
||||||
|
}
|
||||||
|
// Local ISO with no Windows/Linux boot files and no El Torito catalog:
|
||||||
|
// a data/appliance image (e.g. a VMware vCenter bundle), not a bootable
|
||||||
|
// installer.
|
||||||
|
return { ok: false, reason: 'data/appliance ISO — no El Torito boot catalog and no Windows/Linux installer files, so it can’t be PXE-booted' };
|
||||||
|
}
|
||||||
|
|
||||||
// v0.5.2: pretty label for an unattended file's detected kind.
|
// v0.5.2: pretty label for an unattended file's detected kind.
|
||||||
function unattendedKindLabel(k) {
|
function unattendedKindLabel(k) {
|
||||||
@@ -291,14 +304,23 @@
|
|||||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||||
el('div', {class: 'label'}, 'Images available'),
|
el('div', {class: 'label'}, 'Images available'),
|
||||||
el('div', {class: 'value'}, String(isos.length)),
|
el('div', {class: 'value'}, String(isos.length)),
|
||||||
el('div', {class: 'trend'},
|
el('div', {class: 'trend'}, (() => {
|
||||||
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
|
// v0.5.9: count families honestly. Anything that isn't a known
|
||||||
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
|
// Linux family or Windows lands in "other" (data/appliance ISOs
|
||||||
|
// like VMware VCSA, or as-yet-unclassified images) instead of
|
||||||
|
// being lumped under "Linux".
|
||||||
|
const LINUX = ['debian_ubuntu', 'rhel_fedora', 'opensuse', 'arch', 'alpine'];
|
||||||
|
const win = isos.filter(i => i.introspection.family === 'windows_pe').length;
|
||||||
|
const lin = isos.filter(i => LINUX.includes(i.introspection.family)).length;
|
||||||
|
const other = isos.length - win - lin;
|
||||||
// v0.4.67+v0.5.5: count all remote-share protocols. Label
|
// v0.4.67+v0.5.5: count all remote-share protocols. Label
|
||||||
// generically since operators may use any mix of SMB/NFS/SFTP.
|
// generically since operators may use any mix of SMB/NFS/SFTP.
|
||||||
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) +
|
const remote = (status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0);
|
||||||
' remote share' +
|
const parts = [win + ' Windows', lin + ' Linux'];
|
||||||
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) === 1 ? '' : 's')),
|
if (other > 0) parts.push(other + ' other');
|
||||||
|
parts.push(remote + ' remote share' + (remote === 1 ? '' : 's'));
|
||||||
|
return parts.join(' · ');
|
||||||
|
})()),
|
||||||
])),
|
])),
|
||||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||||
el('div', {class: 'label'}, 'Uptime'),
|
el('div', {class: 'label'}, 'Uptime'),
|
||||||
@@ -2280,7 +2302,9 @@
|
|||||||
// own self-contained <form>; when SSO is enabled, a distinct
|
// own self-contained <form>; when SSO is enabled, a distinct
|
||||||
// "Sign in with …" button sits below a divider — the credential
|
// "Sign in with …" button sits below a divider — the credential
|
||||||
// fields no longer double as the SSO trigger.
|
// fields no longer double as the SSO trigger.
|
||||||
const ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata);
|
// `enabled` from /api/me already means "usable" (enabled AND a metadata
|
||||||
|
// source is configured), so the button only shows when SSO will work.
|
||||||
|
const ssoLive = !!(ssoConfig && ssoConfig.enabled);
|
||||||
const ssoBlock = ssoLive
|
const ssoBlock = ssoLive
|
||||||
? el('div', {class:'sso-block'}, [
|
? el('div', {class:'sso-block'}, [
|
||||||
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
|
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
|
||||||
@@ -2525,10 +2549,12 @@
|
|||||||
])));
|
])));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Preload the SSO config so the login card can offer the operator
|
// v0.5.9: the login card's "Sign in with …" button keys off the SSO
|
||||||
// an "Sign in with X" button when configured. Failure is harmless.
|
// descriptor that /api/me now carries (public, non-sensitive: enabled
|
||||||
try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); }
|
// + idp_name + idp_logo_url). It's available signed in or out, so the
|
||||||
catch { ssoConfig = null; }
|
// button is static — it no longer relied on the auth-gated /api/sso,
|
||||||
|
// which 401s pre-auth and made the button vanish on fresh login loads.
|
||||||
|
ssoConfig = me.sso || null;
|
||||||
|
|
||||||
if (me.setup_required) {
|
if (me.setup_required) {
|
||||||
showAuthScreen('setup');
|
showAuthScreen('setup');
|
||||||
|
|||||||
Reference in New Issue
Block a user