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:
@@ -108,6 +108,11 @@ pub struct LaunchSession {
|
|||||||
/// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4).
|
/// 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).
|
/// `None` if the address could not be captured (then RTSP falls back to launch-present only).
|
||||||
pub peer_ip: Option<std::net::IpAddr>,
|
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.
|
/// Shared control-plane state used as the axum app state.
|
||||||
|
|||||||
@@ -126,15 +126,70 @@ async fn h_launch(
|
|||||||
peer: Option<Extension<PeerCertFingerprint>>,
|
peer: Option<Extension<PeerCertFingerprint>>,
|
||||||
addr: Option<Extension<PeerAddr>>,
|
addr: Option<Extension<PeerAddr>>,
|
||||||
Query(q): Query<HashMap<String, String>>,
|
Query(q): Query<HashMap<String, String>>,
|
||||||
) -> impl IntoResponse {
|
) -> Response {
|
||||||
if !peer_is_paired(&peer, &st) {
|
if !peer_is_paired(&peer, &st) {
|
||||||
tracing::warn!("launch rejected — client is not paired");
|
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) {
|
match launch(&st, &q) {
|
||||||
Ok(mut session) => {
|
Ok(mut session) => {
|
||||||
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP.
|
// 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.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);
|
*st.launch.lock().unwrap() = Some(session);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
w = session.width,
|
w = session.width,
|
||||||
@@ -144,11 +199,11 @@ async fn h_launch(
|
|||||||
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
|
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
|
||||||
st.host.local_ip
|
st.host.local_ip
|
||||||
);
|
);
|
||||||
xml(session_url_xml(&st, "gamesession"))
|
xml(session_url_xml(&st, "gamesession")).into_response()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "launch failed");
|
tracing::warn!(error = %format!("{e:#}"), "launch failed");
|
||||||
xml(error_xml())
|
xml(error_xml()).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +266,7 @@ fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession>
|
|||||||
fps,
|
fps,
|
||||||
appid,
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2498,6 +2498,7 @@ mod tests {
|
|||||||
fps: 120,
|
fps: 120,
|
||||||
appid: 1,
|
appid: 1,
|
||||||
peer_ip: None,
|
peer_ip: None,
|
||||||
|
owner_fp: None,
|
||||||
});
|
});
|
||||||
state.streaming.store(true, Ordering::SeqCst);
|
state.streaming.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
@@ -2624,6 +2625,7 @@ mod tests {
|
|||||||
fps: 60,
|
fps: 60,
|
||||||
appid: 1,
|
appid: 1,
|
||||||
peer_ip: None,
|
peer_ip: None,
|
||||||
|
owner_fp: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let del = axum::http::Request::delete("/api/v1/session")
|
let del = axum::http::Request::delete("/api/v1/session")
|
||||||
|
|||||||
@@ -341,6 +341,11 @@ pub(crate) async fn serve(
|
|||||||
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
|
/// 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);
|
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
|
/// 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
|
/// (`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.
|
/// link (e.g. after a speed test) requests an explicit rate instead.
|
||||||
@@ -718,6 +723,11 @@ async fn serve_session(
|
|||||||
}
|
}
|
||||||
Admission::Reject(reason) => {
|
Admission::Reject(reason) => {
|
||||||
tracing::warn!("mode-conflict: 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 <name>")
|
||||||
|
// instead of seeing a bare connection drop. Then end the handshake.
|
||||||
|
conn.close(REJECT_BUSY_CODE.into(), reason.as_bytes());
|
||||||
anyhow::bail!("{reason}");
|
anyhow::bail!("{reason}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,13 +91,33 @@ pub fn decide(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the effective decision for a connecting session: read the console `mode_conflict` policy
|
/// 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 {
|
pub fn admit(req_identity: Option<[u8; 32]>) -> Admission {
|
||||||
let conflict = policy::prefs()
|
#[allow(unused_mut)]
|
||||||
|
let mut conflict = policy::prefs()
|
||||||
.configured_effective()
|
.configured_effective()
|
||||||
.map(|e| e.mode_conflict)
|
.map(|e| e.mode_conflict)
|
||||||
.unwrap_or(ModeConflict::Separate);
|
.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
|
/// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call
|
||||||
|
|||||||
Reference in New Issue
Block a user