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
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:
Generated
+3
@@ -2490,6 +2490,8 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"khronos-egl",
|
"khronos-egl",
|
||||||
"libc",
|
"libc",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -2508,6 +2510,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ x509-parser = "0.16"
|
|||||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
rustls-pemfile = "2"
|
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"
|
rusty_enet = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ mod pairing;
|
|||||||
mod rtsp;
|
mod rtsp;
|
||||||
mod serverinfo;
|
mod serverinfo;
|
||||||
mod stream;
|
mod stream;
|
||||||
mod tls;
|
pub(crate) mod tls;
|
||||||
mod video;
|
mod video;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ use std::sync::Arc;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct AcceptAnyClientCert {
|
struct AcceptAnyClientCert {
|
||||||
provider: Arc<CryptoProvider>,
|
provider: Arc<CryptoProvider>,
|
||||||
|
/// 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 {
|
impl ClientCertVerifier for AcceptAnyClientCert {
|
||||||
@@ -25,7 +28,7 @@ impl ClientCertVerifier for AcceptAnyClientCert {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn client_auth_mandatory(&self) -> bool {
|
fn client_auth_mandatory(&self) -> bool {
|
||||||
true
|
self.mandatory
|
||||||
}
|
}
|
||||||
|
|
||||||
fn root_hint_subjects(&self) -> &[DistinguishedName] {
|
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<Arc<ServerConfig>> {
|
pub fn server_config(cert_pem: &str, key_pem: &str) -> Result<Arc<ServerConfig>> {
|
||||||
|
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<Arc<ServerConfig>> {
|
||||||
|
build_server_config(cert_pem, key_pem, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_server_config(
|
||||||
|
cert_pem: &str,
|
||||||
|
key_pem: &str,
|
||||||
|
mandatory: bool,
|
||||||
|
) -> Result<Arc<ServerConfig>> {
|
||||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||||
let certs = rustls_pemfile::certs(&mut cert_pem.as_bytes())
|
let certs = rustls_pemfile::certs(&mut cert_pem.as_bytes())
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
@@ -88,6 +107,7 @@ pub fn server_config(cert_pem: &str, key_pem: &str) -> Result<Arc<ServerConfig>>
|
|||||||
|
|
||||||
let verifier = Arc::new(AcceptAnyClientCert {
|
let verifier = Arc::new(AcceptAnyClientCert {
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
|
mandatory,
|
||||||
});
|
});
|
||||||
let config = ServerConfig::builder_with_provider(provider)
|
let config = ServerConfig::builder_with_provider(provider)
|
||||||
.with_safe_default_protocol_versions()
|
.with_safe_default_protocol_versions()
|
||||||
|
|||||||
@@ -86,16 +86,83 @@ pub async fn run(
|
|||||||
opts.bind
|
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!(
|
tracing::info!(
|
||||||
addr = %opts.bind,
|
addr = %opts.bind,
|
||||||
auth = if token.is_some() { "bearer" } else { "none (loopback)" },
|
auth = if token.is_some() { "mTLS (paired cert) or bearer" } else { "mTLS (paired cert); bearer disabled (loopback)" },
|
||||||
"management API listening (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, token, opts.bind.port(), native);
|
||||||
axum_server::bind(opts.bind)
|
serve_https(opts.bind, app, tls).await
|
||||||
.serve(app.into_make_service())
|
}
|
||||||
|
|
||||||
|
/// 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
|
.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).
|
/// 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,
|
/// 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.
|
/// 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 {
|
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 {
|
let Some(expected) = st.token.as_deref() else {
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
};
|
};
|
||||||
if req.uri().path() == "/api/v1/health" {
|
|
||||||
return next.run(req).await;
|
|
||||||
}
|
|
||||||
let presented = req
|
let presented = req
|
||||||
.headers()
|
.headers()
|
||||||
.get(header::AUTHORIZATION)
|
.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 "));
|
.and_then(|v| v.strip_prefix("Bearer "));
|
||||||
match presented {
|
match presented {
|
||||||
Some(token) if token_eq(token, expected) => next.run(req).await,
|
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)",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user