feat(host/mgmt): mTLS auth — a paired client's cert authorizes the REST API
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 30s
apple / swift (push) Successful in 1m15s
ci / bench (push) Successful in 1m35s
deb / build-publish (push) Successful in 4m31s
ci / rust (push) Successful in 7m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m30s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m37s
docker / deploy-docs (push) Successful in 19s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 17:37:25 +00:00
parent 8c2e245c8b
commit b4a85a8610
5 changed files with 122 additions and 12 deletions
+89 -9
View File
@@ -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<String>);
/// 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<rustls::ServerConfig>) -> 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<hyper::body::Incoming>| {
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<Arc<MgmtState>>, 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::<PeerCertFingerprint>() {
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<Arc<MgmtState>>, 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)",
),
}
}