fix: only show devices if /register succeed
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled

This commit is contained in:
Tien Do Nam
2026-07-28 20:18:03 +02:00
parent 20f7849aa5
commit 275b399e7c
4 changed files with 106 additions and 29 deletions
@@ -3,11 +3,45 @@ use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, Server
use rustls::client::WebPkiServerVerifier; use rustls::client::WebPkiServerVerifier;
use rustls::pki_types::pem::PemObject; use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{CertificateError, DigitallySignedStruct, Error, RootCertStore, SignatureScheme}; use rustls::{
use std::fmt::{Debug, Formatter}; CertificateError, DigitallySignedStruct, Error, OtherError, RootCertStore, SignatureScheme,
};
use std::fmt::{Debug, Display, Formatter};
use std::sync::Arc; use std::sync::Arc;
use x509_parser::nom::AsBytes; use x509_parser::nom::AsBytes;
/// The reason a peer certificate was rejected, as an error that rustls carries
/// along.
///
/// Without this, every check in this file collapses into rustls'
/// [`CertificateError::ApplicationVerificationFailure`], which surfaces in the
/// app as a bare "error sending request for url (...)" with no hint that the
/// certificate was the problem.
struct RejectedCert(String);
impl Display for RejectedCert {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// rustls renders `InvalidCertificate` with `Debug`, so the derived
/// `RejectedCert("…")` would end up in the message.
impl Debug for RejectedCert {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for RejectedCert {}
fn rejected(reason: String) -> Error {
tracing::warn!("{reason}");
Error::InvalidCertificate(CertificateError::Other(OtherError(Arc::new(RejectedCert(
reason,
)))))
}
/// Verifies the certificate of the remote peer during the TLS handshake. /// Verifies the certificate of the remote peer during the TLS handshake.
/// ///
/// LocalSend peers use self-signed certificates, so there is no authority to /// LocalSend peers use self-signed certificates, so there is no authority to
@@ -65,20 +99,15 @@ impl ServerCertVerifier for PinnedServerCertVerifier {
// The hostname is deliberately ignored: peers are addressed by IP and // The hostname is deliberately ignored: peers are addressed by IP and
// their certificates carry no matching SAN. The fingerprint below is // their certificates carry no matching SAN. The fingerprint below is
// what identifies the peer. // what identifies the peer.
crate::crypto::cert::verify_cert_from_der(end_entity.as_bytes(), None).map_err(|e| { crate::crypto::cert::verify_cert_from_der(end_entity.as_bytes(), None)
tracing::warn!("Server certificate verification failed: {e:#}"); .map_err(|e| rejected(format!("server certificate is not valid: {e:#}")))?;
Error::InvalidCertificate(CertificateError::ApplicationVerificationFailure)
})?;
if let Some(expected) = &self.expected_fingerprint { if let Some(expected) = &self.expected_fingerprint {
let actual = fingerprint_from_cert_der(end_entity.as_bytes()); let actual = fingerprint_from_cert_der(end_entity.as_bytes());
if &actual != expected { if &actual != expected {
tracing::warn!( return Err(rejected(format!(
"Server certificate fingerprint mismatch: expected {expected}, got {actual}" "server certificate fingerprint mismatch: expected {expected}, got {actual}"
); )));
return Err(Error::InvalidCertificate(
CertificateError::ApplicationVerificationFailure,
));
} }
} }
@@ -167,7 +196,11 @@ VRus1zGVD8IVpIdPMyz01WJyS7M0fWaHXKWo+Bo=
#[test] #[test]
fn rejects_mismatching_fingerprint() { fn rejects_mismatching_fingerprint() {
let other = "0".repeat(64); let other = "0".repeat(64);
assert!(verify(Some(&other)).is_err()); let error = verify(Some(&other)).unwrap_err();
let message = error.to_string();
assert!(message.contains("fingerprint mismatch"), "{message}");
assert!(message.contains(&other), "{message}");
assert!(message.contains(FINGERPRINT), "{message}");
} }
#[test] #[test]
@@ -14,10 +14,15 @@ class HttpClientCollection {
/// on that address. /// on that address.
final RsHttpClient discovery; final RsHttpClient discovery;
/// The request timeout of the [discovery] client.
/// Also apply it to short-lived pinned requests made during discovery.
final int discoveryTimeout;
HttpClientCollection({ HttpClientCollection({
required String privateKey, required String privateKey,
required String certificate, required String certificate,
required this.discovery, required this.discovery,
required this.discoveryTimeout,
}) : _privateKey = privateKey, }) : _privateKey = privateKey,
_certificate = certificate; _certificate = certificate;
@@ -27,12 +32,17 @@ class HttpClientCollection {
/// The check happens during the TLS handshake, so a different peer never /// The check happens during the TLS handshake, so a different peer never
/// receives the request. Create one per upload task and reuse it for all /// receives the request. Create one per upload task and reuse it for all
/// files of that task, so the connection is kept alive between them. /// files of that task, so the connection is kept alive between them.
RsHttpClient pinnedTo(String fingerprint) { ///
/// [timeoutMs] bounds each request. Leave it out for uploads, which may
/// legitimately run for a long time; set it when the peer is unverified and
/// could keep the request open forever.
RsHttpClient pinnedTo(String fingerprint, {int? timeoutMs}) {
return createClient( return createClient(
privateKey: _privateKey, privateKey: _privateKey,
cert: _certificate, cert: _certificate,
version: LsHttpClientVersion.v2, version: LsHttpClientVersion.v2,
expectedFingerprint: fingerprint, expectedFingerprint: fingerprint,
timeoutMs: timeoutMs,
); );
} }
} }
@@ -51,5 +61,6 @@ final httpProvider = ViewProvider((ref) {
expectedFingerprint: null, expectedFingerprint: null,
timeoutMs: discoveryTimeout, timeoutMs: discoveryTimeout,
), ),
discoveryTimeout: discoveryTimeout,
); );
}); });
@@ -26,16 +26,22 @@ class MulticastService {
bool _listening = false; bool _listening = false;
/// Binds the UDP sockets and listens to multicast announcements. /// Binds the UDP sockets and listens to multicast announcements.
/// Announcements of other devices are answered with an HTTP register Stream<Device> startListener() {
/// request while the server is running.
Stream<Device> startListener() async* {
if (_listening) { if (_listening) {
_logger.info('Already listening to multicast'); _logger.info('Already listening to multicast');
return; return const Stream.empty();
} }
_listening = true; _listening = true;
// Verified devices are pushed in as [_answerAnnouncement] resolves, which
// runs concurrently: one unreachable peer must not hold up the others.
final devices = StreamController<Device>();
unawaited(_runListener(devices));
return devices.stream;
}
Future<void> _runListener(StreamController<Device> devices) async {
while (true) { while (true) {
final syncState = _ref.read(syncProvider); final syncState = _ref.read(syncProvider);
@@ -68,13 +74,17 @@ class MulticastService {
unawaited(multicast.announce()); unawaited(multicast.announce());
await for (final event in multicast.listen()) { await for (final event in multicast.listen()) {
final device = event.message.toDevice(event.ip); if (!_ref.read(syncProvider).serverRunning) {
yield device;
if (_ref.read(syncProvider).serverRunning) {
// only respond when server is running // only respond when server is running
unawaited(_answerAnnouncement(device)); continue;
} }
unawaited(() async {
final device = await _answerAnnouncement(event.message.toDevice(event.ip));
if (device != null && !devices.isClosed) {
devices.add(device);
}
}());
} }
// The stream ended because [restartListener] stopped the discovery. // The stream ended because [restartListener] stopped the discovery.
@@ -106,21 +116,28 @@ class MulticastService {
await multicast.announce(); await multicast.announce();
} }
/// Responds to an announcement over HTTP. /// Responds to an announcement and returns the peer.
Future<void> _answerAnnouncement(Device peer) async { /// Data from the multicast announcement is ignored.
///
/// Returns null if the peer could not be reached or could not be verified.
Future<Device?> _answerAnnouncement(Device peer) async {
final clients = _ref.read(httpProvider);
try { try {
await _ref final response = await clients
.read(httpProvider) .pinnedTo(peer.fingerprint, timeoutMs: clients.discoveryTimeout)
.discovery
.register( .register(
protocol: peer.getProtocolType(), protocol: peer.getProtocolType(),
ip: peer.ip!, ip: peer.ip!,
port: peer.port, port: peer.port,
payload: _ref.read(syncProvider).toRegisterDto(), payload: _ref.read(syncProvider).toRegisterDto(),
); );
_logger.info('Respond to announcement of ${peer.alias} (${peer.ip}, model: ${peer.deviceModel}) via TCP'); _logger.info('Respond to announcement of ${peer.alias} (${peer.ip}, model: ${peer.deviceModel}) via TCP');
return response.body.toDevice(peer.ip!, peer.port, peer.https, const MulticastDiscovery());
} catch (e) { } catch (e) {
_logger.warning('Could not respond to announcement of ${peer.alias} (${peer.ip})', e); _logger.warning('Could not respond to announcement of ${peer.alias} (${peer.ip})', e);
return null;
} }
} }
} }
@@ -7,6 +7,7 @@ pub use localsend::http::dto::{
PrepareUploadRequestDto, PrepareUploadResponseDto, PrepareUploadResult, ProtocolType, PrepareUploadRequestDto, PrepareUploadResponseDto, PrepareUploadResult, ProtocolType,
RegisterDto, RegisterResponseDto, RegisterDto, RegisterResponseDto,
}; };
use localsend::reqwest;
pub struct RsHttpClient { pub struct RsHttpClient {
inner: localsend::http::client::LsHttpClient, inner: localsend::http::client::LsHttpClient,
@@ -195,7 +196,7 @@ impl From<ClientError> for RsHttpClientError {
status: e.status, status: e.status,
message: e.message, message: e.message,
}, },
ClientError::Reqwest(e) => RsHttpClientError::Reqwest(e.to_string()), ClientError::Reqwest(e) => RsHttpClientError::Reqwest(error_chain(&e)),
ClientError::Json(e) => RsHttpClientError::Json(e.to_string()), ClientError::Json(e) => RsHttpClientError::Json(e.to_string()),
ClientError::Io(e) => RsHttpClientError::Io(e.to_string()), ClientError::Io(e) => RsHttpClientError::Io(e.to_string()),
ClientError::Other(e) => RsHttpClientError::Other(e.to_string()), ClientError::Other(e) => RsHttpClientError::Other(e.to_string()),
@@ -204,6 +205,21 @@ impl From<ClientError> for RsHttpClientError {
} }
} }
/// Renders an error together with everything that caused it.
///
/// [`reqwest::Error`] alone only says "error sending request for url (...)".
pub fn error_chain(e: &dyn std::error::Error) -> String {
use std::fmt::Write;
let mut message = e.to_string();
let mut source = e.source();
while let Some(current) = source {
let _ = write!(message, ": {current}");
source = current.source();
}
message
}
#[frb(mirror(LsHttpClientVersion))] #[frb(mirror(LsHttpClientVersion))]
pub enum _LsHttpClientVersion { pub enum _LsHttpClientVersion {
V2, V2,