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]>
This commit is contained in:
Miles Ward
2026-05-31 16:11:04 -04:00
co-authored by Claude Opus 4.8
parent cbcd63bb14
commit 7adf5e2918
19 changed files with 2295 additions and 330 deletions
+518 -35
View File
@@ -31,11 +31,14 @@ use axum::{
Json, Router,
};
use openpxe_core::{
ext_for_mime, wol, BootEvent, ClientEvent, Error, NotifyConfig, Settings, SsoConfig,
ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error, LogoSlot, NotifyConfig,
Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
use openpxe_iso_store::{
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, UnattendedKind,
UnattendedMeta,
};
use serde::Deserialize;
use serde_json::json;
use std::net::SocketAddr;
@@ -49,7 +52,14 @@ pub fn build_router(state: AppState) -> Router {
.route("/", get(index))
.route("/assets/app.js", get(ui_js))
.route("/assets/app.css", get(ui_css))
// v0.5.2: theme-aware brand mark. `?theme=light|dark` selects the
// operator's per-theme logo slot (falling back across themes, then
// to the bundled mark). The WebUI swaps `?theme=` on theme toggle.
.route("/assets/logo.svg", get(ui_logo))
// v0.5.2: favicon is pinned to the *bundled* OpenPXE mark for
// continuity — it never follows the operator's custom branding, so
// the browser-tab icon stays recognisably "OpenPXE".
.route("/assets/favicon.svg", get(ui_favicon))
.route("/assets/loader.svg", get(ui_loader))
// v0.4.6: PXE menu logo — the raster form of the operator's
// uploaded mark, served so iPXE's `console --picture` can
@@ -61,6 +71,16 @@ pub fn build_router(state: AppState) -> Router {
// iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu))
.route("/boot/:filename", get(boot_sub))
// v0.5.2: unattended answer-file *serving* — public (like /iso),
// because the booting installer fetches these with no session.
// `/unattended/:id` serves a Kickstart/Preseed with `{{HOSTNAME}}`
// / `{{IP}}` / `{{MAC}}` substituted from the query string. The
// 3-segment form is the cloud-init NoCloud seed dir for Ubuntu
// autoinstall (`…/<ctx>/user-data` + `/meta-data`), where `<ctx>`
// base64url-encodes the per-host hostname/ip/mac. Management
// (upload/list/delete) lives under the gated `/api/unattended`.
.route("/unattended/:id", get(serve_unattended))
.route("/unattended/:id/:ctx/:sub", get(serve_unattended_seed))
// Bundled binaries and raw ISO access.
.route("/ipxe/:name", get(ipxe_binary))
.route("/iso/:filename", get(iso_raw))
@@ -95,12 +115,21 @@ pub fn build_router(state: AppState) -> Router {
// volume — surfaced as a small card on the Storage tab so the
// operator knows when they're about to run out of room.
.route("/api/storage/disk", get(api_storage_disk))
// v0.4.4: operator-controlled WebUI branding overrides
// (custom logo). Multipart upload to POST; DELETE clears.
// v0.4.4: operator-controlled WebUI branding overrides (custom
// logo). v0.5.2: split into three slots — `light` / `dark` /
// `client`. Multipart upload to POST; DELETE clears one slot.
.route(
"/api/branding/logo",
"/api/branding/logo/:slot",
post(api_branding_upload).delete(api_branding_clear),
)
// v0.5.2: unattended-install answer-file management (gated).
// Multipart upload, list, delete. Serving is the public
// `/unattended/*` routes above.
.route(
"/api/unattended",
get(api_unattended_list).post(api_unattended_upload),
)
.route("/api/unattended/:id", delete(api_unattended_delete))
// v0.4.4: self-rendered API reference, served as JSON so the UI
// can format it consistently with the rest of the chrome. Lives
// under the Settings tab — operators chasing an integration get
@@ -133,6 +162,9 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/queue/join", get(api_queue_join))
.route("/api/queue/poll/:entry_id", get(api_queue_poll))
.route("/api/queue/assign", post(api_queue_assign))
// v0.5.2: per-device deployment profile (auto hostname / IP /
// unattended file) set from the queue "Profile" button.
.route("/api/queue/:entry_id/profile", put(api_queue_set_profile))
.route("/api/queue/:entry_id", delete(api_queue_release))
// v0.4.65: SMB share manager (userspace via smbclient). The
// kernel-mount NFS routes that v0.4.64 shipped are gone — they
@@ -231,7 +263,7 @@ async fn index(State(state): State<AppState>) -> Response {
&state.public_base_url,
env!("CARGO_PKG_VERSION"),
state.branding.logo_rev(),
state.branding.has_logo(),
state.branding.has_any_web_logo(),
);
(
[
@@ -284,23 +316,28 @@ async fn ui_css() -> Response {
.into_response()
}
async fn ui_logo(State(state): State<AppState>) -> Response {
#[derive(Debug, Deserialize)]
struct LogoQuery {
/// `light` or `dark` — which theme variant the page is currently
/// showing. Anything else (or absent) resolves to the dark slot,
/// which matches the default theme.
#[serde(default)]
theme: Option<String>,
}
async fn ui_logo(State(state): State<AppState>, Query(q): Query<LogoQuery>) -> Response {
// Custom override first; fall back to the bundled rainbow-horizon
// SVG. We resolve the override on each request rather than caching
// because operators may upload/clear from the Settings tab while the
// server is live, and we want them to see their change immediately
// without bouncing the binary.
if let Some(path) = state.branding.logo_path() {
let mime = state
.branding
.logo_mime()
.unwrap_or_else(|| "image/svg+xml".to_string());
// without bouncing the binary. The theme query selects the per-theme
// slot, with cross-theme + bundled fallback handled in BrandingStore.
let theme_is_light = q.theme.as_deref() == Some("light");
if let Some((path, mime)) = state.branding.web_logo(theme_is_light) {
match tokio::fs::read(&path).await {
Ok(bytes) => {
let ct = match HeaderValue::from_str(&mime) {
Ok(v) => v,
Err(_) => HeaderValue::from_static("application/octet-stream"),
};
let ct = HeaderValue::from_str(&mime)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
return (
[
(header::CONTENT_TYPE, ct),
@@ -321,6 +358,17 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
}
}
}
bundled_logo_response()
}
/// Favicon — always the bundled OpenPXE mark, decoupled from operator
/// branding (v0.5.2) so the browser-tab icon stays "OpenPXE" for
/// continuity regardless of any uploaded light/dark logo.
async fn ui_favicon() -> Response {
bundled_logo_response()
}
fn bundled_logo_response() -> Response {
(
[
(
@@ -351,8 +399,8 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
// Resolve the operator's raster upload, if any and if it's a format
// iPXE/our compositor can consume. SVG (or a missing/unreadable
// file) yields `None`, which composes the default background.
let raster: Option<Vec<u8>> = match (state.branding.logo_path(), state.branding.logo_mime()) {
(Some(path), Some(mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(),
let raster: Option<Vec<u8>> = match state.branding.client_logo() {
Some((path, mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(),
_ => None,
};
@@ -642,7 +690,26 @@ async fn boot_sub(
"PXE boot started",
&format!("{who} started booting {} ({}).", iso.filename, entry.title),
);
return text_plain(render_entry(entry, &settings, base));
// v0.5.2: if this MAC has a deployment profile with
// an unattended answer file selected (via a host
// pin or queue Profile), inject the right kernel
// arg so the install runs unattended.
let unattended_args = mac_normalized.as_deref().and_then(|m| {
resolve_profile(&state, m).and_then(|p| {
p.unattended_file
.as_deref()
.and_then(|fid| state.unattended.get(fid))
.and_then(|meta| {
build_unattended_args(base, &meta, Some(m), &p)
})
})
});
return text_plain(render_entry(
entry,
&settings,
base,
unattended_args.as_deref(),
));
}
}
}
@@ -1052,7 +1119,18 @@ async fn api_storage_disk(State(state): State<AppState>) -> Json<serde_json::Val
// ─── Branding (custom logo) ───────────────────────────────────────────────
async fn api_branding_upload(State(state): State<AppState>, mut multipart: Multipart) -> Response {
async fn api_branding_upload(
State(state): State<AppState>,
AxumPath(slot): AxumPath<String>,
mut multipart: Multipart,
) -> Response {
let Some(slot) = LogoSlot::parse(&slot) else {
return (
StatusCode::BAD_REQUEST,
"unknown logo slot; expected light, dark, or client",
)
.into_response();
};
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name != "file" && name != "logo" {
@@ -1089,11 +1167,21 @@ async fn api_branding_upload(State(state): State<AppState>, mut multipart: Multi
let Some(ext) = ext_for_mime(&mime) else {
return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response();
};
match state.branding.set_logo(&mime, ext, &bytes) {
// The PXE "client" logo is rasterized for the boot screen; an SVG
// there can't be composited, so steer operators to a raster.
if slot == LogoSlot::Client && mime == "image/svg+xml" {
return (
StatusCode::BAD_REQUEST,
"the client (PXE) logo must be a raster image (PNG/JPEG/WebP/GIF); SVG can't be painted on the boot screen",
)
.into_response();
}
match state.branding.set_logo(slot, &mime, ext, &bytes) {
Ok(filename) => {
return (
StatusCode::OK,
Json(json!({
"slot": slot.as_str(),
"filename": filename,
"mime": mime,
"size_bytes": bytes.len(),
@@ -1107,13 +1195,238 @@ async fn api_branding_upload(State(state): State<AppState>, mut multipart: Multi
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
}
async fn api_branding_clear(State(state): State<AppState>) -> Response {
match state.branding.clear_logo() {
async fn api_branding_clear(
State(state): State<AppState>,
AxumPath(slot): AxumPath<String>,
) -> Response {
let Some(slot) = LogoSlot::parse(&slot) else {
return (StatusCode::BAD_REQUEST, "unknown logo slot").into_response();
};
match state.branding.clear_logo(slot) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
// ─── Unattended answer files (v0.5.2) ──────────────────────────────────────
//
// Management (list/upload/delete) is gated behind the auth middleware.
// *Serving* the files to the booting installer is the public
// `/unattended/*` route pair below — the installer has no session.
async fn api_unattended_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "files": state.unattended.list() }))
}
async fn api_unattended_upload(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Response {
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name != "file" && name != "unattended" {
continue;
}
let filename = field.file_name().map(str::to_string).unwrap_or_default();
if filename.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "missing filename on upload").into_response();
}
let bytes = match field.bytes().await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(),
};
return match state.unattended.add(&filename, &bytes).await {
Ok(meta) => (StatusCode::CREATED, Json(meta)).into_response(),
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
};
}
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
}
async fn api_unattended_delete(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> StatusCode {
if state.unattended.remove(&id).await {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[derive(Debug, Deserialize)]
struct UnattendedServeQuery {
#[serde(default)]
mac: Option<String>,
#[serde(default)]
hostname: Option<String>,
#[serde(default)]
ip: Option<String>,
}
/// Public: serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` /
/// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns
/// `text/plain` so installers (anaconda, debian-installer, Windows setup
/// fetching over HTTP) read it verbatim.
async fn serve_unattended(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
Query(q): Query<UnattendedServeQuery>,
) -> Response {
let Ok(bytes) = state.unattended.read(&id).await else {
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
};
let content = String::from_utf8_lossy(&bytes);
let rendered = render_template(
&content,
q.mac.as_deref(),
q.hostname.as_deref(),
q.ip.as_deref(),
);
text_plain(rendered)
}
/// Public: cloud-init NoCloud seed directory for Ubuntu autoinstall. The
/// kernel arg points iPXE/cloud-init at `…/<id>/<ctx>/`; cloud-init then
/// fetches `user-data`, `meta-data`, (and `vendor-data`). `<ctx>`
/// base64url-encodes the per-host hostname/ip/mac so they survive the
/// seedfrom URL (which can't carry a query string).
async fn serve_unattended_seed(
State(state): State<AppState>,
AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>,
) -> Response {
let (mac, hostname, ip) = decode_seed_ctx(&ctx);
match sub.as_str() {
"user-data" => {
let Ok(bytes) = state.unattended.read(&id).await else {
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
};
let content = String::from_utf8_lossy(&bytes);
let rendered =
render_template(&content, mac.as_deref(), hostname.as_deref(), ip.as_deref());
text_plain(rendered)
}
"meta-data" => {
let host_line = hostname
.as_deref()
.map(|h| format!("local-hostname: {h}\n"))
.unwrap_or_default();
text_plain(format!("instance-id: openpxe-{id}\n{host_line}"))
}
// cloud-init probes vendor-data too; an empty 200 keeps it quiet.
"vendor-data" => text_plain(String::new()),
_ => (StatusCode::NOT_FOUND, "unknown seed resource").into_response(),
}
}
/// Resolve the deployment profile for a booting MAC: a host pin wins, else
/// a queued device's Profile. `None` when neither carries one.
fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> {
if let Some(b) = state.hosts.lookup(mac) {
if !b.profile.is_empty() {
return Some(b.profile);
}
}
state.queue.profile_for_mac(mac)
}
/// Build the per-host unattended kernel arguments for a Linux entry.
/// Returns `None` for Windows answer files / unclassified uploads (no
/// kernel cmdline injection applies).
fn build_unattended_args(
base: &str,
meta: &UnattendedMeta,
mac: Option<&str>,
profile: &DeployProfile,
) -> Option<String> {
let base = base.trim_end_matches('/');
let id = &meta.id;
let host = profile.auto_hostname.as_deref();
let ip = profile.auto_ip.as_deref();
let query = build_query(&[("mac", mac), ("hostname", host), ("ip", ip)]);
match meta.kind {
UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")),
UnattendedKind::Preseed => {
let mut s = format!("auto=true priority=critical url={base}/unattended/{id}{query}");
if let Some(h) = host {
s.push_str(" hostname=");
s.push_str(h);
}
Some(s)
}
UnattendedKind::Autoinstall => {
let ctx = encode_seed_ctx(mac, host, ip);
Some(format!(
"autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/"
))
}
UnattendedKind::AnswerFile | UnattendedKind::Unknown => None,
}
}
/// Build a `?k=v&…` query string from present key/value pairs, percent-
/// encoding the values. Empty when nothing is present.
fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for (k, v) in pairs {
if let Some(val) = v {
out.push(if out.is_empty() { '?' } else { '&' });
let _ = write!(out, "{k}={}", pct_encode(val));
}
}
out
}
/// Minimal RFC 3986 percent-encoding for query values (unreserved set
/// passes through; everything else becomes `%XX`).
fn pct_encode(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len());
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
}
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
/// the cloud-init seed directory. Empty values become empty fields.
fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String {
use base64::Engine as _;
let raw = format!(
"{}\n{}\n{}",
hostname.unwrap_or(""),
ip.unwrap_or(""),
mac.unwrap_or("")
);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
}
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad
/// or empty segment yields all-`None` so the seed still serves (just
/// without per-host substitution).
fn decode_seed_ctx(ctx: &str) -> (Option<String>, Option<String>, Option<String>) {
use base64::Engine as _;
let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else {
return (None, None, None);
};
let s = String::from_utf8_lossy(&bytes).into_owned();
let mut it = s.splitn(3, '\n');
let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty());
let hostname = clean(it.next());
let ip = clean(it.next());
let mac = clean(it.next());
(mac, hostname, ip)
}
// ─── API reference (Settings → bottom) ────────────────────────────────────
async fn api_docs() -> Json<serde_json::Value> {
@@ -1209,12 +1522,12 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
{"method": "PUT", "path": "/api/settings",
"summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."},
{"method": "POST", "path": "/api/branding/logo",
"summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
{"method": "DELETE", "path": "/api/branding/logo",
"summary": "Remove the custom logo and revert to the bundled mark."},
{"method": "POST", "path": "/api/branding/logo/:slot",
"summary": "Upload a custom logo for a slot (light | dark | client). Multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB. The client slot is raster-only."},
{"method": "DELETE", "path": "/api/branding/logo/:slot",
"summary": "Remove the custom logo for a slot and revert to the bundled mark."},
{"method": "GET", "path": "/branding/pxe-logo",
"summary": "Raster form of the operator's logo for the iPXE menu's `console --picture`. SVG uploads 404 here."},
"summary": "Raster form of the operator's 'client' logo for the iPXE menu's `console --picture`. Default background when unset/SVG."},
{"method": "GET", "path": "/api/sso",
"summary": "Current SAML SSO configuration."},
{"method": "PUT", "path": "/api/sso",
@@ -1245,13 +1558,28 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Free / used / total bytes for the volume hosting the ISO directory."},
],
},
{
"name": "Unattended answer files",
"endpoints": [
{"method": "GET", "path": "/api/unattended",
"summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."},
{"method": "POST", "path": "/api/unattended",
"summary": "Upload an answer file (multipart 'file', .ks/.cfg/.seed/.yaml/.yml/.xml/user-data, up to 1 MB)."},
{"method": "DELETE", "path": "/api/unattended/:id",
"summary": "Delete an uploaded answer file."},
{"method": "GET", "path": "/unattended/:id",
"summary": "Public: serve an answer file with {{HOSTNAME}}/{{IP}}/{{MAC}} substituted from the query string."},
],
},
{
"name": "Queued Deployment",
"endpoints": [
{"method": "GET", "path": "/api/queue",
"summary": "List queue entries (waiting + assigned)."},
"summary": "List queue entries (waiting + assigned, with any deployment profile)."},
{"method": "POST", "path": "/api/queue/assign",
"summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
{"method": "PUT", "path": "/api/queue/:entry_id/profile",
"summary": "Set a queued device's deployment profile. Body: { auto_hostname?, auto_ip?, unattended_file? }."},
{"method": "DELETE", "path": "/api/queue/:entry_id",
"summary": "Release a queue entry without assigning."},
],
@@ -1262,7 +1590,7 @@ async fn api_docs() -> Json<serde_json::Value> {
{"method": "GET", "path": "/api/hosts",
"summary": "List per-MAC boot bindings."},
{"method": "POST", "path": "/api/hosts",
"summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
"summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."},
{"method": "DELETE", "path": "/api/hosts/:mac",
"summary": "Remove a binding."},
{"method": "POST", "path": "/api/hosts/:mac/wol",
@@ -1672,7 +2000,14 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"nfs_share_count": nfs_shares.len(),
"nfs_share_reachable": nfs_reachable,
"host_bindings": state.hosts.len(),
"custom_logo": state.branding.has_logo(),
"custom_logo": state.branding.has_any_web_logo(),
"branding": {
"light": state.branding.has_logo(LogoSlot::Light),
"dark": state.branding.has_logo(LogoSlot::Dark),
"client": state.branding.has_logo(LogoSlot::Client),
"rev": state.branding.logo_rev(),
},
"unattended_count": state.unattended.len(),
"uptime_secs": uptime_secs,
"started_at": state.started_at,
"nic_name": state.nic_name,
@@ -1819,10 +2154,15 @@ async fn api_queue_poll(
entry_id=%entry_id, mac=%g.mac, target=%target,
"queue assignment delivered"
);
// Carry `?mac=` so the per-entry handler can resolve this
// device's deployment profile (auto hostname/IP + unattended
// file) and inject the unattended kernel args, mirroring the
// pinned-host path.
let qmac = g.mac.clone();
text_plain(format!(
"#!ipxe\n\
echo Queue assignment received: {target}\n\
chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n"
chain {base}/boot/{target}.ipxe?mac={qmac} || chain {base}/api/queue/poll/{entry_id}\n"
))
}
Some(g) => {
@@ -1875,6 +2215,21 @@ async fn api_queue_assign(
Json(json!({ "ok": true, "assigned": n, "target": body.target }))
}
async fn api_queue_set_profile(
State(state): State<AppState>,
AxumPath(entry_id): AxumPath<String>,
Json(body): Json<DeployProfile>,
) -> Response {
let profile = body.normalized();
if let Err(msg) = validate_profile(&state, &profile) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
match state.queue.set_profile(&entry_id, profile) {
Some(entry) => (StatusCode::OK, Json(entry)).into_response(),
None => (StatusCode::NOT_FOUND, "no such queue entry").into_response(),
}
}
async fn api_queue_release(
State(state): State<AppState>,
AxumPath(entry_id): AxumPath<String>,
@@ -2013,6 +2368,11 @@ struct HostsUpsertBody {
target: String,
#[serde(default)]
label: String,
/// v0.5.2: optional unattended-install profile. Flattened so the
/// front-end posts `auto_hostname` / `auto_ip` / `unattended_file`
/// at the top level alongside mac/target/label.
#[serde(default, flatten)]
profile: DeployProfile,
}
async fn api_hosts_upsert(
@@ -2040,10 +2400,32 @@ async fn api_hosts_upsert(
)
.into_response();
}
let binding = state.hosts.upsert(mac, target, body.label.trim());
let profile = body.profile.normalized();
if let Err(msg) = validate_profile(&state, &profile) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
let binding = state.hosts.upsert(mac, target, body.label.trim(), profile);
(StatusCode::CREATED, Json(binding)).into_response()
}
/// Shared validation for a deployment profile (host pin + queue profile):
/// the referenced unattended file must exist, and a supplied IP must
/// parse. Hostname is free-form (installers vary), so we only length-cap
/// it (done in `DeployProfile::normalized`).
fn validate_profile(state: &AppState, profile: &DeployProfile) -> Result<(), String> {
if let Some(id) = profile.unattended_file.as_deref() {
if state.unattended.get(id).is_none() {
return Err(format!("unknown unattended file: {id}"));
}
}
if let Some(ip) = profile.auto_ip.as_deref() {
if ip.parse::<std::net::IpAddr>().is_err() {
return Err(format!("auto_ip is not a valid IP address: {ip}"));
}
}
Ok(())
}
async fn api_hosts_remove(
State(state): State<AppState>,
AxumPath(mac): AxumPath<String>,
@@ -2326,6 +2708,107 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
mod tests {
use super::*;
fn meta(kind: UnattendedKind) -> UnattendedMeta {
UnattendedMeta {
id: "ks1".into(),
filename: "f".into(),
kind,
size_bytes: 0,
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
}
}
#[test]
fn unattended_kickstart_arg_carries_query() {
let p = DeployProfile {
auto_hostname: Some("node7".into()),
auto_ip: Some("10.0.0.7".into()),
unattended_file: Some("ks1".into()),
};
let a = build_unattended_args(
"http://h",
&meta(UnattendedKind::Kickstart),
Some("aa:bb:cc:dd:ee:ff"),
&p,
)
.unwrap();
assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}");
assert!(a.contains("hostname=node7"), "{a}");
assert!(a.contains("ip=10.0.0.7"), "{a}");
// MAC colons are percent-encoded.
assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}");
}
#[test]
fn unattended_preseed_appends_hostname_kernel_arg() {
let p = DeployProfile {
auto_hostname: Some("deb1".into()),
..Default::default()
};
let a =
build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap();
assert!(
a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"),
"{a}"
);
assert!(a.ends_with(" hostname=deb1"), "{a}");
}
#[test]
fn unattended_autoinstall_uses_nocloud_seed_dir() {
let p = DeployProfile {
auto_hostname: Some("u1".into()),
auto_ip: Some("10.1.1.5".into()),
unattended_file: Some("ks1".into()),
};
let a = build_unattended_args(
"http://h",
&meta(UnattendedKind::Autoinstall),
Some("aa:bb"),
&p,
)
.unwrap();
assert!(
a.starts_with("autoinstall ds=nocloud-net;s=http://h/unattended/ks1/"),
"{a}"
);
assert!(a.ends_with('/'), "seed URL must end with '/': {a}");
// The ctx segment round-trips back to the per-host values.
let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap();
let (mac, host, ip) = decode_seed_ctx(ctx);
assert_eq!(mac.as_deref(), Some("aa:bb"));
assert_eq!(host.as_deref(), Some("u1"));
assert_eq!(ip.as_deref(), Some("10.1.1.5"));
}
#[test]
fn windows_answer_file_is_not_injected() {
let p = DeployProfile {
unattended_file: Some("ks1".into()),
..Default::default()
};
assert!(
build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p)
.is_none()
);
}
#[test]
fn seed_ctx_empty_segment_decodes_to_none() {
let ctx = encode_seed_ctx(None, None, None);
let (m, h, i) = decode_seed_ctx(&ctx);
assert!(m.is_none() && h.is_none() && i.is_none());
// Garbage decodes safely to all-None.
let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!");
assert!(m2.is_none() && h2.is_none() && i2.is_none());
}
#[test]
fn pct_encode_escapes_reserved() {
assert_eq!(pct_encode("aa:bb cc"), "aa%3Abb%20cc");
assert_eq!(pct_encode("node-7.lab_1~"), "node-7.lab_1~");
}
#[test]
fn version_newer_detects_updates() {
assert!(version_is_newer("0.5.1", "0.5.0"));
+1 -1
View File
@@ -317,7 +317,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
// 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_logo();
let has_custom_logo = state.branding.has_any_web_logo();
let logo_rev = state.branding.logo_rev();
if !state.admin.is_configured() {
return (
+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).
///
/// `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]
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 base = base_url.trim_end_matches('/');
let _ = writeln!(s, "#!ipxe");
@@ -477,6 +490,12 @@ pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> S
cmdline.push(' ');
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}");
for u in initrd_urls {
let _ = writeln!(s, "initrd {base}/{u}");
+6 -1
View File
@@ -5,7 +5,7 @@ use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
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 time::OffsetDateTime;
@@ -67,6 +67,11 @@ pub struct AppState {
/// In-process (no subprocess); supports HTTP Range requests on
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
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
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files.