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:
2026-08-11 22:05:30 +02:00
parent e658ad726b
commit 9c6e06d3b9
11 changed files with 494 additions and 222 deletions
+15
View File
@@ -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
+21
View File
@@ -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
+18 -6
View File
@@ -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 -2
View File
@@ -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")]
+156 -58
View File
@@ -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.
+15 -6
View File
@@ -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,
+51 -11
View File
@@ -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());
}
}
+90 -87
View File
@@ -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 {
/// 116 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",
+47 -30
View File
@@ -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
}
+67 -22
View File
@@ -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)