diff --git a/core/src/http/client/mod.rs b/core/src/http/client/mod.rs index 5fdddae2..b34862fc 100644 --- a/core/src/http/client/mod.rs +++ b/core/src/http/client/mod.rs @@ -1,302 +1,11 @@ mod url; +pub mod v2; +pub mod v3; -use crate::http; -use crate::http::client::url::{ApiVersion, TargetUrl}; -use crate::http::dto::ProtocolType; -use crate::http::StatusCodeError; -use crate::{crypto, util}; -use futures_util::StreamExt; -use lru::LruCache; -use reqwest::{Response, StatusCode}; -use serde::{Deserialize, Serialize}; -use std::num::NonZeroUsize; -use std::sync::Arc; -use tokio::sync::{mpsc, Mutex}; -use tokio_stream::wrappers::ReceiverStream; +pub use v2::LsHttpClientV2; +pub use v3::LsHttpClientV3; -const BASE_PATH: &str = "/api/localsend/v3"; - -pub struct LsHttpClient { - client: reqwest::Client, - - /// Maps client identifiers to nonces that have been received from remote. - received_nonce_map: Arc>>>, - - /// Maps client identifiers to nonces that are expected to be received from remote. - generated_nonce_map: Arc>>>, -} - -pub struct RegisterResult { - /// The public key extracted from the certificate. - /// Encoded in PEM format. - /// Only available in HTTPS. - pub public_key: Option, - - /// The response body from the register request. - pub body: http::dto::RegisterResponseDto, -} - -impl LsHttpClient { - pub fn try_new(private_key: &str, cert: &str) -> anyhow::Result { - let _ = rustls::crypto::ring::default_provider().install_default(); - - let identity = { - let pem = &[cert.as_bytes(), "\n".as_bytes(), private_key.as_bytes()].concat(); - reqwest::Identity::from_pem(pem)? - }; - - let client = reqwest::Client::builder() - .use_rustls_tls() - .danger_accept_invalid_certs(true) - .tls_info(true) - .identity(identity) - .build()?; - - Ok(Self { - client, - received_nonce_map: Arc::new(Mutex::new(LruCache::new( - NonZeroUsize::new(200).unwrap(), - ))), - generated_nonce_map: Arc::new(Mutex::new(LruCache::new( - NonZeroUsize::new(200).unwrap(), - ))), - }) - } - - pub async fn nonce( - &self, - protocol: &ProtocolType, - ip: &str, - port: u16, - ) -> anyhow::Result { - // Generate nonce to send to server - let generated_nonce = crypto::nonce::generate_nonce(); - let generated_nonce_base64 = util::base64::encode(&generated_nonce); - - let request_body = http::dto::NonceRequest { - nonce: generated_nonce_base64, - }; - - let res = self - .client - .post( - TargetUrl { - version: ApiVersion::V3, - protocol: protocol.clone(), - host: ip.to_string(), - port, - path: "/nonce", - } - .to_string(), - ) - .body(serde_json::to_string(&request_body)?) - .send() - .await?; - - if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); - } - - let remote_key = to_identifier(&res, protocol == &ProtocolType::Https, None)?; - let body = res.json::().await?; - - // Save the response nonce and our generated nonce - let response_nonce = util::base64::decode(&body.nonce)?; - - let mut received_nonce_map = self.received_nonce_map.lock().await; - received_nonce_map.put(remote_key.clone(), response_nonce); - - let mut generated_nonce_map = self.generated_nonce_map.lock().await; - generated_nonce_map.put(remote_key.clone(), generated_nonce); - - tracing::info!("Nonce exchange successful for server: {ip} (ID: {remote_key})"); - - tracing::debug!( - "Received map: {:?}", - received_nonce_map.get(&remote_key).unwrap() - ); - tracing::debug!( - "Generated map: {:?}", - generated_nonce_map.get(&remote_key).unwrap() - ); - - Ok(body.nonce) - } - - pub async fn register( - &self, - protocol: &ProtocolType, - ip: &str, - port: u16, - payload: http::dto::RegisterDto, - ) -> anyhow::Result { - let res = self - .client - .post(format!( - "{}://{}:{}{}/register", - protocol.as_str(), - ip, - port, - BASE_PATH - )) - .body(serde_json::to_string(&payload)?) - .send() - .await?; - - let public_key = match protocol { - ProtocolType::Https => Some(verify_cert_from_res(&res, None)?), - _ => None, - }; - - let body = res.json::().await?; - - Ok(RegisterResult { public_key, body }) - } - - pub async fn prepare_upload( - &self, - protocol: &ProtocolType, - ip: &str, - port: u16, - public_key: Option, - payload: http::dto::PrepareUploadRequestDto, - ) -> anyhow::Result { - let res = self - .client - .post(format!( - "{}://{}:{}{}/prepare-upload", - protocol.as_str(), - ip, - port, - BASE_PATH - )) - .body(serde_json::to_string(&payload)?) - .send() - .await?; - - if let Some(public_key) = public_key { - verify_cert_from_res(&res, Some(public_key))?; - } - - if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); - } - - let body = res.json::().await?; - - Ok(body) - } - - /// Uploads a file to the server. - pub async fn upload( - &self, - protocol: &ProtocolType, - ip: &str, - port: u16, - session_id: String, - file_id: String, - token: String, - binary: mpsc::Receiver>, - ) -> anyhow::Result<()> { - let res = self - .client - .post(format!( - "{}://{}:{}{}/upload?sessionId={}&fileId={}&token={}", - protocol.as_str(), - ip, - port, - BASE_PATH, - session_id, - file_id, - token - )) - .body({ - let stream = ReceiverStream::new(binary).map(Ok::, anyhow::Error>); - reqwest::Body::wrap_stream(stream) - }) - .send() - .await?; - - if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); - } - - Ok(()) - } - - pub async fn cancel( - &self, - protocol: &ProtocolType, - ip: &str, - port: u16, - session_id: String, - ) -> anyhow::Result<()> { - self.client - .post(format!( - "{}://{}:{}{}/cancel?sessionId={}", - protocol.as_str(), - ip, - port, - BASE_PATH, - session_id - )) - .send() - .await?; - - Ok(()) - } -} - -#[derive(Serialize, Deserialize)] -struct ErrorResponse { - message: String, -} - -async fn status_code_error_from_res(response: Response) -> anyhow::Result { - let status = response.status().as_u16(); - let body = response.text().await?; - let body = match serde_json::from_str::(&body) { - Ok(error) => error.message, - Err(_) => body, - }; - - Ok(anyhow::Error::new(StatusCodeError { - status, - message: match body { - _ if body.is_empty() => None, - _ => Some(body), - }, - })) -} - -fn to_identifier( - response: &Response, - require_cert: bool, - public_key: Option, -) -> anyhow::Result { - match require_cert { - true => verify_cert_from_res(response, public_key), - false => response - .remote_addr() - .map(|addr| addr.ip().to_string()) - .ok_or_else(|| anyhow::anyhow!("Remote address not found in response")), - } -} - -/// Verifies the certificate from the response. -/// Returns the public key extracted from the certificate. -fn verify_cert_from_res(response: &Response, public_key: Option) -> anyhow::Result { - let tls_info_ext = response - .extensions() - .get::() - .ok_or_else(|| anyhow::anyhow!("TLS info not found"))?; - let cert = tls_info_ext - .peer_certificate() - .ok_or_else(|| anyhow::anyhow!("Certificate not found"))?; - let public_key = match public_key { - Some(public_key) => public_key.to_owned(), - None => crypto::cert::public_key_from_cert_der(cert)?, - }; - crypto::cert::verify_cert_from_der(cert, Some(public_key.clone()))?; - Ok(public_key) +pub enum LsHttpClient { + V2(LsHttpClientV2), + V3(LsHttpClientV3), } diff --git a/core/src/http/client/url.rs b/core/src/http/client/url.rs index 33ffe090..cc02f3af 100644 --- a/core/src/http/client/url.rs +++ b/core/src/http/client/url.rs @@ -1,12 +1,15 @@ -use crate::http::dto::ProtocolType; use std::borrow::Cow; -pub struct TargetUrl { +pub struct TargetUrl<'a> { pub version: ApiVersion, - pub protocol: ProtocolType, + pub protocol: &'static str, pub host: String, pub port: u16, pub path: &'static str, + + /// Query parameters as key-value pairs. + /// Note: It is expected that the caller will URL-encode the values if necessary. + pub params: &'a [(&'static str, &'a str)], } pub enum ApiVersion { @@ -14,11 +17,11 @@ pub enum ApiVersion { V3, } -impl TargetUrl { +impl<'a> TargetUrl<'a> { pub fn to_string(&self) -> String { - format!( + let base = format!( "{}://{}:{}/api/localsend/{}{}", - self.protocol.as_str(), + self.protocol, match self.host.contains(':') { true => Cow::Owned(format!("[{}]", self.host)), // IPv6 addresses need to be enclosed in brackets false => Cow::Borrowed(&self.host), @@ -29,6 +32,17 @@ impl TargetUrl { ApiVersion::V3 => "v3", }, self.path - ) + ); + if self.params.is_empty() { + base + } else { + let query = self + .params + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join("&"); + format!("{}?{}", base, query) + } } } diff --git a/core/src/http/client/v2.rs b/core/src/http/client/v2.rs new file mode 100644 index 00000000..8aa311b9 --- /dev/null +++ b/core/src/http/client/v2.rs @@ -0,0 +1,559 @@ +use crate::http::client::url::{ApiVersion, TargetUrl}; +use crate::http::dto_v2::{ + InfoResponseDtoV2, PrepareDownloadResponseDtoV2, PrepareUploadRequestDtoV2, + PrepareUploadResponseDtoV2, ProtocolTypeV2, RegisterDtoV2, RegisterResponseDtoV2, +}; +use crate::http::StatusCodeError; +use crate::crypto; +use futures_util::StreamExt; +use reqwest::{Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; + +/// Result of a successful register request. +pub struct RegisterResultV2 { + /// The public key extracted from the certificate. + /// Encoded in PEM format. + /// Only available in HTTPS mode. + pub public_key: Option, + + /// The response body from the register request. + pub body: RegisterResponseDtoV2, +} + +/// HTTP client for LocalSend Protocol v2.1. +pub struct LsHttpClientV2 { + client: reqwest::Client, +} + +impl LsHttpClientV2 { + /// Creates a new HTTP client for v2.1 protocol. + /// + /// # Arguments + /// * `private_key` - PEM-encoded private key for client certificate + /// * `cert` - PEM-encoded certificate for client authentication + /// + /// # Returns + /// A new client instance or an error if TLS setup fails. + pub fn try_new(private_key: &str, cert: &str) -> anyhow::Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let identity = { + let pem = &[cert.as_bytes(), "\n".as_bytes(), private_key.as_bytes()].concat(); + reqwest::Identity::from_pem(pem)? + }; + + let client = reqwest::Client::builder() + .use_rustls_tls() + .danger_accept_invalid_certs(true) + .tls_info(true) + .identity(identity) + .build()?; + + Ok(Self { + client, + }) + } + + /// Creates a new HTTP client without TLS client certificate. + /// + /// Use this for HTTP-only connections or when client authentication is not needed. + pub fn try_new_without_cert() -> anyhow::Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let client = reqwest::Client::builder() + .use_rustls_tls() + .danger_accept_invalid_certs(true) + .tls_info(true) + .build()?; + + Ok(Self { + client, + }) + } + + fn url(&self, protocol: &ProtocolTypeV2, ip: &str, port: u16, path: &'static str) -> String { + TargetUrl { + version: ApiVersion::V2, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path, + params: &[], + } + .to_string() + } + + /// Registers with another device for discovery. + /// + /// POST /api/localsend/v2/register + /// + /// # Arguments + /// * `protocol` - HTTP or HTTPS + /// * `ip` - Target device IP address + /// * `port` - Target device port + /// * `payload` - Device information to register + /// + /// # Returns + /// Registration result containing the remote device info and optional public key. + pub async fn register( + &self, + protocol: &ProtocolTypeV2, + ip: &str, + port: u16, + payload: RegisterDtoV2, + ) -> anyhow::Result { + let url = self.url(protocol, ip, port, "/register"); + + let res = self + .client + .post(&url) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&payload)?) + .send() + .await?; + + if res.status() != StatusCode::OK { + return Err(status_code_error_from_res(res).await?); + } + + let public_key = match protocol { + ProtocolTypeV2::Https => Some(verify_cert_from_res(&res, None)?), + _ => None, + }; + + let body = res.json::().await?; + + Ok(RegisterResultV2 { public_key, body }) + } + + /// Prepares a file upload session with the receiver. + /// + /// POST /api/localsend/v2/prepare-upload + /// + /// The receiver will decide if this request gets accepted, partially accepted, or rejected. + /// + /// # Arguments + /// * `protocol` - HTTP or HTTPS + /// * `ip` - Receiver's IP address + /// * `port` - Receiver's port + /// * `public_key` - Expected public key for verification (HTTPS only) + /// * `payload` - Upload request with device info and file metadata + /// * `pin` - Optional PIN if required by receiver + /// + /// # Returns + /// Session ID and accepted file tokens, or an error. + /// + /// # Errors + /// * 204 - No file transfer needed (all files already exist) + /// * 400 - Invalid body + /// * 401 - PIN required or invalid + /// * 403 - Rejected by user + /// * 409 - Blocked by another session + /// * 429 - Too many requests + /// * 500 - Unknown error + pub async fn prepare_upload( + &self, + protocol: &ProtocolTypeV2, + ip: &str, + port: u16, + public_key: Option, + payload: PrepareUploadRequestDtoV2, + pin: Option<&str>, + ) -> anyhow::Result { + let pin_params: &[(&'static str, &str)] = match &pin { + Some(pin) => &[("pin", pin)], + None => &[], + }; + let url = TargetUrl { + version: ApiVersion::V2, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/prepare-upload", + params: pin_params, + } + .to_string(); + + let res = self + .client + .post(&url) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&payload)?) + .send() + .await?; + + if let Some(public_key) = public_key { + verify_cert_from_res(&res, Some(public_key))?; + } + + if res.status() == StatusCode::NO_CONTENT { + // 204 - No file transfer needed + return Ok(PrepareUploadResponseDtoV2 { + session_id: String::new(), + files: std::collections::HashMap::new(), + }); + } + + if res.status() != StatusCode::OK { + return Err(status_code_error_from_res(res).await?); + } + + let body = res.json::().await?; + + Ok(body) + } + + /// Uploads a file to the receiver. + /// + /// POST /api/localsend/v2/upload?sessionId=...&fileId=...&token=... + /// + /// Use the session_id, file_id, and token from prepare_upload response. + /// This method can be called in parallel for multiple files. + /// + /// # Arguments + /// * `protocol` - HTTP or HTTPS + /// * `ip` - Receiver's IP address + /// * `port` - Receiver's port + /// * `session_id` - Session ID from prepare_upload + /// * `file_id` - File ID to upload + /// * `token` - File-specific token from prepare_upload + /// * `binary` - Channel receiving file chunks + /// + /// # Errors + /// * 400 - Missing parameters + /// * 403 - Invalid token or IP address + /// * 409 - Blocked by another session + /// * 500 - Unknown error + pub async fn upload( + &self, + protocol: &ProtocolTypeV2, + ip: &str, + port: u16, + session_id: &str, + file_id: &str, + token: &str, + binary: mpsc::Receiver>, + ) -> anyhow::Result<()> { + let url = TargetUrl { + version: ApiVersion::V2, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/upload", + params: &[("sessionId", session_id), ("fileId", file_id), ("token", token)], + } + .to_string(); + + let stream = ReceiverStream::new(binary).map(Ok::, anyhow::Error>); + let body = reqwest::Body::wrap_stream(stream); + + let res = self.client.post(&url).body(body).send().await?; + + if res.status() != StatusCode::OK { + return Err(status_code_error_from_res(res).await?); + } + + Ok(()) + } + + /// Uploads a file from bytes. + /// + /// Convenience method that wraps upload() for simple byte array uploads. + pub async fn upload_bytes( + &self, + protocol: &ProtocolTypeV2, + ip: &str, + port: u16, + session_id: &str, + file_id: &str, + token: &str, + data: Vec, + ) -> anyhow::Result<()> { + let url = TargetUrl { + version: ApiVersion::V2, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/upload", + params: &[("sessionId", session_id), ("fileId", file_id), ("token", token)], + } + .to_string(); + + let res = self.client.post(&url).body(data).send().await?; + + if res.status() != StatusCode::OK { + return Err(status_code_error_from_res(res).await?); + } + + Ok(()) + } + + /// Cancels an ongoing file transfer session. + /// + /// POST /api/localsend/v2/cancel?sessionId=... + /// + /// # Arguments + /// * `protocol` - HTTP or HTTPS + /// * `ip` - Receiver's IP address + /// * `port` - Receiver's port + /// * `session_id` - Session ID to cancel + pub async fn cancel( + &self, + protocol: &ProtocolTypeV2, + ip: &str, + port: u16, + session_id: &str, + ) -> anyhow::Result<()> { + let url = TargetUrl { + version: ApiVersion::V2, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/cancel", + params: &[("sessionId", session_id)], + } + .to_string(); + + self.client.post(&url).send().await?; + + Ok(()) + } + + /// Gets device info from a remote device. + /// + /// GET /api/localsend/v2/info + /// + /// This is primarily for debugging purposes. + /// + /// # Arguments + /// * `protocol` - HTTP or HTTPS + /// * `ip` - Target device IP address + /// * `port` - Target device port + /// + /// # Returns + /// Device information including alias, version, device type, fingerprint, etc. + pub async fn info( + &self, + protocol: &ProtocolTypeV2, + ip: &str, + port: u16, + ) -> anyhow::Result { + let url = self.url(protocol, ip, port, "/info"); + + let res = self.client.get(&url).send().await?; + + if res.status() != StatusCode::OK { + return Err(status_code_error_from_res(res).await?); + } + + let body = res.json::().await?; + + Ok(body) + } + + /// Prepares to download files from a sender (Download API). + /// + /// POST /api/localsend/v2/prepare-download + /// + /// This is used in reverse file transfer mode where the sender hosts the files + /// and receivers download them. + /// + /// # Arguments + /// * `protocol` - HTTP or HTTPS + /// * `ip` - Sender's IP address + /// * `port` - Sender's port + /// * `session_id` - Optional existing session ID (for browser refresh scenarios) + /// * `pin` - Optional PIN if required by sender + /// + /// # Returns + /// Sender info, session ID, and available files. + /// + /// # Errors + /// * 401 - PIN required or invalid + /// * 403 - Rejected + /// * 429 - Too many requests + /// * 500 - Unknown error + pub async fn prepare_download( + &self, + protocol: &ProtocolTypeV2, + ip: &str, + port: u16, + session_id: Option<&str>, + pin: Option<&str>, + ) -> anyhow::Result { + let mut params: Vec<(&'static str, &str)> = Vec::new(); + if let Some(session_id) = session_id { + params.push(("sessionId", session_id)); + } + if let Some(pin) = pin { + params.push(("pin", pin)); + } + let url = TargetUrl { + version: ApiVersion::V2, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/prepare-download", + params: ¶ms, + } + .to_string(); + + let res = self.client.post(&url).send().await?; + + if res.status() != StatusCode::OK { + return Err(status_code_error_from_res(res).await?); + } + + let body = res.json::().await?; + + Ok(body) + } + + /// Downloads a file from a sender (Download API). + /// + /// GET /api/localsend/v2/download?sessionId=...&fileId=... + /// + /// This method can be called in parallel for multiple files. + /// + /// # Arguments + /// * `protocol` - HTTP or HTTPS + /// * `ip` - Sender's IP address + /// * `port` - Sender's port + /// * `session_id` - Session ID from prepare_download + /// * `file_id` - File ID to download + /// + /// # Returns + /// Response containing the file data stream. + pub async fn download( + &self, + protocol: &ProtocolTypeV2, + ip: &str, + port: u16, + session_id: &str, + file_id: &str, + ) -> anyhow::Result { + let url = TargetUrl { + version: ApiVersion::V2, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/download", + params: &[("sessionId", session_id), ("fileId", file_id)], + } + .to_string(); + + let res = self.client.get(&url).send().await?; + + if res.status() != StatusCode::OK { + return Err(status_code_error_from_res(res).await?); + } + + Ok(res) + } + + /// Downloads a file to a writer (convenience method). + /// + /// # Arguments + /// * `protocol` - HTTP or HTTPS + /// * `ip` - Sender's IP address + /// * `port` - Sender's port + /// * `session_id` - Session ID from prepare_download + /// * `file_id` - File ID to download + /// * `writer` - AsyncWrite destination for file data + /// + /// # Returns + /// Total bytes written. + pub async fn download_to_writer( + &self, + protocol: &ProtocolTypeV2, + ip: &str, + port: u16, + session_id: &str, + file_id: &str, + writer: &mut W, + ) -> anyhow::Result { + let response = self.download(protocol, ip, port, session_id, file_id).await?; + + let mut stream = response.bytes_stream(); + let mut total_bytes = 0u64; + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + writer.write_all(&chunk).await?; + total_bytes += chunk.len() as u64; + } + + writer.flush().await?; + + Ok(total_bytes) + } +} + +#[derive(Serialize, Deserialize)] +struct ErrorResponse { + message: String, +} + +async fn status_code_error_from_res(response: Response) -> anyhow::Result { + let status = response.status().as_u16(); + let body = response.text().await?; + let body = match serde_json::from_str::(&body) { + Ok(error) => error.message, + Err(_) => body, + }; + + Ok(anyhow::Error::new(StatusCodeError { + status, + message: match body { + _ if body.is_empty() => None, + _ => Some(body), + }, + })) +} + +/// Verifies the certificate from the response. +/// Returns the public key extracted from the certificate. +fn verify_cert_from_res(response: &Response, public_key: Option) -> anyhow::Result { + let tls_info_ext = response + .extensions() + .get::() + .ok_or_else(|| anyhow::anyhow!("TLS info not found"))?; + let cert = tls_info_ext + .peer_certificate() + .ok_or_else(|| anyhow::anyhow!("Certificate not found"))?; + let public_key = match public_key { + Some(public_key) => public_key.to_owned(), + None => crypto::cert::public_key_from_cert_der(cert)?, + }; + crypto::cert::verify_cert_from_der(cert, Some(public_key.clone()))?; + Ok(public_key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_url_ipv4() { + let client = LsHttpClientV2::try_new_without_cert().unwrap(); + let url = client.url(&ProtocolTypeV2::Https, "192.168.1.1", 53317, "/register"); + assert_eq!(url, "https://192.168.1.1:53317/api/localsend/v2/register"); + } + + #[test] + fn test_build_url_ipv6() { + let client = LsHttpClientV2::try_new_without_cert().unwrap(); + let url = client.url(&ProtocolTypeV2::Https, "::1", 53317, "/register"); + assert_eq!(url, "https://[::1]:53317/api/localsend/v2/register"); + } + + #[test] + fn test_build_url_http() { + let client = LsHttpClientV2::try_new_without_cert().unwrap(); + let url = client.url(&ProtocolTypeV2::Http, "192.168.1.1", 53317, "/info"); + assert_eq!(url, "http://192.168.1.1:53317/api/localsend/v2/info"); + } +} diff --git a/core/src/http/client/v3.rs b/core/src/http/client/v3.rs new file mode 100644 index 00000000..68a25f19 --- /dev/null +++ b/core/src/http/client/v3.rs @@ -0,0 +1,299 @@ +use crate::http; +use crate::http::client::url::{ApiVersion, TargetUrl}; +use crate::http::dto::ProtocolType; +use crate::http::StatusCodeError; +use crate::{crypto, util}; +use futures_util::StreamExt; +use lru::LruCache; +use reqwest::{Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use std::num::NonZeroUsize; +use std::sync::Arc; +use tokio::sync::{mpsc, Mutex}; +use tokio_stream::wrappers::ReceiverStream; + +pub struct LsHttpClientV3 { + client: reqwest::Client, + + /// Maps client identifiers to nonces that have been received from remote. + received_nonce_map: Arc>>>, + + /// Maps client identifiers to nonces that are expected to be received from remote. + generated_nonce_map: Arc>>>, +} + +pub struct RegisterResult { + /// The public key extracted from the certificate. + /// Encoded in PEM format. + /// Only available in HTTPS. + pub public_key: Option, + + /// The response body from the register request. + pub body: http::dto::RegisterResponseDto, +} + +impl LsHttpClientV3 { + pub fn try_new(private_key: &str, cert: &str) -> anyhow::Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let identity = { + let pem = &[cert.as_bytes(), "\n".as_bytes(), private_key.as_bytes()].concat(); + reqwest::Identity::from_pem(pem)? + }; + + let client = reqwest::Client::builder() + .use_rustls_tls() + .danger_accept_invalid_certs(true) + .tls_info(true) + .identity(identity) + .build()?; + + Ok(Self { + client, + received_nonce_map: Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(200).unwrap(), + ))), + generated_nonce_map: Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(200).unwrap(), + ))), + }) + } + + pub async fn nonce( + &self, + protocol: &ProtocolType, + ip: &str, + port: u16, + ) -> anyhow::Result { + // Generate nonce to send to server + let generated_nonce = crypto::nonce::generate_nonce(); + let generated_nonce_base64 = util::base64::encode(&generated_nonce); + + let request_body = http::dto::NonceRequest { + nonce: generated_nonce_base64, + }; + + let res = self + .client + .post( + TargetUrl { + version: ApiVersion::V3, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/nonce", + params: &[], + } + .to_string(), + ) + .body(serde_json::to_string(&request_body)?) + .send() + .await?; + + if res.status() != StatusCode::OK { + return Err(status_code_error_from_res(res).await?); + } + + let remote_key = to_identifier(&res, protocol == &ProtocolType::Https, None)?; + let body = res.json::().await?; + + // Save the response nonce and our generated nonce + let response_nonce = util::base64::decode(&body.nonce)?; + + let mut received_nonce_map = self.received_nonce_map.lock().await; + received_nonce_map.put(remote_key.clone(), response_nonce); + + let mut generated_nonce_map = self.generated_nonce_map.lock().await; + generated_nonce_map.put(remote_key.clone(), generated_nonce); + + tracing::info!("Nonce exchange successful for server: {ip} (ID: {remote_key})"); + + tracing::debug!( + "Received map: {:?}", + received_nonce_map.get(&remote_key).unwrap() + ); + tracing::debug!( + "Generated map: {:?}", + generated_nonce_map.get(&remote_key).unwrap() + ); + + Ok(body.nonce) + } + + pub async fn register( + &self, + protocol: &ProtocolType, + ip: &str, + port: u16, + payload: http::dto::RegisterDto, + ) -> anyhow::Result { + let res = self + .client + .post(TargetUrl { + version: ApiVersion::V3, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/register", + params: &[], + }.to_string()) + .body(serde_json::to_string(&payload)?) + .send() + .await?; + + let public_key = match protocol { + ProtocolType::Https => Some(verify_cert_from_res(&res, None)?), + _ => None, + }; + + let body = res.json::().await?; + + Ok(RegisterResult { public_key, body }) + } + + pub async fn prepare_upload( + &self, + protocol: &ProtocolType, + ip: &str, + port: u16, + public_key: Option, + payload: http::dto::PrepareUploadRequestDto, + ) -> anyhow::Result { + let res = self + .client + .post(TargetUrl { + version: ApiVersion::V3, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/prepare-upload", + params: &[], + }.to_string()) + .body(serde_json::to_string(&payload)?) + .send() + .await?; + + if let Some(public_key) = public_key { + verify_cert_from_res(&res, Some(public_key))?; + } + + if res.status() != StatusCode::OK { + return Err(status_code_error_from_res(res).await?); + } + + let body = res.json::().await?; + + Ok(body) + } + + /// Uploads a file to the server. + pub async fn upload( + &self, + protocol: &ProtocolType, + ip: &str, + port: u16, + session_id: String, + file_id: String, + token: String, + binary: mpsc::Receiver>, + ) -> anyhow::Result<()> { + let res = self + .client + .post(TargetUrl { + version: ApiVersion::V3, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/upload", + params: &[("sessionId", &session_id), ("fileId", &file_id), ("token", &token)], + }.to_string()) + .body({ + let stream = ReceiverStream::new(binary).map(Ok::, anyhow::Error>); + reqwest::Body::wrap_stream(stream) + }) + .send() + .await?; + + if res.status() != StatusCode::OK { + return Err(status_code_error_from_res(res).await?); + } + + Ok(()) + } + + pub async fn cancel( + &self, + protocol: &ProtocolType, + ip: &str, + port: u16, + session_id: String, + ) -> anyhow::Result<()> { + self.client + .post(TargetUrl { + version: ApiVersion::V3, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/cancel", + params: &[("sessionId", &session_id)], + }.to_string()) + .send() + .await?; + + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +struct ErrorResponse { + message: String, +} + +async fn status_code_error_from_res(response: Response) -> anyhow::Result { + let status = response.status().as_u16(); + let body = response.text().await?; + let body = match serde_json::from_str::(&body) { + Ok(error) => error.message, + Err(_) => body, + }; + + Ok(anyhow::Error::new(StatusCodeError { + status, + message: match body { + _ if body.is_empty() => None, + _ => Some(body), + }, + })) +} + +fn to_identifier( + response: &Response, + require_cert: bool, + public_key: Option, +) -> anyhow::Result { + match require_cert { + true => verify_cert_from_res(response, public_key), + false => response + .remote_addr() + .map(|addr| addr.ip().to_string()) + .ok_or_else(|| anyhow::anyhow!("Remote address not found in response")), + } +} + +/// Verifies the certificate from the response. +/// Returns the public key extracted from the certificate. +fn verify_cert_from_res(response: &Response, public_key: Option) -> anyhow::Result { + let tls_info_ext = response + .extensions() + .get::() + .ok_or_else(|| anyhow::anyhow!("TLS info not found"))?; + let cert = tls_info_ext + .peer_certificate() + .ok_or_else(|| anyhow::anyhow!("Certificate not found"))?; + let public_key = match public_key { + Some(public_key) => public_key.to_owned(), + None => crypto::cert::public_key_from_cert_der(cert)?, + }; + crypto::cert::verify_cert_from_der(cert, Some(public_key.clone()))?; + Ok(public_key) +} diff --git a/core/src/http/dto.rs b/core/src/http/dto.rs index c1598b3c..a6d97d2e 100644 --- a/core/src/http/dto.rs +++ b/core/src/http/dto.rs @@ -52,7 +52,7 @@ pub enum ProtocolType { } impl ProtocolType { - pub fn as_str(&self) -> &str { + pub fn as_str(&self) -> &'static str { match self { ProtocolType::Http => "http", ProtocolType::Https => "https", diff --git a/core/src/http/dto_v2.rs b/core/src/http/dto_v2.rs new file mode 100644 index 00000000..ebee43bf --- /dev/null +++ b/core/src/http/dto_v2.rs @@ -0,0 +1,300 @@ +use crate::model::discovery::DeviceType; +use crate::model::transfer::FileDto; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Protocol type for HTTP or HTTPS connections. +#[derive(Clone, Debug, Deserialize, Eq, Serialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ProtocolTypeV2 { + Http, + Https, +} + +impl ProtocolTypeV2 { + pub fn as_str(&self) -> &'static str { + match self { + ProtocolTypeV2::Http => "http", + ProtocolTypeV2::Https => "https", + } + } +} + +/// Multicast announcement/response message for UDP discovery (v2.1). +/// +/// Used for both sending announcements and responding to announcements. +/// When `announce` is true, other devices should respond. +/// When `announce` is false, this is a response to an announcement. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MulticastMessageV2 { + /// The display name of the device. + pub alias: String, + + /// Protocol version (e.g., "2.1"). + pub version: String, + + /// Device model (e.g., "Samsung", "Windows"). Optional. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_model: Option, + + /// Device type category. Optional. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_type: Option, + + /// Fingerprint for device identification. + /// In HTTPS mode: SHA-256 hash of the certificate. + /// In HTTP mode: randomly generated string. + pub fingerprint: String, + + /// Port number the device is listening on. + pub port: u16, + + /// Protocol type (http or https). + pub protocol: ProtocolTypeV2, + + /// Whether the download API (sections 5.2, 5.3) is active. + #[serde(default)] + pub download: bool, + + /// Whether this is an announcement (true) or a response (false). + /// Other devices should only respond when this is true. + pub announce: bool, +} + +/// Register request DTO for v2.1 protocol. +/// +/// Sent to POST /api/localsend/v2/register for device discovery. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterDtoV2 { + /// The display name of the device. + pub alias: String, + + /// Protocol version (e.g., "2.0", "2.1"). + pub version: String, + + /// Device model (e.g., "Samsung", "Windows"). Optional. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_model: Option, + + /// Device type category. Optional. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_type: Option, + + /// Fingerprint for device identification. + /// Ignored in HTTPS mode (certificate is used instead). + pub fingerprint: String, + + /// Port number the device is listening on. + pub port: u16, + + /// Protocol type (http or https). + pub protocol: ProtocolTypeV2, + + /// Whether the download API (sections 5.2, 5.3) is active. + #[serde(default)] + pub download: bool, +} + +/// Register response DTO for v2.1 protocol. +/// +/// Response from POST /api/localsend/v2/register. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterResponseDtoV2 { + /// The display name of the device. + pub alias: String, + + /// Protocol version (e.g., "2.0", "2.1"). + pub version: String, + + /// Device model. Optional. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_model: Option, + + /// Device type category. Optional. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_type: Option, + + /// Fingerprint for device identification. + /// Ignored in HTTPS mode (certificate is used instead). + #[serde(default)] + pub fingerprint: String, + + /// Whether the download API (sections 5.2, 5.3) is active. + #[serde(default)] + pub download: bool, +} + +/// Prepare upload request DTO for v2.1 protocol. +/// +/// Sent to POST /api/localsend/v2/prepare-upload to initiate a file transfer. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrepareUploadRequestDtoV2 { + /// Sender's device information. + pub info: RegisterDtoV2, + + /// Map of file ID to file metadata. + pub files: HashMap, +} + +/// Prepare upload response DTO for v2.1 protocol. +/// +/// Response from POST /api/localsend/v2/prepare-upload. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrepareUploadResponseDtoV2 { + /// Session ID for the file transfer. + pub session_id: String, + + /// Map of file ID to file token. + /// Only contains files that were accepted by the receiver. + pub files: HashMap, +} + +/// Prepare download response DTO for v2.1 protocol (Download API). +/// +/// Response from POST /api/localsend/v2/prepare-download. +/// Used when the sender provides files for others to download. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrepareDownloadResponseDtoV2 { + /// Sender's device information. + pub info: InfoResponseDtoV2, + + /// Session ID for the download session. + pub session_id: String, + + /// Map of file ID to file metadata. + pub files: HashMap, +} + +/// Info response DTO for v2.1 protocol. +/// +/// Response from GET /api/localsend/v2/info. +/// Also used as the `info` field in PrepareDownloadResponseDtoV2. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InfoResponseDtoV2 { + /// The display name of the device. + pub alias: String, + + /// Protocol version (e.g., "2.0", "2.1"). + pub version: String, + + /// Device model. Optional. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_model: Option, + + /// Device type category. Optional. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_type: Option, + + /// Fingerprint for device identification. + pub fingerprint: String, + + /// Whether the download API (sections 5.2, 5.3) is active. + #[serde(default)] + pub download: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multicast_message_serialization() { + let msg = MulticastMessageV2 { + alias: "Nice Orange".to_string(), + version: "2.1".to_string(), + device_model: Some("Samsung".to_string()), + device_type: Some(DeviceType::Mobile), + fingerprint: "random string".to_string(), + port: 53317, + protocol: ProtocolTypeV2::Https, + download: true, + announce: true, + }; + + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"alias\":\"Nice Orange\"")); + assert!(json.contains("\"version\":\"2.1\"")); + assert!(json.contains("\"fingerprint\":\"random string\"")); + assert!(json.contains("\"announce\":true")); + assert!(json.contains("\"download\":true")); + assert!(json.contains("\"protocol\":\"https\"")); + } + + #[test] + fn test_register_dto_v2_deserialization() { + let json = r#"{ + "alias": "Secret Banana", + "version": "2.0", + "deviceModel": "Windows", + "deviceType": "DESKTOP", + "fingerprint": "random string", + "port": 53317, + "protocol": "https", + "download": true + }"#; + + let dto: RegisterDtoV2 = serde_json::from_str(json).unwrap(); + assert_eq!(dto.alias, "Secret Banana"); + assert_eq!(dto.version, "2.0"); + assert_eq!(dto.device_model, Some("Windows".to_string())); + assert_eq!(dto.device_type, Some(DeviceType::Desktop)); + assert_eq!(dto.fingerprint, "random string"); + assert_eq!(dto.port, 53317); + assert_eq!(dto.protocol, ProtocolTypeV2::Https); + assert!(dto.download); + } + + #[test] + fn test_register_response_without_download_field() { + // Test that download defaults to false when not present + let json = r#"{ + "alias": "Test Device", + "version": "2.0", + "fingerprint": "abc123" + }"#; + + let dto: RegisterResponseDtoV2 = serde_json::from_str(json).unwrap(); + assert_eq!(dto.alias, "Test Device"); + assert!(!dto.download); + } + + #[test] + fn test_prepare_upload_request_v2() { + let request = PrepareUploadRequestDtoV2 { + info: RegisterDtoV2 { + alias: "Sender".to_string(), + version: "2.1".to_string(), + device_model: None, + device_type: None, + fingerprint: "sender-fingerprint".to_string(), + port: 53317, + protocol: ProtocolTypeV2::Https, + download: false, + }, + files: HashMap::from([( + "file1".to_string(), + FileDto { + id: "file1".to_string(), + file_name: "test.png".to_string(), + size: 1024, + file_type: "image/png".to_string(), + sha256: None, + preview: None, + metadata: None, + }, + )]), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"info\"")); + assert!(json.contains("\"files\"")); + assert!(json.contains("\"fingerprint\":\"sender-fingerprint\"")); + } +} diff --git a/core/src/http/mod.rs b/core/src/http/mod.rs index 9b450b64..8de706d7 100644 --- a/core/src/http/mod.rs +++ b/core/src/http/mod.rs @@ -2,6 +2,7 @@ use thiserror::Error; pub mod client; pub mod dto; +pub mod dto_v2; pub mod server; pub mod state; diff --git a/core/src/main.rs b/core/src/main.rs index 1b23f4b7..be74e653 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -5,7 +5,7 @@ mod util; mod webrtc; use crate::crypto::token; -use crate::http::client::LsHttpClient; +use crate::http::client::LsHttpClientV3; use crate::http::dto::{PrepareUploadRequestDto, ProtocolType, RegisterDto}; use crate::http::server::TlsConfig; use crate::model::discovery::DeviceType; @@ -158,7 +158,7 @@ async fn server_test() -> Result<()> { } async fn client_test() -> Result<()> { - let client = LsHttpClient::try_new(PRIVATE_KEY, CERT)?; + let client = LsHttpClientV3::try_new(PRIVATE_KEY, CERT)?; let nonce = client .nonce(&ProtocolType::Https, "localhost", 53317) diff --git a/core/src/webrtc/signaling.rs b/core/src/webrtc/signaling.rs index 4a6ca57a..302de5bb 100644 --- a/core/src/webrtc/signaling.rs +++ b/core/src/webrtc/signaling.rs @@ -443,7 +443,7 @@ mod tests { "alias": "Cute Apple", "version": "2.3", "deviceModel": "Dell", - "deviceType": "desktop", + "deviceType": "DESKTOP", "token": "123" }, "peers": [] @@ -480,7 +480,7 @@ mod tests { "id": "00000000-0000-0000-0000-000000000000", "alias": "Cute Apple", "version": "2.3", - "deviceType": "desktop", + "deviceType": "DESKTOP", "token": "123" }, "sessionId": "456", @@ -515,7 +515,7 @@ mod tests { "alias": "Cute Apple", "version": "2.3", "deviceModel": "Dell", - "deviceType": "desktop", + "deviceType": "DESKTOP", "token": "123" } }"#