Add TCP transport implementation and test harness support

Implement TCP transport for FIPS enabling firewall traversal and serving
as the foundation for future Tor transport. This is the first
connection-oriented transport in the system.

Key design decisions:
- FMP header-based framing: reuses existing 4-byte FMP common prefix for
  packet boundary recovery with zero framing overhead
- Session survives TCP reconnection: Noise/MMP/FSP state bound to npub,
  not TCP connection; MMP liveness is sole authority for peer death
- Connect-on-send: fresh connection on first send, transparent reconnect
- close_connection() trait method for cross-connection deduplication cleanup

New transport files:
- src/transport/tcp/mod.rs: TcpTransport, connection pool, accept loop
- src/transport/tcp/stream.rs: FMP-aware stream reader (shared with Tor)

Modified: transport trait (close_connection), TcpConfig, TransportHandle
match arms, create_transports(), initiate_connection() for connection-
oriented links, cross-connection tie-breaker cleanup, design docs.

Tree announce loop and TCP stability fixes:
- Preserve tree announce rate-limit state across reconnection: carry
  forward last_tree_announce_sent_ms when a peer reconnects so the
  rate-limit window isn't reset to zero
- Drop oversize TCP packets at sender: pre-send MTU check returns
  MtuExceeded instead of writing to the stream, preventing receiver-side
  connection teardown and reset-reconnect cycles

Chaos harness:
- TCP transport support: tcp_edges/has_tcp/tcp_peers in SimTopology,
  transport-aware config_gen with per-edge transport type, TCP port 443,
  pure-TCP node support
- Include all non-Ethernet edges in directed_outbound()
- Fix netem/links log messages to say "IP-based" instead of "UDP"
- Add tcp-chain, tcp-only, and tcp-mesh scenario files

Static harness:
- Transport-aware config generation (get_default_transport, transport_port)
- TCP transport injection via Python post-processing
- Add tcp-chain topology and docker-compose profile
This commit is contained in:
Johnathan Corgan
2026-02-27 00:41:37 +00:00
parent c48b7aec5a
commit ec64a0dce1
22 changed files with 2171 additions and 63 deletions
+41
View File
@@ -280,6 +280,14 @@ impl Node {
if let Some(peer) = self.peers.get_mut(&node_addr) {
peer.set_handshake_msg2(wire_msg2.clone());
}
// Close the losing TCP connection (no-op for connectionless)
if let Some(loser_link) = self.links.get(&loser_link_id) {
let loser_tid = loser_link.transport_id();
let loser_addr = loser_link.remote_addr().clone();
if let Some(transport) = self.transports.get(&loser_tid) {
transport.close_connection(&loser_addr).await;
}
}
// Clean up the losing connection's link
self.remove_link(&loser_link_id);
info!(
@@ -295,6 +303,10 @@ impl Node {
self.bloom_state.mark_update_needed(node_addr);
}
PromotionResult::CrossConnectionLost { winner_link_id } => {
// Close the losing TCP connection (no-op for connectionless)
if let Some(transport) = self.transports.get(&packet.transport_id) {
transport.close_connection(&packet.remote_addr).await;
}
// This connection lost — clean up its link
self.remove_link(&link_id);
// Restore addr_to_link for the winner's link
@@ -519,6 +531,14 @@ impl Node {
// Clean up outbound connection state
self.pending_outbound.remove(&key);
// Close the losing TCP connection (no-op for connectionless)
if let Some(link) = self.links.get(&link_id) {
let tid = link.transport_id();
let addr = link.remote_addr().clone();
if let Some(transport) = self.transports.get(&tid) {
transport.close_connection(&addr).await;
}
}
self.remove_link(&link_id);
// Send TreeAnnounce now that sessions are aligned
@@ -550,6 +570,14 @@ impl Node {
self.bloom_state.mark_update_needed(node_addr);
}
PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => {
// Close the losing TCP connection (no-op for connectionless)
if let Some(loser_link) = self.links.get(&loser_link_id) {
let loser_tid = loser_link.transport_id();
let loser_addr = loser_link.remote_addr().clone();
if let Some(transport) = self.transports.get(&loser_tid) {
transport.close_connection(&loser_addr).await;
}
}
// Clean up the losing connection's link
self.remove_link(&loser_link_id);
// Ensure addr_to_link points to the winning link
@@ -570,6 +598,10 @@ impl Node {
self.bloom_state.mark_update_needed(node_addr);
}
PromotionResult::CrossConnectionLost { winner_link_id } => {
// Close the losing TCP connection (no-op for connectionless)
if let Some(transport) = self.transports.get(&packet.transport_id) {
transport.close_connection(&packet.remote_addr).await;
}
// This connection lost — clean up its link
self.remove_link(&link_id);
// Ensure addr_to_link points to the winner's link
@@ -756,6 +788,12 @@ impl Node {
return Err(NodeError::MaxPeersExceeded { max: self.max_peers });
}
// Preserve tree announce rate-limit state from old peer (if reconnecting).
// Without this, reconnection resets the rate limit window to zero,
// allowing an immediate announce that can feed an announce loop.
let old_announce_ts = self.peers.get(&peer_node_addr)
.map(|p| p.last_tree_announce_sent_ms());
let mut new_peer = ActivePeer::with_session(
verified_identity,
link_id,
@@ -771,6 +809,9 @@ impl Node {
remote_epoch,
);
new_peer.set_tree_announce_min_interval_ms(self.config.node.tree.announce_min_interval_ms);
if let Some(ts) = old_announce_ts {
new_peer.set_last_tree_announce_sent_ms(ts);
}
self.peers.insert(peer_node_addr, new_peer);
self.peers_by_index
+22 -7
View File
@@ -157,13 +157,28 @@ impl Node {
// Allocate link ID and create link
let link_id = self.allocate_link_id();
let link = Link::connectionless(
link_id,
transport_id,
remote_addr.clone(),
LinkDirection::Outbound,
Duration::from_millis(self.config.node.base_rtt_ms),
);
// Use Link::new() for connection-oriented transports (Connecting state)
// and Link::connectionless() for connectionless transports (Connected state)
let link = if self.transports.get(&transport_id)
.map(|t| t.transport_type().connection_oriented)
.unwrap_or(false)
{
Link::new(
link_id,
transport_id,
remote_addr.clone(),
LinkDirection::Outbound,
Duration::from_millis(self.config.node.base_rtt_ms),
)
} else {
Link::connectionless(
link_id,
transport_id,
remote_addr.clone(),
LinkDirection::Outbound,
Duration::from_millis(self.config.node.base_rtt_ms),
)
};
self.links.insert(link_id, link);
+16
View File
@@ -28,6 +28,7 @@ use crate::transport::{
Link, LinkId, PacketRx, PacketTx, TransportAddr, TransportError, TransportHandle, TransportId,
};
use crate::transport::udp::UdpTransport;
use crate::transport::tcp::TcpTransport;
#[cfg(target_os = "linux")]
use crate::transport::ethernet::EthernetTransport;
use crate::tree::TreeState;
@@ -598,6 +599,21 @@ impl Node {
}
}
// Create TCP transport instances
let tcp_instances: Vec<_> = self
.config
.transports
.tcp
.iter()
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
.collect();
for (name, tcp_config) in tcp_instances {
let transport_id = self.allocate_transport_id();
let tcp = TcpTransport::new(transport_id, name, tcp_config, packet_tx.clone());
transports.push(TransportHandle::Tcp(tcp));
}
transports
}