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:
co-authored by
Claude Opus 4.8
parent
cbcd63bb14
commit
7adf5e2918
+518
-35
@@ -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"));
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -96,6 +96,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
let settings = SettingsStore::load_or_default(dir.path());
|
||||
let smb_shares = SmbShareManager::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 hosts = HostBindings::load_or_default(dir.path());
|
||||
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
|
||||
@@ -122,6 +124,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
smb: None,
|
||||
smb_shares,
|
||||
nfs_shares,
|
||||
unattended,
|
||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||
log_bus,
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
@@ -1488,7 +1491,8 @@ async fn api_docs_lists_known_endpoints() {
|
||||
"/api/isos",
|
||||
"/api/isos/:id/category",
|
||||
"/api/storage/disk",
|
||||
"/api/branding/logo",
|
||||
"/api/branding/logo/:slot",
|
||||
"/api/unattended",
|
||||
"/api/boot-log",
|
||||
"/metrics",
|
||||
] {
|
||||
@@ -1509,7 +1513,7 @@ async fn branding_clear_when_no_logo_is_no_content() {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/branding/logo")
|
||||
.uri("/api/branding/logo/dark")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -1950,6 +1954,7 @@ async fn pxe_background_falls_back_to_default_for_svg_upload() {
|
||||
state
|
||||
.branding
|
||||
.set_logo(
|
||||
openpxe_core::LogoSlot::Client,
|
||||
"image/svg+xml",
|
||||
"svg",
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#,
|
||||
@@ -1985,7 +1990,10 @@ async fn pxe_logo_composes_to_1024x768_png() {
|
||||
// the iPXE menu always paints at consistent dimensions.
|
||||
let (state, _dir) = build_state().await;
|
||||
let png = tiny_png();
|
||||
state.branding.set_logo("image/png", "png", &png).unwrap();
|
||||
state
|
||||
.branding
|
||||
.set_logo(openpxe_core::LogoSlot::Client, "image/png", "png", &png)
|
||||
.unwrap();
|
||||
let app = build_router(state);
|
||||
let res = app
|
||||
.clone()
|
||||
@@ -2025,7 +2033,10 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
|
||||
// auth allowlist gates `/api/*` only.
|
||||
let (state, _dir) = build_state().await;
|
||||
let png = tiny_png();
|
||||
state.branding.set_logo("image/png", "png", &png).unwrap();
|
||||
state
|
||||
.branding
|
||||
.set_logo(openpxe_core::LogoSlot::Client, "image/png", "png", &png)
|
||||
.unwrap();
|
||||
let app = build_router(state);
|
||||
// Configure an admin so the middleware kicks in.
|
||||
let (s, _, _) = post_collect(
|
||||
@@ -2040,6 +2051,201 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
|
||||
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
|
||||
@@ -2145,8 +2351,16 @@ fn urlencode(s: &str) -> String {
|
||||
}
|
||||
_ => {
|
||||
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.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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user