diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4ccad414..838de026 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -114,6 +114,21 @@ jobs: - name: Clippy (deny warnings) run: cargo clippy --workspace --all-targets --locked -- -D warnings + # WP19 (rust-safety): the hardened NATIVE-ONLY host — no Moonlight-compat planes, no + # `rusty_enet` (transpiled C ENet), no `rsa`. Kept compiling here so the cfg boundary can't + # rot, and the dependency claim is ASSERTED, not assumed: `cargo tree -i` must find neither + # crate in the native-only graph (it exits non-zero with "nothing depends on" — inverted). + - name: Clippy + tree (native-only host, no gamestream feature) + run: | + cargo clippy -p punktfunk-host --no-default-features --features pyrowave \ + --all-targets --locked -- -D warnings + if cargo tree -p punktfunk-host --no-default-features --features pyrowave \ + --locked -i rusty_enet 2>/dev/null | grep -q rusty_enet; then + echo "native-only build still depends on rusty_enet"; exit 1; fi + if cargo tree -p punktfunk-host --no-default-features --features pyrowave \ + --locked -i rsa 2>/dev/null | grep -q "^rsa"; then + echo "native-only build still depends on rsa"; exit 1; fi + - name: Build run: cargo build --workspace --locked diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c1d22b..641a0a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,27 @@ never touches the port, so a never-paired `--gamestream` host exposes no ENet at the management API's unpair endpoint never persisted (`save_paired` was missing), so an unpair lasted only until the next restart — fixed. `rusty_enet` is now pinned `=0.4.0`. +### GameStream is now a cargo feature (compile-time isolation — packager-visible) + +The Moonlight-compat planes (nvhttp pairing, RTSP, the ENet control stream, `_nvstream` mDNS, +the compat media path) are gated behind a new **`gamestream` cargo feature — default ON**, so +every stock package is behaviorally identical (GameStream stays runtime-opt-in via +`--gamestream` / `PUNKTFUNK_GAMESTREAM`). Building with +`--no-default-features --features pyrowave` produces the **hardened native-only host**: + +- **no `rusty_enet`** — the c2rust-transpiled C ENet stack (158 unsafe sites) is absent from + the binary, provably (`cargo tree -i rusty_enet` finds nothing; CI asserts it); +- **no `rsa`** — the native planes run on the P-256 identity (above), and the legacy-identity + fallback is a pem-only read (rustls/ring serves an existing RSA cert without the crate), so + the accepted Marvin advisory (RUSTSEC-2023-0071) no longer applies to native-only builds; +- ~6,700 lines of Moonlight protocol code gone; `serve --gamestream` (or the env knob) against + such a binary **refuses to start** with a clear error rather than serving less than asked; +- the native-only management API (and its OpenAPI document) has no GameStream PIN endpoints + (`/api/v1/pair`, `/api/v1/pair/pin`); everything else — including the paired-client list and + unpair — is identical, so consoles work unchanged. + +The checked-in `api/openapi.json` remains the default-features document. + ### The identity split — the native planes get their own (P-256) host identity One RSA-2048 identity historically served every plane, because Moonlight mandates RSA and the diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 10cb5ddb..3fe611c0 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -67,7 +67,11 @@ mac_address = "1" if-addrs = "0.13" tokio = { version = "1", features = ["full"] } parking_lot = "0.12" -rsa = "0.9" +# GameStream-only (behind the `gamestream` feature): the Moonlight RSA-2048 identity generator + +# pairing signer (cert.rs, pairing.rs) and the legacy-client-cert leniency verifier (tls.rs). +# The native planes use the P-256 identity (src/identity.rs) and never touch this crate — so a +# native-only build also sheds the accepted Marvin advisory (RUSTSEC-2023-0071, .cargo/audit.toml). +rsa = { version = "0.9", optional = true } sha2 = { version = "0.10", features = ["oid"] } aes = "0.8" aes-gcm = "0.10" @@ -107,10 +111,11 @@ futures-util = "0.3" hmac = "0.12" # GameStream control-stream ENet — a c2rust-style transpile of C ENet (158 unsafe sites; raw # pointer arithmetic, manual allocation). Its port binds only while a pairing exists (rust-safety -# WP0, gamestream/control.rs), but the crate stays a transpiled C stack on the paired-client path: -# pinned EXACTLY so a bump is a deliberate, reviewed act rather than a lockfile refresh, and left -# to the cargo-audit job (audit.yml) to flag advisories against it. -rusty_enet = "=0.4.0" +# WP0, gamestream/control.rs) and the whole crate exists only behind the `gamestream` feature +# (WP19) — a native-only build contains no transpiled C ENet at all. Pinned EXACTLY so a bump is +# a deliberate, reviewed act rather than a lockfile refresh, and left to the cargo-audit job +# (audit.yml) to flag advisories against it. +rusty_enet = { version = "=0.4.0", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" # Management/control-plane REST API + OpenAPI (control pane, M2). `axum_extras` wires @@ -308,7 +313,14 @@ bytemuck = { version = "1.19", features = ["derive"] } [features] # PyroWave ships in every default build (the codec stays strictly opt-in per session — a client # must explicitly prefer CODEC_PYROWAVE; nothing changes for normal HEVC/AV1 sessions). -default = ["pyrowave"] +# GameStream ships in every default build too (still runtime-OPT-IN via --gamestream / +# PUNKTFUNK_GAMESTREAM); building with `--no-default-features --features pyrowave` produces the +# hardened NATIVE-ONLY host — no Moonlight-compat planes, no `rusty_enet` (transpiled C), no +# `rsa` (rust-safety WP19). `serve --gamestream` on such a binary refuses to start, loudly. +default = ["pyrowave", "gamestream"] +# The GameStream/Moonlight-compat planes (nvhttp pairing, RTSP, ENet control, `_nvstream` mDNS) +# at COMPILE time — see the `default` note above for what leaving it off buys. +gamestream = ["dep:rusty_enet", "dep:rsa"] # NVENC hardware encode (Linux CUDA + Windows D3D11). OFF by default; entry points resolved at # RUNTIME from the driver DLL/so, so the same binary starts fine on AMD/Intel boxes. Build the GPU # host with `--features nvenc`. diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index 6b4662d9..045b5147 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -10,12 +10,20 @@ use anyhow::Result; // The shared frame vocabulary lives in `pf-frame`; re-export the pieces host modules still name via // `crate::capture::*` (the capture mechanics that used the rest moved into pf-capture). -pub use pf_frame::{CapturedFrame, OutputFormat, PixelFormat}; +pub use pf_frame::{CapturedFrame, OutputFormat}; +// `PixelFormat` is named through `crate::capture::` only by the GameStream media path; the Linux +// pyrowave-modifier plumbing below uses it in-module. Off both (a native-only Windows build, +// WP19), the re-export would be dead and -D warnings rejects it. +#[cfg(any(target_os = "linux", feature = "gamestream"))] +pub use pf_frame::PixelFormat; // The capturer types + trait + synthetics live in `pf-capture`; re-export them at the old paths. // `capturer_supports_hdr` is deliberately NOT re-exported: on Linux it is only the platform floor, // and a caller reaching for it by that name would silently miss the gamescope arm. The host's // answer is [`capturer_supports_hdr_for`] below. -pub use pf_capture::{capturer_supports_444, Capturer, FastSyntheticCapturer, SyntheticCapturer}; +pub use pf_capture::{capturer_supports_444, Capturer, SyntheticCapturer}; +// Only the GameStream compat media path uses the fast synthetic source (WP19). +#[cfg(feature = "gamestream")] +pub use pf_capture::FastSyntheticCapturer; // `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest_at}` (main.rs subcommands) and // `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules. #[cfg(target_os = "windows")] diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 74c499dc..b5beaa99 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -6,24 +6,45 @@ //! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and //! the media streams follow (see the GameStream host task list / plan). +// The Moonlight-protocol modules exist only behind the `gamestream` cargo feature (rust-safety +// WP19): a native-only build (`--no-default-features --features pyrowave`) contains none of this +// code — and none of its dependencies (`rusty_enet`, `rsa`). What stays unconditional in this +// module is the shared vocabulary history parked here: `AppState`, `Host`, the port/version +// constants, the paired-list persistence, `serve` itself, and `tls` (the mgmt API's TLS lives +// there; only its Moonlight-client-cert leniency is feature-gated). +#[cfg(feature = "gamestream")] pub mod apps; // Platform-neutral wire/negotiation logic + the Linux capture/encode pipeline (non-Linux // builds get a stub `start` inside the module). +#[cfg(feature = "gamestream")] mod audio; +#[cfg(feature = "gamestream")] pub(crate) mod cert; +#[cfg(feature = "gamestream")] mod control; +#[cfg(feature = "gamestream")] mod crypto; +#[cfg(feature = "gamestream")] pub mod gamepad; +#[cfg(feature = "gamestream")] mod input; +#[cfg(feature = "gamestream")] mod mdns; +#[cfg(feature = "gamestream")] mod nvhttp; +#[cfg(feature = "gamestream")] mod pairing; /// Moonlight `SS_PEN`/`SS_TOUCH` → the native pen model / wire touch (design/pen-tablet-input.md §4). +#[cfg(feature = "gamestream")] mod pen; +#[cfg(feature = "gamestream")] mod rtsp; +#[cfg(feature = "gamestream")] mod serverinfo; +#[cfg(feature = "gamestream")] mod stream; pub(crate) mod tls; +#[cfg(feature = "gamestream")] mod video; use anyhow::{Context, Result}; @@ -170,19 +191,28 @@ pub struct LaunchSession { /// Shared control-plane state used as the axum app state. pub struct AppState { pub host: Host, + /// The GameStream (RSA-2048) identity — Moonlight pins it, its pairing hashes bind its X.509 + /// signature bytes. The native planes present `crate::identity` instead (the identity split). + #[cfg(feature = "gamestream")] pub identity: cert::ServerIdentity, + #[cfg(feature = "gamestream")] pub pairing: pairing::Pairing, - /// Pinned (paired) client certificate DERs — the post-pair allow-list. + /// Pinned (paired) client certificate DERs — the post-pair allow-list. Unconditional on + /// purpose: the mgmt list/unpair endpoints stay in every build (a native-only host can still + /// list + revoke pairings made by a GameStream-featured build sharing the config dir). pub paired: std::sync::Mutex>>, /// The ENet control port's lifecycle gate (rust-safety WP0): 47999 is bound only while /// `paired` is non-empty — see [`control::sync`] / [`sync_control`]. + #[cfg(feature = "gamestream")] pub(crate) control_gate: control::Gate, /// The active launch session (set by `/launch`, consumed by RTSP/media). pub launch: std::sync::Mutex>, /// Negotiated video config from RTSP ANNOUNCE (consumed by the stream on PLAY). + #[cfg(feature = "gamestream")] pub stream: std::sync::Mutex>, /// Negotiated audio parameters from RTSP ANNOUNCE (channels/quality/packet duration); /// defaults to stereo when a client never ANNOUNCEs them. + #[cfg(feature = "gamestream")] pub audio_params: std::sync::Mutex, /// True while the video stream thread is running (also its keep-running flag). pub streaming: std::sync::Arc, @@ -210,6 +240,7 @@ pub struct AppState { /// it was opened with the HDR (10-bit PQ) offer — a stream whose negotiated `hdr` differs /// drops the pooled capturer and opens a fresh screencast session at the right depth /// (mirroring the audio capturer's channel-count reuse gate). + #[cfg(feature = "gamestream")] pub video_cap: stream::CapturerSlot, /// Persistent audio capturer, reused across streams when the channel count still matches /// (avoids a PipeWire stream setup per reconnect); drained on reuse so no stale audio is @@ -248,6 +279,7 @@ impl AppState { .unwrap_or_else(|e| e.into_inner()) .take() .is_some(); + #[cfg(feature = "gamestream")] self.stream.lock().unwrap_or_else(|e| e.into_inner()).take(); if was_streaming || was_audio || had_launch { tracing::info!( @@ -274,7 +306,9 @@ impl AppState { /// Fresh control-plane state: no active session; the pairing allow-list is loaded from /// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the - /// mgmt API and the streaming loops. + /// mgmt API and the streaming loops. (The native-only build's variant is below — same state + /// minus the Moonlight identity/pairing machinery.) + #[cfg(feature = "gamestream")] pub fn new( host: Host, identity: cert::ServerIdentity, @@ -299,16 +333,43 @@ impl AppState { stats, } } + + /// The native-only build's [`AppState::new`]: identical control-plane state minus the + /// Moonlight machinery (identity/pairing/control-gate and the RTSP-negotiated stream slots), + /// which does not exist in this build. + #[cfg(not(feature = "gamestream"))] + pub fn new(host: Host, stats: Arc) -> AppState { + AppState { + host, + paired: std::sync::Mutex::new(load_paired()), + launch: std::sync::Mutex::new(None), + streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + quit: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + audio_streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + force_idr: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)), + audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)), + stats, + } + } } /// Reconcile the ENet control port to the paired-client list — bound iff at least one pairing /// exists (rust-safety WP0; see [`control::sync`]). A crate-visible wrapper so callers outside /// `gamestream` (the management API's unpair) can reach it past the private `control` module. /// A no-op unless `serve` armed the gate (`--gamestream`). +#[cfg(feature = "gamestream")] pub(crate) fn sync_control(state: &Arc) -> Result<()> { control::sync(state) } +/// Native-only build: there is no ENet control port to reconcile — the callers (the mgmt unpair) +/// stay uniform and this is the whole implementation. +#[cfg(not(feature = "gamestream"))] +pub(crate) fn sync_control(_state: &Arc) -> Result<()> { + Ok(()) +} + /// Run the host (blocks): mDNS, the nvhttp servers, and the management REST API. /// `native = Some(cfg)` makes this the **unified** host — it also runs the native punktfunk/1 /// QUIC server on `cfg.port` in the same process, sharing one [`crate::native_pairing`] handle with @@ -324,12 +385,28 @@ pub fn serve( native: crate::native::NativeServe, gamestream: bool, ) -> Result<()> { + // WP19: `serve --gamestream` / PUNKTFUNK_GAMESTREAM=1 against a native-only binary is an + // explicit ask this build cannot honor — refuse loudly rather than quietly serve less than + // the operator configured. + #[cfg(not(feature = "gamestream"))] + if gamestream { + anyhow::bail!( + "this punktfunk-host was built WITHOUT the 'gamestream' feature — stock-Moonlight \ + compat is unavailable in this binary. Remove --gamestream / PUNKTFUNK_GAMESTREAM \ + from the configuration, or install a standard (GameStream-featured) build." + ); + } let host = Host::detect()?; - let identity = cert::ServerIdentity::load_or_create().context("host certificate")?; // The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop // (via `AppState`), and the native punktfunk/1 loops (passed to `native::serve`). let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir()); - let state = Arc::new(AppState::new(host, identity, stats.clone())); + #[cfg(feature = "gamestream")] + let state = { + let identity = cert::ServerIdentity::load_or_create().context("host certificate")?; + Arc::new(AppState::new(host, identity, stats.clone())) + }; + #[cfg(not(feature = "gamestream"))] + let state = Arc::new(AppState::new(host, stats.clone())); // The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony // and the management API) always exists. let np = Arc::new( @@ -395,49 +472,58 @@ pub fn serve( gamestream, }); let served: anyhow::Result<()> = if gamestream { - // Unified host: GameStream compat planes + native + mgmt. The `_nvstream` advert is - // fatal on failure when enabled (Moonlight clients can't find the host without it) — - // `--no-mdns` / PUNKTFUNK_MDNS=0 skips it for multicast-dead environments (stock - // Moonlight then needs a manually-added host). - let _advert = if native.mdns { - Some(mdns::advertise(&state.host).context("mDNS advertise")?) - } else { + // WP19: `gamestream` can only be true when the feature is compiled in — serve()'s + // top bails otherwise — so the native-only build's arm is a plain unreachable. + #[cfg(not(feature = "gamestream"))] + { + unreachable!("serve() refuses --gamestream in a native-only build") + } + #[cfg(feature = "gamestream")] + { + // Unified host: GameStream compat planes + native + mgmt. The `_nvstream` advert is + // fatal on failure when enabled (Moonlight clients can't find the host without it) — + // `--no-mdns` / PUNKTFUNK_MDNS=0 skips it for multicast-dead environments (stock + // Moonlight then needs a manually-added host). + let _advert = if native.mdns { + Some(mdns::advertise(&state.host).context("mDNS advertise")?) + } else { + tracing::info!( + "GameStream mDNS advertisement disabled (--no-mdns / PUNKTFUNK_MDNS)" + ); + None + }; + rtsp::spawn(state.clone()).context("start RTSP server")?; + // WP0 (rust-safety): the ENet control port is the host's one pre-auth-reachable + // unsafe surface (`rusty_enet` is a transpiled C stack), so it binds only while a + // pairing exists — a never-paired host on a hostile LAN exposes no ENet at all. + // Pairing is HTTPS on nvhttp and never touches 47999; phase 4 re-syncs the port the + // moment the first client pins, so it is up before that client can `/launch`. + state.control_gate.enable(); + sync_control(&state).context("start ENet control server")?; tracing::info!( - "GameStream mDNS advertisement disabled (--no-mdns / PUNKTFUNK_MDNS)" + port = native.port, + "unified host: GameStream/Moonlight compat + native punktfunk/1 (QUIC)" ); - None - }; - rtsp::spawn(state.clone()).context("start RTSP server")?; - // WP0 (rust-safety): the ENet control port is the host's one pre-auth-reachable - // unsafe surface (`rusty_enet` is a transpiled C stack), so it binds only while a - // pairing exists — a never-paired host on a hostile LAN exposes no ENet at all. - // Pairing is HTTPS on nvhttp and never touches 47999; phase 4 re-syncs the port the - // moment the first client pins, so it is up before that client can `/launch`. - state.control_gate.enable(); - sync_control(&state).context("start ENet control server")?; - tracing::info!( - port = native.port, - "unified host: GameStream/Moonlight compat + native punktfunk/1 (QUIC)" - ); - tokio::try_join!( - nvhttp::run(state.clone()), - crate::mgmt::run( - state.clone(), - mgmt, - Some(np.clone()), - stats.clone(), - gamestream, - native_ident.clone(), - ), - crate::native::serve( - native_opts, - native.mgmt_port, - np, - stats.clone(), - native_ident - ), - ) - .map(|_| ()) + tokio::try_join!( + nvhttp::run(state.clone()), + crate::mgmt::run( + state.clone(), + mgmt, + Some(np.clone()), + stats.clone(), + gamestream, + native_ident.clone(), + ), + crate::native::serve( + native_opts, + native.mgmt_port, + np, + stats.clone(), + native_ident + ), + ) + .map(|_| ()) + } } else { // Secure default: native punktfunk/1 + management API only (no GameStream surface). tracing::info!( @@ -644,13 +730,21 @@ mod session_tests { os_chain: "linux".into(), os_name: "Linux".into(), }; - let identity = cert::ServerIdentity::ephemeral().expect("ephemeral identity"); let stats = crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!( "pf-gs-endsession-{}-{:p}", std::process::id(), &0u8 as *const u8 ))); - AppState::new(host, identity, stats) + // Both build flavors: the session teardown under test is feature-independent. + #[cfg(feature = "gamestream")] + { + let identity = cert::ServerIdentity::ephemeral().expect("ephemeral identity"); + AppState::new(host, identity, stats) + } + #[cfg(not(feature = "gamestream"))] + { + AppState::new(host, stats) + } } /// `end_session` is THE compat-plane teardown: one call must clear the whole session — both @@ -673,22 +767,26 @@ mod session_tests { peer_ip: None, owner_fp: None, }); - *state.stream.lock().unwrap() = Some(stream::StreamConfig { - width: 1920, - height: 1080, - fps: 60, - packet_size: 1024, - bitrate_kbps: 20_000, - codec: crate::encode::Codec::H265, - min_fec: 0, - hdr: false, - slices: 1, // the no-request default — hardware decoders get single-slice AUs - }); + #[cfg(feature = "gamestream")] + { + *state.stream.lock().unwrap() = Some(stream::StreamConfig { + width: 1920, + height: 1080, + fps: 60, + packet_size: 1024, + bitrate_kbps: 20_000, + codec: crate::encode::Codec::H265, + min_fec: 0, + hdr: false, + slices: 1, // the no-request default — hardware decoders get single-slice AUs + }); + } assert!(state.end_session("test"), "video was live"); assert!(!state.streaming.load(Ordering::SeqCst)); assert!(!state.audio_streaming.load(Ordering::SeqCst)); assert!(state.launch.lock().unwrap().is_none()); + #[cfg(feature = "gamestream")] assert!(state.stream.lock().unwrap().is_none()); // Idempotent: a second end (e.g. `/cancel` racing the ENet Disconnect) is a no-op. diff --git a/crates/punktfunk-host/src/gamestream/tls.rs b/crates/punktfunk-host/src/gamestream/tls.rs index 7addaa3a..a5643eed 100644 --- a/crates/punktfunk-host/src/gamestream/tls.rs +++ b/crates/punktfunk-host/src/gamestream/tls.rs @@ -133,13 +133,17 @@ impl ClientCertVerifier for AcceptAnyClientCert { cert: &CertificateDer, dss: &DigitallySignedStruct, ) -> Result { - verify_tls12_signature( + let verdict = verify_tls12_signature( message, cert, dss, &self.provider.signature_verification_algorithms, - ) - .or_else(|e| accept_legacy_moonlight_cert(message, cert, dss, e)) + ); + // The Moonlight-client-cert leniency exists only when the compat planes do (WP19) — + // native clients present webpki-clean certs and never need it. + #[cfg(feature = "gamestream")] + let verdict = verdict.or_else(|e| accept_legacy_moonlight_cert(message, cert, dss, e)); + verdict } fn verify_tls13_signature( @@ -148,13 +152,17 @@ impl ClientCertVerifier for AcceptAnyClientCert { cert: &CertificateDer, dss: &DigitallySignedStruct, ) -> Result { - verify_tls13_signature( + let verdict = verify_tls13_signature( message, cert, dss, &self.provider.signature_verification_algorithms, - ) - .or_else(|e| accept_legacy_moonlight_cert(message, cert, dss, e)) + ); + // The Moonlight-client-cert leniency exists only when the compat planes do (WP19) — + // native clients present webpki-clean certs and never need it. + #[cfg(feature = "gamestream")] + let verdict = verdict.or_else(|e| accept_legacy_moonlight_cert(message, cert, dss, e)); + verdict } fn supported_verify_schemes(&self) -> Vec { @@ -187,6 +195,7 @@ impl ClientCertVerifier for AcceptAnyClientCert { /// signature still fails, and any non-RSA / unsupported scheme falls through to webpki's original /// error `webpki_err`. Moonlight/Sunshine client certs are RSA-2048, so this matches Sunshine's /// leniency without loosening the pinned trust model. +#[cfg(feature = "gamestream")] fn accept_legacy_moonlight_cert( message: &[u8], cert: &CertificateDer, diff --git a/crates/punktfunk-host/src/identity.rs b/crates/punktfunk-host/src/identity.rs index 82c29967..694952c6 100644 --- a/crates/punktfunk-host/src/identity.rs +++ b/crates/punktfunk-host/src/identity.rs @@ -73,16 +73,37 @@ pub fn load_or_adopt(np: &crate::native_pairing::NativePairing) -> Result (Router>, utoipa::openapi::OpenApi) { + let api_v1 = OpenApiRouter::new() + .routes(routes!(host::get_health)) + .routes(routes!(host::get_host_info)) + .routes(routes!(host::list_compositors)) + .routes(routes!(gpu::list_gpus)) + .routes(routes!(gpu::set_gpu_preference)) + .routes(routes!(display::get_display_settings)) + .routes(routes!(display::set_display_settings)) + .routes(routes!(display::get_display_state)) + .routes(routes!(display::get_display_monitors)) + .routes(routes!(display::release_display)) + .routes(routes!(display::set_display_layout)) + .routes(routes!( + display::list_custom_presets, + display::create_custom_preset + )) + .routes(routes!( + display::update_custom_preset, + display::delete_custom_preset + )) + .routes(routes!(host::get_status)) + .routes(routes!(host::get_local_summary)) + .routes(routes!(clients::list_paired_clients)) + .routes(routes!(clients::unpair_client)); + // The GameStream PIN flow exists only when the compat planes do (WP19) — a native-only + // build's API (and its OpenAPI document) simply has no such endpoints. + #[cfg(feature = "gamestream")] + let api_v1 = api_v1 + .routes(routes!(clients::get_pairing_status)) + .routes(routes!(clients::submit_pairing_pin)); + let api_v1 = api_v1 + .routes(routes!(native::get_native_pairing)) + .routes(routes!(native::arm_native_pairing)) + .routes(routes!(native::disarm_native_pairing)) + .routes(routes!(native::list_native_clients)) + .routes(routes!(native::unpair_native_client)) + .routes(routes!(native::list_pending_devices)) + .routes(routes!(native::approve_pending_device)) + .routes(routes!(native::deny_pending_device)) + .routes(routes!(session::stop_session)) + .routes(routes!(session::request_idr)) + .routes(routes!( + session::get_session_settings, + session::set_session_settings + )) + .routes(routes!(session::end_game)) + .routes(routes!(library::get_library)) + .routes(routes!(library::list_library_scanners)) + .routes(routes!(library::set_library_scanner)) + .routes(routes!(library::set_library_entry_hidden)) + .routes(routes!(library::create_custom_game)) + .routes(routes!( + library::update_custom_game, + library::delete_custom_game + )) + .routes(routes!( + library::reconcile_provider_entries, + library::delete_provider_entries + )) + .routes(routes!(library::get_library_art)) + .routes(routes!(stats::stats_capture_start)) + .routes(routes!(stats::stats_capture_stop)) + .routes(routes!(stats::stats_capture_status)) + .routes(routes!(stats::stats_capture_live)) + .routes(routes!(stats::stats_recordings_list)) + .routes(routes!( + stats::stats_recording_get, + stats::stats_recording_delete + )) + .routes(routes!(stats::logs_get)) + .routes(routes!(events::stream_events)) + .routes(routes!(hooks::get_hooks, hooks::set_hooks)) + .routes(routes!(plugins::list_plugins)) + .routes(routes!(plugins::register_plugin, plugins::delete_plugin)) + .routes(routes!(plugins::get_ui_credential)) + .routes(routes!(plugins::ingest_plugin_logs)) + .routes(routes!(store::get_catalog)) + .routes(routes!(store::refresh_catalog)) + .routes(routes!(store::list_installed)) + .routes(routes!(store::install_plugin)) + .routes(routes!(store::uninstall_plugin)) + .routes(routes!(store::list_jobs)) + .routes(routes!(store::get_job)) + .routes(routes!(store::list_sources)) + .routes(routes!(store::put_source, store::delete_source)) + .routes(routes!(store::get_runtime, store::set_runtime)) + .routes(routes!(update::get_update_status)) + .routes(routes!(update::force_update_check)) + .routes(routes!(update::apply_update)); OpenApiRouter::with_openapi(ApiDoc::openapi()) - .nest( - "/api/v1", - OpenApiRouter::new() - .routes(routes!(host::get_health)) - .routes(routes!(host::get_host_info)) - .routes(routes!(host::list_compositors)) - .routes(routes!(gpu::list_gpus)) - .routes(routes!(gpu::set_gpu_preference)) - .routes(routes!(display::get_display_settings)) - .routes(routes!(display::set_display_settings)) - .routes(routes!(display::get_display_state)) - .routes(routes!(display::get_display_monitors)) - .routes(routes!(display::release_display)) - .routes(routes!(display::set_display_layout)) - .routes(routes!( - display::list_custom_presets, - display::create_custom_preset - )) - .routes(routes!( - display::update_custom_preset, - display::delete_custom_preset - )) - .routes(routes!(host::get_status)) - .routes(routes!(host::get_local_summary)) - .routes(routes!(clients::list_paired_clients)) - .routes(routes!(clients::unpair_client)) - .routes(routes!(clients::get_pairing_status)) - .routes(routes!(clients::submit_pairing_pin)) - .routes(routes!(native::get_native_pairing)) - .routes(routes!(native::arm_native_pairing)) - .routes(routes!(native::disarm_native_pairing)) - .routes(routes!(native::list_native_clients)) - .routes(routes!(native::unpair_native_client)) - .routes(routes!(native::list_pending_devices)) - .routes(routes!(native::approve_pending_device)) - .routes(routes!(native::deny_pending_device)) - .routes(routes!(session::stop_session)) - .routes(routes!(session::request_idr)) - .routes(routes!( - session::get_session_settings, - session::set_session_settings - )) - .routes(routes!(session::end_game)) - .routes(routes!(library::get_library)) - .routes(routes!(library::list_library_scanners)) - .routes(routes!(library::set_library_scanner)) - .routes(routes!(library::set_library_entry_hidden)) - .routes(routes!(library::create_custom_game)) - .routes(routes!( - library::update_custom_game, - library::delete_custom_game - )) - .routes(routes!( - library::reconcile_provider_entries, - library::delete_provider_entries - )) - .routes(routes!(library::get_library_art)) - .routes(routes!(stats::stats_capture_start)) - .routes(routes!(stats::stats_capture_stop)) - .routes(routes!(stats::stats_capture_status)) - .routes(routes!(stats::stats_capture_live)) - .routes(routes!(stats::stats_recordings_list)) - .routes(routes!( - stats::stats_recording_get, - stats::stats_recording_delete - )) - .routes(routes!(stats::logs_get)) - .routes(routes!(events::stream_events)) - .routes(routes!(hooks::get_hooks, hooks::set_hooks)) - .routes(routes!(plugins::list_plugins)) - .routes(routes!(plugins::register_plugin, plugins::delete_plugin)) - .routes(routes!(plugins::get_ui_credential)) - .routes(routes!(plugins::ingest_plugin_logs)) - .routes(routes!(store::get_catalog)) - .routes(routes!(store::refresh_catalog)) - .routes(routes!(store::list_installed)) - .routes(routes!(store::install_plugin)) - .routes(routes!(store::uninstall_plugin)) - .routes(routes!(store::list_jobs)) - .routes(routes!(store::get_job)) - .routes(routes!(store::list_sources)) - .routes(routes!(store::put_source, store::delete_source)) - .routes(routes!(store::get_runtime, store::set_runtime)) - .routes(routes!(update::get_update_status)) - .routes(routes!(update::force_update_check)) - .routes(routes!(update::apply_update)), - ) + .nest("/api/v1", api_v1) .split_for_parts() } diff --git a/crates/punktfunk-host/src/mgmt/clients.rs b/crates/punktfunk-host/src/mgmt/clients.rs index b6f21ccb..1990692a 100644 --- a/crates/punktfunk-host/src/mgmt/clients.rs +++ b/crates/punktfunk-host/src/mgmt/clients.rs @@ -19,6 +19,7 @@ pub(crate) struct PairedClient { } /// Pairing-flow status. +#[cfg(feature = "gamestream")] #[derive(Serialize, ToSchema)] pub(crate) struct PairingStatus { /// True while a pairing handshake is parked waiting for the user's PIN. @@ -26,6 +27,7 @@ pub(crate) struct PairingStatus { } /// The PIN Moonlight displays during pairing. +#[cfg(feature = "gamestream")] #[derive(Deserialize, ToSchema)] pub(crate) struct SubmitPin { /// 1–16 ASCII digits (Moonlight shows 4). @@ -136,6 +138,7 @@ pub(crate) async fn unpair_client( /// Pairing-flow status /// /// Poll this to know when to prompt the user for the PIN Moonlight displays. +#[cfg(feature = "gamestream")] #[utoipa::path( get, path = "/pair", @@ -156,6 +159,7 @@ pub(crate) async fn get_pairing_status(State(st): State>) -> Json /// /// Delivers the PIN the Moonlight client is displaying, completing the out-of-band half /// of the pairing handshake. +#[cfg(feature = "gamestream")] #[utoipa::path( post, path = "/pair/pin", diff --git a/crates/punktfunk-host/src/mgmt/host.rs b/crates/punktfunk-host/src/mgmt/host.rs index f72f4b7b..80c1a262 100644 --- a/crates/punktfunk-host/src/mgmt/host.rs +++ b/crates/punktfunk-host/src/mgmt/host.rs @@ -432,6 +432,9 @@ pub(crate) async fn list_compositors() -> Json> { pub(crate) async fn get_status(State(st): State>) -> Json { // GameStream plane (set by RTSP/nvhttp on the compat path). let gs_launch = *st.app.launch.lock().unwrap_or_else(|e| e.into_inner()); + // The RTSP-negotiated stream slot only exists in GameStream-featured builds (WP19); a + // native-only build never has a compat-plane stream to report. + #[cfg(feature = "gamestream")] let gs_stream = *st.app.stream.lock().unwrap_or_else(|e| e.into_inner()); let gs_video = st.app.streaming.load(Ordering::SeqCst); let gs_audio = st.app.audio_streaming.load(Ordering::SeqCst); @@ -454,39 +457,41 @@ pub(crate) async fn get_status(State(st): State>) -> Json = None; + let stream = gs_stream_info.or_else(|| { + native.first().map(|s| StreamInfo { + width: s.width, + height: s.height, + fps: s.fps, + bitrate_kbps: s.bitrate_kbps, + // FEC/packetization are RTSP-negotiated (GameStream only); the native QUIC plane + // shards differently, so these are 0 (not applicable) for a native session. + packet_size: 0, + min_fec: 0, + codec: s.codec.into(), + time_to_first_frame_ms: (s.time_to_first_frame_ms > 0) + .then_some(s.time_to_first_frame_ms), + last_resize_ms: (s.last_resize_ms > 0).then_some(s.last_resize_ms), }) - .or_else(|| { - native.first().map(|s| StreamInfo { - width: s.width, - height: s.height, - fps: s.fps, - bitrate_kbps: s.bitrate_kbps, - // FEC/packetization are RTSP-negotiated (GameStream only); the native QUIC plane - // shards differently, so these are 0 (not applicable) for a native session. - packet_size: 0, - min_fec: 0, - codec: s.codec.into(), - time_to_first_frame_ms: (s.time_to_first_frame_ms > 0) - .then_some(s.time_to_first_frame_ms), - last_resize_ms: (s.last_resize_ms > 0).then_some(s.last_resize_ms), - }) - }); + }); Json(RuntimeStatus { video_streaming: gs_video || !native.is_empty(), audio_streaming: gs_audio || !native.is_empty(), - pin_pending: st.app.pairing.pin.awaiting_pin(), + pin_pending: gs_pin_pending(&st), paired_clients: st .app .paired @@ -575,7 +580,7 @@ pub(crate) async fn get_local_summary(State(st): State>) -> Json< .unwrap_or_else(|e| e.into_inner()) .len() as u32, native_paired_clients, - pin_pending: st.app.pairing.pin.awaiting_pin(), + pin_pending: gs_pin_pending(&st), pending_approvals, kept_displays: crate::vdisplay::registry::snapshot() .displays @@ -594,3 +599,15 @@ pub(crate) async fn get_local_summary(State(st): State>) -> Json< .collect(), }) } + +/// Whether the GameStream PIN flow is parked waiting for a PIN — `false` by construction in a +/// native-only build (WP19), where the pairing machinery does not exist. The API field stays so +/// the schema (and every console) is identical across build flavors. +#[cfg(feature = "gamestream")] +fn gs_pin_pending(st: &Arc) -> bool { + st.app.pairing.pin.awaiting_pin() +} +#[cfg(not(feature = "gamestream"))] +fn gs_pin_pending(_st: &Arc) -> bool { + false +} diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 176c842a..81800a71 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -3,8 +3,10 @@ use super::*; use crate::encode::Codec; +#[cfg(feature = "gamestream")] +use crate::gamestream::cert::ServerIdentity; use crate::gamestream::tls::{PeerAddr, PeerCertFingerprint}; -use crate::gamestream::{cert::ServerIdentity, Host, LaunchSession, HTTPS_PORT, HTTP_PORT}; +use crate::gamestream::{Host, LaunchSession, HTTPS_PORT, HTTP_PORT}; use axum::body::Body; use axum::http::StatusCode; use http_body_util::BodyExt; @@ -32,8 +34,15 @@ fn test_state() -> Arc { os_chain: "linux/arch/steamos".into(), os_name: "SteamOS".into(), }; - let identity = ServerIdentity::ephemeral().expect("ephemeral identity"); - Arc::new(AppState::new(host, identity, test_stats())) + #[cfg(feature = "gamestream")] + { + let identity = ServerIdentity::ephemeral().expect("ephemeral identity"); + Arc::new(AppState::new(host, identity, test_stats())) + } + #[cfg(not(feature = "gamestream"))] + { + Arc::new(AppState::new(host, test_stats())) + } } // The mgmt API now always requires auth, so the router always has a token. A test that passes @@ -638,10 +647,10 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() { assert_eq!(send(&app, req).await.0, StatusCode::NO_CONTENT); // The carve-outs answer 403 (authenticated but not authorized), not 401. - for (method, path) in [ + #[cfg_attr(not(feature = "gamestream"), allow(unused_mut))] + let mut carveouts = vec![ (Method::GET, "/api/v1/hooks"), (Method::PUT, "/api/v1/hooks"), - (Method::GET, "/api/v1/pair"), (Method::POST, "/api/v1/native/pair/arm"), (Method::GET, "/api/v1/native/pending"), (Method::DELETE, "/api/v1/clients/aabbcc"), @@ -652,7 +661,11 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() { (Method::POST, "/api/v1/store/uninstall"), (Method::POST, "/api/v1/store/runtime"), (Method::PUT, "/api/v1/store/sources/evil"), - ] { + ]; + // The PIN route only exists in GameStream-featured builds (WP19). + #[cfg(feature = "gamestream")] + carveouts.push((Method::GET, "/api/v1/pair")); + for (method, path) in carveouts { let (status, body) = send(&app, plugin_req(method.clone(), path)).await; assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}"); assert!(body["error"].as_str().unwrap().contains("plugin token")); @@ -789,8 +802,10 @@ async fn paired_clients_list_and_unpair() { let state = test_state(); let app = test_app(state.clone(), None); - // Pin the host's own cert DER as a stand-in client. - let (_, pem) = x509_parser::pem::parse_x509_pem(state.identity.cert_pem.as_bytes()).unwrap(); + // Pin a throwaway cert DER as a stand-in client (the native ephemeral identity — CN + // "punktfunk" — so this works in both build flavors; WP19). + let stand_in = crate::identity::ephemeral().unwrap(); + let (_, pem) = x509_parser::pem::parse_x509_pem(stand_in.cert_pem.as_bytes()).unwrap(); let der = pem.contents.clone(); let fingerprint = hex::encode(Sha256::digest(&der)); // Isolate from any real paired store on the dev box: AppState::new loads @@ -837,6 +852,7 @@ async fn paired_clients_list_and_unpair() { ); } +#[cfg(feature = "gamestream")] #[tokio::test] async fn submit_pin_validates_and_requires_pending_pairing() { let app = test_app(test_state(), None); @@ -1292,6 +1308,15 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() { .join("/") } + // The GameStream PIN routes exist only in gamestream-featured builds (WP19) — drop their + // rows from the expectation when the feature is off (`cfg!` keeps both sides type-checked). + let expected: Vec<(&str, &str, bool, bool)> = EXPECTED + .iter() + .copied() + .filter(|(_, p, _, _)| { + cfg!(feature = "gamestream") || !matches!(*p, "/api/v1/pair" | "/api/v1/pair/pin") + }) + .collect(); let doc: serde_json::Value = serde_json::from_str(&openapi_json()).unwrap(); let mut live: Vec<(String, String)> = Vec::new(); for (path, ops) in doc["paths"].as_object().unwrap() { @@ -1305,7 +1330,7 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() { // 1. Every LIVE route has a classification row. A new route fails here until it gets one. for (method, path) in &live { assert!( - EXPECTED + expected .iter() .any(|(m, p, _, _)| m == method && p == path), "route {method} {path} has no lane classification — add a row to EXPECTED in this test \ @@ -1314,14 +1339,14 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() { ); } // 2. No STALE rows: a removed route must not leave a classification behind claiming coverage. - for (method, path, _, _) in EXPECTED { + for (method, path, _, _) in &expected { assert!( live.iter().any(|(m, p)| m == method && p == path), "EXPECTED lists {method} {path}, which is not in the live route table — remove the row" ); } // 3. The gates agree with the classification, on both lanes. - for (method, path, plugin_ok, cert_ok) in EXPECTED { + for (method, path, plugin_ok, cert_ok) in &expected { let m = Method::from_bytes(method.as_bytes()).unwrap(); let concrete = concrete(path); assert_eq!( @@ -1377,7 +1402,10 @@ fn plugin_allowlist_matches_whole_segments_only() { } /// The OpenAPI document lists every route with a unique operationId (codegen relies -/// on both), and the checked-in copy is current. +/// on both), and the checked-in copy is current. Feature-gated: `api/openapi.json` IS the +/// default-features document — a native-only build's spec (no PIN routes) is intentionally +/// different and not checked in (WP19). +#[cfg(feature = "gamestream")] #[test] fn openapi_document_is_complete_and_checked_in() { let json = openapi_json(); @@ -1765,7 +1793,10 @@ async fn gpu_endpoints_list_and_validate() { async fn logs_endpoint_pages_by_cursor() { let app = test_app(test_state(), None); - // The ring is a process-wide singleton — start from wherever its cursor currently is. + // The ring is a process-wide singleton — start from wherever its cursor currently is. Other + // tests in this binary legitimately log (e.g. the identity tests' adopt/migrate lines), so a + // page can carry THEIR entries interleaved with ours: assert on OUR markers within the page, + // never on the page being exactly ours (that raced once and failed the suite). let (s, json) = send(&app, get_req("/api/v1/logs")).await; assert_eq!(s, StatusCode::OK); let start = json["next"].as_u64().unwrap(); @@ -1777,18 +1808,32 @@ async fn logs_endpoint_pages_by_cursor() { let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={start}"))).await; assert_eq!(s, StatusCode::OK); let entries = json["entries"].as_array().unwrap(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0]["msg"], "first"); - assert_eq!(entries[0]["level"], "WARN"); - assert_eq!(json["next"].as_u64().unwrap(), start + 2); + let ours: Vec<_> = entries + .iter() + .filter(|e| e["target"] == "mgmt::tests") + .collect(); + assert_eq!(ours.len(), 2, "both markers on the page, in order"); + assert_eq!(ours[0]["msg"], "first"); + assert_eq!(ours[0]["level"], "WARN"); + assert_eq!(ours[1]["msg"], "second"); + let next = json["next"].as_u64().unwrap(); + assert_eq!( + next, + start + entries.len() as u64, + "the cursor advances by exactly the entries served" + ); assert_eq!(json["dropped"], false); - // Nothing newer → empty page, cursor unchanged. - let after = start + 2; - let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={after}"))).await; + // Nothing newer than the served cursor at the time we ask — the page may again carry a + // concurrent test's fresh entries, but never our (already-served) markers a second time. + let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={next}"))).await; assert_eq!(s, StatusCode::OK); - assert!(json["entries"].as_array().unwrap().is_empty()); - assert_eq!(json["next"].as_u64().unwrap(), after); + assert!(json["entries"] + .as_array() + .unwrap() + .iter() + .all(|e| e["target"] != "mgmt::tests")); + assert!(json["next"].as_u64().unwrap() >= next); } // ------------------------------------------------------------------ events (SSE)