Files
ferrix/crates/server/src/error.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 630c175918 Rework the speaker: sans-I/O FSM, sharded table, RFC 7606, ADD-PATH
The work of the speaker-cleanup branch as one commit; the branch keeps
the step-by-step history.

Sessions
- The state machine is a pure function from events to actions, driven by
  one task per connection. Idle, Connect and Active belong to a dialer
  that keeps configured peers up with backoff.
- Capabilities are negotiated: address families, four-octet AS numbers
  with AS_TRANS and AS4_PATH, route refresh, ADD-PATH per family and
  direction.
- Collision detection per RFC 4271 section 6.8, by address and
  identifier.
- Connections are taken only from configured peers, a few per address.
- A panic in a session is contained and answered with a Cease; a core
  task that stops shuts the server down with a Cease to every peer.
- Writes that make no progress are given up (RFC 9687).

Codec
- Malformed UPDATEs are handled as RFC 7606 says: treat-as-withdraw,
  attribute discard, or session reset, with the NOTIFICATION to send.
- Address families are open types; any can be named and ignored.
- Prefixes are fixed-size values, normalised, with path identifiers.
- Lengths are filled in after writing; one definition of each type code.
- Property tests and fuzz targets for every decoder.

Routing table
- Adj-RIBs-In and Loc-RIB in one sharded table that sessions write to
  directly. Routes are sixteen bytes; attribute sets are interned in
  wire form. Best-path selection per RFC 4271 section 9.1.2.2 with
  deterministic MED.
- A journal per shard lets followers read changes at their own pace;
  one that falls behind is resynchronised.
- On a RIPE RIS dump of 148 peers and 39.3M routes: loaded in 2.0 s on
  12 cores, about 52 bytes a route.

Announcing
- Originated routes come from the configuration. Each session has its
  own exporter with an Adj-RIB-Out, so a slow peer delays only itself.
- Routes to a peer in our own AS have an empty path and a LOCAL_PREF.

Operations
- HTTP status page, JSON API, event stream and Prometheus metrics.
- Benchmarks on synthetic and real tables.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 12:35:54 +02:00

249 lines
9.1 KiB
Rust

//! Error types of the server crate.
use std::io;
use std::net::SocketAddr;
use std::time::Duration;
use bgp_packet::errors::{BgpError, CeaseSubcode, DecodeError, FsmSubcode, OpenMsgSubcode};
use bgp_packet::message::NotificationMessage;
use bgp_primitives::parser::EncodeError;
use bgp_rib::RibStopped;
use bgp_rib::table::TooManyPeers;
use thiserror::Error;
use crate::family::FamilySet;
use crate::fsm::State;
/// Why a session ended abnormally.
#[derive(Debug, Error)]
pub enum SessionError {
#[error("connection failed: {0}")]
Io(#[from] io::Error),
#[error("received a message that could not be decoded: {0}")]
Decode(#[from] DecodeError),
#[error("could not encode a message: {0}")]
Encode(#[from] EncodeError),
/// The peer sent nothing for as long as the pre-negotiation hold time.
#[error("timed out waiting for a message in the {0} state")]
Timeout(State),
#[error("hold timer expired")]
HoldTimerExpired,
#[error("received {received} in the {state} state")]
UnexpectedMessage {
state: State,
received: &'static str,
},
/// The peer ended the session. Nothing is sent in return.
#[error("peer sent NOTIFICATION: {0}")]
NotifiedByPeer(BgpError),
#[error("refused the peer's OPEN: {0}")]
OpenRefused(BgpError),
#[error("peer announced AS{actual} but AS{expected} was configured")]
WrongPeerAs { expected: u32, actual: u32 },
#[error("closed by connection collision resolution")]
Collision,
/// The routing table has no room for another peer.
#[error(transparent)]
TableFull(#[from] TooManyPeers),
/// A bug: the session's own code panicked. The peer is told the session
/// is being reset, and the session is cleaned up like any other.
#[error("the session panicked: {0}")]
Panicked(String),
/// The peer offers none of the address families configured for it, so
/// the session could carry nothing.
#[error("no address family in common: we offer {ours}, the peer offers {theirs}")]
NoCommonAddressFamily { ours: FamilySet, theirs: FamilySet },
/// The peer took nothing we sent for this long (RFC 9687): it has
/// stopped reading, and there is no telling it so.
#[error("the peer accepted no data for {0:?}")]
SendHoldTimerExpired(Duration),
}
impl SessionError {
/// The NOTIFICATION that tells the peer about this error (RFC 4271 §6),
/// or `None` when there is nothing to say or no way to say it.
pub fn notification(&self) -> Option<NotificationMessage> {
let error = match self {
Self::Io(_) | Self::SendHoldTimerExpired(_) | Self::NotifiedByPeer(_) => return None,
// Something of ours could not be written out, which is a bug
// here: as for a panic, the session is reset.
Self::Encode(_) => BgpError::Cease(CeaseSubcode::AdminReset),
Self::Decode(e) => {
return Some(NotificationMessage {
error: e.notification.clone(),
data: e.data.clone(),
});
}
Self::Timeout(_) | Self::HoldTimerExpired => BgpError::HoldTimer,
Self::UnexpectedMessage { state, .. } => BgpError::FsmError(match state {
State::OpenSent => FsmSubcode::UnexpectedOpenSent,
State::OpenConfirm => FsmSubcode::UnexpectedOpenConfirm,
// A closed machine handles no messages; Established is the
// only other state one can arrive in.
State::Established | State::Closed => FsmSubcode::UnexpectedEstablished,
}),
Self::OpenRefused(error) => error.clone(),
Self::WrongPeerAs { .. } => BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs),
Self::Collision => BgpError::Cease(CeaseSubcode::ConnCollisionResolution),
Self::TableFull(_) => BgpError::Cease(CeaseSubcode::OutOfResources),
// The session will be set up again; nothing is wrong at the
// peer's end.
Self::Panicked(_) => BgpError::Cease(CeaseSubcode::AdminReset),
// RFC 5492 §5: the Data lists the capabilities we need and the
// peer did not offer.
Self::NoCommonAddressFamily { ours, .. } => {
return Some(NotificationMessage {
error: BgpError::OpenMsg(OpenMsgSubcode::UnsupportedCapability),
data: ours.capabilities_wire(),
});
}
};
Some(NotificationMessage {
error,
data: vec![],
})
}
}
/// Why the server could not start, or could not go on.
#[derive(Debug, Error)]
pub enum ServerError {
#[error("failed to bind listener on {addr}: {source}")]
Bind {
addr: SocketAddr,
#[source]
source: io::Error,
},
#[error("listener failed: {0}")]
Io(#[from] io::Error),
/// A RIB's actor died. The server cannot work without its tables.
#[error(transparent)]
RibStopped(#[from] RibStopped),
/// A task the server depends on ended, which none of them do unless
/// they panic.
#[error("the {task} task stopped unexpectedly: {reason}")]
TaskStopped { task: &'static str, reason: String },
}
/// Why a configuration was rejected.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ConfigError {
#[error("invalid router_id {0:?}: must be a dotted-quad IPv4 address other than 0.0.0.0")]
RouterId(String),
#[error("local_asn must be non-zero")]
LocalAsn,
#[error("invalid IP address {0:?}")]
Address(String),
#[error("port {0} is out of range")]
Port(u32),
#[error("http_endpoint.port is required")]
HttpPort,
#[error("peer {peer:?}: {problem}")]
Peer { peer: String, problem: PeerProblem },
#[error("originate {prefix:?}: {problem}")]
Originate {
prefix: String,
problem: OriginateProblem,
},
}
/// What is wrong with one `peers` entry.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PeerProblem {
#[error("endpoint is missing")]
MissingEndpoint,
#[error("endpoint is invalid: {0}")]
Endpoint(Box<ConfigError>),
#[error("remote_asn must be non-zero")]
RemoteAsn,
#[error("hold_time_seconds must be 0 or at least 3")]
HoldTime,
#[error("there is another peer of this name")]
DuplicateName,
#[error("there is another peer at this address")]
DuplicateAddress,
#[error("local_address {0:?} is not an IP address")]
LocalAddress(String),
#[error("local_address and endpoint are different address families")]
AddressFamilyMismatch,
#[error("address_families has a value that is not an address family")]
AddressFamily,
#[error("add_path has a value that is not one of its settings")]
AddPath,
}
/// What is wrong with one `originate` entry.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum OriginateProblem {
#[error("{0}")]
Prefix(#[from] bgp_primitives::ip_prefix::PrefixError),
#[error("origin {0:?} must be IGP, EGP or INCOMPLETE")]
Origin(String),
#[error("as_path_prepend must be at most {max}")]
Prepend { max: u32 },
#[error("{0}")]
Community(#[from] bgp_packet::community::CommunityError),
#[error("listed more than once")]
Duplicate,
}
#[cfg(test)]
mod tests {
use bgp_packet::errors::MsgHeaderSubcode;
use super::*;
#[test]
fn each_session_error_names_the_notification_rfc_4271_assigns_it() {
let notification = |e: SessionError| e.notification().map(|n| (n.error, n.data));
assert_eq!(
notification(SessionError::Decode(DecodeError {
notification: BgpError::MsgHeader(MsgHeaderSubcode::BadMessageLength),
data: vec![0x13, 0x88],
detail: "too long".into(),
})),
Some((
BgpError::MsgHeader(MsgHeaderSubcode::BadMessageLength),
vec![0x13, 0x88]
))
);
assert_eq!(
notification(SessionError::Timeout(State::OpenSent)),
Some((BgpError::HoldTimer, vec![]))
);
assert_eq!(
notification(SessionError::UnexpectedMessage {
state: State::OpenConfirm,
received: "update"
}),
Some((
BgpError::FsmError(FsmSubcode::UnexpectedOpenConfirm),
vec![]
))
);
assert_eq!(
notification(SessionError::WrongPeerAs {
expected: 1,
actual: 2
}),
Some((BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs), vec![]))
);
// Something of ours that cannot be written is our fault: the peer
// is told the session is being reset, not left to time it out.
assert_eq!(
notification(SessionError::Encode(EncodeError::TooLong {
what: "path attribute",
len: 70000,
max: 65535
})),
Some((BgpError::Cease(CeaseSubcode::AdminReset), vec![]))
);
// A broken connection cannot carry a NOTIFICATION.
assert_eq!(
notification(SessionError::Io(io::ErrorKind::BrokenPipe.into())),
None
);
}
}