feat: add secure relay and Android controller
This commit is contained in:
+75
-1
@@ -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:
|
||||
|
||||
Generated
+6
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<EdgePresenceConfig>,
|
||||
totp: Arc<TotpVerifier>,
|
||||
) -> 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<AtomicBool>,
|
||||
totp: Arc<TotpVerifier>,
|
||||
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<ClientGrant, Error> {
|
||||
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,
|
||||
|
||||
@@ -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<u64, 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 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<SigningKey, EdgePrese
|
||||
Ok(signing)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn signed_presence_proof(
|
||||
device_id: &str,
|
||||
device_public_key: &str,
|
||||
region: &str,
|
||||
gateway_id: &str,
|
||||
connection_id: &str,
|
||||
action: AgentPresenceAction,
|
||||
presence_expires_unix: Option<u64>,
|
||||
signing: &SigningKey,
|
||||
) -> Result<SignedAgentProof, EdgePresenceError> {
|
||||
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 = {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
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,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<TotpConfig, StateError> {
|
||||
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<Option<TotpConfig>, 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
|
||||
|
||||
@@ -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<String, FailureState>,
|
||||
used_steps: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FailureState {
|
||||
count: u8,
|
||||
blocked_until: u64,
|
||||
}
|
||||
|
||||
pub struct TotpVerifier {
|
||||
secret: Zeroizing<Vec<u8>>,
|
||||
state: Mutex<AttemptState>,
|
||||
}
|
||||
|
||||
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<Self, TotpError> {
|
||||
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::<Sha1>::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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,48 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:label="RemoteDesk"
|
||||
android:name="${applicationName}"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
+149
@@ -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<String, RelayTunnel>()
|
||||
|
||||
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<String>("relayAddress")
|
||||
val ticket = call.argument<String>("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<String>("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<Byte>(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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -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<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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<AgentState> _states =
|
||||
StreamController<AgentState>.broadcast(sync: true);
|
||||
final StreamController<RemoteFrame> _frames =
|
||||
StreamController<RemoteFrame>.broadcast(sync: true);
|
||||
|
||||
IOWebSocketChannel? _channel;
|
||||
StreamIterator<dynamic>? _messages;
|
||||
HttpClient? _httpClient;
|
||||
_FrameAssembly? _assembly;
|
||||
bool _closed = false;
|
||||
bool _certificateRejected = false;
|
||||
bool _usingRelay = false;
|
||||
bool _desktopOpen = false;
|
||||
RelayTunnel? _relayTunnel;
|
||||
|
||||
Stream<AgentState> get states => _states.stream;
|
||||
Stream<RemoteFrame> get frames => _frames.stream;
|
||||
|
||||
Future<void> 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<String, dynamic> 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 = <int>[
|
||||
...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
|
||||
? <String, Object>{
|
||||
'type': 'authenticate',
|
||||
'protocol_major': protocolMajor,
|
||||
'protocol_minor': protocolMinor,
|
||||
'client_public_key': publicKey,
|
||||
'signature': _encodeUnpaddedBase64(signature),
|
||||
}
|
||||
: <String, Object>{
|
||||
'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<Uri> _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<Map<String, dynamic>> _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<void> _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<void> 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<void> dispose() async {
|
||||
await close();
|
||||
await _states.close();
|
||||
await _frames.close();
|
||||
}
|
||||
|
||||
Future<void> _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<String, dynamic> event) {
|
||||
if (_assembly != null) {
|
||||
throw const AgentException(AgentError.frame);
|
||||
}
|
||||
final metadata = event['metadata'];
|
||||
if (metadata is! Map<String, dynamic>) {
|
||||
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<String, dynamic> 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<void> _completeFrame(Map<String, dynamic> 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<Map<String, dynamic>> _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<String, dynamic>) {
|
||||
throw const AgentException(AgentError.protocol);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
void _validateHello(Map<String, dynamic> 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<String, dynamic> 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<String, Object?> 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<void> _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<String, dynamic> value, String key) {
|
||||
final result = value[key];
|
||||
if (result is! int) throw const AgentException(AgentError.protocol);
|
||||
return result;
|
||||
}
|
||||
|
||||
String _string(Map<String, dynamic> value, String key) {
|
||||
final result = value[key];
|
||||
if (result is! String) throw const AgentException(AgentError.protocol);
|
||||
return result;
|
||||
}
|
||||
|
||||
String _encodeUnpaddedBase64(List<int> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<RelayTunnel> 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<int>.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 = <String, Object>{
|
||||
'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 = <String, Object>{
|
||||
'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<String, dynamic> ||
|
||||
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<String, dynamic>(
|
||||
'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<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
throw const EdgeException();
|
||||
}
|
||||
|
||||
static Future<void> stopTunnel(String id) async {
|
||||
try {
|
||||
await _tunnelChannel.invokeMethod<void>('stopRelay', {'id': id});
|
||||
} on PlatformException {
|
||||
// Process shutdown also closes native sockets.
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _post(Uri uri, Map<String, Object> 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<String, dynamic>) 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<int>.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<int> 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<int> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<ClientIdentity> 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<List<int>> sign(ClientIdentity identity, List<int> payload) async {
|
||||
final signature = await _algorithm.sign(payload, keyPair: identity.keyPair);
|
||||
return signature.bytes;
|
||||
}
|
||||
}
|
||||
@@ -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>[Locale('en'), Locale('zh')];
|
||||
|
||||
static const LocalizationsDelegate<AppLocalizations> delegate =
|
||||
_AppLocalizationsDelegate();
|
||||
|
||||
static AppLocalizations of(BuildContext context) =>
|
||||
Localizations.of<AppLocalizations>(context, AppLocalizations)!;
|
||||
|
||||
static const _values = <String, Map<String, String>>{
|
||||
'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<String, Object> 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<AppLocalizations> {
|
||||
const _AppLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => AppLocalizations.supportedLocales.any(
|
||||
(supported) => supported.languageCode == locale.languageCode,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) =>
|
||||
SynchronousFuture(AppLocalizations(locale));
|
||||
|
||||
@override
|
||||
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String, Object?> 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<String, dynamic> 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<RemoteHost> decodeList(String value) {
|
||||
final decoded = jsonDecode(value) as List<dynamic>;
|
||||
return decoded
|
||||
.map((item) => RemoteHost.fromJson(item as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static String encodeList(List<RemoteHost> hosts) =>
|
||||
jsonEncode(hosts.map((host) => host.toJson()).toList());
|
||||
}
|
||||
|
||||
ConnectionMode _connectionModeFromJson(Map<String, dynamic> 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());
|
||||
@@ -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<List<RemoteHost>> 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<void> saveHosts(List<RemoteHost> hosts) async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
await preferences.setString(_hostsKey, RemoteHost.encodeList(hosts));
|
||||
}
|
||||
|
||||
Future<AppSettings> 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<void> 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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -69,7 +69,9 @@ const _zh = <String, String>{
|
||||
'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 = <String, String>{
|
||||
'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 = <String, String>{
|
||||
'Read clipboard': '读取剪贴板',
|
||||
'Write clipboard': '写入剪贴板',
|
||||
'Language': '语言',
|
||||
'Close to system tray': '关闭到系统托盘',
|
||||
'Keep RemoteDesk running when the window is closed.':
|
||||
'关闭窗口时让 RemoteDesk 继续在后台运行。',
|
||||
'System default': '跟随系统',
|
||||
'English': 'English',
|
||||
'简体中文': '简体中文',
|
||||
|
||||
@@ -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<AppSettings> load();
|
||||
|
||||
Future<void> 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<AppSettings> 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<void> 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class DesktopTrayController
|
||||
Locale? _localeOverride;
|
||||
bool _initialized = false;
|
||||
bool _exiting = false;
|
||||
bool _closeToTray = true;
|
||||
|
||||
Future<void> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> main() async {
|
||||
}
|
||||
|
||||
class RemoteDeskControlApp extends StatefulWidget {
|
||||
const RemoteDeskControlApp({super.key});
|
||||
const RemoteDeskControlApp({
|
||||
super.key,
|
||||
this.preferences = const SharedAppPreferences(),
|
||||
});
|
||||
|
||||
final AppPreferences preferences;
|
||||
|
||||
@override
|
||||
State<RemoteDeskControlApp> createState() => _RemoteDeskControlAppState();
|
||||
}
|
||||
|
||||
class _RemoteDeskControlAppState extends State<RemoteDeskControlApp> {
|
||||
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<void> _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<void> _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<RemoteDeskControlApp> {
|
||||
),
|
||||
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<AppSettings> onSettingsChanged;
|
||||
final ValueChanged<Locale?> onLocaleChanged;
|
||||
|
||||
@override
|
||||
@@ -130,6 +185,7 @@ class _ControlWorkspaceState extends State<ControlWorkspace> {
|
||||
final _user = TextEditingController();
|
||||
final _fingerprint = TextEditingController();
|
||||
final _search = TextEditingController();
|
||||
final _searchFocus = FocusNode();
|
||||
|
||||
Timer? _healthTimer;
|
||||
Timer? _sessionTimer;
|
||||
@@ -145,19 +201,25 @@ class _ControlWorkspaceState extends State<ControlWorkspace> {
|
||||
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<ControlWorkspace> {
|
||||
);
|
||||
}
|
||||
|
||||
@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<ControlWorkspace> {
|
||||
_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<ControlWorkspace> {
|
||||
if (!result.ok) _notify(result.message, error: true);
|
||||
}
|
||||
|
||||
void _selectHost(SavedHost host) {
|
||||
Future<void> _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<void> _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<ControlWorkspace> {
|
||||
_fingerprint.clear();
|
||||
_probe = null;
|
||||
_page = _WorkspacePage.devices;
|
||||
_formDirty = false;
|
||||
_updatingForm = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscardChanges() async {
|
||||
if (!_formDirty) return true;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return await showDialog<bool>(
|
||||
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<ControlWorkspace> {
|
||||
if (result.ok) {
|
||||
_hosts = updated;
|
||||
_selectedHostId = host.id;
|
||||
_formDirty = false;
|
||||
}
|
||||
});
|
||||
_notify(result.message, error: !result.ok);
|
||||
@@ -309,7 +434,7 @@ class _ControlWorkspaceState extends State<ControlWorkspace> {
|
||||
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<ControlWorkspace> {
|
||||
);
|
||||
}
|
||||
|
||||
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<ControlWorkspace> {
|
||||
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<ControlWorkspace> {
|
||||
_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<String>(
|
||||
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<String>(
|
||||
key: ValueKey(state._resolution),
|
||||
initialValue: state._resolution,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.text('Stream resolution'),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<AppSettings> load() async => settings;
|
||||
|
||||
@override
|
||||
Future<void> 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<SwitchListTile>(
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1259,15 +1259,16 @@ async fn authenticate<S>(
|
||||
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<S>(
|
||||
async fn authenticate_with_codes<S>(
|
||||
socket: &mut tokio_tungstenite::WebSocketStream<S>,
|
||||
args: &Args,
|
||||
signing_key: &SigningKey,
|
||||
pair: bool,
|
||||
supplied_pairing_code: Option<&str>,
|
||||
supplied_totp_code: Option<&str>,
|
||||
) -> Result<AuthenticatedAgent, String>
|
||||
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()))
|
||||
|
||||
+14
-4
@@ -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。
|
||||
|
||||
+1
-1
@@ -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 验收
|
||||
|
||||
|
||||
+1
-1
@@ -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` 报告这个服务端边界。
|
||||
|
||||
|
||||
@@ -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 剪贴板仍关闭。 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
+318
-62
@@ -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<T> {
|
||||
#[serde(flatten)]
|
||||
@@ -656,6 +680,37 @@ struct AuthorizedSignalResponse<T> {
|
||||
turn: Vec<TurnCredential>,
|
||||
}
|
||||
|
||||
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<T: for<'de> Deserialize<'de>>(body: &[u8]) -> Result<T, ApiReply> {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
+122
-95
@@ -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<Self>, mut stream: TcpStream) -> io::Result<()> {
|
||||
async fn handle_connection(
|
||||
self: &Arc<Self>,
|
||||
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<RelayHello> {
|
||||
async fn read_hello(stream: &mut TcpStream) -> io::Result<RelayHello> {
|
||||
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<RelayHello> {
|
||||
"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<R, W>(
|
||||
mut source: R,
|
||||
mut destination: W,
|
||||
used: &AtomicU64,
|
||||
last_activity: &Mutex<Instant>,
|
||||
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<RelayBroker>,
|
||||
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<RelayBroker>,
|
||||
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)))
|
||||
|
||||
@@ -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<HashMap<String, Presence>>,
|
||||
used_nonces: Mutex<HashMap<[u8; SIGNAL_NONCE_LENGTH], u64>>,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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<String, &'static str> {
|
||||
pub(crate) fn device_id_from_public_key(public_key: &str) -> Result<String, &'static str> {
|
||||
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<String, &'static str> {
|
||||
Ok(format_hex(&digest.finalize()))
|
||||
}
|
||||
|
||||
fn decode_fixed<const LENGTH: usize>(value: &str) -> Result<[u8; LENGTH], ()> {
|
||||
pub(crate) fn decode_fixed<const LENGTH: usize>(value: &str) -> Result<[u8; LENGTH], ()> {
|
||||
let decoded = STANDARD_NO_PAD.decode(value).map_err(|_| ())?;
|
||||
if STANDARD_NO_PAD.encode(&decoded) != value {
|
||||
return Err(());
|
||||
|
||||
@@ -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"
|
||||
@@ -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 <REMOTEDESK_EDGE_API_TOKEN>`; 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 <REMOTEDESK_EDGE_API_TOKEN>`; 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/<request-id>`.
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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" <<EOF
|
||||
REMOTEDESK_EDGE_API_TOKEN=$api_token
|
||||
REMOTEDESK_EDGE_PRESENCE_TOKEN=$presence_token
|
||||
REMOTEDESK_EDGE_TICKET_SECRET=$ticket_secret
|
||||
REMOTEDESK_EDGE_PUBLIC_RELAY_ADDRESS=$public_relay_address
|
||||
REMOTEDESK_EDGE_POP_ID=$pop_id
|
||||
REMOTEDESK_EDGE_REGION=$region
|
||||
EOF
|
||||
chown root:remotedesk-edge "$temporary"
|
||||
chmod 0640 "$temporary"
|
||||
mv -f -- "$temporary" "$config"
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
echo "RemoteDesk Edge configuration written to $config"
|
||||
echo "Publish the API with HTTPS and the relay with TLS before distributing client settings."
|
||||
echo "Agents need REMOTEDESK_EDGE_API_URL plus REMOTEDESK_EDGE_PRESENCE_TOKEN from this file."
|
||||
|
||||
if [ "$start_service" = true ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now remotedesk-edge.service
|
||||
echo "remotedesk-edge.service is running"
|
||||
fi
|
||||
@@ -10,6 +10,7 @@ systemctl disable --now remotedesk-edge.service >/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."
|
||||
|
||||
@@ -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/<uid>/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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<u64>,
|
||||
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<Vec<u8>, 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<EdgeNegotiationKind>,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -195,7 +195,7 @@ impl StrictPathCoordinator {
|
||||
) -> Result<StrictPathState, PathReportError> {
|
||||
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<StrictPathState, PathReportError> {
|
||||
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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user