feat: add v2 client

This commit is contained in:
Tien Do Nam
2026-02-27 02:37:42 +01:00
parent da6d18da43
commit 731a48b10f
9 changed files with 1193 additions and 311 deletions
+7 -298
View File
@@ -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<Mutex<LruCache<String, Vec<u8>>>>,
/// Maps client identifiers to nonces that are expected to be received from remote.
generated_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
}
pub struct RegisterResult {
/// The public key extracted from the certificate.
/// Encoded in PEM format.
/// Only available in HTTPS.
pub public_key: Option<String>,
/// 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<Self> {
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<String> {
// 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::<http::dto::NonceResponse>().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<RegisterResult> {
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::<http::dto::RegisterResponseDto>().await?;
Ok(RegisterResult { public_key, body })
}
pub async fn prepare_upload(
&self,
protocol: &ProtocolType,
ip: &str,
port: u16,
public_key: Option<String>,
payload: http::dto::PrepareUploadRequestDto,
) -> anyhow::Result<http::dto::PrepareUploadResponseDto> {
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::<http::dto::PrepareUploadResponseDto>().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<Vec<u8>>,
) -> 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::<Vec<u8>, 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<anyhow::Error> {
let status = response.status().as_u16();
let body = response.text().await?;
let body = match serde_json::from_str::<ErrorResponse>(&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<String>,
) -> anyhow::Result<String> {
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<String>) -> anyhow::Result<String> {
let tls_info_ext = response
.extensions()
.get::<reqwest::tls::TlsInfo>()
.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),
}
+21 -7
View File
@@ -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::<Vec<_>>()
.join("&");
format!("{}?{}", base, query)
}
}
}
+559
View File
@@ -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<String>,
/// 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<Self> {
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<Self> {
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<RegisterResultV2> {
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::<RegisterResponseDtoV2>().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<String>,
payload: PrepareUploadRequestDtoV2,
pin: Option<&str>,
) -> anyhow::Result<PrepareUploadResponseDtoV2> {
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::<PrepareUploadResponseDtoV2>().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<Vec<u8>>,
) -> 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::<Vec<u8>, 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<u8>,
) -> 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<InfoResponseDtoV2> {
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::<InfoResponseDtoV2>().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<PrepareDownloadResponseDtoV2> {
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: &params,
}
.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::<PrepareDownloadResponseDtoV2>().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<Response> {
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<W: tokio::io::AsyncWrite + Unpin>(
&self,
protocol: &ProtocolTypeV2,
ip: &str,
port: u16,
session_id: &str,
file_id: &str,
writer: &mut W,
) -> anyhow::Result<u64> {
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<anyhow::Error> {
let status = response.status().as_u16();
let body = response.text().await?;
let body = match serde_json::from_str::<ErrorResponse>(&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<String>) -> anyhow::Result<String> {
let tls_info_ext = response
.extensions()
.get::<reqwest::tls::TlsInfo>()
.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");
}
}
+299
View File
@@ -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<Mutex<LruCache<String, Vec<u8>>>>,
/// Maps client identifiers to nonces that are expected to be received from remote.
generated_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
}
pub struct RegisterResult {
/// The public key extracted from the certificate.
/// Encoded in PEM format.
/// Only available in HTTPS.
pub public_key: Option<String>,
/// 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<Self> {
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<String> {
// 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::<http::dto::NonceResponse>().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<RegisterResult> {
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::<http::dto::RegisterResponseDto>().await?;
Ok(RegisterResult { public_key, body })
}
pub async fn prepare_upload(
&self,
protocol: &ProtocolType,
ip: &str,
port: u16,
public_key: Option<String>,
payload: http::dto::PrepareUploadRequestDto,
) -> anyhow::Result<http::dto::PrepareUploadResponseDto> {
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::<http::dto::PrepareUploadResponseDto>().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<Vec<u8>>,
) -> 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::<Vec<u8>, 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<anyhow::Error> {
let status = response.status().as_u16();
let body = response.text().await?;
let body = match serde_json::from_str::<ErrorResponse>(&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<String>,
) -> anyhow::Result<String> {
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<String>) -> anyhow::Result<String> {
let tls_info_ext = response
.extensions()
.get::<reqwest::tls::TlsInfo>()
.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)
}
+1 -1
View File
@@ -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",
+300
View File
@@ -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<String>,
/// Device type category. Optional.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_type: Option<DeviceType>,
/// 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<String>,
/// Device type category. Optional.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_type: Option<DeviceType>,
/// 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<String>,
/// Device type category. Optional.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_type: Option<DeviceType>,
/// 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<String, FileDto>,
}
/// 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<String, String>,
}
/// 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<String, FileDto>,
}
/// 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<String>,
/// Device type category. Optional.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_type: Option<DeviceType>,
/// 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\""));
}
}
+1
View File
@@ -2,6 +2,7 @@ use thiserror::Error;
pub mod client;
pub mod dto;
pub mod dto_v2;
pub mod server;
pub mod state;
+2 -2
View File
@@ -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)
+3 -3
View File
@@ -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"
}
}"#