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>
A peer can now send several routes for one prefix, told apart by a path
identifier, and each is a route of its own in the table.
Wire:
- The ADD-PATH capability lists several address families. It was read as
exactly one, so the OPEN of a router offering it for IPv4 and IPv6 was
malformed and the session never came up.
- Prefixes in UPDATEs are `Nlri`: a prefix and a path identifier. The
identifier is on the wire, in the body and in MP_REACH/MP_UNREACH, for
the families the session uses ADD-PATH for in that direction. Sending
and receiving are settled separately, so a session has one codec context
for each.
Negotiation:
- `add_path` on a peer in the configuration: off (the default), receive,
send or both, offered for every family offered to the peer.
- In use for a family and a direction when one side offered to send and
the other to receive. Shown on /statusz and in /api/peers.
Table:
- A route is known by its peer and path identifier together. Identifiers
are stored only in rows that have a non-zero one, so routes of peers
that send one route each cost nothing more.
- Equal routes from one peer are chosen between by the lower identifier.
- /api/routes/received gives each route's path_id.
Journal:
- A second journal in each shard records every change to any route, for
followers that want all the routes of a prefix and not only the best:
RibTable::reader_of_all_routes and Event::Routes. This is what sending
several routes to a peer will read. It costs nothing while nobody
follows it.
Sending: prefixes go out with a path identifier where that was agreed, but
there is still one route for each prefix to send: only originated routes
are announced.
Tested with the table and journal model tests extended to path identifiers
and to followers of all routes, the codec property tests and fuzz targets
extended to ADD-PATH sessions, and end to end in both directions and for
the case where only one side offers it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The server now keeps what it learns in table::RibTable. The Adj-RIB-In
actor is gone, and with it the queue every UPDATE from every session went
through.
- A session takes a number in the table when it is established and writes
its UPDATEs into the table itself. Its routes are removed when it ends,
on a blocking thread since that looks at every prefix; a session torn
down without cleaning up still has them removed.
- For every prefix the best route is chosen (RFC 4271 §9.1.2.2) as routes
come and go. Nothing acts on the choice yet beyond showing it.
- The UPDATE decoder hands over the bytes of the attributes it kept, so the
table finds the attribute set from them without writing the attributes
out again. On a session with two-octet AS numbers the bytes are not in
the table's form, and the parsed attributes are used.
- /api/routes/received marks the best route of each prefix and returns at
most `limit` routes (default 1000): the table can hold hundreds of
millions. /api/status has the totals, and the status page says how many
it is showing. The time a route was received is no longer kept or shown.
- The event stream reports changes of best route (best_changed,
best_withdrawn) in place of every route received. Each client follows
the table's journal with a reader of its own, from the moment it
connects: table::RibTable::reader_from_now.
- Metrics: bgp_rib_routes, bgp_rib_prefixes, bgp_rib_attribute_sets and
bgp_rib_journal_entries replace bgp_adj_rib_in_routes.
- A line is no longer logged at INFO for every UPDATE sent or received; a
full table is a million of them. They are at DEBUG.
- A table with no room for another peer ends the session with Cease / Out
of Resources.
Four peers sending a synthetic full table each over TCP, 4.9 million
routes: 0.80 s, against 4.76 s before. One peer: 0.45 s against 0.69 s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The RIB handles swallowed every failure of their actor: an UPDATE applied
to a stopped Adj-RIB-In vanished, and a snapshot of it came back empty.
Sessions stayed up, peers' routes went nowhere, and the status page showed
a healthy router with no routes. An actor stops only by panicking, so this
is the state the server would be in after a bug.
- Every operation on AdjRibIn and LocalRib returns Result<_, RibStopped>.
LocalRibError is replaced by it.
- The server waits on both actors, the advertiser and the dialers. If one
stops it sends every peer Cease / Administrative Shutdown and returns an
error, so the process exits and can be restarted. Before, the tasks'
handles were held and never looked at.
- A session that cannot store an UPDATE ends with the same Cease.
- The HTTP API answers 503 with the reason for anything that needs a table
it cannot read, including /metricz: a scrape that fails is noticed, last
known sizes are not.
Also:
- The routes of a session are now removed even if its task is torn down
without running its cleanup (a panic), instead of staying in the table
as routes of a peer that is gone.
- Table sizes for the status page and metrics are asked for as a count
instead of copying the whole table to take its length.
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>
Default settings. No change in behaviour; the tree was last formatted
before the lecture work and had drifted to a mix of line widths.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Routes were announced and withdrawn through an HTTP form, each tagged with a
free-text owner and checked against an allow-list and a per-owner limit.
That was the lab's interface, not a router's.
Routes now come from 'originate' entries in the configuration file and are
in the local RIB before any session starts. The local RIB holds one route
per prefix with no notion of owners or policy. local_route_policy is gone
from the configuration.
The HTTP API keeps its GET endpoints (status, config, peers, originated and
received routes, events, metrics). The announce, withdraw, wire-bytes and
well-known-community endpoints are removed, and the status page loses the
announce form, the community presets and the byte decoder. Community text
parsing moves from the web crate to bgp-packet so the configuration can use
it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Against a production BIRD router, restarting ferrix was followed by
several refused connection attempts before the session came back.
- The daemon had no signal handling: Ctrl-C killed the process and the
peer only ever saw a dead TCP connection. On SIGINT or SIGTERM it now
sends Cease / Administrative Shutdown (RFC 4486) to every established
peer, waits up to two seconds for the sessions to close, and stops the
dialers from reconnecting meanwhile. A NOTIFICATION queued for a session
ends that session once sent.
- ROUTE-REFRESH was parsed and ignored. A peer sends it when its import
policy changes (e.g. after `birdc configure`), expecting our routes
again so the new policy can be applied. The session now reports the
request and the advertiser re-sends every local route to that peer. The
request also appears in the event stream and the status page log.
Tests: a refresh request in real wire format re-sends all routes and keeps
the session up; shutdown delivers the Cease to every peer and closes the
sessions. Verified with the real binary: SIGINT, peer logs the Cease.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When both ends dial each other, exactly one TCP connection must survive
and both ends must pick the same one. Previously ferrix relied on the
other router to sort this out and could briefly hold two sessions.
- collision: a shared map keyed by the peer's BGP Identifier holds the one
connection currently preferred for that peer plus a kill signal. Each
connection calls admit() on receiving the peer's OPEN. The connection
initiated by the higher BGP Identifier wins; the loser gets Cease /
Connection Collision Resolution, whether it is the new connection or an
existing one in OpenConfirm running in another task.
- A collision with an Established session closes the new connection (RFC
default). The optional CollisionDetectEstablishedState is available as
collision_detect_established_state: a restarted peer's new connection
then replaces the stale session at once instead of being refused until
the hold timer expires. Enabled in the lab config.
- Two connections opened by the same side: the newer wins.
- OPEN validation rejects a peer using our own BGP Identifier or 0.0.0.0
with Bad BGP Identifier; collision resolution needs distinct identifiers.
- OpenConfirm no longer times out instantly when the negotiated hold time
is zero.
- Sessions record which side opened them; shown as "opened by" on the
status page and as `direction` in /api/peers. New counter
bgp_connection_collisions_total{closed}.
- Tests: unit tests for the rule (including that both ends agree), and
integration tests for simultaneous open in both identifier orders and
both arrival orders, Established default, restart replacement with route
flush, distinct identifiers, slot release, and Bad BGP Identifier.
Existing fan-out test now gives its two peers distinct identifiers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 5 of the teaching demo plus two fixes found while exercising the
dialer on the status page.
- Adj-RIB-In: routes learned from a peer are removed when its session ends
(RFC 4271 §8.2.2), emitting a Withdrawn event per prefix before the
session Down event. Previously stale routes from dead sessions stayed in
the received table.
- statusz: poll the peers table every 5 s so dial state (Connecting /
Backoff and its retry countdown) stays current; those changes carry no
event.
- docs/lab: docker-compose topology with ferrix (AS65000) and two FRR
routers. r2 accepts nothing directly from ferrix so student prefixes
only reach it via r1, then come back with AS path 65002 65001 65000. r1
applies community-driven policy: NO_EXPORT stops forwarding, 65001:100
prepends, BLACKHOLE drops. Dockerfile for the daemon.
- docs/lab/HANDOUT.md: student exercises walking through announcement,
reading the bytes, prepending, NO_EXPORT, action communities,
blackholing and withdrawal.
The container files have not been executed (no runtime available here);
docs/lab/README.md lists what is most likely to need adjusting.
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>