Full project rename, decided 2026-06-10: - Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs. - C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h, PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl. PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants). - Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1. WIRE BREAK: clients must be rebuilt from this revision. - Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / …. - Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the persistent identity is unchanged, pinned fingerprints stay valid). - Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection (Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated. - scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated. Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of "desktop but no apps/settings" over the stream: plasmashell launched without XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and rendered an empty menu. The script sets the complete KDE session env (menu prefix, KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell. Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS, zero lumen references left outside .git. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
//! The app catalog: what `/applist` advertises and what `/launch?appid=N` selects. Each entry
|
||||
//! maps to a session recipe — which compositor backend hosts it and (for gamescope) which
|
||||
//! command runs nested. Loaded from `~/.config/punktfunk/apps.json`; sensible defaults otherwise.
|
||||
//!
|
||||
//! ```json
|
||||
//! [ {"id":1,"title":"Desktop"},
|
||||
//! {"id":2,"title":"Steam","compositor":"gamescope","cmd":"steam -gamepadui"} ]
|
||||
//! ```
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppEntry {
|
||||
pub id: u32,
|
||||
pub title: String,
|
||||
/// `None` = auto-detect (the desktop session's compositor).
|
||||
pub compositor: Option<crate::vdisplay::Compositor>,
|
||||
/// Command gamescope runs nested (gamescope entries only).
|
||||
pub cmd: Option<String>,
|
||||
}
|
||||
|
||||
fn config_path() -> Option<std::path::PathBuf> {
|
||||
Some(std::path::Path::new(&std::env::var("HOME").ok()?).join(".config/punktfunk/apps.json"))
|
||||
}
|
||||
|
||||
fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
|
||||
use crate::vdisplay::Compositor::*;
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"kwin" | "kde" => Some(Kwin),
|
||||
"mutter" | "gnome" => Some(Mutter),
|
||||
"gamescope" => Some(Gamescope),
|
||||
"wlroots" | "sway" => Some(Wlroots),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog: the user's `apps.json` if present, else defaults (Desktop, plus gamescope
|
||||
/// entries when gamescope is installed).
|
||||
pub fn catalog() -> Vec<AppEntry> {
|
||||
if let Some(path) = config_path() {
|
||||
if let Ok(raw) = std::fs::read_to_string(&path) {
|
||||
match serde_json::from_str::<Value>(&raw) {
|
||||
Ok(Value::Array(items)) => {
|
||||
let apps: Vec<AppEntry> = items
|
||||
.iter()
|
||||
.filter_map(|it| {
|
||||
Some(AppEntry {
|
||||
id: it.get("id")?.as_u64()? as u32,
|
||||
title: it.get("title")?.as_str()?.to_string(),
|
||||
compositor: it
|
||||
.get("compositor")
|
||||
.and_then(|c| c.as_str())
|
||||
.and_then(parse_compositor),
|
||||
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if !apps.is_empty() {
|
||||
return apps;
|
||||
}
|
||||
tracing::warn!(path = %path.display(), "apps.json parsed to zero entries — using defaults");
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(path = %path.display(), "apps.json malformed — using defaults")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut apps = vec![AppEntry {
|
||||
id: 1,
|
||||
title: "Desktop".into(),
|
||||
compositor: None,
|
||||
cmd: None,
|
||||
}];
|
||||
if which("gamescope") {
|
||||
if which("steam") {
|
||||
apps.push(AppEntry {
|
||||
id: 2,
|
||||
title: "Steam".into(),
|
||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||
cmd: Some("steam -gamepadui".into()),
|
||||
});
|
||||
}
|
||||
if which("vkcube") {
|
||||
apps.push(AppEntry {
|
||||
id: 3,
|
||||
title: "vkcube (test)".into(),
|
||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||
cmd: Some("vkcube".into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
pub fn by_id(id: u32) -> Option<AppEntry> {
|
||||
catalog().into_iter().find(|a| a.id == id)
|
||||
}
|
||||
|
||||
/// Render the GameStream `/applist` XML.
|
||||
pub fn applist_xml() -> String {
|
||||
let mut xml =
|
||||
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
||||
for app in catalog() {
|
||||
xml.push_str(&format!(
|
||||
"<App>\n<IsHdrSupported>0</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
|
||||
xml_escape(&app.title),
|
||||
app.id
|
||||
));
|
||||
}
|
||||
xml.push_str("</root>\n");
|
||||
xml
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn which(bin: &str) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join(bin).is_file()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_catalog_has_desktop() {
|
||||
let apps = catalog();
|
||||
assert!(apps.iter().any(|a| a.id == 1 && a.title == "Desktop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applist_xml_is_wellformed_ish() {
|
||||
let xml = applist_xml();
|
||||
assert!(xml.contains("<AppTitle>Desktop</AppTitle>"));
|
||||
assert!(xml.starts_with("<?xml"));
|
||||
assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! The audio data plane (UDP 48000). On RTSP PLAY we learn the client's audio endpoint from
|
||||
//! its port-learning ping, capture the default-sink monitor, Opus-encode 5 ms stereo frames,
|
||||
//! and send each as a GameStream RTP audio packet.
|
||||
//!
|
||||
//! Wire format (moonlight-common-c `AudioStream.c`): a 12-byte big-endian `RTP_PACKET`
|
||||
//! (`packetType = 97`, `sequenceNumber++`, `timestamp += packetDuration`, `ssrc = 0`)
|
||||
//! followed by the AES-128-CBC-encrypted Opus payload. Stereo Opus is a single coupled
|
||||
//! multistream, so a plain `opus_encode` bitstream is what the client's multistream decoder
|
||||
//! expects. Like the control stream, modern Moonlight always AES-CBC-decrypts audio (it
|
||||
//! reports "Failed to decrypt audio packet" on plaintext), so we encrypt the payload under the
|
||||
//! `/launch` `rikey` with a per-packet IV `BE32(rikeyid + seq)` (PKCS7 padding, RTP header
|
||||
//! left in the clear). Reed-Solomon audio FEC is layered on top in P1.5.
|
||||
|
||||
use super::AUDIO_PORT;
|
||||
use crate::audio::{self, AudioCapturer, CHANNELS, SAMPLE_RATE};
|
||||
use anyhow::{Context, Result};
|
||||
use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit};
|
||||
use opus::{Application, Bitrate, Channels, Encoder};
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
|
||||
|
||||
/// Opus frame duration; 5 ms is moonlight's default (`x-nv-aqos.packetDuration`).
|
||||
const FRAME_MS: usize = 5;
|
||||
/// Samples per channel per Opus frame (48 kHz · 5 ms = 240).
|
||||
const SAMPLES_PER_FRAME: usize = SAMPLE_RATE as usize * FRAME_MS / 1000;
|
||||
/// RTP payload type for audio (moonlight `AudioStream.c` checks `packetType == 97`).
|
||||
const AUDIO_PACKET_TYPE: u8 = 97;
|
||||
const OPUS_BITRATE: i32 = 128_000;
|
||||
|
||||
/// Slot for the persistent audio capturer, reused across streams (no leaked PipeWire thread).
|
||||
pub type AudioCapSlot = Arc<std::sync::Mutex<Option<Box<dyn AudioCapturer>>>>;
|
||||
|
||||
/// Spawn the audio stream thread (idempotent via `running`). Stops when `running` clears.
|
||||
/// `gcm_key`/`rikeyid` come from `/launch` and key the AES-CBC payload encryption.
|
||||
pub fn start(running: Arc<AtomicBool>, gcm_key: [u8; 16], rikeyid: i32, audio_cap: AudioCapSlot) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-audio".into())
|
||||
.spawn(move || {
|
||||
tracing::info!("audio stream starting");
|
||||
if let Err(e) = run(&running, &gcm_key, rikeyid, &audio_cap) {
|
||||
tracing::error!(error = %format!("{e:#}"), "audio stream failed");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
tracing::info!("audio stream stopped");
|
||||
});
|
||||
}
|
||||
|
||||
fn run(
|
||||
running: &AtomicBool,
|
||||
gcm_key: &[u8; 16],
|
||||
rikeyid: i32,
|
||||
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
|
||||
) -> Result<()> {
|
||||
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
|
||||
// The client pings the audio port (~every 500ms) so we learn where to send.
|
||||
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
tracing::info!(port = AUDIO_PORT, "audio: awaiting client ping");
|
||||
let mut probe = [0u8; 256];
|
||||
let (_, client) = sock
|
||||
.recv_from(&mut probe)
|
||||
.context("audio: no client ping within 10s")?;
|
||||
sock.connect(client)
|
||||
.context("connect client audio endpoint")?;
|
||||
tracing::info!(%client, "audio: client endpoint learned");
|
||||
|
||||
// Reuse the persistent capturer (create on first stream); drain stale buffered audio.
|
||||
let mut cap = match audio_cap.lock().unwrap().take() {
|
||||
Some(mut c) => {
|
||||
c.drain();
|
||||
c
|
||||
}
|
||||
None => audio::open_audio_capture().context("open audio capture")?,
|
||||
};
|
||||
let result = audio_body(&mut *cap, &sock, gcm_key, rikeyid, running);
|
||||
*audio_cap.lock().unwrap() = Some(cap);
|
||||
result
|
||||
}
|
||||
|
||||
fn audio_body(
|
||||
cap: &mut dyn AudioCapturer,
|
||||
sock: &UdpSocket,
|
||||
gcm_key: &[u8; 16],
|
||||
rikeyid: i32,
|
||||
running: &AtomicBool,
|
||||
) -> Result<()> {
|
||||
// RESTRICTED_LOWDELAY + CBR, matching Sunshine — CBR keeps the Opus TOC byte constant,
|
||||
// which the client asserts per stream.
|
||||
let mut enc = Encoder::new(SAMPLE_RATE, Channels::Stereo, Application::LowDelay)
|
||||
.context("create Opus encoder")?;
|
||||
enc.set_bitrate(Bitrate::Bits(OPUS_BITRATE)).ok();
|
||||
enc.set_vbr(false).ok();
|
||||
|
||||
let frame_len = SAMPLES_PER_FRAME * CHANNELS; // interleaved samples per Opus frame
|
||||
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
|
||||
let mut out = vec![0u8; 1400];
|
||||
let mut seq: u16 = 0;
|
||||
let mut timestamp: u32 = 0;
|
||||
let mut sent: u64 = 0;
|
||||
// Pacing anchor: PipeWire hands us large capture buffers (~1024 frames), so we'd otherwise
|
||||
// emit packets in bursts the client's low-latency jitter buffer hears as glitching. Emit
|
||||
// each frame at its 5 ms slot instead. Production is real-time, so the backlog stays small.
|
||||
let start = Instant::now();
|
||||
let mut frame_no: u64 = 0;
|
||||
// Optional linear gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0).
|
||||
let gain: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let chunk = cap.next_chunk().context("capture audio chunk")?;
|
||||
acc.extend_from_slice(&chunk);
|
||||
while acc.len() >= frame_len {
|
||||
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
if gain != 1.0 {
|
||||
for s in &mut frame {
|
||||
*s = (*s * gain).clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
let n = enc.encode_float(&frame, &mut out).context("opus encode")?;
|
||||
// AES-128-CBC the Opus payload (RTP header stays plaintext). Per-packet IV =
|
||||
// BE32(rikeyid + seq) in [0..4], zero elsewhere; PKCS7 padding.
|
||||
let iv_seq = (rikeyid as u32).wrapping_add(seq as u32);
|
||||
let mut iv = [0u8; 16];
|
||||
iv[0..4].copy_from_slice(&iv_seq.to_be_bytes());
|
||||
let ct = Aes128CbcEnc::new(gcm_key.into(), (&iv).into())
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(&out[..n]);
|
||||
let pkt = build_rtp(seq, timestamp, &ct);
|
||||
if sock.send(&pkt).is_err() {
|
||||
tracing::info!(sent, "audio: client unreachable — stopping");
|
||||
return Ok(());
|
||||
}
|
||||
seq = seq.wrapping_add(1);
|
||||
// GameStream's audio RTP timestamp ticks by packetDuration (ms), not by samples.
|
||||
timestamp = timestamp.wrapping_add(FRAME_MS as u32);
|
||||
sent += 1;
|
||||
if sent % 400 == 0 {
|
||||
tracing::info!(sent, "audio: streaming");
|
||||
}
|
||||
|
||||
// Hold each frame to its 5 ms slot (skip if we've fallen behind a burst).
|
||||
frame_no += 1;
|
||||
let scheduled = start + Duration::from_millis(5 * frame_no);
|
||||
let now = Instant::now();
|
||||
if scheduled > now {
|
||||
std::thread::sleep((scheduled - now).min(Duration::from_millis(20)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a GameStream RTP audio packet: 12-byte BE `RTP_PACKET` header + Opus payload.
|
||||
fn build_rtp(seq: u16, timestamp: u32, opus: &[u8]) -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(12 + opus.len());
|
||||
p.push(0x80); // RTP version 2, no padding/extension/CSRC
|
||||
p.push(AUDIO_PACKET_TYPE);
|
||||
p.extend_from_slice(&seq.to_be_bytes());
|
||||
p.extend_from_slice(×tamp.to_be_bytes());
|
||||
p.extend_from_slice(&0u32.to_be_bytes()); // ssrc
|
||||
p.extend_from_slice(opus);
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rtp_header_layout() {
|
||||
let p = build_rtp(0x0102, 0x03040506, &[0xaa, 0xbb]);
|
||||
assert_eq!(p[0], 0x80);
|
||||
assert_eq!(p[1], 97);
|
||||
assert_eq!(&p[2..4], &[0x01, 0x02]); // seq BE
|
||||
assert_eq!(&p[4..8], &[0x03, 0x04, 0x05, 0x06]); // timestamp BE
|
||||
assert_eq!(&p[8..12], &[0, 0, 0, 0]); // ssrc
|
||||
assert_eq!(&p[12..], &[0xaa, 0xbb]); // opus payload
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_sizing() {
|
||||
assert_eq!(SAMPLES_PER_FRAME, 240);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! 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 punktfunk host certificate (RSA-2048)");
|
||||
(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 signing_key = SigningKey::<Sha256>::new(priv_key);
|
||||
let signature = cert_signature(&cert_pem)?;
|
||||
Ok(ServerIdentity {
|
||||
cert_pem,
|
||||
key_pem,
|
||||
signature,
|
||||
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)> {
|
||||
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, "punktfunk");
|
||||
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,428 @@
|
||||
//! The GameStream control stream: an ENet host on UDP 47999. Moonlight connects this
|
||||
//! BEFORE the video stream starts (`STAGE_CONTROL_STREAM_START` precedes
|
||||
//! `STAGE_VIDEO_STREAM_START`), so it must be up or the whole connection aborts. It carries
|
||||
//! input (mouse/keyboard/gamepad), keepalives, and QoS feedback.
|
||||
//!
|
||||
//! Sunshine-mode hosts (we advertise `state=SUNSHINE_SERVER_FREE`) make Moonlight encrypt the
|
||||
//! control stream with AES-128-GCM under the `/launch` `rikey`, even though we negotiate no
|
||||
//! media encryption. Wire framing (all little-endian):
|
||||
//!
|
||||
//! ```text
|
||||
//! u16 encType = 0x0001 | u16 length | u32 seq | [16-byte GCM tag] | ciphertext
|
||||
//! length = sizeof(seq) + 16 (tag) + plaintext
|
||||
//! ```
|
||||
//!
|
||||
//! The GCM nonce depends on what Moonlight negotiated (`encryptControlMessage` in
|
||||
//! moonlight-common-c). For `SS_ENC_CONTROL_V2` it is a 12-byte nonce with `seq` (LE) in bytes
|
||||
//! [0..4] and `b"CC"` (client→host) at [10..12]. For the legacy path — which we hit, since we
|
||||
//! advertise no encryption — it is a 16-byte nonce with only `iv[0] = seq & 0xff` and the rest
|
||||
//! zero. The tag is prepended to the ciphertext; there is no AAD; the key is the forward
|
||||
//! `hex::decode(rikey)`. We auto-detect the exact scheme via [`decrypt_control`] on the first
|
||||
//! packet that authenticates, since GCM gives no partial credit.
|
||||
//!
|
||||
//! Runs on its own native thread for the host's lifetime.
|
||||
|
||||
use super::{AppState, CONTROL_PORT};
|
||||
use crate::inject::gamepad::GamepadManager;
|
||||
use crate::inject::InputInjector;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Bind the ENet control host on 47999 and service it forever on a dedicated thread.
|
||||
pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
let socket = UdpSocket::bind(("0.0.0.0", CONTROL_PORT)).context("bind control UDP")?;
|
||||
socket
|
||||
.set_nonblocking(true)
|
||||
.context("control socket nonblocking")?;
|
||||
let mut host = Host::new(
|
||||
socket,
|
||||
HostSettings {
|
||||
peer_limit: 4,
|
||||
// Moonlight connects with CTRL_CHANNEL_COUNT (0x30) channels and sends gamepad
|
||||
// input on channel 0x10+n — a smaller limit silently discards controller input.
|
||||
channel_limit: 0x30,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow!("ENet host init: {e:?}"))?;
|
||||
tracing::info!(port = CONTROL_PORT, "ENet control listening");
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-control".into())
|
||||
.spawn(move || {
|
||||
// Thread-local (the injector owns non-Send Wayland/xkb state, so it must be
|
||||
// created and live here rather than be captured into the closure).
|
||||
// GCM scheme detected from the first authenticating packet; reused thereafter.
|
||||
let mut detected: Option<Scheme> = None;
|
||||
// Lazily opened on the first input event (Sway's Wayland socket is up by then).
|
||||
let mut injector: Option<Box<dyn InputInjector>> = None;
|
||||
// Virtual gamepads (uinput) + the host→client rumble sequence counter.
|
||||
let mut pads = GamepadManager::new();
|
||||
let mut rumble_seq: u32 = 0;
|
||||
let mut peer: Option<PeerID> = None;
|
||||
loop {
|
||||
loop {
|
||||
match host.service() {
|
||||
Ok(Some(event)) => match event {
|
||||
Event::Connect { peer: p, .. } => {
|
||||
tracing::info!("control: client connected");
|
||||
peer = Some(p.id());
|
||||
}
|
||||
Event::Disconnect { .. } => {
|
||||
tracing::info!("control: client disconnected");
|
||||
detected = None;
|
||||
peer = None;
|
||||
// Unplug the session's virtual pads.
|
||||
pads = GamepadManager::new();
|
||||
}
|
||||
Event::Receive {
|
||||
channel_id, packet, ..
|
||||
} => {
|
||||
on_receive(
|
||||
&state,
|
||||
channel_id,
|
||||
packet.data(),
|
||||
&mut detected,
|
||||
&mut injector,
|
||||
&mut pads,
|
||||
);
|
||||
}
|
||||
},
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:?}"), "control: service error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Service the pads' force-feedback protocol every tick (games block inside
|
||||
// EVIOCSFF until answered) and relay mixed rumble levels to the client.
|
||||
if let (Some(pid), Some(scheme)) = (peer, detected) {
|
||||
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
|
||||
if let Some(key) = key {
|
||||
let mut out: Vec<Vec<u8>> = Vec::new();
|
||||
pads.pump_rumble(|index, low, high| {
|
||||
let pt = super::gamepad::rumble_plaintext(index, low, high);
|
||||
out.push(encrypt_control(&key, &scheme, rumble_seq, &pt));
|
||||
rumble_seq = rumble_seq.wrapping_add(1);
|
||||
});
|
||||
for wire in out {
|
||||
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
|
||||
{
|
||||
tracing::warn!(error = %format!("{e:?}"), "rumble send failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No client/scheme yet: still answer FF uploads so games don't block.
|
||||
pads.pump_rumble(|_, _, _| {});
|
||||
}
|
||||
// ENet needs frequent servicing for handshake/keepalive/retransmit.
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
})
|
||||
.context("spawn control thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle one received control packet: decrypt it (learning the GCM scheme on the first one),
|
||||
/// decode any input event, and inject it into the host session.
|
||||
fn on_receive(
|
||||
state: &AppState,
|
||||
_channel_id: u8,
|
||||
d: &[u8],
|
||||
detected: &mut Option<Scheme>,
|
||||
injector: &mut Option<Box<dyn InputInjector>>,
|
||||
pads: &mut GamepadManager,
|
||||
) {
|
||||
let Some(key) = state.launch.lock().unwrap().map(|s| s.gcm_key) else {
|
||||
return; // control traffic before /launch — no key yet
|
||||
};
|
||||
// Encrypted control packets begin with u16 LE encType = 0x0001 and an 8-byte header.
|
||||
if d.len() < 8 || d[0] != 0x01 || d[1] != 0x00 {
|
||||
return;
|
||||
}
|
||||
|
||||
let pt = match decrypt_control(&key, d, detected) {
|
||||
Some((scheme, pt)) => {
|
||||
if detected.is_none() {
|
||||
tracing::info!(?scheme, "control: GCM scheme locked in");
|
||||
}
|
||||
*detected = Some(scheme);
|
||||
pt
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(len = d.len(), "control: GCM decrypt failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Recovery requests after loss: invalidate-reference-frames (0x0301, Gen7) or request-IDR
|
||||
// (0x0302, Gen7Enc). Force a keyframe so the client can resync without a multi-second stall.
|
||||
if pt.len() >= 2 {
|
||||
let inner = u16::from_le_bytes([pt[0], pt[1]]);
|
||||
if matches!(inner, 0x0301 | 0x0302 | 0x0305) {
|
||||
state
|
||||
.force_idr
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
tracing::info!(
|
||||
ty = format!("{inner:#06x}"),
|
||||
"control: IDR/RFI request → keyframe"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Controller events go to the uinput virtual pads (created on demand per the mask).
|
||||
if let Some(gp) = super::gamepad::decode(&pt) {
|
||||
pads.handle(&gp);
|
||||
return;
|
||||
}
|
||||
|
||||
let events = super::input::decode(&pt);
|
||||
if events.is_empty() {
|
||||
return; // keepalive / QoS / unhandled input kind
|
||||
}
|
||||
|
||||
// Open the injector on demand — by the first input event the compositor session is up.
|
||||
// Backend auto-selects per desktop (wlr on Sway, libei on KWin/GNOME); override with
|
||||
// PUNKTFUNK_INPUT_BACKEND.
|
||||
if injector.is_none() {
|
||||
let backend = crate::inject::default_backend();
|
||||
match crate::inject::open(backend) {
|
||||
Ok(i) => {
|
||||
tracing::info!(?backend, "input injection backend opened");
|
||||
*injector = Some(i);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "input injection unavailable");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let inj = injector.as_mut().unwrap();
|
||||
for ev in events {
|
||||
if let Err(e) = inj.inject(&ev) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "inject failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a control packet's nonce is built — Moonlight picks one based on the negotiated flags.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum NonceKind {
|
||||
/// `SS_ENC_CONTROL_V2`: 12-byte nonce, `seq` in [0..4], marker bytes at [10..12].
|
||||
V2 { seq_be: bool, marker: [u8; 2] },
|
||||
/// Legacy: 16-byte nonce, only `iv[0] = seq & 0xff` (the rest zero).
|
||||
LegacyLowByte,
|
||||
/// Legacy variant: 16-byte nonce, full `seq` in [0..4] (the rest zero).
|
||||
Legacy16Seq { seq_be: bool },
|
||||
}
|
||||
|
||||
impl NonceKind {
|
||||
fn nonce(&self, seq: u32) -> Vec<u8> {
|
||||
let seq_bytes = |be: bool| {
|
||||
if be {
|
||||
seq.to_be_bytes()
|
||||
} else {
|
||||
seq.to_le_bytes()
|
||||
}
|
||||
};
|
||||
match *self {
|
||||
NonceKind::V2 { seq_be, marker } => {
|
||||
let mut iv = vec![0u8; 12];
|
||||
iv[0..4].copy_from_slice(&seq_bytes(seq_be));
|
||||
iv[10] = marker[0];
|
||||
iv[11] = marker[1];
|
||||
iv
|
||||
}
|
||||
NonceKind::LegacyLowByte => {
|
||||
let mut iv = vec![0u8; 16];
|
||||
iv[0] = (seq & 0xff) as u8;
|
||||
iv
|
||||
}
|
||||
NonceKind::Legacy16Seq { seq_be } => {
|
||||
let mut iv = vec![0u8; 16];
|
||||
iv[0..4].copy_from_slice(&seq_bytes(seq_be));
|
||||
iv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte-exact GCM scheme that opened a control packet. Determined empirically once per
|
||||
/// connection (AES-GCM gives no partial credit, so an authenticating combination is proof).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Scheme {
|
||||
/// `gcm_key` is byte-reversed before use (defensive; Sunshine's net effect is forward).
|
||||
key_rev: bool,
|
||||
nonce: NonceKind,
|
||||
/// GCM tag sits before the ciphertext (vs after).
|
||||
tag_first: bool,
|
||||
aad: Aad,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Aad {
|
||||
None,
|
||||
/// The 4-byte cleartext header prefix (encType + length), `d[0..4]`.
|
||||
Header4,
|
||||
}
|
||||
|
||||
impl Scheme {
|
||||
fn key(&self, base: &[u8; 16]) -> [u8; 16] {
|
||||
let mut k = *base;
|
||||
if self.key_rev {
|
||||
k.reverse();
|
||||
}
|
||||
k
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an encrypted control packet `d` (8-byte cleartext header + `[tag?][ciphertext]`). If
|
||||
/// `detected` is set only that scheme is tried (fast path); otherwise the full cross-product
|
||||
/// of plausible schemes (nonce construction × key byte-order × tag position × AAD) is swept
|
||||
/// and the combination whose GCM tag authenticates is returned.
|
||||
fn decrypt_control(
|
||||
key: &[u8; 16],
|
||||
d: &[u8],
|
||||
detected: &Option<Scheme>,
|
||||
) -> Option<(Scheme, Vec<u8>)> {
|
||||
let seq = u32::from_le_bytes([d[4], d[5], d[6], d[7]]);
|
||||
let payload = &d[8..];
|
||||
if payload.len() < 16 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let attempt = |s: Scheme| -> Option<Vec<u8>> {
|
||||
// aes-gcm wants `ciphertext || tag`; reassemble from whichever wire order this is.
|
||||
let (ct, tag) = if s.tag_first {
|
||||
(&payload[16..], &payload[..16])
|
||||
} else {
|
||||
(
|
||||
&payload[..payload.len() - 16],
|
||||
&payload[payload.len() - 16..],
|
||||
)
|
||||
};
|
||||
let mut ct_tag = Vec::with_capacity(ct.len() + 16);
|
||||
ct_tag.extend_from_slice(ct);
|
||||
ct_tag.extend_from_slice(tag);
|
||||
let aad: &[u8] = match s.aad {
|
||||
Aad::None => &[],
|
||||
Aad::Header4 => &d[0..4],
|
||||
};
|
||||
gcm_open(&s.key(key), &s.nonce.nonce(seq), &ct_tag, aad)
|
||||
};
|
||||
|
||||
if let Some(s) = *detected {
|
||||
return attempt(s).map(|pt| (s, pt));
|
||||
}
|
||||
|
||||
// Candidate nonce constructions, most-likely first.
|
||||
const MARKERS: [[u8; 2]; 3] = [*b"CC", *b"HC", *b"CH"];
|
||||
let mut kinds: Vec<NonceKind> = vec![NonceKind::LegacyLowByte];
|
||||
for seq_be in [false, true] {
|
||||
for marker in MARKERS {
|
||||
kinds.push(NonceKind::V2 { seq_be, marker });
|
||||
}
|
||||
kinds.push(NonceKind::Legacy16Seq { seq_be });
|
||||
}
|
||||
|
||||
for &nonce in &kinds {
|
||||
for key_rev in [false, true] {
|
||||
for tag_first in [true, false] {
|
||||
for aad in [Aad::None, Aad::Header4] {
|
||||
let s = Scheme {
|
||||
key_rev,
|
||||
nonce,
|
||||
tag_first,
|
||||
aad,
|
||||
};
|
||||
if let Some(pt) = attempt(s) {
|
||||
return Some((s, pt));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Seal a host→client control message, mirroring the client's `detected` scheme with the
|
||||
/// direction flipped: V2 nonces use marker `H?` (host-originated) instead of `C?`; legacy
|
||||
/// nonces keep their construction with our own independent `seq` counter. Wire layout matches
|
||||
/// what the client sends us: `[0x0001][length][seq][tag|ct per scheme.tag_first]`.
|
||||
fn encrypt_control(key: &[u8; 16], scheme: &Scheme, seq: u32, pt: &[u8]) -> Vec<u8> {
|
||||
let nonce_kind = match scheme.nonce {
|
||||
NonceKind::V2 { seq_be, marker } => NonceKind::V2 {
|
||||
seq_be,
|
||||
marker: [b'H', marker[1]],
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
let length = (4 + 16 + pt.len()) as u16;
|
||||
let mut wire = Vec::with_capacity(8 + 16 + pt.len());
|
||||
wire.extend_from_slice(&0x0001u16.to_le_bytes());
|
||||
wire.extend_from_slice(&length.to_le_bytes());
|
||||
wire.extend_from_slice(&seq.to_le_bytes());
|
||||
let aad: Vec<u8> = match scheme.aad {
|
||||
Aad::None => Vec::new(),
|
||||
Aad::Header4 => wire[0..4].to_vec(),
|
||||
};
|
||||
let ct_tag = gcm_seal(&scheme.key(key), &nonce_kind.nonce(seq), pt, &aad);
|
||||
let (ct, tag) = ct_tag.split_at(ct_tag.len() - 16);
|
||||
if scheme.tag_first {
|
||||
wire.extend_from_slice(tag);
|
||||
wire.extend_from_slice(ct);
|
||||
} else {
|
||||
wire.extend_from_slice(ct);
|
||||
wire.extend_from_slice(tag);
|
||||
}
|
||||
wire
|
||||
}
|
||||
|
||||
/// AES-128-GCM seal (companion to [`gcm_open`]); returns `ciphertext || tag`.
|
||||
fn gcm_seal(key: &[u8; 16], nonce: &[u8], pt: &[u8], aad: &[u8]) -> Vec<u8> {
|
||||
use aes_gcm::aead::consts::{U12, U16};
|
||||
use aes_gcm::aead::generic_array::GenericArray;
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
use aes_gcm::{aes::Aes128, AesGcm};
|
||||
|
||||
let p = Payload { msg: pt, aad };
|
||||
match nonce.len() {
|
||||
12 => AesGcm::<Aes128, U12>::new_from_slice(key)
|
||||
.unwrap()
|
||||
.encrypt(GenericArray::from_slice(nonce), p)
|
||||
.expect("GCM seal"),
|
||||
16 => AesGcm::<Aes128, U16>::new_from_slice(key)
|
||||
.unwrap()
|
||||
.encrypt(GenericArray::from_slice(nonce), p)
|
||||
.expect("GCM seal"),
|
||||
_ => unreachable!("nonce length"),
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-128-GCM open with a 12- or 16-byte nonce and explicit AAD. Returns the plaintext iff
|
||||
/// the tag authenticates. `ct_tag` is `ciphertext || tag` (aes-gcm's expected order).
|
||||
fn gcm_open(key: &[u8; 16], nonce: &[u8], ct_tag: &[u8], aad: &[u8]) -> Option<Vec<u8>> {
|
||||
use aes_gcm::aead::consts::{U12, U16};
|
||||
use aes_gcm::aead::generic_array::GenericArray;
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
use aes_gcm::{aes::Aes128, AesGcm};
|
||||
|
||||
let p = Payload { msg: ct_tag, aad };
|
||||
match nonce.len() {
|
||||
12 => AesGcm::<Aes128, U12>::new_from_slice(key)
|
||||
.ok()?
|
||||
.decrypt(GenericArray::from_slice(nonce), p)
|
||||
.ok(),
|
||||
16 => AesGcm::<Aes128, U16>::new_from_slice(key)
|
||||
.ok()?
|
||||
.decrypt(GenericArray::from_slice(nonce), p)
|
||||
.ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Pairing crypto primitives (control plane only — distinct from `punktfunk_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,203 @@
|
||||
//! Decode GameStream controller packets (carried on the same encrypted control stream as
|
||||
//! mouse/keyboard — see [`super::input`]) into [`GamepadFrame`]s for the uinput virtual pads.
|
||||
//!
|
||||
//! Layouts mirror moonlight-common-c `Input.h` (all `#pragma pack(1)`; the `size` header field
|
||||
//! is big-endian, everything else little-endian). We implement the Gen5+ `MULTI_CONTROLLER`
|
||||
//! event (magic `0x0C`) — the only controller event Sunshine-class hosts receive — plus the
|
||||
//! Sunshine-extension `CONTROLLER_ARRIVAL` (`0x55000004`). Because our serverinfo advertises a
|
||||
//! Sunshine appversion (4th component negative), clients also send `buttonFlags2` (paddles /
|
||||
//! touchpad-click / Share) inside the MC packet.
|
||||
|
||||
/// Inner control-message type for input (same as [`super::input`]).
|
||||
const INPUT_DATA_TYPE: u16 = 0x0206;
|
||||
|
||||
/// `NV_INPUT_HEADER.magic` for the Gen5+ multi-controller event.
|
||||
const MAGIC_MULTI_CONTROLLER: u32 = 0x0C;
|
||||
/// Sunshine extension: controller arrival metadata (type/capabilities).
|
||||
const MAGIC_CONTROLLER_ARRIVAL: u32 = 0x5500_0004;
|
||||
|
||||
/// Most controllers a session tracks (Sunshine's MAX_GAMEPADS).
|
||||
pub const MAX_PADS: usize = 16;
|
||||
|
||||
/// One decoded controller event.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GamepadEvent {
|
||||
/// Full state of one controller + the set of attached controllers.
|
||||
State(GamepadFrame),
|
||||
/// Sunshine arrival metadata (precedes the first State for that pad).
|
||||
Arrival {
|
||||
index: u8,
|
||||
/// 0 unknown, 1 xbox, 2 ps, 3 nintendo.
|
||||
kind: u8,
|
||||
/// LI_CCAP_* bits (0x02 = rumble).
|
||||
capabilities: u16,
|
||||
},
|
||||
}
|
||||
|
||||
/// Snapshot of one controller's inputs (Moonlight conventions: sticks −32768..32767 with +Y
|
||||
/// up, triggers 0..255, buttons = `buttonFlags | buttonFlags2 << 16`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct GamepadFrame {
|
||||
pub index: i16,
|
||||
/// Bit n set = controller n attached; a clear bit for an allocated pad means unplug.
|
||||
pub active_mask: u16,
|
||||
pub buttons: u32,
|
||||
pub left_trigger: u8,
|
||||
pub right_trigger: u8,
|
||||
pub ls_x: i16,
|
||||
pub ls_y: i16,
|
||||
pub rs_x: i16,
|
||||
pub rs_y: i16,
|
||||
}
|
||||
|
||||
// buttonFlags bits (Limelight.h).
|
||||
pub const BTN_DPAD_UP: u32 = 0x0001;
|
||||
pub const BTN_DPAD_DOWN: u32 = 0x0002;
|
||||
pub const BTN_DPAD_LEFT: u32 = 0x0004;
|
||||
pub const BTN_DPAD_RIGHT: u32 = 0x0008;
|
||||
pub const BTN_START: u32 = 0x0010;
|
||||
pub const BTN_BACK: u32 = 0x0020;
|
||||
pub const BTN_LS_CLK: u32 = 0x0040;
|
||||
pub const BTN_RS_CLK: u32 = 0x0080;
|
||||
pub const BTN_LB: u32 = 0x0100;
|
||||
pub const BTN_RB: u32 = 0x0200;
|
||||
pub const BTN_GUIDE: u32 = 0x0400;
|
||||
pub const BTN_A: u32 = 0x1000;
|
||||
pub const BTN_B: u32 = 0x2000;
|
||||
pub const BTN_X: u32 = 0x4000;
|
||||
pub const BTN_Y: u32 = 0x8000;
|
||||
|
||||
/// Decode one decrypted control plaintext into a controller event, if it is one. Mouse,
|
||||
/// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]).
|
||||
pub fn decode(plaintext: &[u8]) -> Option<GamepadEvent> {
|
||||
if plaintext.len() < 4 || u16::from_le_bytes([plaintext[0], plaintext[1]]) != INPUT_DATA_TYPE {
|
||||
return None;
|
||||
}
|
||||
let p = &plaintext[4..];
|
||||
if p.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let magic = u32::from_le_bytes([p[4], p[5], p[6], p[7]]);
|
||||
let b = &p[8..]; // body after NV_INPUT_HEADER
|
||||
let le16 = |o: usize| -> Option<i16> { Some(i16::from_le_bytes([*b.get(o)?, *b.get(o + 1)?])) };
|
||||
|
||||
match magic {
|
||||
MAGIC_MULTI_CONTROLLER => {
|
||||
// Body: headerB@0, controllerNumber@2, activeGamepadMask@4, midB@6, buttonFlags@8,
|
||||
// LT@10, RT@11, lsX@12, lsY@14, rsX@16, rsY@18, tailA@20, buttonFlags2@22, tailB@24.
|
||||
// The constants (headerB/midB/tail*) are never validated, mirroring Sunshine.
|
||||
let buttons_lo = le16(8)? as u16 as u32;
|
||||
// buttonFlags2 is absent on pre-extension clients (shorter packet) — treat as 0.
|
||||
let buttons_hi = le16(22).map(|v| v as u16 as u32).unwrap_or(0);
|
||||
Some(GamepadEvent::State(GamepadFrame {
|
||||
index: le16(2)?,
|
||||
active_mask: le16(4)? as u16,
|
||||
buttons: buttons_lo | (buttons_hi << 16),
|
||||
left_trigger: *b.get(10)?,
|
||||
right_trigger: *b.get(11)?,
|
||||
ls_x: le16(12)?,
|
||||
ls_y: le16(14)?,
|
||||
rs_x: le16(16)?,
|
||||
rs_y: le16(18)?,
|
||||
}))
|
||||
}
|
||||
MAGIC_CONTROLLER_ARRIVAL => Some(GamepadEvent::Arrival {
|
||||
index: *b.first()?,
|
||||
kind: *b.get(1)?,
|
||||
capabilities: le16(2)? as u16,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the host→client rumble plaintext (type `0x010B`): `[type][len=10][u32 filler]
|
||||
/// [controllerNumber][lowFreqMotor][highFreqMotor]` (all LE; motors 0..0xFFFF). The caller
|
||||
/// seals it with the host-direction GCM scheme and sends it on the ENet control peer.
|
||||
pub fn rumble_plaintext(index: u16, low: u16, high: u16) -> Vec<u8> {
|
||||
let mut pt = Vec::with_capacity(14);
|
||||
pt.extend_from_slice(&0x010Bu16.to_le_bytes());
|
||||
pt.extend_from_slice(&10u16.to_le_bytes());
|
||||
pt.extend_from_slice(&0x00C0_FFEEu32.to_le_bytes()); // filler — present but ignored
|
||||
pt.extend_from_slice(&index.to_le_bytes());
|
||||
pt.extend_from_slice(&low.to_le_bytes());
|
||||
pt.extend_from_slice(&high.to_le_bytes());
|
||||
pt
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wrap(magic: u32, body: &[u8]) -> Vec<u8> {
|
||||
let mut inp = Vec::new();
|
||||
inp.extend_from_slice(&((4 + body.len()) as u32).to_be_bytes());
|
||||
inp.extend_from_slice(&magic.to_le_bytes());
|
||||
inp.extend_from_slice(body);
|
||||
let mut pt = Vec::new();
|
||||
pt.extend_from_slice(&INPUT_DATA_TYPE.to_le_bytes());
|
||||
pt.extend_from_slice(&(inp.len() as u16).to_le_bytes());
|
||||
pt.extend_from_slice(&inp);
|
||||
pt
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_multi_controller() {
|
||||
// Pad 1 attached (mask 0b10), A+RB held, LT=10 RT=200, LS=(1000,-2000), RS=(-1,32767),
|
||||
// paddle1 via buttonFlags2.
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&0x001Ai16.to_le_bytes()); // headerB
|
||||
body.extend_from_slice(&1i16.to_le_bytes()); // controllerNumber
|
||||
body.extend_from_slice(&0b10i16.to_le_bytes()); // activeGamepadMask
|
||||
body.extend_from_slice(&0x0014i16.to_le_bytes()); // midB
|
||||
body.extend_from_slice(&((BTN_A | BTN_RB) as u16).to_le_bytes());
|
||||
body.push(10); // LT
|
||||
body.push(200); // RT
|
||||
body.extend_from_slice(&1000i16.to_le_bytes());
|
||||
body.extend_from_slice(&(-2000i16).to_le_bytes());
|
||||
body.extend_from_slice(&(-1i16).to_le_bytes());
|
||||
body.extend_from_slice(&32767i16.to_le_bytes());
|
||||
body.extend_from_slice(&0x009Ci16.to_le_bytes()); // tailA
|
||||
body.extend_from_slice(&0x0001u16.to_le_bytes()); // buttonFlags2 (paddle1)
|
||||
body.extend_from_slice(&0x0055i16.to_le_bytes()); // tailB
|
||||
|
||||
let Some(GamepadEvent::State(f)) = decode(&wrap(MAGIC_MULTI_CONTROLLER, &body)) else {
|
||||
panic!("expected State");
|
||||
};
|
||||
assert_eq!(f.index, 1);
|
||||
assert_eq!(f.active_mask, 0b10);
|
||||
assert_eq!(f.buttons, BTN_A | BTN_RB | 0x0001_0000);
|
||||
assert_eq!((f.left_trigger, f.right_trigger), (10, 200));
|
||||
assert_eq!((f.ls_x, f.ls_y, f.rs_x, f.rs_y), (1000, -2000, -1, 32767));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_arrival() {
|
||||
let body = [0u8, 1, 0x02, 0x00, 0xFF, 0xFF, 0x0F, 0x00]; // pad 0, xbox, rumble cap
|
||||
let Some(GamepadEvent::Arrival {
|
||||
index,
|
||||
kind,
|
||||
capabilities,
|
||||
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
|
||||
else {
|
||||
panic!("expected Arrival");
|
||||
};
|
||||
assert_eq!((index, kind, capabilities), (0, 1, 0x0002));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_mouse_and_short_packets() {
|
||||
assert!(decode(&wrap(0x07, &[0, 1, 0, 2])).is_none()); // relative mouse
|
||||
assert!(decode(&[0u8; 3]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumble_layout() {
|
||||
let pt = rumble_plaintext(2, 0x1234, 0xBEEF);
|
||||
assert_eq!(pt.len(), 14);
|
||||
assert_eq!(u16::from_le_bytes([pt[0], pt[1]]), 0x010B);
|
||||
assert_eq!(u16::from_le_bytes([pt[2], pt[3]]), 10);
|
||||
assert_eq!(u16::from_le_bytes([pt[8], pt[9]]), 2);
|
||||
assert_eq!(u16::from_le_bytes([pt[10], pt[11]]), 0x1234);
|
||||
assert_eq!(u16::from_le_bytes([pt[12], pt[13]]), 0xBEEF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Decode the GameStream input wire format (carried AES-GCM-encrypted on the ENet control
|
||||
//! stream — see [`super::control`]) into platform-agnostic
|
||||
//! [`punktfunk_core::input::InputEvent`]s for injection.
|
||||
//!
|
||||
//! A decrypted control message is `[u16 type LE][u16 length LE][NV_INPUT packet]`. We only
|
||||
//! handle the input type (`0x0206`); the packet is an 8-byte `NV_INPUT_HEADER` (`size` BE,
|
||||
//! `magic` LE) followed by a magic-specific body. Multi-byte body fields are big-endian
|
||||
//! (network order) except `magic` and the keyboard `keyCode` (little-endian). Struct layouts
|
||||
//! mirror moonlight-common-c `Input.h`; the magic dispatch matches Sunshine `input.cpp`
|
||||
//! (Gen5+, where scroll is `0x0A` and controllers are `0x0C`, so there's no ambiguity).
|
||||
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
|
||||
/// Inner control-message type for input (moonlight `packetTypesGen7[IDX_INPUT_DATA]`).
|
||||
const INPUT_DATA_TYPE: u16 = 0x0206;
|
||||
|
||||
// NV_INPUT_HEADER.magic values (Input.h), with the Gen5+ variants where they differ.
|
||||
const MAGIC_KEY_DOWN: u32 = 0x03;
|
||||
const MAGIC_KEY_UP: u32 = 0x04;
|
||||
const MAGIC_MOUSE_ABS: u32 = 0x05;
|
||||
const MAGIC_MOUSE_REL: u32 = 0x06;
|
||||
const MAGIC_MOUSE_REL_GEN5: u32 = 0x07;
|
||||
const MAGIC_MOUSE_BTN_DOWN: u32 = 0x08;
|
||||
const MAGIC_MOUSE_BTN_UP: u32 = 0x09;
|
||||
const MAGIC_SCROLL_GEN5: u32 = 0x0A;
|
||||
const MAGIC_UTF8: u32 = 0x17;
|
||||
const MAGIC_HSCROLL: u32 = 0x5500_0001;
|
||||
|
||||
/// `code` value marking a [`InputKind::MouseScroll`] as horizontal (vs `0` = vertical).
|
||||
pub const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// Decode one decrypted control plaintext into zero or more input events. Non-input control
|
||||
/// messages (keepalives, QoS) and unhandled input kinds (gamepad/pen/touch) yield nothing.
|
||||
pub fn decode(plaintext: &[u8]) -> Vec<InputEvent> {
|
||||
if plaintext.len() < 4 || u16::from_le_bytes([plaintext[0], plaintext[1]]) != INPUT_DATA_TYPE {
|
||||
return Vec::new();
|
||||
}
|
||||
decode_input_packet(&plaintext[4..]).into_iter().collect()
|
||||
}
|
||||
|
||||
fn decode_input_packet(p: &[u8]) -> Option<InputEvent> {
|
||||
if p.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
// NV_INPUT_HEADER: size (BE u32, excludes itself) + magic (LE u32). Body follows.
|
||||
let magic = u32::from_le_bytes([p[4], p[5], p[6], p[7]]);
|
||||
let b = &p[8..];
|
||||
let be16 = |o: usize| -> Option<i16> { Some(i16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?])) };
|
||||
|
||||
Some(match magic {
|
||||
MAGIC_MOUSE_REL | MAGIC_MOUSE_REL_GEN5 => {
|
||||
ev(InputKind::MouseMove, 0, be16(0)? as i32, be16(2)? as i32, 0)
|
||||
}
|
||||
MAGIC_MOUSE_ABS => {
|
||||
// short x, y, unused, width, height (all BE). Carry the client's reference extent
|
||||
// (width<<16 | height) in `flags` so the injector can scale to its output.
|
||||
let (x, y) = (be16(0)? as i32, be16(2)? as i32);
|
||||
let flags = ((be16(6)? as u16 as u32) << 16) | (be16(8)? as u16 as u32);
|
||||
ev(InputKind::MouseMoveAbs, 0, x, y, flags)
|
||||
}
|
||||
MAGIC_MOUSE_BTN_DOWN => ev(InputKind::MouseButtonDown, *b.first()? as u32, 0, 0, 0),
|
||||
MAGIC_MOUSE_BTN_UP => ev(InputKind::MouseButtonUp, *b.first()? as u32, 0, 0, 0),
|
||||
MAGIC_SCROLL_GEN5 => ev(InputKind::MouseScroll, 0, be16(0)? as i32, 0, 0),
|
||||
MAGIC_HSCROLL => ev(
|
||||
InputKind::MouseScroll,
|
||||
SCROLL_HORIZONTAL,
|
||||
be16(0)? as i32,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
MAGIC_KEY_DOWN | MAGIC_KEY_UP => {
|
||||
// char flags, short keyCode (LE), char modifiers, short zero2. The client stuffs a
|
||||
// 0x80 high byte on key-down; Sunshine masks to the low-byte VK (`& 0xFF`).
|
||||
let key_code = (u16::from_le_bytes([*b.get(1)?, *b.get(2)?]) & 0x00FF) as u32;
|
||||
let modifiers = *b.get(3)? as u32;
|
||||
let kind = if magic == MAGIC_KEY_DOWN {
|
||||
InputKind::KeyDown
|
||||
} else {
|
||||
InputKind::KeyUp
|
||||
};
|
||||
ev(kind, key_code, 0, 0, modifiers)
|
||||
}
|
||||
// UTF-8 text, gamepad, pen, touch, haptics — not yet injected.
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn ev(kind: InputKind, code: u32, x: i32, y: i32, flags: u32) -> InputEvent {
|
||||
InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x,
|
||||
y,
|
||||
flags,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a control plaintext: inner header + NV_INPUT_HEADER + body.
|
||||
fn wrap(magic: u32, body: &[u8]) -> Vec<u8> {
|
||||
let mut inp = Vec::new();
|
||||
inp.extend_from_slice(&((4 + body.len()) as u32).to_be_bytes()); // size (excl. itself)
|
||||
inp.extend_from_slice(&magic.to_le_bytes());
|
||||
inp.extend_from_slice(body);
|
||||
let mut pt = Vec::new();
|
||||
pt.extend_from_slice(&INPUT_DATA_TYPE.to_le_bytes());
|
||||
pt.extend_from_slice(&(inp.len() as u16).to_le_bytes());
|
||||
pt.extend_from_slice(&inp);
|
||||
pt
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_relative_mouse() {
|
||||
// deltaX = -1 (ffff BE), deltaY = +2 (0002 BE) — matches a real captured packet.
|
||||
let pt = wrap(MAGIC_MOUSE_REL_GEN5, &[0xff, 0xff, 0x00, 0x02]);
|
||||
let ev = decode(&pt);
|
||||
assert_eq!(ev.len(), 1);
|
||||
assert_eq!(ev[0].kind, InputKind::MouseMove);
|
||||
assert_eq!((ev[0].x, ev[0].y), (-1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_key_down_masking_high_byte() {
|
||||
// keyCode 0x80A4 (LE a4 80) → VK 0xA4 (VK_LMENU); modifiers 0x04 (Alt).
|
||||
let pt = wrap(MAGIC_KEY_DOWN, &[0x00, 0xa4, 0x80, 0x04, 0x00, 0x00]);
|
||||
let ev = decode(&pt);
|
||||
assert_eq!(ev.len(), 1);
|
||||
assert_eq!(ev[0].kind, InputKind::KeyDown);
|
||||
assert_eq!(ev[0].code, 0xA4);
|
||||
assert_eq!(ev[0].flags, 0x04);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_input_type() {
|
||||
let mut pt = vec![0x00, 0x02]; // type 0x0200 (keepalive)
|
||||
pt.extend_from_slice(&[0x08, 0x00, 0x04, 0, 0, 0, 0, 0, 0, 0]);
|
||||
assert!(decode(&pt).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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,252 @@
|
||||
//! 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 `punktfunk_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).
|
||||
|
||||
pub mod apps;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod audio;
|
||||
/// Stub — the audio plane needs Linux (PipeWire capture + libopus); this keeps non-Linux
|
||||
/// dev builds compiling (crate doc: "the crate compiles everywhere"). Reports failure the
|
||||
/// same way the real stream thread does: by clearing `running`.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod audio {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub fn start(
|
||||
running: Arc<AtomicBool>,
|
||||
_gcm_key: [u8; 16],
|
||||
_rikeyid: i32,
|
||||
_audio_cap: Arc<Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>,
|
||||
) {
|
||||
tracing::error!("GameStream audio requires Linux (PipeWire + libopus)");
|
||||
running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
pub(crate) mod cert;
|
||||
mod control;
|
||||
mod crypto;
|
||||
pub mod gamepad;
|
||||
mod input;
|
||||
mod mdns;
|
||||
mod nvhttp;
|
||||
mod pairing;
|
||||
mod rtsp;
|
||||
mod serverinfo;
|
||||
mod stream;
|
||||
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,
|
||||
/// `/launch?appid=N` — selects the app-catalog entry (session recipe).
|
||||
pub appid: 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>>,
|
||||
/// Negotiated video config from RTSP ANNOUNCE (consumed by the stream on PLAY).
|
||||
pub stream: std::sync::Mutex<Option<stream::StreamConfig>>,
|
||||
/// True while the video stream thread is running (also its keep-running flag).
|
||||
pub streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
/// True while the audio stream thread is running (also its keep-running flag).
|
||||
pub audio_streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
/// Set by the control stream when the client requests an IDR / invalidates reference
|
||||
/// frames (recovery after loss); the video thread forces a keyframe and clears it.
|
||||
pub force_idr: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
/// Persistent screen capturer, reused across streams so reconnects don't spawn a second
|
||||
/// (conflicting) screencast session. The video thread borrows it for the stream's duration
|
||||
/// and returns it; `set_active` gates its cost while idle.
|
||||
pub video_cap: std::sync::Arc<std::sync::Mutex<Option<Box<dyn crate::capture::Capturer>>>>,
|
||||
/// Persistent audio capturer, reused across streams (avoids leaking a PipeWire capture
|
||||
/// thread per reconnect); drained on reuse so no stale audio is sent.
|
||||
pub audio_cap: std::sync::Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
|
||||
/// disk (pairings persist across restarts).
|
||||
pub fn new(host: Host, identity: cert::ServerIdentity) -> AppState {
|
||||
AppState {
|
||||
host,
|
||||
identity,
|
||||
pairing: pairing::Pairing::new(),
|
||||
paired: std::sync::Mutex::new(load_paired()),
|
||||
launch: std::sync::Mutex::new(None),
|
||||
stream: std::sync::Mutex::new(None),
|
||||
streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
audio_streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
force_idr: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the GameStream control plane (blocks): mDNS advertisement, the nvhttp servers, and
|
||||
/// the management REST API.
|
||||
pub fn serve(mgmt: crate::mgmt::Options) -> Result<()> {
|
||||
let host = Host::detect()?;
|
||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||
let state = Arc::new(AppState::new(host, identity));
|
||||
tracing::info!(
|
||||
hostname = %state.host.hostname,
|
||||
uniqueid = %state.host.uniqueid,
|
||||
ip = %state.host.local_ip,
|
||||
"punktfunk 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")?;
|
||||
control::spawn(state.clone()).context("start ENet control server")?;
|
||||
tokio::try_join!(nvhttp::run(state.clone()), crate::mgmt::run(state, mgmt))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// `~/.config/punktfunk`, 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("punktfunk")
|
||||
}
|
||||
|
||||
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(|| "punktfunk-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())
|
||||
}
|
||||
|
||||
/// Where the paired-client allow-list persists (survives host restarts, like Sunshine).
|
||||
fn paired_path() -> Option<std::path::PathBuf> {
|
||||
Some(std::path::Path::new(&std::env::var("HOME").ok()?).join(".config/punktfunk/paired.json"))
|
||||
}
|
||||
|
||||
/// Load the persisted paired-client certificate DERs (empty on first run / parse failure).
|
||||
fn load_paired() -> Vec<Vec<u8>> {
|
||||
let Some(path) = paired_path() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(raw) = std::fs::read(&path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
match serde_json::from_slice::<Vec<Vec<u8>>>(&raw) {
|
||||
Ok(v) => {
|
||||
tracing::info!(clients = v.len(), "loaded persisted pairings");
|
||||
v
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "paired.json unreadable — starting unpaired");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the paired-client allow-list (called after each successful pairing).
|
||||
pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
let Some(path) = paired_path() else { return };
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
match serde_json::to_vec(paired) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = std::fs::write(&path, bytes) {
|
||||
tracing::warn!(error = %e, "persisting pairings failed");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "serializing pairings failed"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! The nvhttp servers: plain HTTP on 47989 and mutual-TLS on 47984. Serves `/serverinfo`,
|
||||
//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`, plus a punktfunk-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(super::apps::applist_xml())
|
||||
}
|
||||
|
||||
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;
|
||||
// Quit semantics: stop the running media threads (they observe these flags) so the session
|
||||
// actually ends — the virtual output/gamescope teardown follows via the capturer's RAII.
|
||||
st.streaming
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
st.audio_streaming
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
tracing::info!("cancel — launch session cleared, streams stopping");
|
||||
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));
|
||||
let appid = q.get("appid").and_then(|s| s.parse().ok()).unwrap_or(1);
|
||||
Ok(LaunchSession {
|
||||
gcm_key,
|
||||
rikeyid,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
appid,
|
||||
})
|
||||
}
|
||||
|
||||
/// `"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,306 @@
|
||||
//! 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::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the user submits it
|
||||
/// (via the management API's `POST /api/v1/pair/pin` or nvhttp's `GET /pin?pin=NNNN`).
|
||||
/// `getservercert` parks until a PIN arrives.
|
||||
pub struct PinGate {
|
||||
pin: Mutex<Option<String>>,
|
||||
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 {
|
||||
fn new() -> Self {
|
||||
PinGate {
|
||||
pin: Mutex::new(None),
|
||||
notify: Notify::new(),
|
||||
waiters: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(&self, pin: String) {
|
||||
*self.pin.lock().unwrap() = Some(pin);
|
||||
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> {
|
||||
self.waiters.fetch_add(1, Ordering::SeqCst);
|
||||
// Decrement on every exit path (PIN delivered, timeout, or future cancellation).
|
||||
struct WaiterGuard<'a>(&'a AtomicUsize);
|
||||
impl Drop for WaiterGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let _guard = WaiterGuard(&self.waiters);
|
||||
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(p) = self.pin.lock().unwrap().take() {
|
||||
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 {
|
||||
{
|
||||
let mut store = paired_store.lock().unwrap();
|
||||
store.push(s.client_cert_der.clone());
|
||||
super::save_paired(&store);
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! 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::audio;
|
||||
use super::stream::{self, StreamConfig};
|
||||
use super::{AppState, AUDIO_PORT, CONTROL_PORT, RTSP_PORT, VIDEO_PORT};
|
||||
use crate::encode::Codec;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::Ordering;
|
||||
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("punktfunk-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 map = parse_announce(&req.body);
|
||||
match stream_config(&map) {
|
||||
Some(cfg) => {
|
||||
tracing::info!(?cfg, "RTSP ANNOUNCE — negotiated stream config");
|
||||
*state.stream.lock().unwrap() = Some(cfg);
|
||||
}
|
||||
None => tracing::warn!("RTSP ANNOUNCE — missing required video config keys"),
|
||||
}
|
||||
response(&req.cseq, &[], None)
|
||||
}
|
||||
"PLAY" => {
|
||||
let cfg = *state.stream.lock().unwrap();
|
||||
match cfg {
|
||||
Some(cfg) if !state.streaming.swap(true, Ordering::SeqCst) => {
|
||||
// Resolve the launched catalog entry (session recipe) for the stream.
|
||||
let app = state
|
||||
.launch
|
||||
.lock()
|
||||
.unwrap()
|
||||
.map(|l| l.appid)
|
||||
.and_then(super::apps::by_id);
|
||||
tracing::info!(app = ?app.as_ref().map(|a| &a.title), "RTSP PLAY — starting video stream");
|
||||
stream::start(
|
||||
cfg,
|
||||
app,
|
||||
state.streaming.clone(),
|
||||
state.force_idr.clone(),
|
||||
state.video_cap.clone(),
|
||||
);
|
||||
}
|
||||
Some(_) => tracing::info!("RTSP PLAY — stream already running"),
|
||||
None => tracing::warn!("RTSP PLAY — no negotiated config (ANNOUNCE missing)"),
|
||||
}
|
||||
// Audio runs independently (stereo Opus on UDP 48000); it needs the launch key for
|
||||
// the AES-CBC payload encryption the client expects.
|
||||
let launch = *state.launch.lock().unwrap();
|
||||
if let Some(ls) = launch {
|
||||
if !state.audio_streaming.swap(true, Ordering::SeqCst) {
|
||||
tracing::info!("RTSP PLAY — starting audio stream");
|
||||
audio::start(
|
||||
state.audio_streaming.clone(),
|
||||
ls.gcm_key,
|
||||
ls.rikeyid,
|
||||
state.audio_cap.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
|
||||
}
|
||||
"TEARDOWN" => {
|
||||
// Signal both stream threads to stop.
|
||||
state.streaming.store(false, Ordering::SeqCst);
|
||||
state.audio_streaming.store(false, Ordering::SeqCst);
|
||||
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
|
||||
// Opus config the client matches by channel count (Sunshine emits one per config):
|
||||
// surround-params = channelCount, streams, coupledStreams, then the channel mapping.
|
||||
// The client negotiated stereo, so advertise just that.
|
||||
"a=fmtp:97 surround-params=21101", // stereo: 2ch, 1 stream, 1 coupled, mapping [0,1]
|
||||
"",
|
||||
]
|
||||
.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
|
||||
}
|
||||
|
||||
/// Map the negotiated ANNOUNCE keys to a [`StreamConfig`] (resolution/packetSize required).
|
||||
fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
||||
let parse_u = |k: &str| map.get(k).and_then(|s| s.trim().parse::<u32>().ok());
|
||||
let width = parse_u("x-nv-video[0].clientViewportWd")?;
|
||||
let height = parse_u("x-nv-video[0].clientViewportHt")?;
|
||||
let packet_size = parse_u("x-nv-video[0].packetSize")? as usize;
|
||||
let fps = parse_u("x-nv-video[0].maxFPS")
|
||||
.filter(|&f| f > 0)
|
||||
.unwrap_or(60);
|
||||
let bitrate_kbps = parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").unwrap_or(20_000);
|
||||
let codec = match map.get("x-nv-vqos[0].bitStreamFormat").map(|s| s.trim()) {
|
||||
Some("1") => Codec::H265,
|
||||
Some("2") => Codec::Av1,
|
||||
_ => Codec::H264,
|
||||
};
|
||||
// Parity floor the client asks for (protects small frames); clamp to a sane max.
|
||||
let min_fec = parse_u("x-nv-vqos[0].fec.minRequiredFecPackets")
|
||||
.unwrap_or(2)
|
||||
.min(16) as u8;
|
||||
Some(StreamConfig {
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
packet_size,
|
||||
bitrate_kbps,
|
||||
codec,
|
||||
min_fec,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,478 @@
|
||||
//! The video data plane: on RTSP PLAY, learn the client's UDP endpoint (it pings the video
|
||||
//! port), then run capture → NVENC encode → [`VideoPacketizer`] → UDP send. The source is
|
||||
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the M0 PipeWire path) or
|
||||
//! a synthetic test pattern (default). Runs on its own native thread.
|
||||
|
||||
use super::video::{FrameType, VideoPacketizer};
|
||||
use super::VIDEO_PORT;
|
||||
use crate::capture::{self, Capturer, FastSyntheticCapturer};
|
||||
use crate::encode::{self, Codec};
|
||||
use anyhow::{Context, Result};
|
||||
use rand::Rng;
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Negotiated video parameters from the RTSP ANNOUNCE.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct StreamConfig {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub packet_size: usize,
|
||||
pub bitrate_kbps: u32,
|
||||
pub codec: Codec,
|
||||
/// Client's `x-nv-vqos[0].fec.minRequiredFecPackets` — parity floor per FEC block.
|
||||
pub min_fec: u8,
|
||||
}
|
||||
|
||||
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
||||
/// streams so a reconnect doesn't open a second (conflicting) screencast session.
|
||||
pub type CapturerSlot = Arc<std::sync::Mutex<Option<Box<dyn Capturer>>>>;
|
||||
|
||||
/// Spawn the video stream thread (idempotent via `running`). Stops when `running` clears.
|
||||
/// `force_idr` is set by the control stream on a client recovery request; `video_cap` holds
|
||||
/// the persistent capturer the thread borrows for the stream's duration.
|
||||
pub fn start(
|
||||
cfg: StreamConfig,
|
||||
app: Option<super::apps::AppEntry>,
|
||||
running: Arc<AtomicBool>,
|
||||
force_idr: Arc<AtomicBool>,
|
||||
video_cap: CapturerSlot,
|
||||
) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-video".into())
|
||||
.spawn(move || {
|
||||
tracing::info!(?cfg, "video stream starting");
|
||||
if let Err(e) = run(cfg, app.as_ref(), &running, &force_idr, &video_cap) {
|
||||
tracing::error!(error = %format!("{e:#}"), "video stream failed");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
tracing::info!("video stream stopped");
|
||||
});
|
||||
}
|
||||
|
||||
fn run(
|
||||
cfg: StreamConfig,
|
||||
app: Option<&super::apps::AppEntry>,
|
||||
running: &Arc<AtomicBool>,
|
||||
force_idr: &AtomicBool,
|
||||
video_cap: &std::sync::Mutex<Option<Box<dyn Capturer>>>,
|
||||
) -> Result<()> {
|
||||
// Reject an out-of-range client mode before allocating capture/encode buffers.
|
||||
encode::validate_dimensions(cfg.codec, cfg.width, cfg.height)
|
||||
.context("client-requested video mode")?;
|
||||
let sock = UdpSocket::bind(("0.0.0.0", VIDEO_PORT)).context("bind video UDP")?;
|
||||
// The client pings the video port so we learn where to send; it re-pings until video
|
||||
// flows, so a missed early ping is fine.
|
||||
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
tracing::info!(
|
||||
port = VIDEO_PORT,
|
||||
"video: awaiting client ping to learn endpoint"
|
||||
);
|
||||
let mut probe = [0u8; 256];
|
||||
let (_, client) = sock
|
||||
.recv_from(&mut probe)
|
||||
.context("video: no client ping within 10s")?;
|
||||
sock.connect(client)
|
||||
.context("connect client video endpoint")?;
|
||||
tracing::info!(%client, "video: client endpoint learned");
|
||||
|
||||
// Native client-resolution source: create a compositor virtual output sized to the client's
|
||||
// request and capture it (no scaling). Self-contained — deliberately NOT pooled in
|
||||
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
|
||||
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
||||
if std::env::var("PUNKTFUNK_VIDEO_SOURCE").as_deref() == Ok("virtual") {
|
||||
// The launched app picks the compositor (e.g. gamescope for game entries) and the
|
||||
// nested command; env vars remain manual overrides / fallbacks.
|
||||
let compositor = app
|
||||
.and_then(|a| a.compositor)
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| crate::vdisplay::detect().context("detect compositor"))?;
|
||||
if let Some(cmd) = app.and_then(|a| a.cmd.as_deref()) {
|
||||
// The gamescope backend reads the nested command from this env var; setting it
|
||||
// per-launch is safe (one stream session at a time).
|
||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", cmd);
|
||||
}
|
||||
tracing::info!(
|
||||
?compositor,
|
||||
app = ?app.map(|a| &a.title),
|
||||
w = cfg.width,
|
||||
h = cfg.height,
|
||||
"video source: virtual display (native client resolution)"
|
||||
);
|
||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||
let vout = vd
|
||||
.create(punktfunk_core::Mode {
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
refresh_hz: cfg.fps,
|
||||
})
|
||||
.context("create virtual output at client resolution")?;
|
||||
let mut capturer =
|
||||
capture::capture_virtual_output(vout).context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
return stream_body(&mut *capturer, &sock, cfg, running, force_idr);
|
||||
}
|
||||
|
||||
// Reuse the persistent capturer (one screencast session → clean reconnect); create it on
|
||||
// the first stream. Borrow it for this stream and return it on exit.
|
||||
let mut capturer: Box<dyn Capturer> = match video_cap.lock().unwrap().take() {
|
||||
Some(c) => {
|
||||
tracing::info!("video source: reusing capturer");
|
||||
c
|
||||
}
|
||||
None if std::env::var("PUNKTFUNK_VIDEO_SOURCE").is_ok_and(|v| v == "portal") => {
|
||||
tracing::info!("video source: portal desktop capture");
|
||||
capture::open_portal_monitor().context("open portal capturer")?
|
||||
}
|
||||
None => {
|
||||
tracing::info!("video source: synthetic test pattern");
|
||||
Box::new(FastSyntheticCapturer::new(cfg.width, cfg.height))
|
||||
}
|
||||
};
|
||||
capturer.set_active(true);
|
||||
let result = stream_body(&mut *capturer, &sock, cfg, running, force_idr);
|
||||
capturer.set_active(false);
|
||||
*video_cap.lock().unwrap() = Some(capturer);
|
||||
result
|
||||
}
|
||||
|
||||
/// One frame's packets, handed from the encode thread to the send thread.
|
||||
type PacketBatch = Vec<Vec<u8>>;
|
||||
|
||||
/// Send `pkts` with as few syscalls as possible (`sendmmsg`, up to 64 per call). The socket is
|
||||
/// connected, so no per-message address. Returns an error on the first send failure.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
|
||||
use std::os::fd::AsRawFd;
|
||||
const CHUNK: usize = 64;
|
||||
let fd = sock.as_raw_fd();
|
||||
for chunk in pkts.chunks(CHUNK) {
|
||||
let mut iovs: Vec<libc::iovec> = chunk
|
||||
.iter()
|
||||
.map(|p| libc::iovec {
|
||||
iov_base: p.as_ptr() as *mut libc::c_void,
|
||||
iov_len: p.len(),
|
||||
})
|
||||
.collect();
|
||||
let mut hdrs: Vec<libc::mmsghdr> = iovs
|
||||
.iter_mut()
|
||||
.map(|iov| {
|
||||
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
|
||||
h.msg_hdr.msg_iov = iov;
|
||||
h.msg_hdr.msg_iovlen = 1;
|
||||
h
|
||||
})
|
||||
.collect();
|
||||
let mut off = 0usize;
|
||||
while off < hdrs.len() {
|
||||
let n = unsafe {
|
||||
libc::sendmmsg(fd, hdrs[off..].as_mut_ptr(), (hdrs.len() - off) as u32, 0)
|
||||
};
|
||||
if n < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
off += n as usize;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Portable fallback (non-Linux dev builds — GameStream hosting never ships there): one
|
||||
/// syscall per packet.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
|
||||
for p in pkts {
|
||||
sock.send(p)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in
|
||||
/// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval
|
||||
/// (microburst shaping at chunk granularity — a real link drops line-rate bursts; the encode
|
||||
/// thread is never blocked by this). On send failure (client gone) it clears `running`.
|
||||
fn spawn_sender(
|
||||
sock: UdpSocket,
|
||||
rx: std::sync::mpsc::Receiver<PacketBatch>,
|
||||
frame_interval: Duration,
|
||||
running: Arc<AtomicBool>,
|
||||
drop_pct: u32,
|
||||
) -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-send".into())
|
||||
.spawn(move || {
|
||||
// Chunk pacing: 16 packets per burst, bursts spread across the send budget.
|
||||
const PACE_CHUNK: usize = 16;
|
||||
let budget = frame_interval.mul_f32(0.75);
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut sent: u64 = 0;
|
||||
let mut dropped: u64 = 0;
|
||||
while let Ok(mut batch) = rx.recv() {
|
||||
if drop_pct > 0 {
|
||||
batch.retain(|_| {
|
||||
let keep = rng.gen_range(0..100) >= drop_pct;
|
||||
if !keep {
|
||||
dropped += 1;
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
let n = batch.len();
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let per_chunk = budget.mul_f64((PACE_CHUNK as f64 / n as f64).min(1.0));
|
||||
let start = Instant::now();
|
||||
for (i, chunk) in batch.chunks(PACE_CHUNK).enumerate() {
|
||||
if let Err(e) = sendmmsg_all(&sock, chunk) {
|
||||
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
|
||||
running.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
sent += chunk.len() as u64;
|
||||
// Sleep toward the next chunk's deadline; skip sub-500µs sleeps (jitter).
|
||||
let target = start + per_chunk.mul_f64((i + 1) as f64);
|
||||
if let Some(ahead) = target.checked_duration_since(Instant::now()) {
|
||||
if ahead >= Duration::from_micros(500) {
|
||||
std::thread::sleep(ahead);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!(sent, dropped, "video sender exiting");
|
||||
})
|
||||
.context("spawn send thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
|
||||
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
|
||||
fn stream_body(
|
||||
capturer: &mut dyn Capturer,
|
||||
sock: &UdpSocket,
|
||||
cfg: StreamConfig,
|
||||
running: &Arc<AtomicBool>,
|
||||
force_idr: &AtomicBool,
|
||||
) -> Result<()> {
|
||||
// The first frame establishes the authoritative size/format for the encoder.
|
||||
let mut frame = capturer.next_frame().context("capture first frame")?;
|
||||
if frame.width != cfg.width || frame.height != cfg.height {
|
||||
tracing::warn!(
|
||||
captured = ?(frame.width, frame.height),
|
||||
negotiated = ?(cfg.width, cfg.height),
|
||||
"captured size != negotiated size — Moonlight expects the negotiated size; resize the output"
|
||||
);
|
||||
}
|
||||
let mut enc = encode::open_video(
|
||||
cfg.codec,
|
||||
frame.format,
|
||||
frame.width,
|
||||
frame.height,
|
||||
cfg.fps,
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
)
|
||||
.context("open NVENC for stream")?;
|
||||
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
||||
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20);
|
||||
let mut pk = VideoPacketizer::new(cfg.packet_size, fec_pct, cfg.min_fec);
|
||||
|
||||
// Pace at the client's negotiated frame rate, re-encoding the last captured frame when the
|
||||
// compositor produced no new one. Compositors only emit frames on damage, so a static or
|
||||
// slow-updating desktop would otherwise starve the client into a "network too slow" abort.
|
||||
// Re-encoding an unchanged frame is cheap — NVENC emits a near-empty P-frame. The upper
|
||||
// bound just guards against an absurd client request (the encoder is opened at `cfg.fps`).
|
||||
let target_fps = cfg.fps.clamp(1, 240);
|
||||
let frame_interval = Duration::from_secs_f64(1.0 / target_fps as f64);
|
||||
let mut fps_count: u32 = 0;
|
||||
let mut fps_t = Instant::now();
|
||||
let stream_start = Instant::now();
|
||||
// Test knob: drop this % of outbound packets to exercise FEC recovery (0 = off).
|
||||
let drop_pct: u32 = std::env::var("PUNKTFUNK_VIDEO_DROP")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let mut sent_batches: u64 = 0;
|
||||
let mut dropped_batches: u64 = 0;
|
||||
|
||||
// The send thread: one frame's batch at a time over a small bounded queue. Depth 2 means a
|
||||
// slow send can buffer one frame while the next encodes; beyond that the NEWEST batch is
|
||||
// dropped (the client recovers via FEC/RFI) rather than ever stalling the encode loop.
|
||||
let (batch_tx, batch_rx) = std::sync::mpsc::sync_channel::<PacketBatch>(2);
|
||||
spawn_sender(
|
||||
sock.try_clone().context("clone video socket")?,
|
||||
batch_rx,
|
||||
Duration::from_secs_f64(1.0 / target_fps as f64),
|
||||
running.clone(),
|
||||
drop_pct,
|
||||
)?;
|
||||
|
||||
// Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames,
|
||||
// to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
|
||||
let perf = std::env::var_os("PUNKTFUNK_PERF").is_some();
|
||||
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) =
|
||||
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32);
|
||||
// Absolute next-frame deadline — the single pacing clock for the loop.
|
||||
let mut next_frame = Instant::now();
|
||||
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let tick = Instant::now();
|
||||
// Advance to the freshest captured frame if one arrived; otherwise reuse the last.
|
||||
if let Some(f) = capturer.try_latest().context("capture frame")? {
|
||||
frame = f;
|
||||
uniq += 1;
|
||||
}
|
||||
let t_cap = tick.elapsed();
|
||||
// Honor a client recovery request (RFI / request-IDR): force a keyframe so the client
|
||||
// resyncs immediately instead of waiting for the next GOP boundary.
|
||||
if force_idr.swap(false, Ordering::SeqCst) {
|
||||
enc.request_keyframe();
|
||||
}
|
||||
enc.submit(&frame).context("encoder submit")?;
|
||||
let t_enc = tick.elapsed();
|
||||
|
||||
// 90 kHz RTP timestamp from wall-clock, so a variable capture rate stays correct.
|
||||
let ts = (stream_start.elapsed().as_secs_f64() * 90_000.0) as u32;
|
||||
let mut batch: Vec<Vec<u8>> = Vec::new();
|
||||
while let Some(au) = enc.poll().context("encoder poll")? {
|
||||
let ft = if au.keyframe {
|
||||
FrameType::Idr
|
||||
} else {
|
||||
FrameType::P
|
||||
};
|
||||
batch.extend(pk.packetize(&au.data, ft, ts));
|
||||
}
|
||||
let t_pkt = tick.elapsed();
|
||||
|
||||
// Hand the frame's packets to the send thread; never block here. A full queue means
|
||||
// the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding.
|
||||
let n = batch.len();
|
||||
if n > 0 {
|
||||
match batch_tx.try_send(batch) {
|
||||
Ok(()) => sent_batches += 1,
|
||||
Err(std::sync::mpsc::TrySendError::Full(_)) => {
|
||||
dropped_batches += 1;
|
||||
if dropped_batches.is_power_of_two() {
|
||||
tracing::warn!(dropped_batches, "video: send queue full — frame dropped");
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
|
||||
break; // sender exited (client gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
if perf {
|
||||
let t_send = tick.elapsed();
|
||||
mx_cap = mx_cap.max(t_cap.as_micros());
|
||||
mx_enc = mx_enc.max((t_enc - t_cap).as_micros());
|
||||
mx_pkt = mx_pkt.max((t_pkt - t_enc).as_micros());
|
||||
mx_send = mx_send.max((t_send - t_pkt).as_micros());
|
||||
mx_pkts = mx_pkts.max(n);
|
||||
}
|
||||
|
||||
fps_count += 1;
|
||||
if fps_t.elapsed() >= Duration::from_secs(1) {
|
||||
if perf {
|
||||
// Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device
|
||||
// copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new
|
||||
// captured frames (vs re-encoded). `pkts`=max packets in one frame (IDR spike).
|
||||
tracing::info!(
|
||||
fps = fps_count,
|
||||
uniq,
|
||||
enc_us = mx_enc,
|
||||
pkt_us = mx_pkt,
|
||||
send_us = mx_send,
|
||||
cap_us = mx_cap,
|
||||
max_pkts = mx_pkts,
|
||||
"video: streaming (perf)"
|
||||
);
|
||||
mx_cap = 0;
|
||||
mx_enc = 0;
|
||||
mx_pkt = 0;
|
||||
mx_send = 0;
|
||||
mx_pkts = 0;
|
||||
uniq = 0;
|
||||
} else {
|
||||
tracing::info!(
|
||||
fps = fps_count,
|
||||
sent_batches,
|
||||
dropped_batches,
|
||||
"video: streaming"
|
||||
);
|
||||
}
|
||||
fps_count = 0;
|
||||
fps_t = Instant::now();
|
||||
}
|
||||
// Single pacing authority: hold a steady cadence at the target rate from an absolute
|
||||
// clock. No double-sleep. If a slow frame put us behind, resync to now rather than
|
||||
// bursting to catch up.
|
||||
next_frame += frame_interval;
|
||||
match next_frame.checked_duration_since(Instant::now()) {
|
||||
Some(d) => std::thread::sleep(d),
|
||||
None => next_frame = Instant::now(),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// End-to-end check of the send thread: batches pushed on the channel arrive, complete and
|
||||
/// byte-identical, at a peer socket via the paced sendmmsg path.
|
||||
#[test]
|
||||
fn sender_delivers_batches() {
|
||||
let rx_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
rx_sock
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let tx_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
tx_sock.connect(rx_sock.local_addr().unwrap()).unwrap();
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<PacketBatch>(2);
|
||||
spawn_sender(
|
||||
tx_sock,
|
||||
rx,
|
||||
Duration::from_millis(8), // ~120fps frame interval
|
||||
running.clone(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 3 frames of 100 packets, content-tagged for verification.
|
||||
let mut sent = Vec::new();
|
||||
for f in 0..3u8 {
|
||||
let batch: PacketBatch = (0..100u8)
|
||||
.map(|i| {
|
||||
let mut p = vec![0u8; 1200];
|
||||
p[0] = f;
|
||||
p[1] = i;
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
sent.extend(batch.iter().cloned());
|
||||
tx.send(batch).unwrap();
|
||||
}
|
||||
drop(tx); // sender drains then exits
|
||||
|
||||
let mut got = 0usize;
|
||||
let mut buf = [0u8; 2048];
|
||||
while got < sent.len() {
|
||||
let n = rx_sock.recv(&mut buf).expect("packet within timeout");
|
||||
assert_eq!(n, 1200);
|
||||
let (f, i) = (buf[0] as usize, buf[1] as usize);
|
||||
assert_eq!(&buf[..n], &sent[f * 100 + i][..], "payload intact");
|
||||
got += 1;
|
||||
}
|
||||
assert_eq!(got, 300);
|
||||
assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
|
||||
}
|
||||
}
|
||||
@@ -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,312 @@
|
||||
//! GameStream video wire packetization: an encoded access unit → UDP datagrams a stock
|
||||
//! Moonlight client decodes (and recovers under loss). 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 shards. Byte-exact spec:
|
||||
//! `docs/research/gamestream-protocol-research.json` (video plane).
|
||||
//!
|
||||
//! FEC (P1.5): each block carries `m = ⌈k·pct/100⌉` Reed–Solomon parity shards generated by
|
||||
//! `punktfunk_core::fec::Gf8Coder` (the nanors-compatible Cauchy GF(2⁸) coder). Crucially, RS runs
|
||||
//! over the **whole `blocksize` shard** — Moonlight decodes over `packetSize + 16` bytes from
|
||||
//! the datagram start (`RtpVideoQueue.c`), and rejects a recovered shard whose reconstructed
|
||||
//! `flags` byte isn't valid — so the NV header fields RS must reproduce (streamPacketIndex,
|
||||
//! frameIndex, flags, multiFec*) are written into the data shards **before** encoding, and only
|
||||
//! the transport fields (RTP header/seq/timestamp + fecInfo) are stamped **after**, matching
|
||||
//! Sunshine `stream.cpp`. `pct = 0` falls back to data-shards-only. Plaintext (AES-GCM video
|
||||
//! encryption is negotiated off for now).
|
||||
|
||||
use punktfunk_core::fec::{ErasureCoder, Gf8Coder};
|
||||
|
||||
/// 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 (data + FEC parity shards).
|
||||
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,
|
||||
/// Requested FEC overhead percent (0 = data shards only). The wire carries the recomputed
|
||||
/// per-block `(100·m)/k` so Moonlight derives the same parity count.
|
||||
fec_percentage: usize,
|
||||
/// Minimum parity shards per block (the client's `fec.minRequiredFecPackets`) — protects
|
||||
/// small frames whose `⌈k·pct/100⌉` would otherwise be just 1.
|
||||
min_fec: usize,
|
||||
frame_index: u32,
|
||||
/// Monotonic per-stream packet counter (the RTP sequence / streamPacketIndex source).
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
impl VideoPacketizer {
|
||||
pub fn new(packet_size: usize, fec_percentage: u8, min_fec: u8) -> Self {
|
||||
VideoPacketizer {
|
||||
packet_size,
|
||||
payload_per_shard: packet_size + 16 - SHARD_HEADER,
|
||||
fec_percentage: fec_percentage as usize,
|
||||
min_fec: min_fec as usize,
|
||||
frame_index: 0,
|
||||
seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Packetize one encoded AU into wire datagrams (data shards + Cauchy RS parity shards).
|
||||
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;
|
||||
let blocksize = SHARD_HEADER + pps; // = packet_size + 16
|
||||
let pct = self.fec_percentage;
|
||||
|
||||
// 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);
|
||||
// With parity, cap per-block data so k + m ≤ 255 (the GF(2⁸) ceiling): parity for k
|
||||
// data shards is ⌈k·pct/100⌉, so k ≤ 255·100/(100+pct).
|
||||
let max_data = if pct > 0 {
|
||||
(255 * 100) / (100 + pct)
|
||||
} else {
|
||||
MAX_DATA_SHARDS_PER_BLOCK
|
||||
};
|
||||
let n_blocks = total_data.div_ceil(max_data).clamp(1, MAX_FEC_BLOCKS);
|
||||
let per_block = total_data.div_ceil(n_blocks);
|
||||
|
||||
let mut packets = Vec::with_capacity(total_data + total_data * pct / 100 + n_blocks);
|
||||
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 k = last - first;
|
||||
let block_seq_base = self.seq;
|
||||
let multi_fec_blocks = ((b as u8) << 4) | (((n_blocks - 1) as u8) << 6);
|
||||
|
||||
// 1. Build this block's k data-shard datagrams (full `blocksize`), writing the NV
|
||||
// header fields RS must reproduce on recovery (streamPacketIndex, frameIndex,
|
||||
// flags, multiFec*). The RTP header + fecInfo are left zero (stamped post-RS).
|
||||
let mut shards: Vec<Vec<u8>> = Vec::with_capacity(k);
|
||||
for i in 0..k {
|
||||
let global = first + i;
|
||||
let seq = block_seq_base + i as u32;
|
||||
let mut buf = vec![0u8; blocksize];
|
||||
let mut flags = FLAG_PIC;
|
||||
if global == 0 {
|
||||
flags |= FLAG_SOF;
|
||||
}
|
||||
if global == total_data - 1 {
|
||||
flags |= FLAG_EOF;
|
||||
}
|
||||
buf[16..20].copy_from_slice(&(seq << 8).to_le_bytes()); // streamPacketIndex
|
||||
buf[20..24].copy_from_slice(&frame_index.to_le_bytes()); // frameIndex
|
||||
buf[24] = flags;
|
||||
buf[26] = MULTI_FEC_FLAGS;
|
||||
buf[27] = multi_fec_blocks;
|
||||
let ps = global * pps;
|
||||
let pe = (ps + pps).min(fp.len());
|
||||
buf[SHARD_HEADER..SHARD_HEADER + (pe - ps)].copy_from_slice(&fp[ps..pe]);
|
||||
shards.push(buf);
|
||||
}
|
||||
|
||||
// 2. m = ⌈k·pct/100⌉ parity shards (floored at the client's min, capped so k+m≤255)
|
||||
// over the full datagrams. The wire percentage is recomputed from m so the client
|
||||
// derives the same count.
|
||||
let m = if pct > 0 {
|
||||
(k * pct).div_ceil(100).max(self.min_fec).min(255 - k)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let wire_pct = if m > 0 { (100 * m) / k } else { 0 };
|
||||
let parity = if m > 0 {
|
||||
Gf8Coder.encode(&shards, m).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// 3. Stamp transport headers (RTP + fecInfo) on every shard. We do NOT touch the
|
||||
// flags/streamPacketIndex bytes, so a recovered data shard's RS-reconstructed
|
||||
// NV header stays valid.
|
||||
self.seq = block_seq_base + k as u32;
|
||||
for (i, mut buf) in shards.into_iter().enumerate() {
|
||||
let seq = block_seq_base + i as u32;
|
||||
finalize(
|
||||
&mut buf,
|
||||
seq,
|
||||
timestamp_90k,
|
||||
frame_index,
|
||||
multi_fec_blocks,
|
||||
fec_info(k, i, wire_pct),
|
||||
);
|
||||
packets.push(buf);
|
||||
}
|
||||
for (j, mut buf) in parity.into_iter().enumerate() {
|
||||
let seq = self.seq;
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
finalize(
|
||||
&mut buf,
|
||||
seq,
|
||||
timestamp_90k,
|
||||
frame_index,
|
||||
multi_fec_blocks,
|
||||
fec_info(k, k + j, wire_pct),
|
||||
);
|
||||
packets.push(buf);
|
||||
}
|
||||
}
|
||||
packets
|
||||
}
|
||||
}
|
||||
|
||||
/// `fecInfo` (u32, little-endian): `dataShards<<22 | fecIndex<<12 | fecPercentage<<4`.
|
||||
fn fec_info(k: usize, fec_index: usize, pct: usize) -> u32 {
|
||||
((k as u32) << 22) | ((fec_index as u32) << 12) | ((pct as u32) << 4)
|
||||
}
|
||||
|
||||
/// Stamp the post-RS transport fields into a shard datagram (in place). Leaves the NV
|
||||
/// `flags`/`streamPacketIndex`/`multiFecFlags` bytes untouched (RS-covered).
|
||||
fn finalize(
|
||||
buf: &mut [u8],
|
||||
seq: u32,
|
||||
ts_90k: u32,
|
||||
frame_index: u32,
|
||||
multi_fec_blocks: u8,
|
||||
fec_info: u32,
|
||||
) {
|
||||
buf[0] = RTP_HEADER_BYTE; // header (version 2 + extension)
|
||||
buf[2..4].copy_from_slice(&(seq as u16).to_be_bytes()); // sequenceNumber (BE)
|
||||
buf[4..8].copy_from_slice(&ts_90k.to_be_bytes()); // timestamp (90 kHz, BE)
|
||||
buf[20..24].copy_from_slice(&frame_index.to_le_bytes()); // frameIndex (re-affirm for parity)
|
||||
buf[27] = multi_fec_blocks; // re-affirm for parity
|
||||
buf[28..32].copy_from_slice(&fec_info.to_le_bytes()); // fecInfo (LE)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_block_layout() {
|
||||
let mut pk = VideoPacketizer::new(1392, 0, 0); // data-only; pps = 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);
|
||||
for p in &pkts {
|
||||
assert_eq!(p.len(), SHARD_HEADER + 1376);
|
||||
assert_eq!(p[0], 0x90); // RTP header byte
|
||||
}
|
||||
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
|
||||
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);
|
||||
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, 0, 0); // data-only
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_parity_shards() {
|
||||
let mut pk = VideoPacketizer::new(1392, 20, 0); // pps = 1376, 20% FEC
|
||||
let au = vec![0xABu8; 4000]; // 8+4000 = 4008 → 3 data shards (k=3)
|
||||
let pkts = pk.packetize(&au, FrameType::Idr, 0);
|
||||
// m = ceil(3*20/100) = 1 parity shard → 4 packets; wire_pct = 100*1/3 = 33.
|
||||
assert_eq!(pkts.len(), 4);
|
||||
for p in &pkts {
|
||||
let fec_info = u32::from_le_bytes(p[28..32].try_into().unwrap());
|
||||
assert_eq!(fec_info >> 22, 3); // dataShards = k = 3
|
||||
assert_eq!((fec_info >> 4) & 0xff, 33); // wire fecPercentage
|
||||
}
|
||||
// The parity shard is last: fecIndex = k = 3.
|
||||
let parity = &pkts[3];
|
||||
let fec_info = u32::from_le_bytes(parity[28..32].try_into().unwrap());
|
||||
assert_eq!((fec_info >> 12) & 0x3ff, 3);
|
||||
// Data shards keep SOF (first) / EOF (last data shard) / PIC.
|
||||
assert_eq!(pkts[0][24] & FLAG_SOF, FLAG_SOF);
|
||||
assert_eq!(pkts[2][24] & FLAG_EOF, FLAG_EOF);
|
||||
// RTP sequence numbers are contiguous across data + parity (0,1,2,3).
|
||||
for (i, p) in pkts.iter().enumerate() {
|
||||
assert_eq!(u16::from_be_bytes(p[2..4].try_into().unwrap()), i as u16);
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end recovery: parity over the full datagram reconstructs a dropped data shard's
|
||||
/// payload AND its NV `flags` byte (the byte Moonlight validates), proving the layout.
|
||||
#[test]
|
||||
fn parity_recovers_full_datagram_incl_flags() {
|
||||
let mut pk = VideoPacketizer::new(1392, 50, 0); // high pct → plenty of parity
|
||||
let au = vec![0x5Au8; 4000]; // k = 3
|
||||
let pkts = pk.packetize(&au, FrameType::Idr, 0);
|
||||
let k = 3usize;
|
||||
let m = pkts.len() - k;
|
||||
assert!(m >= 1);
|
||||
// Drop data shard 1; reconstruct from the rest via the same Cauchy coder.
|
||||
let mut received: Vec<Option<Vec<u8>>> = pkts.iter().map(|p| Some(p.clone())).collect();
|
||||
received[1] = None;
|
||||
let recovered = Gf8Coder.reconstruct(k, m, &mut received).unwrap();
|
||||
// The recovered shard equals the original data shard's RS-covered bytes: its flags
|
||||
// byte (offset 24) is PIC (middle shard), proving the NV header recovers correctly.
|
||||
assert_eq!(recovered[1][24], FLAG_PIC);
|
||||
// ...and the payload region matches the original.
|
||||
assert_eq!(recovered[1][SHARD_HEADER..], pkts[1][SHARD_HEADER..]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user