mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
feat: add discovery to core
This commit is contained in:
@@ -43,11 +43,12 @@ x509-parser = { version = "0.18.1", features = ["verify"], optional = true }
|
||||
[features]
|
||||
default = []
|
||||
crypto = ["ed25519-dalek", "rcgen", "rsa", "sha2", "tokio-util"]
|
||||
discovery = ["http", "multicast"]
|
||||
http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "tokio-rustls", "tokio-util", "x509-parser"]
|
||||
multicast = ["if-addrs", "socket2", "tokio-util"]
|
||||
webrtc-signaling = ["tokio-tungstenite"]
|
||||
webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"]
|
||||
full = ["crypto", "http", "multicast", "webrtc"]
|
||||
full = ["crypto", "discovery", "http", "multicast", "webrtc"]
|
||||
|
||||
# RSA key generation is bignum-heavy and takes ~10x longer unoptimized;
|
||||
# keep the crypto crates optimized in dev so tests stay fast.
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
mod store;
|
||||
|
||||
pub use store::{
|
||||
DeviceChannel, DeviceLog, DiscoveredDevice, DiscoveredDeviceWithLogs, HttpChannel,
|
||||
};
|
||||
|
||||
use crate::http::client::{ClientError, LsHttpClientV2};
|
||||
use crate::http::dto::ProtocolType;
|
||||
use crate::http::dto_v2::{RegisterDtoV2, RegisterResponseDtoV2};
|
||||
use crate::model::discovery::{MulticastMessageV2, ProtocolTypeV2};
|
||||
use crate::multicast::{
|
||||
self, InterfaceFilter, MulticastConfig, MulticastDevice, MulticastEvent, MulticastHandle,
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashSet;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use store::DeviceStore;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
/// The default timeout of the register requests sent by discovery, matching
|
||||
/// the Flutter app's default discovery timeout. Discovery only talks to LAN
|
||||
/// peers, which answer quickly or not at all.
|
||||
pub const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Capacity of the internal multicast event channel.
|
||||
const MULTICAST_CHANNEL_SIZE: usize = 64;
|
||||
|
||||
/// How many hosts a subnet scan probes at once, matching the Flutter app's
|
||||
/// legacy HTTP discovery.
|
||||
const SCAN_CONCURRENCY: usize = 50;
|
||||
|
||||
/// This device's TLS identity, sent as client certificate with every register
|
||||
/// request (client certificates are mandatory in HTTPS mode).
|
||||
///
|
||||
/// Its fingerprint is the one carried in [`MulticastDevice::fingerprint`].
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceIdentity {
|
||||
/// PEM-encoded certificate.
|
||||
pub cert_pem: String,
|
||||
|
||||
/// PEM-encoded private key.
|
||||
pub private_key_pem: String,
|
||||
}
|
||||
|
||||
/// Configuration of the discovery.
|
||||
pub struct DiscoveryConfig {
|
||||
/// The multicast group to join, usually [`multicast::DEFAULT_MULTICAST_GROUP`].
|
||||
pub group: Ipv4Addr,
|
||||
|
||||
/// The IPv6 multicast group to additionally join, usually
|
||||
/// [`multicast::DEFAULT_MULTICAST_GROUP_V6`]. `None` disables IPv6 discovery.
|
||||
pub group_v6: Option<Ipv6Addr>,
|
||||
|
||||
/// The multicast port to bind, usually [`multicast::DEFAULT_PORT`].
|
||||
pub port: u16,
|
||||
|
||||
/// Restricts the network interfaces that are used.
|
||||
pub interface_filter: InterfaceFilter,
|
||||
|
||||
/// The device information announced to the network and sent in register
|
||||
/// requests.
|
||||
pub device: MulticastDevice,
|
||||
|
||||
/// The TLS identity used for the register requests.
|
||||
pub identity: DeviceIdentity,
|
||||
|
||||
/// Timeout of each register request sent by discovery, usually
|
||||
/// [`DEFAULT_DISCOVERY_TIMEOUT`]. Bounds how long an unresponsive host
|
||||
/// stalls a subnet scan, so keep it short.
|
||||
pub timeout: Duration,
|
||||
|
||||
/// Channel on which discovery events are emitted. `None` when the
|
||||
/// application only polls [`DiscoveryHandle::devices`].
|
||||
pub event_tx: Option<mpsc::Sender<DiscoveryEvent>>,
|
||||
}
|
||||
|
||||
/// An event emitted by the discovery. Every event is also logged in
|
||||
/// [`DiscoveredDeviceWithLogs::logs`]; the accumulated state is read from
|
||||
/// [`DiscoveryHandle::devices`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DiscoveryEvent {
|
||||
/// A device was confirmed over one of its channels for the first time
|
||||
/// in this run.
|
||||
Discovered {
|
||||
/// The device as it was confirmed, i.e. carrying only the channel
|
||||
/// the confirmation happened on.
|
||||
device: DiscoveredDevice,
|
||||
},
|
||||
|
||||
/// An already known device was confirmed again: it re-announced itself
|
||||
/// or was re-discovered, at a known or a new address.
|
||||
Updated {
|
||||
/// The device as it was confirmed, like in
|
||||
/// [`DiscoveryEvent::Discovered`].
|
||||
device: DiscoveredDevice,
|
||||
},
|
||||
}
|
||||
|
||||
struct DiscoveryState {
|
||||
device: MulticastDevice,
|
||||
identity: DeviceIdentity,
|
||||
timeout: Duration,
|
||||
store: DeviceStore,
|
||||
event_tx: Option<mpsc::Sender<DiscoveryEvent>>,
|
||||
|
||||
/// The interface addresses a subnet scan is currently running for.
|
||||
scanning: std::sync::Mutex<HashSet<Ipv4Addr>>,
|
||||
}
|
||||
|
||||
impl DiscoveryState {
|
||||
fn register_dto(&self) -> RegisterDtoV2 {
|
||||
RegisterDtoV2 {
|
||||
alias: self.device.alias.clone(),
|
||||
version: self.device.version.clone(),
|
||||
device_model: self.device.device_model.clone(),
|
||||
device_type: self.device.device_type.clone(),
|
||||
fingerprint: self.device.fingerprint.clone(),
|
||||
port: self.device.port,
|
||||
protocol: self.device.protocol,
|
||||
download: self.device.download,
|
||||
}
|
||||
}
|
||||
|
||||
/// A client accepting any valid certificate, for peers whose fingerprint
|
||||
/// is not known before they answer. The fingerprint is then read off the
|
||||
/// handshake and pinned by connections that transfer data.
|
||||
fn unpinned_client(&self) -> Result<LsHttpClientV2, ClientError> {
|
||||
LsHttpClientV2::try_new(
|
||||
&self.identity.private_key_pem,
|
||||
&self.identity.cert_pem,
|
||||
None,
|
||||
Some(self.timeout),
|
||||
)
|
||||
}
|
||||
|
||||
/// Registers with `host:port` and, when a device answers, puts it into
|
||||
/// the store. Returns the device's stored state after the merge, or
|
||||
/// `None` when the answer carried this device's own fingerprint.
|
||||
async fn probe(
|
||||
&self,
|
||||
client: &LsHttpClientV2,
|
||||
host: &str,
|
||||
port: u16,
|
||||
protocol: ProtocolTypeV2,
|
||||
) -> Result<Option<DiscoveredDeviceWithLogs>, ClientError> {
|
||||
let response = client
|
||||
.register(client_protocol(protocol), host, port, self.register_dto())
|
||||
.await?;
|
||||
|
||||
// In HTTPS mode the certificate is the peer's identity; the
|
||||
// fingerprint claimed in the body only counts without encryption.
|
||||
let fingerprint = match protocol {
|
||||
ProtocolTypeV2::Https => response
|
||||
.cert_fingerprint
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("HTTPS response carried no peer certificate"))?,
|
||||
ProtocolTypeV2::Http => response.body.fingerprint.clone(),
|
||||
};
|
||||
if fingerprint == self.device.fingerprint {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let device = confirmed_device(response.body, host.to_string(), port, protocol, fingerprint);
|
||||
let (_, merged) = self.found(device).await;
|
||||
Ok(Some(merged))
|
||||
}
|
||||
|
||||
/// Puts a device into the store, logging the confirmation on it, and
|
||||
/// emits the resulting event. Returns whether the device is new, and
|
||||
/// its stored state after the merge.
|
||||
async fn found(&self, device: DiscoveredDevice) -> (bool, DiscoveredDeviceWithLogs) {
|
||||
let (event, merged) = self.store.upsert(device, SystemTime::now());
|
||||
let is_new = matches!(event, DiscoveryEvent::Discovered { .. });
|
||||
if let Some(event_tx) = &self.event_tx {
|
||||
let _ = event_tx.send(event).await;
|
||||
}
|
||||
(is_new, merged)
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to a running discovery: the store of discovered devices and the
|
||||
/// application-initiated operations.
|
||||
pub struct DiscoveryHandle {
|
||||
multicast: MulticastHandle,
|
||||
state: Arc<DiscoveryState>,
|
||||
}
|
||||
|
||||
impl DiscoveryHandle {
|
||||
/// Announces this device to the network, which makes every other LocalSend
|
||||
/// device on it register with this device over HTTP.
|
||||
///
|
||||
/// Devices registering in response arrive at the application as server
|
||||
/// events, not here: discovery only learns about peers that announce
|
||||
/// themselves. Feed them back via [`DiscoveryHandle::add_device`].
|
||||
///
|
||||
/// Returns once the whole announcement burst has been sent, which takes a
|
||||
/// few seconds, or immediately once discovery has been stopped.
|
||||
pub async fn announce(&self) {
|
||||
self.multicast.announce().await;
|
||||
}
|
||||
|
||||
/// Discovers a device at a known address, e.g. a favorite or a peer that
|
||||
/// multicast does not reach, by sending it a register request.
|
||||
///
|
||||
/// On success the device is put into the store (and emitted, as
|
||||
/// `Discovered` or `Updated`), and its full stored state — all known
|
||||
/// channels and logs — is returned.
|
||||
/// Returns `None` when the address answered with this device's own
|
||||
/// fingerprint, i.e. the device discovered itself.
|
||||
pub async fn discover(
|
||||
&self,
|
||||
host: &str,
|
||||
port: u16,
|
||||
protocol: ProtocolTypeV2,
|
||||
) -> Result<Option<DiscoveredDeviceWithLogs>, ClientError> {
|
||||
let client = self.state.unpinned_client()?;
|
||||
self.state.probe(&client, host, port, protocol).await
|
||||
}
|
||||
|
||||
/// Scans the `/24` subnet of the local interface address `interface_ip`
|
||||
/// by sending every other host a register request,
|
||||
/// for networks that do not carry multicast.
|
||||
///
|
||||
/// At most one scan runs per interface: a call for an address that is
|
||||
/// still being scanned returns an empty list immediately.
|
||||
pub async fn scan_subnet(
|
||||
&self,
|
||||
interface_ip: Ipv4Addr,
|
||||
port: u16,
|
||||
protocol: ProtocolTypeV2,
|
||||
) -> Result<Vec<DiscoveredDeviceWithLogs>, ClientError> {
|
||||
if !self.state.scanning.lock().unwrap().insert(interface_ip) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let _guard = ScanGuard {
|
||||
state: &self.state,
|
||||
interface_ip,
|
||||
};
|
||||
|
||||
let client = self.state.unpinned_client()?;
|
||||
let base = interface_ip.octets();
|
||||
|
||||
let state = &self.state;
|
||||
let client = &client;
|
||||
let found = futures_util::stream::iter(
|
||||
(0..=255u8)
|
||||
.map(|host| Ipv4Addr::new(base[0], base[1], base[2], host))
|
||||
.filter(|ip| *ip != interface_ip),
|
||||
)
|
||||
.map(|ip| async move {
|
||||
state
|
||||
.probe(client, &ip.to_string(), port, protocol)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.buffer_unordered(SCAN_CONCURRENCY)
|
||||
.filter_map(std::future::ready)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Puts a device confirmed outside of discovery into the store, e.g. one
|
||||
/// that answered an announcement by registering with this device's HTTP
|
||||
/// server. The confirmation is emitted as `Discovered` or `Updated`;
|
||||
/// returns `true` when the device is new.
|
||||
pub async fn add_device(&self, device: DiscoveredDevice) -> bool {
|
||||
self.state.found(device).await.0
|
||||
}
|
||||
|
||||
/// All discovered devices in discovery order.
|
||||
pub fn devices(&self) -> Vec<DiscoveredDeviceWithLogs> {
|
||||
self.state.store.devices()
|
||||
}
|
||||
|
||||
pub fn device_by_fingerprint(&self, fingerprint: &str) -> Option<DiscoveredDeviceWithLogs> {
|
||||
self.state.store.by_fingerprint(fingerprint)
|
||||
}
|
||||
|
||||
/// Waits until discovery has terminated and the multicast sockets have
|
||||
/// been closed, so that the port can be bound again.
|
||||
/// Must be called after requesting a stop via the stop channel.
|
||||
pub async fn wait_stopped(&self) {
|
||||
self.multicast.wait_stopped().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases the interface of a finished subnet scan, also when the scan is
|
||||
/// cancelled by dropping its future.
|
||||
struct ScanGuard<'a> {
|
||||
state: &'a DiscoveryState,
|
||||
interface_ip: Ipv4Addr,
|
||||
}
|
||||
|
||||
impl Drop for ScanGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.state
|
||||
.scanning
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&self.interface_ip);
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds the multicast sockets and starts answering announcements of other
|
||||
/// devices. Nothing is announced until [`DiscoveryHandle::announce`] is called.
|
||||
///
|
||||
/// Fails when no network interface could be used, e.g. because the port is
|
||||
/// already bound by another process or because there is no network at all.
|
||||
pub async fn start(
|
||||
config: DiscoveryConfig,
|
||||
stop_rx: oneshot::Receiver<()>,
|
||||
) -> anyhow::Result<DiscoveryHandle> {
|
||||
let (multicast_tx, mut multicast_rx) = mpsc::channel(MULTICAST_CHANNEL_SIZE);
|
||||
|
||||
let multicast = multicast::start(
|
||||
MulticastConfig {
|
||||
group: config.group,
|
||||
group_v6: config.group_v6,
|
||||
port: config.port,
|
||||
interface_filter: config.interface_filter,
|
||||
device: config.device.clone(),
|
||||
event_tx: multicast_tx,
|
||||
},
|
||||
stop_rx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let state = Arc::new(DiscoveryState {
|
||||
device: config.device,
|
||||
identity: config.identity,
|
||||
timeout: config.timeout,
|
||||
store: DeviceStore::new(),
|
||||
event_tx: config.event_tx,
|
||||
scanning: std::sync::Mutex::new(HashSet::new()),
|
||||
});
|
||||
|
||||
// Ends once discovery is stopped: the multicast side then drops its
|
||||
// sender and the channel closes.
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
async move {
|
||||
while let Some(event) = multicast_rx.recv().await {
|
||||
let MulticastEvent::Discovered {
|
||||
ip,
|
||||
scope_id,
|
||||
message,
|
||||
} = event;
|
||||
|
||||
// The register request may take a while (up to the timeout),
|
||||
// so announcements are answered concurrently.
|
||||
tokio::spawn(answer_announcement(state.clone(), ip, scope_id, message));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(DiscoveryHandle { multicast, state })
|
||||
}
|
||||
|
||||
/// Answers an announcement with a register request, as the protocol requires.
|
||||
/// The device enters the store only once that request succeeded, so that
|
||||
/// everything in the store is known to be reachable.
|
||||
async fn answer_announcement(
|
||||
state: Arc<DiscoveryState>,
|
||||
ip: IpAddr,
|
||||
scope_id: Option<u32>,
|
||||
message: MulticastMessageV2,
|
||||
) {
|
||||
let host = match scope_id {
|
||||
Some(scope_id) => format!("{ip}%{scope_id}"),
|
||||
None => ip.to_string(),
|
||||
};
|
||||
|
||||
// Pin the claimed fingerprint, so nothing is sent to a device that does
|
||||
// not hold the matching certificate.
|
||||
let expected_fingerprint = match message.protocol {
|
||||
ProtocolTypeV2::Https => Some(message.fingerprint.clone()),
|
||||
ProtocolTypeV2::Http => None,
|
||||
};
|
||||
let client = match LsHttpClientV2::try_new(
|
||||
&state.identity.private_key_pem,
|
||||
&state.identity.cert_pem,
|
||||
expected_fingerprint,
|
||||
Some(state.timeout),
|
||||
) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
tracing::error!("Could not create the client to answer {host}: {err:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let result = client
|
||||
.register(
|
||||
client_protocol(message.protocol),
|
||||
&host,
|
||||
message.port,
|
||||
state.register_dto(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
// The pinned certificate identifies the peer, so the fingerprint
|
||||
// is taken from the announcement, not from the response body.
|
||||
let device = confirmed_device(
|
||||
response.body,
|
||||
host,
|
||||
message.port,
|
||||
message.protocol,
|
||||
message.fingerprint,
|
||||
);
|
||||
state.found(device).await;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!("Could not register with announcing device {host}: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the stored device from the register response of a peer confirmed
|
||||
/// over HTTP.
|
||||
fn confirmed_device(
|
||||
response: RegisterResponseDtoV2,
|
||||
host: String,
|
||||
port: u16,
|
||||
protocol: ProtocolTypeV2,
|
||||
fingerprint: String,
|
||||
) -> DiscoveredDevice {
|
||||
DiscoveredDevice {
|
||||
alias: response.alias,
|
||||
version: response.version,
|
||||
device_model: response.device_model,
|
||||
device_type: response.device_type,
|
||||
fingerprint,
|
||||
channels: vec![DeviceChannel::Http(HttpChannel {
|
||||
host,
|
||||
port,
|
||||
protocol,
|
||||
})],
|
||||
download: response.download,
|
||||
}
|
||||
}
|
||||
|
||||
fn client_protocol(protocol: ProtocolTypeV2) -> ProtocolType {
|
||||
match protocol {
|
||||
ProtocolTypeV2::Http => ProtocolType::Http,
|
||||
ProtocolTypeV2::Https => ProtocolType::Https,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//! The in-memory store of discovered devices.
|
||||
|
||||
use super::DiscoveryEvent;
|
||||
use crate::model::discovery::{DeviceType, ProtocolTypeV2};
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// How many log entries a device keeps. Every confirmation is logged and
|
||||
/// chatty peers re-announce for the whole run, so the oldest entries are
|
||||
/// dropped beyond this.
|
||||
const MAX_LOGS: usize = 100;
|
||||
|
||||
/// A device discovered on the network, confirmed over one of its channels.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredDevice {
|
||||
/// The display name of the device.
|
||||
pub alias: String,
|
||||
|
||||
/// Protocol version (major.minor) implemented by the device.
|
||||
pub version: String,
|
||||
|
||||
/// Device model (e.g. "Samsung", "Windows").
|
||||
pub device_model: Option<String>,
|
||||
|
||||
/// Device type category.
|
||||
pub device_type: Option<DeviceType>,
|
||||
|
||||
/// Fingerprint identifying the device; devices are deduplicated by it.
|
||||
/// In HTTPS mode the SHA-256 hash of the certificate, otherwise a random string.
|
||||
pub fingerprint: String,
|
||||
|
||||
/// The channels the device is reachable on, in discovery order.
|
||||
/// A transfer can jump to another channel when one fails.
|
||||
pub channels: Vec<DeviceChannel>,
|
||||
|
||||
/// Whether the device's download API is active.
|
||||
pub download: bool,
|
||||
}
|
||||
|
||||
/// A [`DiscoveredDevice`] as kept in the store, together with the history of
|
||||
/// events that affected it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredDeviceWithLogs {
|
||||
pub device: DiscoveredDevice,
|
||||
|
||||
/// The events that affected this device, oldest first, at most
|
||||
/// [`MAX_LOGS`]. Every confirmation is logged, so the last entry is when
|
||||
/// the device was last seen.
|
||||
pub logs: Vec<DeviceLog>,
|
||||
}
|
||||
|
||||
/// A [`DiscoveryEvent`] that affected a device, with the time it happened.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DeviceLog {
|
||||
pub timestamp: SystemTime,
|
||||
pub event: DiscoveryEvent,
|
||||
}
|
||||
|
||||
impl DiscoveredDevice {
|
||||
/// The device's HTTP channels — the addresses it is reachable on — in
|
||||
/// discovery order. A multi-homed device has one per address it was
|
||||
/// discovered on.
|
||||
pub fn http_channels(&self) -> impl Iterator<Item = &HttpChannel> {
|
||||
// With more channel kinds this becomes a `filter_map`.
|
||||
self.channels.iter().map(|channel| match channel {
|
||||
DeviceChannel::Http(http) => http,
|
||||
})
|
||||
}
|
||||
|
||||
/// The device's first HTTP channel, when it has one.
|
||||
pub fn http(&self) -> Option<&HttpChannel> {
|
||||
self.http_channels().next()
|
||||
}
|
||||
}
|
||||
|
||||
/// A channel a device is reachable on.
|
||||
///
|
||||
/// Only HTTP exists so far; other transports (e.g. WebRTC, Bluetooth) will
|
||||
/// become further variants.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DeviceChannel {
|
||||
/// The device's HTTP server (protocol v2), reachable at one address.
|
||||
Http(HttpChannel),
|
||||
}
|
||||
|
||||
impl DeviceChannel {
|
||||
/// Whether two channels address the same endpoint, so that a
|
||||
/// re-confirmation updates the known channel instead of adding one.
|
||||
fn same_endpoint(&self, other: &DeviceChannel) -> bool {
|
||||
match (self, other) {
|
||||
(DeviceChannel::Http(own), DeviceChannel::Http(other)) => own.host == other.host,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The address of a device's HTTP server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HttpChannel {
|
||||
/// The host to dial: an IP address, or the scoped form `fe80::1%3` for
|
||||
/// link-local IPv6 (the HTTP client accepts both).
|
||||
pub host: String,
|
||||
|
||||
/// The port of the HTTP server.
|
||||
pub port: u16,
|
||||
|
||||
/// Whether the HTTP server uses TLS.
|
||||
pub protocol: ProtocolTypeV2,
|
||||
}
|
||||
|
||||
/// All devices discovered in this run, identified by fingerprint, in
|
||||
/// discovery order.
|
||||
pub(super) struct DeviceStore {
|
||||
devices: Mutex<Vec<DiscoveredDeviceWithLogs>>,
|
||||
}
|
||||
|
||||
impl DeviceStore {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
devices: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or updates a device, logging the confirmation on it. Returns the
|
||||
/// event describing what happened — [`DiscoveryEvent::Discovered`] for a
|
||||
/// new device, [`DiscoveryEvent::Updated`] for a known one — and the
|
||||
/// stored state after the merge.
|
||||
///
|
||||
/// Channels are merged by endpoint: a rediscovery over a known address
|
||||
/// updates its channel in place, an unknown address adds one.
|
||||
pub(super) fn upsert(
|
||||
&self,
|
||||
device: DiscoveredDevice,
|
||||
timestamp: SystemTime,
|
||||
) -> (DiscoveryEvent, DiscoveredDeviceWithLogs) {
|
||||
let mut devices = self.devices.lock().unwrap();
|
||||
match devices
|
||||
.iter_mut()
|
||||
.find(|known| known.device.fingerprint == device.fingerprint)
|
||||
{
|
||||
Some(known) => {
|
||||
let event = DiscoveryEvent::Updated {
|
||||
device: device.clone(),
|
||||
};
|
||||
|
||||
let mut channels = std::mem::take(&mut known.device.channels);
|
||||
for channel in device.channels {
|
||||
match channels.iter_mut().find(|c| c.same_endpoint(&channel)) {
|
||||
Some(known) => *known = channel,
|
||||
None => channels.push(channel),
|
||||
}
|
||||
}
|
||||
|
||||
known.logs.push(DeviceLog {
|
||||
timestamp,
|
||||
event: event.clone(),
|
||||
});
|
||||
if known.logs.len() > MAX_LOGS {
|
||||
let excess = known.logs.len() - MAX_LOGS;
|
||||
known.logs.drain(..excess);
|
||||
}
|
||||
|
||||
known.device = DiscoveredDevice { channels, ..device };
|
||||
(event, known.clone())
|
||||
}
|
||||
None => {
|
||||
let event = DiscoveryEvent::Discovered {
|
||||
device: device.clone(),
|
||||
};
|
||||
let known = DiscoveredDeviceWithLogs {
|
||||
device,
|
||||
logs: vec![DeviceLog {
|
||||
timestamp,
|
||||
event: event.clone(),
|
||||
}],
|
||||
};
|
||||
devices.push(known.clone());
|
||||
(event, known)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All discovered devices in discovery order.
|
||||
pub(super) fn devices(&self) -> Vec<DiscoveredDeviceWithLogs> {
|
||||
self.devices.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub(super) fn by_fingerprint(&self, fingerprint: &str) -> Option<DiscoveredDeviceWithLogs> {
|
||||
self.devices
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|known| known.device.fingerprint == fingerprint)
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device(fingerprint: &str, host: &str) -> DiscoveredDevice {
|
||||
DiscoveredDevice {
|
||||
alias: format!("Alias of {fingerprint}"),
|
||||
version: "2.1".to_string(),
|
||||
device_model: None,
|
||||
device_type: Some(DeviceType::Desktop),
|
||||
fingerprint: fingerprint.to_string(),
|
||||
channels: vec![DeviceChannel::Http(HttpChannel {
|
||||
host: host.to_string(),
|
||||
port: 53317,
|
||||
protocol: ProtocolTypeV2::Https,
|
||||
})],
|
||||
download: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker telling log entries apart: the host the logged snapshot
|
||||
/// was confirmed on.
|
||||
fn log_marker(log: &DeviceLog) -> &str {
|
||||
let device = match &log.event {
|
||||
DiscoveryEvent::Discovered { device } | DiscoveryEvent::Updated { device } => device,
|
||||
};
|
||||
device.http().unwrap().host.as_str()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_reports_only_the_first_confirmation_as_discovered() {
|
||||
let store = DeviceStore::new();
|
||||
let now = SystemTime::now();
|
||||
|
||||
let (event, _) = store.upsert(device("a", "192.168.0.10"), now);
|
||||
assert!(matches!(event, DiscoveryEvent::Discovered { .. }));
|
||||
let (event, _) = store.upsert(device("b", "192.168.0.11"), now);
|
||||
assert!(matches!(event, DiscoveryEvent::Discovered { .. }));
|
||||
let (event, _) = store.upsert(device("a", "10.0.0.10"), now);
|
||||
assert!(matches!(event, DiscoveryEvent::Updated { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_logs_every_confirmation() {
|
||||
let store = DeviceStore::new();
|
||||
|
||||
store.upsert(device("a", "192.168.0.10"), SystemTime::now());
|
||||
store.upsert(device("a", "fe80::1%3"), SystemTime::now());
|
||||
|
||||
let known = store.by_fingerprint("a").unwrap();
|
||||
assert_eq!(known.logs.len(), 2, "every confirmation must be logged");
|
||||
assert!(matches!(
|
||||
known.logs[0].event,
|
||||
DiscoveryEvent::Discovered { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
known.logs[1].event,
|
||||
DiscoveryEvent::Updated { .. }
|
||||
));
|
||||
assert_eq!(log_marker(&known.logs[1]), "fe80::1%3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oldest_logs_are_dropped_beyond_the_cap() {
|
||||
let store = DeviceStore::new();
|
||||
|
||||
for i in 0..MAX_LOGS + 5 {
|
||||
store.upsert(device("a", &i.to_string()), SystemTime::now());
|
||||
}
|
||||
|
||||
let logs = store.by_fingerprint("a").unwrap().logs;
|
||||
assert_eq!(logs.len(), MAX_LOGS);
|
||||
assert_eq!(log_marker(&logs[0]), "5", "the oldest entries must go");
|
||||
assert_eq!(
|
||||
log_marker(logs.last().unwrap()),
|
||||
&(MAX_LOGS + 4).to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_collects_channels_of_a_multi_homed_device() {
|
||||
let store = DeviceStore::new();
|
||||
|
||||
store.upsert(device("a", "192.168.0.10"), SystemTime::now());
|
||||
store.upsert(device("a", "fe80::1%3"), SystemTime::now());
|
||||
|
||||
let devices = store.devices();
|
||||
assert_eq!(devices.len(), 1);
|
||||
let hosts: Vec<&str> = devices[0]
|
||||
.device
|
||||
.http_channels()
|
||||
.map(|http| http.host.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
hosts,
|
||||
["192.168.0.10", "fe80::1%3"],
|
||||
"every address the device was confirmed on must be kept"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_updates_channel_of_known_endpoint_in_place() {
|
||||
let store = DeviceStore::new();
|
||||
|
||||
store.upsert(device("a", "192.168.0.10"), SystemTime::now());
|
||||
store.upsert(device("a", "fe80::1%3"), SystemTime::now());
|
||||
|
||||
let mut update = device("a", "192.168.0.10");
|
||||
match &mut update.channels[0] {
|
||||
DeviceChannel::Http(http) => http.port = 54000,
|
||||
}
|
||||
store.upsert(update, SystemTime::now());
|
||||
|
||||
let known = store.by_fingerprint("a").unwrap();
|
||||
let channels: Vec<(&str, u16)> = known
|
||||
.device
|
||||
.http_channels()
|
||||
.map(|http| (http.host.as_str(), http.port))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
channels,
|
||||
[("192.168.0.10", 54000), ("fe80::1%3", 53317)],
|
||||
"a known address must be updated in place, not duplicated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_keeps_channels_missing_from_the_update() {
|
||||
let store = DeviceStore::new();
|
||||
|
||||
store.upsert(device("a", "192.168.0.10"), SystemTime::now());
|
||||
|
||||
let mut update = device("a", "10.0.0.10");
|
||||
update.channels.clear();
|
||||
store.upsert(update, SystemTime::now());
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.by_fingerprint("a")
|
||||
.unwrap()
|
||||
.device
|
||||
.http()
|
||||
.unwrap()
|
||||
.host,
|
||||
"192.168.0.10",
|
||||
"an update without an HTTP channel must not drop the known one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_devices_keep_discovery_order() {
|
||||
let store = DeviceStore::new();
|
||||
|
||||
store.upsert(device("a", "192.168.0.10"), SystemTime::now());
|
||||
store.upsert(device("b", "192.168.0.11"), SystemTime::now());
|
||||
store.upsert(device("a", "192.168.0.12"), SystemTime::now());
|
||||
|
||||
let fingerprints: Vec<String> = store
|
||||
.devices()
|
||||
.into_iter()
|
||||
.map(|known| known.device.fingerprint)
|
||||
.collect();
|
||||
assert_eq!(fingerprints, ["a", "b"]);
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.by_fingerprint("b")
|
||||
.unwrap()
|
||||
.device
|
||||
.http()
|
||||
.unwrap()
|
||||
.host,
|
||||
"192.168.0.11"
|
||||
);
|
||||
assert!(store.by_fingerprint("c").is_none());
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ impl LsHttpClient {
|
||||
let result = client.register(protocol, ip, port, payload.into()).await?;
|
||||
Ok(ResultWithPublicKey {
|
||||
public_key: result.public_key,
|
||||
cert_fingerprint: result.cert_fingerprint,
|
||||
body: result.body.into(),
|
||||
})
|
||||
}
|
||||
@@ -279,6 +280,20 @@ pub(super) fn verify_cert_from_res(
|
||||
Ok(public_key)
|
||||
}
|
||||
|
||||
/// The SHA-256 fingerprint (uppercase hex) of the peer certificate the
|
||||
/// response was received over. This — not any fingerprint claimed in the
|
||||
/// body — is the peer's identity in HTTPS mode.
|
||||
pub(super) fn cert_fingerprint_from_res(response: &Response) -> 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"))?;
|
||||
Ok(crypto::cert::fingerprint_from_cert_der(cert))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ErrorResponse {
|
||||
message: String,
|
||||
@@ -290,6 +305,11 @@ pub struct ResultWithPublicKey<T> {
|
||||
/// Only available in HTTPS mode.
|
||||
pub public_key: Option<String>,
|
||||
|
||||
/// The SHA-256 fingerprint (uppercase hex) of the peer certificate.
|
||||
/// Only available in HTTPS mode, where it is the peer's identity and
|
||||
/// overrules any fingerprint claimed in the body.
|
||||
pub cert_fingerprint: Option<String>,
|
||||
|
||||
/// The response body.
|
||||
pub body: T,
|
||||
}
|
||||
|
||||
@@ -96,14 +96,21 @@ impl LsHttpClientV2 {
|
||||
return res.into_error().await;
|
||||
}
|
||||
|
||||
let public_key = match protocol {
|
||||
ProtocolType::Https => Some(super::verify_cert_from_res(&res, None)?),
|
||||
_ => None,
|
||||
let (public_key, cert_fingerprint) = match protocol {
|
||||
ProtocolType::Https => (
|
||||
Some(super::verify_cert_from_res(&res, None)?),
|
||||
Some(super::cert_fingerprint_from_res(&res)?),
|
||||
),
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
let body = res.json::<RegisterResponseDtoV2>().await?;
|
||||
|
||||
Ok(ResultWithPublicKey { public_key, body })
|
||||
Ok(ResultWithPublicKey {
|
||||
public_key,
|
||||
cert_fingerprint,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// Prepares a file upload session with the receiver.
|
||||
|
||||
@@ -123,14 +123,21 @@ impl LsHttpClientV3 {
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let public_key = match protocol {
|
||||
ProtocolType::Https => Some(super::verify_cert_from_res(&res, None)?),
|
||||
_ => None,
|
||||
let (public_key, cert_fingerprint) = match protocol {
|
||||
ProtocolType::Https => (
|
||||
Some(super::verify_cert_from_res(&res, None)?),
|
||||
Some(super::cert_fingerprint_from_res(&res)?),
|
||||
),
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
let body = res.json::<http::dto::RegisterResponseDto>().await?;
|
||||
|
||||
Ok(ResultWithPublicKey { public_key, body })
|
||||
Ok(ResultWithPublicKey {
|
||||
public_key,
|
||||
cert_fingerprint,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// `cancel` is a cancellation token; cancelling it aborts the request with
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#[cfg(feature = "crypto")]
|
||||
pub mod crypto;
|
||||
#[cfg(feature = "discovery")]
|
||||
pub mod discovery;
|
||||
#[cfg(feature = "http")]
|
||||
pub mod http;
|
||||
pub mod model;
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
#![cfg(feature = "discovery")]
|
||||
|
||||
//! End-to-end discovery tests: real HTTP servers confirm the register
|
||||
//! requests. Most tests use plain HTTP; the client side of TLS pinning is
|
||||
//! covered by `v2_tls_pinning.rs`.
|
||||
//!
|
||||
//! Binding multicast sockets and delivering multicast traffic depends on the
|
||||
//! machine, so these tests skip themselves instead of failing when the
|
||||
//! environment does not cooperate.
|
||||
|
||||
use localsend::crypto::cert::generate_self_signed;
|
||||
use localsend::discovery::{
|
||||
self, DeviceIdentity, DiscoveryConfig, DiscoveryEvent, DiscoveryHandle,
|
||||
};
|
||||
use localsend::http::server::{start_with_port, ServerConfigV2, TlsConfig};
|
||||
use localsend::http::state::ClientInfo;
|
||||
use localsend::model::discovery::{DeviceType, ProtocolTypeV2, PROTOCOL_VERSION_V2};
|
||||
use localsend::multicast::MulticastDevice;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
/// Like the group the protocol uses, but distinct from it (and from the one in
|
||||
/// `multicast.rs`) so that unrelated instances stay out.
|
||||
const TEST_GROUP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 169);
|
||||
|
||||
/// See [TEST_GROUP].
|
||||
const TEST_GROUP_V6: Ipv6Addr = Ipv6Addr::new(0xff12, 0, 0, 0, 0, 0, 0xfd3a, 0xe422);
|
||||
|
||||
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Multicast ports are not reused between tests: a lingering membership of a
|
||||
/// stopped instance would leak messages into the next test.
|
||||
static NEXT_MULTICAST_PORT: AtomicU16 = AtomicU16::new(55317);
|
||||
|
||||
/// Returns a free port for an HTTP server.
|
||||
///
|
||||
/// A counter is used instead of binding to port 0 because the OS may hand out
|
||||
/// the same just-freed ephemeral port to multiple tests running in parallel.
|
||||
fn free_port() -> u16 {
|
||||
static PORT_COUNTER: AtomicU16 = AtomicU16::new(41551);
|
||||
|
||||
loop {
|
||||
let port = PORT_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a v2 server on `port` that only exists to answer register requests
|
||||
/// with the given identity, over TLS when `tls` is given.
|
||||
async fn start_register_server(
|
||||
port: u16,
|
||||
alias: &str,
|
||||
fingerprint: &str,
|
||||
tls: Option<TlsConfig>,
|
||||
) -> oneshot::Sender<()> {
|
||||
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
|
||||
|
||||
// The receiver is dropped: the register endpoint responds either way.
|
||||
let (event_tx, _) = mpsc::channel(16);
|
||||
let (stop_tx, stop_rx) = oneshot::channel();
|
||||
|
||||
start_with_port(
|
||||
port,
|
||||
tls,
|
||||
ClientInfo {
|
||||
alias: alias.to_string(),
|
||||
version: PROTOCOL_VERSION_V2.to_string(),
|
||||
device_model: Some("Rust".to_string()),
|
||||
device_type: Some(DeviceType::Headless),
|
||||
token: fingerprint.to_string(),
|
||||
},
|
||||
None,
|
||||
Some(ServerConfigV2 {
|
||||
pin: None,
|
||||
event_tx,
|
||||
}),
|
||||
None,
|
||||
stop_rx,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start server");
|
||||
|
||||
for _ in 0..100 {
|
||||
if tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return stop_tx;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
panic!("Server did not become reachable on port {port}");
|
||||
}
|
||||
|
||||
struct TestInstance {
|
||||
fingerprint: String,
|
||||
handle: DiscoveryHandle,
|
||||
events: mpsc::Receiver<DiscoveryEvent>,
|
||||
_stop_tx: oneshot::Sender<()>,
|
||||
}
|
||||
|
||||
impl TestInstance {
|
||||
/// Waits for the discovery of the device with the given fingerprint,
|
||||
/// ignoring unrelated devices that may be on the network.
|
||||
async fn next_discovery(
|
||||
&mut self,
|
||||
fingerprint: &str,
|
||||
) -> Option<localsend::discovery::DiscoveredDevice> {
|
||||
let deadline = tokio::time::Instant::now() + RECEIVE_TIMEOUT;
|
||||
loop {
|
||||
let event = tokio::time::timeout_at(deadline, self.events.recv())
|
||||
.await
|
||||
.ok()??;
|
||||
let DiscoveryEvent::Discovered { device } = event else {
|
||||
continue;
|
||||
};
|
||||
if device.fingerprint == fingerprint {
|
||||
return Some(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a discovery instance announcing `server_port` as its HTTP port, or
|
||||
/// returns `None` when this machine has no interface multicast can be bound to.
|
||||
async fn start_instance(
|
||||
alias: &str,
|
||||
multicast_port: u16,
|
||||
server_port: u16,
|
||||
) -> Option<TestInstance> {
|
||||
let cert = generate_self_signed().expect("Failed to generate an identity");
|
||||
let (event_tx, events) = mpsc::channel(32);
|
||||
let (stop_tx, stop_rx) = oneshot::channel();
|
||||
|
||||
let handle = discovery::start(
|
||||
DiscoveryConfig {
|
||||
group: TEST_GROUP,
|
||||
group_v6: Some(TEST_GROUP_V6),
|
||||
port: multicast_port,
|
||||
interface_filter: Default::default(),
|
||||
device: MulticastDevice {
|
||||
alias: alias.to_string(),
|
||||
version: PROTOCOL_VERSION_V2.to_string(),
|
||||
device_model: Some("Rust".to_string()),
|
||||
device_type: Some(DeviceType::Headless),
|
||||
fingerprint: cert.fingerprint.clone(),
|
||||
port: server_port,
|
||||
protocol: ProtocolTypeV2::Http,
|
||||
download: false,
|
||||
},
|
||||
identity: DeviceIdentity {
|
||||
cert_pem: cert.certificate_pem,
|
||||
private_key_pem: cert.private_key_pem,
|
||||
},
|
||||
timeout: discovery::DEFAULT_DISCOVERY_TIMEOUT,
|
||||
event_tx: Some(event_tx),
|
||||
},
|
||||
stop_rx,
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(TestInstance {
|
||||
fingerprint: cert.fingerprint,
|
||||
handle,
|
||||
events,
|
||||
_stop_tx: stop_tx,
|
||||
})
|
||||
}
|
||||
|
||||
fn skip(reason: &str) {
|
||||
eprintln!("skipping discovery test: {reason}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_targeted_discovery_stores_and_emits_device() {
|
||||
let multicast_port = NEXT_MULTICAST_PORT.fetch_add(1, Ordering::Relaxed);
|
||||
let server_port = free_port();
|
||||
let _server_stop =
|
||||
start_register_server(server_port, "Target", "target-fingerprint", None).await;
|
||||
|
||||
let Some(mut instance) = start_instance("Finder", multicast_port, free_port()).await else {
|
||||
return skip("no network interface available for multicast");
|
||||
};
|
||||
|
||||
let device = instance
|
||||
.handle
|
||||
.discover("127.0.0.1", server_port, ProtocolTypeV2::Http)
|
||||
.await
|
||||
.expect("Targeted discovery failed")
|
||||
.expect("The target must not be mistaken for the device itself");
|
||||
|
||||
assert_eq!(device.device.alias, "Target");
|
||||
assert_eq!(device.device.fingerprint, "target-fingerprint");
|
||||
let http = device
|
||||
.device
|
||||
.http()
|
||||
.expect("The device must have an HTTP channel");
|
||||
assert_eq!(http.host, "127.0.0.1");
|
||||
assert_eq!(http.port, server_port);
|
||||
|
||||
let stored = instance
|
||||
.handle
|
||||
.device_by_fingerprint("target-fingerprint")
|
||||
.expect("The discovered device must be stored");
|
||||
assert_eq!(stored.device.alias, "Target");
|
||||
|
||||
let emitted = instance
|
||||
.next_discovery("target-fingerprint")
|
||||
.await
|
||||
.expect("The discovered device must be emitted");
|
||||
assert_eq!(emitted.alias, "Target");
|
||||
|
||||
// Discovering the same device again emits Updated instead of Discovered.
|
||||
let updated = instance
|
||||
.handle
|
||||
.discover("127.0.0.1", server_port, ProtocolTypeV2::Http)
|
||||
.await
|
||||
.expect("Targeted discovery failed")
|
||||
.expect("The target must still be discoverable");
|
||||
assert_eq!(instance.handle.devices().len(), 1);
|
||||
assert_eq!(
|
||||
updated.logs.len(),
|
||||
2,
|
||||
"every confirmation must be logged on the stored device"
|
||||
);
|
||||
match instance.events.try_recv() {
|
||||
Ok(DiscoveryEvent::Updated { device }) => {
|
||||
assert_eq!(device.fingerprint, "target-fingerprint");
|
||||
}
|
||||
other => panic!("expected an Updated event for a known device, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_targeted_discovery_does_not_discover_itself() {
|
||||
let multicast_port = NEXT_MULTICAST_PORT.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let Some(instance) = start_instance("Selfish", multicast_port, free_port()).await else {
|
||||
return skip("no network interface available for multicast");
|
||||
};
|
||||
|
||||
// A server answering with this instance's own fingerprint, as the
|
||||
// instance's real server would.
|
||||
let server_port = free_port();
|
||||
let _server_stop =
|
||||
start_register_server(server_port, "Selfish", &instance.fingerprint, None).await;
|
||||
|
||||
let device = instance
|
||||
.handle
|
||||
.discover("127.0.0.1", server_port, ProtocolTypeV2::Http)
|
||||
.await
|
||||
.expect("Targeted discovery failed");
|
||||
|
||||
assert!(device.is_none(), "a device must not discover itself");
|
||||
assert!(instance.handle.devices().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_subnet_scan_finds_device_on_loopback() {
|
||||
let multicast_port = NEXT_MULTICAST_PORT.fetch_add(1, Ordering::Relaxed);
|
||||
let server_port = free_port();
|
||||
let _server_stop =
|
||||
start_register_server(server_port, "ScanTarget", "scan-fingerprint", None).await;
|
||||
|
||||
let Some(mut instance) = start_instance("Scanner", multicast_port, free_port()).await else {
|
||||
return skip("no network interface available for multicast");
|
||||
};
|
||||
|
||||
// The scan probes 127.0.0.0/24 except the interface address itself.
|
||||
// Loopback routes the whole subnet to this host, so depending on the OS
|
||||
// the server is found on one or many of the addresses.
|
||||
let found = instance
|
||||
.handle
|
||||
.scan_subnet(
|
||||
Ipv4Addr::new(127, 0, 0, 99),
|
||||
server_port,
|
||||
ProtocolTypeV2::Http,
|
||||
)
|
||||
.await
|
||||
.expect("Subnet scan failed");
|
||||
|
||||
assert!(!found.is_empty(), "the scan must find the loopback server");
|
||||
assert!(found
|
||||
.iter()
|
||||
.all(|device| device.device.fingerprint == "scan-fingerprint"));
|
||||
|
||||
let stored = instance
|
||||
.handle
|
||||
.device_by_fingerprint("scan-fingerprint")
|
||||
.expect("The scanned device must be stored");
|
||||
assert_eq!(stored.device.alias, "ScanTarget");
|
||||
assert!(
|
||||
stored
|
||||
.device
|
||||
.http_channels()
|
||||
.all(|http| http.host != "127.0.0.99"),
|
||||
"the interface address itself must not be probed"
|
||||
);
|
||||
|
||||
let emitted = instance
|
||||
.next_discovery("scan-fingerprint")
|
||||
.await
|
||||
.expect("The scanned device must be emitted");
|
||||
assert_eq!(emitted.alias, "ScanTarget");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_targeted_discovery_reads_fingerprint_from_certificate_on_https() {
|
||||
let multicast_port = NEXT_MULTICAST_PORT.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let Some(instance) = start_instance("TlsFinder", multicast_port, free_port()).await else {
|
||||
return skip("no network interface available for multicast");
|
||||
};
|
||||
|
||||
// A TLS server whose response body claims a fingerprint that does not
|
||||
// match its certificate.
|
||||
let server_cert = generate_self_signed().expect("Failed to generate an identity");
|
||||
let server_port = free_port();
|
||||
let _server_stop = start_register_server(
|
||||
server_port,
|
||||
"TlsTarget",
|
||||
"claimed-fingerprint",
|
||||
Some(TlsConfig {
|
||||
cert: server_cert.certificate_pem.clone(),
|
||||
private_key: server_cert.private_key_pem.clone(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let device = instance
|
||||
.handle
|
||||
.discover("127.0.0.1", server_port, ProtocolTypeV2::Https)
|
||||
.await
|
||||
.expect("Targeted discovery failed")
|
||||
.expect("The target must not be mistaken for the device itself");
|
||||
|
||||
assert_eq!(
|
||||
device.device.fingerprint, server_cert.fingerprint,
|
||||
"on HTTPS the identity must be the certificate fingerprint, not the claimed one"
|
||||
);
|
||||
assert!(instance
|
||||
.handle
|
||||
.device_by_fingerprint(&server_cert.fingerprint)
|
||||
.is_some());
|
||||
assert!(instance
|
||||
.handle
|
||||
.device_by_fingerprint("claimed-fingerprint")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_announcement_is_answered_and_device_stored() {
|
||||
let multicast_port = NEXT_MULTICAST_PORT.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// The receiver answers the announcement with a register request to the
|
||||
// announcer's HTTP server, so the announcer needs a real one.
|
||||
let Some(mut receiver) = start_instance("Receiver", multicast_port, free_port()).await else {
|
||||
return skip("no network interface available for multicast");
|
||||
};
|
||||
let announcer_port = free_port();
|
||||
let Some(announcer) = start_instance("Announcer", multicast_port, announcer_port).await else {
|
||||
return skip("no network interface available for multicast");
|
||||
};
|
||||
let _server_stop =
|
||||
start_register_server(announcer_port, "Announcer", &announcer.fingerprint, None).await;
|
||||
|
||||
announcer.handle.announce().await;
|
||||
|
||||
let Some(device) = receiver.next_discovery(&announcer.fingerprint).await else {
|
||||
return skip("multicast traffic is not delivered on this machine");
|
||||
};
|
||||
|
||||
assert_eq!(device.alias, "Announcer");
|
||||
let http = device.http().expect("The device must have an HTTP channel");
|
||||
assert_eq!(http.port, announcer_port);
|
||||
assert_eq!(http.protocol, ProtocolTypeV2::Http);
|
||||
|
||||
assert!(
|
||||
receiver
|
||||
.handle
|
||||
.device_by_fingerprint(&announcer.fingerprint)
|
||||
.is_some(),
|
||||
"the announced device must be stored"
|
||||
);
|
||||
assert!(
|
||||
announcer
|
||||
.handle
|
||||
.device_by_fingerprint(&receiver.fingerprint)
|
||||
.is_none(),
|
||||
"answering over HTTP must not make the receiver appear on the announcer's side"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user