Files
punktfunk/crates/punktfunk-host/src/gamestream/nvhttp.rs
T
enricobuehler 12c7ec9e57
apple / swift (push) Successful in 1m6s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 4m52s
apple / screenshots (push) Successful in 5m20s
windows-host / package (push) Successful in 6m30s
ci / bench (push) Successful in 4m42s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
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 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m34s
docker / deploy-docs (push) Successful in 18s
feat(gamestream): advertise HDR + surface the game library (with covers) to Moonlight
Bring the GameStream/Moonlight plane up to the native plane's capability parity.

HDR (Windows only):
- New host_hdr_capable() gate (Windows + PUNKTFUNK_10BIT, matching the native
  policy). serverinfo layers SCM_HEVC_MAIN10 onto the probed/static codec mask, so
  Moonlight finally offers its HDR toggle (live: mask 0x10101 -> 0x10301).
- Parse the client's dynamicRangeMode into StreamConfig.hdr and pass it through to
  OutputFormat::resolve, so a client HDR request proactively enables advanced color
  on the per-session virtual display (PQ flows even from an SDR desktop). The
  encoder bit depth now derives from the captured frame format (gs_bit_depth) rather
  than a hard-coded 8 that mislabeled the already-Main10 HDR stream.

Game library in /applist:
- The catalog now layers library::all_games() (Steam/Epic/GOG/Xbox/custom) on top of
  Desktop/apps.json, each with a STABLE GameStream id (FNV-1a, dedup-probed) and the
  store-qualified library id. Launch routes through the existing security-reviewed
  launch_title/launch_command via library::launch_gamestream_library — a client can
  only pick an existing title, never inject a command.
- /appasset cover proxy: Moonlight fetches per-app covers from the host, so resolve
  appid -> library cover URL and proxy the bytes (portrait -> header -> hero -> logo;
  data: + bounded http(s) fetch), on a blocking thread. IsHdrSupported reflects the
  host HDR capability.

4:4:4 stays off on GameStream by design: stock Moonlight is 4:2:0 and the Windows
IDD-push capturer can't deliver full chroma yet (capturer_supports_444() == false);
the gate is documented so it lights up once IDD-push full-chroma capture lands.

Validated live (Moonlight -> Windows NVENC host): HDR advertised, the Epic library
shows with covers, launch works. clippy clean; apps/serverinfo/library unit tests
cover the HDR mask, stable-id, dedup, and data-URL paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:07:07 +02:00

353 lines
14 KiB
Rust

//! The nvhttp servers: plain HTTP on 47989 and mutual-TLS on 47984. Serves `/serverinfo`,
//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`. Over HTTPS the client is
//! mutual-TLS-authenticated, so `/serverinfo` reports `PairStatus=1` there.
//!
//! The pairing PIN is delivered out-of-band ONLY through the bearer-authenticated management
//! API (`POST /api/v1/pair/pin`): the operator reads the PIN off the Moonlight client and
//! types it into the host console. There is deliberately NO unauthenticated nvhttp PIN
//! endpoint — one would let a network client submit its own displayed PIN and drive the whole
//! ceremony to a pinned cert with no operator consent (security-review 2026-06-28 #1).
use super::tls::{PeerAddr, PeerCertFingerprint};
use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_PORT};
use anyhow::{anyhow, Context, Result};
use axum::{
extract::{Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::get,
Extension, Router,
};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
/// Which listener a request arrived on — HTTPS means a mutual-TLS-authenticated client.
#[derive(Clone, Copy)]
struct Https(bool);
pub async fn run(state: Arc<AppState>) -> Result<()> {
// Mutual-TLS: request + verify the client cert (Moonlight presents one for the
// post-pairing pairchallenge + all post-pair endpoints).
let tls = super::tls::server_config(&state.identity.cert_pem, &state.identity.key_pem)?;
let http_addr = SocketAddr::from(([0, 0, 0, 0], HTTP_PORT));
let https_addr = SocketAddr::from(([0, 0, 0, 0], HTTPS_PORT));
tracing::info!(%http_addr, %https_addr, "nvhttp listening (serverinfo + pair + launch)");
let http = axum_server::bind(http_addr).serve(router(state.clone(), false).into_make_service());
// HTTPS runs the handshake itself (super::tls::serve_https) so handlers see the verified peer
// cert as a PeerCertFingerprint extension; the post-pair endpoints gate on the paired allow-list.
tokio::try_join!(
async { http.await.context("nvhttp HTTP server") },
super::tls::serve_https(https_addr, router(state, true), tls),
)?;
Ok(())
}
/// True iff the request arrived over HTTPS with a client cert whose SHA-256 fingerprint is pinned
/// in the paired allow-list. Plain-HTTP requests carry no client cert and are never paired. This is
/// the post-handshake authorization check (Apollo's `get_verified_cert`) gating the launch surface.
fn peer_is_paired(peer: &Option<Extension<PeerCertFingerprint>>, st: &AppState) -> bool {
let Some(Extension(PeerCertFingerprint(Some(fp)))) = peer else {
return false;
};
st.paired
.lock()
.unwrap()
.iter()
.any(|der| hex::encode(punktfunk_core::quic::endpoint::cert_fingerprint(der)) == *fp)
}
fn router(state: Arc<AppState>, https: bool) -> Router {
Router::new()
.route("/serverinfo", get(h_serverinfo))
.route("/pair", get(h_pair))
.route("/applist", get(h_applist))
.route("/appasset", get(h_appasset))
.route("/launch", get(h_launch))
.route("/resume", get(h_resume))
.route("/cancel", get(h_cancel))
.layer(Extension(Https(https)))
.with_state(state)
}
fn xml(body: String) -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/xml")], body)
}
async fn h_serverinfo(
State(st): State<Arc<AppState>>,
Extension(Https(https)): Extension<Https>,
peer: Option<Extension<PeerCertFingerprint>>,
) -> impl IntoResponse {
// PairStatus=1 only when the HTTPS peer presented a *pinned* client cert; an unpaired client
// (or plain HTTP) sees 0 and is steered into the pairing flow.
let paired = https && peer_is_paired(&peer, &st);
xml(serverinfo::serverinfo_xml(&st.host, https, paired))
}
async fn h_applist(
State(st): State<Arc<AppState>>,
peer: Option<Extension<PeerCertFingerprint>>,
) -> impl IntoResponse {
if !peer_is_paired(&peer, &st) {
tracing::warn!("applist rejected — client is not paired");
return xml(error_xml());
}
xml(super::apps::applist_xml())
}
/// Box-art cover proxy (`/appasset?appid=N&AssetType=2&AssetIdx=0`). Moonlight fetches per-app covers
/// from the HOST, so we resolve the appid to its library title and proxy the cover image bytes (Steam/
/// Epic CDN, etc.). 404 for Desktop / apps.json entries (no art) or any fetch failure — Moonlight then
/// shows its title-only placeholder. Paired clients only (same gate as `/applist`). The resolve+fetch is
/// blocking (disk + network), so it runs on a blocking thread off the async runtime.
async fn h_appasset(
State(st): State<Arc<AppState>>,
peer: Option<Extension<PeerCertFingerprint>>,
Query(q): Query<HashMap<String, String>>,
) -> Response {
if !peer_is_paired(&peer, &st) {
tracing::warn!("appasset rejected — client is not paired");
return StatusCode::FORBIDDEN.into_response();
}
let Some(appid) = q.get("appid").and_then(|s| s.parse::<u32>().ok()) else {
return StatusCode::BAD_REQUEST.into_response();
};
match tokio::task::spawn_blocking(move || super::apps::appasset_bytes(appid)).await {
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
}
}
async fn h_launch(
State(st): State<Arc<AppState>>,
peer: Option<Extension<PeerCertFingerprint>>,
addr: Option<Extension<PeerAddr>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
if !peer_is_paired(&peer, &st) {
tracing::warn!("launch rejected — client is not paired");
return xml(error_xml());
}
match launch(&st, &q) {
Ok(mut session) => {
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP.
session.peer_ip = addr.map(|Extension(PeerAddr(a))| a.ip());
*st.launch.lock().unwrap() = Some(session);
tracing::info!(
w = session.width,
h = session.height,
fps = session.fps,
rikeyid = session.rikeyid,
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip
);
xml(session_url_xml(&st, "gamesession"))
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "launch failed");
xml(error_xml())
}
}
}
async fn h_resume(
State(st): State<Arc<AppState>>,
peer: Option<Extension<PeerCertFingerprint>>,
) -> impl IntoResponse {
if !peer_is_paired(&peer, &st) {
tracing::warn!("resume rejected — client is not paired");
return xml(error_xml());
}
if st.launch.lock().unwrap().is_some() {
xml(session_url_xml(&st, "resume"))
} else {
xml(error_xml())
}
}
async fn h_cancel(
State(st): State<Arc<AppState>>,
peer: Option<Extension<PeerCertFingerprint>>,
) -> impl IntoResponse {
if !peer_is_paired(&peer, &st) {
tracing::warn!("cancel rejected — client is not paired");
return xml(error_xml());
}
*st.launch.lock().unwrap() = None;
// Quit semantics: stop the running media threads (they observe these flags) so the session
// actually ends — the virtual output/gamescope teardown follows via the capturer's RAII.
st.streaming
.store(false, std::sync::atomic::Ordering::SeqCst);
st.audio_streaming
.store(false, std::sync::atomic::Ordering::SeqCst);
tracing::info!("cancel — launch session cleared, streams stopping");
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><cancel>1</cancel></root>\n".to_string())
}
/// Parse the `/launch` query (rikey/rikeyid/mode) into a [`LaunchSession`].
fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession> {
let rikey = q.get("rikey").ok_or_else(|| anyhow!("missing rikey"))?;
let key_bytes = hex::decode(rikey).context("rikey hex")?;
if key_bytes.len() < 16 {
return Err(anyhow!("rikey too short"));
}
let mut gcm_key = [0u8; 16];
gcm_key.copy_from_slice(&key_bytes[..16]);
// rikeyid is a signed 32-bit int (negative values wrap to a big-endian u32 IV later).
let rikeyid: i32 = q.get("rikeyid").and_then(|s| s.parse().ok()).unwrap_or(0);
let (width, height, fps) = q
.get("mode")
.and_then(|m| parse_mode(m))
.unwrap_or((1920, 1080, 60));
let appid = q.get("appid").and_then(|s| s.parse().ok()).unwrap_or(1);
Ok(LaunchSession {
gcm_key,
rikeyid,
width,
height,
fps,
appid,
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
})
}
/// `"1920x1080x60"` → `(1920, 1080, 60)`.
fn parse_mode(mode: &str) -> Option<(u32, u32, u32)> {
let mut it = mode.split('x');
let w = it.next()?.parse().ok()?;
let h = it.next()?.parse().ok()?;
let fps = it.next()?.parse().ok()?;
Some((w, h, fps))
}
fn session_url_xml(st: &AppState, tag: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
st.host.local_ip
)
}
async fn h_pair(
State(st): State<Arc<AppState>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let uniqueid = q.get("uniqueid").cloned().unwrap_or_default();
let phrase = q.get("phrase").map(String::as_str);
let step = phrase
.filter(|p| *p == "getservercert" || *p == "pairchallenge")
.or_else(|| {
[
"clientchallenge",
"serverchallengeresp",
"clientpairingsecret",
]
.into_iter()
.find(|k| q.contains_key(*k))
})
.unwrap_or("?");
tracing::info!(uniqueid, step, "pair request");
let result = if phrase == Some("getservercert") {
match (q.get("salt"), q.get("clientcert")) {
(Some(salt), Some(cc)) => {
st.pairing
.getservercert(&st.identity, &uniqueid, salt, cc)
.await
}
_ => Ok(pair_error_xml()),
}
} else if phrase == Some("pairchallenge") {
// Reached only over the TLS port with the pinned host cert; the handshake is the
// proof, so acknowledge success.
Ok(paired_ok_xml())
} else if let Some(v) = q.get("clientchallenge") {
st.pairing.clientchallenge(&st.identity, &uniqueid, v)
} else if let Some(v) = q.get("serverchallengeresp") {
st.pairing.serverchallengeresp(&st.identity, &uniqueid, v)
} else if let Some(v) = q.get("clientpairingsecret") {
st.pairing.clientpairingsecret(&uniqueid, v, &st.paired)
} else {
Ok(pair_error_xml())
};
let body = result.unwrap_or_else(|e| {
tracing::warn!(error = %format!("{e:#}"), uniqueid, "pair handler error");
pair_error_xml()
});
xml(body)
}
fn paired_ok_xml() -> String {
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><paired>1</paired></root>\n"
.to_string()
}
fn pair_error_xml() -> String {
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><paired>0</paired></root>\n"
.to_string()
}
fn error_xml() -> String {
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"400\"></root>\n".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
fn test_state() -> Arc<AppState> {
let host = super::super::Host {
hostname: "t".into(),
uniqueid: "id".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
};
let identity = super::super::cert::ServerIdentity::ephemeral().expect("ephemeral identity");
let stats = crate::stats_recorder::StatsRecorder::new(
std::env::temp_dir().join(format!("pf-nvhttp-stats-{}", std::process::id())),
);
Arc::new(AppState::new(host, identity, stats))
}
fn fp_of(der: &[u8]) -> String {
hex::encode(punktfunk_core::quic::endpoint::cert_fingerprint(der))
}
/// The launch surface (launch/resume/applist/cancel) must reject any client whose cert
/// fingerprint is not in the paired allow-list — including a certless (plain-HTTP) peer.
#[test]
fn launch_gate_requires_a_pinned_client_cert() {
let st = test_state();
let der = b"a-client-cert-der".to_vec();
let peer = Some(Extension(PeerCertFingerprint(Some(fp_of(&der)))));
// Empty allow-list: a presented cert, an absent extension, and an explicit None all fail.
assert!(!peer_is_paired(&peer, &st), "unknown cert must be rejected");
assert!(
!peer_is_paired(&None, &st),
"no client cert must be rejected"
);
assert!(
!peer_is_paired(&Some(Extension(PeerCertFingerprint(None))), &st),
"certless HTTPS peer must be rejected"
);
// After pinning, the same fingerprint is accepted but a different cert still isn't.
st.paired.lock().unwrap().push(der);
assert!(peer_is_paired(&peer, &st), "pinned cert must be accepted");
let other = Some(Extension(PeerCertFingerprint(Some(fp_of(
b"different-der",
)))));
assert!(
!peer_is_paired(&other, &st),
"a non-pinned cert stays rejected"
);
}
}