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)", + ), } }