From a339a0466e6fa39eb607e54f3fd8a98191451808 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 9 Jun 2026 21:35:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20M2=20=E2=80=94=20management=20REST?= =?UTF-8?q?=20API=20with=20OpenAPI=20doc=20(control-pane=20groundwork)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A versioned control-plane REST API (/api/v1) on its own port (default 127.0.0.1:47990) serving host info, runtime status, paired-client management, the pairing PIN flow, and session control (stop / force-IDR). The OpenAPI 3.1 document is generated from the handlers by utoipa, served live at /api/v1/openapi.json (+ Scalar docs at /api/docs), printable via `lumen-host openapi`, and checked in at docs/api/openapi.json for client codegen — a test fails if it drifts, mirroring the cbindgen header rule. Auth: optional bearer token (--mgmt-token / LUMEN_MGMT_TOKEN), enforced on everything but /health, and mandatory for non-loopback binds. PinGate gains a waiter count so the API can report pin_pending; logs moved to stderr so stdout stays machine-readable. Supersedes the web.rs stub. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 8 +- Cargo.lock | 62 ++ crates/lumen-host/Cargo.toml | 14 + crates/lumen-host/src/gamestream/cert.rs | 11 + crates/lumen-host/src/gamestream/mod.rs | 43 +- crates/lumen-host/src/gamestream/pairing.rs | 53 +- crates/lumen-host/src/main.rs | 60 +- crates/lumen-host/src/mgmt.rs | 917 ++++++++++++++++++++ crates/lumen-host/src/web.rs | 24 - docs/api/openapi.json | 719 +++++++++++++++ 10 files changed, 1862 insertions(+), 49 deletions(-) create mode 100644 crates/lumen-host/src/mgmt.rs delete mode 100644 crates/lumen-host/src/web.rs create mode 100644 docs/api/openapi.json diff --git a/CLAUDE.md b/CLAUDE.md index 0d9447a..3260411 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,13 +34,17 @@ bash crates/lumen-core/tests/c/run.sh # standalone C-ABI link + round-trip pro `include/lumen_core.h` is generated from `crates/lumen-core/src/abi.rs` by cbindgen (`build.rs`) on every build and is **checked in**; CI fails if it drifts, so commit the -regenerated header when the ABI changes. +regenerated header when the ABI changes. Same deal for the management API's OpenAPI +document: `docs/api/openapi.json` is **checked in** for client codegen and a test fails if +it drifts — regenerate with `cargo run -p lumen-host -- openapi > docs/api/openapi.json` +(the spec lives in `crates/lumen-host/src/mgmt.rs`). ## Layout ``` crates/lumen-core/ protocol · FEC · pacing · crypto — the C ABI (lib + cdylib + staticlib) -crates/lumen-host/ Linux host: vdisplay · capture · encode · inject · web · pipeline (cfg-gated) +crates/lumen-host/ Linux host: vdisplay · capture · encode · inject · gamestream · mgmt · pipeline +docs/api/openapi.json generated management-API spec (codegen input) crates/lumen-client-rs/ reference client (M4) tools/{loss-harness,latency-probe}/ measurement (plan §10) clients/{apple,android}/ native client scaffolds (import lumen_core.h) diff --git a/Cargo.lock b/Cargo.lock index 8166486..1eda623 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1493,6 +1493,7 @@ dependencies = [ "ffmpeg-next", "futures-util", "hex", + "http-body-util", "khronos-egl", "libc", "lumen-core", @@ -1506,10 +1507,16 @@ dependencies = [ "rustls", "rustls-pemfile", "rusty_enet", + "serde", + "serde_json", "sha2", "tokio", + "tower", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-axum", + "utoipa-scalar", "wayland-backend", "wayland-client", "wayland-protocols-misc", @@ -1790,6 +1797,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.6" @@ -3096,6 +3109,55 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-axum" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0" +dependencies = [ + "axum", + "paste", + "tower-layer", + "tower-service", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + [[package]] name = "uuid" version = "1.23.2" diff --git a/crates/lumen-host/Cargo.toml b/crates/lumen-host/Cargo.toml index 711dee0..c1b660b 100644 --- a/crates/lumen-host/Cargo.toml +++ b/crates/lumen-host/Cargo.toml @@ -29,6 +29,20 @@ axum-server = { version = "0.7", features = ["tls-rustls"] } rustls = "0.23" rustls-pemfile = "2" rusty_enet = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Management/control-plane REST API + OpenAPI (control pane, M2). `axum_extras` wires +# utoipa into axum 0.8 extractors; utoipa-axum collects `#[utoipa::path]` routes into the +# spec; utoipa-scalar serves the interactive docs. Codegen-friendly: the spec is emitted +# verbatim by the `openapi` subcommand. Control plane only — never the per-frame path. +utoipa = { version = "5", features = ["axum_extras"] } +utoipa-axum = "0.2" +utoipa-scalar = { version = "0.3", features = ["axum"] } + +[dev-dependencies] +# Drive the management API router in-process (no socket) in the handler tests. +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" [target.'cfg(target_os = "linux")'.dependencies] # `screencast` gates the ScreenCast portal module; `remote_desktop` adds the RemoteDesktop diff --git a/crates/lumen-host/src/gamestream/cert.rs b/crates/lumen-host/src/gamestream/cert.rs index 3ceb1ff..7ade341 100644 --- a/crates/lumen-host/src/gamestream/cert.rs +++ b/crates/lumen-host/src/gamestream/cert.rs @@ -42,6 +42,11 @@ impl ServerIdentity { (c, k) } }; + Self::from_pems(cert_pem, key_pem) + } + + /// Build an identity from PEMs (no I/O). + pub fn from_pems(cert_pem: String, key_pem: String) -> Result { let priv_key = RsaPrivateKey::from_pkcs8_pem(&key_pem).context("parse host private key")?; let signing_key = SigningKey::::new(priv_key); let signature = cert_signature(&cert_pem)?; @@ -52,6 +57,12 @@ impl ServerIdentity { signing_key, }) } + + /// Throwaway in-memory identity — nothing touches the config dir (used by tests). + pub fn ephemeral() -> Result { + let (cert_pem, key_pem) = generate()?; + Self::from_pems(cert_pem, key_pem) + } } fn generate() -> Result<(String, String)> { diff --git a/crates/lumen-host/src/gamestream/mod.rs b/crates/lumen-host/src/gamestream/mod.rs index da1948a..d730640 100644 --- a/crates/lumen-host/src/gamestream/mod.rs +++ b/crates/lumen-host/src/gamestream/mod.rs @@ -7,7 +7,7 @@ //! the media streams follow (see the M2 task list / plan). mod audio; -mod cert; +pub(crate) mod cert; mod control; mod crypto; mod input; @@ -101,23 +101,31 @@ pub struct AppState { pub audio_cap: std::sync::Arc>>>, } -/// Run the GameStream control plane (blocks): mDNS advertisement + the nvhttp servers. -pub fn serve() -> Result<()> { +impl AppState { + /// Fresh control-plane state: no active session, empty pairing store. + pub fn new(host: Host, identity: cert::ServerIdentity) -> AppState { + AppState { + host, + identity, + pairing: pairing::Pairing::new(), + paired: std::sync::Mutex::new(Vec::new()), + launch: std::sync::Mutex::new(None), + stream: std::sync::Mutex::new(None), + streaming: 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)), + video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)), + audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)), + } + } +} + +/// Run the GameStream control plane (blocks): mDNS advertisement, the nvhttp servers, and +/// the management REST API. +pub fn serve(mgmt: crate::mgmt::Options) -> Result<()> { let host = Host::detect()?; let identity = cert::ServerIdentity::load_or_create().context("host certificate")?; - let state = Arc::new(AppState { - host, - identity, - pairing: pairing::Pairing::new(), - paired: std::sync::Mutex::new(Vec::new()), - launch: std::sync::Mutex::new(None), - stream: std::sync::Mutex::new(None), - streaming: 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)), - video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)), - audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)), - }); + let state = Arc::new(AppState::new(host, identity)); tracing::info!( hostname = %state.host.hostname, uniqueid = %state.host.uniqueid, @@ -131,7 +139,8 @@ pub fn serve() -> Result<()> { let _advert = mdns::advertise(&state.host).context("mDNS advertise")?; rtsp::spawn(state.clone()).context("start RTSP server")?; control::spawn(state.clone()).context("start ENet control server")?; - nvhttp::run(state).await + tokio::try_join!(nvhttp::run(state.clone()), crate::mgmt::run(state, mgmt))?; + Ok(()) }) } diff --git a/crates/lumen-host/src/gamestream/pairing.rs b/crates/lumen-host/src/gamestream/pairing.rs index 304f2b8..9340267 100644 --- a/crates/lumen-host/src/gamestream/pairing.rs +++ b/crates/lumen-host/src/gamestream/pairing.rs @@ -12,15 +12,20 @@ use rsa::signature::{SignatureEncoding, Signer, Verifier}; use rsa::RsaPublicKey; use sha2::Sha256; use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; use std::time::Duration; use tokio::sync::Notify; /// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the user submits it -/// (here via `GET /pin?pin=NNNN`). `getservercert` parks until a PIN arrives. +/// (via the management API's `POST /api/v1/pair/pin` or nvhttp's `GET /pin?pin=NNNN`). +/// `getservercert` parks until a PIN arrives. pub struct PinGate { pin: Mutex>, notify: Notify, + /// Handshakes currently parked in [`take`](Self::take) — drives the management API's + /// `pin_pending` so a control pane knows when to prompt for the PIN. + waiters: AtomicUsize, } impl PinGate { @@ -28,6 +33,7 @@ impl PinGate { PinGate { pin: Mutex::new(None), notify: Notify::new(), + waiters: AtomicUsize::new(0), } } @@ -36,7 +42,22 @@ impl PinGate { self.notify.notify_waiters(); } + /// True while a pairing handshake is parked waiting for the user's PIN. + pub fn awaiting_pin(&self) -> bool { + self.waiters.load(Ordering::SeqCst) > 0 + } + async fn take(&self, timeout: Duration) -> Option { + self.waiters.fetch_add(1, Ordering::SeqCst); + // Decrement on every exit path (PIN delivered, timeout, or future cancellation). + struct WaiterGuard<'a>(&'a AtomicUsize); + impl Drop for WaiterGuard<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } + } + let _guard = WaiterGuard(&self.waiters); + let deadline = tokio::time::Instant::now() + timeout; loop { if let Some(p) = self.pin.lock().unwrap().take() { @@ -249,3 +270,33 @@ fn paired_xml(inner: &str, paired: bool) -> String { inner ) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + /// `awaiting_pin` flips true while `take` is parked and back to false on every exit + /// path (delivered + timeout) — the management API's pairing UX depends on it. + #[tokio::test] + async fn pin_gate_reports_waiting() { + let pairing = Arc::new(Pairing::new()); + assert!(!pairing.pin.awaiting_pin()); + + let waiter = { + let p = pairing.clone(); + tokio::spawn(async move { p.pin.take(Duration::from_secs(5)).await }) + }; + while !pairing.pin.awaiting_pin() { + tokio::time::sleep(Duration::from_millis(2)).await; + } + + pairing.pin.submit("1234".into()); + assert_eq!(waiter.await.unwrap().as_deref(), Some("1234")); + assert!(!pairing.pin.awaiting_pin()); + + // Timeout path also clears the flag. + assert_eq!(pairing.pin.take(Duration::from_millis(10)).await, None); + assert!(!pairing.pin.awaiting_pin()); + } +} diff --git a/crates/lumen-host/src/main.rs b/crates/lumen-host/src/main.rs index c13b874..30bb687 100644 --- a/crates/lumen-host/src/main.rs +++ b/crates/lumen-host/src/main.rs @@ -19,10 +19,10 @@ mod encode; mod gamestream; mod inject; mod m0; +mod mgmt; mod pipeline; mod pwinit; mod vdisplay; -mod web; #[cfg(target_os = "linux")] mod zerocopy; @@ -32,10 +32,12 @@ use m0::{Options, Source}; use std::path::PathBuf; fn main() { + // Logs go to stderr so stdout stays machine-readable (`lumen-host openapi > spec.json`). tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) + .with_writer(std::io::stderr) .init(); if let Err(e) = real_main() { @@ -49,8 +51,13 @@ fn real_main() -> Result<()> { let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { - // M2 GameStream host control plane (P1.1: mDNS + serverinfo). - Some("serve") => gamestream::serve(), + // M2 GameStream host control plane (P1.1: mDNS + serverinfo) + management API. + Some("serve") => gamestream::serve(parse_serve(&args[1..])?), + // Print the management API's OpenAPI document (for client codegen). + Some("openapi") => { + print!("{}", mgmt::openapi_json()); + Ok(()) + } // Standalone input-injection smoke test (no client needed): open the session's input // backend and inject a scripted mouse/keyboard pattern. Watch a focused app / `wev`. Some("input-test") => input_test(), @@ -120,6 +127,42 @@ fn input_test() -> Result<()> { bail!("input-test requires Linux") } +/// `serve` options — all about the management API; the GameStream ports are protocol-fixed. +fn parse_serve(args: &[String]) -> Result { + let mut opts = mgmt::Options::default(); + let mut i = 0; + while i < args.len() { + let arg = args[i].as_str(); + let mut next = || { + i += 1; + args.get(i) + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing value for {arg}")) + }; + match arg { + "--mgmt-bind" => { + opts.bind = next()? + .parse() + .map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))? + } + "--mgmt-token" => opts.token = Some(next()?), + "-h" | "--help" => { + print_usage(); + std::process::exit(0); + } + other => bail!("unknown argument '{other}' (try --help)"), + } + i += 1; + } + // Flag wins over the environment so a unit file can set a default and a shell override it. + if opts.token.is_none() { + opts.token = std::env::var("LUMEN_MGMT_TOKEN") + .ok() + .filter(|t| !t.is_empty()); + } + Ok(opts) +} + fn parse_m0(args: &[String]) -> Result { let mut source = Source::Portal; let mut width = 1920u32; @@ -222,10 +265,17 @@ fn print_usage() { "lumen-host — Linux streaming host USAGE: - lumen-host serve GameStream host control plane (M2: mDNS + serverinfo …) + lumen-host serve [OPTIONS] GameStream host control plane (M2: mDNS + serverinfo …) + + the management REST API + lumen-host openapi print the management API's OpenAPI document (codegen) lumen-host m0 [OPTIONS] M0 capture→encode→file pipeline spike -OPTIONS: +SERVE OPTIONS: + --mgmt-bind management API address (default: 127.0.0.1:47990) + --mgmt-token bearer token for the management API (or LUMEN_MGMT_TOKEN); + required when --mgmt-bind is not loopback + +M0 OPTIONS: --source frame source (default: portal). 'kwin-virtual' creates a KWin virtual output at --width x --height and captures it diff --git a/crates/lumen-host/src/mgmt.rs b/crates/lumen-host/src/mgmt.rs new file mode 100644 index 0000000..7970eb4 --- /dev/null +++ b/crates/lumen-host/src/mgmt.rs @@ -0,0 +1,917 @@ +//! Management REST API (plan §4) — the control-plane surface a control pane / CLI talks +//! to: host identity + capabilities, runtime status, paired-client management, the pairing +//! PIN flow, and session control. Control plane only — `tokio`/`axum` are permitted here; +//! the per-frame pipeline never touches this module. +//! +//! The API is versioned under `/api/v1` and described by an OpenAPI 3.1 document generated +//! at compile time with `utoipa` — `lumen-host openapi` prints it for client codegen, the +//! running server serves it at `/api/v1/openapi.json` plus interactive docs at `/api/docs`, +//! and a copy is checked in at `docs/api/openapi.json` (a test fails if it drifts, like the +//! cbindgen header). +//! +//! Security: binds loopback by default. A bearer token (`--mgmt-token` / `LUMEN_MGMT_TOKEN`) +//! is enforced on every `/api/v1` route except `/api/v1/health`, and is mandatory for +//! non-loopback binds. The OpenAPI document and docs UI are served unauthenticated (the +//! spec is public knowledge — it lives in this repo). + +use crate::encode::Codec; +use crate::gamestream::{ + AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT, +}; +use anyhow::{bail, Context, Result}; +use axum::{ + extract::{Path, Request, State}, + http::{header, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::net::SocketAddr; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use utoipa::{Modify, OpenApi, ToSchema}; +use utoipa_axum::{router::OpenApiRouter, routes}; +use utoipa_scalar::{Scalar, Servable}; + +/// Default management port — adjacent to the GameStream block (47984…48010), and the same +/// number Sunshine users already associate with "the config UI". +pub const DEFAULT_PORT: u16 = 47990; + +/// Management server options (CLI: `serve --mgmt-bind ADDR --mgmt-token TOKEN`). +#[derive(Clone, Debug)] +pub struct Options { + pub bind: SocketAddr, + /// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated, + /// which [`run`] only permits on loopback binds. + pub token: Option, +} + +impl Default for Options { + fn default() -> Self { + Options { + bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)), + token: None, + } + } +} + +/// Axum state for the management routes: the shared control-plane state + auth config. +struct MgmtState { + app: Arc, + token: Option, + /// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map. + port: u16, +} + +/// Run the management API server (control plane; spawned alongside the nvhttp servers). +pub async fn run(state: Arc, opts: Options) -> Result<()> { + if opts.token.is_none() && !opts.bind.ip().is_loopback() { + bail!( + "management API bind {} is not loopback — set --mgmt-token (or LUMEN_MGMT_TOKEN) \ + to expose it beyond this machine", + opts.bind + ); + } + tracing::info!( + addr = %opts.bind, + auth = if opts.token.is_some() { "bearer" } else { "none (loopback)" }, + "management API listening (docs at /api/docs, spec at /api/v1/openapi.json)" + ); + let app = app(state, opts.token, opts.bind.port()); + axum_server::bind(opts.bind) + .serve(app.into_make_service()) + .await + .context("management API server") +} + +/// Compose the full management router (also used directly by the handler tests). +fn app(state: Arc, token: Option, port: u16) -> Router { + let shared = Arc::new(MgmtState { + app: state, + token, + port, + }); + let (api_routes, api) = api_router_parts(); + api_routes + .route_layer(middleware::from_fn_with_state(shared.clone(), require_auth)) + .with_state(shared) + .merge(Scalar::with_url("/api/docs", api.clone())) + .route( + "/api/v1/openapi.json", + get(move || { + let spec = api.clone(); + async move { Json(spec) } + }), + ) +} + +/// 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>, utoipa::openapi::OpenApi) { + OpenApiRouter::with_openapi(ApiDoc::openapi()) + .nest( + "/api/v1", + OpenApiRouter::new() + .routes(routes!(get_health)) + .routes(routes!(get_host_info)) + .routes(routes!(get_status)) + .routes(routes!(list_paired_clients)) + .routes(routes!(unpair_client)) + .routes(routes!(get_pairing_status)) + .routes(routes!(submit_pairing_pin)) + .routes(routes!(stop_session)) + .routes(routes!(request_idr)), + ) + .split_for_parts() +} + +/// The OpenAPI document as pretty JSON — what `lumen-host openapi` prints and what is +/// checked in at `docs/api/openapi.json` for client codegen. +pub fn openapi_json() -> String { + let (_, api) = api_router_parts(); + let mut json = api.to_pretty_json().expect("serialize OpenAPI document"); + json.push('\n'); + json +} + +#[derive(OpenApi)] +#[openapi( + info( + title = "lumen management API", + description = "Control-plane API for managing a lumen streaming host: host \ + capabilities, runtime status, paired clients, the pairing PIN flow, \ + and session control. Authentication: HTTP bearer token, enforced on \ + every route except `/api/v1/health` when the host is started with a \ + management token (mandatory for non-loopback binds)." + ), + modifiers(&SecurityAddon), + tags( + (name = "host", description = "Host identity, capabilities, and liveness"), + (name = "clients", description = "Paired Moonlight client management"), + (name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"), + (name = "session", description = "Active streaming session control"), + ) +)] +struct ApiDoc; + +/// Registers the `bearerAuth` scheme and applies it globally (utoipa has no first-class +/// "all operations" shorthand, hence a modifier). +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme}; + openapi + .components + .get_or_insert_with(Default::default) + .add_security_scheme( + "bearerAuth", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + openapi.security = Some(vec![utoipa::openapi::security::SecurityRequirement::new( + "bearerAuth", + Vec::::new(), + )]); + } +} + +// --------------------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------------------- + +/// Liveness + version probe. +#[derive(Serialize, ToSchema)] +struct Health { + /// Always `"ok"` when the host responds. + #[schema(example = "ok")] + status: String, + /// `lumen-host` crate version. + version: String, + /// `lumen-core` C ABI version. + abi_version: u32, +} + +/// Host identity and advertised capabilities (static for the life of the process). +#[derive(Serialize, ToSchema)] +struct HostInfo { + hostname: String, + /// Stable per-host id (persisted across restarts), matched on pairing. + uniqueid: String, + /// Best-effort primary LAN IP. + local_ip: String, + /// `lumen-host` crate version. + version: String, + /// `lumen-core` C ABI version. + abi_version: u32, + /// GameStream host version advertised to Moonlight clients. + app_version: String, + /// GFE version advertised to Moonlight clients. + gfe_version: String, + /// Codecs the host can encode (NVENC). + codecs: Vec, + ports: PortMap, +} + +/// Every port a client integration may need (Moonlight derives the stream ports from the +/// HTTP base; a control pane should not have to). +#[derive(Serialize, ToSchema)] +struct PortMap { + /// This management API. + mgmt: u16, + /// nvhttp plain HTTP (serverinfo, pairing). + http: u16, + /// nvhttp mutual-TLS HTTPS (post-pairing). + https: u16, + rtsp: u16, + video: u16, + control: u16, + audio: u16, +} + +/// Video codec identifier. +#[derive(Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq, Debug)] +#[serde(rename_all = "lowercase")] +enum ApiCodec { + H264, + H265, + Av1, +} + +impl From for ApiCodec { + fn from(c: Codec) -> Self { + match c { + Codec::H264 => ApiCodec::H264, + Codec::H265 => ApiCodec::H265, + Codec::Av1 => ApiCodec::Av1, + } + } +} + +/// Live host status (changes as clients launch/end sessions). +#[derive(Serialize, ToSchema)] +struct RuntimeStatus { + /// True while the video stream thread is running. + video_streaming: bool, + /// True while the audio stream thread is running. + audio_streaming: bool, + /// True while a pairing handshake is parked waiting for the user's PIN + /// (submit it via `POST /api/v1/pair/pin`). + pin_pending: bool, + /// Number of pinned (paired) client certificates. + paired_clients: u32, + /// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop). + session: Option, + /// The RTSP-negotiated stream parameters (present once a client has completed ANNOUNCE). + stream: Option, +} + +/// Client-requested launch parameters (key material is never exposed here). +#[derive(Serialize, ToSchema)] +struct SessionInfo { + width: u32, + height: u32, + fps: u32, +} + +/// RTSP-negotiated stream parameters. +#[derive(Serialize, ToSchema)] +struct StreamInfo { + width: u32, + height: u32, + fps: u32, + bitrate_kbps: u32, + /// Video payload size per packet (bytes). + packet_size: u32, + /// Client's parity floor per FEC block (`minRequiredFecPackets`). + min_fec: u8, + codec: ApiCodec, +} + +/// A paired (certificate-pinned) Moonlight client. +#[derive(Serialize, ToSchema)] +struct PairedClient { + /// Lowercase hex SHA-256 of the client certificate DER — the client's stable id here. + #[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")] + fingerprint: String, + /// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses. + subject: Option, + /// Certificate validity start (unix seconds). + not_before_unix: Option, + /// Certificate validity end (unix seconds). + not_after_unix: Option, +} + +/// Pairing-flow status. +#[derive(Serialize, ToSchema)] +struct PairingStatus { + /// True while a pairing handshake is parked waiting for the user's PIN. + pin_pending: bool, +} + +/// The PIN Moonlight displays during pairing. +#[derive(Deserialize, ToSchema)] +struct SubmitPin { + /// 1–16 ASCII digits (Moonlight shows 4). + #[schema(example = "1234")] + pin: String, +} + +/// Error envelope for every non-2xx response. +#[derive(Serialize, Deserialize, ToSchema)] +struct ApiError { + error: String, +} + +fn api_error(status: StatusCode, message: &str) -> Response { + ( + status, + Json(ApiError { + error: message.to_string(), + }), + ) + .into_response() +} + +// --------------------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------------------- + +/// Bearer-token gate on the `/api/v1` routes. No token configured ⇒ open (loopback-only, +/// enforced in [`run`]); `/api/v1/health` stays open for monitoring probes either way. +async fn require_auth(State(st): State>, req: Request, next: Next) -> Response { + let Some(expected) = st.token.as_deref() else { + return next.run(req).await; + }; + if req.uri().path() == "/api/v1/health" { + return next.run(req).await; + } + let presented = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")); + match presented { + Some(token) if token_eq(token, expected) => next.run(req).await, + _ => api_error(StatusCode::UNAUTHORIZED, "missing or invalid bearer token"), + } +} + +/// Compare SHA-256 digests instead of the strings — constant-time with respect to the +/// secret without pulling in a ct-eq dependency. +fn token_eq(presented: &str, expected: &str) -> bool { + Sha256::digest(presented.as_bytes()) == Sha256::digest(expected.as_bytes()) +} + +// --------------------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------------------- + +/// Liveness probe +/// +/// Always available without authentication. +#[utoipa::path( + get, + path = "/health", + tag = "host", + operation_id = "getHealth", + responses((status = OK, description = "Host is up", body = Health)) +)] +async fn get_health() -> Json { + Json(Health { + status: "ok".into(), + version: env!("CARGO_PKG_VERSION").into(), + abi_version: lumen_core::ABI_VERSION, + }) +} + +/// Host identity and capabilities +#[utoipa::path( + get, + path = "/host", + tag = "host", + operation_id = "getHostInfo", + responses( + (status = OK, description = "Host identity, versions, codecs, and port map", body = HostInfo), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn get_host_info(State(st): State>) -> Json { + let h = &st.app.host; + Json(HostInfo { + hostname: h.hostname.clone(), + uniqueid: h.uniqueid.clone(), + local_ip: h.local_ip.to_string(), + version: env!("CARGO_PKG_VERSION").into(), + abi_version: lumen_core::ABI_VERSION, + app_version: APP_VERSION.into(), + gfe_version: GFE_VERSION.into(), + // Everything NVENC encodes here (mirrors SERVER_CODEC_MODE_SUPPORT = 3843). + codecs: vec![ApiCodec::H264, ApiCodec::H265, ApiCodec::Av1], + ports: PortMap { + mgmt: st.port, + http: h.http_port, + https: h.https_port, + rtsp: RTSP_PORT, + video: VIDEO_PORT, + control: CONTROL_PORT, + audio: AUDIO_PORT, + }, + }) +} + +/// Live host status +#[utoipa::path( + get, + path = "/status", + tag = "host", + operation_id = "getStatus", + responses( + (status = OK, description = "Streaming/pairing state and the active session, if any", body = RuntimeStatus), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn get_status(State(st): State>) -> Json { + let session = st.app.launch.lock().unwrap().map(|l| SessionInfo { + width: l.width, + height: l.height, + fps: l.fps, + }); + let stream = st.app.stream.lock().unwrap().as_ref().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(), + }); + Json(RuntimeStatus { + video_streaming: st.app.streaming.load(Ordering::SeqCst), + audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst), + pin_pending: st.app.pairing.pin.awaiting_pin(), + paired_clients: st.app.paired.lock().unwrap().len() as u32, + session, + stream, + }) +} + +/// List paired clients +#[utoipa::path( + get, + path = "/clients", + tag = "clients", + operation_id = "listPairedClients", + responses( + (status = OK, description = "All certificate-pinned clients", body = [PairedClient]), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn list_paired_clients(State(st): State>) -> Json> { + let ders = st.app.paired.lock().unwrap().clone(); + Json(ders.iter().map(|der| client_info(der)).collect()) +} + +fn client_info(der: &[u8]) -> PairedClient { + let fingerprint = hex::encode(Sha256::digest(der)); + match x509_parser::parse_x509_certificate(der) { + Ok((_, x509)) => PairedClient { + fingerprint, + subject: Some(x509.subject().to_string()), + not_before_unix: Some(x509.validity().not_before.timestamp()), + not_after_unix: Some(x509.validity().not_after.timestamp()), + }, + Err(_) => PairedClient { + fingerprint, + subject: None, + not_before_unix: None, + not_after_unix: None, + }, + } +} + +/// Unpair a client +/// +/// Removes the pinned certificate; the client must pair again to reconnect. +#[utoipa::path( + delete, + path = "/clients/{fingerprint}", + tag = "clients", + operation_id = "unpairClient", + params( + ("fingerprint" = String, Path, + description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)") + ), + responses( + (status = NO_CONTENT, description = "Client unpaired"), + (status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError), + ) +)] +async fn unpair_client( + State(st): State>, + Path(fingerprint): Path, +) -> Response { + if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) { + return api_error( + StatusCode::BAD_REQUEST, + "fingerprint must be the 64-char hex SHA-256 of the client certificate DER", + ); + } + let mut paired = st.app.paired.lock().unwrap(); + let before = paired.len(); + paired.retain(|der| !hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint)); + if paired.len() < before { + tracing::info!(fingerprint, "management API: client unpaired"); + StatusCode::NO_CONTENT.into_response() + } else { + api_error( + StatusCode::NOT_FOUND, + "no paired client with that fingerprint", + ) + } +} + +/// Pairing-flow status +/// +/// Poll this to know when to prompt the user for the PIN Moonlight displays. +#[utoipa::path( + get, + path = "/pair", + tag = "pairing", + operation_id = "getPairingStatus", + responses( + (status = OK, description = "Whether a pairing handshake is waiting for a PIN", body = PairingStatus), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn get_pairing_status(State(st): State>) -> Json { + Json(PairingStatus { + pin_pending: st.app.pairing.pin.awaiting_pin(), + }) +} + +/// Submit the pairing PIN +/// +/// Delivers the PIN the Moonlight client is displaying, completing the out-of-band half +/// of the pairing handshake. +#[utoipa::path( + post, + path = "/pair/pin", + tag = "pairing", + operation_id = "submitPairingPin", + request_body = SubmitPin, + responses( + (status = NO_CONTENT, description = "PIN delivered to the waiting handshake"), + (status = BAD_REQUEST, description = "Malformed PIN", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = CONFLICT, description = "No pairing handshake is waiting for a PIN", body = ApiError), + ) +)] +async fn submit_pairing_pin( + State(st): State>, + Json(req): Json, +) -> Response { + let pin = req.pin.trim(); + if pin.is_empty() || pin.len() > 16 || !pin.bytes().all(|b| b.is_ascii_digit()) { + return api_error(StatusCode::BAD_REQUEST, "pin must be 1-16 ASCII digits"); + } + if !st.app.pairing.pin.awaiting_pin() { + // Refusing (rather than parking the PIN) prevents a stale PIN from silently + // satisfying a *future* pairing attempt. + return api_error( + StatusCode::CONFLICT, + "no pairing handshake is waiting for a PIN", + ); + } + st.app.pairing.pin.submit(pin.to_string()); + StatusCode::NO_CONTENT.into_response() +} + +/// Stop the active session +/// +/// Kicks the connected client: stops the video/audio stream threads and clears the launch +/// state. Idempotent — succeeds even when nothing is streaming. +#[utoipa::path( + delete, + path = "/session", + tag = "session", + operation_id = "stopSession", + responses( + (status = NO_CONTENT, description = "Session stopped (or none was active)"), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn stop_session(State(st): State>) -> StatusCode { + let was_streaming = st.app.streaming.swap(false, Ordering::SeqCst); + st.app.audio_streaming.store(false, Ordering::SeqCst); + *st.app.launch.lock().unwrap() = None; + *st.app.stream.lock().unwrap() = None; + tracing::info!(was_streaming, "management API: session stopped"); + StatusCode::NO_CONTENT +} + +/// Force a keyframe +/// +/// Asks the encoder for an IDR frame on the active video stream (what a client requests +/// after unrecoverable loss — exposed for debugging). +#[utoipa::path( + post, + path = "/session/idr", + tag = "session", + operation_id = "requestIdr", + responses( + (status = ACCEPTED, description = "Keyframe requested"), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = CONFLICT, description = "No active video stream", body = ApiError), + ) +)] +async fn request_idr(State(st): State>) -> Response { + if !st.app.streaming.load(Ordering::SeqCst) { + return api_error(StatusCode::CONFLICT, "no active video stream"); + } + st.app.force_idr.store(true, Ordering::SeqCst); + StatusCode::ACCEPTED.into_response() +} + +// --------------------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::gamestream::{cert::ServerIdentity, Host, LaunchSession, HTTPS_PORT, HTTP_PORT}; + use axum::body::Body; + use http_body_util::BodyExt; + use std::net::{IpAddr, Ipv4Addr}; + use tower::ServiceExt; + + fn test_state() -> Arc { + let host = Host { + hostname: "test-host".into(), + uniqueid: "deadbeef".into(), + local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + http_port: HTTP_PORT, + https_port: HTTPS_PORT, + }; + let identity = ServerIdentity::ephemeral().expect("ephemeral identity"); + Arc::new(AppState::new(host, identity)) + } + + fn test_app(state: Arc, token: Option<&str>) -> Router { + app(state, token.map(String::from), DEFAULT_PORT) + } + + async fn send(app: &Router, req: axum::http::Request) -> (StatusCode, serde_json::Value) { + let resp = app.clone().oneshot(req).await.expect("infallible"); + let status = resp.status(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) + }; + (status, json) + } + + fn get_req(path: &str) -> axum::http::Request { + axum::http::Request::get(path).body(Body::empty()).unwrap() + } + + #[tokio::test] + async fn health_is_open_and_versioned() { + let app = test_app(test_state(), None); + let (status, body) = send(&app, get_req("/api/v1/health")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ok"); + assert_eq!(body["abi_version"], lumen_core::ABI_VERSION); + } + + #[tokio::test] + async fn bearer_token_is_enforced() { + let app = test_app(test_state(), Some("sekrit")); + + // No/wrong token → 401 with the error envelope. + let (status, body) = send(&app, get_req("/api/v1/status")).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(body["error"].as_str().unwrap().contains("bearer")); + let wrong = axum::http::Request::get("/api/v1/status") + .header("authorization", "Bearer nope") + .body(Body::empty()) + .unwrap(); + assert_eq!(send(&app, wrong).await.0, StatusCode::UNAUTHORIZED); + + // Right token → 200. + let right = axum::http::Request::get("/api/v1/status") + .header("authorization", "Bearer sekrit") + .body(Body::empty()) + .unwrap(); + assert_eq!(send(&app, right).await.0, StatusCode::OK); + + // Health + the spec/docs stay open. + assert_eq!( + send(&app, get_req("/api/v1/health")).await.0, + StatusCode::OK + ); + assert_eq!( + send(&app, get_req("/api/v1/openapi.json")).await.0, + StatusCode::OK + ); + let docs = app.clone().oneshot(get_req("/api/docs")).await.unwrap(); + assert_eq!(docs.status(), StatusCode::OK); + let html = docs.into_body().collect().await.unwrap().to_bytes(); + assert!( + html.starts_with(b""), + "Scalar UI should serve HTML" + ); + } + + #[tokio::test] + async fn host_info_reports_identity_and_ports() { + let app = test_app(test_state(), None); + let (status, body) = send(&app, get_req("/api/v1/host")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["hostname"], "test-host"); + assert_eq!(body["uniqueid"], "deadbeef"); + assert_eq!(body["ports"]["http"], HTTP_PORT); + assert_eq!(body["ports"]["mgmt"], DEFAULT_PORT); + assert_eq!(body["codecs"], serde_json::json!(["h264", "h265", "av1"])); + } + + #[tokio::test] + async fn status_reflects_runtime_state() { + let state = test_state(); + let app = test_app(state.clone(), None); + + let (_, body) = send(&app, get_req("/api/v1/status")).await; + assert_eq!(body["video_streaming"], false); + assert_eq!(body["session"], serde_json::Value::Null); + + *state.launch.lock().unwrap() = Some(LaunchSession { + gcm_key: [0; 16], + rikeyid: 1, + width: 2560, + height: 1440, + fps: 120, + }); + state.streaming.store(true, Ordering::SeqCst); + + let (_, body) = send(&app, get_req("/api/v1/status")).await; + assert_eq!(body["video_streaming"], true); + assert_eq!(body["session"]["width"], 2560); + assert_eq!(body["session"]["fps"], 120); + // Key material must never appear anywhere in the response. + assert!(!body.to_string().contains("gcm")); + } + + #[tokio::test] + 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(); + let der = pem.contents.clone(); + let fingerprint = hex::encode(Sha256::digest(&der)); + state.paired.lock().unwrap().push(der); + + let (status, body) = send(&app, get_req("/api/v1/clients")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body[0]["fingerprint"], fingerprint); + assert_eq!(body[0]["subject"], "CN=lumen"); + + // Malformed fingerprint → 400. + let bad = axum::http::Request::delete("/api/v1/clients/zz") + .body(Body::empty()) + .unwrap(); + assert_eq!(send(&app, bad).await.0, StatusCode::BAD_REQUEST); + + // Unpair (uppercase hex must match too) → 204, list empties, second delete → 404. + let del = |fp: String| { + axum::http::Request::delete(format!("/api/v1/clients/{fp}")) + .body(Body::empty()) + .unwrap() + }; + assert_eq!( + send(&app, del(fingerprint.to_uppercase())).await.0, + StatusCode::NO_CONTENT + ); + let (_, body) = send(&app, get_req("/api/v1/clients")).await; + assert_eq!(body, serde_json::json!([])); + assert_eq!(send(&app, del(fingerprint)).await.0, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn submit_pin_validates_and_requires_pending_pairing() { + let app = test_app(test_state(), None); + let post = |body: &str| { + axum::http::Request::post("/api/v1/pair/pin") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap() + }; + + // Malformed PINs → 400. + assert_eq!( + send(&app, post(r#"{"pin":""}"#)).await.0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + send(&app, post(r#"{"pin":"12ab"}"#)).await.0, + StatusCode::BAD_REQUEST + ); + + // Well-formed but nothing waiting → 409 (a parked stale PIN would poison the + // next pairing attempt). + assert_eq!( + send(&app, post(r#"{"pin":"1234"}"#)).await.0, + StatusCode::CONFLICT + ); + } + + #[tokio::test] + async fn stop_session_clears_runtime_state() { + let state = test_state(); + let app = test_app(state.clone(), None); + state.streaming.store(true, Ordering::SeqCst); + state.audio_streaming.store(true, Ordering::SeqCst); + *state.launch.lock().unwrap() = Some(LaunchSession { + gcm_key: [0; 16], + rikeyid: 0, + width: 1920, + height: 1080, + fps: 60, + }); + + let del = axum::http::Request::delete("/api/v1/session") + .body(Body::empty()) + .unwrap(); + assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT); + assert!(!state.streaming.load(Ordering::SeqCst)); + assert!(!state.audio_streaming.load(Ordering::SeqCst)); + assert!(state.launch.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn idr_requires_an_active_stream() { + let state = test_state(); + let app = test_app(state.clone(), None); + let post = || { + axum::http::Request::post("/api/v1/session/idr") + .body(Body::empty()) + .unwrap() + }; + assert_eq!(send(&app, post()).await.0, StatusCode::CONFLICT); + + state.streaming.store(true, Ordering::SeqCst); + assert_eq!(send(&app, post()).await.0, StatusCode::ACCEPTED); + assert!(state.force_idr.load(Ordering::SeqCst)); + } + + /// The OpenAPI document lists every route with a unique operationId (codegen relies + /// on both), and the checked-in copy is current. + #[test] + fn openapi_document_is_complete_and_checked_in() { + let json = openapi_json(); + let doc: serde_json::Value = serde_json::from_str(&json).unwrap(); + + let paths = doc["paths"].as_object().unwrap(); + for p in [ + "/api/v1/health", + "/api/v1/host", + "/api/v1/status", + "/api/v1/clients", + "/api/v1/clients/{fingerprint}", + "/api/v1/pair", + "/api/v1/pair/pin", + "/api/v1/session", + "/api/v1/session/idr", + ] { + assert!(paths.contains_key(p), "spec is missing {p}"); + } + + let mut op_ids: Vec<&str> = paths + .values() + .flat_map(|ops| ops.as_object().unwrap().values()) + .filter_map(|op| op["operationId"].as_str()) + .collect(); + let total = op_ids.len(); + op_ids.sort_unstable(); + op_ids.dedup(); + assert_eq!(total, op_ids.len(), "duplicate operationIds"); + assert!(doc["components"]["securitySchemes"]["bearerAuth"].is_object()); + + let checked_in = include_str!("../../../docs/api/openapi.json"); + assert_eq!( + json.trim(), + checked_in.trim(), + "docs/api/openapi.json is stale — regenerate with: \ + cargo run -p lumen-host -- openapi > docs/api/openapi.json" + ); + } +} diff --git a/crates/lumen-host/src/web.rs b/crates/lumen-host/src/web.rs deleted file mode 100644 index a41b671..0000000 --- a/crates/lumen-host/src/web.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Web config / pairing API (plan §4) — control plane only. This is where `tokio`/`axum` -//! are permitted; the per-frame pipeline never touches them. Serves pairing, client -//! identity/permissions, and surfaces [`lumen_core::Stats`] for the measurement UI (M3). - -use anyhow::Result; - -/// Control-plane configuration server. Stub until the pairing/RTSP surface is scoped -/// (plan §12 action 4: confirm exactly which serverinfo/RTSP/pairing messages a current -/// Moonlight client needs for P1). -pub struct WebConfig { - pub bind: String, -} - -impl WebConfig { - pub fn new(bind: impl Into) -> Self { - WebConfig { bind: bind.into() } - } - - /// Run the control-plane server. TODO(M2): axum + tokio; GameStream `serverinfo`, - /// pairing handshake, RTSP SETUP with the `lumen/1` capability flag for negotiation. - pub fn run(&self) -> Result<()> { - anyhow::bail!("web/pairing control plane not yet implemented (M2)") - } -} diff --git a/docs/api/openapi.json b/docs/api/openapi.json new file mode 100644 index 0000000..fcac4e5 --- /dev/null +++ b/docs/api/openapi.json @@ -0,0 +1,719 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "lumen management API", + "description": "Control-plane API for managing a lumen streaming host: host capabilities, runtime status, paired clients, the pairing PIN flow, and session control. Authentication: HTTP bearer token, enforced on every route except `/api/v1/health` when the host is started with a management token (mandatory for non-loopback binds).", + "contact": { + "name": "unom" + }, + "license": { + "name": "MIT OR Apache-2.0", + "identifier": "MIT OR Apache-2.0" + }, + "version": "0.0.1" + }, + "paths": { + "/api/v1/clients": { + "get": { + "tags": [ + "clients" + ], + "summary": "List paired clients", + "operationId": "listPairedClients", + "responses": { + "200": { + "description": "All certificate-pinned clients", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PairedClient" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/clients/{fingerprint}": { + "delete": { + "tags": [ + "clients" + ], + "summary": "Unpair a client", + "description": "Removes the pinned certificate; the client must pair again to reconnect.", + "operationId": "unpairClient", + "parameters": [ + { + "name": "fingerprint", + "in": "path", + "description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Client unpaired" + }, + "400": { + "description": "Malformed fingerprint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No paired client with that fingerprint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/health": { + "get": { + "tags": [ + "host" + ], + "summary": "Liveness probe", + "description": "Always available without authentication.", + "operationId": "getHealth", + "responses": { + "200": { + "description": "Host is up", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + } + } + } + }, + "/api/v1/host": { + "get": { + "tags": [ + "host" + ], + "summary": "Host identity and capabilities", + "operationId": "getHostInfo", + "responses": { + "200": { + "description": "Host identity, versions, codecs, and port map", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostInfo" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/pair": { + "get": { + "tags": [ + "pairing" + ], + "summary": "Pairing-flow status", + "description": "Poll this to know when to prompt the user for the PIN Moonlight displays.", + "operationId": "getPairingStatus", + "responses": { + "200": { + "description": "Whether a pairing handshake is waiting for a PIN", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairingStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/pair/pin": { + "post": { + "tags": [ + "pairing" + ], + "summary": "Submit the pairing PIN", + "description": "Delivers the PIN the Moonlight client is displaying, completing the out-of-band half\nof the pairing handshake.", + "operationId": "submitPairingPin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitPin" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "PIN delivered to the waiting handshake" + }, + "400": { + "description": "Malformed PIN", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "No pairing handshake is waiting for a PIN", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/session": { + "delete": { + "tags": [ + "session" + ], + "summary": "Stop the active session", + "description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.", + "operationId": "stopSession", + "responses": { + "204": { + "description": "Session stopped (or none was active)" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/session/idr": { + "post": { + "tags": [ + "session" + ], + "summary": "Force a keyframe", + "description": "Asks the encoder for an IDR frame on the active video stream (what a client requests\nafter unrecoverable loss — exposed for debugging).", + "operationId": "requestIdr", + "responses": { + "202": { + "description": "Keyframe requested" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "No active video stream", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/status": { + "get": { + "tags": [ + "host" + ], + "summary": "Live host status", + "operationId": "getStatus", + "responses": { + "200": { + "description": "Streaming/pairing state and the active session, if any", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApiCodec": { + "type": "string", + "description": "Video codec identifier.", + "enum": [ + "h264", + "h265", + "av1" + ] + }, + "ApiError": { + "type": "object", + "description": "Error envelope for every non-2xx response.", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + "Health": { + "type": "object", + "description": "Liveness + version probe.", + "required": [ + "status", + "version", + "abi_version" + ], + "properties": { + "abi_version": { + "type": "integer", + "format": "int32", + "description": "`lumen-core` C ABI version.", + "minimum": 0 + }, + "status": { + "type": "string", + "description": "Always `\"ok\"` when the host responds.", + "example": "ok" + }, + "version": { + "type": "string", + "description": "`lumen-host` crate version." + } + } + }, + "HostInfo": { + "type": "object", + "description": "Host identity and advertised capabilities (static for the life of the process).", + "required": [ + "hostname", + "uniqueid", + "local_ip", + "version", + "abi_version", + "app_version", + "gfe_version", + "codecs", + "ports" + ], + "properties": { + "abi_version": { + "type": "integer", + "format": "int32", + "description": "`lumen-core` C ABI version.", + "minimum": 0 + }, + "app_version": { + "type": "string", + "description": "GameStream host version advertised to Moonlight clients." + }, + "codecs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiCodec" + }, + "description": "Codecs the host can encode (NVENC)." + }, + "gfe_version": { + "type": "string", + "description": "GFE version advertised to Moonlight clients." + }, + "hostname": { + "type": "string" + }, + "local_ip": { + "type": "string", + "description": "Best-effort primary LAN IP." + }, + "ports": { + "$ref": "#/components/schemas/PortMap" + }, + "uniqueid": { + "type": "string", + "description": "Stable per-host id (persisted across restarts), matched on pairing." + }, + "version": { + "type": "string", + "description": "`lumen-host` crate version." + } + } + }, + "PairedClient": { + "type": "object", + "description": "A paired (certificate-pinned) Moonlight client.", + "required": [ + "fingerprint" + ], + "properties": { + "fingerprint": { + "type": "string", + "description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.", + "example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + }, + "not_after_unix": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Certificate validity end (unix seconds)." + }, + "not_before_unix": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Certificate validity start (unix seconds)." + }, + "subject": { + "type": [ + "string", + "null" + ], + "description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses." + } + } + }, + "PairingStatus": { + "type": "object", + "description": "Pairing-flow status.", + "required": [ + "pin_pending" + ], + "properties": { + "pin_pending": { + "type": "boolean", + "description": "True while a pairing handshake is parked waiting for the user's PIN." + } + } + }, + "PortMap": { + "type": "object", + "description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).", + "required": [ + "mgmt", + "http", + "https", + "rtsp", + "video", + "control", + "audio" + ], + "properties": { + "audio": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "control": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "http": { + "type": "integer", + "format": "int32", + "description": "nvhttp plain HTTP (serverinfo, pairing).", + "minimum": 0 + }, + "https": { + "type": "integer", + "format": "int32", + "description": "nvhttp mutual-TLS HTTPS (post-pairing).", + "minimum": 0 + }, + "mgmt": { + "type": "integer", + "format": "int32", + "description": "This management API.", + "minimum": 0 + }, + "rtsp": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "video": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "RuntimeStatus": { + "type": "object", + "description": "Live host status (changes as clients launch/end sessions).", + "required": [ + "video_streaming", + "audio_streaming", + "pin_pending", + "paired_clients" + ], + "properties": { + "audio_streaming": { + "type": "boolean", + "description": "True while the audio stream thread is running." + }, + "paired_clients": { + "type": "integer", + "format": "int32", + "description": "Number of pinned (paired) client certificates.", + "minimum": 0 + }, + "pin_pending": { + "type": "boolean", + "description": "True while a pairing handshake is parked waiting for the user's PIN\n(submit it via `POST /api/v1/pair/pin`)." + }, + "session": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SessionInfo", + "description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)." + } + ] + }, + "stream": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StreamInfo", + "description": "The RTSP-negotiated stream parameters (present once a client has completed ANNOUNCE)." + } + ] + }, + "video_streaming": { + "type": "boolean", + "description": "True while the video stream thread is running." + } + } + }, + "SessionInfo": { + "type": "object", + "description": "Client-requested launch parameters (key material is never exposed here).", + "required": [ + "width", + "height", + "fps" + ], + "properties": { + "fps": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "StreamInfo": { + "type": "object", + "description": "RTSP-negotiated stream parameters.", + "required": [ + "width", + "height", + "fps", + "bitrate_kbps", + "packet_size", + "min_fec", + "codec" + ], + "properties": { + "bitrate_kbps": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "codec": { + "$ref": "#/components/schemas/ApiCodec" + }, + "fps": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "min_fec": { + "type": "integer", + "format": "int32", + "description": "Client's parity floor per FEC block (`minRequiredFecPackets`).", + "minimum": 0 + }, + "packet_size": { + "type": "integer", + "format": "int32", + "description": "Video payload size per packet (bytes).", + "minimum": 0 + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "SubmitPin": { + "type": "object", + "description": "The PIN Moonlight displays during pairing.", + "required": [ + "pin" + ], + "properties": { + "pin": { + "type": "string", + "description": "1–16 ASCII digits (Moonlight shows 4).", + "example": "1234" + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer" + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "host", + "description": "Host identity, capabilities, and liveness" + }, + { + "name": "clients", + "description": "Paired Moonlight client management" + }, + { + "name": "pairing", + "description": "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)" + }, + { + "name": "session", + "description": "Active streaming session control" + } + ] +} From bd25f5e02f8331b046c0dbb37e63ebb047b68ab0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 9 Jun 2026 22:00:22 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20M2=20=E2=80=94=20harden=20the=20mana?= =?UTF-8?q?gement=20API=20after=20adversarial=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five confirmed findings from a 46-agent review panel: - Empty --mgmt-token no longer satisfies the non-loopback token gate (critical: 'Bearer ' with an empty token authenticated; parse_serve now bails on blank tokens and mgmt::run treats blank as none) - axum's built-in body rejections (400/415/422) now wear the documented ApiError envelope via an ApiJson extractor, and the spec documents them - GET /health carries security([{}]) in the spec, matching the server's auth exemption - unpairClient's description no longer claims revocation the TLS layer doesn't enforce yet (gamestream/tls.rs accepts any cert — known gap) - CLAUDE.md/README.md no longer reference the deleted web.rs Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 14 ++++--- README.md | 9 +++-- crates/lumen-host/src/main.rs | 11 +++++- crates/lumen-host/src/mgmt.rs | 72 ++++++++++++++++++++++++++++++++--- docs/api/openapi.json | 29 ++++++++++++-- 5 files changed, 115 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3260411..ccd98e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,9 +16,11 @@ Low-latency desktop streaming stack, Linux-first, with a shared Rust protocol co round-trips every access unit through a `lumen_core` host→client session (0 mismatches). See [`docs/linux-setup.md`](docs/linux-setup.md); the code is in `crates/lumen-host/src/{m0,capture,encode}.rs` (+ `capture/linux.rs`, `encode/linux.rs`). -- **The remaining host backends are `#[cfg(target_os = "linux")]` stubs** — KWin/Mutter - virtual displays (`vdisplay.rs`), libei/uinput input (`inject.rs`), web/pairing - (`web.rs`). They compile everywhere but `bail!` until implemented. This is **M2**. +- **M2 is in flight.** The GameStream control plane lives in `gamestream/` (mDNS, + serverinfo, pairing, RTSP, ENet control, video/audio streams) and the management REST + API in `mgmt.rs`; the remaining `#[cfg(target_os = "linux")]` backends — KWin/Mutter + virtual displays (`vdisplay.rs`), libei/uinput input (`inject.rs`) — compile everywhere + and `bail!` where unimplemented. ## Build / test / run @@ -83,9 +85,9 @@ tokio runtime) + `pipewire` **0.9** (must match ashpd's; not 0.10) + `ffmpeg-nex ## Next: M2 — P1 host to a stock Moonlight client Wire M0's capture→encode pipeline (`m0.rs` / `pipeline.rs`) into a streaming host: KWin -virtual output (`vdisplay.rs`, study KRdp), `serverinfo`/pairing/RTSP (`web.rs`) enough for -a real Moonlight client, input via reis/uinput (`inject.rs`). The module seams exist and -`bail!` today. +virtual output (`vdisplay.rs`, study KRdp), `serverinfo`/pairing/RTSP +(`gamestream/{nvhttp,pairing,rtsp}.rs`) enough for a real Moonlight client, input via +reis/uinput (`inject.rs`), management/config REST API (`mgmt.rs`). ## Conventions diff --git a/README.md b/README.md index 62346ed..362b3a9 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,17 @@ loopback round-trip under loss, property tests, and a **C ABI harness**) passes macOS/aarch64. **M0 is done:** `lumen-host` captures a headless wlroots output via the ScreenCast portal + PipeWire, encodes it with NVENC, writes a playable H.265 file, and round-trips every access unit through a `lumen_core` host→client session (see -`docs/linux-setup.md`). The remaining Linux host backends (KWin/Mutter virtual displays, -libei input, web/pairing) are `#[cfg(target_os = "linux")]` seams — defined and compiling, -implementations pending (M2). +`docs/linux-setup.md`). M2 is in flight: the GameStream control plane (`gamestream/`) and +the management REST API (`mgmt.rs`, OpenAPI spec in `docs/api/`) are implemented; the +remaining Linux host backends (KWin/Mutter virtual displays, libei input) are +`#[cfg(target_os = "linux")]` seams — defined and compiling, implementations pending. ## Layout ``` crates/ lumen-core/ protocol · FEC · pacing · crypto — the C ABI (lib + cdylib + staticlib) - lumen-host/ Linux host: vdisplay · capture · encode · inject · web (cfg-gated) + lumen-host/ Linux host: vdisplay · capture · encode · inject · gamestream · mgmt lumen-client-rs/ reference client (M4): VAAPI decode + wgpu present clients/{apple,android}/ native client scaffolds (import lumen_core.h) include/lumen_core.h cbindgen-generated C header (checked in) diff --git a/crates/lumen-host/src/main.rs b/crates/lumen-host/src/main.rs index 30bb687..914d32c 100644 --- a/crates/lumen-host/src/main.rs +++ b/crates/lumen-host/src/main.rs @@ -145,7 +145,16 @@ fn parse_serve(args: &[String]) -> Result { .parse() .map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))? } - "--mgmt-token" => opts.token = Some(next()?), + "--mgmt-token" => { + let token = next()?; + // An empty token would satisfy the non-loopback "token required" guard + // while authenticating nobody (or, worse, everybody) — refuse it loudly + // rather than letting `--mgmt-token "$UNSET_VAR"` ship a dead credential. + if token.trim().is_empty() { + bail!("--mgmt-token must not be empty"); + } + opts.token = Some(token); + } "-h" | "--help" => { print_usage(); std::process::exit(0); diff --git a/crates/lumen-host/src/mgmt.rs b/crates/lumen-host/src/mgmt.rs index 7970eb4..f77828f 100644 --- a/crates/lumen-host/src/mgmt.rs +++ b/crates/lumen-host/src/mgmt.rs @@ -68,7 +68,10 @@ struct MgmtState { /// Run the management API server (control plane; spawned alongside the nvhttp servers). pub async fn run(state: Arc, opts: Options) -> Result<()> { - if opts.token.is_none() && !opts.bind.ip().is_loopback() { + // A blank token is no token: it must neither satisfy the non-loopback guard below nor + // become a credential an empty `Authorization: Bearer ` header would match. + let token = opts.token.filter(|t| !t.trim().is_empty()); + if token.is_none() && !opts.bind.ip().is_loopback() { bail!( "management API bind {} is not loopback — set --mgmt-token (or LUMEN_MGMT_TOKEN) \ to expose it beyond this machine", @@ -77,10 +80,10 @@ pub async fn run(state: Arc, opts: Options) -> Result<()> { } tracing::info!( addr = %opts.bind, - auth = if opts.token.is_some() { "bearer" } else { "none (loopback)" }, + auth = if token.is_some() { "bearer" } else { "none (loopback)" }, "management API listening (docs at /api/docs, spec at /api/v1/openapi.json)" ); - let app = app(state, opts.token, opts.bind.port()); + let app = app(state, token, opts.bind.port()); axum_server::bind(opts.bind) .serve(app.into_make_service()) .await @@ -335,6 +338,25 @@ fn api_error(status: StatusCode, message: &str) -> Response { .into_response() } +/// `axum::Json` whose rejections (bad JSON → 400/422, wrong content-type → 415) are +/// rewrapped in the [`ApiError`] envelope, keeping "every non-2xx body is `ApiError`" true. +struct ApiJson(T); + +impl axum::extract::FromRequest for ApiJson +where + Json: axum::extract::FromRequest, + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + match Json::::from_request(req, state).await { + Ok(Json(value)) => Ok(ApiJson(value)), + Err(rejection) => Err(api_error(rejection.status(), &rejection.body_text())), + } + } +} + // --------------------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------------------- @@ -377,6 +399,8 @@ fn token_eq(presented: &str, expected: &str) -> bool { path = "/health", tag = "host", operation_id = "getHealth", + // Override the document-global bearerAuth: this route is exempt in `require_auth`. + security(()), responses((status = OK, description = "Host is up", body = Health)) )] async fn get_health() -> Json { @@ -494,7 +518,10 @@ fn client_info(der: &[u8]) -> PairedClient { /// Unpair a client /// -/// Removes the pinned certificate; the client must pair again to reconnect. +/// Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer +/// does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed +/// client cert — a planned hardening step), so until that lands this removes the client +/// from the listing without severing its ability to reconnect. #[utoipa::path( delete, path = "/clients/{fingerprint}", @@ -566,14 +593,16 @@ async fn get_pairing_status(State(st): State>) -> Json>, - Json(req): Json, + ApiJson(req): ApiJson, ) -> Response { let pin = req.pin.trim(); if pin.is_empty() || pin.len() > 16 || !pin.bytes().all(|b| b.is_ascii_digit()) { @@ -832,6 +861,31 @@ mod tests { send(&app, post(r#"{"pin":"1234"}"#)).await.0, StatusCode::CONFLICT ); + + // axum's own body rejections must still wear the ApiError envelope (ApiJson). + let (status, body) = send(&app, post("{not json")).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body["error"].is_string(), "syntax error: {body}"); + let (status, body) = send(&app, post(r#"{"wrong":"shape"}"#)).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert!(body["error"].is_string(), "schema mismatch: {body}"); + let no_ct = axum::http::Request::post("/api/v1/pair/pin") + .body(Body::from(r#"{"pin":"1234"}"#)) + .unwrap(); + let (status, body) = send(&app, no_ct).await; + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert!(body["error"].is_string(), "media type: {body}"); + } + + /// A blank token must not satisfy the "non-loopback requires a token" guard. + #[tokio::test] + async fn blank_token_rejected_for_public_bind() { + let opts = Options { + bind: "0.0.0.0:0".parse().unwrap(), + token: Some(" ".into()), + }; + let err = run(test_state(), opts).await.unwrap_err(); + assert!(err.to_string().contains("not loopback"), "{err}"); } #[tokio::test] @@ -905,6 +959,12 @@ mod tests { op_ids.dedup(); assert_eq!(total, op_ids.len(), "duplicate operationIds"); assert!(doc["components"]["securitySchemes"]["bearerAuth"].is_object()); + // The health probe overrides the document-global bearer requirement (the server + // exempts it in `require_auth`; the spec must agree). + assert_eq!( + doc["paths"]["/api/v1/health"]["get"]["security"], + serde_json::json!([{}]) + ); let checked_in = include_str!("../../../docs/api/openapi.json"); assert_eq!( diff --git a/docs/api/openapi.json b/docs/api/openapi.json index fcac4e5..7568cc0 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -53,7 +53,7 @@ "clients" ], "summary": "Unpair a client", - "description": "Removes the pinned certificate; the client must pair again to reconnect.", + "description": "Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer\ndoes not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.", "operationId": "unpairClient", "parameters": [ { @@ -122,7 +122,10 @@ } } } - } + }, + "security": [ + {} + ] } }, "/api/v1/host": { @@ -211,7 +214,7 @@ "description": "PIN delivered to the waiting handshake" }, "400": { - "description": "Malformed PIN", + "description": "Malformed PIN or unparseable JSON body", "content": { "application/json": { "schema": { @@ -239,6 +242,26 @@ } } } + }, + "415": { + "description": "Body is not application/json", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "422": { + "description": "JSON body does not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } } } }