Files
ferrix/crates/server/src/session.rs
T
Rayhaan JaufeerallyandClaude Opus 5.5 65f136048e Graceful restart (RFC 4724, RFC 8538)
A peer that restarts keeps its routes with us, and we keep ours with our
peers while we restart, so that a restart withdraws nothing across the
network and the machine goes on forwarding. On by default for every
peer; `graceful_restart` sets the restart time we give, caps how long a
peer's routes are kept, or turns it off.

When a peer restarts: its routes are marked stale in the routing table,
and chosen as before, for at most its restart time. When it is back, what
it sends again is fresh, and the stale routes of a family go at its
End-of-RIB marker, or after the cap; at once for a family whose
forwarding it did not keep. Only a connection that failed counts as a
restart, and, with RFC 8538 agreed, an expired hold timer or a
NOTIFICATION other than a Hard Reset. The stale mark is a bit of the
route's word that selection never looks at.

When we restart: SIGUSR1 leaves the kernel's routes where they are,
closes the sessions without a word where graceful restart is agreed, and
exits with status 75, on which the systemd unit starts us again. The next
start finds the routes in the kernel, says so in its OPENs (R, and F where
forwarding was kept), and has them go once every peer has sent its
End-of-RIB. Our own End-of-RIB markers for learned routes wait for that
too. SIGTERM still stops for good, now with a Hard Reset where RFC 8538 is
agreed, as are errors of ours.

The status page and API show what was agreed with each peer, the routes
kept while a peer restarts, and which routes are stale. Tested against
FRR and BIRD in a new phase of the interoperability lab, and with routes
kept in a real kernel over a restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:43:47 +02:00

424 lines
15 KiB
Rust

//! Registry of established BGP sessions.
//!
//! A session registers itself when it reaches Established and is removed when
//! its [`SessionGuard`] drops, so a session that errors out is always
//! deregistered. Other parts of the server (the advertiser, the HTTP API) use
//! the registry to enumerate live sessions and queue outbound messages.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime};
use bgp_packet::message::Message;
use bgp_primitives::ip_prefix::Nlri;
use bgp_rib::table::PeerIndex;
use tokio::sync::{broadcast, mpsc, watch};
use crate::address::{Endpoint, PeerAddress, socket_text};
use crate::collision::Direction;
use crate::family::FamilySet;
use crate::fsm::Negotiated;
use crate::metrics::{PEER_UP, SESSIONS_ESTABLISHED};
use crate::policy::{ExportPolicy, PeerRole};
use crate::restart::{Agreed, Leave};
use crate::rpki::Subject;
use crate::sync::IgnorePoisonRw;
const EVENT_CHANNEL_CAPACITY: usize = 256;
/// Identifies a session by the peer's TCP address and its AS number.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PeerId {
pub addr: SocketAddr,
pub asn: u32,
}
impl std::fmt::Display for PeerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}(AS{})", socket_text(self.addr), self.asn)
}
}
/// Where the dialer is in bringing up a configured peer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DialState {
/// Not dialling: the peer connected to us, or the dialer has not started.
Idle,
Connecting,
/// The last attempt failed or the session ended; waiting to retry.
Backoff {
until: SystemTime,
/// How the last attempt or session ended.
last_error: String,
},
}
/// A peer from the configuration file, whether or not it is currently up.
#[derive(Debug, Clone)]
pub struct ConfiguredPeer {
pub name: String,
pub endpoint: Endpoint,
pub remote_asn: u32,
/// What the peer is to us, if that was said.
pub role: Option<PeerRole>,
}
/// Counts kept by a session as it goes, for whoever wants to look.
#[derive(Debug, Default)]
pub struct SessionCounters {
/// Routes of the peer's that are in the routing table now.
routes: AtomicU64,
/// Routes the peer announced that its import policy refused, since the
/// session came up.
rejected: AtomicU64,
/// Routes of the peer's that are held out of the table as invalid, to
/// be taken if route origin validation comes to say otherwise.
held: AtomicU64,
/// Routes of the peer's kept from its last session, which it has not
/// sent again yet (graceful restart).
stale: AtomicU64,
}
impl SessionCounters {
pub fn routes(&self) -> u64 {
self.routes.load(Ordering::Relaxed)
}
pub fn rejected(&self) -> u64 {
self.rejected.load(Ordering::Relaxed)
}
pub fn held(&self) -> u64 {
self.held.load(Ordering::Relaxed)
}
pub(crate) fn set_held(&self, held: usize) {
self.held.store(held as u64, Ordering::Relaxed);
}
pub fn stale(&self) -> u64 {
self.stale.load(Ordering::Relaxed)
}
pub(crate) fn set_stale(&self, stale: usize) {
self.stale.store(stale as u64, Ordering::Relaxed);
}
/// Notes that `cleared` stale routes were sent again or went.
pub(crate) fn clear_stale(&self, cleared: usize) {
// Only the session writes this.
let left = self.stale().saturating_sub(cleared as u64);
self.stale.store(left, Ordering::Relaxed);
}
/// Notes what one UPDATE did, and returns how many routes the peer has
/// in the table after it.
pub(crate) fn note(&self, added: usize, removed: usize, rejected: usize) -> u64 {
self.rejected.fetch_add(rejected as u64, Ordering::Relaxed);
// Only the session writes this, so the two steps are not a race.
let routes = (self.routes() + added as u64).saturating_sub(removed as u64);
self.routes.store(routes, Ordering::Relaxed);
routes
}
}
/// Handle to one established session. Cloning is cheap.
#[derive(Debug, Clone)]
pub struct SessionHandle {
pub peer: PeerId,
/// Name from the configuration, if this peer is configured.
pub name: Option<String>,
/// Which side opened the TCP connection.
pub direction: Direction,
/// Our side of the TCP connection; used as the next hop for routes we
/// originate to this peer.
pub local_addr: SocketAddr,
pub established_at: SystemTime,
/// Whether the peer is in our own AS.
pub ibgp: bool,
/// What the peer is to us, if that is known.
pub role: Option<PeerRole>,
/// Whether the peer said so itself in its OPEN (RFC 9234).
pub role_confirmed: bool,
/// Whether IPv4 routes go with IPv6 next hops on this session
/// (RFC 8950).
pub extended_next_hop: bool,
/// Whether the peer negotiated RFC 6793 four-octet ASNs. Determines how
/// AS_PATH is encoded on the wire for this session.
pub four_octet_asn: bool,
/// The address families negotiated with the peer. Routes of any other
/// family are neither sent to it nor accepted from it.
pub families: FamilySet,
/// The families for which the peer sends us several routes for a
/// prefix (ADD-PATH, RFC 7911), and those for which we send it path
/// identifiers.
pub add_path_receive: FamilySet,
pub add_path_send: FamilySet,
/// The peer's number in the routing table: what its routes there are
/// marked with.
pub rib_peer: PeerIndex,
/// Graceful restart (RFC 4724), if both sides offered it.
pub graceful_restart: Option<Agreed>,
/// How many routes the peer has, and how many were refused.
pub counters: Arc<SessionCounters>,
/// What the peer is told, and how.
pub export: Arc<ExportPolicy>,
outbound: mpsc::Sender<Message>,
/// Which of the RPKI's checks the peer's routes are subject to, if
/// any, and where the session is told to look at its routes again.
validation: Option<(Option<Subject>, mpsc::UnboundedSender<Vec<Nlri>>)>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("session with {0} has closed")]
pub struct SessionClosed(pub PeerId);
impl SessionHandle {
pub(crate) fn new(
peer: PeerId,
name: Option<String>,
direction: Direction,
local_addr: SocketAddr,
negotiated: &Negotiated,
rib_peer: PeerIndex,
outbound: mpsc::Sender<Message>,
) -> Self {
Self {
peer,
name,
direction,
local_addr,
established_at: SystemTime::now(),
ibgp: negotiated.ibgp,
role: None,
role_confirmed: false,
extended_next_hop: negotiated.extended_next_hop,
four_octet_asn: negotiated.four_octet_asn,
families: negotiated.families.clone(),
add_path_receive: negotiated.add_path_receive.clone(),
add_path_send: negotiated.add_path_send.clone(),
rib_peer,
graceful_restart: negotiated.graceful_restart.clone(),
counters: Arc::default(),
export: Arc::default(),
outbound,
validation: None,
}
}
/// Marks the session's routes as subject to validation with the
/// RPKI's data. What is sent with [`SessionHandle::revalidate`]
/// arrives at the receiver returned.
pub(crate) fn validate(
&mut self,
subject: Option<Subject>,
) -> mpsc::UnboundedReceiver<Vec<Nlri>> {
let (tx, rx) = mpsc::unbounded_channel();
self.validation = Some((subject, tx));
rx
}
/// Which of the RPKI's checks the session's routes are subject to, if
/// any.
pub fn validated(&self) -> Option<Subject> {
self.validation.as_ref().and_then(|(subject, _)| *subject)
}
/// Tells the session that the validation data has changed. `suspects`
/// are routes of its peer's in the table that are now invalid.
pub(crate) fn revalidate(&self, suspects: Vec<Nlri>) {
if let Some((_, revalidate)) = &self.validation {
// Fails only if the session has just ended.
let _ = revalidate.send(suspects);
}
}
/// Whether `other` is a handle to this same session, and not to another
/// session with the same peer.
pub fn is_same_session(&self, other: &SessionHandle) -> bool {
self.outbound.same_channel(&other.outbound)
}
/// Queues a message for transmission on this session. Waits for queue
/// space if the session's writer is behind.
pub async fn send(&self, msg: Message) -> Result<(), SessionClosed> {
self.outbound
.send(msg)
.await
.map_err(|_| SessionClosed(self.peer))
}
}
#[derive(Debug, Clone)]
pub enum SessionEvent {
Up(SessionHandle),
Down(PeerId),
/// The peer sent ROUTE-REFRESH (RFC 2918): it wants everything we
/// advertise to it sent again, typically because its import policy
/// changed.
RefreshRequested(SessionHandle),
}
#[derive(Clone)]
pub struct SessionRegistry {
inner: Arc<RwLock<HashMap<PeerId, SessionHandle>>>,
configured: Arc<Vec<ConfiguredPeer>>,
dial_states: Arc<RwLock<HashMap<String, DialState>>>,
shutting_down: Arc<AtomicBool>,
/// How the daemon is leaving, once it is: every connection hears it.
leaving: watch::Sender<Option<Leave>>,
events: broadcast::Sender<SessionEvent>,
}
impl Default for SessionRegistry {
fn default() -> Self {
Self::new()
}
}
impl SessionRegistry {
pub fn new() -> Self {
Self::with_configured(vec![])
}
pub fn with_configured(configured: Vec<ConfiguredPeer>) -> Self {
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
let dial_states = configured
.iter()
.map(|p| (p.name.clone(), DialState::Idle))
.collect();
for peer in &configured {
PEER_UP.with_label_values(&[&peer.name]).set(0);
}
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
configured: Arc::new(configured),
dial_states: Arc::new(RwLock::new(dial_states)),
shutting_down: Arc::new(AtomicBool::new(false)),
leaving: watch::Sender::new(None),
events,
}
}
/// Peers from the configuration file, each with the dialer's current
/// state for it.
pub fn configured_peers(&self) -> Vec<(ConfiguredPeer, DialState)> {
let states = self.dial_states.reading();
self.configured
.iter()
.map(|p| {
(
p.clone(),
states.get(&p.name).cloned().unwrap_or(DialState::Idle),
)
})
.collect()
}
pub(crate) fn set_dial_state(&self, name: &str, state: DialState) {
self.dial_states.writing().insert(name.to_string(), state);
}
/// An established session with the peer at this address and with this
/// ASN, regardless of which side opened the TCP connection.
pub fn find(&self, address: PeerAddress, asn: u32) -> Option<SessionHandle> {
self.inner
.reading()
.values()
.find(|h| PeerAddress::from(h.peer.addr) == address && h.peer.asn == asn)
.cloned()
}
/// Adds a session and emits [`SessionEvent::Up`]. The returned guard
/// removes the session again when dropped.
pub(crate) fn register(&self, handle: SessionHandle) -> SessionGuard {
let peer = handle.peer;
self.inner.writing().insert(peer, handle.clone());
SESSIONS_ESTABLISHED.inc();
if let Some(name) = &handle.name {
PEER_UP.with_label_values(&[name]).set(1);
}
let _ = self.events.send(SessionEvent::Up(handle));
SessionGuard {
registry: self.clone(),
peer,
}
}
/// All currently established sessions, in no particular order.
pub fn list(&self) -> Vec<SessionHandle> {
self.inner.reading().values().cloned().collect()
}
/// Called by a session when its peer asks for a route refresh.
pub(crate) fn request_refresh(&self, peer: &PeerId) {
let handle = self.inner.reading().get(peer).cloned();
if let Some(handle) = handle {
let _ = self.events.send(SessionEvent::RefreshRequested(handle));
}
}
/// True once [`shutdown`](Self::shutdown) has begun; dialers stop
/// reconnecting.
pub fn is_shutting_down(&self) -> bool {
self.shutting_down.load(Ordering::Relaxed)
}
/// Closes every session politely: each peer is sent Cease /
/// Administrative Shutdown (RFC 4486) so it can drop the session at once
/// instead of discovering a dead TCP connection. Waits up to `grace` for
/// the sessions to finish; returns how many were still open.
pub async fn shutdown(&self, grace: Duration) -> usize {
self.leave(Leave::Stop, grace).await
}
/// Closes every connection because the daemon is going, as `leave`
/// says: for good, or to restart (see [`Leave`]). Waits up to `grace`
/// for the sessions to finish; returns how many were still open.
pub async fn leave(&self, leave: Leave, grace: Duration) -> usize {
self.shutting_down.store(true, Ordering::Relaxed);
// Every connection watches this, whatever state it is in; one whose
// peer is not reading is not held up by the others.
self.leaving.send_replace(Some(leave));
let deadline = tokio::time::Instant::now() + grace;
while !self.list().is_empty() && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
self.list().len()
}
/// How the daemon is leaving, once it is.
pub fn leaving(&self) -> watch::Receiver<Option<Leave>> {
self.leaving.subscribe()
}
pub fn subscribe(&self) -> broadcast::Receiver<SessionEvent> {
self.events.subscribe()
}
fn remove(&self, peer: &PeerId) {
let removed = self.inner.writing().remove(peer);
if let Some(handle) = &removed {
SESSIONS_ESTABLISHED.dec();
if let Some(name) = &handle.name {
PEER_UP.with_label_values(&[name]).set(0);
}
let _ = self.events.send(SessionEvent::Down(*peer));
}
}
}
/// Removes the session from its registry on drop.
pub struct SessionGuard {
registry: SessionRegistry,
peer: PeerId,
}
impl Drop for SessionGuard {
fn drop(&mut self) {
self.registry.remove(&self.peer);
}
}