feat(host): GameStream is now a cargo feature — WP19, compile-time isolation
A new 'gamestream' feature (default ON — every stock package is behaviorally identical, and GameStream stays runtime-opt-in via --gamestream / PUNKTFUNK_GAMESTREAM) gates the whole Moonlight-protocol surface: control (the ENet plane), rtsp, nvhttp, pairing, serverinfo, the _nvstream mDNS advert, the compat media path (stream/video/audio), pen/gamepad/input decode, apps, crypto, cert (the RSA identity), and tls's Moonlight-client-cert leniency. AppState keeps the shared vocabulary unconditional and cfg-gates the Moonlight-only fields; the mgmt API's PIN endpoints (routes, handlers, OpenAPI entries, lane classifications, tests) exist only under the feature. Building --no-default-features --features pyrowave yields the hardened NATIVE-ONLY host: no rusty_enet (the c2rust-transpiled C ENet stack, 158 unsafe sites) and no rsa (the identity split's legacy fallback became a pem-only read — rustls/ring serves an existing RSA cert without the crate — so the accepted Marvin advisory no longer applies to native-only builds). Both claims are ASSERTED, not assumed: a new CI leg keeps the native-only flavor clippy-clean and fails if cargo tree finds either crate in its graph. serve --gamestream (or the env knob) against such a binary refuses to start with a clear error rather than serving less than the operator configured. En route: the logs-paging test assumed a quiet process-global log ring between its cursors and raced other tests' legitimate log lines (the identity tests added new emitters) — it now asserts on its own markers within the page. Gates: Linux amd64 — BOTH flavors clippy --all-targets -D warnings clean; default tests identity 3/3, mgmt 37/37, gamestream 59/59; native-only tests identity 3/3, mgmt 35/35, residue 4/4; rusty_enet+rsa absent native-only, present default. .133 Windows — both flavors clippy clean (clean-first, sentinel-checked), tree claims hold, and the WP0 port-lifecycle functional gate PASSES on the default build.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<Vec<Vec<u8>>>,
|
||||
/// 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<Option<LaunchSession>>,
|
||||
/// Negotiated video config from RTSP ANNOUNCE (consumed by the stream on PLAY).
|
||||
#[cfg(feature = "gamestream")]
|
||||
pub stream: std::sync::Mutex<Option<stream::StreamConfig>>,
|
||||
/// 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<audio::AudioParams>,
|
||||
/// True while the video stream thread is running (also its keep-running flag).
|
||||
pub streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
@@ -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<crate::stats_recorder::StatsRecorder>) -> 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<AppState>) -> 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<AppState>) -> 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.
|
||||
|
||||
@@ -133,13 +133,17 @@ impl ClientCertVerifier for AcceptAnyClientCert {
|
||||
cert: &CertificateDer,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
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<HandshakeSignatureValid, rustls::Error> {
|
||||
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<SignatureScheme> {
|
||||
@@ -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,
|
||||
|
||||
@@ -73,16 +73,37 @@ pub fn load_or_adopt(np: &crate::native_pairing::NativePairing) -> Result<Native
|
||||
}
|
||||
// Live native pairings pinned the legacy RSA cert — switching identities now would strand
|
||||
// every one of them (the pin is the SHA-256 of the leaf DER). Keep serving what they pinned.
|
||||
tracing::info!(
|
||||
"native identity: keeping the legacy RSA cert — paired native clients pinned it. To \
|
||||
migrate to the P-256 identity: unpair ALL native clients, restart the host, re-pair."
|
||||
// A pem-only read on purpose (WP19): rustls/ring can SERVE an existing RSA cert without the
|
||||
// `rsa` crate, so the native-only build never links it — the crate exists solely behind the
|
||||
// `gamestream` feature (generation + the pairing signer).
|
||||
if let (Ok(c), Ok(k)) = (
|
||||
fs::read_to_string(dir.join("cert.pem")),
|
||||
fs::read_to_string(dir.join("key.pem")),
|
||||
) {
|
||||
if !c.trim().is_empty() && !k.trim().is_empty() {
|
||||
tracing::info!(
|
||||
"native identity: keeping the legacy RSA cert — paired native clients pinned it. \
|
||||
To migrate to the P-256 identity: unpair ALL native clients, restart the host, \
|
||||
re-pair."
|
||||
);
|
||||
return Ok(NativeIdentity {
|
||||
cert_pem: c,
|
||||
key_pem: k,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Degenerate: native pairings exist but the cert they pinned is gone from disk — those
|
||||
// clients are stranded whatever we serve, so mint the P-256 identity and say so.
|
||||
tracing::warn!(
|
||||
"native identity: paired native clients exist but the legacy cert.pem/key.pem they \
|
||||
pinned is missing — minting the P-256 identity; those clients must re-pair"
|
||||
);
|
||||
let legacy = crate::gamestream::cert::ServerIdentity::load_or_create()
|
||||
.context("load legacy host identity for the native planes")?;
|
||||
Ok(NativeIdentity {
|
||||
cert_pem: legacy.cert_pem,
|
||||
key_pem: legacy.key_pem,
|
||||
})
|
||||
let (cert_pem, key_pem) = generate()?;
|
||||
pf_paths::write_secret_file(&key_path, key_pem.as_bytes())
|
||||
.with_context(|| format!("write {}", key_path.display()))?;
|
||||
pf_paths::write_secret_file(&cert_path, cert_pem.as_bytes())
|
||||
.with_context(|| format!("write {}", cert_path.display()))?;
|
||||
Ok(NativeIdentity { cert_pem, key_pem })
|
||||
}
|
||||
|
||||
/// Throwaway in-memory identity — nothing touches the config dir (tests).
|
||||
@@ -197,11 +218,30 @@ mod tests {
|
||||
let _env = EnvGuard::set(tmp.path());
|
||||
let np = empty_store(tmp.path());
|
||||
np.add("old-client", &"ab".repeat(32)).unwrap();
|
||||
// The legacy identity that client pinned. Contents are opaque to the fallback — it
|
||||
// serves the files verbatim (pem-only read; no `rsa` crate involved, WP19).
|
||||
std::fs::write(tmp.path().join("cert.pem"), "legacy cert pem").unwrap();
|
||||
std::fs::write(tmp.path().join("key.pem"), "legacy key pem").unwrap();
|
||||
let id = load_or_adopt(&np).unwrap();
|
||||
// The legacy RSA identity is what that client pinned — it must be what we serve…
|
||||
let legacy = crate::gamestream::cert::ServerIdentity::load_or_create().unwrap();
|
||||
assert_eq!(id.cert_pem, legacy.cert_pem);
|
||||
assert_eq!(id.cert_pem, "legacy cert pem");
|
||||
// …and no P-256 identity may be minted while the pin is live.
|
||||
assert!(!tmp.path().join("native-cert.pem").exists());
|
||||
}
|
||||
|
||||
/// Degenerate: pairings exist but the legacy cert they pinned is gone — those clients are
|
||||
/// stranded whatever we serve, so the P-256 identity is minted rather than failing to start.
|
||||
#[test]
|
||||
fn mints_p256_when_legacy_files_vanished() {
|
||||
let _serial = super::CONFIG_DIR_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _env = EnvGuard::set(tmp.path());
|
||||
let np = empty_store(tmp.path());
|
||||
np.add("stranded-client", &"cd".repeat(32)).unwrap();
|
||||
let id = load_or_adopt(&np).unwrap();
|
||||
assert!(id.cert_pem.contains("BEGIN CERTIFICATE"));
|
||||
assert!(tmp.path().join("native-cert.pem").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,94 +192,97 @@ fn app(
|
||||
/// The versioned API routes + the OpenAPI document collected from them. Single source of
|
||||
/// truth for both the live server and the `openapi` subcommand.
|
||||
fn api_router_parts() -> (Router<Arc<MgmtState>>, 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Arc<MgmtState>>) -> 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",
|
||||
|
||||
@@ -432,6 +432,9 @@ pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> {
|
||||
pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<RuntimeStatus> {
|
||||
// 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<Arc<MgmtState>>) -> Json<Runtime
|
||||
fps: s.fps,
|
||||
})
|
||||
});
|
||||
let stream = gs_stream
|
||||
.map(|c| StreamInfo {
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
fps: c.fps,
|
||||
bitrate_kbps: c.bitrate_kbps,
|
||||
packet_size: c.packet_size as u32,
|
||||
min_fec: c.min_fec,
|
||||
codec: c.codec.into(),
|
||||
// Transition latencies are traced on the native plane only (latency plan P0.1).
|
||||
time_to_first_frame_ms: None,
|
||||
last_resize_ms: None,
|
||||
#[cfg(feature = "gamestream")]
|
||||
let gs_stream_info = gs_stream.map(|c| StreamInfo {
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
fps: c.fps,
|
||||
bitrate_kbps: c.bitrate_kbps,
|
||||
packet_size: c.packet_size as u32,
|
||||
min_fec: c.min_fec,
|
||||
codec: c.codec.into(),
|
||||
// Transition latencies are traced on the native plane only (latency plan P0.1).
|
||||
time_to_first_frame_ms: None,
|
||||
last_resize_ms: None,
|
||||
});
|
||||
#[cfg(not(feature = "gamestream"))]
|
||||
let gs_stream_info: Option<StreamInfo> = 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<Arc<MgmtState>>) -> 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<Arc<MgmtState>>) -> 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<MgmtState>) -> bool {
|
||||
st.app.pairing.pin.awaiting_pin()
|
||||
}
|
||||
#[cfg(not(feature = "gamestream"))]
|
||||
fn gs_pin_pending(_st: &Arc<MgmtState>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -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<AppState> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user