From c9751883c45bdb24403fee6d7bb2d44432627730 Mon Sep 17 00:00:00 2001 From: Rayhaan Jaufeerally Date: Tue, 6 Oct 2026 00:05:54 +0200 Subject: [PATCH] Passive peers: configured, and left to connect A session could only be configured for a peer we would then dial, every few seconds to two minutes, for as long as it was not up. A new peer whose side was not ready yet filled the log with failed attempts, and a peer that opens its sessions itself was dialled for nothing. A peer with `passive: true` is not dialled. A connection from its address is taken as for any configured peer, and the session comes up with all that is configured for it: role, import policy, export limits, hold-downs. Until then the status page and /api/peers show it as `Passive`, waiting for the peer to connect; after a session, with how that session ended, which nothing else would say since nobody dials it. Co-Authored-By: Claude Opus 5.5 --- crates/integration-tests/tests/add_path.rs | 1 + crates/integration-tests/tests/admission.rs | 1 + crates/integration-tests/tests/aspa.rs | 1 + crates/integration-tests/tests/collision.rs | 1 + crates/integration-tests/tests/debugging.rs | 1 + crates/integration-tests/tests/dialer.rs | 69 +++++++++++++++++++ .../integration-tests/tests/export_limits.rs | 1 + .../integration-tests/tests/import_policy.rs | 1 + crates/integration-tests/tests/negotiation.rs | 1 + crates/integration-tests/tests/roles.rs | 1 + crates/integration-tests/tests/rpki.rs | 1 + crates/server/proto/config.proto | 7 ++ crates/server/src/config.rs | 16 +++++ crates/server/src/connection.rs | 4 ++ crates/server/src/dialer.rs | 2 + crates/server/src/server.rs | 22 +++++- crates/server/src/session.rs | 17 ++++- crates/web/src/api.rs | 7 +- crates/web/static/statusz.html | 6 +- docs/content/docs/guides/peers.md | 23 +++++++ docs/content/docs/reference/configuration.md | 1 + docs/content/docs/reference/http-api.md | 2 +- 22 files changed, 178 insertions(+), 8 deletions(-) diff --git a/crates/integration-tests/tests/add_path.rs b/crates/integration-tests/tests/add_path.rs index 75057b0..979735a 100644 --- a/crates/integration-tests/tests/add_path.rs +++ b/crates/integration-tests/tests/add_path.rs @@ -43,6 +43,7 @@ async fn server_offering( graceful_restart: Default::default(), groups: Vec::new(), export_limits: None, + passive: false, reflector_client: false, name: "route-server-client".into(), endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1).into(), diff --git a/crates/integration-tests/tests/admission.rs b/crates/integration-tests/tests/admission.rs index ae1cd0d..bfb1a0d 100644 --- a/crates/integration-tests/tests/admission.rs +++ b/crates/integration-tests/tests/admission.rs @@ -33,6 +33,7 @@ fn peer_at(endpoint: SocketAddr) -> PeerConfig { require_role: false, export: Default::default(), export_limits: None, + passive: false, } } diff --git a/crates/integration-tests/tests/aspa.rs b/crates/integration-tests/tests/aspa.rs index 075364e..e7e5070 100644 --- a/crates/integration-tests/tests/aspa.rs +++ b/crates/integration-tests/tests/aspa.rs @@ -60,6 +60,7 @@ async fn server_with( graceful_restart: Default::default(), groups: Vec::new(), export_limits: None, + passive: false, reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), diff --git a/crates/integration-tests/tests/collision.rs b/crates/integration-tests/tests/collision.rs index d6159ea..ccbce61 100644 --- a/crates/integration-tests/tests/collision.rs +++ b/crates/integration-tests/tests/collision.rs @@ -39,6 +39,7 @@ async fn server_dialing(listener: &TcpListener) -> Result { graceful_restart: Default::default(), groups: Vec::new(), export_limits: None, + passive: false, reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), diff --git a/crates/integration-tests/tests/debugging.rs b/crates/integration-tests/tests/debugging.rs index 29f0f24..7da07b3 100644 --- a/crates/integration-tests/tests/debugging.rs +++ b/crates/integration-tests/tests/debugging.rs @@ -170,6 +170,7 @@ async fn test_refused_routes_can_be_seen_and_counted() -> Result<()> { graceful_restart: Default::default(), groups: Vec::new(), export_limits: None, + passive: false, reflector_client: false, name: "refused-demo".into(), endpoint: endpoint.into(), diff --git a/crates/integration-tests/tests/dialer.rs b/crates/integration-tests/tests/dialer.rs index 200f77e..86b85d5 100644 --- a/crates/integration-tests/tests/dialer.rs +++ b/crates/integration-tests/tests/dialer.rs @@ -23,13 +23,25 @@ const TIMEOUT: Duration = Duration::from_secs(5); /// Starts a server configured to dial `listener`'s address as peer "r1". async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result { + server_with_peer(listener, remote_asn, false).await +} + +/// Starts a server with peer "r1" at `listener`'s address, which it dials +/// unless the peer is `passive`. +async fn server_with_peer( + listener: &TcpListener, + remote_asn: u32, + passive: bool, +) -> Result { let endpoint: SocketAddr = listener.local_addr()?; TestServer::start_with(LOCAL_ASN, |c| { c.connect_retry = Duration::from_millis(200); + c.accept_unconfigured_peers = !passive; c.peers = vec![PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), export_limits: None, + passive, reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), @@ -192,6 +204,7 @@ async fn test_inbound_from_configured_address_uses_config() -> Result<()> { graceful_restart: Default::default(), groups: Vec::new(), export_limits: None, + passive: false, reflector_client: false, name: "r1".into(), endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1).into(), @@ -248,6 +261,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> { graceful_restart: Default::default(), groups: Vec::new(), export_limits: None, + passive: false, reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), @@ -273,6 +287,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> { graceful_restart: Default::default(), groups: Vec::new(), export_limits: None, + passive: false, reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), @@ -350,3 +365,57 @@ async fn test_a_peer_that_refuses_the_session_is_dialled_ever_more_slowly() -> R ); Ok(()) } + +/// A passive peer is not dialled: its session comes up when it connects, +/// and until then, and after, it is shown as waiting, with how its last +/// session ended. +#[tokio::test] +async fn test_a_passive_peer_is_not_dialled_and_comes_up_when_it_connects() -> Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let server = server_with_peer(&listener, PEER_ASN, true).await?; + let state = || server.registry.configured_peers()[0].1.clone(); + + // Many retry intervals pass, and nothing connects. + assert!( + tokio::time::timeout(Duration::from_secs(1), listener.accept()) + .await + .is_err(), + "a passive peer was dialled" + ); + assert_eq!(state(), DialState::Passive { last_error: None }); + + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + let listed = tokio::time::timeout(TIMEOUT, async { + loop { + if let Some(session) = server.registry.list().first().cloned() { + return session; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await?; + assert_eq!(listed.name.as_deref(), Some("r1")); + + drop(peer); + tokio::time::timeout(TIMEOUT, async { + while !matches!( + state(), + DialState::Passive { + last_error: Some(_) + } + ) { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await?; + assert!(server.registry.list().is_empty()); + assert!( + tokio::time::timeout(Duration::from_millis(600), listener.accept()) + .await + .is_err(), + "a passive peer was dialled after its session ended" + ); + Ok(()) +} diff --git a/crates/integration-tests/tests/export_limits.rs b/crates/integration-tests/tests/export_limits.rs index 7917b00..c3a9014 100644 --- a/crates/integration-tests/tests/export_limits.rs +++ b/crates/integration-tests/tests/export_limits.rs @@ -54,6 +54,7 @@ async fn server_with(originated: &[&str], limits: ExportLimits) -> Result graceful_restart: Default::default(), groups: Vec::new(), export_limits: Some(Arc::new(limits)), + passive: false, reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), diff --git a/crates/integration-tests/tests/import_policy.rs b/crates/integration-tests/tests/import_policy.rs index b1ffed1..0b357dd 100644 --- a/crates/integration-tests/tests/import_policy.rs +++ b/crates/integration-tests/tests/import_policy.rs @@ -45,6 +45,7 @@ async fn server_with(import: ImportPolicy) -> Result<(TestServer, TcpListener)> graceful_restart: Default::default(), groups: Vec::new(), export_limits: None, + passive: false, reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), diff --git a/crates/integration-tests/tests/negotiation.rs b/crates/integration-tests/tests/negotiation.rs index 9357bfa..6f61a39 100644 --- a/crates/integration-tests/tests/negotiation.rs +++ b/crates/integration-tests/tests/negotiation.rs @@ -186,6 +186,7 @@ async fn test_no_family_in_common_refuses_the_session() -> Result<()> { graceful_restart: Default::default(), groups: Vec::new(), export_limits: None, + passive: false, reflector_client: false, name: "v6-only".into(), endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1).into(), diff --git a/crates/integration-tests/tests/roles.rs b/crates/integration-tests/tests/roles.rs index 26c40e5..14bba9e 100644 --- a/crates/integration-tests/tests/roles.rs +++ b/crates/integration-tests/tests/roles.rs @@ -51,6 +51,7 @@ async fn server_with(role: Option, require_role: bool) -> Result = config.peers.iter().map(|peer| peer.passive).collect(); + assert_eq!(passive, [false, true]); + } } diff --git a/crates/server/src/connection.rs b/crates/server/src/connection.rs index b07f990..82f2757 100644 --- a/crates/server/src/connection.rs +++ b/crates/server/src/connection.rs @@ -124,6 +124,8 @@ pub struct PeerSettings { pub reflector_client: bool, /// Graceful restart with the peer. pub graceful_restart: GracefulRestartConfig, + /// Whether the peer is left to connect to us, and not dialled. + pub passive: bool, } impl PeerSettings { @@ -150,6 +152,7 @@ impl PeerSettings { groups: Arc::default(), reflector_client: false, graceful_restart, + passive: false, } } } @@ -170,6 +173,7 @@ impl From<&PeerConfig> for PeerSettings { groups: peer.groups.as_slice().into(), reflector_client: peer.reflector_client, graceful_restart: peer.graceful_restart, + passive: peer.passive, } } } diff --git a/crates/server/src/dialer.rs b/crates/server/src/dialer.rs index 47605b2..7d037d9 100644 --- a/crates/server/src/dialer.rs +++ b/crates/server/src/dialer.rs @@ -32,6 +32,8 @@ pub fn spawn_dialers( ) -> Vec> { peers .iter() + // A passive peer is left to connect to us. + .filter(|peer| !peer.passive) .map(|peer| tokio::spawn(dial_loop(peer.clone(), Arc::clone(server), connect_retry))) .collect() } diff --git a/crates/server/src/server.rs b/crates/server/src/server.rs index 875c899..11fccce 100644 --- a/crates/server/src/server.rs +++ b/crates/server/src/server.rs @@ -32,7 +32,7 @@ use crate::reach; use crate::refusals::RefusalLog; use crate::restart::{Leave, Restarts}; use crate::rpki::spawn_revalidator; -use crate::session::{ConfiguredPeer, SessionRegistry}; +use crate::session::{ConfiguredPeer, DialState, SessionRegistry}; use crate::sync::IgnorePoison; use crate::throttle::Throttle; @@ -125,6 +125,7 @@ impl BgpServer { endpoint: p.endpoint.clone(), remote_asn: p.remote_asn, role: p.role, + passive: p.passive, }) .collect(); // The client starts now: with luck the data is there before the @@ -304,6 +305,9 @@ impl BgpServer { reach::spawn_revalidator(interfaces, Arc::clone(&self.rib), self.registry.clone()), )); } + for peer in self.config.peers.iter().filter(|peer| peer.passive) { + info!(peer = %peer.name, endpoint = %peer.endpoint, "Waiting for the peer to connect (passive)"); + } for dialer in spawn_dialers(&self.config.peers, &context, self.config.connect_retry) { tasks.push(named("dialer", dialer)); } @@ -476,10 +480,24 @@ async fn serve( peer: PeerSettings, _slot: ConnectionSlot, ) { + // Nobody dials a passive peer, so its state is said here. + let passive = peer.name.clone().filter(|_| peer.passive); + let registry = context.registry.clone(); let end = run_session(stream, peer_addr, Direction::Inbound, context, peer).await; - if let Err(e) = end.result { + if let Err(e) = &end.result { warn!(peer_addr = %socket_text(peer_addr), err = %e, "BGP session terminated with error"); } + if let Some(name) = passive { + registry.set_dial_state( + &name, + DialState::Passive { + last_error: Some(match end.result { + Ok(()) => "session closed at our request".to_string(), + Err(e) => e.to_string(), + }), + }, + ); + } } /// Why an incoming connection was closed without a session. diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs index 0a848ba..9bf57be 100644 --- a/crates/server/src/session.rs +++ b/crates/server/src/session.rs @@ -54,6 +54,12 @@ pub enum DialState { /// How the last attempt or session ended. last_error: String, }, + /// The peer is passive: not dialled, waiting for it to connect. + Passive { + /// How its last session, or the last connection from it, ended, + /// if one did. + last_error: Option, + }, } /// A peer from the configuration file, whether or not it is currently up. @@ -64,6 +70,8 @@ pub struct ConfiguredPeer { pub remote_asn: u32, /// What the peer is to us, if that was said. pub role: Option, + /// Whether it is left to the peer to connect. + pub passive: bool, } /// Counts kept by a session as it goes, for whoever wants to look. @@ -321,7 +329,14 @@ impl SessionRegistry { let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); let dial_states = configured .iter() - .map(|p| (p.name.clone(), DialState::Idle)) + .map(|p| { + let state = if p.passive { + DialState::Passive { last_error: None } + } else { + DialState::Idle + }; + (p.name.clone(), state) + }) .collect(); for peer in &configured { PEER_UP.with_label_values(&[&peer.name]).set(0); diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index aff7add..0830f6c 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -63,7 +63,8 @@ pub struct PeerView { pub name: Option, pub peer_addr: String, pub peer_asn: u32, - /// `Established`, `Connecting`, `Backoff` or `Idle`. + /// `Established`, `Connecting`, `Backoff`, `Idle`, or `Passive` for a + /// peer that is not dialled and has not connected. pub state: String, /// `inbound` or `outbound`: which side opened the live TCP connection. pub direction: Option, @@ -95,7 +96,8 @@ pub struct PeerView { /// Whether the peer's OPEN said the same (RFC 9234). `false` for a /// peer that does not say; one that says otherwise gets no session. pub role_confirmed: Option, - /// Why the last connection attempt or session ended, while in Backoff. + /// Why the last connection attempt or session ended, while in Backoff; + /// for a passive peer, why its last session ended, if one did. pub last_error: Option, pub retry_at_unix: Option, /// Graceful restart (RFC 4724), if both sides offered it. @@ -172,6 +174,7 @@ impl PeerView { DialState::Backoff { until, last_error } => { ("Backoff", Some(last_error.clone()), Some(unix_secs(*until))) } + DialState::Passive { last_error } => ("Passive", last_error.clone(), None), }; Self { name: Some(peer.name.clone()), diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html index d2d33ce..99cb84a 100644 --- a/crates/web/static/statusz.html +++ b/crates/web/static/statusz.html @@ -242,7 +242,7 @@ from tick(); } - const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'bad', Idle: 'dim' }; + const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'bad', Idle: 'dim', Passive: 'dim' }; // Where several routes for a prefix are exchanged (ADD-PATH), and which way. function addPath(p) { const rx = p.add_path_receive || [], tx = p.add_path_send || []; @@ -260,7 +260,9 @@ from const up = p.state === 'Established'; const restarting = !up && p.stale_until_unix ? `restarting: ${p.stale_routes} routes kept for ${until(p.stale_until_unix)}` : ''; - const detail = [restarting, p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : ''] + const detail = [restarting, + p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '', + p.state === 'Passive' ? `waiting for the peer to connect${p.last_error ? `; last session: ${esc(p.last_error)}` : ''}` : ''] .filter(Boolean).join('; '); const gr = p.graceful_restart; const graceful = gr ? ` · graceful restart` : ''; diff --git a/docs/content/docs/guides/peers.md b/docs/content/docs/guides/peers.md index 29426c7..dda70e2 100644 --- a/docs/content/docs/guides/peers.md +++ b/docs/content/docs/guides/peers.md @@ -57,6 +57,29 @@ Ferrix rejects a neighbor whose OPEN message contains an AS number other than `remote_asn`, with the NOTIFICATION message OPEN Message Error / Bad Peer AS. +### Passive Neighbors + +A passive neighbor is a neighbor to which Ferrix does not initiate +connections. Ferrix accepts connections from the neighbor's address and +establishes the session with the configured settings when the neighbor +connects. Use a passive neighbor to configure a session before the +neighbor's side is ready, or when the neighbor initiates the connections: + +```textproto +peers { + name: "new-peer" + endpoint { address: "192.0.2.10" } + remote_asn: 64510 + role: PEER + passive: true +} +``` + +Until the neighbor connects, the status page and `/api/peers` show the +state `Passive`. After a session ends, they show the reason in the +neighbor's last error. Ferrix does not log connection attempts to a +passive neighbor, because it makes none. + ## Inbound Connection Filtering Ferrix accepts inbound connections only from the addresses of configured diff --git a/docs/content/docs/reference/configuration.md b/docs/content/docs/reference/configuration.md index 1cf5e6f..8df7e9b 100644 --- a/docs/content/docs/reference/configuration.md +++ b/docs/content/docs/reference/configuration.md @@ -75,6 +75,7 @@ Statements for a BGP neighbor. | `import` [ImportPolicy](#importpolicy) | Default checks | Specifies the import policy for routes received from the neighbor. | | `role` [Role](#role) | None | Specifies the BGP role of the neighbor (RFC 9234). Not permitted for IBGP neighbors. Required for every EBGP neighbor if any `export` statement enables `learned_routes`. | | `require_role` bool | `false` | Rejects the session if the neighbor does not advertise a BGP role. Requires `role`. | +| `passive` bool | `false` | Prevents Ferrix from initiating connections to the neighbor. Ferrix accepts connections from the address of `endpoint`; the port of `endpoint` is not used. See [Passive Neighbors](@/docs/guides/peers.md#passive-neighbors). | | `export` [ExportPolicy](#exportpolicy) | Top-level `export` | Specifies the export policy for the neighbor. Replaces the top-level `export` statement. | | `graceful_restart` [GracefulRestart](#gracefulrestart) | Top-level `graceful_restart` | Specifies graceful restart for the neighbor. Each statement that is set replaces the corresponding top-level statement. | | `route_reflector_client` bool | `false` | Makes the IBGP neighbor a route reflector client: Ferrix advertises to it the routes learned from other IBGP neighbors, and advertises its routes to them. Permitted only for IBGP neighbors. See [Route Reflection](@/docs/guides/passing-routes-on.md#route-reflection). | diff --git a/docs/content/docs/reference/http-api.md b/docs/content/docs/reference/http-api.md index ef3c16c..82e3101 100644 --- a/docs/content/docs/reference/http-api.md +++ b/docs/content/docs/reference/http-api.md @@ -72,7 +72,7 @@ neighbor: | Field | Description | |---|---| -| `state` | Session state: `Established`, `Connecting`, `Backoff`, or `Idle`. | +| `state` | Session state: `Established`, `Connecting`, `Backoff`, `Idle`, or `Passive` (a passive neighbor that has no session). | | `direction` | `inbound` if the neighbor initiated the connection, `outbound` otherwise. | | `routes` | Number of the neighbor's routes in the routing table. | | `rejected_routes` | Number of the neighbor's routes rejected by its import policy. |