520d7342dd
ci / rust (push) Has been cancelled
m3-host is now a real host, not a one-shot demo. Everything validated live on this box (two back-to-back sessions, pinned + TOFU, ~200 audio pkts/s, p50 0.84 ms at 720p60). lumen-core: - quic.rs: QUIC-datagram side planes demuxed by first byte — Opus audio 0xC9 ([magic][u32 seq][u64 pts_ns][opus], host→client) and rumble 0xCA ([magic][pad][low][high]). - Trust: endpoint::server_with_identity (persistent PEM identity) and endpoint::client_pinned — SHA-256 cert-fingerprint pinning with TOFU (observed fingerprint reported back for persisting). The verifier checks the TLS 1.3 CertificateVerify signature for real (an MITM replaying the host's public cert without its key is rejected; cert pinning alone would not prove key possession). - client.rs: NativeClient gains pin + host_fingerprint, audio/rumble receivers (next_audio / next_rumble); pull methods take &self so the C ABI's per-plane threads never alias a &mut (per-plane mutexed borrow slots in abi.rs). - abi.rs: lumen_connect(pin_sha256, observed_sha256_out) + lumen_connection_next_audio / next_rumble. input.rs: documented gamepad wire contract (GameStream buttonFlags bits, XInput axis conventions, +y = up) — exported as LUMEN_BTN_*/LUMEN_AXIS_* (bare BTN_* collides with <linux/input-event-codes.h> at different values). lumen-host (m3): - Persistent accept loop: sessions back to back on one endpoint (--max-sessions, 0 = forever); per-session failures log and the loop keeps serving; 10 s handshake deadline so a silent client can't wedge the sequential accept queue; teardown on every exit path (stop flag → conn.close → join audio+input threads). - Audio plane: desktop PipeWire capture → Opus 48 kHz stereo 5 ms CBR → datagrams; ONE capturer reused across sessions via an AudioCapSlot (PipeWire streams have no cheap teardown — per-session opens would leak a thread + core connection + live node each). - Gamepad routing: incremental GamepadButton/GamepadAxis datagrams accumulate into per-pad state feeding the uinput xpad manager; force feedback returns as rumble datagrams, with current state re-sent every 500 ms (idempotent-state healing for the lossy channel). QUIC endpoint serves the persistent ~/.config/lumen identity and logs the pinnable fingerprint. lumen-client-rs: --pin (malformed values abort — never silently downgrade to TOFU), TOFU fingerprint logging, audio/rumble datagram counters, gamepad events in --input-test. clients/apple: scaffold synced — pinSHA256/hostFingerprint (wrong-size pin throws, fail-closed), nextAudio/nextRumble, gamepad event constructors; README handoff updated (persistent listener, audio decode notes, trust UX). Adversarially reviewed (5-dimension multi-agent pass over the diff, 2-skeptic verification): fixed the MITM signature-check gap, a Y-axis contract inversion, header macro collisions, ABI aliasing UB, the PipeWire per-session leak, the missing handshake deadline, fail-open pin parsing, and teardown-on-error paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
338 lines
12 KiB
Rust
338 lines
12 KiB
Rust
//! `lumen-host` — the Linux streaming host (plan §2, §6, §7).
|
|
//!
|
|
//! Creates a client-sized virtual display, captures it via PipeWire, encodes with
|
|
//! VAAPI/NVENC, and hands encoded access units to `lumen_core` for FEC + packetization +
|
|
//! pacing + send. Input flows back via libei/uinput. The platform backends are
|
|
//! `#[cfg(target_os = "linux")]`; the crate compiles everywhere so the workspace builds
|
|
//! on non-Linux dev machines — it just can't run the pipeline there.
|
|
//!
|
|
//! Status: M0. The `m0` subcommand runs the capture→encode→file pipeline spike and feeds
|
|
//! the encoded AUs through a `lumen_core` loopback. M2 wires the full P1 host that a stock
|
|
//! Moonlight client connects to.
|
|
|
|
// Scaffold: trait methods and config paths are defined ahead of their backends.
|
|
#![allow(dead_code)]
|
|
|
|
mod audio;
|
|
mod capture;
|
|
mod encode;
|
|
mod gamestream;
|
|
mod inject;
|
|
mod m0;
|
|
mod m3;
|
|
mod mgmt;
|
|
mod pipeline;
|
|
mod pwinit;
|
|
mod vdisplay;
|
|
#[cfg(target_os = "linux")]
|
|
mod zerocopy;
|
|
|
|
use anyhow::{bail, Result};
|
|
use encode::Codec;
|
|
use m0::{Options, Source};
|
|
use std::path::PathBuf;
|
|
|
|
fn main() {
|
|
// Logs go to stderr so stdout stays machine-readable (`lumen-host openapi > spec.json`).
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
|
)
|
|
.with_writer(std::io::stderr)
|
|
.init();
|
|
|
|
if let Err(e) = real_main() {
|
|
tracing::error!("{e:#}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn real_main() -> Result<()> {
|
|
tracing::info!("lumen-host (lumen_core ABI v{})", lumen_core::ABI_VERSION);
|
|
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
match args.first().map(String::as_str) {
|
|
// M2 GameStream host control plane (P1.1: mDNS + serverinfo) + management API.
|
|
Some("serve") => gamestream::serve(parse_serve(&args[1..])?),
|
|
// Print the management API's OpenAPI document (for client codegen).
|
|
Some("openapi") => {
|
|
print!("{}", mgmt::openapi_json());
|
|
Ok(())
|
|
}
|
|
// Standalone input-injection smoke test (no client needed): open the session's input
|
|
// backend and inject a scripted mouse/keyboard pattern. Watch a focused app / `wev`.
|
|
Some("input-test") => input_test(),
|
|
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
|
#[cfg(target_os = "linux")]
|
|
Some("zerocopy-probe") => zerocopy::probe(),
|
|
// M0 pipeline spike.
|
|
Some("m0") => m0::run(parse_m0(&args[1..])?),
|
|
// M3: native lumen/1 host (QUIC control plane + UDP data plane).
|
|
Some("m3-host") => {
|
|
let get = |flag: &str| {
|
|
args.iter()
|
|
.skip_while(|a| *a != flag)
|
|
.nth(1)
|
|
.map(String::as_str)
|
|
};
|
|
let source = match get("--source") {
|
|
Some("virtual") => m3::M3Source::Virtual,
|
|
_ => m3::M3Source::Synthetic,
|
|
};
|
|
m3::run(m3::M3Options {
|
|
port: get("--port").and_then(|s| s.parse().ok()).unwrap_or(9777),
|
|
source,
|
|
seconds: get("--seconds").and_then(|s| s.parse().ok()).unwrap_or(30),
|
|
frames: get("--frames").and_then(|s| s.parse().ok()).unwrap_or(300),
|
|
max_sessions: get("--max-sessions")
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(0),
|
|
})
|
|
}
|
|
Some("-h") | Some("--help") | Some("help") | None => {
|
|
print_usage();
|
|
Ok(())
|
|
}
|
|
// Bare flags (no subcommand) default to the m0 spike for back-compat.
|
|
Some(_) => m0::run(parse_m0(&args)?),
|
|
}
|
|
}
|
|
|
|
/// Inject a scripted mouse + keyboard pattern through the session's input backend (libei on
|
|
/// KWin/GNOME, wlr on Sway). Lets us validate input injection without a Moonlight client.
|
|
#[cfg(target_os = "linux")]
|
|
fn input_test() -> Result<()> {
|
|
use lumen_core::input::{InputEvent, InputKind};
|
|
use std::time::Duration;
|
|
|
|
let backend = inject::default_backend();
|
|
tracing::info!(?backend, "input-test: opening injector");
|
|
let mut inj = inject::open(backend)?;
|
|
// An async backend (libei) needs a moment to establish its portal/EIS session + device
|
|
// resume; events injected before then are dropped.
|
|
std::thread::sleep(Duration::from_secs(4));
|
|
|
|
let ev = |kind, code, x, y| InputEvent {
|
|
kind,
|
|
_pad: [0; 3],
|
|
code,
|
|
x,
|
|
y,
|
|
flags: 0,
|
|
};
|
|
tracing::info!(
|
|
"input-test: injecting a mouse square + 'A'/click taps for ~8s (watch wev / focused app)"
|
|
);
|
|
for i in 0..160u32 {
|
|
let (dx, dy) = match (i / 10) % 4 {
|
|
0 => (12, 0),
|
|
1 => (0, 12),
|
|
2 => (-12, 0),
|
|
_ => (0, -12),
|
|
};
|
|
if let Err(e) = inj.inject(&ev(InputKind::MouseMove, 0, dx, dy)) {
|
|
tracing::warn!(error = %format!("{e:#}"), "input-test: inject failed");
|
|
}
|
|
if i % 20 == 0 {
|
|
let _ = inj.inject(&ev(InputKind::KeyDown, 0x41, 0, 0)); // 'A'
|
|
let _ = inj.inject(&ev(InputKind::KeyUp, 0x41, 0, 0));
|
|
let _ = inj.inject(&ev(InputKind::MouseButtonDown, 1, 0, 0)); // left click
|
|
let _ = inj.inject(&ev(InputKind::MouseButtonUp, 1, 0, 0));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
tracing::info!("input-test: done");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
fn input_test() -> Result<()> {
|
|
bail!("input-test requires Linux")
|
|
}
|
|
|
|
/// `serve` options — all about the management API; the GameStream ports are protocol-fixed.
|
|
fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
|
|
let mut opts = mgmt::Options::default();
|
|
let mut i = 0;
|
|
while i < args.len() {
|
|
let arg = args[i].as_str();
|
|
let mut next = || {
|
|
i += 1;
|
|
args.get(i)
|
|
.cloned()
|
|
.ok_or_else(|| anyhow::anyhow!("missing value for {arg}"))
|
|
};
|
|
match arg {
|
|
"--mgmt-bind" => {
|
|
opts.bind = next()?
|
|
.parse()
|
|
.map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))?
|
|
}
|
|
"--mgmt-token" => {
|
|
let token = next()?;
|
|
// An empty token would satisfy the non-loopback "token required" guard
|
|
// while authenticating nobody (or, worse, everybody) — refuse it loudly
|
|
// rather than letting `--mgmt-token "$UNSET_VAR"` ship a dead credential.
|
|
if token.trim().is_empty() {
|
|
bail!("--mgmt-token must not be empty");
|
|
}
|
|
opts.token = Some(token);
|
|
}
|
|
"-h" | "--help" => {
|
|
print_usage();
|
|
std::process::exit(0);
|
|
}
|
|
other => bail!("unknown argument '{other}' (try --help)"),
|
|
}
|
|
i += 1;
|
|
}
|
|
// Flag wins over the environment so a unit file can set a default and a shell override it.
|
|
if opts.token.is_none() {
|
|
opts.token = std::env::var("LUMEN_MGMT_TOKEN")
|
|
.ok()
|
|
.filter(|t| !t.is_empty());
|
|
}
|
|
Ok(opts)
|
|
}
|
|
|
|
fn parse_m0(args: &[String]) -> Result<Options> {
|
|
let mut source = Source::Portal;
|
|
let mut width = 1920u32;
|
|
let mut height = 1080u32;
|
|
let mut fps = 60u32;
|
|
let mut seconds = 5u32;
|
|
let mut codec = Codec::H265;
|
|
let mut bitrate_mbps = 20u64;
|
|
let mut out: Option<PathBuf> = None;
|
|
let mut loopback = true;
|
|
|
|
let mut i = 0;
|
|
while i < args.len() {
|
|
let arg = args[i].as_str();
|
|
let mut next = || {
|
|
i += 1;
|
|
args.get(i)
|
|
.cloned()
|
|
.ok_or_else(|| anyhow::anyhow!("missing value for {arg}"))
|
|
};
|
|
match arg {
|
|
"--source" => {
|
|
source = match next()?.as_str() {
|
|
"synthetic" => Source::Synthetic,
|
|
"portal" => Source::Portal,
|
|
"kwin-virtual" => Source::KwinVirtual,
|
|
other => {
|
|
bail!("unknown --source '{other}' (synthetic|portal|kwin-virtual)")
|
|
}
|
|
}
|
|
}
|
|
"--width" => {
|
|
width = next()?
|
|
.parse()
|
|
.map_err(|_| anyhow::anyhow!("bad --width"))?
|
|
}
|
|
"--height" => {
|
|
height = next()?
|
|
.parse()
|
|
.map_err(|_| anyhow::anyhow!("bad --height"))?
|
|
}
|
|
"--fps" => fps = next()?.parse().map_err(|_| anyhow::anyhow!("bad --fps"))?,
|
|
"--seconds" => {
|
|
seconds = next()?
|
|
.parse()
|
|
.map_err(|_| anyhow::anyhow!("bad --seconds"))?
|
|
}
|
|
"--codec" => {
|
|
codec = match next()?.as_str() {
|
|
"h264" => Codec::H264,
|
|
"h265" | "hevc" => Codec::H265,
|
|
"av1" => Codec::Av1,
|
|
other => bail!("unknown --codec '{other}' (h264|h265|av1)"),
|
|
}
|
|
}
|
|
"--bitrate" => {
|
|
bitrate_mbps = next()?
|
|
.parse()
|
|
.map_err(|_| anyhow::anyhow!("bad --bitrate (Mbps)"))?
|
|
}
|
|
"--out" => out = Some(PathBuf::from(next()?)),
|
|
"--no-loopback" => loopback = false,
|
|
"-h" | "--help" => {
|
|
print_usage();
|
|
std::process::exit(0);
|
|
}
|
|
other => bail!("unknown argument '{other}' (try --help)"),
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
if fps == 0 || width == 0 || height == 0 || seconds == 0 {
|
|
bail!("--fps/--width/--height/--seconds must be > 0");
|
|
}
|
|
|
|
let out = out.unwrap_or_else(|| {
|
|
let ext = match codec {
|
|
Codec::H264 => "h264",
|
|
Codec::H265 => "h265",
|
|
Codec::Av1 => "obu",
|
|
};
|
|
PathBuf::from(format!("/tmp/lumen-m0.{ext}"))
|
|
});
|
|
|
|
Ok(Options {
|
|
source,
|
|
width,
|
|
height,
|
|
fps,
|
|
seconds,
|
|
codec,
|
|
bitrate_bps: bitrate_mbps.saturating_mul(1_000_000),
|
|
out,
|
|
loopback,
|
|
})
|
|
}
|
|
|
|
fn print_usage() {
|
|
eprintln!(
|
|
"lumen-host — Linux streaming host
|
|
|
|
USAGE:
|
|
lumen-host serve [OPTIONS] GameStream host control plane (M2: mDNS + serverinfo …)
|
|
+ the management REST API
|
|
lumen-host openapi print the management API's OpenAPI document (codegen)
|
|
lumen-host m3-host [OPTIONS] native lumen/1 host (QUIC control plane + UDP data plane)
|
|
lumen-host m0 [OPTIONS] M0 capture→encode→file pipeline spike
|
|
|
|
SERVE OPTIONS:
|
|
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990)
|
|
--mgmt-token <TOKEN> bearer token for the management API (or LUMEN_MGMT_TOKEN);
|
|
required when --mgmt-bind is not loopback
|
|
|
|
M3-HOST OPTIONS:
|
|
--port <N> QUIC listen port (default: 9777)
|
|
--source <synthetic|virtual> test frames, or virtual display + NVENC (default: synthetic)
|
|
--seconds <N> per-session stream duration, virtual source (default: 30)
|
|
--frames <N> per-session frame count, synthetic source (default: 300)
|
|
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
|
|
|
|
M0 OPTIONS:
|
|
--source <synthetic|portal|kwin-virtual>
|
|
frame source (default: portal). 'kwin-virtual' creates a
|
|
KWin virtual output at --width x --height and captures it
|
|
--seconds <N> capture duration in seconds (default: 5)
|
|
--fps <N> target frame rate (default: 60)
|
|
--codec <h264|h265|av1> NVENC codec (default: h265)
|
|
--bitrate <MBPS> target bitrate in Mbps (default: 20)
|
|
--width <W> --height <H> synthetic source size (default: 1920x1080)
|
|
--out <PATH> raw Annex-B output (default: /tmp/lumen-m0.<ext>)
|
|
--no-loopback skip the lumen_core round-trip verification
|
|
-h, --help this help
|
|
|
|
NOTES:
|
|
'portal' needs headless Sway + xdg-desktop-portal-wlr running in this session
|
|
(see docs/linux-setup.md). 'synthetic' needs no capture session and always runs.
|
|
Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a
|
|
lumen_core host→client loopback that reassembles and byte-verifies each one."
|
|
);
|
|
}
|