From cfad0cf7ee13fd06f455498fc35a862f996e86b3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 10:34:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(vdisplay):=20finish=20Stage=204=20?= =?UTF-8?q?=E2=80=94=20typed=20reject,=20Windows=20join-default,=20GameStr?= =?UTF-8?q?eam=20503?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the mode-conflict admission surface deferred from the initial Stage 4: - REJECT now delivers the reason to the client: punktfunk/1 closes the QUIC connection with a distinct BUSY code (0x42) + the 'host busy: streaming WxH@Hz to ' string, which the client reads from ApplicationClosed (validated on loopback: the probe logs 'closed by peer: host busy … (code 66)'). - Windows default: separate (incl. the unconfigured default) resolves to JOIN — the Windows native host admits a second client at the live mode instead of the old silent last-wins reconfigure of the shared monitor (release-note behavior fix; the reconfigure is now opt-in as steal). separate stays multi-view on Linux. - GameStream 503: h_launch tracks the session owner fp (LaunchSession.owner_fp, kept [u8;32] for Copy) and applies the policy when a DIFFERENT paired client launches — reject → 503 (Moonlight 'host busy'), join → serve the live mode, steal/separate → take over. Same-client re-launch is never a conflict. Native reject-reason loopback-validated; Windows join-default pending .173 rebuild; GameStream 503 pending a Moonlight client (can't drive /launch autonomously). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/gamestream/mod.rs | 5 ++ .../punktfunk-host/src/gamestream/nvhttp.rs | 66 +++++++++++++++++-- crates/punktfunk-host/src/mgmt.rs | 2 + crates/punktfunk-host/src/punktfunk1.rs | 10 +++ .../punktfunk-host/src/vdisplay/admission.rs | 26 +++++++- 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index c1ebf56..ba626fb 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -108,6 +108,11 @@ pub struct LaunchSession { /// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4). /// `None` if the address could not be captured (then RTSP falls back to launch-present only). pub peer_ip: Option, + /// SHA-256 cert fingerprint of the paired client that owns this session — mode-conflict admission + /// (Stage 4) compares it against a launching client to tell a same-client re-launch (always + /// allowed) from a DIFFERENT client (subject to the `mode_conflict` policy). `[u8; 32]` keeps + /// [`LaunchSession`] `Copy`; `None` when the peer cert couldn't be read. + pub owner_fp: Option<[u8; 32]>, } /// Shared control-plane state used as the axum app state. diff --git a/crates/punktfunk-host/src/gamestream/nvhttp.rs b/crates/punktfunk-host/src/gamestream/nvhttp.rs index 7c02d25..eb78dac 100644 --- a/crates/punktfunk-host/src/gamestream/nvhttp.rs +++ b/crates/punktfunk-host/src/gamestream/nvhttp.rs @@ -126,15 +126,70 @@ async fn h_launch( peer: Option>, addr: Option>, Query(q): Query>, -) -> impl IntoResponse { +) -> Response { if !peer_is_paired(&peer, &st) { tracing::warn!("launch rejected — client is not paired"); - return xml(error_xml()); + return xml(error_xml()).into_response(); } + let req_fp: Option<[u8; 32]> = match &peer { + Some(Extension(PeerCertFingerprint(Some(fp)))) => { + hex::decode(fp).ok().and_then(|v| <[u8; 32]>::try_from(v).ok()) + } + _ => None, + }; + + // Mode-conflict ADMISSION (Stage 4). GameStream is single-session (`st.launch`), so when a + // DIFFERENT paired client launches while a session is live, the `mode_conflict` policy governs: + // `reject` → 503 (Moonlight shows "host is busy"); `join` → serve at the live session's mode; + // `steal`/`separate` (GameStream can't do separate) / unconfigured → take over (today's last-wins). + // A same-client re-launch is never a conflict. + let mut forced_mode: Option<(u32, u32, u32)> = None; + { + let cur = st.launch.lock().unwrap(); + if let Some(s) = cur.as_ref() { + let different = match (&s.owner_fp, &req_fp) { + (Some(owner), Some(req)) => owner != req, + _ => true, // unknown owner or anonymous requester → treat as a different client + }; + if different { + use crate::vdisplay::policy::{self, ModeConflict}; + let conflict = policy::prefs() + .configured_effective() + .map(|e| e.mode_conflict) + .unwrap_or(ModeConflict::Separate); + match conflict { + ModeConflict::Reject => { + tracing::warn!( + "GameStream launch REJECTED — host busy streaming {}x{}@{} to another client", + s.width, s.height, s.fps + ); + return (StatusCode::SERVICE_UNAVAILABLE, xml(error_xml())).into_response(); + } + ModeConflict::Join => { + forced_mode = Some((s.width, s.height, s.fps)); + tracing::info!( + "GameStream launch JOIN — admitting at the live session's mode {}x{}@{}", + s.width, s.height, s.fps + ); + } + ModeConflict::Steal | ModeConflict::Separate => tracing::info!( + "GameStream launch STEAL — a different client is taking over the live session" + ), + } + } + } + } + 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()); + session.owner_fp = req_fp; + if let Some((w, h, f)) = forced_mode { + session.width = w; + session.height = h; + session.fps = f; + } *st.launch.lock().unwrap() = Some(session); tracing::info!( w = session.width, @@ -144,11 +199,11 @@ async fn h_launch( "launch — session created; RTSP at rtsp://{}:{RTSP_PORT}", st.host.local_ip ); - xml(session_url_xml(&st, "gamesession")) + xml(session_url_xml(&st, "gamesession")).into_response() } Err(e) => { tracing::warn!(error = %format!("{e:#}"), "launch failed"); - xml(error_xml()) + xml(error_xml()).into_response() } } } @@ -210,7 +265,8 @@ fn launch(_st: &AppState, q: &HashMap) -> Result height, fps, appid, - peer_ip: None, // set by `h_launch` from the verified HTTPS peer address + peer_ip: None, // set by `h_launch` from the verified HTTPS peer address + owner_fp: None, // set by `h_launch` from the verified HTTPS peer cert fingerprint }) } diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index b3ce773..f5cf7c1 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -2498,6 +2498,7 @@ mod tests { fps: 120, appid: 1, peer_ip: None, + owner_fp: None, }); state.streaming.store(true, Ordering::SeqCst); @@ -2624,6 +2625,7 @@ mod tests { fps: 60, appid: 1, peer_ip: None, + owner_fp: None, }); let del = axum::http::Request::delete("/api/v1/session") diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 368fb4d..8feba5a 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -341,6 +341,11 @@ pub(crate) async fn serve( /// connects and never finishes the handshake would otherwise wedge the host for everyone. const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal, +/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct +/// code lets a client tell "host busy" apart from a transport failure. +const REJECT_BUSY_CODE: u32 = 0x42; + /// Encoder bitrate (kbps) the host falls back to when the client expresses no preference /// (`Hello::bitrate_kbps == 0`) — the long-standing 20 Mbps default. A client that knows its /// link (e.g. after a speed test) requests an explicit rate instead. @@ -718,6 +723,11 @@ async fn serve_session( } Admission::Reject(reason) => { tracing::warn!("mode-conflict: REJECT — {reason}"); + // Deliver the reason to the client as a TYPED refusal: close the QUIC connection + // with the BUSY application code + the reason bytes, which the client reads from + // the `ApplicationClosed` error (so its UI can say "host is streaming X to ") + // instead of seeing a bare connection drop. Then end the handshake. + conn.close(REJECT_BUSY_CODE.into(), reason.as_bytes()); anyhow::bail!("{reason}"); } } diff --git a/crates/punktfunk-host/src/vdisplay/admission.rs b/crates/punktfunk-host/src/vdisplay/admission.rs index ff9fc6a..42bfb7a 100644 --- a/crates/punktfunk-host/src/vdisplay/admission.rs +++ b/crates/punktfunk-host/src/vdisplay/admission.rs @@ -91,13 +91,33 @@ pub fn decide( } /// Resolve the effective decision for a connecting session: read the console `mode_conflict` policy -/// (default `Separate` when unconfigured — no behavior change) and [`decide`] against the live set. +/// (default `Separate` when unconfigured) and [`decide`] against the live set. +/// +/// **Windows** can't create SEPARATE virtual displays until the multi-monitor stage (§6.6), so a +/// `separate` outcome — including the **unconfigured default** — resolves to `join` (admit at the live +/// mode) rather than the old silent last-wins reconfigure of the shared monitor. This is the deliberate +/// Windows default change (release-note behavior fix); `steal` remains the way to force the new mode. pub fn admit(req_identity: Option<[u8; 32]>) -> Admission { - let conflict = policy::prefs() + #[allow(unused_mut)] + let mut conflict = policy::prefs() .configured_effective() .map(|e| e.mode_conflict) .unwrap_or(ModeConflict::Separate); - decide(conflict, req_identity, &table().lock().unwrap()) + let live = table().lock().unwrap(); + #[cfg(windows)] + if matches!(conflict, ModeConflict::Separate) { + if live + .iter() + .any(|s| !same_client(s.identity, req_identity)) + { + tracing::warn!( + "mode_conflict=separate is not yet supported on Windows (multi-monitor is §6.6) — \ + JOINing the live session's mode instead" + ); + } + conflict = ModeConflict::Join; + } + decide(conflict, req_identity, &live) } /// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call