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
2489 lines
89 KiB
Rust
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;
|