Files
ferrix/crates/integration-tests/tests/dialer.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 fd496d26bf feature: run on the router itself: protoc-free build, per-peer source address
Preparing to run ferrix next to BIRD on the production router.

- build: the config proto is now parsed in pure Rust, so building needs no
  `protoc` binary. It previously worked only where one happened to be
  installed; the Dockerfile in this repo could not have built.
- peers gain `local_address`, the source IP for connections we open (and
  therefore the next hop we advertise). When both speakers share a host
  the OS would otherwise pick an arbitrary local address and the peer,
  which identifies us by address, would refuse the session. Address-family
  mismatches are rejected at config load; an address the host lacks shows
  up as the peer's backoff reason.
- docs/lab/prod/on-router: static musl cross-build instructions, config,
  hardened systemd unit (unprivileged, SIGTERM gives BIRD a Cease),
  loopback addresses for networkd, the BIRD stanza for a same-host
  multihop session, and reverse-proxy examples with TLS and a password,
  since the API has no authentication.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 18:15:09 +02:00

225 lines
8.4 KiB
Rust

//! The active side: the server dials its configured peers and keeps
//! retrying.
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::errors::{BgpError, OpenMsgSubcode};
use bgp_packet::message::Message;
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRoute, LocalRouteAttrs};
use bgp_server::config::PeerConfig;
use bgp_server::session::DialState;
use eyre::Result;
use tokio::net::TcpListener;
const LOCAL_ASN: u32 = 65000;
const PEER_ASN: u32 = 65001;
const TIMEOUT: Duration = Duration::from_secs(5);
/// Starts a server configured to dial `listener`'s address as peer "lab".
async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestServer> {
let endpoint: SocketAddr = listener.local_addr()?;
TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig { name: "lab".into(), endpoint, remote_asn, hold_time_secs: 90, local_address: None }];
})
.await
}
async fn accept(listener: &TcpListener) -> Result<TestPeer> {
let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
Ok(TestPeer::from_stream(stream))
}
#[tokio::test]
async fn test_server_dials_configured_peer_and_establishes() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let server = server_dialing(&listener, PEER_ASN).await?;
let mut peer = accept(&listener).await?;
// Both sides send OPEN on connect; the test peer's handshake order works
// whichever side opened TCP.
let open = peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
assert_eq!(open.asn, LOCAL_ASN as u16);
let mut events = server.registry.subscribe();
// Either the Up event is still queued or the session is already listed.
let listed = tokio::time::timeout(TIMEOUT, async {
loop {
if let Some(s) = server.registry.list().first().cloned() {
return s;
}
let _ = events.recv().await;
}
})
.await?;
assert_eq!(listed.name.as_deref(), Some("lab"));
assert_eq!(listed.peer.asn, PEER_ASN);
assert_eq!(listed.peer.addr, listener.local_addr()?);
// Configured view reflects it; the dialer is not in backoff.
let (cfg, state) = server.registry.configured_peers().into_iter().next().unwrap();
assert_eq!(cfg.name, "lab");
assert!(matches!(state, DialState::Connecting | DialState::Idle), "{state:?}");
// Local routes flow over the dialled session too.
server
.local_rib
.announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, "alice", LocalRouteAttrs::default()))
.await?;
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24");
Ok(())
}
#[tokio::test]
async fn test_server_reconnects_after_peer_closes() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let server = server_dialing(&listener, PEER_ASN).await?;
let mut first = accept(&listener).await?;
first.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
drop(first);
// Session goes away, then the dialer comes back.
tokio::time::timeout(TIMEOUT, async {
while !server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
let (_, state) = server.registry.configured_peers().into_iter().next().unwrap();
assert!(matches!(state, DialState::Backoff { .. }), "{state:?}");
let mut second = accept(&listener).await?;
second.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
Ok(())
}
#[tokio::test]
async fn test_wrong_asn_is_refused_with_bad_peer_as() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let _server = server_dialing(&listener, PEER_ASN).await?;
let mut peer = accept(&listener).await?;
peer.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
let _ = peer.recv_open().await?;
let notif = tokio::time::timeout(TIMEOUT, async {
loop {
match peer.recv().await? {
Message::Notification(n) => return Ok::<_, eyre::Error>(n),
Message::KeepAlive => {}
other => eyre::bail!("unexpected {other:?}"),
}
}
})
.await??;
assert!(matches!(notif.error, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs)), "{notif:?}");
// The dialer retries rather than giving up.
let _again = accept(&listener).await?;
Ok(())
}
/// An inbound connection from a configured peer's address is also checked
/// against the configured ASN and gets the configured name.
#[tokio::test]
async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
// Configure a peer at the loopback address on a port nobody listens on,
// so the dialer never succeeds; then connect inbound from loopback.
let server = TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_secs(60);
c.peers = vec![PeerConfig {
name: "lab".into(),
endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1),
remote_asn: PEER_ASN,
hold_time_secs: 90,
local_address: None,
}];
})
.await?;
// Wrong ASN inbound → refused.
let mut bad = TestPeer::connect(server.addr).await?;
bad.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
let _ = bad.recv_open().await?;
let msg = tokio::time::timeout(TIMEOUT, bad.recv()).await??;
assert!(matches!(&msg, Message::Notification(n) if matches!(n.error, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs))), "{msg:?}");
// Right ASN inbound → established, named.
let mut good = TestPeer::connect(server.addr).await?;
good.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
let listed = tokio::time::timeout(TIMEOUT, async {
loop {
if let Some(s) = server.registry.list().first().cloned() {
return s;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
assert_eq!(listed.name.as_deref(), Some("lab"));
Ok(())
}
/// With `local_address` set the connection must come from that address (the
/// peer may identify us by it), and an address the host does not have is
/// reported as the reason for backing off rather than silently ignored.
#[tokio::test]
async fn test_local_address_is_used_as_the_source() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let endpoint: SocketAddr = listener.local_addr()?;
let _server = TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig {
name: "lab".into(),
endpoint,
remote_asn: PEER_ASN,
hold_time_secs: 90,
local_address: Some(Ipv4Addr::LOCALHOST.into()),
}];
})
.await?;
let (_stream, from) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
assert_eq!(from.ip(), std::net::IpAddr::from(Ipv4Addr::LOCALHOST));
// 192.0.2.1 is documentation space; no host has it.
let server = TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig {
name: "lab".into(),
endpoint,
remote_asn: PEER_ASN,
hold_time_secs: 90,
local_address: Some(Ipv4Addr::new(192, 0, 2, 1).into()),
}];
})
.await?;
let reason = tokio::time::timeout(TIMEOUT, async {
loop {
if let Some((_, DialState::Backoff { last_error, .. })) = server.registry.configured_peers().into_iter().next() {
return last_error;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await?;
assert!(reason.contains("local_address 192.0.2.1"), "{reason}");
Ok(())
}