//! Device streams for other devices, as T3 Code passes them through its //! environment server. A client's Device panel connects to this device's //! remote server under `/device-hub/…` with the key; the request goes on to //! the device hub on loopback and the connection is spliced through, so //! video (H.264 over a WebSocket, or MJPEG) and touches flow straight //! between the two. //! //! The hub never faces the network itself: it has routes that run commands. //! Only the stream and input routes pass, and only GETs (WebSocket upgrades //! are GETs too). Everything else a client does with devices goes through //! typed messages on the remote connection. use std::{ io::{Read as _, Write as _}, net::{Shutdown, TcpStream}, thread, time::Duration, }; use super::keys_match; /// Where device routes start on the remote server. pub const PREFIX: &str = "/device-hub"; /// The most a request head may take. const MAX_HEAD: usize = 16 * 1024; /// Looks at a new connection's request head without taking it, and says /// whether it's for the device hub. pub fn is_device_request(stream: &TcpStream) -> bool { let mut buffer = [0u8; 512]; let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { match stream.peek(&mut buffer) { Ok(0) => return false, Ok(n) => { let head = String::from_utf8_lossy(&buffer[..n]); if let Some(line) = head.split("\r\n").next().filter(|_| head.contains("\r\n")) { return line.split_whitespace().nth(1).is_some_and(|path| path.starts_with(&format!("{PREFIX}/"))); } if n == buffer.len() || std::time::Instant::now() > deadline { return false; } thread::sleep(Duration::from_millis(10)); } Err(_) => return false, } } } /// Whether a hub route may be reached from another device. fn allowed(path: &str) -> bool { let route = path.split('?').next().unwrap_or_default(); match route { // Android: H.264 out, gestures in. "/vendor/serve-emu/ws" => true, // iOS: input. "/vendor/serve-sim/helper/ws" => true, // iOS: video, `/vendor/serve-sim/helper//stream.mjpeg`. _ => route .strip_prefix("/vendor/serve-sim/helper/") .and_then(|rest| rest.strip_suffix("/stream.mjpeg")) .is_some_and(|udid| !udid.is_empty() && !udid.contains('/')), } } fn respond(stream: &mut TcpStream, status: &str) { let body = status.split_once(' ').map(|(_, text)| text).unwrap_or(status); let _ = write!(stream, "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); let _ = stream.flush(); } /// Serves one device request: checks the key and the route, then splices /// the connection through to the hub. `hub` finds the hub's loopback /// origin, starting it if device support is on. pub fn serve(mut stream: TcpStream, key: &str, hub: impl FnOnce() -> Option) { let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); let Some(head) = read_head(&mut stream) else { return }; let mut lines = head.split("\r\n"); let request_line = lines.next().unwrap_or_default(); let mut parts = request_line.split_whitespace(); let (method, path, version) = (parts.next().unwrap_or_default(), parts.next().unwrap_or_default(), parts.next().unwrap_or("HTTP/1.1")); let headers: Vec<(&str, &str)> = lines .filter(|line| !line.is_empty()) .filter_map(|line| line.split_once(':').map(|(name, value)| (name.trim(), value.trim()))) .collect(); let offered = headers .iter() .find(|(name, _)| name.eq_ignore_ascii_case("authorization")) .and_then(|(_, value)| value.strip_prefix("Bearer ")) .unwrap_or_default(); if !keys_match(offered, key) { // Slow down anyone guessing keys. thread::sleep(Duration::from_secs(1)); return respond(&mut stream, "401 Unauthorized"); } let route = path.strip_prefix(PREFIX).unwrap_or_default(); if method != "GET" || !allowed(route) { return respond(&mut stream, "403 Forbidden"); } let Some(origin) = hub() else { return respond(&mut stream, "503 Service Unavailable") }; let address = origin.trim_start_matches("http://").to_string(); let Ok(mut upstream) = TcpStream::connect(&address) else { return respond(&mut stream, "502 Bad Gateway") }; let _ = upstream.set_nodelay(true); let _ = stream.set_nodelay(true); // The same request, for the hub: its own path, its own host, no key. let mut forwarded = format!("{method} {route} {version}\r\n"); for (name, value) in &headers { if name.eq_ignore_ascii_case("authorization") || name.eq_ignore_ascii_case("host") { continue; } forwarded.push_str(&format!("{name}: {value}\r\n")); } forwarded.push_str(&format!("Host: {address}\r\n\r\n")); if upstream.write_all(forwarded.as_bytes()).is_err() { return respond(&mut stream, "502 Bad Gateway"); } splice(stream, upstream); } /// Reads the request head, up to and including its blank line. fn read_head(stream: &mut TcpStream) -> Option { let mut head = Vec::new(); let mut byte = [0u8; 1]; while !head.ends_with(b"\r\n\r\n") { if head.len() >= MAX_HEAD || stream.read(&mut byte).ok()? == 0 { return None; } head.push(byte[0]); } Some(String::from_utf8_lossy(&head).into_owned()) } /// Copies both ways until either side hangs up. fn splice(client: TcpStream, hub: TcpStream) { let _ = client.set_read_timeout(None); let (Ok(mut client_read), Ok(mut hub_write)) = (client.try_clone(), hub.try_clone()) else { return }; let upward = thread::spawn(move || { let _ = std::io::copy(&mut client_read, &mut hub_write); let _ = hub_write.shutdown(Shutdown::Both); }); let (mut hub_read, mut client_write) = (hub, client); let _ = std::io::copy(&mut hub_read, &mut client_write); let _ = client_write.shutdown(Shutdown::Both); let _ = upward.join(); } #[cfg(test)] mod tests { use super::allowed; #[::core::prelude::v1::test] fn passes_only_stream_routes() { assert!(allowed("/vendor/serve-emu/ws?device=emulator-5554&frame-meta=1")); assert!(allowed("/vendor/serve-sim/helper/ws?device=ABC")); assert!(allowed("/vendor/serve-sim/helper/ABC-123/stream.mjpeg")); assert!(!allowed("/vendor/serve-sim/helper/a/b/stream.mjpeg")); assert!(!allowed("/vendor/serve-sim/api")); assert!(!allowed("/vendor/serve-sim/exec")); assert!(!allowed("/api/devices/boot")); assert!(!allowed("/vendor/serve-emu/api/screenshot?device=x")); } }