The release's Windows leg failed to compile joule-moq-relay: the DVR read and wrote its segments through std::os::unix::fs::FileExt. On Windows the same two calls now run over seek_read and seek_write. wai-remote-host would have failed next on its Unix file modes; they now apply on Unix only, as the room's keys already do. Co-Authored-By: Claude Opus 5.5 <[email protected]>
350 lines
17 KiB
Rust
350 lines
17 KiB
Rust
//! `wai-remote-host`: a remote desktop host in a WAI room.
|
|
//!
|
|
//! wai-remote-host room <dir> [--seed <hex>] [--size WxH] [--fps N] [--participant name:classes]...
|
|
//! make a desk, a room the host keeps: room.json, host-keys.json (mode 0600), a file for
|
|
//! each participant (mode 0600, for the headless viewer), and relay.toml, the lines the
|
|
//! relay's configuration takes for it
|
|
//! wai-remote-host key <file>
|
|
//! a host key for a room service's rooms (mode 0600, never over a file); prints its public key
|
|
//! wai-remote-host run <dir> --relay <ws-url> [options]
|
|
//! wai-remote-host run --room <service-url> --key <file> [--invitation <hex>] --relay <ws-url> [options]
|
|
//! host the desk in <dir>, or the room the room service at <service-url> keeps (joule-moq-room
|
|
//! with [remote]: the service names the host's key, or takes the invitation its authority
|
|
//! signed): capture, encode, publish, decide and inject until the time runs out or ^C.
|
|
//! The service's URL is http:// or https://, the relay's ws:// or wss://; over TLS each
|
|
//! certificate is verified against the system's roots and, with --ca, the certificates in a
|
|
//! PEM file. The host makes a sealing key for the session and states it: controllers seal
|
|
//! their key, text and clipboard actions to it, and the host opens each before it injects it.
|
|
//! Options: [--source display|pattern] [--encoder platform|platform-hevc|ipcm]
|
|
//! [--input platform|record] [--floor open|floor] [--give-on-request] [--fps N] [--max-age-ms N]
|
|
//! [--redact x,y,w,h]... [--seconds N] [--ca <pem>] [--record <path>] [--opening-key <path>];
|
|
//! with --record, the session's records as a verifier takes them (descriptor, grants,
|
|
//! chains), which `remote_desktop_verify.py --room` checks; with --opening-key, the
|
|
//! session's opening key (mode 0600, never over a file), which that verifier takes to check
|
|
//! the session at the opened level; with --input record and --record-injected <path>, what
|
|
//! the recorder injected, as counts and digests (no text)
|
|
//! wai-remote-host check
|
|
//! what this machine offers: the platform encoder and whether it runs on the media engine,
|
|
//! and whether the operating system lets this process capture the screen and inject input
|
|
//!
|
|
//! The room's classes are the control power's bits (WAI remote-desktop §3.1): 1 pointer, 2 keys,
|
|
//! 4 touch, 8 clipboard; 0 a viewer.
|
|
|
|
use joule_moq_remote::agent::{self, Command, Config, Room};
|
|
use joule_moq_remote::capture::{Capture, Pattern};
|
|
use joule_moq_remote::encode::{Encoder, Ipcm};
|
|
use joule_moq_remote::inject::{Injector, Recorder};
|
|
use joule_moq_remote::room::{self, HostKeys, RoomFile, Spec};
|
|
use joule_moq_remote::tls::Trust;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
use wai::remote_desktop::{floor_mode, Rect};
|
|
|
|
fn arg<'a>(a: &'a [String], name: &str) -> Option<&'a str> {
|
|
a.iter().position(|x| x == name).and_then(|i| a.get(i + 1)).map(String::as_str)
|
|
}
|
|
|
|
fn args<'a>(a: &'a [String], name: &str) -> Vec<&'a str> {
|
|
a.windows(2).filter(|w| w[0] == name).map(|w| w[1].as_str()).collect()
|
|
}
|
|
|
|
fn read_json<T: serde::de::DeserializeOwned>(p: &Path) -> Result<T, String> {
|
|
serde_json::from_slice(&std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?).map_err(|e| format!("{}: {e}", p.display()))
|
|
}
|
|
|
|
fn write(p: &Path, body: &str, private: bool) -> Result<(), String> {
|
|
std::fs::write(p, body).map_err(|e| format!("{}: {e}", p.display()))?;
|
|
if private {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn make_room(a: &[String]) -> Result<(), String> {
|
|
let dir = PathBuf::from(a.get(2).ok_or("a directory")?);
|
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
let seed: [u8; 32] = match arg(a, "--seed") {
|
|
Some(h) => room::unhex32(h)?,
|
|
None => {
|
|
let mut s = [0u8; 32];
|
|
rand_core_06::RngCore::fill_bytes(&mut rand_core_06::OsRng, &mut s);
|
|
s
|
|
}
|
|
};
|
|
let (w, h) = arg(a, "--size").map_or(Ok((1280, 720)), |s| {
|
|
let (w, h) = s.split_once('x').ok_or("--size WxH")?;
|
|
Ok::<_, String>((w.parse().map_err(|_| "a width")?, h.parse().map_err(|_| "a height")?))
|
|
})?;
|
|
let fps = arg(a, "--fps").map_or(Ok(30), |s| s.parse().map_err(|_| "--fps N".to_string()))?;
|
|
let mut participants = Vec::new();
|
|
for p in args(a, "--participant") {
|
|
let (n, c) = p.split_once(':').ok_or("--participant name:classes")?;
|
|
participants.push((n.to_string(), c.parse().map_err(|_| "classes 0 to 15")?));
|
|
}
|
|
let t0 = joule_moq_remote::capture::now_unix_us();
|
|
let spec = Spec { width: w, height: h, fps, participants, grant_s: 12 * 3600 };
|
|
let (rf, keys, _authority, files) = room::new_room(&seed, t0, &spec)?;
|
|
write(&dir.join("room.json"), &serde_json::to_string_pretty(&rf).map_err(|e| e.to_string())?, false)?;
|
|
write(&dir.join("host-keys.json"), &serde_json::to_string_pretty(&keys).map_err(|e| e.to_string())?, true)?;
|
|
for f in &files {
|
|
write(&dir.join(format!("participant-{}.json", f.me)), &serde_json::to_string_pretty(f).map_err(|e| e.to_string())?, true)?;
|
|
}
|
|
write(&dir.join("relay.toml"), &room::relay_lines(&rf, &keys)?, false)?;
|
|
println!("room {} in {}: {} participant file(s); add relay.toml to the relay's configuration", rf.room, dir.display(), files.len());
|
|
Ok(())
|
|
}
|
|
|
|
/// What a pattern source draws, shared with the injector that draws on it.
|
|
type Drawn = std::sync::Arc<std::sync::Mutex<joule_moq_remote::capture::Drawn>>;
|
|
|
|
fn source(kind: &str, size: (u32, u32)) -> Result<(Box<dyn Capture>, Option<Drawn>), String> {
|
|
match kind {
|
|
"pattern" => {
|
|
let p = Pattern::new(size.0, size.1);
|
|
let d = p.drawn.clone();
|
|
Ok((Box::new(p), Some(d)))
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
"display" => Ok((Box::new(joule_moq_remote::adapter_macos_capture::DisplayCapture::new(size.0, size.1, true)?), None)),
|
|
#[cfg(target_os = "linux")]
|
|
"display" => Ok((Box::new(joule_moq_remote::adapter_linux_x11::X11Capture::new()?), None)),
|
|
other => Err(format!("no source {other} here")),
|
|
}
|
|
}
|
|
|
|
fn encoder(kind: &str, w: u32, h: u32, fps: u32) -> Result<Box<dyn Encoder>, String> {
|
|
match kind {
|
|
"ipcm" => Ok(Box::new(Ipcm::new(w, h)?)),
|
|
#[cfg(target_os = "macos")]
|
|
"platform" => Ok(Box::new(joule_moq_remote::adapter_macos_encode::PlatformEncoder::new(w, h, fps, 4_000, joule_moq_remote::adapter_macos_encode::Codec::Avc)?)),
|
|
#[cfg(target_os = "macos")]
|
|
"platform-hevc" => Ok(Box::new(joule_moq_remote::adapter_macos_encode::PlatformEncoder::new(w, h, fps, 4_000, joule_moq_remote::adapter_macos_encode::Codec::Hevc)?)),
|
|
other => {
|
|
let _ = fps;
|
|
Err(format!("no encoder {other} here"))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What a recorder injected, shared with the session that hands it actions.
|
|
type Injected = std::sync::Arc<std::sync::Mutex<Vec<wai::remote_desktop::Action>>>;
|
|
|
|
fn injector(kind: &str, drawn: Option<Drawn>, size: (u32, u32), log: &mut Option<Injected>) -> Result<Box<dyn Injector>, String> {
|
|
match kind {
|
|
"record" => {
|
|
let r = Recorder::new(drawn.unwrap_or_default());
|
|
*log = Some(r.log.clone());
|
|
Ok(Box::new(r))
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
"platform" => {
|
|
let _ = size;
|
|
Ok(Box::new(joule_moq_remote::adapter_macos_inject::PlatformInjector::new()?))
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
"platform" => Ok(Box::new(joule_moq_remote::adapter_linux_uinput::UinputInjector::new(size.0, size.1)?)),
|
|
other => {
|
|
let _ = size;
|
|
Err(format!("no input {other} here"))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What a recorder injected, for a test that checks the host injected what was typed without
|
|
/// the text written anywhere: the count of each action type, and the SHA-256 of the HID usages of
|
|
/// the keys pressed (u16 little-endian each), of the texts and of the clipboard texts, each in
|
|
/// order.
|
|
fn injected_summary(log: &[wai::remote_desktop::Action]) -> String {
|
|
use sha2::{Digest, Sha256};
|
|
use wai::remote_desktop::{type_name, Action};
|
|
let (mut keys, mut text, mut clip) = (Sha256::new(), Sha256::new(), Sha256::new());
|
|
let mut counts = std::collections::BTreeMap::new();
|
|
for a in log {
|
|
*counts.entry(type_name(a.type_code())).or_insert(0u32) += 1;
|
|
match a {
|
|
Action::Key { usage, down: true, .. } => keys.update(usage.to_le_bytes()),
|
|
Action::Text(t) => text.update(t.as_bytes()),
|
|
Action::Clipboard(t) => clip.update(t.as_bytes()),
|
|
_ => {}
|
|
}
|
|
}
|
|
serde_json::json!({"counts": counts, "keys_sha256": hex::encode(keys.finalize()), "text_sha256": hex::encode(text.finalize()), "clipboard_sha256": hex::encode(clip.finalize())}).to_string()
|
|
}
|
|
|
|
/// A host key file for a room service's rooms: made with mode 0600, never over a file.
|
|
fn make_key(a: &[String]) -> Result<(), String> {
|
|
let p = PathBuf::from(a.get(2).ok_or("a key file")?);
|
|
let mut seed = [0u8; 32];
|
|
rand_core_06::RngCore::fill_bytes(&mut rand_core_06::OsRng, &mut seed);
|
|
let mut o = std::fs::OpenOptions::new();
|
|
o.write(true).create_new(true);
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
|
|
let mut f = o.open(&p).map_err(|e| format!("{}: {e}", p.display()))?;
|
|
std::io::Write::write_all(&mut f, format!("{}\n", hex::encode(seed)).as_bytes()).map_err(|e| e.to_string())?;
|
|
println!("{}", hex::encode(ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes()));
|
|
Ok(())
|
|
}
|
|
|
|
/// A host key file, readable by its owner alone.
|
|
fn read_key(p: &Path) -> Result<ed25519_dalek::SigningKey, String> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let m = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?;
|
|
if m.permissions().mode() & 0o077 != 0 {
|
|
return Err(format!("{}: the key must be readable by its owner alone (chmod 600)", p.display()));
|
|
}
|
|
}
|
|
let s = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
|
|
Ok(ed25519_dalek::SigningKey::from_bytes(&room::unhex32(s.trim())?))
|
|
}
|
|
|
|
/// Write a secret to a new file, readable by its owner alone, never over a file.
|
|
fn write_private_new(p: &Path, body: &str) -> Result<(), String> {
|
|
let mut o = std::fs::OpenOptions::new();
|
|
o.write(true).create_new(true);
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
|
|
let mut f = o.open(p).map_err(|e| format!("{}: {e}", p.display()))?;
|
|
std::io::Write::write_all(&mut f, body.as_bytes()).map_err(|e| e.to_string())
|
|
}
|
|
|
|
async fn run(a: &[String]) -> Result<(), String> {
|
|
let relay = arg(a, "--relay").ok_or("--relay ws://…/moq or wss://…/moq")?.to_string();
|
|
let trust = match arg(a, "--ca") {
|
|
Some(p) => Trust::system_and_pem(Path::new(p))?,
|
|
None => Trust::system(),
|
|
};
|
|
// A desk in a directory, or a room a room service keeps.
|
|
let (room, descriptor) = match arg(a, "--room") {
|
|
Some(url) => {
|
|
let key = read_key(Path::new(arg(a, "--key").ok_or("--key <file> (wai-remote-host key <file>)")?))?;
|
|
let v = joule_moq_remote::service::RoomService::new(url)?.trusting(trust.clone()).room().await?;
|
|
(Room::Service { url: url.to_string(), key, invitation: arg(a, "--invitation").map(String::from) }, v.descriptor)
|
|
}
|
|
None => {
|
|
let dir = PathBuf::from(a.get(2).ok_or("a room directory, or --room <service-url>")?);
|
|
let rf: RoomFile = read_json(&dir.join("room.json"))?;
|
|
let keys: HostKeys = read_json(&dir.join("host-keys.json"))?;
|
|
let d = rf.descriptor_bytes()?;
|
|
(Room::Desk { room: rf, keys }, d)
|
|
}
|
|
};
|
|
let d = wai::live_room::Descriptor::parse(&descriptor).map_err(|e| e.code().to_string())?;
|
|
let layer = &d.profiles[0].layers[0];
|
|
let fps = arg(a, "--fps").map_or(Ok(layer.fps as u32), |s| s.parse().map_err(|_| "--fps N".to_string()))?;
|
|
let (cap, drawn) = source(arg(a, "--source").unwrap_or("display"), (layer.width as u32, layer.height as u32))?;
|
|
let (w, h) = cap.size();
|
|
let enc = encoder(arg(a, "--encoder").unwrap_or(if cfg!(target_os = "macos") { "platform" } else { "ipcm" }), w, h, fps)?;
|
|
let mut injected = None;
|
|
let inj = injector(arg(a, "--input").unwrap_or(if drawn.is_some() { "record" } else { "platform" }), drawn, cap.target(), &mut injected)?;
|
|
let mut redactions = Vec::new();
|
|
for r in args(a, "--redact") {
|
|
let v: Vec<u16> = r.split(',').map(|x| x.parse().map_err(|_| "--redact x,y,w,h".to_string())).collect::<Result<_, _>>()?;
|
|
let [x, y, w, h] = v[..] else { return Err("--redact x,y,w,h".into()) };
|
|
redactions.push(Rect { x, y, w, h });
|
|
}
|
|
// The session's opening key, written before the session starts when it is asked for; otherwise
|
|
// the agent makes one that never leaves it.
|
|
let opening_key = match arg(a, "--opening-key") {
|
|
Some(p) => {
|
|
let mut ikm = [0u8; 32];
|
|
rand_core_06::RngCore::fill_bytes(&mut rand_core_06::OsRng, &mut ikm);
|
|
let (sk, _) = wai::hpke::derive_key_pair(&ikm);
|
|
write_private_new(Path::new(p), &format!("{}\n", hex::encode(sk)))?;
|
|
Some(agent::OpeningKey(sk))
|
|
}
|
|
None => None,
|
|
};
|
|
let cfg = Config {
|
|
relay_url: relay,
|
|
trust,
|
|
opening_key,
|
|
room,
|
|
fps,
|
|
max_age_ms: arg(a, "--max-age-ms").map_or(Ok(1_000), |s| s.parse().map_err(|_| "--max-age-ms N".to_string()))?,
|
|
floor: if arg(a, "--floor") == Some("open") { floor_mode::OPEN } else { floor_mode::FLOOR },
|
|
give_on_request: a.iter().any(|x| x == "--give-on-request"),
|
|
redactions,
|
|
clip_max: 4000,
|
|
pointer: true,
|
|
key_every: Duration::from_secs(2),
|
|
run_for: arg(a, "--seconds").map(|s| s.parse().map(Duration::from_secs)).transpose().map_err(|_| "--seconds N".to_string())?,
|
|
};
|
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
|
tokio::spawn(async move {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
let _ = tx.send(Command::Stop);
|
|
});
|
|
let r = agent::run(cfg, cap, enc, inj, rx).await?;
|
|
if let (Some(p), Some(log)) = (arg(a, "--record-injected"), &injected) {
|
|
write(Path::new(p), &injected_summary(&log.lock().map_err(|_| "a poisoned lock")?), false)?;
|
|
}
|
|
if let Some(p) = arg(a, "--record") {
|
|
let room = serde_json::json!({
|
|
"descriptor": hex::encode(&r.descriptor),
|
|
"grants": r.grants.iter().map(hex::encode).collect::<Vec<_>>(),
|
|
"key_log": null,
|
|
"chains": r.chains.iter().map(|c| c.iter().map(hex::encode).collect::<Vec<_>>()).collect::<Vec<_>>(),
|
|
});
|
|
write(Path::new(p), &room.to_string(), false)?;
|
|
}
|
|
if r.ended {
|
|
println!("the room's clock ended the session");
|
|
}
|
|
println!(
|
|
"{} frames ({} intra-coded, {} bytes), {} inputs decided; encoder {} (interface {}, hardware {})",
|
|
r.frames,
|
|
r.key_frames,
|
|
r.bytes,
|
|
r.decided.len(),
|
|
r.identity.encoder,
|
|
r.identity.api,
|
|
r.identity.hardware
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn check() -> Result<(), String> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
match joule_moq_remote::adapter_macos_encode::PlatformEncoder::new(640, 360, 30, 2_000, joule_moq_remote::adapter_macos_encode::Codec::Avc) {
|
|
Ok(e) => {
|
|
let id = e.identity();
|
|
println!("encoder: {} ({}, {}), on the media engine: {}", id.encoder, id.capability, id.codec, id.hardware == wai::remote_desktop::hardware::MEDIA_ENGINE);
|
|
}
|
|
Err(e) => println!("encoder: {e}"),
|
|
}
|
|
println!("screen capture allowed: {}", joule_moq_remote::adapter_macos_capture::may_capture());
|
|
println!("input injection allowed: {}", joule_moq_remote::adapter_macos_inject::may_inject());
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
println!("encoder: ipcm (a pure function); a platform encoder adapter is not built here");
|
|
println!("display: {}", std::env::var("DISPLAY").unwrap_or_else(|_| "none".into()));
|
|
println!("input device: {}", if std::path::Path::new("/dev/uinput").exists() { "/dev/uinput" } else { "none" });
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
tracing_subscriber::fmt().with_writer(std::io::stderr).init();
|
|
let a: Vec<String> = std::env::args().collect();
|
|
let r = match a.get(1).map(String::as_str) {
|
|
Some("room") => make_room(&a),
|
|
Some("run") => run(&a).await,
|
|
Some("key") => make_key(&a),
|
|
Some("check") => check(),
|
|
_ => Err("usage: wai-remote-host room <dir> … | key <file> | run (<dir> | --room <service-url> --key <file>) --relay <url> … | check".into()),
|
|
};
|
|
if let Err(e) = r {
|
|
eprintln!("wai-remote-host: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|