feat: M2 — management REST API with OpenAPI doc (control-pane groundwork)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
`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
|
(`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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/lumen-core/ protocol · FEC · pacing · crypto — the C ABI (lib + cdylib + staticlib)
|
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)
|
crates/lumen-client-rs/ reference client (M4)
|
||||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||||
clients/{apple,android}/ native client scaffolds (import lumen_core.h)
|
clients/{apple,android}/ native client scaffolds (import lumen_core.h)
|
||||||
|
|||||||
Generated
+62
@@ -1493,6 +1493,7 @@ dependencies = [
|
|||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"http-body-util",
|
||||||
"khronos-egl",
|
"khronos-egl",
|
||||||
"libc",
|
"libc",
|
||||||
"lumen-core",
|
"lumen-core",
|
||||||
@@ -1506,10 +1507,16 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"rusty_enet",
|
"rusty_enet",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"utoipa",
|
||||||
|
"utoipa-axum",
|
||||||
|
"utoipa-scalar",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
"wayland-protocols-misc",
|
"wayland-protocols-misc",
|
||||||
@@ -1790,6 +1797,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -3096,6 +3109,55 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
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]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.2"
|
version = "1.23.2"
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ axum-server = { version = "0.7", features = ["tls-rustls"] }
|
|||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
rustls-pemfile = "2"
|
rustls-pemfile = "2"
|
||||||
rusty_enet = "0.4"
|
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]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
# `screencast` gates the ScreenCast portal module; `remote_desktop` adds the RemoteDesktop
|
# `screencast` gates the ScreenCast portal module; `remote_desktop` adds the RemoteDesktop
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ impl ServerIdentity {
|
|||||||
(c, k)
|
(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<ServerIdentity> {
|
||||||
let priv_key = RsaPrivateKey::from_pkcs8_pem(&key_pem).context("parse host private key")?;
|
let priv_key = RsaPrivateKey::from_pkcs8_pem(&key_pem).context("parse host private key")?;
|
||||||
let signing_key = SigningKey::<Sha256>::new(priv_key);
|
let signing_key = SigningKey::<Sha256>::new(priv_key);
|
||||||
let signature = cert_signature(&cert_pem)?;
|
let signature = cert_signature(&cert_pem)?;
|
||||||
@@ -52,6 +57,12 @@ impl ServerIdentity {
|
|||||||
signing_key,
|
signing_key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Throwaway in-memory identity — nothing touches the config dir (used by tests).
|
||||||
|
pub fn ephemeral() -> Result<ServerIdentity> {
|
||||||
|
let (cert_pem, key_pem) = generate()?;
|
||||||
|
Self::from_pems(cert_pem, key_pem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate() -> Result<(String, String)> {
|
fn generate() -> Result<(String, String)> {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! the media streams follow (see the M2 task list / plan).
|
//! the media streams follow (see the M2 task list / plan).
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
mod cert;
|
pub(crate) mod cert;
|
||||||
mod control;
|
mod control;
|
||||||
mod crypto;
|
mod crypto;
|
||||||
mod input;
|
mod input;
|
||||||
@@ -101,23 +101,31 @@ pub struct AppState {
|
|||||||
pub audio_cap: std::sync::Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>,
|
pub audio_cap: std::sync::Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the GameStream control plane (blocks): mDNS advertisement + the nvhttp servers.
|
impl AppState {
|
||||||
pub fn serve() -> Result<()> {
|
/// 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 host = Host::detect()?;
|
||||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState::new(host, identity));
|
||||||
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)),
|
|
||||||
});
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
hostname = %state.host.hostname,
|
hostname = %state.host.hostname,
|
||||||
uniqueid = %state.host.uniqueid,
|
uniqueid = %state.host.uniqueid,
|
||||||
@@ -131,7 +139,8 @@ pub fn serve() -> Result<()> {
|
|||||||
let _advert = mdns::advertise(&state.host).context("mDNS advertise")?;
|
let _advert = mdns::advertise(&state.host).context("mDNS advertise")?;
|
||||||
rtsp::spawn(state.clone()).context("start RTSP server")?;
|
rtsp::spawn(state.clone()).context("start RTSP server")?;
|
||||||
control::spawn(state.clone()).context("start ENet control 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(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,20 @@ use rsa::signature::{SignatureEncoding, Signer, Verifier};
|
|||||||
use rsa::RsaPublicKey;
|
use rsa::RsaPublicKey;
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the user submits it
|
/// 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 {
|
pub struct PinGate {
|
||||||
pin: Mutex<Option<String>>,
|
pin: Mutex<Option<String>>,
|
||||||
notify: Notify,
|
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 {
|
impl PinGate {
|
||||||
@@ -28,6 +33,7 @@ impl PinGate {
|
|||||||
PinGate {
|
PinGate {
|
||||||
pin: Mutex::new(None),
|
pin: Mutex::new(None),
|
||||||
notify: Notify::new(),
|
notify: Notify::new(),
|
||||||
|
waiters: AtomicUsize::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +42,22 @@ impl PinGate {
|
|||||||
self.notify.notify_waiters();
|
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<String> {
|
async fn take(&self, timeout: Duration) -> Option<String> {
|
||||||
|
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;
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
loop {
|
loop {
|
||||||
if let Some(p) = self.pin.lock().unwrap().take() {
|
if let Some(p) = self.pin.lock().unwrap().take() {
|
||||||
@@ -249,3 +270,33 @@ fn paired_xml(inner: &str, paired: bool) -> String {
|
|||||||
inner
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ mod encode;
|
|||||||
mod gamestream;
|
mod gamestream;
|
||||||
mod inject;
|
mod inject;
|
||||||
mod m0;
|
mod m0;
|
||||||
|
mod mgmt;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod pwinit;
|
mod pwinit;
|
||||||
mod vdisplay;
|
mod vdisplay;
|
||||||
mod web;
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod zerocopy;
|
mod zerocopy;
|
||||||
|
|
||||||
@@ -32,10 +32,12 @@ use m0::{Options, Source};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Logs go to stderr so stdout stays machine-readable (`lumen-host openapi > spec.json`).
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
)
|
)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
if let Err(e) = real_main() {
|
if let Err(e) = real_main() {
|
||||||
@@ -49,8 +51,13 @@ fn real_main() -> Result<()> {
|
|||||||
|
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
match args.first().map(String::as_str) {
|
match args.first().map(String::as_str) {
|
||||||
// M2 GameStream host control plane (P1.1: mDNS + serverinfo).
|
// M2 GameStream host control plane (P1.1: mDNS + serverinfo) + management API.
|
||||||
Some("serve") => gamestream::serve(),
|
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
|
// 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`.
|
// backend and inject a scripted mouse/keyboard pattern. Watch a focused app / `wev`.
|
||||||
Some("input-test") => input_test(),
|
Some("input-test") => input_test(),
|
||||||
@@ -120,6 +127,42 @@ fn input_test() -> Result<()> {
|
|||||||
bail!("input-test requires Linux")
|
bail!("input-test requires Linux")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `serve` options — all about the management API; the GameStream ports are protocol-fixed.
|
||||||
|
fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
|
||||||
|
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<Options> {
|
fn parse_m0(args: &[String]) -> Result<Options> {
|
||||||
let mut source = Source::Portal;
|
let mut source = Source::Portal;
|
||||||
let mut width = 1920u32;
|
let mut width = 1920u32;
|
||||||
@@ -222,10 +265,17 @@ fn print_usage() {
|
|||||||
"lumen-host — Linux streaming host
|
"lumen-host — Linux streaming host
|
||||||
|
|
||||||
USAGE:
|
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
|
lumen-host m0 [OPTIONS] M0 capture→encode→file pipeline spike
|
||||||
|
|
||||||
OPTIONS:
|
SERVE OPTIONS:
|
||||||
|
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990)
|
||||||
|
--mgmt-token <TOKEN> bearer token for the management API (or LUMEN_MGMT_TOKEN);
|
||||||
|
required when --mgmt-bind is not loopback
|
||||||
|
|
||||||
|
M0 OPTIONS:
|
||||||
--source <synthetic|portal|kwin-virtual>
|
--source <synthetic|portal|kwin-virtual>
|
||||||
frame source (default: portal). 'kwin-virtual' creates a
|
frame source (default: portal). 'kwin-virtual' creates a
|
||||||
KWin virtual output at --width x --height and captures it
|
KWin virtual output at --width x --height and captures it
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AppState>,
|
||||||
|
token: Option<String>,
|
||||||
|
/// 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<AppState>, 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<AppState>, token: Option<String>, 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<Arc<MgmtState>>, 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::<String>::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<ApiCodec>,
|
||||||
|
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<Codec> 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<SessionInfo>,
|
||||||
|
/// The RTSP-negotiated stream parameters (present once a client has completed ANNOUNCE).
|
||||||
|
stream: Option<StreamInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// Certificate validity start (unix seconds).
|
||||||
|
not_before_unix: Option<i64>,
|
||||||
|
/// Certificate validity end (unix seconds).
|
||||||
|
not_after_unix: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Arc<MgmtState>>, 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<Health> {
|
||||||
|
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<Arc<MgmtState>>) -> Json<HostInfo> {
|
||||||
|
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<Arc<MgmtState>>) -> Json<RuntimeStatus> {
|
||||||
|
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<Arc<MgmtState>>) -> Json<Vec<PairedClient>> {
|
||||||
|
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<Arc<MgmtState>>,
|
||||||
|
Path(fingerprint): Path<String>,
|
||||||
|
) -> 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<Arc<MgmtState>>) -> Json<PairingStatus> {
|
||||||
|
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<Arc<MgmtState>>,
|
||||||
|
Json(req): Json<SubmitPin>,
|
||||||
|
) -> 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<Arc<MgmtState>>) -> 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<Arc<MgmtState>>) -> 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<AppState> {
|
||||||
|
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<AppState>, token: Option<&str>) -> Router {
|
||||||
|
app(state, token.map(String::from), DEFAULT_PORT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(app: &Router, req: axum::http::Request<Body>) -> (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<Body> {
|
||||||
|
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"<!doctype html>"),
|
||||||
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>) -> 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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user