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
@@ -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