diff --git a/.drone.yml b/.drone.yml index 11ea37f..edc995b 100644 --- a/.drone.yml +++ b/.drone.yml @@ -39,6 +39,79 @@ steps: xargs -r sha256sum) > artifacts/DRONE-SHA256SUMS.txt - test -s artifacts/DRONE-SHA256SUMS.txt +--- +kind: pipeline +type: docker +name: android-package + +platform: + os: linux + arch: amd64 + +trigger: + event: + - push + - pull_request + - tag + +steps: + - name: test + image: ghcr.io/cirruslabs/flutter:3.41.9 + commands: + - cd client/flutter_android + - flutter pub get --enforce-lockfile + - dart format --output=none --set-exit-if-changed lib test + - flutter analyze + - flutter test + + - name: package-test + image: ghcr.io/cirruslabs/flutter:3.41.9 + commands: + - cd client/flutter_android + - flutter pub get --enforce-lockfile + - cd ../.. + - rm -rf artifacts + - mkdir -p artifacts + - sh ./packaging/android/package.sh + - test "$(find artifacts -maxdepth 1 -type f -name 'RemoteDesk-Android-*.apk' | wc -l)" -eq 1 + - >- + (cd artifacts && find . -maxdepth 1 -type f + ! -name 'DRONE-ANDROID-SHA256SUMS.txt' -print | sort | + xargs -r sha256sum) > artifacts/DRONE-ANDROID-SHA256SUMS.txt + - test -s artifacts/DRONE-ANDROID-SHA256SUMS.txt + when: + event: + - push + - pull_request + + - name: package-release + image: ghcr.io/cirruslabs/flutter:3.41.9 + environment: + ANDROID_KEYSTORE_BASE64: + from_secret: android_keystore_base64 + ANDROID_KEYSTORE_PASSWORD: + from_secret: android_keystore_password + ANDROID_KEY_ALIAS: + from_secret: android_key_alias + ANDROID_KEY_PASSWORD: + from_secret: android_key_password + commands: + - cd client/flutter_android + - flutter pub get --enforce-lockfile + - cd ../.. + - rm -rf artifacts + - mkdir -p artifacts + - sh ./packaging/android/package.sh + - test "$(find artifacts -maxdepth 1 -type f -name 'RemoteDesk-Android-*.apk' | wc -l)" -eq 1 + - >- + (cd artifacts && find . -maxdepth 1 -type f + ! -name 'DRONE-ANDROID-SHA256SUMS.txt' -print | sort | + xargs -r sha256sum) > artifacts/DRONE-ANDROID-SHA256SUMS.txt + - test -s artifacts/DRONE-ANDROID-SHA256SUMS.txt + when: + event: + - tag + --- kind: pipeline type: exec @@ -58,7 +131,8 @@ steps: - name: test commands: - powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; cargo fmt --all -- --check" - - powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; cargo test --workspace --locked" + - powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; cargo test --workspace --locked --exclude remotedesk-windows-agent --exclude remotedesk-windows-agent-viewer" + - powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; cargo test --locked -p remotedesk-windows-agent -p remotedesk-windows-agent-viewer" - name: package commands: diff --git a/Cargo.lock b/Cargo.lock index 5b12201..da4fc3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4300,11 +4300,13 @@ dependencies = [ "base64", "bytes", "clap", + "data-encoding", "ed25519-dalek", "flate2", "futures-util", "gstreamer", "gstreamer-app", + "hmac 0.12.1", "portable-pty", "rand 0.9.5", "rcgen", @@ -4320,7 +4322,9 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "sha1 0.10.7", "sha2 0.10.9", + "subtle", "tiny_http", "tokio", "tokio-rustls", @@ -4395,6 +4399,8 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "tiny_http", + "tokio", + "url", "zeroize", ] diff --git a/README.md b/README.md index 02bc11e..89ba777 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ RemoteDesk/ - [安全设计](docs/security.md) - [差网络自适应](docs/network-adaptation.md) - [CDN 与边缘中继](docs/edge-relay.md) +- [中转服务器部署](packaging/edge/README.md) - [客户端 GPU 加速](docs/gpu-acceleration.md) - [在线升级](docs/online-updates.md) - [M0 技术验证](docs/m0-validation.md) diff --git a/agent/agent-runtime/Cargo.toml b/agent/agent-runtime/Cargo.toml index 4a44e5e..722e70d 100644 --- a/agent/agent-runtime/Cargo.toml +++ b/agent/agent-runtime/Cargo.toml @@ -35,9 +35,11 @@ async-trait = "0.1" base64 = "0.22" bytes = "1.12" clap = { version = "4.5", features = ["derive"] } +data-encoding = "2.9" ed25519-dalek = "2.2" flate2 = "1.1" futures-util = "0.3" +hmac = "0.12" rand = "0.9" rcgen = "0.14" remotedesk-protocol = { path = "../../protocol" } @@ -50,6 +52,8 @@ rustls-pemfile = "2.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" +sha1 = "0.10" +subtle = "2.6" tokio = { version = "1.47", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] } url = "2.5" diff --git a/agent/agent-runtime/src/bin/remotedesk-agentd.rs b/agent/agent-runtime/src/bin/remotedesk-agentd.rs index fc70468..fbb3557 100644 --- a/agent/agent-runtime/src/bin/remotedesk-agentd.rs +++ b/agent/agent-runtime/src/bin/remotedesk-agentd.rs @@ -30,9 +30,9 @@ mod linux { DesktopMediaEvent, DesktopMediaSession, DeviceIdentity, EdgePresenceClient, EdgePresenceConfig, EdgeWebRtcRole, EncodedMediaSender, FILE_CHUNK_BYTES, FileResult, FileTransferReady, IpcMessage, IpcResponse, MAX_FILE_BYTES, PROTOCOL_MAJOR, PROTOCOL_MINOR, - ServerEvent, ShellCommand, ShellEvent, WebRtcSessionConfig, WebRtcSessionError, - WebRtcSignalMessage, WebRtcSignalTransport, edge_device_id, establish_webrtc, - unix_timestamp, verify_auth_signature, + ServerEvent, ShellCommand, ShellEvent, TotpVerifier, WebRtcSessionConfig, + WebRtcSessionError, WebRtcSignalMessage, WebRtcSignalTransport, edge_device_id, + establish_webrtc, unix_timestamp, verify_auth_signature, }; use remotedesk_protocol::EdgeNegotiationKind; use rustls::ServerConfig; @@ -169,6 +169,10 @@ mod linux { #[derive(Debug, Subcommand)] enum AgentCommand { Init, + TotpSetup { + #[arg(long)] + force: bool, + }, Serve { #[arg(long, default_value = "0.0.0.0:39500")] listen: SocketAddr, @@ -209,10 +213,24 @@ mod linux { let identity = state.ensure_identity(&hostname())?; println!("device_public_key={}", identity.public_key); } + AgentCommand::TotpSetup { force } => { + state.ensure_identity(&hostname())?; + let config = state.configure_totp(force)?; + let account = hostname(); + println!("totp_secret={}", config.secret_base32); + println!( + "otpauth_uri=otpauth://totp/RemoteDesk:{account}?secret={}&issuer=RemoteDesk&algorithm=SHA1&digits=6&period=30", + config.secret_base32 + ); + } AgentCommand::Serve { listen, ipc_socket } => { let identity = state.ensure_identity(&hostname())?; + let totp_config = state.totp_config()?.ok_or( + "TOTP is mandatory; run `remotedesk-agentd totp-setup` before starting the service", + )?; + let totp = Arc::new(TotpVerifier::new(&totp_config.secret_base32)?); let edge_presence = EdgePresenceConfig::from_environment()?; - serve(state, identity, listen, ipc_socket, edge_presence).await?; + serve(state, identity, listen, ipc_socket, edge_presence, totp).await?; } AgentCommand::Status { json } => { let identity = state.ensure_identity(&hostname())?; @@ -235,6 +253,7 @@ mod linux { "desktop": false, "protocol_major": PROTOCOL_MAJOR, "protocol_minor": PROTOCOL_MINOR, + "totp_required": true, "edge_presence": { "configured": edge_config.is_some(), "online": edge_online, @@ -285,6 +304,11 @@ mod linux { allowed_users, } => { state.ensure_identity(&hostname())?; + if state.totp_config()?.is_none() { + return Err( + "TOTP is mandatory; run `remotedesk-agentd totp-setup` first".into(), + ); + } validate_pairing_scope(&permissions, &allowed_users)?; let code = state.create_pairing_grant(ttl, permissions, allowed_users)?; println!("pairing_code={}", code.expose()); @@ -354,6 +378,7 @@ mod linux { listen: SocketAddr, ipc_socket: PathBuf, edge_presence: Option, + totp: Arc, ) -> Result<(), Error> { let tls = TlsAcceptor::from(Arc::new(tls_config(&identity)?)); let listener = TcpListener::bind(listen).await?; @@ -416,6 +441,7 @@ mod linux { let session_registry = Arc::clone(&session_registry); let desktop_media_registry = Arc::clone(&desktop_media_registry); let edge_online = Arc::clone(&edge_online); + let totp = Arc::clone(&totp); tokio::spawn(async move { let result = async { let tls_stream = tls.accept(stream).await?; @@ -433,6 +459,8 @@ mod linux { desktop_media_registry, edge_configured, edge_online, + totp, + peer, ) .await } @@ -500,6 +528,8 @@ mod linux { desktop_media_registry: DesktopMediaRegistry, edge_configured: bool, edge_online: Arc, + totp: Arc, + peer: SocketAddr, ) -> Result<(), Error> { let desktop_available = !session_registry.lock().await.is_empty(); let client = authenticate_client( @@ -509,6 +539,8 @@ mod linux { edge_configured, &edge_online, desktop_available, + &totp, + peer, ) .await?; @@ -646,6 +678,8 @@ mod linux { edge_configured: bool, edge_online: &AtomicBool, desktop_available: bool, + totp: &TotpVerifier, + peer: SocketAddr, ) -> Result { let challenge = AuthChallenge::generate(); let tls_certificate_sha256 = identity.tls_certificate_fingerprint()?; @@ -680,6 +714,7 @@ mod linux { } => { require_protocol(protocol_major)?; verify_auth_signature(&client_public_key, &signature, &challenge)?; + require_totp(socket, totp, peer, &client_public_key).await?; let state = state.lock().await; let pairing = state.consume_pairing_grant(&code)?; let client = state.grant_client( @@ -706,6 +741,7 @@ mod linux { } => { require_protocol(protocol_major)?; verify_auth_signature(&client_public_key, &signature, &challenge)?; + require_totp(socket, totp, peer, &client_public_key).await?; let client = state .lock() .await @@ -728,6 +764,54 @@ mod linux { Ok(client) } + async fn require_totp( + socket: &mut AgentSocket, + verifier: &TotpVerifier, + peer: SocketAddr, + client_public_key: &str, + ) -> Result<(), Error> { + send_event( + socket, + &ServerEvent::TotpRequired { + digits: 6, + period_seconds: 30, + }, + ) + .await?; + let command = timeout(Duration::from_secs(30), receive_command(socket)).await??; + let ClientCommand::VerifyTotp { code } = command else { + send_event( + socket, + &ServerEvent::Error { + code: "totp_required".into(), + message: "a TOTP verification code is required".into(), + }, + ) + .await?; + return Err("client did not provide the mandatory TOTP code".into()); + }; + if verifier + .verify( + client_public_key, + &peer.ip().to_string(), + &code, + unix_timestamp(), + ) + .is_err() + { + send_event( + socket, + &ServerEvent::Error { + code: "totp_invalid".into(), + message: "TOTP verification failed".into(), + }, + ) + .await?; + return Err("TOTP verification failed".into()); + } + Ok(()) + } + fn authorize_terminal( client: &ClientGrant, user: &str, diff --git a/agent/agent-runtime/src/edge_presence.rs b/agent/agent-runtime/src/edge_presence.rs index fb3957f..040440d 100644 --- a/agent/agent-runtime/src/edge_presence.rs +++ b/agent/agent-runtime/src/edge_presence.rs @@ -2,8 +2,8 @@ use base64::{Engine as _, engine::general_purpose}; use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey}; use rand::RngCore as _; use remotedesk_protocol::{ - AgentSignalAction, AgentSignalProofV1, EdgeNegotiationEndpoint, EdgeSessionIntentV1, - EdgeSessionType, SIGNAL_NONCE_LENGTH, + AgentPresenceAction, AgentPresenceProofV1, AgentSignalAction, AgentSignalProofV1, + EdgeNegotiationEndpoint, EdgeSessionIntentV1, EdgeSessionType, SIGNAL_NONCE_LENGTH, }; use reqwest::{Client, StatusCode, redirect::Policy}; use serde::{Deserialize, Serialize}; @@ -177,16 +177,35 @@ impl EdgePresenceClient { &self, device_id: &str, connection_id: &str, + signing: &SigningKey, + device_public_key: &str, ) -> Result { validate_identifier(device_id, 128)?; validate_identifier(connection_id, 128)?; + if edge_device_id(device_public_key)? != device_id { + return Err(EdgePresenceError::InvalidDeviceKey); + } let expires_unix = unix_timestamp().saturating_add(PRESENCE_TTL_SECONDS); + let proof = signed_presence_proof( + device_id, + device_public_key, + &self.config.region, + &self.config.gateway_id, + connection_id, + AgentPresenceAction::Register, + Some(expires_unix), + signing, + )?; let request = PresenceRequest { device_id, + device_public_key, region: &self.config.region, gateway_id: &self.config.gateway_id, connection_id, expires_unix, + nonce: &proof.nonce, + issued_unix: proof.issued_unix, + signature: &proof.signature, }; let response = self .http @@ -222,14 +241,37 @@ impl EdgePresenceClient { &self, device_id: &str, connection_id: &str, + signing: &SigningKey, + device_public_key: &str, ) -> Result<(), EdgePresenceError> { + validate_identifier(device_id, 128)?; + validate_identifier(connection_id, 128)?; + if edge_device_id(device_public_key)? != device_id { + return Err(EdgePresenceError::InvalidDeviceKey); + } + let proof = signed_presence_proof( + device_id, + device_public_key, + &self.config.region, + &self.config.gateway_id, + connection_id, + AgentPresenceAction::Unregister, + None, + signing, + )?; let response = self .http .delete(self.config.presence_url()) .bearer_auth(self.config.token.as_str()) .json(&UnregisterRequest { device_id, + device_public_key, + region: &self.config.region, + gateway_id: &self.config.gateway_id, connection_id, + nonce: &proof.nonce, + issued_unix: proof.issued_unix, + signature: &proof.signature, }) .send() .await @@ -573,7 +615,19 @@ impl EdgePresenceClient { persist_status(&state, &status); let mut backoff = RetryBackoff::default(); 'running: while !*shutdown.borrow() { - let wait = match self.register(&device_id, &connection_id).await { + let registration = match signal_identity.as_ref() { + Some(identity) => { + self.register( + &device_id, + &connection_id, + &identity.signing, + &identity.identity.public_key, + ) + .await + } + None => Err(EdgePresenceError::InvalidDeviceKey), + }; + let wait = match registration { Ok(expires_unix) => { let now = unix_timestamp(); status.online = true; @@ -621,15 +675,22 @@ impl EdgePresenceClient { } } - match timeout( - Duration::from_secs(5), - self.unregister(&device_id, &connection_id), - ) - .await - { - Err(_) => status.last_error = Some("unregister_timeout".into()), - Ok(Err(error)) => status.last_error = Some(error.category()), - Ok(Ok(())) => {} + if let Some(identity) = signal_identity.as_ref() { + match timeout( + Duration::from_secs(5), + self.unregister( + &device_id, + &connection_id, + &identity.signing, + &identity.identity.public_key, + ), + ) + .await + { + Err(_) => status.last_error = Some("unregister_timeout".into()), + Ok(Err(error)) => status.last_error = Some(error.category()), + Ok(Ok(())) => {} + } } status.online = false; online.store(false, Ordering::Release); @@ -641,10 +702,14 @@ impl EdgePresenceClient { #[derive(Serialize)] struct PresenceRequest<'a> { device_id: &'a str, + device_public_key: &'a str, region: &'a str, gateway_id: &'a str, connection_id: &'a str, expires_unix: u64, + nonce: &'a str, + issued_unix: u64, + signature: &'a str, } #[derive(Deserialize)] @@ -660,7 +725,13 @@ struct PresenceResponse { #[derive(Serialize)] struct UnregisterRequest<'a> { device_id: &'a str, + device_public_key: &'a str, + region: &'a str, + gateway_id: &'a str, connection_id: &'a str, + nonce: &'a str, + issued_unix: u64, + signature: &'a str, } #[derive(Serialize)] @@ -919,6 +990,41 @@ fn device_signing_key(identity: &DeviceIdentity) -> Result, + signing: &SigningKey, +) -> Result { + let mut nonce = [0_u8; SIGNAL_NONCE_LENGTH]; + rand::rng().fill_bytes(&mut nonce); + let issued_unix = unix_timestamp(); + let proof = AgentPresenceProofV1 { + device_id: device_id.into(), + device_public_key: device_public_key.into(), + region: region.into(), + gateway_id: gateway_id.into(), + connection_id: connection_id.into(), + action, + presence_expires_unix, + nonce, + issued_unix, + }; + let payload = proof + .stable_signing_input() + .map_err(|_| EdgePresenceError::InvalidDeviceKey)?; + Ok(SignedAgentProof { + nonce: general_purpose::STANDARD_NO_PAD.encode(nonce), + issued_unix, + signature: general_purpose::STANDARD_NO_PAD.encode(signing.sign(&payload).to_bytes()), + }) +} + fn signed_agent_proof( device_id: &str, device_public_key: &str, @@ -1456,11 +1562,20 @@ mod tests { async fn client_registers_and_connection_bound_unregisters_against_edge_service() { let edge = TestEdge::start(); let client = EdgePresenceClient::new(edge.config()).unwrap(); - let device_id = "ab".repeat(32); - let expiry = client.register(&device_id, "connection-1").await.unwrap(); + let signing = SigningKey::from_bytes(&[21; 32]); + let public_key = + general_purpose::STANDARD_NO_PAD.encode(signing.verifying_key().as_bytes()); + let device_id = edge_device_id(&public_key).unwrap(); + let expiry = client + .register(&device_id, "connection-1", &signing, &public_key) + .await + .unwrap(); assert!(expiry > unix_timestamp()); assert_eq!(edge.lookup_status(&device_id), 200); - client.unregister(&device_id, "connection-1").await.unwrap(); + client + .unregister(&device_id, "connection-1", &signing, &public_key) + .await + .unwrap(); assert_eq!(edge.lookup_status(&device_id), 404); } @@ -1468,9 +1583,10 @@ mod tests { async fn heartbeat_task_persists_online_state_and_unregisters_on_shutdown() { let edge = TestEdge::start(); let client = EdgePresenceClient::new(edge.config()).unwrap(); - let device_id = "cd".repeat(32); let root = test_root("edge-presence-lifecycle"); let state = AgentState::new(&root); + let identity = state.ensure_identity("test-host").unwrap(); + let device_id = edge_device_id(&identity.public_key).unwrap(); let online = Arc::new(AtomicBool::new(false)); let (shutdown_tx, shutdown_rx) = watch::channel(false); let task = { diff --git a/agent/agent-runtime/src/lib.rs b/agent/agent-runtime/src/lib.rs index db9d391..6e71c1f 100644 --- a/agent/agent-runtime/src/lib.rs +++ b/agent/agent-runtime/src/lib.rs @@ -15,6 +15,7 @@ mod pairing; mod protocol; mod quality; mod state; +mod totp; #[cfg(all(target_os = "linux", feature = "wayland-eis"))] mod wayland_eis; #[cfg(all(target_os = "linux", not(feature = "wayland-eis")))] @@ -104,8 +105,9 @@ pub use protocol::{ pub use quality::{QualityController, QualityLadderEntry, QualitySample, QualityState}; pub use state::{ AgentState, ClientGrant, DeviceIdentity, EdgePresenceStatus, PairingGrant, StateError, - unix_timestamp, + TotpConfig, unix_timestamp, }; +pub use totp::{TotpError, TotpVerifier}; #[cfg(target_os = "linux")] pub use wayland_eis::{WaylandEisError, WaylandEisInput}; #[cfg(target_os = "linux")] diff --git a/agent/agent-runtime/src/protocol.rs b/agent/agent-runtime/src/protocol.rs index 4743958..cc7e057 100644 --- a/agent/agent-runtime/src/protocol.rs +++ b/agent/agent-runtime/src/protocol.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; pub const PROTOCOL_MAJOR: u16 = 1; -pub const PROTOCOL_MINOR: u16 = 15; +pub const PROTOCOL_MINOR: u16 = 16; pub const OPUS_AUDIO_PROTOCOL_MINOR: u16 = 14; pub const CLIPBOARD_PROTOCOL_MINOR: u16 = 15; pub const MAX_RELATIVE_POINTER_DELTA: u16 = 4_096; @@ -122,6 +122,9 @@ pub enum ClientCommand { client_public_key: String, signature: String, }, + VerifyTotp { + code: String, + }, Status, OpenTerminal { user: String, @@ -250,6 +253,10 @@ pub enum ServerEvent { client_fingerprint: String, permissions: Vec, }, + TotpRequired { + digits: u8, + period_seconds: u16, + }, Status { session_agents: usize, terminal: bool, @@ -589,7 +596,7 @@ mod tests { .. } if resume_token == "wayland-rotated-token" )); - assert_eq!(PROTOCOL_MINOR, 15); + assert_eq!(PROTOCOL_MINOR, 16); let direct_begin = ClientCommand::BeginDirectWebRtc { user: "alice".into(), max_width: 1_920, diff --git a/agent/agent-runtime/src/state.rs b/agent/agent-runtime/src/state.rs index a592064..b599292 100644 --- a/agent/agent-runtime/src/state.rs +++ b/agent/agent-runtime/src/state.rs @@ -8,6 +8,7 @@ use std::{ }; use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; +use data_encoding::BASE32_NOPAD; use ed25519_dalek::SigningKey; use rand::RngCore as _; use rcgen::generate_simple_self_signed; @@ -116,6 +117,12 @@ pub struct PairingGrant { pub allowed_users: Vec, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TotpConfig { + pub schema: u8, + pub secret_base32: String, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct EdgePresenceStatus { pub schema: u8, @@ -182,6 +189,44 @@ impl AgentState { read_json(&self.root.join("device.json")) } + /// Generates and stores the mandatory TOTP secret with owner-only permissions. + /// + /// # Errors + /// + /// Returns an error if TOTP already exists without `force`, or persistence fails. + pub fn configure_totp(&self, force: bool) -> Result { + fs::create_dir_all(&self.root)?; + set_owner_only_directory(&self.root)?; + let path = self.root.join("totp.json"); + if path.is_file() && !force { + return Err(StateError::Invalid( + "TOTP is already configured; use --force to rotate it".into(), + )); + } + let mut secret = [0_u8; 20]; + rand::rng().fill_bytes(&mut secret); + let config = TotpConfig { + schema: 1, + secret_base32: BASE32_NOPAD.encode(&secret), + }; + write_json_atomic(&path, &config)?; + set_owner_only_file(&path)?; + Ok(config) + } + + /// Loads the mandatory TOTP configuration. + /// + /// # Errors + /// + /// Returns an error when the configuration exists but cannot be read. + pub fn totp_config(&self) -> Result, StateError> { + let path = self.root.join("totp.json"); + if !path.is_file() { + return Ok(None); + } + read_json(&path).map(Some) + } + /// Loads the latest non-secret Edge Presence state written by the daemon. /// /// # Errors diff --git a/agent/agent-runtime/src/totp.rs b/agent/agent-runtime/src/totp.rs new file mode 100644 index 0000000..ebbec1e --- /dev/null +++ b/agent/agent-runtime/src/totp.rs @@ -0,0 +1,205 @@ +use std::{collections::HashMap, sync::Mutex}; + +use data_encoding::BASE32_NOPAD; +use hmac::{Hmac, Mac as _}; +use sha1::Sha1; +use subtle::ConstantTimeEq as _; +use zeroize::Zeroizing; + +const DIGITS: u32 = 6; +const PERIOD_SECONDS: u64 = 30; +const MAX_FAILURES: u8 = 5; +const BLOCK_SECONDS: u64 = 300; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TotpError { + InvalidSecret, + InvalidCode, + Replayed, + RateLimited, +} + +impl std::fmt::Display for TotpError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidSecret => "TOTP secret is invalid", + Self::InvalidCode => "TOTP code is invalid", + Self::Replayed => "TOTP code was already used", + Self::RateLimited => "TOTP verification is temporarily rate limited", + }) + } +} + +impl std::error::Error for TotpError {} + +#[derive(Default)] +struct AttemptState { + failures: HashMap, + used_steps: HashMap, +} + +#[derive(Clone, Copy)] +struct FailureState { + count: u8, + blocked_until: u64, +} + +pub struct TotpVerifier { + secret: Zeroizing>, + state: Mutex, +} + +impl TotpVerifier { + /// Creates a verifier from an unpadded RFC 4648 Base32 secret. + /// + /// # Errors + /// + /// Returns an error when the secret is malformed or shorter than 160 bits. + pub fn new(secret_base32: &str) -> Result { + if secret_base32.len() < 32 + || !secret_base32 + .bytes() + .all(|byte| byte.is_ascii_uppercase() || (b'2'..=b'7').contains(&byte)) + { + return Err(TotpError::InvalidSecret); + } + let secret = BASE32_NOPAD + .decode(secret_base32.as_bytes()) + .map_err(|_| TotpError::InvalidSecret)?; + if secret.len() < 20 { + return Err(TotpError::InvalidSecret); + } + Ok(Self { + secret: Zeroizing::new(secret), + state: Mutex::new(AttemptState::default()), + }) + } + + /// Verifies one code and prevents reuse by the same authenticated client. + /// + /// `remote` is used only for failed-attempt throttling. `subject` must be the + /// already signature-verified client public key. + /// + /// # Errors + /// + /// Returns an error for invalid, replayed, or rate-limited codes. + pub fn verify( + &self, + subject: &str, + remote: &str, + code: &str, + unix_seconds: u64, + ) -> Result<(), TotpError> { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state + .failures + .get(remote) + .is_some_and(|failure| failure.blocked_until > unix_seconds) + { + return Err(TotpError::RateLimited); + } + + let current_step = unix_seconds / PERIOD_SECONDS; + let matched_step = (code.len() == DIGITS as usize + && code.bytes().all(|byte| byte.is_ascii_digit())) + .then(|| { + [ + current_step, + current_step.saturating_sub(1), + current_step.saturating_add(1), + ] + .into_iter() + .find(|step| { + let expected = self.code_for_step(*step); + bool::from(expected.as_bytes().ct_eq(code.as_bytes())) + }) + }) + .flatten(); + + let Some(step) = matched_step else { + record_failure(&mut state, remote, unix_seconds); + return Err(TotpError::InvalidCode); + }; + if state + .used_steps + .get(subject) + .is_some_and(|previous| *previous >= step) + { + record_failure(&mut state, remote, unix_seconds); + return Err(TotpError::Replayed); + } + state.failures.remove(remote); + state.used_steps.insert(subject.to_owned(), step); + state + .used_steps + .retain(|_, used| used.saturating_add(2) >= current_step); + Ok(()) + } + + fn code_for_step(&self, step: u64) -> String { + let mut mac = + Hmac::::new_from_slice(&self.secret).expect("HMAC accepts keys of any length"); + mac.update(&step.to_be_bytes()); + let digest = mac.finalize().into_bytes(); + let offset = usize::from(digest[digest.len() - 1] & 0x0f); + let binary = (u32::from(digest[offset] & 0x7f) << 24) + | (u32::from(digest[offset + 1]) << 16) + | (u32::from(digest[offset + 2]) << 8) + | u32::from(digest[offset + 3]); + format!("{:06}", binary % 10_u32.pow(DIGITS)) + } +} + +fn record_failure(state: &mut AttemptState, remote: &str, now: u64) { + let failure = state + .failures + .entry(remote.to_owned()) + .or_insert(FailureState { + count: 0, + blocked_until: 0, + }); + failure.count = failure.count.saturating_add(1); + if failure.count >= MAX_FAILURES { + failure.count = 0; + failure.blocked_until = now.saturating_add(BLOCK_SECONDS); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RFC_SECRET: &str = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + + #[test] + fn rfc_6238_sha1_vector_is_supported() { + let verifier = TotpVerifier::new(RFC_SECRET).unwrap(); + assert_eq!(verifier.code_for_step(59 / 30), "287082"); + assert!(verifier.verify("client-a", "peer-a", "287082", 59).is_ok()); + } + + #[test] + fn a_code_cannot_be_replayed_by_the_same_client() { + let verifier = TotpVerifier::new(RFC_SECRET).unwrap(); + assert!(verifier.verify("client-a", "peer-a", "287082", 59).is_ok()); + assert_eq!( + verifier.verify("client-a", "peer-a", "287082", 59), + Err(TotpError::Replayed) + ); + } + + #[test] + fn repeated_failures_are_rate_limited() { + let verifier = TotpVerifier::new(RFC_SECRET).unwrap(); + for _ in 0..5 { + assert_eq!( + verifier.verify("client-a", "peer-a", "000000", 59), + Err(TotpError::InvalidCode) + ); + } + assert_eq!( + verifier.verify("client-a", "peer-a", "287082", 59), + Err(TotpError::RateLimited) + ); + } +} diff --git a/client/flutter_android/.gitignore b/client/flutter_android/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/client/flutter_android/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/client/flutter_android/.metadata b/client/flutter_android/.metadata new file mode 100644 index 0000000..fc44c8e --- /dev/null +++ b/client/flutter_android/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: android + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/client/flutter_android/README.md b/client/flutter_android/README.md new file mode 100644 index 0000000..6cf503d --- /dev/null +++ b/client/flutter_android/README.md @@ -0,0 +1,15 @@ +# RemoteDesk Android Controller + +The Android controller is a standalone Flutter application. It supports direct Agent connections, explicit Edge relay connections, and automatic network-only fallback from direct to Edge. + +## Local verification + +```sh +flutter pub get --enforce-lockfile +dart format --output=none --set-exit-if-changed lib test +flutter analyze +flutter test +flutter build apk --release +``` + +Without release-signing environment variables, Gradle uses the Android debug certificate so local release-mode builds remain installable for testing. Production tag builds must run through the Drone signing flow documented in `docs/ci-drone.md`. diff --git a/client/flutter_android/analysis_options.yaml b/client/flutter_android/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/client/flutter_android/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/client/flutter_android/android/.gitignore b/client/flutter_android/android/.gitignore new file mode 100644 index 0000000..73b6f93 --- /dev/null +++ b/client/flutter_android/android/.gitignore @@ -0,0 +1,15 @@ +gradle-wrapper.jar +/.gradle +/build/ +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/client/flutter_android/android/app/build.gradle.kts b/client/flutter_android/android/app/build.gradle.kts new file mode 100644 index 0000000..2c432b0 --- /dev/null +++ b/client/flutter_android/android/app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val releaseSigningEnvironment = mapOf( + "storeFile" to System.getenv("REMOTEDESK_ANDROID_KEYSTORE_PATH"), + "storePassword" to System.getenv("REMOTEDESK_ANDROID_KEYSTORE_PASSWORD"), + "keyAlias" to System.getenv("REMOTEDESK_ANDROID_KEY_ALIAS"), + "keyPassword" to System.getenv("REMOTEDESK_ANDROID_KEY_PASSWORD"), +) +val hasAnyReleaseSigningValue = releaseSigningEnvironment.values.any { !it.isNullOrBlank() } +val hasCompleteReleaseSigning = releaseSigningEnvironment.values.all { !it.isNullOrBlank() } +if (hasAnyReleaseSigningValue && !hasCompleteReleaseSigning) { + throw GradleException("RemoteDesk Android release signing environment is incomplete") +} + +android { + namespace = "com.remotedesk.remotedesk_android" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + applicationId = "com.remotedesk.remotedesk_android" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + val releaseSigning = if (hasCompleteReleaseSigning) { + signingConfigs.create("release") { + storeFile = file(releaseSigningEnvironment.getValue("storeFile")!!) + storePassword = releaseSigningEnvironment.getValue("storePassword") + keyAlias = releaseSigningEnvironment.getValue("keyAlias") + keyPassword = releaseSigningEnvironment.getValue("keyPassword") + } + } else { + null + } + + buildTypes { + release { + // Local and non-tag CI builds stay installable without production credentials. + signingConfig = releaseSigning ?: signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/client/flutter_android/android/app/src/debug/AndroidManifest.xml b/client/flutter_android/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/client/flutter_android/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/client/flutter_android/android/app/src/main/AndroidManifest.xml b/client/flutter_android/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8573fa4 --- /dev/null +++ b/client/flutter_android/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/flutter_android/android/app/src/main/kotlin/com/remotedesk/remotedesk_android/MainActivity.kt b/client/flutter_android/android/app/src/main/kotlin/com/remotedesk/remotedesk_android/MainActivity.kt new file mode 100644 index 0000000..bf5c80f --- /dev/null +++ b/client/flutter_android/android/app/src/main/kotlin/com/remotedesk/remotedesk_android/MainActivity.kt @@ -0,0 +1,149 @@ +package com.remotedesk.remotedesk_android + +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import org.json.JSONObject +import java.io.Closeable +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.URI +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory + +class MainActivity : FlutterActivity() { + private val executor = Executors.newCachedThreadPool() + private val tunnels = ConcurrentHashMap() + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.remotedesk/tunnel") + .setMethodCallHandler { call, result -> + when (call.method) { + "startRelay" -> { + val address = call.argument("relayAddress") + val ticket = call.argument("ticket") + if (address.isNullOrBlank() || ticket.isNullOrBlank()) { + result.error("invalid_relay", "Relay access is invalid", null) + return@setMethodCallHandler + } + executor.execute { + try { + val tunnel = RelayTunnel.open(address, ticket, executor) + tunnels[tunnel.id] = tunnel + runOnUiThread { + result.success(mapOf("id" to tunnel.id, "port" to tunnel.port)) + } + } catch (_: Exception) { + runOnUiThread { + result.error("relay_failed", "Relay connection failed", null) + } + } + } + } + "stopRelay" -> { + call.argument("id")?.let { tunnels.remove(it)?.close() } + result.success(null) + } + else -> result.notImplemented() + } + } + } + + override fun onDestroy() { + tunnels.values.forEach { it.close() } + tunnels.clear() + executor.shutdownNow() + super.onDestroy() + } +} + +private class RelayTunnel private constructor( + val id: String, + private val listener: ServerSocket, + private val relay: Socket, + executor: java.util.concurrent.ExecutorService, +) : Closeable { + val port: Int = listener.localPort + + init { + executor.execute { + try { + val local = listener.accept().apply { tcpNoDelay = true } + listener.close() + executor.execute { copy(local, relay) } + executor.execute { copy(relay, local) } + } catch (_: Exception) { + close() + } + } + } + + override fun close() { + runCatching { listener.close() } + runCatching { relay.close() } + } + + companion object { + fun open( + relayAddress: String, + ticket: String, + executor: java.util.concurrent.ExecutorService, + ): RelayTunnel { + require(relayAddress.length <= 512 && ticket.length <= 8192) + val uri = URI("relay://$relayAddress/") + val host = requireNotNull(uri.host) + require(uri.port in 1..65535 && uri.userInfo == null) + val address = InetAddress.getByName(host) + val socket = if (address.isLoopbackAddress) { + Socket(host, uri.port) + } else { + (SSLSocketFactory.getDefault().createSocket(host, uri.port) as SSLSocket).apply { + sslParameters = sslParameters.apply { endpointIdentificationAlgorithm = "HTTPS" } + startHandshake() + } + }.apply { tcpNoDelay = true } + socket.soTimeout = 10_000 + val hello = JSONObject().put("ticket", ticket).toString() + "\n" + socket.getOutputStream().apply { + write(hello.toByteArray(Charsets.UTF_8)) + flush() + } + val response = ArrayList(8) + while (response.size < 16) { + val byte = socket.getInputStream().read() + if (byte < 0) break + response.add(byte.toByte()) + if (byte == '\n'.code) break + } + require(response.toByteArray().toString(Charsets.US_ASCII) == "READY\n") + socket.soTimeout = 0 + val listener = ServerSocket(0, 1, InetAddress.getLoopbackAddress()) + return RelayTunnel(UUID.randomUUID().toString(), listener, socket, executor) + } + + private fun copy(source: Socket, destination: Socket) { + try { + source.getInputStream().use { input -> + val output = destination.getOutputStream() + val buffer = ByteArray(32 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + output.flush() + } + } + } catch (_: Exception) { + // Closing either direction tears down the complete tunnel. + } finally { + runCatching { source.close() } + runCatching { destination.close() } + } + } + } +} diff --git a/client/flutter_android/android/app/src/main/res/drawable-v21/launch_background.xml b/client/flutter_android/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/client/flutter_android/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/client/flutter_android/android/app/src/main/res/drawable/launch_background.xml b/client/flutter_android/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/client/flutter_android/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/client/flutter_android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/client/flutter_android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/client/flutter_android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/client/flutter_android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/client/flutter_android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/client/flutter_android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/client/flutter_android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/client/flutter_android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/client/flutter_android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/client/flutter_android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/client/flutter_android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/client/flutter_android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/client/flutter_android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/client/flutter_android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/client/flutter_android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/client/flutter_android/android/app/src/main/res/values-night/styles.xml b/client/flutter_android/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/client/flutter_android/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/client/flutter_android/android/app/src/main/res/values/styles.xml b/client/flutter_android/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/client/flutter_android/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/client/flutter_android/android/app/src/profile/AndroidManifest.xml b/client/flutter_android/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/client/flutter_android/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/client/flutter_android/android/build.gradle.kts b/client/flutter_android/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/client/flutter_android/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/client/flutter_android/android/gradle.properties b/client/flutter_android/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/client/flutter_android/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/client/flutter_android/android/gradle/wrapper/gradle-wrapper.properties b/client/flutter_android/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/client/flutter_android/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/client/flutter_android/android/settings.gradle.kts b/client/flutter_android/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/client/flutter_android/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/client/flutter_android/lib/agent_client.dart b/client/flutter_android/lib/agent_client.dart new file mode 100644 index 0000000..3c79bc4 --- /dev/null +++ b/client/flutter_android/lib/agent_client.dart @@ -0,0 +1,621 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' as hashes; +import 'package:web_socket_channel/io.dart'; + +import 'edge_client.dart'; +import 'identity_store.dart'; +import 'models.dart'; + +const protocolMajor = 1; +const protocolMinor = 16; +const _maxDimension = 8192; +const _maxPixels = 33177600; +const _maxCompressedBytes = 64 * 1024 * 1024; + +enum AgentPhase { + connecting, + switchingToRelay, + authenticating, + openingDesktop, + waitingForFrame, + connected, + disconnected, + failed, +} + +enum AgentError { + network, + certificate, + protocol, + authentication, + totp, + permission, + desktop, + frame, + unknown, +} + +class AgentState { + const AgentState(this.phase, [this.error]); + + final AgentPhase phase; + final AgentError? error; +} + +class RemoteFrame { + const RemoteFrame({ + required this.sequence, + required this.width, + required this.height, + required this.bgra, + }); + + final int sequence; + final int width; + final int height; + final Uint8List bgra; +} + +class AgentException implements Exception { + const AgentException(this.error); + + final AgentError error; +} + +class AgentClient { + AgentClient({ + required this.host, + required this.settings, + IdentityStore? identityStore, + }) : _identityStore = identityStore ?? IdentityStore(); + + final RemoteHost host; + final AppSettings settings; + final IdentityStore _identityStore; + final StreamController _states = + StreamController.broadcast(sync: true); + final StreamController _frames = + StreamController.broadcast(sync: true); + + IOWebSocketChannel? _channel; + StreamIterator? _messages; + HttpClient? _httpClient; + _FrameAssembly? _assembly; + bool _closed = false; + bool _certificateRejected = false; + bool _usingRelay = false; + bool _desktopOpen = false; + RelayTunnel? _relayTunnel; + + Stream get states => _states.stream; + Stream get frames => _frames.stream; + + Future connect({String? pairingCode, required String totpCode}) async { + _closed = false; + _emit(AgentPhase.connecting); + try { + final expectedFingerprint = normalizeFingerprint(host.certificateSha256); + if (!isValidFingerprint(expectedFingerprint)) { + throw const AgentException(AgentError.certificate); + } + + final identity = await _identityStore.loadOrCreate(); + late Map hello; + if (host.connectionMode == ConnectionMode.relay) { + final relayUri = await _openRelay(identity, pairingCode != null); + hello = await _openTransport( + relayUri, + expectedFingerprint, + usingRelay: true, + ); + } else { + try { + hello = await _openTransport( + host.uri, + expectedFingerprint, + usingRelay: false, + ); + } on AgentException catch (error) { + if (host.connectionMode != ConnectionMode.automatic || + error.error != AgentError.network) { + rethrow; + } + _emit(AgentPhase.switchingToRelay); + final relayUri = await _openRelay(identity, pairingCode != null); + hello = await _openTransport( + relayUri, + expectedFingerprint, + usingRelay: true, + ); + } + } + _emit(AgentPhase.authenticating); + + final challenge = _decodeUnpaddedBase64(_string(hello, 'challenge')); + if (challenge.length != 32) { + throw const AgentException(AgentError.protocol); + } + final payload = [ + ...utf8.encode('remotedesk-agent-auth-v1\u0000'), + ...challenge, + ]; + final signature = await _identityStore.sign(identity, payload); + final publicKey = _encodeUnpaddedBase64(identity.publicKey.bytes); + final command = pairingCode == null + ? { + 'type': 'authenticate', + 'protocol_major': protocolMajor, + 'protocol_minor': protocolMinor, + 'client_public_key': publicKey, + 'signature': _encodeUnpaddedBase64(signature), + } + : { + 'type': 'pair', + 'protocol_major': protocolMajor, + 'protocol_minor': protocolMinor, + 'client_name': 'RemoteDesk Android', + 'client_public_key': publicKey, + 'signature': _encodeUnpaddedBase64(signature), + 'code': pairingCode, + }; + _send(command); + + final totpRequired = await _nextMessage(const Duration(seconds: 8)); + if (totpRequired['type'] == 'error') { + throw AgentException(_serverError(_string(totpRequired, 'code'))); + } + if (totpRequired['type'] != 'totp_required' || + _integer(totpRequired, 'digits') != 6 || + _integer(totpRequired, 'period_seconds') != 30) { + throw const AgentException(AgentError.protocol); + } + _send({'type': 'verify_totp', 'code': totpCode}); + + final authenticated = await _nextMessage(const Duration(seconds: 8)); + _validateAuthentication(authenticated); + _emit(AgentPhase.openingDesktop); + _send({ + 'type': 'open_desktop', + 'user': host.user, + 'max_width': settings.maxWidth, + 'max_height': settings.maxHeight, + 'frames_per_second': settings.framesPerSecond, + 'resume_token': null, + 'client_fingerprint': null, + 'edge_session_id': null, + 'webrtc_h264': false, + 'opus_audio': false, + 'clipboard_read': false, + 'clipboard_write': false, + }); + + final opened = await _nextMessage(const Duration(seconds: 15)); + if (opened['type'] == 'error') { + throw const AgentException(AgentError.desktop); + } + if (opened['type'] != 'desktop_opened' || + opened['encoding'] != 'zlib_bgra') { + throw const AgentException(AgentError.protocol); + } + _desktopOpen = true; + _emit(AgentPhase.waitingForFrame); + unawaited(_readLoop()); + } on AgentException catch (error) { + await _fail(error.error); + rethrow; + } on HandshakeException { + final error = _certificateRejected + ? AgentError.certificate + : AgentError.network; + await _fail(error); + throw AgentException(error); + } on Object { + final error = _certificateRejected + ? AgentError.certificate + : AgentError.network; + await _fail(error); + throw AgentException(error); + } + } + + Future _openRelay(ClientIdentity identity, bool pairing) async { + final tunnel = await EdgeClient( + identityStore: _identityStore, + ).authorize(host: host, identity: identity, pairing: pairing); + _relayTunnel = tunnel; + return Uri(scheme: 'wss', host: '127.0.0.1', port: tunnel.port, path: '/'); + } + + Future> _openTransport( + Uri uri, + String expectedFingerprint, { + required bool usingRelay, + }) async { + _certificateRejected = false; + _usingRelay = usingRelay; + final httpClient = HttpClient( + context: SecurityContext(withTrustedRoots: false), + ); + _httpClient = httpClient; + httpClient.connectionTimeout = const Duration(seconds: 10); + httpClient.badCertificateCallback = (certificate, _, _) { + final actual = hashes.sha256.convert(certificate.der).toString(); + final accepted = actual == expectedFingerprint; + _certificateRejected = !accepted; + return accepted; + }; + try { + final channel = IOWebSocketChannel.connect( + uri, + customClient: httpClient, + connectTimeout: const Duration(seconds: 12), + pingInterval: const Duration(seconds: 20), + ); + _channel = channel; + await channel.ready.timeout(const Duration(seconds: 12)); + _messages = StreamIterator(channel.stream); + final hello = await _nextMessage(const Duration(seconds: 8)); + _validateHello(hello, expectedFingerprint); + return hello; + } on AgentException { + await _closeTransport(); + rethrow; + } on HandshakeException { + final error = _certificateRejected + ? AgentError.certificate + : AgentError.network; + await _closeTransport(); + throw AgentException(error); + } on Object { + final error = _certificateRejected + ? AgentError.certificate + : AgentError.network; + await _closeTransport(); + throw AgentException(error); + } + } + + Future _closeTransport() async { + final channel = _channel; + _channel = null; + _messages = null; + try { + await channel?.sink.close(); + } on Object { + // The transport may already be closed. + } + _httpClient?.close(force: true); + _httpClient = null; + } + + void sendPointer(int x, int y) { + if (!_desktopOpen) return; + _send({ + 'type': 'desktop_input', + 'event': { + 'kind': 'pointer_move', + 'x': x.clamp(0, 65535), + 'y': y.clamp(0, 65535), + }, + }); + } + + void sendButton(String button, bool pressed) { + if (!_desktopOpen) return; + _send({ + 'type': 'desktop_input', + 'event': {'kind': 'pointer_button', 'button': button, 'pressed': pressed}, + }); + } + + void sendKey(int keysym) { + if (!_desktopOpen) return; + for (final state in const ['pressed', 'released']) { + _send({ + 'type': 'desktop_input', + 'event': {'kind': 'key', 'keysym': keysym, 'state': state}, + }); + } + } + + void sendText(String value) { + for (final rune in value.runes) { + sendKey(rune <= 0xff ? rune : 0x01000000 | rune); + } + } + + void acknowledgeFrame(int sequence) { + if (!_desktopOpen) return; + _send({'type': 'desktop_frame_ack', 'sequence': sequence}); + } + + Future close() async { + if (_closed) return; + _closed = true; + _desktopOpen = false; + final channel = _channel; + if (channel != null) { + try { + _send({'type': 'close'}); + } on Object { + // The connection may already be closed. + } + try { + await channel.sink.close(); + } on Object { + // Nothing else is required during shutdown. + } + } + _httpClient?.close(force: true); + final relayTunnel = _relayTunnel; + _relayTunnel = null; + if (relayTunnel != null) await EdgeClient.stopTunnel(relayTunnel.id); + _emit(AgentPhase.disconnected); + } + + Future dispose() async { + await close(); + await _states.close(); + await _frames.close(); + } + + Future _readLoop() async { + try { + while (!_closed) { + final event = await _nextMessage(const Duration(seconds: 45)); + switch (event['type']) { + case 'desktop_frame_start': + _startFrame(event); + case 'desktop_frame_chunk': + _addFrameChunk(event); + case 'desktop_frame_complete': + await _completeFrame(event); + case 'desktop_closed': + await _fail(AgentError.desktop); + return; + case 'error': + await _fail(_serverError(_string(event, 'code'))); + return; + case 'desktop_pong': + break; + default: + break; + } + } + } on AgentException catch (error) { + await _fail(error.error); + } on Object { + if (!_closed) await _fail(AgentError.network); + } + } + + void _startFrame(Map event) { + if (_assembly != null) { + throw const AgentException(AgentError.frame); + } + final metadata = event['metadata']; + if (metadata is! Map) { + throw const AgentException(AgentError.frame); + } + final sequence = _integer(metadata, 'sequence'); + final width = _integer(metadata, 'width'); + final height = _integer(metadata, 'height'); + final uncompressed = _integer(metadata, 'uncompressed_bytes'); + final compressed = _integer(metadata, 'compressed_bytes'); + final chunks = _integer(metadata, 'chunk_count'); + final pixels = width * height; + if (metadata['encoding'] != 'zlib_bgra' || + width <= 0 || + height <= 0 || + width > _maxDimension || + height > _maxDimension || + pixels > _maxPixels || + uncompressed != pixels * 4 || + compressed <= 0 || + compressed > _maxCompressedBytes || + chunks <= 0 || + chunks > 65535) { + throw const AgentException(AgentError.frame); + } + _assembly = _FrameAssembly( + sequence: sequence, + width: width, + height: height, + uncompressedBytes: uncompressed, + compressedBytes: compressed, + chunkCount: chunks, + ); + } + + void _addFrameChunk(Map event) { + final assembly = _assembly; + if (assembly == null || + _integer(event, 'sequence') != assembly.sequence || + _integer(event, 'index') != assembly.nextChunk) { + throw const AgentException(AgentError.frame); + } + final bytes = _decodeUnpaddedBase64(_string(event, 'data')); + if (bytes.isEmpty || + assembly.length + bytes.length > assembly.compressedBytes) { + throw const AgentException(AgentError.frame); + } + assembly.add(bytes); + } + + Future _completeFrame(Map event) async { + final assembly = _assembly; + _assembly = null; + if (assembly == null || + _integer(event, 'sequence') != assembly.sequence || + assembly.nextChunk != assembly.chunkCount || + assembly.length != assembly.compressedBytes) { + throw const AgentException(AgentError.frame); + } + final compressed = assembly.takeBytes(); + final actualHash = hashes.sha256.convert(compressed).toString(); + if (actualHash != _string(event, 'sha256').toLowerCase()) { + throw const AgentException(AgentError.frame); + } + final decompressed = await Isolate.run( + () => Uint8List.fromList(ZLibCodec().decode(compressed)), + ); + if (decompressed.length != assembly.uncompressedBytes) { + throw const AgentException(AgentError.frame); + } + if (_closed) return; + _emit(AgentPhase.connected); + _frames.add( + RemoteFrame( + sequence: assembly.sequence, + width: assembly.width, + height: assembly.height, + bgra: decompressed, + ), + ); + } + + Future> _nextMessage(Duration timeout) async { + final messages = _messages; + if (messages == null || !await messages.moveNext().timeout(timeout)) { + throw const AgentException(AgentError.network); + } + final current = messages.current; + if (current is! String) { + throw const AgentException(AgentError.protocol); + } + final decoded = jsonDecode(current); + if (decoded is! Map) { + throw const AgentException(AgentError.protocol); + } + return decoded; + } + + void _validateHello(Map hello, String fingerprint) { + final relayIdentityMatches = + !_usingRelay || + _string(hello, 'device_public_key') == host.agentPublicKey; + if (hello['type'] != 'hello' || + _integer(hello, 'protocol_major') != protocolMajor || + _integer(hello, 'protocol_minor') < 16 || + hello['desktop'] != true || + _string(hello, 'tls_certificate_sha256').toLowerCase() != fingerprint || + !relayIdentityMatches) { + throw const AgentException(AgentError.protocol); + } + } + + void _validateAuthentication(Map response) { + if (response['type'] == 'error') { + throw AgentException(_serverError(_string(response, 'code'))); + } + if (response['type'] != 'authenticated' && response['type'] != 'paired') { + throw const AgentException(AgentError.authentication); + } + final permissions = response['permissions']; + if (permissions is! List || !permissions.contains('desktop')) { + throw const AgentException(AgentError.permission); + } + } + + void _send(Map value) { + final channel = _channel; + if (channel == null) throw const AgentException(AgentError.network); + channel.sink.add(jsonEncode(value)); + } + + void _emit(AgentPhase phase, [AgentError? error]) { + if (!_states.isClosed) _states.add(AgentState(phase, error)); + } + + Future _fail(AgentError error) async { + _desktopOpen = false; + _assembly = null; + _emit(AgentPhase.failed, error); + _closed = true; + try { + await _channel?.sink.close(); + } on Object { + // The transport is already unusable. + } + _httpClient?.close(force: true); + final relayTunnel = _relayTunnel; + _relayTunnel = null; + if (relayTunnel != null) await EdgeClient.stopTunnel(relayTunnel.id); + } + + AgentError _serverError(String code) { + final value = code.toLowerCase(); + if (value.contains('pair') || value.contains('auth')) { + return AgentError.authentication; + } + if (value.contains('totp')) return AgentError.totp; + if (value.contains('permission') || value.contains('denied')) { + return AgentError.permission; + } + if (value.contains('desktop') || value.contains('session')) { + return AgentError.desktop; + } + return AgentError.unknown; + } +} + +class _FrameAssembly { + _FrameAssembly({ + required this.sequence, + required this.width, + required this.height, + required this.uncompressedBytes, + required this.compressedBytes, + required this.chunkCount, + }); + + final int sequence; + final int width; + final int height; + final int uncompressedBytes; + final int compressedBytes; + final int chunkCount; + final BytesBuilder _bytes = BytesBuilder(copy: false); + int nextChunk = 0; + + int get length => _bytes.length; + + void add(Uint8List bytes) { + _bytes.add(bytes); + nextChunk += 1; + } + + Uint8List takeBytes() => _bytes.takeBytes(); +} + +int _integer(Map value, String key) { + final result = value[key]; + if (result is! int) throw const AgentException(AgentError.protocol); + return result; +} + +String _string(Map value, String key) { + final result = value[key]; + if (result is! String) throw const AgentException(AgentError.protocol); + return result; +} + +String _encodeUnpaddedBase64(List bytes) => + base64Encode(bytes).replaceAll('=', ''); + +Uint8List _decodeUnpaddedBase64(String value) { + if (!RegExp(r'^[A-Za-z0-9+/]*$').hasMatch(value) || value.length % 4 == 1) { + throw const AgentException(AgentError.protocol); + } + final padding = '=' * ((4 - value.length % 4) % 4); + try { + return Uint8List.fromList(base64Decode('$value$padding')); + } on FormatException { + throw const AgentException(AgentError.protocol); + } +} diff --git a/client/flutter_android/lib/edge_client.dart b/client/flutter_android/lib/edge_client.dart new file mode 100644 index 0000000..1c460be --- /dev/null +++ b/client/flutter_android/lib/edge_client.dart @@ -0,0 +1,242 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' as hashes; +import 'package:flutter/services.dart'; + +import 'identity_store.dart'; +import 'models.dart'; + +const _intentDomain = 'RemoteDesk/EdgeSessionIntent/v1\u0000'; +const _deviceIdDomain = 'remotedesk-edge-device-id-v1\u0000'; +const _maxResponseBytes = 64 * 1024; + +class RelayTunnel { + const RelayTunnel({required this.id, required this.port}); + + final String id; + final int port; +} + +class EdgeException implements Exception { + const EdgeException(); +} + +class EdgeClient { + EdgeClient({required this.identityStore}); + + static const _tunnelChannel = MethodChannel('com.remotedesk/tunnel'); + final IdentityStore identityStore; + final Random _random = Random.secure(); + + Future authorize({ + required RemoteHost host, + required ClientIdentity identity, + required bool pairing, + }) async { + final api = normalizeEdgeApiUri(host.edgeApiUrl ?? ''); + final agentPublicKey = host.agentPublicKey ?? ''; + final agentKeyBytes = _decodeUnpadded(agentPublicKey); + if (agentKeyBytes.length != 32 || + _encodeUnpadded(agentKeyBytes) != agentPublicKey) { + throw const EdgeException(); + } + final deviceId = hashes.sha256.convert([ + ...utf8.encode(_deviceIdDomain), + ...agentKeyBytes, + ]).toString(); + final publicKey = _encodeUnpadded(identity.publicKey.bytes); + final requestId = _identifier('request'); + final sessionId = _identifier('session'); + final sessionType = pairing ? 'pairing' : 'desktop'; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final expires = now + 60; + final nonce = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + final payload = BytesBuilder(copy: false)..add(utf8.encode(_intentDomain)); + _appendField(payload, 1, utf8.encode(deviceId)); + _appendField(payload, 2, utf8.encode(requestId)); + _appendField(payload, 3, utf8.encode(sessionId)); + _appendField(payload, 4, utf8.encode(sessionType)); + _appendField(payload, 5, utf8.encode(host.user)); + _appendField(payload, 6, utf8.encode(sessionType)); + _appendField(payload, 7, utf8.encode(publicKey)); + _appendField(payload, 8, nonce); + _appendField(payload, 9, _uint64(now)); + _appendField(payload, 10, _uint64(expires)); + final signature = _encodeUnpadded( + await identityStore.sign(identity, payload.takeBytes()), + ); + final intent = { + 'device_id': deviceId, + 'request_id': requestId, + 'session_id': sessionId, + 'session_type': sessionType, + 'target_user': host.user, + 'requested_permissions': [sessionType], + 'client_public_key': publicKey, + 'nonce': _encodeUnpadded(nonce), + 'issued_unix': now, + 'expires_unix': expires, + 'signature': signature, + }; + final submitted = await _post(api.resolve('v1/signals/requests'), intent); + if (submitted['request_id'] != requestId) throw const EdgeException(); + + final statusRequest = { + 'request_id': requestId, + 'client_public_key': publicKey, + 'signature': signature, + }; + final deadline = DateTime.now().add(const Duration(seconds: 18)); + while (DateTime.now().isBefore(deadline)) { + final status = await _post( + api.resolve('v1/signals/status'), + statusRequest, + ); + if (status['request_id'] != requestId || + status['device_id'] != deviceId || + status['session_id'] != sessionId || + status['expires_unix'] != expires) { + throw const EdgeException(); + } + if (status['state'] == 'completed') { + final expected = pairing ? 'pairing_window_open' : 'authorized'; + if (status['accepted'] != true || status['result_code'] != expected) { + throw const EdgeException(); + } + final relay = status['relay']; + if (relay is! Map || + relay['expires_unix'] != expires || + relay['relay_address'] is! String || + relay['ticket'] is! String || + relay['max_bytes'] is! int || + (relay['max_bytes'] as int) <= 0) { + throw const EdgeException(); + } + final result = await _tunnelChannel.invokeMapMethod( + 'startRelay', + {'relayAddress': relay['relay_address'], 'ticket': relay['ticket']}, + ); + final id = result?['id']; + final port = result?['port']; + if (id is! String || port is! int || port <= 0 || port > 65535) { + throw const EdgeException(); + } + return RelayTunnel(id: id, port: port); + } + if (status['state'] != 'pending' && status['state'] != 'delivered') { + throw const EdgeException(); + } + await Future.delayed(const Duration(milliseconds: 250)); + } + throw const EdgeException(); + } + + static Future stopTunnel(String id) async { + try { + await _tunnelChannel.invokeMethod('stopRelay', {'id': id}); + } on PlatformException { + // Process shutdown also closes native sockets. + } + } + + Future> _post(Uri uri, Map body) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 6); + try { + final request = await client + .postUrl(uri) + .timeout(const Duration(seconds: 8)); + request.followRedirects = false; + request.headers.contentType = ContentType.json; + request.headers.set( + HttpHeaders.userAgentHeader, + 'RemoteDesk-Android/1.0', + ); + request.write(jsonEncode(body)); + final response = await request.close().timeout( + const Duration(seconds: 10), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw const EdgeException(); + } + final bytes = BytesBuilder(copy: false); + await for (final chunk in response) { + if (bytes.length + chunk.length > _maxResponseBytes) { + throw const EdgeException(); + } + bytes.add(chunk); + } + final decoded = jsonDecode(utf8.decode(bytes.takeBytes())); + if (decoded is! Map) throw const EdgeException(); + return decoded; + } on EdgeException { + rethrow; + } on Object { + throw const EdgeException(); + } finally { + client.close(force: true); + } + } + + String _identifier(String prefix) { + final bytes = List.generate(18, (_) => _random.nextInt(256)); + return '$prefix-${base64UrlEncode(bytes).replaceAll('=', '')}'; + } +} + +Uri normalizeEdgeApiUri(String input) { + if (input.length > 2048) throw const FormatException('Edge URL is too long'); + final uri = Uri.parse(input.trim()); + final loopbackHttp = + uri.scheme == 'http' && + (uri.host == 'localhost' || + InternetAddress.tryParse(uri.host)?.isLoopback == true); + if ((uri.scheme != 'https' && !loopbackHttp) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + (uri.path.isNotEmpty && uri.path != '/') || + uri.hasQuery || + uri.hasFragment || + uri.port == 0) { + throw const FormatException('Edge URL must be an HTTPS origin'); + } + return Uri( + scheme: uri.scheme, + host: uri.host, + port: uri.hasPort ? uri.port : null, + path: '/', + ); +} + +void _appendField(BytesBuilder output, int tag, List value) { + output.addByte(tag); + final length = ByteData(4)..setUint32(0, value.length, Endian.big); + output.add(length.buffer.asUint8List()); + output.add(value); +} + +Uint8List _uint64(int value) { + final data = ByteData(8)..setUint64(0, value, Endian.big); + return data.buffer.asUint8List(); +} + +String _encodeUnpadded(List bytes) => + base64Encode(bytes).replaceAll('=', ''); + +Uint8List _decodeUnpadded(String value) { + if (!RegExp(r'^[A-Za-z0-9+/]*$').hasMatch(value) || value.length % 4 == 1) { + throw const EdgeException(); + } + try { + return Uint8List.fromList( + base64Decode('$value${'=' * ((4 - value.length % 4) % 4)}'), + ); + } on FormatException { + throw const EdgeException(); + } +} diff --git a/client/flutter_android/lib/identity_store.dart b/client/flutter_android/lib/identity_store.dart new file mode 100644 index 0000000..391d374 --- /dev/null +++ b/client/flutter_android/lib/identity_store.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class ClientIdentity { + const ClientIdentity({required this.keyPair, required this.publicKey}); + + final SimpleKeyPair keyPair; + final SimplePublicKey publicKey; +} + +class IdentityStore { + IdentityStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _seedKey = 'ed25519_seed_v1'; + final FlutterSecureStorage _storage; + final Ed25519 _algorithm = Ed25519(); + + Future loadOrCreate() async { + final encoded = await _storage.read(key: _seedKey); + final SimpleKeyPair keyPair; + if (encoded == null) { + keyPair = await _algorithm.newKeyPair(); + final seed = await keyPair.extractPrivateKeyBytes(); + await _storage.write(key: _seedKey, value: base64Encode(seed)); + } else { + final seed = base64Decode(encoded); + if (seed.length != 32) { + throw StateError('invalid stored Ed25519 seed'); + } + keyPair = await _algorithm.newKeyPairFromSeed(seed); + } + return ClientIdentity( + keyPair: keyPair, + publicKey: await keyPair.extractPublicKey(), + ); + } + + Future> sign(ClientIdentity identity, List payload) async { + final signature = await _algorithm.sign(payload, keyPair: identity.keyPair); + return signature.bytes; + } +} diff --git a/client/flutter_android/lib/l10n.dart b/client/flutter_android/lib/l10n.dart new file mode 100644 index 0000000..3241416 --- /dev/null +++ b/client/flutter_android/lib/l10n.dart @@ -0,0 +1,217 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class AppLocalizations { + AppLocalizations(this.locale); + + final Locale locale; + + static const supportedLocales = [Locale('en'), Locale('zh')]; + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + static AppLocalizations of(BuildContext context) => + Localizations.of(context, AppLocalizations)!; + + static const _values = >{ + 'en': { + 'appName': 'RemoteDesk', + 'devices': 'Devices', + 'noDevices': 'No remote devices', + 'noDevicesHint': 'Add a Linux device to start a secure remote session.', + 'addDevice': 'Add device', + 'editDevice': 'Edit device', + 'deviceName': 'Device name', + 'address': 'Address', + 'addressHint': 'host.example.com:39500', + 'linuxUser': 'Linux user', + 'certificate': 'TLS certificate SHA-256', + 'certificateHint': '64 lowercase hexadecimal characters', + 'useRelay': 'Use Edge relay', + 'useRelayHint': + 'Route the encrypted Agent connection through the relay server.', + 'edgeApiUrl': 'Edge API address', + 'edgeApiUrlHint': 'https://edge.example.com/', + 'agentPublicKey': 'Agent public key', + 'agentPublicKeyHint': 'Value shown by the Linux pairing command', + 'directConnection': 'Direct', + 'relayConnection': 'Edge relay', + 'automaticConnection': 'Automatic (direct, then relay)', + 'connectionMode': 'Connection mode', + 'automatic': 'Automatic', + 'direct': 'Direct', + 'relay': 'Relay', + 'automaticConnectionHint': + 'Try the direct route first and use the encrypted relay only when the network route fails.', + 'directConnectionHint': 'Connect only to the saved Agent address.', + 'relayConnectionHint': 'Always connect through the Edge relay.', + 'save': 'Save', + 'cancel': 'Cancel', + 'connect': 'Connect', + 'pair': 'Pair', + 'edit': 'Edit', + 'delete': 'Delete', + 'deleteDevice': 'Delete device?', + 'deleteDeviceBody': 'This removes the saved connection settings.', + 'pairingCode': 'Pairing code', + 'pairingCodeHint': '8-digit code shown on the Linux device', + 'totpCode': 'Authenticator code', + 'totpCodeHint': '6-digit code from your authenticator app', + 'continueAction': 'Continue', + 'settings': 'Settings', + 'language': 'Language', + 'english': 'English', + 'chinese': 'Simplified Chinese', + 'resolution': 'Remote resolution', + 'frameRate': 'Frame rate', + 'fps': '{value} fps', + 'connecting': 'Connecting securely...', + 'switchingToRelay': 'Direct route unavailable. Switching to relay...', + 'authenticating': 'Authenticating...', + 'openingDesktop': 'Opening desktop...', + 'waitingForFrame': 'Waiting for desktop image...', + 'connected': 'Connected', + 'disconnected': 'Disconnected', + 'disconnect': 'Disconnect', + 'showKeyboard': 'Show keyboard', + 'hideKeyboard': 'Hide keyboard', + 'keyboardInput': 'Type on the remote device', + 'enter': 'Enter', + 'backspace': 'Backspace', + 'tab': 'Tab', + 'escape': 'Escape', + 'invalidName': 'Enter a device name.', + 'invalidAddress': 'Enter a valid WSS host or host:port.', + 'invalidUser': 'Enter a Linux user.', + 'invalidCertificate': 'Enter the full 64-character SHA-256 fingerprint.', + 'invalidEdgeUrl': 'Enter a valid HTTPS Edge API origin.', + 'invalidAgentPublicKey': 'Enter the Agent\'s canonical public key.', + 'invalidPairingCode': 'Enter exactly 8 digits.', + 'invalidTotpCode': 'Enter exactly 6 digits.', + 'errorTitle': 'Connection failed', + 'errorNetwork': 'Could not reach the remote device.', + 'errorCertificate': 'The TLS certificate does not match this device.', + 'errorProtocol': 'The remote device uses an incompatible protocol.', + 'errorAuthentication': 'Authentication failed. Pair this device again.', + 'errorTotp': + 'The authenticator code is invalid, expired, or already used.', + 'errorPermission': 'Desktop control is not permitted for this client.', + 'errorDesktop': 'The remote desktop could not be opened.', + 'errorFrame': 'The remote desktop sent an invalid video frame.', + 'errorUnknown': 'The remote session ended unexpectedly.', + 'retry': 'Retry', + 'close': 'Close', + }, + 'zh': { + 'appName': 'RemoteDesk', + 'devices': '设备', + 'noDevices': '暂无远程设备', + 'noDevicesHint': '添加 Linux 设备以建立安全远程会话。', + 'addDevice': '添加设备', + 'editDevice': '编辑设备', + 'deviceName': '设备名称', + 'address': '地址', + 'addressHint': 'host.example.com:39500', + 'linuxUser': 'Linux 用户', + 'certificate': 'TLS 证书 SHA-256', + 'certificateHint': '64 位小写十六进制字符', + 'useRelay': '使用 Edge 中转', + 'useRelayHint': '通过中转服务器传输端到端加密的 Agent 连接。', + 'edgeApiUrl': 'Edge API 地址', + 'edgeApiUrlHint': 'https://edge.example.com/', + 'agentPublicKey': 'Agent 公钥', + 'agentPublicKeyHint': 'Linux 配对命令显示的公钥', + 'directConnection': '直连', + 'relayConnection': 'Edge 中转', + 'automaticConnection': '自动(直连优先,中转回退)', + 'connectionMode': '连接模式', + 'automatic': '自动', + 'direct': '直连', + 'relay': '中转', + 'automaticConnectionHint': '优先尝试直连,仅在网络路径失败时使用端到端加密中转。', + 'directConnectionHint': '仅连接已保存的 Agent 地址。', + 'relayConnectionHint': '始终通过 Edge 中转连接。', + 'save': '保存', + 'cancel': '取消', + 'connect': '连接', + 'pair': '配对', + 'edit': '编辑', + 'delete': '删除', + 'deleteDevice': '删除设备?', + 'deleteDeviceBody': '这会移除已保存的连接设置。', + 'pairingCode': '配对码', + 'pairingCodeHint': 'Linux 设备上显示的 8 位数字', + 'totpCode': '身份验证器动态码', + 'totpCodeHint': '身份验证器应用中的 6 位动态码', + 'continueAction': '继续', + 'settings': '设置', + 'language': '语言', + 'english': 'English', + 'chinese': '简体中文', + 'resolution': '远程分辨率', + 'frameRate': '帧率', + 'fps': '{value} 帧/秒', + 'connecting': '正在安全连接...', + 'switchingToRelay': '直连路径不可用,正在切换到中转...', + 'authenticating': '正在认证...', + 'openingDesktop': '正在打开桌面...', + 'waitingForFrame': '正在等待桌面画面...', + 'connected': '已连接', + 'disconnected': '已断开', + 'disconnect': '断开连接', + 'showKeyboard': '显示键盘', + 'hideKeyboard': '隐藏键盘', + 'keyboardInput': '输入到远程设备', + 'enter': '回车', + 'backspace': '退格', + 'tab': '制表符', + 'escape': '退出键', + 'invalidName': '请输入设备名称。', + 'invalidAddress': '请输入有效的 WSS 主机或主机:端口。', + 'invalidUser': '请输入 Linux 用户。', + 'invalidCertificate': '请输入完整的 64 位 SHA-256 指纹。', + 'invalidEdgeUrl': '请输入有效的 HTTPS Edge API 地址。', + 'invalidAgentPublicKey': '请输入规范格式的 Agent 公钥。', + 'invalidPairingCode': '请输入恰好 8 位数字。', + 'invalidTotpCode': '请输入恰好 6 位数字。', + 'errorTitle': '连接失败', + 'errorNetwork': '无法连接远程设备。', + 'errorCertificate': 'TLS 证书与该设备不匹配。', + 'errorProtocol': '远程设备使用了不兼容的协议。', + 'errorAuthentication': '认证失败,请重新配对该设备。', + 'errorTotp': '动态验证码无效、已过期或已被使用。', + 'errorPermission': '此客户端没有桌面控制权限。', + 'errorDesktop': '无法打开远程桌面。', + 'errorFrame': '远程桌面发送了无效的视频帧。', + 'errorUnknown': '远程会话意外结束。', + 'retry': '重试', + 'close': '关闭', + }, + }; + + String text(String key, [Map values = const {}]) { + var result = (_values[locale.languageCode] ?? _values['en']!)[key] ?? key; + for (final entry in values.entries) { + result = result.replaceAll('{${entry.key}}', '${entry.value}'); + } + return result; + } +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => AppLocalizations.supportedLocales.any( + (supported) => supported.languageCode == locale.languageCode, + ); + + @override + Future load(Locale locale) => + SynchronousFuture(AppLocalizations(locale)); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} diff --git a/client/flutter_android/lib/main.dart b/client/flutter_android/lib/main.dart new file mode 100644 index 0000000..8b9bb97 --- /dev/null +++ b/client/flutter_android/lib/main.dart @@ -0,0 +1,1216 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:uuid/uuid.dart'; + +import 'agent_client.dart'; +import 'edge_client.dart'; +import 'l10n.dart'; +import 'models.dart'; +import 'storage.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(RemoteDeskApp()); +} + +class RemoteDeskApp extends StatefulWidget { + RemoteDeskApp({super.key, AppStorage? storage}) + : storage = storage ?? AppStorage(); + + final AppStorage storage; + + @override + State createState() => _RemoteDeskAppState(); +} + +class _RemoteDeskAppState extends State { + List _hosts = const []; + AppSettings _settings = const AppSettings(); + bool _loaded = false; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + final values = await Future.wait([ + widget.storage.loadHosts(), + widget.storage.loadSettings(), + ]); + if (!mounted) return; + setState(() { + _hosts = values[0] as List; + _settings = values[1] as AppSettings; + _loaded = true; + }); + } + + Future _saveHost(RemoteHost host) async { + final next = [..._hosts]; + final index = next.indexWhere((item) => item.id == host.id); + if (index < 0) { + next.add(host); + } else { + next[index] = host; + } + setState(() => _hosts = next); + await widget.storage.saveHosts(next); + } + + Future _deleteHost(RemoteHost host) async { + final next = _hosts.where((item) => item.id != host.id).toList(); + setState(() => _hosts = next); + await widget.storage.saveHosts(next); + } + + Future _saveSettings(AppSettings settings) async { + setState(() => _settings = settings); + await widget.storage.saveSettings(settings); + } + + @override + Widget build(BuildContext context) { + final locale = _settings.languageCode == null + ? null + : Locale(_settings.languageCode!); + return MaterialApp( + debugShowCheckedModeBanner: false, + onGenerateTitle: (context) => + AppLocalizations.of(context).text('appName'), + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xff006c5b), + brightness: Brightness.light, + ), + scaffoldBackgroundColor: const Color(0xfff6f8f7), + cardTheme: const CardThemeData( + margin: EdgeInsets.zero, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + side: BorderSide(color: Color(0xffd9dfdc)), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + ), + ), + home: !_loaded + ? const Scaffold(body: Center(child: CircularProgressIndicator())) + : HostListScreen( + hosts: _hosts, + settings: _settings, + onSaveHost: _saveHost, + onDeleteHost: _deleteHost, + onSaveSettings: _saveSettings, + ), + ); + } +} + +class HostListScreen extends StatelessWidget { + const HostListScreen({ + super.key, + required this.hosts, + required this.settings, + required this.onSaveHost, + required this.onDeleteHost, + required this.onSaveSettings, + }); + + final List hosts; + final AppSettings settings; + final Future Function(RemoteHost) onSaveHost; + final Future Function(RemoteHost) onDeleteHost; + final Future Function(AppSettings) onSaveSettings; + + Future _editHost(BuildContext context, [RemoteHost? host]) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => HostEditorScreen(host: host, onSave: onSaveHost), + ), + ); + } + + Future _openSettings(BuildContext context) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + SettingsScreen(settings: settings, onSave: onSaveSettings), + ), + ); + } + + Future _delete(BuildContext context, RemoteHost host) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.text('deleteDevice')), + content: Text(l10n.text('deleteDeviceBody')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.text('cancel')), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.text('delete')), + ), + ], + ), + ); + if (confirmed == true) await onDeleteHost(host); + } + + Future _connect( + BuildContext context, + RemoteHost host, { + required bool pair, + }) async { + String? pairingCode; + if (pair) { + pairingCode = await _requestPairingCode(context); + if (pairingCode == null || !context.mounted) return; + } + final totpCode = await _requestTotpCode(context); + if (totpCode == null || !context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DesktopScreen( + host: host, + settings: settings, + pairingCode: pairingCode, + initialTotpCode: totpCode, + ), + ), + ); + } + + Future _requestPairingCode(BuildContext context) { + final controller = TextEditingController(); + final formKey = GlobalKey(); + final l10n = AppLocalizations.of(context); + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.text('pairingCode')), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.number, + maxLength: 8, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: l10n.text('pairingCode'), + helperText: l10n.text('pairingCodeHint'), + ), + validator: (value) => RegExp(r'^\d{8}$').hasMatch(value ?? '') + ? null + : l10n.text('invalidPairingCode'), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.text('cancel')), + ), + FilledButton( + onPressed: () { + if (formKey.currentState!.validate()) { + Navigator.pop(dialogContext, controller.text); + } + }, + child: Text(l10n.text('continueAction')), + ), + ], + ), + ).whenComplete(controller.dispose); + } + + Future _requestTotpCode(BuildContext context) { + final controller = TextEditingController(); + final formKey = GlobalKey(); + final l10n = AppLocalizations.of(context); + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.text('totpCode')), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.number, + maxLength: 6, + obscureText: true, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: l10n.text('totpCode'), + helperText: l10n.text('totpCodeHint'), + ), + validator: (value) => RegExp(r'^\d{6}$').hasMatch(value ?? '') + ? null + : l10n.text('invalidTotpCode'), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.text('cancel')), + ), + FilledButton( + onPressed: () { + if (formKey.currentState!.validate()) { + Navigator.pop(dialogContext, controller.text); + } + }, + child: Text(l10n.text('continueAction')), + ), + ], + ), + ).whenComplete(controller.dispose); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar( + title: Text(l10n.text('devices')), + actions: [ + IconButton( + onPressed: () => _openSettings(context), + tooltip: l10n.text('settings'), + icon: const Icon(Icons.settings_outlined), + ), + ], + ), + body: hosts.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.desktop_windows_outlined, + size: 52, + color: Theme.of(context).colorScheme.secondary, + ), + const SizedBox(height: 16), + Text( + l10n.text('noDevices'), + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.text('noDevicesHint'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 96), + itemCount: hosts.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final host = hosts[index]; + return Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 8, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.secondaryContainer, + child: const Icon(Icons.computer), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + host.name, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text( + '${host.user} @ ${host.address}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + l10n.text(switch (host.connectionMode) { + ConnectionMode.automatic => + 'automaticConnection', + ConnectionMode.direct => + 'directConnection', + ConnectionMode.relay => 'relayConnection', + }), + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + ], + ), + ), + PopupMenuButton( + onSelected: (value) { + if (value == 'edit') _editHost(context, host); + if (value == 'delete') _delete(context, host); + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'edit', + child: ListTile( + leading: const Icon(Icons.edit_outlined), + title: Text(l10n.text('edit')), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem( + value: 'delete', + child: ListTile( + leading: const Icon(Icons.delete_outline), + title: Text(l10n.text('delete')), + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: () => + _connect(context, host, pair: false), + icon: const Icon(Icons.play_arrow), + label: Text(l10n.text('connect')), + ), + OutlinedButton.icon( + onPressed: () => + _connect(context, host, pair: true), + icon: const Icon(Icons.link), + label: Text(l10n.text('pair')), + ), + ], + ), + ], + ), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _editHost(context), + tooltip: l10n.text('addDevice'), + child: const Icon(Icons.add), + ), + ); + } +} + +class HostEditorScreen extends StatefulWidget { + const HostEditorScreen({super.key, this.host, required this.onSave}); + + final RemoteHost? host; + final Future Function(RemoteHost) onSave; + + @override + State createState() => _HostEditorScreenState(); +} + +class _HostEditorScreenState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _name; + late final TextEditingController _address; + late final TextEditingController _user; + late final TextEditingController _certificate; + late final TextEditingController _edgeApiUrl; + late final TextEditingController _agentPublicKey; + late ConnectionMode _connectionMode; + bool _saving = false; + + @override + void initState() { + super.initState(); + _name = TextEditingController(text: widget.host?.name ?? ''); + _address = TextEditingController(text: widget.host?.address ?? ''); + _user = TextEditingController(text: widget.host?.user ?? ''); + _certificate = TextEditingController( + text: widget.host?.certificateSha256 ?? '', + ); + _edgeApiUrl = TextEditingController(text: widget.host?.edgeApiUrl ?? ''); + _agentPublicKey = TextEditingController( + text: widget.host?.agentPublicKey ?? '', + ); + _connectionMode = widget.host?.connectionMode ?? ConnectionMode.automatic; + } + + @override + void dispose() { + _name.dispose(); + _address.dispose(); + _user.dispose(); + _certificate.dispose(); + _edgeApiUrl.dispose(); + _agentPublicKey.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + final host = RemoteHost( + id: widget.host?.id ?? const Uuid().v4(), + name: _name.text.trim(), + address: _address.text.trim(), + user: _user.text.trim(), + certificateSha256: normalizeFingerprint(_certificate.text), + connectionMode: _connectionMode, + edgeApiUrl: _connectionMode != ConnectionMode.direct + ? _edgeApiUrl.text.trim() + : null, + agentPublicKey: _connectionMode != ConnectionMode.direct + ? _agentPublicKey.text.trim() + : null, + ); + await widget.onSave(host); + if (mounted) Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar( + title: Text( + l10n.text(widget.host == null ? 'addDevice' : 'editDevice'), + ), + ), + body: SafeArea( + child: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _name, + textInputAction: TextInputAction.next, + decoration: InputDecoration(labelText: l10n.text('deviceName')), + validator: (value) => (value ?? '').trim().isEmpty + ? l10n.text('invalidName') + : null, + ), + const SizedBox(height: 14), + TextFormField( + controller: _address, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + autocorrect: false, + decoration: InputDecoration( + labelText: l10n.text('address'), + hintText: l10n.text('addressHint'), + ), + validator: (value) { + try { + normalizeAgentUri(value ?? ''); + return null; + } on FormatException { + return l10n.text('invalidAddress'); + } + }, + ), + const SizedBox(height: 14), + TextFormField( + controller: _user, + textInputAction: TextInputAction.next, + autocorrect: false, + decoration: InputDecoration(labelText: l10n.text('linuxUser')), + validator: (value) => (value ?? '').trim().isEmpty + ? l10n.text('invalidUser') + : null, + ), + const SizedBox(height: 14), + TextFormField( + controller: _certificate, + autocorrect: false, + enableSuggestions: false, + maxLength: 95, + decoration: InputDecoration( + labelText: l10n.text('certificate'), + helperText: l10n.text('certificateHint'), + ), + validator: (value) => isValidFingerprint(value ?? '') + ? null + : l10n.text('invalidCertificate'), + ), + const SizedBox(height: 6), + Text( + l10n.text('connectionMode'), + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + SegmentedButton( + segments: [ + ButtonSegment( + value: ConnectionMode.automatic, + icon: const Icon(Icons.alt_route), + label: Text(l10n.text('automatic')), + ), + ButtonSegment( + value: ConnectionMode.direct, + icon: const Icon(Icons.link), + label: Text(l10n.text('direct')), + ), + ButtonSegment( + value: ConnectionMode.relay, + icon: const Icon(Icons.hub_outlined), + label: Text(l10n.text('relay')), + ), + ], + selected: {_connectionMode}, + onSelectionChanged: (selection) => + setState(() => _connectionMode = selection.single), + ), + const SizedBox(height: 6), + Text( + l10n.text(switch (_connectionMode) { + ConnectionMode.automatic => 'automaticConnectionHint', + ConnectionMode.direct => 'directConnectionHint', + ConnectionMode.relay => 'relayConnectionHint', + }), + style: Theme.of(context).textTheme.bodySmall, + ), + if (_connectionMode != ConnectionMode.direct) ...[ + const SizedBox(height: 8), + TextFormField( + controller: _edgeApiUrl, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + autocorrect: false, + decoration: InputDecoration( + labelText: l10n.text('edgeApiUrl'), + hintText: l10n.text('edgeApiUrlHint'), + ), + validator: (value) { + if (_connectionMode == ConnectionMode.direct) return null; + try { + normalizeEdgeApiUri(value ?? ''); + return null; + } on FormatException { + return l10n.text('invalidEdgeUrl'); + } + }, + ), + const SizedBox(height: 14), + TextFormField( + controller: _agentPublicKey, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: l10n.text('agentPublicKey'), + helperText: l10n.text('agentPublicKeyHint'), + ), + validator: (value) => + _connectionMode == ConnectionMode.direct || + isValidAgentPublicKey(value ?? '') + ? null + : l10n.text('invalidAgentPublicKey'), + ), + ], + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(l10n.text('save')), + ), + ], + ), + ), + ), + ); + } +} + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({ + super.key, + required this.settings, + required this.onSave, + }); + + final AppSettings settings; + final Future Function(AppSettings) onSave; + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + late AppSettings _settings = widget.settings; + + Future _update(AppSettings value) async { + setState(() => _settings = value); + await widget.onSave(value); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final resolution = '${_settings.maxWidth}x${_settings.maxHeight}'; + return Scaffold( + appBar: AppBar(title: Text(l10n.text('settings'))), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + l10n.text('language'), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 10), + SegmentedButton( + segments: [ + ButtonSegment(value: 'en', label: Text(l10n.text('english'))), + ButtonSegment(value: 'zh', label: Text(l10n.text('chinese'))), + ], + selected: {_settings.languageCode ?? 'en'}, + onSelectionChanged: (value) => + _update(_settings.copyWith(languageCode: value.first)), + ), + const SizedBox(height: 28), + DropdownButtonFormField( + initialValue: resolution, + decoration: InputDecoration(labelText: l10n.text('resolution')), + items: const [ + DropdownMenuItem(value: '960x540', child: Text('960 x 540')), + DropdownMenuItem(value: '1280x720', child: Text('1280 x 720')), + DropdownMenuItem(value: '1600x900', child: Text('1600 x 900')), + DropdownMenuItem(value: '1920x1080', child: Text('1920 x 1080')), + ], + onChanged: (value) { + if (value == null) return; + final parts = value.split('x'); + _update( + _settings.copyWith( + maxWidth: int.parse(parts[0]), + maxHeight: int.parse(parts[1]), + ), + ); + }, + ), + const SizedBox(height: 28), + Text( + '${l10n.text('frameRate')}: ${l10n.text('fps', {'value': _settings.framesPerSecond})}', + style: Theme.of(context).textTheme.titleSmall, + ), + Slider( + min: 5, + max: 30, + divisions: 5, + value: _settings.framesPerSecond.toDouble(), + label: l10n.text('fps', {'value': _settings.framesPerSecond}), + onChanged: (value) => setState( + () => _settings = _settings.copyWith( + framesPerSecond: value.round(), + ), + ), + onChangeEnd: (value) => + _update(_settings.copyWith(framesPerSecond: value.round())), + ), + ], + ), + ); + } +} + +class DesktopScreen extends StatefulWidget { + const DesktopScreen({ + super.key, + required this.host, + required this.settings, + this.pairingCode, + required this.initialTotpCode, + }); + + final RemoteHost host; + final AppSettings settings; + final String? pairingCode; + final String initialTotpCode; + + @override + State createState() => _DesktopScreenState(); +} + +class _DesktopScreenState extends State { + late final AgentClient _client; + StreamSubscription? _stateSubscription; + StreamSubscription? _frameSubscription; + final TextEditingController _keyboardController = TextEditingController(); + final FocusNode _keyboardFocus = FocusNode(); + AgentState _state = const AgentState(AgentPhase.connecting); + ui.Image? _image; + int _frameWidth = 0; + int _frameHeight = 0; + bool _showKeyboard = false; + late String? _totpCode = widget.initialTotpCode; + + @override + void initState() { + super.initState(); + _client = AgentClient(host: widget.host, settings: widget.settings); + _stateSubscription = _client.states.listen((state) { + if (mounted) setState(() => _state = state); + }); + _frameSubscription = _client.frames.listen(_decodeFrame); + unawaited( + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), + ); + unawaited(_connect()); + } + + Future _connect() async { + var totpCode = _totpCode; + if (totpCode == null) { + totpCode = await _requestTotpCode(); + if (totpCode == null) return; + } + _totpCode = null; + try { + await _client.connect( + pairingCode: widget.pairingCode, + totpCode: totpCode, + ); + } on AgentException { + // The state stream provides the localized error category. + } + } + + Future _requestTotpCode() { + final controller = TextEditingController(); + final formKey = GlobalKey(); + final l10n = AppLocalizations.of(context); + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.text('totpCode')), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.number, + maxLength: 6, + obscureText: true, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration(helperText: l10n.text('totpCodeHint')), + validator: (value) => RegExp(r'^\d{6}$').hasMatch(value ?? '') + ? null + : l10n.text('invalidTotpCode'), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.text('cancel')), + ), + FilledButton( + onPressed: () { + if (formKey.currentState!.validate()) { + Navigator.pop(dialogContext, controller.text); + } + }, + child: Text(l10n.text('continueAction')), + ), + ], + ), + ).whenComplete(controller.dispose); + } + + void _decodeFrame(RemoteFrame frame) { + ui.decodeImageFromPixels( + frame.bgra, + frame.width, + frame.height, + ui.PixelFormat.bgra8888, + (image) { + if (!mounted) { + image.dispose(); + return; + } + final previous = _image; + setState(() { + _image = image; + _frameWidth = frame.width; + _frameHeight = frame.height; + }); + _client.acknowledgeFrame(frame.sequence); + if (previous != null) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => previous.dispose(), + ); + } + }, + ); + } + + void _toggleKeyboard() { + setState(() => _showKeyboard = !_showKeyboard); + if (_showKeyboard) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _keyboardFocus.requestFocus(), + ); + } else { + _keyboardFocus.unfocus(); + } + } + + void _sendPointer(Offset local, Size size) { + if (_frameWidth == 0 || size.width <= 0 || size.height <= 0) return; + final x = (local.dx / size.width * _frameWidth).floor().clamp( + 0, + _frameWidth - 1, + ); + final y = (local.dy / size.height * _frameHeight).floor().clamp( + 0, + _frameHeight - 1, + ); + _client.sendPointer(x, y); + } + + @override + void dispose() { + unawaited(_stateSubscription?.cancel()); + unawaited(_frameSubscription?.cancel()); + _keyboardController.dispose(); + _keyboardFocus.dispose(); + _image?.dispose(); + unawaited(_client.dispose()); + unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return PopScope( + onPopInvokedWithResult: (_, _) => unawaited(_client.close()), + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + if (_image == null) + _SessionStatus(state: _state, onRetry: _connect) + else + LayoutBuilder( + builder: (context, constraints) { + final imageAspect = _frameWidth / _frameHeight; + final viewAspect = + constraints.maxWidth / constraints.maxHeight; + final width = imageAspect > viewAspect + ? constraints.maxWidth + : constraints.maxHeight * imageAspect; + final height = imageAspect > viewAspect + ? constraints.maxWidth / imageAspect + : constraints.maxHeight; + return Center( + child: SizedBox( + width: width, + height: height, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) => _sendPointer( + details.localPosition, + Size(width, height), + ), + onTapUp: (_) { + _client.sendButton('left', true); + _client.sendButton('left', false); + }, + onPanStart: (details) => _sendPointer( + details.localPosition, + Size(width, height), + ), + onPanUpdate: (details) => _sendPointer( + details.localPosition, + Size(width, height), + ), + onLongPressStart: (_) => + _client.sendButton('right', true), + onLongPressEnd: (_) => + _client.sendButton('right', false), + child: RawImage( + image: _image, + fit: BoxFit.fill, + filterQuality: FilterQuality.low, + ), + ), + ), + ); + }, + ), + Positioned( + left: 8, + right: 8, + top: 0, + child: SafeArea( + bottom: false, + child: Material( + color: const Color(0xcc171b1a), + borderRadius: BorderRadius.circular(6), + child: SizedBox( + height: 48, + child: Row( + children: [ + const SizedBox(width: 12), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _state.phase == AgentPhase.connected + ? const Color(0xff45d483) + : const Color(0xffffc857), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.host.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white), + ), + ), + IconButton( + onPressed: _toggleKeyboard, + tooltip: l10n.text( + _showKeyboard ? 'hideKeyboard' : 'showKeyboard', + ), + color: Colors.white, + icon: Icon( + _showKeyboard + ? Icons.keyboard_hide + : Icons.keyboard_alt_outlined, + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + tooltip: l10n.text('disconnect'), + color: Colors.white, + icon: const Icon(Icons.close), + ), + ], + ), + ), + ), + ), + ), + if (_showKeyboard) + Positioned( + left: 8, + right: 8, + bottom: 8, + child: SafeArea( + top: false, + child: Material( + color: const Color(0xf21f2422), + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _keyboardController, + focusNode: _keyboardFocus, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + isDense: true, + hintText: l10n.text('keyboardInput'), + hintStyle: const TextStyle(color: Colors.white60), + filled: true, + fillColor: Colors.black26, + ), + onChanged: (value) { + if (value.isEmpty) return; + _client.sendText(value); + _keyboardController.clear(); + }, + ), + const SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _KeyButton( + label: l10n.text('escape'), + onPressed: () => _client.sendKey(0xff1b), + ), + _KeyButton( + label: l10n.text('tab'), + onPressed: () => _client.sendKey(0xff09), + ), + _KeyButton( + label: l10n.text('backspace'), + onPressed: () => _client.sendKey(0xff08), + ), + _KeyButton( + label: l10n.text('enter'), + onPressed: () => _client.sendKey(0xff0d), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _SessionStatus extends StatelessWidget { + const _SessionStatus({required this.state, required this.onRetry}); + + final AgentState state; + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + if (state.phase == AgentPhase.failed) { + final errorKey = switch (state.error) { + AgentError.network => 'errorNetwork', + AgentError.certificate => 'errorCertificate', + AgentError.protocol => 'errorProtocol', + AgentError.authentication => 'errorAuthentication', + AgentError.totp => 'errorTotp', + AgentError.permission => 'errorPermission', + AgentError.desktop => 'errorDesktop', + AgentError.frame => 'errorFrame', + _ => 'errorUnknown', + }; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + color: Color(0xffff8a80), + size: 44, + ), + const SizedBox(height: 14), + Text( + l10n.text('errorTitle'), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: Colors.white), + ), + const SizedBox(height: 8), + Text( + l10n.text(errorKey), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(l10n.text('retry')), + ), + ], + ), + ), + ), + ); + } + final statusKey = switch (state.phase) { + AgentPhase.switchingToRelay => 'switchingToRelay', + AgentPhase.authenticating => 'authenticating', + AgentPhase.openingDesktop => 'openingDesktop', + AgentPhase.waitingForFrame => 'waitingForFrame', + _ => 'connecting', + }; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(color: Colors.white), + const SizedBox(height: 16), + Text( + l10n.text(statusKey), + style: const TextStyle(color: Colors.white), + ), + ], + ), + ); + } +} + +class _KeyButton extends StatelessWidget { + const _KeyButton({required this.label, required this.onPressed}); + + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(right: 8), + child: OutlinedButton(onPressed: onPressed, child: Text(label)), + ); +} diff --git a/client/flutter_android/lib/models.dart b/client/flutter_android/lib/models.dart new file mode 100644 index 0000000..69c74d2 --- /dev/null +++ b/client/flutter_android/lib/models.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +enum ConnectionMode { automatic, direct, relay } + +class RemoteHost { + const RemoteHost({ + required this.id, + required this.name, + required this.address, + required this.user, + required this.certificateSha256, + this.connectionMode = ConnectionMode.direct, + this.edgeApiUrl, + this.agentPublicKey, + }); + + final String id; + final String name; + final String address; + final String user; + final String certificateSha256; + final ConnectionMode connectionMode; + final String? edgeApiUrl; + final String? agentPublicKey; + + Uri get uri => normalizeAgentUri(address); + + RemoteHost copyWith({ + String? id, + String? name, + String? address, + String? user, + String? certificateSha256, + ConnectionMode? connectionMode, + String? edgeApiUrl, + String? agentPublicKey, + }) => RemoteHost( + id: id ?? this.id, + name: name ?? this.name, + address: address ?? this.address, + user: user ?? this.user, + certificateSha256: certificateSha256 ?? this.certificateSha256, + connectionMode: connectionMode ?? this.connectionMode, + edgeApiUrl: edgeApiUrl ?? this.edgeApiUrl, + agentPublicKey: agentPublicKey ?? this.agentPublicKey, + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'address': address, + 'user': user, + 'certificate_sha256': certificateSha256, + 'connection_mode': connectionMode.name, + 'use_relay': connectionMode != ConnectionMode.direct, + 'edge_api_url': edgeApiUrl, + 'agent_public_key': agentPublicKey, + }; + + factory RemoteHost.fromJson(Map json) => RemoteHost( + id: json['id'] as String, + name: json['name'] as String, + address: json['address'] as String, + user: json['user'] as String, + certificateSha256: json['certificate_sha256'] as String, + connectionMode: _connectionModeFromJson(json), + edgeApiUrl: json['edge_api_url'] as String?, + agentPublicKey: json['agent_public_key'] as String?, + ); + + static List decodeList(String value) { + final decoded = jsonDecode(value) as List; + return decoded + .map((item) => RemoteHost.fromJson(item as Map)) + .toList(growable: false); + } + + static String encodeList(List hosts) => + jsonEncode(hosts.map((host) => host.toJson()).toList()); +} + +ConnectionMode _connectionModeFromJson(Map json) { + final value = json['connection_mode']; + if (value is String) { + for (final mode in ConnectionMode.values) { + if (mode.name == value) return mode; + } + } + return json['use_relay'] == true + ? ConnectionMode.relay + : ConnectionMode.direct; +} + +class AppSettings { + const AppSettings({ + this.languageCode, + this.maxWidth = 1280, + this.maxHeight = 720, + this.framesPerSecond = 15, + }); + + final String? languageCode; + final int maxWidth; + final int maxHeight; + final int framesPerSecond; + + AppSettings copyWith({ + String? languageCode, + int? maxWidth, + int? maxHeight, + int? framesPerSecond, + }) => AppSettings( + languageCode: languageCode ?? this.languageCode, + maxWidth: maxWidth ?? this.maxWidth, + maxHeight: maxHeight ?? this.maxHeight, + framesPerSecond: framesPerSecond ?? this.framesPerSecond, + ); +} + +Uri normalizeAgentUri(String input) { + final value = input.trim(); + if (value.isEmpty) { + throw const FormatException('empty address'); + } + final withScheme = value.contains('://') ? value : 'wss://$value'; + final parsed = Uri.parse(withScheme); + if (parsed.scheme != 'wss' || parsed.host.isEmpty) { + throw const FormatException('invalid WSS address'); + } + if (parsed.path.isNotEmpty && parsed.path != '/') { + throw const FormatException('agent URL must not contain a path'); + } + return Uri( + scheme: 'wss', + host: parsed.host, + port: parsed.hasPort ? parsed.port : 39500, + path: '/', + ); +} + +String normalizeFingerprint(String input) => + input.trim().replaceAll(':', '').toLowerCase(); + +bool isValidFingerprint(String input) => + RegExp(r'^[0-9a-f]{64}$').hasMatch(normalizeFingerprint(input)); + +bool isValidAgentPublicKey(String input) => + RegExp(r'^[A-Za-z0-9+/]{43}$').hasMatch(input.trim()); diff --git a/client/flutter_android/lib/storage.dart b/client/flutter_android/lib/storage.dart new file mode 100644 index 0000000..d2f972b --- /dev/null +++ b/client/flutter_android/lib/storage.dart @@ -0,0 +1,49 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import 'models.dart'; + +class AppStorage { + static const _hostsKey = 'remote_hosts_v1'; + static const _localeKey = 'locale'; + static const _widthKey = 'desktop_width'; + static const _heightKey = 'desktop_height'; + static const _fpsKey = 'desktop_fps'; + + Future> loadHosts() async { + final preferences = await SharedPreferences.getInstance(); + final encoded = preferences.getString(_hostsKey); + if (encoded == null) return const []; + try { + return RemoteHost.decodeList(encoded); + } on Object { + return const []; + } + } + + Future saveHosts(List hosts) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString(_hostsKey, RemoteHost.encodeList(hosts)); + } + + Future loadSettings() async { + final preferences = await SharedPreferences.getInstance(); + return AppSettings( + languageCode: preferences.getString(_localeKey), + maxWidth: preferences.getInt(_widthKey) ?? 1280, + maxHeight: preferences.getInt(_heightKey) ?? 720, + framesPerSecond: preferences.getInt(_fpsKey) ?? 15, + ); + } + + Future saveSettings(AppSettings settings) async { + final preferences = await SharedPreferences.getInstance(); + if (settings.languageCode == null) { + await preferences.remove(_localeKey); + } else { + await preferences.setString(_localeKey, settings.languageCode!); + } + await preferences.setInt(_widthKey, settings.maxWidth); + await preferences.setInt(_heightKey, settings.maxHeight); + await preferences.setInt(_fpsKey, settings.framesPerSecond); + } +} diff --git a/client/flutter_android/pubspec.lock b/client/flutter_android/pubspec.lock new file mode 100644 index 0000000..6bd89fc --- /dev/null +++ b/client/flutter_android/pubspec.lock @@ -0,0 +1,623 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: "direct main" + description: + name: archive + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.9.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" + image: + dependency: "direct main" + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.9.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.17.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.5 <4.0.0" + flutter: ">=3.38.4" diff --git a/client/flutter_android/pubspec.yaml b/client/flutter_android/pubspec.yaml new file mode 100644 index 0000000..94ffb0e --- /dev/null +++ b/client/flutter_android/pubspec.yaml @@ -0,0 +1,99 @@ +name: remotedesk_android +description: "RemoteDesk Android control client" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.11.5 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + flutter_localizations: + sdk: flutter + shared_preferences: ^2.5.5 + flutter_secure_storage: 9.2.4 + web_socket_channel: ^3.0.3 + cryptography: ^2.9.0 + archive: ^4.2.0 + crypto: ^3.0.7 + image: ^4.9.2 + uuid: ^4.6.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/client/flutter_android/test/models_test.dart b/client/flutter_android/test/models_test.dart new file mode 100644 index 0000000..2d7a63e --- /dev/null +++ b/client/flutter_android/test/models_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:remotedesk_android/edge_client.dart'; +import 'package:remotedesk_android/models.dart'; + +void main() { + test('host settings round-trip with relay configuration', () { + const host = RemoteHost( + id: 'host-1', + name: 'Workstation', + address: 'linux.example.test:39500', + user: 'alice', + certificateSha256: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + connectionMode: ConnectionMode.automatic, + edgeApiUrl: 'https://edge.example.test/', + agentPublicKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ); + final decoded = RemoteHost.decodeList(RemoteHost.encodeList([host])).single; + expect(decoded.toJson(), host.toJson()); + }); + + test('legacy relay setting migrates to explicit relay mode', () { + final host = RemoteHost.fromJson({ + 'id': 'legacy', + 'name': 'Legacy', + 'address': 'linux.example.test:39500', + 'user': 'alice', + 'certificate_sha256': 'aa' * 32, + 'use_relay': true, + }); + expect(host.connectionMode, ConnectionMode.relay); + }); + + test('agent addresses are normalized to pinned WSS', () { + expect( + normalizeAgentUri('linux.example.test'), + Uri.parse('wss://linux.example.test:39500/'), + ); + expect( + () => normalizeAgentUri('ws://linux.example.test:39500'), + throwsFormatException, + ); + }); + + test('fingerprints and Edge origins are strict', () { + expect(isValidFingerprint('ab:' * 31 + 'ab'), isTrue); + expect(isValidFingerprint('ab' * 31), isFalse); + expect( + normalizeEdgeApiUri('https://edge.example.test/'), + Uri.parse('https://edge.example.test/'), + ); + expect( + () => normalizeEdgeApiUri('http://edge.example.test/'), + throwsFormatException, + ); + }); +} diff --git a/client/flutter_android/test/widget_test.dart b/client/flutter_android/test/widget_test.dart new file mode 100644 index 0000000..fac2c88 --- /dev/null +++ b/client/flutter_android/test/widget_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remotedesk_android/main.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('empty device list exposes the add action', (tester) async { + SharedPreferences.setMockInitialValues({'locale': 'en'}); + await tester.pumpWidget(RemoteDeskApp()); + await tester.pumpAndSettle(); + + expect(find.text('No remote devices'), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); + }); +} diff --git a/client/flutter_control/lib/app_localizations.dart b/client/flutter_control/lib/app_localizations.dart index 0027fa6..d081297 100644 --- a/client/flutter_control/lib/app_localizations.dart +++ b/client/flutter_control/lib/app_localizations.dart @@ -69,7 +69,9 @@ const _zh = { 'Add host': '添加主机', 'Refresh control service': '刷新控制服务', 'Search hosts': '搜索主机', + 'Clear search': '清除搜索', 'No saved hosts': '暂无已保存主机', + 'No matching hosts': '没有匹配的主机', 'Unable to load hosts': '无法加载主机列表', 'Host database has an invalid format': '主机数据库格式无效', 'Host list saved': '主机列表已保存', @@ -95,6 +97,11 @@ const _zh = { 'Open terminal': '打开终端', 'Test connection': '测试连接', 'Save': '保存', + 'Unsaved changes': '未保存', + 'Discard unsaved changes?': '放弃未保存的更改?', + 'The current host changes have not been saved.': '当前主机更改尚未保存。', + 'Keep editing': '继续编辑', + 'Discard': '放弃更改', 'Native desktop session started': '原生桌面会话已启动', 'Native terminal started': '原生终端已启动', 'Unable to start desktop session': '无法启动桌面会话', @@ -135,6 +142,9 @@ const _zh = { 'Read clipboard': '读取剪贴板', 'Write clipboard': '写入剪贴板', 'Language': '语言', + 'Close to system tray': '关闭到系统托盘', + 'Keep RemoteDesk running when the window is closed.': + '关闭窗口时让 RemoteDesk 继续在后台运行。', 'System default': '跟随系统', 'English': 'English', '简体中文': '简体中文', diff --git a/client/flutter_control/lib/app_preferences.dart b/client/flutter_control/lib/app_preferences.dart new file mode 100644 index 0000000..ddfa8c4 --- /dev/null +++ b/client/flutter_control/lib/app_preferences.dart @@ -0,0 +1,147 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class AppSettings { + const AppSettings({ + this.localeCode, + this.closeToTray = true, + this.fullscreen = true, + this.followWindow = false, + this.captureInput = true, + this.clipboardRead = true, + this.clipboardWrite = true, + this.framesPerSecond = 30, + this.resolution = '1920x1080', + }); + + final String? localeCode; + final bool closeToTray; + final bool fullscreen; + final bool followWindow; + final bool captureInput; + final bool clipboardRead; + final bool clipboardWrite; + final int framesPerSecond; + final String resolution; + + AppSettings copyWith({ + bool? closeToTray, + bool? fullscreen, + bool? followWindow, + bool? captureInput, + bool? clipboardRead, + bool? clipboardWrite, + int? framesPerSecond, + String? resolution, + }) => AppSettings( + localeCode: localeCode, + closeToTray: closeToTray ?? this.closeToTray, + fullscreen: fullscreen ?? this.fullscreen, + followWindow: followWindow ?? this.followWindow, + captureInput: captureInput ?? this.captureInput, + clipboardRead: clipboardRead ?? this.clipboardRead, + clipboardWrite: clipboardWrite ?? this.clipboardWrite, + framesPerSecond: framesPerSecond ?? this.framesPerSecond, + resolution: resolution ?? this.resolution, + ); + + AppSettings withLocale(String? value) => AppSettings( + localeCode: value, + closeToTray: closeToTray, + fullscreen: fullscreen, + followWindow: followWindow, + captureInput: captureInput, + clipboardRead: clipboardRead, + clipboardWrite: clipboardWrite, + framesPerSecond: framesPerSecond, + resolution: resolution, + ); + + @override + bool operator ==(Object other) => + other is AppSettings && + localeCode == other.localeCode && + closeToTray == other.closeToTray && + fullscreen == other.fullscreen && + followWindow == other.followWindow && + captureInput == other.captureInput && + clipboardRead == other.clipboardRead && + clipboardWrite == other.clipboardWrite && + framesPerSecond == other.framesPerSecond && + resolution == other.resolution; + + @override + int get hashCode => Object.hash( + localeCode, + closeToTray, + fullscreen, + followWindow, + captureInput, + clipboardRead, + clipboardWrite, + framesPerSecond, + resolution, + ); +} + +abstract interface class AppPreferences { + Future load(); + + Future save(AppSettings settings); +} + +class SharedAppPreferences implements AppPreferences { + const SharedAppPreferences(); + + static const _locale = 'locale'; + static const _closeToTray = 'close_to_tray'; + static const _fullscreen = 'fullscreen'; + static const _followWindow = 'follow_window'; + static const _captureInput = 'capture_input'; + static const _clipboardRead = 'clipboard_read'; + static const _clipboardWrite = 'clipboard_write'; + static const _framesPerSecond = 'frames_per_second'; + static const _resolution = 'resolution'; + + @override + Future load() async { + final preferences = await SharedPreferences.getInstance(); + final resolution = preferences.getString(_resolution); + final framesPerSecond = preferences.getInt(_framesPerSecond); + return AppSettings( + localeCode: preferences.getString(_locale), + closeToTray: preferences.getBool(_closeToTray) ?? true, + fullscreen: preferences.getBool(_fullscreen) ?? true, + followWindow: preferences.getBool(_followWindow) ?? false, + captureInput: preferences.getBool(_captureInput) ?? true, + clipboardRead: preferences.getBool(_clipboardRead) ?? true, + clipboardWrite: preferences.getBool(_clipboardWrite) ?? true, + framesPerSecond: framesPerSecond == 15 || framesPerSecond == 30 + ? framesPerSecond! + : 30, + resolution: + const {'1280x720', '1920x1080', '2560x1440'}.contains(resolution) + ? resolution! + : '1920x1080', + ); + } + + @override + Future save(AppSettings settings) async { + final preferences = await SharedPreferences.getInstance(); + if (settings.localeCode == null) { + await preferences.remove(_locale); + } else { + await preferences.setString(_locale, settings.localeCode!); + } + await Future.wait([ + preferences.setBool(_closeToTray, settings.closeToTray), + preferences.setBool(_fullscreen, settings.fullscreen), + preferences.setBool(_followWindow, settings.followWindow), + preferences.setBool(_captureInput, settings.captureInput), + preferences.setBool(_clipboardRead, settings.clipboardRead), + preferences.setBool(_clipboardWrite, settings.clipboardWrite), + preferences.setInt(_framesPerSecond, settings.framesPerSecond), + preferences.setString(_resolution, settings.resolution), + ]); + } +} diff --git a/client/flutter_control/lib/desktop_tray.dart b/client/flutter_control/lib/desktop_tray.dart index 0a0112b..c41dcf8 100644 --- a/client/flutter_control/lib/desktop_tray.dart +++ b/client/flutter_control/lib/desktop_tray.dart @@ -14,6 +14,7 @@ class DesktopTrayController Locale? _localeOverride; bool _initialized = false; bool _exiting = false; + bool _closeToTray = true; Future initialize() async { if (!Platform.isWindows || _initialized) return; @@ -45,6 +46,10 @@ class DesktopTrayController if (_initialized) await _updateMenu(); } + void updateCloseToTray(bool value) { + _closeToTray = value; + } + Locale get _effectiveLocale { final locale = _localeOverride ?? WidgetsBinding.instance.platformDispatcher.locale; @@ -115,6 +120,11 @@ class DesktopTrayController @override void onWindowClose() { - if (!_exiting) windowManager.hide(); + if (_exiting) return; + if (_closeToTray) { + windowManager.hide(); + } else { + _exitApp(); + } } } diff --git a/client/flutter_control/lib/main.dart b/client/flutter_control/lib/main.dart index e88d3dc..7dcdb4a 100644 --- a/client/flutter_control/lib/main.dart +++ b/client/flutter_control/lib/main.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/services.dart'; import 'app_localizations.dart'; +import 'app_preferences.dart'; import 'control_service.dart'; import 'desktop_tray.dart'; @@ -16,14 +18,62 @@ Future main() async { } class RemoteDeskControlApp extends StatefulWidget { - const RemoteDeskControlApp({super.key}); + const RemoteDeskControlApp({ + super.key, + this.preferences = const SharedAppPreferences(), + }); + + final AppPreferences preferences; @override State createState() => _RemoteDeskControlAppState(); } class _RemoteDeskControlAppState extends State { - Locale? _locale; + AppSettings _settings = const AppSettings(); + + Locale? get _locale => switch (_settings.localeCode) { + 'zh' => const Locale('zh', 'Hans'), + 'en' => const Locale('en'), + _ => null, + }; + + @override + void initState() { + super.initState(); + unawaited(_loadSettings()); + } + + Future _loadSettings() async { + AppSettings settings; + try { + settings = await widget.preferences.load(); + } on Object { + settings = const AppSettings(); + } + if (!mounted) return; + setState(() => _settings = settings); + _desktopTray + ..updateCloseToTray(settings.closeToTray) + ..updateLocale(_locale); + } + + void _updateSettings(AppSettings settings) { + if (settings == _settings) return; + setState(() => _settings = settings); + _desktopTray + ..updateCloseToTray(settings.closeToTray) + ..updateLocale(_locale); + unawaited(_saveSettings(settings)); + } + + Future _saveSettings(AppSettings settings) async { + try { + await widget.preferences.save(settings); + } on Object { + // Keep runtime settings usable if the preference backend is unavailable. + } + } @override Widget build(BuildContext context) { @@ -75,9 +125,10 @@ class _RemoteDeskControlAppState extends State { ), home: ControlWorkspace( locale: _locale, + settings: _settings, + onSettingsChanged: _updateSettings, onLocaleChanged: (locale) { - setState(() => _locale = locale); - unawaited(_desktopTray.updateLocale(locale)); + _updateSettings(_settings.withLocale(locale?.languageCode)); }, ), ); @@ -112,10 +163,14 @@ class ControlWorkspace extends StatefulWidget { const ControlWorkspace({ super.key, this.locale, + required this.settings, + required this.onSettingsChanged, required this.onLocaleChanged, }); final Locale? locale; + final AppSettings settings; + final ValueChanged onSettingsChanged; final ValueChanged onLocaleChanged; @override @@ -130,6 +185,7 @@ class _ControlWorkspaceState extends State { final _user = TextEditingController(); final _fingerprint = TextEditingController(); final _search = TextEditingController(); + final _searchFocus = FocusNode(); Timer? _healthTimer; Timer? _sessionTimer; @@ -145,19 +201,25 @@ class _ControlWorkspaceState extends State { bool _probing = false; bool _launching = false; bool _terminalLaunching = false; + bool _updatingForm = false; + bool _formDirty = false; - bool _fullscreen = true; - bool _followWindow = false; - bool _captureInput = true; - bool _clipboardRead = true; - bool _clipboardWrite = true; - int _framesPerSecond = 30; - String _resolution = '1920x1080'; + late bool _fullscreen; + late bool _followWindow; + late bool _captureInput; + late bool _clipboardRead; + late bool _clipboardWrite; + late int _framesPerSecond; + late String _resolution; @override void initState() { super.initState(); + _applySettings(widget.settings); _search.addListener(_refresh); + for (final controller in [_name, _address, _user, _fingerprint]) { + controller.addListener(_markFormDirty); + } unawaited(_initialize()); _healthTimer = Timer.periodic( const Duration(seconds: 5), @@ -169,6 +231,14 @@ class _ControlWorkspaceState extends State { ); } + @override + void didUpdateWidget(ControlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.settings != widget.settings) { + _applySettings(widget.settings); + } + } + @override void dispose() { _healthTimer?.cancel(); @@ -178,9 +248,26 @@ class _ControlWorkspaceState extends State { _user.dispose(); _fingerprint.dispose(); _search.dispose(); + _searchFocus.dispose(); super.dispose(); } + void _applySettings(AppSettings settings) { + _fullscreen = settings.fullscreen; + _followWindow = settings.followWindow; + _captureInput = settings.captureInput; + _clipboardRead = settings.clipboardRead; + _clipboardWrite = settings.clipboardWrite; + _framesPerSecond = settings.framesPerSecond; + _resolution = settings.resolution; + } + + void _markFormDirty() { + if (!_updatingForm && !_formDirty && mounted) { + setState(() => _formDirty = true); + } + } + void _refresh() { if (mounted) setState(() {}); } @@ -220,19 +307,29 @@ class _ControlWorkspaceState extends State { if (!result.ok) _notify(result.message, error: true); } - void _selectHost(SavedHost host) { + Future _selectHost(SavedHost host) async { + if (host.id == _selectedHostId || !await _confirmDiscardChanges()) return; setState(() { + _updatingForm = true; _selectedHostId = host.id; _name.text = host.name; _address.text = host.address; _user.text = host.user; _fingerprint.text = host.certificateSha256; _probe = null; + _formDirty = false; + _updatingForm = false; }); } - void _newHost() { + Future _newHost() async { + if (!await _confirmDiscardChanges()) return; + _clearHostEditor(); + } + + void _clearHostEditor() { setState(() { + _updatingForm = true; _selectedHostId = null; _name.clear(); _address.clear(); @@ -240,9 +337,36 @@ class _ControlWorkspaceState extends State { _fingerprint.clear(); _probe = null; _page = _WorkspacePage.devices; + _formDirty = false; + _updatingForm = false; }); } + Future _confirmDiscardChanges() async { + if (!_formDirty) return true; + final l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.text('Discard unsaved changes?')), + content: Text( + l10n.text('The current host changes have not been saved.'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.text('Keep editing')), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.text('Discard')), + ), + ], + ), + ) ?? + false; + } + SavedHost _hostFromForm() => SavedHost( id: _selectedHostId ?? @@ -271,6 +395,7 @@ class _ControlWorkspaceState extends State { if (result.ok) { _hosts = updated; _selectedHostId = host.id; + _formDirty = false; } }); _notify(result.message, error: !result.ok); @@ -309,7 +434,7 @@ class _ControlWorkspaceState extends State { if (!mounted) return; if (result.ok) { setState(() => _hosts = updated); - _newHost(); + _clearHostEditor(); } _notify(result.message, error: !result.ok); } @@ -409,7 +534,27 @@ class _ControlWorkspaceState extends State { ); } - void _updateSessionDefaults(VoidCallback update) => setState(update); + void _updateSessionDefaults(VoidCallback update) { + setState(update); + widget.onSettingsChanged( + widget.settings.copyWith( + fullscreen: _fullscreen, + followWindow: _followWindow, + captureInput: _captureInput, + clipboardRead: _clipboardRead, + clipboardWrite: _clipboardWrite, + framesPerSecond: _framesPerSecond, + resolution: _resolution, + ), + ); + } + + void _showPage(_WorkspacePage page) => setState(() => _page = page); + + void _focusSearch() { + setState(() => _page = _WorkspacePage.devices); + _searchFocus.requestFocus(); + } @override Widget build(BuildContext context) { @@ -422,59 +567,83 @@ class _ControlWorkspaceState extends State { serviceStatus: _serviceStatus, serviceMessage: _serviceMessage, onRefresh: _initialize, - onAdd: _page == _WorkspacePage.devices ? _newHost : null, + onAdd: _page == _WorkspacePage.devices + ? () => unawaited(_newHost()) + : null, ), const SizedBox(height: 18), Expanded(child: _buildPage()), ], ); - return Scaffold( - body: SafeArea( - child: compact - ? Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), - child: content, - ) - : Row( - children: [ - _AppRail( - page: _page, - status: _serviceStatus, - onChanged: (page) => setState(() => _page = page), + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.keyN, control: true): () => + unawaited(_newHost()), + const SingleActivator(LogicalKeyboardKey.keyS, control: true): () { + if (_page == _WorkspacePage.devices) unawaited(_saveHost()); + }, + const SingleActivator(LogicalKeyboardKey.keyF, control: true): + _focusSearch, + const SingleActivator(LogicalKeyboardKey.f5): () => + unawaited(_initialize()), + const SingleActivator(LogicalKeyboardKey.digit1, alt: true): () => + _showPage(_WorkspacePage.devices), + const SingleActivator(LogicalKeyboardKey.digit2, alt: true): () => + _showPage(_WorkspacePage.sessions), + const SingleActivator(LogicalKeyboardKey.digit3, alt: true): () => + _showPage(_WorkspacePage.settings), + }, + child: Focus( + autofocus: true, + child: Scaffold( + body: SafeArea( + child: compact + ? Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), + child: content, + ) + : Row( + children: [ + _AppRail( + page: _page, + status: _serviceStatus, + onChanged: _showPage, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(24), + child: content, + ), + ), + ], ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(24), - child: content, + ), + bottomNavigationBar: compact + ? NavigationBar( + selectedIndex: _page.index, + onDestinationSelected: (index) => + _showPage(_WorkspacePage.values[index]), + destinations: [ + NavigationDestination( + icon: const Icon(Icons.computer_outlined), + selectedIcon: const Icon(Icons.computer), + label: l10n.text('Devices'), ), - ), - ], - ), + NavigationDestination( + icon: const Icon(Icons.monitor_heart_outlined), + selectedIcon: const Icon(Icons.monitor_heart), + label: l10n.text('Sessions'), + ), + NavigationDestination( + icon: const Icon(Icons.tune_outlined), + selectedIcon: const Icon(Icons.tune), + label: l10n.text('Settings'), + ), + ], + ) + : null, + ), ), - bottomNavigationBar: compact - ? NavigationBar( - selectedIndex: _page.index, - onDestinationSelected: (index) => - setState(() => _page = _WorkspacePage.values[index]), - destinations: [ - NavigationDestination( - icon: const Icon(Icons.computer_outlined), - selectedIcon: const Icon(Icons.computer), - label: l10n.text('Devices'), - ), - NavigationDestination( - icon: const Icon(Icons.monitor_heart_outlined), - selectedIcon: const Icon(Icons.monitor_heart), - label: l10n.text('Sessions'), - ), - NavigationDestination( - icon: const Icon(Icons.tune_outlined), - selectedIcon: const Icon(Icons.tune), - label: l10n.text('Settings'), - ), - ], - ) - : null, ); } @@ -483,7 +652,7 @@ class _ControlWorkspaceState extends State { _WorkspacePage.sessions => _SessionsPage( sessions: _sessions, onRefresh: _refreshSessions, - onOpenDevices: () => setState(() => _page = _WorkspacePage.devices), + onOpenDevices: () => _showPage(_WorkspacePage.devices), ), _WorkspacePage.settings => _SettingsPage(state: this), }; @@ -707,9 +876,17 @@ class _HostList extends StatelessWidget { children: [ TextField( controller: state._search, + focusNode: state._searchFocus, decoration: InputDecoration( hintText: l10n.text('Search hosts'), - prefixIcon: Icon(Icons.search), + prefixIcon: const Icon(Icons.search), + suffixIcon: query.isEmpty + ? null + : IconButton( + tooltip: l10n.text('Clear search'), + onPressed: state._search.clear, + icon: const Icon(Icons.close), + ), ), ), const SizedBox(height: 12), @@ -718,8 +895,15 @@ class _HostList extends StatelessWidget { else if (hosts.isEmpty) _EmptyState( icon: Icons.dns_outlined, - title: l10n.text('No saved hosts'), - actionLabel: l10n.text('Add host'), + title: l10n.text( + query.isEmpty ? 'No saved hosts' : 'No matching hosts', + ), + actionLabel: l10n.text( + query.isEmpty ? 'Add host' : 'Clear search', + ), + onAction: query.isEmpty + ? () => unawaited(state._newHost()) + : state._search.clear, ) else ConstrainedBox( @@ -756,7 +940,7 @@ class _HostList extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - onTap: () => state._selectHost(host), + onTap: () => unawaited(state._selectHost(host)), ); }, ), @@ -795,6 +979,18 @@ class _HostEditor extends StatelessWidget { ?.copyWith(fontWeight: FontWeight.w700), ), ), + if (state._formDirty) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + l10n.text('Unsaved changes'), + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ), IconButton( tooltip: l10n.text('Delete host'), onPressed: state._selectedHostId == null @@ -1134,6 +1330,9 @@ class _SettingsPage extends StatelessWidget { child: LayoutBuilder( builder: (context, constraints) { final languageField = DropdownButtonFormField( + key: ValueKey( + state.widget.locale?.languageCode ?? 'system', + ), initialValue: state.widget.locale == null ? 'system' : state.widget.locale!.languageCode, @@ -1192,6 +1391,23 @@ class _SettingsPage extends StatelessWidget { ), ), const SizedBox(height: 16), + _Surface( + child: SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.move_to_inbox_outlined), + title: Text(l10n.text('Close to system tray')), + subtitle: Text( + l10n.text( + 'Keep RemoteDesk running when the window is closed.', + ), + ), + value: state.widget.settings.closeToTray, + onChanged: (value) => state.widget.onSettingsChanged( + state.widget.settings.copyWith(closeToTray: value), + ), + ), + ), + const SizedBox(height: 16), _RendererControls(state: state, compact: false), const SizedBox(height: 16), _Surface( @@ -1253,6 +1469,7 @@ class _RendererControls extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField( + key: ValueKey(state._resolution), initialValue: state._resolution, decoration: InputDecoration( labelText: l10n.text('Stream resolution'), diff --git a/client/flutter_control/pubspec.lock b/client/flutter_control/pubspec.lock index a7052fd..832f698 100644 --- a/client/flutter_control/pubspec.lock +++ b/client/flutter_control/pubspec.lock @@ -49,6 +49,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -72,6 +88,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" intl: dependency: transitive description: @@ -160,6 +181,38 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -208,6 +261,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.2.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" shortid: dependency: transitive description: @@ -293,6 +402,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" window_manager: dependency: "direct main" description: @@ -301,6 +418,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.5.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" sdks: dart: ">=3.11.5 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.0" diff --git a/client/flutter_control/pubspec.yaml b/client/flutter_control/pubspec.yaml index 852b72e..e4aff6c 100644 --- a/client/flutter_control/pubspec.yaml +++ b/client/flutter_control/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + shared_preferences: ^2.5.5 tray_manager: ^0.5.3 window_manager: ^0.5.2 diff --git a/client/flutter_control/test/widget_test.dart b/client/flutter_control/test/widget_test.dart index 9b1a5a9..ccb9daa 100644 --- a/client/flutter_control/test/widget_test.dart +++ b/client/flutter_control/test/widget_test.dart @@ -1,8 +1,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:remotedesk_control/app_preferences.dart'; import 'package:remotedesk_control/control_service.dart'; import 'package:remotedesk_control/main.dart'; +class _MemoryPreferences implements AppPreferences { + _MemoryPreferences(this.settings); + + AppSettings settings; + + @override + Future load() async => settings; + + @override + Future save(AppSettings settings) async { + this.settings = settings; + } +} + void main() { test('saved host JSON round-trips control-service fields', () { const host = SavedHost( @@ -75,4 +90,71 @@ void main() { expect(find.text('语言'), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); }); + + testWidgets('restores and saves workspace preferences', (tester) async { + final preferences = _MemoryPreferences( + const AppSettings(localeCode: 'zh', closeToTray: false), + ); + await tester.pumpWidget(RemoteDeskControlApp(preferences: preferences)); + await tester.pump(); + await tester.pump(); + + expect(find.text('设备'), findsOneWidget); + await tester.tap(find.byIcon(Icons.tune_outlined)); + await tester.pump(); + + final closeToTray = tester.widget( + find.widgetWithText(SwitchListTile, '关闭到系统托盘'), + ); + expect(closeToTray.value, isFalse); + await tester.tap(find.text('关闭到系统托盘')); + await tester.pump(); + expect(preferences.settings.closeToTray, isTrue); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('clears a host search with no matches', (tester) async { + await tester.pumpWidget( + RemoteDeskControlApp( + preferences: _MemoryPreferences(const AppSettings()), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'missing-host'); + await tester.pump(); + expect(find.text('No matching hosts'), findsOneWidget); + + await tester.tap(find.byTooltip('Clear search')); + await tester.pump(); + expect(find.text('No saved hosts'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('confirms before discarding host edits', (tester) async { + await tester.pumpWidget( + RemoteDeskControlApp( + preferences: _MemoryPreferences(const AppSettings()), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Display name'), + 'Unsaved host', + ); + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + expect(find.text('Discard unsaved changes?'), findsOneWidget); + await tester.tap(find.text('Keep editing')); + await tester.pump(); + expect(find.text('Unsaved host'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + }); } diff --git a/client/helpers/linux-terminal/src/main.rs b/client/helpers/linux-terminal/src/main.rs index 4a79dd4..e237527 100644 --- a/client/helpers/linux-terminal/src/main.rs +++ b/client/helpers/linux-terminal/src/main.rs @@ -1259,15 +1259,16 @@ async fn authenticate( where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { - authenticate_with_pairing_code(socket, args, signing_key, pair, None).await + authenticate_with_codes(socket, args, signing_key, pair, None, None).await } -async fn authenticate_with_pairing_code( +async fn authenticate_with_codes( socket: &mut tokio_tungstenite::WebSocketStream, args: &Args, signing_key: &SigningKey, pair: bool, supplied_pairing_code: Option<&str>, + supplied_totp_code: Option<&str>, ) -> Result where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, @@ -1289,6 +1290,7 @@ where edge_signaling_online, .. } if protocol_major == PROTOCOL_MAJOR + && protocol_minor >= 16 && ((needs_desktop && desktop) || (needs_files && files) || (!needs_desktop && !needs_files && terminal)) => @@ -1348,6 +1350,27 @@ where } }; send_command(socket, &command).await?; + match receive_event(socket).await? { + ServerEvent::TotpRequired { + digits: 6, + period_seconds: 30, + } => {} + ServerEvent::Error { code, message } => { + return Err(format!("Agent authentication failed ({code}): {message}")); + } + _ => return Err("Linux Agent did not require mandatory TOTP verification".into()), + } + let totp_code = match supplied_totp_code { + Some(code) => code.to_owned(), + None => Password::new("Linux Agent TOTP code:") + .without_confirmation() + .prompt() + .map_err(|_| "TOTP input was cancelled".to_owned())?, + }; + if totp_code.len() != 6 || !totp_code.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("TOTP code must contain exactly six digits".into()); + } + send_command(socket, &ClientCommand::VerifyTotp { code: totp_code }).await?; let required_permission = if args.transfer.is_some() { "files" } else if args.desktop.is_some() { @@ -1987,6 +2010,21 @@ mod tests { let command: ClientCommand = serde_json::from_slice(&socket.next().await.unwrap().unwrap().into_data()) .unwrap(); + socket + .send(Message::Text( + serde_json::to_string(&ServerEvent::TotpRequired { + digits: 6, + period_seconds: 30, + }) + .unwrap() + .into(), + )) + .await + .unwrap(); + let totp: ClientCommand = + serde_json::from_slice(&socket.next().await.unwrap().unwrap().into_data()) + .unwrap(); + assert!(matches!(totp, ClientCommand::VerifyTotp { code } if code == "123456")); match (connection_index, command) { ( 0, @@ -2109,12 +2147,13 @@ mod tests { let mut paired = connect_agent(&agent_url, fingerprint, Some(&pairing_session.relay)) .await .unwrap(); - let authenticated = authenticate_with_pairing_code( + let authenticated = authenticate_with_codes( &mut paired, &pair_args, &client_signing, true, Some(&pairing_code), + Some("123456"), ) .await .unwrap(); @@ -2146,9 +2185,16 @@ mod tests { let mut relayed = connect_agent(&agent_url, fingerprint, Some(&authorized.relay)) .await .unwrap(); - let authenticated = authenticate(&mut relayed, &args, &client_signing, false) - .await - .unwrap(); + let authenticated = authenticate_with_codes( + &mut relayed, + &args, + &client_signing, + false, + None, + Some("123456"), + ) + .await + .unwrap(); assert_eq!(authenticated.device_public_key, identity.public_key); relayed .send(Message::Binary(b"known-over-edge".to_vec().into())) diff --git a/docs/ci-drone.md b/docs/ci-drone.md index b06ff91..aad000c 100644 --- a/docs/ci-drone.md +++ b/docs/ci-drone.md @@ -1,20 +1,30 @@ # Drone CI 打包 -仓库提供 `.drone.yml`,包含两个独立流水线: +仓库提供 `.drone.yml`,包含三个独立流水线。Drone 是仓库唯一的 CI 配置,仓库不包含 GitHub Actions workflow: - `linux-package` 使用 Linux Docker runner,执行格式检查、工作区测试和 Clippy,然后生成 Linux Agent 的 DEB/RPM 与 Edge DEB。 +- `android-package` 使用固定 Flutter 3.41.9 Docker 镜像,执行依赖锁定、格式检查、analyze 和测试,然后生成 Android release APK。 - `windows-package` 使用 Windows Exec runner,执行格式检查和工作区测试,然后调用 `packaging/build-all.ps1` 生成同时包含 Windows Client/Host 的单一便携 ZIP 和统一校验清单。 -两条流水线都会在 `push`、`pull_request` 和 `tag` 事件运行。Windows 流水线严格要求一个 ZIP,并拒绝 ZIP 和 `BUILD-SHA256SUMS.txt` 之外的额外产物。 +三条流水线都会在 `push`、`pull_request` 和 `tag` 事件运行。Windows 流水线严格要求一个 ZIP,并拒绝 ZIP 和 `BUILD-SHA256SUMS.txt` 之外的额外产物。Android 流水线先运行不含任何签名 secret 的测试步骤;push/pull request 只执行使用 Android debug 证书的 `package-test`,tag 只执行注入正式签名 secret 的 `package-release`。tag 缺少 secret 时立即失败,不会回退成 debug 签名的正式产物。 ## Runner 要求 Linux Docker runner 使用 `rust:1.89-bookworm`,流水线会安装 GStreamer 开发包、`rpm`、`pkg-config` 和 CA 证书。Runner 需要允许访问 crates.io 以及项目依赖的 HTTPS 服务。 -Windows Exec runner 需要 Rust stable(包含 `rustfmt` 和 `cargo`)与 PowerShell。Runner 应使用 x64 Windows 主机;不需要 WiX、NuGet、Node.js、C# 编译器或 .NET 构建链。 +Android Docker runner 需要能够拉取 `ghcr.io/cirruslabs/flutter:3.41.9`,并访问 Flutter/Dart/Gradle/Maven 依赖源。正式 tag 构建需要在 Drone 仓库 secrets 中配置: + +- `android_keystore_base64`:整个 JKS/PKCS12 keystore 的标准 Base64 文本。 +- `android_keystore_password`:keystore 密码。 +- `android_key_alias`:签名 key alias。 +- `android_key_password`:签名 key 密码。 + +打包脚本只把 keystore 解码到 tag 步骤容器的临时目录,设置 `0600` 权限,并在退出时删除。keystore、密码和 alias 不会写入仓库、构建目录、APK 文件名或校验清单。Gradle 只接受四项完整的签名环境,部分配置会失败。Drone 中这些 secrets 不要开启 pull request 访问权限;`.drone.yml` 也不会在 push/pull request 步骤引用它们。 + +Windows Exec runner 需要 Rust stable(包含 `rustfmt` 和 `cargo`)、Flutter stable(包含 Windows desktop 支持)、Visual Studio C++ desktop workload 与 PowerShell。Runner 应使用 x64 Windows 主机;不需要 WiX、NuGet、Node.js、C# 编译器或 .NET 构建链。Windows 测试分成两个 Cargo 调用,避免同一 feature graph 同时启用 `opus2` 的 libopus 与 mousiki 互斥后端;两个调用合起来仍覆盖整个工作区。 ## 产物 -Linux 产物位于 `artifacts/`,并生成 `DRONE-SHA256SUMS.txt`。Windows 产物位于 `artifacts/`,并生成 `BUILD-SHA256SUMS.txt`。Drone 本身不会跨 Runner 自动保存工作区文件,部署时应给 Runner 配置对象存储/制品插件或主机卷,将这两个目录上传到构建编号对应的路径;上传步骤不包含在仓库中,避免把组织级凭据和存储端点写入源码。 +Linux 产物位于 `artifacts/`,并生成 `DRONE-SHA256SUMS.txt`。Android 产物位于 `artifacts/`,包含 APK、单包 `.sha256` 和 `DRONE-ANDROID-SHA256SUMS.txt`。Windows 产物位于 `artifacts/`,并生成 `BUILD-SHA256SUMS.txt`。Drone 本身不会跨 Runner 自动保存工作区文件,部署时应给 Runner 配置对象存储/制品插件或主机卷,将对应目录上传到构建编号路径;上传步骤不包含在仓库中,避免把组织级凭据和存储端点写入源码。 建议制品路径使用 `${DRONE_REPO_OWNER}/${DRONE_REPO_NAME}/${DRONE_BUILD_NUMBER}`,并只允许 tag 构建进入正式发布桶。发布前必须再次验证清单中的 SHA-256。 diff --git a/docs/development.md b/docs/development.md index 64ac572..220d420 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,7 +5,7 @@ - Rust stable,包含 rustfmt 和 Clippy。 - Windows 管理界面使用 Flutter/Dart;原生媒体与 helper 继续使用 Rust、Media Foundation 和 D3D11。仓库不需要 Node.js、npm、JavaScript、C# 编译器或 WebView 构建链。 - Linux PipeWire、Portal、DRI3 与硬件编码器必须在对应 Linux 测试机执行 M0 Spike。 -- Drone CI 的 Linux/Windows 打包流水线、Runner 前置条件和产物留存约定见 [Drone CI 打包](ci-drone.md)。 +- Drone CI 的 Linux/Android/Windows 打包流水线、Android tag 签名 secrets、Runner 前置条件和产物留存约定见 [Drone CI 打包](ci-drone.md)。仓库不使用 GitHub Actions。 ## 1.1 Windows Headless compatibility 验收 diff --git a/docs/edge-relay.md b/docs/edge-relay.md index e394490..054c384 100644 --- a/docs/edge-relay.md +++ b/docs/edge-relay.md @@ -331,7 +331,7 @@ Control plane: ## 17. 实施阶段 -当前仓库已交付独立 `remotedesk-edge-service`:短 TTL Presence、管理员/Agent 分权认证、Direct/Single/Dual 评分、coturn REST 凭据、签名 Session Intent 队列和限额透明 TCP byte relay。Intent 使用共享的域隔离、字段标签、长度前缀稳定签名输入,最长 120 秒、request ID 幂等、全局/设备/Client 容量有界并带 15 秒投递租约;Windows Client 以 Ed25519 key 直接认证公开提交和状态查询,不持有 Edge 管理员令牌。Agent poll/ack 除 Presence Token 外还必须提供设备私钥签名、短时钟窗口和一次性 nonce,且 connection ID 必须仍是当前 Presence。普通会话由 Linux Agent 最终验证已配对 Client、permission 和 allowed user;`pairing` 会话只在 Agent 本机存在未过期、未耗尽且允许目标用户/terminal 的配对窗口时接受。Edge 随后分别交付 Client/Agent 角色绑定一次性票据,Agent 主动连接 relay 并代理本地 TLS listener;Windows helper 在 opaque relay 内重新完成证书固定 TLS/WSS、Agent 公钥核对、Client challenge 和一次性配对码验证,配对码不进入 Edge API。成功后 Windows Credential Manager 以证书指纹绑定 Agent 公钥,后续会话同样无需 Client 连接 Agent listener。控制端提供映射状态和显式删除。 +当前仓库已交付独立 `remotedesk-edge-service`:短 TTL Presence、管理员/Agent 分权认证、Direct/Single/Dual 评分、coturn REST 凭据、签名 Session Intent 队列和限额透明 TCP byte relay。Relay 数据面在单个 Tokio runtime 上使用有界握手并发和异步会话任务,不为每个连接或传输方向创建 OS 线程;状态仍是单节点内存实现,不代表多 POP 高可用。Intent 使用共享的域隔离、字段标签、长度前缀稳定签名输入,最长 120 秒、request ID 幂等、全局/设备/Client 容量有界并带 15 秒投递租约;Windows Client 以 Ed25519 key 直接认证公开提交和状态查询,不持有 Edge 管理员令牌。Agent 首次启动自动生成并保护设备身份,不要求用户准备密钥文件。Presence register/unregister 和 signal poll/ack 除 Presence Token 外都必须提供设备私钥签名、短时钟窗口和一次性 nonce;Presence 签名额外绑定公钥派生的 Device ID、region、gateway、connection、动作和租约,因而共享 token 不能伪造、覆盖或删除其他设备。普通会话由 Linux Agent 最终验证已配对 Client、permission 和 allowed user;`pairing` 会话只在 Agent 本机存在未过期、未耗尽且允许目标用户/terminal 的配对窗口时接受。Edge 随后分别交付 Client/Agent 角色绑定一次性票据,Agent 主动连接 relay 并代理本地 TLS listener;Windows helper 在 opaque relay 内重新完成证书固定 TLS/WSS、Agent 公钥核对、Client challenge 和一次性配对码验证,配对码不进入 Edge API。成功后 Windows Credential Manager 以证书指纹绑定 Agent 公钥,后续会话同样无需 Client 连接 Agent listener。控制端提供映射状态和显式删除。 已接受且未过期的会话另外开放 `POST /v1/negotiation/send` 和 `POST /v1/negotiation/poll`。每次请求由发起端设备 key 签名,稳定输入覆盖 request/session、Client/Agent 角色、send/poll、sequence、generation、offer/answer/candidate/end/restart、payload、一次性 nonce 和最长 30 秒生命周期。Edge 从已接受 Intent 和 Agent ack 取得两端公钥,不使用共享 Presence token 认证协商流量。mailbox 只向调用者返回 peer 消息,并限制会话、消息、candidate、单次响应和总内存;只有 `restart` 能将共享 generation 精确增加一。联合测试通过真实 Intent、Agent poll/ack 建立绑定后连续验证双向 offer/answer,以及端点伪造、nonce 重放、错误会话、乱序和错误 generation 拒绝。`GET /health` 以 `sdp_ice_signaling=true` 和 `active_negotiation_sessions` 报告这个服务端边界。 diff --git a/docs/implementation-status.md b/docs/implementation-status.md index d015c9c..7d7e60b 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -47,7 +47,7 @@ | Windows 控制端自动升级 | 未提供 | 便携版通过新目录解压和 SHA-256 验证手工升级;运行时自动安装能力关闭 | | 协议与策略核心 | 部分 | 会话状态、显示布局、required features、零拷贝报告和 GPU 规划已有纯 Rust 模型及测试 | | Native Video Helper | 部分 | 保留严格/兼容 GPU 管线规划和 Windows 硬件 D3D11 device/immediate-context 创建探针;H.264/DXVA allocation probe 源码会枚举适配器真实 H.264 VLD profiles、验证 NV12、选择未加密 raw-bitstream config,并实际创建 decoder、NV12 texture array 和 output view;`--probe-h264-file` 使用绑定同一 device manager 的 Media Foundation Source Reader,在最多 512 次读取内严格验证真实 H.264 文件的 NV12 `IMFDXGIBuffer`、完整 subresource layout 和所属 device。`--play-h264-file` 本地窗口链路有界枚举并选中 H.264 原生类型、跟踪动态 NV12 媒体类型和可见帧尺寸、拒绝 CPU buffer/跨 device texture,以媒体时间戳调度帧;同一 device 的 `ID3D11VideoProcessor` 将 NV12 texture subresource 转换和等比缩放到 BGRA swap chain。Windows Linux 桌面 helper 进一步接入 RFC 6184 单 NAL/STAP-A/FU-A 重组、16 MiB AU 上限、跨帧 RTP 序号检测、PLI/关键帧恢复和一槽有界邮箱;Annex-B 输入送入 D3D11-aware Media Foundation H.264 MFT,只接受同 device NV12 `IMFDXGIBuffer`,再由 VideoProcessor 呈现到现有桌面窗口。协议 minor 9 在第一张原生帧呈现后执行帧边界模式确认,确认后停止完整 zlib 编码/传输,以 sequence 绑定的 H.264 呈现 ACK 驱动下一次捕获;解码、积压、轨道、sender/encoder 或 ACK 超时均显式恢复 zlib。H.264 采集、编码、解码和呈现耗时分别进入质量诊断。该链路按要求尚未编译/实机执行,且 DXGI surface/VideoProcessor 呈现不能替代 ETW/GPUView 的硬解和隐藏复制证明,所以继续报告 `hardware_decode_verified=false`、`zero_copy_verified=false` 和 `linux.native_video=false`;IronRDP viewer 继续使用独立 D3D11 CPU framebuffer 路径 | -| Linux Agent 基础 | 部分 | 四个可运行进程已实现设备身份、限时一次性配对码、配对/重连 Ed25519 挑战签名、证书指纹固定、TLS WebSocket、`SO_PEERCRED` Unix IPC、systemd 服务和健康 CLI;控制 helper 源码新增无需用户认证的固定证书 TLS/WSS `Hello` 探测,控制 API 与 UI 可显示实际握手耗时、协议版本、终端/桌面/文件能力和 Edge 在线状态,不读取 Client 私钥或配对码;可选 Edge 客户端使用独立作用域令牌主动注册 60 秒短 TTL 路由、每 30 秒续期并在优雅退出时 connection-bound 注销,在心跳间隔内用设备私钥和一次性 nonce 主动 poll/ack 签名 Session Intent,只有 Client Ed25519 签名、本地配对公钥、permission 与 allowed user 全部通过才接受;在线/信令计数进入 CLI 和 WSS Hello/Status;已生成静态 x64 便携包与 DEB,RPM/原生 DEB 由 Ubuntu CI 构建;新增探测按要求尚未编译,整体仍未在 Linux 主机运行验证 | +| Linux Agent 基础 | 部分 | 四个可运行进程已实现设备身份、限时一次性配对码、配对/重连 Ed25519 挑战签名、证书指纹固定、TLS WebSocket、`SO_PEERCRED` Unix IPC、systemd 服务和健康 CLI;控制 helper 源码新增无需用户认证的固定证书 TLS/WSS `Hello` 探测,控制 API 与 UI 可显示实际握手耗时、协议版本、终端/桌面/文件能力和 Edge 在线状态,不读取 Client 私钥或配对码;可选 Edge 客户端首次启动自动生成并保护设备身份,使用独立作用域令牌及设备签名主动注册 60 秒短 TTL 路由、每 30 秒续期并在优雅退出时 connection-bound 签名注销,在心跳间隔内用设备私钥和一次性 nonce 主动 poll/ack 签名 Session Intent,用户无需提供密钥文件,只有 Client Ed25519 签名、本地配对公钥、permission 与 allowed user 全部通过才接受;在线/信令计数进入 CLI 和 WSS Hello/Status;已生成静态 x64 便携包与 DEB,RPM/原生 DEB 由 Ubuntu CI 构建;相关 Agent runtime 自动化测试已通过,整体仍未在 Linux 主机运行验证 | | Linux 终端会话 | 部分 | Agent 端具备权限绑定、普通用户限制、`runuser --login` PAM 会话、PTY 字节流/resize/interrupt 和生命周期回收;Windows 控制端 helper 使用固定 TLS 证书 WSS、Credential Manager 中的 Ed25519 身份、一次性本地遮罩配对、challenge 签名认证、原始按键/ANSI 输出和 resize,并可由 UI 启动独立终端窗口;配置 Edge 并录入 Agent 设备公钥后,首次配对和后续会话均无需 Client 直达 Agent,分别提交签名 `pairing`/`terminal` Intent,并在角色绑定 opaque relay 内完成证书固定 TLS/WSS、Agent 公钥核对、Client challenge 和一次性码认证;公钥随后按证书指纹保存,UI 与控制 API 可查询或显式删除,Windows 自动化测试覆盖真实 Credential Manager 往返;联合测试连续覆盖远程首次配对、已知设备重连、双向字节和票据重放,尚缺真实 Linux/PAM 端到端验收 | | Linux 文件传输 | 可用 | 与终端共用证书固定、Ed25519 配对身份、Edge opaque relay 和独立 `files` 权限;上传/下载限制为授权普通用户主目录内相对路径和 2 GiB,使用 45 KiB 分块、声明大小及 SHA-256 端到端校验;协议 minor 4 支持双向断点续传:上传 transfer ID 绑定 user/remote path/size/hash,降权 helper 从同 ID 的普通文件实测 offset、保留未完成 partial 并在整文件校验后原子替换;下载由本地隐藏 partial 长度请求 offset,Agent 按远端真实大小校验,只发送剩余字节,Client 重新哈希已有前缀和新增数据后才改名,最终哈希不符会删除 partial;Ubuntu CI 脚本实际中断 4/10 字节后续传并验证上传/下载剩余段;UI 可启动独立传输窗口,仍待真实 Linux 主机专项验收 | | Linux X11 桌面 | 部分 | 协议 minor 10 源码包含 `desktop` 权限、严格 zlib BGRA 分块/哈希/呈现 ACK、RTT、X11 `GetImage`/XTEST、绝对及有界相对鼠标、五键/滚轮/键盘、失焦释放、全屏/退出、跟随窗口 resize、三次有界重连和完整质量诊断;Windows helper 可通过 Raw Input 合并相对位移并显式抓取/释放光标。用户会话使用真实呈现 ACK 的 1..30 FPS 有界自适应 pacer,并依据真实编码预算与 ACK 在 zlib level 1..6 内调整压缩策略。Edge 桌面会话复用同一次 BGRA 捕获,以 GStreamer 按 VA/VAAPI/NVENC/QSV/OpenH264/x264 顺序建立 H.264 Annex-B AU 编码器;16 MiB AU 上限、45 KiB 分块、严格顺序、canonical base64、精确长度和 SHA-256 经 root/user IPC 验证后交给 WebRTC sender。Windows helper 已接入远端 H.264 RTP 到 Media Foundation/D3D11 原生窗口;第一帧呈现后双方在完整帧边界切换至 H.264-only,以呈现 ACK 驱动 pacer,不再并行传输完整 zlib 帧。RTP 序号中断立即触发回退;积压、重连、解码失败、媒体终止或三秒 H.264 ACK 超时也会恢复 zlib。协议 minor 14 源码增加默认关闭的 `opus_audio` 协商:只有同版本 Client 明确请求且已绑定 WebRTC sender 时,用户会话才从 Linux 输出 monitor 捕获 48 kHz 双声道 Opus,经 4 KiB 有界 IPC/SRTP 发送并由 Windows WASAPI 播放;音频与视频失败相互独立,旧客户端不接收音频事件。协议 minor 15 源码增加独立 `clipboard_read`/`clipboard_write` 权限、X11 selection 与 Windows `CF_UNICODETEXT` 双向纯文本剪贴板、Offer/Request/Data 按需传输、32 KiB/UTF-8/NUL/canonical Base64/SHA-256 校验和摘要防回环;root agentd 只授权和转发结构化消息。WSS/Edge 异常断开时立即释放输入并保留 15 秒 X11 租约、帧序列、pacer 和压缩控制器;32 字节随机恢复令牌绑定 agentd 注入的已认证 Client 指纹与 Linux 用户。新增输入/媒体/音频/剪贴板链路尚未解析依赖、编译或在真实 Windows/Xorg 主机验收,因此仍不标记为原生视频可用;Wayland 及 Wayland 剪贴板仍关闭。 | diff --git a/docs/protocol.md b/docs/protocol.md index 8a884e5..562441c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -250,6 +250,8 @@ Hysteria2 音频包使用独立的 RDA1 datagram,不进入视频分片重组 当前 Edge HTTP 信令边界使用域隔离的 `EdgeNegotiationEnvelopeV1`,只允许已被 Agent 接受且仍有效的 Session Intent 建立 mailbox。send/poll 都由对应 Client 或 Agent Ed25519 key 签名;签名覆盖 request ID、session ID、端点角色、操作、sequence、generation、消息类型、payload、nonce、签发和过期时间。send sequence 从 1 开始并按端点严格递增;共享 generation 从 1 开始,只有 `restart` 可精确加一;poll 的 sequence 是已消费的 peer cursor。服务端拒绝过期、重放、跨会话、跨角色、乱序、错误 generation 和超限消息,只返回 peer 产生的消息。 +Agent Presence 注册、续期和注销使用域隔离的 `AgentPresenceProofV1`。Agent 首次启动自动生成并保护 Ed25519 设备身份,不要求用户提供密钥文件;Device ID 从设备公钥稳定派生。签名覆盖 Device ID、公钥、region、gateway、connection ID、register/unregister 动作、注册租约到期时间、一次性 nonce 和签发时间。Edge 在修改路由前验证身份绑定、最长 30 秒时钟偏差和 nonce 未使用,因此共享 Presence token 只能作为 API 访问凭据,不能单独创建、覆盖或删除设备路由。Agent signal poll/ack 继续使用独立的设备签名证明和一次性 nonce。 + 资源上限为 SDP 128 KiB、单 candidate 4 KiB、每端点每 generation 128 candidates、每会话 512 消息、单次 poll 64 条且 payload 最多 192 KiB、最多 1024 活跃 mailbox 和总 payload 8 MiB。该 mailbox 只完成控制信令转发;DTLS fingerprint、TURN credential 和最终媒体连接仍必须由端点 WebRTC 层验证。 ### 9.1 Edge Path Messages @@ -375,6 +377,8 @@ zero_copy_verified 协议 minor 15 将文本剪贴板接入 X11 桌面会话。`begin_direct_web_rtc` 和 `open_desktop` 新增默认关闭的 `clipboard_read`、`clipboard_write`,分别表示 Client 可读取被控端剪贴板、可写入被控端剪贴板;agentd 必须再次对照配对授权检查方向,并只透明转发结构化消息,root 进程不得读取内容。发送方先用单调非零 sequence、UTF-8 字节数和小写 SHA-256 发送 `DesktopClipboardOffer`,接收方验证 32 KiB 上限后以同 sequence 请求,发送方才返回无 padding 的 canonical Base64 `DesktopClipboardData`。接收方将 Data 与当前待处理 Offer 的 sequence、长度和摘要绑定,拒绝无效 UTF-8、NUL、非规范 Base64、CR/CRLF 未规范化或摘要不匹配的内容。双方记录最近应用/发出的摘要,避免系统剪贴板所有权变化造成回写循环;只保留最新待请求内容。minor 14 及更早客户端缺少协商字段时两方向均为 `false`。当前源码实现 X11 与 Windows `CF_UNICODETEXT`,Wayland Portal 剪贴板尚未接入。 +协议 minor 16 在 Ed25519 challenge 签名成功后增加强制 TOTP 步骤。Agent 发送 `TotpRequired { digits: 6, period_seconds: 30 }`,Client 必须在 30 秒内返回 `VerifyTotp`,随后 Agent 才能消费一次性配对码或返回 `Authenticated`。Agent 不保存客户端提交的动态码,对失败来源执行限流,并按已验证 Client 公钥拒绝同一时间步重放。未配置 TOTP 的 agentd 拒绝启动;Edge relay 仅透明转发这段固定证书的 TLS/WSS 流量,不参与 TOTP 校验。 + ## 14. 文件传输 ```text diff --git a/edge/edge-service/Cargo.toml b/edge/edge-service/Cargo.toml index ca7d2c6..fd16040 100644 --- a/edge/edge-service/Cargo.toml +++ b/edge/edge-service/Cargo.toml @@ -18,6 +18,8 @@ serde_json = "=1.0.151" sha1 = "=0.11.0" sha2 = "=0.11.0" tiny_http = "=0.12.0" +tokio = { version = "=1.53.1", features = ["io-util", "macros", "net", "rt", "sync", "time"] } +url = "=2.5.8" zeroize = "=1.9.0" [lints] diff --git a/edge/edge-service/src/api.rs b/edge/edge-service/src/api.rs index ce211ec..3926967 100644 --- a/edge/edge-service/src/api.rs +++ b/edge/edge-service/src/api.rs @@ -5,13 +5,15 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; +use url::Url; use zeroize::Zeroizing; use crate::{ AgentPollProof, AgentSignalAck, ClientSignalStatusRequest, EnqueueResult, NegotiationRegistry, - PathSelectionRequest, Presence, PresenceRegistry, RelayAccess, RelayBroker, RelayTicketIssuer, - RelayTicketPair, SignalRegistry, SignedNegotiationEnvelope, SignedSessionIntent, - TurnCredential, issue_turn_credential, select_path, unix_time_seconds, + PathSelectionRequest, PresenceRegistry, RelayAccess, RelayBroker, RelayTicketIssuer, + RelayTicketPair, SignalRegistry, SignedNegotiationEnvelope, SignedPresenceLease, + SignedPresenceRelease, SignedSessionIntent, TurnCredential, issue_turn_credential, select_path, + unix_time_seconds, }; const MAX_REQUEST_BYTES: usize = 256 * 1024; @@ -53,7 +55,8 @@ impl EdgeApi { /// /// # Errors /// - /// Returns an error if the API token is weak. + /// Returns an error if credentials, the advertised relay authority, or + /// the local POP metadata are invalid. pub fn new( mut config: ApiConfig, issuer: RelayTicketIssuer, @@ -71,6 +74,12 @@ impl EdgeApi { ) { return Err("edge API and Presence tokens must be different"); } + if config.pop_id != issuer.pop_id() { + return Err("edge API POP ID must match the relay ticket issuer"); + } + validate_identifier(&config.region, 64) + .map_err(|()| "edge region contains unsupported characters")?; + validate_relay_authority(&config.public_relay_address)?; if config.default_relay_ttl_seconds == 0 || config.default_relay_ttl_seconds > 900 { return Err("default relay TTL must be between 1 and 900 seconds"); } @@ -192,14 +201,19 @@ impl EdgeApi { } fn register_presence(&self, body: &[u8]) -> ApiReply { - let presence: Presence = match parse_json(body) { + let request: SignedPresenceLease = match parse_json(body) { Ok(value) => value, Err(reply) => return reply, }; - match self - .registry - .register(presence.clone(), unix_time_seconds()) - { + let now = unix_time_seconds(); + let (presence, nonce) = match request.verify(now) { + Ok(value) => value, + Err(error) => return presence_error(error), + }; + if let Err(error) = self.registry.consume_nonce(nonce, now) { + return presence_error(error); + } + match self.registry.register(presence.clone(), now) { Ok(()) => ApiReply::json(200, &presence), Err(message) => ApiReply::error(400, "invalid_presence", message), } @@ -214,21 +228,22 @@ impl EdgeApi { } fn unregister_presence(&self, body: &[u8]) -> ApiReply { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct Unregister { - device_id: String, - connection_id: String, - } - let request: Unregister = match parse_json(body) { + let request: SignedPresenceRelease = match parse_json(body) { Ok(value) => value, Err(reply) => return reply, }; - match self.registry.unregister( - &request.device_id, - &request.connection_id, - unix_time_seconds(), - ) { + let now = unix_time_seconds(); + let nonce = match request.verify(now) { + Ok(value) => value, + Err(error) => return presence_error(error), + }; + if let Err(error) = self.registry.consume_nonce(nonce, now) { + return presence_error(error); + } + match self + .registry + .unregister(&request.device_id, &request.connection_id, now) + { Ok(true) => ApiReply::json(200, &json!({ "removed": true })), Ok(false) => ApiReply::error( 409, @@ -648,6 +663,15 @@ impl EdgeApi { } } +fn presence_error(error: &'static str) -> ApiReply { + let status = match error { + "presence_proof_replayed" => 409, + "stale_presence_proof" => 401, + _ => 400, + }; + ApiReply::error(status, error, "Edge Presence proof was rejected") +} + #[derive(Serialize)] struct AuthorizedSignalResponse { #[serde(flatten)] @@ -656,6 +680,37 @@ struct AuthorizedSignalResponse { turn: Vec, } +fn validate_identifier(value: &str, maximum: usize) -> Result<(), ()> { + if value.is_empty() + || value.len() > maximum + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(()); + } + Ok(()) +} + +fn validate_relay_authority(value: &str) -> Result<(), &'static str> { + if value.is_empty() || value.len() > 512 { + return Err("public relay address must be a bounded host:port authority"); + } + let url = Url::parse(&format!("relay://{value}/")) + .map_err(|_| "public relay address must be a valid host:port authority")?; + if url.host().is_none() + || url.port().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err("public relay address must be a secret-free host:port authority"); + } + Ok(()) +} + fn parse_json Deserialize<'de>>(body: &[u8]) -> Result { if body.is_empty() || body.len() > MAX_REQUEST_BYTES { return Err(ApiReply::error( @@ -814,9 +869,9 @@ mod tests { use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; use ed25519_dalek::{Signer as _, SigningKey}; use remotedesk_protocol::{ - AgentSignalAction, AgentSignalProofV1, EdgeNegotiationEndpoint, EdgeNegotiationEnvelopeV1, - EdgeNegotiationKind, EdgeNegotiationOperation, EdgeSessionIntentV1, EdgeSessionType, - SIGNAL_NONCE_LENGTH, + AgentPresenceAction, AgentPresenceProofV1, AgentSignalAction, AgentSignalProofV1, + EdgeNegotiationEndpoint, EdgeNegotiationEnvelopeV1, EdgeNegotiationKind, + EdgeNegotiationOperation, EdgeSessionIntentV1, EdgeSessionType, SIGNAL_NONCE_LENGTH, }; use sha2::{Digest as _, Sha256}; @@ -844,6 +899,45 @@ mod tests { .unwrap() } + #[test] + fn configuration_rejects_unusable_relay_metadata() { + let make_api = |pop_id: &str, region: &str, relay_address: &str, issuer_pop: &str| { + let issuer = RelayTicketIssuer::new(SECRET, issuer_pop).unwrap(); + let broker = Arc::new(RelayBroker::new(issuer.clone(), RelayConfig::default())); + EdgeApi::new( + ApiConfig { + api_token: API_TOKEN.to_owned(), + presence_token: PRESENCE_TOKEN.to_owned(), + pop_id: pop_id.to_owned(), + region: region.to_owned(), + public_relay_address: relay_address.to_owned(), + default_relay_ttl_seconds: 300, + default_relay_max_bytes: 1024, + turn_pops: vec![], + }, + issuer, + broker, + ) + }; + + assert!(make_api("test-pop", "cn-east", "relay.example.test:443", "test-pop").is_ok()); + assert!(make_api("test-pop", "cn-east", "[::1]:7444", "test-pop").is_ok()); + assert!(make_api("test-pop", "cn east", "relay.example.test:443", "test-pop").is_err()); + assert!(make_api("test-pop", "cn-east", ":443", "test-pop").is_err()); + assert!(make_api("test-pop", "cn-east", "relay.example.test", "test-pop").is_err()); + assert!( + make_api( + "test-pop", + "cn-east", + "user:secret@relay.test:443", + "test-pop" + ) + .is_err() + ); + assert!(make_api("test-pop", "cn-east", "relay.test:443/path", "test-pop").is_err()); + assert!(make_api("test-pop", "cn-east", "relay.test:443", "other-pop").is_err()); + } + fn signed_intent(signing: &SigningKey, device_id: &str) -> SignedSessionIntent { let now = unix_time_seconds(); let client_public_key = STANDARD_NO_PAD.encode(signing.verifying_key().as_bytes()); @@ -893,6 +987,63 @@ mod tests { }) } + fn signed_presence( + signing: &SigningKey, + connection_id: &str, + action: AgentPresenceAction, + nonce: u8, + ) -> serde_json::Value { + signed_presence_for_device( + signing, + &agent_device_id(signing), + connection_id, + action, + nonce, + ) + } + + fn signed_presence_for_device( + signing: &SigningKey, + device_id: &str, + connection_id: &str, + action: AgentPresenceAction, + nonce: u8, + ) -> serde_json::Value { + let now = unix_time_seconds(); + let public_key = STANDARD_NO_PAD.encode(signing.verifying_key().as_bytes()); + let expires_unix = (action == AgentPresenceAction::Register).then_some(now + 60); + let proof = AgentPresenceProofV1 { + device_id: device_id.into(), + device_public_key: public_key.clone(), + region: "test-region".into(), + gateway_id: "gateway-1".into(), + connection_id: connection_id.into(), + action, + presence_expires_unix: expires_unix, + nonce: [nonce; SIGNAL_NONCE_LENGTH], + issued_unix: now, + }; + let signature = STANDARD_NO_PAD.encode( + signing + .sign(&proof.stable_signing_input().unwrap()) + .to_bytes(), + ); + let mut value = json!({ + "device_id": proof.device_id, + "device_public_key": public_key, + "region": proof.region, + "gateway_id": proof.gateway_id, + "connection_id": proof.connection_id, + "nonce": STANDARD_NO_PAD.encode(proof.nonce), + "issued_unix": proof.issued_unix, + "signature": signature, + }); + if let Some(expires_unix) = expires_unix { + value["expires_unix"] = expires_unix.into(); + } + value + } + fn signed_agent_poll( signing: &SigningKey, device_id: &str, @@ -1022,17 +1173,84 @@ mod tests { ); } + #[test] + fn presence_token_cannot_forge_or_replay_device_routes() { + let api = api(); + let auth = format!("Bearer {PRESENCE_TOKEN}"); + let owner = SigningKey::from_bytes(&[29; 32]); + let owner_device_id = agent_device_id(&owner); + + let unsigned = serde_json::to_vec(&json!({ + "device_id": owner_device_id, + "device_public_key": STANDARD_NO_PAD.encode(owner.verifying_key().as_bytes()), + "region": "test-region", + "gateway_id": "gateway-1", + "connection_id": "unsigned", + "expires_unix": unix_time_seconds() + 60, + })) + .unwrap(); + assert_eq!( + api.handle("POST", "/v1/presence", Some(&auth), &unsigned) + .status, + 400 + ); + + let valid = serde_json::to_vec(&signed_presence( + &owner, + "owner-connection", + AgentPresenceAction::Register, + 1, + )) + .unwrap(); + assert_eq!( + api.handle("POST", "/v1/presence", Some(&auth), &valid) + .status, + 200 + ); + assert_eq!( + api.handle("POST", "/v1/presence", Some(&auth), &valid) + .status, + 409 + ); + + let attacker = SigningKey::from_bytes(&[31; 32]); + let forged = serde_json::to_vec(&signed_presence_for_device( + &attacker, + &owner_device_id, + "attacker-connection", + AgentPresenceAction::Register, + 2, + )) + .unwrap(); + assert_eq!( + api.handle("POST", "/v1/presence", Some(&auth), &forged) + .status, + 400 + ); + + let reply = api.handle( + "GET", + &format!("/v1/presence/{owner_device_id}"), + Some(&format!("Bearer {API_TOKEN}")), + b"", + ); + assert_eq!(reply.status, 200); + let route: serde_json::Value = serde_json::from_slice(&reply.body).unwrap(); + assert_eq!(route["connection_id"], "owner-connection"); + } + #[test] fn presence_token_cannot_read_directory_or_allocate_relay() { let api = api(); let auth = format!("Bearer {PRESENCE_TOKEN}"); - let body = serde_json::to_vec(&json!({ - "device_id": "device-scoped", - "region": "test-region", - "gateway_id": "gateway-1", - "connection_id": "connection-1", - "expires_unix": unix_time_seconds() + 60 - })) + let agent = SigningKey::from_bytes(&[19; 32]); + let device_id = agent_device_id(&agent); + let body = serde_json::to_vec(&signed_presence( + &agent, + "connection-1", + AgentPresenceAction::Register, + 1, + )) .unwrap(); assert_eq!( api.handle("POST", "/v1/presence", Some(&auth), &body) @@ -1040,8 +1258,13 @@ mod tests { 200 ); assert_eq!( - api.handle("GET", "/v1/presence/device-scoped", Some(&auth), b"") - .status, + api.handle( + "GET", + &format!("/v1/presence/{device_id}"), + Some(&auth), + b"", + ) + .status, 401 ); assert_eq!( @@ -1059,7 +1282,9 @@ mod tests { "POST", "/v1/signals/requests", Some(&auth), - br#"{"device_id":"device-scoped"}"#, + serde_json::to_string(&json!({ "device_id": device_id })) + .unwrap() + .as_bytes(), ) .status, 400 @@ -1069,14 +1294,14 @@ mod tests { #[test] fn signed_client_submission_and_status_are_public_but_admin_status_is_protected() { let api = api(); - let device_id = "ab".repeat(32); - let presence = serde_json::to_vec(&json!({ - "device_id": device_id, - "region": "test-region", - "gateway_id": "gateway-1", - "connection_id": "connection-1", - "expires_unix": unix_time_seconds() + 60 - })) + let agent = SigningKey::from_bytes(&[17; 32]); + let device_id = agent_device_id(&agent); + let presence = serde_json::to_vec(&signed_presence( + &agent, + "connection-1", + AgentPresenceAction::Register, + 1, + )) .unwrap(); assert_eq!( api.handle( @@ -1152,13 +1377,14 @@ mod tests { fn presence_api_registers_looks_up_and_connection_binds_delete() { let api = api(); let auth = format!("Bearer {API_TOKEN}"); - let body = serde_json::to_vec(&json!({ - "device_id": "device-1", - "region": "test-region", - "gateway_id": "gateway-1", - "connection_id": "connection-1", - "expires_unix": unix_time_seconds() + 60 - })) + let agent = SigningKey::from_bytes(&[23; 32]); + let device_id = agent_device_id(&agent); + let body = serde_json::to_vec(&signed_presence( + &agent, + "connection-1", + AgentPresenceAction::Register, + 1, + )) .unwrap(); assert_eq!( api.handle("POST", "/v1/presence", Some(&auth), &body) @@ -1166,19 +1392,50 @@ mod tests { 200 ); assert_eq!( - api.handle("GET", "/v1/presence/device-1", Some(&auth), b"") - .status, + api.handle( + "GET", + &format!("/v1/presence/{device_id}"), + Some(&auth), + b"", + ) + .status, 200 ); + let stale = serde_json::to_vec(&signed_presence_for_device( + &agent, + &device_id, + "stale", + AgentPresenceAction::Unregister, + 2, + )) + .unwrap(); + assert_eq!( + api.handle("DELETE", "/v1/presence", Some(&auth), &stale) + .status, + 409 + ); assert_eq!( api.handle( - "DELETE", - "/v1/presence", + "GET", + &format!("/v1/presence/{device_id}"), Some(&auth), - br#"{"device_id":"device-1","connection_id":"stale"}"#, + b"", ) .status, - 409 + 200 + ); + let current = serde_json::to_vec(&signed_presence_for_device( + &agent, + &device_id, + "connection-1", + AgentPresenceAction::Unregister, + 3, + )) + .unwrap(); + assert_eq!( + api.handle("DELETE", "/v1/presence", Some(&auth), ¤t) + .status, + 200 ); } @@ -1207,13 +1464,12 @@ mod tests { let agent = SigningKey::from_bytes(&[17; 32]); let device_id = agent_device_id(&agent); let connection_id = "connection-negotiation"; - let presence = serde_json::to_vec(&json!({ - "device_id": device_id, - "region": "test-region", - "gateway_id": "gateway-1", - "connection_id": connection_id, - "expires_unix": unix_time_seconds() + 60 - })) + let presence = serde_json::to_vec(&signed_presence( + &agent, + connection_id, + AgentPresenceAction::Register, + 1, + )) .unwrap(); assert_eq!( api.handle( diff --git a/edge/edge-service/src/lib.rs b/edge/edge-service/src/lib.rs index 90d11cf..270250c 100644 --- a/edge/edge-service/src/lib.rs +++ b/edge/edge-service/src/lib.rs @@ -17,7 +17,7 @@ pub use negotiation::{ NegotiationMessage, NegotiationPoll, NegotiationRegistry, SignedNegotiationEnvelope, }; pub use relay::{RelayBroker, RelayConfig, RelayStats, run_relay_listener}; -pub use rendezvous::{Presence, PresenceRegistry}; +pub use rendezvous::{Presence, PresenceRegistry, SignedPresenceLease, SignedPresenceRelease}; pub use signaling::{ AgentPollProof, AgentSignalAck, AgentSignalCompletion, ClientSignalStatus, ClientSignalStatusRequest, EnqueueResult, RelayAccess, RelayTicketPair, SignalRegistry, diff --git a/edge/edge-service/src/relay.rs b/edge/edge-service/src/relay.rs index 723dba7..39687e5 100644 --- a/edge/edge-service/src/relay.rs +++ b/edge/edge-service/src/relay.rs @@ -1,11 +1,16 @@ use serde::Deserialize; use std::collections::HashMap; -use std::io::{self, ErrorKind, Read as _, Write as _}; -use std::net::{Shutdown, TcpListener, TcpStream}; +use std::io::{self, ErrorKind}; +use std::net::TcpListener; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use std::thread; use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpStream; +use tokio::runtime::Builder; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio::task::JoinSet; +use tokio::time::{sleep, timeout}; use crate::{RelayRole, RelayTicketClaims, RelayTicketIssuer, unix_time_seconds}; @@ -115,13 +120,17 @@ impl RelayBroker { self.state.lock().map_or(0, |state| state.pending.len()) } - fn handle_connection(self: &Arc, mut stream: TcpStream) -> io::Result<()> { + async fn handle_connection( + self: &Arc, + mut stream: TcpStream, + handshake_permit: OwnedSemaphorePermit, + ) -> io::Result<()> { self.stats .accepted_connections .fetch_add(1, Ordering::Relaxed); - stream.set_read_timeout(Some(self.config.handshake_timeout))?; - stream.set_write_timeout(Some(self.config.handshake_timeout))?; - let hello = read_hello(&mut stream)?; + let hello = timeout(self.config.handshake_timeout, read_hello(&mut stream)) + .await + .map_err(|_| io::Error::new(ErrorKind::TimedOut, "relay handshake timed out"))??; let now = unix_time_seconds(); let claims = self .issuer @@ -196,6 +205,7 @@ impl RelayBroker { None } }; + drop(handshake_permit); if let Some((client, agent)) = pair { if let Err(error) = validate_pair(&client.claims, &agent.claims) { @@ -209,6 +219,7 @@ impl RelayBroker { &self.config, &self.stats, ) + .await } else { Ok(()) } @@ -251,7 +262,7 @@ struct RelayHello { ticket: String, } -fn read_hello(stream: &mut TcpStream) -> io::Result { +async fn read_hello(stream: &mut TcpStream) -> io::Result { let mut bytes = Vec::with_capacity(512); let mut byte = [0_u8; 1]; loop { @@ -261,7 +272,7 @@ fn read_hello(stream: &mut TcpStream) -> io::Result { "relay hello is too large", )); } - stream.read_exact(&mut byte)?; + stream.read_exact(&mut byte).await?; if byte[0] == b'\n' { break; } @@ -294,7 +305,7 @@ fn validate_pair(client: &RelayTicketClaims, agent: &RelayTicketClaims) -> io::R Ok(()) } -fn relay_pair( +async fn relay_pair( mut client: TcpStream, mut agent: TcpStream, claims: &RelayTicketClaims, @@ -308,97 +319,99 @@ fn relay_pair( } } let _active_session = ActiveSession(stats); - client.write_all(b"READY\n")?; - agent.write_all(b"READY\n")?; + timeout(config.handshake_timeout, async { + client.write_all(b"READY\n").await?; + agent.write_all(b"READY\n").await?; + Ok::<(), io::Error>(()) + }) + .await + .map_err(|_| io::Error::new(ErrorKind::TimedOut, "relay ready response timed out"))??; let poll_timeout = config.idle_timeout.min(Duration::from_millis(500)); - client.set_read_timeout(Some(poll_timeout))?; - client.set_write_timeout(Some(config.idle_timeout))?; - agent.set_read_timeout(Some(poll_timeout))?; - agent.set_write_timeout(Some(config.idle_timeout))?; - - let client_reader = client.try_clone()?; - let agent_reader = agent.try_clone()?; - let stop = Arc::new(AtomicBool::new(false)); - let used = Arc::new(AtomicU64::new(0)); - let last_activity = Arc::new(Mutex::new(Instant::now())); + let (client_reader, client_writer) = client.into_split(); + let (agent_reader, agent_writer) = agent.into_split(); + let used = AtomicU64::new(0); + let last_activity = Mutex::new(Instant::now()); let max_bytes = claims.max_bytes; let idle_timeout = config.idle_timeout; - let first_stop = Arc::clone(&stop); - let first_used = Arc::clone(&used); - let first_activity = Arc::clone(&last_activity); - let first = thread::spawn(move || { - copy_direction( - client_reader, - agent, - &first_stop, - &first_used, - &first_activity, - max_bytes, - idle_timeout, - ) - }); - let second = copy_direction( - agent_reader, - client, - &stop, + let client_to_agent = copy_direction( + client_reader, + agent_writer, &used, &last_activity, max_bytes, idle_timeout, + poll_timeout, ); - stop.store(true, Ordering::Release); - let first = first - .join() - .map_err(|_| io::Error::other("relay worker panicked"))?; + let agent_to_client = copy_direction( + agent_reader, + client_writer, + &used, + &last_activity, + max_bytes, + idle_timeout, + poll_timeout, + ); + let result = tokio::select! { + result = client_to_agent => result, + result = agent_to_client => result, + }; let transferred = used.load(Ordering::Relaxed); stats .relayed_bytes .fetch_add(transferred, Ordering::Relaxed); stats.completed_sessions.fetch_add(1, Ordering::Relaxed); - first.and(second) + result } -fn copy_direction( - mut source: TcpStream, - mut destination: TcpStream, - stop: &AtomicBool, +async fn copy_direction( + mut source: R, + mut destination: W, used: &AtomicU64, last_activity: &Mutex, max_bytes: u64, idle_timeout: Duration, -) -> io::Result<()> { + poll_timeout: Duration, +) -> io::Result<()> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ let mut buffer = vec![0_u8; COPY_BUFFER_BYTES].into_boxed_slice(); - while !stop.load(Ordering::Acquire) { - match source.read(&mut buffer) { - Ok(0) => break, - Ok(count) => { - if let Ok(mut activity) = last_activity.lock() { - *activity = Instant::now(); - } - let allowed = reserve_bytes(used, count as u64, max_bytes); - if allowed > 0 { - let allowed = usize::try_from(allowed) - .map_err(|_| io::Error::other("relay quota conversion failed"))?; - destination.write_all(&buffer[..allowed])?; - } - if allowed < count as u64 { - break; - } - } - Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { + loop { + let count = tokio::select! { + result = source.read(&mut buffer) => result?, + () = sleep(poll_timeout) => { let idle = last_activity .lock() .map_or(true, |activity| activity.elapsed() >= idle_timeout); if idle { break; } + continue; } - Err(error) => return Err(error), + }; + if count == 0 { + break; + } + if let Ok(mut activity) = last_activity.lock() { + *activity = Instant::now(); + } + let allowed = reserve_bytes(used, count as u64, max_bytes); + if allowed > 0 { + let allowed = usize::try_from(allowed) + .map_err(|_| io::Error::other("relay quota conversion failed"))?; + timeout(idle_timeout, destination.write_all(&buffer[..allowed])) + .await + .map_err(|_| io::Error::new(ErrorKind::TimedOut, "relay write timed out"))??; + if let Ok(mut activity) = last_activity.lock() { + *activity = Instant::now(); + } + } + if allowed < count as u64 { + break; } } - stop.store(true, Ordering::Release); - let _ = source.shutdown(Shutdown::Both); - let _ = destination.shutdown(Shutdown::Both); + let _ = destination.shutdown().await; Ok(()) } @@ -431,12 +444,26 @@ pub fn run_relay_listener( broker: &Arc, shutdown: &AtomicBool, ) -> io::Result<()> { + let listener = listener.try_clone()?; listener.set_nonblocking(true)?; - let handshakes = Arc::new(AtomicUsize::new(0)); + let runtime = Builder::new_current_thread().enable_all().build()?; + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener)?; + run_relay_accept_loop(listener, Arc::clone(broker), shutdown).await + }) +} + +async fn run_relay_accept_loop( + listener: tokio::net::TcpListener, + broker: Arc, + shutdown: &AtomicBool, +) -> io::Result<()> { + let handshakes = Arc::new(Semaphore::new(broker.config.max_concurrent_handshakes)); + let mut connections = JoinSet::new(); while !shutdown.load(Ordering::Acquire) { - match listener.accept() { - Ok((mut stream, _)) => { - if stream.set_nonblocking(false).is_err() { + match timeout(Duration::from_millis(50), listener.accept()).await { + Ok(Ok((mut stream, _))) => { + if stream.set_nodelay(true).is_err() { broker .stats .rejected_connections @@ -444,19 +471,17 @@ pub fn run_relay_listener( eprintln!("relay connection rejected: socket_configuration"); continue; } - if handshakes.load(Ordering::Relaxed) >= broker.config.max_concurrent_handshakes { + let Ok(permit) = Arc::clone(&handshakes).try_acquire_owned() else { broker .stats .rejected_connections .fetch_add(1, Ordering::Relaxed); - let _ = stream.write_all(b"ERROR busy\n"); + let _ = stream.write_all(b"ERROR busy\n").await; continue; - } - handshakes.fetch_add(1, Ordering::Relaxed); - let broker = Arc::clone(broker); - let handshakes = Arc::clone(&handshakes); - thread::spawn(move || { - if let Err(error) = broker.handle_connection(stream) { + }; + let broker = Arc::clone(&broker); + connections.spawn(async move { + if let Err(error) = broker.handle_connection(stream, permit).await { broker .stats .rejected_connections @@ -466,15 +491,15 @@ pub fn run_relay_listener( rejection_category(&error) ); } - handshakes.fetch_sub(1, Ordering::Relaxed); }); } - Err(error) if error.kind() == ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(error) => return Err(error), + Ok(Err(error)) => return Err(error), + Err(_) => {} } + while connections.try_join_next().is_some() {} } + connections.abort_all(); + while connections.join_next().await.is_some() {} Ok(()) } @@ -493,15 +518,17 @@ fn rejection_category(error: &io::Error) -> &'static str { #[cfg(test)] mod tests { use super::*; - use std::io::{BufRead as _, BufReader}; + use std::io::{BufRead as _, BufReader, Read as _, Write as _}; + use std::net::TcpStream as StdTcpStream; + use std::thread; const SECRET: &[u8] = b"0123456789abcdef0123456789abcdef"; - fn send_ticket(stream: &mut TcpStream, ticket: &str) { + fn send_ticket(stream: &mut StdTcpStream, ticket: &str) { writeln!(stream, "{}", serde_json::json!({ "ticket": ticket })).unwrap(); } - fn read_ready(stream: &TcpStream) { + fn read_ready(stream: &StdTcpStream) { let mut reader = BufReader::new(stream.try_clone().unwrap()); let mut line = String::new(); reader.read_line(&mut line).unwrap(); @@ -530,8 +557,8 @@ mod tests { thread::spawn(move || run_relay_listener(&listener, &broker, &shutdown).unwrap()) }; - let mut client = TcpStream::connect(address).unwrap(); - let mut agent = TcpStream::connect(address).unwrap(); + let mut client = StdTcpStream::connect(address).unwrap(); + let mut agent = StdTcpStream::connect(address).unwrap(); thread::sleep(Duration::from_millis(50)); send_ticket(&mut client, &client_ticket); send_ticket(&mut agent, &agent_ticket); @@ -545,7 +572,7 @@ mod tests { client.read_exact(&mut received).unwrap(); assert_eq!(&received, b"agent-to-client"); - let mut replay = TcpStream::connect(address).unwrap(); + let mut replay = StdTcpStream::connect(address).unwrap(); send_ticket(&mut replay, &client_ticket); replay .set_read_timeout(Some(Duration::from_secs(1))) diff --git a/edge/edge-service/src/rendezvous.rs b/edge/edge-service/src/rendezvous.rs index adc3165..73879b3 100644 --- a/edge/edge-service/src/rendezvous.rs +++ b/edge/edge-service/src/rendezvous.rs @@ -1,7 +1,12 @@ +use remotedesk_protocol::{AgentPresenceAction, AgentPresenceProofV1, SIGNAL_NONCE_LENGTH}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Mutex; +use crate::signaling::{decode_fixed, device_id_from_public_key, verify_signature}; + +const PRESENCE_CLOCK_SKEW_SECONDS: u64 = 30; + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Presence { @@ -12,12 +17,137 @@ pub struct Presence { pub expires_unix: u64, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SignedPresenceLease { + pub device_id: String, + pub device_public_key: String, + pub region: String, + pub gateway_id: String, + pub connection_id: String, + pub expires_unix: u64, + pub nonce: String, + pub issued_unix: u64, + pub signature: String, +} + +impl SignedPresenceLease { + /// Verifies and converts a device-signed Presence registration. + /// + /// # Errors + /// + /// Returns an error when the proof is malformed, stale, forged, or identity-mismatched. + pub fn verify(&self, now: u64) -> Result<(Presence, [u8; SIGNAL_NONCE_LENGTH]), &'static str> { + let nonce = decode_fixed(&self.nonce).map_err(|()| "invalid_presence_proof")?; + let proof = AgentPresenceProofV1 { + device_id: self.device_id.clone(), + device_public_key: self.device_public_key.clone(), + region: self.region.clone(), + gateway_id: self.gateway_id.clone(), + connection_id: self.connection_id.clone(), + action: AgentPresenceAction::Register, + presence_expires_unix: Some(self.expires_unix), + nonce, + issued_unix: self.issued_unix, + }; + verify_presence_proof(&proof, &self.signature, now)?; + Ok(( + Presence { + device_id: self.device_id.clone(), + region: self.region.clone(), + gateway_id: self.gateway_id.clone(), + connection_id: self.connection_id.clone(), + expires_unix: self.expires_unix, + }, + nonce, + )) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SignedPresenceRelease { + pub device_id: String, + pub device_public_key: String, + pub region: String, + pub gateway_id: String, + pub connection_id: String, + pub nonce: String, + pub issued_unix: u64, + pub signature: String, +} + +impl SignedPresenceRelease { + /// Verifies a device-signed Presence removal. + /// + /// # Errors + /// + /// Returns an error when the proof is malformed, stale, forged, or identity-mismatched. + pub fn verify(&self, now: u64) -> Result<[u8; SIGNAL_NONCE_LENGTH], &'static str> { + let nonce = decode_fixed(&self.nonce).map_err(|()| "invalid_presence_proof")?; + let proof = AgentPresenceProofV1 { + device_id: self.device_id.clone(), + device_public_key: self.device_public_key.clone(), + region: self.region.clone(), + gateway_id: self.gateway_id.clone(), + connection_id: self.connection_id.clone(), + action: AgentPresenceAction::Unregister, + presence_expires_unix: None, + nonce, + issued_unix: self.issued_unix, + }; + verify_presence_proof(&proof, &self.signature, now)?; + Ok(nonce) + } +} + +fn verify_presence_proof( + proof: &AgentPresenceProofV1, + signature: &str, + now: u64, +) -> Result<(), &'static str> { + let payload = proof + .stable_signing_input() + .map_err(|_| "invalid_presence_proof")?; + if proof.issued_unix.abs_diff(now) > PRESENCE_CLOCK_SKEW_SECONDS { + return Err("stale_presence_proof"); + } + if device_id_from_public_key(&proof.device_public_key)? != proof.device_id { + return Err("device_identity_mismatch"); + } + verify_signature(&proof.device_public_key, signature, &payload) + .map_err(|()| "invalid_presence_signature") +} + #[derive(Default)] pub struct PresenceRegistry { entries: Mutex>, + used_nonces: Mutex>, } impl PresenceRegistry { + /// Records a proof nonce until its replay window expires. + /// + /// # Errors + /// + /// Returns an error when the nonce was already used or the registry lock is unavailable. + pub fn consume_nonce( + &self, + nonce: [u8; SIGNAL_NONCE_LENGTH], + now: u64, + ) -> Result<(), &'static str> { + let mut used = self + .used_nonces + .lock() + .map_err(|_| "presence registry lock failed")?; + used.retain(|_, expiry| *expiry > now); + if used.contains_key(&nonce) { + return Err("presence_proof_replayed"); + } + used.insert(nonce, now.saturating_add(PRESENCE_CLOCK_SKEW_SECONDS * 2)); + Ok(()) + } + /// Registers or refreshes one short-lived device route. /// /// # Errors diff --git a/edge/edge-service/src/signaling.rs b/edge/edge-service/src/signaling.rs index 45d021a..3da1a9d 100644 --- a/edge/edge-service/src/signaling.rs +++ b/edge/edge-service/src/signaling.rs @@ -609,7 +609,11 @@ fn prune(state: &mut SignalState, now: u64) { state.used_agent_nonces.retain(|_, expiry| *expiry > now); } -fn verify_signature(public_key: &str, signature: &str, payload: &[u8]) -> Result<(), ()> { +pub(crate) fn verify_signature( + public_key: &str, + signature: &str, + payload: &[u8], +) -> Result<(), ()> { let public = decode_fixed::<32>(public_key)?; let signature = decode_fixed::<64>(signature)?; let key = VerifyingKey::from_bytes(&public).map_err(|_| ())?; @@ -628,7 +632,7 @@ fn constant_time_equal(left: &[u8], right: &[u8]) -> bool { different == 0 } -fn device_id_from_public_key(public_key: &str) -> Result { +pub(crate) fn device_id_from_public_key(public_key: &str) -> Result { let public = decode_fixed::<32>(public_key).map_err(|()| "invalid_device_public_key")?; let mut digest = Sha256::new(); digest.update(DEVICE_ID_DOMAIN); @@ -636,7 +640,7 @@ fn device_id_from_public_key(public_key: &str) -> Result { Ok(format_hex(&digest.finalize())) } -fn decode_fixed(value: &str) -> Result<[u8; LENGTH], ()> { +pub(crate) fn decode_fixed(value: &str) -> Result<[u8; LENGTH], ()> { let decoded = STANDARD_NO_PAD.decode(value).map_err(|_| ())?; if STANDARD_NO_PAD.encode(&decoded) != value { return Err(()); diff --git a/packaging/android/package.sh b/packaging/android/package.sh new file mode 100644 index 0000000..39337cb --- /dev/null +++ b/packaging/android/package.sh @@ -0,0 +1,107 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +app_root="$repo_root/client/flutter_android" +artifacts_root="$repo_root/artifacts" +apk_source="$app_root/build/app/outputs/flutter-apk/app-release.apk" +keystore_path= + +cleanup() { + if [ -n "$keystore_path" ] && [ -f "$keystore_path" ]; then + rm -f -- "$keystore_path" + fi + unset REMOTEDESK_ANDROID_KEYSTORE_PATH + unset REMOTEDESK_ANDROID_KEYSTORE_PASSWORD + unset REMOTEDESK_ANDROID_KEY_ALIAS + unset REMOTEDESK_ANDROID_KEY_PASSWORD +} +trap cleanup EXIT HUP INT TERM + +has_signing=0 +if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ] || + [ -n "${ANDROID_KEYSTORE_PASSWORD:-}" ] || + [ -n "${ANDROID_KEY_ALIAS:-}" ] || + [ -n "${ANDROID_KEY_PASSWORD:-}" ]; then + has_signing=1 +fi + +if [ "$has_signing" -eq 1 ] && { + [ -z "${ANDROID_KEYSTORE_BASE64:-}" ] || + [ -z "${ANDROID_KEYSTORE_PASSWORD:-}" ] || + [ -z "${ANDROID_KEY_ALIAS:-}" ] || + [ -z "${ANDROID_KEY_PASSWORD:-}" ]; +}; then + echo "Android release signing secrets are incomplete" >&2 + exit 1 +fi + +require_signing=${REQUIRE_RELEASE_SIGNING:-0} +if [ "${DRONE_BUILD_EVENT:-}" = "tag" ]; then + require_signing=1 +fi +if [ "$require_signing" -eq 1 ] && [ "$has_signing" -ne 1 ]; then + echo "A tag build requires all Android release signing secrets" >&2 + exit 1 +fi + +if [ "$has_signing" -eq 1 ]; then + keystore_path=$(mktemp "${TMPDIR:-/tmp}/remotedesk-android-keystore.XXXXXX") + chmod 600 "$keystore_path" + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$keystore_path" + if [ ! -s "$keystore_path" ]; then + echo "Decoded Android keystore is empty" >&2 + exit 1 + fi + export REMOTEDESK_ANDROID_KEYSTORE_PATH="$keystore_path" + export REMOTEDESK_ANDROID_KEYSTORE_PASSWORD="$ANDROID_KEYSTORE_PASSWORD" + export REMOTEDESK_ANDROID_KEY_ALIAS="$ANDROID_KEY_ALIAS" + export REMOTEDESK_ANDROID_KEY_PASSWORD="$ANDROID_KEY_PASSWORD" +fi + +cd "$app_root" +flutter build apk --release +if [ ! -s "$apk_source" ]; then + echo "Android release APK was not generated" >&2 + exit 1 +fi + +android_sdk=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}} +if [ -z "$android_sdk" ] || [ ! -d "$android_sdk/build-tools" ]; then + echo "Android SDK build-tools are unavailable" >&2 + exit 1 +fi +apksigner=$(find "$android_sdk/build-tools" -type f -name apksigner -print | sort -V | tail -n 1) +if [ -z "$apksigner" ]; then + echo "apksigner is unavailable" >&2 + exit 1 +fi +signature_report=$($apksigner verify --verbose --print-certs "$apk_source") +printf '%s\n' "$signature_report" +if [ "$has_signing" -eq 1 ] && printf '%s\n' "$signature_report" | grep -Fq 'CN=Android Debug'; then + echo "Production signing was requested but the APK uses the Android debug certificate" >&2 + exit 1 +fi + +version=$(sed -n 's/^version:[[:space:]]*//p' "$app_root/pubspec.yaml" | head -n 1 | tr -d '\r') +case "$version" in + *+*) ;; + *) echo "Android pubspec version must contain a build number" >&2; exit 1 ;; +esac +version_name=${version%+*} +version_code=${version#*+} +case "$version_name" in + ''|*[!0-9A-Za-z._-]*) echo "Android version name is unsafe" >&2; exit 1 ;; +esac +case "$version_code" in + ''|*[!0-9]*) echo "Android build number is invalid" >&2; exit 1 ;; +esac + +mkdir -p "$artifacts_root" +apk_name="RemoteDesk-Android-${version_name}-build${version_code}.apk" +apk_output="$artifacts_root/$apk_name" +install -m 0644 "$apk_source" "$apk_output" +apk_hash=$(sha256sum "$apk_output" | cut -d ' ' -f 1) +printf '%s %s\n' "$apk_hash" "$apk_name" > "$apk_output.sha256" +printf 'Package: %s\n' "$apk_output" +printf 'SHA256: %s\n' "$apk_hash" diff --git a/packaging/edge/README.md b/packaging/edge/README.md index 72f92e3..5572bab 100644 --- a/packaging/edge/README.md +++ b/packaging/edge/README.md @@ -4,12 +4,22 @@ This package contains the independent RemoteDesk Edge control plane and authenti ## Configure -1. Generate three different random values of at least 32 bytes for `REMOTEDESK_EDGE_API_TOKEN`, `REMOTEDESK_EDGE_PRESENCE_TOKEN`, and `REMOTEDESK_EDGE_TICKET_SECRET`. Agents receive only the Presence token; it cannot query devices or allocate relay/TURN credentials. -2. Set the public relay address, POP ID, and region in `/etc/remotedesk-edge/edge.env`. -3. Publish `127.0.0.1:7080` through an HTTPS reverse proxy. The process refuses a public HTTP API bind. -4. Publish `127.0.0.1:7444` through a TCP/TLS load balancer that preserves connection affinity. -5. Optionally copy `turn-pops.json.example`, reference one independent environment secret per coturn POP, and append `--turn-config /etc/remotedesk-edge/turn-pops.json` to the systemd command. -6. Run `systemctl enable --now remotedesk-edge.service`. +1. Initialize the service. This generates three independent 48-byte random credentials without printing them and writes an owner-protected environment file: + + ```sh + sudo remotedesk-edge-setup \ + --public-relay-address relay.example.com:7443 \ + --pop-id cn-east-1 \ + --region cn-east + ``` + +2. Publish `127.0.0.1:7080` through an HTTPS reverse proxy, for example as `https://api.example.com/`. The process refuses a public HTTP API bind. +3. Publish `127.0.0.1:7444` through a TCP/TLS load balancer at the exact authority passed to `--public-relay-address`. TLS is terminated at the load balancer; connection affinity is required. +4. Set the Agent's `REMOTEDESK_EDGE_API_URL` to the public HTTPS API and copy only `REMOTEDESK_EDGE_PRESENCE_TOKEN` from `/etc/remotedesk-edge/edge.env` into its protected environment. Never distribute the API token or ticket secret. +5. Run `systemctl enable --now remotedesk-edge.service`, or pass `--start` to the setup command after both public listeners are ready. +6. Optionally copy `turn-pops.json.example`, reference one independent environment secret per coturn POP, and append `--turn-config /etc/remotedesk-edge/turn-pops.json` to the systemd command. + +Use `--force` only for an intentional credential rotation. Rotation immediately invalidates Agent registration and outstanding relay tickets, so update every Agent Presence token before restarting the service. The package also includes an optional coturn baseline at `/etc/remotedesk-edge/coturn.conf.example`, a secret-only environment template, `remotedesk-turn.service`, and `remotedesk-verify-coturn`. Install the distribution `coturn` package, copy both templates without the `.example` suffix, set explicit listening/relay/external addresses, install a trusted certificate and private key, then enable the TURN unit. Verification requires the REST secret and trusted CA path and performs STUN plus TURN UDP, TCP, and TLS data loops: @@ -20,7 +30,7 @@ sudo REMOTEDESK_TURN_REST_SECRET='...' \ remotedesk-verify-coturn ``` -`GET /health` is unauthenticated and exposes only service health, local capability flags, and aggregate counters. `POST /v1/presence`, `DELETE /v1/presence`, `POST /v1/signals/poll`, and `POST /v1/signals/ack` accept either the dedicated Presence token or the administrator token. Signal poll/ack additionally require a fresh one-use nonce and an Ed25519 proof from the current Agent device key; the shared Presence token alone cannot poll another device. Presence lookup, path selection, relay allocation, TURN credentials, and administrator signal status require `Authorization: Bearer `; never distribute that administrator token to Agents or Windows Clients. +`GET /health` is unauthenticated and exposes only service health, local capability flags, and aggregate counters. `POST /v1/presence`, `DELETE /v1/presence`, `POST /v1/signals/poll`, and `POST /v1/signals/ack` accept either the dedicated Presence token or the administrator token. Every one of these Agent routes also requires a fresh one-use nonce and an Ed25519 proof from the current Agent device key. Registration and removal proofs bind the derived device ID, public key, region, gateway, connection, action, and lease lifetime, so the shared Presence token alone cannot create, overwrite, remove, or poll another device route. The Agent creates and protects this device identity automatically on first startup; operators do not provide a key file. Presence lookup, path selection, relay allocation, TURN credentials, and administrator signal status require `Authorization: Bearer `; never distribute that administrator token to Agents or Windows Clients. `POST /v1/signals/requests` is a public Client-authenticated route: it accepts a valid Client-key-signed session intent only while the target has a live Presence route. `POST /v1/signals/status` returns that request only when the caller repeats its stored Client public key and original signature. Requests use canonical domain-separated signing bytes, expire within 120 seconds, are idempotent by request ID, and enter bounded global, per-device, and per-Client/device queues. The Agent independently verifies that Client key against its local pairing store and checks the requested permission and target user before sending a device-key-signed acceptance or rejection. An accepted terminal/file request allocates independent one-use Client and Agent relay tickets; each endpoint receives only its own role. Operators can read non-secret status from the administrator-only `GET /v1/signals/requests/`. diff --git a/packaging/edge/debian/postinst b/packaging/edge/debian/postinst index 50f0a1a..0cdfd14 100644 --- a/packaging/edge/debian/postinst +++ b/packaging/edge/debian/postinst @@ -12,4 +12,4 @@ chown root:remotedesk-edge /etc/remotedesk-edge/edge.env chmod 0640 /etc/remotedesk-edge/edge.env systemctl daemon-reload systemctl enable remotedesk-edge.service >/dev/null 2>&1 || true -echo "RemoteDesk Edge installed but not started. Configure /etc/remotedesk-edge/edge.env first." +echo "RemoteDesk Edge installed but not started. Run remotedesk-edge-setup first." diff --git a/packaging/edge/edge.env b/packaging/edge/edge.env index ecdc6b7..aea7a44 100644 --- a/packaging/edge/edge.env +++ b/packaging/edge/edge.env @@ -1,3 +1,4 @@ +# Run remotedesk-edge-setup to populate this file. Do not reuse any value. # Required: use independent, randomly generated values of at least 32 bytes. REMOTEDESK_EDGE_API_TOKEN= REMOTEDESK_EDGE_PRESENCE_TOKEN= diff --git a/packaging/edge/install.sh b/packaging/edge/install.sh index 3bccff5..20160b1 100644 --- a/packaging/edge/install.sh +++ b/packaging/edge/install.sh @@ -16,6 +16,7 @@ if ! getent passwd remotedesk-edge >/dev/null 2>&1; then fi install -m 0755 "$package_root/bin/remotedesk-edge-service" /usr/bin/remotedesk-edge-service +install -m 0755 "$package_root/setup.sh" /usr/bin/remotedesk-edge-setup install -d -m 0750 -o root -g remotedesk-edge /etc/remotedesk-edge if [ ! -f /etc/remotedesk-edge/edge.env ]; then install -m 0640 -o root -g remotedesk-edge "$package_root/config/edge.env" /etc/remotedesk-edge/edge.env @@ -34,4 +35,4 @@ install -m 0644 "$package_root/systemd/remotedesk-turn.service" /usr/lib/systemd install -m 0755 "$package_root/verify-coturn.sh" /usr/bin/remotedesk-verify-coturn systemctl daemon-reload systemctl enable remotedesk-edge.service -echo "Configure /etc/remotedesk-edge/edge.env, publish both loopback listeners securely, then start remotedesk-edge.service. Optional coturn templates were installed but not enabled." +echo "Initialize the relay with remotedesk-edge-setup, publish both loopback listeners securely, then start remotedesk-edge.service. Optional coturn templates were installed but not enabled." diff --git a/packaging/edge/package-deb.sh b/packaging/edge/package-deb.sh index 76ad7f2..946d4ad 100644 --- a/packaging/edge/package-deb.sh +++ b/packaging/edge/package-deb.sh @@ -20,6 +20,7 @@ mkdir -p "$package_root/DEBIAN" "$package_root/usr/bin" \ "$package_root/usr/share/doc/remotedesk-edge" "$artifacts_root" cargo build --locked --release --manifest-path "$repo_root/Cargo.toml" -p remotedesk-edge-service install -m 0755 "$repo_root/target/release/remotedesk-edge-service" "$package_root/usr/bin/" +install -m 0755 "$repo_root/packaging/edge/setup.sh" "$package_root/usr/bin/remotedesk-edge-setup" install -m 0644 "$repo_root/packaging/edge/remotedesk-edge.service" "$package_root/usr/lib/systemd/system/" install -m 0640 "$repo_root/packaging/edge/edge.env" "$package_root/etc/remotedesk-edge/" install -m 0644 "$repo_root/packaging/edge/turn-pops.json.example" "$package_root/etc/remotedesk-edge/" diff --git a/packaging/edge/package-musl.ps1 b/packaging/edge/package-musl.ps1 index 2f5b99c..0108612 100644 --- a/packaging/edge/package-musl.ps1 +++ b/packaging/edge/package-musl.ps1 @@ -84,6 +84,7 @@ try { Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'coturn.env.example') -Destination (Join-Path $portableRoot 'config') Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'remotedesk-turn.service') -Destination (Join-Path $portableRoot 'systemd') Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'verify-coturn.sh') -Destination $portableRoot + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'setup.sh') -Destination $portableRoot Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'install.sh') -Destination $portableRoot Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'uninstall.sh') -Destination $portableRoot Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'README.md') -Destination $portableRoot @@ -97,7 +98,7 @@ try { $tarPath = Join-Path $buildRoot "$portableName.tar" $directories = @($portableName, "$portableName/bin", "$portableName/config", "$portableName/systemd") - $executables = @("$portableName/install.sh", "$portableName/uninstall.sh", "$portableName/verify-coturn.sh", "$portableName/bin/remotedesk-edge-service") + $executables = @("$portableName/install.sh", "$portableName/uninstall.sh", "$portableName/setup.sh", "$portableName/verify-coturn.sh", "$portableName/bin/remotedesk-edge-service") $data = @("$portableName/README.md", "$portableName/SHA256SUMS.txt", "$portableName/config/edge.env", "$portableName/config/turn-pops.json.example", "$portableName/config/coturn.conf.example", "$portableName/config/coturn.env.example", "$portableName/systemd/remotedesk-edge.service", "$portableName/systemd/remotedesk-turn.service") Push-Location $buildRoot try { @@ -121,6 +122,7 @@ try { Copy-Item -LiteralPath (Join-Path $portableRoot 'config\coturn.env.example') -Destination (Join-Path $debianRoot 'etc\remotedesk-edge') Copy-Item -LiteralPath (Join-Path $portableRoot 'systemd\remotedesk-turn.service') -Destination (Join-Path $debianRoot 'usr\lib\systemd\system') Copy-Item -LiteralPath (Join-Path $portableRoot 'verify-coturn.sh') -Destination (Join-Path $debianRoot 'usr\bin\remotedesk-verify-coturn') + Copy-Item -LiteralPath (Join-Path $portableRoot 'setup.sh') -Destination (Join-Path $debianRoot 'usr\bin\remotedesk-edge-setup') Copy-Item -LiteralPath (Join-Path $portableRoot 'README.md') -Destination (Join-Path $debianRoot 'usr\share\doc\remotedesk-edge\README.md') $control = (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'debian\control') -Raw).Replace('@VERSION@', $version).Replace('@ARCH@', 'amd64') [IO.File]::WriteAllText((Join-Path $debianRoot 'DEBIAN\control'), $control.Replace("`r`n", "`n"), [Text.UTF8Encoding]::new($false)) @@ -142,7 +144,7 @@ try { $dataTar = Join-Path $debParts 'data.tar' Push-Location $debianRoot try { - Invoke-Checked -Description 'Edge DEB executable payload' -Command { & $gnuTar --force-local --format=gnu --owner=0 --group=0 --mode=0755 -cf $dataTar './usr/bin/remotedesk-edge-service' './usr/bin/remotedesk-verify-coturn' } + Invoke-Checked -Description 'Edge DEB executable payload' -Command { & $gnuTar --force-local --format=gnu --owner=0 --group=0 --mode=0755 -cf $dataTar './usr/bin/remotedesk-edge-service' './usr/bin/remotedesk-edge-setup' './usr/bin/remotedesk-verify-coturn' } Invoke-Checked -Description 'Edge DEB protected configuration' -Command { & $gnuTar --force-local --format=gnu --owner=0 --group=0 --mode=0640 -rf $dataTar './etc/remotedesk-edge/edge.env' } Invoke-Checked -Description 'Edge DEB data payload' -Command { & $gnuTar --force-local --format=gnu --owner=0 --group=0 --mode=0644 -rf $dataTar './etc/remotedesk-edge/turn-pops.json.example' './etc/remotedesk-edge/coturn.conf.example' './etc/remotedesk-edge/coturn.env.example' './usr/lib/systemd/system/remotedesk-edge.service' './usr/lib/systemd/system/remotedesk-turn.service' './usr/share/doc/remotedesk-edge/README.md' } } finally { Pop-Location } diff --git a/packaging/edge/setup.sh b/packaging/edge/setup.sh new file mode 100644 index 0000000..2da878a --- /dev/null +++ b/packaging/edge/setup.sh @@ -0,0 +1,165 @@ +#!/bin/sh +set -eu + +config=/etc/remotedesk-edge/edge.env +start_service=false +force=false +public_relay_address= +pop_id= +region= + +usage() { + cat <<'EOF' +Usage: remotedesk-edge-setup --public-relay-address HOST:PORT --pop-id ID --region ID [options] + +Options: + --config PATH Write a different environment file + --start Enable and start remotedesk-edge.service after setup + --force Replace an already configured environment file + -h, --help Show this help +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --public-relay-address) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + public_relay_address=$2 + shift 2 + ;; + --pop-id) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + pop_id=$2 + shift 2 + ;; + --region) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + region=$2 + shift 2 + ;; + --config) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + config=$2 + shift 2 + ;; + --start) + start_service=true + shift + ;; + --force) + force=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ "$(id -u)" -ne 0 ]; then + echo "remotedesk-edge-setup must run as root" >&2 + exit 1 +fi +if ! getent group remotedesk-edge >/dev/null 2>&1; then + echo "the remotedesk-edge service account is missing; install the Edge package first" >&2 + exit 1 +fi + +valid_identifier() { + value=$1 + maximum=$2 + [ -n "$value" ] && [ "${#value}" -le "$maximum" ] && + ! printf '%s' "$value" | grep -q '[^A-Za-z0-9._-]' +} + +if ! valid_identifier "$pop_id" 128; then + echo "--pop-id must use 1-128 ASCII letters, digits, dot, underscore, or hyphen" >&2 + exit 2 +fi +if ! valid_identifier "$region" 64; then + echo "--region must use 1-64 ASCII letters, digits, dot, underscore, or hyphen" >&2 + exit 2 +fi +case "$public_relay_address" in + \[*\]:*) + relay_host=${public_relay_address%:*} + relay_host=${relay_host#\[} + relay_host=${relay_host%\]} + relay_port=${public_relay_address##*:} + if [ -z "$relay_host" ] || printf '%s' "$relay_host" | grep -q '[^A-Fa-f0-9:.]'; then + echo "--public-relay-address contains an invalid IPv6 host" >&2 + exit 2 + fi + ;; + *:*) + relay_host=${public_relay_address%:*} + relay_port=${public_relay_address##*:} + if [ -z "$relay_host" ] || printf '%s' "$relay_host" | grep -q '[^A-Za-z0-9._-]'; then + echo "--public-relay-address contains an invalid host" >&2 + exit 2 + fi + ;; + *) + echo "--public-relay-address must include a port" >&2 + exit 2 + ;; +esac +case "$relay_port" in + ''|*[!0-9]*) + echo "--public-relay-address must include a numeric port" >&2 + exit 2 + ;; +esac +if [ "${#relay_port}" -gt 5 ] || [ "$relay_port" -lt 1 ] || [ "$relay_port" -gt 65535 ]; then + echo "--public-relay-address port must be between 1 and 65535" >&2 + exit 2 +fi + +if [ -f "$config" ] && grep -Eq '^REMOTEDESK_EDGE_(API_TOKEN|PRESENCE_TOKEN|TICKET_SECRET)=.{32,}$' "$config"; then + if [ "$force" != true ]; then + echo "$config already contains Edge credentials; use --force to rotate them" >&2 + exit 1 + fi +fi + +config_dir=$(dirname -- "$config") +install -d -m 0750 -o root -g remotedesk-edge "$config_dir" +temporary=$(mktemp "$config_dir/.edge.env.XXXXXX") +trap 'rm -f -- "$temporary"' EXIT HUP INT TERM + +random_secret() { + od -An -N48 -tx1 /dev/urandom | tr -d ' \n' +} + +umask 0077 +api_token=$(random_secret) +presence_token=$(random_secret) +ticket_secret=$(random_secret) +cat >"$temporary" </dev/null 2>&1 || true systemctl disable --now remotedesk-turn.service >/dev/null 2>&1 || true rm -f /usr/lib/systemd/system/remotedesk-edge.service \ /usr/lib/systemd/system/remotedesk-turn.service \ - /usr/bin/remotedesk-edge-service /usr/bin/remotedesk-verify-coturn + /usr/bin/remotedesk-edge-service /usr/bin/remotedesk-edge-setup \ + /usr/bin/remotedesk-verify-coturn systemctl daemon-reload echo "Configuration in /etc/remotedesk-edge was preserved." diff --git a/packaging/linux/README.md b/packaging/linux/README.md index f134371..c5082a8 100644 --- a/packaging/linux/README.md +++ b/packaging/linux/README.md @@ -12,13 +12,23 @@ File protocol minor 4 resumes both directions. Interrupted uploads retain a mode Protocol minor 10 includes the X11 desktop compatibility path, bounded zlib level adaptation, short-lived session resume, bounded H.264 media IPC, presentation-ACK-driven switching from zlib fallback to H.264-only transport, and bounded relative-pointer input. Pairing grants may include `desktop`; an Xorg user session registers `/run/user//remotedesk/desktop.sock`, captures the root window through X11 `GetImage`, and injects authorized absolute/relative pointer, five-button mouse, wheel, and keyboard input through XTEST. Real encode timing and presentation ACKs adapt 1..30 FPS pacing without exceeding the requested FPS ceiling; zlib mode also adapts levels 1..6. Native mode stops full zlib frame encoding and transfer only after a frame-boundary confirmation, and returns to zlib on media or decode failure. On transport loss it releases all input and retains the X11 connection and both controllers for 15 seconds behind a rotating token bound to the authenticated Client and Linux user. The Windows helper stores only that token in the current user's Credential Manager so a restarted helper can resume within the lease. The systemd user unit exposes the X11 socket read-only inside its private `/tmp`. Protocol minor 11 adds the source path for authenticated Wayland Portal authorization, strict PipeWire DMABUF-to-VA-H.264 transport, presentation-ACK pacing, dynamic pipeline rebuilds, and `reis` EIS input. Minor 12 adds a 15-second Portal/EIS resume lease bound to the authenticated Client fingerprint, Linux user, and rotating token; disconnect releases all input and the old encoder, while resume requires a new authorized WebRTC sender and a new IDR. Minor 15 adds X11/Windows bidirectional UTF-8 text clipboard source with separate `clipboard_read` and `clipboard_write` grants, Offer/Request/Data transfer, a 32 KiB bound, canonical Base64, SHA-256 integrity, and loop suppression. Wayland clipboard remains unavailable. Both desktop paths and the new clipboard path still require rebuilt packages and real desktop validation. -After installation, create a short-lived pairing code locally: +After installation, configure mandatory TOTP and add the printed secret or +`otpauth_uri` to an authenticator application. The daemon refuses to start until +this step is complete. Rotating with `--force` invalidates current authenticator +enrollment immediately. + +```sh +sudo remotedesk-agentd totp-setup +sudo systemctl start remotedesk-agentd +``` + +Then create a short-lived pairing code locally: ```sh sudo remotedesk-agentd pairing-code --allow-user "$USER" ``` -The code expires after five minutes, is stored only as a salted digest, permits five failed attempts, and is consumed after one successful pairing. The pairing request must also sign the server challenge with the submitted Ed25519 client key. The command prints the device public key and TLS certificate SHA-256 fingerprint; the controller must pin the TLS fingerprint before sending the code. Accepting an arbitrary self-signed certificate is not secure. List or revoke clients with: +The code expires after five minutes, is stored only as a salted digest, permits five failed attempts, and is consumed after one successful pairing. Every pairing and reconnect must also pass a six-digit TOTP challenge after signing the server challenge with the submitted Ed25519 client key. TOTP attempts are rate limited and a code cannot be reused by the same client. The command prints the device public key and TLS certificate SHA-256 fingerprint; the controller must pin the TLS fingerprint before sending either code. Accepting an arbitrary self-signed certificate is not secure. List or revoke clients with: ```sh sudo remotedesk-agentd list-clients diff --git a/packaging/linux/agent.env b/packaging/linux/agent.env index b2e9cf4..a41bde5 100644 --- a/packaging/linux/agent.env +++ b/packaging/linux/agent.env @@ -2,6 +2,10 @@ # Opening the port in the host or cloud firewall is an explicit administrator action. REMOTEDESK_LISTEN=0.0.0.0:39500 +# TOTP is mandatory. Configure it once before starting the daemon: +# sudo remotedesk-agentd totp-setup +# The secret is stored with mode 0600 under /var/lib/remotedesk. + # Optional Edge Presence configuration. Set all four values or leave all four unset. # Public endpoints must use HTTPS; HTTP is accepted only for loopback development. # REMOTEDESK_EDGE_API_URL=https://edge.example.com/ diff --git a/packaging/linux/debian/postinst b/packaging/linux/debian/postinst index e99cb1c..92147ff 100644 --- a/packaging/linux/debian/postinst +++ b/packaging/linux/debian/postinst @@ -5,8 +5,10 @@ if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true systemctl enable remotedesk-agentd.service || true systemctl --global enable remotedesk-agent-session.service || true - if [ "${1:-}" = "configure" ]; then + if [ "${1:-}" = "configure" ] && [ -f /var/lib/remotedesk/totp.json ]; then systemctl restart remotedesk-agentd.service || true + elif [ "${1:-}" = "configure" ]; then + echo "RemoteDesk requires TOTP setup before start: sudo remotedesk-agentd totp-setup" >&2 fi fi diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh index 429243c..1c33e51 100644 --- a/packaging/linux/install.sh +++ b/packaging/linux/install.sh @@ -21,7 +21,10 @@ if [ ! -e /etc/remotedesk/agent.env ]; then fi systemctl daemon-reload -systemctl enable --now remotedesk-agentd.service +systemctl enable remotedesk-agentd.service systemctl --global enable remotedesk-agent-session.service -echo "RemoteDesk Agent installed. Generate a pairing code locally with:" +echo "RemoteDesk Agent installed. Configure mandatory TOTP, then start the service:" +echo " sudo remotedesk-agentd totp-setup" +echo " sudo systemctl start remotedesk-agentd" +echo "Generate a pairing code locally with:" echo " sudo remotedesk-agentd pairing-code --allow-user YOUR_USER" diff --git a/packaging/linux/remotedesk-agent.spec b/packaging/linux/remotedesk-agent.spec index 09e01ae..7e96c5b 100644 --- a/packaging/linux/remotedesk-agent.spec +++ b/packaging/linux/remotedesk-agent.spec @@ -27,7 +27,11 @@ install -m 0644 %{_sourcedir}/README.md %{buildroot}%{_docdir}/%{name}/ %post systemctl daemon-reload >/dev/null 2>&1 || : systemctl enable remotedesk-agentd.service >/dev/null 2>&1 || : -systemctl restart remotedesk-agentd.service >/dev/null 2>&1 || : +if [ -f /var/lib/remotedesk/totp.json ]; then + systemctl restart remotedesk-agentd.service >/dev/null 2>&1 || : +else + echo "RemoteDesk requires TOTP setup before start: sudo remotedesk-agentd totp-setup" >&2 +fi systemctl --global enable remotedesk-agent-session.service >/dev/null 2>&1 || : %preun diff --git a/protocol/src/edge_signaling.rs b/protocol/src/edge_signaling.rs index b92590a..0b0f200 100644 --- a/protocol/src/edge_signaling.rs +++ b/protocol/src/edge_signaling.rs @@ -2,11 +2,87 @@ use std::fmt; pub const SESSION_INTENT_DOMAIN_V1: &[u8] = b"RemoteDesk/EdgeSessionIntent/v1\0"; pub const AGENT_SIGNAL_PROOF_DOMAIN_V1: &[u8] = b"RemoteDesk/AgentSignalProof/v1\0"; +pub const AGENT_PRESENCE_PROOF_DOMAIN_V1: &[u8] = b"RemoteDesk/AgentPresenceProof/v1\0"; pub const NEGOTIATION_ENVELOPE_DOMAIN_V1: &[u8] = b"RemoteDesk/EdgeNegotiationEnvelope/v1\0"; pub const SIGNAL_NONCE_LENGTH: usize = 32; pub const MAX_SDP_BYTES: usize = 128 * 1024; pub const MAX_ICE_CANDIDATE_BYTES: usize = 4 * 1024; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AgentPresenceAction { + Register, + Unregister, +} + +impl AgentPresenceAction { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Register => "register", + Self::Unregister => "unregister", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AgentPresenceProofV1 { + pub device_id: String, + pub device_public_key: String, + pub region: String, + pub gateway_id: String, + pub connection_id: String, + pub action: AgentPresenceAction, + pub presence_expires_unix: Option, + pub nonce: [u8; SIGNAL_NONCE_LENGTH], + pub issued_unix: u64, +} + +impl AgentPresenceProofV1 { + /// Validates a device-bound Presence lease operation. + /// + /// # Errors + /// + /// Returns an error for malformed identity, routing, or lifetime fields. + pub fn validate(&self) -> Result<(), EdgeSignalBuildError> { + validate_identifier(&self.device_id, 128)?; + validate_public_key_text(&self.device_public_key)?; + validate_identifier(&self.region, 64)?; + validate_identifier(&self.gateway_id, 128)?; + validate_identifier(&self.connection_id, 128)?; + match (self.action, self.presence_expires_unix) { + (AgentPresenceAction::Register, Some(expires_unix)) + if expires_unix > self.issued_unix + && expires_unix <= self.issued_unix.saturating_add(120) => {} + (AgentPresenceAction::Unregister, None) => {} + _ => return Err(EdgeSignalBuildError::InvalidActionFields), + } + Ok(()) + } + + /// Produces stable bytes covered by the Agent device-key signature. + /// + /// # Errors + /// + /// Returns an error when the proof violates the schema boundary. + pub fn stable_signing_input(&self) -> Result, EdgeSignalBuildError> { + self.validate()?; + let mut output = Vec::with_capacity(384); + output.extend_from_slice(AGENT_PRESENCE_PROOF_DOMAIN_V1); + append_field(&mut output, 1, self.device_id.as_bytes()); + append_field(&mut output, 2, self.device_public_key.as_bytes()); + append_field(&mut output, 3, self.region.as_bytes()); + append_field(&mut output, 4, self.gateway_id.as_bytes()); + append_field(&mut output, 5, self.connection_id.as_bytes()); + append_field(&mut output, 6, self.action.as_str().as_bytes()); + if let Some(expires_unix) = self.presence_expires_unix { + append_field(&mut output, 7, &expires_unix.to_be_bytes()); + } + append_field(&mut output, 8, &self.nonce); + append_field(&mut output, 9, &self.issued_unix.to_be_bytes()); + Ok(output) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum EdgeSessionType { Pairing, @@ -562,6 +638,33 @@ mod tests { ); } + #[test] + fn presence_proof_binds_route_action_and_lifetime() { + let proof = AgentPresenceProofV1 { + device_id: "ab".repeat(32), + device_public_key: "A".repeat(43), + region: "cn-east".into(), + gateway_id: "gateway-1".into(), + connection_id: "connection-1".into(), + action: AgentPresenceAction::Register, + presence_expires_unix: Some(160), + nonce: [4; SIGNAL_NONCE_LENGTH], + issued_unix: 100, + }; + let encoded = proof.stable_signing_input().unwrap(); + let mut changed = proof.clone(); + changed.connection_id = "connection-2".into(); + assert_ne!(encoded, changed.stable_signing_input().unwrap()); + let mut unregister = proof; + unregister.action = AgentPresenceAction::Unregister; + assert_eq!( + unregister.stable_signing_input().unwrap_err(), + EdgeSignalBuildError::InvalidActionFields + ); + unregister.presence_expires_unix = None; + assert!(unregister.stable_signing_input().is_ok()); + } + fn negotiation( operation: EdgeNegotiationOperation, kind: Option, diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index 1922c34..76d6b58 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -16,7 +16,8 @@ pub use displays::{ DisplaySelectionMode, }; pub use edge_signaling::{ - AGENT_SIGNAL_PROOF_DOMAIN_V1, AgentSignalAction, AgentSignalProofV1, EdgeNegotiationEndpoint, + AGENT_PRESENCE_PROOF_DOMAIN_V1, AGENT_SIGNAL_PROOF_DOMAIN_V1, AgentPresenceAction, + AgentPresenceProofV1, AgentSignalAction, AgentSignalProofV1, EdgeNegotiationEndpoint, EdgeNegotiationEnvelopeV1, EdgeNegotiationKind, EdgeNegotiationOperation, EdgeSessionIntentV1, EdgeSessionType, EdgeSignalBuildError, MAX_ICE_CANDIDATE_BYTES, MAX_SDP_BYTES, NEGOTIATION_ENVELOPE_DOMAIN_V1, SESSION_INTENT_DOMAIN_V1, SIGNAL_NONCE_LENGTH, diff --git a/protocol/src/path_validation.rs b/protocol/src/path_validation.rs index 43c322c..d949fc2 100644 --- a/protocol/src/path_validation.rs +++ b/protocol/src/path_validation.rs @@ -195,7 +195,7 @@ impl StrictPathCoordinator { ) -> Result { Self::validate_binding(&report.binding, &self.context.agent_binding())?; self.ensure_current_epoch_accepts_reports()?; - if let Err(error) = self.validate_path( + if let Err(error) = Self::validate_path( report.status, report.surface_type, report.cpu_map_count, @@ -226,7 +226,7 @@ impl StrictPathCoordinator { ) -> Result { Self::validate_binding(&report.binding, &self.context.client_binding())?; self.ensure_current_epoch_accepts_reports()?; - if let Err(error) = self.validate_path( + if let Err(error) = Self::validate_path( report.status, report.surface_type, report.cpu_map_count, @@ -308,7 +308,6 @@ impl StrictPathCoordinator { } fn validate_path( - &self, status: MemoryPathStatus, surface_type: MemorySurfaceType, cpu_map_count: u64, diff --git a/protocol/src/windows_media.rs b/protocol/src/windows_media.rs index 52ceaa1..5abade3 100644 --- a/protocol/src/windows_media.rs +++ b/protocol/src/windows_media.rs @@ -850,7 +850,9 @@ mod tests { let mut output = vec![0_u8; VIDEO_DATAGRAM_HEADER_BYTES]; output[..4].copy_from_slice(VIDEO_DATAGRAM_MAGIC); output[4] = VIDEO_DATAGRAM_VERSION; - output[6..8].copy_from_slice(&(VIDEO_DATAGRAM_HEADER_BYTES as u16).to_le_bytes()); + let header_bytes = u16::try_from(VIDEO_DATAGRAM_HEADER_BYTES) + .expect("video datagram header size fits the wire field"); + output[6..8].copy_from_slice(&header_bytes.to_le_bytes()); output[8..12].copy_from_slice(&1_u32.to_le_bytes()); output[12..20].copy_from_slice(&2_u64.to_le_bytes()); output[20..28].copy_from_slice(&sequence.to_le_bytes());