diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index ea4023a..12ee98d 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -202,7 +202,7 @@ pub fn serve(mgmt: crate::mgmt::Options, native: Option) } /// `~/.config/punktfunk`, created on demand — host identity + (later) pairing state live here. -fn config_dir() -> PathBuf { +pub(crate) fn config_dir() -> PathBuf { let base = std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index fd742c1..19ec8ec 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -27,6 +27,7 @@ mod library; mod m0; mod m3; mod mgmt; +mod mgmt_token; mod native_pairing; mod pipeline; mod pwinit; @@ -293,11 +294,12 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option>, ) -> Result<()> { - // A blank token is no token: it must neither satisfy the non-loopback guard below nor - // become a credential an empty `Authorization: Bearer ` header would match. - let token = opts.token.filter(|t| !t.trim().is_empty()); - if token.is_none() && !opts.bind.ip().is_loopback() { - bail!( - "management API bind {} is not loopback — set --mgmt-token (or PUNKTFUNK_MGMT_TOKEN) \ - to expose it beyond this machine", - opts.bind - ); - } + // The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve` + // guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated). + // A blank token is treated as none — fail loudly rather than ever serve unauthenticated. + let token = opts + .token + .filter(|t| !t.trim().is_empty()) + .context("management API has no token — internal error: parse_serve must provide one")?; // Serve over HTTPS with the host's persistent identity (the cert clients already pin) and // OPTIONAL client-cert auth: a paired native client presents its cert (authorized by // fingerprint, no token), a browser presents none and uses the bearer token. See `require_auth`. @@ -98,10 +96,10 @@ pub async fn run( .context("management API TLS config")?; tracing::info!( addr = %opts.bind, - auth = if token.is_some() { "mTLS (paired cert) or bearer" } else { "mTLS (paired cert); bearer disabled (loopback)" }, + auth = "mTLS (paired cert) or bearer (required)", "management API listening over HTTPS (docs at /api/docs, spec at /api/v1/openapi.json)" ); - let app = app(state, token, opts.bind.port(), native); + let app = app(state, Some(token), opts.bind.port(), native); serve_https(opts.bind, app, tls).await } @@ -515,8 +513,8 @@ where // Auth // --------------------------------------------------------------------------------------- -/// Bearer-token gate on the `/api/v1` routes. No token configured ⇒ open (loopback-only, -/// enforced in [`run`]); `/api/v1/health` stays open for monitoring probes either way. +/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS) or the bearer token — required +/// always (the host runs with a token by construction). `/api/v1/health` stays open for probes. async fn require_auth(State(st): State>, req: Request, next: Next) -> Response { if req.uri().path() == "/api/v1/health" { return next.run(req).await; // liveness probe is always open @@ -529,10 +527,10 @@ async fn require_auth(State(st): State>, req: Request, next: Next return next.run(req).await; } } - // Otherwise fall back to the bearer token (the web console / admin). No token configured ⇒ - // open, which `run` only permits on a loopback bind. + // Otherwise require the bearer token (the web console / admin). `run` always passes a token, so + // no-token means a misconfigured caller (e.g. a test constructing `app` directly) — deny. let Some(expected) = st.token.as_deref() else { - return next.run(req).await; + return api_error(StatusCode::UNAUTHORIZED, "authentication required"); }; let presented = req .headers() @@ -1278,18 +1276,48 @@ mod tests { Arc::new(AppState::new(host, identity)) } + // The mgmt API now always requires auth, so the router always has a token. A test that passes + // `None` gets the default "test-secret" (and `send` auto-attaches the matching bearer); a test + // that passes an explicit token exercises a mismatch (e.g. `bearer_token_is_enforced`). fn test_app(state: Arc, token: Option<&str>) -> Router { - app(state, token.map(String::from), DEFAULT_PORT, None) + app( + state, + Some(token.unwrap_or("test-secret").to_string()), + DEFAULT_PORT, + None, + ) } fn test_app_native( state: Arc, np: Arc, ) -> Router { - app(state, None, DEFAULT_PORT, Some(np)) + // Auth required always; the paired-cert tests inject a fingerprint (cert branch wins), the + // rest authenticate via the `send`-attached default bearer. + app( + state, + Some("test-secret".to_string()), + DEFAULT_PORT, + Some(np), + ) } - async fn send(app: &Router, req: axum::http::Request) -> (StatusCode, serde_json::Value) { + async fn send( + app: &Router, + mut req: axum::http::Request, + ) -> (StatusCode, serde_json::Value) { + // Auto-attach the default bearer unless the test set its own Authorization (e.g. the + // mismatch cases in `bearer_token_is_enforced`). Open routes ignore it; authed routes + // accept it against the `test-secret` default token. + if !req + .headers() + .contains_key(axum::http::header::AUTHORIZATION) + { + req.headers_mut().insert( + axum::http::header::AUTHORIZATION, + axum::http::HeaderValue::from_static("Bearer test-secret"), + ); + } let resp = app.clone().oneshot(req).await.expect("infallible"); let status = resp.status(); let bytes = resp.into_body().collect().await.unwrap().to_bytes(); @@ -1497,15 +1525,16 @@ mod tests { assert!(body["error"].is_string(), "media type: {body}"); } - /// A blank token must not satisfy the "non-loopback requires a token" guard. + /// A blank token is treated as no token: the mgmt API requires auth always (even on loopback), + /// so `run` refuses to start unauthenticated rather than serve open. #[tokio::test] - async fn blank_token_rejected_for_public_bind() { + async fn blank_token_rejected() { let opts = Options { - bind: "0.0.0.0:0".parse().unwrap(), + bind: "127.0.0.1:0".parse().unwrap(), token: Some(" ".into()), }; let err = run(test_state(), opts, None).await.unwrap_err(); - assert!(err.to_string().contains("not loopback"), "{err}"); + assert!(err.to_string().contains("no token"), "{err}"); } #[tokio::test] diff --git a/crates/punktfunk-host/src/mgmt_token.rs b/crates/punktfunk-host/src/mgmt_token.rs new file mode 100644 index 0000000..5a4dd2a --- /dev/null +++ b/crates/punktfunk-host/src/mgmt_token.rs @@ -0,0 +1,103 @@ +//! Management-API bearer token resolution. +//! +//! The mgmt API always serves HTTPS (the host's identity cert) and now always requires auth — even +//! on a loopback bind. This module guarantees a token always exists: an explicit +//! `PUNKTFUNK_MGMT_TOKEN` env wins (operator override, not persisted); otherwise the persisted +//! `~/.config/punktfunk/mgmt-token` is used; otherwise a fresh 32-byte hex token is generated and +//! persisted. The file is written in `PUNKTFUNK_MGMT_TOKEN=` form (0600) so the bundled web +//! console can source it directly as a systemd `EnvironmentFile` — a single source of truth shared +//! between the host and the console with no copying. + +use anyhow::{Context, Result}; +use rand::RngCore; +use std::fs; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::Path; + +const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN"; +const FILE: &str = "mgmt-token"; + +/// Resolve the mgmt token (env > persisted file > generate+persist). Hex (not base64) so the +/// persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`. +pub fn load_or_generate() -> Result { + if let Ok(v) = std::env::var(ENV_VAR) { + let v = v.trim(); + if !v.is_empty() { + return Ok(v.to_string()); + } + } + let path = crate::gamestream::config_dir().join(FILE); + if let Ok(contents) = fs::read_to_string(&path) { + if let Some(tok) = parse_token(&contents) { + return Ok(tok); + } + } + let mut buf = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut buf); + let token = hex::encode(buf); + let dir = crate::gamestream::config_dir(); + fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + write_token(&path, &token)?; + tracing::info!(path = %path.display(), "generated and persisted management API token (0600)"); + Ok(token) +} + +/// Parse the token from the persisted file: accept either a bare token line or a +/// `PUNKTFUNK_MGMT_TOKEN=` line (the form we write, also valid as an EnvironmentFile). +fn parse_token(contents: &str) -> Option { + let line = contents.lines().find(|l| !l.trim().is_empty())?.trim(); + let tok = line + .strip_prefix("PUNKTFUNK_MGMT_TOKEN=") + .unwrap_or(line) + .trim(); + (!tok.is_empty()).then(|| tok.to_string()) +} + +/// Write `PUNKTFUNK_MGMT_TOKEN=` to `path`, mode 0600 (never briefly world-readable). +fn write_token(path: &Path, token: &str) -> Result<()> { + let mut opts = fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + opts.mode(0o600); + let mut f = opts + .open(path) + .with_context(|| format!("write {}", path.display()))?; + writeln!(f, "PUNKTFUNK_MGMT_TOKEN={token}")?; + #[cfg(unix)] + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_bare_and_keyvalue_forms() { + assert_eq!(parse_token("abc123\n").as_deref(), Some("abc123")); + assert_eq!( + parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n").as_deref(), + Some("deadbeef") + ); + assert_eq!(parse_token("\n \n"), None); + assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n"), None); + } + + #[test] + fn generated_token_round_trips_through_the_file() { + let dir = std::env::temp_dir().join(format!("pf-mgmt-token-test-{}", std::process::id())); + let _ = fs::create_dir_all(&dir); + let path = dir.join(FILE); + write_token(&path, "cafef00d").unwrap(); + let read = fs::read_to_string(&path).unwrap(); + assert_eq!(parse_token(&read).as_deref(), Some("cafef00d")); + #[cfg(unix)] + { + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/scripts/host.env.example b/scripts/host.env.example index 74ecea5..5e527d9 100644 --- a/scripts/host.env.example +++ b/scripts/host.env.example @@ -49,3 +49,7 @@ PUNKTFUNK_ZEROCOPY=1 #PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent #PUNKTFUNK_PERF=1 # per-stage timing logs #RUST_LOG=info +# Management API bearer token. The mgmt API is HTTPS + token-authenticated ALWAYS (even on +# loopback); if unset it is auto-generated + persisted to ~/.config/punktfunk/mgmt-token (which the +# bundled web console sources). Set here only to pin a specific token. +#PUNKTFUNK_MGMT_TOKEN= diff --git a/web/server/routes/api/[...].ts b/web/server/routes/api/[...].ts index 95e6544..22d66bd 100644 --- a/web/server/routes/api/[...].ts +++ b/web/server/routes/api/[...].ts @@ -3,17 +3,24 @@ // (the browser never sees it) and drop the browser's own cookies/auth from the upstream // request, then proxy. The management API itself binds loopback only — this proxy is the // ONLY path to it from the LAN, and it's authenticated. -import { defineEventHandler, getRequestURL, proxyRequest } from 'h3' +import { defineEventHandler, getRequestURL, proxyRequest, setResponseStatus } from 'h3' import { mgmtToken, mgmtUrl } from '../../util/auth' export default defineEventHandler((event) => { const { pathname, search } = getRequestURL(event) const target = `${mgmtUrl()}${pathname}${search}` const token = mgmtToken() + // The mgmt API now requires a token always. Without one configured, forwarding an empty bearer + // would just bounce as 401 — fail fast and legibly instead (the packaged service sources the + // host's ~/.config/punktfunk/mgmt-token, so this only fires on a misconfigured/early-start deploy). + if (!token) { + setResponseStatus(event, 503) + return { error: 'management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)' } + } return proxyRequest(event, target, { headers: { // Overwrite, not append: the host-held token replaces anything the browser sent. - authorization: token ? `Bearer ${token}` : '', + authorization: `Bearer ${token}`, // Don't forward the session cookie to the management API. cookie: '', },