use std::{ sync::LazyLock, time::{Duration, SystemTime, UNIX_EPOCH}, }; use crate::{ config::{Config, ServerUrl}, console::ConsoleHub, identity::Identity, obfstr, protocol::{self, CommandResult, Hello, Ping}, tls, }; use anyhow::{Context, Result, anyhow, bail}; use futures_util::{SinkExt, StreamExt}; use rmpv::Value; use tokio::net::TcpStream; use tokio_tungstenite::{ MaybeTlsStream, WebSocketStream, connect_async_tls_with_config, tungstenite::{ Message, client::IntoClientRequest, http::{HeaderName, HeaderValue}, }, }; type Socket = WebSocketStream>; static API_CLIENTS_PREFIX: LazyLock = LazyLock::new(|| obfstr!("/api/clients/")); static STREAM_WS_SUFFIX: LazyLock = LazyLock::new(|| obfstr!("/stream/ws")); static ROLE_CLIENT: LazyLock = LazyLock::new(|| obfstr!("role=client")); static WEBSOCKET_PROTOCOL_HEADER: LazyLock = LazyLock::new(|| obfstr!("Sec-WebSocket-Protocol")); static AGENT_TOKEN_HEADER: LazyLock = LazyLock::new(|| obfstr!("x-agent-token")); static BINARY_PROTOCOL: LazyLock = LazyLock::new(|| obfstr!("binary")); static FIELD_TYPE: LazyLock = LazyLock::new(|| obfstr!("type")); static FIELD_NONCE: LazyLock = LazyLock::new(|| obfstr!("nonce")); static FIELD_STATUS: LazyLock = LazyLock::new(|| obfstr!("status")); static FIELD_ID: LazyLock = LazyLock::new(|| obfstr!("id")); static FIELD_COMMAND_TYPE: LazyLock = LazyLock::new(|| obfstr!("commandType")); static FIELD_COMMAND_VERSION: LazyLock = LazyLock::new(|| obfstr!("commandVersion")); static FIELD_PAYLOAD: LazyLock = LazyLock::new(|| obfstr!("payload")); static FIELD_SESSION_ID: LazyLock = LazyLock::new(|| obfstr!("sessionId")); static FIELD_DATA: LazyLock = LazyLock::new(|| obfstr!("data")); static FIELD_COLS: LazyLock = LazyLock::new(|| obfstr!("cols")); static FIELD_ROWS: LazyLock = LazyLock::new(|| obfstr!("rows")); static FIELD_TS: LazyLock = LazyLock::new(|| obfstr!("ts")); static ENROLLMENT_CHALLENGE: LazyLock = LazyLock::new(|| obfstr!("enrollment_challenge")); static ENROLLMENT_STATUS: LazyLock = LazyLock::new(|| obfstr!("enrollment_status")); static HELLO: LazyLock = LazyLock::new(|| obfstr!("hello")); static HELLO_ACK: LazyLock = LazyLock::new(|| obfstr!("hello_ack")); static PING: LazyLock = LazyLock::new(|| obfstr!("ping")); static PONG: LazyLock = LazyLock::new(|| obfstr!("pong")); static COMMAND: LazyLock = LazyLock::new(|| obfstr!("command")); static COMMAND_RESULT: LazyLock = LazyLock::new(|| obfstr!("command_result")); static CONSOLE_START: LazyLock = LazyLock::new(|| obfstr!("console_start")); static CONSOLE_INPUT: LazyLock = LazyLock::new(|| obfstr!("console_input")); static CONSOLE_RESIZE: LazyLock = LazyLock::new(|| obfstr!("console_resize")); static CONSOLE_STOP: LazyLock = LazyLock::new(|| obfstr!("console_stop")); static CLIENT_VERSION: LazyLock = LazyLock::new(|| format!("{}{}", obfstr!("rust-lite/"), env!("CARGO_PKG_VERSION"))); pub async fn run(config: &Config, identity: &Identity, server: &ServerUrl) -> Result<()> { let socket_url = format!( "{}{}{}{}?{}", server.as_str().trim_end_matches('/'), API_CLIENTS_PREFIX.as_str(), identity.client_id, STREAM_WS_SUFFIX.as_str(), ROLE_CLIENT.as_str(), ); let mut request = socket_url.into_client_request()?; request.headers_mut().insert( HeaderName::from_bytes(WEBSOCKET_PROTOCOL_HEADER.as_bytes())?, HeaderValue::from_str(BINARY_PROTOCOL.as_str())?, ); if let Some(token) = &config.agent_token { request.headers_mut().insert( HeaderName::from_bytes(AGENT_TOKEN_HEADER.as_bytes())?, HeaderValue::from_str(token)?, ); } let connector = tls::websocket_connector(config.insecure_tls, &config.tls_spki_pins); let (mut socket, _) = connect_async_tls_with_config(request, None, true, connector) .await .with_context(|| format!("connect to {server}"))?; let first = tokio::time::timeout(Duration::from_secs(30), receive_envelope(&mut socket)) .await .context("timed out waiting for enrollment challenge")??; let first_type = protocol::required_string(&first, FIELD_TYPE.as_str())?; let (public_key, signature, needs_ack) = if first_type == ENROLLMENT_CHALLENGE.as_str() { let nonce = protocol::required_string(&first, FIELD_NONCE.as_str())?; ( identity.public_key_base64(), identity.sign_base64(nonce)?, true, ) } else if first_type == HELLO_ACK.as_str() { (String::new(), String::new(), false) } else { bail!("unexpected first server message {first_type:?}") }; let hello = Hello { message_type: HELLO.as_str(), id: &identity.client_id, hwid: &identity.client_id, host: hostname(), os: wire_os(), arch: wire_arch(), version: CLIENT_VERSION.as_str(), user: username(), monitors: 0, build_tag: config.build_tag.as_deref(), public_key: &public_key, signature: &signature, protocol_version: protocol::WIRE_PROTOCOL_VERSION, command_versions: protocol::command_versions(), }; send_value(&mut socket, &hello).await?; if needs_ack { loop { let response = tokio::time::timeout(Duration::from_secs(30), receive_envelope(&mut socket)) .await .context("timed out waiting for enrollment status")??; let response_type = protocol::required_string(&response, FIELD_TYPE.as_str())?; if response_type == HELLO_ACK.as_str() { break; } else if response_type == ENROLLMENT_STATUS.as_str() { let status = protocol::string_field(&response, FIELD_STATUS.as_str()).unwrap_or("unknown"); bail!("enrollment status is {status}"); } else if response_type == PING.as_str() { send_protocol_pong(&mut socket, &response).await?; } else if response_type != PONG.as_str() { bail!("unexpected enrollment response {response_type:?}"); } } } eprintln!("authenticated and connected as {}", identity.client_id); send_value( &mut socket, &Ping { message_type: PING.as_str(), ts: now_millis(), }, ) .await?; let mut heartbeat = tokio::time::interval(config.heartbeat_interval.max(Duration::from_secs(1))); heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); heartbeat.tick().await; let (console_tx, mut console_rx) = tokio::sync::mpsc::channel(64); let mut consoles = ConsoleHub::new(console_tx); loop { tokio::select! { _ = heartbeat.tick() => { send_value(&mut socket, &Ping { message_type: PING.as_str(), ts: now_millis() }).await?; } incoming = socket.next() => { let message = incoming.ok_or_else(|| anyhow!("server closed the WebSocket"))??; match message { Message::Binary(bytes) => { let envelope = protocol::decode(&bytes)?; handle_envelope(&mut socket, &mut consoles, &envelope).await?; } Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await?, Message::Pong(_) => {} Message::Close(frame) => bail!("server closed the session: {frame:?}"), Message::Text(_) | Message::Frame(_) => {} } } output = console_rx.recv() => { if let Some(output) = output { socket.send(Message::Binary(output.into())).await?; } } } } } async fn handle_envelope( socket: &mut Socket, consoles: &mut ConsoleHub, envelope: &Value, ) -> Result<()> { let message_type = protocol::required_string(envelope, FIELD_TYPE.as_str())?; if message_type == PING.as_str() { send_protocol_pong(socket, envelope).await?; } else if message_type == COMMAND.as_str() { handle_command(socket, consoles, envelope).await?; } else if message_type != PONG.as_str() && message_type != HELLO_ACK.as_str() { eprintln!("ignoring unsupported server message {message_type}"); } Ok(()) } async fn handle_command( socket: &mut Socket, consoles: &mut ConsoleHub, envelope: &Value, ) -> Result<()> { let command = protocol::required_string(envelope, FIELD_COMMAND_TYPE.as_str())?; if !is_console_command(command) { return send_unsupported_command(socket, envelope).await; } let command_id = protocol::string_field(envelope, FIELD_ID.as_str()).unwrap_or(""); let command_version = protocol::integer_field(envelope, FIELD_COMMAND_VERSION.as_str()) .unwrap_or(1) .max(1) as u16; if command_version != 1 { return send_command_result( socket, command_id, command, command_version, false, Some("unsupported console command version"), Some("unsupported_command_version"), ) .await; } let payload = protocol::field(envelope, FIELD_PAYLOAD.as_str()).unwrap_or(&Value::Nil); let session_id = protocol::string_field(payload, FIELD_SESSION_ID.as_str()).unwrap_or(""); let result = if command == CONSOLE_START.as_str() { consoles.start( session_id, protocol::integer_field(payload, FIELD_COLS.as_str()).unwrap_or(0), protocol::integer_field(payload, FIELD_ROWS.as_str()).unwrap_or(0), ) } else if command == CONSOLE_INPUT.as_str() { if session_id.is_empty() { Ok(()) } else { let data = protocol::string_field(payload, FIELD_DATA.as_str()).unwrap_or(""); consoles.write(session_id, data) } } else if command == CONSOLE_RESIZE.as_str() { if session_id.is_empty() { Ok(()) } else { consoles.resize( session_id, protocol::integer_field(payload, FIELD_COLS.as_str()).unwrap_or(0), protocol::integer_field(payload, FIELD_ROWS.as_str()).unwrap_or(0), ) } } else { if !session_id.is_empty() { consoles.stop(session_id); } Ok(()) }; match result { Ok(()) => send_command_result(socket, command_id, command, 1, true, None, None).await, Err(error) => { let message = error.to_string(); send_command_result( socket, command_id, command, 1, false, Some(&message), Some("console_error"), ) .await } } } async fn send_command_result( socket: &mut Socket, command_id: &str, command_type: &str, command_version: u16, ok: bool, message: Option<&str>, error_code: Option<&str>, ) -> Result<()> { send_value( socket, &CommandResult { message_type: COMMAND_RESULT.as_str(), command_id, command_type, command_version, ok, message, error_code, }, ) .await } async fn send_unsupported_command(socket: &mut Socket, envelope: &Value) -> Result<()> { let command = protocol::required_string(envelope, FIELD_COMMAND_TYPE.as_str())?; let command_id = protocol::string_field(envelope, FIELD_ID.as_str()).unwrap_or(""); let version = protocol::integer_field(envelope, FIELD_COMMAND_VERSION.as_str()) .unwrap_or(1) .max(1) as u16; send_value( socket, &CommandResult { message_type: COMMAND_RESULT.as_str(), command_id, command_type: command, command_version: version, ok: false, message: Some("command is not supported by Overlord Lite"), error_code: Some("unsupported_command"), }, ) .await } async fn receive_envelope(socket: &mut Socket) -> Result { loop { match socket .next() .await .ok_or_else(|| anyhow!("server closed the WebSocket"))?? { Message::Binary(bytes) => return protocol::decode(&bytes), Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await?, Message::Pong(_) | Message::Text(_) | Message::Frame(_) => {} Message::Close(frame) => bail!("server closed the session: {frame:?}"), } } } async fn send_value(socket: &mut Socket, value: &T) -> Result<()> { socket .send(Message::Binary(protocol::encode(value)?.into())) .await?; Ok(()) } async fn send_protocol_pong(socket: &mut Socket, incoming: &Value) -> Result<()> { send_value( socket, &Ping { message_type: PONG.as_str(), ts: protocol::integer_field(incoming, FIELD_TS.as_str()).unwrap_or_else(now_millis), }, ) .await } fn is_console_command(command: &str) -> bool { command == CONSOLE_START.as_str() || command == CONSOLE_INPUT.as_str() || command == CONSOLE_RESIZE.as_str() || command == CONSOLE_STOP.as_str() } fn hostname() -> String { std::env::var(if cfg!(windows) { "COMPUTERNAME" } else { "HOSTNAME" }) .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "unknown".to_owned()) } fn username() -> String { std::env::var(if cfg!(windows) { "USERNAME" } else { "USER" }) .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "unknown".to_owned()) } fn wire_os() -> &'static str { match std::env::consts::OS { "macos" => "darwin", other => other, } } fn wire_arch() -> &'static str { match std::env::consts::ARCH { "x86_64" => "amd64", "aarch64" => "arm64", "x86" => "386", other => other, } } fn now_millis() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64 } #[cfg(test)] mod tests { use super::*; use base64::{Engine as _, engine::general_purpose::STANDARD}; use ed25519_dalek::{Signature, VerifyingKey}; use tempfile::tempdir; use tokio::net::TcpListener; use tokio_tungstenite::accept_hdr_async; #[tokio::test] #[allow(clippy::result_large_err)] // Required by tungstenite's handshake callback signature. async fn authenticates_and_advertises_console_commands() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let state = tempdir().unwrap(); let identity = Identity::load_or_create(state.path()).unwrap(); let server_url = ServerUrl::parse(&format!("ws://{address}")).unwrap(); let config = Config { servers: vec![server_url.clone()], agent_token: Some("test-agent-token".to_owned()), build_tag: Some("test-build-tag".to_owned()), insecure_tls: true, tls_spki_pins: Vec::new(), state_dir: state.path().to_owned(), reconnect_delay: Duration::from_millis(1), heartbeat_interval: Duration::from_secs(60), }; let expected_client_id = identity.client_id.clone(); let mock_server = tokio::spawn(async move { let (tcp, _) = listener.accept().await.unwrap(); let mut socket = accept_hdr_async(tcp, |request: &tokio_tungstenite::tungstenite::handshake::server::Request, mut response: tokio_tungstenite::tungstenite::handshake::server::Response| { assert_eq!(request.headers()["x-agent-token"], "test-agent-token"); assert!(request.uri().path().contains(&expected_client_id)); response.headers_mut().insert("Sec-WebSocket-Protocol", HeaderValue::from_static("binary")); Ok(response) }).await.unwrap(); let nonce = [7u8; 32]; let challenge = serde_json::json!({ "type": "enrollment_challenge", "nonce": STANDARD.encode(nonce) }); socket .send(Message::Binary( protocol::encode(&challenge).unwrap().into(), )) .await .unwrap(); let Message::Binary(hello_bytes) = socket.next().await.unwrap().unwrap() else { panic!("expected binary hello") }; let hello = protocol::decode(&hello_bytes).unwrap(); assert_eq!(protocol::string_field(&hello, "type"), Some("hello")); assert_eq!( protocol::string_field(&hello, "buildTag"), Some("test-build-tag") ); assert_eq!( protocol::string_field(&hello, "version"), Some(concat!("rust-lite/", env!("CARGO_PKG_VERSION"))) ); let command_versions = protocol::field(&hello, "commandVersions") .unwrap() .as_map() .unwrap(); assert_eq!(command_versions.len(), 4); for command in [ "console_input", "console_resize", "console_start", "console_stop", ] { assert!( command_versions .iter() .any(|(key, _)| key.as_str() == Some(command)) ); } let public_key: [u8; 32] = STANDARD .decode(protocol::required_string(&hello, "publicKey").unwrap()) .unwrap() .try_into() .unwrap(); let signature = Signature::from_slice( &STANDARD .decode(protocol::required_string(&hello, "signature").unwrap()) .unwrap(), ) .unwrap(); VerifyingKey::from_bytes(&public_key) .unwrap() .verify_strict(&nonce, &signature) .unwrap(); let ack = serde_json::json!({ "type": "hello_ack", "id": expected_client_id, "protocolVersion": 1 }); socket .send(Message::Binary(protocol::encode(&ack).unwrap().into())) .await .unwrap(); let Message::Binary(ping_bytes) = socket.next().await.unwrap().unwrap() else { panic!("expected initial ping") }; assert_eq!( protocol::string_field(&protocol::decode(&ping_bytes).unwrap(), "type"), Some("ping") ); socket.close(None).await.unwrap(); }); assert!(run(&config, &identity, &server_url).await.is_err()); mock_server.await.unwrap(); } }