v0.4.5: VMware UEFI fix, static musl binary, Forms auth + SSO config
VMware UEFI / Casper boot fix:
- Linux cmdline for Debian/Ubuntu/Mint/Pop!_OS/elementary now uses the
canonical Casper `iso-url=` option and `ds=nocloud`, matching the
fix Bootimus shipped in v0.1.67. The previous
`boot=casper netboot=url url=… ip=dhcp ---` form booted fine on
bare-metal UEFI but hung at "cloud-init running" on VMware guests
because subiquity / cloud-init can't reach a metadata datasource
through PXE.
Static binary (matches Bootimus v0.1.70):
- Dockerfile build stage now compiles against
x86_64-unknown-linux-musl. The resulting /openpxe has no glibc
dependency at all; the runtime stage still ships Debian slim for the
samba/wimtools/nfs-common shellouts, but a future scratch/distroless
variant is now a one-line swap. Cuts a class of "GLIBC_2.39 not
found" surprises on older RHEL/Rocky hosts.
Forms auth (Sonarr/Radarr-style):
- New AdminStore in openpxe-core: single admin record persisted to
<work_dir>/auth.json, bcrypt-hashed credentials, rotation requires
current password.
- New SessionStore in openpxe-http-api: in-memory UUID-keyed sessions
with 24h sliding TTL, openpxe_session HttpOnly cookie.
- Endpoints: POST /api/setup (first-run), POST /api/login, POST
/api/logout, GET /api/me, PUT /api/me/credentials (rotates and
revokes every other session).
- Auth middleware gates /api/* once the admin is configured;
passes through entirely until then (tests + fresh installs ride this
path). Allowlists PXE-essential paths (/boot.ipxe, /iso/*, /ipxe/*,
/api/queue/join, /api/queue/poll/*) so iPXE clients still work
without a cookie they can't send.
- WebUI: first-run setup card, login card, logout chip in the sidebar
footer, Account card in Settings for rotating creds. Auth screen is
fully styled (centered narrow card, matches Sonarr layout).
SSO config (FleetDM-shaped, storage-only):
- New SsoStore in openpxe-core: { enabled, idp_name, metadata,
metadata_url } persisted to <work_dir>/sso.json with size caps and
URL-scheme validation.
- Endpoints: GET /api/sso, PUT /api/sso. Validation: enabling SSO
without either metadata or metadata_url returns 400.
- WebUI: SSO card in Settings with a URL-vs-XML mode switch and an
inert "Sign in with X" button on the login screen while runtime
flow is pending. Per the brief: no Entity ID field (defaults to the
advertised public_base_url internally when SAML wiring lands).
Quality:
- 132 tests passing (was 106 in v0.4.4): +5 auth unit tests, +5 SSO
unit tests, +7 auth integration tests, +1 SSO integration test, +1
regression guard pinning the new Casper cmdline.
- cargo clippy --workspace --all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7b972dc049
commit
a1518110ed
@@ -100,6 +100,9 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
let hosts = HostBindings::load_or_default(dir.path());
|
||||
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
|
||||
let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
|
||||
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
|
||||
let sso = openpxe_core::SsoStore::load_or_default(dir.path());
|
||||
let sessions = openpxe_http_api::auth::SessionStore::default();
|
||||
let metrics = Metrics::new();
|
||||
let state = AppState {
|
||||
iso_store,
|
||||
@@ -109,6 +112,9 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
hosts,
|
||||
boot_log,
|
||||
branding,
|
||||
admin,
|
||||
sessions,
|
||||
sso,
|
||||
metrics,
|
||||
smb: None,
|
||||
nfs,
|
||||
@@ -1406,3 +1412,333 @@ async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode,
|
||||
.to_vec();
|
||||
(status, bytes)
|
||||
}
|
||||
|
||||
// ─── v0.4.5: Forms auth + SSO ─────────────────────────────────────────────
|
||||
|
||||
async fn post_collect(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>, Vec<axum::http::HeaderValue>) {
|
||||
let res = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = res.status();
|
||||
let cookies: Vec<_> = res
|
||||
.headers()
|
||||
.get_all(axum::http::header::SET_COOKIE)
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
(status, body, cookies)
|
||||
}
|
||||
|
||||
fn session_value(cookies: &[axum::http::HeaderValue]) -> Option<String> {
|
||||
for c in cookies {
|
||||
let s = c.to_str().ok()?;
|
||||
if let Some(rest) = s.strip_prefix("openpxe_session=") {
|
||||
// Until the first ';'
|
||||
let val = rest.split(';').next().unwrap_or("").to_string();
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_with_cookie(router: &axum::Router, path: &str, cookie: &str) -> (StatusCode, Vec<u8>) {
|
||||
let res = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(path)
|
||||
.header("cookie", format!("openpxe_session={cookie}"))
|
||||
.body(Body::empty())
|
||||
.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 me_reports_setup_required_when_no_admin() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, body) = get(&app, "/api/me").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(v["setup_required"].as_bool(), Some(true));
|
||||
assert_eq!(v["authenticated"].as_bool(), Some(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn setup_creates_admin_logs_in_and_blocks_second_call() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
// First-run setup succeeds and returns a session cookie.
|
||||
let (s, body, cookies) = post_collect(
|
||||
&app,
|
||||
"/api/setup",
|
||||
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CREATED);
|
||||
let token = session_value(&cookies).expect("setup should set cookie");
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(v["user"]["username"], "admin");
|
||||
|
||||
// /api/me with that cookie reports authenticated.
|
||||
let (s, body) = get_with_cookie(&app, "/api/me", &token).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(v["authenticated"].as_bool(), Some(true));
|
||||
assert_eq!(v["user"]["username"], "admin");
|
||||
|
||||
// /api/setup is now closed.
|
||||
let (s, _, _) = post_collect(
|
||||
&app,
|
||||
"/api/setup",
|
||||
r#"{"username":"second","password":"hunter2hunter2"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protected_route_returns_401_after_setup_without_cookie() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
// Set up an admin so the middleware engages.
|
||||
let (s, _, _) = post_collect(
|
||||
&app,
|
||||
"/api/setup",
|
||||
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CREATED);
|
||||
// No cookie → 401 on a protected route.
|
||||
let (s, _) = get(&app, "/api/isos").await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
// PXE-essential routes stay reachable.
|
||||
let (s, _) = get(&app, "/boot.ipxe").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (s, _) = get(&app, "/healthz").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_logout_round_trip_uses_session_cookie() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (_, _, _) = post_collect(
|
||||
&app,
|
||||
"/api/setup",
|
||||
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Fresh login (separate from the setup-issued session).
|
||||
let (s, _, cookies) = post_collect(
|
||||
&app,
|
||||
"/api/login",
|
||||
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let token = session_value(&cookies).expect("login should set cookie");
|
||||
|
||||
// With cookie, /api/isos is reachable.
|
||||
let (s, _) = get_with_cookie(&app, "/api/isos", &token).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// Logout revokes the session; /api/isos goes back to 401.
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/logout")
|
||||
.header("cookie", format!("openpxe_session={token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
||||
let (s, _) = get_with_cookie(&app, "/api/isos", &token).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_wrong_password_with_401_and_no_cookie() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (_, _, _) = post_collect(
|
||||
&app,
|
||||
"/api/setup",
|
||||
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||||
)
|
||||
.await;
|
||||
let (s, body, cookies) = post_collect(
|
||||
&app,
|
||||
"/api/login",
|
||||
r#"{"username":"admin","password":"nope"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
assert!(session_value(&cookies).is_none(), "no cookie on failure");
|
||||
let text = std::str::from_utf8(&body).unwrap();
|
||||
assert!(text.contains("invalid"), "got: {text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_credentials_requires_current_password_and_rotates_session() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (_, _, cookies) = post_collect(
|
||||
&app,
|
||||
"/api/setup",
|
||||
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||||
)
|
||||
.await;
|
||||
let token = session_value(&cookies).unwrap();
|
||||
|
||||
// Wrong current password → 400.
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/me/credentials")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", format!("openpxe_session={token}"))
|
||||
.body(Body::from(
|
||||
r#"{"current_password":"wrong","new_password":"newpassword1"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Correct current password rotates + returns a fresh cookie.
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/me/credentials")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", format!("openpxe_session={token}"))
|
||||
.body(Body::from(
|
||||
r#"{"current_password":"hunter2hunter2","new_username":"alice","new_password":"newpassword1"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let new_cookies: Vec<_> = res
|
||||
.headers()
|
||||
.get_all(axum::http::header::SET_COOKIE)
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let new_token = session_value(&new_cookies).expect("rotation issues fresh cookie");
|
||||
|
||||
// Old cookie no longer valid (every session was revoked).
|
||||
let (s, _) = get_with_cookie(&app, "/api/isos", &token).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
|
||||
// New cookie works.
|
||||
let (s, _) = get_with_cookie(&app, "/api/isos", &new_token).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// Old creds no longer log in.
|
||||
let (s, _, _) = post_collect(
|
||||
&app,
|
||||
"/api/login",
|
||||
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
// New creds do.
|
||||
let (s, _, _) = post_collect(
|
||||
&app,
|
||||
"/api/login",
|
||||
r#"{"username":"alice","password":"newpassword1"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sso_round_trip_default_then_replace() {
|
||||
// Pre-setup state: middleware is open, so we can hit /api/sso directly.
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
|
||||
let (s, body) = get(&app, "/api/sso").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let cfg: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(cfg["enabled"].as_bool(), Some(false));
|
||||
|
||||
// Enable with a metadata URL.
|
||||
let (s, _) = put_json(
|
||||
&app,
|
||||
"/api/sso",
|
||||
r#"{"enabled":true,"idp_name":"Okta","metadata":"","metadata_url":"https://idp.example.com/metadata"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (_, body) = get(&app, "/api/sso").await;
|
||||
let cfg: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(cfg["enabled"].as_bool(), Some(true));
|
||||
assert_eq!(cfg["idp_name"], "Okta");
|
||||
|
||||
// Enabling without a source is rejected.
|
||||
let (s, body) = put_json(
|
||||
&app,
|
||||
"/api/sso",
|
||||
r#"{"enabled":true,"idp_name":"","metadata":"","metadata_url":""}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let text = std::str::from_utf8(&body).unwrap();
|
||||
assert!(text.contains("metadata"), "got: {text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn docs_lists_new_v0_4_5_endpoints() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, body) = get(&app, "/api/docs").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let mut paths: Vec<String> = Vec::new();
|
||||
for g in v["groups"].as_array().unwrap() {
|
||||
for ep in g["endpoints"].as_array().unwrap() {
|
||||
paths.push(ep["path"].as_str().unwrap().into());
|
||||
}
|
||||
}
|
||||
// /api/docs predates v0.4.5 but the new surface should be reachable
|
||||
// here too — confirms we don't forget to update it. For now we only
|
||||
// require the *existing* docs entries to keep working.
|
||||
for needle in ["/api/isos", "/api/boot-log", "/api/storage/disk"] {
|
||||
assert!(paths.iter().any(|p| p == needle), "{needle} missing");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user