Files
RemoteDesk/client/helpers/linux-terminal/src/main.rs
T
曾志威 5db6b9ef68
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / package-preview (push) Canceled after 0s
ci / package-installer (push) Canceled after 0s
ci / linux-agent (push) Canceled after 0s
ci / edge-service (push) Canceled after 0s
ci / coturn-pop (push) Canceled after 0s
ci / package-windows-host (push) Canceled after 0s
Initial commit
2026-08-14 00:35:42 +08:00

2489 lines
89 KiB
Rust

use async_trait::async_trait;
use base64::{
Engine as _,
engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD},
};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ed25519_dalek::{Signer as _, SigningKey};
use futures_util::{SinkExt as _, StreamExt as _};
use inquire::Password;
use remotedesk_agent_runtime::{
AuthChallenge, ClientCommand, EdgeNegotiationClient, EdgeNegotiationSession, EdgeRelayAccess,
EdgeWebRtcRole, FILE_CHUNK_BYTES, MAX_FILE_BYTES, OPUS_AUDIO_PROTOCOL_MINOR, PROTOCOL_MAJOR,
PROTOCOL_MINOR, RelayStream, ServerEvent, WebRtcIceServer, WebRtcSession, WebRtcSessionConfig,
WebRtcSessionError, WebRtcSignalMessage, WebRtcSignalTransport, connect_edge_relay,
edge_device_id, establish_edge_webrtc, establish_webrtc,
};
#[cfg(windows)]
use remotedesk_credential_store::{
LinuxAgentIdentityRef, linux_agent_identity_load, linux_agent_identity_save,
};
use remotedesk_protocol::{
EdgeNegotiationEndpoint, EdgeNegotiationKind, EdgeSessionIntentV1, EdgeSessionType,
SIGNAL_NONCE_LENGTH,
};
use reqwest::{Client, StatusCode, redirect::Policy};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use std::env;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _, AsyncWriteExt as _, SeekFrom};
use tokio::sync::mpsc;
use tokio_tungstenite::{
Connector, MaybeTlsStream, WebSocketStream, client_async_tls_with_config, tungstenite::Message,
};
use url::{Host, Url};
use zeroize::Zeroizing;
const IDENTITY_TARGET: &str = "RemoteDesk/Linux/client-identity";
const IDENTITY_SERVICE: &str = "RemoteDesk Linux Agent";
const IDENTITY_OWNER: &str = "RemoteDesk";
const EDGE_RESPONSE_LIMIT: usize = 16 * 1024;
const DIRECT_WEBRTC_PROTOCOL_MINOR: u16 = 13;
const DIRECT_SIGNAL_PAYLOAD_BYTES: usize = 60 * 1024;
#[derive(Debug, PartialEq, Eq)]
struct Args {
target: String,
user: String,
certificate_sha256: [u8; 32],
probe_json: bool,
pair: bool,
agent_public_key: Option<String>,
transfer: Option<FileTransfer>,
edge_api_url: Option<Url>,
desktop: Option<DesktopOptions>,
session_id: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DesktopOptions {
width: u16,
height: u16,
frames_per_second: u8,
fullscreen: bool,
follow_window: bool,
capture_input: bool,
clipboard_read: bool,
clipboard_write: bool,
}
struct AuthenticatedAgent {
device_public_key: String,
edge_signaling_online: bool,
protocol_minor: u16,
permissions: Vec<String>,
}
struct AuthenticatedAgentConnection {
socket: WebSocketStream<MaybeTlsStream<RelayStream>>,
realtime: Option<WebRtcSession>,
edge_session_id: Option<String>,
protocol_minor: u16,
clipboard_read: bool,
clipboard_write: bool,
}
impl AuthenticatedAgentConnection {
async fn close_realtime(&mut self) {
if let Some(session) = self.realtime.take() {
let _ = session.close().await;
}
}
}
impl std::ops::Deref for AuthenticatedAgentConnection {
type Target = WebSocketStream<MaybeTlsStream<RelayStream>>;
fn deref(&self) -> &Self::Target {
&self.socket
}
}
impl std::ops::DerefMut for AuthenticatedAgentConnection {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.socket
}
}
struct DirectClientSignalTransport<'a> {
socket: &'a mut WebSocketStream<MaybeTlsStream<RelayStream>>,
}
#[async_trait]
impl WebRtcSignalTransport for DirectClientSignalTransport<'_> {
async fn send(&mut self, message: WebRtcSignalMessage) -> Result<(), WebRtcSessionError> {
let message = WebRtcSignalMessage::new_direct(message.kind, message.payload)?;
if message.payload.len() > DIRECT_SIGNAL_PAYLOAD_BYTES {
return Err(WebRtcSessionError::InvalidSignal);
}
send_command(
self.socket,
&ClientCommand::DirectWebRtcSignal {
kind: message.kind.as_str().into(),
payload: message.payload,
},
)
.await
.map_err(|_| WebRtcSessionError::SignalingTransport)
}
async fn poll(&mut self) -> Result<Vec<WebRtcSignalMessage>, WebRtcSessionError> {
let event =
match tokio::time::timeout(Duration::from_millis(25), receive_event(self.socket)).await
{
Err(_) => return Ok(Vec::new()),
Ok(Ok(event)) => event,
Ok(Err(_)) => return Err(WebRtcSessionError::SignalingTransport),
};
let ServerEvent::DirectWebRtcSignal { kind, payload } = event else {
return Err(WebRtcSessionError::UnexpectedSignal);
};
if payload.len() > DIRECT_SIGNAL_PAYLOAD_BYTES {
return Err(WebRtcSessionError::InvalidSignal);
}
let kind =
EdgeNegotiationKind::parse(&kind).map_err(|_| WebRtcSessionError::InvalidSignal)?;
Ok(vec![WebRtcSignalMessage::new_direct(kind, payload)?])
}
}
#[derive(Debug, PartialEq, Eq)]
enum FileTransfer {
Upload { local: PathBuf, remote: String },
Download { remote: String, local: PathBuf },
}
#[derive(Debug)]
struct PinnedCertificateVerifier {
expected_sha256: [u8; 32],
supported_algorithms: rustls::crypto::WebPkiSupportedAlgorithms,
}
impl ServerCertVerifier for PinnedCertificateVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_: &[CertificateDer<'_>],
_: &ServerName<'_>,
_: &[u8],
_: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
if self.expected_sha256 != [0; 32]
&& Sha256::digest(end_entity.as_ref()).as_slice() != self.expected_sha256
{
return Err(rustls::Error::General(
"Linux Agent TLS certificate fingerprint mismatch".to_owned(),
));
}
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
signature: &rustls::DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(message, cert, signature, &self.supported_algorithms)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
signature: &rustls::DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(message, cert, signature, &self.supported_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.supported_algorithms.supported_schemes()
}
}
enum ConsoleInput {
Bytes(Vec<u8>),
Resize { cols: u16, rows: u16 },
Close,
}
struct RawModeGuard;
impl RawModeGuard {
fn enable() -> Result<Self, String> {
crossterm::terminal::enable_raw_mode()
.map_err(|error| format!("unable to enable terminal raw mode: {error}"))?;
Ok(Self)
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
let _ = crossterm::terminal::disable_raw_mode();
}
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("RemoteDesk Linux terminal failed: {error}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), String> {
let args = parse_args(env::args().skip(1))?;
if args.probe_json {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("unable to create probe runtime: {error}"))?;
return runtime.block_on(run_agent_probe(&args));
}
if args.desktop.is_some() {
return desktop_viewer::run(args);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| format!("unable to create async runtime: {error}"))?;
runtime.block_on(run_terminal(args))
}
#[derive(Serialize)]
#[expect(
clippy::struct_excessive_bools,
reason = "the probe JSON reports independent Agent capabilities"
)]
struct AgentProbeResponse {
ok: bool,
latency_ms: u64,
protocol_major: u16,
protocol_minor: u16,
terminal: bool,
desktop: bool,
files: bool,
edge_presence_configured: bool,
edge_presence_online: bool,
edge_signaling_configured: bool,
edge_signaling_online: bool,
}
async fn run_agent_probe(args: &Args) -> Result<(), String> {
let _ = rustls::crypto::ring::default_provider().install_default();
let url = terminal_url(&args.target)?;
let started = std::time::Instant::now();
let mut socket = connect_agent(&url, args.certificate_sha256, None).await?;
let hello = tokio::time::timeout(Duration::from_secs(5), receive_event(&mut socket))
.await
.map_err(|_| "Linux Agent hello timed out".to_owned())??;
let response = match hello {
ServerEvent::Hello {
protocol_major,
protocol_minor,
tls_certificate_sha256,
terminal,
desktop,
files,
edge_presence_configured,
edge_presence_online,
edge_signaling_configured,
edge_signaling_online,
..
} if protocol_major == PROTOCOL_MAJOR
&& (args.certificate_sha256 == [0; 32]
|| parse_fingerprint(&tls_certificate_sha256)? == args.certificate_sha256) =>
{
AgentProbeResponse {
ok: true,
latency_ms: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX),
protocol_major,
protocol_minor,
terminal,
desktop,
files,
edge_presence_configured,
edge_presence_online,
edge_signaling_configured,
edge_signaling_online,
}
}
ServerEvent::Hello { .. } => {
return Err(
"Linux Agent protocol or hello certificate fingerprint is incompatible".into(),
);
}
_ => return Err("Linux Agent did not send a hello event".into()),
};
let _ = socket.close(None).await;
println!(
"{}",
serde_json::to_string(&response).map_err(|error| error.to_string())?
);
Ok(())
}
async fn run_terminal(args: Args) -> Result<(), String> {
let _ = rustls::crypto::ring::default_provider().install_default();
let signing_key = load_or_create_identity()?;
let url = terminal_url(&args.target)?;
let mut socket = connect_authenticated_agent(&args, &signing_key, &url).await?;
if let Some(transfer) = args.transfer {
return run_file_transfer(&mut socket, &args.user, transfer).await;
}
let (cols, rows) = crossterm::terminal::size().unwrap_or((120, 40));
send_command(
&mut socket,
&ClientCommand::OpenTerminal {
user: args.user,
cols: cols.clamp(20, 500),
rows: rows.clamp(5, 200),
},
)
.await?;
match receive_event(&mut socket).await? {
ServerEvent::TerminalOpened => {}
ServerEvent::Error { code, message } => {
return Err(format!("terminal open failed ({code}): {message}"));
}
_ => return Err("Linux Agent did not open the terminal".to_owned()),
}
let _raw_mode = RawModeGuard::enable()?;
let (input_sender, mut input_receiver) = mpsc::unbounded_channel();
std::thread::spawn(move || read_console(&input_sender));
let mut stdout = std::io::stdout();
loop {
tokio::select! {
input = input_receiver.recv() => {
match input.unwrap_or(ConsoleInput::Close) {
ConsoleInput::Bytes(data) => send_command(&mut socket, &ClientCommand::TerminalInput { data: STANDARD_NO_PAD.encode(data) }).await?,
ConsoleInput::Resize { cols, rows } => send_command(&mut socket, &ClientCommand::TerminalResize { cols: cols.clamp(20, 500), rows: rows.clamp(5, 200) }).await?,
ConsoleInput::Close => {
send_command(&mut socket, &ClientCommand::Close).await?;
break;
}
}
}
event = receive_event(&mut socket) => {
match event? {
ServerEvent::TerminalOutput { data } => {
let bytes = STANDARD_NO_PAD.decode(data).map_err(|_| "Agent returned invalid terminal output".to_owned())?;
stdout.write_all(&bytes).map_err(|error| error.to_string())?;
stdout.flush().map_err(|error| error.to_string())?;
}
ServerEvent::TerminalExited { .. } => break,
ServerEvent::Error { code, message } => return Err(format!("terminal failed ({code}): {message}")),
_ => {}
}
}
}
}
Ok(())
}
async fn connect_authenticated_agent(
args: &Args,
signing_key: &SigningKey,
url: &Url,
) -> Result<AuthenticatedAgentConnection, String> {
if let (true, Some(edge_api_url), Some(device_public_key)) =
(args.pair, &args.edge_api_url, &args.agent_public_key)
{
let authorized =
authorize_edge_session(edge_api_url, device_public_key, args, signing_key).await?;
let mut relayed =
connect_agent(url, args.certificate_sha256, Some(&authorized.relay)).await?;
let authenticated = authenticate(&mut relayed, args, signing_key, true).await?;
if authenticated.device_public_key != *device_public_key {
return Err("Edge relay connected to a different Linux Agent identity".into());
}
if !authenticated.edge_signaling_online {
return Err("Linux Agent reports Edge signaling offline".into());
}
save_known_agent(args.certificate_sha256, &authenticated.device_public_key)?;
return Ok(AuthenticatedAgentConnection {
socket: relayed,
realtime: None,
edge_session_id: None,
protocol_minor: authenticated.protocol_minor,
clipboard_read: supports_clipboard(authenticated.protocol_minor)
&& has_permission(&authenticated, "clipboard_read"),
clipboard_write: supports_clipboard(authenticated.protocol_minor)
&& has_permission(&authenticated, "clipboard_write"),
});
}
let known_agent = if args.edge_api_url.is_some() && !args.pair {
load_known_agent(args.certificate_sha256)?
} else {
None
};
if let (Some(edge_api_url), Some(device_public_key)) = (&args.edge_api_url, known_agent) {
let authorized =
authorize_edge_session(edge_api_url, &device_public_key, args, signing_key).await?;
let mut relayed =
connect_agent(url, args.certificate_sha256, Some(&authorized.relay)).await?;
let authenticated = authenticate(&mut relayed, args, signing_key, false).await?;
if authenticated.device_public_key != device_public_key {
return Err("Edge relay connected to a different Linux Agent identity".into());
}
if !authenticated.edge_signaling_online {
return Err("Linux Agent reports Edge signaling offline".into());
}
let realtime =
connect_edge_desktop_webrtc(args, signing_key, edge_api_url, &authorized).await?;
let edge_session_id = args
.desktop
.is_some()
.then(|| authorized.negotiation.session_id.clone());
Ok(AuthenticatedAgentConnection {
socket: relayed,
realtime,
edge_session_id,
protocol_minor: authenticated.protocol_minor,
clipboard_read: supports_clipboard(authenticated.protocol_minor)
&& has_permission(&authenticated, "clipboard_read"),
clipboard_write: supports_clipboard(authenticated.protocol_minor)
&& has_permission(&authenticated, "clipboard_write"),
})
} else {
let mut direct = connect_agent(url, args.certificate_sha256, None).await?;
let authenticated = authenticate(&mut direct, args, signing_key, args.pair).await?;
if args.pair || args.edge_api_url.is_some() {
save_known_agent(args.certificate_sha256, &authenticated.device_public_key)?;
}
if let Some(edge_api_url) = &args.edge_api_url {
if !authenticated.edge_signaling_online {
return Err("Linux Agent reports Edge signaling offline".into());
}
let authorized = authorize_edge_session(
edge_api_url,
&authenticated.device_public_key,
args,
signing_key,
)
.await?;
drop(direct);
let mut relayed =
connect_agent(url, args.certificate_sha256, Some(&authorized.relay)).await?;
let relayed_identity = authenticate(&mut relayed, args, signing_key, false).await?;
if relayed_identity.device_public_key != authenticated.device_public_key {
return Err("Edge relay connected to a different Linux Agent identity".into());
}
let realtime =
connect_edge_desktop_webrtc(args, signing_key, edge_api_url, &authorized).await?;
let edge_session_id = args
.desktop
.is_some()
.then(|| authorized.negotiation.session_id.clone());
Ok(AuthenticatedAgentConnection {
socket: relayed,
realtime,
edge_session_id,
protocol_minor: relayed_identity.protocol_minor,
clipboard_read: supports_clipboard(relayed_identity.protocol_minor)
&& has_permission(&relayed_identity, "clipboard_read"),
clipboard_write: supports_clipboard(relayed_identity.protocol_minor)
&& has_permission(&relayed_identity, "clipboard_write"),
})
} else {
let clipboard_read = supports_clipboard(authenticated.protocol_minor)
&& has_permission(&authenticated, "clipboard_read");
let clipboard_write = supports_clipboard(authenticated.protocol_minor)
&& has_permission(&authenticated, "clipboard_write");
let realtime = connect_direct_desktop_webrtc(
&mut direct,
&args.user,
args.desktop,
authenticated.protocol_minor,
clipboard_read,
clipboard_write,
)
.await?;
Ok(AuthenticatedAgentConnection {
socket: direct,
realtime,
edge_session_id: None,
protocol_minor: authenticated.protocol_minor,
clipboard_read,
clipboard_write,
})
}
}
}
async fn connect_direct_desktop_webrtc(
socket: &mut WebSocketStream<MaybeTlsStream<RelayStream>>,
user: &str,
options: Option<DesktopOptions>,
protocol_minor: u16,
clipboard_read: bool,
clipboard_write: bool,
) -> Result<Option<WebRtcSession>, String> {
let Some(options) = options else {
return Ok(None);
};
if !supports_direct_webrtc(protocol_minor) {
return Ok(None);
}
send_command(
&mut *socket,
&ClientCommand::BeginDirectWebRtc {
user: user.to_owned(),
max_width: options.width,
max_height: options.height,
frames_per_second: options.frames_per_second,
opus_audio: supports_opus_audio(protocol_minor),
clipboard_read: clipboard_read && options.clipboard_read,
clipboard_write: clipboard_write && options.clipboard_write,
},
)
.await?;
match receive_event(&mut *socket).await? {
ServerEvent::DirectWebRtcReady => {}
ServerEvent::Error { code, message } => {
return Err(format!("direct WebRTC rejected ({code}): {message}"));
}
_ => return Err("Linux Agent returned an invalid direct WebRTC response".into()),
}
let signaling = DirectClientSignalTransport { socket };
let session = establish_webrtc(
signaling,
WebRtcSessionConfig {
role: EdgeWebRtcRole::ClientOfferer,
bind_address: "0.0.0.0:0"
.parse()
.map_err(|_| "unable to create the direct WebRTC bind address".to_owned())?,
ice_servers: Vec::new(),
connect_timeout: Duration::from_secs(15),
},
)
.await;
match session {
Ok(session) => Ok(Some(session)),
Err(_) => {
send_command(&mut *socket, &ClientCommand::AbortDirectWebRtc).await?;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
match receive_event(&mut *socket).await? {
ServerEvent::DirectWebRtcUnavailable { .. } => return Ok(()),
ServerEvent::DirectWebRtcSignal { .. } => {}
ServerEvent::Error { code, message } => {
return Err(format!(
"direct WebRTC fallback failed ({code}): {message}"
));
}
_ => {
return Err(
"Linux Agent returned an invalid WebRTC fallback response".into()
);
}
}
}
})
.await
.map_err(|_| "direct WebRTC fallback synchronization timed out".to_owned())??;
Ok(None)
}
}
}
fn supports_direct_webrtc(protocol_minor: u16) -> bool {
protocol_minor >= DIRECT_WEBRTC_PROTOCOL_MINOR
}
fn supports_opus_audio(protocol_minor: u16) -> bool {
protocol_minor >= OPUS_AUDIO_PROTOCOL_MINOR
}
fn supports_clipboard(protocol_minor: u16) -> bool {
protocol_minor >= remotedesk_agent_runtime::CLIPBOARD_PROTOCOL_MINOR
}
fn has_permission(agent: &AuthenticatedAgent, permission: &str) -> bool {
agent
.permissions
.iter()
.any(|granted| granted == permission)
}
async fn connect_edge_desktop_webrtc(
args: &Args,
signing_key: &SigningKey,
edge_api_url: &Url,
authorized: &AuthorizedEdgeSession,
) -> Result<Option<WebRtcSession>, String> {
if args.desktop.is_none() || args.pair {
return Ok(None);
}
let negotiation = EdgeNegotiationClient::new(
edge_api_url.as_str(),
EdgeNegotiationEndpoint::Client,
signing_key.clone(),
authorized.negotiation.clone(),
)
.map_err(|error| format!("unable to initialize Edge WebRTC negotiation: {error}"))?;
let session = establish_edge_webrtc(
negotiation,
WebRtcSessionConfig {
role: EdgeWebRtcRole::ClientOfferer,
bind_address: "0.0.0.0:0"
.parse()
.map_err(|_| "unable to create the WebRTC bind address".to_owned())?,
ice_servers: authorized.ice_servers.clone(),
connect_timeout: Duration::from_secs(30),
},
)
.await
.map_err(|error| format!("Edge WebRTC connection failed: {error}"))?;
Ok(Some(session))
}
async fn run_file_transfer<S>(
socket: &mut tokio_tungstenite::WebSocketStream<S>,
user: &str,
transfer: FileTransfer,
) -> Result<(), String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
match transfer {
FileTransfer::Upload { local, remote } => upload_file(socket, user, local, remote).await,
FileTransfer::Download { remote, local } => {
download_file(socket, user, remote, local).await
}
}
}
async fn upload_file<S>(
socket: &mut tokio_tungstenite::WebSocketStream<S>,
user: &str,
local: PathBuf,
remote: String,
) -> Result<(), String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let metadata = tokio::fs::metadata(&local)
.await
.map_err(|error| format!("unable to read local file: {error}"))?;
if !metadata.is_file() || metadata.len() > MAX_FILE_BYTES {
return Err("local file is not regular or exceeds 2 GiB".into());
}
let size = metadata.len();
let sha256 = hash_file(&local).await?;
let transfer_id = upload_transfer_id(user, &remote, size, &sha256);
send_command(
socket,
&ClientCommand::UploadFile {
user: user.to_owned(),
path: remote,
size,
sha256: sha256.clone(),
transfer_id,
},
)
.await?;
let offset = match receive_event(socket).await? {
ServerEvent::FileReady {
direction,
size: ready_size,
offset,
..
} if direction == "upload" && ready_size == size && offset <= size => offset,
ServerEvent::Error { code, message } => {
return Err(format!("upload rejected ({code}): {message}"));
}
_ => return Err("Agent did not accept the upload".into()),
};
let mut file = tokio::fs::File::open(local)
.await
.map_err(|error| error.to_string())?;
file.seek(SeekFrom::Start(offset))
.await
.map_err(|error| error.to_string())?;
let mut buffer = vec![0_u8; FILE_CHUNK_BYTES];
loop {
let read = file
.read(&mut buffer)
.await
.map_err(|error| error.to_string())?;
if read == 0 {
break;
}
send_command(
socket,
&ClientCommand::FileChunk {
data: STANDARD_NO_PAD.encode(&buffer[..read]),
},
)
.await?;
}
send_command(socket, &ClientCommand::FileCommit).await?;
match receive_event(socket).await? {
ServerEvent::FileComplete {
size: complete_size,
sha256: complete_hash,
} if complete_size == size && complete_hash == sha256 => {
println!("upload complete: {size} bytes, sha256={sha256}");
Ok(())
}
ServerEvent::Error { code, message } => Err(format!("upload failed ({code}): {message}")),
_ => Err("Agent did not confirm the uploaded file".into()),
}
}
async fn download_file<S>(
socket: &mut tokio_tungstenite::WebSocketStream<S>,
user: &str,
remote: String,
local: PathBuf,
) -> Result<(), String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
if tokio::fs::try_exists(&local)
.await
.map_err(|error| error.to_string())?
{
return Err("local download target already exists".into());
}
let temporary = download_partial_path(&local)?;
let offset = match tokio::fs::metadata(&temporary).await {
Ok(metadata) if metadata.is_file() && metadata.len() <= MAX_FILE_BYTES => metadata.len(),
Ok(_) => return Err("local download partial is invalid".into()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
Err(error) => return Err(error.to_string()),
};
send_command(
socket,
&ClientCommand::DownloadFile {
user: user.to_owned(),
path: remote,
offset,
},
)
.await?;
let (size, expected_hash) = match receive_event(socket).await? {
ServerEvent::FileReady {
direction,
size,
sha256: Some(hash),
offset: ready_offset,
} if direction == "download"
&& size <= MAX_FILE_BYTES
&& ready_offset == offset
&& offset <= size =>
{
(size, hash)
}
ServerEvent::Error { code, message } => {
return Err(format!("download rejected ({code}): {message}"));
}
_ => return Err("Agent did not prepare the download".into()),
};
receive_download(socket, &temporary, &local, size, &expected_hash, offset).await
}
async fn receive_download<S>(
socket: &mut tokio_tungstenite::WebSocketStream<S>,
temporary: &Path,
local: &Path,
size: u64,
expected_hash: &str,
offset: u64,
) -> Result<(), String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let mut file = tokio::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(temporary)
.await
.map_err(|error| error.to_string())?;
file.seek(SeekFrom::Start(0))
.await
.map_err(|error| error.to_string())?;
let mut received = 0_u64;
let mut hasher = Sha256::new();
let mut prefix_buffer = vec![0_u8; 64 * 1024];
while received < offset {
let limit = usize::try_from((offset - received).min(prefix_buffer.len() as u64))
.map_err(|_| "file size overflow")?;
let read = file
.read(&mut prefix_buffer[..limit])
.await
.map_err(|error| error.to_string())?;
if read == 0 {
return Err("local download partial ended before its declared size".into());
}
received += u64::try_from(read).map_err(|_| "file size overflow")?;
hasher.update(&prefix_buffer[..read]);
}
file.seek(SeekFrom::End(0))
.await
.map_err(|error| error.to_string())?;
loop {
match receive_event(socket).await? {
ServerEvent::FileChunk { data } => {
let bytes = STANDARD_NO_PAD
.decode(data)
.map_err(|_| "Agent returned invalid file data".to_owned())?;
if bytes.len() > FILE_CHUNK_BYTES {
return Err("Agent returned an oversized file chunk".into());
}
received = received
.checked_add(u64::try_from(bytes.len()).map_err(|_| "file size overflow")?)
.ok_or("file size overflow")?;
if received > size {
return Err("Agent returned more file data than declared".into());
}
file.write_all(&bytes)
.await
.map_err(|error| error.to_string())?;
hasher.update(&bytes);
}
ServerEvent::FileComplete {
size: complete_size,
sha256,
} if complete_size == size && received == size && sha256 == expected_hash => {
file.sync_all().await.map_err(|error| error.to_string())?;
let actual = hex_digest(&hasher.finalize());
if actual != expected_hash {
drop(file);
let _ = tokio::fs::remove_file(temporary).await;
return Err("download SHA-256 mismatch".into());
}
drop(file);
tokio::fs::rename(temporary, local)
.await
.map_err(|error| error.to_string())?;
println!("download complete: {size} bytes, sha256={actual}");
return Ok(());
}
ServerEvent::Error { code, message } => {
return Err(format!("download failed ({code}): {message}"));
}
_ => return Err("Agent returned an invalid download event".into()),
}
}
}
fn upload_transfer_id(user: &str, remote: &str, size: u64, sha256: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"RemoteDesk/UploadTransfer/v1\0");
let size_bytes = size.to_be_bytes();
for value in [
user.as_bytes(),
remote.as_bytes(),
size_bytes.as_slice(),
sha256.as_bytes(),
] {
hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
hasher.update(value);
}
hex_digest(&hasher.finalize())[..32].into()
}
fn download_partial_path(local: &Path) -> Result<PathBuf, String> {
let name = local
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| "local download target has no UTF-8 file name".to_owned())?;
Ok(local.with_file_name(format!(".{name}.remotedesk.part")))
}
async fn hash_file(path: &PathBuf) -> Result<String, String> {
let mut file = tokio::fs::File::open(path)
.await
.map_err(|error| error.to_string())?;
let mut buffer = vec![0_u8; 64 * 1024];
let mut hasher = Sha256::new();
loop {
let read = file
.read(&mut buffer)
.await
.map_err(|error| error.to_string())?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hex_digest(&hasher.finalize()))
}
fn hex_digest(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
write!(output, "{byte:02x}").expect("String writes cannot fail");
}
output
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct EdgeSignalStatus {
request_id: String,
device_id: String,
session_id: String,
state: String,
accepted: Option<bool>,
result_code: Option<String>,
expires_unix: u64,
completed_unix: Option<u64>,
relay: Option<EdgeRelayAccess>,
#[serde(default)]
turn: Vec<EdgeTurnCredential>,
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct EdgeTurnCredential {
#[serde(rename = "pop_id")]
_pop_id: String,
#[serde(rename = "realm")]
_realm: String,
urls: Vec<String>,
username: String,
password: String,
expires_unix: u64,
}
struct AuthorizedEdgeSession {
relay: EdgeRelayAccess,
negotiation: EdgeNegotiationSession,
ice_servers: Vec<WebRtcIceServer>,
}
struct SignedEdgeIntent {
device_id: String,
request_id: String,
session_id: String,
session_type: EdgeSessionType,
client_public_key: String,
signature: String,
expires_unix: u64,
body: serde_json::Value,
}
fn build_edge_intent(
device_public_key: &str,
args: &Args,
signing_key: &SigningKey,
) -> Result<SignedEdgeIntent, String> {
let device_id = edge_device_id(device_public_key)
.map_err(|_| "Agent returned an invalid device public key".to_owned())?;
let now = unix_timestamp();
let request_id = random_identifier("request");
let session_id = random_identifier("session");
let session_type = if args.pair {
EdgeSessionType::Pairing
} else if args.transfer.is_some() {
EdgeSessionType::Files
} else if args.desktop.is_some() {
EdgeSessionType::Desktop
} else {
EdgeSessionType::Terminal
};
let mut nonce = [0_u8; SIGNAL_NONCE_LENGTH];
rand::fill(&mut nonce);
let client_public_key = STANDARD_NO_PAD.encode(signing_key.verifying_key().as_bytes());
let expires_unix = now.saturating_add(60);
let model = EdgeSessionIntentV1 {
device_id: device_id.clone(),
request_id: request_id.clone(),
session_id: session_id.clone(),
session_type,
target_user: args.user.clone(),
requested_permissions: vec![session_type.as_str().into()],
client_public_key: client_public_key.clone(),
nonce,
issued_unix: now,
expires_unix,
};
let payload = model
.stable_signing_input()
.map_err(|_| "Edge session intent is invalid".to_owned())?;
let signature = STANDARD_NO_PAD.encode(signing_key.sign(&payload).to_bytes());
let body = serde_json::json!({
"device_id": device_id,
"request_id": request_id,
"session_id": session_id,
"session_type": session_type.as_str(),
"target_user": args.user,
"requested_permissions": [session_type.as_str()],
"client_public_key": client_public_key,
"nonce": STANDARD_NO_PAD.encode(nonce),
"issued_unix": now,
"expires_unix": expires_unix,
"signature": signature,
});
Ok(SignedEdgeIntent {
device_id,
request_id,
session_id,
session_type,
client_public_key,
signature,
expires_unix,
body,
})
}
async fn authorize_edge_session(
api_url: &Url,
device_public_key: &str,
args: &Args,
signing_key: &SigningKey,
) -> Result<AuthorizedEdgeSession, String> {
let intent = build_edge_intent(device_public_key, args, signing_key)?;
let client = Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
.redirect(Policy::none())
.user_agent(concat!(
"RemoteDesk-Linux-Controller/",
env!("CARGO_PKG_VERSION")
))
.build()
.map_err(|_| "unable to initialize Edge HTTPS client".to_owned())?;
let submit = client
.post(join_edge_url(api_url, "v1/signals/requests"))
.json(&intent.body)
.send()
.await
.map_err(|error| edge_transport_error(&error))?;
if !matches!(submit.status(), StatusCode::CREATED | StatusCode::OK) {
return Err(format!(
"Edge session intent was rejected with HTTP {}",
submit.status().as_u16()
));
}
let submit_body = edge_response_bytes(submit).await?;
let submitted: serde_json::Value = serde_json::from_slice(&submit_body)
.map_err(|_| "Edge returned an invalid submission response".to_owned())?;
if submitted["request_id"] != intent.request_id {
return Err("Edge acknowledged a different request".into());
}
wait_for_edge_authorization(&client, api_url, &intent).await
}
async fn wait_for_edge_authorization(
client: &Client,
api_url: &Url,
intent: &SignedEdgeIntent,
) -> Result<AuthorizedEdgeSession, String> {
let status_body = serde_json::json!({
"request_id": intent.request_id,
"client_public_key": intent.client_public_key,
"signature": intent.signature,
});
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let response = client
.post(join_edge_url(api_url, "v1/signals/status"))
.json(&status_body)
.send()
.await
.map_err(|error| edge_transport_error(&error))?;
if response.status() != StatusCode::OK {
return Err(format!(
"Edge session status failed with HTTP {}",
response.status().as_u16()
));
}
let bytes = edge_response_bytes(response).await?;
let status: EdgeSignalStatus = serde_json::from_slice(&bytes)
.map_err(|_| "Edge returned an invalid session status".to_owned())?;
if status.request_id != intent.request_id
|| status.device_id != intent.device_id
|| status.session_id != intent.session_id
|| status.expires_unix != intent.expires_unix
{
return Err("Edge session status binding did not match".into());
}
if status.state == "completed" {
if status.completed_unix.is_none() {
return Err("Edge completion timestamp is missing".into());
}
let expected_result = if intent.session_type == EdgeSessionType::Pairing {
"pairing_window_open"
} else {
"authorized"
};
return if status.accepted == Some(true)
&& status.result_code.as_deref() == Some(expected_result)
{
let relay = status.relay.ok_or_else(|| {
"Edge authorization did not include a relay ticket".to_owned()
})?;
if relay.expires_unix != intent.expires_unix {
return Err("Edge relay expiry did not match the signed session".into());
}
let mut ice_servers = Vec::with_capacity(status.turn.len());
for credential in status.turn {
if credential.expires_unix != intent.expires_unix {
return Err("Edge TURN expiry did not match the signed session".into());
}
ice_servers.push(WebRtcIceServer {
urls: credential.urls,
username: credential.username,
credential: credential.password,
});
}
Ok(AuthorizedEdgeSession {
relay,
negotiation: EdgeNegotiationSession {
request_id: intent.request_id.clone(),
session_id: intent.session_id.clone(),
expires_unix: intent.expires_unix,
},
ice_servers,
})
} else {
Err(format!(
"Linux Agent rejected Edge authorization ({})",
status.result_code.as_deref().unwrap_or("rejected")
))
};
}
if !matches!(status.state.as_str(), "pending" | "delivered")
|| status.accepted.is_some()
|| status.result_code.is_some()
|| status.relay.is_some()
{
return Err("Edge returned an invalid session state".into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
})
.await
.map_err(|_| "Edge session authorization timed out".to_owned())?
}
fn parse_edge_api_url(value: &str) -> Result<Url, String> {
if value.len() > 2048 {
return Err("Edge API URL is too long".into());
}
let url = Url::parse(value).map_err(|_| "Edge API URL is invalid".to_owned())?;
let loopback_http = url.scheme() == "http"
&& match url.host() {
Some(Host::Domain("localhost")) => true,
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
_ => false,
};
if (url.scheme() != "https" && !loopback_http)
|| url.username() != ""
|| url.password().is_some()
|| url.path() != "/"
|| url.query().is_some()
|| url.fragment().is_some()
|| url.port() == Some(0)
{
return Err("Edge API URL must be an HTTPS origin".into());
}
Ok(url)
}
fn join_edge_url(api_url: &Url, path: &str) -> Url {
api_url
.join(path)
.expect("validated Edge base URL joins a static path")
}
async fn edge_response_bytes(mut response: reqwest::Response) -> Result<Vec<u8>, String> {
if response
.content_length()
.is_some_and(|length| length > EDGE_RESPONSE_LIMIT as u64)
{
return Err("Edge response exceeds the size limit".into());
}
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| edge_transport_error(&error))?
{
if bytes.len().saturating_add(chunk.len()) > EDGE_RESPONSE_LIMIT {
return Err("Edge response exceeds the size limit".into());
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}
fn edge_transport_error(error: &reqwest::Error) -> String {
if error.is_timeout() {
"Edge request timed out".into()
} else if error.is_connect() {
"unable to connect to Edge service".into()
} else {
"Edge HTTPS request failed".into()
}
}
fn random_identifier(prefix: &str) -> String {
let random = rand::random::<[u8; 18]>();
format!("{prefix}-{}", URL_SAFE_NO_PAD.encode(random))
}
fn unix_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
async fn authenticate<S>(
socket: &mut tokio_tungstenite::WebSocketStream<S>,
args: &Args,
signing_key: &SigningKey,
pair: bool,
) -> Result<AuthenticatedAgent, String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
authenticate_with_pairing_code(socket, args, signing_key, pair, None).await
}
async fn authenticate_with_pairing_code<S>(
socket: &mut tokio_tungstenite::WebSocketStream<S>,
args: &Args,
signing_key: &SigningKey,
pair: bool,
supplied_pairing_code: Option<&str>,
) -> Result<AuthenticatedAgent, String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let hello = receive_event(socket).await?;
let needs_files = args.transfer.is_some();
let needs_desktop = args.desktop.is_some();
let (challenge, reported_fingerprint, device_public_key, edge_signaling_online, protocol_minor) =
match hello {
ServerEvent::Hello {
protocol_major,
protocol_minor,
challenge,
tls_certificate_sha256,
device_public_key,
terminal,
desktop,
files,
edge_signaling_online,
..
} if protocol_major == PROTOCOL_MAJOR
&& ((needs_desktop && desktop)
|| (needs_files && files)
|| (!needs_desktop && !needs_files && terminal)) =>
{
(
challenge,
tls_certificate_sha256,
device_public_key,
edge_signaling_online,
protocol_minor,
)
}
ServerEvent::Hello { .. } => {
return Err(
"Linux Agent protocol or requested capability is incompatible".to_owned(),
);
}
_ => return Err("Linux Agent did not send a hello event".to_owned()),
};
if args.certificate_sha256 != [0; 32]
&& parse_fingerprint(&reported_fingerprint)? != args.certificate_sha256
{
return Err(
"Linux Agent hello fingerprint does not match the pinned TLS certificate".to_owned(),
);
}
let challenge = AuthChallenge::parse_encoded(&challenge).map_err(str::to_owned)?;
let public_key = STANDARD_NO_PAD.encode(signing_key.verifying_key().as_bytes());
let signature =
STANDARD_NO_PAD.encode(signing_key.sign(&challenge.signing_payload()).to_bytes());
let command = if pair {
let code = match supplied_pairing_code {
Some(code) => code.to_owned(),
None => Password::new("Linux Agent pairing code:")
.without_confirmation()
.prompt()
.map_err(|_| "pairing code input was cancelled".to_owned())?,
};
if code.len() != 8 || !code.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("pairing code must contain exactly eight digits".to_owned());
}
ClientCommand::Pair {
protocol_major: PROTOCOL_MAJOR,
protocol_minor: PROTOCOL_MINOR,
client_name: "RemoteDesk Windows".to_owned(),
client_public_key: public_key,
signature,
code,
}
} else {
ClientCommand::Authenticate {
protocol_major: PROTOCOL_MAJOR,
protocol_minor: PROTOCOL_MINOR,
client_public_key: public_key,
signature,
}
};
send_command(socket, &command).await?;
let required_permission = if args.transfer.is_some() {
"files"
} else if args.desktop.is_some() {
"desktop"
} else {
"terminal"
};
match receive_event(socket).await? {
ServerEvent::Paired { permissions, .. }
| ServerEvent::Authenticated { permissions, .. }
if permissions
.iter()
.any(|permission| permission == required_permission) =>
{
Ok(AuthenticatedAgent {
device_public_key,
edge_signaling_online,
protocol_minor,
permissions,
})
}
ServerEvent::Error { code, message } => {
Err(format!("Agent authentication failed ({code}): {message}"))
}
_ => Err(format!(
"Linux Agent did not authorize {required_permission} access"
)),
}
}
fn read_console(sender: &mpsc::UnboundedSender<ConsoleInput>) {
loop {
let Ok(event) = crossterm::event::read() else {
let _ = sender.send(ConsoleInput::Close);
return;
};
let input = match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
key_bytes(key).map(ConsoleInput::Bytes)
}
Event::Resize(cols, rows) => Some(ConsoleInput::Resize { cols, rows }),
_ => None,
};
if input.is_some_and(|input| sender.send(input).is_err()) {
return;
}
}
}
fn key_bytes(key: KeyEvent) -> Option<Vec<u8>> {
let bytes: &[u8] = match key.code {
KeyCode::Enter => b"\r",
KeyCode::Backspace => b"\x7f",
KeyCode::Tab => b"\t",
KeyCode::Esc => b"\x1b",
KeyCode::Up => b"\x1b[A",
KeyCode::Down => b"\x1b[B",
KeyCode::Right => b"\x1b[C",
KeyCode::Left => b"\x1b[D",
KeyCode::Home => b"\x1b[H",
KeyCode::End => b"\x1b[F",
KeyCode::Delete => b"\x1b[3~",
KeyCode::PageUp => b"\x1b[5~",
KeyCode::PageDown => b"\x1b[6~",
KeyCode::Char(character)
if key.modifiers.contains(KeyModifiers::CONTROL) && character.is_ascii() =>
{
return Some(vec![(character.to_ascii_uppercase() as u8) & 0x1f]);
}
KeyCode::Char(character) => return Some(character.to_string().into_bytes()),
_ => return None,
};
Some(bytes.to_vec())
}
async fn receive_event<S>(
socket: &mut tokio_tungstenite::WebSocketStream<S>,
) -> Result<ServerEvent, String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
loop {
let message = socket
.next()
.await
.ok_or_else(|| "Linux Agent closed the connection".to_owned())?
.map_err(|error| format!("Linux Agent WebSocket failed: {error}"))?;
if message.is_close() {
return Err("Linux Agent closed the connection".to_owned());
}
if message.is_text() {
return serde_json::from_str(message.to_text().map_err(|error| error.to_string())?)
.map_err(|_| "Linux Agent returned an invalid control event".to_owned());
}
}
}
async fn send_command<S>(
socket: &mut tokio_tungstenite::WebSocketStream<S>,
command: &ClientCommand,
) -> Result<(), String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let encoded = serde_json::to_string(command).map_err(|error| error.to_string())?;
socket
.send(Message::Text(encoded.into()))
.await
.map_err(|error| format!("unable to send Linux Agent command: {error}"))
}
async fn connect_agent(
url: &Url,
certificate_sha256: [u8; 32],
relay: Option<&EdgeRelayAccess>,
) -> Result<WebSocketStream<MaybeTlsStream<RelayStream>>, String> {
let transport: RelayStream = if let Some(access) = relay {
connect_edge_relay(access)
.await
.map_err(|error| error.to_string())?
} else {
let host = match url
.host()
.ok_or_else(|| "Linux Agent target is invalid".to_owned())?
{
Host::Domain(value) => value.to_owned(),
Host::Ipv4(address) => address.to_string(),
Host::Ipv6(address) => address.to_string(),
};
let port = url
.port_or_known_default()
.ok_or_else(|| "Linux Agent target port is missing".to_owned())?;
let tcp = tokio::time::timeout(
Duration::from_secs(10),
tokio::net::TcpStream::connect((host.as_str(), port)),
)
.await
.map_err(|_| "Linux Agent connection timed out".to_owned())?
.map_err(|_| "unable to connect to Linux Agent".to_owned())?;
tcp.set_nodelay(true)
.map_err(|_| "unable to configure Linux Agent connection".to_owned())?;
Box::new(tcp)
};
let connector = Connector::Rustls(Arc::new(tls_config(certificate_sha256)?));
client_async_tls_with_config(url.as_str(), transport, None, Some(connector))
.await
.map(|(socket, _)| socket)
.map_err(|_| "Linux Agent TLS/WebSocket handshake failed".to_owned())
}
fn tls_config(expected_sha256: [u8; 32]) -> Result<rustls::ClientConfig, String> {
let supported_algorithms = rustls::crypto::CryptoProvider::get_default()
.ok_or_else(|| "rustls crypto provider is unavailable".to_owned())?
.signature_verification_algorithms;
Ok(rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinnedCertificateVerifier {
expected_sha256,
supported_algorithms,
}))
.with_no_client_auth())
}
fn terminal_url(target: &str) -> Result<Url, String> {
let url = Url::parse(&format!("wss://{target}/"))
.map_err(|_| "Linux Agent target must be host:port or [IPv6]:port".to_owned())?;
if url.username() != ""
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err("Linux Agent target contains unsupported URL data".to_owned());
}
if url.port().is_none() {
return Url::parse(&format!("wss://{target}:39500/"))
.map_err(|_| "Linux Agent target is invalid".to_owned());
}
Ok(url)
}
fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
let mut target = None;
let mut user = None;
let mut certificate_sha256 = None;
let mut probe_json = false;
let mut pair = false;
let mut agent_public_key = None;
let mut edge_api_url = None;
let mut upload = None;
let mut download = None;
let mut remote_path = None;
let mut local_path = None;
let mut desktop = false;
let mut desktop_width = 1_920;
let mut desktop_height = 1_080;
let mut desktop_fps = 15;
let mut fullscreen = false;
let mut follow_window = false;
let mut capture_input = false;
let mut clipboard_read = false;
let mut clipboard_write = false;
let mut session_id = None;
while let Some(argument) = args.next() {
match argument.as_str() {
"--target" => target = Some(required_value(&mut args, "--target")?),
"--user" => user = Some(required_value(&mut args, "--user")?),
"--certificate-sha256" => {
certificate_sha256 = Some(parse_fingerprint(&required_value(
&mut args,
"--certificate-sha256",
)?)?);
}
"--probe-json" => probe_json = true,
"--pair" => pair = true,
"--agent-public-key" => {
let value = required_value(&mut args, "--agent-public-key")?;
validate_agent_public_key(&value)?;
agent_public_key = Some(value);
}
"--edge-api-url" => {
edge_api_url = Some(parse_edge_api_url(&required_value(
&mut args,
"--edge-api-url",
)?)?);
}
"--upload" => upload = Some(PathBuf::from(required_value(&mut args, "--upload")?)),
"--download" => download = Some(required_value(&mut args, "--download")?),
"--remote-path" => remote_path = Some(required_value(&mut args, "--remote-path")?),
"--local-path" => {
local_path = Some(PathBuf::from(required_value(&mut args, "--local-path")?));
}
"--desktop" => desktop = true,
"--width" => {
desktop_width = parse_desktop_dimension(&required_value(&mut args, "--width")?)?;
}
"--height" => {
desktop_height = parse_desktop_dimension(&required_value(&mut args, "--height")?)?;
}
"--fps" => {
desktop_fps = required_value(&mut args, "--fps")?
.parse::<u8>()
.ok()
.filter(|value| (1..=30).contains(value))
.ok_or_else(|| "desktop FPS must be between 1 and 30".to_owned())?;
}
"--fullscreen" => fullscreen = true,
"--follow-window" => follow_window = true,
"--capture-input" => capture_input = true,
"--clipboard-read" => clipboard_read = true,
"--clipboard-write" => clipboard_write = true,
"--session-id" => {
let value = required_value(&mut args, "--session-id")?;
if value.is_empty()
|| value.len() > 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err("desktop session ID is invalid".into());
}
session_id = Some(value);
}
"--help" | "-h" => return Err(usage().to_owned()),
_ => return Err("unknown Linux terminal argument".to_owned()),
}
}
let user = if probe_json {
if user.is_some() {
return Err("--probe-json does not accept --user".into());
}
String::new()
} else {
let user = user.ok_or_else(|| "--user is required".to_owned())?;
let user = user.trim().to_owned();
if user.is_empty() {
return Err("Linux terminal user is empty; enter a regular Linux user".to_owned());
}
if user.len() > 32
|| !user
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
{
return Err("Linux terminal user contains unsupported characters".to_owned());
}
user
};
let transfer = match (upload, download, remote_path, local_path) {
(None, None, None, None) => None,
(Some(local), None, Some(remote), None) => Some(FileTransfer::Upload { local, remote }),
(None, Some(remote), None, Some(local)) => Some(FileTransfer::Download { remote, local }),
_ => return Err("use either --upload <local> --remote-path <relative> or --download <relative> --local-path <local>".into()),
};
if pair && transfer.is_some() {
return Err("pairing and file transfer must be separate operations".into());
}
if desktop && transfer.is_some() {
return Err("desktop and file transfer modes are mutually exclusive".into());
}
if probe_json
&& (pair
|| agent_public_key.is_some()
|| edge_api_url.is_some()
|| transfer.is_some()
|| desktop
|| fullscreen
|| follow_window
|| capture_input
|| clipboard_read
|| clipboard_write
|| session_id.is_some())
{
return Err("--probe-json cannot be combined with session options".into());
}
if !desktop
&& (desktop_width != 1_920
|| desktop_height != 1_080
|| desktop_fps != 15
|| fullscreen
|| follow_window
|| capture_input
|| clipboard_read
|| clipboard_write
|| session_id.is_some())
{
return Err("desktop display options require --desktop".into());
}
if agent_public_key.is_some() && (!pair || edge_api_url.is_none()) {
return Err("--agent-public-key requires --pair and --edge-api-url".into());
}
Ok(Args {
target: target.ok_or_else(|| "--target is required".to_owned())?,
user,
certificate_sha256: certificate_sha256.unwrap_or([0; 32]),
probe_json,
pair,
agent_public_key,
transfer,
edge_api_url,
desktop: desktop.then_some(DesktopOptions {
width: desktop_width,
height: desktop_height,
frames_per_second: desktop_fps,
fullscreen,
follow_window,
capture_input,
clipboard_read,
clipboard_write,
}),
session_id,
})
}
fn required_value(args: &mut impl Iterator<Item = String>, option: &str) -> Result<String, String> {
args.next()
.filter(|value| !value.is_empty() && !value.starts_with("--"))
.ok_or_else(|| format!("{option} requires a value"))
}
fn parse_fingerprint(value: &str) -> Result<[u8; 32], String> {
let compact = value
.chars()
.filter(|character| *character != ':')
.collect::<String>();
if compact.len() != 64 || !compact.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("certificate SHA-256 must contain exactly 64 hexadecimal digits".to_owned());
}
let mut output = [0_u8; 32];
for (index, byte) in output.iter_mut().enumerate() {
*byte = u8::from_str_radix(&compact[index * 2..index * 2 + 2], 16)
.map_err(|_| "certificate SHA-256 is invalid".to_owned())?;
}
Ok(output)
}
fn parse_desktop_dimension(value: &str) -> Result<u16, String> {
value
.parse::<u16>()
.ok()
.filter(|value| (200..=8_192).contains(value))
.ok_or_else(|| "desktop dimensions must be between 200 and 8192".to_owned())
}
fn usage() -> &'static str {
"usage: remotedesk-linux-terminal --target <host[:port]> --certificate-sha256 <hex> [--probe-json | --user <name> [--edge-api-url <https-origin>] [--pair [--agent-public-key <base64>] | --desktop [--width <200-8192>] [--height <200-8192>] [--fps <1-30>] [--fullscreen] [--follow-window] [--capture-input] [--clipboard-read] [--clipboard-write] [--session-id <id>] | --upload <local> --remote-path <relative> | --download <relative> --local-path <local>]]"
}
#[cfg(windows)]
fn load_or_create_identity() -> Result<SigningKey, String> {
let entry = keyring::Entry::new_with_target(IDENTITY_TARGET, IDENTITY_SERVICE, IDENTITY_OWNER)
.map_err(|_| "unable to access the Linux client identity".to_owned())?;
match entry.get_password() {
Ok(encoded) => {
let encoded = Zeroizing::new(encoded);
let bytes = STANDARD_NO_PAD
.decode(encoded.as_bytes())
.map_err(|_| "stored Linux client identity is invalid".to_owned())?;
let seed: [u8; 32] = bytes
.try_into()
.map_err(|_| "stored Linux client identity is invalid".to_owned())?;
Ok(SigningKey::from_bytes(&seed))
}
Err(keyring::Error::NoEntry) => {
let seed = rand::random::<[u8; 32]>();
let key = SigningKey::from_bytes(&seed);
let encoded = Zeroizing::new(STANDARD_NO_PAD.encode(seed));
entry
.set_password(&encoded)
.map_err(|_| "unable to save the Linux client identity".to_owned())?;
Ok(key)
}
Err(_) => Err("Windows Credential Manager is unavailable".to_owned()),
}
}
fn validate_agent_public_key(public_key: &str) -> Result<(), String> {
edge_device_id(public_key)
.map(|_| ())
.map_err(|_| "stored Linux Agent identity is invalid".to_owned())
}
#[cfg(windows)]
fn load_known_agent(certificate_sha256: [u8; 32]) -> Result<Option<String>, String> {
let fingerprint = hex_digest(&certificate_sha256);
let reference = LinuxAgentIdentityRef::new(&fingerprint)
.map_err(|_| "Linux Agent certificate fingerprint is invalid".to_owned())?;
match linux_agent_identity_load(&reference) {
Ok(public_key) => {
validate_agent_public_key(&public_key)?;
Ok(Some(public_key))
}
Err(remotedesk_credential_store::CredentialStoreError::Missing) => Ok(None),
Err(_) => Err("Windows Credential Manager is unavailable".to_owned()),
}
}
#[cfg(windows)]
fn save_known_agent(certificate_sha256: [u8; 32], public_key: &str) -> Result<(), String> {
validate_agent_public_key(public_key)?;
let fingerprint = hex_digest(&certificate_sha256);
let reference = LinuxAgentIdentityRef::new(&fingerprint)
.map_err(|_| "Linux Agent certificate fingerprint is invalid".to_owned())?;
linux_agent_identity_save(&reference, public_key)
.map_err(|_| "unable to save the Linux Agent identity".to_owned())
}
#[cfg(not(windows))]
fn load_or_create_identity() -> Result<SigningKey, String> {
Err("the Linux terminal controller is currently packaged for Windows only".to_owned())
}
#[cfg(not(windows))]
fn load_known_agent(_: [u8; 32]) -> Result<Option<String>, String> {
Ok(None)
}
#[cfg(not(windows))]
fn save_known_agent(_: [u8; 32], public_key: &str) -> Result<(), String> {
validate_agent_public_key(public_key)
}
#[cfg(test)]
mod tests {
use super::*;
use remotedesk_agent_runtime::{
AgentState, EdgePresenceClient, EdgePresenceConfig, verify_auth_signature,
};
use remotedesk_edge_service::{
ApiConfig, EdgeApi, RelayBroker, RelayConfig, RelayTicketIssuer, run_http_server,
run_relay_listener,
};
use std::fs;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use tiny_http::Server;
const ADMIN_TOKEN: &str = "admin-token-0123456789abcdef0123456789";
const PRESENCE_TOKEN: &str = "presence-token-0123456789abcdef012345";
const TICKET_SECRET: &[u8] = b"ticket-secret-0123456789abcdef0123456";
struct TestEdge {
address: SocketAddr,
shutdown: Arc<AtomicBool>,
http_worker: Option<JoinHandle<()>>,
relay_worker: Option<JoinHandle<()>>,
}
impl TestEdge {
fn start() -> Self {
let issuer = RelayTicketIssuer::new(TICKET_SECRET, "test-pop").unwrap();
let broker = Arc::new(RelayBroker::new(issuer.clone(), RelayConfig::default()));
let relay_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let relay_address = relay_listener.local_addr().unwrap();
let api = Arc::new(
EdgeApi::new(
ApiConfig {
api_token: ADMIN_TOKEN.into(),
presence_token: PRESENCE_TOKEN.into(),
pop_id: "test-pop".into(),
region: "test-region".into(),
public_relay_address: relay_address.to_string(),
default_relay_ttl_seconds: 300,
default_relay_max_bytes: 1024 * 1024,
turn_pops: vec![],
},
issuer,
Arc::clone(&broker),
)
.unwrap(),
);
let server = Server::http("127.0.0.1:0").unwrap();
let address = server.server_addr().to_ip().unwrap();
let shutdown = Arc::new(AtomicBool::new(false));
let http_worker = {
let shutdown = Arc::clone(&shutdown);
thread::spawn(move || run_http_server(&server, &api, &shutdown).unwrap())
};
let relay_worker = {
let shutdown = Arc::clone(&shutdown);
thread::spawn(move || {
run_relay_listener(&relay_listener, &broker, &shutdown).unwrap();
})
};
Self {
address,
shutdown,
http_worker: Some(http_worker),
relay_worker: Some(relay_worker),
}
}
fn url(&self) -> Url {
parse_edge_api_url(&format!("http://{}/", self.address)).unwrap()
}
}
impl Drop for TestEdge {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Release);
if let Some(worker) = self.http_worker.take() {
worker.join().unwrap();
}
if let Some(worker) = self.relay_worker.take() {
worker.join().unwrap();
}
}
}
fn test_root(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"remotedesk-{name}-{}-{}",
std::process::id(),
rand::random::<u64>()
))
}
fn strings<'a>(values: &'a [&'a str]) -> impl Iterator<Item = String> + 'a {
values.iter().map(|value| (*value).to_owned())
}
#[tokio::test]
#[expect(
clippy::too_many_lines,
reason = "the joint test keeps the signed admission, TLS tunnel, and one-use ticket lifecycle visible"
)]
async fn remote_pairing_and_known_device_sessions_use_outbound_only_edge_relay() {
let _ = rustls::crypto::ring::default_provider().install_default();
let edge = TestEdge::start();
let root = test_root("controller-edge-signal");
let state = AgentState::new(&root);
let identity = state.ensure_identity("test-host").unwrap();
let client_signing = SigningKey::from_bytes(&[12; 32]);
let pairing_code = state
.create_pairing_grant(60, vec!["terminal".into()], vec!["alice".into()])
.unwrap()
.expose()
.to_owned();
let config = EdgePresenceConfig::new(
edge.url().as_str(),
PRESENCE_TOKEN.into(),
"test-region".into(),
"gateway-1".into(),
)
.unwrap();
let online = Arc::new(AtomicBool::new(false));
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let local_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let local_target = local_listener.local_addr().unwrap();
let fingerprint_text = identity.tls_certificate_fingerprint().unwrap();
let fingerprint = parse_fingerprint(&fingerprint_text).unwrap();
let certificates = rustls_pemfile::certs(&mut std::io::BufReader::new(
identity.tls_certificate_pem.as_bytes(),
))
.collect::<Result<Vec<_>, _>>()
.unwrap();
let private_key = rustls_pemfile::private_key(&mut std::io::BufReader::new(
identity.tls_private_key_pem.as_bytes(),
))
.unwrap()
.unwrap();
let tls_acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(
rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certificates, private_key)
.unwrap(),
));
let server_device_public_key = identity.public_key.clone();
let server_state = state.clone();
let server_pairing_code = pairing_code.clone();
let echo_task = tokio::spawn(async move {
for connection_index in 0..2 {
let (stream, _) = local_listener.accept().await.unwrap();
let tls = tls_acceptor.accept(stream).await.unwrap();
let mut socket = tokio_tungstenite::accept_async(tls).await.unwrap();
let challenge = AuthChallenge::generate();
socket
.send(Message::Text(
serde_json::to_string(&ServerEvent::Hello {
protocol_major: PROTOCOL_MAJOR,
protocol_minor: PROTOCOL_MINOR,
device_public_key: server_device_public_key.clone(),
tls_certificate_sha256: fingerprint_text.clone(),
challenge: challenge.encoded(),
terminal: true,
desktop: false,
files: true,
edge_presence_configured: true,
edge_presence_online: true,
edge_signaling_configured: true,
edge_signaling_online: true,
})
.unwrap()
.into(),
))
.await
.unwrap();
let command: ClientCommand =
serde_json::from_slice(&socket.next().await.unwrap().unwrap().into_data())
.unwrap();
match (connection_index, command) {
(
0,
ClientCommand::Pair {
client_name,
client_public_key,
signature,
code,
..
},
) => {
verify_auth_signature(&client_public_key, &signature, &challenge).unwrap();
assert_eq!(code, server_pairing_code);
let grant = server_state.consume_pairing_grant(&code).unwrap();
let client = server_state
.grant_client(
client_name,
client_public_key,
grant.permissions,
grant.allowed_users,
)
.unwrap();
socket
.send(Message::Text(
serde_json::to_string(&ServerEvent::Paired {
client_fingerprint: client.fingerprint,
permissions: client.permissions,
})
.unwrap()
.into(),
))
.await
.unwrap();
}
(
1,
ClientCommand::Authenticate {
client_public_key,
signature,
..
},
) => {
verify_auth_signature(&client_public_key, &signature, &challenge).unwrap();
socket
.send(Message::Text(
serde_json::to_string(&ServerEvent::Authenticated {
client_fingerprint: "test-client".into(),
permissions: vec!["terminal".into()],
})
.unwrap()
.into(),
))
.await
.unwrap();
}
_ => panic!("relay client used an unexpected authentication command"),
}
let expected = if connection_index == 0 {
b"pair-over-edge".as_slice()
} else {
b"known-over-edge".as_slice()
};
assert_eq!(socket.next().await.unwrap().unwrap().into_data(), expected);
socket
.send(Message::Binary(if connection_index == 0 {
b"paired-over-edge".to_vec().into()
} else {
b"agent-over-edge!".to_vec().into()
}))
.await
.unwrap();
}
});
let presence_task = {
let state = state.clone();
let online = Arc::clone(&online);
let device_id = edge_device_id(&identity.public_key).unwrap();
tokio::spawn(async move {
EdgePresenceClient::new(config)
.unwrap()
.run(
device_id,
state,
online,
shutdown_rx,
Some(local_target),
None,
)
.await;
})
};
tokio::time::timeout(Duration::from_secs(5), async {
while !online.load(Ordering::Acquire) {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.unwrap();
let pair_args = Args {
target: format!("localhost:{}", local_target.port()),
user: "alice".into(),
certificate_sha256: fingerprint,
probe_json: false,
pair: true,
agent_public_key: Some(identity.public_key.clone()),
transfer: None,
edge_api_url: Some(edge.url()),
desktop: None,
session_id: None,
};
let pairing_session = authorize_edge_session(
pair_args.edge_api_url.as_ref().unwrap(),
&identity.public_key,
&pair_args,
&client_signing,
)
.await
.unwrap();
let agent_url = terminal_url(&format!("localhost:{}", local_target.port())).unwrap();
let mut paired = connect_agent(&agent_url, fingerprint, Some(&pairing_session.relay))
.await
.unwrap();
let authenticated = authenticate_with_pairing_code(
&mut paired,
&pair_args,
&client_signing,
true,
Some(&pairing_code),
)
.await
.unwrap();
assert_eq!(authenticated.device_public_key, identity.public_key);
paired
.send(Message::Binary(b"pair-over-edge".to_vec().into()))
.await
.unwrap();
assert_eq!(
paired.next().await.unwrap().unwrap().into_data(),
b"paired-over-edge".as_slice()
);
drop(paired);
assert!(connect_edge_relay(&pairing_session.relay).await.is_err());
let args = Args {
pair: false,
agent_public_key: None,
..pair_args
};
let authorized = authorize_edge_session(
args.edge_api_url.as_ref().unwrap(),
&identity.public_key,
&args,
&client_signing,
)
.await
.unwrap();
let mut relayed = connect_agent(&agent_url, fingerprint, Some(&authorized.relay))
.await
.unwrap();
let authenticated = authenticate(&mut relayed, &args, &client_signing, false)
.await
.unwrap();
assert_eq!(authenticated.device_public_key, identity.public_key);
relayed
.send(Message::Binary(b"known-over-edge".to_vec().into()))
.await
.unwrap();
assert_eq!(
relayed.next().await.unwrap().unwrap().into_data(),
b"agent-over-edge!".as_slice()
);
drop(relayed);
tokio::time::timeout(Duration::from_secs(2), echo_task)
.await
.unwrap()
.unwrap();
assert!(connect_edge_relay(&authorized.relay).await.is_err());
let status = state.edge_presence_status().unwrap().unwrap();
assert_eq!(status.signal_requests_received, 2);
assert_eq!(status.signal_requests_accepted, 2);
assert_eq!(status.signal_requests_rejected, 0);
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(6), presence_task)
.await
.unwrap()
.unwrap();
fs::remove_dir_all(root).unwrap();
}
#[test]
fn arguments_require_pin_and_accept_root() {
let args = parse_args(strings(&[
"--target",
"linux.example.test:39500",
"--user",
"alice",
"--certificate-sha256",
&"ab".repeat(32),
"--pair",
]))
.unwrap();
assert_eq!(args.user, "alice");
assert!(args.pair);
assert!(!args.probe_json);
let agent_key =
STANDARD_NO_PAD.encode(SigningKey::from_bytes(&[33; 32]).verifying_key().as_bytes());
let remote = parse_args(strings(&[
"--target",
"linux.example.test:39500",
"--user",
"alice",
"--certificate-sha256",
&"ab".repeat(32),
"--pair",
"--edge-api-url",
"https://edge.example.test/",
"--agent-public-key",
&agent_key,
]))
.unwrap();
assert_eq!(remote.agent_public_key.as_deref(), Some(agent_key.as_str()));
let root = parse_args(strings(&[
"--target",
"host",
"--user",
"root",
"--certificate-sha256",
&"ab".repeat(32),
]))
.unwrap();
assert_eq!(root.user, "root");
}
#[test]
fn probe_arguments_require_only_target_and_certificate_pin() {
let args = parse_args(strings(&[
"--probe-json",
"--target",
"linux.example.test:39500",
"--certificate-sha256",
&"ab".repeat(32),
]))
.unwrap();
assert!(args.probe_json);
assert!(args.user.is_empty());
assert!(args.desktop.is_none());
assert!(args.transfer.is_none());
assert!(
parse_args(strings(&[
"--probe-json",
"--target",
"host",
"--user",
"alice",
"--certificate-sha256",
&"ab".repeat(32),
]))
.is_err()
);
assert!(
parse_args(strings(&[
"--probe-json",
"--target",
"host",
"--certificate-sha256",
&"ab".repeat(32),
"--edge-api-url",
"https://edge.example.test/",
]))
.is_err()
);
}
#[test]
fn terminal_targets_default_to_agent_port() {
assert_eq!(
terminal_url("linux.example.test").unwrap().port(),
Some(39_500)
);
assert_eq!(
terminal_url("linux.example.test:40000").unwrap().port(),
Some(40_000)
);
assert!(terminal_url("user:secret@host").is_err());
}
#[test]
fn desktop_arguments_are_bounded_and_create_a_desktop_edge_intent() {
let args = parse_args(strings(&[
"--target",
"linux.example.test",
"--user",
"alice",
"--certificate-sha256",
&"ab".repeat(32),
"--desktop",
"--width",
"2560",
"--height",
"1440",
"--fps",
"20",
"--fullscreen",
"--follow-window",
"--capture-input",
"--clipboard-read",
"--clipboard-write",
"--session-id",
"1234-5678-9",
]))
.unwrap();
assert_eq!(
args.desktop,
Some(DesktopOptions {
width: 2560,
height: 1440,
frames_per_second: 20,
fullscreen: true,
follow_window: true,
capture_input: true,
clipboard_read: true,
clipboard_write: true,
})
);
assert_eq!(args.session_id.as_deref(), Some("1234-5678-9"));
assert!(
parse_args(strings(&[
"--target",
"linux.example.test",
"--user",
"alice",
"--certificate-sha256",
&"ab".repeat(32),
"--clipboard-read",
]))
.is_err()
);
let agent = SigningKey::from_bytes(&[44; 32]);
let agent_public_key = STANDARD_NO_PAD.encode(agent.verifying_key().as_bytes());
let client = SigningKey::from_bytes(&[45; 32]);
let intent = build_edge_intent(&agent_public_key, &args, &client).unwrap();
assert_eq!(intent.session_type, EdgeSessionType::Desktop);
assert_eq!(intent.body["requested_permissions"][0], "desktop");
assert!(
parse_args(strings(&[
"--target",
"host",
"--user",
"alice",
"--certificate-sha256",
&"ab".repeat(32),
"--desktop",
"--fps",
"31",
]))
.is_err()
);
}
#[test]
fn edge_api_requires_secret_free_https_origin_or_loopback() {
assert!(parse_edge_api_url("https://edge.example.test/").is_ok());
assert!(parse_edge_api_url("http://127.0.0.1:7080/").is_ok());
assert!(parse_edge_api_url("http://edge.example.test/").is_err());
assert!(parse_edge_api_url("https://user:secret@edge.example.test/").is_err());
assert!(parse_edge_api_url("https://edge.example.test/path").is_err());
assert!(parse_edge_api_url("https://edge.example.test/?token=secret").is_err());
let args = parse_args(strings(&[
"--target",
"linux.example.test",
"--user",
"alice",
"--certificate-sha256",
&"ab".repeat(32),
"--edge-api-url",
"https://edge.example.test/",
]))
.unwrap();
assert_eq!(
args.edge_api_url.unwrap().as_str(),
"https://edge.example.test/"
);
}
#[test]
fn stored_agent_public_key_is_canonical() {
let signing = SigningKey::from_bytes(&[21; 32]);
let public_key = STANDARD_NO_PAD.encode(signing.verifying_key().as_bytes());
assert!(validate_agent_public_key(&public_key).is_ok());
assert!(validate_agent_public_key("not-a-public-key").is_err());
}
#[test]
fn direct_webrtc_requires_protocol_minor_thirteen() {
assert!(!supports_direct_webrtc(12));
assert!(supports_direct_webrtc(13));
assert!(supports_direct_webrtc(14));
}
#[test]
fn opus_audio_requires_protocol_minor_fourteen() {
assert!(!supports_opus_audio(13));
assert!(supports_opus_audio(14));
assert!(supports_opus_audio(15));
}
#[test]
fn clipboard_requires_protocol_minor_fifteen_and_directional_grants() {
let mut agent = AuthenticatedAgent {
device_public_key: "key".into(),
edge_signaling_online: false,
protocol_minor: 15,
permissions: vec!["desktop".into(), "clipboard_read".into()],
};
assert!(!supports_clipboard(14));
assert!(supports_clipboard(15));
assert!(has_permission(&agent, "clipboard_read"));
assert!(!has_permission(&agent, "clipboard_write"));
agent.permissions.push("clipboard_write".into());
assert!(has_permission(&agent, "clipboard_write"));
}
#[test]
fn transfer_identity_is_stable_and_binds_target_and_content() {
let first = upload_transfer_id("alice", "Documents/report.bin", 10, &"ab".repeat(32));
assert_eq!(first.len(), 32);
assert_eq!(
first,
upload_transfer_id("alice", "Documents/report.bin", 10, &"ab".repeat(32))
);
assert_ne!(
first,
upload_transfer_id("alice", "Documents/other.bin", 10, &"ab".repeat(32))
);
assert_ne!(
first,
upload_transfer_id("alice", "Documents/report.bin", 11, &"ab".repeat(32))
);
}
#[test]
fn download_partial_is_stable_and_hidden_beside_target() {
let target = PathBuf::from("Downloads").join("report.bin");
assert_eq!(
download_partial_path(&target).unwrap(),
PathBuf::from("Downloads").join(".report.bin.remotedesk.part")
);
}
#[test]
fn file_transfer_arguments_require_exactly_one_direction() {
let common = [
"--target",
"host",
"--user",
"alice",
"--certificate-sha256",
];
let mut upload = common.map(str::to_owned).to_vec();
upload.extend([
"ab".repeat(32),
"--upload".into(),
"C:\\file.bin".into(),
"--remote-path".into(),
"Documents/file.bin".into(),
]);
assert!(matches!(
parse_args(upload.into_iter()).unwrap().transfer,
Some(FileTransfer::Upload { .. })
));
let mut incomplete = common.map(str::to_owned).to_vec();
incomplete.extend([
"ab".repeat(32),
"--download".into(),
"Documents/file.bin".into(),
]);
assert!(parse_args(incomplete.into_iter()).is_err());
}
#[test]
fn key_mapping_preserves_control_and_navigation() {
assert_eq!(
key_bytes(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
Some(vec![3])
);
assert_eq!(
key_bytes(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
Some(b"\x1b[A".to_vec())
);
}
}
mod desktop_viewer;
#[cfg(windows)]
mod native_audio;
#[cfg(windows)]
mod native_clipboard;
#[cfg(windows)]
mod native_stream;