Files
lanspread/crates/lanspread-peer/src/network.rs
T
ddidderr 348a02c35f fix(peer): record listener addresses during handshakes
Peers discovered over mDNS could still attribute later library sync traffic to
temporary QUIC source ports. In a real GUI LAN run this made Host B try to
push its library to Host A's outbound port instead of Host A's advertised
listener, so Host A discovered the peer but never saw its games.

Carry the stable listener address in Hello and HelloAck, and key library sync
messages by peer_id instead of inferring identity from the transport source
address. The handshake path now explicitly refreshes an empty peer library from
the known listener address, matching the reliability of the direct-connect CLI
path without overwriting richer snapshot state when it already arrived.

This changes the current wire protocol, so PROTOCOL_VERSION is bumped to 3 and
all peers must be rebuilt together. The architecture note now documents that
listener addresses come from mDNS or Hello/HelloAck, never from ephemeral QUIC
source ports.

Test Plan:
- just fmt
- just test
- just clippy
- just build
- git diff --check

Refs: Local Linux/Win11 GUI LAN test logs from 2026-05-18.
2026-05-18 17:27:15 +02:00

334 lines
9.7 KiB
Rust

//! Network utilities for QUIC connections and peer communication.
use std::{
net::{IpAddr, SocketAddr},
time::Duration,
};
use bytes::BytesMut;
use futures::{SinkExt, StreamExt};
use if_addrs::{IfAddr, Interface, get_if_addrs};
use lanspread_db::db::{Game, GameFileDescription};
use lanspread_proto::{
Hello,
HelloAck,
LibraryDelta,
LibrarySnapshot,
LibrarySummary,
Message,
Request,
Response,
};
use s2n_quic::{Client as QuicClient, Connection, client::Connect, provider::limits::Limits};
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
use crate::config::CERT_PEM;
/// Establishes a QUIC connection to a peer.
pub async fn connect_to_peer(addr: SocketAddr) -> eyre::Result<Connection> {
let limits = Limits::default().with_max_handshake_duration(Duration::from_secs(3))?;
let client = QuicClient::builder()
.with_tls(CERT_PEM)?
.with_io("0.0.0.0:0")?
.with_limits(limits)?
.start()?;
let conn = Connect::new(addr).with_server_name("localhost");
let conn = client.connect(conn).await?;
Ok(conn)
}
/// Performs an initial ping check to verify peer is alive.
pub async fn initial_peer_alive_check(conn: &mut Connection) -> bool {
let remote_addr = conn.remote_addr().ok();
let stream = match conn.open_bidirectional_stream().await {
Ok(stream) => stream,
Err(e) => {
log::error!("{remote_addr:?} failed to open stream: {e}");
return false;
}
};
let (rx, tx) = stream.split();
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
// send ping
if let Err(e) = framed_tx.send(Request::Ping.encode()).await {
log::error!("{remote_addr:?} failed to send ping to peer: {e}");
return false;
}
let _ = framed_tx.close().await;
// receive pong
if let Some(Ok(response_bytes)) = framed_rx.next().await {
let response = Response::decode(response_bytes.freeze());
match response {
Response::Pong => {
log::trace!("{remote_addr:?} peer is alive");
return true;
}
_ => {
log::error!("{remote_addr:?} peer sent invalid response to ping: {response:?}");
}
}
}
false
}
/// Pings a peer to check if it's alive.
pub async fn ping_peer(peer_addr: SocketAddr) -> eyre::Result<bool> {
let mut conn = connect_to_peer(peer_addr).await?;
let is_alive = initial_peer_alive_check(&mut conn).await;
Ok(is_alive)
}
/// Sends a single request without waiting for a response.
pub async fn send_oneway_request(peer_addr: SocketAddr, request: Request) -> eyre::Result<()> {
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
let (_, tx) = stream.split();
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
framed_tx.send(request.encode()).await?;
let _ = framed_tx.close().await;
Ok(())
}
/// Performs a hello/ack handshake with a peer.
pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result<HelloAck> {
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
let (rx, tx) = stream.split();
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
framed_tx.send(Request::Hello(hello).encode()).await?;
let _ = framed_tx.close().await;
let mut data = BytesMut::new();
while let Some(Ok(bytes)) = framed_rx.next().await {
data.extend_from_slice(&bytes);
}
let response = Response::decode(data.freeze());
match response {
Response::HelloAck(ack) => Ok(ack),
other => eyre::bail!("Unexpected response from peer {peer_addr}: {other:?}"),
}
}
pub async fn send_library_summary(
peer_addr: SocketAddr,
peer_id: &str,
summary: LibrarySummary,
) -> eyre::Result<()> {
send_oneway_request(
peer_addr,
Request::LibrarySummary {
peer_id: peer_id.to_string(),
summary,
},
)
.await
}
pub async fn send_library_snapshot(
peer_addr: SocketAddr,
peer_id: &str,
snapshot: LibrarySnapshot,
) -> eyre::Result<()> {
send_oneway_request(
peer_addr,
Request::LibrarySnapshot {
peer_id: peer_id.to_string(),
snapshot,
},
)
.await
}
pub async fn send_library_delta(
peer_addr: SocketAddr,
peer_id: &str,
delta: LibraryDelta,
) -> eyre::Result<()> {
send_oneway_request(
peer_addr,
Request::LibraryDelta {
peer_id: peer_id.to_string(),
delta,
},
)
.await
}
pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Result<()> {
send_oneway_request(peer_addr, Request::Goodbye { peer_id }).await
}
/// Requests the current game list from a peer.
pub async fn request_game_list_from_peer(peer_addr: SocketAddr) -> eyre::Result<Vec<Game>> {
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
let (rx, tx) = stream.split();
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
framed_tx.send(Request::ListGames.encode()).await?;
framed_tx.close().await?;
let mut data = BytesMut::new();
while let Some(Ok(bytes)) = framed_rx.next().await {
data.extend_from_slice(&bytes);
}
let response = Response::decode(data.freeze());
match response {
Response::ListGames(games) => Ok(games),
Response::InternalPeerError(error_msg) => {
eyre::bail!("peer {peer_addr} reported internal error: {error_msg}")
}
other => eyre::bail!("unexpected response from {peer_addr}: {other:?}"),
}
}
/// Requests game file details from a peer.
pub async fn request_game_details_from_peer(
peer_addr: SocketAddr,
game_id: &str,
) -> eyre::Result<(Vec<GameFileDescription>, Response)> {
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
let (rx, tx) = stream.split();
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
framed_tx
.send(
Request::GetGame {
id: game_id.to_string(),
}
.encode(),
)
.await?;
framed_tx.close().await?;
let mut data = BytesMut::new();
while let Some(Ok(bytes)) = framed_rx.next().await {
data.extend_from_slice(&bytes);
}
let response = Response::decode(data.freeze());
match &response {
Response::GetGame {
id,
file_descriptions,
} => {
if id != game_id {
eyre::bail!("peer {peer_addr} responded with mismatched game id {id}");
}
Ok((file_descriptions.clone(), response))
}
Response::GameNotFound(_) => {
eyre::bail!("peer {peer_addr} does not have game {game_id}")
}
Response::InternalPeerError(error_msg) => {
eyre::bail!("peer {peer_addr} reported internal error: {error_msg}")
}
_ => eyre::bail!("unexpected response from {peer_addr}: {response:?}"),
}
}
// =============================================================================
// IP address selection for mDNS advertisement
// =============================================================================
/// Selects the best IP address to advertise via mDNS.
pub fn select_advertise_ip() -> eyre::Result<IpAddr> {
let mut best_candidate: Option<(u8, IpAddr)> = None;
let mut loopback_fallback = None;
for interface in get_if_addrs()? {
if interface.is_loopback() {
loopback_fallback.get_or_insert(interface.ip());
continue;
}
if let Some(candidate) = classify_interface(&interface)
&& best_candidate
.as_ref()
.is_none_or(|(rank, _)| candidate.0 < *rank)
{
best_candidate = Some(candidate);
}
}
if let Some((_, ip)) = best_candidate {
return Ok(ip);
}
if let Some(ip) = loopback_fallback {
log::warn!(
"No non-loopback interface suitable for mDNS advertisement; falling back to {ip}"
);
return Ok(ip);
}
eyre::bail!("No usable network interface found for mDNS advertisement");
}
/// Classifies a network interface for mDNS advertisement priority.
fn classify_interface(interface: &Interface) -> Option<(u8, IpAddr)> {
match interface.addr {
IfAddr::V4(ref v4) => {
let ip = v4.ip;
if ip.is_unspecified() || ip.is_link_local() {
return None;
}
let mut rank = if ip.is_private() { 0 } else { 2 };
if is_virtual_interface(&interface.name) {
rank += 2;
}
Some((rank, IpAddr::V4(ip)))
}
IfAddr::V6(_) => None,
}
}
/// Checks if an interface name suggests it's a virtual interface.
fn is_virtual_interface(name: &str) -> bool {
const VIRTUAL_HINTS: &[&str] = &[
"awdl",
"br-",
"bridge",
"docker",
"ham",
"llw",
"tap",
"tailscale",
"tun",
"utun",
"vbox",
"veth",
"virbr",
"vmnet",
"wg",
"zt",
];
let lower = name.to_ascii_lowercase();
VIRTUAL_HINTS.iter().any(|hint| lower.contains(hint))
}