feat: firewalled peer connections via server-brokered connect

Implement the FIREWALLED connection mode: when a direct outbound connection to
a peer fails (peer unreachable), fall back to asking the server to broker it.

- Add MessageFactory::build_connect_to_peer (server code 18).
- A pre-established outbound failure now emits ClientOperation::PeerConnectFailed
  instead of a normal disconnect; the client registers a correlation token and
  sends ConnectToPeer so the peer connects back.
- The listener accepts the peer's PierceFirewall (peer code 0) connect-back,
  matches the pending token, registers it as the P control connection, and
  fires PeerConnected to flush the queued download.

Fix a latent aliasing bug this surfaced: a peer actor could emit two terminal
notifications (a connect-failure, then a disconnect when later replaced and
stopped), and the second removed the *newer* actor registered under the same
username, killing the brokered connection mid-transfer. An actor now reports a
terminal outcome at most once (disconnect_reported guard).

Verified end to end against soulfind: the mock advertises an unlistened port so
the direct connect is refused, the server brokers the connection, the mock
pierces back, and the file transfers — bytes match. A companion test
(soulfind_brokers_connect_to_peer_between_users) pins the server behaviour the
mode relies on.
This commit is contained in:
michel
2026-07-19 17:26:52 +02:00
parent e4093b1ea8
commit b82b03a143
5 changed files with 427 additions and 5 deletions
+33 -3
View File
@@ -53,6 +53,14 @@ pub struct PeerActor {
/// True when we initiated this connection (no stream supplied at
/// construction), so we must send a `PeerInit` once connected.
outbound: bool,
/// Set once the connection is up. A pre-established failure on an outbound
/// connection means the peer is unreachable (likely firewalled).
established: bool,
/// Set once this actor has reported a terminal outcome (disconnect or
/// connect-failure). Guards against a second notification — e.g. when a
/// replaced actor is later stopped — which would otherwise alias and evict
/// a newer actor registered under the same username.
disconnect_reported: bool,
}
impl PeerActor {
@@ -83,6 +91,8 @@ impl PeerActor {
queued_messages: Vec::new(),
own_username,
outbound,
established: false,
disconnect_reported: false,
}
}
@@ -429,9 +439,22 @@ impl PeerActor {
self.stream.take();
if let Err(e) = self.client_channel.send(
ClientOperation::PeerDisconnected(username, Some(error.into())),
) {
if self.disconnect_reported {
return;
}
self.disconnect_reported = true;
// A direct outbound connection that never established means the peer is
// unreachable (likely firewalled): signal a connect failure so the
// client can fall back to server-brokered connect. Anything else is a
// normal disconnect.
let op = if self.outbound && !self.established {
ClientOperation::PeerConnectFailed(username)
} else {
ClientOperation::PeerDisconnected(username, Some(error.into()))
};
if let Err(e) = self.client_channel.send(op) {
error!("Failed to send disconnect notification: {}", e);
}
}
@@ -441,6 +464,11 @@ impl PeerActor {
self.stream.take();
if self.disconnect_reported {
return;
}
self.disconnect_reported = true;
if let Err(e) = self
.client_channel
.send(ClientOperation::PeerDisconnected(username, None))
@@ -552,6 +580,8 @@ impl PeerActor {
}
};
self.established = true;
let Some(ref mut stream) = self.stream else {
return;
};
+61 -1
View File
@@ -18,7 +18,7 @@ use std::{
net::TcpStream,
sync::{
RwLock,
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicU32, Ordering},
mpsc::{Receiver, Sender},
},
thread::{self, sleep},
@@ -31,6 +31,13 @@ use std::{
use crate::{debug, error, info, trace, warn};
const DEFAULT_LISTEN_PORT: u16 = 2234;
/// Source of non-zero correlation tokens for server-brokered connections.
static NEXT_CONNECT_TOKEN: AtomicU32 = AtomicU32::new(1);
fn next_connect_token() -> u32 {
NEXT_CONNECT_TOKEN.fetch_add(1, Ordering::Relaxed).max(1)
}
#[derive(Debug, Clone)]
pub struct ClientSettings {
pub username: String,
@@ -98,6 +105,10 @@ pub enum ClientOperation {
SetServerSender(Sender<ServerMessage>),
PrivateMessageReceived(UserMessage),
PeerConnected(String),
/// A direct outbound connection to this peer failed before it was
/// established — the peer is likely firewalled, so fall back to asking the
/// server to broker the connection.
PeerConnectFailed(String),
}
pub struct ClientContext {
pub peer_registry: Option<PeerRegistry>,
@@ -106,6 +117,9 @@ pub struct ClientContext {
server_sender: Option<Sender<ServerMessage>>,
searches: HashMap<String, Search>,
private_messages: Vec<UserMessage>,
/// Correlation tokens for server-brokered (firewalled) connections, mapping
/// a token we sent in a ConnectToPeer to the peer we expect back.
pending_connect_tokens: HashMap<u32, String>,
actor_system: Arc<ActorSystem>,
}
impl Default for ClientContext {
@@ -333,11 +347,23 @@ impl ClientContext {
server_sender: None,
searches: HashMap::new(),
private_messages: Vec::new(),
pending_connect_tokens: HashMap::new(),
downloads: DownloadStore::new(),
actor_system,
}
}
/// Remember that a server-brokered connection to `username` is pending under
/// `token`; the peer will quote it back in a PierceFirewall.
pub fn add_pending_connect(&mut self, token: u32, username: String) {
self.pending_connect_tokens.insert(token, username);
}
/// Resolve and consume the peer expected for a brokered connection `token`.
pub fn take_pending_connect(&mut self, token: u32) -> Option<String> {
self.pending_connect_tokens.remove(&token)
}
/// Record a private message received from another user.
pub fn push_private_message(&mut self, message: UserMessage) {
self.private_messages.push(message);
@@ -1277,6 +1303,40 @@ impl Client {
}
}
}
ClientOperation::PeerConnectFailed(username) => {
// Direct connect failed: ask the server to
// broker it. Register a correlation token, then
// send ConnectToPeer so the (firewalled) peer
// connects back to our listener quoting it.
let token = next_connect_token();
let server_sender = match client_context
.write_safe()
{
Ok(mut ctx) => {
ctx.add_pending_connect(
token,
username.clone(),
);
ctx.server_sender.clone()
}
Err(e) => {
error!(
"[client] PeerConnectFailed write: {}",
e
);
continue;
}
};
if let Some(sender) = server_sender {
let msg = crate::message::server::MessageFactory::build_connect_to_peer(
token,
&username,
ConnectionType::P,
);
let _ = sender
.send(ServerMessage::SendMessage(msg));
}
}
}
}
Err(e) => {
@@ -80,6 +80,22 @@ impl MessageFactory {
Message::new().write_int32(23).write_int32(id).clone()
}
/// Ask the server (code 18) to broker a connection to a firewalled peer:
/// the server tells that peer to connect back to us, quoting `token`.
#[must_use]
pub fn build_connect_to_peer(
token: u32,
username: &str,
connection_type: ConnectionType,
) -> Message {
Message::new()
.write_int32(18)
.write_int32(token)
.write_string(username)
.write_string(&connection_type.to_string())
.clone()
}
#[must_use]
pub fn build_set_status_message(status_code: u32) -> Message {
Message::new()
+57 -1
View File
@@ -16,7 +16,6 @@ const PEER_INIT_MESSAGE_CODE: u8 = 1;
#[derive(Clone)]
struct ConnectionContext {
#[allow(dead_code)]
client_sender: Sender<ClientOperation>,
client_context: Arc<RwLock<ClientContext>>,
own_username: String,
@@ -227,6 +226,54 @@ fn handle_file_connection(
}
}
/// Handle a `PierceFirewall` (peer init code 0): a peer we asked the server to
/// broker (because a direct connection failed) connecting back to us. It quotes
/// the correlation token we registered; we register the stream as its P control
/// connection and tell the client the connection is live.
fn handle_pierce_firewall(
mut message: Message,
stream: TcpStream,
reader: MessageReader,
context: &ConnectionContext,
peer_ip: &str,
peer_port: u16,
) {
message.set_pointer(5); // skip length prefix (4) + int8 code (1)
let token = message.read_int32();
let username = match context.client_context.write_safe() {
Ok(mut ctx) => ctx.take_pending_connect(token),
Err(e) => {
error!("[listener] pierce firewall lock: {}", e);
return;
}
};
let Some(username) = username else {
debug!(
"[listener:{peer_ip}:{peer_port}] PierceFirewall token {token} is not pending; ignoring"
);
return;
};
let peer = Peer::new(
username.clone(),
ConnectionType::P,
peer_ip.to_string(),
peer_port.into(),
None,
0,
0,
0,
);
handle_peer_connection(peer, stream, reader, context, peer_ip, peer_port);
// Inbound peers don't self-announce, so nudge the client to flush any
// downloads queued for this now-connected peer.
let _ = context
.client_sender
.send(ClientOperation::PeerConnected(username));
}
fn handle_incoming_connection(stream: TcpStream, context: ConnectionContext) {
let Ok(peer_addr) = stream.peer_addr() else {
error!("[listener] failed to get peer address");
@@ -245,6 +292,15 @@ fn handle_incoming_connection(stream: TcpStream, context: ConnectionContext) {
return;
};
// A firewalled peer brokered through the server connects back with a
// PierceFirewall (code 0) instead of a PeerInit (code 1).
if message.get_message_code() == 0 {
handle_pierce_firewall(
message, stream, reader, &context, &peer_ip, peer_port,
);
return;
}
let Some(init_data) = parse_peer_init_message(message) else {
error!(
"[listener:{peer_ip}:{peer_port}] Invalid or unknown peer init message"