feat(host): Apollo-backlog hardening — cert gate, NVENC RFI, media QoS, async injector
A pass over the apollo-comparison backlog (re-verified against current code). Lands four items end-to-end plus a Windows-DualSense scoping doc. - #5/#92/#26 — GameStream paired-cert allow-list. tls.rs surfaces the verified peer cert to handlers (serve_https + PeerCertFingerprint, now shared with the mgmt API instead of duplicated); nvhttp gates /launch /resume /applist /cancel on AppState.paired and reports a real PairStatus; save_paired writes atomically (temp+rename). Closes the "mTLS accepts any client cert" hole. + regression test. - #6/#51/#19/#22 — NVENC caps query -> reference-frame invalidation. nvenc.rs query_caps probes nvEncGetEncodeCaps (max dims / 10-bit / custom-VBV / RFI), rejecting over-range modes and degrading 10-bit->8-bit instead of an opaque InvalidParam. New Encoder::invalidate_ref_frames (default false -> caller keyframes); the Windows NVENC path implements real RFI (multi-ref DPB + nvEncInvalidateRefFrames, dedup + IDR-on-overflow). control.rs decodes the 0x0301 lost-frame range (Apollo's IDX_INVALIDATE_REF_FRAMES) -> AppState.rfi_range -> encode loop, falling back to a keyframe. NOTE: the Windows NVENC impl is RTX-box/CI-pending (can't compile on Linux); adversarially reviewed vs the SDK. - #43/#72 — media socket QoS + buffer growth. New punktfunk_core::transport::qos: grow_socket_buffers (factored out the native plane's 32MB SO_SNDBUF growth so the GameStream sockets reuse it) + set_media_qos (opt-in PUNKTFUNK_DSCP=1: DSCP CS5 video / CS6 audio + Linux SO_PRIORITY, Apollo's scheme). Wired into UdpTransport and the GameStream video/audio sockets. Windows IP_TOS needs qWAVE (follow-up). - #8/#45 — GameStream input injection off the ENet service thread. on_receive no longer injects inline (a slow inject head-blocked ENet keepalive/retransmit); it forwards to a dedicated injector thread. The hardened InjectorService moved from punktfunk1 into crate::inject (shared by both planes) + a coalesce step that sums adjacent relative-mouse/scroll deltas while preserving button/key/abs ordering. Docs: re-verified apollo-comparison.md status (22 items already done/obsolete since the snapshot) + windows-dualsense-scoping.md (ViGEm can't emulate a DualSense; real DS5 on Windows needs a VHF virtual-HID driver — web-research pass pending). fmt + clippy -D warnings clean; full workspace test suite green; no C-ABI/OpenAPI drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -303,6 +303,9 @@ fn run(
|
||||
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
|
||||
) -> Result<()> {
|
||||
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
|
||||
// Grow SO_SNDBUF/RCVBUF + opt-in DSCP/QoS-tag this as the audio class (PUNKTFUNK_DSCP=1).
|
||||
punktfunk_core::transport::grow_socket_buffers(&sock);
|
||||
punktfunk_core::transport::set_media_qos(&sock, punktfunk_core::transport::MediaClass::Audio);
|
||||
// The client pings the audio port (~every 500ms) so we learn where to send.
|
||||
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
tracing::info!(port = AUDIO_PORT, "audio: awaiting client ping");
|
||||
|
||||
@@ -24,10 +24,11 @@
|
||||
|
||||
use super::{AppState, CONTROL_PORT};
|
||||
use crate::inject::gamepad::GamepadManager;
|
||||
use crate::inject::InputInjector;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::input::InputEvent;
|
||||
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -53,12 +54,14 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-control".into())
|
||||
.spawn(move || {
|
||||
// Thread-local (the injector owns non-Send Wayland/xkb state, so it must be
|
||||
// created and live here rather than be captured into the closure).
|
||||
// GCM scheme detected from the first authenticating packet; reused thereafter.
|
||||
let mut detected: Option<Scheme> = None;
|
||||
// Lazily opened on the first input event (Sway's Wayland socket is up by then).
|
||||
let mut injector: Option<Box<dyn InputInjector>> = None;
|
||||
// Decoded keyboard/mouse is forwarded to a dedicated host-lifetime injector thread —
|
||||
// NEVER injected inline, so a slow Wayland/libei/SendInput call can't head-block ENet
|
||||
// keepalive/retransmit servicing on this thread. The injector owns non-Send compositor
|
||||
// state and lives on its own thread (see crate::inject::InjectorService); the held
|
||||
// `inj_tx` clone keeps it alive for the control thread's lifetime.
|
||||
let inj_tx = crate::inject::InjectorService::start().sender();
|
||||
// Virtual gamepads (uinput) + the host→client rumble sequence counter.
|
||||
let mut pads = GamepadManager::new();
|
||||
let mut rumble_seq: u32 = 0;
|
||||
@@ -86,7 +89,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
channel_id,
|
||||
packet.data(),
|
||||
&mut detected,
|
||||
&mut injector,
|
||||
&inj_tx,
|
||||
&mut pads,
|
||||
);
|
||||
}
|
||||
@@ -128,6 +131,19 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode the lost-frame range from an invalidate-reference-frames (0x0301) control message: two
|
||||
/// little-endian `i64` (firstFrame, lastFrame) after the 4-byte `[u16 type][u16 length]` header,
|
||||
/// matching Sunshine/Apollo's `IDX_INVALIDATE_REF_FRAMES`. Returns `None` when the body is too
|
||||
/// short or the range is nonsensical, in which case the caller falls back to a full IDR.
|
||||
fn decode_rfi_range(pt: &[u8]) -> Option<(i64, i64)> {
|
||||
if pt.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
let first = i64::from_le_bytes(pt[4..12].try_into().ok()?);
|
||||
let last = i64::from_le_bytes(pt[12..20].try_into().ok()?);
|
||||
(first >= 0 && last >= first).then_some((first, last))
|
||||
}
|
||||
|
||||
/// Handle one received control packet: decrypt it (learning the GCM scheme on the first one),
|
||||
/// decode any input event, and inject it into the host session.
|
||||
fn on_receive(
|
||||
@@ -135,7 +151,7 @@ fn on_receive(
|
||||
_channel_id: u8,
|
||||
d: &[u8],
|
||||
detected: &mut Option<Scheme>,
|
||||
injector: &mut Option<Box<dyn InputInjector>>,
|
||||
inj_tx: &Sender<InputEvent>,
|
||||
pads: &mut GamepadManager,
|
||||
) {
|
||||
let Some(key) = state.launch.lock().unwrap().map(|s| s.gcm_key) else {
|
||||
@@ -160,17 +176,32 @@ fn on_receive(
|
||||
}
|
||||
};
|
||||
|
||||
// Recovery requests after loss: invalidate-reference-frames (0x0301, Gen7) or request-IDR
|
||||
// (0x0302, Gen7Enc). Force a keyframe so the client can resync without a multi-second stall.
|
||||
// Recovery requests after loss. Invalidate-reference-frames (0x0301, Gen7) carries the lost
|
||||
// frame range (two LE i64 after the [type][len] header, like Sunshine/Apollo's
|
||||
// IDX_INVALIDATE_REF_FRAMES) — route it to the encoder, which invalidates those refs instead of
|
||||
// a full IDR when it can (NVENC RFI). Request-IDR (0x0302 / 0x0305) and a malformed 0x0301 force
|
||||
// a keyframe. The video thread drains rfi_range/force_idr and resyncs without a multi-second stall.
|
||||
if pt.len() >= 2 {
|
||||
let inner = u16::from_le_bytes([pt[0], pt[1]]);
|
||||
if matches!(inner, 0x0301 | 0x0302 | 0x0305) {
|
||||
if inner == 0x0301 {
|
||||
if let Some((first, last)) = decode_rfi_range(&pt) {
|
||||
*state.rfi_range.lock().unwrap() = Some((first, last));
|
||||
tracing::info!(first, last, "control: RFI request → invalidate ref frames");
|
||||
} else {
|
||||
state
|
||||
.force_idr
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
tracing::info!("control: RFI request (no range) → keyframe");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if matches!(inner, 0x0302 | 0x0305) {
|
||||
state
|
||||
.force_idr
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
tracing::info!(
|
||||
ty = format!("{inner:#06x}"),
|
||||
"control: IDR/RFI request → keyframe"
|
||||
"control: IDR request → keyframe"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -187,27 +218,11 @@ fn on_receive(
|
||||
return; // keepalive / QoS / unhandled input kind
|
||||
}
|
||||
|
||||
// Open the injector on demand — by the first input event the compositor session is up.
|
||||
// Backend auto-selects per desktop (wlr on Sway, libei on KWin/GNOME); override with
|
||||
// PUNKTFUNK_INPUT_BACKEND.
|
||||
if injector.is_none() {
|
||||
let backend = crate::inject::default_backend();
|
||||
match crate::inject::open(backend) {
|
||||
Ok(i) => {
|
||||
tracing::info!(?backend, "input injection backend opened");
|
||||
*injector = Some(i);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "input injection unavailable");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let inj = injector.as_mut().unwrap();
|
||||
// Forward to the dedicated injector thread (it opens the backend on the first event and
|
||||
// coalesces redundant motion). A closed channel means the injector thread died at startup —
|
||||
// input is lossy, so drop silently rather than spam.
|
||||
for ev in events {
|
||||
if let Err(e) = inj.inject(&ev) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "inject failed");
|
||||
}
|
||||
let _ = inj_tx.send(ev);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,3 +441,29 @@ fn gcm_open(key: &[u8; 16], nonce: &[u8], ct_tag: &[u8], aad: &[u8]) -> Option<V
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::decode_rfi_range;
|
||||
|
||||
/// Build a 0x0301 invalidate-ref-frames plaintext: `[type LE][len LE][firstFrame i64 LE][last i64 LE]`.
|
||||
fn rfi_msg(first: i64, last: i64) -> Vec<u8> {
|
||||
let mut v = vec![0x01, 0x03, 0x10, 0x00]; // type 0x0301, length 16
|
||||
v.extend_from_slice(&first.to_le_bytes());
|
||||
v.extend_from_slice(&last.to_le_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_valid_rfi_range() {
|
||||
assert_eq!(decode_rfi_range(&rfi_msg(40, 47)), Some((40, 47)));
|
||||
assert_eq!(decode_rfi_range(&rfi_msg(5, 5)), Some((5, 5))); // single frame
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_or_nonsensical_ranges() {
|
||||
assert_eq!(decode_rfi_range(&[0x01, 0x03, 0x00, 0x00]), None); // header only, no body
|
||||
assert_eq!(decode_rfi_range(&rfi_msg(-1, 9)), None); // negative first
|
||||
assert_eq!(decode_rfi_range(&rfi_msg(9, 4)), None); // last < first
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,10 @@ pub struct AppState {
|
||||
/// Set by the control stream when the client requests an IDR / invalidates reference
|
||||
/// frames (recovery after loss); the video thread forces a keyframe and clears it.
|
||||
pub force_idr: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
/// A client reference-frame-invalidation request carrying the lost frame range (0x0301). The
|
||||
/// video thread drains it and calls `Encoder::invalidate_ref_frames`, falling back to a full
|
||||
/// IDR when the encoder can't invalidate (range too old / no NVENC RFI). `None` = nothing pending.
|
||||
pub rfi_range: std::sync::Arc<std::sync::Mutex<Option<(i64, i64)>>>,
|
||||
/// Persistent screen capturer, reused across streams so reconnects don't spawn a second
|
||||
/// (conflicting) screencast session. The video thread borrows it for the stream's duration
|
||||
/// and returns it; `set_active` gates its cost while idle.
|
||||
@@ -138,6 +142,7 @@ impl AppState {
|
||||
streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
audio_streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
force_idr: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
}
|
||||
@@ -293,18 +298,30 @@ fn load_paired() -> Vec<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the paired-client allow-list (called after each successful pairing).
|
||||
/// Persist the paired-client allow-list (called after each successful pairing). Written
|
||||
/// atomically (temp file + rename) so a crash mid-write can't truncate `paired.json` — a partial
|
||||
/// write would otherwise lock out every paired client until they re-pair.
|
||||
pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
let Some(path) = paired_path() else { return };
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
match serde_json::to_vec(paired) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = std::fs::write(&path, bytes) {
|
||||
tracing::warn!(error = %e, "persisting pairings failed");
|
||||
}
|
||||
let bytes = match serde_json::to_vec(paired) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "serializing pairings failed");
|
||||
return;
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "serializing pairings failed"),
|
||||
};
|
||||
// Write to a sibling temp file, then rename over the target (atomic replace on Unix and
|
||||
// Windows). Never write `path` in place.
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, &bytes) {
|
||||
tracing::warn!(error = %e, "persisting pairings failed (temp write)");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, &path) {
|
||||
tracing::warn!(error = %e, "persisting pairings failed (rename)");
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! `/pin` endpoint to deliver the Moonlight-displayed PIN. Over HTTPS the client is
|
||||
//! mutual-TLS-authenticated, so `/serverinfo` reports `PairStatus=1` there.
|
||||
|
||||
use super::tls::PeerCertFingerprint;
|
||||
use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_PORT};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::{
|
||||
@@ -23,24 +24,36 @@ 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 = axum_server::tls_rustls::RustlsConfig::from_config(super::tls::server_config(
|
||||
&state.identity.cert_pem,
|
||||
&state.identity.key_pem,
|
||||
)?);
|
||||
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());
|
||||
let https =
|
||||
axum_server::bind_rustls(https_addr, tls).serve(router(state, true).into_make_service());
|
||||
tokio::try_join!(async { http.await.context("nvhttp HTTP server") }, async {
|
||||
https.await.context("nvhttp HTTPS server")
|
||||
},)?;
|
||||
// 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))
|
||||
@@ -61,9 +74,12 @@ fn xml(body: String) -> impl IntoResponse {
|
||||
async fn h_serverinfo(
|
||||
State(st): State<Arc<AppState>>,
|
||||
Extension(Https(https)): Extension<Https>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
) -> impl IntoResponse {
|
||||
// Over the mutual-TLS port the peer is an authenticated (paired) client → PairStatus=1.
|
||||
xml(serverinfo::serverinfo_xml(&st.host, https))
|
||||
// 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_pin(
|
||||
@@ -79,15 +95,27 @@ async fn h_pin(
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_applist(State(_st): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
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());
|
||||
}
|
||||
// One app for now: the headless desktop (the wlroots virtual output).
|
||||
xml(super::apps::applist_xml())
|
||||
}
|
||||
|
||||
async fn h_launch(
|
||||
State(st): State<Arc<AppState>>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
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(session) => {
|
||||
*st.launch.lock().unwrap() = Some(session);
|
||||
@@ -108,7 +136,14 @@ async fn h_launch(
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_resume(State(st): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
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 {
|
||||
@@ -116,7 +151,14 @@ async fn h_resume(State(st): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_cancel(State(st): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
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.
|
||||
@@ -234,3 +276,56 @@ fn pair_error_xml() -> 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");
|
||||
Arc::new(AppState::new(host, identity))
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ fn handle_request(req: &Request, state: &AppState) -> String {
|
||||
app,
|
||||
state.streaming.clone(),
|
||||
state.force_idr.clone(),
|
||||
state.rfi_range.clone(),
|
||||
state.video_cap.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
use super::{Host, APP_VERSION, GFE_VERSION, SERVER_CODEC_MODE_SUPPORT};
|
||||
|
||||
/// Build the `<root status_code="200">…</root>` serverinfo document. `https` selects the
|
||||
/// paired-HTTPS variant (real MAC). Element names are case-sensitive and match what
|
||||
/// moonlight-common-c parses.
|
||||
pub fn serverinfo_xml(host: &Host, https: bool) -> String {
|
||||
// MAC is hidden over plain HTTP; PairStatus reflects the pairing store once the HTTPS
|
||||
// path carries per-client identity (a hardening follow-up — 0 for now).
|
||||
/// paired-HTTPS variant (real MAC); `paired` is whether the HTTPS peer presented a client cert
|
||||
/// that is in the paired allow-list (drives `PairStatus`). Element names are case-sensitive and
|
||||
/// match what moonlight-common-c parses.
|
||||
pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
|
||||
// MAC is hidden over plain HTTP (no per-client identity there).
|
||||
let mac = if https {
|
||||
"01:02:03:04:05:06"
|
||||
} else {
|
||||
"00:00:00:00:00:00"
|
||||
};
|
||||
// Over the mutual-TLS HTTPS port the peer is an authenticated (paired) client.
|
||||
let pair_status = u8::from(https);
|
||||
// PairStatus reflects the real allow-list: 1 only when the HTTPS peer's client-cert
|
||||
// fingerprint is pinned (the nvhttp handler computes `paired`); 0 otherwise (incl. plain HTTP).
|
||||
let pair_status = u8::from(paired);
|
||||
let codec_mode_support = codec_mode_support();
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
@@ -104,7 +105,7 @@ mod tests {
|
||||
http_port: 47989,
|
||||
https_port: 47984,
|
||||
};
|
||||
let xml = serverinfo_xml(&host, false);
|
||||
let xml = serverinfo_xml(&host, false, false);
|
||||
// The mask is the GPU-aware value (NVENC/no-GPU → the static 65793; a VAAPI host →
|
||||
// whatever it probes). Assert the XML embeds exactly what `codec_mode_support()` returns,
|
||||
// so the test is deterministic regardless of the build host's GPU.
|
||||
|
||||
@@ -31,6 +31,10 @@ pub struct StreamConfig {
|
||||
/// streams so a reconnect doesn't open a second (conflicting) screencast session.
|
||||
pub type CapturerSlot = Arc<std::sync::Mutex<Option<Box<dyn Capturer>>>>;
|
||||
|
||||
/// A pending client reference-frame-invalidation range (lost `firstFrame..=lastFrame`), set by the
|
||||
/// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)).
|
||||
pub type RfiSlot = Arc<std::sync::Mutex<Option<(i64, i64)>>>;
|
||||
|
||||
/// Spawn the video stream thread (idempotent via `running`). Stops when `running` clears.
|
||||
/// `force_idr` is set by the control stream on a client recovery request; `video_cap` holds
|
||||
/// the persistent capturer the thread borrows for the stream's duration.
|
||||
@@ -39,13 +43,21 @@ pub fn start(
|
||||
app: Option<super::apps::AppEntry>,
|
||||
running: Arc<AtomicBool>,
|
||||
force_idr: Arc<AtomicBool>,
|
||||
rfi_range: RfiSlot,
|
||||
video_cap: CapturerSlot,
|
||||
) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-video".into())
|
||||
.spawn(move || {
|
||||
tracing::info!(?cfg, "video stream starting");
|
||||
if let Err(e) = run(cfg, app.as_ref(), &running, &force_idr, &video_cap) {
|
||||
if let Err(e) = run(
|
||||
cfg,
|
||||
app.as_ref(),
|
||||
&running,
|
||||
&force_idr,
|
||||
&rfi_range,
|
||||
&video_cap,
|
||||
) {
|
||||
tracing::error!(error = %format!("{e:#}"), "video stream failed");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
@@ -58,6 +70,7 @@ fn run(
|
||||
app: Option<&super::apps::AppEntry>,
|
||||
running: &Arc<AtomicBool>,
|
||||
force_idr: &AtomicBool,
|
||||
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
||||
video_cap: &std::sync::Mutex<Option<Box<dyn Capturer>>>,
|
||||
) -> Result<()> {
|
||||
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
|
||||
@@ -66,6 +79,10 @@ fn run(
|
||||
encode::validate_dimensions(cfg.codec, cfg.width, cfg.height)
|
||||
.context("client-requested video mode")?;
|
||||
let sock = UdpSocket::bind(("0.0.0.0", VIDEO_PORT)).context("bind video UDP")?;
|
||||
// Grow SO_SNDBUF/RCVBUF (avoid host-side ENOBUFS at high bitrate) like the native plane, and
|
||||
// opt-in DSCP/QoS-tag this as the video class (PUNKTFUNK_DSCP=1).
|
||||
punktfunk_core::transport::grow_socket_buffers(&sock);
|
||||
punktfunk_core::transport::set_media_qos(&sock, punktfunk_core::transport::MediaClass::Video);
|
||||
// The client pings the video port so we learn where to send; it re-pings until video
|
||||
// flows, so a missed early ping is fine.
|
||||
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
@@ -115,7 +132,7 @@ fn run(
|
||||
let mut capturer =
|
||||
capture::capture_virtual_output(vout).context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
return stream_body(&mut *capturer, &sock, cfg, running, force_idr);
|
||||
return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range);
|
||||
}
|
||||
|
||||
// Reuse the persistent capturer (one screencast session → clean reconnect); create it on
|
||||
@@ -135,7 +152,7 @@ fn run(
|
||||
}
|
||||
};
|
||||
capturer.set_active(true);
|
||||
let result = stream_body(&mut *capturer, &sock, cfg, running, force_idr);
|
||||
let result = stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range);
|
||||
capturer.set_active(false);
|
||||
*video_cap.lock().unwrap() = Some(capturer);
|
||||
result
|
||||
@@ -275,6 +292,7 @@ fn stream_body(
|
||||
cfg: StreamConfig,
|
||||
running: &Arc<AtomicBool>,
|
||||
force_idr: &AtomicBool,
|
||||
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
||||
) -> Result<()> {
|
||||
// The first frame establishes the authoritative size/format for the encoder.
|
||||
let mut frame = capturer.next_frame().context("capture first frame")?;
|
||||
@@ -349,8 +367,16 @@ fn stream_body(
|
||||
uniq += 1;
|
||||
}
|
||||
let t_cap = tick.elapsed();
|
||||
// Honor a client recovery request (RFI / request-IDR): force a keyframe so the client
|
||||
// resyncs immediately instead of waiting for the next GOP boundary.
|
||||
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
|
||||
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
||||
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
||||
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
|
||||
if !enc.invalidate_ref_frames(first, last) {
|
||||
enc.request_keyframe();
|
||||
}
|
||||
}
|
||||
// An explicit IDR request (or a rangeless RFI) forces a keyframe so the client resyncs
|
||||
// immediately instead of waiting for the next GOP boundary.
|
||||
if force_idr.swap(false, Ordering::SeqCst) {
|
||||
enc.request_keyframe();
|
||||
}
|
||||
|
||||
@@ -1,17 +1,88 @@
|
||||
//! TLS for the HTTPS nvhttp port (47984). 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 (for now) accepts any well-formed cert; enforcing the
|
||||
//! paired allow-list (rejecting unpaired clients on /launch) is a follow-up hardening step.
|
||||
//! 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::{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::{CertificateDer, 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>);
|
||||
|
||||
/// 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) => {
|
||||
tracing::warn!(error = %e, "HTTPS 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 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 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
|
||||
Reference in New Issue
Block a user