diff --git a/crates/punktfunk-client-linux/src/app.rs b/crates/punktfunk-client-linux/src/app.rs index ec60822..5d64da0 100644 --- a/crates/punktfunk-client-linux/src/app.rs +++ b/crates/punktfunk-client-linux/src/app.rs @@ -286,6 +286,7 @@ fn speed_test(app: Rc, req: ConnectRequest) { CompositorPref::Auto, GamepadPref::Auto, 0, + None, // launch: speed-test probe connect, no game pin, Some(identity), std::time::Duration::from_secs(15), diff --git a/crates/punktfunk-client-linux/src/session.rs b/crates/punktfunk-client-linux/src/session.rs index 1e7fb25..32d2788 100644 --- a/crates/punktfunk-client-linux/src/session.rs +++ b/crates/punktfunk-client-linux/src/session.rs @@ -90,6 +90,7 @@ fn pump( params.compositor, params.gamepad, params.bitrate_kbps, + None, // launch: the Linux client has no library picker yet params.pin, Some(params.identity), Duration::from_secs(15), diff --git a/crates/punktfunk-client-rs/src/main.rs b/crates/punktfunk-client-rs/src/main.rs index 577d780..7382918 100644 --- a/crates/punktfunk-client-rs/src/main.rs +++ b/crates/punktfunk-client-rs/src/main.rs @@ -76,6 +76,9 @@ struct Args { gamepad: GamepadPref, /// `--bitrate KBPS` — request this encoder bitrate (kilobits/s); 0 = host default. bitrate_kbps: u32, + /// `--launch ID` — ask the host to launch a library title in this session (a store-qualified + /// id from the host's `GET /api/v1/library`, e.g. `steam:570`). Host resolves it; `None` = none. + launch: Option, /// `--speed-test KBPS:MS` — after the stream starts, ask the host for a `MS`-millisecond /// bandwidth probe burst at `KBPS`, then report measured throughput + loss. speed_test: Option<(u32, u32)>, @@ -194,6 +197,7 @@ fn parse_args() -> Args { compositor, gamepad, bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0), + launch: get("--launch").map(str::to_string), speed_test: get("--speed-test").and_then(|s| { let (kbps, ms) = s.split_once(':')?; Some((kbps.parse().ok()?, ms.parse().ok()?)) @@ -374,6 +378,8 @@ async fn session(args: Args) -> Result<()> { // `--name` (also the pairing label) — shown in the host's pending-approval list when // this client knocks on a pairing-required host. name: Some(args.name.clone()), + // `--launch ID` — host resolves it against its own library and runs it this session. + launch: args.launch.clone(), } .encode(), ) diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 73525ae..5c026df 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -796,6 +796,53 @@ pub unsafe extern "C" fn punktfunk_connect_ex3( client_cert_pem: *const std::os::raw::c_char, client_key_pem: *const std::os::raw::c_char, timeout_ms: u32, +) -> *mut PunktfunkConnection { + // Delegate to the launch-aware variant with no game requested (the host's default session). + unsafe { + punktfunk_connect_ex4( + host, + port, + width, + height, + refresh_hz, + compositor, + gamepad, + bitrate_kbps, + std::ptr::null(), + pin_sha256, + observed_sha256_out, + client_cert_pem, + client_key_pem, + timeout_ms, + ) + } +} + +/// Like [`punktfunk_connect_ex3`], but additionally asks the host to launch a library title in +/// this session. `launch_id` is a store-qualified [`crate::library`-style] id as returned by the +/// host's `GET /api/v1/library` (`steam:` / `custom:`); the host resolves it against +/// its OWN library and runs the matching recipe — the client never sends a raw command. `NULL` +/// (or an empty / unknown id) ⇒ the host's default session, no game launched. +/// +/// # Safety +/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connect_ex4( + host: *const std::os::raw::c_char, + port: u16, + width: u32, + height: u32, + refresh_hz: u32, + compositor: u32, + gamepad: u32, + bitrate_kbps: u32, + launch_id: *const std::os::raw::c_char, + pin_sha256: *const u8, + observed_sha256_out: *mut u8, + client_cert_pem: *const std::os::raw::c_char, + client_key_pem: *const std::os::raw::c_char, + timeout_ms: u32, ) -> *mut PunktfunkConnection { let r = std::panic::catch_unwind(AssertUnwindSafe(|| { if host.is_null() { @@ -805,6 +852,11 @@ pub unsafe extern "C" fn punktfunk_connect_ex3( Ok(s) => s, Err(_) => return std::ptr::null_mut(), }; + // A bad-UTF-8 launch id is non-fatal — treat it as "no game" rather than failing connect. + let launch = match unsafe { opt_cstr(launch_id) } { + Ok(Some(s)) if !s.is_empty() => Some(s.to_string()), + _ => None, + }; let mode = crate::config::Mode { width, height, @@ -839,6 +891,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex3( pref, gamepad, bitrate_kbps, + launch, pin, identity, std::time::Duration::from_millis(timeout_ms as u64), diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 20d3601..e9010ab 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -172,6 +172,7 @@ impl NativeClient { compositor: CompositorPref, gamepad: GamepadPref, bitrate_kbps: u32, + launch: Option, pin: Option<[u8; 32]>, identity: Option<(String, String)>, timeout: Duration, @@ -214,6 +215,7 @@ impl NativeClient { compositor, gamepad, bitrate_kbps, + launch, pin, identity, frame_tx, @@ -526,6 +528,7 @@ struct WorkerArgs { compositor: CompositorPref, gamepad: GamepadPref, bitrate_kbps: u32, + launch: Option, pin: Option<[u8; 32]>, identity: Option<(String, String)>, frame_tx: SyncSender, @@ -552,6 +555,7 @@ async fn worker_main(args: WorkerArgs) { compositor, gamepad, bitrate_kbps, + launch, pin, identity, frame_tx, @@ -608,6 +612,8 @@ async fn worker_main(args: WorkerArgs) { // No device name yet: the connect ABI has no name parameter (pairing does). The // host falls back to a fingerprint-derived label in its pending-approval list. name: None, + // Library id to launch this session, if the embedder asked for one. + launch: launch.clone(), } .encode(), ) diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index 300247c..153e2b8 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -62,12 +62,24 @@ pub struct Hello { /// Appended to the wire form as `len u8 || UTF-8` (≤ [`HELLO_NAME_MAX`] bytes) — omitted by /// older clients (decodes to `None`; the host falls back to a fingerprint-derived label). pub name: Option, + /// Library entry the client wants this session to launch (the store-qualified `GameEntry.id`, + /// e.g. `steam:570` / `custom:abc123`). The host resolves it against ITS OWN library and runs + /// the matching launch recipe in the session — the client never sends a raw command, so a + /// remote peer can't inject one. `None` = no game requested (the host's default session). + /// Appended after `name` as `len u8 || UTF-8` (≤ [`HELLO_LAUNCH_MAX`] bytes); when present but + /// `name` is absent, a zero-length name placeholder precedes it so the offset stays + /// deterministic. Omitted by older clients (decodes to `None`). + pub launch: Option, } /// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on /// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway). pub const HELLO_NAME_MAX: usize = 64; +/// Longest library id carried in a [`Hello::launch`] (bytes of UTF-8). Ids are short +/// (`steam:` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field. +pub const HELLO_LAUNCH_MAX: usize = 128; + /// `host → client`: the complete session offer. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Welcome { @@ -473,6 +485,19 @@ pub mod pake { } } +/// Truncate `s` to at most `max` bytes on a UTF-8 char boundary (so a multi-byte char straddling +/// the cap is dropped whole, never split). Shared by Hello's length-prefixed name/launch fields. +fn truncate_to(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut cut = max; + while !s.is_char_boundary(cut) { + cut -= 1; + } + &s[..cut] +} + impl Hello { pub fn encode(&self) -> Vec { let mut b = Vec::with_capacity(22); @@ -484,21 +509,23 @@ impl Hello { b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26 - if let Some(name) = &self.name { - // Appended at offset 26: len u8 || UTF-8. This is the LAST trailing field — `None` - // emits nothing (so a no-name Hello is byte-identical to the bitrate-era form), which - // means a *future* field can't simply follow `name` at a fixed offset; it would need - // its own presence flag. Truncate to a char boundary within HELLO_NAME_MAX. - let mut n = name.as_str(); - while n.len() > HELLO_NAME_MAX { - let mut cut = HELLO_NAME_MAX; - while !n.is_char_boundary(cut) { - cut -= 1; - } - n = &n[..cut]; + // name at offset 26: len u8 || UTF-8. Omitted when `None` *and* there is no later field — + // so a Hello with neither name nor launch stays byte-identical to the bitrate-era form + // (26 bytes). When `launch` is present we must still emit name's length byte (0 for None) + // so `launch` lands at a deterministic offset. + match (&self.name, &self.launch) { + (None, None) => {} + (name, _) => { + let n = truncate_to(name.as_deref().unwrap_or(""), HELLO_NAME_MAX); + b.push(n.len() as u8); + b.extend_from_slice(n.as_bytes()); } - b.push(n.len() as u8); - b.extend_from_slice(n.as_bytes()); + } + // launch after name: len u8 || UTF-8. Last trailing field. + if let Some(launch) = &self.launch { + let l = truncate_to(launch, HELLO_LAUNCH_MAX); + b.push(l.len() as u8); + b.extend_from_slice(l.as_bytes()); } b } @@ -540,6 +567,19 @@ impl Hello { .and_then(|s| std::str::from_utf8(s).ok()) .map(String::from) }), + // Optional trailing launch id, positioned right after name's `len u8 || UTF-8` block. + // The raw name-length byte (even when oversized/zero) determines where launch starts, + // so a corrupt name never panics — it just pushes the launch offset out of range → None. + launch: b.get(26).and_then(|&name_len| { + let off = 27 + name_len as usize; // start of launch's length byte + let len = *b.get(off)? as usize; + if len == 0 || len > HELLO_LAUNCH_MAX { + return None; + } + b.get(off + 1..off + 1 + len) + .and_then(|s| std::str::from_utf8(s).ok()) + .map(String::from) + }), }) } } @@ -1495,6 +1535,7 @@ mod tests { gamepad: GamepadPref::DualSense, bitrate_kbps: 25_000, name: Some("Test Device".into()), + launch: Some("steam:570".into()), }; assert_eq!(Hello::decode(&h.encode()).unwrap(), h); let s = Start { @@ -1560,6 +1601,7 @@ mod tests { gamepad: GamepadPref::DualSense, bitrate_kbps: 80_000, name: None, + launch: None, }; let enc = h.encode(); assert_eq!(enc.len(), 26); @@ -1629,6 +1671,7 @@ mod tests { gamepad: GamepadPref::Auto, bitrate_kbps: 0, name: Some("Enrico's MacBook".into()), + launch: None, }; let enc = base.encode(); assert_eq!( @@ -1661,6 +1704,60 @@ mod tests { assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None); } + #[test] + fn hello_launch_roundtrip_and_back_compat() { + let base = Hello { + abi_version: 2, + mode: Mode { + width: 1920, + height: 1080, + refresh_hz: 60, + }, + compositor: CompositorPref::Auto, + gamepad: GamepadPref::Auto, + bitrate_kbps: 0, + name: None, + launch: None, + }; + // launch alone (no name): a zero-length name placeholder keeps the offset deterministic. + let with_launch = Hello { + launch: Some("steam:570".into()), + ..base.clone() + }; + assert_eq!(Hello::decode(&with_launch.encode()).unwrap(), with_launch); + // launch + name together. + let both = Hello { + name: Some("Enrico's Mac".into()), + launch: Some("custom:abc123".into()), + ..base.clone() + }; + assert_eq!(Hello::decode(&both.encode()).unwrap(), both); + // name but no launch (a name-era client): launch decodes None. + let name_only = Hello { + name: Some("Enrico's Mac".into()), + ..base.clone() + }; + assert_eq!(Hello::decode(&name_only.encode()).unwrap().launch, None); + // Neither field → still the 26-byte bitrate-era form (no launch placeholder emitted). + assert_eq!(base.encode().len(), 26); + assert_eq!(Hello::decode(&base.encode()).unwrap().launch, None); + // A bitrate-era (26-byte) peer reading a launch-bearing Hello ignores it. + assert_eq!( + Hello::decode(&with_launch.encode()[..26]).unwrap().launch, + None + ); + // Over-long ids truncate on a char boundary within HELLO_LAUNCH_MAX. + let long = Hello { + launch: Some(format!("{}ü", "x".repeat(HELLO_LAUNCH_MAX - 1))), + ..base.clone() + }; + let dec = Hello::decode(&long.encode()) + .unwrap() + .launch + .expect("present"); + assert!(dec.len() <= HELLO_LAUNCH_MAX && dec.starts_with('x')); + } + #[test] fn reconfigure_roundtrip() { let rq = Reconfigure { @@ -1784,6 +1881,7 @@ mod tests { gamepad: GamepadPref::Auto, bitrate_kbps: 0, name: None, + launch: None, } .encode(); assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair"); diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index b909f1a..75f2a11 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -353,6 +353,35 @@ pub fn delete_custom(id: &str) -> Result { // Unified library // --------------------------------------------------------------------------------------- +/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell +/// command the host should run for it — looked up in the host's OWN library so a client can only +/// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a +/// malformed Steam appid. +/// +/// - `steam_appid` → `steam steam://rungameid/` (appid validated as digits, so the only +/// client-controlled part of the command is a number). +/// - `command` → the stored command verbatim. This string comes from the host's own custom store +/// (added by the host operator via the admin UI), never from the client, so it is trusted. +pub fn launch_command(id: &str) -> Option { + let spec = all_games().into_iter().find(|g| g.id == id)?.launch?; + command_for(&spec) +} + +/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of +/// [`launch_command`], split out so the appid-validation can be tested without a Steam install). +fn command_for(spec: &LaunchSpec) -> Option { + match spec.kind.as_str() { + "steam_appid" => { + // Only digits — the appid is the sole client-influenced part of the command. + (!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit())) + .then(|| format!("steam steam://rungameid/{}", spec.value)) + } + // Trusted: the command comes from the host's own custom store, never the client. + "command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()), + _ => None, + } +} + /// The full library: every store's titles merged + the custom entries, sorted by title. pub fn all_games() -> Vec { let mut games = SteamProvider.list(); @@ -420,6 +449,45 @@ mod tests { assert!(art.header.unwrap().ends_with("/570/header.jpg")); } + #[test] + fn launch_command_resolves_and_guards() { + let steam = LaunchSpec { + kind: "steam_appid".into(), + value: "570".into(), + }; + assert_eq!( + command_for(&steam).as_deref(), + Some("steam steam://rungameid/570") + ); + // A non-numeric "appid" (e.g. a client trying to inject) is rejected, never interpolated. + let evil = LaunchSpec { + kind: "steam_appid".into(), + value: "570; rm -rf ~".into(), + }; + assert_eq!(command_for(&evil), None); + // Custom commands (from the host's own store) pass through verbatim. + let custom = LaunchSpec { + kind: "command".into(), + value: "dolphin-emu --batch".into(), + }; + assert_eq!(command_for(&custom).as_deref(), Some("dolphin-emu --batch")); + // Empty / unknown kinds → no command. + assert_eq!( + command_for(&LaunchSpec { + kind: "command".into(), + value: " ".into() + }), + None + ); + assert_eq!( + command_for(&LaunchSpec { + kind: "wat".into(), + value: "x".into() + }), + None + ); + } + #[test] fn custom_entry_maps_to_game_entry() { let g: GameEntry = CustomEntry { diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 00f1c85..4633081 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -520,6 +520,24 @@ async fn serve_session( M3Source::Synthetic => None, }; + // 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. + if let Some(id) = hello.launch.as_deref() { + match crate::library::launch_command(id) { + Some(cmd) => { + tracing::info!(launch_id = id, command = %cmd, "launching library title"); + std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd); + } + None => tracing::warn!( + launch_id = id, + "client requested a launch id not in this host's library — ignoring" + ), + } + } + // Resolve the client's gamepad-backend preference (pure env/cfg check — no probing // needed; the actual pads are created lazily by the input thread). let gamepad = resolve_gamepad(hello.gamepad); @@ -2389,6 +2407,7 @@ mod tests { CompositorPref::Auto, GamepadPref::Auto, 0, + None, // launch None, Some((cert.clone(), key.clone())), timeout @@ -2419,6 +2438,7 @@ mod tests { CompositorPref::Auto, GamepadPref::Auto, 0, + None, // launch None, Some((cert, key)), timeout, @@ -2478,6 +2498,7 @@ mod tests { CompositorPref::Auto, GamepadPref::Auto, 0, + None, // launch None, None, timeout @@ -2503,6 +2524,7 @@ mod tests { CompositorPref::Auto, GamepadPref::Auto, 0, + None, // launch Some(host_fp), Some((cert.clone(), key.clone())), timeout, diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 7162288..7e24de7 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -152,6 +152,12 @@ #define HELLO_NAME_MAX 64 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Longest library id carried in a [`Hello::launch`] (bytes of UTF-8). Ids are short +// (`steam:` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field. +#define HELLO_LAUNCH_MAX 128 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`Reconfigure`] (first byte after the magic). #define MSG_RECONFIGURE 1 @@ -648,6 +654,31 @@ PunktfunkConnection *punktfunk_connect_ex3(const char *host, uint32_t timeout_ms); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Like [`punktfunk_connect_ex3`], but additionally asks the host to launch a library title in +// this session. `launch_id` is a store-qualified [`crate::library`-style] id as returned by the +// host's `GET /api/v1/library` (`steam:` / `custom:`); the host resolves it against +// its OWN library and runs the matching recipe — the client never sends a raw command. `NULL` +// (or an empty / unknown id) ⇒ the host's default session, no game launched. +// +// # Safety +// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string. +PunktfunkConnection *punktfunk_connect_ex4(const char *host, + uint16_t port, + uint32_t width, + uint32_t height, + uint32_t refresh_hz, + uint32_t compositor, + uint32_t gamepad, + uint32_t bitrate_kbps, + const char *launch_id, + const uint8_t *pin_sha256, + uint8_t *observed_sha256_out, + const char *client_cert_pem, + const char *client_key_pem, + uint32_t timeout_ms); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Generate a persistent client identity: a self-signed certificate + private key, both // PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both