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
@@ -13,6 +13,7 @@
|
||||
//! | `/iso/<id>/*` | Files inside the ISO (for wimboot & kernel/initrd) |
|
||||
//! | `/api/*` | JSON/HTML API for the web UI |
|
||||
|
||||
use crate::auth as auth_api;
|
||||
use crate::ipxe_script::{
|
||||
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
|
||||
render_queue_entry, render_shell, render_tools_menu, render_util,
|
||||
@@ -30,7 +31,8 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use openpxe_core::{
|
||||
ext_for_mime, BootEvent, ClientEvent, Error, Settings, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
|
||||
ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES,
|
||||
MAX_LOGO_BYTES,
|
||||
};
|
||||
use openpxe_ipxe_assets::asset_bytes;
|
||||
use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest};
|
||||
@@ -97,6 +99,24 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// under the Settings tab — operators chasing an integration get
|
||||
// it in-product instead of having to fetch the OpenAPI YAML.
|
||||
.route("/api/docs", get(api_docs))
|
||||
// v0.4.5: Sonarr/Radarr-style admin Forms auth. First-run
|
||||
// /setup creates the single admin account; /login validates;
|
||||
// /logout revokes the session; /me powers the front-end's
|
||||
// "should I show the setup page, the login page, or the
|
||||
// dashboard?" decision. /me/credentials rotates the admin's
|
||||
// username/password.
|
||||
.route("/api/setup", post(auth_api::api_setup))
|
||||
.route("/api/login", post(auth_api::api_login))
|
||||
.route("/api/logout", post(auth_api::api_logout))
|
||||
.route("/api/me", get(auth_api::api_me))
|
||||
.route(
|
||||
"/api/me/credentials",
|
||||
put(auth_api::api_update_credentials),
|
||||
)
|
||||
// v0.4.5: SAML SSO configuration (FleetDM-shaped, storage-only).
|
||||
// The actual sign-in flow lands in a later release; this just
|
||||
// gives operators a place to paste their IdP metadata today.
|
||||
.route("/api/sso", get(api_sso_get).put(api_sso_put))
|
||||
.route("/api/clients", get(api_list_clients))
|
||||
.route("/api/status", get(api_status))
|
||||
.route("/api/settings", get(api_get_settings).put(api_put_settings))
|
||||
@@ -128,12 +148,41 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// format. No auth — the metrics surface is intentionally
|
||||
// boring (counts, no payloads).
|
||||
.route("/metrics", get(api_metrics))
|
||||
// v0.4.5: Forms-auth middleware. Layered *after* `.route(...)`
|
||||
// calls so it applies uniformly; passes everything through when
|
||||
// no admin is configured (tests + fresh installs ride this path).
|
||||
// The allowlist inside `auth_api::require_auth` keeps PXE-essential
|
||||
// endpoints reachable for iPXE clients that can't authenticate.
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth_api::require_auth,
|
||||
))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
|
||||
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ─── SSO config endpoints ─────────────────────────────────────────────────
|
||||
|
||||
async fn api_sso_get(State(state): State<AppState>) -> Json<SsoConfig> {
|
||||
// We deliberately do not redact the metadata — the operator who's
|
||||
// signed in needs to be able to round-trip it. /api/sso requires
|
||||
// the auth middleware anyway, so unauthenticated callers can't see
|
||||
// it once admin is configured.
|
||||
Json(state.sso.snapshot())
|
||||
}
|
||||
|
||||
async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response {
|
||||
match state.sso.replace(body) {
|
||||
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
|
||||
Err(Error::Invalid(msg)) => {
|
||||
(StatusCode::BAD_REQUEST, msg).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn index(State(state): State<AppState>) -> Response {
|
||||
@@ -896,10 +945,29 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"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": "GET", "path": "/api/sso",
|
||||
"summary": "Current SAML SSO configuration."},
|
||||
{"method": "PUT", "path": "/api/sso",
|
||||
"summary": "Replace SAML SSO configuration. Body: { enabled, idp_name, metadata, metadata_url }."},
|
||||
{"method": "GET", "path": "/api/docs",
|
||||
"summary": "This API reference."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Auth (Forms)",
|
||||
"endpoints": [
|
||||
{"method": "POST", "path": "/api/setup",
|
||||
"summary": "First-run admin bootstrap. Body: { username, password }. Refuses after the admin exists."},
|
||||
{"method": "POST", "path": "/api/login",
|
||||
"summary": "Sign in. Body: { username, password }. Sets the openpxe_session cookie."},
|
||||
{"method": "POST", "path": "/api/logout",
|
||||
"summary": "Revoke the current session and clear the cookie."},
|
||||
{"method": "GET", "path": "/api/me",
|
||||
"summary": "Auth status — { setup_required, authenticated, user }. Always 200."},
|
||||
{"method": "PUT", "path": "/api/me/credentials",
|
||||
"summary": "Rotate the admin's credentials. Body: { current_password, new_username?, new_password? }. Revokes all other sessions on success."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Storage telemetry",
|
||||
"endpoints": [
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Forms auth layer — sessions, login, setup, middleware.
|
||||
//!
|
||||
//! Three states:
|
||||
//!
|
||||
//! * **Unconfigured** (`AdminStore::is_configured() == false`). The
|
||||
//! middleware passes every request through — there's no one to gate
|
||||
//! against. The UI's `/api/me` returns `setup_required: true` and the
|
||||
//! front-end pushes the operator into the first-run flow.
|
||||
//! * **Logged in**. The session cookie maps to an in-memory session
|
||||
//! record with an idle expiry; `/api/me` returns the username.
|
||||
//! * **Logged out**. The middleware bounces `/api/*` (with the PXE
|
||||
//! allowlist below) to `401 Unauthorized`; the front-end intercepts
|
||||
//! that and shows `/login`.
|
||||
//!
|
||||
//! Allowlist for unauthenticated access *after* the admin is set up:
|
||||
//!
|
||||
//! * everything outside `/api/*` (the WebUI bundle, asset chrome, PXE
|
||||
//! script endpoints, the bundled iPXE/wimboot binaries, ISO bytes,
|
||||
//! liveness/readiness probes, the Prometheus scrape) — these are
|
||||
//! read-only or PXE-essential and breaking them locks out booting
|
||||
//! machines that have no way to authenticate;
|
||||
//! * `/api/setup`, `/api/login`, `/api/me` (the auth surface itself);
|
||||
//! * `/api/queue/join`, `/api/queue/poll/:entry_id` (iPXE long-poll for
|
||||
//! Queued Deployment — the iPXE client can't send a session cookie).
|
||||
//!
|
||||
//! Everything else inside `/api/*` requires a valid session.
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Request, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use openpxe_core::AdminPublic;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Idle session lifetime. Sliding — every authenticated request resets
|
||||
/// the expiry. 24h is the Sonarr default and matches what most operators
|
||||
/// expect for an on-prem admin console.
|
||||
const SESSION_TTL: Duration = Duration::from_hours(24);
|
||||
|
||||
/// Name of the cookie we set/read. Distinct from a generic `session=`
|
||||
/// to avoid collisions with anything else sharing the host.
|
||||
pub const SESSION_COOKIE: &str = "openpxe_session";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Session {
|
||||
username: String,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// In-memory session table. Cheap to clone (Arc-shared) and contention
|
||||
/// is rare — operators sign in once per browser session.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SessionStore {
|
||||
inner: Arc<RwLock<HashMap<String, Session>>>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// Mint a fresh session for `username` and return the opaque cookie
|
||||
/// value. UUID v4 gives us 122 random bits — comfortably more than
|
||||
/// the 64-128 bits typical for session IDs.
|
||||
#[must_use]
|
||||
pub fn create(&self, username: &str) -> String {
|
||||
let id = Uuid::new_v4().simple().to_string();
|
||||
let session = Session {
|
||||
username: username.to_string(),
|
||||
expires_at: Instant::now() + SESSION_TTL,
|
||||
};
|
||||
self.inner.write().insert(id.clone(), session);
|
||||
id
|
||||
}
|
||||
|
||||
/// Resolve a cookie value to the owning username, refreshing the
|
||||
/// idle timer. Returns `None` for missing / expired sessions and
|
||||
/// proactively evicts the expired entry so the map doesn't grow
|
||||
/// unbounded across long-lived deployments.
|
||||
pub fn touch(&self, id: &str) -> Option<String> {
|
||||
let mut g = self.inner.write();
|
||||
let s = g.get_mut(id)?;
|
||||
if s.expires_at <= Instant::now() {
|
||||
g.remove(id);
|
||||
return None;
|
||||
}
|
||||
s.expires_at = Instant::now() + SESSION_TTL;
|
||||
Some(s.username.clone())
|
||||
}
|
||||
|
||||
/// Invalidate one session (the user's `/api/logout`).
|
||||
pub fn revoke(&self, id: &str) {
|
||||
self.inner.write().remove(id);
|
||||
}
|
||||
|
||||
/// Invalidate every session — used after a credentials rotation so
|
||||
/// stale cookies for the old password can't keep operating.
|
||||
pub fn revoke_all(&self) {
|
||||
self.inner.write().clear();
|
||||
}
|
||||
|
||||
/// Periodic / opportunistic GC. Not currently scheduled (we evict
|
||||
/// on touch), but exposed for a future janitor task.
|
||||
pub fn gc(&self) {
|
||||
let now = Instant::now();
|
||||
self.inner.write().retain(|_, s| s.expires_at > now);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.read().len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cookie helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn cookie_attrs(value: &str, max_age: Option<i64>) -> String {
|
||||
// Same flags FleetDM and Sonarr ship by default:
|
||||
// - HttpOnly: blocks JS access (XSS containment)
|
||||
// - SameSite=Lax: allows top-level GET navigations from the IdP
|
||||
// to land authenticated when SSO arrives, but blocks
|
||||
// cross-site POST CSRF;
|
||||
// - Path=/: the cookie applies to the whole app;
|
||||
// - no Secure flag yet — many operators host on plain http://
|
||||
// LAN IPs (Unraid templates default to that); we'll add Secure
|
||||
// opportunistically when we add a TLS terminator option.
|
||||
// SESSION_TTL fits in 32 bits comfortably (24h ≈ 86400 seconds); we
|
||||
// never overflow i64, but clippy's `cast_possible_wrap` lint wants
|
||||
// us to be explicit. `cast_signed` is the documented form.
|
||||
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
|
||||
format!(
|
||||
"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}"
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
|
||||
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||
for part in raw.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Return `true` if `path` is on the allowlist and should bypass the
|
||||
/// session check. The middleware applies this rule only when the admin
|
||||
/// account is configured; before then everything is open.
|
||||
fn is_public_path(path: &str) -> bool {
|
||||
// Non-API paths: WebUI bundle, PXE chain, ISO bytes, health probes,
|
||||
// metrics. All read-only / PXE-essential.
|
||||
if !path.starts_with("/api/") {
|
||||
return true;
|
||||
}
|
||||
// Auth surface and iPXE long-poll endpoints (no cookie available).
|
||||
matches!(
|
||||
path,
|
||||
"/api/setup" | "/api/login" | "/api/logout" | "/api/me"
|
||||
) || path.starts_with("/api/queue/join")
|
||||
|| path.starts_with("/api/queue/poll/")
|
||||
}
|
||||
|
||||
/// Axum middleware: gate `/api/*` behind a valid session, with the
|
||||
/// allowlist above. `State<AppState>` reaches in for the admin store +
|
||||
/// session store.
|
||||
pub async fn require_auth(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Bypass entirely while unconfigured. The /api/setup endpoint is
|
||||
// the only one that can flip this back to "configured", and it
|
||||
// refuses to run a second time. Tests + fresh installs ride this
|
||||
// path.
|
||||
if !state.admin.is_configured() {
|
||||
return next.run(req).await;
|
||||
}
|
||||
let path = req.uri().path();
|
||||
if is_public_path(path) {
|
||||
return next.run(req).await;
|
||||
}
|
||||
// Authenticated path. The cookie must be present, map to a live
|
||||
// session, and the TTL refresh happens as a side-effect.
|
||||
let token = parse_cookie(req.headers());
|
||||
if let Some(t) = token {
|
||||
if state.sessions.touch(&t).is_some() {
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "authentication required" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetupBody {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// First-run setup. Refuses to run once an admin already exists — that
|
||||
/// guards against a leaked WebUI being re-bootstrapped by an attacker
|
||||
/// who's seen the deployment URL. After bootstrap, the new session
|
||||
/// cookie is set so the operator goes straight to the dashboard.
|
||||
pub async fn api_setup(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<SetupBody>,
|
||||
) -> Response {
|
||||
if state.admin.is_configured() {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": "admin account already configured" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match state.admin.bootstrap(&body.username, &body.password) {
|
||||
Ok(pub_) => {
|
||||
let session = state.sessions.create(&pub_.username);
|
||||
login_response(StatusCode::CREATED, &pub_, &session)
|
||||
}
|
||||
Err(openpxe_core::Error::Invalid(msg)) => {
|
||||
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("{e}") })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginBody {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
|
||||
// Brief, deliberately vague — "invalid credentials" rather than
|
||||
// "no such user" / "wrong password". Same anti-enumeration posture
|
||||
// as Sonarr/Radarr.
|
||||
let pub_ = match state.admin.verify(&body.username, &body.password) {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid username or password" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("{e}") })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let session = state.sessions.create(&pub_.username);
|
||||
login_response(StatusCode::OK, &pub_, &session)
|
||||
}
|
||||
|
||||
pub async fn api_logout(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
if let Some(t) = parse_cookie(&headers) {
|
||||
state.sessions.revoke(&t);
|
||||
}
|
||||
// Stomp the cookie unconditionally — even if the request didn't
|
||||
// carry one, the browser shouldn't keep a stale value.
|
||||
let mut resp = StatusCode::NO_CONTENT.into_response();
|
||||
resp.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie_attrs("", Some(0))).unwrap(),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Status surface for the front-end shell. Returns four cases:
|
||||
///
|
||||
/// * `setup_required: true` — no admin yet; show first-run page.
|
||||
/// * `authenticated: false` — admin exists, no session; show login.
|
||||
/// * `authenticated: true` + `user` — let the dashboard load.
|
||||
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
|
||||
if !state.admin.is_configured() {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"setup_required": true,
|
||||
"authenticated": false,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let token = parse_cookie(&headers);
|
||||
let username = token.as_deref().and_then(|t| state.sessions.touch(t));
|
||||
match username {
|
||||
Some(u) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"setup_required": false,
|
||||
"authenticated": true,
|
||||
"user": state.admin.snapshot(),
|
||||
"session_user": u,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
None => (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"setup_required": false,
|
||||
"authenticated": false,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateCredentialsBody {
|
||||
pub current_password: String,
|
||||
#[serde(default)]
|
||||
pub new_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub new_password: Option<String>,
|
||||
}
|
||||
|
||||
/// Rotate the admin's username and/or password. Auth middleware has
|
||||
/// already proved the caller owns a session; we additionally require
|
||||
/// the *current* password to prove "person at the keyboard right now".
|
||||
/// On success we issue a fresh session cookie keyed to the (possibly
|
||||
/// new) username and revoke every prior session so a stolen cookie
|
||||
/// from before the rotation stops working.
|
||||
pub async fn api_update_credentials(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<UpdateCredentialsBody>,
|
||||
) -> Response {
|
||||
if !state.admin.is_configured() {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": "no admin configured" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let result = state.admin.update_credentials(
|
||||
&body.current_password,
|
||||
body.new_username.as_deref(),
|
||||
body.new_password.as_deref(),
|
||||
);
|
||||
match result {
|
||||
Ok(pub_) => {
|
||||
state.sessions.revoke_all();
|
||||
let session = state.sessions.create(&pub_.username);
|
||||
login_response(StatusCode::OK, &pub_, &session)
|
||||
}
|
||||
Err(openpxe_core::Error::Invalid(msg)) => {
|
||||
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("{e}") })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LoginPayload<'a> {
|
||||
user: &'a AdminPublic,
|
||||
authenticated: bool,
|
||||
}
|
||||
|
||||
fn login_response(status: StatusCode, user: &AdminPublic, session: &str) -> Response {
|
||||
let body = Json(LoginPayload {
|
||||
user,
|
||||
authenticated: true,
|
||||
});
|
||||
let mut resp = (status, body).into_response();
|
||||
resp.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie_attrs(session, None)).unwrap(),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_create_touch_revoke() {
|
||||
let s = SessionStore::default();
|
||||
assert!(s.is_empty());
|
||||
let t = s.create("admin");
|
||||
assert_eq!(s.len(), 1);
|
||||
assert_eq!(s.touch(&t).as_deref(), Some("admin"));
|
||||
s.revoke(&t);
|
||||
assert!(s.is_empty());
|
||||
// Stale token doesn't error, just returns None.
|
||||
assert!(s.touch(&t).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_revoke_all_clears() {
|
||||
let s = SessionStore::default();
|
||||
let _ = s.create("a");
|
||||
let _ = s.create("b");
|
||||
assert_eq!(s.len(), 2);
|
||||
s.revoke_all();
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_path_allowlist() {
|
||||
// PXE + chrome paths bypass auth.
|
||||
for p in [
|
||||
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe",
|
||||
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz",
|
||||
"/metrics",
|
||||
] {
|
||||
assert!(is_public_path(p), "expected {p} to be public");
|
||||
}
|
||||
// Auth surface itself is public.
|
||||
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
|
||||
assert!(is_public_path(p), "expected {p} to be public");
|
||||
}
|
||||
// iPXE long-poll endpoints are public (no cookie available).
|
||||
assert!(is_public_path("/api/queue/join"));
|
||||
assert!(is_public_path("/api/queue/poll/abc"));
|
||||
// Everything else under /api/* must auth.
|
||||
for p in [
|
||||
"/api/isos",
|
||||
"/api/isos/x/category",
|
||||
"/api/storage/disk",
|
||||
"/api/branding/logo",
|
||||
"/api/sso",
|
||||
"/api/hosts",
|
||||
] {
|
||||
assert!(!is_public_path(p), "expected {p} to require auth");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_parse_picks_session_value() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux"))
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
|
||||
// Different name → None.
|
||||
let mut h2 = axum::http::HeaderMap::new();
|
||||
h2.insert(header::COOKIE, HeaderValue::from_str("foo=bar").unwrap());
|
||||
assert!(parse_cookie(&h2).is_none());
|
||||
// No cookie header at all → None.
|
||||
assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod ipxe_script;
|
||||
pub mod iso_fs;
|
||||
pub mod log_stream;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::uploads::UploadSessions;
|
||||
use crate::auth::SessionStore;
|
||||
use openpxe_core::{
|
||||
BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics,
|
||||
SettingsStore,
|
||||
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
||||
Metrics, SettingsStore, SsoStore,
|
||||
};
|
||||
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
|
||||
use std::sync::Arc;
|
||||
@@ -25,6 +26,17 @@ pub struct AppState {
|
||||
/// operator hasn't uploaded anything, the WebUI serves the bundled
|
||||
/// rainbow-horizon mark.
|
||||
pub branding: BrandingStore,
|
||||
/// Forms-auth admin record + first-run bootstrap state. When
|
||||
/// `admin.is_configured() == false`, the auth middleware passes
|
||||
/// every request through and `/api/me` reports `setup_required`.
|
||||
pub admin: AdminStore,
|
||||
/// In-memory session table for active operator logins. Cleared on
|
||||
/// process restart (sessions are tied to UI state, not persisted —
|
||||
/// matches Sonarr/Radarr behaviour).
|
||||
pub sessions: SessionStore,
|
||||
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
|
||||
/// flow ships in a later release.
|
||||
pub sso: SsoStore,
|
||||
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
|
||||
/// text format. Cheap to clone (handles to atomics).
|
||||
pub metrics: Metrics,
|
||||
|
||||
Reference in New Issue
Block a user