Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 18 additions & 13 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -341,11 +341,11 @@ async fn cmd_send(
if t.parse::<std::net::IpAddr>().is_ok() || t.parse::<SocketAddr>().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)?
};

Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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}\"");
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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}"))?
Expand All @@ -647,15 +653,14 @@ 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(),
port,
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 {
Expand All @@ -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)
}

Expand Down
12 changes: 10 additions & 2 deletions src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<Peer>> {
pub async fn discover(
me: &SelfDevice,
wait: Duration,
identity: Option<&crate::certs::Identity>,
) -> Result<Vec<Peer>> {
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.
Expand Down
6 changes: 3 additions & 3 deletions src/proto.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/pull.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,11 @@ pub async fn pull_files(
pin: Option<&str>,
max_bytes: Option<u64>,
quiet: bool,
identity: Option<&crate::certs::Identity>,
) -> std::result::Result<PullOutcome, PullError> {
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
Expand Down
14 changes: 13 additions & 1 deletion src/receiver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ChecksumMismatch>().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.",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -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::<SocketAddr>().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();
}
}
Loading
Loading