A review of the session driver, the server loop, the dialer, the
advertiser and the HTTP API, with the fixes.
Bugs:
- A peer that stopped reading held its session, and our routes, for as
long as TCP kept retrying: a blocked write was awaited with no timer
running. Writes now give up after the Send Hold Time of RFC 9687.
- Routes we originate were sent to a peer in our own AS with our AS
number on the path and no LOCAL_PREF, which such a peer refuses. They
now go with an empty path and a LOCAL_PREF.
- Collision detection knew a peer by its BGP Identifier alone. A
neighbour with one session over IPv4 and one over IPv6 had the second
closed as a collision, and any host that could connect could displace
a peer's connection by claiming its identifier. A peer is now its
address and identifier together.
- The dialer reset its backoff whenever TCP connected, so a peer that
accepts the connection and refuses the session was dialled every few
seconds for ever. It resets only after a session was established.
- A failure on our side (routes that cannot be stored) dropped the
connection without a NOTIFICATION. It goes through the state machine
and the peer is sent Cease. A queued message that cannot be encoded
is logged and skipped instead of ending the session.
- The accept loop retried at once after an error, spinning when the
process was out of file descriptors.
- Attribute sets no route used any more were dropped only when a peer
went away. A task now does it every minute.
- The advertiser subscribed to session events inside its task, after the
dialers were started, and could miss the first session.
- Shutdown waited without limit on a session whose queue was full.
- /api/routes/received took any limit and built the reply on a runtime
thread. The limit is capped and the work is done off those threads.
- The default log level was TRACE, which logs every UPDATE.
- IPv6 listeners are IPv6-only: listening on 0.0.0.0 and [::] fails on
systems where the second would take IPv4 too, and IPv4 peers arriving
under mapped addresses matched no configured peer.
- A connection closed after a NOTIFICATION is kept for a moment so that
the peer can read it.
- The configuration accepted two peers with one name or one address,
and a router id of 0.0.0.0.
- An HTTP address that cannot be bound now stops the daemon at startup.
Simplifications:
- SessionConfig mixed what a server's sessions share with what is
configured for one peer, and was cloned and patched field by field in
two places. It is ServerContext, shared, and PeerSettings, made from a
PeerConfig in one place.
- The attributes of an UPDATE as they were on the wire travelled from
the reader to the table through a field of the session, around the
state machine. They go through it, with the message.
- run_session returns how the session ended and whether it had been
established; Ok no longer reads as "closed by peer".
- Session is not generic over its reader.
- BgpServerBuilder had nothing to build: BgpServer::bind takes the
configuration.
- One shutdown path, close_sessions, for the signal handler and for a
failed server.
- The advertiser's three copies of "send every route" are one.
- PeerId moved from the rib crate, which did not use it, to the server.
- Unused API removed or made private to the crate.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Property tests (run by `cargo test`):
- crates/packet/tests/properties.rs: every kind of message, generated,
must be written, be as long as wire_len says, and read back equal; and
noise, noise behind a valid header, and valid messages with bytes damaged
must never do more than return an error. Anything the decoder accepts
must be showable and survive being written and read again.
- IpPrefix: text round trip, noise as text, `contains` against a bitwise
model.
- The session's framing: a stream of messages yields the same messages
however it is cut into reads.
Fuzz targets (fuzz/, cargo-fuzz on nightly, a minute each in CI): raw
messages, message bodies behind a valid header, prefix text, the
configuration file.
Bugs found, each with a named regression test:
- A prefix longer than its address family (e.g. an IPv4 NLRI of length 99)
was accepted, and showing it panicked. The session logs every route, so
one UPDATE from a peer ended the session task. Such a prefix is now
malformed, and showing a prefix cannot fail.
- Bits past a prefix's length were kept, so the same prefix sent with stray
bits was a different key in the RIB. Prefixes now have one form.
- An opaque extended community was read as seven octets instead of eight,
putting every community after it out of step.
- AGGREGATOR could be neither read from nor written to a peer with
two-octet AS numbers.
- The Outbound Route Filter and Long-Lived Graceful Restart capabilities
were written differently from how they are read.
- A capability value or unknown attribute too long for its length field
was written with the length wrapped around; it is now an error, or for
the attribute the two-octet length.
Also: ROUTE-REFRESH carries its AFI and SAFI instead of being modelled as
an empty message, and one with a body of the wrong length is refused.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The library crates returned eyre reports, so a failure was a string and the
session code could not tell a truncated UPDATE from a bad header. Whatever
went wrong, the peer got an FSM error or nothing.
Each crate now has its own error types:
- bgp-primitives: PrefixError, UnknownAfi and UnknownSafi, and EncodeError
for every to_wire.
- bgp-packet: Message::decode returns a DecodeError naming the NOTIFICATION
code, subcode and data for the failure (header errors per section 6.1,
OPEN per 6.2, UPDATE per 6.3). InvalidValue and CommunityError replace
the remaining strings. from_wire is now a thin wrapper around decode.
- bgp-server: SessionError, ServerError and ConfigError. Every abnormal end
of a session goes through one function that sends the NOTIFICATION the
error maps to. The message length is checked against the 4096-octet
maximum before the body is read.
- bgp-web: serve returns io::Error.
eyre remains in the binary and in tests.
Bugs found and fixed on the way:
- An OPEN with no optional parameters, which is valid, failed to parse.
- An unsupported BGP version was reported as an unknown optional parameter.
It now gets Unsupported Version Number with the version we speak as data.
- A SAFI above 255 was truncated to its low octet and could match a known
one.
- Prefix text with a length beyond the address size (10.0.0.0/99) was
accepted.
- Message::to_wire truncated the body length to 16 bits before checking it.
- A peer hold time above 65535 seconds in the configuration wrapped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Lints are declared once under [workspace.lints] and every crate opts in:
clippy's default set, plus dbg!, todo! and unimplemented!, and unsafe code
is forbidden. Every crate now inherits its version, licence, authors and
repository from the workspace, so they no longer disagree (four were at
0.0.0).
The poptrie module is unfinished and unused; it says so and expects the
dead-code lint instead of warning on every build. Its construction method
is renamed from new, which took &mut self, to build.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The slides, handout, lab router configs, RIPE objects and load test were
for the Network Security 2026 lecture and are kept on the netsec-2026 tag.
The ping monitor sniffed ICMPv6 echo requests so students could watch their
prefix being used. It has no place in a BGP speaker: it needs CAP_NET_RAW,
adds the only unsafe code in the tree and a libc dependency.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 1 of the BGP teaching demo: the daemon can now originate routes and
advertise them to every established peer.
- rib: add LocalRib actor holding locally originated routes with owner,
origin, AS-path prepend, MED, standard and large communities. Enforces a
LocalRibPolicy (prefix allowlist, per-owner limit, owner match on
replace/withdraw) and emits LocalRibEvents.
- server: add SessionRegistry with per-session outbound queues; sessions
register on Established and deregister via a drop guard. Emits
SessionEvent::{Up,Down}.
- server: advertiser task fans LocalRib changes out to all sessions and
replays the table to new sessions. UPDATE builders handle IPv4 (legacy
NLRI) and IPv6 (MP_REACH/MP_UNREACH).
- fsm: merge the timer and timer-less established loops into one select!
with an outbound branch; reset the hold timer on any received message
per RFC 4271 §8.2.2 (previously only on KEEPALIVE).
- config: new local_route_policy proto message.
- packet: fix Communitites typo, expose CommunitiesPathAttribute inner vec.
- primitives: IpPrefix::contains.
- integration tests: announce/withdraw/replay/fan-out/policy coverage.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Implement passive-side BGP FSM (OpenSent → OpenConfirm → Established)
with hold timer, keepalive timer, and NOTIFICATION on expiry
- Add bgp-server crate: config validation, TCP listener accept loop
(BgpServerBuilder → BgpServer), connection dispatch
- Add bgp-rib crate: Adj-RIB-In actor owning route state with event
broadcast for streaming consumers and snapshot for introspection
- Wire Adj-RIB-In into FSM: announce/withdraw on UPDATE for both legacy
IPv4 NLRI fields and MP_REACH/MP_UNREACH path attributes
- Add bgp-integration-tests crate with 15 end-to-end tests covering
session establishment, timers, UPDATE handling, and RIB introspection
- Fix require_empty! macro bug in open.rs (checked wrong buffer)
- Fix FourByteAsn capability parser returning consumed buffer as remainder
- Fix tracing subscriber not being installed in TracingArgs::install
- Expand docs/RFCs.md with full implementation status across 15 RFCs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>