fix(security): remaining audit findings — mgmt admin gate, RTSP DoS bounds, FEC drop, ALPN, ct-compare
apple / swift (push) Successful in 56s
windows-host / package (push) Successful in 2m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
android / android (push) Successful in 4m42s
ci / rust (push) Successful in 4m44s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 35s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m0s
deb / build-publish (push) Successful in 2m10s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
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 3s
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
ci / bench (push) Successful in 4m43s
flatpak / build-publish (push) Successful in 3m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m13s
apple / swift (push) Successful in 56s
windows-host / package (push) Successful in 2m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
android / android (push) Successful in 4m42s
ci / rust (push) Successful in 4m44s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 35s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m0s
deb / build-publish (push) Successful in 2m10s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
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 3s
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
ci / bench (push) Successful in 4m43s
flatpak / build-publish (push) Successful in 3m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m13s
Addresses the lower-severity findings from docs/security-review.md (#4-#12). Each fix was adversarially re-reviewed (5-agent pass); two review catches folded in (the Apple client's GET /library cert path; an RTSP header-cap bypass + a spawn-panic counter leak). - #4 [low] mgmt mTLS-paired-cert no longer grants full admin. A paired STREAMING cert authorizes only a read-only allowlist (GET /host,/compositors,/status,/clients,/native/clients,/library); every state-changing route and every PIN-exposing route (/pair, /native/pair) requires the operator's bearer token. New cert_auth_is_a_read_only_allowlist test. (/library kept on the allowlist — the native clients browse it cert-only; its mutations stay token-only.) - #6 [low] RTSP pre-auth DoS bounds: a concurrent-connection cap (RAII slot guard), a per-read timeout (slow-loris), and Content-Length/header/message size caps — closing an unauthenticated slow-loris / memory-growth / thread-exhaustion vector on TCP 48010. - #11 [info] A FEC reconstruction failure is now a counted drop (discard the block, keep the session) instead of being stream-fatal — a lossy link can't be torn down by one bad block. - #10 [info] Fixed ALPN ("pkf1") on both native QUIC endpoints (defense-in-depth; a deliberate coordinated client+host upgrade — a new host rejects an ALPN-less old client). - #8 [info] Constant-time GameStream pairing phase-4 hash compare (crypto::ct_eq). - #7 [low] New VirtualDisplay::set_launch_command carries the launch command per-session on the GameStream path (no process-global env stomp under concurrent sessions); native path keeps the env under today's single-session model (documented; plumb per-session with concurrent sessions). - #5 [low] Legacy GameStream GCM nonce reuse: documented as inherent to Nvidia's old-style control encryption (Apollo/Moonlight identical; key is client-known) — unfixable on the legacy wire; the real fix is V2 control-encryption negotiation. Code comment at control.rs. - #9 [info] GameStream plain-HTTP pairing: documented (inherent to GFE compat; use punktfunk/1). - #12 [low] Web global NODE_TLS_REJECT_UNAUTHORIZED: fix designed (undici dispatcher scoped to the loopback mgmt fetch) but DEFERRED — needs `bun add undici` in the web build env; reverted to keep the web working. Latent-only (the loopback mgmt fetch is the console's only outbound TLS). fmt + clippy -D warnings clean; 94 host + core tests green; no C-ABI/OpenAPI drift. (The HDR Steps 1-2 client work in the tree is the user's parallel WIP — deliberately NOT included here.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -296,8 +296,9 @@ impl Reassembler {
|
||||
stats: &StatsCounters,
|
||||
) -> Result<Option<Frame>> {
|
||||
// On a lossy datagram link a malformed or non-video packet is dropped, never
|
||||
// fatal: it must not abort `poll_frame`. Only a genuine FEC reconstruction
|
||||
// failure propagates as an error.
|
||||
// fatal: it must not abort `poll_frame`. A FEC reconstruction failure (corrupt or
|
||||
// incompatible shards that passed the header checks) likewise drops the block rather
|
||||
// than killing the whole session — the stream recovers at the next keyframe/RFI.
|
||||
if pkt.len() < HEADER_LEN {
|
||||
StatsCounters::add(&stats.packets_dropped, 1);
|
||||
return Ok(None);
|
||||
@@ -407,8 +408,22 @@ impl Reassembler {
|
||||
.iter()
|
||||
.filter(|s| s.is_some())
|
||||
.count();
|
||||
let recovered =
|
||||
coder.reconstruct(block.data_shards, block.recovery_shards, &mut block.shards)?;
|
||||
let recovered = match coder.reconstruct(
|
||||
block.data_shards,
|
||||
block.recovery_shards,
|
||||
&mut block.shards,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
// Corrupt/incompatible shards that slipped past the header checks: discard this
|
||||
// block (mark done so later shards for it are ignored) and keep the session
|
||||
// alive — a lossy link must not be torn down by one unrecoverable block; the
|
||||
// frame stays incomplete and the client recovers at the next keyframe/RFI.
|
||||
block.done = true;
|
||||
StatsCounters::add(&stats.packets_dropped, 1);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
block.done = true;
|
||||
StatsCounters::add(
|
||||
&stats.fec_recovered_shards,
|
||||
|
||||
@@ -1490,6 +1490,12 @@ pub mod endpoint {
|
||||
server_from_der(cert_der, key_der, addr)
|
||||
}
|
||||
|
||||
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
|
||||
/// TLS layer (defense-in-depth) and makes the wire protocol explicit. Both ends set the SAME value;
|
||||
/// a host with ALPN configured rejects a client that offers none, so client + host must be updated
|
||||
/// together (acceptable while the protocol/ABI is still evolving).
|
||||
const QUIC_ALPN: &[u8] = b"pkf1";
|
||||
|
||||
fn server_from_der(
|
||||
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||
@@ -1500,10 +1506,11 @@ pub mod endpoint {
|
||||
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
|
||||
// one that presents none still connects (and is rejected at the app layer when
|
||||
// pairing is required).
|
||||
let rustls_cfg = rustls::ServerConfig::builder()
|
||||
let mut rustls_cfg = rustls::ServerConfig::builder()
|
||||
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
|
||||
.with_single_cert(vec![cert_der], key_der)
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
||||
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
|
||||
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
||||
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
||||
@@ -1580,7 +1587,7 @@ pub mod endpoint {
|
||||
pin,
|
||||
observed: observed.clone(),
|
||||
}));
|
||||
let rustls_cfg = match identity {
|
||||
let mut rustls_cfg = match identity {
|
||||
None => builder.with_no_client_auth(),
|
||||
Some((cert_pem, key_pem)) => {
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
@@ -1596,6 +1603,8 @@ pub mod endpoint {
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))?
|
||||
}
|
||||
};
|
||||
// Must match the server's ALPN ([`QUIC_ALPN`]) or the handshake is rejected.
|
||||
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
|
||||
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
||||
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
|
||||
|
||||
@@ -103,6 +103,18 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
}
|
||||
// Service the pads' force-feedback protocol every tick (games block inside
|
||||
// EVIOCSFF until answered) and relay mixed rumble levels to the client.
|
||||
//
|
||||
// SECURITY NOTE (audit #5, legacy GCM nonce reuse): on the LEGACY control scheme
|
||||
// (`NonceKind::Legacy*`, which we hit because we advertise no encryption) the nonce is
|
||||
// just the per-direction `seq` (`iv[0]=seq&0xff`, rest zero) with NO direction byte —
|
||||
// so host rumble (this `rumble_seq`) and client input (its own seq) share the same
|
||||
// (key, nonce) space when their seqs collide. This is INHERENT to Nvidia's old-style
|
||||
// GameStream control encryption (Apollo/moonlight-common-c are identical: only the V2
|
||||
// scheme adds `iv[10..12] = 'H','C'` to separate the host direction). It can't be fixed
|
||||
// on the legacy wire without breaking Moonlight; the GCM key is the client-supplied
|
||||
// `rikey` (so only a passive eavesdropper who missed the HTTPS /launch is the
|
||||
// adversary). The real fix is V2 control-encryption negotiation; for untrusted networks
|
||||
// use the native punktfunk/1 plane (correct per-direction nonces + seq-as-AAD).
|
||||
if let (Some(pid), Some(scheme)) = (peer, detected) {
|
||||
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
|
||||
if let Some(key) = key {
|
||||
|
||||
@@ -25,6 +25,12 @@ pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
||||
h.finalize().into()
|
||||
}
|
||||
|
||||
/// Constant-time byte-slice equality — no early exit, so a timing side-channel can't probe the
|
||||
/// expected value byte-by-byte. Returns false on a length mismatch (the length isn't secret here).
|
||||
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// The PIN-derived AES-128 key: `SHA-256(salt || pin)[..16]` (salt first, PIN as ASCII).
|
||||
pub fn pin_key(salt: &[u8; 16], pin: &str) -> [u8; 16] {
|
||||
let d = sha256(&[salt, pin.as_bytes()]);
|
||||
|
||||
@@ -224,7 +224,8 @@ impl Pairing {
|
||||
let client_secret = &data[..16];
|
||||
let client_sig = &data[16..];
|
||||
let expected = crypto::sha256(&[&s.server_challenge, &s.client_cert_sig, client_secret]);
|
||||
let hash_ok = expected[..] == s.client_hash[..];
|
||||
// Constant-time compare so a timing side-channel can't probe the expected hash.
|
||||
let hash_ok = crypto::ct_eq(&expected, &s.client_hash);
|
||||
let sig_ok = verify256(&s.client_pubkey, client_secret, client_sig).is_ok();
|
||||
if hash_ok && sig_ok {
|
||||
{
|
||||
|
||||
@@ -15,12 +15,34 @@ use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Opaque per-session payload the client echoes as its first UDP datagram (port-learning).
|
||||
const PING_PAYLOAD: &str = "0011223344556677";
|
||||
|
||||
// The RTSP listener is UNAUTHENTICATED (no TLS/pairing) and one-thread-per-connection, so bound
|
||||
// every attacker-controllable dimension to deny a pre-auth slow-loris / memory-growth DoS: a hard
|
||||
// cap on concurrent connections, a per-read timeout so a stalled peer can't pin a thread, and
|
||||
// size caps on the request headers + body (real GameStream RTSP messages are a few hundred bytes).
|
||||
const MAX_RTSP_CONNS: usize = 8;
|
||||
const RTSP_READ_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const MAX_RTSP_HEADER: usize = 16 * 1024;
|
||||
const MAX_RTSP_BODY: usize = 64 * 1024;
|
||||
const MAX_RTSP_MSG: usize = 128 * 1024;
|
||||
|
||||
/// Live RTSP connection count, so a flood can't spawn unbounded threads. Decremented by [`ConnGuard`].
|
||||
static RTSP_ACTIVE: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Decrements [`RTSP_ACTIVE`] when a connection thread exits (normally OR on panic).
|
||||
struct ConnGuard;
|
||||
impl Drop for ConnGuard {
|
||||
fn drop(&mut self) {
|
||||
RTSP_ACTIVE.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind 48010 and accept RTSP connections on a dedicated thread.
|
||||
pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
let listener = TcpListener::bind(("0.0.0.0", RTSP_PORT))
|
||||
@@ -32,8 +54,19 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
for conn in listener.incoming() {
|
||||
match conn {
|
||||
Ok(stream) => {
|
||||
// Reserve a slot; over the cap, drop the connection (close) without a thread.
|
||||
if RTSP_ACTIVE.fetch_add(1, Ordering::Relaxed) >= MAX_RTSP_CONNS {
|
||||
RTSP_ACTIVE.fetch_sub(1, Ordering::Relaxed);
|
||||
tracing::warn!("RTSP: too many concurrent connections — dropping");
|
||||
continue; // `stream` drops → connection closed
|
||||
}
|
||||
// Construct the slot guard BEFORE spawning and move it into the worker, so the
|
||||
// slot is released even if `thread::spawn` itself panics (OS thread-limit) —
|
||||
// the closure (and its captured guard) is dropped during the unwind.
|
||||
let guard = ConnGuard;
|
||||
let st = state.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _guard = guard; // releases the slot on exit/panic
|
||||
if let Err(e) = handle_conn(stream, st) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "RTSP connection ended");
|
||||
}
|
||||
@@ -57,6 +90,8 @@ struct Request {
|
||||
|
||||
fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
|
||||
let peer = stream.peer_addr().ok();
|
||||
// A per-read timeout so a stalled/slow-loris peer can't pin this thread indefinitely.
|
||||
let _ = stream.set_read_timeout(Some(RTSP_READ_TIMEOUT));
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
// GameStream RTSP is one request per TCP connection: moonlight-common-c reads the
|
||||
// response until EOF, so we answer one message and close the connection (which signals
|
||||
@@ -82,10 +117,19 @@ fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
|
||||
fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Request>> {
|
||||
loop {
|
||||
if let Some(end) = find_subslice(buf, b"\r\n\r\n") {
|
||||
// Cap the header section even when the terminator IS present (a single oversized header
|
||||
// block that fits a `\r\n\r\n` would otherwise skip the no-terminator cap below).
|
||||
if end > MAX_RTSP_HEADER {
|
||||
anyhow::bail!("RTSP headers exceed limit");
|
||||
}
|
||||
let head = std::str::from_utf8(&buf[..end]).context("RTSP header utf8")?;
|
||||
let content_len = header_value(head, "content-length")
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
// Reject an absurd Content-Length before waiting to buffer it (allocation amplification).
|
||||
if content_len > MAX_RTSP_BODY {
|
||||
anyhow::bail!("RTSP Content-Length {content_len} exceeds limit");
|
||||
}
|
||||
let total = end + 4 + content_len;
|
||||
if buf.len() < total {
|
||||
// headers complete but body still arriving — read more
|
||||
@@ -95,6 +139,9 @@ fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Requ
|
||||
buf.drain(..total);
|
||||
return Ok(Some(parse_request(&head, body)));
|
||||
}
|
||||
} else if buf.len() > MAX_RTSP_HEADER {
|
||||
// No header terminator within the cap — a slow-loris dribbling headers forever.
|
||||
anyhow::bail!("RTSP headers exceed limit");
|
||||
}
|
||||
let mut tmp = [0u8; 8192];
|
||||
let n = stream.read(&mut tmp).context("RTSP read")?;
|
||||
@@ -102,6 +149,9 @@ fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Requ
|
||||
return Ok(None); // peer closed
|
||||
}
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
if buf.len() > MAX_RTSP_MSG {
|
||||
anyhow::bail!("RTSP message exceeds limit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,16 +104,11 @@ fn run(
|
||||
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
||||
if std::env::var("PUNKTFUNK_VIDEO_SOURCE").as_deref() == Ok("virtual") {
|
||||
// The launched app picks the compositor (e.g. gamescope for game entries) and the
|
||||
// nested command; env vars remain manual overrides / fallbacks.
|
||||
// nested command.
|
||||
let compositor = app
|
||||
.and_then(|a| a.compositor)
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| crate::vdisplay::detect().context("detect compositor"))?;
|
||||
if let Some(cmd) = app.and_then(|a| a.cmd.as_deref()) {
|
||||
// The gamescope backend reads the nested command from this env var; setting it
|
||||
// per-launch is safe (one stream session at a time).
|
||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", cmd);
|
||||
}
|
||||
tracing::info!(
|
||||
?compositor,
|
||||
app = ?app.map(|a| &a.title),
|
||||
@@ -122,6 +117,9 @@ fn run(
|
||||
"video source: virtual display (native client resolution)"
|
||||
);
|
||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
||||
// process-global env var, so concurrent sessions can't stomp each other's launch target.
|
||||
vd.set_launch_command(app.and_then(|a| a.cmd.clone()));
|
||||
let vout = vd
|
||||
.create(punktfunk_core::Mode {
|
||||
width: cfg.width,
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::gamestream::{
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
extract::{Path, Request, State},
|
||||
http::{header, StatusCode},
|
||||
http::{header, Method, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
@@ -461,10 +461,15 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
|
||||
return next.run(req).await; // liveness probe is always open
|
||||
}
|
||||
// A paired native client authenticates by its mTLS certificate — the same identity + trust the
|
||||
// QUIC data plane uses — so it never needs a bearer token. The fingerprint is attached by
|
||||
// `serve_https` from the verified peer cert; we authorize it iff it's in the paired store.
|
||||
// QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert
|
||||
// authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration
|
||||
// routes (which would let one paired client unpair others, read/arm the pairing PIN, stop
|
||||
// sessions, or edit the library). Everything outside the allowlist requires the operator's bearer
|
||||
// token. The fingerprint is attached by `serve_https` from the verified peer cert.
|
||||
if let Some(PeerCertFingerprint(Some(fp))) = req.extensions().get::<PeerCertFingerprint>() {
|
||||
if st.native.as_ref().is_some_and(|n| n.is_paired(fp)) {
|
||||
if cert_may_access(req.method(), req.uri().path())
|
||||
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
|
||||
{
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
@@ -487,6 +492,27 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
|
||||
}
|
||||
}
|
||||
|
||||
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
|
||||
/// safe, read-only status routes only. Deny-by-default — every state-changing route and every route
|
||||
/// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so
|
||||
/// a streaming client can't administer the host (unpair others, arm/read the PIN, stop sessions,
|
||||
/// edit the library). `/health` is handled separately (always open).
|
||||
fn cert_may_access(method: &Method, path: &str) -> bool {
|
||||
method == Method::GET
|
||||
&& matches!(
|
||||
path,
|
||||
"/api/v1/host"
|
||||
| "/api/v1/compositors"
|
||||
| "/api/v1/status"
|
||||
| "/api/v1/clients"
|
||||
| "/api/v1/native/clients"
|
||||
// The native clients browse the game library with their cert (no bearer token); the
|
||||
// library MUTATIONS (POST/PUT/DELETE /library/custom) stay token-only via the exact
|
||||
// GET-path match above.
|
||||
| "/api/v1/library"
|
||||
)
|
||||
}
|
||||
|
||||
/// Compare SHA-256 digests instead of the strings — constant-time with respect to the
|
||||
/// secret without pulling in a ct-eq dependency.
|
||||
fn token_eq(presented: &str, expected: &str) -> bool {
|
||||
@@ -1274,6 +1300,86 @@ mod tests {
|
||||
axum::http::Request::get(path).body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
/// Send a request authenticated ONLY by a paired streaming cert (the `PeerCertFingerprint`
|
||||
/// `serve_https` would attach) — no bearer header — so `require_auth`'s cert branch decides.
|
||||
async fn send_cert(app: &Router, mut req: axum::http::Request<Body>, fp: &str) -> StatusCode {
|
||||
req.extensions_mut()
|
||||
.insert(PeerCertFingerprint(Some(fp.to_string())));
|
||||
app.clone().oneshot(req).await.expect("infallible").status()
|
||||
}
|
||||
|
||||
/// A paired *streaming* cert (mTLS, no bearer) authorizes only the read-only allowlist; every
|
||||
/// state-changing or PIN-exposing route still requires the operator's bearer token (audit #4).
|
||||
#[tokio::test]
|
||||
async fn cert_auth_is_a_read_only_allowlist() {
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(
|
||||
std::env::temp_dir().join(format!("pf-mgmt-cert-{}.json", std::process::id())),
|
||||
),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let fp = "deadbeefcafe";
|
||||
np.add("streaming-client", fp).unwrap();
|
||||
let app = test_app_native(test_state(), np);
|
||||
|
||||
// Allowlisted read-only GETs → the cert authorizes them (not 401).
|
||||
for p in [
|
||||
"/api/v1/host",
|
||||
"/api/v1/status",
|
||||
"/api/v1/compositors",
|
||||
"/api/v1/clients",
|
||||
"/api/v1/native/clients",
|
||||
"/api/v1/library",
|
||||
] {
|
||||
assert_ne!(
|
||||
send_cert(&app, get_req(p), fp).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a paired streaming cert should authorize GET {p}"
|
||||
);
|
||||
}
|
||||
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
|
||||
assert_eq!(
|
||||
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /native/pair exposes the PIN → must require the bearer token"
|
||||
);
|
||||
assert_eq!(
|
||||
send_cert(
|
||||
&app,
|
||||
post_json(
|
||||
"/api/v1/native/pair/arm",
|
||||
serde_json::json!({"ttl_secs": 60})
|
||||
),
|
||||
fp,
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"arming pairing must require the bearer token"
|
||||
);
|
||||
assert_eq!(
|
||||
send_cert(
|
||||
&app,
|
||||
axum::http::Request::delete("/api/v1/native/clients/deadbeefcafe")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
fp,
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unpair (DELETE) must require the bearer token"
|
||||
);
|
||||
// An UNPAIRED cert is rejected even on an allowlisted path.
|
||||
assert_eq!(
|
||||
send_cert(&app, get_req("/api/v1/status"), "not-paired").await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"an unpaired cert must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_is_open_and_versioned() {
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
@@ -564,10 +564,12 @@ async fn serve_session(
|
||||
};
|
||||
|
||||
// Resolve a requested library launch (the client sends only the store-qualified id;
|
||||
// we look it up in OUR library so a client can't inject a command). Set the gamescope
|
||||
// backend's app env var, exactly as the GameStream /launch path does — safe per-session
|
||||
// (one session at a time). Only the bare-spawn gamescope path reads it; on a shared
|
||||
// desktop (kwin/mutter/wlroots) or an attach-to-existing session it's a harmless no-op.
|
||||
// we look it up in OUR library so a client can't inject a command). The bare-spawn gamescope
|
||||
// backend picks this up via the `PUNKTFUNK_GAMESCOPE_APP` env fallback in `spawn` (on a shared
|
||||
// desktop / attach-to-existing session it's a harmless no-op). This is the process-global env
|
||||
// path — safe under today's ONE-session-at-a-time model; when concurrent native sessions land
|
||||
// (`what's left` §3), resolve the command into the per-session VirtualDisplay via
|
||||
// `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other.
|
||||
if let Some(id) = hello.launch.as_deref() {
|
||||
match crate::library::launch_command(id) {
|
||||
Some(cmd) => {
|
||||
|
||||
@@ -50,6 +50,11 @@ pub trait VirtualDisplay: Send {
|
||||
/// Create a virtual output of the given mode. Teardown is RAII: drop the returned
|
||||
/// [`VirtualOutput`]'s `keepalive`.
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput>;
|
||||
/// Set the per-session command this display should launch into its nested output (the resolved
|
||||
/// app/game). Carried on the backend instance — NOT a process-global env var — so concurrent
|
||||
/// sessions can't stomp each other's launch target. Default: no-op (backends that attach to an
|
||||
/// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it).
|
||||
fn set_launch_command(&mut self, _cmd: Option<String>) {}
|
||||
}
|
||||
|
||||
/// Compositors punktfunk knows how to drive (plan §6).
|
||||
|
||||
@@ -25,7 +25,12 @@ use std::time::{Duration, Instant};
|
||||
/// * `PUNKTFUNK_GAMESCOPE_NODE=<id|auto>` — ATTACH to an already-running gamescope (capture +
|
||||
/// inject, no lifecycle ownership).
|
||||
/// * else — SPAWN a bare headless gamescope sized to the mode, running `PUNKTFUNK_GAMESCOPE_APP`.
|
||||
pub struct GamescopeDisplay;
|
||||
#[derive(Default)]
|
||||
pub struct GamescopeDisplay {
|
||||
/// The resolved per-session launch command (set via [`VirtualDisplay::set_launch_command`]); the
|
||||
/// bare-spawn path runs it instead of reading the process-global `PUNKTFUNK_GAMESCOPE_APP`.
|
||||
cmd: Option<String>,
|
||||
}
|
||||
|
||||
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
|
||||
struct SessionState {
|
||||
@@ -79,7 +84,7 @@ static STEAMOS_TOOK_OVER: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
impl GamescopeDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(GamescopeDisplay)
|
||||
Ok(GamescopeDisplay::default())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +93,10 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
"gamescope"
|
||||
}
|
||||
|
||||
fn set_launch_command(&mut self, cmd: Option<String>) {
|
||||
self.cmd = cmd;
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Host-managed gamescope-session-plus at the CLIENT's mode (the Bazzite path): launch the
|
||||
// full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE
|
||||
@@ -121,7 +130,12 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
});
|
||||
}
|
||||
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
|
||||
let proc = GamescopeProc(spawn(mode.width, mode.height, mode.refresh_hz.max(1))?);
|
||||
let proc = GamescopeProc(spawn(
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz.max(1),
|
||||
self.cmd.as_deref(),
|
||||
)?);
|
||||
// gamescope creates its PipeWire node a moment after start; poll for it (the proc is held
|
||||
// alive meanwhile, and killed if we give up).
|
||||
let node_id = wait_for_node(Duration::from_secs(15)).ok_or_else(|| {
|
||||
@@ -626,9 +640,17 @@ pub const EI_SOCKET_FILE: &str = "/tmp/punktfunk-gamescope-ei";
|
||||
/// stdout/stderr go to `/tmp/punktfunk-gamescope.log`. The app is launched through a tiny shell
|
||||
/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`EI_SOCKET_FILE`]
|
||||
/// so the input injector can connect to gamescope's EIS server from outside.
|
||||
fn spawn(w: u32, h: u32, hz: u32) -> Result<Child> {
|
||||
let app =
|
||||
std::env::var("PUNKTFUNK_GAMESCOPE_APP").unwrap_or_else(|_| "sleep infinity".to_string());
|
||||
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
// A non-empty per-session command (set via `set_launch_command`) wins; else the
|
||||
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
|
||||
// gamescope alive. Each level is taken only if non-empty, so a blank per-session cmd transparently
|
||||
// falls through to the env (matching the pre-fix behaviour).
|
||||
let app = cmd
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "sleep infinity".to_string());
|
||||
let _ = std::fs::remove_file(EI_SOCKET_FILE); // stale socket path from a previous session
|
||||
let mut cmd = Command::new("gamescope");
|
||||
cmd.args(["--backend", "headless"])
|
||||
|
||||
Reference in New Issue
Block a user