feat: M0 capture→encode pipeline + M2 GameStream host (pairing, RTSP, video)
M0 (lumen-host) — verified on NVIDIA RTX 5070 Ti / Ubuntu 25.10: headless wlroots → xdg ScreenCast portal → PipeWire → NVENC HEVC → playable file, with each access unit round-tripped through a lumen_core host↔client Session (FEC + packetize + reassemble), 0 mismatches. - capture.rs: SyntheticCapturer + portal capture (ashpd 0.13 + pipewire 0.9), format-aware - encode/linux.rs: NVENC via ffmpeg-next 7 (BGRx/RGB → rgb0, no host-side swscale) - m0.rs: capture→encode→file + lumen-core loopback verification M2 P1 (lumen-host gamestream/) — a stock Moonlight client pairs + launches, verified live: - mDNS _nvstream._tcp + nvhttp /serverinfo (HTTP 47989, mutual-TLS HTTPS 47984) - 4-phase pairing: PIN→AES-128-ECB / SHA-256 / RSA-PKCS1v15 / X.509, custom rustls ClientCertVerifier for the mutual-TLS pairchallenge - /applist, /launch (rikey/rikeyid/mode), hand-rolled RTSP (OPTIONS/DESCRIBE/SETUP×3/ ANNOUNCE/PLAY, one-request-per-TCP-connection per moonlight-common-c's read-to-EOF) - video.rs: GameStream RTP + NV_VIDEO_PACKET wire packetizer, data-shards-only (0% FEC, clean-LAN), unit-tested (single/multi-block) Docs: docs/m2-plan.md (phased plan) + docs/research/ (ground-truth protocol spec). Bootstrap/setup updated for the verified path (libnvidia-gl, render/video groups, GPU EGL, pipewire 0.9). Workspace clippy-clean, tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
//! The host's self-signed RSA-2048 identity: the cert returned to clients as `plaincert`
|
||||
//! during pairing AND presented as the TLS server cert on 47984 (Moonlight pins it). The
|
||||
//! cert's own X.509 signature bytes are an input to the pairing hashes, so we extract them.
|
||||
|
||||
use super::config_dir;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use rsa::pkcs1v15::SigningKey;
|
||||
use rsa::pkcs8::DecodePrivateKey;
|
||||
use rsa::RsaPrivateKey;
|
||||
use sha2::Sha256;
|
||||
use std::fs;
|
||||
|
||||
pub struct ServerIdentity {
|
||||
/// PEM of the cert (returned hex-encoded as `plaincert`; also the TLS server cert).
|
||||
pub cert_pem: String,
|
||||
/// PKCS#8 PEM of the private key (TLS server key).
|
||||
pub key_pem: String,
|
||||
/// The cert's X.509 `signatureValue` bytes — bound into the pairing challenge hashes.
|
||||
pub signature: Vec<u8>,
|
||||
/// RSA-PKCS1v15-SHA256 signer over the host key (the pairing `sign256`).
|
||||
pub signing_key: SigningKey<Sha256>,
|
||||
}
|
||||
|
||||
impl ServerIdentity {
|
||||
pub fn load_or_create() -> Result<ServerIdentity> {
|
||||
let dir = config_dir();
|
||||
let cert_path = dir.join("cert.pem");
|
||||
let key_path = dir.join("key.pem");
|
||||
let (cert_pem, key_pem) = match (
|
||||
fs::read_to_string(&cert_path),
|
||||
fs::read_to_string(&key_path),
|
||||
) {
|
||||
(Ok(c), Ok(k)) if !c.trim().is_empty() && !k.trim().is_empty() => (c, k),
|
||||
_ => {
|
||||
let (c, k) = generate()?;
|
||||
fs::create_dir_all(&dir).ok();
|
||||
fs::write(&cert_path, &c)
|
||||
.with_context(|| format!("write {}", cert_path.display()))?;
|
||||
fs::write(&key_path, &k)
|
||||
.with_context(|| format!("write {}", key_path.display()))?;
|
||||
tracing::info!(path = %cert_path.display(), "generated lumen host certificate (RSA-2048)");
|
||||
(c, k)
|
||||
}
|
||||
};
|
||||
let priv_key = RsaPrivateKey::from_pkcs8_pem(&key_pem).context("parse host private key")?;
|
||||
let signing_key = SigningKey::<Sha256>::new(priv_key);
|
||||
let signature = cert_signature(&cert_pem)?;
|
||||
Ok(ServerIdentity {
|
||||
cert_pem,
|
||||
key_pem,
|
||||
signature,
|
||||
signing_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn generate() -> Result<(String, String)> {
|
||||
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_RSA_SHA256).context("rcgen RSA keygen")?;
|
||||
let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?;
|
||||
params
|
||||
.distinguished_name
|
||||
.push(rcgen::DnType::CommonName, "lumen");
|
||||
params.not_before = rcgen::date_time_ymd(2020, 1, 1);
|
||||
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
|
||||
let cert = params.self_signed(&key).context("self-sign cert")?;
|
||||
Ok((cert.pem(), key.serialize_pem()))
|
||||
}
|
||||
|
||||
/// Extract the X.509 `signatureValue` bytes from a cert PEM.
|
||||
fn cert_signature(cert_pem: &str) -> Result<Vec<u8>> {
|
||||
let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes())
|
||||
.map_err(|e| anyhow!("parse cert pem: {e}"))?;
|
||||
let x509 = pem.parse_x509().context("parse x509")?;
|
||||
Ok(x509.signature_value.data.to_vec())
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Pairing crypto primitives (control plane only — distinct from `lumen_core`'s AES-GCM
|
||||
//! data-plane sealing). GameStream pairing uses: AES-128-**ECB** with **no padding**,
|
||||
//! SHA-256 (host appversion major ≥ 7), and RSA-PKCS1v15-SHA256 signatures. See the
|
||||
//! `serverinfo + pairing` section of `docs/research/gamestream-protocol-research.json`.
|
||||
|
||||
use aes::cipher::generic_array::GenericArray;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// `n` cryptographically-random bytes.
|
||||
pub fn random<const N: usize>() -> [u8; N] {
|
||||
let mut b = [0u8; N];
|
||||
rand::thread_rng().fill_bytes(&mut b);
|
||||
b
|
||||
}
|
||||
|
||||
/// SHA-256 over the concatenation of `parts`.
|
||||
pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
||||
let mut h = Sha256::new();
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
h.finalize().into()
|
||||
}
|
||||
|
||||
/// The PIN-derived AES-128 key: `SHA-256(salt || pin)[..16]` (salt first, PIN as ASCII).
|
||||
pub fn pin_key(salt: &[u8; 16], pin: &str) -> [u8; 16] {
|
||||
let d = sha256(&[salt, pin.as_bytes()]);
|
||||
let mut k = [0u8; 16];
|
||||
k.copy_from_slice(&d[..16]);
|
||||
k
|
||||
}
|
||||
|
||||
/// AES-128-ECB encrypt, no padding: input is zero-extended to a 16-byte multiple.
|
||||
pub fn ecb_encrypt(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut out = data.to_vec();
|
||||
let rem = out.len() % 16;
|
||||
if rem != 0 {
|
||||
out.resize(out.len() + (16 - rem), 0);
|
||||
}
|
||||
for chunk in out.chunks_mut(16) {
|
||||
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-ECB decrypt, no padding: trailing bytes past the last whole block are ignored.
|
||||
pub fn ecb_decrypt(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
for chunk in data.chunks_exact(16) {
|
||||
let mut block = *GenericArray::from_slice(chunk);
|
||||
cipher.decrypt_block(&mut block);
|
||||
out.extend_from_slice(&block);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! mDNS advertisement of `_nvstream._tcp.local.` so Moonlight auto-discovers the host.
|
||||
//! (Manual "add host by IP" also works as a fallback, which is what we test with first.)
|
||||
|
||||
use super::Host;
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Holds the mDNS daemon; dropping it unregisters the service.
|
||||
pub struct Advert {
|
||||
_daemon: ServiceDaemon,
|
||||
}
|
||||
|
||||
pub fn advertise(host: &Host) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
let host_name = format!("{}.local.", host.hostname);
|
||||
// No TXT records are required for Moonlight discovery; it resolves the A record and then
|
||||
// GETs /serverinfo for capabilities.
|
||||
let props: HashMap<String, String> = HashMap::new();
|
||||
let service = ServiceInfo::new(
|
||||
"_nvstream._tcp.local.",
|
||||
&host.hostname,
|
||||
&host_name,
|
||||
host.local_ip,
|
||||
host.http_port,
|
||||
props,
|
||||
)
|
||||
.context("build mDNS ServiceInfo")?;
|
||||
daemon.register(service).context("register mDNS service")?;
|
||||
tracing::info!(
|
||||
service = "_nvstream._tcp",
|
||||
port = host.http_port,
|
||||
host = %host_name,
|
||||
"mDNS advertising"
|
||||
);
|
||||
Ok(Advert { _daemon: daemon })
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! GameStream (P1) control plane — what a stock Moonlight/Artemis client talks to around
|
||||
//! the media streams: mDNS discovery, the nvhttp serverinfo + pairing HTTP(S) API, RTSP,
|
||||
//! and the ENet control stream. `tokio`/`axum` live here (control plane, I/O-bound — never
|
||||
//! the per-frame hot path; that is `lumen_core`'s P1 wire codec). See `docs/m2-plan.md`.
|
||||
//!
|
||||
//! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and
|
||||
//! the media streams follow (see the M2 task list / plan).
|
||||
|
||||
mod cert;
|
||||
mod crypto;
|
||||
mod mdns;
|
||||
mod nvhttp;
|
||||
mod pairing;
|
||||
mod rtsp;
|
||||
mod serverinfo;
|
||||
mod tls;
|
||||
mod video;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::{IpAddr, Ipv4Addr, UdpSocket};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// nvhttp ports (Moonlight derives all stream ports by offset from the HTTP base 47989).
|
||||
pub const HTTP_PORT: u16 = 47989;
|
||||
pub const HTTPS_PORT: u16 = 47984;
|
||||
pub const RTSP_PORT: u16 = 48010;
|
||||
pub const VIDEO_PORT: u16 = 47998;
|
||||
pub const CONTROL_PORT: u16 = 47999;
|
||||
pub const AUDIO_PORT: u16 = 48000;
|
||||
|
||||
/// Advertised host version. Major ≥ 7 tells Moonlight to use SHA-256 for pairing.
|
||||
pub const APP_VERSION: &str = "7.1.431.-1";
|
||||
pub const GFE_VERSION: &str = "3.23.0.74";
|
||||
/// Codec support bitmask: 3=H264, 259=+HEVC, 3843=+AV1 (we encode HEVC/H264/AV1 via NVENC).
|
||||
pub const SERVER_CODEC_MODE_SUPPORT: u32 = 3843;
|
||||
|
||||
/// Stable host identity + advertised capabilities, shared across control-plane handlers.
|
||||
pub struct Host {
|
||||
pub hostname: String,
|
||||
/// Stable per-host id (persisted), echoed in serverinfo + matched on pairing.
|
||||
pub uniqueid: String,
|
||||
pub local_ip: IpAddr,
|
||||
pub http_port: u16,
|
||||
pub https_port: u16,
|
||||
// Pairing state (server cert, paired client certs) lands in the next P1.1 slice.
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn detect() -> Result<Host> {
|
||||
Ok(Host {
|
||||
hostname: hostname_string(),
|
||||
uniqueid: load_or_create_uniqueid()?,
|
||||
local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The stream parameters a client passes at `/launch`, shared with the RTSP + media stages.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LaunchSession {
|
||||
/// AES-128 key for the RTSP/control/video/audio planes (from `rikey`).
|
||||
pub gcm_key: [u8; 16],
|
||||
/// `rikeyid` — seeds the per-stream GCM IVs.
|
||||
pub rikeyid: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
}
|
||||
|
||||
/// Shared control-plane state used as the axum app state.
|
||||
pub struct AppState {
|
||||
pub host: Host,
|
||||
pub identity: cert::ServerIdentity,
|
||||
pub pairing: pairing::Pairing,
|
||||
/// Pinned (paired) client certificate DERs — the post-pair allow-list.
|
||||
pub paired: std::sync::Mutex<Vec<Vec<u8>>>,
|
||||
/// The active launch session (set by `/launch`, consumed by RTSP/media).
|
||||
pub launch: std::sync::Mutex<Option<LaunchSession>>,
|
||||
}
|
||||
|
||||
/// Run the GameStream control plane (blocks): mDNS advertisement + the nvhttp servers.
|
||||
pub fn serve() -> 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),
|
||||
});
|
||||
tracing::info!(
|
||||
hostname = %state.host.hostname,
|
||||
uniqueid = %state.host.uniqueid,
|
||||
ip = %state.host.local_ip,
|
||||
"lumen GameStream host (P1.1: serverinfo + pairing + mDNS)"
|
||||
);
|
||||
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
|
||||
rt.block_on(async move {
|
||||
// rustls needs a process-wide crypto provider before any TLS config is built.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let _advert = mdns::advertise(&state.host).context("mDNS advertise")?;
|
||||
rtsp::spawn(state.clone()).context("start RTSP server")?;
|
||||
nvhttp::run(state).await
|
||||
})
|
||||
}
|
||||
|
||||
/// `~/.config/lumen`, created on demand — host identity + (later) pairing state live here.
|
||||
fn config_dir() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("lumen")
|
||||
}
|
||||
|
||||
fn hostname_string() -> String {
|
||||
std::fs::read_to_string("/proc/sys/kernel/hostname")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "lumen-host".to_string())
|
||||
}
|
||||
|
||||
/// Load the persisted host uniqueid, or mint one (from the kernel UUID source) and store it.
|
||||
fn load_or_create_uniqueid() -> Result<String> {
|
||||
let path = config_dir().join("uniqueid");
|
||||
if let Ok(s) = std::fs::read_to_string(&path) {
|
||||
let t = s.trim();
|
||||
if !t.is_empty() {
|
||||
return Ok(t.to_string());
|
||||
}
|
||||
}
|
||||
let id = std::fs::read_to_string("/proc/sys/kernel/random/uuid")
|
||||
.map(|u| u.trim().replace('-', ""))
|
||||
.unwrap_or_else(|_| format!("{:016x}{:016x}", std::process::id(), HTTP_PORT));
|
||||
std::fs::create_dir_all(config_dir()).ok();
|
||||
std::fs::write(&path, &id).with_context(|| format!("write {}", path.display()))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Best-effort primary LAN IP: open a UDP socket "toward" a public address and read the
|
||||
/// local address the OS would route through. No packets are actually sent.
|
||||
fn primary_local_ip() -> Option<IpAddr> {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
sock.connect("8.8.8.8:80").ok()?;
|
||||
sock.local_addr().ok().map(|a| a.ip())
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! The nvhttp servers: plain HTTP on 47989 and mutual-TLS on 47984. Serves `/serverinfo`,
|
||||
//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`, plus a lumen-only
|
||||
//! `/pin` endpoint to deliver the Moonlight-displayed PIN. Over HTTPS the client is
|
||||
//! mutual-TLS-authenticated, so `/serverinfo` reports `PairStatus=1` there.
|
||||
|
||||
use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_PORT};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Extension, Router,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Which listener a request arrived on — HTTPS means a mutual-TLS-authenticated client.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Https(bool);
|
||||
|
||||
pub async fn run(state: Arc<AppState>) -> Result<()> {
|
||||
// Mutual-TLS: request + verify the client cert (Moonlight presents one for the
|
||||
// post-pairing pairchallenge + all post-pair endpoints).
|
||||
let tls = axum_server::tls_rustls::RustlsConfig::from_config(super::tls::server_config(
|
||||
&state.identity.cert_pem,
|
||||
&state.identity.key_pem,
|
||||
)?);
|
||||
|
||||
let http_addr = SocketAddr::from(([0, 0, 0, 0], HTTP_PORT));
|
||||
let https_addr = SocketAddr::from(([0, 0, 0, 0], HTTPS_PORT));
|
||||
tracing::info!(%http_addr, %https_addr, "nvhttp listening (serverinfo + pair + launch)");
|
||||
|
||||
let http = axum_server::bind(http_addr).serve(router(state.clone(), false).into_make_service());
|
||||
let https =
|
||||
axum_server::bind_rustls(https_addr, tls).serve(router(state, true).into_make_service());
|
||||
tokio::try_join!(async { http.await.context("nvhttp HTTP server") }, async {
|
||||
https.await.context("nvhttp HTTPS server")
|
||||
},)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn router(state: Arc<AppState>, https: bool) -> Router {
|
||||
Router::new()
|
||||
.route("/serverinfo", get(h_serverinfo))
|
||||
.route("/pair", get(h_pair))
|
||||
.route("/pin", get(h_pin))
|
||||
.route("/applist", get(h_applist))
|
||||
.route("/launch", get(h_launch))
|
||||
.route("/resume", get(h_resume))
|
||||
.route("/cancel", get(h_cancel))
|
||||
.layer(Extension(Https(https)))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn xml(body: String) -> impl IntoResponse {
|
||||
([(header::CONTENT_TYPE, "application/xml")], body)
|
||||
}
|
||||
|
||||
async fn h_serverinfo(
|
||||
State(st): State<Arc<AppState>>,
|
||||
Extension(Https(https)): Extension<Https>,
|
||||
) -> impl IntoResponse {
|
||||
// Over the mutual-TLS port the peer is an authenticated (paired) client → PairStatus=1.
|
||||
xml(serverinfo::serverinfo_xml(&st.host, https))
|
||||
}
|
||||
|
||||
async fn h_pin(
|
||||
State(st): State<Arc<AppState>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
match q.get("pin").filter(|p| !p.is_empty()) {
|
||||
Some(pin) => {
|
||||
st.pairing.pin.submit(pin.clone());
|
||||
"PIN accepted\n".to_string()
|
||||
}
|
||||
None => "usage: GET /pin?pin=NNNN\n".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_applist(State(_st): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
// One app for now: the headless desktop (the wlroots virtual output).
|
||||
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<App>\n<IsHdrSupported>0</IsHdrSupported>\n<AppTitle>Desktop</AppTitle>\n<ID>1</ID>\n</App>\n</root>\n".to_string())
|
||||
}
|
||||
|
||||
async fn h_launch(
|
||||
State(st): State<Arc<AppState>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
match launch(&st, &q) {
|
||||
Ok(session) => {
|
||||
*st.launch.lock().unwrap() = Some(session);
|
||||
tracing::info!(
|
||||
w = session.width,
|
||||
h = session.height,
|
||||
fps = session.fps,
|
||||
rikeyid = session.rikeyid,
|
||||
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
|
||||
st.host.local_ip
|
||||
);
|
||||
xml(session_url_xml(&st, "gamesession"))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "launch failed");
|
||||
xml(error_xml())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_resume(State(st): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
if st.launch.lock().unwrap().is_some() {
|
||||
xml(session_url_xml(&st, "resume"))
|
||||
} else {
|
||||
xml(error_xml())
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_cancel(State(st): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
*st.launch.lock().unwrap() = None;
|
||||
tracing::info!("cancel — launch session cleared");
|
||||
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><cancel>1</cancel></root>\n".to_string())
|
||||
}
|
||||
|
||||
/// Parse the `/launch` query (rikey/rikeyid/mode) into a [`LaunchSession`].
|
||||
fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession> {
|
||||
let rikey = q.get("rikey").ok_or_else(|| anyhow!("missing rikey"))?;
|
||||
let key_bytes = hex::decode(rikey).context("rikey hex")?;
|
||||
if key_bytes.len() < 16 {
|
||||
return Err(anyhow!("rikey too short"));
|
||||
}
|
||||
let mut gcm_key = [0u8; 16];
|
||||
gcm_key.copy_from_slice(&key_bytes[..16]);
|
||||
// rikeyid is a signed 32-bit int (negative values wrap to a big-endian u32 IV later).
|
||||
let rikeyid: i32 = q.get("rikeyid").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let (width, height, fps) = q
|
||||
.get("mode")
|
||||
.and_then(|m| parse_mode(m))
|
||||
.unwrap_or((1920, 1080, 60));
|
||||
Ok(LaunchSession {
|
||||
gcm_key,
|
||||
rikeyid,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
})
|
||||
}
|
||||
|
||||
/// `"1920x1080x60"` → `(1920, 1080, 60)`.
|
||||
fn parse_mode(mode: &str) -> Option<(u32, u32, u32)> {
|
||||
let mut it = mode.split('x');
|
||||
let w = it.next()?.parse().ok()?;
|
||||
let h = it.next()?.parse().ok()?;
|
||||
let fps = it.next()?.parse().ok()?;
|
||||
Some((w, h, fps))
|
||||
}
|
||||
|
||||
fn session_url_xml(st: &AppState, tag: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
|
||||
st.host.local_ip
|
||||
)
|
||||
}
|
||||
|
||||
async fn h_pair(
|
||||
State(st): State<Arc<AppState>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let uniqueid = q.get("uniqueid").cloned().unwrap_or_default();
|
||||
let phrase = q.get("phrase").map(String::as_str);
|
||||
|
||||
let step = phrase
|
||||
.filter(|p| *p == "getservercert" || *p == "pairchallenge")
|
||||
.or_else(|| {
|
||||
[
|
||||
"clientchallenge",
|
||||
"serverchallengeresp",
|
||||
"clientpairingsecret",
|
||||
]
|
||||
.into_iter()
|
||||
.find(|k| q.contains_key(*k))
|
||||
})
|
||||
.unwrap_or("?");
|
||||
tracing::info!(uniqueid, step, "pair request");
|
||||
|
||||
let result = if phrase == Some("getservercert") {
|
||||
match (q.get("salt"), q.get("clientcert")) {
|
||||
(Some(salt), Some(cc)) => {
|
||||
st.pairing
|
||||
.getservercert(&st.identity, &uniqueid, salt, cc)
|
||||
.await
|
||||
}
|
||||
_ => Ok(pair_error_xml()),
|
||||
}
|
||||
} else if phrase == Some("pairchallenge") {
|
||||
// Reached only over the TLS port with the pinned host cert; the handshake is the
|
||||
// proof, so acknowledge success.
|
||||
Ok(paired_ok_xml())
|
||||
} else if let Some(v) = q.get("clientchallenge") {
|
||||
st.pairing.clientchallenge(&st.identity, &uniqueid, v)
|
||||
} else if let Some(v) = q.get("serverchallengeresp") {
|
||||
st.pairing.serverchallengeresp(&st.identity, &uniqueid, v)
|
||||
} else if let Some(v) = q.get("clientpairingsecret") {
|
||||
st.pairing.clientpairingsecret(&uniqueid, v, &st.paired)
|
||||
} else {
|
||||
Ok(pair_error_xml())
|
||||
};
|
||||
|
||||
let body = result.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %format!("{e:#}"), uniqueid, "pair handler error");
|
||||
pair_error_xml()
|
||||
});
|
||||
xml(body)
|
||||
}
|
||||
|
||||
fn paired_ok_xml() -> String {
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><paired>1</paired></root>\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn pair_error_xml() -> String {
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><paired>0</paired></root>\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn error_xml() -> String {
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"400\"></root>\n".to_string()
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! The 4-phase GameStream pairing state machine (over HTTP), keyed by `uniqueid`. Proves
|
||||
//! both sides know the PIN (via the SHA-256(salt||pin) AES-ECB key) and own their certs
|
||||
//! (RSA signatures), then pins the client cert. The final `pairchallenge` happens over
|
||||
//! HTTPS (handled in `nvhttp`). Byte-exact spec: `docs/research/…-research.json`.
|
||||
|
||||
use super::cert::ServerIdentity;
|
||||
use super::crypto;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use rsa::pkcs1v15::{Signature, VerifyingKey};
|
||||
use rsa::pkcs8::DecodePublicKey;
|
||||
use rsa::signature::{SignatureEncoding, Signer, Verifier};
|
||||
use rsa::RsaPublicKey;
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
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.
|
||||
pub struct PinGate {
|
||||
pin: Mutex<Option<String>>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
impl PinGate {
|
||||
fn new() -> Self {
|
||||
PinGate {
|
||||
pin: Mutex::new(None),
|
||||
notify: Notify::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(&self, pin: String) {
|
||||
*self.pin.lock().unwrap() = Some(pin);
|
||||
self.notify.notify_waiters();
|
||||
}
|
||||
|
||||
async fn take(&self, timeout: Duration) -> Option<String> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(p) = self.pin.lock().unwrap().take() {
|
||||
return Some(p);
|
||||
}
|
||||
if tokio::time::timeout_at(deadline, self.notify.notified())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-client pairing session carried across the 4 separate HTTP GETs.
|
||||
struct Session {
|
||||
aes_key: [u8; 16],
|
||||
client_cert_der: Vec<u8>,
|
||||
client_cert_sig: Vec<u8>,
|
||||
client_pubkey: RsaPublicKey,
|
||||
serversecret: [u8; 16],
|
||||
server_challenge: [u8; 16],
|
||||
/// The client's phase-3 hash, recomputed + checked in phase 4.
|
||||
client_hash: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct Pairing {
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
pub pin: PinGate,
|
||||
}
|
||||
|
||||
impl Pairing {
|
||||
pub fn new() -> Self {
|
||||
Pairing {
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
pin: PinGate::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 1: store the client cert, await the PIN, derive the AES key, return our cert.
|
||||
pub async fn getservercert(
|
||||
&self,
|
||||
id: &ServerIdentity,
|
||||
uniqueid: &str,
|
||||
salt_hex: &str,
|
||||
clientcert_hex: &str,
|
||||
) -> Result<String> {
|
||||
let salt_bytes = hex::decode(salt_hex).context("salt hex")?;
|
||||
if salt_bytes.len() < 16 {
|
||||
bail!("salt too short");
|
||||
}
|
||||
let mut salt = [0u8; 16];
|
||||
salt.copy_from_slice(&salt_bytes[..16]);
|
||||
let pem_bytes = hex::decode(clientcert_hex).context("clientcert hex")?;
|
||||
let (der, sig, pubkey) = parse_client_cert(&pem_bytes)?;
|
||||
|
||||
tracing::info!(
|
||||
uniqueid,
|
||||
"pairing phase 1 (getservercert) — awaiting PIN: submit `GET /pin?pin=NNNN`"
|
||||
);
|
||||
let pin = self
|
||||
.pin
|
||||
.take(Duration::from_secs(300))
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("no PIN submitted within 300s"))?;
|
||||
let aes_key = crypto::pin_key(&salt, &pin);
|
||||
|
||||
self.sessions.lock().unwrap().insert(
|
||||
uniqueid.to_string(),
|
||||
Session {
|
||||
aes_key,
|
||||
client_cert_der: der,
|
||||
client_cert_sig: sig,
|
||||
client_pubkey: pubkey,
|
||||
serversecret: [0; 16],
|
||||
server_challenge: [0; 16],
|
||||
client_hash: Vec::new(),
|
||||
},
|
||||
);
|
||||
tracing::info!(
|
||||
uniqueid,
|
||||
"pairing phase 1 — PIN accepted, returning host cert"
|
||||
);
|
||||
let inner = format!(
|
||||
"<plaincert>{}</plaincert>",
|
||||
hex::encode(id.cert_pem.as_bytes())
|
||||
);
|
||||
Ok(paired_xml(&inner, true))
|
||||
}
|
||||
|
||||
/// Phase 2: decrypt the client challenge, return our hash + server challenge.
|
||||
pub fn clientchallenge(
|
||||
&self,
|
||||
id: &ServerIdentity,
|
||||
uniqueid: &str,
|
||||
hexv: &str,
|
||||
) -> Result<String> {
|
||||
let mut map = self.sessions.lock().unwrap();
|
||||
let s = map
|
||||
.get_mut(uniqueid)
|
||||
.ok_or_else(|| anyhow!("no pairing session"))?;
|
||||
let enc = hex::decode(hexv).context("clientchallenge hex")?;
|
||||
let client_challenge = crypto::ecb_decrypt(&s.aes_key, &enc);
|
||||
if client_challenge.len() < 16 {
|
||||
bail!("short client challenge");
|
||||
}
|
||||
s.serversecret = crypto::random();
|
||||
s.server_challenge = crypto::random();
|
||||
let server_hash =
|
||||
crypto::sha256(&[&client_challenge[..16], &id.signature, &s.serversecret]);
|
||||
let mut plain = Vec::with_capacity(48);
|
||||
plain.extend_from_slice(&server_hash);
|
||||
plain.extend_from_slice(&s.server_challenge);
|
||||
let resp = crypto::ecb_encrypt(&s.aes_key, &plain);
|
||||
let inner = format!(
|
||||
"<challengeresponse>{}</challengeresponse>",
|
||||
hex::encode(resp)
|
||||
);
|
||||
Ok(paired_xml(&inner, true))
|
||||
}
|
||||
|
||||
/// Phase 3: store the client's hash, return our RSA-signed serversecret.
|
||||
pub fn serverchallengeresp(
|
||||
&self,
|
||||
id: &ServerIdentity,
|
||||
uniqueid: &str,
|
||||
hexv: &str,
|
||||
) -> Result<String> {
|
||||
let mut map = self.sessions.lock().unwrap();
|
||||
let s = map
|
||||
.get_mut(uniqueid)
|
||||
.ok_or_else(|| anyhow!("no pairing session"))?;
|
||||
let enc = hex::decode(hexv).context("serverchallengeresp hex")?;
|
||||
let client_hash = crypto::ecb_decrypt(&s.aes_key, &enc);
|
||||
if client_hash.len() < 32 {
|
||||
bail!("short challenge response");
|
||||
}
|
||||
s.client_hash = client_hash[..32].to_vec();
|
||||
let sig: Signature = id.signing_key.sign(&s.serversecret);
|
||||
let mut secret = Vec::with_capacity(16 + 256);
|
||||
secret.extend_from_slice(&s.serversecret);
|
||||
secret.extend_from_slice(&sig.to_vec());
|
||||
let inner = format!("<pairingsecret>{}</pairingsecret>", hex::encode(secret));
|
||||
Ok(paired_xml(&inner, true))
|
||||
}
|
||||
|
||||
/// Phase 4: verify the client knew the PIN (hash match) and owns its cert (RSA verify);
|
||||
/// on success, pin the client cert.
|
||||
pub fn clientpairingsecret(
|
||||
&self,
|
||||
uniqueid: &str,
|
||||
hexv: &str,
|
||||
paired_store: &Mutex<Vec<Vec<u8>>>,
|
||||
) -> Result<String> {
|
||||
let mut map = self.sessions.lock().unwrap();
|
||||
let s = map
|
||||
.get_mut(uniqueid)
|
||||
.ok_or_else(|| anyhow!("no pairing session"))?;
|
||||
let data = hex::decode(hexv).context("clientpairingsecret hex")?;
|
||||
if data.len() < 16 {
|
||||
bail!("short pairing secret");
|
||||
}
|
||||
let client_secret = &data[..16];
|
||||
let client_sig = &data[16..];
|
||||
let expected = crypto::sha256(&[&s.server_challenge, &s.client_cert_sig, client_secret]);
|
||||
let hash_ok = expected[..] == s.client_hash[..];
|
||||
let sig_ok = verify256(&s.client_pubkey, client_secret, client_sig).is_ok();
|
||||
if hash_ok && sig_ok {
|
||||
paired_store.lock().unwrap().push(s.client_cert_der.clone());
|
||||
tracing::info!(uniqueid, "pairing phase 4 — SUCCESS, client cert pinned");
|
||||
Ok(paired_xml("", true))
|
||||
} else {
|
||||
tracing::warn!(
|
||||
uniqueid,
|
||||
hash_ok,
|
||||
sig_ok,
|
||||
"pairing phase 4 — FAILED (PIN/cert)"
|
||||
);
|
||||
map.remove(uniqueid);
|
||||
Ok(paired_xml("", false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify256(pubkey: &RsaPublicKey, msg: &[u8], sig: &[u8]) -> Result<()> {
|
||||
let vk = VerifyingKey::<Sha256>::new(pubkey.clone());
|
||||
let signature = Signature::try_from(sig).context("parse client signature")?;
|
||||
vk.verify(msg, &signature)
|
||||
.context("verify client signature")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_client_cert(pem_bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>, RsaPublicKey)> {
|
||||
let (_, pem) =
|
||||
x509_parser::pem::parse_x509_pem(pem_bytes).map_err(|e| anyhow!("client cert pem: {e}"))?;
|
||||
let der = pem.contents.clone();
|
||||
let x509 = pem.parse_x509().context("parse client x509")?;
|
||||
let sig = x509.signature_value.data.to_vec();
|
||||
let pubkey =
|
||||
RsaPublicKey::from_public_key_der(x509.public_key().raw).context("client rsa pubkey")?;
|
||||
Ok((der, sig, pubkey))
|
||||
}
|
||||
|
||||
/// `<root status_code="200"><paired>0|1</paired> inner </root>`.
|
||||
fn paired_xml(inner: &str, paired: bool) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<paired>{}</paired>\n{}</root>\n",
|
||||
u8::from(paired),
|
||||
inner
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! The GameStream RTSP handshake (TCP 48010). Hand-rolled because GameStream's RTSP is
|
||||
//! non-standard (streamid= targets, the literal `DEADBEEFCAFE` session, the X-SS-* headers)
|
||||
//! and off-the-shelf RTSP crates assume standard semantics. Sequence Moonlight drives:
|
||||
//! OPTIONS → DESCRIBE → SETUP(audio/video/control) → ANNOUNCE → PLAY. ANNOUNCE carries the
|
||||
//! negotiated stream config; PLAY is where the media stages start (P1.3+).
|
||||
//!
|
||||
//! Runs on its own native thread (control-plane setup, not the per-frame hot path), one
|
||||
//! thread per connection. Plaintext only for now (encryption is negotiated; P1.5).
|
||||
|
||||
use super::{AppState, AUDIO_PORT, CONTROL_PORT, RTSP_PORT, VIDEO_PORT};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Opaque per-session payload the client echoes as its first UDP datagram (port-learning).
|
||||
const PING_PAYLOAD: &str = "0011223344556677";
|
||||
|
||||
/// Bind 48010 and accept RTSP connections on a dedicated thread.
|
||||
pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
let listener = TcpListener::bind(("0.0.0.0", RTSP_PORT))
|
||||
.with_context(|| format!("bind RTSP {RTSP_PORT}"))?;
|
||||
tracing::info!(port = RTSP_PORT, "RTSP listening");
|
||||
std::thread::Builder::new()
|
||||
.name("lumen-rtsp".into())
|
||||
.spawn(move || {
|
||||
for conn in listener.incoming() {
|
||||
match conn {
|
||||
Ok(stream) => {
|
||||
let st = state.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = handle_conn(stream, st) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "RTSP connection ended");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "RTSP accept failed"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.context("spawn RTSP thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Request {
|
||||
method: String,
|
||||
uri: String,
|
||||
cseq: String,
|
||||
head: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
|
||||
let peer = stream.peer_addr().ok();
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
// GameStream RTSP is one request per TCP connection: moonlight-common-c reads the
|
||||
// response until EOF, so we answer one message and close the connection (which signals
|
||||
// the end of the response). Session state lives in `AppState`, not the connection.
|
||||
if let Some(req) = read_message(&mut stream, &mut buf)? {
|
||||
tracing::info!(
|
||||
method = %req.method, cseq = %req.cseq,
|
||||
"RTSP {} | {}", req.head.replace("\r\n", " | "),
|
||||
if req.body.is_empty() { String::new() } else { format!("body: {}", req.body.replace("\r\n", " | ")) }
|
||||
);
|
||||
let resp = handle_request(&req, &state);
|
||||
stream.write_all(resp.as_bytes()).context("RTSP write")?;
|
||||
stream.flush().ok();
|
||||
// Close (FIN after the flushed response) so the client detects end-of-response.
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
let _ = peer;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read one complete RTSP message (headers + any Content-Length body) from the stream,
|
||||
/// buffering across reads and leaving any pipelined remainder in `buf`.
|
||||
fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Request>> {
|
||||
loop {
|
||||
if let Some(end) = find_subslice(buf, b"\r\n\r\n") {
|
||||
let head = std::str::from_utf8(&buf[..end]).context("RTSP header utf8")?;
|
||||
let content_len = header_value(head, "content-length")
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let total = end + 4 + content_len;
|
||||
if buf.len() < total {
|
||||
// headers complete but body still arriving — read more
|
||||
} else {
|
||||
let head = head.to_string();
|
||||
let body = String::from_utf8_lossy(&buf[end + 4..total]).into_owned();
|
||||
buf.drain(..total);
|
||||
return Ok(Some(parse_request(&head, body)));
|
||||
}
|
||||
}
|
||||
let mut tmp = [0u8; 8192];
|
||||
let n = stream.read(&mut tmp).context("RTSP read")?;
|
||||
if n == 0 {
|
||||
return Ok(None); // peer closed
|
||||
}
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_request(head: &str, body: String) -> Request {
|
||||
let mut lines = head.split("\r\n");
|
||||
let request_line = lines.next().unwrap_or("");
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let method = parts.next().unwrap_or("").to_string();
|
||||
let uri = parts.next().unwrap_or("").to_string();
|
||||
let cseq = header_value(head, "cseq").unwrap_or("0").trim().to_string();
|
||||
Request {
|
||||
method,
|
||||
uri,
|
||||
cseq,
|
||||
head: head.to_string(),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(req: &Request, state: &AppState) -> String {
|
||||
match req.method.as_str() {
|
||||
"OPTIONS" => response(
|
||||
&req.cseq,
|
||||
&[("Public", "OPTIONS DESCRIBE SETUP ANNOUNCE PLAY TEARDOWN")],
|
||||
None,
|
||||
),
|
||||
"DESCRIBE" => response(
|
||||
&req.cseq,
|
||||
&[("Content-Type", "application/sdp")],
|
||||
Some(&describe_sdp()),
|
||||
),
|
||||
"SETUP" => {
|
||||
let (port, extra_key) = match stream_type(&req.uri) {
|
||||
Some("audio") => (AUDIO_PORT, "X-SS-Ping-Payload"),
|
||||
Some("video") => (VIDEO_PORT, "X-SS-Ping-Payload"),
|
||||
Some("control") => (CONTROL_PORT, "X-SS-Connect-Data"),
|
||||
_ => return response_status("404 Not Found", &req.cseq, &[], None),
|
||||
};
|
||||
let transport = format!("server_port={port}");
|
||||
response(
|
||||
&req.cseq,
|
||||
&[
|
||||
("Session", "DEADBEEFCAFE;timeout = 90"),
|
||||
("Transport", &transport),
|
||||
(extra_key, PING_PAYLOAD),
|
||||
],
|
||||
None,
|
||||
)
|
||||
}
|
||||
"ANNOUNCE" => {
|
||||
let cfg = parse_announce(&req.body);
|
||||
tracing::info!(
|
||||
width = cfg
|
||||
.get("x-nv-video[0].clientViewportWd")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("?"),
|
||||
height = cfg
|
||||
.get("x-nv-video[0].clientViewportHt")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("?"),
|
||||
fps = cfg
|
||||
.get("x-nv-video[0].maxFPS")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("?"),
|
||||
bitrate_kbps = cfg
|
||||
.get("x-nv-vqos[0].bw.maximumBitrateKbps")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("?"),
|
||||
packet_size = cfg
|
||||
.get("x-nv-video[0].packetSize")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("?"),
|
||||
codec = cfg
|
||||
.get("x-nv-vqos[0].bitStreamFormat")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("?"),
|
||||
fec_pct = cfg
|
||||
.get("x-nv-vqos[0].fec.repairPercent")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("?"),
|
||||
"RTSP ANNOUNCE — negotiated stream config"
|
||||
);
|
||||
// TODO(P1.3): map `cfg` → lumen_core::Config and stash it for the media stages.
|
||||
let _ = state;
|
||||
response(&req.cseq, &[], None)
|
||||
}
|
||||
"PLAY" => {
|
||||
tracing::info!("RTSP PLAY — client ready; media streams would start here (P1.3)");
|
||||
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
|
||||
}
|
||||
"TEARDOWN" => response(&req.cseq, &[], None),
|
||||
other => {
|
||||
tracing::warn!(method = other, "RTSP unsupported method");
|
||||
response_status("501 Not Implemented", &req.cseq, &[], None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host capability SDP returned by DESCRIBE. Advertises HEVC + AV1 and no encryption
|
||||
/// (plaintext streams for now; P1.5 adds the negotiated AES paths).
|
||||
fn describe_sdp() -> String {
|
||||
// Line-oriented a=key:value, matching what moonlight-common-c scans for.
|
||||
[
|
||||
"a=x-ss-general.featureFlags:0",
|
||||
"a=x-ss-general.encryptionSupported:0",
|
||||
"a=x-ss-general.encryptionRequested:0",
|
||||
"sprop-parameter-sets=AAAAAU", // HEVC capability indicator
|
||||
"a=rtpmap:98 AV1/90000", // AV1 capability indicator
|
||||
"",
|
||||
]
|
||||
.join("\r\n")
|
||||
}
|
||||
|
||||
/// Parse an ANNOUNCE SDP body's `a=key:value` lines into a map.
|
||||
fn parse_announce(body: &str) -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
for line in body.lines() {
|
||||
if let Some(rest) = line.strip_prefix("a=") {
|
||||
if let Some((k, v)) = rest.split_once(':') {
|
||||
map.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Extract the stream type from a SETUP URI like `…/streamid=video/0/0`.
|
||||
fn stream_type(uri: &str) -> Option<&str> {
|
||||
let after = uri.split("streamid=").nth(1)?;
|
||||
let token = after.split('/').next()?;
|
||||
match token {
|
||||
"audio" | "video" | "control" => Some(token),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn response(cseq: &str, headers: &[(&str, &str)], body: Option<&str>) -> String {
|
||||
response_status("200 OK", cseq, headers, body)
|
||||
}
|
||||
|
||||
fn response_status(
|
||||
status: &str,
|
||||
cseq: &str,
|
||||
headers: &[(&str, &str)],
|
||||
body: Option<&str>,
|
||||
) -> String {
|
||||
let body = body.unwrap_or("");
|
||||
let mut out = format!("RTSP/1.0 {status}\r\nCSeq: {cseq}\r\n");
|
||||
for (k, v) in headers {
|
||||
out.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
out.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
|
||||
out.push_str(body);
|
||||
out
|
||||
}
|
||||
|
||||
fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
fn header_value<'a>(head: &'a str, key_lower: &str) -> Option<&'a str> {
|
||||
head.split("\r\n").find_map(|line| {
|
||||
let (k, v) = line.split_once(':')?;
|
||||
(k.trim().eq_ignore_ascii_case(key_lower)).then(|| v.trim_start())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! The `/serverinfo` capability/status XML Moonlight GETs before pairing and each launch.
|
||||
|
||||
use super::{Host, APP_VERSION, GFE_VERSION, SERVER_CODEC_MODE_SUPPORT};
|
||||
|
||||
/// Build the `<root status_code="200">…</root>` serverinfo document. `https` selects the
|
||||
/// paired-HTTPS variant (real MAC). Element names are case-sensitive and match what
|
||||
/// moonlight-common-c parses.
|
||||
pub fn serverinfo_xml(host: &Host, https: bool) -> String {
|
||||
// MAC is hidden over plain HTTP; PairStatus reflects the pairing store once the HTTPS
|
||||
// path carries per-client identity (a hardening follow-up — 0 for now).
|
||||
let mac = if https {
|
||||
"01:02:03:04:05:06"
|
||||
} else {
|
||||
"00:00:00:00:00:00"
|
||||
};
|
||||
// Over the mutual-TLS HTTPS port the peer is an authenticated (paired) client.
|
||||
let pair_status = u8::from(https);
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<root status_code="200">
|
||||
<hostname>{hostname}</hostname>
|
||||
<appversion>{APP_VERSION}</appversion>
|
||||
<GfeVersion>{GFE_VERSION}</GfeVersion>
|
||||
<uniqueid>{uniqueid}</uniqueid>
|
||||
<HttpsPort>{https_port}</HttpsPort>
|
||||
<ExternalPort>{http_port}</ExternalPort>
|
||||
<MaxLumaPixelsHEVC>1869449984</MaxLumaPixelsHEVC>
|
||||
<mac>{mac}</mac>
|
||||
<LocalIP>{local_ip}</LocalIP>
|
||||
<ServerCodecModeSupport>{SERVER_CODEC_MODE_SUPPORT}</ServerCodecModeSupport>
|
||||
<PairStatus>{pair_status}</PairStatus>
|
||||
<currentgame>0</currentgame>
|
||||
<state>SUNSHINE_SERVER_FREE</state>
|
||||
</root>
|
||||
"#,
|
||||
hostname = host.hostname,
|
||||
uniqueid = host.uniqueid,
|
||||
https_port = host.https_port,
|
||||
http_port = host.http_port,
|
||||
local_ip = host.local_ip,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! TLS for the HTTPS nvhttp port (47984). Moonlight does **mutual TLS** — it presents its
|
||||
//! client cert and expects the server to request one — so a plain server-auth config makes
|
||||
//! the post-pairing `pairchallenge` fail. This config requests the client cert and verifies
|
||||
//! the client owns its key, but (for now) accepts any well-formed cert; enforcing the
|
||||
//! paired allow-list (rejecting unpaired clients on /launch) is a follow-up hardening step.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use rustls::client::danger::HandshakeSignatureValid;
|
||||
use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider};
|
||||
use rustls::pki_types::{CertificateDer, UnixTime};
|
||||
use rustls::server::danger::{ClientCertVerified, ClientCertVerifier};
|
||||
use rustls::{DigitallySignedStruct, DistinguishedName, ServerConfig, SignatureScheme};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Requests + signature-checks the client cert but accepts any (the pairing handshake is
|
||||
/// the real proof). Pinning to the paired set is a hardening follow-up.
|
||||
#[derive(Debug)]
|
||||
struct AcceptAnyClientCert {
|
||||
provider: Arc<CryptoProvider>,
|
||||
}
|
||||
|
||||
impl ClientCertVerifier for AcceptAnyClientCert {
|
||||
fn offer_client_auth(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn client_auth_mandatory(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn root_hint_subjects(&self) -> &[DistinguishedName] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn verify_client_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer,
|
||||
_intermediates: &[CertificateDer],
|
||||
_now: UnixTime,
|
||||
) -> Result<ClientCertVerified, rustls::Error> {
|
||||
Ok(ClientCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a mutual-TLS `ServerConfig` presenting the host cert/key.
|
||||
pub fn server_config(cert_pem: &str, key_pem: &str) -> Result<Arc<ServerConfig>> {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let certs = rustls_pemfile::certs(&mut cert_pem.as_bytes())
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.context("parse host cert PEM")?;
|
||||
let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
|
||||
.context("parse host key PEM")?
|
||||
.ok_or_else(|| anyhow!("no private key in host key PEM"))?;
|
||||
|
||||
let verifier = Arc::new(AcceptAnyClientCert {
|
||||
provider: provider.clone(),
|
||||
});
|
||||
let config = ServerConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.context("rustls protocol versions")?
|
||||
.with_client_cert_verifier(verifier)
|
||||
.with_single_cert(certs, key)
|
||||
.context("rustls server cert")?;
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! GameStream video wire packetization: an encoded access unit → UDP datagrams a stock
|
||||
//! Moonlight client decodes. Each datagram is
|
||||
//! `RTP_PACKET(12, big-endian) + reserved[4] + NV_VIDEO_PACKET(16, little-endian) + payload`
|
||||
//! and the frame's bitstream is prefixed with an 8-byte `video_short_frame_header_t`, then
|
||||
//! striped into ≤4 FEC blocks of ≤255 data shards. Byte-exact spec:
|
||||
//! `docs/research/gamestream-protocol-research.json` (video plane).
|
||||
//!
|
||||
//! P1.3 sends **data shards only** (`fecPercentage = 0`): on a clean LAN the client has
|
||||
//! every data shard and never runs Reed–Solomon recovery, so we get a decodable frame
|
||||
//! without matching Moonlight's `nanors` parity matrix (that interop work is P1.5). Plaintext
|
||||
//! only (encryption negotiated off for now). This lives in lumen-host for fast iteration;
|
||||
//! the wire codec moves into lumen-core (the P1 wire mode) once proven.
|
||||
|
||||
/// RTP `header` byte: version 2 (0x80) | extension (0x10) — Moonlight keys on the extension.
|
||||
const RTP_HEADER_BYTE: u8 = 0x80 | 0x10;
|
||||
const FLAG_PIC: u8 = 0x1;
|
||||
const FLAG_EOF: u8 = 0x2;
|
||||
const FLAG_SOF: u8 = 0x4;
|
||||
const MULTI_FEC_FLAGS: u8 = 0x10;
|
||||
const MAX_DATA_SHARDS_PER_BLOCK: usize = 255;
|
||||
const MAX_FEC_BLOCKS: usize = 4;
|
||||
/// Per-shard header: RTP(12) + reserved(4) + NV_VIDEO_PACKET(16).
|
||||
const SHARD_HEADER: usize = 32;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FrameType {
|
||||
Idr,
|
||||
P,
|
||||
}
|
||||
|
||||
/// Splits encoded access units into GameStream video datagrams.
|
||||
pub struct VideoPacketizer {
|
||||
/// Negotiated `packetSize` (ANNOUNCE `x-nv-video[0].packetSize`).
|
||||
packet_size: usize,
|
||||
/// Per-shard payload bytes = `blocksize - SHARD_HEADER`, `blocksize = packetSize + 16`.
|
||||
payload_per_shard: usize,
|
||||
frame_index: u32,
|
||||
/// Monotonic per-stream packet counter (the RTP sequence / streamPacketIndex source).
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
impl VideoPacketizer {
|
||||
pub fn new(packet_size: usize) -> Self {
|
||||
VideoPacketizer {
|
||||
packet_size,
|
||||
payload_per_shard: packet_size + 16 - SHARD_HEADER,
|
||||
frame_index: 0,
|
||||
seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Packetize one encoded AU into wire datagrams (ready for UDP send).
|
||||
pub fn packetize(
|
||||
&mut self,
|
||||
au: &[u8],
|
||||
frame_type: FrameType,
|
||||
timestamp_90k: u32,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let frame_index = self.frame_index;
|
||||
self.frame_index = self.frame_index.wrapping_add(1);
|
||||
let pps = self.payload_per_shard;
|
||||
|
||||
// frame payload = 8-byte short frame header + the AU bitstream.
|
||||
let total_len = 8 + au.len();
|
||||
let last_payload_len = match total_len % pps {
|
||||
0 => pps,
|
||||
r => r,
|
||||
};
|
||||
let mut fp = Vec::with_capacity(total_len);
|
||||
fp.extend_from_slice(&short_frame_header(frame_type, last_payload_len as u16));
|
||||
fp.extend_from_slice(au);
|
||||
|
||||
let total_data = total_len.div_ceil(pps).max(1);
|
||||
let n_blocks = total_data
|
||||
.div_ceil(MAX_DATA_SHARDS_PER_BLOCK)
|
||||
.clamp(1, MAX_FEC_BLOCKS);
|
||||
let per_block = total_data.div_ceil(n_blocks);
|
||||
|
||||
let mut packets = Vec::with_capacity(total_data);
|
||||
for b in 0..n_blocks {
|
||||
let first = b * per_block;
|
||||
let last = ((b + 1) * per_block).min(total_data);
|
||||
if first >= last {
|
||||
break;
|
||||
}
|
||||
let block_data_count = last - first;
|
||||
for (fec_index, shard) in (first..last).enumerate() {
|
||||
let start = shard * pps;
|
||||
let end = (start + pps).min(fp.len());
|
||||
let mut payload = vec![0u8; pps]; // last shard zero-padded
|
||||
payload[..end - start].copy_from_slice(&fp[start..end]);
|
||||
|
||||
let mut flags = FLAG_PIC;
|
||||
if shard == 0 {
|
||||
flags |= FLAG_SOF;
|
||||
}
|
||||
if shard == total_data - 1 {
|
||||
flags |= FLAG_EOF;
|
||||
}
|
||||
let multi_fec_blocks = ((b as u8) << 4) | (((n_blocks - 1) as u8) << 6);
|
||||
// fecInfo: dataShards<<22 | fecIndex<<12 | fecPercentage<<4 (pct = 0).
|
||||
let fec_info: u32 = ((block_data_count as u32) << 22) | ((fec_index as u32) << 12);
|
||||
let seq = self.seq;
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
|
||||
packets.push(build_packet(
|
||||
seq,
|
||||
timestamp_90k,
|
||||
frame_index,
|
||||
flags,
|
||||
multi_fec_blocks,
|
||||
fec_info,
|
||||
&payload,
|
||||
));
|
||||
}
|
||||
}
|
||||
packets
|
||||
}
|
||||
}
|
||||
|
||||
/// 8-byte `video_short_frame_header_t` (little-endian), prefixed to the AU bitstream.
|
||||
fn short_frame_header(frame_type: FrameType, last_payload_len: u16) -> [u8; 8] {
|
||||
let mut h = [0u8; 8];
|
||||
h[0] = 0x01; // headerType
|
||||
h[1..3].copy_from_slice(&0u16.to_le_bytes()); // frame_processing_latency
|
||||
h[3] = match frame_type {
|
||||
FrameType::Idr => 2,
|
||||
FrameType::P => 1,
|
||||
};
|
||||
h[4..6].copy_from_slice(&last_payload_len.to_le_bytes());
|
||||
// h[6..8] unknown = 0
|
||||
h
|
||||
}
|
||||
|
||||
/// Build one wire datagram: RTP(BE) + reserved + NV_VIDEO_PACKET(LE) + payload.
|
||||
fn build_packet(
|
||||
seq: u32,
|
||||
timestamp_90k: u32,
|
||||
frame_index: u32,
|
||||
flags: u8,
|
||||
multi_fec_blocks: u8,
|
||||
fec_info: u32,
|
||||
payload: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(SHARD_HEADER + payload.len());
|
||||
// --- RTP_PACKET (12 bytes, big-endian) ---
|
||||
p.push(RTP_HEADER_BYTE); // header
|
||||
p.push(0); // packetType (unused for video)
|
||||
p.extend_from_slice(&(seq as u16).to_be_bytes()); // sequenceNumber
|
||||
p.extend_from_slice(×tamp_90k.to_be_bytes()); // timestamp (90 kHz)
|
||||
p.extend_from_slice(&0u32.to_be_bytes()); // ssrc
|
||||
// --- reserved[4] ---
|
||||
p.extend_from_slice(&[0u8; 4]);
|
||||
// --- NV_VIDEO_PACKET (16 bytes, little-endian) ---
|
||||
p.extend_from_slice(&(seq << 8).to_le_bytes()); // streamPacketIndex (low byte 0)
|
||||
p.extend_from_slice(&frame_index.to_le_bytes()); // frameIndex
|
||||
p.push(flags);
|
||||
p.push(0); // extraFlags
|
||||
p.push(MULTI_FEC_FLAGS);
|
||||
p.push(multi_fec_blocks);
|
||||
p.extend_from_slice(&fec_info.to_le_bytes()); // fecInfo
|
||||
// --- payload ---
|
||||
p.extend_from_slice(payload);
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_block_layout() {
|
||||
let mut pk = VideoPacketizer::new(1392); // payload_per_shard = 1392+16-32 = 1376
|
||||
assert_eq!(pk.payload_per_shard, 1376);
|
||||
let au = vec![0xABu8; 4000]; // 8+4000 = 4008 → ceil(4008/1376) = 3 data shards
|
||||
let pkts = pk.packetize(&au, FrameType::Idr, 90_000);
|
||||
assert_eq!(pkts.len(), 3);
|
||||
// Every datagram is SHARD_HEADER + payload_per_shard.
|
||||
for p in &pkts {
|
||||
assert_eq!(p.len(), SHARD_HEADER + 1376);
|
||||
assert_eq!(p[0], 0x90); // RTP header byte
|
||||
}
|
||||
// First packet: SOF set, fecIndex 0, frameIndex 0.
|
||||
let first = &pkts[0];
|
||||
assert_eq!(first[24] & FLAG_SOF, FLAG_SOF);
|
||||
assert_eq!(first[24] & FLAG_PIC, FLAG_PIC);
|
||||
let frame_index = u32::from_le_bytes(first[20..24].try_into().unwrap());
|
||||
assert_eq!(frame_index, 0);
|
||||
let fec_info = u32::from_le_bytes(first[28..32].try_into().unwrap());
|
||||
assert_eq!(fec_info >> 22, 3); // dataShards = 3
|
||||
assert_eq!((fec_info >> 12) & 0x3ff, 0); // fecIndex 0
|
||||
// Last packet: EOF set, fecIndex 2.
|
||||
let last = &pkts[2];
|
||||
assert_eq!(last[24] & FLAG_EOF, FLAG_EOF);
|
||||
let fec_info_last = u32::from_le_bytes(last[28..32].try_into().unwrap());
|
||||
assert_eq!((fec_info_last >> 12) & 0x3ff, 2);
|
||||
// RTP sequence numbers are 0,1,2.
|
||||
for (i, p) in pkts.iter().enumerate() {
|
||||
assert_eq!(u16::from_be_bytes(p[2..4].try_into().unwrap()), i as u16);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_block_split() {
|
||||
let mut pk = VideoPacketizer::new(1392);
|
||||
// Need > 255 data shards → multi-block. 255*1376 ≈ 351 KB; use 600 KB.
|
||||
let au = vec![0u8; 600_000];
|
||||
let pkts = pk.packetize(&au, FrameType::P, 0);
|
||||
let total = (8 + au.len()).div_ceil(1376);
|
||||
assert_eq!(pkts.len(), total);
|
||||
// n_blocks = ceil(total/255), clamped to 4; check multiFecBlocks lastBlock nibble.
|
||||
let n_blocks = total.div_ceil(255).clamp(1, 4);
|
||||
let last_block = ((pkts.last().unwrap()[27]) >> 6) & 0x3;
|
||||
assert_eq!(last_block as usize, n_blocks - 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user