feat(host/mgmt): HTTPS + token auth by default (no loopback no-auth fallback)
android / android (push) Failing after 21s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1s
ci / rust (push) Failing after 2m27s
ci / web (push) Failing after 10s
ci / docs-site (push) Failing after 0s
ci / bench (push) Failing after 1s
deb / build-publish (push) Failing after 0s
decky / build-publish (push) Failing after 1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 0s
docker / deploy-docs (push) Has been skipped
flatpak / build-publish (push) Failing after 0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 0s
apple / swift (push) Successful in 53s

The mgmt API already always serves HTTPS (the host identity cert), but on a
loopback bind with no token it ran unauthenticated — any local process could
drive it. Make auth required ALWAYS:

- new mgmt_token::load_or_generate(): token precedence is --mgmt-token > env
  PUNKTFUNK_MGMT_TOKEN > persisted ~/.config/punktfunk/mgmt-token > freshly
  generated 32-byte hex, persisted 0600 in KEY=VALUE form (so the bundled web
  console can source it directly as a systemd EnvironmentFile — one source of
  truth). config_dir() made pub(crate).
- parse_serve() resolves the token via load_or_generate() when unset, so a bare
  `serve` Just Works with auth on and no operator step.
- mgmt::run() drops the loopback no-token exemption and requires a token;
  require_auth()'s unauthenticated fallback now returns 401. The paired-cert
  (mTLS) branch is unchanged — Apple client + library auth unaffected.
- web /api proxy: 503 (legible) instead of forwarding an empty bearer.
- tests: test_app/test_app_native default a token, send() auto-attaches the
  bearer; blank-token test asserts the new "no token" refusal. 80 pass.
- docs: mgmt module doc + host.env.example reflect always-on auth + auto-gen.

Compiles, clippy/fmt clean, openapi no drift. Part B (bundle the web console into
apt, auto-wired to this token) follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 08:42:28 +00:00
parent 104639bcc1
commit b2e5878711
6 changed files with 181 additions and 36 deletions
+1 -1
View File
@@ -202,7 +202,7 @@ pub fn serve(mgmt: crate::mgmt::Options, native: Option<crate::m3::NativeServe>)
} }
/// `~/.config/punktfunk`, created on demand — host identity + (later) pairing state live here. /// `~/.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") let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
+6 -4
View File
@@ -27,6 +27,7 @@ mod library;
mod m0; mod m0;
mod m3; mod m3;
mod mgmt; mod mgmt;
mod mgmt_token;
mod native_pairing; mod native_pairing;
mod pipeline; mod pipeline;
mod pwinit; mod pwinit;
@@ -293,11 +294,12 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option<m3::NativeServe
} }
i += 1; i += 1;
} }
// Flag wins over the environment so a unit file can set a default and a shell override it. // The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback). Resolve the token:
// the --mgmt-token flag (above) wins, else PUNKTFUNK_MGMT_TOKEN env, else the persisted
// ~/.config/punktfunk/mgmt-token, else a freshly generated + persisted one — so a bare `serve`
// Just Works with auth on, no operator step, and the bundled web console reads the same file.
if opts.token.is_none() { if opts.token.is_none() {
opts.token = std::env::var("PUNKTFUNK_MGMT_TOKEN") opts.token = Some(crate::mgmt_token::load_or_generate()?);
.ok()
.filter(|t| !t.is_empty());
} }
let native = native_port.map(|port| m3::NativeServe { let native = native_port.map(|port| m3::NativeServe {
port, port,
+58 -29
View File
@@ -9,16 +9,17 @@
//! and a copy is checked in at `docs/api/openapi.json` (a test fails if it drifts, like the //! and a copy is checked in at `docs/api/openapi.json` (a test fails if it drifts, like the
//! cbindgen header). //! cbindgen header).
//! //!
//! Security: binds loopback by default. A bearer token (`--mgmt-token` / `PUNKTFUNK_MGMT_TOKEN`) //! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires
//! is enforced on every `/api/v1` route except `/api/v1/health`, and is mandatory for //! auth on every `/api/v1` route except `/api/v1/health` — **always**, even on loopback. A paired
//! non-loopback binds. The OpenAPI document and docs UI are served unauthenticated (the //! native client authenticates by its mTLS cert; everyone else by a bearer token (`--mgmt-token` /
//! spec is public knowledge — it lives in this repo). //! `PUNKTFUNK_MGMT_TOKEN`, else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`). The
//! OpenAPI document and docs UI are served unauthenticated (the spec is public — it lives in this repo).
use crate::encode::Codec; use crate::encode::Codec;
use crate::gamestream::{ use crate::gamestream::{
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT, AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
}; };
use anyhow::{bail, Context, Result}; use anyhow::{Context, Result};
use axum::{ use axum::{
extract::{Path, Request, State}, extract::{Path, Request, State},
http::{header, StatusCode}, http::{header, StatusCode},
@@ -76,16 +77,13 @@ pub async fn run(
opts: Options, opts: Options,
native: Option<Arc<crate::native_pairing::NativePairing>>, native: Option<Arc<crate::native_pairing::NativePairing>>,
) -> Result<()> { ) -> Result<()> {
// A blank token is no token: it must neither satisfy the non-loopback guard below nor // The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve`
// become a credential an empty `Authorization: Bearer ` header would match. // guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated).
let token = opts.token.filter(|t| !t.trim().is_empty()); // A blank token is treated as none — fail loudly rather than ever serve unauthenticated.
if token.is_none() && !opts.bind.ip().is_loopback() { let token = opts
bail!( .token
"management API bind {} is not loopback — set --mgmt-token (or PUNKTFUNK_MGMT_TOKEN) \ .filter(|t| !t.trim().is_empty())
to expose it beyond this machine", .context("management API has no token — internal error: parse_serve must provide one")?;
opts.bind
);
}
// Serve over HTTPS with the host's persistent identity (the cert clients already pin) and // 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 // 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`. // 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")?; .context("management API TLS config")?;
tracing::info!( tracing::info!(
addr = %opts.bind, 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)" "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 serve_https(opts.bind, app, tls).await
} }
@@ -515,8 +513,8 @@ where
// Auth // Auth
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
/// Bearer-token gate on the `/api/v1` routes. No token configured ⇒ open (loopback-only, /// Auth gate on the `/api/v1` routes: a paired client cert (mTLS) or the bearer token — required
/// enforced in [`run`]); `/api/v1/health` stays open for monitoring probes either way. /// always (the host runs with a token by construction). `/api/v1/health` stays open for probes.
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response { async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
if req.uri().path() == "/api/v1/health" { if req.uri().path() == "/api/v1/health" {
return next.run(req).await; // liveness probe is always open return next.run(req).await; // liveness probe is always open
@@ -529,10 +527,10 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
return next.run(req).await; return next.run(req).await;
} }
} }
// Otherwise fall back to the bearer token (the web console / admin). No token configured ⇒ // Otherwise require the bearer token (the web console / admin). `run` always passes a token, so
// open, which `run` only permits on a loopback bind. // no-token means a misconfigured caller (e.g. a test constructing `app` directly) — deny.
let Some(expected) = st.token.as_deref() else { let Some(expected) = st.token.as_deref() else {
return next.run(req).await; return api_error(StatusCode::UNAUTHORIZED, "authentication required");
}; };
let presented = req let presented = req
.headers() .headers()
@@ -1278,18 +1276,48 @@ mod tests {
Arc::new(AppState::new(host, identity)) 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<AppState>, token: Option<&str>) -> Router { fn test_app(state: Arc<AppState>, 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( fn test_app_native(
state: Arc<AppState>, state: Arc<AppState>,
np: Arc<crate::native_pairing::NativePairing>, np: Arc<crate::native_pairing::NativePairing>,
) -> Router { ) -> 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<Body>) -> (StatusCode, serde_json::Value) { async fn send(
app: &Router,
mut req: axum::http::Request<Body>,
) -> (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 resp = app.clone().oneshot(req).await.expect("infallible");
let status = resp.status(); let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes(); let bytes = resp.into_body().collect().await.unwrap().to_bytes();
@@ -1497,15 +1525,16 @@ mod tests {
assert!(body["error"].is_string(), "media type: {body}"); 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] #[tokio::test]
async fn blank_token_rejected_for_public_bind() { async fn blank_token_rejected() {
let opts = Options { let opts = Options {
bind: "0.0.0.0:0".parse().unwrap(), bind: "127.0.0.1:0".parse().unwrap(),
token: Some(" ".into()), token: Some(" ".into()),
}; };
let err = run(test_state(), opts, None).await.unwrap_err(); 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] #[tokio::test]
+103
View File
@@ -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=<hex>` 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<String> {
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=<token>` line (the form we write, also valid as an EnvironmentFile).
fn parse_token(contents: &str) -> Option<String> {
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=<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);
}
}
+4
View File
@@ -49,3 +49,7 @@ PUNKTFUNK_ZEROCOPY=1
#PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent #PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent
#PUNKTFUNK_PERF=1 # per-stage timing logs #PUNKTFUNK_PERF=1 # per-stage timing logs
#RUST_LOG=info #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=
+9 -2
View File
@@ -3,17 +3,24 @@
// (the browser never sees it) and drop the browser's own cookies/auth from the upstream // (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 // 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. // 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' import { mgmtToken, mgmtUrl } from '../../util/auth'
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const { pathname, search } = getRequestURL(event) const { pathname, search } = getRequestURL(event)
const target = `${mgmtUrl()}${pathname}${search}` const target = `${mgmtUrl()}${pathname}${search}`
const token = mgmtToken() 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, { return proxyRequest(event, target, {
headers: { headers: {
// Overwrite, not append: the host-held token replaces anything the browser sent. // 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. // Don't forward the session cookie to the management API.
cookie: '', cookie: '',
}, },