From cceb430659d15ecdb3fffa24e7c7969663eb4bcf Mon Sep 17 00:00:00 2001 From: vdmo Date: Thu, 1 Oct 2026 19:10:38 +1000 Subject: [PATCH] feat(conductor): runtime rule loading via CLI, HTTP Basic Auth on the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real operational gaps, closed: - Rules were only ever loadable from a bundled, compile-time-embedded file (RuleEngine::load_from_file and PCHIConductor::with_rules_file already existed, but main.rs never called them) — every venue wanting different thresholds needed a recompile. `--rules ` / PCHI_RULES_FILE now wires straight through to the real binary. - The dashboard, its /ws feed, and /governance/export were either fully open or (export only) gated by a bespoke X-Governance-Key header that a plain browser navigation can't satisfy. PCHI_DASHBOARD_PASSWORD now gates all three behind HTTP Basic Auth (username "operator") — the one scheme a direct `GET /` in a browser can answer on its own, via the browser's native credential prompt. Still open by default, unchanged posture: self-hosted on a machine its operator already controls. Verified live, not just in tests: a custom .only-pchi file with a stricter threshold than the bundled default actually denies traffic the default would allow, over real UDP; the auth layer returns real 401s without credentials and real 200s with correct ones, including on /governance/export. 9 new tests (31 -> 40 total). clippy clean. fmt unchanged (pre-existing report-only policy, see .github/workflows/rust.yml). Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 97 +++++++++++++ PRICING.md | 9 +- README.md | 24 ++- docs/LIMITATIONS.md | 31 ++-- primeswarm-pchi/Cargo.toml | 2 + primeswarm-pchi/src/conductor.rs | 58 ++++++++ primeswarm-pchi/src/main.rs | 58 +++++++- primeswarm-pchi/src/web_server.rs | 233 ++++++++++++++++++++++++++---- 8 files changed, 466 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ff9f064..dc768f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,12 +26,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -240,6 +284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -248,8 +293,22 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -258,6 +317,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const-oid" version = "0.9.6" @@ -576,6 +641,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -709,6 +780,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -825,6 +902,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -931,7 +1014,9 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64", "chrono", + "clap", "ed25519-dalek", "futures-util", "hex", @@ -1277,6 +1362,12 @@ dependencies = [ "der", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1578,6 +1669,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.26.1" diff --git a/PRICING.md b/PRICING.md index a452aec..f11d454 100644 --- a/PRICING.md +++ b/PRICING.md @@ -11,9 +11,12 @@ code. Schema, conductor, rule engine, signed governance log, bridges — all of it, Apache-2.0/MIT (see `LICENSE`), no key required, no phone-home, no feature gated behind payment for someone running their own copy on their own -machine. `primeswarm-pchi`'s `/governance/export` route supports an -optional `PCHI_GOVERNANCE_KEY` — that's a *security* control for anyone -exposing a conductor beyond localhost, not a paywall; it defaults open. +machine. `primeswarm-pchi` supports an optional `PCHI_DASHBOARD_PASSWORD` +gating its dashboard, `/ws` feed, and `/governance/export` — that's a +*security* control for anyone exposing a conductor beyond localhost, not +a paywall; it defaults open. Same for `--rules`/`PCHI_RULES_FILE`: a +venue's own rule thresholds, loaded from a plain text file, free to write +and change as often as you like. What's paid is work someone has to actually do on your behalf, not software we could give you for free and chose not to: diff --git a/README.md b/README.md index 907a656..3ef3bbf 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ This repository contains all the core libraries, tools, and documentation needed ```bash cargo build # workspace root — builds pchi-schema and primeswarm-pchi -cargo test # 25 tests: schema validation, the Only-Lang parser/evaluator, +cargo test # 40 tests: schema validation, the Only-Lang parser/evaluator, # signed-receipt chaining and tamper detection, end-to-end - # rule denial through the conductor + # rule denial through the conductor, dashboard auth ``` ## Governance — what's real @@ -37,6 +37,26 @@ curl http://127.0.0.1:3000/governance/export > export.json python3 scripts/verify_governance_chain.py export.json ``` +A fresh checkout runs the bundled default rule set +(`pchi-schema/examples/kraken-tentacle.only-pchi`). Point it at a +different `.only-pchi` file for a real venue/show instead — no recompile +needed: + +```bash +cargo run -p primeswarm-pchi -- --rules ./my-venue-rules.only-pchi +# or: PCHI_RULES_FILE=./my-venue-rules.only-pchi cargo run -p primeswarm-pchi +``` + +The dashboard, its `/ws` feed, and `/governance/export` are open by +default (self-hosted on a machine its operator already controls). Set +`PCHI_DASHBOARD_PASSWORD` to require HTTP Basic Auth (username +`operator`) before exposing a conductor beyond localhost: + +```bash +PCHI_DASHBOARD_PASSWORD=change-me cargo run -p primeswarm-pchi +curl -u operator:change-me http://127.0.0.1:3000/governance/export +``` + See `PRICING.md` for what's free (all of the above, unrestricted, self-hosted) versus what's a real paid service, and `docs/LIMITATIONS.md` for what this does and doesn't yet prove. diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index bfc9f27..ffad6de 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -56,16 +56,27 @@ separately; there is no cross-conductor verification story yet. **Not yet built:** the Cloud Conductor described in `PRICING.md` is the natural place to add this. -## 4. `/governance/export` Access Control Is Opt-In, Not Default-Closed - -`PCHI_GOVERNANCE_KEY` gates the export endpoint when set; it is **open by -default**, deliberately, because this conductor is meant to be -self-hosted on a machine its operator already controls — unlike DGV's -`/decisions/export`, which was found open on a genuinely multi-tenant, -publicly reachable deployment and is now closed by default (see DGV's -`LIMITATIONS.md` §4 for that finding). If you expose a PCHI conductor -beyond localhost, set the key yourself; the software will not do it for -you. +## 4. Dashboard Access Control Is Opt-In, Not Default-Closed + +**[Improved]** `PCHI_DASHBOARD_PASSWORD`, when set, now gates all three +HTTP surfaces — the dashboard page (`/`), its `/ws` telemetry feed, and +`/governance/export` — behind HTTP Basic Auth (username `operator`), +not just the export endpoint on its own as the earlier +`PCHI_GOVERNANCE_KEY` did. It is still **open by default**, deliberately, +because this conductor is meant to be self-hosted on a machine its +operator already controls — unlike DGV's `/decisions/export`, which was +found open on a genuinely multi-tenant, publicly reachable deployment and +is now closed by default (see DGV's `LIMITATIONS.md` §4 for that +finding). If you expose a PCHI conductor beyond localhost, set the +password yourself; the software will not do it for you. + +One real gap: a non-browser WebSocket client connecting to `/ws` +directly (not a page that already loaded `/` and cached credentials) +must send its own `Authorization: Basic ...` header on the handshake — +the plain browser `WebSocket` API has no way to attach one itself, so +this only works automatically for the dashboard's own JS, which inherits +credentials the browser cached from loading the authenticated page +first. ## 5. No Independent Security Review diff --git a/primeswarm-pchi/Cargo.toml b/primeswarm-pchi/Cargo.toml index 70c07da..a078a1a 100644 --- a/primeswarm-pchi/Cargo.toml +++ b/primeswarm-pchi/Cargo.toml @@ -25,6 +25,8 @@ futures-util = "0.3" axum = { version = "0.7", features = ["ws"] } tower = "0.5" tower-http = { version = "0.5", features = ["fs", "cors"] } +clap = { version = "4", features = ["derive", "env"] } +base64 = "0.22" [dev-dependencies] tokio-test = "0.4" diff --git a/primeswarm-pchi/src/conductor.rs b/primeswarm-pchi/src/conductor.rs index e7c1e62..81ef38e 100644 --- a/primeswarm-pchi/src/conductor.rs +++ b/primeswarm-pchi/src/conductor.rs @@ -445,4 +445,62 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + #[tokio::test] + async fn with_rules_file_actually_uses_the_custom_rules_not_the_bundled_default() { + let dir = std::env::temp_dir().join(format!("pchi-rulesfile-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + + // A threshold (2.0) stricter than the bundled default's (4.0) — + // chosen so a value that the bundled rules would silently allow + // only denies if this file is genuinely the one in effect. + let rules_path = dir.join("strict.only-pchi"); + std::fs::write( + &rules_path, + "harmony(1e-12)\n\nif tentacle_system.total_curl > 2.0 then\n deny(\"too strict for the bundled default to agree with\")\nend\n", + ) + .unwrap(); + + let conductor = PCHIConductor::with_rules_file( + 1e-12, + rules_path.to_str().unwrap(), + dir.join("key.hex").to_str().unwrap(), + dir.join("log.jsonl").to_str().unwrap(), + ) + .unwrap(); + + let msg = PCHIMessage::new( + MessageType::ControlParameter, + "test".to_string(), + Payload::ControlParameter(ControlParameterData { + target_id: "tentacle_system".to_string(), + parameter: "total_curl".to_string(), + value: serde_json::json!(3.0), + }), + ); + let result = conductor.process_message(msg).await; + assert!( + result.is_err(), + "3.0 is under the bundled default's 4.0 threshold but over this file's 2.0 — \ + an error here proves the custom file is the one actually loaded" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn with_rules_file_rejects_a_missing_path_instead_of_silently_falling_back() { + let dir = std::env::temp_dir().join(format!("pchi-rulesfile-missing-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + + let result = PCHIConductor::with_rules_file( + 1e-12, + dir.join("does-not-exist.only-pchi").to_str().unwrap(), + dir.join("key.hex").to_str().unwrap(), + dir.join("log.jsonl").to_str().unwrap(), + ); + assert!(result.is_err(), "a missing rules file must be a startup error, not a silent fallback"); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/primeswarm-pchi/src/main.rs b/primeswarm-pchi/src/main.rs index 001c507..ed5dcbc 100644 --- a/primeswarm-pchi/src/main.rs +++ b/primeswarm-pchi/src/main.rs @@ -2,16 +2,52 @@ use primeswarm_pchi::{PCHIConductor, WebServerState, create_router}; use pchi_schema::{PCHIMessage, MessageType, Payload, ControlParameterData}; +use clap::Parser; use std::sync::Arc; +/// PCHI Conductor — central state management with PIR mathematical +/// governance. All flags also read from an env var of the same name +/// (upper-cased), so a container/service deployment can configure this +/// without touching the invocation. +#[derive(Parser)] +#[command(about = "PCHI Conductor - central state management with PIR mathematical governance")] +struct Cli { + /// Path to a .only-pchi rule file. Omit to use the bundled default + /// rule set (pchi-schema/examples/kraken-tentacle.only-pchi) — the + /// same rules every fresh checkout has always run, unchanged. + #[arg(long, env = "PCHI_RULES_FILE")] + rules: Option, +} + #[tokio::main] async fn main() -> Result<(), Box> { // Initialize logging tracing_subscriber::fmt::init(); - - // Create PCHI Conductor - let mut conductor = PCHIConductor::new(1e-12); - + + let cli = Cli::parse(); + + // Create PCHI Conductor. Loading rules from a file at startup — rather + // than only ever running the rule set compiled into the binary — is + // what makes a single `primeswarm-pchi` build usable across different + // venues/shows: each one points at its own .only-pchi file instead of + // needing a recompile to change a safety threshold. + let mut conductor = match &cli.rules { + Some(path) => { + println!("Loading rules from {}", path); + PCHIConductor::with_rules_file( + 1e-12, + path, + "pchi_governance_key.hex", + "pchi_governance_log.jsonl", + ) + .unwrap_or_else(|e| { + eprintln!("Failed to load rules from {}: {}", path, e); + std::process::exit(1); + }) + } + None => PCHIConductor::new(1e-12), + }; + println!("PCHI Conductor started"); // Start transport layer (UDP + WebSocket) @@ -19,10 +55,22 @@ async fn main() -> Result<(), Box> { println!("Transport layer started on UDP 127.0.0.1:8888"); println!("WebSocket server started on ws://127.0.0.1:8889"); - // Create web server state + // Create web server state. PCHI_DASHBOARD_PASSWORD, when set, requires + // HTTP Basic Auth (username "operator") on the dashboard, its /ws feed, + // and /governance/export — unset by default, same self-hosted-trusted- + // machine posture this conductor has always had. + let dashboard_password = std::env::var("PCHI_DASHBOARD_PASSWORD") + .ok() + .filter(|s| !s.is_empty()); + if dashboard_password.is_some() { + println!("Dashboard auth enabled (HTTP Basic, username: operator)"); + } else { + println!("Dashboard auth disabled — set PCHI_DASHBOARD_PASSWORD to require credentials"); + } let web_state = WebServerState { scene_state: conductor.get_state_arc(), governance: Some(conductor.get_governance_arc()), + dashboard_password, }; println!( "Governance log verifying_key: {}", diff --git a/primeswarm-pchi/src/web_server.rs b/primeswarm-pchi/src/web_server.rs index 87112e1..c241434 100644 --- a/primeswarm-pchi/src/web_server.rs +++ b/primeswarm-pchi/src/web_server.rs @@ -2,17 +2,25 @@ use crate::{GovernanceLog, SceneState}; use axum::{ - extract::{State, ws::WebSocket, WebSocketUpgrade}, - http::{HeaderMap, StatusCode}, - response::{Html, IntoResponse, Json}, + extract::{Request, State, ws::WebSocket, WebSocketUpgrade}, + http::{header, HeaderMap, StatusCode}, + middleware::{self, Next}, + response::{Html, IntoResponse, Json, Response}, routing::get, Router, }; +use base64::Engine; use std::sync::Arc; use tokio::sync::RwLock; use futures_util::StreamExt; use serde_json::json; +/// Fixed username for dashboard Basic Auth. This is a single-operator +/// tool (self-hosted on a machine the operator already controls), not a +/// multi-user system — one shared secret is enough, so there's nothing +/// gained by making the username configurable too. +const DASHBOARD_USERNAME: &str = "operator"; + /// Web server state #[derive(Clone)] pub struct WebServerState { @@ -23,29 +31,89 @@ pub struct WebServerState { /// stays constructible from just a scene-state arc, as it always has /// been, for anyone already embedding it that way). pub governance: Option>, + /// Shared-secret password gating `/`, `/ws`, and `/governance/export` + /// via HTTP Basic Auth. `None` disables auth entirely — the default, + /// since this conductor is meant to be self-hosted on a machine its + /// operator already controls; there's no default audience this needs + /// to be closed against. Set this before exposing a conductor beyond + /// localhost. + pub dashboard_password: Option, } -/// Create web server router +/// Create web server router. When `state.dashboard_password` is set, +/// every route requires HTTP Basic Auth — chosen (over a bespoke header, +/// what `/governance/export` used before) because it's the one scheme a +/// plain browser `GET /` navigation can satisfy on its own, via the +/// browser's native credential prompt, with no login page or session +/// cookie to build. `/ws` works the same way in practice: a browser that +/// already authenticated to load the dashboard page caches those +/// credentials for the origin and attaches them automatically to the +/// same-origin WebSocket handshake its own JS opens — standard browser +/// behavior, not something this code arranges. A non-browser WebSocket +/// client (a script, not a loaded page) has no page to inherit +/// credentials from, so it must send its own `Authorization: Basic ...` +/// header on the handshake request. pub fn create_router(state: WebServerState) -> Router { Router::new() .route("/", get(dashboard_handler)) .route("/ws", get(websocket_handler)) .route("/governance/export", get(governance_export_handler)) + .layer(middleware::from_fn_with_state(state.clone(), require_dashboard_auth)) .with_state(state) } -/// GET /governance/export — the signed, hash-chained batch of every rule -/// firing this conductor has recorded. Guarded by `PCHI_GOVERNANCE_KEY` -/// (sent as `X-Governance-Key`) when that env var is set; open otherwise, -/// since this conductor is meant to be self-hosted on a machine the -/// operator already controls — unlike DGV's `/decisions/export` (a -/// multi-tenant service other people's agents call), there's no default -/// audience this needs to be closed against. Set the key anyway before -/// exposing this conductor beyond localhost. -async fn governance_export_handler( +/// Constant-time byte comparison. Not critical for a single self-hosted +/// operator secret the way it would be for a multi-tenant credential +/// store, but cheap to do correctly rather than with `==`. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0 +} + +fn basic_auth_ok(headers: &HeaderMap, required_password: &str) -> bool { + let Some(auth) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) else { + return false; + }; + let Some(encoded) = auth.strip_prefix("Basic ") else { + return false; + }; + let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded) else { + return false; + }; + let Ok(decoded) = String::from_utf8(decoded) else { + return false; + }; + let Some((user, pass)) = decoded.split_once(':') else { + return false; + }; + user == DASHBOARD_USERNAME && constant_time_eq(pass.as_bytes(), required_password.as_bytes()) +} + +async fn require_dashboard_auth( State(state): State, headers: HeaderMap, -) -> impl IntoResponse { + request: Request, + next: Next, +) -> Response { + match &state.dashboard_password { + None => next.run(request).await, + Some(required) if basic_auth_ok(&headers, required) => next.run(request).await, + Some(_) => ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, "Basic realm=\"pchi-conductor\"")], + "Authentication required", + ) + .into_response(), + } +} + +/// GET /governance/export — the signed, hash-chained batch of every rule +/// firing this conductor has recorded. Auth, if any, is already enforced +/// by the router-level Basic Auth layer; this handler only has to decide +/// whether governance is configured at all. +async fn governance_export_handler(State(state): State) -> impl IntoResponse { let Some(gov) = &state.governance else { return ( StatusCode::NOT_IMPLEMENTED, @@ -53,18 +121,6 @@ async fn governance_export_handler( ) .into_response(); }; - if let Ok(required) = std::env::var("PCHI_GOVERNANCE_KEY") { - if !required.is_empty() { - let provided = headers.get("X-Governance-Key").and_then(|v| v.to_str().ok()); - if provided != Some(required.as_str()) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "governance_key_required", "hint": "set X-Governance-Key"})), - ) - .into_response(); - } - } - } (StatusCode::OK, Json(gov.export())).into_response() } @@ -162,3 +218,128 @@ pub async fn broadcast_state_update(_state: &WebServerState) { // and broadcast to all of them. For now, this is a placeholder. tracing::debug!("Broadcasting state update"); } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request as HttpRequest; + use tower::ServiceExt; + + fn test_state(dashboard_password: Option<&str>) -> WebServerState { + let dir = std::env::temp_dir().join(format!("pchi-websrv-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let gov = crate::governance::GovernanceLog::open( + dir.join("key.hex").to_str().unwrap(), + dir.join("log.jsonl").to_str().unwrap(), + ) + .unwrap(); + WebServerState { + scene_state: Arc::new(RwLock::new(SceneState::new())), + governance: Some(Arc::new(gov)), + dashboard_password: dashboard_password.map(str::to_string), + } + } + + fn basic_auth_header(user: &str, pass: &str) -> String { + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass)) + ) + } + + #[tokio::test] + async fn auth_disabled_by_default_allows_dashboard() { + let router = create_router(test_state(None)); + let res = router + .oneshot(HttpRequest::get("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + } + + #[tokio::test] + async fn auth_enabled_rejects_missing_credentials() { + let router = create_router(test_state(Some("secret"))); + let res = router + .oneshot(HttpRequest::get("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert!(res.headers().get(header::WWW_AUTHENTICATE).is_some()); + } + + #[tokio::test] + async fn auth_enabled_rejects_wrong_password() { + let router = create_router(test_state(Some("secret"))); + let res = router + .oneshot( + HttpRequest::get("/") + .header(header::AUTHORIZATION, basic_auth_header("operator", "nope")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn auth_enabled_rejects_wrong_username() { + let router = create_router(test_state(Some("secret"))); + let res = router + .oneshot( + HttpRequest::get("/") + .header(header::AUTHORIZATION, basic_auth_header("someone-else", "secret")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn auth_enabled_accepts_correct_credentials() { + let router = create_router(test_state(Some("secret"))); + let res = router + .oneshot( + HttpRequest::get("/") + .header(header::AUTHORIZATION, basic_auth_header("operator", "secret")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + } + + #[tokio::test] + async fn auth_enabled_gates_governance_export_too() { + let router = create_router(test_state(Some("secret"))); + let res = router + .oneshot(HttpRequest::get("/governance/export").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "the auth layer must cover every route, not just the dashboard page" + ); + } + + #[tokio::test] + async fn governance_export_works_once_authenticated() { + let router = create_router(test_state(Some("secret"))); + let res = router + .oneshot( + HttpRequest::get("/governance/export") + .header(header::AUTHORIZATION, basic_auth_header("operator", "secret")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + } +}