Commit Graph
14 Commits
Author SHA1 Message Date
Rayhaan JaufeerallyandClaude Fable 5.1 db8edaae8e policy, config, web: the TCP gauges named once, one revalidator, one peer-naming rule
ci / interop (push) Canceled after 0s
ci / check (push) Canceled after 0s
ci / docs (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s
A code-quality pass over policy, configuration and the status API.
The twenty TCP gauges were spelled out in the struct, the
constructor, the removal, the sampler in the web crate and the status
view; one macro names each once with its help text, `PeerTcp::set`
fills them from a connection, and the view is a `From`. Every metric
and the list that forces them at start come from one declaration,
with a test that every declared family is registered. The two
revalidators, for next hops and for RPKI, share the task body that
scans the table off the sessions' threads and fans the findings out
to every session; the RPKI scan judges a route with the same `Checks`
the import path uses. Which peer a query names is one rule for the
received, rejected and TCP endpoints, so `/api/tcp` takes a name or an
address like the others. The config parser says proto3's "zero is
unset" in three helpers instead of a dozen shapes, and `parse_peer`
is three named parts shorter. The policy's test-only next-hop flag is
named for what it does, `accept_own_host_next_hop`; the "listed, or
else ours" prefix rule and the mark-then-prefer step each exist once.
The status page derives its spanning rows from the table's header,
shares one key-value grid and one detail-row rule, and defines its
helpers before their use. The web crate no longer re-exports views
nothing uses, and the TCP endpoint sits above the test module.

Testing: the TCP view's arithmetic (a share of no busy time is zero,
not NaN), the peer-naming rule, the RPKI scan finding only what a
change can have made invalid, and `/api/tcp` by address, by name and
for an unknown peer. The server and web tests, the HTTP, roles, RPKI
and import-policy integration tests, the docs build and every lab
phase pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 22:44:32 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 b0dd357986 export: a kilobyte less a route advertised with all_paths; memory accounted for
ci / check (push) Canceled after 0s
ci / docs (push) Canceled after 0s
ci / interop (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s
A router feeding a collector every path (all_paths) was 4.8 GB resident
with a table that should take a third of that. A heap profile of the
daemon in the Linux VM, with four full tables and such a collector,
showed where it went: the exporter kept, for every route it had
advertised, the route's outgoing form, the parsed attribute list it was
dressed with, through an `Arc<Dressed>` in the per-route record. One
such form lived for every distinct attribute set advertised, about a
kilobyte each, for the life of the session, on top of the exporter's
cache of 262,144 of them. The record needs only to tell whether a route
changed, and the table's own attribute set, which is in memory anyway,
tells it that: the outgoing form is a function of the set, the peer the
route came from and the policy.

This change:

* `Sent` holds an `Arc<AttrSet>` instead of an `Arc<Dressed>`, and a
  route is sent again when its set differs. `Verdict::All` carries both
  while a flush is on.
* `DRESSED_KEPT` goes from 262,144 to 65,536: a kilobyte a set, so 64 MB
  rather than 256 MB a session at most; what a flush is sending holds
  its own.
* The status page's header shows the daemon's memory as jemalloc
  accounts for it (allocated and resident), `/api/status` has the six
  figures (`memory`: allocated, active, metadata, resident, mapped,
  retained), and `bgp_memory_bytes{kind}` exports them. jemalloc is built
  with statistics for this, and `bgp-web` reads them through
  `tikv-jemalloc-ctl`.
* `scripts/profile/full-table.sh` gains `COLLECTOR=1`, a collector
  session over IPv6 that takes both families with ADD-PATH, and `HEAP=1`,
  which builds the daemon with jemalloc's heap profiler (the new
  `heap-profiling` feature of the daemon crate, not for production) and
  turns the profile into a flame graph with jeprof; and it reports
  memory with the table in and again 30 s later. The feeder
  (`crates/bench/examples/feed.rs`) plays the collector with
  `FEED_COLLECTOR=1`.

Measured in the VM, four peers' full tables and a collector sent every
path (4.9 million routes advertised): 1,723 MiB resident before, 1,286
MiB after, with jemalloc's allocated figure from 1,500 MiB to 946 MiB.
Without the collector the same tables take 630 MiB, so the collector's
bookkeeping is now about 50 bytes a route advertised. Of the resident
figure, some 400 MiB was pages jemalloc had freed and not yet returned:
it returns them on allocator activity only, and a router that has its
tables in allocates little. jemalloc is now built with its background
thread on (the `background_threads` feature), which returns them on
time: 1,020 MiB resident 30 s after the load, against 1,286 MiB without
it.

Documentation: `memory` in the HTTP API reference, `bgp_memory_bytes`
in the metrics reference and the monitoring guide, the script's new
settings in the testing guide.

Testing: the ADD-PATH integration tests pass, including the collector
ones; the heap profile and the figures above are from
`PEERS=4 COLLECTOR=1 HEAP=1 scripts/profile/full-table.sh` in the VM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 22:51:11 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 99a47da2af status: a session's TCP connection as the kernel sees it; a route in full
When a session advertised a full table to a route collector at two
thousand routes a second, nothing on the status page said whether the
daemon or the peer set that pace. The answer is in the kernel: the
peer's receive window, the queues, and how long the connection was held
up by the window or by the send buffer, which `ss -ti` shows.

This change asks the kernel the same way `ss` does, over the socket
diagnostics netlink family (`NETLINK_SOCK_DIAG`, `SOCK_DIAG_BY_FAMILY`
with the connection's addresses and ports), and reads `tcp_info` and
the congestion control name from the answer. No external program is
run and no unsafe code is needed: the request and the answer are byte
layouts of the kernel's ABI, read field by field, with fields an older
kernel does not send left at zero. A socket on a link-local address is
found by its interface, which the request names.

* `bgp_fib::sockdiag`: the request, the parser, `TcpConnection`, and
  `tcp_connection(local, peer)`; `Ok(None)` where there is no such
  connection or the system is not Linux.
* `GET /api/tcp?peer=ADDRESS` in the HTTP API: the connection of the
  session with that peer, with times in milliseconds, the two windows
  named by what they allow, and the held-up times also as shares of the
  busy time.
* The status page: a "tcp" link on each established session, which
  expands a row below it with the figures, re-asked on every refresh
  while open. The group rows' span is corrected for the "sent" column.

Also on the status page: the attributes of a received route that have
no column of their own were badges with their value in a tooltip, which
is hard to read. Clicking the attrs cell now expands a row under the
route with every attribute by its name (ORIGIN, AS_PATH, NEXT_HOP, MED,
LOCAL_PREF, COMMUNITIES, LARGE_COMMUNITIES, AGGREGATOR and the rest),
the path identifier, the selection rank and the RPKI and ASPA states;
the badges stay. Both expansions use the terms of `ss` and the RFCs
rather than sentences.

Documentation: the endpoint in the HTTP API reference, and the link and
the expansion in the monitoring guide.

Testing: unit tests of the request and of the parser on a reply built
field by field, and of a "no such socket" and a refused answer; a
Linux-only test that asks the kernel about a loopback connection of the
test's own and about one that does not exist; the lab's basics phase
checks the endpoint for the FRR session; the page was checked by hand
against the daemon in the Linux VM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 11:41:47 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 1ce7eef27e Status page: what needs attention first, glyphs for what repeats
The page opened with every session in bold green, the normal state, and
the brightest numbers on it were the rejection counts, which RPKI doing
its job produces by the thousand. The two sessions that were actually
down were in the same red, thirty rows down. Now:

- A status line at the top says what needs attention, by name and with
  the reason: sessions down, peers restarting, the kernel behind or
  refusing routes, RPKI data missing or about to expire. When nothing
  does, it says so. A second line gives the normal state in grey.
- Colour means one thing each. Red: failed, needs a hand. Amber:
  degraded, worth watching. Green, set small: a system doing its job.
  Grey: inactive and expected. Normal is black; "Established" is no
  longer green, and a rejection count no longer red.
- Peers that need attention come first, then the others grouped by what
  they are to us, with a "problems only" switch. Numbers are right
  aligned with thousands separators; the established time and uptime
  are one cell; the session's properties are glyphs (v4, v6, GR, ENH,
  AP, 2B) with the meaning as a tooltip, as are the roles.
- Received routes: the best route leads, marked ★; attributes without a
  column are glyphs (OTC, AA, AGG, AS4, OID, CL); validity is ✓ ✗ –.
- Rejected routes: those alike (same prefix, reason and path) are one
  row with a count and the last time; each peer's reasons are a bar.
- Communities with a configured name (community_names, new) are badges
  with that name, the number as the tooltip: "comrack", "SwissIX",
  "interconnection 2011" rather than 210036:100:2 and so on. A name for
  a pattern shows the open part after it. The daemon does nothing with
  the names but pass them to the page.
- One lookup box at the top, which takes a peer's name too; a peer's
  name anywhere on the page leads to its routes; table headers and the
  status line stay in view while scrolling.
- The host name comes from gethostname(2), not from running `hostname`,
  which a static binary on a bare machine could not find: comrack said
  "unknown host".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:49:06 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 23e8b4aadb One word for a route that is not taken: rejected
ci / check (push) Canceled after 0s
ci / docs (push) Canceled after 0s
ci / interop (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s
The API, the metrics, the status page and the documentation say a route
is "rejected" (/api/routes/rejected, rejected_routes,
bgp_routes_rejected_total, "Rejected routes"), and the reason type is
Rejection. The code that keeps the record said "refused": RefusalLog,
PeerRefusals, RefusedRoute, RefusalsView, RefusedRouteView, the refusals
module. Those are RejectionLog, PeerRejections, RejectedRoute,
RejectionsView, RejectedRouteView and rejections now, and the log line
says "Rejected a route". Nothing the API or the metrics expose changes.

Refusing stays what it was: a connection turned away at the door
(server::Refusal), a kernel that would not take a route, an OPEN we
would not have.

The two aliases of SendReceive, AddPathSendRecv and OrfSendRecv, are
gone; it is one type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 12:10:58 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 175a13d506 Hygiene: scripts, CI, docs and manifests
- scripts/in-namespace.sh set CARGO_TARGET_DIR to the empty string when
  it was unset, which cargo refuses, and dropped RUSTFLAGS, so the run
  in the namespace rebuilt what kernel-tests.sh had just built. Each is
  passed on only where set.
- The README's list of unsupported features still had route reflection
  and graceful restart, both of which exist; the command-line reference
  lacked SIGUSR1 and exit status 75; the HTTP API reference did not say
  what errors look like.
- The event stream said "missed 0 events" for the best-routes stream,
  which does not count what it missed; it leaves the count out now.
- CI lints bgp-bench with its count-memory feature, which nothing built.
- bgp-bench had two unused dev-dependencies; bgp-integration-tests had
  three dependencies that only its tests and examples use.
- install.sh updates apt before installing; two-routers.sh loses an
  unused variable; small things in the web crate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 11:15:58 +02:00
Rayhaan JaufeerallyandClaude Opus 5.5 17045a3634 Status page: tables for the kernel, communities by AS, attributes as text
The kernel section was one run-on line of interfaces and addresses. It is
now a small table of the installation state, and a table of interfaces
with their IPv4, IPv6 and link-local addresses one per line; ports that
are down and have no addresses share one line at the bottom.

Communities ran together into one block that wrapped anywhere. Each AS's
communities are now a line of their own, kept whole so that they copy,
with a hanging indent where a line wraps; pointing at a line names the AS.
Where control communities are on (now in /api/status), those of ours that
ask for something say what as a tooltip.

A rejected route's details were a fold-out that repeated the summary and
then dumped the decoded attributes as JSON. Rejected routes now have the
same columns as received ones, and both have an "other attributes" column
for what the summary had no field for, as text: aggregator, extended
communities (route targets and origins by name), AS4_PATH with its sets in
braces, originator ID, cluster list, and unknown attributes in hex. The
API has them as summary.other; the JSON stays in `attributes`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:09:02 +02:00
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
Rayhaan JaufeerallyandClaude Fable 5.1 3d946c32ce Code health after review: docs, metrics, structure, one more fuzz target
What the code-health review found, and what a fuzz target written for it
found in its first seconds.

- A set of attributes with the same attribute twice is refused by the
  routing table. Its readers (the selection key, the path, the next hop)
  could each take a different one of the two. The decoder never lets one
  through; the new fuzz target `attrs` found that the table would.
- bgp_peer_up{peer}, to alert on a peer going away, and bgp_build_info.
  Every metric is there from the first scrape. Alerts for a peer down,
  hijack attempts and kernel errors are in the README.
- `bgp --check config` validates a configuration without starting, and
  `--version` says which build it is. CI runs the first on the example
  configuration, which a test now also parses with its commented fields
  put in; the example had to learn about the next hop check.
- Path attributes have a module of their own, packet::attributes,
  re-exported from packet::message as before.
- policy::From, which shadowed the trait, is RouteSource. The server's
  connection, dialer and fsm modules are private. Logging arguments live
  in the binary; the server library no longer depends on clap.
- One is_unicast_link_local (the standard library's) instead of two of
  our own; SelectionKey::origin(); one place where an Import is made.
- READMEs say what the daemon does today rather than what it did not do
  a year ago. Documentation links are checked in CI. Unused dependencies
  and bgp_web::serve are gone; the Docker build is --locked and does not
  ship the fuzz corpus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 19:31:19 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 1fc44798b7 Install best routes in the Linux kernel over netlink
New crate bgp-fib. It follows the routing table's best routes and keeps
the kernel's routing table the same, and learns the machine's interfaces
and addresses from the kernel, which is what says whether a next hop can
be reached.

- netlink: the rtnetlink messages used (routes, addresses, links, errors),
  as bytes and back. No I/O; property tested and fuzzed.
- interfaces: what is directly reachable, and from which interface.
- plan: what to ask the kernel for, given the best routes and what the
  kernel has. No I/O; tested against a model kernel.
- kernel (Linux only): the sockets and the task. Requests go in batches;
  routes of ours found at startup are kept if still right and removed if
  not; a change of interfaces has every route worked out again; a clean
  stop removes our routes.

Only routes marked proto bgp with the configured table and metric are
ever touched. A best route is installed if its next hop is a neighbor;
next hops further away are not resolved through other routes yet, and
such prefixes are counted and shown instead.

Configured with `kernel { table metric }`; off by default. The daemon
needs CAP_NET_ADMIN and stops if the kernel refuses it. On shutdown it
closes sessions and then removes its routes.

/api/kernel and the status page show installed, unresolved and refused
counts, the last error and the interfaces; five bgp_kernel_* metrics.

Testing: scripts/kernel-tests.sh runs, in network namespaces of their
own and with only the capabilities the daemon needs, the installer
against `ip route`, the daemon with a peer, and two daemons that route
for each other with pings across over IPv4 and IPv6.
scripts/linux-vm.sh runs that, or anything else, in a Lima VM from
macOS. CI runs the kernel tests and fuzzes the netlink decoder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 15:15:43 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 1f84235e87 Page through the received routes, on the status page and in the API
/api/routes/received now returns one page: {routes, next_after,
prev_before}. Pages are in prefix order and begin at a prefix (after= or
before=), not at a count, so a table that changes between two pages does
not make them overlap or leave a gap. A page holds whole prefixes, about
`limit` routes' worth.

The table keeps prefixes in no order, so a page is found by looking at
every prefix once and keeping the nearest few; the routes of the others
are not touched (RibTable::for_each_prefix).

The status page shows about 100 routes with first / previous / next
links; the place is kept in the page's address with the lookup.

The response of /api/routes/received was an array before: this changes
its shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 14:33:38 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 bdaf226a16 Validate route origins against the RPKI and refuse invalid routes
With rpki { caches { ... } } in the configuration the server keeps the
validated ROA payloads of a validator and validates every route from a
peer outside its own AS (RFC 6811). An invalid route is refused, unless
its peer has accept_rpki_invalid. Valid and not-found routes are taken;
validity does not otherwise enter route selection.

Validity is not settled when a route arrives: the data changes every few
minutes. The routes follow, without the peer sending anything again.

- A route refused as invalid is held by its session, out of the table.
  When the data changes the session looks at what it holds, and what is
  no longer invalid goes into the table. A held route is the peer's to
  withdraw or replace like any other.
- A route in the table that becomes invalid is found by a task that
  looks through the table when the data changes, at the prefixes the
  changed VRPs cover, and is taken out and held by its session.
- If the data expires because no cache confirms it, nothing is invalid
  any more and everything held is taken.
- A session holds at most 200,000 routes; past that it asks the peer for
  a route refresh when the data changes.

So that the table can be looked through without parsing attributes, an
attribute set's key now has the route's origin AS. Memory per route is
unchanged at 52 bytes on the real table.

Shown: /api/rpki and a section of the status page with the cache, its
state and the age of the data; each route's validity; each peer's count
of held routes. Refusals are recorded under "RPKI invalid". Metrics:
bgp_rpki_vrps and bgp_rpki_updates_total.

Also: Negotiated says whether the peer supports route refresh, and
docs/RFCs.md is brought up to date.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 13:39:40 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 599cd0ad8b Look routes up, and see and alert on the ones that were refused
Two things were hard to find out from the status page and the API: what
the table has for a given prefix or address, and what a peer announced
that was refused and why.

Looking up. /api/routes/received takes a query, and the status page has
a box for it:
- prefix: every route for that prefix, the best first;
- address: every prefix that covers the address, most specific first;
- within: a prefix and everything inside it;
- peer (by name or address) and best narrow any of these.
A query that makes no sense is answered 400 with the reason, which the
page shows. Results come in address order. Routes are shown with the
peer's configured name.

Refused routes. A refused route is not stored, so until now there was
only a count. For each peer the server now keeps how many routes were
refused for each reason, and the latest 200 with the AS path they came
with. The record is kept by the peer's address and outlives the
session, so that a peer that was cut off can still be looked into. It is
at /api/routes/rejected and in a new section of the status page; the
peers table links to it.

Alerting. bgp_routes_rejected_total is now by peer as well as reason.
New: bgp_peer_routes and bgp_peer_max_prefixes, to see a peer nearing
its limit, and bgp_prefix_limit_exceeded_total. The README has rules to
start from. The log has a line for a peer's first refused route and
then at most one a minute, with the count of the others.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 13:14:41 +02:00
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