Commit Graph
67 Commits
Author SHA1 Message Date
Rayhaan JaufeerallyandClaude Fable 5.1 a772322d9e Unwind panics safely: say goodbye to the peer, keep the other sessions
A panic is a bug, and it should cost as little as it can. The server
already closed every session with a Cease and exited when a RIB or a
background task was lost. This fills in the rest:

- A panic inside a session is caught. The peer is sent Cease /
  Administrative Reset, the session's routes are removed in the usual
  order, and the dialer reconnects. Before, the peer was left with a TCP
  connection closing under it and no reason.
- The tables every session shares (the session registry and the collision
  table) no longer refuse to be locked after a panic. Lock poisoning would
  have made each remaining session panic in turn the next time it touched
  one, so one bug in one session took all of them down.
- The server's watcher reports a task that panicked with the panic's
  message, and is now tested for both a panicking task and a stopped RIB.
- The daemon sends panics to its log, with their location, instead of
  leaving them to the default report on stderr.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 23:24:00 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 41090966e4 Stop treating a dead RIB as an empty one
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>
2026-10-01 23:18:59 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 6197a7ad74 Handle errors in UPDATE messages as RFC 7606 says, not by resetting
Any malformed UPDATE used to reset the session, withdrawing every route
learned over it. One bad attribute, typically originated ASes away and
passed on by a peer that did not look at it, could take down a session
carrying a full table.

UpdateMessage::decode now applies RFC 7606 and returns the UPDATE as it is
to be acted on, plus a report of what was wrong:

- Treat-as-withdraw: a malformed or misflagged attribute, a missing
  mandatory one, or an attribute that overruns the attribute list. The
  routes the UPDATE announced (in the body and in MP_REACH_NLRI) are
  withdrawn.
- Attribute discard: malformed ATOMIC_AGGREGATE, AGGREGATOR, AS4_PATH and
  AS4_AGGREGATOR; an attribute sent twice (the first is kept); LOCAL_PREF,
  ORIGINATOR_ID and CLUSTER_LIST from an external peer; AS4_PATH between
  four-octet speakers.
- Session reset remains where the prefixes are in doubt: the lengths that
  frame the message, an unreadable prefix, a malformed or repeated
  MP_REACH_NLRI or MP_UNREACH_NLRI, an unrecognised well-known attribute.
  Each now sends its own subcode instead of always Malformed Attribute
  List.

The session logs each handled error and counts it in
bgp_update_errors_total{handling}. The parser context says whether the
peer is internal (replacing a field nothing read).

Stricter than before, because the checks did not exist:
- An announcement must carry ORIGIN and AS_PATH, NEXT_HOP for routes in the
  body, and LOCAL_PREF from an internal peer.
- An AS_PATH segment must be an AS_SET or AS_SEQUENCE and not empty.
- An attribute value must be used up entirely by its parser; a NEXT_HOP of
  six octets, say, was accepted before.

Bugs found on the way: we sent ATOMIC_AGGREGATE flagged optional and
AGGREGATOR flagged non-transitive. The flags now come from one table used
for both writing and checking.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 23:12:33 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 39632e3c38 Property tests and fuzz targets for the parsers, and the bugs they found
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>
2026-10-01 22:54:37 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 b0762874e0 Make address families open values; keep "supported" a separate list
AFI and SAFI were closed enums, so a family without a variant could not be
represented at all, and the set of negotiated families was a struct with
one bool per family. Supporting another family meant touching both.

- `Afi`, `Safi` and `AfiSafi` in bgp-primitives are newtypes over the wire
  numbers, with constants and names for the common families. Capabilities
  and MP_REACH/MP_UNREACH use them, so any family a peer names is read as
  what it is. The special case that turned a capability for an unlisted
  family into an unknown capability is gone.
- `FamilySet` is a set of `AfiSafi`. What we can exchange routes for is the
  explicit `family::SUPPORTED` list; only those are offered in our OPEN.
- `AddressFamilyId` stays as the IP version of a prefix.
- NLRI is still a list of IP prefixes. MP_REACH/MP_UNREACH for a family
  whose NLRI is something else (labelled, VPN, EVPN, ...) is refused
  rather than misread as prefixes, which is what happened before for the
  SAFIs the old enum listed.

Bug found on the way: the reserved octet in MP_REACH_NLRI was skipped only
for IPv6, so an IPv4 MP_REACH_NLRI was parsed with an extra 0.0.0.0/0
route that the peer never sent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 22:38:40 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 22d0099a46 Negotiate address families and AS number width instead of assuming them
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>
2026-10-01 22:22:54 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 a4a180e948 Make the session state machine explicit and free of I/O
A session was one function that walked OpenSent, OpenConfirm and
Established with the socket, the timers, collision detection, the registry
and the RIB all in the same loop. There was no state or event type, and no
way to test a transition without a TCP connection.

It is now two pieces:

- fsm.rs is the state machine of RFC 4271 section 8 for a connection. It
  takes an Event and returns the Actions to carry out. It owns no socket,
  timer or task. What the OPENs negotiated lives in the states that have
  it, so there is nothing to unwrap. Thirteen tests cover the handshake,
  hold-time negotiation, refused OPENs, messages out of turn, timer expiry,
  collisions and shutdown by feeding it events.
- connection.rs is the driver: it turns the socket, the timers and the
  outbound queue into events and carries the actions out.

Behaviour changes:

- A message that arrived in pieces was lost if a timer fired between the
  pieces: read_exact was used inside select!, which discards what it has
  read when another branch wins. Messages are now framed from a buffer
  that survives cancellation.
- A NOTIFICATION from the peer during the handshake is no longer answered
  with an FSM error; RFC 4271 says to close without replying.
- A session the peer ended is reported as 'peer sent NOTIFICATION: ...'
  with its error, where the dialer used to record 'session closed by peer'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 21:59:55 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 16a4e3b640 Typed errors in the library crates; send the NOTIFICATION RFC 4271 assigns
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>
2026-10-01 21:18:35 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 666471e38d Share path attributes between Adj-RIB-In entries
Each received prefix kept its own copy of the UPDATE's whole attribute list,
and for multiprotocol routes that list included MP_REACH_NLRI with every
other prefix from the same UPDATE: n prefixes stored n copies of n prefixes.

An UPDATE is now turned into a ReceivedUpdate once. Its prefixes share one
attribute list, which leaves out MP_REACH_NLRI and MP_UNREACH_NLRI; the next
hop from MP_REACH_NLRI is kept on the entry. The RIB takes one command per
UPDATE instead of one per prefix, and its table is keyed by peer so that
dropping a peer's routes no longer scans every route.

The status API shows the multiprotocol next hop as an address instead of
its Debug form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 21:08:14 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 2de0a14712 Add the Apache 2.0 licence text and CI
Cargo.toml has declared Apache-2.0 without the licence being in the tree.

CI checks formatting, clippy and the tests on every push, with warnings as
errors. The workflow lives in .github/workflows, which Gitea Actions also
reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 21:01:26 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 b7c7514d3e Fix all clippy warnings and enforce lints across the workspace
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>
2026-10-01 21:01:26 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 126368852a Parse the Multiple Labels capability instead of panicking
The capability parser had a todo!() for capability code 8 (RFC 8277), and
the encoder two more. A peer advertising it took the session task down.
It is now parsed into its (AFI, SAFI, count) entries and round-trips.

Also carries clippy's mechanical fixes for this file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 21:01:26 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 2ab6c1c38b Format the workspace with rustfmt
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>
2026-10-01 20:58:12 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 d8e8924b4e Point the repository URL at ferrix
The repository was renamed. Also drops workspace.package.name, which Cargo
ignores (a package name cannot be inherited) and warned about on every build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 20:53:26 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 b7e5084f83 Tidy the remaining top-level files after the lecture
The preview launcher pointed at the removed slides and editor state does
not belong in the tree. The Dockerfile and README no longer refer to the
lab.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 20:51:32 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 22b9eeacdc Count multiprotocol prefixes in the UPDATE log lines
The 'Sending UPDATE' and 'Received UPDATE' lines counted only the IPv4
fields of the message body, so every IPv6 UPDATE logged announced=0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 20:51:15 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 b800e3a322 Originate routes from the configuration; make the HTTP API read-only
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>
2026-10-01 20:51:15 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 c37cfd029a Remove the lecture material and the ping monitor
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>
2026-10-01 20:45:24 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 b1b72fed39 lab: multi-line answer boxes, one sheet per student
The fill-in fields put the label on its own line with dotted lines to write
on. handout.tex takes \studentnum and \studenthex; build-class.py compiles
one copy per student with the prefix filled in, checks each is two pages
and joins them for double-sided printing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 20:40:55 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 2910a2a800 slides: light theme, final edits, single-file bundle
As presented. The palette is light on request of the reviewer; code blocks
and byte dumps keep a dark ground so the byte colours still work. The OPEN
walk lights the hovered bytes as an inverted block and its table cells are
dark text on a tint of their group colour.

build.py inlines reveal.js, the stylesheets, scripts and Markdown sections
into slides/dist/hands-on-bgp.html for uploading.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 20:40:55 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 8c6e8a80e1 chore: ignore .DS_Store and generated lecture output
slides/dist/ (the single-file bundle) and docs/lab/handout/class.pdf (one
sheet per student) are build products.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 20:40:55 +02:00
Rayhaan Jaufeerally 597941a70a handout finalized 2026-09-24 10:33:45 +02:00
Rayhaan Jaufeerally b607058650 Ferrix web UI working for demo and slides mostly done 2026-09-23 15:38:06 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 f3bb8cc89e lab: exercise sheet cut to two pages, three simple steps
One double-sided A4 sheet with 1in margins, still in the LNCS look.
1. Who announces the address you are using: find your current address and
   its prefix and origin AS, then whois it and your lab prefix.
2. Announce your prefix, read the bytes, find it from outside, ping it.
3. Announce with communities: only the SwissIX route servers, then only
   SWITCH, with reachability checkboxes, one discussion question, clean up.
Sections 1 and 2 fill page one; section 3 has page two. Dropped the ETH
lookup, the long registry comparison, NO_EXPORT, the byte-decoding task,
the reference appendix and most of the bibliography.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 22:07:23 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 83ae367b1c lab: printable exercise sheet for the Hands-on BGP lecture
docs/lab/handout/handout.tex, typeset with Springer's LNCS class (title
block, abstract and keywords, figure, booktabs tables, RFC references), six
A4 pages so it prints double-sided on three sheets. The built PDF is
committed alongside for printing.

Three sections students work through with the slides:
1. Who holds which addresses: whois their own /48 and ethz.ch's prefix in
   the RIPE database, then compare with what is actually routed.
2. Announcing the prefix on the lab page, reading the UPDATE bytes, finding
   it from outside, and reaching it.
3. Steering with communities until the prefix is reachable only from the
   university network (AS559 via the SwissIX route servers), with
   reachability checkboxes for laptop and phone at each step.
Plus a one-page reference appendix: IPv6 notation and hex, the UPDATE
layout, path attributes, glossary.

The lab host, the lab address space and the prefix pattern are macros at
the top of the file. Expected answers were checked against the RIPE
database and RIPEstat (ETH: 2001:67c:10ec::/48, ASSIGNED PI, origin AS559;
lab: 2a0d:d741::/40, AGGREGATED-BY-LIR, route6 origin AS210036).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 22:02:32 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 9d6996c6c1 slides: no step-by-step reveals; UPDATE example is 2a0d:d740::/48 at SwissIX
- Removed every fragment and stepped code highlight: a slide now shows
  everything at once. Two code blocks keep a static highlight on one line.
- Dropped "What stands between you and an outage".
- The UPDATE walked through in section 3 is now 2a0d:d740::/48 as AS210036
  announces it to the SwissIX route servers: next hop 2001:7f8:24::c9
  (pr01's port on the peering LAN), AS path 210036, MED 0 and 210036:10:2,
  matching what pr01's export filter puts on routes to peers. Encoded with
  the packet crate and parsed back to the identical message; the slide
  generator asserted every quoted number, including the next hop, against
  the bytes. Adds a MULTI_EXIT_DISC slide to the walkthrough.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:48:12 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 1399cbd39f slides: "Hands-on BGP", restructured into five sections
- Title is now "Hands-on BGP"; the AS-path diagram is gone from the cover.
- Sections: IPv6 addresses and prefixes; where AS numbers and prefixes
  come from (IANA, RIRs, the RIPE database, IRR vs RPKI); BGP and the
  bytes of an UPDATE; IP transit vs Internet exchanges (the peering LAN as
  an Ethernet segment, route servers); the lab (ferrix, pr01.rue, SwissIX,
  NL-ix, France-IX, DE-CIX, transit).
- The UPDATE walked through in section 3 is a real 94-byte capture from
  ferrix for 2a0d:d741:1::/48. Vertical slides light up one part at a
  time with its decode; the generator asserted every quoted number
  against the capture. Session counts come from the tracked bird.conf,
  the route6 object from the RPSL file, SwissIX's LAN from PeeringDB.
- New components: focusable bytes, decode table, prefix bits, and SVG
  diagrams drawn in the theme's colours.
- Opening index.html straight from disk used to give a title and empty
  panels, because browsers block the module and the section files there.
  The page now explains that, and also reports a failed reveal.js load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:41:31 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 106edd1df0 slides: reveal.js deck template for the BGP lecture
Structured like Matt Godbolt's newer talks (hrt-theme.css in
what-happens-beneath-accu-2026): reveal.js with no stock theme but a custom
dark one, every slide on a rounded panel, IBM Plex Sans and Mono, monokai
code with glowing stepped lines, 16:9 with fade transitions, and the
content in one Markdown file per section.

Made our own: the palette is a handful of variables (with ?accent= presets
for comparing), and his ring-and-thread corner motif became a chain of
nodes and links, an AS path, which also opens the title slide. A wordmark
sits where his decks carry the employer logo.

Also: a small `bird` syntax highlighter for router config on slides, a
bytes component in the four groups of the /statusz bytes view, columns,
boxes, and a `bare` slide class. reveal.js 6 loads from a vendored copy
when slides/vendor-reveal.sh has been run (works offline, ignored by git)
and from the CDN otherwise. Section files carry a starter outline for the
lab that doubles as a tour of the slide types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:30:41 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 e35507d082 lab: track the applied router config and the RIPE objects
- docs/lab/prod/pr01/bird.conf is now the config generated for per-session
  export control (every eBGP session passes its neighbour AS and location
  to the shared export function; customer sessions go through the same
  gate), with the ferrix stanza in its same-host form (::1, multihop).
- docs/lab/ripe/lab-objects.rpsl: the inet6num and the 128 route6 objects
  for the student /48s. Public registry data, no credentials.
- Ignore Python bytecode caches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:21:25 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 c60bd8baf5 docs: use built-in Apache error documents so the login covers every path
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:13:56 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 36b73844af docs: keep Apache's /error/ pages out of the proxy so 401 prompts for a password
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:09:37 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 9d644a212a tools: concurrent load test for the announce API
tools/loadtest.py releases N simulated students through a barrier so they
announce at the same instant, then verifies behaviour rather than only
status codes: the table holds every route under the right owner, each
route became an UPDATE per established session, the event stream lost no
event, concurrent takeover attempts are all refused, withdrawal leaves
nothing behind, and the daemon's own counters show the matching number of
UPDATEs sent for announcements and withdrawals. Standard library only;
supports basic auth and repeated rounds.

Safe by default against a production router: every route carries
210036:0:0 (announce nowhere), leftovers from earlier runs are removed,
prefixes held by real owners are skipped, and everything is withdrawn even
if the run fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 19:49:19 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 1ea2a12bd6 docs: tested Apache reverse proxy with basic auth for the status page
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 19:32:50 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 42d79354e3 docs: peer with BIRD over ::1 on the router; explain the multihop requirement
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 19:29:31 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 fd496d26bf feature: run on the router itself: protoc-free build, per-peer source address
Preparing to run ferrix next to BIRD on the production router.

- build: the config proto is now parsed in pure Rust, so building needs no
  `protoc` binary. It previously worked only where one happened to be
  installed; the Dockerfile in this repo could not have built.
- peers gain `local_address`, the source IP for connections we open (and
  therefore the next hop we advertise). When both speakers share a host
  the OS would otherwise pick an arbitrary local address and the peer,
  which identifies us by address, would refuse the session. Address-family
  mismatches are rejected at config load; an address the host lacks shows
  up as the peer's backoff reason.
- docs/lab/prod/on-router: static musl cross-build instructions, config,
  hardened systemd unit (unprivileged, SIGTERM gives BIRD a Cease),
  loopback addresses for networkd, the BIRD stanza for a same-host
  multihop session, and reverse-proxy examples with TLS and a password,
  since the API has no authentication.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 18:15:09 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 af76ecb345 fix: leave sessions politely on shutdown; answer ROUTE-REFRESH
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>
2026-09-20 17:51:04 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 57099da73d feature: export control by neighbour AS and by location
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>
2026-09-20 17:34:57 +02:00
Rayhaan Jaufeerally 98f25a5847 Add existing BIRD config 2026-09-20 17:29:01 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 77c406e7d1 docs: wire lab export control into a shared BIRD filter via a function and wrapper filters
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 17:21:36 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 b28f59f30d feature: AS210036 export-control large communities for lab routes
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>
2026-09-20 17:02:42 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 eb77090d01 feature: config zippy on statusz; drop prepend options from the UI
- 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>
2026-09-19 22:46:07 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 3f04152f19 docs: simplify prod ferrix config, name the peer pr01.rue.rayhaan.net
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 22:44:46 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 dd838eec9a feature: decoded hover for UPDATE bytes; opt-in auto-refresh
- 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>
2026-09-19 22:20:37 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 20cad53b24 fix: statusz tables no longer flicker on refresh
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>
2026-09-19 17:10:42 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 cc022c3b7b feature: restyle statusz after the classic Google status pages
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>
2026-09-19 17:06:00 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 d23a578d72 docs: listen on 9179 in the prod ferrix config (179 needs root on macOS)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 16:08:22 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 22384d0b07 docs: use the real WireGuard tunnel addresses in the prod peering configs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 16:05:44 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 e41776f253 docs: allow SwissIX large action communities through the ferrix import filter
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 16:00:42 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 160e28690a fix: capability list parsing; advertise IPv6 unicast; BIRD peering config
Preparing to peer with a production BIRD router turned up two problems.

- packet: most Capability::from_wire arms returned the per-capability
  buffer (always empty by then) instead of the bytes following the
  capability. The list parser then saw an empty remainder and silently
  dropped every later capability. Any OPEN listing extended message, role,
  graceful restart, add-path, enhanced route refresh, LLGR, extended next
  hop or an unknown capability before the 4-octet ASN capability lost the
  ASN, so the peer appeared as AS_TRANS (23456) and a configured
  remote_asn was refused with Bad Peer AS. BIRD and FRR both do this.
  Single-capability round-trip tests could not catch it; new tests parse
  every capability with another following it, and a full BIRD capability
  list.
- server: advertise MultiProtocol IPv6 unicast alongside IPv4. Without it
  a peer will not accept the IPv6 routes we originate via MP_REACH_NLRI.
- Interop test: byte-for-byte BIRD 2 OPEN is accepted with the right ASN,
  and an IPv6 End-of-RIB neither drops the session nor creates a route.
- docs/lab/prod: BIRD config for an announce-only ferrix neighbour
  (strict /48 import filter, community scrubbing, unreachable next hop,
  import limit, export none, passive) and the matching ferrix config.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 15:57:23 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 87f96f9389 feature: RFC 4271 §6.8 connection collision detection
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>
2026-09-19 15:05:13 +02:00