17a262aace
android / android (push) Has been cancelled
audit / bun-audit (push) Successful in 14s
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
deb / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
decky / build-publish (push) Successful in 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
The Windows host clippy CI broke building aws-lc-sys 0.41.0 (a heavy C dep that
cl.exe fails to compile on the runner). The host was the ONLY thing pulling it:
its rcgen used features=["aws_lc_rs"], and bare `rustls`/`tokio-rustls` take
the default (aws-lc-rs) backend, and gamestream/{mod,tls}.rs installed the
aws_lc_rs crypto provider explicitly — a pre-existing deviation from the
project's documented ring-only design (punktfunk-core + the client are ring).
Align the host to ring: rcgen/rustls/tokio-rustls pinned to the `ring` feature
(keeping tls12 for Moonlight/GameStream TLS-1.2 clients) and the two GameStream
provider installs switched to `rustls::crypto::ring::default_provider()`. Both
providers offer the same standard TLS 1.2/1.3 suites, so it's transparent to the
TLS paths; aws-lc-sys/aws-lc-rs leave the tree entirely (`cargo tree -i
aws-lc-sys` => no match). Not caused by the preceding log fix (that touched no
crypto deps / the lock).
Validated: cargo tree (aws-lc-sys gone, windows target, amf-qsv,qsv features) +
.21 host clippy -D warnings (--features nvenc) clean. Owed: GameStream on-glass
re-check with a real Moonlight client (low risk — same TLS suites, client is
already ring).
203 lines
8.4 KiB
Rust
203 lines
8.4 KiB
Rust
//! TLS for the HTTPS nvhttp port (47984) and the management API. Moonlight does **mutual TLS** —
|
|
//! it presents its client cert and expects the server to request one — so a plain server-auth
|
|
//! config makes the post-pairing `pairchallenge` fail. This config requests the client cert and
|
|
//! verifies the client owns its key, but accepts any well-formed cert at the *handshake* (the
|
|
//! pairing ceremony is the real proof of identity). Authorization against the paired allow-list is
|
|
//! then enforced per-request: [`serve_https`] reads the verified peer cert and attaches its
|
|
//! fingerprint ([`PeerCertFingerprint`]) to each request, and the nvhttp/mgmt handlers reject
|
|
//! callers whose fingerprint is not pinned (mirroring Apollo's post-handshake `get_verified_cert`).
|
|
|
|
use anyhow::{Context, Result};
|
|
use axum::Router;
|
|
use rustls::client::danger::HandshakeSignatureValid;
|
|
use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider};
|
|
use rustls::pki_types::pem::PemObject;
|
|
use rustls::pki_types::{CertificateDer, PrivateKeyDer, UnixTime};
|
|
use rustls::server::danger::{ClientCertVerified, ClientCertVerifier};
|
|
use rustls::{DigitallySignedStruct, DistinguishedName, ServerConfig, SignatureScheme};
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
|
|
/// 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 (plain HTTP, or a
|
|
/// browser falling back to a bearer token). Handlers authorize a request whose fingerprint is in
|
|
/// the paired store.
|
|
#[derive(Clone)]
|
|
pub(crate) struct PeerCertFingerprint(pub Option<String>);
|
|
|
|
/// The TCP source address of an HTTPS request, injected per-connection by [`serve_https`]. Used by
|
|
/// `/launch` to record which paired client owns the session so the unauthenticated RTSP/UDP media
|
|
/// plane can bind to that peer's IP (security-review 2026-06-28 #4).
|
|
#[derive(Clone, Copy)]
|
|
pub(crate) struct PeerAddr(pub SocketAddr);
|
|
|
|
/// HTTPS server that surfaces the verified client cert to handlers. `axum_server` can't expose the
|
|
/// peer cert, so this runs the rustls handshake itself (tokio-rustls), reads the peer certificate,
|
|
/// and serves the axum `Router` over hyper with the peer's fingerprint attached to every request as
|
|
/// a [`PeerCertFingerprint`] extension. Shared by the nvhttp HTTPS listener and the management API.
|
|
pub(crate) async fn serve_https(
|
|
bind: SocketAddr,
|
|
app: Router,
|
|
tls: Arc<ServerConfig>,
|
|
) -> Result<()> {
|
|
use tower::ServiceExt;
|
|
let acceptor = tokio_rustls::TlsAcceptor::from(tls);
|
|
let listener = tokio::net::TcpListener::bind(bind)
|
|
.await
|
|
.with_context(|| format!("bind HTTPS {bind}"))?;
|
|
loop {
|
|
let (tcp, peer) = match listener.accept().await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
// A persistent accept() error (fd exhaustion / EMFILE) would otherwise hot-spin
|
|
// this loop and storm the log; back off so a stuck accept can't burn a core.
|
|
tracing::warn!(error = %e, "HTTPS accept failed — backing off 100ms");
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
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 peer that hung up) — not fatal.
|
|
Err(_) => return,
|
|
};
|
|
// The verified peer cert (the verifier accepts any well-formed one; handlers authorize
|
|
// by fingerprint) → 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 fp = PeerCertFingerprint(fp);
|
|
let addr = PeerAddr(peer);
|
|
let svc =
|
|
hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
|
|
let app = app.clone();
|
|
let fp = fp.clone();
|
|
async move {
|
|
let mut req = req.map(axum::body::Body::new);
|
|
req.extensions_mut().insert(fp);
|
|
req.extensions_mut().insert(addr);
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Requests + signature-checks the client cert but accepts any (the pairing handshake is
|
|
/// the real proof). Pinning to the paired set is a hardening follow-up.
|
|
#[derive(Debug)]
|
|
struct AcceptAnyClientCert {
|
|
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 {
|
|
fn offer_client_auth(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn client_auth_mandatory(&self) -> bool {
|
|
self.mandatory
|
|
}
|
|
|
|
fn root_hint_subjects(&self) -> &[DistinguishedName] {
|
|
&[]
|
|
}
|
|
|
|
fn verify_client_cert(
|
|
&self,
|
|
_end_entity: &CertificateDer,
|
|
_intermediates: &[CertificateDer],
|
|
_now: UnixTime,
|
|
) -> Result<ClientCertVerified, rustls::Error> {
|
|
Ok(ClientCertVerified::assertion())
|
|
}
|
|
|
|
fn verify_tls12_signature(
|
|
&self,
|
|
message: &[u8],
|
|
cert: &CertificateDer,
|
|
dss: &DigitallySignedStruct,
|
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
|
verify_tls12_signature(
|
|
message,
|
|
cert,
|
|
dss,
|
|
&self.provider.signature_verification_algorithms,
|
|
)
|
|
}
|
|
|
|
fn verify_tls13_signature(
|
|
&self,
|
|
message: &[u8],
|
|
cert: &CertificateDer,
|
|
dss: &DigitallySignedStruct,
|
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
|
verify_tls13_signature(
|
|
message,
|
|
cert,
|
|
dss,
|
|
&self.provider.signature_verification_algorithms,
|
|
)
|
|
}
|
|
|
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
|
self.provider
|
|
.signature_verification_algorithms
|
|
.supported_schemes()
|
|
}
|
|
}
|
|
|
|
/// 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>> {
|
|
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::ring::default_provider());
|
|
// PEM parsing via rustls-pki-types (the same `PemObject` path punktfunk-core/quic.rs uses),
|
|
// so we don't pull the unmaintained `rustls-pemfile`.
|
|
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
|
|
.collect::<std::result::Result<Vec<_>, _>>()
|
|
.context("parse host cert PEM")?;
|
|
let key = PrivateKeyDer::from_pem_slice(key_pem.as_bytes()).context("parse host key PEM")?;
|
|
|
|
let verifier = Arc::new(AcceptAnyClientCert {
|
|
provider: provider.clone(),
|
|
mandatory,
|
|
});
|
|
let config = ServerConfig::builder_with_provider(provider)
|
|
.with_safe_default_protocol_versions()
|
|
.context("rustls protocol versions")?
|
|
.with_client_cert_verifier(verifier)
|
|
.with_single_cert(certs, key)
|
|
.context("rustls server cert")?;
|
|
Ok(Arc::new(config))
|
|
}
|