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>
The OPEN used to offer IPv4 and IPv6 unicast unconditionally, and what the
peer offered was ignored apart from its four-octet AS number.
Address families (RFC 4760):
- A peer's `address_families` in the configuration says which to offer it;
all of them by default.
- A session carries the families both OPENs offered. A peer that sends no
Multiprotocol capability is a plain RFC 4271 speaker: IPv4 unicast.
- Routes of a family that was not negotiated are not sent, and are ignored
when received; the rest of the UPDATE is applied and the session stays up.
- A session with no family in common is refused with Unsupported
Capability, naming the capabilities we needed (RFC 5492).
- The negotiated families are shown on /statusz and in /api/peers.
Bugs found on the way:
- A capability naming an address family we have no constant for (EVPN,
VPNv4, ...) failed the whole OPEN, so such a peer could never establish.
It is now kept as an unknown capability and ignored.
- An UPDATE could not be encoded for a peer without four-octet AS support,
which ended the session on the first route. AS_PATH is now written two
octets wide with AS_TRANS where needed, and AS4_PATH carries the real
numbers (RFC 6793).
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>
The production router has the same neighbour AS at several exchanges, so
per-AS control alone cannot express "only at SwissIX". Every session now
has two coordinates, neighbour AS and location, and the scheme gains two
functions:
210036:0:<as> / 210036:2:<location> never announce (wins)
210036:1:<as> / 210036:3:<location> announce
210036:0:0 announce nowhere
nothing announce everywhere
docs/lab/prod/bird-ferrix.conf documents the functions, the per-session
call (export where peers_out(<as>, <location>)) and the gate that sessions
exporting everything need as well.
Status page: location presets, and AS210036 communities are decoded from
their structure so any value reads well in the bytes hover.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BIRD side (docs/lab/prod/bird-ferrix.conf): the ferrix import filter now
admits 210036:0:* and 210036:1:*, and lab_export_allowed(peer_as) decides
per eBGP session whether a lab route is announced, with the same shape and
precedence as the SwissIX route-server communities:
1. 210036:0:<peer-as> never 2. 210036:1:<peer-as> announce
3. 210036:0:0 nobody 4. default everyone
The per-session export block applies only to routes learned from ferrix,
strips the private ASN and removes the action communities before sending.
Status page: the large-community picker is grouped into "AS210036" and
"SwissIX route servers" presets, including "announce to the SwissIX route
servers" and "send to SWITCH (AS559)"; the bytes hover names both families.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The status page has a collapsible "Configuration" section showing the
config file as read at startup, with its path. Served by the new
GET /api/config; RouterInfo::with_config carries the text.
- Removed the AS-path prepends field and the SwissIX prepend presets
(42476:101..103) from the form to keep the exercise focused. The API
still accepts `prepend`.
- Docs follow: the handout loses its prepending exercise, the lab README
its prepend row, and the BIRD config notes that the 42476 namespace is
still admitted for hand-typed values.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Hovering (or focusing) a byte segment opens a popup that decodes it:
header fields, the two length fields, and each path attribute with its
flags spelled out and its value in readable form. AS_PATH shows segment
type, ASNs, path length and ASN width; COMMUNITIES shows well-known
names; LARGE_COMMUNITIES shows the SwissIX meaning of known values;
MP_REACH/UNREACH show address family, next hop and prefixes; NLRI and
withdrawn routes list prefixes. Decoding happens on hover from the bytes
themselves. /api/routes/local/wire gains four_octet_asn so the AS_PATH
width is known.
- The page is now a snapshot by default. ?refresh=<seconds> (1..3600)
turns on reloading of all tables at that interval and connects the
event log; the header offers 2s / 5s / 30s, "turn off" and "reload now".
Events only feed the log, so the tables never change faster than asked.
The reader's own announce / withdraw still shows its effect at once.
- Removed the "tables refresh on every event" footer text.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every poll and event rebuilt each table from scratch, and an open bytes
view collapsed to "loading..." and was refetched every five seconds.
- Tables are rewritten only when their markup changes. Clock-dependent
text (ages, retry countdowns, uptime) is rendered as placeholders and
ticked in place once a second, so unchanged data means no DOM writes.
- The bytes view is cached per route and set of established sessions, and
the previous bytes stay visible while new ones load.
Measured with a MutationObserver: zero table rebuilds across 13 s of
polling with a bytes view open; one announcement redraws only the local
and received tables once each, with no "loading..." flash.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Modelled on Vitess's servenv status page and golang.org/x/net/trace, both
written by Google engineers to mirror the internal pages: plain sans-serif
HTML, default links and native form controls, dense bordered tables, and a
full-width centred light blue (#eef) heading bar per section. Header shows
"Status for ferrix_bgp" with start time, uptime, version, router ID and AS
on the left and host, "View:" links and live-update state on the right.
The dark theme is dropped on purpose.
Behaviour is unchanged (live updates, owner-gated withdraw, per-session
UPDATE bytes, looped-route highlight, peers polling). Additions:
- a SwissIX large-community picker next to the field; presets with a peer
AS ask for the number
- event log lines include prepend and MED
- /api/status gains hostname and started_at_unix (RouterInfo::new)
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 4 of the teaching demo: the daemon now brings up its own sessions.
- New dialer task per configured peer: connect with a timeout, run the
same FSM as the passive side, back off exponentially (from
connect_retry_seconds, capped at two minutes) and retry forever. Stays
idle while a session with the same remote IP and ASN already exists.
- The peer's OPEN must carry the configured ASN, otherwise the session is
refused with a Bad Peer AS notification. Inbound connections from a
configured peer's address get the same check plus the configured name
and hold time.
- SessionRegistry tracks configured peers and their dial state
(Idle / Connecting / Backoff with last error and retry time).
- /api/peers merges configured peers with live sessions so the page shows
peers that are down, why, and when the next attempt is; unconfigured
inbound sessions are listed after them.
- fake_peer gains --listen so it can stand in for a router the daemon
dials.
- Integration tests: dial and establish, reconnect after the peer closes,
wrong ASN refused, inbound from a configured address checked and named.
Not implemented: RFC 4271 §6.8 collision detection; a real router on the
other end resolves the rare race by closing one connection.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 3 of the teaching demo.
- crates/web serves an embedded, dependency-free status page at /statusz
(/ redirects there). Sections: router header with live indicator, peers,
announce form (owner remembered locally, well-known community picker,
prepend / MED / large communities), local routes with per-session UPDATE
bytes segmented into header / lengths / attributes / NLRI, received
routes with rows highlighted when a locally announced prefix has come
back, and a live event log. Tables refresh on every SSE event. Light
and dark themes.
- fake_peer example in crates/integration-tests: connects, announces,
prints received UPDATEs and with --echo re-announces everything back
with its ASN prepended (dropping NO_EXPORT), so one daemon plus one
process demonstrates the full loop without lab routers.
- README for the binary documents the endpoints and the demo setup.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>