Compare commits

...
5 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 7adf5e2918 v0.5.2: FleetDM login split, 3-slot branding, unattended installs
Authentication / login:
- Separate the local username/password form from the SSO "Sign in with …"
  button (FleetDM-style divider + optional IdP logo); credential fields no
  longer double as the SSO trigger. Settings → SSO copy now says SAML is live.

Branding — three slots (light / dark / client) on one row:
- Light/Dark feed the top-left mark + sign-in page by active theme (with
  cross-theme fallback; theme toggle swaps the logo live). Client feeds the
  PXE boot-menu background. Favicon pinned to the bundled mark via a new
  /assets/favicon.svg endpoint. Legacy single logo migrates to dark + client.
- BrandingStore refactored to per-slot storage; /api/branding/logo/:slot.

Unattended installs (Storage → Advanced):
- New UnattendedStore (iso-store) + /api/unattended upload/list/delete and a
  public templated serve at /unattended/:id (+ NoCloud seed dir for
  autoinstall). Accepts .ks/.cfg/.seed/.yaml/.yml/.xml/user-data; classified
  on upload; stored in its own unattended/ dir, never the ISO listing/menu.
- {{HOSTNAME}}/{{IP}}/{{MAC}} substituted per host at serve time.

Host pins + Queue profiles:
- HostBinding + QueueEntry carry an optional DeployProfile (auto_hostname /
  auto_ip / unattended_file). Hosts pin form + a per-device Queue "Profile"
  button collect them. On boot, a matched MAC has the right kernel arg
  injected (inst.ks= / preseed url= / autoinstall ds=nocloud-net) and the
  hostname/IP templated into the served answer file. DHCP stays proxy-only.

Storage:
- Remote shares default protocol is now NFS; updated descriptive copy.

235 tests green, clippy clean. Still a single static musl binary, pure Rust.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-31 16:11:04 -04:00
Miles WardandClaude Opus 4.8 cbcd63bb14 feat(saml): wire SAML 2.0 SSO end-to-end (pure-Rust) + Settings/Storage UI consolidation (v0.5.1)
SAML SSO (the config was storage-only since v0.4.5; now it logs you in):
- New openpxe-core::saml — pure-Rust SP built on bergshamra (XML-DSig +
  exclusive c14n via RustCrypto, no OpenSSL/xmlsec/libxml2). The static
  musl binary stays C-free; samael was rejected for hard-requiring OpenSSL.
  * metadata.rs   — parse IdP EntityDescriptor (SSO URLs + signing certs),
                    build our SP metadata.
  * authn_request.rs — build + HTTP-Redirect-encode AuthnRequests.
  * response.rs   — verify the signature against the pinned IdP cert
                    (trusted_keys_only + strict_verification for XSW),
                    then enforce Status/Destination/Audience/time-bounds/
                    signature-scope. Stateless; returns the IDs the HTTP
                    layer needs.
- http-api saml_routes: GET /api/sso/login (302 to IdP), POST /api/sso/acs
  (verify -> InResponseTo correlation / IdP-initiated gating / assertion
  replay guard -> mint operator session -> 302), GET /api/sso/metadata.
  Added to the pre-auth allowlist; /api/sso config stays gated.
- SsoConfig gains entity_id (SP Entity ID, defaults to public base URL)
  and allow_idp_initiated (default off), mirroring FleetDM.
- Access model: any IdP-authenticated, cryptographically-verified user gets
  an operator session (single-tier; local admin remains the fallback owner).
- Login page: the "Sign in with <IdP>" button now drives the real flow and
  surfaces sso_error redirects.

UI consolidation:
- Removed the Advanced sidebar tab; folded its webhook-notifications +
  API-reference cards into a collapsible "Advanced" disclosure at the
  bottom of Settings.
- Merged the Storage tab's separate SMB and NFS cards into one "Remote
  shares" card with a protocol dropdown and a unified, protocol-badged
  table. No backend changes — same /api/smb-shares + /api/nfs-shares.

Tests: 17 SAML core tests (accept + reject tampered/unsigned/wrong-key/
wrong-audience/expired/future/wrong-issuer/non-success) and 6 ACS
integration tests (happy path, IdP-initiated gating, SP correlation,
replay, garbage). Full workspace: 206 tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-31 00:50:28 -04:00
Miles WardandClaude Opus 4.8 252b557b9c docs: v0.5.1 design spec — SAML SSO wiring + Settings/Storage UI consolidation
Pure-Rust SAML SP (bergshamra), Advanced tab folded into Settings,
SMB+NFS merged into a Remote shares card with a protocol dropdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-31 00:10:40 -04:00
Miles WardandClaude Opus 4.8 f9df3f8bd8 v0.5.0: fix update-check repository URL (inherit workspace repository)
The About-tab "check for updates" returned "repository URL not
configured at build time" because the http-api crate didn't inherit the
workspace `repository` field, leaving CARGO_PKG_REPOSITORY empty. Add
`repository.workspace = true` so the Gitea releases API URL derives
correctly, and strengthen the unit test to assert the URL is present.

Caught by the v0.5.0 container smoke test before publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-29 15:05:17 -04:00
Miles WardandClaude Opus 4.8 fc99973ac3 v0.5.0: Wake-on-LAN, webhook notifications, Advanced tab, login logo, update check
Closes the v0.4.x chapter — NFS works end to end. Five additions:

## Wake-on-LAN (Hosts → Bound hosts)
- New core::wol module: parse any MAC form, build the 102-byte magic
  packet, broadcast it. No special capability needed (ephemeral source
  port; SO_BROADCAST). Sends to the limited broadcast (255.255.255.255)
  AND the server's own subnet broadcast (computed from advertised IP +
  detected mask) so it reaches the right VLAN.
- POST /api/hosts/:mac/wol — only fires for *bound* MACs (404 otherwise)
  so it's not an open packet sprayer.
- Bound-hosts table grows a "Wake" button with inline Waking…/Sent ✓
  state.

## Webhook notifications (Advanced tab)
- core::notify: NotifyConfig + NotifyStore (notify.json), one provider
  at a time — Slack / Discord / Teams (incoming-webhook JSON) or SMTP.
  SMTP password is persisted but redacted on GET behind a __keep__
  sentinel the UI round-trips so the secret never leaves the box.
- http-api::notify: delivery — reqwest POST for chat (provider-shaped
  bodies), lettre for SMTP (rustls, STARTTLS/implicit TLS, no plaintext).
  10s timeout; every send is best-effort.
- GET/PUT /api/notify, POST /api/notify/test.
- Fired fire-and-forget on the canonical "machine is imaging" boot event
  and on WoL — never blocks the boot path.

## UI: Advanced tab
- New nav item. Holds the webhook config card and the API reference
  block (relocated from the bottom of Settings).

## UI: login/setup logo (FleetDM treatment)
- /api/me now returns has_custom_logo + logo_rev (public bootstrap).
  The login, setup, and connection-error cards render the uploaded logo
  full-width with the "OpenPXE" wordmark dropped — matching the sidebar.

## About: update check + licenses
- "Check for updates" button → GET /api/updates/check queries the Gitea
  releases API (derived from CARGO_PKG_REPOSITORY) and compares to the
  running version. Strictly on-demand — no background polling, keeps the
  air-gapped promise.
- License card documents the MIT OR Apache-2.0 dual license with links,
  plus a note on bundled components (iPXE GPLv2/UBDL, samba, wimtools).

Deps: lettre (SMTP, rustls) + reqwest gains the json feature. Both
rustls so the static musl binary stays OpenSSL-free.

Tests: 179 passing (+notify round-trip/redaction, webhook validation,
WoL-unbound-404, WoL packet loopback, version-compare). clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-29 14:34:20 -04:00
33 changed files with 7975 additions and 601 deletions
Generated
+2025 -36
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.4.69" version = "0.5.2"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -35,7 +35,7 @@ axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] } tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.4" hyper = "1.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
mime = "0.3" mime = "0.3"
mime_guess = "2.0" mime_guess = "2.0"
@@ -66,6 +66,25 @@ rust-embed = { version = "8.5", features = ["include-exclude"] }
nfs3_client = { version = "0.9", features = ["tokio"] } nfs3_client = { version = "0.9", features = ["tokio"] }
nfs3_types = "0.5" nfs3_types = "0.5"
# v0.5.0: SMTP for webhook notifications (Slack/Teams/Discord go over
# plain HTTP via reqwest; email needs a real SMTP client). rustls TLS
# to match reqwest and stay musl-static-friendly — no OpenSSL.
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
# v0.5.1: pure-Rust SAML 2.0 Service Provider. bergshamra does XML-DSig
# verification + exclusive c14n with RustCrypto (no OpenSSL/xmlsec/libxml2
# C deps), so the static musl binary stays OpenSSL-free — samael was
# rejected precisely because it hard-requires OpenSSL. We build the thin
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
bergshamra = "0.4"
roxmltree = "0.21"
quick-xml = "0.40"
x509-parser = "0.18"
# flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the
# zlib/zlib-ng C backends, which would break the musl-static build.
flate2 = "1.1"
base64 = "0.22"
openpxe-core = { path = "crates/core" } openpxe-core = { path = "crates/core" }
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" } openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
openpxe-tftp = { path = "crates/tftp" } openpxe-tftp = { path = "crates/tftp" }
+14
View File
@@ -25,5 +25,19 @@ tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
# for per-ISO boot passwords; just re-exported here. # for per-ISO boot passwords; just re-exported here.
bcrypt.workspace = true bcrypt.workspace = true
# v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive
# c14n (no OpenSSL/C). roxmltree/quick-xml parse + build SAML XML;
# x509-parser pulls the IdP signing cert out of metadata; flate2+base64
# encode the HTTP-Redirect binding's SAMLRequest.
bergshamra.workspace = true
roxmltree.workspace = true
quick-xml.workspace = true
x509-parser.workspace = true
flate2.workspace = true
base64.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
# v0.5.1: generate a throwaway self-signed signing cert/key so SAML
# verification tests can produce genuinely signed SAMLResponses.
rcgen = "0.13"
+434 -144
View File
@@ -1,11 +1,23 @@
//! Operator-controlled branding overrides. //! Operator-controlled branding overrides.
//! //!
//! The browser tab's logo (`/assets/logo.svg`) defaults to the bundled //! v0.5.2 splits the single brand mark into **three independent slots**,
//! rainbow-horizon mark. Operators who deploy OpenPXE behind their own //! FleetDM-style:
//! branding can upload a replacement that lives at //!
//! `<work_dir>/branding/logo.<ext>` and is served in preference to the //! * `light` — shown in the WebUI top-left and on the form-login page
//! bundled SVG when present. Borrowed-from-FleetDM: tenant chrome, same //! when the active theme is light.
//! product. //! * `dark` — same surfaces, when the active theme is dark.
//! * `client` — the raster painted above the iPXE boot menu entries
//! (`/branding/pxe-logo`), i.e. what a PXE client sees on the screen.
//!
//! Each slot lives at `<work_dir>/branding/logo-<slot>.<ext>` and is
//! served in preference to the bundled rainbow-horizon mark when present.
//! Borrowed-from-FleetDM: tenant chrome, same product.
//!
//! Legacy continuity: a pre-v0.5.2 single `logo.<ext>` (recorded under
//! the old `logo_filename`/`logo_mime` keys) is migrated on first load
//! into both the `dark` and `client` slots — that preserves the previous
//! behaviour (one mark fed both the dark WebUI and the PXE screen) until
//! the operator uploads dedicated variants.
//! //!
//! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is //! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is
//! authoritative for the current process, disk is the source of truth on //! authoritative for the current process, disk is the source of truth on
@@ -35,27 +47,104 @@ pub const ALLOWED_LOGO_MIMES: &[&str] = &[
/// puts a clear bound on memory + serialization cost. /// puts a clear bound on memory + serialization cost.
pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024; pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024;
/// Which branded surface a logo upload targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogoSlot {
/// WebUI + form-login page, light theme.
Light,
/// WebUI + form-login page, dark theme.
Dark,
/// iPXE boot-menu background seen by PXE clients.
Client,
}
impl LogoSlot {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
LogoSlot::Light => "light",
LogoSlot::Dark => "dark",
LogoSlot::Client => "client",
}
}
/// Parse a slot name from the URL path segment. Case-insensitive.
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"light" => Some(LogoSlot::Light),
"dark" => Some(LogoSlot::Dark),
"client" => Some(LogoSlot::Client),
_ => None,
}
}
}
/// One brand-mark slot: a filename (relative to the branding dir) plus
/// the MIME we cached at upload time so the HTTP layer can set the
/// Content-Type without re-sniffing.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Slot {
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mime: Option<String>,
}
impl Slot {
fn clear_file(&mut self, dir: &Path) {
if let Some(name) = self.filename.take() {
let _ = std::fs::remove_file(dir.join(name));
}
self.mime = None;
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner { struct Inner {
/// File name (relative to the branding dir) for the active logo, if #[serde(default)]
/// any. Always under `<work_dir>/branding/`; never an absolute path light: Slot,
/// from the operator. #[serde(default)]
logo_filename: Option<String>, dark: Slot,
/// MIME of the active logo, mirroring `logo_filename`. Cached here #[serde(default)]
/// so the HTTP layer can set Content-Type without re-sniffing. client: Slot,
logo_mime: Option<String>, /// Monotonic counter bumped on every set/clear (any slot). Surfaces
/// Monotonic counter bumped on every set/clear. Surfaces as a /// as a cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser
/// cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser /// fetches the new bytes the moment the operator swaps a logo — the
/// fetches the new bytes the moment the operator swaps the logo — /// app version alone can't do this since it doesn't change on upload.
/// the app version alone can't do this since it doesn't change on /// Persisted so the token stays stable across restarts and keeps
/// upload. Persisted so the token stays stable across restarts and /// climbing across multiple swaps.
/// keeps climbing across multiple swaps.
#[serde(default)] #[serde(default)]
rev: u64, rev: u64,
// ── Legacy (pre-v0.5.2) single-logo keys ──────────────────────────
// Read on load for one-way migration into `dark` + `client`, then
// dropped from the persisted form (skip_serializing_if).
#[serde(default, skip_serializing_if = "Option::is_none")]
logo_filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
logo_mime: Option<String>,
}
impl Inner {
fn slot(&self, slot: LogoSlot) -> &Slot {
match slot {
LogoSlot::Light => &self.light,
LogoSlot::Dark => &self.dark,
LogoSlot::Client => &self.client,
}
}
fn slot_mut(&mut self, slot: LogoSlot) -> &mut Slot {
match slot {
LogoSlot::Light => &mut self.light,
LogoSlot::Dark => &mut self.dark,
LogoSlot::Client => &mut self.client,
}
}
} }
/// In-memory + on-disk override registry. Cheap to clone; locks are /// In-memory + on-disk override registry. Cheap to clone; locks are
/// brief. The `branding.json` cache lives alongside the active asset /// brief. The `branding.json` cache lives alongside the active assets
/// inside `<work_dir>/branding/`. /// inside `<work_dir>/branding/`.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BrandingStore { pub struct BrandingStore {
@@ -67,7 +156,8 @@ pub struct BrandingStore {
impl BrandingStore { impl BrandingStore {
/// Load (or initialise empty) from `<work_dir>/branding/`. Tolerates /// Load (or initialise empty) from `<work_dir>/branding/`. Tolerates
/// missing directories, partial state, and corrupt JSON — a bad /// missing directories, partial state, and corrupt JSON — a bad
/// cache should never block PXE for the network. /// cache should never block PXE for the network. Migrates a legacy
/// single-logo file into the dark + client slots.
#[must_use] #[must_use]
pub fn load_or_default(work_dir: &Path) -> Self { pub fn load_or_default(work_dir: &Path) -> Self {
let dir = work_dir.join("branding"); let dir = work_dir.join("branding");
@@ -75,25 +165,7 @@ impl BrandingStore {
let mut inner = Inner::default(); let mut inner = Inner::default();
if let Ok(text) = std::fs::read_to_string(&path) { if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<Inner>(&text) { match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => { Ok(parsed) => inner = parsed,
// Sanity: if the JSON says we have a logo but the
// file is gone, clear the in-memory pointer so
// /assets/logo.svg falls back to the bundled SVG
// rather than 500ing on a missing file.
if let Some(name) = parsed.logo_filename.as_deref() {
if dir.join(name).is_file() {
inner = parsed;
} else {
tracing::warn!(
target: "openpxe::branding",
file = %name,
"branding.json points at missing file; clearing"
);
}
} else {
inner = parsed;
}
}
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
target: "openpxe::branding", target: "openpxe::branding",
@@ -102,102 +174,242 @@ impl BrandingStore {
} }
} }
} }
Self { let store = Self {
dir: Arc::new(dir), dir: Arc::new(dir),
inner: Arc::new(RwLock::new(inner)), inner: Arc::new(RwLock::new(inner)),
} };
store.migrate_legacy();
store.prune_missing();
store
} }
/// Absolute path to the active logo, if one is set and present on /// One-way migration: a pre-v0.5.2 `logo.<ext>` becomes the dark +
/// disk. `None` means the HTTP layer should serve the bundled SVG. /// client slots (the old single mark fed both the dark WebUI and the
#[must_use] /// PXE screen). Best-effort; failures leave the legacy file in place
pub fn logo_path(&self) -> Option<PathBuf> { /// rather than blocking startup.
fn migrate_legacy(&self) {
let (legacy_name, legacy_mime) = {
let g = self.inner.read(); let g = self.inner.read();
g.logo_filename.as_deref().map(|n| self.dir.join(n)) (g.logo_filename.clone(), g.logo_mime.clone())
};
let Some(name) = legacy_name else { return };
let src = self.dir.join(&name);
if !src.is_file() {
// Legacy pointer is stale — just drop it.
let mut g = self.inner.write();
g.logo_filename = None;
g.logo_mime = None;
drop(g);
self.persist();
return;
} }
let mime = legacy_mime.unwrap_or_else(|| "image/svg+xml".to_string());
/// MIME of the active logo, if any. The HTTP layer pairs this with let ext = ext_for_mime(&mime).unwrap_or("bin");
/// the bytes returned by [`Self::logo_path`]. if let Ok(bytes) = std::fs::read(&src) {
#[must_use] // Seed dark + client only when those slots are still empty so
pub fn logo_mime(&self) -> Option<String> { // a re-run (or a manual edit) never clobbers operator intent.
self.inner.read().logo_mime.clone() let needs_dark = self.inner.read().dark.filename.is_none();
let needs_client = self.inner.read().client.filename.is_none();
if needs_dark {
let _ = self.write_slot(LogoSlot::Dark, &mime, ext, &bytes);
} }
if needs_client {
/// Replace the active logo. Returns the chosen on-disk filename so let _ = self.write_slot(LogoSlot::Client, &mime, ext, &bytes);
/// the caller can echo it back in the API response. Old logos are
/// removed best-effort.
pub fn set_logo(&self, mime: &str, ext: &str, bytes: &[u8]) -> std::io::Result<String> {
std::fs::create_dir_all(self.dir.as_path())?;
// Single canonical filename per upload — overwriting the old one
// (after clearing it) keeps the directory tidy and avoids any
// path-traversal concern: the operator never supplies the name.
let safe_ext = sanitize_ext(ext);
let filename = format!("logo.{safe_ext}");
let final_path = self.dir.join(&filename);
// Atomic write: tmp -> rename. Guarantees the file is either
// entirely the old logo or entirely the new one.
let tmp = final_path.with_extension(format!("{safe_ext}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &final_path)?;
// Clean up any sibling logo.<otherext> so there's exactly one
// canonical file at any time.
if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) {
for e in entries.flatten() {
let p = e.path();
let name = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if name.starts_with("logo.") && name != filename {
let _ = std::fs::remove_file(&p);
} }
} }
} let _ = std::fs::remove_file(&src);
{ {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.logo_filename = Some(filename.clone()); g.logo_filename = None;
g.logo_mime = Some(mime.to_string()); g.logo_mime = None;
g.rev = g.rev.wrapping_add(1);
} }
self.persist(); self.persist();
tracing::info!( tracing::info!(
target: "openpxe::branding", target: "openpxe::branding",
file = %filename, mime = %mime, size = bytes.len(), "migrated legacy single logo into dark + client slots"
);
}
/// Drop in-memory slot pointers whose backing file vanished from disk
/// so the HTTP layer falls back to the bundled mark instead of 500ing.
fn prune_missing(&self) {
let mut changed = false;
{
let mut g = self.inner.write();
for slot in [LogoSlot::Light, LogoSlot::Dark, LogoSlot::Client] {
let present = g
.slot(slot)
.filename
.as_deref()
.is_some_and(|n| self.dir.join(n).is_file());
if !present && g.slot(slot).filename.is_some() {
g.slot_mut(slot).filename = None;
g.slot_mut(slot).mime = None;
changed = true;
}
}
}
if changed {
self.persist();
}
}
/// Absolute path to the logo for `slot`, if set and present on disk.
#[must_use]
pub fn slot_path(&self, slot: LogoSlot) -> Option<PathBuf> {
let g = self.inner.read();
g.slot(slot).filename.as_deref().map(|n| self.dir.join(n))
}
/// MIME of the logo for `slot`, if any.
#[must_use]
pub fn slot_mime(&self, slot: LogoSlot) -> Option<String> {
self.inner.read().slot(slot).mime.clone()
}
/// Resolve the WebUI logo for a theme, with fallback: light falls
/// back to dark and vice-versa, so a single uploaded variant still
/// shows on both themes. Returns `(path, mime)` or `None` (→ bundled).
#[must_use]
pub fn web_logo(&self, theme_is_light: bool) -> Option<(PathBuf, String)> {
let (primary, secondary) = if theme_is_light {
(LogoSlot::Light, LogoSlot::Dark)
} else {
(LogoSlot::Dark, LogoSlot::Light)
};
let g = self.inner.read();
let chosen = if g.slot(primary).filename.is_some() {
primary
} else {
secondary
};
let s = g.slot(chosen);
s.filename.as_deref().map(|n| {
(
self.dir.join(n),
s.mime
.clone()
.unwrap_or_else(|| "image/svg+xml".to_string()),
)
})
}
/// Resolve the PXE client logo (no theme fallback — the PXE screen
/// has a single mark). Returns `(path, mime)` or `None` (→ default
/// composed background).
#[must_use]
pub fn client_logo(&self) -> Option<(PathBuf, String)> {
let g = self.inner.read();
let s = &g.client;
s.filename.as_deref().map(|n| {
(
self.dir.join(n),
s.mime
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string()),
)
})
}
/// Replace the logo for `slot`. Returns the chosen on-disk filename so
/// the caller can echo it back in the API response.
pub fn set_logo(
&self,
slot: LogoSlot,
mime: &str,
ext: &str,
bytes: &[u8],
) -> std::io::Result<String> {
let filename = self.write_slot(slot, mime, ext, bytes)?;
self.persist();
tracing::info!(
target: "openpxe::branding",
slot = slot.as_str(), file = %filename, mime = %mime, size = bytes.len(),
"custom logo installed" "custom logo installed"
); );
Ok(filename) Ok(filename)
} }
/// Drop the override and return to the bundled SVG. /// Write the bytes for a slot and update the in-memory pointer + rev,
pub fn clear_logo(&self) -> std::io::Result<()> { /// without persisting (the caller decides when to flush). Cleans up
let removed = { /// any sibling `logo-<slot>.*` so there's exactly one file per slot.
let mut g = self.inner.write(); fn write_slot(
let removed = g.logo_filename.take(); &self,
g.logo_mime = None; slot: LogoSlot,
g.rev = g.rev.wrapping_add(1); mime: &str,
removed ext: &str,
}; bytes: &[u8],
if let Some(name) = removed { ) -> std::io::Result<String> {
let p = self.dir.join(&name); std::fs::create_dir_all(self.dir.as_path())?;
let safe_ext = sanitize_ext(ext);
let stem = format!("logo-{}", slot.as_str());
let filename = format!("{stem}.{safe_ext}");
let final_path = self.dir.join(&filename);
// Atomic write: tmp -> rename.
let tmp = final_path.with_extension(format!("{safe_ext}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &final_path)?;
// Clean up any sibling `logo-<slot>.<otherext>`.
if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) {
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if name.starts_with(&format!("{stem}.")) && name != filename {
let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(&p);
tracing::info!(target: "openpxe::branding", file = %name, "custom logo cleared"); }
}
}
let mut g = self.inner.write();
let s = g.slot_mut(slot);
s.filename = Some(filename.clone());
s.mime = Some(mime.to_string());
g.rev = g.rev.wrapping_add(1);
Ok(filename)
}
/// Drop the override for `slot` and return to the bundled / default.
pub fn clear_logo(&self, slot: LogoSlot) -> std::io::Result<()> {
{
let mut g = self.inner.write();
let dir = self.dir.as_path();
g.slot_mut(slot).clear_file(dir);
g.rev = g.rev.wrapping_add(1);
} }
self.persist(); self.persist();
tracing::info!(target: "openpxe::branding", slot = slot.as_str(), "custom logo cleared");
Ok(()) Ok(())
} }
/// Convenience: true if a custom logo is configured. Surfaces on /// True if a custom logo is configured for `slot`.
/// `/api/status` so the WebUI can show "Custom logo: yes" without
/// fetching the asset itself.
#[must_use] #[must_use]
pub fn has_logo(&self) -> bool { pub fn has_logo(&self, slot: LogoSlot) -> bool {
self.inner.read().logo_filename.is_some() self.inner.read().slot(slot).filename.is_some()
} }
/// Cache-bust token for the logo asset URL. Changes on every /// True if either WebUI theme slot has a custom logo — drives the
/// FleetDM-style full-width brand block (and the `has-custom-logo`
/// class) on the sidebar + login page.
#[must_use]
pub fn has_any_web_logo(&self) -> bool {
let g = self.inner.read();
g.light.filename.is_some() || g.dark.filename.is_some()
}
/// Presence triple `(light, dark, client)` for the `/api/me` and
/// `/api/status` bootstrap payloads.
#[must_use]
pub fn presence(&self) -> (bool, bool, bool) {
let g = self.inner.read();
(
g.light.filename.is_some(),
g.dark.filename.is_some(),
g.client.filename.is_some(),
)
}
/// Cache-bust token for the logo asset URLs. Changes on every
/// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL /// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL
/// whenever the operator swaps the brand mark. Stable otherwise. /// whenever the operator swaps a brand mark. Stable otherwise.
#[must_use] #[must_use]
pub fn logo_rev(&self) -> u64 { pub fn logo_rev(&self) -> u64 {
self.inner.read().rev self.inner.read().rev
@@ -267,47 +479,87 @@ mod tests {
fn empty_after_load_when_no_branding_dir() { fn empty_after_load_when_no_branding_dir() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo()); assert!(!b.has_logo(LogoSlot::Light));
assert!(b.logo_path().is_none()); assert!(!b.has_logo(LogoSlot::Dark));
assert!(b.logo_mime().is_none()); assert!(!b.has_logo(LogoSlot::Client));
assert!(b.web_logo(false).is_none());
assert!(b.client_logo().is_none());
assert!(!b.has_any_web_logo());
} }
#[test] #[test]
fn set_clear_round_trip_persists() { fn set_clear_round_trip_persists() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
let name = b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap(); let name = b
assert_eq!(name, "logo.png"); .set_logo(LogoSlot::Dark, "image/png", "png", b"\x89PNG\r\n\x1a\nfake")
assert!(b.has_logo()); .unwrap();
assert_eq!(b.logo_mime().as_deref(), Some("image/png")); assert_eq!(name, "logo-dark.png");
let p = b.logo_path().unwrap(); assert!(b.has_logo(LogoSlot::Dark));
assert_eq!(b.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png"));
let (p, _) = b.web_logo(false).unwrap();
assert!(p.is_file()); assert!(p.is_file());
// Re-open and confirm the override survives a restart. // Re-open and confirm the override survives a restart.
drop(b); drop(b);
let b2 = BrandingStore::load_or_default(dir.path()); let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo()); assert!(b2.has_logo(LogoSlot::Dark));
assert_eq!(b2.logo_mime().as_deref(), Some("image/png")); assert_eq!(b2.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png"));
// Clear; the file goes away and has_logo flips off. // Clear; the file goes away and has_logo flips off.
b2.clear_logo().unwrap(); b2.clear_logo(LogoSlot::Dark).unwrap();
assert!(!b2.has_logo()); assert!(!b2.has_logo(LogoSlot::Dark));
assert!(!p.exists()); assert!(!p.exists());
} }
#[test] #[test]
fn replacing_logo_removes_old_extension_sibling() { fn web_logo_falls_back_across_themes() {
// PNG then SVG; only the SVG should remain on disk.
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap(); // Only dark uploaded — light theme falls back to it.
b.set_logo("image/svg+xml", "svg", br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#).unwrap(); b.set_logo(LogoSlot::Dark, "image/png", "png", b"dark")
.unwrap();
let (p_light, _) = b.web_logo(true).expect("light falls back to dark");
assert!(p_light.ends_with("logo-dark.png"));
// Upload a distinct light — now light theme uses its own.
b.set_logo(LogoSlot::Light, "image/png", "png", b"light")
.unwrap();
let (p_light2, _) = b.web_logo(true).unwrap();
assert!(p_light2.ends_with("logo-light.png"));
// Client is independent and still unset.
assert!(b.client_logo().is_none());
}
#[test]
fn replacing_slot_removes_old_extension_sibling() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
b.set_logo(
LogoSlot::Client,
"image/png",
"png",
b"\x89PNG\r\n\x1a\nfake",
)
.unwrap();
b.set_logo(
LogoSlot::Client,
"image/svg+xml",
"svg",
br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#,
)
.unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding")) let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding"))
.unwrap() .unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect(); .collect();
assert!(entries.iter().any(|n| n == "logo.svg"), "got {entries:?}"); assert!(
assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}"); entries.iter().any(|n| n == "logo-client.svg"),
"got {entries:?}"
);
assert!(
!entries.iter().any(|n| n == "logo-client.png"),
"stale PNG left over: {entries:?}"
);
} }
#[test] #[test]
@@ -315,54 +567,92 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
assert_eq!(b.logo_rev(), 0); assert_eq!(b.logo_rev(), 0);
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap(); b.set_logo(LogoSlot::Light, "image/png", "png", b"a")
.unwrap();
assert_eq!(b.logo_rev(), 1); assert_eq!(b.logo_rev(), 1);
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake2").unwrap(); b.set_logo(LogoSlot::Dark, "image/png", "png", b"b")
.unwrap();
assert_eq!(b.logo_rev(), 2); assert_eq!(b.logo_rev(), 2);
b.clear_logo().unwrap(); b.clear_logo(LogoSlot::Light).unwrap();
assert_eq!(b.logo_rev(), 3); assert_eq!(b.logo_rev(), 3);
// Survives a restart.
drop(b); drop(b);
let b2 = BrandingStore::load_or_default(dir.path()); let b2 = BrandingStore::load_or_default(dir.path());
assert_eq!(b2.logo_rev(), 3); assert_eq!(b2.logo_rev(), 3);
} }
#[test]
fn legacy_single_logo_migrates_to_dark_and_client() {
// A pre-v0.5.2 branding.json + logo.png migrates on load.
let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap();
std::fs::write(brand_dir.join("logo.png"), b"\x89PNG\r\n\x1a\nlegacy").unwrap();
// Hand-write the old shape (logo_filename/logo_mime, no slots).
std::fs::write(
brand_dir.join("branding.json"),
br#"{"logo_filename":"logo.png","logo_mime":"image/png","rev":4}"#,
)
.unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(b.has_logo(LogoSlot::Dark), "dark seeded from legacy");
assert!(b.has_logo(LogoSlot::Client), "client seeded from legacy");
assert!(!b.has_logo(LogoSlot::Light), "light stays empty");
// The old logo.png is gone; per-slot files exist.
assert!(!brand_dir.join("logo.png").exists());
assert!(brand_dir.join("logo-dark.png").is_file());
assert!(brand_dir.join("logo-client.png").is_file());
// rev carried over from the legacy file and advanced as the two
// slots were seeded (each write bumps it), so it never regresses.
let migrated_rev = b.logo_rev();
assert!(
migrated_rev >= 4,
"rev should not regress below legacy: {migrated_rev}"
);
// And the migration is sticky across a restart (no re-migrate, no
// further rev churn).
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo(LogoSlot::Dark));
assert!(b2.has_logo(LogoSlot::Client));
assert!(!b2.has_logo(LogoSlot::Light));
assert_eq!(b2.logo_rev(), migrated_rev, "restart must not re-migrate");
}
#[test] #[test]
fn sanitize_ext_strips_separators_and_path_chars() { fn sanitize_ext_strips_separators_and_path_chars() {
assert_eq!(sanitize_ext("svg"), "svg"); assert_eq!(sanitize_ext("svg"), "svg");
// Path separators and non-alphanumerics filter out, leaving just
// letters. The remaining "etcpasswd" exceeds the 5-char cap so
// it collapses to `bin` rather than producing `etcpa`.
assert_eq!(sanitize_ext("../etc/passwd"), "bin"); assert_eq!(sanitize_ext("../etc/passwd"), "bin");
// Short alphanumeric strip-through stays itself.
assert_eq!(sanitize_ext("../svg"), "svg"); assert_eq!(sanitize_ext("../svg"), "svg");
assert_eq!(sanitize_ext(""), "bin"); assert_eq!(sanitize_ext(""), "bin");
assert_eq!(sanitize_ext("PNG"), "png"); assert_eq!(sanitize_ext("PNG"), "png");
// Anything past five chars is suspicious — collapse to `bin`.
assert_eq!(sanitize_ext("svgvvvv"), "bin"); assert_eq!(sanitize_ext("svgvvvv"), "bin");
} }
#[test] #[test]
fn missing_file_referenced_by_json_resolves_to_empty() { fn missing_file_referenced_by_json_resolves_to_empty() {
// If the operator nukes the file out from under the JSON cache,
// we should silently fall back to no-override rather than
// hanging on to a bogus path.
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding"); let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap(); std::fs::create_dir_all(&brand_dir).unwrap();
// Hand-write a branding.json claiming logo.png exists. // branding.json claims a dark slot whose file doesn't exist.
let inner = Inner {
logo_filename: Some("logo.png".into()),
logo_mime: Some("image/png".into()),
rev: 0,
};
std::fs::write( std::fs::write(
brand_dir.join("branding.json"), brand_dir.join("branding.json"),
serde_json::to_vec_pretty(&inner).unwrap(), br#"{"dark":{"filename":"logo-dark.png","mime":"image/png"},"rev":1}"#,
) )
.unwrap(); .unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo(), "should fall back when referenced file is missing"); assert!(
!b.has_logo(LogoSlot::Dark),
"should fall back when referenced file is missing"
);
}
#[test]
fn slot_parse_round_trips() {
assert_eq!(LogoSlot::parse("light"), Some(LogoSlot::Light));
assert_eq!(LogoSlot::parse("DARK"), Some(LogoSlot::Dark));
assert_eq!(LogoSlot::parse(" client "), Some(LogoSlot::Client));
assert_eq!(LogoSlot::parse("nope"), None);
assert_eq!(LogoSlot::Light.as_str(), "light");
} }
#[test] #[test]
+6
View File
@@ -74,6 +74,11 @@ pub struct Paths {
/// Only used when `settings.windows_enabled = true`. Defaults to /// Only used when `settings.windows_enabled = true`. Defaults to
/// `/var/lib/openpxe/smb` in the container image. /// `/var/lib/openpxe/smb` in the container image.
pub smb_dir: PathBuf, pub smb_dir: PathBuf,
/// v0.5.2: directory holding uploaded unattended-install answer files
/// (Kickstart / Preseed / Autoinstall / Windows answer files). Kept
/// separate from `iso_dir` so answer files never appear in the ISO
/// listing or the PXE menu. Defaults to `/var/lib/openpxe/unattended`.
pub unattended_dir: PathBuf,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
@@ -109,6 +114,7 @@ impl Default for Paths {
ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"), ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"),
wimboot_path: None, wimboot_path: None,
smb_dir: PathBuf::from("/var/lib/openpxe/smb"), smb_dir: PathBuf::from("/var/lib/openpxe/smb"),
unattended_dir: PathBuf::from("/var/lib/openpxe/unattended"),
} }
} }
} }
+67 -7
View File
@@ -21,6 +21,8 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::profile::DeployProfile;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostBinding { pub struct HostBinding {
/// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The /// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The
@@ -35,6 +37,11 @@ pub struct HostBinding {
/// `"rack-3 spine"`). Empty if unset. /// `"rack-3 spine"`). Empty if unset.
#[serde(default)] #[serde(default)]
pub label: String, pub label: String,
/// v0.5.2: optional unattended-install hints (auto hostname / IP /
/// answer-file id). Flattened into the binding JSON so pre-v0.5.2
/// `hosts.json` files (which lack these keys) still deserialize.
#[serde(default, flatten)]
pub profile: DeployProfile,
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime, pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
@@ -94,20 +101,31 @@ impl HostBindings {
} }
/// Insert or update. Returns the resulting binding (with timestamps). /// Insert or update. Returns the resulting binding (with timestamps).
pub fn upsert(&self, mac: &str, target: &str, label: &str) -> HostBinding { /// The `profile` carries optional unattended-install hints (v0.5.2);
/// pass `DeployProfile::default()` for a plain pin.
pub fn upsert(
&self,
mac: &str,
target: &str,
label: &str,
profile: DeployProfile,
) -> HostBinding {
let key = normalize_mac(mac); let key = normalize_mac(mac);
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let profile = profile.normalized();
let binding = { let binding = {
let mut g = self.inner.write(); let mut g = self.inner.write();
let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding { let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding {
mac: key.clone(), mac: key.clone(),
target: target.to_string(), target: target.to_string(),
label: label.to_string(), label: label.to_string(),
profile: profile.clone(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
entry.target = target.to_string(); entry.target = target.to_string();
entry.label = label.to_string(); entry.label = label.to_string();
entry.profile = profile.clone();
entry.updated_at = now; entry.updated_at = now;
entry.clone() entry.clone()
}; };
@@ -179,6 +197,10 @@ mod tests {
use super::*; use super::*;
use tempfile::tempdir; use tempfile::tempdir;
fn np() -> DeployProfile {
DeployProfile::default()
}
#[test] #[test]
fn normalize_handles_case_and_dashes() { fn normalize_handles_case_and_dashes() {
assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff"); assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff");
@@ -191,7 +213,12 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
assert!(h.is_empty()); assert!(h.is_empty());
h.upsert("AA:BB:CC:00:00:01", "ubuntu-24-04-linux", "rack-3 spine"); h.upsert(
"AA:BB:CC:00:00:01",
"ubuntu-24-04-linux",
"rack-3 spine",
np(),
);
let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup"); let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup");
assert_eq!(found.target, "ubuntu-24-04-linux"); assert_eq!(found.target, "ubuntu-24-04-linux");
assert_eq!(found.label, "rack-3 spine"); assert_eq!(found.label, "rack-3 spine");
@@ -202,8 +229,8 @@ mod tests {
fn upsert_replaces_existing_target() { fn upsert_replaces_existing_target() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "old-target", "label1"); h.upsert("aa:bb:cc:00:00:01", "old-target", "label1", np());
h.upsert("aa:bb:cc:00:00:01", "new-target", "label2"); h.upsert("aa:bb:cc:00:00:01", "new-target", "label2", np());
assert_eq!(h.len(), 1); assert_eq!(h.len(), 1);
let b = h.lookup("aa:bb:cc:00:00:01").unwrap(); let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "new-target"); assert_eq!(b.target, "new-target");
@@ -214,7 +241,7 @@ mod tests {
fn remove_works_and_reports_outcome() { fn remove_works_and_reports_outcome() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "x", ""); h.upsert("aa:bb:cc:00:00:01", "x", "", np());
assert!(h.remove("AA:BB:CC:00:00:01")); assert!(h.remove("AA:BB:CC:00:00:01"));
assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone
assert!(h.is_empty()); assert!(h.is_empty());
@@ -224,11 +251,44 @@ mod tests {
fn round_trip_persists_to_disk() { fn round_trip_persists_to_disk() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3"); h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3", np());
h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop"); h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop", np());
drop(h); drop(h);
let h2 = HostBindings::load_or_default(dir.path()); let h2 = HostBindings::load_or_default(dir.path());
assert_eq!(h2.len(), 2); assert_eq!(h2.len(), 2);
assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local"); assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local");
} }
#[test]
fn profile_round_trips_to_disk() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
let prof = DeployProfile {
auto_hostname: Some("node-7".into()),
auto_ip: Some("10.0.0.7".into()),
unattended_file: Some("ubuntu-ks".into()),
};
h.upsert("aa:bb:cc:00:00:09", "ubuntu-linux", "lab", prof);
drop(h);
let h2 = HostBindings::load_or_default(dir.path());
let b = h2.lookup("aa:bb:cc:00:00:09").unwrap();
assert_eq!(b.profile.auto_hostname.as_deref(), Some("node-7"));
assert_eq!(b.profile.auto_ip.as_deref(), Some("10.0.0.7"));
assert_eq!(b.profile.unattended_file.as_deref(), Some("ubuntu-ks"));
}
#[test]
fn legacy_hosts_json_without_profile_still_loads() {
// A pre-v0.5.2 hosts.json has no profile keys at all.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("hosts.json"),
br#"[{"mac":"aa:bb:cc:00:00:01","target":"_local","label":"old","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}]"#,
)
.unwrap();
let h = HostBindings::load_or_default(dir.path());
let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "_local");
assert!(b.profile.is_empty());
}
} }
+9 -2
View File
@@ -12,20 +12,27 @@ pub mod error;
pub mod host_bindings; pub mod host_bindings;
pub mod log_bus; pub mod log_bus;
pub mod metrics; pub mod metrics;
pub mod notify;
pub mod profile;
pub mod queue; pub mod queue;
pub mod saml;
pub mod settings; pub mod settings;
pub mod sso; pub mod sso;
pub mod wol;
pub use arch::{ClientArch, FirmwareClass}; pub use arch::{ClientArch, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore}; pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use boot_log::{BootEvent, BootLog}; pub use boot_log::{BootEvent, BootLog};
pub use branding::{ext_for_mime, BrandingStore, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES}; pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use sso::{SsoConfig, SsoStore};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings}; pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine}; pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics}; pub use metrics::{HttpRoute, Metrics};
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
pub use profile::DeployProfile;
pub use queue::{DeploymentQueue, QueueEntry}; pub use queue::{DeploymentQueue, QueueEntry};
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};
+362
View File
@@ -0,0 +1,362 @@
//! Webhook / email notification configuration.
//!
//! v0.5.0: OpenPXE can ping a chat webhook or send an email when
//! something noteworthy happens (a machine PXE-booted an image, a
//! deployment was assigned, a WoL was sent). One active provider at a
//! time, chosen by `kind` — dead-simple for an L1 tech: pick Slack,
//! paste the incoming-webhook URL, done.
//!
//! This module owns only the *configuration* (validation + persistence
//! to `<work_dir>/notify.json`). The actual sending — HTTP POST for the
//! chat providers, SMTP for email — lives in the http-api crate, which
//! already carries an HTTP client and the SMTP dependency. Keeping the
//! network I/O out of `core` matches how `BrandingStore`/`SsoStore`
//! stay pure config stores.
//!
//! Secrets note: the SMTP password is persisted in `notify.json`
//! alongside the rest of the config (0644 like the other state files).
//! It is never echoed back through the API — the snapshot used for the
//! GET response blanks it (see `Self::redacted`).
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{Error, Result};
const MAX_URL_LEN: usize = 2048;
const MAX_FIELD_LEN: usize = 512;
/// Which notification transport is active.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotifyKind {
/// Slack incoming webhook (`{ "text": ... }`).
#[default]
Slack,
/// Discord webhook (`{ "content": ... }`).
Discord,
/// Microsoft Teams incoming webhook (legacy MessageCard JSON).
Teams,
/// Email via SMTP.
Smtp,
}
impl NotifyKind {
/// True when this kind drives a chat webhook (POST a JSON body to a
/// single URL) rather than SMTP.
#[must_use]
pub fn is_webhook(self) -> bool {
matches!(self, Self::Slack | Self::Discord | Self::Teams)
}
}
/// Operator-configurable notification settings. Single provider active
/// at a time; the inactive fields are kept so switching providers
/// doesn't wipe the other one's values.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NotifyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub kind: NotifyKind,
/// Incoming-webhook URL for Slack / Discord / Teams.
#[serde(default)]
pub webhook_url: String,
// ── SMTP fields (used when kind == Smtp) ──
#[serde(default)]
pub smtp_host: String,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
#[serde(default)]
pub smtp_username: String,
#[serde(default)]
pub smtp_password: String,
/// `From:` address. Falls back to `smtp_username` when blank.
#[serde(default)]
pub smtp_from: String,
/// `To:` address (single recipient — keep it simple).
#[serde(default)]
pub smtp_to: String,
/// Use implicit TLS (port 465). When false we use STARTTLS on the
/// configured port (587 typical). Either way the connection is
/// encrypted — we never offer plaintext SMTP.
#[serde(default)]
pub smtp_implicit_tls: bool,
}
fn default_smtp_port() -> u16 {
587
}
impl NotifyConfig {
/// True when enabled and the active provider has the fields it
/// needs to actually send.
#[must_use]
pub fn is_usable(&self) -> bool {
if !self.enabled {
return false;
}
if self.kind.is_webhook() {
!self.webhook_url.trim().is_empty()
} else {
!self.smtp_host.trim().is_empty() && !self.smtp_to.trim().is_empty()
}
}
/// A copy safe to return over the API: the SMTP password is blanked
/// (replaced with a non-empty sentinel only when one is set, so the
/// UI can show "configured" without leaking it).
#[must_use]
pub fn redacted(&self) -> NotifyConfig {
let mut c = self.clone();
if !c.smtp_password.is_empty() {
c.smtp_password = SECRET_SENTINEL.to_string();
}
c
}
}
/// Returned by the API in place of a stored password. When the UI PUTs
/// this value back unchanged we keep the existing password rather than
/// overwriting it with the sentinel.
pub const SECRET_SENTINEL: &str = "__keep__";
/// In-memory + on-disk notification config registry.
#[derive(Debug, Clone)]
pub struct NotifyStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<NotifyConfig>>,
}
impl NotifyStore {
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("notify.json");
let cfg = match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str::<NotifyConfig>(&text).unwrap_or_else(|e| {
tracing::warn!(
target: "openpxe::notify",
"notify.json unreadable ({e}); starting with defaults"
);
NotifyConfig::default()
}),
Err(_) => NotifyConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(cfg)),
}
}
#[must_use]
pub fn snapshot(&self) -> NotifyConfig {
self.inner.read().clone()
}
/// Replace the whole config. `incoming.smtp_password == SECRET_SENTINEL`
/// is treated as "keep the existing password" so the UI never has to
/// round-trip the real secret.
pub fn replace(&self, mut incoming: NotifyConfig) -> Result<NotifyConfig> {
incoming.webhook_url = incoming.webhook_url.trim().to_string();
incoming.smtp_host = incoming.smtp_host.trim().to_string();
incoming.smtp_username = incoming.smtp_username.trim().to_string();
incoming.smtp_from = incoming.smtp_from.trim().to_string();
incoming.smtp_to = incoming.smtp_to.trim().to_string();
// Preserve the stored password when the UI sends the sentinel.
if incoming.smtp_password == SECRET_SENTINEL {
incoming
.smtp_password
.clone_from(&self.inner.read().smtp_password);
}
// Length caps.
if incoming.webhook_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"webhook URL exceeds {MAX_URL_LEN}-char cap"
)));
}
for (name, v) in [
("smtp_host", &incoming.smtp_host),
("smtp_username", &incoming.smtp_username),
("smtp_from", &incoming.smtp_from),
("smtp_to", &incoming.smtp_to),
] {
if v.len() > MAX_FIELD_LEN {
return Err(Error::Invalid(format!(
"{name} exceeds {MAX_FIELD_LEN}-char cap"
)));
}
}
// Validate the active provider only when enabling.
if incoming.enabled {
if incoming.kind.is_webhook() {
if incoming.webhook_url.is_empty() {
return Err(Error::Invalid(
"a webhook URL is required to enable chat notifications".into(),
));
}
if !incoming.webhook_url.starts_with("https://")
&& !incoming.webhook_url.starts_with("http://")
{
return Err(Error::Invalid(
"webhook URL must start with http:// or https://".into(),
));
}
} else {
if incoming.smtp_host.is_empty() {
return Err(Error::Invalid(
"SMTP host is required to enable email notifications".into(),
));
}
if incoming.smtp_to.is_empty() {
return Err(Error::Invalid(
"a recipient (To) is required to enable email notifications".into(),
));
}
if incoming.smtp_port == 0 {
return Err(Error::Invalid("SMTP port must be non-zero".into()));
}
}
}
{
let mut g = self.inner.write();
*g = incoming.clone();
}
self.persist();
tracing::info!(
target: "openpxe::notify",
enabled = incoming.enabled, kind = ?incoming.kind,
"notification configuration updated"
);
Ok(incoming)
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::notify", "serialize notify.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::notify", "write notify.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::notify", "rename notify.json: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn default_disabled_not_usable() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
assert!(!s.snapshot().enabled);
assert!(!s.snapshot().is_usable());
}
#[test]
fn slack_requires_url_when_enabled() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Slack,
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))));
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Slack,
webhook_url: "https://hooks.slack.com/services/XXX".into(),
..Default::default()
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn smtp_requires_host_and_recipient() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject");
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_from: "[email protected]".into(),
..Default::default()
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn password_sentinel_preserves_stored_secret() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_password: "s3cret".into(),
..Default::default()
})
.unwrap();
// Redacted snapshot hides the password behind the sentinel.
assert_eq!(s.snapshot().redacted().smtp_password, SECRET_SENTINEL);
// PUTting the sentinel back keeps the real password.
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_password: SECRET_SENTINEL.into(),
..Default::default()
})
.unwrap();
assert_eq!(s.snapshot().smtp_password, "s3cret");
}
#[test]
fn webhook_url_scheme_enforced() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Discord,
webhook_url: "ftp://example.com/hook".into(),
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Per-host deployment profile.
//!
//! v0.5.2: a small, optional bundle of "what should this machine do when
//! it images" attached to either a pinned host binding ([`crate::HostBinding`])
//! or a queued device ([`crate::QueueEntry`]). All three fields are
//! optional and independent:
//!
//! * `auto_hostname` — substituted into the served unattended answer file
//! (`{{HOSTNAME}}`) so the installer sets the machine name.
//! * `auto_ip` — substituted as `{{IP}}`. OpenPXE is a DHCP **proxy** and
//! does not hand out leases, so this is applied by the installer as a
//! static-network directive inside the answer file, not by DHCP.
//! * `unattended_file` — the id of an uploaded file in the unattended
//! store (Kickstart / Preseed / Autoinstall / Windows answer file). When
//! set, the boot chain injects the appropriate kernel argument so the
//! install runs unattended.
use serde::{Deserialize, Serialize};
/// Optional deployment hints carried on a host pin or a queue entry.
///
/// The fields are flattened into `HostBinding` / `QueueEntry` on the wire
/// (so existing JSON stays compatible via `#[serde(default)]`); this type
/// is the in-code bundle the boot chain consumes.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeployProfile {
/// Hostname to set on the imaged machine (`{{HOSTNAME}}`). Empty/None
/// leaves the installer default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_hostname: Option<String>,
/// Static IPv4/IPv6 the installer should configure (`{{IP}}`). Stored
/// as a free-form string — validated lightly at the HTTP layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_ip: Option<String>,
/// Id of an uploaded file in the unattended store. Empty/None means
/// "no unattended install — boot interactively".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unattended_file: Option<String>,
}
/// Cap on the stored hostname / IP strings — generous for any real value
/// but bounds what an operator can stuff into the JSON.
pub const MAX_PROFILE_FIELD_LEN: usize = 255;
impl DeployProfile {
/// True when nothing is set — lets call sites skip work entirely.
#[must_use]
pub fn is_empty(&self) -> bool {
self.auto_hostname.is_none() && self.auto_ip.is_none() && self.unattended_file.is_none()
}
/// True when an unattended file is selected (drives boot-chain injection).
#[must_use]
pub fn has_unattended(&self) -> bool {
self.unattended_file
.as_deref()
.is_some_and(|s| !s.trim().is_empty())
}
/// Normalise: trim every field and collapse empty strings to `None`
/// so persisted JSON never carries `""` for an unset value.
#[must_use]
pub fn normalized(mut self) -> Self {
fn clean(v: Option<String>) -> Option<String> {
v.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| s.chars().take(MAX_PROFILE_FIELD_LEN).collect())
}
self.auto_hostname = clean(self.auto_hostname);
self.auto_ip = clean(self.auto_ip);
self.unattended_file = clean(self.unattended_file);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_profile_is_empty() {
assert!(DeployProfile::default().is_empty());
assert!(!DeployProfile::default().has_unattended());
}
#[test]
fn normalize_trims_and_nulls_empty() {
let p = DeployProfile {
auto_hostname: Some(" node-7 ".into()),
auto_ip: Some(" ".into()),
unattended_file: Some(String::new()),
}
.normalized();
assert_eq!(p.auto_hostname.as_deref(), Some("node-7"));
assert_eq!(p.auto_ip, None);
assert_eq!(p.unattended_file, None);
assert!(!p.is_empty());
}
#[test]
fn has_unattended_detects_real_id() {
let p = DeployProfile {
unattended_file: Some("ubuntu-ks".into()),
..Default::default()
};
assert!(p.has_unattended());
}
#[test]
fn long_field_is_capped() {
let long = "a".repeat(1000);
let p = DeployProfile {
auto_hostname: Some(long),
..Default::default()
}
.normalized();
assert_eq!(
p.auto_hostname.as_deref().map(str::len),
Some(MAX_PROFILE_FIELD_LEN)
);
}
}
+32
View File
@@ -21,6 +21,7 @@ use time::OffsetDateTime;
use tokio::sync::Notify; use tokio::sync::Notify;
use uuid::Uuid; use uuid::Uuid;
use crate::profile::DeployProfile;
use crate::ClientArch; use crate::ClientArch;
/// Per-client queue state visible to the WebUI. /// Per-client queue state visible to the WebUI.
@@ -37,6 +38,11 @@ pub struct QueueEntry {
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub last_poll_at: OffsetDateTime, pub last_poll_at: OffsetDateTime,
pub assigned_target: Option<String>, pub assigned_target: Option<String>,
/// v0.5.2: optional per-device deployment profile set via the queue
/// "Profile" button (auto hostname / IP / unattended file). Flattened
/// so the JSON stays flat alongside the other queue fields.
#[serde(default, flatten)]
pub profile: DeployProfile,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -49,6 +55,7 @@ struct QueueEntryInner {
joined_at: OffsetDateTime, joined_at: OffsetDateTime,
last_poll_at: OffsetDateTime, last_poll_at: OffsetDateTime,
assigned_target: Option<String>, assigned_target: Option<String>,
profile: DeployProfile,
/// Broadcast primitive that wakes the long-poll as soon as an /// Broadcast primitive that wakes the long-poll as soon as an
/// assignment lands — no polling on our side, no sleep-loops. /// assignment lands — no polling on our side, no sleep-loops.
notify: Arc<Notify>, notify: Arc<Notify>,
@@ -65,6 +72,7 @@ impl QueueEntryInner {
joined_at: self.joined_at, joined_at: self.joined_at,
last_poll_at: self.last_poll_at, last_poll_at: self.last_poll_at,
assigned_target: self.assigned_target.clone(), assigned_target: self.assigned_target.clone(),
profile: self.profile.clone(),
} }
} }
} }
@@ -110,6 +118,7 @@ impl DeploymentQueue {
joined_at: now, joined_at: now,
last_poll_at: now, last_poll_at: now,
assigned_target: None, assigned_target: None,
profile: DeployProfile::default(),
notify: Arc::new(Notify::new()), notify: Arc::new(Notify::new()),
}; };
let snap = inner.snapshot(); let snap = inner.snapshot();
@@ -133,6 +142,29 @@ impl DeploymentQueue {
Some(g.snapshot()) Some(g.snapshot())
} }
/// Operator sets (or clears) the deployment profile for a queued
/// device via the WebUI "Profile" button. Returns the updated
/// snapshot, or `None` if the entry has since been released.
pub fn set_profile(&self, entry_id: &str, profile: DeployProfile) -> Option<QueueEntry> {
let mut guard = self.inner.write();
let g = guard.get_mut(entry_id)?;
g.profile = profile.normalized();
Some(g.snapshot())
}
/// Look up the deployment profile for a queued MAC, if any. Used by
/// the boot chain to inject an unattended file / template the
/// hostname + IP when an assigned device chains to its target.
#[must_use]
pub fn profile_for_mac(&self, mac: &str) -> Option<DeployProfile> {
let guard = self.inner.read();
guard
.values()
.find(|g| g.mac == mac)
.map(|g| g.profile.clone())
.filter(|p| !p.is_empty())
}
/// Operator assigns an ISO entry (boot_entry id) to one or more clients. /// Operator assigns an ISO entry (boot_entry id) to one or more clients.
/// Returns the number of queue entries that were updated. Entries not in the /// Returns the number of queue entries that were updated. Entries not in the
/// queue are silently skipped. /// queue are silently skipped.
+188
View File
@@ -0,0 +1,188 @@
//! AuthnRequest construction + HTTP-Redirect binding encoding.
//!
//! For SP-initiated login we build an `<AuthnRequest>`, then encode it for the
//! HTTP-Redirect binding: raw DEFLATE (RFC 1951) → base64 → percent-encode,
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
//! unsigned in this release (the IdP must not require client signatures).
use std::fmt::Write as _;
use std::io::Write as _;
use base64::Engine;
use flate2::write::DeflateEncoder;
use flate2::Compression;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use super::{SamlError, SpParams};
const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
const NAMEID_EMAIL: &str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
/// A built AuthnRequest, ready to redirect the browser to the IdP.
#[derive(Debug, Clone)]
pub struct AuthnRequest {
/// The request `ID` — the caller records this so the matching response's
/// `InResponseTo` can be correlated (replay/CSRF protection).
pub id: String,
/// The full IdP URL to 302 the browser to (includes `SAMLRequest` and,
/// when supplied, `RelayState`).
pub location: String,
}
/// Build an AuthnRequest targeting `idp_sso_url` and encode it for the
/// HTTP-Redirect binding. `relay_state`, if given, round-trips back to us via
/// the response (we use it to send the operator to their intended page).
pub fn build(
sp: &SpParams,
idp_sso_url: &str,
relay_state: Option<&str>,
) -> Result<AuthnRequest, SamlError> {
let id = format!("_{}", uuid::Uuid::new_v4().simple());
let issue_instant = OffsetDateTime::now_utc()
.replace_nanosecond(0)
.unwrap_or_else(|_| OffsetDateTime::now_utc())
.format(&Rfc3339)
.map_err(|e| SamlError::Timestamp(e.to_string()))?;
let xml = format!(
r#"<samlp:AuthnRequest xmlns:samlp="{NS_PROTOCOL}" xmlns:saml="{NS_ASSERTION}" ID="{id}" Version="2.0" IssueInstant="{instant}" Destination="{dest}" ProtocolBinding="{BINDING_POST}" AssertionConsumerServiceURL="{acs}"><saml:Issuer>{issuer}</saml:Issuer><samlp:NameIDPolicy Format="{NAMEID_EMAIL}" AllowCreate="true"/></samlp:AuthnRequest>"#,
instant = issue_instant,
dest = xml_escape(idp_sso_url),
acs = xml_escape(&sp.acs_url),
issuer = xml_escape(&sp.entity_id),
);
let encoded = deflate_base64(&xml)?;
let sep = if idp_sso_url.contains('?') { '&' } else { '?' };
let mut location = format!("{idp_sso_url}{sep}SAMLRequest={}", pct_encode(&encoded));
if let Some(rs) = relay_state {
location.push_str("&RelayState=");
location.push_str(&pct_encode(rs));
}
Ok(AuthnRequest { id, location })
}
/// Raw-DEFLATE then base64 — the HTTP-Redirect binding's `SAMLRequest` payload.
fn deflate_base64(xml: &str) -> Result<String, SamlError> {
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
enc.write_all(xml.as_bytes())
.and_then(|()| enc.try_finish())
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
let compressed = enc
.finish()
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
Ok(base64::engine::general_purpose::STANDARD.encode(compressed))
}
/// Percent-encode a query-string component (RFC 3986 unreserved set passes
/// through; everything else is `%XX`).
fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::read::DeflateDecoder;
use std::io::Read;
fn sp() -> SpParams {
SpParams {
entity_id: "https://pxe.example.com".into(),
acs_url: "https://pxe.example.com/api/sso/acs".into(),
}
}
fn pct_decode(s: &str) -> Vec<u8> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16).unwrap();
let lo = (bytes[i + 2] as char).to_digit(16).unwrap();
out.push((hi * 16 + lo) as u8);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
out
}
#[test]
fn id_is_ncname_and_location_has_request() {
let req = build(&sp(), "https://idp.example.com/sso", Some("/dashboard")).unwrap();
assert!(req.id.starts_with('_'));
assert!(req
.location
.starts_with("https://idp.example.com/sso?SAMLRequest="));
assert!(req.location.contains("&RelayState=%2Fdashboard"));
}
#[test]
fn redirect_payload_round_trips_to_our_authn_request() {
let req = build(&sp(), "https://idp.example.com/sso", None).unwrap();
// Pull SAMLRequest value out of the query string.
let q = req.location.split("SAMLRequest=").nth(1).unwrap();
let val = q.split('&').next().unwrap();
let compressed = base64::engine::general_purpose::STANDARD
.decode(pct_decode(val))
.unwrap();
let mut inflate = DeflateDecoder::new(&compressed[..]);
let mut xml = String::new();
inflate.read_to_string(&mut xml).unwrap();
let doc = roxmltree::Document::parse(&xml).unwrap();
let root = doc.root_element();
assert_eq!(root.tag_name().name(), "AuthnRequest");
assert_eq!(root.attribute("ID").unwrap(), req.id);
assert_eq!(
root.attribute("AssertionConsumerServiceURL").unwrap(),
"https://pxe.example.com/api/sso/acs"
);
let issuer = root
.descendants()
.find(|n| n.tag_name().name() == "Issuer")
.unwrap();
assert_eq!(issuer.text().unwrap(), "https://pxe.example.com");
}
#[test]
fn existing_query_uses_ampersand_separator() {
let req = build(&sp(), "https://idp.example.com/sso?foo=bar", None).unwrap();
assert!(req.location.contains("?foo=bar&SAMLRequest="));
}
}
+241
View File
@@ -0,0 +1,241 @@
//! IdP metadata parsing + SP metadata generation.
//!
//! We parse only what the SP flow needs: the IdP Entity ID, its
//! `SingleSignOnService` endpoints (HTTP-Redirect / HTTP-POST), and the
//! X.509 signing certificate(s). Everything else in the document is ignored.
use base64::Engine;
use super::{SamlError, SpParams};
/// SAML 2.0 binding URIs.
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
pub const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
/// The subset of an IdP's `EntityDescriptor` the SP flow consumes.
#[derive(Debug, Clone)]
pub struct IdpMetadata {
/// The IdP's Entity ID — we require incoming assertions to be issued by it.
pub entity_id: String,
/// SSO endpoint for the HTTP-Redirect binding (where we send AuthnRequests).
pub sso_redirect_url: Option<String>,
/// SSO endpoint for the HTTP-POST binding (fallback target).
pub sso_post_url: Option<String>,
/// DER-encoded X.509 signing certificate(s). More than one appears during
/// key rotation; verification tries each.
pub signing_certs_der: Vec<Vec<u8>>,
}
impl IdpMetadata {
/// Parse an IdP `EntityDescriptor` document.
///
/// Robust to namespace-prefix variation (matches on local element names),
/// since IdPs disagree on prefixes (`md:`, `ns0:`, default, …).
pub fn parse(xml: &str) -> Result<Self, SamlError> {
let doc = roxmltree::Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
let root = doc.root_element();
// The signing IDP descriptor. Some metadata wraps multiple
// descriptors (AA, SP) in one document; we want IDPSSODescriptor.
let idp_desc = root
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "IDPSSODescriptor")
.ok_or_else(|| SamlError::Metadata("IDPSSODescriptor".into()))?;
// Entity ID lives on the EntityDescriptor (root, or an ancestor of the
// IDPSSODescriptor when several are nested).
let entity_id = idp_desc
.ancestors()
.find_map(|n| {
if n.tag_name().name() == "EntityDescriptor" {
n.attribute("entityID")
} else {
None
}
})
.or_else(|| root.attribute("entityID"))
.map(str::to_owned)
.ok_or_else(|| SamlError::Metadata("entityID".into()))?;
let mut sso_redirect_url = None;
let mut sso_post_url = None;
for sso in idp_desc
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "SingleSignOnService")
{
let binding = sso.attribute("Binding").unwrap_or("");
let location = sso.attribute("Location").map(str::to_owned);
match binding {
BINDING_REDIRECT if sso_redirect_url.is_none() => sso_redirect_url = location,
BINDING_POST if sso_post_url.is_none() => sso_post_url = location,
_ => {}
}
}
// Signing certs: KeyDescriptor with use="signing" or no use attribute
// (a bare KeyDescriptor is valid for both signing and encryption).
let mut signing_certs_der = Vec::new();
for kd in idp_desc
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "KeyDescriptor")
{
match kd.attribute("use") {
Some("signing") | None => {}
Some(_) => continue, // encryption-only key — skip
}
for cert_node in kd
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "X509Certificate")
{
let b64: String = node_text(&cert_node)
.chars()
.filter(|c| !c.is_whitespace())
.collect();
if b64.is_empty() {
continue;
}
let der = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| SamlError::Base64(e.to_string()))?;
signing_certs_der.push(der);
}
}
if signing_certs_der.is_empty() {
return Err(SamlError::NoSigningCert);
}
Ok(Self {
entity_id,
sso_redirect_url,
sso_post_url,
signing_certs_der,
})
}
/// Preferred SSO destination for an outbound AuthnRequest: HTTP-Redirect
/// if advertised, otherwise HTTP-POST.
pub fn sso_destination(&self) -> Option<&str> {
self.sso_redirect_url
.as_deref()
.or(self.sso_post_url.as_deref())
}
}
/// Build our SP `EntityDescriptor` XML so an IdP admin can import OpenPXE as a
/// relying party. Advertises the ACS URL (HTTP-POST binding) and an emailAddress
/// NameID format — matching what the response path expects.
pub fn build_sp_metadata(sp: &SpParams) -> String {
let entity = xml_escape(&sp.entity_id);
let acs = xml_escape(&sp.acs_url);
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entity}">
<SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService Binding="{BINDING_POST}" Location="{acs}" index="0" isDefault="true"/>
</SPSSODescriptor>
</EntityDescriptor>
"#
)
}
/// Collect the concatenated text of an element's direct text children.
fn node_text(n: &roxmltree::Node<'_, '_>) -> String {
n.children()
.filter(roxmltree::Node::is_text)
.filter_map(|c| c.text())
.collect()
}
/// Minimal XML attribute/text escaping for the values we interpolate.
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
// A trimmed-down Keycloak-style IdP descriptor (cert body is a stand-in;
// signing tests build real certs in the parent module's tests).
const SAMPLE: &str = r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="https://idp.example.com/realms/fleet">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>
QUJDREVG
</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>WlpaWg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#;
#[test]
fn parses_entity_sso_and_signing_cert() {
let m = IdpMetadata::parse(SAMPLE).unwrap();
assert_eq!(m.entity_id, "https://idp.example.com/realms/fleet");
assert_eq!(
m.sso_redirect_url.as_deref(),
Some("https://idp.example.com/realms/fleet/protocol/saml")
);
assert!(m.sso_post_url.is_some());
// Only the signing KeyDescriptor's cert is collected (ABCDEF), not the
// encryption one (ZZZZ).
assert_eq!(m.signing_certs_der.len(), 1);
assert_eq!(m.signing_certs_der[0], b"ABCDEF");
}
#[test]
fn missing_signing_cert_is_rejected() {
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x">
<IDPSSODescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://x/sso"/>
</IDPSSODescriptor></EntityDescriptor>"#;
assert!(matches!(
IdpMetadata::parse(xml),
Err(SamlError::NoSigningCert)
));
}
#[test]
fn missing_idp_descriptor_is_rejected() {
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"></EntityDescriptor>"#;
assert!(matches!(
IdpMetadata::parse(xml),
Err(SamlError::Metadata(_))
));
}
#[test]
fn sp_metadata_contains_entity_and_acs() {
let sp = SpParams {
entity_id: "https://pxe.example.com".into(),
acs_url: "https://pxe.example.com/api/sso/acs".into(),
};
let xml = build_sp_metadata(&sp);
assert!(xml.contains(r#"entityID="https://pxe.example.com""#));
assert!(xml.contains("https://pxe.example.com/api/sso/acs"));
assert!(xml.contains(BINDING_POST));
// Must be well-formed.
roxmltree::Document::parse(&xml).unwrap();
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Pure-Rust SAML 2.0 Service Provider (v0.5.1).
//!
//! This module implements the SP half of a SAML Web-Browser-SSO profile:
//!
//! * [`metadata`] — parse the IdP's `EntityDescriptor` (SSO URLs + signing
//! certificates) and build *our* SP metadata for the IdP admin to import.
//! * [`authn_request`] — build an `AuthnRequest` and encode it for the
//! HTTP-Redirect binding.
//! * [`response`] — decode a `SAMLResponse`, **verify its XML signature**
//! against the IdP's pinned certificate (via the pure-Rust `bergshamra`
//! crate — no OpenSSL/libxml2/xmlsec, so the static musl binary stays
//! C-free), then enforce the SP-side semantic checks (Status, Destination,
//! Audience, time bounds) that are where SAML SPs actually get attacked.
//!
//! Stateful checks (replay of assertion IDs, correlating `InResponseTo`
//! against requests *we* issued, gating IdP-initiated login) live in the
//! HTTP layer — [`response::consume`] is deliberately stateless and returns
//! the IDs the caller needs to perform them.
//!
//! Access model: any assertion the IdP authenticates and we cryptographically
//! verify yields an operator [`VerifiedPrincipal`]. OpenPXE is single-tier —
//! there is no per-user role table — and the local admin account remains a
//! guaranteed fallback owner regardless of SSO state.
pub mod authn_request;
pub mod metadata;
pub mod response;
pub use authn_request::AuthnRequest;
pub use metadata::IdpMetadata;
pub use response::{VerifiedPrincipal, VerifiedResponse};
use thiserror::Error;
/// Default clock-skew tolerance applied to assertion time bounds. SAML IdPs
/// and SPs rarely have perfectly synced clocks; 60s matches common practice
/// (Shibboleth/FleetDM defaults are in this ballpark).
pub const DEFAULT_CLOCK_SKEW_SECS: i64 = 60;
/// Runtime SP parameters, derived from [`crate::SsoConfig`] + the advertised
/// public base URL by the HTTP layer.
#[derive(Debug, Clone)]
pub struct SpParams {
/// Our SP Entity ID (the `<Issuer>` we send and the `Audience` we require
/// in responses). Defaults to the public base URL when the operator left
/// the Entity ID field blank.
pub entity_id: String,
/// The Assertion Consumer Service URL the IdP POSTs the response to —
/// `<public_base_url>/api/sso/acs`.
pub acs_url: String,
}
/// Everything that can go wrong consuming a SAML response. Kept coarse on
/// purpose: the HTTP layer logs the detail and shows the operator a generic
/// "SSO sign-in failed" — we never leak which specific check tripped to the
/// browser, since that aids an attacker probing the SP.
#[derive(Debug, Error)]
pub enum SamlError {
#[error("SAML XML parse error: {0}")]
Xml(String),
#[error("IdP metadata is missing a required element: {0}")]
Metadata(String),
#[error("no usable IdP signing certificate in metadata")]
NoSigningCert,
#[error("signature verification failed: {0}")]
Signature(String),
#[error("the signature does not cover the assertion we read")]
SignatureScope,
#[error("SAML response status was not Success: {0}")]
Status(String),
#[error("response is missing a required element: {0}")]
MissingElement(String),
#[error("encrypted assertions are not supported in this release")]
EncryptedAssertionUnsupported,
#[error("expected exactly one assertion, found {0}")]
AssertionCount(usize),
#[error("issuer mismatch: response was not issued by the configured IdP")]
IssuerMismatch,
#[error("audience mismatch: assertion is not addressed to this service provider")]
AudienceMismatch,
#[error("response destination does not match our ACS URL")]
DestinationMismatch,
#[error("assertion is expired or not yet valid")]
TimeBounds,
#[error("invalid SAML timestamp: {0}")]
Timestamp(String),
#[error("base64 decode failed: {0}")]
Base64(String),
}
#[cfg(test)]
mod tests;
+294
View File
@@ -0,0 +1,294 @@
//! SAMLResponse consumption: signature verification + SP-side validation.
//!
//! [`consume`] is intentionally **stateless** — it verifies the XML signature
//! against the IdP's pinned certificate(s) and enforces every check that can
//! be made from the response alone (Status, Destination, Issuer, Audience,
//! time bounds, signature scope). It then returns the `assertion_id` and
//! `in_response_to` so the HTTP layer can perform the *stateful* checks it
//! owns: replay rejection, correlating the request we issued, and gating
//! IdP-initiated login.
use roxmltree::{Document, Node};
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use super::metadata::IdpMetadata;
use super::{SamlError, SpParams};
const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
/// The verified subject of a SAML assertion. OpenPXE is single-tier, so this
/// is all an operator session needs.
#[derive(Debug, Clone)]
pub struct VerifiedPrincipal {
/// The `<NameID>` value (an email, per our requested NameID format).
pub name_id: String,
/// Email used as the session identity. Equals `name_id` for the
/// emailAddress NameID format.
pub email: String,
/// Human-readable display name, if the IdP sent one as an attribute.
pub display_name: Option<String>,
}
/// Result of a successful [`consume`]. The IDs/expiry feed the HTTP layer's
/// stateful checks.
#[derive(Debug, Clone)]
pub struct VerifiedResponse {
pub principal: VerifiedPrincipal,
/// `InResponseTo` from the response, if present. `None` = unsolicited
/// (IdP-initiated) — the HTTP layer only accepts that when the operator
/// enabled it.
pub in_response_to: Option<String>,
/// The assertion's `ID` — used by the caller as the replay-guard key.
pub assertion_id: String,
/// The assertion's expiry (`Conditions/@NotOnOrAfter`) — the replay
/// guard can drop the consumed ID after this instant.
pub assertion_expiry: OffsetDateTime,
/// `AuthnStatement/@SessionIndex`, if present (useful for future SLO).
pub session_index: Option<String>,
}
/// Verify and validate a decoded `SAMLResponse` XML document.
pub fn consume(
xml: &str,
sp: &SpParams,
idp: &IdpMetadata,
now: OffsetDateTime,
clock_skew: Duration,
) -> Result<VerifiedResponse, SamlError> {
// 1. Cryptographically verify the signature against the pinned IdP cert(s).
// `trusted_keys_only` ignores any cert embedded in the document's
// KeyInfo, so an attacker can't substitute their own key.
let verified_uris = verify_signature(xml, &idp.signing_certs_der)?;
// 2. Parse for semantic validation.
let doc = Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
let root = doc.root_element();
if root.tag_name().name() != "Response" {
return Err(SamlError::MissingElement("Response".into()));
}
let response_id = root.attribute("ID").map(str::to_owned);
let in_response_to = root.attribute("InResponseTo").map(str::to_owned);
// 3. Status must be Success.
let status_value = root
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "StatusCode")
.and_then(|sc| sc.attribute("Value"))
.unwrap_or("");
if status_value != STATUS_SUCCESS && !status_value.ends_with(":Success") {
return Err(SamlError::Status(status_value.to_owned()));
}
// 4. Destination (if the IdP set one) must be our ACS.
if let Some(dest) = root.attribute("Destination") {
if !urls_equal(dest, &sp.acs_url) {
return Err(SamlError::DestinationMismatch);
}
}
// 5. Exactly one (unencrypted) Assertion.
if root
.descendants()
.any(|n| n.is_element() && n.tag_name().name() == "EncryptedAssertion")
{
return Err(SamlError::EncryptedAssertionUnsupported);
}
let assertions: Vec<Node<'_, '_>> = root
.children()
.filter(|c| c.is_element() && c.tag_name().name() == "Assertion")
.collect();
if assertions.len() != 1 {
return Err(SamlError::AssertionCount(assertions.len()));
}
let assertion = assertions[0];
let assertion_id = assertion
.attribute("ID")
.map(str::to_owned)
.ok_or_else(|| SamlError::MissingElement("Assertion/@ID".into()))?;
// 6. The signature must actually cover the assertion we're about to trust:
// either the assertion itself, the enclosing response, or the whole
// document. (bergshamra's strict_verification already constrains where
// the signed element may sit; this ties it to *our* assertion.)
let covers_assertion = verified_uris.iter().any(|u| {
u.is_empty()
|| u == &format!("#{assertion_id}")
|| response_id
.as_ref()
.is_some_and(|rid| u == &format!("#{rid}"))
});
if !covers_assertion {
return Err(SamlError::SignatureScope);
}
// 7. Issuer must be the configured IdP.
let issuer = first_child(assertion, "Issuer")
.map(text_of)
.unwrap_or_default();
if !idp.entity_id.is_empty() && issuer != idp.entity_id {
return Err(SamlError::IssuerMismatch);
}
// 8. Subject → NameID + SubjectConfirmationData time/recipient checks.
let subject = first_child(assertion, "Subject")
.ok_or_else(|| SamlError::MissingElement("Subject".into()))?;
let name_id = first_child(subject, "NameID")
.map(text_of)
.filter(|s| !s.is_empty())
.ok_or_else(|| SamlError::MissingElement("NameID".into()))?;
if let Some(scd) = subject
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "SubjectConfirmationData")
{
if let Some(recipient) = scd.attribute("Recipient") {
if !urls_equal(recipient, &sp.acs_url) {
return Err(SamlError::DestinationMismatch);
}
}
if let Some(noa) = scd.attribute("NotOnOrAfter") {
let noa = parse_instant(noa)?;
if now >= noa + clock_skew {
return Err(SamlError::TimeBounds);
}
}
}
// 9. Conditions: time window + audience.
let conditions = first_child(assertion, "Conditions");
if let Some(cond) = conditions {
if let Some(nb) = cond.attribute("NotBefore") {
let nb = parse_instant(nb)?;
if now < nb - clock_skew {
return Err(SamlError::TimeBounds);
}
}
}
let assertion_expiry = conditions
.and_then(|c| c.attribute("NotOnOrAfter"))
.map(parse_instant)
.transpose()?
.ok_or_else(|| SamlError::MissingElement("Conditions/@NotOnOrAfter".into()))?;
if now >= assertion_expiry + clock_skew {
return Err(SamlError::TimeBounds);
}
let audience_ok = conditions.is_some_and(|c| {
c.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "Audience")
.any(|a| text_of(a) == sp.entity_id)
});
if !audience_ok {
return Err(SamlError::AudienceMismatch);
}
// 10. Optional: SessionIndex + display-name attribute.
let session_index = assertion
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "AuthnStatement")
.and_then(|a| a.attribute("SessionIndex"))
.map(str::to_owned);
let display_name = extract_display_name(assertion);
Ok(VerifiedResponse {
principal: VerifiedPrincipal {
email: name_id.clone(),
name_id,
display_name,
},
in_response_to,
assertion_id,
assertion_expiry,
session_index,
})
}
/// Verify the document's XML-DSig against each pinned IdP cert in turn
/// (handles key rotation), returning the verified `<Reference>` URIs.
fn verify_signature(xml: &str, certs_der: &[Vec<u8>]) -> Result<Vec<String>, SamlError> {
let mut last_err = String::from("no signing certificate matched");
for der in certs_der {
let key = match bergshamra::keys::loader::load_x509_cert_der(der) {
Ok(k) => k,
Err(e) => {
last_err = e.to_string();
continue;
}
};
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
// trusted_keys_only: only ever trust the pinned IdP key, never an
// inline KeyInfo cert. strict_verification: XSW positional defense.
let ctx = bergshamra::DsigContext::new(km)
.with_trusted_keys_only(true)
.with_strict_verification(true);
match bergshamra::verify(&ctx, xml) {
Ok(bergshamra::VerifyResult::Valid { references, .. }) => {
return Ok(references.into_iter().map(|r| r.uri).collect());
}
Ok(bergshamra::VerifyResult::Invalid { reason }) => last_err = reason,
Err(e) => last_err = e.to_string(),
}
}
Err(SamlError::Signature(last_err))
}
/// Pull a display name from the assertion's attribute statement, trying the
/// common attribute names IdPs use (FleetDM checks the same set).
fn extract_display_name(assertion: Node<'_, '_>) -> Option<String> {
const WANTED: &[&str] = &[
"name",
"displayname",
"cn",
"urn:oid:2.5.4.3",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
];
for attr in assertion
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "Attribute")
{
let key = attr
.attribute("Name")
.or_else(|| attr.attribute("FriendlyName"))
.unwrap_or("")
.to_ascii_lowercase();
if WANTED.contains(&key.as_str()) {
if let Some(val) = attr
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "AttributeValue")
{
let v = text_of(val);
if !v.is_empty() {
return Some(v);
}
}
}
}
None
}
fn first_child<'a, 'i>(n: Node<'a, 'i>, local: &str) -> Option<Node<'a, 'i>> {
n.children()
.find(|c| c.is_element() && c.tag_name().name() == local)
}
fn text_of(n: Node<'_, '_>) -> String {
n.children()
.filter(Node::is_text)
.filter_map(|c| c.text())
.collect::<String>()
.trim()
.to_owned()
}
/// Parse an `xs:dateTime` (e.g. `2026-05-31T12:00:00.000Z`).
fn parse_instant(s: &str) -> Result<OffsetDateTime, SamlError> {
OffsetDateTime::parse(s.trim(), &Rfc3339).map_err(|e| SamlError::Timestamp(format!("{s}: {e}")))
}
/// Compare two URLs for SAML endpoint-matching purposes: exact, or differing
/// only by a single trailing slash.
fn urls_equal(a: &str, b: &str) -> bool {
a == b || a.trim_end_matches('/') == b.trim_end_matches('/')
}
+287
View File
@@ -0,0 +1,287 @@
//! End-to-end SAML SP tests.
//!
//! We mint a throwaway self-signed cert/key with `rcgen`, sign a SAML Response
//! template with `bergshamra::sign` (the same engine that verifies it), and
//! drive [`response::consume`] through the accept path and every reject path.
//! This proves both the signature wiring and the SP-semantic checks.
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use super::metadata::IdpMetadata;
use super::{response, SamlError, SpParams};
const SP_ENTITY: &str = "https://pxe.example.com";
const ACS: &str = "https://pxe.example.com/api/sso/acs";
const IDP_ENTITY: &str = "https://idp.example.com/realms/fleet";
const EMAIL: &str = "[email protected]";
struct TestIdp {
cert_der: Vec<u8>,
key_pem: String,
}
fn test_idp() -> TestIdp {
let ck = rcgen::generate_simple_self_signed(vec!["idp.example.com".to_string()]).unwrap();
TestIdp {
cert_der: ck.cert.der().as_ref().to_vec(),
key_pem: ck.key_pair.serialize_pem(),
}
}
fn fmt(t: OffsetDateTime) -> String {
t.replace_nanosecond(0).unwrap().format(&Rfc3339).unwrap()
}
/// Knobs for building a response template — defaults are a valid response.
struct Resp {
issuer: String,
audience: String,
status: String,
not_before: OffsetDateTime,
not_on_or_after: OffsetDateTime,
in_response_to: Option<String>,
recipient: String,
}
impl Default for Resp {
fn default() -> Self {
let now = OffsetDateTime::now_utc();
Self {
issuer: IDP_ENTITY.into(),
audience: SP_ENTITY.into(),
status: "urn:oasis:names:tc:SAML:2.0:status:Success".into(),
not_before: now - Duration::minutes(5),
not_on_or_after: now + Duration::hours(1),
in_response_to: Some("_req-abc".into()),
recipient: ACS.into(),
}
}
}
impl Resp {
/// The unsigned template (a `<ds:Signature>` with empty values).
fn template(&self) -> String {
let now = fmt(OffsetDateTime::now_utc());
let irt = self
.in_response_to
.as_ref()
.map(|v| format!(r#" InResponseTo="{v}""#))
.unwrap_or_default();
format!(
r##"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_resp1" Version="2.0" IssueInstant="{now}" Destination="{ACS}"{irt}>
<saml:Issuer>{issuer}</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="{status}"/></samlp:Status>
<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="{now}">
<saml:Issuer>{issuer}</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
<ds:Reference URI="#_assertion1">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue></ds:SignatureValue>
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">{EMAIL}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData Recipient="{recipient}" NotOnOrAfter="{noa}"{irt}/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">
<saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{now}" SessionIndex="sess-123">
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="displayName"><saml:AttributeValue>Miles Ward</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>"##,
issuer = self.issuer,
status = self.status,
audience = self.audience,
recipient = self.recipient,
nb = fmt(self.not_before),
noa = fmt(self.not_on_or_after),
)
}
}
fn sign(template: &str, key_pem: &str) -> String {
let key = bergshamra::keys::loader::load_pem_auto(key_pem.as_bytes(), None)
.expect("load test signing key");
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
let ctx = bergshamra::DsigContext::new(km);
bergshamra::sign(&ctx, template).expect("sign test response")
}
fn sp() -> SpParams {
SpParams {
entity_id: SP_ENTITY.into(),
acs_url: ACS.into(),
}
}
fn idp(cert_der: Vec<u8>) -> IdpMetadata {
IdpMetadata {
entity_id: IDP_ENTITY.into(),
sso_redirect_url: None,
sso_post_url: None,
signing_certs_der: vec![cert_der],
}
}
fn consume(xml: &str, cert_der: Vec<u8>) -> Result<response::VerifiedResponse, SamlError> {
response::consume(
xml,
&sp(),
&idp(cert_der),
OffsetDateTime::now_utc(),
Duration::seconds(60),
)
}
#[test]
fn good_response_yields_principal() {
let t = test_idp();
let signed = sign(&Resp::default().template(), &t.key_pem);
let out = consume(&signed, t.cert_der).expect("valid response should verify");
assert_eq!(out.principal.email, EMAIL);
assert_eq!(out.principal.name_id, EMAIL);
assert_eq!(out.principal.display_name.as_deref(), Some("Miles Ward"));
assert_eq!(out.in_response_to.as_deref(), Some("_req-abc"));
assert_eq!(out.assertion_id, "_assertion1");
assert_eq!(out.session_index.as_deref(), Some("sess-123"));
}
#[test]
fn tampered_assertion_is_rejected() {
let t = test_idp();
let signed = sign(&Resp::default().template(), &t.key_pem);
// Flip the subject email after signing — breaks the digest.
let tampered = signed.replace(EMAIL, "[email protected]");
assert_ne!(signed, tampered);
assert!(matches!(
consume(&tampered, t.cert_der),
Err(SamlError::Signature(_) | SamlError::SignatureScope)
));
}
#[test]
fn unsigned_response_is_rejected() {
let t = test_idp();
// Feed the *unsigned* template (empty SignatureValue) straight in.
let unsigned = Resp::default().template();
assert!(matches!(
consume(&unsigned, t.cert_der),
Err(SamlError::Signature(_))
));
}
#[test]
fn wrong_signing_key_is_rejected() {
let signer = test_idp();
let other = test_idp(); // different keypair pinned as the "IdP" cert
let signed = sign(&Resp::default().template(), &signer.key_pem);
assert!(matches!(
consume(&signed, other.cert_der),
Err(SamlError::Signature(_))
));
}
#[test]
fn wrong_audience_is_rejected() {
let t = test_idp();
let r = Resp {
audience: "https://someone-else.example".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::AudienceMismatch)
));
}
#[test]
fn expired_assertion_is_rejected() {
let t = test_idp();
let now = OffsetDateTime::now_utc();
let r = Resp {
not_before: now - Duration::hours(2),
not_on_or_after: now - Duration::hours(1),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::TimeBounds)
));
}
#[test]
fn future_assertion_is_rejected() {
let t = test_idp();
let now = OffsetDateTime::now_utc();
let r = Resp {
not_before: now + Duration::hours(1),
not_on_or_after: now + Duration::hours(2),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::TimeBounds)
));
}
#[test]
fn wrong_issuer_is_rejected() {
let t = test_idp();
let r = Resp {
issuer: "https://evil-idp.example".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::IssuerMismatch)
));
}
#[test]
fn non_success_status_is_rejected() {
let t = test_idp();
let r = Resp {
status: "urn:oasis:names:tc:SAML:2.0:status:Requester".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::Status(_))
));
}
#[test]
fn idp_initiated_has_no_in_response_to() {
// No InResponseTo => the HTTP layer must gate it behind allow_idp_initiated.
let t = test_idp();
let r = Resp {
in_response_to: None,
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
let out = consume(&signed, t.cert_der).expect("unsolicited but otherwise valid");
assert!(out.in_response_to.is_none());
}
+84 -12
View File
@@ -1,16 +1,17 @@
//! SAML SSO configuration — FleetDM-shaped, storage-only for v0.4.5. //! SAML SSO configuration — FleetDM-shaped.
//! //!
//! The operator pastes their IdP's metadata XML (or its URL) and a //! The operator pastes their IdP's metadata XML (or its URL) and a
//! human-readable label; v0.4.5 just persists it. The actual SAML //! human-readable label. As of v0.5.1 the SAML login flow is wired
//! response-validation / JIT-provisioning flow lands in a later release //! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS
//! — for now we cover the "configurable" half so an operator can teach //! endpoint, pure-Rust signature verification, and operator-session
//! OpenPXE about their IdP today and flip the switch on next upgrade. //! minting. This module owns only the persisted *configuration*.
//! //!
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config //! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you //! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any
//! have access or you don't). Entity ID is omitted from the operator //! IdP-authenticated user the SP cryptographically verifies gets an
//! UI per the v0.4.5 brief — it defaults to the advertised public base //! operator session; there is no per-user role table). Entity ID is
//! URL when SAML wiring lands, which is what most IdPs expect anyway. //! exposed (FleetDM-style) but defaults to the advertised public base
//! URL when blank, which is what most IdPs expect anyway.
use parking_lot::RwLock; use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -47,6 +48,18 @@ pub struct SsoConfig {
/// future SAML flow; not validated here beyond a basic length cap. /// future SAML flow; not validated here beyond a basic length cap.
#[serde(default)] #[serde(default)]
pub metadata_url: String, pub metadata_url: String,
/// SP Entity ID advertised to the IdP — mirrors FleetDM's "Entity ID".
/// Must exactly match the SP/Relying-Party entry configured on the IdP.
/// Empty falls back to the advertised public base URL at runtime, which
/// is what most IdPs expect. Length-capped at [`MAX_URL_LEN`].
#[serde(default)]
pub entity_id: String,
/// Allow IdP-initiated login — an unsolicited `<Response>` POSTed to the
/// ACS with no `InResponseTo`. Mirrors FleetDM's "Allow SSO login
/// initiated by identity provider". Default off; SP-initiated (the
/// "Sign in with X" button) is always allowed regardless.
#[serde(default)]
pub allow_idp_initiated: bool,
} }
impl SsoConfig { impl SsoConfig {
@@ -56,8 +69,7 @@ impl SsoConfig {
/// surface a yellow "configured but not live yet" hint. /// surface a yellow "configured but not live yet" hint.
#[must_use] #[must_use]
pub fn is_usable(&self) -> bool { pub fn is_usable(&self) -> bool {
self.enabled self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
&& (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
} }
} }
@@ -108,6 +120,12 @@ impl SsoStore {
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string(); cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
cfg.metadata = cfg.metadata.trim().to_string(); cfg.metadata = cfg.metadata.trim().to_string();
cfg.metadata_url = cfg.metadata_url.trim().to_string(); cfg.metadata_url = cfg.metadata_url.trim().to_string();
cfg.entity_id = cfg.entity_id.trim().to_string();
if cfg.entity_id.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"entity_id exceeds {MAX_URL_LEN}-char cap"
)));
}
if cfg.metadata.len() > MAX_METADATA_BYTES { if cfg.metadata.len() > MAX_METADATA_BYTES {
return Err(Error::Invalid(format!( return Err(Error::Invalid(format!(
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap" "metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
@@ -217,6 +235,8 @@ mod tests {
metadata: String::new(), metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(), metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(), idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
}) })
.unwrap(); .unwrap();
drop(s); drop(s);
@@ -239,6 +259,8 @@ mod tests {
metadata: xml.into(), metadata: xml.into(),
metadata_url: String::new(), metadata_url: String::new(),
idp_logo_url: String::new(), idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
}) })
.unwrap(); .unwrap();
assert!(s.snapshot().is_usable()); assert!(s.snapshot().is_usable());
@@ -254,6 +276,8 @@ mod tests {
metadata: String::new(), metadata: String::new(),
metadata_url: String::new(), metadata_url: String::new(),
idp_logo_url: String::new(), idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
}); });
assert!(matches!(r, Err(Error::Invalid(_)))); assert!(matches!(r, Err(Error::Invalid(_))));
// …and a disabled blank config is fine. // …and a disabled blank config is fine.
@@ -270,6 +294,8 @@ mod tests {
metadata: String::new(), metadata: String::new(),
metadata_url: "ftp://idp.example.com/metadata".into(), metadata_url: "ftp://idp.example.com/metadata".into(),
idp_logo_url: String::new(), idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
}); });
assert!(matches!(r, Err(Error::Invalid(_)))); assert!(matches!(r, Err(Error::Invalid(_))));
} }
@@ -287,6 +313,8 @@ mod tests {
metadata: String::new(), metadata: String::new(),
metadata_url: String::new(), metadata_url: String::new(),
idp_logo_url: "data:image/png;base64,...".into(), idp_logo_url: "data:image/png;base64,...".into(),
entity_id: String::new(),
allow_idp_initiated: false,
}); });
assert!(matches!(r, Err(Error::Invalid(_)))); assert!(matches!(r, Err(Error::Invalid(_))));
// Real HTTPS URL is fine. // Real HTTPS URL is fine.
@@ -296,9 +324,51 @@ mod tests {
metadata: String::new(), metadata: String::new(),
metadata_url: String::new(), metadata_url: String::new(),
idp_logo_url: "https://idp.example.com/logo.png".into(), idp_logo_url: "https://idp.example.com/logo.png".into(),
entity_id: String::new(),
allow_idp_initiated: false,
}) })
.unwrap(); .unwrap();
assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png"); assert_eq!(
s.snapshot().idp_logo_url,
"https://idp.example.com/logo.png"
);
}
#[test]
fn entity_id_and_idp_initiated_round_trip() {
// v0.5.1: SP Entity ID + IdP-initiated toggle persist across reload.
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
s.replace(SsoConfig {
enabled: true,
idp_name: "Keycloak".into(),
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: "https://pxe.example.com".into(),
allow_idp_initiated: true,
})
.unwrap();
drop(s);
let cfg = SsoStore::load_or_default(dir.path()).snapshot();
assert_eq!(cfg.entity_id, "https://pxe.example.com");
assert!(cfg.allow_idp_initiated);
}
#[test]
fn entity_id_cap_enforced() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: "x".repeat(MAX_URL_LEN + 1),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
} }
#[test] #[test]
@@ -312,6 +382,8 @@ mod tests {
metadata: oversize, metadata: oversize,
metadata_url: String::new(), metadata_url: String::new(),
idp_logo_url: String::new(), idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
}); });
assert!(matches!(r, Err(Error::Invalid(_)))); assert!(matches!(r, Err(Error::Invalid(_))));
} }
+210
View File
@@ -0,0 +1,210 @@
//! Wake-on-LAN.
//!
//! v0.5.0: from the Hosts tab, an operator can wake a bound machine.
//! WoL is a "magic packet" — six `0xFF` bytes followed by the target
//! MAC repeated sixteen times (102 bytes total) — broadcast on the
//! local segment. The NIC's WoL logic matches the repeated MAC and
//! powers the board on.
//!
//! ## Why this is trivial and safe in our container
//!
//! - It's a single UDP datagram to a broadcast address. No privileged
//! *local* port is needed (we bind an ephemeral source port); the
//! destination port is conventionally 9 (discard) or 7 (echo), and
//! nothing actually listens there — the magic is in the payload, not
//! the port. So WoL works without any extra capability.
//! - We send to the limited broadcast `255.255.255.255` (stays on the
//! local link) and, when the caller knows the server's own subnet
//! broadcast, to that too — directed broadcast reaches the right VLAN
//! even when the host bridges multiple segments.
//!
//! ## Limits
//!
//! WoL only crosses L2. If the target is on a different subnet than the
//! OpenPXE host, the intervening router must be configured to forward
//! directed broadcasts (most aren't, by design). For the common case —
//! OpenPXE and its PXE clients on the same VLAN — the limited broadcast
//! is enough.
use crate::{Error, Result};
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
/// Conventional WoL destination port. 9 (discard) is the de-facto
/// default; the port is immaterial since the match is on the payload.
const WOL_PORT: u16 = 9;
/// Parse a MAC string in any common form (`aa:bb:cc:dd:ee:ff`,
/// `aa-bb-...`, `aabb.ccdd.eeff`, or bare hex) into six octets.
///
/// Returns `Error::Invalid` if it doesn't resolve to exactly six bytes.
pub fn parse_mac(mac: &str) -> Result<[u8; 6]> {
// Strip every non-hex-digit, then expect exactly 12 hex chars.
let hex: String = mac.chars().filter(char::is_ascii_hexdigit).collect();
if hex.len() != 12 {
return Err(Error::Invalid(format!(
"invalid MAC '{mac}': expected 6 octets (12 hex digits), got {}",
hex.len()
)));
}
let mut out = [0u8; 6];
for (i, byte) in out.iter_mut().enumerate() {
// Each octet is two hex chars; unwrap is safe — we validated
// the length and that every char is a hex digit above.
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
.map_err(|e| Error::Invalid(format!("invalid MAC '{mac}': {e}")))?;
}
Ok(out)
}
/// Build the 102-byte magic packet for `mac`.
#[must_use]
pub fn magic_packet(mac: [u8; 6]) -> [u8; 102] {
let mut pkt = [0u8; 102];
// 6 bytes of 0xFF.
for b in &mut pkt[..6] {
*b = 0xFF;
}
// MAC repeated 16 times.
for rep in 0..16 {
let start = 6 + rep * 6;
pkt[start..start + 6].copy_from_slice(&mac);
}
pkt
}
/// Send a Wake-on-LAN magic packet for `mac` to every address in
/// `broadcasts` (e.g. `255.255.255.255` plus the server's subnet
/// broadcast). Returns the number of broadcast addresses the packet was
/// successfully sent to; errors only if the MAC is malformed or the
/// socket can't be opened at all.
pub fn wake(mac: &str, broadcasts: &[Ipv4Addr]) -> Result<usize> {
let parsed = parse_mac(mac)?;
let packet = magic_packet(parsed);
// Always include the limited broadcast even if the caller didn't —
// it's the one that works with zero network configuration.
let mut targets: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
for b in broadcasts {
if !targets.contains(b) {
targets.push(*b);
}
}
let sent = send_magic(&packet, &targets, WOL_PORT)?;
tracing::info!(
target: "openpxe::wol",
mac = %mac, broadcasts = sent,
"Wake-on-LAN magic packet sent"
);
Ok(sent)
}
/// Open a broadcast-enabled UDP socket and send `packet` to every
/// `target:port`. Returns how many sends succeeded. Errors if the
/// socket can't be opened or if *no* target accepted the packet.
fn send_magic(packet: &[u8], targets: &[Ipv4Addr], port: u16) -> Result<usize> {
// Bind an ephemeral local UDP port on all interfaces. SO_BROADCAST
// must be enabled to send to a broadcast address.
let sock = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
.map_err(|e| Error::Invalid(format!("could not open WoL socket: {e}")))?;
sock.set_broadcast(true)
.map_err(|e| Error::Invalid(format!("could not enable broadcast: {e}")))?;
let mut sent = 0usize;
for &addr in targets {
match sock.send_to(packet, SocketAddrV4::new(addr, port)) {
Ok(_) => sent += 1,
Err(e) => {
tracing::warn!(
target: "openpxe::wol",
broadcast = %addr,
"WoL send failed: {e}"
);
}
}
}
if sent == 0 {
return Err(Error::Invalid(
"Wake-on-LAN: no broadcast address accepted the packet".into(),
));
}
Ok(sent)
}
/// Compute the IPv4 broadcast address for `ip`/`mask`, if both parse.
/// Used so the caller can include the server's own subnet broadcast
/// alongside the limited broadcast.
#[must_use]
pub fn subnet_broadcast(ip: Ipv4Addr, mask: Ipv4Addr) -> Ipv4Addr {
let ip = u32::from(ip);
let mask = u32::from(mask);
Ipv4Addr::from(ip | !mask)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_mac_accepts_common_forms() {
let want = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff").unwrap(), want);
assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), want);
assert_eq!(parse_mac("aabb.ccdd.eeff").unwrap(), want);
assert_eq!(parse_mac("aabbccddeeff").unwrap(), want);
}
#[test]
fn parse_mac_rejects_bad_length() {
assert!(parse_mac("aa:bb:cc").is_err());
assert!(parse_mac("").is_err());
assert!(parse_mac("zz:bb:cc:dd:ee:ff").is_err()); // non-hex stripped → too short
}
#[test]
fn magic_packet_shape() {
let pkt = magic_packet([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
assert_eq!(&pkt[..6], &[0xFF; 6]);
// First MAC repetition.
assert_eq!(&pkt[6..12], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
// Last (16th) repetition ends the packet.
assert_eq!(&pkt[96..102], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
}
#[test]
fn subnet_broadcast_computes() {
assert_eq!(
subnet_broadcast(
Ipv4Addr::new(192, 168, 1, 49),
Ipv4Addr::new(255, 255, 255, 0)
),
Ipv4Addr::new(192, 168, 1, 255)
);
assert_eq!(
subnet_broadcast(
Ipv4Addr::new(10, 5, 3, 7),
Ipv4Addr::new(255, 255, 0, 0)
),
Ipv4Addr::new(10, 5, 255, 255)
);
}
#[test]
fn send_magic_delivers_intact_packet_over_loopback() {
// Deterministic round-trip that doesn't depend on the sandbox
// permitting a real L2 broadcast: bind a receiver on loopback
// and confirm send_magic transmits the exact 102-byte packet.
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = rx.local_addr().unwrap().port();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
assert_eq!(sent, 1);
let mut buf = [0u8; 128];
let n = rx.recv(&mut buf).unwrap();
assert_eq!(n, 102, "magic packet should be 102 bytes");
assert_eq!(&buf[..102], &packet[..]);
}
}
+17
View File
@@ -4,6 +4,10 @@ version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
# v0.5.0: inherit the workspace repository so CARGO_PKG_REPOSITORY is
# populated at build time — the About-tab update check derives the
# Gitea releases API URL from it.
repository.workspace = true
description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming" description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming"
[lints] [lints]
@@ -34,6 +38,14 @@ mime_guess.workspace = true
uuid.workspace = true uuid.workspace = true
# v0.4.5 Forms auth: lock-free session store and cookie helpers. # v0.4.5 Forms auth: lock-free session store and cookie helpers.
parking_lot.workspace = true parking_lot.workspace = true
# v0.5.0: outbound HTTP for chat webhooks (Slack/Teams/Discord) and the
# About-tab "check for updates" call to the Gitea releases API; SMTP for
# email notifications. Both use rustls so the static musl binary stays
# OpenSSL-free.
reqwest.workspace = true
lettre.workspace = true
# v0.5.1: decode the base64 SAMLResponse at the ACS endpoint.
base64.workspace = true
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
@@ -44,3 +56,8 @@ time = { workspace = true }
# v0.4.61: integration tests need to generate real PNG bytes for the # v0.4.61: integration tests need to generate real PNG bytes for the
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile. # `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
# v0.5.1: the SAML ACS integration tests mint a throwaway IdP keypair
# (rcgen) and sign a SAMLResponse with bergshamra so the happy-path,
# replay, and IdP-initiated-gating flows exercise real signatures.
rcgen = "0.13"
bergshamra = { workspace = true }
File diff suppressed because it is too large Load Diff
+47 -16
View File
@@ -140,9 +140,16 @@ fn cookie_attrs(value: &str, max_age: Option<i64>) -> String {
// never overflow i64, but clippy's `cast_possible_wrap` lint wants // never overflow i64, but clippy's `cast_possible_wrap` lint wants
// us to be explicit. `cast_signed` is the documented form. // us to be explicit. `cast_signed` is the documented form.
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed()); let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
format!( format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}")
"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}" }
)
/// Build the `Set-Cookie` header value that establishes a fresh operator
/// session with the default 24h TTL. Exposed so the SAML ACS handler can
/// attach an operator session to its post-login redirect, exactly as the
/// Forms-login path does via [`login_response`].
#[must_use]
pub fn session_cookie(session: &str) -> String {
cookie_attrs(session, None)
} }
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> { fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
@@ -169,9 +176,18 @@ fn is_public_path(path: &str) -> bool {
return true; return true;
} }
// Auth surface and iPXE long-poll endpoints (no cookie available). // Auth surface and iPXE long-poll endpoints (no cookie available).
// The SAML SP endpoints are pre-auth by nature — the operator hasn't a
// session yet when they start (or arrive from) the IdP. `/api/sso`
// (the config GET/PUT, no trailing slash) stays gated.
matches!( matches!(
path, path,
"/api/setup" | "/api/login" | "/api/logout" | "/api/me" "/api/setup"
| "/api/login"
| "/api/logout"
| "/api/me"
| "/api/sso/login"
| "/api/sso/acs"
| "/api/sso/metadata"
) || path.starts_with("/api/queue/join") ) || path.starts_with("/api/queue/join")
|| path.starts_with("/api/queue/poll/") || path.starts_with("/api/queue/poll/")
} }
@@ -222,10 +238,7 @@ pub struct SetupBody {
/// guards against a leaked WebUI being re-bootstrapped by an attacker /// guards against a leaked WebUI being re-bootstrapped by an attacker
/// who's seen the deployment URL. After bootstrap, the new session /// who's seen the deployment URL. After bootstrap, the new session
/// cookie is set so the operator goes straight to the dashboard. /// cookie is set so the operator goes straight to the dashboard.
pub async fn api_setup( pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody>) -> Response {
State(state): State<AppState>,
Json(body): Json<SetupBody>,
) -> Response {
if state.admin.is_configured() { if state.admin.is_configured() {
return ( return (
StatusCode::CONFLICT, StatusCode::CONFLICT,
@@ -280,10 +293,7 @@ pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody
login_response(StatusCode::OK, &pub_, &session) login_response(StatusCode::OK, &pub_, &session)
} }
pub async fn api_logout( pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(t) = parse_cookie(&headers) { if let Some(t) = parse_cookie(&headers) {
state.sessions.revoke(&t); state.sessions.revoke(&t);
} }
@@ -303,12 +313,20 @@ pub async fn api_logout(
/// * `authenticated: false` — admin exists, no session; show login. /// * `authenticated: false` — admin exists, no session; show login.
/// * `authenticated: true` + `user` — let the dashboard load. /// * `authenticated: true` + `user` — let the dashboard load.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response { pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
// v0.5.0: include branding bootstrap so the pre-auth login/setup
// screens can render the FleetDM-style full-width custom logo (and
// cache-bust it) without an extra round trip. `/api/me` is public,
// and the logo asset is public, so this leaks nothing sensitive.
let has_custom_logo = state.branding.has_any_web_logo();
let logo_rev = state.branding.logo_rev();
if !state.admin.is_configured() { if !state.admin.is_configured() {
return ( return (
StatusCode::OK, StatusCode::OK,
Json(json!({ Json(json!({
"setup_required": true, "setup_required": true,
"authenticated": false, "authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})), })),
) )
.into_response(); .into_response();
@@ -323,6 +341,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"authenticated": true, "authenticated": true,
"user": state.admin.snapshot(), "user": state.admin.snapshot(),
"session_user": u, "session_user": u,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})), })),
) )
.into_response(), .into_response(),
@@ -331,6 +351,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
Json(json!({ Json(json!({
"setup_required": false, "setup_required": false,
"authenticated": false, "authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})), })),
) )
.into_response(), .into_response(),
@@ -437,8 +459,14 @@ mod tests {
fn public_path_allowlist() { fn public_path_allowlist() {
// PXE + chrome paths bypass auth. // PXE + chrome paths bypass auth.
for p in [ for p in [
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe", "/",
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz", "/assets/app.js",
"/boot.ipxe",
"/boot/fake.ipxe",
"/iso/fake.iso",
"/ipxe/snponly.efi",
"/healthz",
"/readyz",
"/metrics", "/metrics",
// v0.4.6: iPXE fetches this for `console --picture` before // v0.4.6: iPXE fetches this for `console --picture` before
// it can possibly have a session cookie. // it can possibly have a session cookie.
@@ -450,6 +478,10 @@ mod tests {
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] { for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
assert!(is_public_path(p), "expected {p} to be public"); assert!(is_public_path(p), "expected {p} to be public");
} }
// v0.5.1: SAML SP endpoints are pre-auth (no session yet).
for p in ["/api/sso/login", "/api/sso/acs", "/api/sso/metadata"] {
assert!(is_public_path(p), "expected {p} to be public");
}
// iPXE long-poll endpoints are public (no cookie available). // iPXE long-poll endpoints are public (no cookie available).
assert!(is_public_path("/api/queue/join")); assert!(is_public_path("/api/queue/join"));
assert!(is_public_path("/api/queue/poll/abc")); assert!(is_public_path("/api/queue/poll/abc"));
@@ -471,8 +503,7 @@ mod tests {
let mut h = axum::http::HeaderMap::new(); let mut h = axum::http::HeaderMap::new();
h.insert( h.insert(
header::COOKIE, header::COOKIE,
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")) HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")).unwrap(),
.unwrap(),
); );
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123")); assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
// Different name → None. // Different name → None.
+20 -1
View File
@@ -460,8 +460,21 @@ pub fn render_queue_entry(base_url: &str) -> String {
} }
/// Per-entry boot script (same as Phase 1, with extra_kernel_args appended). /// Per-entry boot script (same as Phase 1, with extra_kernel_args appended).
///
/// `unattended_args` (v0.5.2) carries the per-host unattended-install
/// kernel arguments (`inst.ks=…`, `auto=true … url=…`, or
/// `autoinstall ds=nocloud-net;s=…`) when the requesting MAC has a
/// deployment profile with an answer file selected. It's appended to the
/// Linux kernel command line after the operator's global extra args, and
/// ignored for Windows (wimboot) / sanboot entries which don't take a
/// kernel cmdline.
#[must_use] #[must_use]
pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String { pub fn render_entry(
entry: &BootEntry,
settings: &Settings,
base_url: &str,
unattended_args: Option<&str>,
) -> String {
let mut s = String::new(); let mut s = String::new();
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
@@ -477,6 +490,12 @@ pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> S
cmdline.push(' '); cmdline.push(' ');
cmdline.push_str(settings.extra_kernel_args.trim()); cmdline.push_str(settings.extra_kernel_args.trim());
} }
if let Some(extra) = unattended_args {
if !extra.trim().is_empty() {
cmdline.push(' ');
cmdline.push_str(extra.trim());
}
}
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}"); let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}");
for u in initrd_urls { for u in initrd_urls {
let _ = writeln!(s, "initrd {base}/{u}"); let _ = writeln!(s, "initrd {base}/{u}");
+2
View File
@@ -18,6 +18,8 @@ pub mod auth;
pub mod ipxe_script; pub mod ipxe_script;
pub mod iso_fs; pub mod iso_fs;
pub mod log_stream; pub mod log_stream;
pub mod notify;
pub mod saml_routes;
pub mod state; pub mod state;
pub mod terminal; pub mod terminal;
pub mod uploads; pub mod uploads;
+138
View File
@@ -0,0 +1,138 @@
//! Notification *delivery* — the network half of the notify feature.
//!
//! `openpxe_core::notify` owns the config + persistence; this module
//! turns a `NotifyConfig` + a message into an actual delivery:
//!
//! - Slack / Discord / Teams → HTTP POST of a provider-shaped JSON
//! body to the operator's incoming-webhook URL (via `reqwest`).
//! - SMTP → a TLS email via `lettre`.
//!
//! Every send is best-effort and time-bounded: a flaky webhook must
//! never wedge a PXE boot. Callers fire these from a detached task.
use openpxe_core::{NotifyConfig, NotifyKind};
use std::time::Duration;
/// Hard ceiling on any single delivery so a hung endpoint can't pin a
/// task forever.
const SEND_TIMEOUT: Duration = Duration::from_secs(10);
/// Deliver `body` (with an optional `subject`, used as the email
/// subject / chat bold-line) using the active provider in `cfg`.
/// Returns `Ok(())` on success, or a human-readable error suitable for
/// surfacing in the "Send test" response.
pub async fn send(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
if !cfg.is_usable() {
return Err("notifications are not enabled / fully configured".into());
}
match cfg.kind {
NotifyKind::Slack | NotifyKind::Discord | NotifyKind::Teams => {
send_webhook(cfg, subject, body).await
}
NotifyKind::Smtp => send_email(cfg, subject, body).await,
}
}
async fn send_webhook(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
// Each chat platform wants a different JSON shape for an incoming
// webhook. Keep the bodies minimal and plain-text-ish so they
// render cleanly everywhere.
let combined = if subject.is_empty() {
body.to_string()
} else {
format!("*{subject}*\n{body}")
};
let payload = match cfg.kind {
NotifyKind::Slack => serde_json::json!({ "text": combined }),
NotifyKind::Discord => serde_json::json!({ "content": combined }),
NotifyKind::Teams => serde_json::json!({
// Legacy MessageCard — the format every Teams "Incoming
// Webhook" connector still accepts.
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": if subject.is_empty() { "OpenPXE" } else { subject },
"title": subject,
"text": body,
}),
NotifyKind::Smtp => unreachable!("smtp handled separately"),
};
let client = reqwest::Client::builder()
.timeout(SEND_TIMEOUT)
.build()
.map_err(|e| format!("could not build HTTP client: {e}"))?;
let resp = client
.post(&cfg.webhook_url)
.json(&payload)
.send()
.await
.map_err(|e| format!("webhook POST failed: {e}"))?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let snippet = resp
.text()
.await
.unwrap_or_default()
.chars()
.take(200)
.collect::<String>();
Err(format!("webhook returned HTTP {status}: {snippet}"))
}
}
async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
let from = if cfg.smtp_from.trim().is_empty() {
cfg.smtp_username.trim()
} else {
cfg.smtp_from.trim()
};
if from.is_empty() {
return Err("SMTP requires a From address (or a username to fall back to)".into());
}
let email = Message::builder()
.from(
from.parse()
.map_err(|e| format!("invalid From address '{from}': {e}"))?,
)
.to(cfg
.smtp_to
.trim()
.parse()
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
.subject(if subject.is_empty() { "OpenPXE" } else { subject })
.body(body.to_string())
.map_err(|e| format!("could not build email: {e}"))?;
// Implicit TLS (465) vs STARTTLS (587). We never send plaintext.
let mut builder = if cfg.smtp_implicit_tls {
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.smtp_host)
.map_err(|e| format!("SMTP relay setup failed: {e}"))?
} else {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.smtp_host)
.map_err(|e| format!("SMTP STARTTLS setup failed: {e}"))?
}
.port(cfg.smtp_port)
.timeout(Some(SEND_TIMEOUT));
// Auth is optional — some internal relays accept unauthenticated
// mail from trusted hosts. Only attach credentials when a username
// is set.
if !cfg.smtp_username.trim().is_empty() {
builder = builder.credentials(Credentials::new(
cfg.smtp_username.trim().to_string(),
cfg.smtp_password.clone(),
));
}
let mailer = builder.build();
mailer
.send(email)
.await
.map(|_| ())
.map_err(|e| format!("SMTP send failed: {e}"))
}
+338
View File
@@ -0,0 +1,338 @@
//! SAML 2.0 Service Provider HTTP endpoints (v0.5.1).
//!
//! * `GET /api/sso/login` — SP-initiated: build an AuthnRequest, record its
//! ID, and 302 the browser to the IdP.
//! * `POST /api/sso/acs` — Assertion Consumer Service: verify + validate
//! the IdP's `SAMLResponse`, perform the stateful checks (InResponseTo
//! correlation, IdP-initiated gating, assertion replay), mint an operator
//! session, and 302 to the dashboard. (Mirrors FleetDM's `/sso/callback`.)
//! * `GET /api/sso/metadata` — serve our SP metadata XML for IdP import.
//!
//! Stateless crypto + semantic validation live in `openpxe_core::saml`; this
//! module owns only the HTTP glue and the in-memory state the SP needs.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration as StdDuration, Instant};
use axum::{
body::Body,
extract::{Form, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
};
use base64::Engine;
use parking_lot::Mutex;
use serde::Deserialize;
use time::{Duration, OffsetDateTime};
use openpxe_core::saml::{self, metadata::IdpMetadata, SamlError, SpParams};
use openpxe_core::SsoConfig;
use crate::auth;
use crate::state::AppState;
/// Outstanding AuthnRequest IDs live at most this long before a matching
/// response is considered stale (covers a slow human at the IdP login form).
const REQUEST_TTL: StdDuration = StdDuration::from_mins(10);
/// How long we fetch-cache IdP metadata loaded from a URL.
const METADATA_FETCH_TIMEOUT: StdDuration = StdDuration::from_secs(10);
/// In-memory SAML runtime state. Cheap to clone (Arc-shared).
#[derive(Clone, Default)]
pub struct SamlRuntime {
/// request_id → issued_at. Correlates a response's `InResponseTo` to a
/// request *we* actually sent (replay / CSRF defense for SP-initiated).
outstanding: Arc<Mutex<HashMap<String, Instant>>>,
/// assertion_id → expiry. A consumed assertion may not be replayed.
consumed: Arc<Mutex<HashMap<String, Instant>>>,
/// Cache of IdP metadata fetched from a URL: (url, parsed).
metadata_cache: Arc<Mutex<Option<(String, IdpMetadata)>>>,
}
impl SamlRuntime {
/// Record an AuthnRequest we just sent.
pub fn register_request(&self, id: &str) {
let mut g = self.outstanding.lock();
prune(&mut g);
g.insert(id.to_owned(), Instant::now());
}
/// Consume an outstanding request ID, returning `true` if it was present
/// and still fresh. A miss means the response doesn't correlate to any
/// live request we issued.
pub fn take_request(&self, id: &str) -> bool {
let mut g = self.outstanding.lock();
prune(&mut g);
g.remove(id).is_some()
}
/// Record a consumed assertion. Returns `false` if it was already
/// consumed (a replay) — in which case the caller must reject.
pub fn record_assertion(&self, id: &str, expiry: OffsetDateTime) -> bool {
let mut g = self.consumed.lock();
prune(&mut g);
if g.contains_key(id) {
return false;
}
let ttl = (expiry - OffsetDateTime::now_utc())
.max(Duration::ZERO)
.unsigned_abs();
g.insert(id.to_owned(), Instant::now() + ttl);
true
}
fn cached_metadata(&self, url: &str) -> Option<IdpMetadata> {
let g = self.metadata_cache.lock();
match &*g {
Some((cached_url, md)) if cached_url == url => Some(md.clone()),
_ => None,
}
}
fn cache_metadata(&self, url: String, md: IdpMetadata) {
*self.metadata_cache.lock() = Some((url, md));
}
}
/// Drop expired entries so neither map grows unbounded.
fn prune(map: &mut HashMap<String, Instant>) {
let now = Instant::now();
// For the request map this over-prunes (entries store issued_at, not
// expiry), so cap by REQUEST_TTL; the consumed map stores absolute
// expiry instants. Using saturating logic keeps both correct: request
// entries older than REQUEST_TTL go, consumed entries past expiry go.
map.retain(|_, &mut t| now.saturating_duration_since(t) < REQUEST_TTL || t > now);
}
// ─── GET /api/sso/login ───────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct LoginQuery {
/// Optional local path to return to after login (becomes RelayState).
#[serde(default)]
pub next: Option<String>,
}
pub async fn sso_login(State(state): State<AppState>, Query(q): Query<LoginQuery>) -> Response {
let cfg = state.sso.snapshot();
if !cfg.is_usable() {
return redirect("/?sso_error=unavailable");
}
let idp = match resolve_idp_metadata(&state, &cfg).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(target: "openpxe::saml", "sso_login: metadata unavailable: {e}");
return redirect("/?sso_error=metadata");
}
};
let Some(dest) = idp.sso_destination().map(str::to_owned) else {
tracing::warn!(target: "openpxe::saml", "sso_login: IdP metadata has no SSO endpoint");
return redirect("/?sso_error=metadata");
};
let sp = sp_params(&state, &cfg);
let relay = safe_local_path(q.next.as_deref());
match saml::authn_request::build(&sp, &dest, Some(&relay)) {
Ok(req) => {
state.saml.register_request(&req.id);
redirect(&req.location)
}
Err(e) => {
tracing::warn!(target: "openpxe::saml", "sso_login: build AuthnRequest failed: {e}");
redirect("/?sso_error=request")
}
}
}
// ─── POST /api/sso/acs ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct AcsForm {
#[serde(rename = "SAMLResponse")]
pub saml_response: String,
#[serde(rename = "RelayState", default)]
pub relay_state: Option<String>,
}
pub async fn sso_acs(State(state): State<AppState>, Form(form): Form<AcsForm>) -> Response {
let cfg = state.sso.snapshot();
if !cfg.is_usable() {
return redirect("/?sso_error=unavailable");
}
let xml = match base64::engine::general_purpose::STANDARD.decode(form.saml_response.as_bytes())
{
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) => {
tracing::warn!(target: "openpxe::saml", "acs: base64 decode failed: {e}");
return redirect("/?sso_error=1");
}
};
let idp = match resolve_idp_metadata(&state, &cfg).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(target: "openpxe::saml", "acs: metadata unavailable: {e}");
return redirect("/?sso_error=metadata");
}
};
let sp = sp_params(&state, &cfg);
// Signature verification + semantic checks are CPU-bound — keep them off
// the async executor.
let now = OffsetDateTime::now_utc();
let skew = Duration::seconds(saml::DEFAULT_CLOCK_SKEW_SECS);
let verify = {
let xml = xml.clone();
let sp = sp.clone();
tokio::task::spawn_blocking(move || saml::response::consume(&xml, &sp, &idp, now, skew))
.await
};
let verified = match verify {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
// Never leak which specific check failed to the browser.
tracing::warn!(target: "openpxe::saml", "acs: response rejected: {e}");
return redirect("/?sso_error=1");
}
Err(join) => {
tracing::error!(target: "openpxe::saml", "acs: verify task panicked: {join}");
return redirect("/?sso_error=1");
}
};
// Stateful checks the core deliberately left to us.
match &verified.in_response_to {
Some(id) => {
if !state.saml.take_request(id) {
tracing::warn!(target: "openpxe::saml", "acs: InResponseTo matches no live request");
return redirect("/?sso_error=1");
}
}
None => {
if !cfg.allow_idp_initiated {
tracing::warn!(target: "openpxe::saml", "acs: IdP-initiated login is disabled");
return redirect("/?sso_error=idp_initiated");
}
}
}
if !state
.saml
.record_assertion(&verified.assertion_id, verified.assertion_expiry)
{
tracing::warn!(target: "openpxe::saml", "acs: assertion replay rejected");
return redirect("/?sso_error=1");
}
// Success → mint an operator session keyed to the verified email.
let session = state.sessions.create(&verified.principal.email);
tracing::info!(
target: "openpxe::saml",
email = %verified.principal.email,
idp_initiated = verified.in_response_to.is_none(),
"SAML SSO sign-in"
);
// safe_local_path already maps None / unsafe values to "/".
let relay = safe_local_path(form.relay_state.as_deref());
redirect_with_session(&relay, &session)
}
// ─── GET /api/sso/metadata ──────────────────────────────────────────────────
pub async fn sso_metadata(State(state): State<AppState>) -> Response {
let cfg = state.sso.snapshot();
let sp = sp_params(&state, &cfg);
let xml = saml::metadata::build_sp_metadata(&sp);
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/samlmetadata+xml")],
xml,
)
.into_response()
}
// ─── helpers ────────────────────────────────────────────────────────────────
/// Derive runtime SP parameters from config + the advertised public base URL.
fn sp_params(state: &AppState, cfg: &SsoConfig) -> SpParams {
let base = state.public_base_url.trim_end_matches('/');
let entity_id = if cfg.entity_id.trim().is_empty() {
base.to_owned()
} else {
cfg.entity_id.trim().to_owned()
};
SpParams {
entity_id,
acs_url: format!("{base}/api/sso/acs"),
}
}
/// Resolve the IdP metadata: prefer the metadata URL (fetched + cached) per
/// the "URL wins" rule, else parse the pasted XML.
async fn resolve_idp_metadata(state: &AppState, cfg: &SsoConfig) -> Result<IdpMetadata, SamlError> {
let url = cfg.metadata_url.trim();
if !url.is_empty() {
if let Some(md) = state.saml.cached_metadata(url) {
return Ok(md);
}
let body = fetch_metadata(url).await?;
let md = IdpMetadata::parse(&body)?;
state.saml.cache_metadata(url.to_owned(), md.clone());
return Ok(md);
}
if !cfg.metadata.trim().is_empty() {
return IdpMetadata::parse(&cfg.metadata);
}
Err(SamlError::Metadata("no metadata source configured".into()))
}
async fn fetch_metadata(url: &str) -> Result<String, SamlError> {
let client = reqwest::Client::builder()
.timeout(METADATA_FETCH_TIMEOUT)
.build()
.map_err(|e| SamlError::Metadata(format!("http client: {e}")))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| SamlError::Metadata(format!("fetch {url}: {e}")))?;
if !resp.status().is_success() {
return Err(SamlError::Metadata(format!(
"fetch {url}: HTTP {}",
resp.status()
)));
}
resp.text()
.await
.map_err(|e| SamlError::Metadata(format!("read {url}: {e}")))
}
/// Only permit a same-site path (single leading slash) as a redirect target —
/// blocks open-redirect / protocol-relative (`//evil.com`) abuse of RelayState.
fn safe_local_path(p: Option<&str>) -> String {
match p {
Some(p) if p.starts_with('/') && !p.starts_with("//") => p.to_owned(),
_ => "/".to_owned(),
}
}
fn redirect(location: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.body(Body::empty())
.map_or_else(
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
IntoResponse::into_response,
)
}
fn redirect_with_session(location: &str, session: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.header(header::SET_COOKIE, auth::session_cookie(session))
.body(Body::empty())
.map_or_else(
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
IntoResponse::into_response,
)
}
+19 -5
View File
@@ -1,10 +1,11 @@
use crate::uploads::UploadSessions;
use crate::auth::SessionStore; use crate::auth::SessionStore;
use crate::saml_routes::SamlRuntime;
use crate::uploads::UploadSessions;
use openpxe_core::{ use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, SettingsStore, SsoStore, Metrics, NotifyStore, SettingsStore, SsoStore,
}; };
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager}; use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager, UnattendedStore};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -34,9 +35,17 @@ pub struct AppState {
/// process restart (sessions are tied to UI state, not persisted — /// process restart (sessions are tied to UI state, not persisted —
/// matches Sonarr/Radarr behaviour). /// matches Sonarr/Radarr behaviour).
pub sessions: SessionStore, pub sessions: SessionStore,
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login /// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles).
/// flow ships in a later release.
pub sso: SsoStore, pub sso: SsoStore,
/// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs
/// (for InResponseTo correlation), consumed-assertion replay guard, and
/// a cache of fetched IdP metadata. Tied to process lifetime, like
/// `sessions`; a restart simply invalidates any in-flight SSO login.
pub saml: SamlRuntime,
/// v0.5.0: webhook / email notification config (Slack/Teams/Discord/
/// SMTP). Drives the fire-and-forget pings on boot events and powers
/// the Advanced tab's config + "Send test" button.
pub notify: NotifyStore,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus /// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics). /// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics, pub metrics: Metrics,
@@ -58,6 +67,11 @@ pub struct AppState {
/// In-process (no subprocess); supports HTTP Range requests on /// In-process (no subprocess); supports HTTP Range requests on
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset. /// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
pub nfs_shares: NfsShareManager, pub nfs_shares: NfsShareManager,
/// v0.5.2: uploaded unattended-install answer files (Kickstart /
/// Preseed / Autoinstall / Windows answer files). Served on demand to
/// booting clients with per-host hostname/IP/MAC templating; lives in
/// its own directory, never the ISO listing or PXE menu.
pub unattended: UnattendedStore,
/// Browser chunked upload state. Multipart uploads still go straight /// Browser chunked upload state. Multipart uploads still go straight
/// through `IsoStore`, but the UI uses sessions so large ISO transfers /// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files. /// can show deterministic progress and leave visible partial files.
+577 -6
View File
@@ -96,12 +96,15 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let settings = SettingsStore::load_or_default(dir.path()); let settings = SettingsStore::load_or_default(dir.path());
let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone()); let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone()); let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone());
let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended"));
unattended.ensure_dir().await.unwrap();
let log_bus = LogBus::new(64); let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path()); let hosts = HostBindings::load_or_default(dir.path());
let boot_log = openpxe_core::BootLog::load_or_default(dir.path()); let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
let branding = openpxe_core::BrandingStore::load_or_default(dir.path()); let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
let admin = openpxe_core::AdminStore::load_or_default(dir.path()); let admin = openpxe_core::AdminStore::load_or_default(dir.path());
let sso = openpxe_core::SsoStore::load_or_default(dir.path()); let sso = openpxe_core::SsoStore::load_or_default(dir.path());
let notify = openpxe_core::NotifyStore::load_or_default(dir.path());
let sessions = openpxe_http_api::auth::SessionStore::default(); let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new(); let metrics = Metrics::new();
let state = AppState { let state = AppState {
@@ -115,10 +118,13 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
admin, admin,
sessions, sessions,
sso, sso,
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
notify,
metrics, metrics,
smb: None, smb: None,
smb_shares, smb_shares,
nfs_shares, nfs_shares,
unattended,
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus, log_bus,
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
@@ -535,6 +541,57 @@ async fn smb_shares_list_starts_empty() {
// v0.4.67: NFSv3 share manager (parallel to SMB). // v0.4.67: NFSv3 share manager (parallel to SMB).
// ── v0.5.0: notifications + Wake-on-LAN ────────────────────────────────────
#[tokio::test]
async fn notify_config_round_trips_and_redacts_smtp_password() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Save an SMTP config with a password.
let (s, _b) = put_json(
&app,
"/api/notify",
r#"{"enabled":true,"kind":"smtp","smtp_host":"smtp.example.com","smtp_port":587,"smtp_to":"[email protected]","smtp_from":"[email protected]","smtp_password":"s3cret"}"#,
)
.await;
assert_eq!(s, StatusCode::OK);
// GET must redact the password (never echo the real secret).
let (s, b) = get(&app, "/api/notify").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["enabled"], true);
assert_eq!(v["kind"], "smtp");
let pw = v["smtp_password"].as_str().unwrap_or("");
assert_ne!(pw, "s3cret", "raw password must never be returned");
assert!(
!pw.is_empty(),
"a set password should surface as a sentinel"
);
}
#[tokio::test]
async fn notify_enable_webhook_without_url_is_rejected() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, _b) = put_json(
&app,
"/api/notify",
r#"{"enabled":true,"kind":"slack","webhook_url":""}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn wol_on_unbound_mac_is_404() {
// WoL only fires for bound MACs — an arbitrary MAC must 404 so the
// endpoint isn't an open packet sprayer.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, _b) = post_json(&app, "/api/hosts/aa:bb:cc:dd:ee:ff/wol", "{}").await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test] #[tokio::test]
async fn nfs_shares_list_starts_empty() { async fn nfs_shares_list_starts_empty() {
let (state, _dir) = build_state().await; let (state, _dir) = build_state().await;
@@ -1434,7 +1491,8 @@ async fn api_docs_lists_known_endpoints() {
"/api/isos", "/api/isos",
"/api/isos/:id/category", "/api/isos/:id/category",
"/api/storage/disk", "/api/storage/disk",
"/api/branding/logo", "/api/branding/logo/:slot",
"/api/unattended",
"/api/boot-log", "/api/boot-log",
"/metrics", "/metrics",
] { ] {
@@ -1455,7 +1513,7 @@ async fn branding_clear_when_no_logo_is_no_content() {
.oneshot( .oneshot(
Request::builder() Request::builder()
.method("DELETE") .method("DELETE")
.uri("/api/branding/logo") .uri("/api/branding/logo/dark")
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
@@ -1501,7 +1559,11 @@ async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode,
// ─── v0.4.5: Forms auth + SSO ───────────────────────────────────────────── // ─── v0.4.5: Forms auth + SSO ─────────────────────────────────────────────
async fn post_collect(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>, Vec<axum::http::HeaderValue>) { async fn post_collect(
router: &axum::Router,
path: &str,
body: &str,
) -> (StatusCode, Vec<u8>, Vec<axum::http::HeaderValue>) {
let res = router let res = router
.clone() .clone()
.oneshot( .oneshot(
@@ -1892,6 +1954,7 @@ async fn pxe_background_falls_back_to_default_for_svg_upload() {
state state
.branding .branding
.set_logo( .set_logo(
openpxe_core::LogoSlot::Client,
"image/svg+xml", "image/svg+xml",
"svg", "svg",
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#, br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#,
@@ -1912,7 +1975,10 @@ async fn pxe_background_falls_back_to_default_for_svg_upload() {
let body = axum::body::to_bytes(res.into_body(), usize::MAX) let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await .await
.unwrap(); .unwrap();
assert!(body.starts_with(b"\x89PNG"), "should serve default PNG for SVG"); assert!(
body.starts_with(b"\x89PNG"),
"should serve default PNG for SVG"
);
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]); let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
assert_eq!(width, 1024); assert_eq!(width, 1024);
} }
@@ -1926,7 +1992,7 @@ async fn pxe_logo_composes_to_1024x768_png() {
let png = tiny_png(); let png = tiny_png();
state state
.branding .branding
.set_logo("image/png", "png", &png) .set_logo(openpxe_core::LogoSlot::Client, "image/png", "png", &png)
.unwrap(); .unwrap();
let app = build_router(state); let app = build_router(state);
let res = app let res = app
@@ -1969,7 +2035,7 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
let png = tiny_png(); let png = tiny_png();
state state
.branding .branding
.set_logo("image/png", "png", &png) .set_logo(openpxe_core::LogoSlot::Client, "image/png", "png", &png)
.unwrap(); .unwrap();
let app = build_router(state); let app = build_router(state);
// Configure an admin so the middleware kicks in. // Configure an admin so the middleware kicks in.
@@ -1984,3 +2050,508 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
let (s, _) = get(&app, "/branding/pxe-logo").await; let (s, _) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
} }
// ─── v0.5.2: unattended files + deployment profiles ─────────────────────────
async fn post_multipart(
router: &axum::Router,
path: &str,
ct: &str,
body: Vec<u8>,
) -> (StatusCode, Vec<u8>) {
let res = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
let status = res.status();
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, body)
}
#[tokio::test]
async fn unattended_upload_list_serve_and_template() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let ks = b"install\nnetwork --hostname={{HOSTNAME}} --ip={{IP}}\n%packages\n@core\n%end\n";
let (ct, body) = multipart_iso_body("rocky.ks", ks);
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::CREATED, "{}", String::from_utf8_lossy(&b));
let m: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(m["kind"], "kickstart");
let id = m["id"].as_str().unwrap().to_string();
let (s, b) = get(&app, "/api/unattended").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["files"].as_array().unwrap().len(), 1);
// Public serve substitutes the query tokens.
let (s, b) = get(
&app,
&format!("/unattended/{id}?hostname=node7&ip=10.0.0.7"),
)
.await;
assert_eq!(s, StatusCode::OK);
let text = String::from_utf8_lossy(&b);
assert!(text.contains("--hostname=node7"), "got: {text}");
assert!(text.contains("--ip=10.0.0.7"), "got: {text}");
assert!(!text.contains("{{"), "tokens left unrendered: {text}");
// Delete.
let res = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/unattended/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (_, b) = get(&app, "/api/unattended").await;
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["files"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn unattended_upload_rejects_bad_type() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("evil.sh", b"#!/bin/sh\n");
let (s, _) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn host_pin_with_unattended_injects_kickstart_arg() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Upload a Linux ISO → synthesises the `fake-alpine-linux` LinuxKernel entry.
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
// Upload a kickstart.
let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n");
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let ks_id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// Pin a MAC to the Linux entry with the unattended profile.
let mac = "aa:bb:cc:dd:ee:01";
let pin = format!(
r#"{{"mac":"{mac}","target":"fake-alpine-linux","label":"lab","auto_hostname":"node7","auto_ip":"10.0.0.7","unattended_file":"{ks_id}"}}"#
);
let (s, b) = post_json(&app, "/api/hosts", &pin).await;
assert_eq!(s, StatusCode::CREATED, "{}", String::from_utf8_lossy(&b));
// Boot the entry as that MAC; the kernel line should carry inst.ks=.
let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8_lossy(&b);
assert!(
script.contains("inst.ks="),
"no kickstart arg injected:\n{script}"
);
assert!(
script.contains("hostname=node7"),
"hostname not passed:\n{script}"
);
}
#[tokio::test]
async fn host_pin_rejects_unknown_unattended_file() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let pin = r#"{"mac":"aa:bb:cc:dd:ee:02","target":"fake-alpine-linux","unattended_file":"does-not-exist"}"#;
let (s, _) = post_json(&app, "/api/hosts", pin).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn host_pin_rejects_bad_auto_ip() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let pin = r#"{"mac":"aa:bb:cc:dd:ee:03","target":"fake-alpine-linux","auto_ip":"not-an-ip"}"#;
let (s, _) = post_json(&app, "/api/hosts", pin).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn per_theme_logo_and_favicon_serve() {
let (state, _dir) = build_state().await;
// Light slot only; dark falls back to it, favicon stays bundled.
let png = tiny_png();
state
.branding
.set_logo(openpxe_core::LogoSlot::Light, "image/png", "png", &png)
.unwrap();
let app = build_router(state);
// Light theme → the uploaded PNG.
let (s, b) = get(&app, "/assets/logo.svg?theme=light").await;
assert_eq!(s, StatusCode::OK);
assert!(b.starts_with(b"\x89PNG"), "light slot should serve the PNG");
// Dark theme → falls back to the light PNG (only slot set).
let (s, b) = get(&app, "/assets/logo.svg?theme=dark").await;
assert_eq!(s, StatusCode::OK);
assert!(
b.starts_with(b"\x89PNG"),
"dark should fall back to light PNG"
);
// Favicon is always the bundled SVG, never the custom raster.
let (s, b) = get(&app, "/assets/favicon.svg").await;
assert_eq!(s, StatusCode::OK);
let txt = String::from_utf8_lossy(&b);
assert!(txt.contains("<svg"), "favicon must be the bundled SVG mark");
}
#[tokio::test]
async fn branding_slot_rejects_unknown_and_client_svg() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Unknown slot name → 400.
let (ct, body) = multipart_iso_body("logo.png", &tiny_png());
let (s, _) = post_multipart(&app, "/api/branding/logo/sideways", &ct, body).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// SVG into the client (PXE) slot → 400 (raster-only).
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#;
let boundary = "----OpenPxeTestBoundary1234";
let mut b = Vec::new();
b.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
b.extend_from_slice(b"Content-Disposition: form-data; name=\"file\"; filename=\"l.svg\"\r\n");
b.extend_from_slice(b"Content-Type: image/svg+xml\r\n\r\n");
b.extend_from_slice(svg);
b.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
let ct = format!("multipart/form-data; boundary={boundary}");
let (s, _) = post_multipart(&app, "/api/branding/logo/client", &ct, b).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
// ─── v0.5.1: SAML SSO flow ──────────────────────────────────────────────────
//
// The core crate exhaustively tests signature verification + semantic
// validation (crates/core/src/saml/tests.rs). These integration tests cover
// the HTTP wiring the core can't: routing, base64 decode, session minting,
// the InResponseTo / IdP-initiated gating, and assertion-replay rejection.
use base64::Engine as _;
use openpxe_core::SsoConfig;
use time::format_description::well_known::Rfc3339;
use time::{Duration as TimeDuration, OffsetDateTime};
const SP_BASE: &str = "http://127.0.0.1"; // build_state's public_base_url
const SP_ACS: &str = "http://127.0.0.1/api/sso/acs";
const IDP_ENTITY: &str = "https://idp.test/realms/fleet";
const IDP_SSO: &str = "https://idp.test/realms/fleet/protocol/saml";
struct TestIdp {
cert_b64: String,
key_pem: String,
}
fn make_idp() -> TestIdp {
let ck = rcgen::generate_simple_self_signed(vec!["idp.test".to_string()]).unwrap();
let der = ck.cert.der().as_ref().to_vec();
TestIdp {
cert_b64: base64::engine::general_purpose::STANDARD.encode(der),
key_pem: ck.key_pair.serialize_pem(),
}
}
fn idp_metadata_xml(cert_b64: &str) -> String {
format!(
r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="{IDP_ENTITY}">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing"><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert_b64}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{IDP_SSO}"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#
)
}
/// Build + sign a SAMLResponse with the test IdP key. `in_response_to: None`
/// makes it an unsolicited (IdP-initiated) response.
fn signed_response(idp: &TestIdp, in_response_to: Option<&str>) -> String {
let now = OffsetDateTime::now_utc().replace_nanosecond(0).unwrap();
let fmt = |t: OffsetDateTime| t.format(&Rfc3339).unwrap();
let irt = in_response_to
.map(|v| format!(r#" InResponseTo="{v}""#))
.unwrap_or_default();
let template = format!(
r##"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_resp1" Version="2.0" IssueInstant="{now}" Destination="{SP_ACS}"{irt}>
<saml:Issuer>{IDP_ENTITY}</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="{now}">
<saml:Issuer>{IDP_ENTITY}</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
<ds:Reference URI="#_assertion1">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue></ds:SignatureValue>
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">[email protected]</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData Recipient="{SP_ACS}" NotOnOrAfter="{noa}"{irt}/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">
<saml:AudienceRestriction><saml:Audience>{SP_BASE}</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{now}" SessionIndex="sess-1">
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
</saml:AuthnStatement>
</saml:Assertion>
</samlp:Response>"##,
now = fmt(now),
nb = fmt(now - TimeDuration::minutes(5)),
noa = fmt(now + TimeDuration::hours(1)),
);
let key = bergshamra::keys::loader::load_pem_auto(idp.key_pem.as_bytes(), None).unwrap();
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
let ctx = bergshamra::DsigContext::new(km);
bergshamra::sign(&ctx, &template).unwrap()
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push(
char::from_digit((b >> 4) as u32, 16)
.unwrap()
.to_ascii_uppercase(),
);
out.push(
char::from_digit((b & 0xf) as u32, 16)
.unwrap()
.to_ascii_uppercase(),
);
}
}
}
out
}
fn configure_sso(state: &AppState, metadata: String, allow_idp_initiated: bool) {
state
.sso
.replace(SsoConfig {
enabled: true,
idp_name: "Test IdP".into(),
idp_logo_url: String::new(),
metadata,
metadata_url: String::new(),
entity_id: String::new(),
allow_idp_initiated,
})
.unwrap();
}
async fn post_acs(router: &axum::Router, signed_xml: &str) -> axum::response::Response {
let b64 = base64::engine::general_purpose::STANDARD.encode(signed_xml.as_bytes());
let body = format!("SAMLResponse={}", urlencode(&b64));
router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/sso/acs")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
fn has_session_cookie(resp: &axum::response::Response) -> bool {
resp.headers().get_all(header::SET_COOKIE).iter().any(|v| {
let s = v.to_str().unwrap_or("");
s.starts_with("openpxe_session=")
&& !s.contains("openpxe_session=;")
&& !s.contains("Max-Age=0")
})
}
fn location(resp: &axum::response::Response) -> String {
resp.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned()
}
#[tokio::test]
async fn sso_login_redirects_to_idp() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), false);
let app = build_router(state);
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/api/sso/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
let loc = location(&resp);
assert!(loc.starts_with(IDP_SSO), "redirect to IdP, got {loc}");
assert!(
loc.contains("SAMLRequest="),
"carries SAMLRequest, got {loc}"
);
}
#[tokio::test]
async fn sso_login_unavailable_when_disabled() {
let (state, _dir) = build_state().await;
let app = build_router(state); // SSO never configured
let resp = app
.oneshot(
Request::builder()
.uri("/api/sso/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
assert!(location(&resp).contains("sso_error"));
}
#[tokio::test]
async fn sso_metadata_is_served() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (status, body) = get(&app, "/api/sso/metadata").await;
assert_eq!(status, StatusCode::OK);
let xml = String::from_utf8(body).unwrap();
assert!(xml.contains("SPSSODescriptor"));
assert!(xml.contains(SP_ACS));
assert!(xml.contains(SP_BASE));
}
#[tokio::test]
async fn acs_idp_initiated_mints_session() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
let app = build_router(state);
let signed = signed_response(&idp, None);
let resp = post_acs(&app, &signed).await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(location(&resp), "/");
assert!(
has_session_cookie(&resp),
"ACS must set an operator session cookie"
);
}
#[tokio::test]
async fn acs_idp_initiated_blocked_when_disabled() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), false); // gate OFF
let app = build_router(state);
let signed = signed_response(&idp, None);
let resp = post_acs(&app, &signed).await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert!(location(&resp).contains("sso_error"));
assert!(
!has_session_cookie(&resp),
"no session when IdP-initiated is disabled"
);
}
#[tokio::test]
async fn acs_sp_initiated_without_known_request_is_rejected() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
let app = build_router(state);
// A valid signature but an InResponseTo we never issued => reject.
let signed = signed_response(&idp, Some("_never-issued"));
let resp = post_acs(&app, &signed).await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert!(location(&resp).contains("sso_error"));
assert!(!has_session_cookie(&resp));
}
#[tokio::test]
async fn acs_replayed_assertion_is_rejected() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
let app = build_router(state);
let signed = signed_response(&idp, None);
// First use succeeds…
let first = post_acs(&app, &signed).await;
assert!(has_session_cookie(&first));
// …replaying the identical assertion is rejected.
let second = post_acs(&app, &signed).await;
assert_eq!(second.status(), StatusCode::FOUND);
assert!(location(&second).contains("sso_error"));
assert!(!has_session_cookie(&second));
}
#[tokio::test]
async fn acs_garbage_is_rejected_without_500() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
let app = build_router(state);
let body = "SAMLResponse=not%20valid%20base64%21%21";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/sso/acs")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
assert!(location(&resp).contains("sso_error"));
assert!(!has_session_cookie(&resp));
}
+6 -2
View File
@@ -23,6 +23,7 @@ pub mod pxe_logo;
pub mod smb; pub mod smb;
pub mod smb_share; pub mod smb_share;
pub mod store; pub mod store;
pub mod unattended;
pub mod windows; pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs}; pub use entry::{BootEntry, BootKind, KernelArgs};
@@ -40,7 +41,10 @@ pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, Smb
// Range requests because NFSv3 READ3 takes an explicit offset. // Range requests because NFSv3 READ3 takes an explicit offset.
pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream}; pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream};
pub use store::{ pub use store::{
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle,
UploadHandle, };
pub use unattended::{
classify as classify_unattended, render_template, UnattendedKind, UnattendedMeta,
UnattendedStore, MAX_UNATTENDED_BYTES,
}; };
pub use windows::{WimPatcher, WinPatchState}; pub use windows::{WimPatcher, WinPatchState};
+412
View File
@@ -0,0 +1,412 @@
//! Unattended-install answer-file store (v0.5.2).
//!
//! Operators upload the answer file their installer expects — a RHEL/
//! Fedora **Kickstart**, a Debian **Preseed**, an Ubuntu **Autoinstall**
//! cloud-init user-data, or a Windows **answer file** (`autounattend.xml`)
//! — and OpenPXE serves it on demand to the booting machine. Files live
//! in their own directory (`<unattended_dir>/`), deliberately *not* under
//! `iso_dir`, so they never appear in the ISO listing or the PXE menu.
//!
//! Storage mirrors [`crate::store::IsoStore`]: in-memory map authoritative
//! for the process, sidecar `*.meta.json` on disk is the source of truth on
//! restart. The raw answer file sits beside it as `<id>.file`.
//!
//! Templating is applied at *serve* time, not store time — see
//! [`render_template`]. The stored bytes are exactly what the operator
//! uploaded; per-host hostname/IP/MAC values are substituted into a copy
//! when the file is fetched for a specific client.
use crate::store::slugify_str;
use openpxe_core::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
/// Disk + memory cap for one answer file. Kickstarts/preseeds/cloud-init
/// configs are a few KB; 1 MiB is a comfortable ceiling that still bounds
/// abuse.
pub const MAX_UNATTENDED_BYTES: usize = 1024 * 1024;
/// Which installer the answer file targets. Drives the kernel-argument
/// injection in the boot chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnattendedKind {
/// RHEL / Fedora / CentOS / AlmaLinux / Rocky — `inst.ks=<url>`.
Kickstart,
/// Debian / older Ubuntu — `auto=true priority=critical url=<url>`.
Preseed,
/// Ubuntu 20.04+ Subiquity autoinstall — cloud-init NoCloud:
/// `autoinstall ds=nocloud-net;s=<url>/`.
Autoinstall,
/// Windows Setup answer file (`autounattend.xml`). Served, not
/// auto-injected (Windows reads it from media/USB, not a kernel arg).
AnswerFile,
/// Couldn't classify — stored + served, no auto-injection.
#[default]
Unknown,
}
impl UnattendedKind {
#[must_use]
pub fn label(self) -> &'static str {
match self {
UnattendedKind::Kickstart => "Kickstart",
UnattendedKind::Preseed => "Preseed",
UnattendedKind::Autoinstall => "Autoinstall",
UnattendedKind::AnswerFile => "Answer file",
UnattendedKind::Unknown => "Unknown",
}
}
}
/// Lowercase file extension (no dot), or `None` if there isn't one.
fn ext_lower(filename: &str) -> Option<String> {
std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
}
/// Classify an upload from its filename + a peek at its content. Best
/// effort: extension first, then a content sniff to disambiguate the
/// `.cfg` case (both Kickstart and Preseed use it).
#[must_use]
pub fn classify(filename: &str, content: &[u8]) -> UnattendedKind {
let lower_name = filename.to_ascii_lowercase();
let ext = ext_lower(filename);
let text = String::from_utf8_lossy(&content[..content.len().min(8192)]);
let looks_preseed = text.contains("d-i ") || text.contains("preseed/");
let looks_kickstart = text.contains("%packages")
|| text.contains("\nlang ")
|| text.contains("\nkeyboard ")
|| text.contains("bootloader --")
|| text.starts_with("install");
let looks_cloud_init = text.contains("autoinstall")
|| text.contains("#cloud-config")
|| text.contains("version: 1");
match ext.as_deref() {
Some("ks") => return UnattendedKind::Kickstart,
Some("seed") => return UnattendedKind::Preseed,
Some("xml") => return UnattendedKind::AnswerFile,
Some("yaml" | "yml") => return UnattendedKind::Autoinstall,
Some("cfg") => {
return if looks_kickstart && !looks_preseed {
UnattendedKind::Kickstart
} else {
UnattendedKind::Preseed
};
}
_ => {}
}
if lower_name == "user-data" {
return UnattendedKind::Autoinstall;
}
// No recognised extension — fall back to content sniffing.
if looks_cloud_init {
UnattendedKind::Autoinstall
} else if looks_kickstart {
UnattendedKind::Kickstart
} else if looks_preseed {
UnattendedKind::Preseed
} else {
UnattendedKind::Unknown
}
}
/// True if the filename carries an extension we accept for upload. We
/// also accept the bare `user-data` name (cloud-init NoCloud convention).
#[must_use]
pub fn is_accepted_filename(filename: &str) -> bool {
if filename.trim().eq_ignore_ascii_case("user-data") {
return true;
}
matches!(
ext_lower(filename).as_deref(),
Some("ks" | "cfg" | "seed" | "yaml" | "yml" | "xml")
)
}
/// Sidecar metadata for a stored answer file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnattendedMeta {
/// URL-safe slug, unique within the store.
pub id: String,
/// Original upload filename, shown in the UI.
pub filename: String,
pub kind: UnattendedKind,
pub size_bytes: u64,
#[serde(with = "time::serde::rfc3339")]
pub uploaded_at: OffsetDateTime,
}
#[derive(Debug, Default)]
struct Inner {
files: HashMap<String, UnattendedMeta>,
}
/// In-memory + on-disk answer-file registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct UnattendedStore {
dir: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl UnattendedStore {
#[must_use]
pub fn new(dir: PathBuf) -> Self {
Self {
dir: Arc::new(dir),
inner: Arc::new(RwLock::new(Inner::default())),
}
}
pub async fn ensure_dir(&self) -> Result<()> {
tokio::fs::create_dir_all(self.dir.as_path()).await?;
Ok(())
}
/// Scan the directory on startup, loading every `*.meta.json` sidecar.
pub async fn load_from_disk(&self) -> Result<()> {
self.ensure_dir().await?;
let mut entries = tokio::fs::read_dir(self.dir.as_path()).await?;
while let Some(e) = entries.next_entry().await? {
let p = e.path();
let is_meta = p
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.ends_with(".meta.json"));
if !is_meta {
continue;
}
if let Ok(text) = tokio::fs::read_to_string(&p).await {
if let Ok(meta) = serde_json::from_str::<UnattendedMeta>(&text) {
self.inner.write().files.insert(meta.id.clone(), meta);
}
}
}
Ok(())
}
fn data_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.file"))
}
fn meta_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.meta.json"))
}
/// Mint a unique slug from the upload filename's stem.
fn unique_id(&self, filename: &str) -> String {
let stem = filename.rsplit_once('.').map_or(filename, |(s, _)| s);
let base = {
let s = slugify_str(stem);
if s.is_empty() {
"unattended".to_string()
} else {
s
}
};
let g = self.inner.read();
if !g.files.contains_key(&base) {
return base;
}
for n in 1.. {
let candidate = format!("{base}-{n}");
if !g.files.contains_key(&candidate) {
return candidate;
}
}
unreachable!("u64 ids exhausted")
}
/// Store an uploaded answer file. Validates type + size, classifies,
/// writes the bytes + a sidecar, and returns the new metadata.
pub async fn add(&self, filename: &str, bytes: &[u8]) -> Result<UnattendedMeta> {
if !is_accepted_filename(filename) {
return Err(Error::Invalid(format!(
"unsupported answer-file type '{filename}'. Accepted: .ks, .cfg, .seed, .yaml, .yml, .xml, user-data"
)));
}
if bytes.len() > MAX_UNATTENDED_BYTES {
return Err(Error::Invalid(format!(
"answer file too large ({} bytes, max {MAX_UNATTENDED_BYTES})",
bytes.len()
)));
}
self.ensure_dir().await?;
let kind = classify(filename, bytes);
let id = self.unique_id(filename);
let meta = UnattendedMeta {
id: id.clone(),
filename: filename.to_string(),
kind,
size_bytes: bytes.len() as u64,
uploaded_at: OffsetDateTime::now_utc(),
};
// Atomic data write: tmp -> rename.
let data = self.data_path(&id);
let tmp = data.with_extension("file.tmp");
tokio::fs::write(&tmp, bytes).await?;
tokio::fs::rename(&tmp, &data).await?;
let meta_text = serde_json::to_string_pretty(&meta).map_err(|e| Error::Other(e.into()))?;
tokio::fs::write(self.meta_path(&id), meta_text).await?;
self.inner.write().files.insert(id.clone(), meta.clone());
tracing::info!(
target: "openpxe::unattended",
id = %id, file = %filename, kind = ?kind, size = bytes.len(),
"unattended answer file stored"
);
Ok(meta)
}
#[must_use]
pub fn list(&self) -> Vec<UnattendedMeta> {
let g = self.inner.read();
let mut v: Vec<_> = g.files.values().cloned().collect();
v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
v
}
#[must_use]
pub fn get(&self, id: &str) -> Option<UnattendedMeta> {
self.inner.read().files.get(id).cloned()
}
/// Read the raw stored bytes for `id`.
pub async fn read(&self, id: &str) -> Result<Vec<u8>> {
if !self.inner.read().files.contains_key(id) {
return Err(Error::NotFound(format!("no unattended file '{id}'")));
}
let bytes = tokio::fs::read(self.data_path(id)).await?;
Ok(bytes)
}
/// Remove a file + its sidecar. Returns true if something was removed.
pub async fn remove(&self, id: &str) -> bool {
let existed = self.inner.write().files.remove(id).is_some();
if existed {
let _ = tokio::fs::remove_file(self.data_path(id)).await;
let _ = tokio::fs::remove_file(self.meta_path(id)).await;
}
existed
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().files.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// Substitute the per-host template tokens into an answer file at serve
/// time. Recognised tokens (case-sensitive, double-brace): `{{HOSTNAME}}`,
/// `{{IP}}`, `{{MAC}}`. Unset values render as an empty string so a
/// half-filled profile never leaves a literal `{{IP}}` in the file.
#[must_use]
pub fn render_template(
content: &str,
mac: Option<&str>,
hostname: Option<&str>,
ip: Option<&str>,
) -> String {
content
.replace("{{HOSTNAME}}", hostname.unwrap_or(""))
.replace("{{IP}}", ip.unwrap_or(""))
.replace("{{MAC}}", mac.unwrap_or(""))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn classify_by_extension() {
assert_eq!(
classify(" subiquity.yaml", b""),
UnattendedKind::Autoinstall
);
assert_eq!(classify("ks.ks", b""), UnattendedKind::Kickstart);
assert_eq!(classify("preseed.seed", b""), UnattendedKind::Preseed);
assert_eq!(
classify("autounattend.xml", b"<xml/>"),
UnattendedKind::AnswerFile
);
assert_eq!(classify("user-data", b""), UnattendedKind::Autoinstall);
}
#[test]
fn classify_cfg_by_content() {
assert_eq!(
classify("answer.cfg", b"d-i debian-installer/locale string en_US"),
UnattendedKind::Preseed
);
assert_eq!(
classify("answer.cfg", b"install\n%packages\n@core\n%end\n"),
UnattendedKind::Kickstart
);
}
#[test]
fn accepted_filenames() {
assert!(is_accepted_filename("a.ks"));
assert!(is_accepted_filename("USER-DATA".to_lowercase().as_str()));
assert!(is_accepted_filename("autounattend.XML"));
assert!(!is_accepted_filename("evil.sh"));
assert!(!is_accepted_filename("image.iso"));
}
#[test]
fn template_substitutes_and_blanks_unset() {
let body = "ip={{IP}} host={{HOSTNAME}} mac={{MAC}}";
let out = render_template(body, Some("aa:bb"), Some("node1"), None);
assert_eq!(out, "ip= host=node1 mac=aa:bb");
}
#[tokio::test]
async fn add_list_read_remove_round_trip() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
let meta = s
.add("rocky.ks", b"install\n%packages\n@core\n%end\n")
.await
.unwrap();
assert_eq!(meta.kind, UnattendedKind::Kickstart);
assert_eq!(s.len(), 1);
let got = s.read(&meta.id).await.unwrap();
assert!(got.starts_with(b"install"));
// Survives a reload.
let s2 = UnattendedStore::new(dir.path().join("unattended"));
s2.load_from_disk().await.unwrap();
assert!(s2.get(&meta.id).is_some());
assert!(s2.remove(&meta.id).await);
assert!(s2.get(&meta.id).is_none());
}
#[tokio::test]
async fn rejects_bad_type_and_oversize() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
assert!(s.add("evil.sh", b"#!/bin/sh").await.is_err());
let big = vec![b'x'; MAX_UNATTENDED_BYTES + 1];
assert!(s.add("big.ks", &big).await.is_err());
}
#[tokio::test]
async fn ids_are_unique() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
let a = s.add("ks.ks", b"install").await.unwrap();
let b = s.add("ks.ks", b"install").await.unwrap();
assert_ne!(a.id, b.id);
}
}
+14
View File
@@ -99,6 +99,16 @@ async fn main() -> anyhow::Result<()> {
let iso_store = IsoStore::new(config.paths.iso_dir.clone()); let iso_store = IsoStore::new(config.paths.iso_dir.clone());
iso_store.load_from_disk().await?; iso_store.load_from_disk().await?;
// v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/
// Windows answer files). Separate directory from the ISO store.
let unattended =
openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
if let Err(e) = unattended.load_from_disk().await {
tracing::warn!(
target: "openpxe::unattended",
"could not load unattended files on startup: {e}"
);
}
let clients = ClientRegistry::new(); let clients = ClientRegistry::new();
let queue = DeploymentQueue::new(); let queue = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(&config.paths.work_dir); let settings = SettingsStore::load_or_default(&config.paths.work_dir);
@@ -107,6 +117,7 @@ async fn main() -> anyhow::Result<()> {
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir); let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir); let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir); let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
let notify = openpxe_core::NotifyStore::load_or_default(&config.paths.work_dir);
let sessions = openpxe_http_api::auth::SessionStore::default(); let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new(); let metrics = Metrics::new();
@@ -167,10 +178,13 @@ async fn main() -> anyhow::Result<()> {
admin: admin.clone(), admin: admin.clone(),
sessions: sessions.clone(), sessions: sessions.clone(),
sso: sso.clone(), sso: sso.clone(),
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
notify: notify.clone(),
metrics: metrics.clone(), metrics: metrics.clone(),
smb: Some(smb.clone()), smb: Some(smb.clone()),
smb_shares: smb_shares.clone(), smb_shares: smb_shares.clone(),
nfs_shares: nfs_shares.clone(), nfs_shares: nfs_shares.clone(),
unattended: unattended.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
+115
View File
@@ -627,6 +627,14 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
} }
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; } .auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); } .auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
/* v0.5.0: FleetDM-style custom logo on the login/setup card the
uploaded logo spans the card header and the "OpenPXE" wordmark is
dropped (the logo is the brand). Matches the sidebar treatment. */
.auth-card .brand-row.has-custom-logo { justify-content: center; gap: 0; margin-bottom: 22px; }
.auth-card .brand-row.has-custom-logo img {
width: auto; height: 52px; max-width: 240px;
object-fit: contain; object-position: center;
}
.auth-card h2 { .auth-card h2 {
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg); margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
} }
@@ -789,3 +797,110 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.logo-preview .info { flex: 1; min-width: 0; } .logo-preview .info { flex: 1; min-width: 0; }
.logo-preview .info .name { color: var(--fg); font-weight: 600; } .logo-preview .info .name { color: var(--fg); font-weight: 600; }
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; } .logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
/* v0.5.1: collapsible "Advanced" disclosure at the bottom of Settings
(the former Advanced sidebar tab). A quiet, full-width toggle that
expands to reveal the notification + API-reference cards. */
.advanced-disclosure { width: 100%; }
.advanced-summary {
list-style: none;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
color: var(--fg-dim);
font-size: 13px;
font-weight: 600;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.advanced-summary:hover { color: var(--fg); }
.advanced-summary::-webkit-details-marker { display: none; }
.advanced-summary::before {
content: "▸";
font-size: 11px;
transition: transform 0.15s ease;
}
.advanced-disclosure[open] .advanced-summary::before { transform: rotate(90deg); }
/* v0.5.1: protocol tag on a unified Remote-shares row (SMB / NFS). */
.proto-badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 1px 6px;
margin-right: 8px;
border-radius: 4px;
vertical-align: middle;
background: var(--bg-panel-2);
border: 1px solid var(--border);
color: var(--fg-dim);
}
/* ── v0.5.2: three-slot branding (light / dark / client) ─────────── */
.logo-slots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
@media (max-width: 720px) { .logo-slots { grid-template-columns: 1fr; } }
.logo-slot {
display: flex; flex-direction: column; gap: 8px;
padding: 12px;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-slot-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.logo-slot-head .name { color: var(--fg); font-weight: 600; font-size: 13px; }
.logo-slot .swatch {
height: 64px;
display: flex; align-items: center; justify-content: center;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-slot .swatch img { max-width: 90%; max-height: 52px; object-fit: contain; }
.logo-slot-hint { color: var(--fg-dim); font-size: 11.5px; }
/* ── v0.5.2: login local/SSO separation ─────────────────────────── */
.auth-card .auth-divider {
display: flex; align-items: center; text-align: center;
color: var(--fg-dimmer); font-size: 11px; text-transform: uppercase;
letter-spacing: 0.08em;
margin: 16px 0 12px;
}
.auth-card .auth-divider::before,
.auth-card .auth-divider::after {
content: ""; flex: 1; height: 1px; background: var(--border-soft);
}
.auth-card .auth-divider span { padding: 0 10px; }
.auth-card .sso-block .sso-btn { margin-top: 0; }
.auth-card .sso-btn {
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.auth-card .sso-btn .sso-logo { width: 16px; height: 16px; object-fit: contain; flex: none; }
/* ── v0.5.2: modal (queue Profile editor) ───────────────────────── */
.modal-overlay {
position: fixed; inset: 0; z-index: 200;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.55);
padding: 24px;
}
.modal-box {
width: 100%; max-width: 520px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 22px;
}
.modal-box h2 { margin: 0 0 14px; font-size: 16px; font-weight: 600; color: var(--fg); }
.modal-actions {
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
}
.modal-actions .submit { width: auto; padding: 8px 18px; }
+730 -268
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -13,7 +13,10 @@
on the asset handlers, the practical caching window is one on the asset handlers, the practical caching window is one
version. --> version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" /> <link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" /> <!-- v0.5.2: favicon is pinned to the bundled OpenPXE mark (its own
endpoint, decoupled from operator branding) for tab-icon
continuity regardless of any uploaded light/dark/client logo. -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg?v={{ASSET_VERSION}}" />
<!-- Theme is read from localStorage *before* paint to avoid the <!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. --> preference and finally to dark. -->
@@ -0,0 +1,199 @@
# OpenPXE v0.5.1 — SAML SSO wiring + Settings/Storage UI consolidation
**Date:** 2026-05-31
**Author:** Miles Ward (with Claude)
**Status:** Approved design → implementation
## Summary
Three workstreams for v0.5.1:
1. **Wire SAML 2.0 SSO** end-to-end (currently config is persisted but no runtime
sign-in exists). Pure-Rust implementation that preserves the static-musl /
no-OpenSSL architecture, mirroring how FleetDM exposes and handles SAML.
2. **Fold the Advanced sidebar tab into Settings** as a collapsible section.
3. **Merge the Storage tab's SMB and NFS cards** into one "Remote shares" card
with a protocol dropdown.
Then bump `0.5.0 → 0.5.1`, build the static musl image, push `:0.5.1` + `:latest`
to Gitea, create the release, and scrub registry credentials.
## Decisions (locked with the user)
- **Crypto:** pure-Rust via `bergshamra` (XML-DSig + exclusive c14n, RustCrypto-based,
`#![forbid(unsafe_code)]`, ~99% xmlsec interop). `samael` is rejected — it
hard-requires OpenSSL/`xmlsec`/`libxml2` C deps, which would break the static
musl binary and the project's pure-Rust / no-OpenSSL architecture.
- **Access model:** any SAML assertion the IdP successfully authenticates and that
we cryptographically verify mints a full operator session. No user table, no
roles, no domain allowlist. The local admin account remains a guaranteed
fallback owner regardless of SSO state.
- **Flows:** SP-initiated (the "Sign in with <IdP>" button) is always on.
IdP-initiated is supported but gated behind an `allow_idp_initiated` toggle
(default off), mirroring FleetDM's "Allow SSO login initiated by identity
provider."
## Scope boundaries (v0.5.1)
In scope: SP-initiated + (gated) IdP-initiated login, signature verification on the
SAML Response/Assertion, full SP-side semantic validation, SP metadata endpoint,
login-page button wiring.
Out of scope (note for later releases): EncryptedAssertion (assertions must be
unencrypted), signed AuthnRequests (sent unsigned; Keycloak "client signature
required" must be off), Single Logout (SLO), multi-user accounts / RBAC / JIT role
mapping.
---
## Workstream 1 — SAML SP wiring (pure-Rust)
### New dependencies (workspace)
- `bergshamra` — XML-DSig verification + exclusive c14n (pure Rust).
- `roxmltree` (read/navigate) and/or `quick-xml` (build/serialize) — parse IdP
metadata + SAMLResponse, build AuthnRequest and SP metadata.
- `x509-parser` — extract the IdP signing certificate / public key from metadata.
- `flate2` — raw DEFLATE for the HTTP-Redirect binding.
- `base64` — encode/decode SAMLRequest/SAMLResponse.
All pure-Rust → the `x86_64-unknown-linux-musl` static build stays OpenSSL-free.
Exact `bergshamra` function signatures (`verify`, `DsigContext`, `KeysManager`,
`Key`, `VerifiedReference`, `VerifyResult`) will be pinned against the installed
crate source during implementation.
### Module boundaries
Pure protocol logic lives in `openpxe-core` (no axum dependency, unit-testable);
HTTP wiring lives in `openpxe-http-api`.
- `crates/core/src/saml/mod.rs` — public surface + shared types
(`VerifiedPrincipal { email, display_name, name_id, session_index }`, `SamlError`).
- `crates/core/src/saml/metadata.rs` — parse IdP `EntityDescriptor`: IdP EntityID,
`SingleSignOnService` locations + bindings, and one or more X.509 signing
certificates. Also build **our** SP metadata XML.
- `crates/core/src/saml/authn_request.rs` — build an AuthnRequest, return both the
request ID (to track) and the encoded HTTP-Redirect query value
(deflate → base64 → URL-encode).
- `crates/core/src/saml/response.rs` — decode `SAMLResponse` (base64 → XML),
**verify the signature via bergshamra** against the IdP cert, then enforce SP
semantics, returning `VerifiedPrincipal` or a typed `SamlError`.
### SP-side validation (response.rs)
After a cryptographically valid signature over the Response and/or the Assertion:
1. `Status` is `Success`.
2. `Destination` (if present) equals our ACS URL.
3. `Conditions/AudienceRestriction/Audience` equals our SP EntityID.
4. `NotBefore` / `NotOnOrAfter` within bounds (allow small clock skew, e.g. ±60s).
5. `InResponseTo` matches an outstanding request we issued (SP-initiated). Absent
for IdP-initiated, which is only accepted when `allow_idp_initiated` is true.
6. Assertion-ID replay guard: reject a previously consumed assertion ID.
7. NameID is the email (`nameid-format:emailAddress`). Display name read from
common attributes (`name`, `displayname`, `cn`, `urn:oid:2.5.4.3`).
XML Signature Wrapping (XSW) defenses come from bergshamra (duplicate-ID rejection,
strict positional verification); enable its strict verification options. We
additionally confirm the verified `Reference` covers the element we read claims from.
### State (in `openpxe-http-api`)
Two small TTL-pruned in-memory stores (parking_lot `Mutex<HashMap<...>>`):
- **Outstanding requests:** `request_id → issued_at`, TTL ≈ 5 min, for `InResponseTo`.
- **Consumed assertions:** `assertion_id → expires_at`, TTL = assertion validity,
for replay protection.
(In-memory is acceptable: a single-container app; a restart simply invalidates
in-flight logins.)
### Routes (all pre-auth; added to the public allowlist in the auth middleware)
- `GET /api/sso/login` → build AuthnRequest, record its ID, 302 to the IdP SSO URL
(HTTP-Redirect binding) with `SAMLRequest` + `RelayState`.
- `POST /api/sso/acs` → consume `SAMLResponse` (form-encoded). Verify + validate.
On success: `SessionStore::create(email)`, set the `openpxe_session` cookie
(same attributes as forms login), 302 to the dashboard. On failure: 302 back to
the login page with an error indicator. (Mirrors FleetDM's `/sso/callback`.)
- `GET /api/sso/metadata` → serve our SP `EntityDescriptor` XML for IdP import.
### Config changes (`crates/core/src/sso.rs`)
Add to `SsoConfig` (preserve existing fields + validation):
- `entity_id: String` — SP Entity ID (mirrors FleetDM's "Entity ID"); defaults to
the configured public base URL. The ACS URL is derived as
`<public_base_url>/api/sso/acs`.
- `allow_idp_initiated: bool` — default `false`.
`GET /api/sso` returns the new fields; `PUT /api/sso` validates and persists them.
### Login page (`crates/webui/src/app.js`)
Replace the "configured · runtime pending" message: the existing
"Sign in with <IdP>" button navigates to `GET /api/sso/login`. Render the IdP logo
(if `idp_logo_url` set) and use `idp_name` as the label. Keep the existing
FleetDM-style login layout.
### Testing
- `core/saml` unit tests using a self-signed test keypair we control:
- Parse representative Keycloak IdP metadata → correct SSO URL + cert.
- Build an AuthnRequest → well-formed, deflate/base64 round-trips, ID recorded.
- A correctly signed Response → `VerifiedPrincipal { email, .. }`.
- Reject: tampered signature, expired (`NotOnOrAfter`), wrong audience,
replayed assertion ID, unsigned response, `Status != Success`.
- `http-api` integration test: `GET /api/sso/login` returns a 302 with a
`SAMLRequest` query param; a crafted signed `SAMLResponse` POSTed to
`/api/sso/acs` (signed with the test key) sets an `openpxe_session` cookie.
---
## Workstream 2 — Advanced tab → Settings
- Remove the `Advanced` sidebar entry (`crates/webui/src/index.html`) and its
`advanced` view route in `app.js`.
- In the Settings view, append a **collapsible "Advanced" disclosure**
(default-collapsed) at the bottom containing the existing **Webhook
Notifications** card and the **API reference** block (moved out of the removed
Advanced view).
- No backend changes; `/api/notify*` and `/api/docs` endpoints are unchanged.
---
## Workstream 3 — Storage: merge SMB + NFS → "Remote shares"
- Replace the separate "SMB shares" and "NFS shares" cards with a single
**"Remote shares"** card:
- One add-form with a **protocol dropdown (SMB / NFS)**. Selecting the protocol
swaps the fields: SMB → server, share, guest checkbox, username, password;
NFS → server, export path.
- One unified table with a leading **Protocol** column (SMB/NFS badge), then
server/share-or-export, auth, ISO count, reachability, and Re-scan / Remove
actions.
- **No backend changes.** The form dispatches to the existing
`POST /api/smb-shares` or `POST /api/nfs-shares`; the table merges
`GET /api/smb-shares` + `GET /api/nfs-shares`, tagging each row with its
protocol. Re-scan/Remove call the existing per-protocol endpoints.
- Leaves the card pattern open for a future "Config files" card.
---
## Release
1. Bump workspace version `0.5.0 → 0.5.1` (`Cargo.toml`).
2. `cargo fmt`, `cargo clippy`, `cargo test` (all crates) green.
3. Build the static musl binary + Docker image; verify SAML deps compile clean
under musl (no OpenSSL/C linkage).
4. Push `openpxe:0.5.1` + `openpxe:latest` to Gitea via the established
temp-DOCKER_CONFIG pipeline; scrub credentials (logout + verify no token traces).
5. Create the Gitea release `v0.5.1` with notes.
## Risks
- `bergshamra` is pre-1.0 and unaudited. Mitigation: pin the version, enable strict
verification, keep the local-admin fallback, and own the SP-semantic checks
carefully (audience/Conditions/replay/InResponseTo — where SP vulns usually live).
- SAML is security-sensitive; negative tests (tamper/expiry/audience/replay/unsigned)
are part of the definition of done, not optional.