feat(vdisplay): finish Stage 4 — typed reject, Windows join-default, GameStream 503

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
  <client>' 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 10:34:49 +00:00
parent 42b1158ea7
commit cfad0cf7ee
5 changed files with 101 additions and 8 deletions
@@ -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<std::net::IpAddr>,
/// 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.
+61 -5
View File
@@ -126,15 +126,70 @@ async fn h_launch(
peer: Option<Extension<PeerCertFingerprint>>,
addr: Option<Extension<PeerAddr>>,
Query(q): Query<HashMap<String, String>>,
) -> 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<String, String>) -> Result<LaunchSession>
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
})
}