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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user