From b4a85a86103a56760d2ff033f77260e97c4a2b47 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 14 Jun 2026 17:37:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(host/mgmt):=20mTLS=20auth=20=E2=80=94=20a?= =?UTF-8?q?=20paired=20client's=20cert=20authorizes=20the=20REST=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of moving the library off a manual mgmt token: the management API now serves over HTTPS with the host's persistent identity (the cert clients already pin) and OPTIONAL client-cert auth. A request is authorized if EITHER the peer presented a client certificate whose SHA-256 is in the punktfunk/1 paired store (the same trust the QUIC data plane uses — so a paired native client needs no token), OR it carries the bearer token (the web console / admin). `/health` stays open. axum-server can't surface the peer cert to a handler, so `serve_https` runs the rustls handshake itself (tokio-rustls), reads the verified peer certificate, and serves the axum Router over hyper with the fingerprint attached to each request; `require_auth` checks it against `NativePairing::is_paired`. The verifier reuses the GameStream AcceptAnyClientCert, parameterized to make client auth optional (a browser with no cert still completes the handshake and falls back to the token). Validated live: paired cert → 200, unpaired cert / no creds / bad token → 401, bearer → 200, /health open. (Note: the API is now HTTPS with a self-signed cert — a browser shows a one-time trust prompt; native clients pin by fingerprint.) Next: Apple client presents its identity over mTLS (drops the token field); embed the web console; enable HTTPS mgmt by default. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 3 + crates/punktfunk-host/Cargo.toml | 7 ++ crates/punktfunk-host/src/gamestream/mod.rs | 2 +- crates/punktfunk-host/src/gamestream/tls.rs | 24 ++++- crates/punktfunk-host/src/mgmt.rs | 98 +++++++++++++++++++-- 5 files changed, 122 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8e8d598..fa2de06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2490,6 +2490,8 @@ dependencies = [ "futures-util", "hex", "http-body-util", + "hyper", + "hyper-util", "khronos-egl", "libc", "mdns-sd", @@ -2508,6 +2510,7 @@ dependencies = [ "serde_json", "sha2", "tokio", + "tokio-rustls", "tower", "tracing", "tracing-subscriber", diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 4f82283..ac67f99 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -30,6 +30,13 @@ x509-parser = "0.16" axum-server = { version = "0.7", features = ["tls-rustls"] } rustls = "0.23" rustls-pemfile = "2" +# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a +# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the +# verified fingerprint injected as a request extension. Versions match the workspace lock. +tokio-rustls = "0.26" +hyper = { version = "1", features = ["server", "http1", "http2"] } +hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] } +tower = { version = "0.5", features = ["util"] } rusty_enet = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 3041116..be351c7 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -21,7 +21,7 @@ mod pairing; mod rtsp; mod serverinfo; mod stream; -mod tls; +pub(crate) mod tls; mod video; use anyhow::{Context, Result}; diff --git a/crates/punktfunk-host/src/gamestream/tls.rs b/crates/punktfunk-host/src/gamestream/tls.rs index 13d151d..c85637c 100644 --- a/crates/punktfunk-host/src/gamestream/tls.rs +++ b/crates/punktfunk-host/src/gamestream/tls.rs @@ -17,6 +17,9 @@ use std::sync::Arc; #[derive(Debug)] struct AcceptAnyClientCert { provider: Arc, + /// nvhttp/pairing REQUIRES the client cert (mandatory); the mgmt API REQUESTS it but lets a + /// certless peer (a browser, falling back to a bearer token) through (optional). + mandatory: bool, } impl ClientCertVerifier for AcceptAnyClientCert { @@ -25,7 +28,7 @@ impl ClientCertVerifier for AcceptAnyClientCert { } fn client_auth_mandatory(&self) -> bool { - true + self.mandatory } fn root_hint_subjects(&self) -> &[DistinguishedName] { @@ -76,8 +79,24 @@ impl ClientCertVerifier for AcceptAnyClientCert { } } -/// Build a mutual-TLS `ServerConfig` presenting the host cert/key. +/// Build a mutual-TLS `ServerConfig` presenting the host cert/key. nvhttp/pairing path: the +/// client cert is **mandatory**. pub fn server_config(cert_pem: &str, key_pem: &str) -> Result> { + build_server_config(cert_pem, key_pem, true) +} + +/// Like [`server_config`] but the client cert is **optional** — a certless peer (a browser using a +/// bearer token) still completes the handshake. Used by the management API's mTLS auth: a paired +/// client presents its cert (authorized by fingerprint), everyone else falls back to the token. +pub fn server_config_optional_client(cert_pem: &str, key_pem: &str) -> Result> { + build_server_config(cert_pem, key_pem, false) +} + +fn build_server_config( + cert_pem: &str, + key_pem: &str, + mandatory: bool, +) -> Result> { let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); let certs = rustls_pemfile::certs(&mut cert_pem.as_bytes()) .collect::, _>>() @@ -88,6 +107,7 @@ pub fn server_config(cert_pem: &str, key_pem: &str) -> Result> let verifier = Arc::new(AcceptAnyClientCert { provider: provider.clone(), + mandatory, }); let config = ServerConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 7303e30..29a8523 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -86,16 +86,83 @@ pub async fn run( opts.bind ); } + // 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`. + let identity = crate::gamestream::cert::ServerIdentity::load_or_create() + .context("load host identity for the management API TLS")?; + let tls = crate::gamestream::tls::server_config_optional_client( + &identity.cert_pem, + &identity.key_pem, + ) + .context("management API TLS config")?; tracing::info!( addr = %opts.bind, - auth = if token.is_some() { "bearer" } else { "none (loopback)" }, - "management API listening (docs at /api/docs, spec at /api/v1/openapi.json)" + auth = if token.is_some() { "mTLS (paired cert) or bearer" } else { "mTLS (paired cert); bearer disabled (loopback)" }, + "management API listening over HTTPS (docs at /api/docs, spec at /api/v1/openapi.json)" ); let app = app(state, token, opts.bind.port(), native); - axum_server::bind(opts.bind) - .serve(app.into_make_service()) + serve_https(opts.bind, app, tls).await +} + +/// SHA-256 of the peer's client certificate (hex), injected per-connection into each request's +/// extensions by [`serve_https`]; `None` when the peer presented no client cert. `require_auth` +/// authorizes a request whose fingerprint is in the paired store. +#[derive(Clone)] +struct PeerCertFingerprint(Option); + +/// HTTPS server for the mgmt API. axum-server can't surface the client cert to a handler, so this +/// runs the rustls handshake itself (via tokio-rustls), reads the verified peer certificate, and +/// serves the axum `Router` over hyper with the peer's fingerprint attached to every request. +async fn serve_https(bind: SocketAddr, app: Router, tls: Arc) -> Result<()> { + use tower::ServiceExt; + let acceptor = tokio_rustls::TlsAcceptor::from(tls); + let listener = tokio::net::TcpListener::bind(bind) .await - .context("management API server") + .with_context(|| format!("bind management API {bind}"))?; + loop { + let (tcp, _peer) = match listener.accept().await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "management API accept failed"); + continue; + } + }; + let acceptor = acceptor.clone(); + let app = app.clone(); + tokio::spawn(async move { + let tls_stream = match acceptor.accept(tcp).await { + Ok(s) => s, + // A failed handshake is routine (port scan, a browser bailing on the self-signed + // cert, a client cert we'd still accept but the peer hung up) — not fatal. + Err(_) => return, + }; + // The verified peer cert (the verifier accepts any well-formed one; we authorize by + // fingerprint in the auth layer) → its SHA-256, matched against the paired store. + let fp = tls_stream + .get_ref() + .1 + .peer_certificates() + .and_then(|c| c.first()) + .map(|c| hex::encode(punktfunk_core::quic::endpoint::cert_fingerprint(c.as_ref()))); + let peer = PeerCertFingerprint(fp); + let svc = + hyper::service::service_fn(move |req: hyper::Request| { + let app = app.clone(); + let peer = peer.clone(); + async move { + let mut req = req.map(axum::body::Body::new); + req.extensions_mut().insert(peer); + app.oneshot(req).await // Router error is Infallible + } + }); + let io = hyper_util::rt::TokioIo::new(tls_stream); + let _ = + hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()) + .serve_connection_with_upgrades(io, svc) + .await; + }); + } } /// Compose the full management router (also used directly by the handler tests). @@ -451,12 +518,22 @@ where /// 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. 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 + } + // A paired native client authenticates by its mTLS certificate — the same identity + trust the + // QUIC data plane uses — so it never needs a bearer token. The fingerprint is attached by + // `serve_https` from the verified peer cert; we authorize it iff it's in the paired store. + if let Some(PeerCertFingerprint(Some(fp))) = req.extensions().get::() { + if st.native.as_ref().is_some_and(|n| n.is_paired(fp)) { + 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. let Some(expected) = st.token.as_deref() else { return next.run(req).await; }; - if req.uri().path() == "/api/v1/health" { - return next.run(req).await; - } let presented = req .headers() .get(header::AUTHORIZATION) @@ -464,7 +541,10 @@ async fn require_auth(State(st): State>, req: Request, next: Next .and_then(|v| v.strip_prefix("Bearer ")); match presented { Some(token) if token_eq(token, expected) => next.run(req).await, - _ => api_error(StatusCode::UNAUTHORIZED, "missing or invalid bearer token"), + _ => api_error( + StatusCode::UNAUTHORIZED, + "missing or invalid credentials (a paired client cert, or a bearer token)", + ), } }