From 18ffa3516851bf062b14f70b917ab8ae0f1fd443 Mon Sep 17 00:00:00 2001 From: AlexDevFlow <107987666+AlexDevFlow@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:00:55 +0200 Subject: [PATCH 1/2] Align with LocalSend 1.18: present the client certificate, protocol 2.2 LocalSend 1.18 requires a client certificate on its HTTPS server whenever it is not serving its web pages, which is the normal receive state. lsq built its discovery HTTP client without an identity, so its reply to an announcement died in the handshake with a CertificateRequired alert and the peer never learned lsq existed. Discovery, presence announcements and pull now all present the certificate, the way the official client does on every request. Bump the protocol to 2.2 and answer a checksum mismatch with 422 instead of 500: that status is the one thing 2.2 adds, and it is what the official server returns. Protocol v3 is deliberately not implemented. 1.18.2 routes only /v3/nonce and /v3/register, its v3 register handler is a stub marked "not wired up yet", and the app pins its own HTTP client to v2. Devices still announce 2.2 and still transfer over the v2 endpoints, so there is nothing live to talk to on v3. Checked against the LocalSend 1.18.2 core in both directions, with the files arriving byte-identical. tests/mtls.rs is a self-contained regression guard: it stands up a server that demands a client certificate and asserts that a certless client is refused and lsq's identity client gets through. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 22 +++++-- src/cli.rs | 31 ++++++---- src/discovery.rs | 12 +++- src/proto.rs | 6 +- src/pull.rs | 5 +- src/receiver.rs | 14 ++++- tests/download.rs | 16 ++--- tests/loopback.rs | 8 ++- tests/mtls.rs | 147 ++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 225 insertions(+), 36 deletions(-) create mode 100644 tests/mtls.rs diff --git a/README.md b/README.md index 8e0fd75..3c91237 100644 --- a/README.md +++ b/README.md @@ -107,16 +107,28 @@ shared link the browser shows a PIN form; `lsq pull` takes `--pin`. ## What works and what doesn't -It implements LocalSend protocol v2.1: multicast discovery, the upload API in +It implements LocalSend protocol v2.2: multicast discovery, the upload API in both directions, the download API (`share`/`pull`, including the browser -page), PIN, and cancel. I've run the upload path against the desktop app -(v1.17.0) both ways, with single and multiple files and with a PIN set, and -files come across intact. +page), PIN, cancel, and the 422 answer to a checksum mismatch that 2.2 adds. + +Checked against the LocalSend 1.18.2 core in both directions — lsq sending to +a real receiver in its normal (non-browser) receive mode, and a real client +sending to `lsq receive` — plus the discovery handshake, with the files +arriving byte-identical. + +Note for anyone reading about "protocol v3": LocalSend 1.18.2 ships a `v3` +namespace, but only `POST /api/localsend/v3/nonce` and +`POST /api/localsend/v3/register` are routed, the register handler is a stub +marked *not wired up yet*, and the app builds its own HTTP client with the v2 +version pinned. Devices still announce `2.2` and still transfer over the v2 +endpoints, so there is nothing live to talk to on v3 yet. Still missing: - IPv6 -- anything newer than protocol v2.1 +- protocol v3: the nonce/signed-token handshake, pairing, and the WebRTC + transport. Dormant in the app as of 1.18.2; worth revisiting when the app + starts using it. ## Tests diff --git a/src/cli.rs b/src/cli.rs index f242f82..3510255 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -205,7 +205,7 @@ async fn cmd_list(alias: String, port: u16, wait: u64, json: bool, eph: bool) -> protocol: Protocol::Https, download: false, }; - let peers = discovery::discover(&me, Duration::from_secs(wait)).await?; + let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?; if json { let out: Vec<_> = peers .iter() @@ -341,11 +341,11 @@ async fn cmd_send( if t.parse::().is_ok() || t.parse::().is_ok() { resolve_target(&[], Some(t))? } else { - let peers = discovery::discover(&me, Duration::from_secs(wait)).await?; + let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?; resolve_target(&peers, Some(t))? } } else { - let peers = discovery::discover(&me, Duration::from_secs(wait)).await?; + let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?; resolve_target(&peers, None)? }; @@ -461,7 +461,7 @@ async fn cmd_receive( // one peer can't permanently block the receiver. tokio::spawn(receiver::reap_stale_sessions(state.clone())); - announce_presence(&me, peers).await?; + announce_presence(&me, peers, Some(&identity)).await?; if !quiet { eprintln!( @@ -508,9 +508,14 @@ async fn serve_until_interrupted( /// Multicast presence for a long-running server (receive/share): initial /// announcement burst, reply loop, and periodic re-announce. -async fn announce_presence(me: &SelfDevice, peers: discovery::PeerMap) -> Result<()> { +async fn announce_presence( + me: &SelfDevice, + peers: discovery::PeerMap, + identity: Option<&crate::certs::Identity>, +) -> Result<()> { let udp = Arc::new(discovery::bind_multicast_socket(MULTICAST_PORT)?); - let http_client = crate::sender::insecure_client()?; + // Presents our certificate: see the note in `discovery::discover`. + let http_client = crate::sender::client_with_identity(identity)?; tokio::spawn(discovery::listen_loop( udp.clone(), me.clone(), @@ -579,7 +584,7 @@ async fn cmd_share( axum_server::bind(addr).serve(app).await.map_err(anyhow::Error::from) }); - announce_presence(&me, peers).await?; + announce_presence(&me, peers, Some(&identity)).await?; if !quiet { eprintln!("sharing {count} file(s), {total} bytes as \"{alias}\""); @@ -610,12 +615,13 @@ async fn cmd_pull( tokio::fs::create_dir_all(&dest) .await .with_context(|| format!("cannot create dest dir {}", dest.display()))?; + let identity = identity(eph)?; // Full URL target: use it as-is. if let Some(t) = from.as_deref() { if t.starts_with("http://") || t.starts_with("https://") { let outcome = - crate::pull::pull_files(t, &dest, pin.as_deref(), max_size, quiet).await?; + crate::pull::pull_files(t, &dest, pin.as_deref(), max_size, quiet, Some(&identity)).await?; return report_pull(outcome, &dest, quiet); } } @@ -627,14 +633,14 @@ async fn cmd_pull( let peer = resolve_target(&[], Some(t))?; let host = format!("{}:{}", peer.addr, peer.info.port_or(DEFAULT_PORT)); let outcome = match crate::pull::pull_files( - &format!("https://{host}"), &dest, pin.as_deref(), max_size, quiet, + &format!("https://{host}"), &dest, pin.as_deref(), max_size, quiet, Some(&identity), ) .await { Ok(o) => o, Err(e) if e.unreachable => { crate::pull::pull_files( - &format!("http://{host}"), &dest, pin.as_deref(), max_size, quiet, + &format!("http://{host}"), &dest, pin.as_deref(), max_size, quiet, Some(&identity), ) .await .map_err(|e2| anyhow::anyhow!("{e}; also failed over http: {e2}"))? @@ -647,7 +653,6 @@ async fn cmd_pull( // Alias/fingerprint target (or no target): discover, keep peers that // announce the download flag. - let identity = identity(eph)?; let me = SelfDevice { alias, fingerprint: identity.fingerprint.clone(), @@ -655,7 +660,7 @@ async fn cmd_pull( protocol: Protocol::Https, download: false, }; - let peers = discovery::discover(&me, Duration::from_secs(wait)).await?; + let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?; let peer = if from.is_some() { let p = resolve_target(&peers, from.as_deref())?; if !p.info.download { @@ -674,7 +679,7 @@ async fn cmd_pull( } let base = crate::sender::base_url(&peer); let outcome = - crate::pull::pull_files(&base, &dest, pin.as_deref(), max_size, quiet).await?; + crate::pull::pull_files(&base, &dest, pin.as_deref(), max_size, quiet, Some(&identity)).await?; report_pull(outcome, &dest, quiet) } diff --git a/src/discovery.rs b/src/discovery.rs index 5406b68..1106bf9 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -287,10 +287,18 @@ async fn spawn_register_endpoint( } /// Active discovery: announce, listen for replies for `wait`, return peers. -pub async fn discover(me: &SelfDevice, wait: Duration) -> Result> { +pub async fn discover( + me: &SelfDevice, + wait: Duration, + identity: Option<&crate::certs::Identity>, +) -> Result> { let sock = Arc::new(bind_multicast_socket(MULTICAST_PORT)?); let peers: PeerMap = Arc::new(Mutex::new(HashMap::new())); - let http = crate::sender::insecure_client()?; + // The reply below is an HTTPS request to the peer. LocalSend 1.18+ makes + // the client certificate mandatory whenever it is not serving its web + // pages, so a certless client is dropped with a `CertificateRequired` + // TLS alert and the peer never learns we exist. + let http = crate::sender::client_with_identity(identity)?; // Announce the ephemeral register port (plain http) so TCP replies // reach us and not some other process on the default port. diff --git a/src/proto.rs b/src/proto.rs index 0b20832..009d5d0 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -1,11 +1,11 @@ -//! LocalSend protocol v2.1 wire models. -//! Field shapes follow the LocalSend v2.1 protocol; unknown fields are ignored +//! LocalSend protocol v2.2 wire models. +//! Field shapes follow the LocalSend v2.2 protocol; unknown fields are ignored //! and unknown enum values fall back gracefully (spec §7.1). use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -pub const PROTOCOL_VERSION: &str = "2.1"; +pub const PROTOCOL_VERSION: &str = "2.2"; pub const DEFAULT_PORT: u16 = 53317; pub const MULTICAST_ADDR: &str = "224.0.0.167"; /// Discovery always happens on this fixed group port, even when the HTTP diff --git a/src/pull.rs b/src/pull.rs index fb429af..aad1b7b 100644 --- a/src/pull.rs +++ b/src/pull.rs @@ -58,8 +58,11 @@ pub async fn pull_files( pin: Option<&str>, max_bytes: Option, quiet: bool, + identity: Option<&crate::certs::Identity>, ) -> std::result::Result { - let client = crate::sender::insecure_client().map_err(PullError::rejected)?; + // Presents our certificate like the official client does on every + // request; a peer that requires one would otherwise refuse the handshake. + let client = crate::sender::client_with_identity(identity).map_err(PullError::rejected)?; let base = base.trim_end_matches('/'); // 1. prepare-download diff --git a/src/receiver.rs b/src/receiver.rs index 26a2aac..055e78a 100644 --- a/src/receiver.rs +++ b/src/receiver.rs @@ -451,6 +451,11 @@ async fn upload( Ok(pair) => pair, Err(e) => { let _ = state.events.send(format!("upload failed: {e}")); + // Protocol 2.2: a checksum mismatch is the sender's problem to + // retry, not a receiver fault, and has its own status code. + if e.downcast_ref::().is_some() { + return err(StatusCode::UNPROCESSABLE_ENTITY, "Checksum mismatch"); + } return err( StatusCode::INTERNAL_SERVER_ERROR, "Could not save file. Check receiving device for more information.", @@ -499,6 +504,13 @@ async fn upload( StatusCode::OK.into_response() } +/// A received body did not match the `sha256` its sender declared. +/// Protocol 2.2 answers this with 422 so the sender can tell a corrupted +/// transfer apart from a fault on the receiving side. +#[derive(Debug, thiserror::Error)] +#[error("sha256 mismatch")] +pub(crate) struct ChecksumMismatch; + /// Stream a body to a unique `.lsq-*.part` temp file in the dest dir, /// enforcing the declared size (and sha256 when provided) and an idle read /// timeout. Returns the temp path plus an armed cleanup guard; on any error @@ -546,7 +558,7 @@ where use sha2::Digest; let actual: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect(); if !actual.eq_ignore_ascii_case(expected) { - anyhow::bail!("sha256 mismatch"); + return Err(ChecksumMismatch.into()); } } // fsync the data before the caller makes the final name visible, so a crash diff --git a/tests/download.rs b/tests/download.rs index 94b3768..f3c7288 100644 --- a/tests/download.rs +++ b/tests/download.rs @@ -338,7 +338,7 @@ async fn pull_module_end_to_end() { let b = b"second file".to_vec(); let s = start_share(&[("data.bin", &a), ("note.txt", &b)], None).await; let dest = TempDir::new().unwrap(); - let outcome = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true) + let outcome = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None) .await .unwrap(); assert_eq!(outcome.fetched, 2); @@ -354,12 +354,12 @@ async fn pull_module_end_to_end() { async fn pull_uses_pin_and_reports_wrong_pin() { let s = start_share(&[("a.txt", b"x")], Some("9999")).await; let dest = TempDir::new().unwrap(); - let err = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true) + let err = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None) .await .unwrap_err(); assert!(err.to_string().contains("PIN")); assert!(!err.unreachable); - let outcome = lsq::pull::pull_files(&base(&s), dest.path(), Some("9999"), None, true) + let outcome = lsq::pull::pull_files(&base(&s), dest.path(), Some("9999"), None, true, None) .await .unwrap(); assert_eq!(outcome.fetched, 1); @@ -370,7 +370,7 @@ async fn pull_never_overwrites_existing_files() { let s = start_share(&[("keep.txt", b"new")], None).await; let dest = TempDir::new().unwrap(); std::fs::write(dest.path().join("keep.txt"), b"original").unwrap(); - lsq::pull::pull_files(&base(&s), dest.path(), None, None, true) + lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None) .await .unwrap(); assert_eq!(std::fs::read(dest.path().join("keep.txt")).unwrap(), b"original"); @@ -381,7 +381,7 @@ async fn pull_never_overwrites_existing_files() { async fn pull_sanitizes_hostile_names() { let s = start_share(&[("../../../tmp/lsq-pull-escape.txt", b"x")], None).await; let dest = TempDir::new().unwrap(); - lsq::pull::pull_files(&base(&s), dest.path(), None, None, true) + lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None) .await .unwrap(); assert!(!std::path::Path::new("/tmp/lsq-pull-escape.txt").exists()); @@ -392,7 +392,7 @@ async fn pull_sanitizes_hostile_names() { async fn pull_respects_max_size() { let s = start_share(&[("big.bin", &vec![0u8; 5000])], None).await; let dest = TempDir::new().unwrap(); - let err = lsq::pull::pull_files(&base(&s), dest.path(), None, Some(1024), true) + let err = lsq::pull::pull_files(&base(&s), dest.path(), None, Some(1024), true, None) .await .unwrap_err(); assert!(err.to_string().contains("max-size")); @@ -404,7 +404,7 @@ async fn pull_marks_dead_peer_unreachable() { // unroutable TEST-NET address → connect timeout flagged as unreachable let dest = TempDir::new().unwrap(); let start = std::time::Instant::now(); - let err = lsq::pull::pull_files("http://192.0.2.1:53317", dest.path(), None, None, true) + let err = lsq::pull::pull_files("http://192.0.2.1:53317", dest.path(), None, None, true, None) .await .unwrap_err(); assert!(err.unreachable); @@ -438,7 +438,7 @@ async fn pull_rejects_oversized_body_from_lying_server() { tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let dest = TempDir::new().unwrap(); - let err = lsq::pull::pull_files(&format!("http://{addr}"), dest.path(), None, None, true) + let err = lsq::pull::pull_files(&format!("http://{addr}"), dest.path(), None, None, true, None) .await .unwrap_err(); assert!(err.to_string().contains("exceeds declared size")); diff --git a/tests/loopback.rs b/tests/loopback.rs index 7660c01..855b23c 100644 --- a/tests/loopback.rs +++ b/tests/loopback.rs @@ -279,13 +279,15 @@ async fn sha256_verified_when_provided() { .json(&req).send().await.unwrap(); let sess: PrepareUploadResponse = resp.json().await.unwrap(); assert_eq!(do_upload(&c, &s, &sess, "f", b"payload").await, 200); - // wrong hash rejected, file absent, no partials + // Wrong hash rejected with 422 (protocol 2.2), file absent, no partials. + // 422 and not 500: the transfer is the sender's to retry, and the + // official server answers a checksum mismatch the same way. let mut req = prepare_req(&[("g", "bad.txt", b"payload")]); req.files.get_mut("g").unwrap().sha256 = Some("00".repeat(32)); let resp = c.post(format!("{}{API_BASE}/prepare-upload", base(&s))) .json(&req).send().await.unwrap(); let sess: PrepareUploadResponse = resp.json().await.unwrap(); - assert_eq!(do_upload(&c, &s, &sess, "g", b"payload").await, 500); + assert_eq!(do_upload(&c, &s, &sess, "g", b"payload").await, 422); assert!(!dest_file(&s, "bad.txt").exists()); assert!(no_part_files(s.dest.path())); } @@ -672,7 +674,7 @@ async fn register_returns_own_info_and_records_peer() { assert_eq!(r.status(), 200); let body: RegisterResponse = r.json().await.unwrap(); assert_eq!(body.alias, "test-receiver"); - assert_eq!(body.version, "2.1"); + assert_eq!(body.version, PROTOCOL_VERSION); assert!(!body.fingerprint.is_empty()); } diff --git a/tests/mtls.rs b/tests/mtls.rs new file mode 100644 index 0000000..9752fb2 --- /dev/null +++ b/tests/mtls.rs @@ -0,0 +1,147 @@ +//! Regression guard for the client certificate. +//! +//! LocalSend 1.18 makes the client certificate mandatory on its HTTPS server +//! whenever it is not serving its web pages — which is the normal receive +//! state. A request from a client that presents no certificate is dropped +//! during the handshake with a `CertificateRequired` alert, so a discovery +//! reply sent that way never reaches the peer and the peer never learns that +//! this device exists. +//! +//! Every outgoing HTTPS request lsq makes must therefore carry its identity. + +use std::sync::Arc; + +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, UnixTime}; +use rustls::server::danger::{ClientCertVerified, ClientCertVerifier}; +use rustls::{DigitallySignedStruct, DistinguishedName, Error, SignatureScheme}; + +/// Mirrors the shape of LocalSend's own verifier: client auth is mandatory, +/// and any certificate that verifies is trusted (peers are identified by +/// fingerprint, not by an authority). +#[derive(Debug)] +struct MandatoryClientCert; + +impl ClientCertVerifier for MandatoryClientCert { + fn offer_client_auth(&self) -> bool { + true + } + + fn client_auth_mandatory(&self) -> bool { + true + } + + fn root_hint_subjects(&self) -> &[DistinguishedName] { + &[] + } + + fn verify_client_cert( + &self, + _: &CertificateDer<'_>, + _: &[CertificateDer<'_>], + _: UnixTime, + ) -> Result { + Ok(ClientCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +/// Starts an HTTPS server that refuses clients without a certificate, +/// the way a LocalSend 1.18 device in receive mode does. +async fn start_mtls_server() -> u16 { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mut params = rcgen::CertificateParams::default(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "LocalSend User"); + let key_pair = rcgen::KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + + let config = rustls::ServerConfig::builder() + .with_client_cert_verifier(Arc::new(MandatoryClientCert)) + .with_single_cert( + vec![CertificateDer::from(cert.der().to_vec())], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())), + ) + .unwrap(); + + // Hand the already-bound listener to the server: the port cannot be taken + // in between, and a client that connects before the accept loop is running + // waits in the backlog instead of being refused. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + let app = axum::Router::new().route( + "/api/localsend/v2/register", + axum::routing::post(|| async { "{}" }), + ); + let tls = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(config)); + tokio::spawn(async move { + let _ = axum_server::from_tcp_rustls(listener, tls) + .serve(app.into_make_service()) + .await; + }); + port +} + +#[tokio::test] +async fn client_without_identity_is_refused_by_an_mtls_peer() { + let port = start_mtls_server().await; + let url = format!("https://127.0.0.1:{port}/api/localsend/v2/register"); + + let certless = lsq::sender::insecure_client().unwrap(); + assert!( + certless.post(&url).send().await.is_err(), + "a certless client must not be able to reach an mTLS peer — if this \ + starts passing, the premise of the test below no longer holds" + ); +} + +#[tokio::test] +async fn client_with_identity_reaches_an_mtls_peer() { + let port = start_mtls_server().await; + let url = format!("https://127.0.0.1:{port}/api/localsend/v2/register"); + + let id = lsq::certs::generate_identity("").unwrap(); + let client = lsq::sender::client_with_identity(Some(&id)).unwrap(); + let res = client.post(&url).send().await; + assert!( + res.is_ok(), + "lsq's identity client must reach a peer that requires a client \ + certificate: {:?}", + res.err() + ); +} From ecdd82a10c8c67c1b16b04f71fbb5a8d1ad2f64a Mon Sep 17 00:00:00 2001 From: AlexDevFlow <107987666+AlexDevFlow@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:16:53 +0200 Subject: [PATCH 2/2] Make peer isolation tests portable and cover mTLS downloads --- .github/workflows/ci.yml | 6 ++++- tests/common/mod.rs | 39 ++++++++++++++++++++++++++++++++ tests/download.rs | 14 +++++++----- tests/loopback.rs | 13 ++++++----- tests/mtls.rs | 48 ++++++++++++++++++++++++++++++++++++---- 5 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 tests/common/mod.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f35e34..b8df4f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,11 @@ on: jobs: test: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..44ee2d1 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,39 @@ +use axum::{extract::ConnectInfo, Extension, Router}; +use std::net::SocketAddr; + +/// Exercise the real routes with a different transport peer address without +/// requiring an OS-specific loopback alias or administrator privileges. +pub struct OtherPeer { + pub base: String, + handle: axum_server::Handle, +} + +impl OtherPeer { + pub async fn start(router: Router) -> Self { + let app = router + .layer(Extension(ConnectInfo( + "192.0.2.1:12345".parse::().unwrap(), + ))) + .into_make_service(); + let handle = axum_server::Handle::new(); + let server_handle = handle.clone(); + tokio::spawn(async move { + axum_server::bind("127.0.0.1:0".parse().unwrap()) + .handle(server_handle) + .serve(app) + .await + .unwrap(); + }); + let addr = handle.listening().await.unwrap(); + Self { + base: format!("http://{addr}"), + handle, + } + } +} + +impl Drop for OtherPeer { + fn drop(&mut self) { + self.handle.shutdown(); + } +} diff --git a/tests/download.rs b/tests/download.rs index f3c7288..6d16efe 100644 --- a/tests/download.rs +++ b/tests/download.rs @@ -1,6 +1,8 @@ //! Loopback integration tests for the Download API (spec §5): a real HTTP //! share server driven by real clients, plus the pull module against it. +mod common; + use lsq::discovery::SelfDevice; use lsq::proto::*; use lsq::share::{ShareState, SharedFile}; @@ -10,6 +12,7 @@ use std::sync::Arc; use tempfile::TempDir; struct ShareServer { + state: Arc, addr: SocketAddr, #[allow(dead_code)] src: TempDir, @@ -65,7 +68,7 @@ async fn start_share(files: &[(&str, &[u8])], pin: Option<&str>) -> ShareServer peers: Default::default(), events: tx, }); - let app = lsq::share::router(state) + let app = lsq::share::router(state.clone()) .into_make_service_with_connect_info::(); let handle = axum_server::Handle::new(); let h2 = handle.clone(); @@ -77,7 +80,7 @@ async fn start_share(files: &[(&str, &[u8])], pin: Option<&str>) -> ShareServer .unwrap(); }); let addr = handle.listening().await.unwrap(); - ShareServer { addr, src, handle } + ShareServer { addr, src, state, handle } } fn base(s: &ShareServer) -> String { @@ -221,12 +224,13 @@ async fn session_is_bound_to_ip() { .unwrap(); let prep: PrepareDownloadResponse = resp.json().await.unwrap(); - // download from another loopback IP with a stolen session id - let c2 = client_from("127.0.0.2"); + // Download as a different peer with a stolen session id. + let other = common::OtherPeer::start(lsq::share::router(s.state.clone())).await; + let c2 = client(); let r = c2 .get(format!( "{}{API_BASE}/download?sessionId={}&fileId=f0", - base(&s), + other.base, prep.session_id )) .send() diff --git a/tests/loopback.rs b/tests/loopback.rs index 855b23c..728172f 100644 --- a/tests/loopback.rs +++ b/tests/loopback.rs @@ -1,5 +1,7 @@ //! Loopback integration tests: a real HTTPS receiver driven by real clients. +mod common; + use lsq::certs; use lsq::discovery::SelfDevice; use lsq::proto::*; @@ -469,11 +471,11 @@ async fn upload_from_different_ip_is_403() { .send().await.unwrap(); let sess: PrepareUploadResponse = resp.json().await.unwrap(); - // same session, different source IP (127.0.0.2 is loopback on Linux) - let c2 = client_from("127.0.0.2"); + let other = common::OtherPeer::start(lsq::receiver::router(s.state.clone())).await; + let c2 = client(); let r = c2.post(format!( "{}{API_BASE}/upload?sessionId={}&fileId=f&token={}", - base(&s), sess.session_id, sess.files["f"] + other.base, sess.session_id, sess.files["f"] )).body("x").send().await.unwrap(); assert_eq!(r.status(), 403); let b: serde_json::Value = r.json().await.unwrap(); @@ -599,9 +601,10 @@ async fn cancel_from_other_ip_is_ignored() { .send().await.unwrap(); let sess: PrepareUploadResponse = resp.json().await.unwrap(); - let c2 = client_from("127.0.0.2"); + let other = common::OtherPeer::start(lsq::receiver::router(s.state.clone())).await; + let c2 = client(); let _ = c2.post(format!( - "{}{API_BASE}/cancel?sessionId={}", base(&s), sess.session_id + "{}{API_BASE}/cancel?sessionId={}", other.base, sess.session_id )).send().await.unwrap(); // session still alive for the legitimate sender let r = c1.post(format!( diff --git a/tests/mtls.rs b/tests/mtls.rs index 9752fb2..ffaa59b 100644 --- a/tests/mtls.rs +++ b/tests/mtls.rs @@ -104,10 +104,28 @@ async fn start_mtls_server() -> u16 { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); - let app = axum::Router::new().route( - "/api/localsend/v2/register", - axum::routing::post(|| async { "{}" }), - ); + let app = axum::Router::new() + .route( + "/api/localsend/v2/register", + axum::routing::post(|| async { "{}" }), + ) + .route( + "/api/localsend/v2/prepare-download", + axum::routing::post(|| async { + axum::Json(serde_json::json!({ + "info": { "alias": "mtls-peer" }, + "sessionId": "test-session", + "files": { + "f": { "id": "f", "fileName": "hello.txt", "size": 5, + "fileType": "text/plain" } + } + })) + }), + ) + .route( + "/api/localsend/v2/download", + axum::routing::get(|| async { "hello" }), + ); let tls = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(config)); tokio::spawn(async move { let _ = axum_server::from_tcp_rustls(listener, tls) @@ -145,3 +163,25 @@ async fn client_with_identity_reaches_an_mtls_peer() { res.err() ); } + +#[tokio::test] +async fn pull_presents_identity_to_an_mtls_peer() { + let port = start_mtls_server().await; + let base = format!("https://127.0.0.1:{port}"); + let dest = tempfile::tempdir().unwrap(); + let id = lsq::certs::generate_identity("").unwrap(); + + let error = lsq::pull::pull_files(&base, dest.path(), None, None, true, None) + .await + .unwrap_err(); + assert!(error.unreachable); + let outcome = lsq::pull::pull_files(&base, dest.path(), None, None, true, Some(&id)) + .await + .unwrap(); + assert_eq!(outcome.fetched, 1); + assert_eq!(outcome.total_bytes, 5); + assert_eq!( + std::fs::read(dest.path().join("hello.txt")).unwrap(), + b"hello" + ); +}