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"));
|
||||
|
||||
Reference in New Issue
Block a user