Compare commits

...
38 Commits
Author SHA1 Message Date
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
Rayhaan JaufeerallyandClaude Fable 5.1 a137160b48 feature: lab topology, student handout; drop peer routes on session end
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>
2026-09-16 23:26:10 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 e2de44ba78 feature: active dialer for configured peers
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>
2026-09-16 23:22:08 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 fb48fc0a85 feature: statusz page and fake_peer example
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>
2026-09-16 23:17:27 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 c3f7713965 feature: HTTP API, event stream and Prometheus metrics (bgp-web)
Phase 2 of the teaching demo: everything the status page will need is now
reachable over HTTP.

- New crate crates/web (axum). JSON endpoints for status, peers, local
  routes (announce / withdraw), received routes, the exact UPDATE bytes a
  local route produces per session, and well-known community names.
  Communities are accepted as ASN:VALUE or by name (NO_EXPORT, BLACKHOLE,
  ...); large communities as A:B:C. Policy errors map to 403/409/422.
- GET /api/events is a server-sent event stream merging session up/down,
  local route and Adj-RIB-In changes, with lag notifications.
- GET /metricz serves Prometheus metrics; the server crate now counts
  messages sent/received by type and sessions established.
- The runnable main moves from crates/server to bin/bgp so it can depend
  on both server and web without a cycle. `cargo run -- config.textpb`.
- Config gains http_endpoint; unset disables the API.
- Integration tests drive the API against a live server with a real BGP
  peer, including verifying the wire view matches the bytes the peer got.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:10:16 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 73e9fbf0ad fix: sample config used bracketed IPv6 address which the endpoint parser rejects
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 22:57:02 +02:00
Rayhaan JaufeerallyandClaude Fable 5.1 ad07233196 feature: locally originated routes, session registry, outbound UPDATEs
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>
2026-09-16 22:56:29 +02:00
91 changed files with 12859 additions and 237 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "slides",
"runtimeExecutable": "python3",
"runtimeArgs": ["slides/serve.py", "8765"],
"port": 8765
}
]
}
+2
View File
@@ -0,0 +1,2 @@
target
.git
+4
View File
@@ -1 +1,5 @@
/target
__pycache__/
slides/reveal.js/
slides/dist/
.DS_Store
Generated
+787 -89
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -19,6 +19,7 @@ bgp-packet = { path = "crates/packet" }
bgp-primitives = { path = "crates/primitives" }
bgp-rib = { path = "crates/rib" }
bgp-server = { path = "crates/server" }
bgp-web = { path = "crates/web" }
bgp-integration-tests = { path = "crates/integration-tests" }
# --- General ---
@@ -39,3 +40,7 @@ protobuf = { package = "protobuf", version = "3.7.2" }
protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" }
clap = { version = "4.5.53", features = ["derive"] }
futures = "0.3"
libc = "0.2"
axum = "0.8.9"
tokio-stream = { version = "0.1.19", features = ["sync"] }
reqwest = { version = "0.13.5", default-features = false, features = ["json"] }
+10
View File
@@ -0,0 +1,10 @@
# Builds the ferrix_bgp daemon for the lab. See docs/lab/README.md.
FROM rust:1.92-bookworm AS build
WORKDIR /src
COPY . .
RUN cargo build --release -p bgp
FROM debian:bookworm-slim
COPY --from=build /src/target/release/bgp /usr/local/bin/bgp
ENTRYPOINT ["/usr/local/bin/bgp"]
CMD ["/etc/ferrix/server.textpb", "--level", "info"]
+4 -2
View File
@@ -20,8 +20,10 @@ We aim for the 1.0 release to be functional and stable to operate peering sessio
- ✅ Capabilities from RFC4271
- ✅ Update message
- ☐ Notification message
- ☐ Implement scaffolding required to start and configure a server
- ☐ Implement connection logic to establish sessions with peers
- ✅ Implement scaffolding required to start and configure a server
- ✅ Implement connection logic to establish sessions with peers (passive and active)
- ✅ Locally originated routes with communities, announced to all peers
- ✅ HTTP API, live status page and Prometheus metrics
- ☐ Implement an efficient data structure for storing prefixes (e.g. Poptrie)
- ☐ Filtering for peer RIB in / out
- ☐ Main / auxillary RIBs for exchanging routes between peers
+7 -2
View File
@@ -7,5 +7,10 @@ name = "bgp"
version.workspace = true
[dependencies]
eyre.workspace = true
tokio.workspace = true
bgp-server.workspace = true
bgp-web.workspace = true
clap.workspace = true
eyre.workspace = true
protobuf.workspace = true
tokio.workspace = true
tracing.workspace = true
+58 -4
View File
@@ -1,9 +1,63 @@
# BGP
This crate provides a binary which runs the Border Gateway Protocol (BGP).
This crate provides the `bgp` binary which runs the Border Gateway Protocol
daemon together with its HTTP API.
Please refer to the other implementation crates part of this repository where most of the actual implemntation lives.
```
cargo run -- path/to/config.textpb
```
Warning: This is still very much a work in progress and is not yet ready for use.
The configuration format is defined in `crates/server/proto/config.proto`;
`testdata/server.textpb` is a worked example. When `http_endpoint` is set the
binary also serves:
The main ongoing work is happening currently in the BGP packet crate.
| Path | Purpose |
|---|---|
| `GET /statusz` | Status page: peers, announce form, local and received routes, live event log |
| `GET /api/status` | Router ID, ASN, uptime and table sizes |
| `GET /api/peers` | Established sessions |
| `GET /api/routes/local` | Locally originated routes |
| `POST /api/routes/local` | Announce a route (JSON body, see below) |
| `DELETE /api/routes/local` | Withdraw a route (JSON body: `prefix`, optional `owner`) |
| `GET /api/routes/local/wire?prefix=…` | The exact UPDATE bytes sent to each peer |
| `GET /api/routes/received` | Adj-RIB-In contents |
| `GET /api/communities/well-known` | Names accepted in place of numeric communities |
| `GET /api/events` | Server-sent events for session and route changes |
| `GET /metricz` | Prometheus metrics |
Announce body:
```json
{
"prefix": "10.100.1.0/24",
"owner": "alice",
"origin": "IGP",
"prepend": 2,
"med": 100,
"communities": ["65000:100", "NO_EXPORT"],
"large_communities": ["65000:1:2"]
}
```
Only `prefix` and `owner` are required. Routes are subject to the
`local_route_policy` in the configuration.
## Trying it without a router
`fake_peer` is a minimal BGP speaker that connects to the daemon, announces a
prefix, prints every UPDATE it receives and, with `--echo`, re-announces each
received prefix back with its own ASN prepended (dropping NO_EXPORT routes).
That makes locally announced prefixes show up in the received table as if
they had travelled through a neighbouring AS:
```
cargo run -- testdata/server.textpb
cargo run -p bgp-integration-tests --example fake_peer -- \
--server 127.0.0.1:179 --asn 65001 --announce 203.0.113.0/24 --echo
```
Please refer to the other implementation crates in this repository where most
of the actual implementation lives.
Warning: This is still very much a work in progress and is not yet ready for
production use.
+93 -3
View File
@@ -1,6 +1,96 @@
use eyre::Result;
use std::sync::Arc;
use std::time::Duration;
use bgp_server::config::Config;
use bgp_server::ping::PingMonitor;
use bgp_server::proto::config::BgpConfig;
use bgp_server::server::BgpServerBuilder;
use bgp_server::tracing::TracingArgs;
use bgp_web::{AppState, RouterInfo};
use clap::Parser;
use eyre::{Context, Result};
use tracing::{error, info, warn};
#[derive(Parser, Debug)]
struct ServerArgs {
/// Path to the textproto configuration file.
config: String,
#[command(flatten)]
tracing: TracingArgs,
}
#[tokio::main]
pub async fn main() -> Result<()> {
todo!("Implement the server");
async fn main() -> Result<()> {
let args = ServerArgs::parse();
args.tracing.install()?;
let config_string =
std::fs::read_to_string(&args.config).wrap_err("Failed to open config file")?;
let proto_config = protobuf::text_format::parse_from_str::<BgpConfig>(&config_string)
.wrap_err("Failed to parse config file")?;
let config = Config::from_proto(proto_config).wrap_err("Invalid configuration")?;
let server = BgpServerBuilder::new(config).bind().await?;
let pings = match &server.config().ping_monitor {
Some(prefixes) => PingMonitor::start(prefixes.clone(), server.local_rib.clone()),
None => PingMonitor::disabled(),
};
if let Some(http_endpoint) = server.config().http_endpoint {
let state = AppState {
info: Arc::new(
RouterInfo::new(
server.config().router_id,
server.config().local_asn,
env!("CARGO_PKG_VERSION"),
)
.with_config(&args.config, &config_string),
),
registry: server.registry.clone(),
local_rib: server.local_rib.clone(),
adj_rib_in: server.adj_rib_in.clone(),
pings,
};
tokio::spawn(async move {
if let Err(e) = bgp_web::serve(state, http_endpoint).await {
error!(err = %e, "HTTP server exited");
}
});
}
// Leave politely: tell every peer we are going (Cease / Administrative
// Shutdown) rather than letting them find a dead TCP connection, which
// makes them slower to take us back.
let registry = server.registry.clone();
tokio::select! {
result = server.run() => result,
signal = shutdown_signal() => {
info!(signal, "Shutting down, closing BGP sessions");
let left = registry.shutdown(Duration::from_secs(2)).await;
if left > 0 {
warn!(sessions = left, "Some sessions did not close in time");
}
Ok(())
}
}
}
/// Resolves with the name of the first termination signal received.
async fn shutdown_signal() -> &'static str {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => "SIGINT",
_ = term.recv() => "SIGTERM",
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
"ctrl-c"
}
}
+4
View File
@@ -8,6 +8,10 @@ bgp-packet.workspace = true
bgp-primitives.workspace = true
bgp-rib.workspace = true
bgp-server.workspace = true
bgp-web.workspace = true
bytes.workspace = true
clap.workspace = true
eyre.workspace = true
reqwest.workspace = true
serde_json.workspace = true
tokio.workspace = true
@@ -0,0 +1,158 @@
//! A minimal stand-in for a lab router: connects to a ferrix_bgp instance,
//! completes the handshake, optionally announces some prefixes, and prints
//! every UPDATE it receives. Useful for demos when no real router is around.
//!
//! cargo run -p bgp-integration-tests --example fake_peer -- \
//! --server 127.0.0.1:1790 --asn 65001 --announce 203.0.113.0/24
//!
//! With `--echo`, every prefix received is re-announced back to the server
//! with this peer's ASN prepended, which lets a single ferrix instance show
//! its own routes "coming back" in the received table.
//!
//! With `--listen ADDR` it instead waits for the server to dial it (one
//! connection at a time), which exercises the server's active side.
use std::net::Ipv4Addr;
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_packet::message::{
AsPathAttribute, CommunitiesPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute,
PathAttribute, UpdateMessage,
};
use bgp_primitives::ip_prefix::IpPrefix;
use clap::Parser;
use eyre::Result;
use tokio::net::TcpListener;
#[derive(Parser, Debug)]
struct Args {
/// Address of the ferrix_bgp listener to connect to.
#[arg(long, default_value = "127.0.0.1:1790", conflicts_with = "listen")]
server: String,
/// Instead of connecting, listen here and accept connections from the
/// server, one at a time, forever.
#[arg(long)]
listen: Option<String>,
/// ASN this peer speaks as.
#[arg(long, default_value_t = 65001)]
asn: u32,
/// Router ID to send in OPEN.
#[arg(long, default_value = "10.0.0.2")]
router_id: Ipv4Addr,
/// Hold time to negotiate.
#[arg(long, default_value_t = 30)]
hold_time: u16,
/// Prefixes to announce after the session is established.
#[arg(long)]
announce: Vec<String>,
/// Re-announce every received prefix back to the server, with our ASN
/// prepended and NO_EXPORT-tagged routes dropped.
#[arg(long)]
echo: bool,
}
const NO_EXPORT: (u16, u16) = (0xFFFF, 0xFF01);
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
match &args.listen {
Some(listen) => {
let listener = TcpListener::bind(listen).await?;
println!("listening on {}", listener.local_addr()?);
loop {
let (stream, from) = listener.accept().await?;
println!("accepted connection from {from}");
if let Err(e) = run(&args, TestPeer::from_stream(stream), from).await {
println!("session ended: {e}");
}
}
}
None => {
let addr = args.server.parse()?;
run(&args, TestPeer::connect(addr).await?, addr).await
}
}
}
async fn run(args: &Args, mut peer: TestPeer, addr: std::net::SocketAddr) -> Result<()> {
let open = peer.handshake(args.asn, args.router_id, args.hold_time).await?;
println!("established with {} (AS{} router-id {})", addr, open.asn, open.identifier);
for p in &args.announce {
let prefix = IpPrefix::try_from(p.as_str())?;
let update = announce(prefix.clone(), vec![args.asn], vec![(args.asn as u16, 1)]);
peer.send(&Message::Update(update)).await?;
println!("announced {prefix}");
}
let keepalive = Duration::from_secs((args.hold_time / 3).max(1) as u64);
let mut ticker = tokio::time::interval(keepalive);
loop {
tokio::select! {
_ = ticker.tick() => peer.send(&Message::KeepAlive).await?,
msg = peer.recv() => match msg? {
Message::KeepAlive => {}
Message::Update(u) => {
for w in &u.withdrawn_routes {
println!("WITHDRAW {w}");
}
for a in &u.announced_routes {
println!("UPDATE {a} attrs={:?}", u.path_attributes);
}
if args.echo {
echo_back(&mut peer, &u, args.asn).await?;
}
}
Message::Notification(n) => {
println!("NOTIFICATION {}; session over", n.error);
return Ok(());
}
other => println!("unexpected {other:?}"),
},
}
}
}
fn announce(prefix: IpPrefix, as_path: Vec<u32>, communities: Vec<(u16, u16)>) -> UpdateMessage {
let mut path_attributes = vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(as_path),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 2))),
];
if !communities.is_empty() {
path_attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(communities)));
}
UpdateMessage { withdrawn_routes: vec![], path_attributes, announced_routes: vec![prefix] }
}
async fn echo_back(peer: &mut TestPeer, u: &UpdateMessage, asn: u32) -> Result<()> {
let mut as_path = vec![asn];
let mut communities = vec![];
for attr in &u.path_attributes {
match attr {
PathAttribute::AsPath(p) => as_path.extend(p.segments.iter().flat_map(|s| s.path.iter().copied())),
PathAttribute::Communities(c) => communities = c.0.clone(),
_ => {}
}
}
if communities.contains(&NO_EXPORT) {
println!(" not echoing: NO_EXPORT");
return Ok(());
}
for prefix in &u.announced_routes {
peer.send(&Message::Update(announce(prefix.clone(), as_path.clone(), communities.clone()))).await?;
println!(" echoed {prefix} back with path {as_path:?}");
}
if !u.withdrawn_routes.is_empty() {
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: u.withdrawn_routes.clone(),
path_attributes: vec![],
announced_routes: vec![],
}))
.await?;
println!(" echoed withdraw of {:?}", u.withdrawn_routes.iter().map(|p| p.to_string()).collect::<Vec<_>>());
}
Ok(())
}
+20 -2
View File
@@ -1,6 +1,6 @@
use std::net::{Ipv4Addr, SocketAddr};
use bgp_packet::message::{BGP_HEADER_LEN, Message};
use bgp_packet::message::{BGP_HEADER_LEN, Message, UpdateMessage};
use bgp_packet::open::{Capability, OpenMessage, OpenOption};
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
use bgp_primitives::parser::ParserContext;
@@ -19,7 +19,13 @@ pub struct TestPeer {
impl TestPeer {
pub async fn connect(addr: SocketAddr) -> Result<Self> {
let stream = TcpStream::connect(addr).await?;
Ok(Self { stream, ctx: ParserContext::default() })
Ok(Self::from_stream(stream))
}
/// Wraps an already-connected stream, e.g. one accepted from a server
/// that dialled us.
pub fn from_stream(stream: TcpStream) -> Self {
Self { stream, ctx: ParserContext::default() }
}
/// Send a BGP message.
@@ -49,6 +55,18 @@ impl TestPeer {
Ok(msg)
}
/// Read messages until an UPDATE arrives, skipping KEEPALIVEs. Any other
/// message type is an error.
pub async fn recv_update(&mut self) -> Result<UpdateMessage> {
loop {
match self.recv().await? {
Message::Update(u) => return Ok(u),
Message::KeepAlive => {}
other => bail!("Expected UPDATE, got {:?}", other),
}
}
}
/// Receive and unwrap as `OpenMessage`, failing if a different type arrives.
pub async fn recv_open(&mut self) -> Result<OpenMessage> {
match self.recv().await? {
+80 -6
View File
@@ -1,9 +1,14 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use bgp_rib::AdjRibIn;
use bgp_rib::{AdjRibIn, LocalRib, LocalRibPolicy};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_server::config::Config;
use bgp_server::ping::PingMonitor;
use bgp_server::server::BgpServerBuilder;
use bgp_server::session::SessionRegistry;
use bgp_web::{AppState, RouterInfo};
use eyre::Result;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
/// A BGP server started on an ephemeral port for testing.
@@ -14,6 +19,14 @@ pub struct TestServer {
pub addr: SocketAddr,
/// Handle to the server's Adj-RIB-In for test introspection.
pub adj_rib_in: AdjRibIn,
/// Handle to the server's locally originated routes.
pub local_rib: LocalRib,
/// Live view of the server's established sessions.
pub registry: SessionRegistry,
/// Ping monitor without a socket, watching 2001:db8::/32; tests feed it
/// through `record`.
pub pings: PingMonitor,
pub local_asn: u32,
}
impl TestServer {
@@ -24,17 +37,38 @@ impl TestServer {
/// Start a server with an explicit hold time (useful for timer tests).
pub async fn start_with_hold_time(local_asn: u32, hold_time_secs: u16) -> Result<Self> {
Self::start_inner(local_asn, hold_time_secs).await
Self::start_inner(local_asn, hold_time_secs, LocalRibPolicy::default()).await
}
async fn start_inner(local_asn: u32, hold_time_secs: u16) -> Result<Self> {
let config = Config {
/// Start a server with a policy restricting locally originated routes.
pub async fn start_with_policy(local_asn: u32, policy: LocalRibPolicy) -> Result<Self> {
Self::start_inner(local_asn, 90, policy).await
}
async fn start_inner(local_asn: u32, hold_time_secs: u16, policy: LocalRibPolicy) -> Result<Self> {
Self::start_with(local_asn, |c| {
c.hold_time_secs = hold_time_secs;
c.local_route_policy = policy;
})
.await
}
/// Start a server after letting `customize` edit the default test config
/// (e.g. to add configured peers).
pub async fn start_with(local_asn: u32, customize: impl FnOnce(&mut Config)) -> Result<Self> {
let mut config = Config {
router_id: Ipv4Addr::new(127, 0, 0, 1),
local_asn,
hold_time_secs,
hold_time_secs: 90,
listen_endpoints: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)],
peers: vec![],
connect_retry: std::time::Duration::from_secs(5),
collision_detect_established_state: false,
local_route_policy: LocalRibPolicy::default(),
http_endpoint: None,
ping_monitor: None,
};
customize(&mut config);
let bound = BgpServerBuilder::new(config).bind().await?;
let addr = bound
@@ -43,9 +77,49 @@ impl TestServer {
.next()
.expect("at least one listener");
let adj_rib_in = bound.adj_rib_in.clone();
let local_rib = bound.local_rib.clone();
let registry = bound.registry.clone();
let pings = PingMonitor::without_socket(
vec![IpPrefix::try_from("2001:db8::/32").expect("valid prefix")],
Some(local_rib.clone()),
);
let handle = tokio::spawn(bound.run());
Ok(Self { handle, addr, adj_rib_in })
Ok(Self { handle, addr, adj_rib_in, local_rib, registry, pings, local_asn })
}
}
impl TestServer {
/// Starts the HTTP API on an ephemeral loopback port and returns its
/// base URL (e.g. `http://127.0.0.1:12345`). The API task is aborted
/// when the returned guard drops.
pub async fn serve_http(&self) -> Result<(String, HttpGuard)> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let base_url = format!("http://{}", listener.local_addr()?);
let state = AppState {
info: std::sync::Arc::new(
RouterInfo::new(Ipv4Addr::new(127, 0, 0, 1), self.local_asn, "test")
.with_config("test.textpb", format!("local_asn: {}\n", self.local_asn)),
),
registry: self.registry.clone(),
local_rib: self.local_rib.clone(),
adj_rib_in: self.adj_rib_in.clone(),
pings: self.pings.clone(),
};
let handle = tokio::spawn(bgp_web::serve_on(state, listener));
Ok((base_url, HttpGuard { handle }))
}
}
/// Aborts the HTTP API task when dropped.
pub struct HttpGuard {
handle: JoinHandle<Result<()>>,
}
impl Drop for HttpGuard {
fn drop(&mut self) {
self.handle.abort();
}
}
+380
View File
@@ -0,0 +1,380 @@
//! End-to-end tests for locally originated routes: announcing via the
//! `LocalRib` handle must produce UPDATEs on every established session.
use std::net::Ipv4Addr;
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::message::{
AsPathAttribute, CommunitiesPathAttribute, LargeCommunitiesPathAttribute, LargeCommunity,
NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage,
};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRibError, LocalRibPolicy, LocalRoute, LocalRouteAttrs};
use bgp_server::session::SessionEvent;
use eyre::Result;
const LOCAL_ASN: u32 = 65000;
const PEER_ASN: u32 = 65001;
const TIMEOUT: Duration = Duration::from_secs(5);
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
fn route(p: &str, attrs: LocalRouteAttrs) -> LocalRoute {
LocalRoute::new(prefix(p), "alice", attrs)
}
async fn established_peer(server: &TestServer) -> Result<TestPeer> {
established_peer_with_id(server, Ipv4Addr::new(10, 0, 0, 1)).await
}
/// Distinct routers need distinct BGP Identifiers, or the second connection
/// is (correctly) treated as a collision with the first.
async fn established_peer_with_id(server: &TestServer, router_id: Ipv4Addr) -> Result<TestPeer> {
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(PEER_ASN, router_id, 90).await?;
Ok(peer)
}
async fn next_update(peer: &mut TestPeer) -> Result<UpdateMessage> {
tokio::time::timeout(TIMEOUT, peer.recv_update()).await?
}
/// Waits until the registry reports `n` established sessions.
async fn wait_for_sessions(server: &TestServer, n: usize) -> Result<()> {
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().len() != n {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Session registry
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_registry_tracks_session_lifecycle() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut events = server.registry.subscribe();
assert!(server.registry.list().is_empty());
let peer = established_peer(&server).await?;
let up = tokio::time::timeout(TIMEOUT, events.recv()).await??;
let SessionEvent::Up(handle) = up else { panic!("expected Up, got {up:?}") };
assert_eq!(handle.peer.asn, PEER_ASN);
assert_eq!(handle.local_addr, server.addr);
assert!(handle.four_octet_asn);
assert_eq!(server.registry.list().len(), 1);
drop(peer);
let down = tokio::time::timeout(TIMEOUT, events.recv()).await??;
assert!(matches!(down, SessionEvent::Down(p) if p == handle.peer), "got {down:?}");
assert!(server.registry.list().is_empty());
Ok(())
}
// ---------------------------------------------------------------------------
// Announce / withdraw
// ---------------------------------------------------------------------------
/// A route announced while the session is up is sent immediately, with the
/// attributes the announcer chose and our address as the next hop.
#[tokio::test]
async fn test_announce_after_session_up() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
server
.local_rib
.announce(route(
"10.100.1.0/24",
LocalRouteAttrs {
origin: OriginPathAttribute::IGP,
prepend: 0,
med: None,
communities: vec![(65000, 100), (65535, 65281)],
large_communities: vec![LargeCommunity { global_admin: 65000, data_1: 7, data_2: 8 }],
},
))
.await?;
let update = next_update(&mut peer).await?;
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
assert!(update.withdrawn_routes.is_empty());
assert_eq!(
update.path_attributes,
vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![LOCAL_ASN]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::LOCALHOST)),
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65000, 100), (65535, 65281)])),
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
communities: vec![LargeCommunity { global_admin: 65000, data_1: 7, data_2: 8 }],
}),
]
);
Ok(())
}
/// Routes announced before any session exists are replayed when a session
/// reaches Established.
#[tokio::test]
async fn test_routes_replayed_on_session_up() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
server.local_rib.announce(route("10.100.2.0/24", LocalRouteAttrs::default())).await?;
let mut peer = established_peer(&server).await?;
let mut seen = vec![next_update(&mut peer).await?, next_update(&mut peer).await?]
.into_iter()
.flat_map(|u| u.announced_routes)
.map(|p| p.to_string())
.collect::<Vec<_>>();
seen.sort();
assert_eq!(seen, vec!["10.100.1.0/24", "10.100.2.0/24"]);
Ok(())
}
/// An announcement is fanned out to every established session.
#[tokio::test]
async fn test_announce_reaches_all_sessions() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut a = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 1)).await?;
let mut b = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 2)).await?;
wait_for_sessions(&server, 2).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
for peer in [&mut a, &mut b] {
let update = next_update(peer).await?;
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
}
Ok(())
}
#[tokio::test]
async fn test_withdraw_sends_withdrawn_route() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
let _ = next_update(&mut peer).await?;
server.local_rib.withdraw(prefix("10.100.1.0/24"), Some("alice")).await?;
let update = next_update(&mut peer).await?;
assert_eq!(update.withdrawn_routes, vec![prefix("10.100.1.0/24")]);
assert!(update.announced_routes.is_empty());
assert!(update.path_attributes.is_empty());
Ok(())
}
/// Re-announcing with new attributes sends a fresh UPDATE (implicit replace).
#[tokio::test]
async fn test_reannounce_with_prepend_and_med() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
let first = next_update(&mut peer).await?;
assert_eq!(first.path_attributes[1], AsPathAttribute::from_asns(vec![LOCAL_ASN]));
server
.local_rib
.announce(route(
"10.100.1.0/24",
LocalRouteAttrs { prepend: 3, med: Some(42), ..LocalRouteAttrs::default() },
))
.await?;
let second = next_update(&mut peer).await?;
assert_eq!(second.announced_routes, vec![prefix("10.100.1.0/24")]);
assert_eq!(second.path_attributes[1], AsPathAttribute::from_asns(vec![LOCAL_ASN; 4]));
assert!(second
.path_attributes
.iter()
.any(|a| matches!(a, PathAttribute::MultiExitDisc(m) if m.0 == 42)));
Ok(())
}
/// The session stays healthy (keepalives keep flowing) while UPDATEs are sent.
#[tokio::test]
async fn test_keepalives_continue_alongside_updates() -> Result<()> {
let server = TestServer::start_with_hold_time(LOCAL_ASN, 3).await?;
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 3).await?;
wait_for_sessions(&server, 1).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
let mut saw_update = false;
let mut saw_keepalive = false;
tokio::time::timeout(TIMEOUT, async {
while !(saw_update && saw_keepalive) {
match peer.recv().await? {
bgp_packet::message::Message::Update(_) => saw_update = true,
bgp_packet::message::Message::KeepAlive => saw_keepalive = true,
other => eyre::bail!("unexpected {other:?}"),
}
}
Ok::<_, eyre::Error>(())
})
.await??;
Ok(())
}
/// Routes learned from a peer disappear from the Adj-RIB-In when its
/// session ends, with a Withdrawn event for each.
#[tokio::test]
async fn test_received_routes_removed_when_session_closes() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut rib_events = server.adj_rib_in.subscribe();
let mut session_events = server.registry.subscribe();
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
peer.send(&bgp_packet::message::Message::Update(bgp_packet::message::UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![PEER_ASN]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 1))),
],
announced_routes: vec![prefix("203.0.113.0/24"), prefix("198.51.100.0/24")],
}))
.await?;
for _ in 0..2 {
let ev = tokio::time::timeout(TIMEOUT, rib_events.recv()).await??;
assert!(matches!(ev, bgp_rib::RibEvent::Announced { .. }), "{ev:?}");
}
assert_eq!(server.adj_rib_in.snapshot().await.len(), 2);
drop(peer);
let mut withdrawn = vec![];
for _ in 0..2 {
match tokio::time::timeout(TIMEOUT, rib_events.recv()).await?? {
bgp_rib::RibEvent::Withdrawn { prefix, .. } => withdrawn.push(prefix.to_string()),
other => panic!("expected Withdrawn, got {other:?}"),
}
}
withdrawn.sort();
assert_eq!(withdrawn, vec!["198.51.100.0/24", "203.0.113.0/24"]);
assert!(server.adj_rib_in.snapshot().await.is_empty());
// Withdrawals precede the Down event.
let _up = tokio::time::timeout(TIMEOUT, session_events.recv()).await??;
let down = tokio::time::timeout(TIMEOUT, session_events.recv()).await??;
assert!(matches!(down, SessionEvent::Down(_)), "{down:?}");
Ok(())
}
// ---------------------------------------------------------------------------
// Route refresh and shutdown
// ---------------------------------------------------------------------------
/// ROUTE-REFRESH for IPv4 unicast exactly as a router sends it (RFC 2918):
/// 19-byte header plus AFI, reserved, SAFI.
#[rustfmt::skip]
const ROUTE_REFRESH_IPV4: &[u8] = &[
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0x00, 0x17, 0x05,
0x00, 0x01, 0x00, 0x01,
];
/// A peer whose import policy changed asks for our routes again; it must get
/// every local route re-sent, and the session must stay up.
#[tokio::test]
async fn test_route_refresh_resends_local_routes() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
server.local_rib.announce(route("10.100.2.0/24", LocalRouteAttrs::default())).await?;
let _ = next_update(&mut peer).await?;
let _ = next_update(&mut peer).await?;
peer.send_raw(ROUTE_REFRESH_IPV4).await?;
let mut resent = vec![next_update(&mut peer).await?, next_update(&mut peer).await?]
.into_iter()
.flat_map(|u| u.announced_routes)
.map(|p| p.to_string())
.collect::<Vec<_>>();
resent.sort();
assert_eq!(resent, vec!["10.100.1.0/24", "10.100.2.0/24"]);
assert_eq!(server.registry.list().len(), 1, "session must survive a route refresh");
Ok(())
}
/// Shutting down tells each peer why (Cease / Administrative Shutdown) and
/// closes the sessions, instead of leaving dead TCP connections behind.
#[tokio::test]
async fn test_shutdown_sends_cease_to_every_peer() -> Result<()> {
use bgp_packet::errors::{BgpError, CeaseSubcode};
use bgp_packet::message::Message;
let server = TestServer::start(LOCAL_ASN).await?;
let mut a = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 1)).await?;
let mut b = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 2)).await?;
wait_for_sessions(&server, 2).await?;
let left = server.registry.shutdown(Duration::from_secs(2)).await;
assert_eq!(left, 0, "sessions still open after shutdown");
assert!(server.registry.is_shutting_down());
for peer in [&mut a, &mut b] {
let notif = tokio::time::timeout(TIMEOUT, async {
loop {
match peer.recv().await? {
Message::Notification(n) => return Ok::<_, eyre::Error>(n),
Message::KeepAlive => {}
other => eyre::bail!("unexpected {other:?}"),
}
}
})
.await??;
assert!(matches!(notif.error, BgpError::Cease(CeaseSubcode::AdminShutdown)), "{notif:?}");
assert!(peer.is_closed().await);
}
Ok(())
}
// ---------------------------------------------------------------------------
// Policy
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_policy_rejects_prefix_outside_allowlist() -> Result<()> {
let server = TestServer::start_with_policy(
LOCAL_ASN,
LocalRibPolicy { allowed_prefixes: vec![prefix("10.100.0.0/16")], max_routes_per_owner: 2 },
)
.await?;
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
let err = server
.local_rib
.announce(route("192.0.2.0/24", LocalRouteAttrs::default()))
.await
.unwrap_err();
assert_eq!(err, LocalRibError::NotAllowed(prefix("192.0.2.0/24")));
// An allowed prefix still goes through on the same session.
server.local_rib.announce(route("10.100.9.0/24", LocalRouteAttrs::default())).await?;
let update = next_update(&mut peer).await?;
assert_eq!(update.announced_routes, vec![prefix("10.100.9.0/24")]);
Ok(())
}
+287
View File
@@ -0,0 +1,287 @@
//! Connection collision detection (RFC 4271 §6.8) against a live server.
//!
//! The test server's BGP Identifier is 127.0.0.1. A test peer picks an
//! identifier above or below that to steer which connection must survive.
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::errors::{BgpError, CeaseSubcode, OpenMsgSubcode};
use bgp_packet::message::{
AsPathAttribute, Message, NextHopPathAttribute, NotificationMessage, OriginPathAttribute,
PathAttribute, UpdateMessage,
};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRoute, LocalRouteAttrs};
use bgp_server::collision::Direction;
use bgp_server::config::PeerConfig;
use eyre::Result;
use tokio::net::TcpListener;
const LOCAL_ASN: u32 = 65000;
const PEER_ASN: u32 = 65001;
const TIMEOUT: Duration = Duration::from_secs(5);
/// Above the server's 127.0.0.1.
const HIGH_ID: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1);
/// Below the server's 127.0.0.1.
const LOW_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1);
async fn server_dialing(listener: &TcpListener) -> Result<TestServer> {
let endpoint: SocketAddr = listener.local_addr()?;
TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig { name: "lab".into(), endpoint, remote_asn: PEER_ASN, hold_time_secs: 90, local_address: None }];
})
.await
}
/// Exchanges OPENs only, leaving the server's side in OpenConfirm (it has
/// sent its KEEPALIVE and is waiting for ours).
async fn open_only(peer: &mut TestPeer, router_id: Ipv4Addr) -> Result<()> {
peer.send_open(PEER_ASN, router_id, 90).await?;
peer.recv_open().await?;
Ok(())
}
/// Finishes the handshake begun by [`open_only`].
async fn confirm(peer: &mut TestPeer) -> Result<()> {
match tokio::time::timeout(TIMEOUT, peer.recv()).await?? {
Message::KeepAlive => {}
other => eyre::bail!("expected KEEPALIVE, got {other:?}"),
}
peer.send(&Message::KeepAlive).await
}
/// Reads until a NOTIFICATION arrives, skipping KEEPALIVEs.
async fn expect_notification(peer: &mut TestPeer) -> Result<NotificationMessage> {
tokio::time::timeout(TIMEOUT, async {
loop {
match peer.recv().await? {
Message::Notification(n) => return Ok(n),
Message::KeepAlive => {}
other => eyre::bail!("expected NOTIFICATION, got {other:?}"),
}
}
})
.await?
}
fn is_collision_cease(n: &NotificationMessage) -> bool {
matches!(n.error, BgpError::Cease(CeaseSubcode::ConnCollisionResolution))
}
async fn wait_for_sessions(server: &TestServer, n: usize) -> Result<()> {
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().len() != n {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
Ok(())
}
/// Peer has the higher identifier, so the connection the *peer* initiated
/// must survive: the server closes its own outbound connection.
#[tokio::test]
async fn test_simultaneous_open_peer_higher_keeps_inbound() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let server = server_dialing(&listener).await?;
// Server's outbound connection reaches OpenConfirm.
let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
let mut dialled = TestPeer::from_stream(stream);
open_only(&mut dialled, HIGH_ID).await?;
// The peer now also dials the server.
let mut inbound = TestPeer::connect(server.addr).await?;
open_only(&mut inbound, HIGH_ID).await?;
let notif = expect_notification(&mut dialled).await?;
assert!(is_collision_cease(&notif), "{notif:?}");
assert!(dialled.is_closed().await);
confirm(&mut inbound).await?;
wait_for_sessions(&server, 1).await?;
let session = server.registry.list().remove(0);
assert_eq!(session.direction, Direction::Inbound);
assert_eq!(session.name.as_deref(), Some("lab"));
// The survivor is fully functional.
server
.local_rib
.announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, "alice", LocalRouteAttrs::default()))
.await?;
let update = tokio::time::timeout(TIMEOUT, inbound.recv_update()).await??;
assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24");
// The dialer notices the surviving session and does not open another.
let extra = tokio::time::timeout(Duration::from_millis(700), listener.accept()).await;
assert!(extra.is_err(), "dialer opened a second connection despite an established session");
Ok(())
}
/// Peer has the lower identifier, so the connection the *server* initiated
/// must survive: the peer's inbound connection is refused.
#[tokio::test]
async fn test_simultaneous_open_peer_lower_keeps_outbound() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let server = server_dialing(&listener).await?;
let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
let mut dialled = TestPeer::from_stream(stream);
open_only(&mut dialled, LOW_ID).await?;
let mut inbound = TestPeer::connect(server.addr).await?;
open_only(&mut inbound, LOW_ID).await?;
let notif = expect_notification(&mut inbound).await?;
assert!(is_collision_cease(&notif), "{notif:?}");
assert!(inbound.is_closed().await);
confirm(&mut dialled).await?;
wait_for_sessions(&server, 1).await?;
assert_eq!(server.registry.list()[0].direction, Direction::Outbound);
Ok(())
}
/// The order in which the two OPENs are processed must not change the
/// outcome: here the inbound connection reaches OpenConfirm first.
#[tokio::test]
async fn test_outcome_is_independent_of_arrival_order() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let server = server_dialing(&listener).await?;
let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
let mut dialled = TestPeer::from_stream(stream);
// Inbound first this time; peer is lower so outbound must still win.
let mut inbound = TestPeer::connect(server.addr).await?;
open_only(&mut inbound, LOW_ID).await?;
open_only(&mut dialled, LOW_ID).await?;
let notif = expect_notification(&mut inbound).await?;
assert!(is_collision_cease(&notif), "{notif:?}");
confirm(&mut dialled).await?;
wait_for_sessions(&server, 1).await?;
assert_eq!(server.registry.list()[0].direction, Direction::Outbound);
Ok(())
}
/// RFC default: a collision with an Established session closes the new
/// connection, whatever the identifiers say.
#[tokio::test]
async fn test_established_session_wins_by_default() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut first = TestPeer::connect(server.addr).await?;
first.handshake(PEER_ASN, HIGH_ID, 90).await?;
wait_for_sessions(&server, 1).await?;
let mut second = TestPeer::connect(server.addr).await?;
open_only(&mut second, HIGH_ID).await?;
let notif = expect_notification(&mut second).await?;
assert!(is_collision_cease(&notif), "{notif:?}");
// The original is untouched.
assert_eq!(server.registry.list().len(), 1);
server
.local_rib
.announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, "alice", LocalRouteAttrs::default()))
.await?;
let update = tokio::time::timeout(TIMEOUT, first.recv_update()).await??;
assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24");
Ok(())
}
/// With collision_detect_established_state, a peer that restarts and dials
/// again replaces the stale session at once, and the routes learned over the
/// old session are flushed.
#[tokio::test]
async fn test_restarted_peer_replaces_stale_session_when_enabled() -> Result<()> {
let server = TestServer::start_with(LOCAL_ASN, |c| c.collision_detect_established_state = true).await?;
let mut stale = TestPeer::connect(server.addr).await?;
stale.handshake(PEER_ASN, HIGH_ID, 90).await?;
wait_for_sessions(&server, 1).await?;
stale
.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![PEER_ASN]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 9))),
],
announced_routes: vec![IpPrefix::try_from("203.0.113.0/24")?],
}))
.await?;
tokio::time::timeout(TIMEOUT, async {
while server.adj_rib_in.snapshot().await.is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
let stale_addr = server.registry.list()[0].peer.addr;
// "Restart": a fresh connection from the same router.
let mut fresh = TestPeer::connect(server.addr).await?;
fresh.handshake(PEER_ASN, HIGH_ID, 90).await?;
let notif = expect_notification(&mut stale).await?;
assert!(is_collision_cease(&notif), "{notif:?}");
tokio::time::timeout(TIMEOUT, async {
loop {
let sessions = server.registry.list();
if sessions.len() == 1 && sessions[0].peer.addr != stale_addr {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
assert!(server.adj_rib_in.snapshot().await.is_empty(), "stale routes were not flushed");
Ok(())
}
/// Different routers (different identifiers) never collide.
#[tokio::test]
async fn test_distinct_identifiers_do_not_collide() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut a = TestPeer::connect(server.addr).await?;
a.handshake(PEER_ASN, HIGH_ID, 90).await?;
let mut b = TestPeer::connect(server.addr).await?;
b.handshake(PEER_ASN, LOW_ID, 90).await?;
wait_for_sessions(&server, 2).await?;
Ok(())
}
/// A failed connection frees its slot: the same router can come back.
#[tokio::test]
async fn test_identifier_is_released_when_connection_ends() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut first = TestPeer::connect(server.addr).await?;
first.handshake(PEER_ASN, HIGH_ID, 90).await?;
wait_for_sessions(&server, 1).await?;
drop(first);
wait_for_sessions(&server, 0).await?;
let mut second = TestPeer::connect(server.addr).await?;
second.handshake(PEER_ASN, HIGH_ID, 90).await?;
wait_for_sessions(&server, 1).await?;
Ok(())
}
/// A peer claiming our own identifier is refused outright.
#[tokio::test]
async fn test_peer_with_our_identifier_is_refused() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut peer = TestPeer::connect(server.addr).await?;
open_only(&mut peer, Ipv4Addr::new(127, 0, 0, 1)).await?;
let notif = expect_notification(&mut peer).await?;
assert!(matches!(notif.error, BgpError::OpenMsg(OpenMsgSubcode::BadBgpId)), "{notif:?}");
Ok(())
}
+224
View File
@@ -0,0 +1,224 @@
//! The active side: the server dials its configured peers and keeps
//! retrying.
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::errors::{BgpError, OpenMsgSubcode};
use bgp_packet::message::Message;
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRoute, LocalRouteAttrs};
use bgp_server::config::PeerConfig;
use bgp_server::session::DialState;
use eyre::Result;
use tokio::net::TcpListener;
const LOCAL_ASN: u32 = 65000;
const PEER_ASN: u32 = 65001;
const TIMEOUT: Duration = Duration::from_secs(5);
/// Starts a server configured to dial `listener`'s address as peer "lab".
async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestServer> {
let endpoint: SocketAddr = listener.local_addr()?;
TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig { name: "lab".into(), endpoint, remote_asn, hold_time_secs: 90, local_address: None }];
})
.await
}
async fn accept(listener: &TcpListener) -> Result<TestPeer> {
let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
Ok(TestPeer::from_stream(stream))
}
#[tokio::test]
async fn test_server_dials_configured_peer_and_establishes() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let server = server_dialing(&listener, PEER_ASN).await?;
let mut peer = accept(&listener).await?;
// Both sides send OPEN on connect; the test peer's handshake order works
// whichever side opened TCP.
let open = peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
assert_eq!(open.asn, LOCAL_ASN as u16);
let mut events = server.registry.subscribe();
// Either the Up event is still queued or the session is already listed.
let listed = tokio::time::timeout(TIMEOUT, async {
loop {
if let Some(s) = server.registry.list().first().cloned() {
return s;
}
let _ = events.recv().await;
}
})
.await?;
assert_eq!(listed.name.as_deref(), Some("lab"));
assert_eq!(listed.peer.asn, PEER_ASN);
assert_eq!(listed.peer.addr, listener.local_addr()?);
// Configured view reflects it; the dialer is not in backoff.
let (cfg, state) = server.registry.configured_peers().into_iter().next().unwrap();
assert_eq!(cfg.name, "lab");
assert!(matches!(state, DialState::Connecting | DialState::Idle), "{state:?}");
// Local routes flow over the dialled session too.
server
.local_rib
.announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, "alice", LocalRouteAttrs::default()))
.await?;
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24");
Ok(())
}
#[tokio::test]
async fn test_server_reconnects_after_peer_closes() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let server = server_dialing(&listener, PEER_ASN).await?;
let mut first = accept(&listener).await?;
first.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
drop(first);
// Session goes away, then the dialer comes back.
tokio::time::timeout(TIMEOUT, async {
while !server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
let (_, state) = server.registry.configured_peers().into_iter().next().unwrap();
assert!(matches!(state, DialState::Backoff { .. }), "{state:?}");
let mut second = accept(&listener).await?;
second.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
Ok(())
}
#[tokio::test]
async fn test_wrong_asn_is_refused_with_bad_peer_as() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let _server = server_dialing(&listener, PEER_ASN).await?;
let mut peer = accept(&listener).await?;
peer.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
let _ = peer.recv_open().await?;
let notif = tokio::time::timeout(TIMEOUT, async {
loop {
match peer.recv().await? {
Message::Notification(n) => return Ok::<_, eyre::Error>(n),
Message::KeepAlive => {}
other => eyre::bail!("unexpected {other:?}"),
}
}
})
.await??;
assert!(matches!(notif.error, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs)), "{notif:?}");
// The dialer retries rather than giving up.
let _again = accept(&listener).await?;
Ok(())
}
/// An inbound connection from a configured peer's address is also checked
/// against the configured ASN and gets the configured name.
#[tokio::test]
async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
// Configure a peer at the loopback address on a port nobody listens on,
// so the dialer never succeeds; then connect inbound from loopback.
let server = TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_secs(60);
c.peers = vec![PeerConfig {
name: "lab".into(),
endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1),
remote_asn: PEER_ASN,
hold_time_secs: 90,
local_address: None,
}];
})
.await?;
// Wrong ASN inbound → refused.
let mut bad = TestPeer::connect(server.addr).await?;
bad.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
let _ = bad.recv_open().await?;
let msg = tokio::time::timeout(TIMEOUT, bad.recv()).await??;
assert!(matches!(&msg, Message::Notification(n) if matches!(n.error, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs))), "{msg:?}");
// Right ASN inbound → established, named.
let mut good = TestPeer::connect(server.addr).await?;
good.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
let listed = tokio::time::timeout(TIMEOUT, async {
loop {
if let Some(s) = server.registry.list().first().cloned() {
return s;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
assert_eq!(listed.name.as_deref(), Some("lab"));
Ok(())
}
/// With `local_address` set the connection must come from that address (the
/// peer may identify us by it), and an address the host does not have is
/// reported as the reason for backing off rather than silently ignored.
#[tokio::test]
async fn test_local_address_is_used_as_the_source() -> Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let endpoint: SocketAddr = listener.local_addr()?;
let _server = TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig {
name: "lab".into(),
endpoint,
remote_asn: PEER_ASN,
hold_time_secs: 90,
local_address: Some(Ipv4Addr::LOCALHOST.into()),
}];
})
.await?;
let (_stream, from) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
assert_eq!(from.ip(), std::net::IpAddr::from(Ipv4Addr::LOCALHOST));
// 192.0.2.1 is documentation space; no host has it.
let server = TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig {
name: "lab".into(),
endpoint,
remote_asn: PEER_ASN,
hold_time_secs: 90,
local_address: Some(Ipv4Addr::new(192, 0, 2, 1).into()),
}];
})
.await?;
let reason = tokio::time::timeout(TIMEOUT, async {
loop {
if let Some((_, DialState::Backoff { last_error, .. })) = server.registry.configured_peers().into_iter().next() {
return last_error;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await?;
assert!(reason.contains("local_address 192.0.2.1"), "{reason}");
Ok(())
}
+326
View File
@@ -0,0 +1,326 @@
//! Drives the HTTP API against a real server with a real BGP peer attached.
use std::net::Ipv4Addr;
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::message::{
AsPathAttribute, CommunitiesPathAttribute, Message, NextHopPathAttribute,
OriginPathAttribute, PathAttribute, UpdateMessage,
};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_primitives::parser::ParserContext;
use bgp_rib::LocalRibPolicy;
use eyre::Result;
use serde_json::{Value, json};
const LOCAL_ASN: u32 = 65000;
const PEER_ASN: u32 = 65001;
const TIMEOUT: Duration = Duration::from_secs(5);
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
async fn established_peer(server: &TestServer) -> Result<TestPeer> {
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 90).await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
Ok(peer)
}
async fn get_json(client: &reqwest::Client, url: &str) -> Result<Value> {
Ok(client.get(url).send().await?.error_for_status()?.json().await?)
}
#[tokio::test]
async fn test_status_and_peers() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let config = get_json(&client, &format!("{base}/api/config")).await?;
assert_eq!(config["path"], "test.textpb");
assert_eq!(config["text"], format!("local_asn: {LOCAL_ASN}\n"));
let status = get_json(&client, &format!("{base}/api/status")).await?;
assert_eq!(status["local_asn"], LOCAL_ASN);
assert_eq!(status["router_id"], "127.0.0.1");
assert_eq!(status["sessions"], 0);
assert!(status["started_at_unix"].as_u64().unwrap() > 1_700_000_000);
assert!(!status["hostname"].as_str().unwrap().is_empty());
let _peer = established_peer(&server).await?;
let status = get_json(&client, &format!("{base}/api/status")).await?;
assert_eq!(status["sessions"], 1);
let peers = get_json(&client, &format!("{base}/api/peers")).await?;
assert_eq!(peers.as_array().unwrap().len(), 1);
assert_eq!(peers[0]["peer_asn"], PEER_ASN);
assert_eq!(peers[0]["local_addr"], server.addr.to_string());
assert_eq!(peers[0]["four_octet_asn"], true);
Ok(())
}
/// The full loop: POST an announcement, the BGP peer receives the UPDATE,
/// the local table lists it, the wire view matches the bytes, DELETE
/// withdraws it.
#[tokio::test]
async fn test_announce_over_http_reaches_peer() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let mut peer = established_peer(&server).await?;
let resp = client
.post(format!("{base}/api/routes/local"))
.json(&json!({
"prefix": "10.100.1.0/24",
"owner": "alice",
"prepend": 1,
"med": 10,
"communities": ["65000:100", "NO_EXPORT"],
"large_communities": ["65000:1:2"],
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let body: Value = resp.json().await?;
assert_eq!(body["prefix"], "10.100.1.0/24");
assert_eq!(body["origin"], "IGP");
assert_eq!(body["communities"], json!(["65000:100", "65535:65281"]));
assert_eq!(body["large_communities"], json!(["65000:1:2"]));
// The peer sees the UPDATE.
let update: UpdateMessage = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
assert_eq!(update.path_attributes[1], AsPathAttribute::from_asns(vec![LOCAL_ASN, LOCAL_ASN]));
assert_eq!(update.path_attributes[2], PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::LOCALHOST)));
assert!(update.path_attributes.contains(&PathAttribute::Communities(CommunitiesPathAttribute(vec![
(65000, 100),
(65535, 65281)
]))));
// Local table lists it.
let local = get_json(&client, &format!("{base}/api/routes/local")).await?;
assert_eq!(local.as_array().unwrap().len(), 1);
assert_eq!(local[0]["owner"], "alice");
// Wire view reproduces exactly what went over the socket.
let wire = get_json(&client, &format!("{base}/api/routes/local/wire?prefix=10.100.1.0/24")).await?;
assert_eq!(wire.as_array().unwrap().len(), 1);
assert_eq!(wire[0]["peer_asn"], PEER_ASN);
assert_eq!(wire[0]["four_octet_asn"], true);
assert_eq!(wire[0]["summary"]["as_path"], json!([LOCAL_ASN, LOCAL_ASN]));
let hex = wire[0]["hex"].as_str().unwrap();
let ctx = ParserContext { four_octet_asn: peer.ctx.four_octet_asn, address_family: None };
let mut expected = bytes::BytesMut::new();
Message::Update(update).to_wire(&ctx, &mut expected)?;
let expected_hex: String = expected.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex, expected_hex);
assert!(hex.starts_with(&"ff".repeat(16)), "marker missing");
// Withdraw.
let resp = client
.delete(format!("{base}/api/routes/local"))
.json(&json!({ "prefix": "10.100.1.0/24", "owner": "alice" }))
.send()
.await?;
assert_eq!(resp.status(), 200);
let withdrawn: UpdateMessage = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
assert_eq!(withdrawn.withdrawn_routes, vec![prefix("10.100.1.0/24")]);
let local = get_json(&client, &format!("{base}/api/routes/local")).await?;
assert!(local.as_array().unwrap().is_empty());
Ok(())
}
#[tokio::test]
async fn test_validation_and_policy_status_codes() -> Result<()> {
let server = TestServer::start_with_policy(
LOCAL_ASN,
LocalRibPolicy { allowed_prefixes: vec![prefix("10.100.0.0/16")], max_routes_per_owner: 1 },
)
.await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let url = format!("{base}/api/routes/local");
let post = |body: Value| client.post(&url).json(&body).send();
// Malformed input → 400 with an error message.
let resp = post(json!({ "prefix": "not-a-prefix", "owner": "alice" })).await?;
assert_eq!(resp.status(), 400);
assert!(resp.json::<Value>().await?["error"].as_str().unwrap().contains("invalid prefix"));
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "" })).await?;
assert_eq!(resp.status(), 400);
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "alice", "communities": ["bogus"] })).await?;
assert_eq!(resp.status(), 400);
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "alice", "origin": "weird" })).await?;
assert_eq!(resp.status(), 400);
// Outside allowlist → 403.
let resp = post(json!({ "prefix": "192.0.2.0/24", "owner": "alice" })).await?;
assert_eq!(resp.status(), 403);
// First allowed route is fine.
assert_eq!(post(json!({ "prefix": "10.100.1.0/24", "owner": "alice" })).await?.status(), 200);
// Per-owner limit → 422.
let resp = post(json!({ "prefix": "10.100.2.0/24", "owner": "alice" })).await?;
assert_eq!(resp.status(), 422);
// Someone else's prefix → 409, for both replace and withdraw.
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "bob" })).await?;
assert_eq!(resp.status(), 409);
let resp = client
.delete(&url)
.json(&json!({ "prefix": "10.100.1.0/24", "owner": "bob" }))
.send()
.await?;
assert_eq!(resp.status(), 409);
// Withdrawing an unknown prefix is a 200 with null.
let resp = client.delete(&url).json(&json!({ "prefix": "10.100.7.0/24" })).send().await?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.json::<Value>().await?, Value::Null);
// Wire view for an unannounced prefix → 404.
let resp = client.get(format!("{base}/api/routes/local/wire?prefix=10.100.7.0/24")).send().await?;
assert_eq!(resp.status(), 404);
Ok(())
}
/// Routes the peer sends us show up in the received table with a summary.
#[tokio::test]
async fn test_received_routes_summary() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let mut peer = established_peer(&server).await?;
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![PEER_ASN, 65002]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 1))),
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65001, 7)])),
],
announced_routes: vec![prefix("203.0.113.0/24")],
}))
.await?;
let received = tokio::time::timeout(TIMEOUT, async {
loop {
let v = get_json(&client, &format!("{base}/api/routes/received")).await?;
if !v.as_array().unwrap().is_empty() {
return Ok::<_, eyre::Error>(v);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await??;
assert_eq!(received[0]["prefix"], "203.0.113.0/24");
assert_eq!(received[0]["peer_asn"], PEER_ASN);
assert_eq!(received[0]["summary"]["as_path"], json!([PEER_ASN, 65002]));
assert_eq!(received[0]["summary"]["next_hop"], "10.0.0.1");
assert_eq!(received[0]["summary"]["communities"], json!(["65001:7"]));
Ok(())
}
/// The SSE stream reports session and route changes as they happen.
#[tokio::test]
async fn test_event_stream() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let mut resp = client.get(format!("{base}/api/events")).send().await?.error_for_status()?;
assert!(resp.headers()["content-type"].to_str()?.starts_with("text/event-stream"));
let _peer = established_peer(&server).await?;
server
.local_rib
.announce(bgp_rib::LocalRoute::new(prefix("10.100.1.0/24"), "alice", Default::default()))
.await?;
// Collect SSE frames until we have seen both events.
let mut buf = String::new();
let mut seen_up = false;
let mut seen_announce = false;
tokio::time::timeout(TIMEOUT, async {
while !(seen_up && seen_announce) {
let chunk = resp.chunk().await?.ok_or_else(|| eyre::eyre!("stream ended"))?;
buf.push_str(std::str::from_utf8(&chunk)?);
for line in buf.lines().filter_map(|l| l.strip_prefix("data:")) {
let ev: Value = serde_json::from_str(line.trim())?;
match ev["type"].as_str() {
Some("session_up") if ev["peer"]["peer_asn"] == PEER_ASN => seen_up = true,
Some("local_announced") if ev["route"]["prefix"] == "10.100.1.0/24" => seen_announce = true,
_ => {}
}
}
}
Ok::<_, eyre::Error>(())
})
.await??;
Ok(())
}
#[tokio::test]
async fn test_statusz_page_is_served() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let resp = client.get(format!("{base}/statusz")).send().await?;
assert_eq!(resp.status(), 200);
assert!(resp.headers()["content-type"].to_str()?.starts_with("text/html"));
let body = resp.text().await?;
assert!(body.contains("<title>Status for ferrix_bgp</title>"));
assert!(body.contains("/api/events"));
// The root redirects to the page.
let no_redirect = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build()?;
let resp = no_redirect.get(format!("{base}/")).send().await?;
assert_eq!(resp.status(), 307);
assert_eq!(resp.headers()["location"], "/statusz");
Ok(())
}
#[tokio::test]
async fn test_metricz_and_well_known() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let _peer = established_peer(&server).await?;
let text = client.get(format!("{base}/metricz")).send().await?.error_for_status()?.text().await?;
// Metrics are process-global, so other tests in this binary contribute
// too; assert on presence and a lower bound rather than exact values.
let metric = |name: &str| -> Option<i64> {
text.lines()
.find(|l| l.starts_with(name) && l[name.len()..].starts_with(' '))
.and_then(|l| l.rsplit(' ').next()?.parse().ok())
};
assert!(metric("bgp_sessions_established").unwrap_or(0) >= 1, "{text}");
assert!(metric("bgp_messages_sent_total{type=\"open\"}").unwrap_or(0) >= 1, "{text}");
assert!(metric("bgp_messages_received_total{type=\"keepalive\"}").unwrap_or(0) >= 1, "{text}");
assert!(metric("bgp_local_routes").is_some(), "{text}");
assert!(metric("bgp_adj_rib_in_routes").is_some(), "{text}");
let wk = get_json(&client, &format!("{base}/api/communities/well-known")).await?;
assert!(wk.as_array().unwrap().iter().any(|c| c["name"] == "NO_EXPORT" && c["value"] == "65535:65281"));
Ok(())
}
+119
View File
@@ -0,0 +1,119 @@
//! Interoperability with what real routers put on the wire.
use std::net::Ipv4Addr;
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::message::{Message, PathAttribute};
use bgp_packet::open::{Capability, OpenOption};
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRoute, LocalRouteAttrs};
use eyre::Result;
const TIMEOUT: Duration = Duration::from_secs(5);
/// The OPEN a BIRD 2 router sends for an IPv6-only session from AS210036:
/// one optional parameter carrying MP(IPv6 unicast), route refresh,
/// extended message, graceful restart (aware only), 4-octet AS, enhanced
/// route refresh and long-lived graceful restart (aware only).
#[rustfmt::skip]
const BIRD_OPEN: &[u8] = &[
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0x00, 0x37, 0x01, // length 55, type OPEN
0x04, // version
0x5b, 0xa0, // AS_TRANS
0x00, 0xf0, // hold time 240
0x0a, 0xff, 0x00, 0x01, // router id 10.255.0.1
0x1a, // optional parameters length 26
0x02, 0x18, // capabilities, 24 bytes
0x01, 0x04, 0x00, 0x02, 0x00, 0x01, // MP: IPv6 unicast
0x02, 0x00, // route refresh
0x06, 0x00, // extended message
0x40, 0x02, 0x00, 0x00, // graceful restart, no address families
0x41, 0x04, 0x00, 0x03, 0x34, 0x74, // 4-octet AS 210036
0x46, 0x00, // enhanced route refresh
0x47, 0x00, // long-lived graceful restart, none
];
/// IPv6 End-of-RIB: an UPDATE whose only attribute is an empty
/// MP_UNREACH_NLRI for IPv6 unicast (RFC 4724 §2).
#[rustfmt::skip]
const IPV6_END_OF_RIB: &[u8] = &[
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0x00, 0x1e, 0x02, // length 30, type UPDATE
0x00, 0x00, // no withdrawn routes
0x00, 0x07, // 7 bytes of path attributes
0x90, 0x0f, 0x00, 0x03, // optional + extended length, MP_UNREACH_NLRI, len 3
0x00, 0x02, 0x01, // IPv6 unicast, no prefixes
];
#[tokio::test]
async fn test_server_advertises_ipv4_and_ipv6_unicast() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut peer = TestPeer::connect(server.addr).await?;
let open = peer.recv_open().await?;
let caps: Vec<&Capability> = open
.options
.iter()
.flat_map(|o| match o {
OpenOption::Capabilities(c) => c.iter(),
})
.collect();
for afi in [AddressFamilyId::Ipv4, AddressFamilyId::Ipv6] {
assert!(
caps.iter().any(|c| matches!(c, Capability::MultiProtocol { afi: a, safi: SubsequentAfi::Unicast } if *a == afi)),
"missing MP capability for {afi:?}: {caps:?}"
);
}
Ok(())
}
#[tokio::test]
async fn test_bird_open_and_end_of_rib_are_accepted() -> Result<()> {
let server = TestServer::start(4200000001).await?;
let mut peer = TestPeer::connect(server.addr).await?;
peer.send_raw(BIRD_OPEN).await?;
peer.recv_open().await?;
match tokio::time::timeout(TIMEOUT, peer.recv()).await?? {
Message::KeepAlive => {}
other => eyre::bail!("server did not accept BIRD's OPEN: {other:?}"),
}
peer.send(&Message::KeepAlive).await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
let session = server.registry.list().remove(0);
assert_eq!(session.peer.asn, 210036, "4-octet ASN must come from the capability, not AS_TRANS");
assert!(session.four_octet_asn);
// End-of-RIB must neither break the session nor create a route.
peer.send_raw(IPV6_END_OF_RIB).await?;
peer.send(&Message::KeepAlive).await?;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(server.registry.list().len(), 1, "session dropped after End-of-RIB");
assert!(server.adj_rib_in.snapshot().await.is_empty());
Ok(())
}
/// Over an IPv4 test transport an IPv6 route cannot be sent (no IPv6 next
/// hop), so this only checks the builder the advertiser uses: the UPDATE for
/// a lab /48 carries MP_REACH_NLRI with an IPv6 next hop and no NEXT_HOP.
#[tokio::test]
async fn test_ipv6_lab_prefix_update_shape() -> Result<()> {
let route = LocalRoute::new(IpPrefix::try_from("2a0d:d741:1::/48")?, "student1", LocalRouteAttrs::default());
let nh: std::net::Ipv6Addr = "fd00:179::2".parse()?;
let update = bgp_server::advertise::build_announce(&route, 4200000001, nh.into()).expect("ipv6 over ipv6");
assert!(update.announced_routes.is_empty());
assert!(!update.path_attributes.iter().any(|a| matches!(a, PathAttribute::NextHop(_))));
assert!(update.path_attributes.iter().any(|a| matches!(a, PathAttribute::MpReachNlri(m)
if m.afi == AddressFamilyId::Ipv6 && m.prefixes[0].to_string() == "2a0d:d741:1::/48")));
let _ = Ipv4Addr::LOCALHOST;
Ok(())
}
+109
View File
@@ -0,0 +1,109 @@
//! The ping monitor as seen through the HTTP API. The socket is left out
//! (it needs privileges); observations are fed in directly.
use std::time::Duration;
use bgp_integration_tests::test_server::TestServer;
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::LocalRoute;
use bgp_server::ping::PingObservation;
use eyre::Result;
use serde_json::Value;
const TIMEOUT: Duration = Duration::from_secs(5);
fn ping(src: &str, dst: &str, hop_limit: u8, seq: u16) -> PingObservation {
PingObservation {
src: src.parse().unwrap(),
dst: dst.parse().unwrap(),
interface: Some("swissix0".into()),
hop_limit: Some(hop_limit),
ident: 1,
seq,
bytes: 64,
packet: [&[128, 0, 0xab, 0xcd, 0, 1][..], &seq.to_be_bytes(), b"hi"].concat(),
}
}
#[tokio::test]
async fn pings_are_listed_with_the_route_they_were_sent_to() -> Result<()> {
let server = TestServer::start(65000).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
server
.local_rib
.announce(LocalRoute::new(IpPrefix::try_from("2001:db8:1a::/48").unwrap(), "alice", Default::default()))
.await?;
server.pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, 0)).await;
server.pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, 1)).await;
server.pings.record(ping("2a02:1210::9", "2001:db8:ff::1", 243, 0)).await;
server.pings.record(ping("2a02:1210::9", "2a0d:d740::1", 243, 0)).await; // not a watched prefix
let view: Value = client.get(format!("{base}/api/pings")).send().await?.error_for_status()?.json().await?;
assert_eq!(view["status"], "listening");
assert_eq!(view["prefixes"][0], "2001:db8::/32");
assert_eq!(view["total_flows"], 2);
let flows = view["flows"].as_array().unwrap();
let alice = flows.iter().find(|f| f["dst"] == "2001:db8:1a::1").unwrap();
assert_eq!(alice["src"], "2001:67c:10ec::5");
assert_eq!(alice["prefix"], "2001:db8:1a::/48");
assert_eq!(alice["owner"], "alice");
assert_eq!(alice["interface"], "swissix0");
assert_eq!((alice["hop_limit"].as_u64(), alice["hops"].as_u64()), (Some(57), Some(7)));
assert_eq!((alice["count"].as_u64(), alice["seq"].as_u64()), (Some(2), Some(1)));
assert_eq!(alice["hex"], "8000abcd000100016869");
assert!(alice["last_seen_unix"].as_u64().unwrap() > 1_700_000_000);
let stray = flows.iter().find(|f| f["dst"] == "2001:db8:ff::1").unwrap();
assert!(stray["prefix"].is_null() && stray["owner"].is_null());
assert_eq!(stray["hops"], 12);
let limited: Value = client.get(format!("{base}/api/pings?limit=1")).send().await?.json().await?;
assert_eq!(limited["total_flows"], 2);
assert_eq!(limited["flows"].as_array().unwrap().len(), 1);
let metrics = client.get(format!("{base}/metricz")).send().await?.text().await?;
assert!(metrics.contains("ping_monitor_echo_requests_total"));
Ok(())
}
#[tokio::test]
async fn pings_appear_on_the_event_stream() -> Result<()> {
let server = TestServer::start(65000).await?;
let (base, _http) = server.serve_http().await?;
let mut resp = reqwest::Client::new().get(format!("{base}/api/events")).send().await?.error_for_status()?;
// The subscription exists once the response has started; a ping recorded
// before that would be missed, so keep pinging until one shows up.
let pings = server.pings.clone();
let pinger = tokio::spawn(async move {
for seq in 0.. {
pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, seq)).await;
tokio::time::sleep(Duration::from_millis(50)).await;
}
});
let mut buf = String::new();
let event = tokio::time::timeout(TIMEOUT, async {
loop {
let chunk = resp.chunk().await?.ok_or_else(|| eyre::eyre!("stream ended"))?;
buf.push_str(std::str::from_utf8(&chunk)?);
for line in buf.lines().filter_map(|l| l.strip_prefix("data:")) {
let ev: Value = serde_json::from_str(line.trim())?;
if ev["type"] == "ping_seen" {
return Ok::<_, eyre::Error>(ev);
}
}
}
})
.await??;
pinger.abort();
assert_eq!(event["ping"]["src"], "2001:67c:10ec::5");
assert_eq!(event["ping"]["dst"], "2001:db8:1a::1");
assert_eq!(event["ping"]["interface"], "swissix0");
assert!(event["new_burst"].is_boolean());
Ok(())
}
+11 -11
View File
@@ -313,7 +313,7 @@ pub enum PathAttribute {
LocalPref(LocalPrefPathAttribute) = 5,
AtomicAggregate(AtomicAggregatePathAttribute) = 6,
Aggregator(AggregatorPathAttribute) = 7,
Communitites(CommunitiesPathAttribute) = 8,
Communities(CommunitiesPathAttribute) = 8,
MpReachNlri(MpReachNlriPathAttribute) = 14,
MpUnreachNlri(MpUnreachNlriPathAttribute) = 15,
ExtendedCommunities(ExtendedCommunitiesPathAttribute) = 16,
@@ -335,7 +335,7 @@ impl From<u8> for PathAttributeDiscriminants {
v if v == Self::LocalPref as u8 => Self::LocalPref,
v if v == Self::AtomicAggregate as u8 => Self::AtomicAggregate,
v if v == Self::Aggregator as u8 => Self::Aggregator,
v if v == Self::Communitites as u8 => Self::Communitites,
v if v == Self::Communities as u8 => Self::Communities,
v if v == Self::MpReachNlri as u8 => Self::MpReachNlri,
v if v == Self::MpUnreachNlri as u8 => Self::MpUnreachNlri,
v if v == Self::ExtendedCommunities as u8 => Self::ExtendedCommunities,
@@ -355,7 +355,7 @@ impl PathAttribute {
PathAttribute::LocalPref(_) => PathAttributeDiscriminants::LocalPref as u8,
PathAttribute::AtomicAggregate(_) => PathAttributeDiscriminants::AtomicAggregate as u8,
PathAttribute::Aggregator(_) => PathAttributeDiscriminants::Aggregator as u8,
PathAttribute::Communitites(_) => PathAttributeDiscriminants::Communitites as u8,
PathAttribute::Communities(_) => PathAttributeDiscriminants::Communities as u8,
PathAttribute::MpReachNlri(_) => PathAttributeDiscriminants::MpReachNlri as u8,
PathAttribute::MpUnreachNlri(_) => PathAttributeDiscriminants::MpUnreachNlri as u8,
PathAttribute::ExtendedCommunities(_) => {
@@ -377,7 +377,7 @@ impl PathAttribute {
PathAttribute::LocalPref(_) => false,
PathAttribute::AtomicAggregate(_) => true,
PathAttribute::Aggregator(_) => true,
PathAttribute::Communitites(_) => true,
PathAttribute::Communities(_) => true,
PathAttribute::MpReachNlri(_) => true,
PathAttribute::MpUnreachNlri(_) => true,
PathAttribute::ExtendedCommunities(_) => true,
@@ -395,7 +395,7 @@ impl PathAttribute {
PathAttribute::LocalPref(_) => true,
PathAttribute::AtomicAggregate(_) => true,
PathAttribute::Aggregator(_) => false,
PathAttribute::Communitites(_) => true,
PathAttribute::Communities(_) => true,
PathAttribute::MpReachNlri(_) => false,
PathAttribute::MpUnreachNlri(_) => false,
PathAttribute::ExtendedCommunities(_) => true,
@@ -467,8 +467,8 @@ impl PathAttribute {
PathAttributeDiscriminants::Aggregator => {
PathAttribute::Aggregator(AggregatorPathAttribute::from_wire(ctx, pa_buf)?.1)
}
PathAttributeDiscriminants::Communitites => {
PathAttribute::Communitites(CommunitiesPathAttribute::from_wire(ctx, pa_buf)?.1)
PathAttributeDiscriminants::Communities => {
PathAttribute::Communities(CommunitiesPathAttribute::from_wire(ctx, pa_buf)?.1)
}
PathAttributeDiscriminants::MpReachNlri => {
PathAttribute::MpReachNlri(MpReachNlriPathAttribute::from_wire(ctx, pa_buf)?.1)
@@ -538,7 +538,7 @@ impl PathAttribute {
PathAttribute::Aggregator(aggregator_path_attribute) => {
write_path_attribute!(out, aggregator_path_attribute);
}
PathAttribute::Communitites(communities_path_attribute) => {
PathAttribute::Communities(communities_path_attribute) => {
write_path_attribute!(out, communities_path_attribute);
}
PathAttribute::MpReachNlri(mp_reach_nlri_path_attribute) => {
@@ -583,7 +583,7 @@ impl PathAttribute {
PathAttribute::LocalPref(a) => a.wire_len(ctx)?,
PathAttribute::AtomicAggregate(a) => a.wire_len(ctx)?,
PathAttribute::Aggregator(a) => a.wire_len(ctx)?,
PathAttribute::Communitites(a) => a.wire_len(ctx)?,
PathAttribute::Communities(a) => a.wire_len(ctx)?,
PathAttribute::MpReachNlri(a) => a.wire_len(ctx)?,
PathAttribute::MpUnreachNlri(a) => a.wire_len(ctx)?,
PathAttribute::ExtendedCommunities(a) => a.wire_len(ctx)?,
@@ -903,7 +903,7 @@ impl AggregatorPathAttribute {
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct CommunitiesPathAttribute(Vec<(u16, u16)>);
pub struct CommunitiesPathAttribute(pub Vec<(u16, u16)>);
impl CommunitiesPathAttribute {
pub fn from_wire<'a>(
@@ -1624,7 +1624,7 @@ mod tests {
test_path_attribute_roundtrip!(
test_communities,
&[0xc0, 0x08, 0x04, 0xca, 0xfe, 0xba, 0xbe],
PathAttribute::Communitites(CommunitiesPathAttribute(vec![(0xcafe, 0xbabe)]))
PathAttribute::Communities(CommunitiesPathAttribute(vec![(0xcafe, 0xbabe)]))
);
// MP Reach NLRI Path Attribute.
+65 -9
View File
@@ -311,6 +311,10 @@ impl Capability {
) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> {
let (buf, typ) = be_u8(buf)?;
let (buf, len) = be_u8(buf)?;
// `cap_buf` holds exactly this capability's value; `buf` is what
// follows it. Every arm must return `buf`, or the caller's list
// parser sees an empty remainder and silently drops all the
// capabilities after this one.
let (buf, cap_buf) = nom::bytes::take(len).parse(buf)?;
macro_rules! require_empty {
($buf:expr, $cap_type:expr) => {
@@ -390,11 +394,11 @@ impl Capability {
let (cap_buf, entries) = nom::multi::many1(parse_enh).parse(cap_buf)?;
require_empty!(cap_buf, "ExtendedNextHop");
Ok((cap_buf, Self::ExtendedNextHop { entries }))
Ok((buf, Self::ExtendedNextHop { entries }))
}
v if v == CapabilityDiscriminants::ExtendedMessage as u8 => {
require_empty!(cap_buf, "ExtendedMessage");
Ok((cap_buf, Self::ExtendedMessage))
Ok((buf, Self::ExtendedMessage))
}
v if v == CapabilityDiscriminants::BgpSec as u8 => {
let (cap_buf, header) = be_u8(cap_buf)?;
@@ -402,7 +406,7 @@ impl Capability {
let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?;
require_empty!(cap_buf, "BgpSec");
Ok((
cap_buf,
buf,
Self::BgpSec {
header: BgpSecHeader(header),
afi,
@@ -416,7 +420,7 @@ impl Capability {
let (cap_buf, role) = be_u8(cap_buf)?;
let role = BgpRole::try_from(role).map_err(|e| fail!("{}", e))?;
require_empty!(cap_buf, "BgpRole");
Ok((cap_buf, Self::BgpRole { role }))
Ok((buf, Self::BgpRole { role }))
}
v if v == CapabilityDiscriminants::GracefulRestart as u8 => {
// flags_restart_time is: (4 bits: restart flags, 12 bits: restart time (s)).
@@ -445,7 +449,7 @@ impl Capability {
let (cap_buf, entries) = nom::multi::many0(parse_entry).parse(cap_buf)?;
require_empty!(cap_buf, "GracefulRestart");
Ok((
cap_buf,
buf,
Self::GracefulRestart {
restart_state,
restart_time,
@@ -467,7 +471,7 @@ impl Capability {
let send_recv = AddPathSendRecv::try_from(send_recv).map_err(|e| fail!("{}", e))?;
require_empty!(cap_buf, "AddPath");
Ok((
cap_buf,
buf,
Self::AddPath {
afi,
safi,
@@ -477,7 +481,7 @@ impl Capability {
}
v if v == CapabilityDiscriminants::EnhancedRouteRefresh as u8 => {
require_empty!(cap_buf, "EnhancedRouteRefresh");
Ok((cap_buf, Self::EnhancedRouteRefresh))
Ok((buf, Self::EnhancedRouteRefresh))
}
v if v == CapabilityDiscriminants::LongLivedGracefulRestart as u8 => {
let parse_llgr = |buf: &'a [u8]| -> IResult<
@@ -503,13 +507,13 @@ impl Capability {
};
let (cap_buf, entries) = nom::multi::many0(parse_llgr).parse(cap_buf)?;
require_empty!(cap_buf, "LongLivedGracefulRestart");
Ok((cap_buf, Self::LongLivedGracefulRestart(entries)))
Ok((buf, Self::LongLivedGracefulRestart(entries)))
}
type_code => {
// Handling an unknown capability, so just parse it out and store the value
// as bytes.
Ok((
&[],
buf,
Self::UnknownCapability {
type_code,
length: cap_buf.len() as u8,
@@ -978,4 +982,56 @@ mod tests {
assert_eq!(buf.to_vec(), out.to_vec());
Ok(())
}
/// Each capability must leave the bytes that follow it for the caller.
/// Returning the (empty) per-capability buffer instead makes the list
/// parser stop early and silently lose every later capability, e.g. the
/// 4-octet ASN that routers list after graceful restart.
#[test]
fn capability_parsers_leave_the_following_bytes() {
let ctx = ParserContext::default();
let trailer: &[u8] = &[0x41, 0x04, 0x00, 0x03, 0x34, 0x74];
let samples: &[(&str, &[u8])] = &[
("multiprotocol", &[0x01, 0x04, 0x00, 0x02, 0x00, 0x01]),
("route refresh", &[0x02, 0x00]),
("extended next hop", &[0x05, 0x06, 0x00, 0x01, 0x00, 0x01, 0x00, 0x02]),
("extended message", &[0x06, 0x00]),
("role", &[0x09, 0x01, 0x00]),
("graceful restart, aware only", &[0x40, 0x02, 0x00, 0x00]),
("graceful restart, one family", &[0x40, 0x06, 0x00, 0x78, 0x00, 0x02, 0x01, 0x80]),
("4-octet asn", &[0x41, 0x04, 0x00, 0x00, 0xfd, 0xe8]),
("add-path", &[0x45, 0x04, 0x00, 0x02, 0x01, 0x03]),
("enhanced route refresh", &[0x46, 0x00]),
("long-lived graceful restart, none", &[0x47, 0x00]),
("unknown (fqdn)", &[0x49, 0x02, b'a', b'b']),
];
for (name, bytes) in samples {
let mut input = bytes.to_vec();
input.extend_from_slice(trailer);
let (rest, _) = Capability::from_wire(&ctx, &input)
.unwrap_or_else(|e| panic!("{name}: failed to parse: {e:?}"));
assert_eq!(rest, trailer, "{name}: did not return the bytes following the capability");
}
}
/// The capability list of a BIRD 2 OPEN, end to end.
#[test]
fn bird_capability_list_is_parsed_completely() {
let ctx = ParserContext::default();
#[rustfmt::skip]
let param: &[u8] = &[
0x02, 0x18,
0x01, 0x04, 0x00, 0x02, 0x00, 0x01,
0x02, 0x00,
0x06, 0x00,
0x40, 0x02, 0x00, 0x00,
0x41, 0x04, 0x00, 0x03, 0x34, 0x74,
0x46, 0x00,
0x47, 0x00,
];
let (rest, OpenOption::Capabilities(caps)) = OpenOption::from_wire(&ctx, param).unwrap();
assert!(rest.is_empty());
assert_eq!(caps.len(), 7, "{caps:?}");
assert!(caps.contains(&Capability::FourByteAsn { asn: 210036 }));
}
}
+35
View File
@@ -34,6 +34,25 @@ impl IpPrefix {
})
}
/// Returns true if `other` is equal to or more specific than `self`, i.e.
/// `self` covers `other`. Prefixes of different address families never
/// contain each other.
pub fn contains(&self, other: &IpPrefix) -> bool {
if self.address_family != other.address_family || other.length < self.length {
return false;
}
let full_bytes = (self.length / 8) as usize;
if self.prefix[..full_bytes] != other.prefix[..full_bytes] {
return false;
}
let rem_bits = self.length % 8;
if rem_bits == 0 {
return true;
}
let mask = u8::MAX << (8 - rem_bits);
(self.prefix[full_bytes] & mask) == (other.prefix[full_bytes] & mask)
}
pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<(), ToWireError> {
// Verify that there is enough space to write the IpPrefix.
if out.remaining() < (self.prefix.len() + 1) {
@@ -205,6 +224,22 @@ mod tests {
32
);
#[test]
fn contains_covers_more_specifics() {
let p = |s: &str| IpPrefix::try_from(s).unwrap();
assert!(p("10.100.0.0/16").contains(&p("10.100.7.0/24")));
assert!(p("10.100.0.0/16").contains(&p("10.100.0.0/16")));
assert!(!p("10.100.0.0/16").contains(&p("10.101.0.0/24")));
assert!(!p("10.100.7.0/24").contains(&p("10.100.0.0/16")));
assert!(p("0.0.0.0/0").contains(&p("192.0.2.0/24")));
// Non-byte-aligned boundary.
assert!(p("10.100.0.0/19").contains(&p("10.100.31.0/24")));
assert!(!p("10.100.0.0/19").contains(&p("10.100.32.0/24")));
// Cross-family never matches.
assert!(!p("::/0").contains(&p("10.0.0.0/8")));
assert!(p("2001:db8::/32").contains(&p("2001:db8:cafe::/48")));
}
verify_roundtrip!(
verify_roundtrip_ipv6_0,
"::/0",
+2
View File
@@ -5,5 +5,7 @@ edition.workspace = true
[dependencies]
bgp-packet.workspace = true
bgp-primitives.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["full"] }
tracing.workspace = true
+23 -7
View File
@@ -13,6 +13,8 @@ const CMD_CHANNEL_CAPACITY: usize = 256;
enum RibCommand {
Announce { peer: PeerId, prefix: IpPrefix, entry: RibEntry },
Withdraw { peer: PeerId, prefix: IpPrefix },
/// Drop everything learned from `peer`, e.g. because its session ended.
RemovePeer { peer: PeerId, reply: oneshot::Sender<usize> },
Snapshot { reply: oneshot::Sender<Vec<SnapshotEntry>> },
}
@@ -42,6 +44,15 @@ impl AdjRibIn {
let _ = self.tx.send(RibCommand::Withdraw { peer, prefix }).await;
}
/// Removes every route learned from `peer`, emitting a
/// [`RibEvent::Withdrawn`] for each (RFC 4271 §8.2.2: routes from a
/// terminated session are deleted). Returns how many were removed.
pub async fn remove_peer(&self, peer: PeerId) -> usize {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.tx.send(RibCommand::RemovePeer { peer, reply: reply_tx }).await;
reply_rx.await.unwrap_or(0)
}
/// Returns a point-in-time snapshot of all routes in the RIB.
pub async fn snapshot(&self) -> Vec<SnapshotEntry> {
let (reply_tx, reply_rx) = oneshot::channel();
@@ -64,20 +75,25 @@ async fn run_actor(mut rx: mpsc::Receiver<RibCommand>, events: broadcast::Sender
while let Some(cmd) = rx.recv().await {
match cmd {
// Send errors below mean there are no subscribers, which is fine.
RibCommand::Announce { peer, prefix, entry } => {
table.insert((peer, prefix.clone()), entry.clone());
if let Err(e) = events.send(RibEvent::Announced { peer, prefix, entry }) {
// No active subscribers — not an error.
let _ = e;
}
let _ = events.send(RibEvent::Announced { peer, prefix, entry });
}
RibCommand::Withdraw { peer, prefix } => {
if table.remove(&(peer, prefix.clone())).is_some() {
if let Err(e) = events.send(RibEvent::Withdrawn { peer, prefix }) {
let _ = e;
}
let _ = events.send(RibEvent::Withdrawn { peer, prefix });
}
}
RibCommand::RemovePeer { peer, reply } => {
let prefixes: Vec<IpPrefix> =
table.keys().filter(|(p, _)| *p == peer).map(|(_, prefix)| prefix.clone()).collect();
for prefix in &prefixes {
table.remove(&(peer, prefix.clone()));
let _ = events.send(RibEvent::Withdrawn { peer, prefix: prefix.clone() });
}
let _ = reply.send(prefixes.len());
}
RibCommand::Snapshot { reply } => {
let snap = table
.iter()
+2
View File
@@ -1,7 +1,9 @@
mod adj_rib_in;
mod entry;
mod event;
mod local_rib;
pub use adj_rib_in::AdjRibIn;
pub use entry::{PeerId, RibEntry, SnapshotEntry};
pub use event::RibEvent;
pub use local_rib::{LocalRib, LocalRibError, LocalRibEvent, LocalRibPolicy, LocalRoute, LocalRouteAttrs};
+305
View File
@@ -0,0 +1,305 @@
//! Locally originated routes (the "Loc-RIB" of routes this router announces
//! on its own behalf, as opposed to routes learned from peers).
//!
//! The actor stores one route per prefix together with the attributes chosen
//! by whoever announced it. It does not know about peers or sessions: the
//! server subscribes to [`LocalRibEvent`]s and turns them into UPDATEs.
use std::collections::HashMap;
use std::time::SystemTime;
use bgp_packet::message::{LargeCommunity, OriginPathAttribute};
use bgp_primitives::ip_prefix::IpPrefix;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, mpsc, oneshot};
use tracing::warn;
const EVENT_CHANNEL_CAPACITY: usize = 1024;
const CMD_CHANNEL_CAPACITY: usize = 256;
/// User-selectable attributes for a locally originated route.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalRouteAttrs {
pub origin: OriginPathAttribute,
/// Number of *additional* copies of the local ASN to prepend to the
/// AS_PATH. `0` yields the plain `[local_asn]` path.
pub prepend: u8,
pub med: Option<u32>,
/// RFC 1997 standard communities as `(high, low)` 16-bit halves.
pub communities: Vec<(u16, u16)>,
/// RFC 8092 large communities.
pub large_communities: Vec<LargeCommunity>,
}
impl Default for LocalRouteAttrs {
fn default() -> Self {
Self {
origin: OriginPathAttribute::IGP,
prepend: 0,
med: None,
communities: vec![],
large_communities: vec![],
}
}
}
/// A route originated by this router.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalRoute {
pub prefix: IpPrefix,
/// Free-form identity of whoever announced the route (e.g. a student
/// name). Used to stop one owner replacing or withdrawing another's route.
pub owner: String,
pub attrs: LocalRouteAttrs,
pub announced_at: SystemTime,
}
impl LocalRoute {
pub fn new(prefix: IpPrefix, owner: impl Into<String>, attrs: LocalRouteAttrs) -> Self {
Self { prefix, owner: owner.into(), attrs, announced_at: SystemTime::now() }
}
}
/// Limits on what may be announced. Enforced inside the actor so every caller
/// (HTTP API, tests, future CLI) goes through the same checks.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LocalRibPolicy {
/// A route is accepted only if it is covered by at least one of these
/// prefixes. Empty means no restriction.
pub allowed_prefixes: Vec<IpPrefix>,
/// Maximum number of routes a single owner may hold. `0` means unlimited.
pub max_routes_per_owner: usize,
}
impl LocalRibPolicy {
fn allows(&self, prefix: &IpPrefix) -> bool {
self.allowed_prefixes.is_empty() || self.allowed_prefixes.iter().any(|p| p.contains(prefix))
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LocalRibError {
#[error("prefix {0} is not covered by the allowed prefixes")]
NotAllowed(IpPrefix),
#[error("owner {owner:?} already has {limit} routes")]
OwnerLimit { owner: String, limit: usize },
#[error("prefix {prefix} is owned by {owner:?}")]
OwnedByOther { prefix: IpPrefix, owner: String },
#[error("local RIB actor has stopped")]
Closed,
}
#[derive(Debug, Clone)]
pub enum LocalRibEvent {
/// A route was added or its attributes replaced.
Announced(LocalRoute),
Withdrawn { prefix: IpPrefix },
}
enum Command {
Announce { route: LocalRoute, reply: oneshot::Sender<Result<(), LocalRibError>> },
Withdraw {
prefix: IpPrefix,
requester: Option<String>,
reply: oneshot::Sender<Result<Option<LocalRoute>, LocalRibError>>,
},
Snapshot { reply: oneshot::Sender<Vec<LocalRoute>> },
}
/// Handle to the local RIB actor. Cheap to clone; all clones share one table.
#[derive(Clone)]
pub struct LocalRib {
tx: mpsc::Sender<Command>,
events: broadcast::Sender<LocalRibEvent>,
}
impl LocalRib {
pub fn spawn(policy: LocalRibPolicy) -> Self {
let (tx, rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
tokio::spawn(run_actor(rx, events.clone(), policy));
Self { tx, events }
}
/// Adds a route, or replaces the attributes of an existing route with the
/// same prefix and owner.
pub async fn announce(&self, route: LocalRoute) -> Result<(), LocalRibError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::Announce { route, reply })
.await
.map_err(|_| LocalRibError::Closed)?;
rx.await.map_err(|_| LocalRibError::Closed)?
}
/// Removes a route. `requester` must match the route's owner; pass `None`
/// to withdraw regardless of owner (administrative override). Returns the
/// removed route, or `None` if the prefix was not present.
pub async fn withdraw(
&self,
prefix: IpPrefix,
requester: Option<&str>,
) -> Result<Option<LocalRoute>, LocalRibError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::Withdraw { prefix, requester: requester.map(str::to_owned), reply })
.await
.map_err(|_| LocalRibError::Closed)?;
rx.await.map_err(|_| LocalRibError::Closed)?
}
/// Point-in-time copy of every route, in no particular order.
pub async fn snapshot(&self) -> Vec<LocalRoute> {
let (reply, rx) = oneshot::channel();
let _ = self.tx.send(Command::Snapshot { reply }).await;
rx.await.unwrap_or_default()
}
/// Subscribe to mutations. See [`crate::AdjRibIn::subscribe`] for lag
/// semantics.
pub fn subscribe(&self) -> broadcast::Receiver<LocalRibEvent> {
self.events.subscribe()
}
}
async fn run_actor(
mut rx: mpsc::Receiver<Command>,
events: broadcast::Sender<LocalRibEvent>,
policy: LocalRibPolicy,
) {
let mut table: HashMap<IpPrefix, LocalRoute> = HashMap::new();
while let Some(cmd) = rx.recv().await {
match cmd {
Command::Announce { route, reply } => {
let result = check_announce(&table, &policy, &route).map(|()| {
table.insert(route.prefix.clone(), route.clone());
let _ = events.send(LocalRibEvent::Announced(route));
});
let _ = reply.send(result);
}
Command::Withdraw { prefix, requester, reply } => {
let result = match (table.get(&prefix), requester) {
(Some(existing), Some(req)) if existing.owner != req => {
Err(LocalRibError::OwnedByOther {
prefix: prefix.clone(),
owner: existing.owner.clone(),
})
}
_ => {
let removed = table.remove(&prefix);
if removed.is_some() {
let _ = events.send(LocalRibEvent::Withdrawn { prefix });
}
Ok(removed)
}
};
let _ = reply.send(result);
}
Command::Snapshot { reply } => {
if reply.send(table.values().cloned().collect()).is_err() {
warn!("Snapshot reply channel closed before send");
}
}
}
}
}
fn check_announce(
table: &HashMap<IpPrefix, LocalRoute>,
policy: &LocalRibPolicy,
route: &LocalRoute,
) -> Result<(), LocalRibError> {
if !policy.allows(&route.prefix) {
return Err(LocalRibError::NotAllowed(route.prefix.clone()));
}
if let Some(existing) = table.get(&route.prefix) {
if existing.owner != route.owner {
return Err(LocalRibError::OwnedByOther {
prefix: route.prefix.clone(),
owner: existing.owner.clone(),
});
}
// Replacing our own route never changes the per-owner count.
return Ok(());
}
if policy.max_routes_per_owner > 0 {
let held = table.values().filter(|r| r.owner == route.owner).count();
if held >= policy.max_routes_per_owner {
return Err(LocalRibError::OwnerLimit {
owner: route.owner.clone(),
limit: policy.max_routes_per_owner,
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
fn route(p: &str, owner: &str) -> LocalRoute {
LocalRoute::new(prefix(p), owner, LocalRouteAttrs::default())
}
#[tokio::test]
async fn announce_and_withdraw_emit_events() {
let rib = LocalRib::spawn(LocalRibPolicy::default());
let mut events = rib.subscribe();
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
assert!(matches!(events.recv().await.unwrap(), LocalRibEvent::Announced(r) if r.prefix == prefix("10.0.0.0/24")));
assert_eq!(rib.snapshot().await.len(), 1);
let removed = rib.withdraw(prefix("10.0.0.0/24"), Some("alice")).await.unwrap();
assert_eq!(removed.unwrap().owner, "alice");
assert!(matches!(events.recv().await.unwrap(), LocalRibEvent::Withdrawn { prefix: p } if p == prefix("10.0.0.0/24")));
assert!(rib.snapshot().await.is_empty());
// Withdrawing an absent prefix is a no-op, not an error.
assert_eq!(rib.withdraw(prefix("10.0.0.0/24"), None).await.unwrap(), None);
}
#[tokio::test]
async fn policy_allowlist_is_enforced() {
let rib = LocalRib::spawn(LocalRibPolicy {
allowed_prefixes: vec![prefix("10.100.0.0/16")],
max_routes_per_owner: 0,
});
rib.announce(route("10.100.3.0/24", "alice")).await.unwrap();
let err = rib.announce(route("192.0.2.0/24", "alice")).await.unwrap_err();
assert_eq!(err, LocalRibError::NotAllowed(prefix("192.0.2.0/24")));
}
#[tokio::test]
async fn owner_limit_ignores_replacements() {
let rib = LocalRib::spawn(LocalRibPolicy { allowed_prefixes: vec![], max_routes_per_owner: 1 });
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
// Replacing the same prefix is fine.
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
let err = rib.announce(route("10.0.1.0/24", "alice")).await.unwrap_err();
assert!(matches!(err, LocalRibError::OwnerLimit { limit: 1, .. }));
// Other owners are unaffected.
rib.announce(route("10.0.1.0/24", "bob")).await.unwrap();
}
#[tokio::test]
async fn other_owner_cannot_replace_or_withdraw() {
let rib = LocalRib::spawn(LocalRibPolicy::default());
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
let err = rib.announce(route("10.0.0.0/24", "bob")).await.unwrap_err();
assert!(matches!(err, LocalRibError::OwnedByOther { .. }));
let err = rib.withdraw(prefix("10.0.0.0/24"), Some("bob")).await.unwrap_err();
assert!(matches!(err, LocalRibError::OwnedByOther { .. }));
// Administrative override works.
assert!(rib.withdraw(prefix("10.0.0.0/24"), None).await.unwrap().is_some());
}
}
+5
View File
@@ -9,12 +9,17 @@ bgp-rib.workspace = true
bytes.workspace = true
clap.workspace = true
futures.workspace = true
libc.workspace = true
eyre.workspace = true
prometheus.workspace = true
tokio.workspace = true
tracing-subscriber.workspace = true
tracing.workspace = true
protobuf.workspace = true
thiserror.workspace = true
[build-dependencies]
protobuf-codegen.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+3
View File
@@ -2,6 +2,9 @@ use protobuf_codegen::Codegen;
fn main() {
Codegen::new()
// Parse the .proto in pure Rust so building needs no `protoc` binary
// on the machine (routers and minimal containers rarely have one).
.pure()
.input("proto/config.proto")
.includes(&["proto"])
.cargo_out_dir("protos")
+54
View File
@@ -17,6 +17,53 @@ message BgpConfig {
// Peers configured on this server.
repeated Peer peers = 4;
// Limits on routes originated locally (e.g. via the demo UI).
LocalRoutePolicy local_route_policy = 5;
// Where to serve the HTTP API, status page and metrics. The port is
// required. Unset disables the HTTP server.
TcpEndpoint http_endpoint = 6;
// Initial delay between attempts to connect to a configured peer, in
// seconds. Doubles after each failure up to two minutes. Defaults to 5.
uint32 connect_retry_seconds = 7;
// Connection collisions (RFC 4271 §6.8) against a session that is already
// Established are, by default, resolved by closing the new connection.
// When true they are resolved like any other collision: the connection
// initiated by the higher BGP Identifier survives, and a second connection
// opened by the same side replaces the first. This lets a peer that
// restarted take over from a session we have not yet noticed is dead,
// instead of being refused until the hold timer expires.
bool collision_detect_established_state = 8;
// Watch for pings (ICMPv6 echo requests) sent to the lab prefixes and show
// them on the status page. Unset disables it.
PingMonitor ping_monitor = 9;
}
// Shows who is pinging addresses that this host answers for.
//
// The host must already own the addresses, e.g. through an AnyIP route
// ("ip -6 route add local 2001:db8::/40 dev lo"); the kernel keeps answering
// the pings and we only receive a copy. Opening the raw ICMPv6 socket this
// needs takes CAP_NET_RAW on Linux. IPv6 only.
message PingMonitor {
// Only pings to destinations inside these prefixes are shown. Defaults to
// the IPv6 prefixes in local_route_policy.allowed_prefixes.
repeated string prefixes = 1;
}
// Limits on locally originated routes.
message LocalRoutePolicy {
// A locally originated route is accepted only if it is covered by one of
// these prefixes, written in CIDR notation (e.g. "10.100.0.0/16").
// Empty means any prefix may be announced.
repeated string allowed_prefixes = 1;
// Maximum number of routes a single owner may announce. 0 means unlimited.
uint32 max_routes_per_owner = 2;
}
// An IP address and port pair.
@@ -41,4 +88,11 @@ message Peer {
// Hold time to advertise in the OPEN message, in seconds (RFC 4271 §4.2).
// Must be 0 or >= 3. Defaults to 90 when unset.
uint32 hold_time_seconds = 4;
// Source IP address for connections we open to this peer. It is also the
// next hop of the routes we send it. Required when the peer identifies us
// by address and the host has several, e.g. when both speakers run on the
// same machine. Must be the same address family as the endpoint. Unset
// lets the operating system choose.
string local_address = 5;
}
+274
View File
@@ -0,0 +1,274 @@
//! Turns locally originated routes into UPDATE messages for every established
//! session.
//!
//! The advertiser task listens for [`LocalRibEvent`]s and [`SessionEvent`]s.
//! A route change is fanned out to all live sessions; a new session receives
//! the full local table. Sending the same route twice is harmless (BGP treats
//! a re-announcement as an implicit replace), so the two event streams need
//! no coordination beyond being handled by one task.
use std::net::IpAddr;
use bgp_packet::message::{
AsPathAttribute, CommunitiesPathAttribute, LargeCommunitiesPathAttribute, Message,
MpReachNlriPathAttribute, MpUnreachNlriPathAttribute, MultiExitDiscPathAttribute,
NextHopPathAttribute, NlriNextHop, PathAttribute, UpdateMessage,
};
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRib, LocalRibEvent, LocalRoute};
use tokio::sync::broadcast::error::RecvError;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
use crate::session::{SessionEvent, SessionHandle, SessionRegistry};
pub fn spawn_advertiser(
local_rib: LocalRib,
registry: SessionRegistry,
local_asn: u32,
) -> JoinHandle<()> {
tokio::spawn(run(local_rib, registry, local_asn))
}
async fn run(local_rib: LocalRib, registry: SessionRegistry, local_asn: u32) {
let mut rib_events = local_rib.subscribe();
let mut session_events = registry.subscribe();
loop {
tokio::select! {
ev = rib_events.recv() => match ev {
Ok(LocalRibEvent::Announced(route)) => {
for session in registry.list() {
send_route(&session, &route, local_asn).await;
}
}
Ok(LocalRibEvent::Withdrawn { prefix }) => {
for session in registry.list() {
send_withdraw(&session, &prefix).await;
}
}
Err(RecvError::Lagged(n)) => {
warn!(missed = n, "Advertiser lagged on local RIB events, resyncing all sessions");
resync_all(&local_rib, &registry, local_asn).await;
}
Err(RecvError::Closed) => return,
},
ev = session_events.recv() => match ev {
Ok(SessionEvent::Up(session)) => {
let routes = local_rib.snapshot().await;
info!(peer = %session.peer, routes = routes.len(), "Session up, sending local routes");
for route in &routes {
send_route(&session, route, local_asn).await;
}
}
Ok(SessionEvent::RefreshRequested(session)) => {
let routes = local_rib.snapshot().await;
info!(peer = %session.peer, routes = routes.len(), "Route refresh requested, re-sending local routes");
for route in &routes {
send_route(&session, route, local_asn).await;
}
}
Ok(SessionEvent::Down(_)) => {}
Err(RecvError::Lagged(n)) => {
warn!(missed = n, "Advertiser lagged on session events, resyncing all sessions");
resync_all(&local_rib, &registry, local_asn).await;
}
Err(RecvError::Closed) => return,
},
}
}
}
async fn resync_all(local_rib: &LocalRib, registry: &SessionRegistry, local_asn: u32) {
let routes = local_rib.snapshot().await;
for session in registry.list() {
for route in &routes {
send_route(&session, route, local_asn).await;
}
}
}
async fn send_route(session: &SessionHandle, route: &LocalRoute, local_asn: u32) {
match build_announce(route, local_asn, session.local_addr.ip()) {
Some(update) => {
debug!(peer = %session.peer, prefix = %route.prefix, "Announcing local route");
if let Err(e) = session.send(Message::Update(update)).await {
warn!(peer = %session.peer, err = %e, "Failed to queue announce");
}
}
None => debug!(
peer = %session.peer,
prefix = %route.prefix,
"Skipping route: address family does not match session transport"
),
}
}
async fn send_withdraw(session: &SessionHandle, prefix: &IpPrefix) {
debug!(peer = %session.peer, %prefix, "Withdrawing local route");
if let Err(e) = session.send(Message::Update(build_withdraw(prefix))).await {
warn!(peer = %session.peer, err = %e, "Failed to queue withdraw");
}
}
/// Builds the UPDATE announcing `route` with `next_hop` as the next hop.
///
/// Returns `None` when the prefix family does not match the next-hop family:
/// carrying IPv4 NLRI over an IPv6 next hop (RFC 8950) or vice versa is not
/// supported yet.
pub fn build_announce(route: &LocalRoute, local_asn: u32, next_hop: IpAddr) -> Option<UpdateMessage> {
let attrs = &route.attrs;
// Path attributes in ascending type-code order, as most implementations
// expect them.
let mut path_attributes = vec![
PathAttribute::Origin(attrs.origin),
AsPathAttribute::from_asns(vec![local_asn; 1 + attrs.prepend as usize]),
];
let mut announced_routes = vec![];
match (route.prefix.address_family, next_hop) {
(AddressFamilyId::Ipv4, IpAddr::V4(nh)) => {
path_attributes.push(PathAttribute::NextHop(NextHopPathAttribute(nh)));
announced_routes.push(route.prefix.clone());
}
(AddressFamilyId::Ipv6, IpAddr::V6(_)) => {}
_ => return None,
}
if let Some(med) = attrs.med {
path_attributes.push(PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(med)));
}
if !attrs.communities.is_empty() {
path_attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(
attrs.communities.clone(),
)));
}
if let (AddressFamilyId::Ipv6, IpAddr::V6(nh)) = (route.prefix.address_family, next_hop) {
path_attributes.push(PathAttribute::MpReachNlri(MpReachNlriPathAttribute {
afi: AddressFamilyId::Ipv6,
safi: SubsequentAfi::Unicast,
next_hop: NlriNextHop::Ipv6(nh),
prefixes: vec![route.prefix.clone()],
}));
}
if !attrs.large_communities.is_empty() {
path_attributes.push(PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
communities: attrs.large_communities.clone(),
}));
}
Some(UpdateMessage { withdrawn_routes: vec![], path_attributes, announced_routes })
}
/// Builds the UPDATE withdrawing `prefix`.
pub fn build_withdraw(prefix: &IpPrefix) -> UpdateMessage {
match prefix.address_family {
AddressFamilyId::Ipv4 => UpdateMessage {
withdrawn_routes: vec![prefix.clone()],
path_attributes: vec![],
announced_routes: vec![],
},
AddressFamilyId::Ipv6 => UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute {
afi: AddressFamilyId::Ipv6,
safi: SubsequentAfi::Unicast,
prefixes: vec![prefix.clone()],
})],
announced_routes: vec![],
},
}
}
#[cfg(test)]
mod tests {
use std::net::{Ipv4Addr, Ipv6Addr};
use bgp_packet::message::{LargeCommunity, OriginPathAttribute};
use bgp_primitives::parser::ParserContext;
use bgp_rib::LocalRouteAttrs;
use bytes::BytesMut;
use super::*;
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
#[test]
fn ipv4_announce_has_expected_attributes_and_roundtrips() {
let route = LocalRoute::new(
prefix("10.100.1.0/24"),
"alice",
LocalRouteAttrs {
origin: OriginPathAttribute::IGP,
prepend: 2,
med: Some(50),
communities: vec![(65000, 100), (65535, 65281)],
large_communities: vec![LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 }],
},
);
let update = build_announce(&route, 65000, IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))).unwrap();
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
assert!(update.withdrawn_routes.is_empty());
assert_eq!(
update.path_attributes,
vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![65000, 65000, 65000]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(50)),
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65000, 100), (65535, 65281)])),
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
communities: vec![LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 }],
}),
]
);
// Serialise and parse back to make sure the wire encoding is sound.
let ctx = ParserContext { four_octet_asn: Some(true), address_family: None };
let msg = Message::Update(update.clone());
let mut buf = BytesMut::new();
msg.to_wire(&ctx, &mut buf).unwrap();
let (_, parsed) = Message::from_wire(&ctx, &buf).unwrap();
let Message::Update(parsed) = parsed else { panic!("expected UPDATE") };
assert_eq!(parsed, update);
}
#[test]
fn ipv6_announce_uses_mp_reach() {
let route = LocalRoute::new(prefix("2001:db8:1::/48"), "alice", LocalRouteAttrs::default());
let nh = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1);
let update = build_announce(&route, 65000, IpAddr::V6(nh)).unwrap();
assert!(update.announced_routes.is_empty());
assert!(!update.path_attributes.iter().any(|a| matches!(a, PathAttribute::NextHop(_))));
assert!(update.path_attributes.iter().any(|a| matches!(
a,
PathAttribute::MpReachNlri(MpReachNlriPathAttribute { next_hop: NlriNextHop::Ipv6(h), prefixes, .. })
if *h == nh && *prefixes == vec![prefix("2001:db8:1::/48")]
)));
}
#[test]
fn family_mismatch_is_skipped() {
let v6 = LocalRoute::new(prefix("2001:db8::/32"), "a", LocalRouteAttrs::default());
assert!(build_announce(&v6, 65000, IpAddr::V4(Ipv4Addr::LOCALHOST)).is_none());
let v4 = LocalRoute::new(prefix("10.0.0.0/8"), "a", LocalRouteAttrs::default());
assert!(build_announce(&v4, 65000, IpAddr::V6(Ipv6Addr::LOCALHOST)).is_none());
}
#[test]
fn withdraw_by_family() {
let v4 = build_withdraw(&prefix("10.0.0.0/8"));
assert_eq!(v4.withdrawn_routes, vec![prefix("10.0.0.0/8")]);
assert!(v4.path_attributes.is_empty());
let v6 = build_withdraw(&prefix("2001:db8::/32"));
assert!(v6.withdrawn_routes.is_empty());
assert!(matches!(&v6.path_attributes[..], [PathAttribute::MpUnreachNlri(m)] if m.prefixes == vec![prefix("2001:db8::/32")]));
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Connection collision detection (RFC 4271 §6.8).
//!
//! Two speakers that dial each other can end up with two TCP connections.
//! Exactly one must survive and both ends must pick the same one: the
//! connection *initiated by the speaker with the higher BGP Identifier*.
//! The other is closed with Cease / Connection Collision Resolution.
//!
//! This cannot be a check local to one connection: the rival connection is
//! usually still mid-handshake (so it is not in the session registry yet),
//! and sometimes it is the rival, running in another task, that must close.
//! Hence one small shared map, keyed by the peer's BGP Identifier, holding
//! the single connection currently preferred for that peer and a way to
//! tell it to stop.
//!
//! A collision with an *Established* connection closes the new one, unless
//! `detect_established` is set (the RFC's optional
//! `CollisionDetectEstablishedState`), which lets a restarted peer replace a
//! session we have not yet noticed is dead. If both connections were opened
//! by the same side the newer wins: the initiator has abandoned the older.
use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::oneshot;
use crate::metrics::COLLISIONS;
/// Which side opened the TCP connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// The peer connected to us.
Inbound,
/// We connected to the peer.
Outbound,
}
impl Direction {
pub fn as_str(&self) -> &'static str {
match self {
Direction::Inbound => "inbound",
Direction::Outbound => "outbound",
}
}
}
struct Entry {
id: u64,
direction: Direction,
established: bool,
kill: Option<oneshot::Sender<()>>,
}
/// True if the new connection is the one to close.
fn new_loses(
local_id: Ipv4Addr,
remote_id: Ipv4Addr,
existing: &Entry,
new: Direction,
detect_established: bool,
) -> bool {
if existing.established && !detect_established {
return true;
}
if existing.direction == new {
return false;
}
// Identifiers compare as unsigned 32-bit integers.
let keep = if u32::from(local_id) > u32::from(remote_id) { Direction::Outbound } else { Direction::Inbound };
new != keep
}
/// Shared by every connection of one server.
#[derive(Clone)]
pub struct CollisionTable {
entries: Arc<Mutex<HashMap<Ipv4Addr, Entry>>>,
next_id: Arc<AtomicU64>,
detect_established: bool,
}
impl CollisionTable {
pub fn new(detect_established: bool) -> Self {
Self { entries: Default::default(), next_id: Default::default(), detect_established }
}
/// Call when a connection receives the peer's OPEN. `None` means this
/// connection lost and must be closed with Cease / Connection Collision
/// Resolution. Otherwise any rival has been told to close and this
/// connection holds the slot until the guard drops.
pub fn admit(&self, local_id: Ipv4Addr, remote_id: Ipv4Addr, direction: Direction) -> Option<ConnectionGuard> {
let mut entries = self.entries.lock().expect("collision table poisoned");
if let Some(existing) = entries.get_mut(&remote_id) {
if new_loses(local_id, remote_id, existing, direction, self.detect_established) {
COLLISIONS.with_label_values(&["new"]).inc();
return None;
}
if let Some(kill) = existing.kill.take() {
COLLISIONS.with_label_values(&["existing"]).inc();
let _ = kill.send(());
}
}
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (kill, killed) = oneshot::channel();
entries.insert(remote_id, Entry { id, direction, established: false, kill: Some(kill) });
Some(ConnectionGuard { table: self.clone(), remote_id, id, killed })
}
}
/// Holds a connection's slot in the table; releases it on drop.
pub struct ConnectionGuard {
table: CollisionTable,
remote_id: Ipv4Addr,
id: u64,
/// Completes when a later connection won against this one; the owner
/// must then send Cease / Connection Collision Resolution and close.
pub killed: oneshot::Receiver<()>,
}
impl ConnectionGuard {
pub fn set_established(&self) {
let mut entries = self.table.entries.lock().expect("collision table poisoned");
if let Some(e) = entries.get_mut(&self.remote_id).filter(|e| e.id == self.id) {
e.established = true;
}
}
}
impl Drop for ConnectionGuard {
fn drop(&mut self) {
// A connection that lost has already been replaced in the map; only
// remove the slot if it is still ours.
let mut entries = self.table.entries.lock().expect("collision table poisoned");
if entries.get(&self.remote_id).is_some_and(|e| e.id == self.id) {
entries.remove(&self.remote_id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use Direction::*;
const LOW: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1);
const HIGH: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1);
fn loses(local: Ipv4Addr, remote: Ipv4Addr, existing: Direction, established: bool, new: Direction, detect: bool) -> bool {
let e = Entry { id: 0, direction: existing, established, kill: None };
new_loses(local, remote, &e, new, detect)
}
#[test]
fn higher_identifier_keeps_the_connection_it_initiated() {
// We are HIGH: our outbound connection survives.
assert!(loses(HIGH, LOW, Outbound, false, Inbound, false));
assert!(!loses(HIGH, LOW, Inbound, false, Outbound, false));
// We are LOW: the peer's connection (inbound to us) survives.
assert!(!loses(LOW, HIGH, Outbound, false, Inbound, false));
assert!(loses(LOW, HIGH, Inbound, false, Outbound, false));
}
#[test]
fn both_ends_agree() {
// A and B dial each other. Each end holds its own dial as the
// existing (Outbound) connection and sees the other's dial arrive as
// new (Inbound). Exactly one of the two dials may be closed, so the
// ends must give opposite answers to "close the other side's dial?".
for (a, b) in [(LOW, HIGH), (HIGH, LOW)] {
let a_closes_dial_from_b = loses(a, b, Outbound, false, Inbound, false);
let b_closes_dial_from_a = loses(b, a, Outbound, false, Inbound, false);
assert_ne!(a_closes_dial_from_b, b_closes_dial_from_a, "ends disagree for a={a} b={b}");
}
}
#[test]
fn established_wins_unless_enabled() {
for new in [Inbound, Outbound] {
for existing in [Inbound, Outbound] {
assert!(loses(LOW, HIGH, existing, true, new, false));
assert!(loses(HIGH, LOW, existing, true, new, false));
}
}
assert!(!loses(LOW, HIGH, Outbound, true, Inbound, true));
assert!(loses(HIGH, LOW, Outbound, true, Inbound, true));
}
#[test]
fn same_direction_newer_wins() {
assert!(!loses(LOW, HIGH, Inbound, false, Inbound, false));
assert!(!loses(HIGH, LOW, Inbound, false, Inbound, false));
assert!(loses(LOW, HIGH, Inbound, true, Inbound, false));
assert!(!loses(LOW, HIGH, Inbound, true, Inbound, true));
}
#[tokio::test]
async fn table_signals_loser_and_releases_slots() {
let table = CollisionTable::new(false);
// We are LOW: outbound first, then the peer's inbound wins.
let mut out = table.admit(LOW, HIGH, Outbound).unwrap();
let inb = table.admit(LOW, HIGH, Inbound).unwrap();
assert!(out.killed.try_recv().is_ok());
// The loser's guard dropping must not free the winner's slot.
drop(out);
inb.set_established();
assert!(table.admit(LOW, HIGH, Outbound).is_none());
// A different identifier never collides.
assert!(table.admit(LOW, Ipv4Addr::new(192, 0, 2, 2), Inbound).is_some());
// Once the survivor goes away the identifier is free again.
drop(inb);
assert!(table.admit(LOW, HIGH, Outbound).is_some());
}
}
+97 -2
View File
@@ -1,12 +1,17 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::str::FromStr;
use std::time::Duration;
use eyre::{Result, bail, eyre};
use bgp_primitives::constants::AddressFamilyId;
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::LocalRibPolicy;
use eyre::{Result, WrapErr, bail, eyre};
use crate::proto::config::BgpConfig;
const DEFAULT_BGP_PORT: u16 = 179;
const DEFAULT_HOLD_TIME_SECS: u16 = 90;
const DEFAULT_CONNECT_RETRY: Duration = Duration::from_secs(5);
/// Validated, Rust-native server configuration derived from the proto config.
pub struct Config {
@@ -16,13 +21,31 @@ pub struct Config {
pub hold_time_secs: u16,
pub listen_endpoints: Vec<SocketAddr>,
pub peers: Vec<PeerConfig>,
/// Initial delay between attempts to connect to a configured peer;
/// doubles on each failure.
pub connect_retry: Duration,
/// Resolve collisions against Established sessions by BGP Identifier
/// instead of always closing the new connection (RFC 4271 §8.1.1
/// `CollisionDetectEstablishedState`).
pub collision_detect_established_state: bool,
/// Limits on locally originated routes.
pub local_route_policy: LocalRibPolicy,
/// Address for the HTTP API; `None` disables it.
pub http_endpoint: Option<SocketAddr>,
/// IPv6 prefixes whose incoming pings are shown; `None` disables the
/// ping monitor.
pub ping_monitor: Option<Vec<IpPrefix>>,
}
#[derive(Debug, Clone)]
pub struct PeerConfig {
pub name: String,
pub endpoint: SocketAddr,
pub remote_asn: u32,
pub hold_time_secs: u16,
/// Source address for connections we open to this peer; `None` lets the
/// operating system choose.
pub local_address: Option<IpAddr>,
}
impl Config {
@@ -65,16 +88,88 @@ impl Config {
p.hold_time_seconds as u16
};
let local_address = if p.local_address.is_empty() {
None
} else {
let ip = IpAddr::from_str(&p.local_address)
.map_err(|_| eyre!("Peer {:?} has invalid local_address {:?}", p.name, p.local_address))?;
if ip.is_ipv4() != endpoint.is_ipv4() {
bail!("Peer {:?}: local_address {ip} and endpoint {endpoint} are different address families", p.name);
}
Some(ip)
};
Ok(PeerConfig {
name: p.name,
endpoint,
remote_asn: p.remote_asn,
hold_time_secs,
local_address,
})
})
.collect::<Result<_>>()?;
Ok(Self { router_id, local_asn: proto.local_asn, hold_time_secs: DEFAULT_HOLD_TIME_SECS, listen_endpoints, peers })
let local_route_policy = match proto.local_route_policy.into_option() {
Some(policy) => LocalRibPolicy {
allowed_prefixes: policy
.allowed_prefixes
.iter()
.map(|p| IpPrefix::try_from(p.as_str()).wrap_err_with(|| format!("Invalid allowed prefix {p:?}")))
.collect::<Result<_>>()?,
max_routes_per_owner: policy.max_routes_per_owner as usize,
},
None => LocalRibPolicy::default(),
};
let http_endpoint = match proto.http_endpoint.into_option() {
Some(ep) => {
if ep.port == 0 {
bail!("http_endpoint.port is required");
}
Some(parse_endpoint(&ep.address, ep.port).wrap_err("Invalid http_endpoint")?)
}
None => None,
};
let ping_monitor = match proto.ping_monitor.into_option() {
Some(monitor) => {
let prefixes: Vec<IpPrefix> = if monitor.prefixes.is_empty() {
local_route_policy.allowed_prefixes.clone()
} else {
monitor
.prefixes
.iter()
.map(|p| IpPrefix::try_from(p.as_str()).wrap_err_with(|| format!("Invalid ping_monitor prefix {p:?}")))
.collect::<Result<_>>()?
};
let prefixes: Vec<IpPrefix> =
prefixes.into_iter().filter(|p| p.address_family == AddressFamilyId::Ipv6).collect();
if prefixes.is_empty() {
bail!("ping_monitor needs IPv6 prefixes, either its own or local_route_policy.allowed_prefixes");
}
Some(prefixes)
}
None => None,
};
let connect_retry = if proto.connect_retry_seconds == 0 {
DEFAULT_CONNECT_RETRY
} else {
Duration::from_secs(proto.connect_retry_seconds as u64)
};
Ok(Self {
router_id,
local_asn: proto.local_asn,
hold_time_secs: DEFAULT_HOLD_TIME_SECS,
listen_endpoints,
peers,
connect_retry,
collision_detect_established_state: proto.collision_detect_established_state,
local_route_policy,
http_endpoint,
ping_monitor,
})
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Active side of the FSM: dial each configured peer and keep the session up.
//!
//! One task per configured peer loops forever: connect, run the session,
//! back off, repeat. While a session with the same remote IP and ASN is
//! already established (for example because the peer dialled us first) the
//! dialer stays idle rather than opening a second one. When both sides dial
//! at once the FSM resolves the collision (see [`crate::collision`]); if our
//! connection is the one closed, the next pass finds the surviving session
//! and goes idle.
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use tokio::net::{TcpSocket, TcpStream};
use tokio::task::JoinHandle;
use tracing::{info, warn};
use crate::collision::Direction;
use crate::config::PeerConfig;
use crate::fsm::{SessionConfig, run_session};
use crate::session::{DialState, SessionRegistry};
/// Upper bound for exponential backoff between connection attempts.
const MAX_BACKOFF: Duration = Duration::from_secs(120);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub fn spawn_dialers(
peers: &[PeerConfig],
base: &SessionConfig,
connect_retry: Duration,
) -> Vec<JoinHandle<()>> {
peers
.iter()
.map(|peer| {
let config = SessionConfig {
hold_time_secs: peer.hold_time_secs,
direction: Direction::Outbound,
peer_name: Some(peer.name.clone()),
expected_asn: Some(peer.remote_asn),
..base.clone()
};
tokio::spawn(dial_loop(Arc::new(peer.clone()), config, connect_retry))
})
.collect()
}
/// Opens the TCP connection, from the configured source address if any.
async fn connect(peer: &PeerConfig) -> std::io::Result<TcpStream> {
let Some(local) = peer.local_address else {
return TcpStream::connect(peer.endpoint).await;
};
let socket = if peer.endpoint.is_ipv4() { TcpSocket::new_v4()? } else { TcpSocket::new_v6()? };
socket
.bind(std::net::SocketAddr::new(local, 0))
.map_err(|e| std::io::Error::new(e.kind(), format!("cannot use local_address {local}: {e}")))?;
socket.connect(peer.endpoint).await
}
async fn dial_loop(peer: Arc<PeerConfig>, config: SessionConfig, connect_retry: Duration) {
let registry: SessionRegistry = config.registry.clone();
let mut backoff = connect_retry;
loop {
if registry.is_shutting_down() {
return;
}
if registry.find(peer.endpoint.ip(), peer.remote_asn).is_some() {
// Peer dialled us; nothing to do until that session goes away.
registry.set_dial_state(&peer.name, DialState::Idle);
tokio::time::sleep(connect_retry).await;
continue;
}
registry.set_dial_state(&peer.name, DialState::Connecting);
info!(peer = %peer.name, endpoint = %peer.endpoint, "Connecting to peer");
let outcome = match tokio::time::timeout(CONNECT_TIMEOUT, connect(&peer)).await {
Ok(Ok(stream)) => {
info!(peer = %peer.name, endpoint = %peer.endpoint, "TCP connected, starting session");
backoff = connect_retry;
run_session(stream, peer.endpoint, config.clone()).await.map(|()| "session closed by peer".to_string())
}
Ok(Err(e)) => Err(eyre::eyre!("connect failed: {e}")),
Err(_) => Err(eyre::eyre!("connect timed out after {CONNECT_TIMEOUT:?}")),
};
let reason = match outcome {
Ok(msg) => msg,
Err(e) => {
warn!(peer = %peer.name, endpoint = %peer.endpoint, err = %e, "Session attempt ended");
e.to_string()
}
};
registry.set_dial_state(
&peer.name,
DialState::Backoff { until: SystemTime::now() + backoff, last_error: reason },
);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
}
}
+147 -56
View File
@@ -1,7 +1,7 @@
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use bgp_packet::errors::{BgpError, FsmSubcode, OpenMsgSubcode};
use bgp_packet::errors::{BgpError, CeaseSubcode, FsmSubcode, OpenMsgSubcode};
use bgp_packet::message::{BGP_HEADER_LEN, Message, NotificationMessage, PathAttribute};
use bgp_packet::open::{Capability, OpenMessage, OpenOption};
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
@@ -12,11 +12,20 @@ use eyre::{Result, bail, eyre};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::{mpsc, oneshot};
use tokio::time::{Instant, interval_at, sleep_until};
use tracing::{info, warn};
use bgp_packet::message::{AS_TRANS, BGP4_VERSION};
use crate::collision::{CollisionTable, Direction};
use crate::metrics::{MESSAGES_RECEIVED, MESSAGES_SENT, SESSIONS_ESTABLISHED_TOTAL, message_type_name};
use crate::session::{SessionHandle, SessionRegistry};
/// Number of outbound messages that may be queued for a session before
/// senders are back-pressured.
const OUTBOUND_QUEUE_CAPACITY: usize = 1024;
/// Minimal per-session config extracted from the server config.
#[derive(Clone)]
pub struct SessionConfig {
@@ -24,6 +33,16 @@ pub struct SessionConfig {
pub local_asn: u32,
pub hold_time_secs: u16,
pub adj_rib_in: AdjRibIn,
pub registry: SessionRegistry,
/// Shared table used to resolve connection collisions (RFC 4271 §6.8).
pub collisions: CollisionTable,
/// Which side opened this TCP connection.
pub direction: Direction,
/// Configured name of the peer, if known.
pub peer_name: Option<String>,
/// If set, the peer's OPEN must carry this ASN or the session is
/// refused with a Bad Peer AS notification.
pub expected_asn: Option<u32>,
}
/// Drive the BGP FSM for a single accepted TCP connection.
@@ -34,6 +53,7 @@ pub async fn run_session(
peer_addr: SocketAddr,
config: SessionConfig,
) -> Result<()> {
let local_addr = stream.local_addr()?;
let (mut reader, mut writer) = stream.into_split();
// --- OpenSent -----------------------------------------------------------
@@ -72,7 +92,7 @@ pub async fn run_session(
}
};
if let Err(bgp_err) = validate_open(&peer_open) {
if let Err(bgp_err) = validate_open(&peer_open, config.router_id) {
send_notification(&mut writer, bgp_err, &ParserContext::default()).await;
bail!("Peer {} sent invalid OPEN", peer_addr);
}
@@ -102,19 +122,46 @@ pub async fn run_session(
peer_open.asn as u32
};
if let Some(expected) = config.expected_asn
&& peer_asn != expected
{
send_notification(&mut writer, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs), &ctx).await;
bail!("Peer {peer_addr} announced AS{peer_asn} but AS{expected} was configured");
}
// Connection collision detection (RFC 4271 §6.8). From here on the
// guard keeps this connection visible to later ones, and `killed` fires
// if one of them wins.
let Some(mut collision_guard) =
config.collisions.admit(config.router_id, peer_open.identifier, config.direction)
else {
return close_for_collision(&mut writer, &ctx, peer_addr).await;
};
info!(
%peer_addr,
peer_asn,
negotiated_hold_time,
direction = config.direction.as_str(),
"OPEN received, transitioning to OpenConfirm"
);
// --- OpenConfirm --------------------------------------------------------
send_message(&mut writer, &Message::KeepAlive, &ctx).await?;
let hold_duration = Duration::from_secs(negotiated_hold_time as u64);
// With a negotiated hold time of zero the hold timer does not run
// (RFC 4271 §4.2); fall back to the large pre-negotiation value so a
// silent peer is still cleaned up eventually.
let hold_duration = Duration::from_secs(if negotiated_hold_time == 0 { 240 } else { negotiated_hold_time as u64 });
match recv_with_timeout(&mut reader, &ctx, hold_duration).await {
let confirm = tokio::select! {
msg = recv_with_timeout(&mut reader, &ctx, hold_duration) => msg,
_ = &mut collision_guard.killed => {
return close_for_collision(&mut writer, &ctx, peer_addr).await;
}
};
match confirm {
Ok(Message::KeepAlive) => {}
Ok(other) => {
send_notification(
@@ -132,19 +179,51 @@ pub async fn run_session(
}
info!(%peer_addr, peer_asn, "Session established");
SESSIONS_ESTABLISHED_TOTAL.inc();
let peer_id = PeerId { addr: peer_addr, asn: peer_asn };
// --- Established --------------------------------------------------------
established_loop(
// Register so the rest of the server can see this session and queue
// messages for it. The guard deregisters on any exit path.
let (outbound_tx, mut outbound_rx) = mpsc::channel(OUTBOUND_QUEUE_CAPACITY);
collision_guard.set_established();
let _guard = config.registry.register(SessionHandle::new(
peer_id,
config.peer_name.clone(),
config.direction,
local_addr,
peer_four_byte_asn,
outbound_tx,
));
let result = established_loop(
&mut reader,
&mut writer,
&ctx,
negotiated_hold_time,
peer_id,
&config.adj_rib_in,
SessionInputs { outbound: &mut outbound_rx, killed: &mut collision_guard.killed, registry: &config.registry },
)
.await
.await;
// Whatever ended the session, routes learned over it are gone
// (RFC 4271 §8.2.2). Do this before the guard drops so subscribers see
// the withdrawals before the Down event.
let removed = config.adj_rib_in.remove_peer(peer_id).await;
info!(peer = %peer_id, removed_routes = removed, "Session ended");
result
}
/// Signals from the rest of the server into an established session.
struct SessionInputs<'a> {
/// Messages queued for transmission to the peer.
outbound: &'a mut mpsc::Receiver<Message>,
/// Fires when a later connection won collision resolution.
killed: &'a mut oneshot::Receiver<()>,
/// Where to report a peer's route-refresh request.
registry: &'a SessionRegistry,
}
async fn established_loop(
@@ -154,14 +233,16 @@ async fn established_loop(
hold_time_secs: u16,
peer_id: PeerId,
adj_rib_in: &AdjRibIn,
inputs: SessionInputs<'_>,
) -> Result<()> {
// When hold_time is 0, keepalives and the hold timer are disabled (RFC 4271 §4.4).
if hold_time_secs == 0 {
return no_timer_loop(reader, writer, ctx, peer_id, adj_rib_in).await;
}
let hold_duration = Duration::from_secs(hold_time_secs as u64);
let keepalive_interval = Duration::from_secs(hold_time_secs as u64 / 3);
let SessionInputs { outbound, killed, registry } = inputs;
// When hold_time is 0, keepalives and the hold timer are disabled
// (RFC 4271 §4.4). The timer branches below are gated off in that case;
// the placeholder durations are never observed.
let timers_enabled = hold_time_secs != 0;
let secs = if timers_enabled { hold_time_secs as u64 } else { 3 };
let hold_duration = Duration::from_secs(secs);
let keepalive_interval = Duration::from_secs(secs / 3);
// interval_at avoids firing immediately; first keepalive is sent after one interval.
let mut keepalive_timer = interval_at(Instant::now() + keepalive_interval, keepalive_interval);
@@ -178,6 +259,7 @@ async fn established_loop(
hold_timer.as_mut().reset(Instant::now() + hold_duration);
}
Message::Update(update) => {
hold_timer.as_mut().reset(Instant::now() + hold_duration);
info!(
peer = %peer_id,
withdrawn = update.withdrawn_routes.len(),
@@ -195,13 +277,36 @@ async fn established_loop(
send_notification(writer, BgpError::FsmError(FsmSubcode::UnexpectedEstablished), ctx).await;
bail!("Received OPEN in Established state from {}", peer_id);
}
Message::RouteRefresh => {}
Message::RouteRefresh => {
hold_timer.as_mut().reset(Instant::now() + hold_duration);
info!(peer = %peer_id, "Received ROUTE-REFRESH, re-sending our routes");
registry.request_refresh(&peer_id);
}
}
}
_ = keepalive_timer.tick() => {
Some(msg) = outbound.recv() => {
if let Message::Update(update) = &msg {
info!(
peer = %peer_id,
withdrawn = update.withdrawn_routes.len(),
announced = update.announced_routes.len(),
"Sending UPDATE",
);
}
send_message(writer, &msg, ctx).await?;
// A NOTIFICATION from our side ends the session (RFC 4271 §6).
if let Message::Notification(notif) = &msg {
info!(peer = %peer_id, error = %notif.error, "Sent NOTIFICATION, closing session");
return Ok(());
}
}
_ = &mut *killed => {
return close_for_collision(writer, ctx, peer_id.addr).await;
}
_ = keepalive_timer.tick(), if timers_enabled => {
send_message(writer, &Message::KeepAlive, ctx).await?;
}
_ = &mut hold_timer => {
_ = &mut hold_timer, if timers_enabled => {
warn!(peer = %peer_id, "Hold timer expired");
send_notification(writer, BgpError::HoldTimer, ctx).await;
bail!("Hold timer expired for peer {}", peer_id);
@@ -210,45 +315,6 @@ async fn established_loop(
}
}
/// Hold-timer-disabled path for hold_time == 0.
async fn no_timer_loop(
reader: &mut OwnedReadHalf,
writer: &mut OwnedWriteHalf,
ctx: &ParserContext,
peer_id: PeerId,
adj_rib_in: &AdjRibIn,
) -> Result<()> {
loop {
match read_message(reader, ctx).await? {
Message::KeepAlive => {}
Message::Update(update) => {
info!(
peer = %peer_id,
withdrawn = update.withdrawn_routes.len(),
announced = update.announced_routes.len(),
"Received UPDATE",
);
apply_update(adj_rib_in, peer_id, &update.path_attributes,
&update.withdrawn_routes, &update.announced_routes).await;
}
Message::Notification(notif) => {
info!(peer = %peer_id, error = %notif.error, "Received NOTIFICATION, closing session");
return Ok(());
}
Message::Open(_) => {
send_notification(
writer,
BgpError::FsmError(FsmSubcode::UnexpectedEstablished),
ctx,
)
.await;
bail!("Received OPEN in Established state from {}", peer_id);
}
Message::RouteRefresh => {}
}
}
}
// ---------------------------------------------------------------------------
// Wire helpers
// ---------------------------------------------------------------------------
@@ -270,6 +336,7 @@ async fn read_message(reader: &mut OwnedReadHalf, ctx: &ParserContext) -> Result
let (_, msg) =
Message::from_wire(ctx, &buf).map_err(|e| eyre!("Failed to parse BGP message: {e:?}"))?;
MESSAGES_RECEIVED.with_label_values(&[message_type_name(&msg)]).inc();
Ok(msg)
}
@@ -291,6 +358,7 @@ async fn send_message(
let mut buf = BytesMut::new();
msg.to_wire(ctx, &mut buf)?;
writer.write_all(&buf).await?;
MESSAGES_SENT.with_label_values(&[message_type_name(msg)]).inc();
Ok(())
}
@@ -306,6 +374,18 @@ async fn send_notification(writer: &mut OwnedWriteHalf, error: BgpError, ctx: &P
}
}
/// A later connection to the same BGP Identifier won collision resolution:
/// tell the peer why and end this session.
async fn close_for_collision(
writer: &mut OwnedWriteHalf,
ctx: &ParserContext,
peer_addr: SocketAddr,
) -> Result<()> {
info!(%peer_addr, "Closing connection: lost collision resolution");
send_notification(writer, BgpError::Cease(CeaseSubcode::ConnCollisionResolution), ctx).await;
bail!("Connection to {peer_addr} closed by collision resolution");
}
// ---------------------------------------------------------------------------
// RIB helpers
// ---------------------------------------------------------------------------
@@ -373,15 +453,26 @@ fn build_open(config: &SessionConfig) -> Message {
afi: AddressFamilyId::Ipv4,
safi: SubsequentAfi::Unicast,
},
// Without this a peer will not accept the IPv6 routes we
// originate via MP_REACH_NLRI (RFC 4760 §8).
Capability::MultiProtocol {
afi: AddressFamilyId::Ipv6,
safi: SubsequentAfi::Unicast,
},
Capability::RouteRefresh,
])],
})
}
fn validate_open(open: &OpenMessage) -> Result<(), BgpError> {
fn validate_open(open: &OpenMessage, local_id: Ipv4Addr) -> Result<(), BgpError> {
if open.version != BGP4_VERSION {
return Err(BgpError::OpenMsg(OpenMsgSubcode::UnsupportedVersion));
}
// A peer cannot share our identifier or use the unspecified address;
// collision resolution depends on identifiers being distinct.
if open.identifier == local_id || open.identifier.is_unspecified() {
return Err(BgpError::OpenMsg(OpenMsgSubcode::BadBgpId));
}
if open.hold_time != 0 && open.hold_time < 3 {
return Err(BgpError::OpenMsg(OpenMsgSubcode::UnacceptableHoldTime));
}
+6
View File
@@ -1,7 +1,13 @@
pub mod advertise;
pub mod collision;
pub mod config;
pub mod connection;
pub mod dialer;
pub mod fsm;
pub mod metrics;
pub mod ping;
pub mod server;
pub mod session;
pub mod tracing;
pub mod proto {
-29
View File
@@ -1,29 +0,0 @@
use bgp_server::config::Config;
use bgp_server::proto::config::BgpConfig;
use bgp_server::server::BgpServerBuilder;
use bgp_server::tracing::TracingArgs;
use clap::Parser;
use eyre::{Context, Result};
#[derive(Parser, Debug)]
struct ServerArgs {
/// Path to the textproto configuration file.
config: String,
#[command(flatten)]
tracing: TracingArgs,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = ServerArgs::parse();
args.tracing.install()?;
let config_string =
std::fs::read_to_string(&args.config).wrap_err("Failed to open config file")?;
let proto_config = protobuf::text_format::parse_from_str::<BgpConfig>(&config_string)
.wrap_err("Failed to parse config file")?;
let config = Config::from_proto(proto_config).wrap_err("Invalid configuration")?;
BgpServerBuilder::new(config).run().await
}
+87
View File
@@ -0,0 +1,87 @@
//! Process-wide Prometheus metrics, registered on the default registry.
use std::sync::LazyLock;
use bgp_packet::message::Message;
use prometheus::{
IntCounter, IntCounterVec, IntGauge, register_int_counter, register_int_counter_vec,
register_int_gauge,
};
pub static MESSAGES_SENT: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!("bgp_messages_sent_total", "BGP messages sent, by type", &["type"])
.expect("metric registration")
});
pub static MESSAGES_RECEIVED: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!("bgp_messages_received_total", "BGP messages received, by type", &["type"])
.expect("metric registration")
});
pub static SESSIONS_ESTABLISHED_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
register_int_counter!("bgp_sessions_established_total", "Sessions that reached Established")
.expect("metric registration")
});
pub static SESSIONS_ESTABLISHED: LazyLock<IntGauge> = LazyLock::new(|| {
register_int_gauge!("bgp_sessions_established", "Sessions currently in Established")
.expect("metric registration")
});
pub static COLLISIONS: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!(
"bgp_connection_collisions_total",
"Connection collisions resolved, by which connection was closed",
&["closed"]
)
.expect("metric registration")
});
pub static LOCAL_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
register_int_gauge!("bgp_local_routes", "Locally originated routes").expect("metric registration")
});
pub static ADJ_RIB_IN_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
register_int_gauge!("bgp_adj_rib_in_routes", "Routes held in Adj-RIB-In across all peers")
.expect("metric registration")
});
pub static PING_ECHO_REQUESTS: LazyLock<IntCounter> = LazyLock::new(|| {
register_int_counter!(
"ping_monitor_echo_requests_total",
"ICMPv6 echo requests seen for the watched prefixes"
)
.expect("metric registration")
});
pub static PING_EVENTS_SUPPRESSED: LazyLock<IntCounter> = LazyLock::new(|| {
register_int_counter!(
"ping_monitor_events_suppressed_total",
"Ping events not published because of the per-second limit"
)
.expect("metric registration")
});
/// Forces registration of every metric so they appear in scrapes before
/// their first observation.
pub fn init() {
LazyLock::force(&MESSAGES_SENT);
LazyLock::force(&MESSAGES_RECEIVED);
LazyLock::force(&SESSIONS_ESTABLISHED_TOTAL);
LazyLock::force(&SESSIONS_ESTABLISHED);
LazyLock::force(&COLLISIONS);
LazyLock::force(&LOCAL_ROUTES);
LazyLock::force(&ADJ_RIB_IN_ROUTES);
LazyLock::force(&PING_ECHO_REQUESTS);
LazyLock::force(&PING_EVENTS_SUPPRESSED);
}
pub fn message_type_name(msg: &Message) -> &'static str {
match msg {
Message::Open(_) => "open",
Message::Update(_) => "update",
Message::Notification(_) => "notification",
Message::KeepAlive => "keepalive",
Message::RouteRefresh => "route_refresh",
}
}
+412
View File
@@ -0,0 +1,412 @@
//! Shows who is pinging the prefixes this router originates.
//!
//! The host answers for the lab address space itself (an AnyIP route on the
//! loopback interface), so the kernel already replies to every ping. A raw
//! ICMPv6 socket gets a copy of each echo request as well; we never reply and
//! never see forwarded traffic. For every packet we learn the source, the
//! destination, the interface it arrived on and the hop limit it arrived
//! with, which is enough to show a student that their route is in use and
//! which way the traffic came in.
//!
//! Anyone on the Internet can send these packets, with any source address, so
//! everything here is bounded: a fixed number of flows is remembered and the
//! number of events published per second is capped.
#[cfg(unix)]
mod sys;
use std::collections::HashMap;
use std::net::Ipv6Addr;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use bgp_primitives::constants::AddressFamilyId;
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRib, LocalRoute};
use tokio::sync::{Mutex, broadcast};
use tokio::time::Instant;
use tracing::{error, info, warn};
use crate::metrics::{PING_ECHO_REQUESTS, PING_EVENTS_SUPPRESSED};
/// Flows (source and destination pairs) remembered. When full, the flow that
/// has been quiet the longest makes room.
const MAX_FLOWS: usize = 2048;
/// A flow that keeps pinging publishes an event this often, carrying the
/// running count.
const EVENT_INTERVAL: Duration = Duration::from_secs(2);
/// A flow that was quiet for this long starts a new burst, which is what the
/// event log reports.
const BURST_GAP: Duration = Duration::from_secs(10);
/// How much of each packet is kept for display.
pub const MAX_PACKET_BYTES: usize = 256;
/// Events published per second across all flows. Packets beyond this are
/// still counted and show up in [`PingMonitor::flows`].
const MAX_EVENTS_PER_SECOND: u32 = 50;
/// One echo request, as seen by the socket.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PingObservation {
pub src: Ipv6Addr,
pub dst: Ipv6Addr,
/// Name of the interface the packet arrived on.
pub interface: Option<String>,
/// Hop limit left when the packet arrived. Senders start at 64, 128 or
/// 255, so this tells roughly how many routers the packet crossed.
pub hop_limit: Option<u8>,
pub ident: u16,
pub seq: u16,
/// Size of the ICMPv6 message, header included.
pub bytes: usize,
/// The ICMPv6 message as received, from its type byte on, cut off after
/// [`MAX_PACKET_BYTES`]. The IPv6 header is not part of it: a raw ICMPv6
/// socket never sees one, which is why the fields above come separately.
pub packet: Vec<u8>,
}
/// The local route a ping was addressed to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PingedRoute {
pub prefix: IpPrefix,
pub owner: String,
}
/// All pings from one source to one destination.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PingFlow {
/// The most recent packet.
pub last: PingObservation,
pub count: u64,
pub first_seen: SystemTime,
pub last_seen: SystemTime,
/// The most specific local route covering the destination right now;
/// `None` when nobody announces it.
pub route: Option<PingedRoute>,
}
#[derive(Debug, Clone)]
pub struct PingEvent {
pub flow: PingFlow,
/// True for the first event of a burst: a new flow, or one that had gone
/// quiet. False for the periodic updates while it keeps pinging.
pub new_burst: bool,
}
/// Whether the monitor is watching, for display.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PingMonitorStatus {
/// Not configured.
Disabled,
Listening,
/// Configured, but the socket could not be opened.
Failed(String),
}
struct FlowState {
flow: PingFlow,
last_packet: Instant,
last_event: Option<Instant>,
/// A burst started and its first event has not gone out yet.
burst_pending: bool,
}
#[derive(Default)]
struct Table {
flows: HashMap<(Ipv6Addr, Ipv6Addr), FlowState>,
window_start: Option<Instant>,
window_events: u32,
}
impl Table {
fn take_event_slot(&mut self, now: Instant) -> bool {
if self.window_start.is_none_or(|start| now.duration_since(start) >= Duration::from_secs(1)) {
self.window_start = Some(now);
self.window_events = 0;
}
if self.window_events >= MAX_EVENTS_PER_SECOND {
return false;
}
self.window_events += 1;
true
}
fn make_room(&mut self) {
if self.flows.len() < MAX_FLOWS {
return;
}
if let Some(key) = self.flows.iter().min_by_key(|(_, s)| s.last_packet).map(|(k, _)| *k) {
self.flows.remove(&key);
}
}
}
struct Inner {
prefixes: Vec<IpPrefix>,
local_rib: Option<LocalRib>,
status: PingMonitorStatus,
table: Mutex<Table>,
events: broadcast::Sender<PingEvent>,
}
/// Handle to the ping monitor. Cheap to clone.
#[derive(Clone)]
pub struct PingMonitor {
inner: Arc<Inner>,
}
impl PingMonitor {
fn new(prefixes: Vec<IpPrefix>, local_rib: Option<LocalRib>, status: PingMonitorStatus) -> Self {
let (events, _) = broadcast::channel(256);
Self { inner: Arc::new(Inner { prefixes, local_rib, status, table: Mutex::default(), events }) }
}
/// A monitor that watches nothing.
pub fn disabled() -> Self {
Self::new(vec![], None, PingMonitorStatus::Disabled)
}
/// A monitor without a socket, fed through [`PingMonitor::record`].
pub fn without_socket(prefixes: Vec<IpPrefix>, local_rib: Option<LocalRib>) -> Self {
Self::new(prefixes, local_rib, PingMonitorStatus::Listening)
}
/// Opens the socket and starts watching for pings to `prefixes`. Failing
/// to open it (usually: no CAP_NET_RAW) is not fatal; the daemon runs on
/// without the monitor and the status page says why.
pub fn start(prefixes: Vec<IpPrefix>, local_rib: LocalRib) -> Self {
#[cfg(unix)]
let socket = sys::EchoSocket::open();
#[cfg(not(unix))]
let socket: std::io::Result<()> = Err(std::io::Error::other("not supported on this platform"));
match socket {
Ok(socket) => {
let shown: Vec<String> = prefixes.iter().map(ToString::to_string).collect();
info!(prefixes = ?shown, "Ping monitor listening for ICMPv6 echo requests");
let monitor = Self::new(prefixes, Some(local_rib), PingMonitorStatus::Listening);
#[cfg(unix)]
tokio::spawn(monitor.clone().run(socket));
monitor
}
Err(e) => {
warn!(err = %e, "Ping monitor disabled: cannot open a raw ICMPv6 socket (needs CAP_NET_RAW)");
Self::new(prefixes, None, PingMonitorStatus::Failed(e.to_string()))
}
}
}
#[cfg(unix)]
async fn run(self, mut socket: sys::EchoSocket) {
loop {
match socket.recv().await {
Ok(observation) => self.record(observation).await,
Err(e) => {
error!(err = %e, "Ping monitor socket failed");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
pub fn status(&self) -> &PingMonitorStatus {
&self.inner.status
}
/// The prefixes being watched.
pub fn prefixes(&self) -> &[IpPrefix] {
&self.inner.prefixes
}
pub fn subscribe(&self) -> broadcast::Receiver<PingEvent> {
self.inner.events.subscribe()
}
/// Accounts for one echo request. Packets to destinations outside the
/// watched prefixes are ignored.
pub async fn record(&self, observation: PingObservation) {
let destination = host_prefix(observation.dst);
if !self.inner.prefixes.iter().any(|p| p.contains(&destination)) {
return;
}
PING_ECHO_REQUESTS.inc();
let now = Instant::now();
let wall = SystemTime::now();
let key = (observation.src, observation.dst);
let mut guard = self.inner.table.lock().await;
let table = &mut *guard;
let is_new = !table.flows.contains_key(&key);
if is_new {
table.make_room();
}
let state = table.flows.entry(key).or_insert_with(|| FlowState {
flow: PingFlow { last: observation.clone(), count: 0, first_seen: wall, last_seen: wall, route: None },
last_packet: now,
last_event: None,
burst_pending: false,
});
if is_new || now.duration_since(state.last_packet) >= BURST_GAP {
state.burst_pending = true;
}
state.flow.last = observation;
state.flow.count += 1;
state.flow.last_seen = wall;
state.last_packet = now;
let due = state.burst_pending || state.last_event.is_none_or(|t| now.duration_since(t) >= EVENT_INTERVAL);
if !due {
return;
}
let new_burst = state.burst_pending;
let mut flow = state.flow.clone();
if !table.take_event_slot(now) {
PING_EVENTS_SUPPRESSED.inc();
return;
}
if let Some(state) = table.flows.get_mut(&key) {
state.last_event = Some(now);
state.burst_pending = false;
}
drop(guard);
flow.route = resolve(&self.local_routes().await, &destination);
// No subscribers is fine.
let _ = self.inner.events.send(PingEvent { flow, new_burst });
}
/// Every remembered flow, most recently seen first.
pub async fn flows(&self) -> Vec<PingFlow> {
let mut flows: Vec<PingFlow> = self.inner.table.lock().await.flows.values().map(|s| s.flow.clone()).collect();
let routes = self.local_routes().await;
for flow in &mut flows {
flow.route = resolve(&routes, &host_prefix(flow.last.dst));
}
flows.sort_by(|a, b| b.last_seen.cmp(&a.last_seen).then_with(|| a.last.dst.cmp(&b.last.dst)));
flows
}
async fn local_routes(&self) -> Vec<LocalRoute> {
match &self.inner.local_rib {
Some(rib) => rib.snapshot().await,
None => vec![],
}
}
}
fn host_prefix(addr: Ipv6Addr) -> IpPrefix {
IpPrefix { address_family: AddressFamilyId::Ipv6, prefix: addr.octets().to_vec(), length: 128 }
}
fn resolve(routes: &[LocalRoute], destination: &IpPrefix) -> Option<PingedRoute> {
routes
.iter()
.filter(|r| r.prefix.contains(destination))
.max_by_key(|r| r.prefix.length)
.map(|r| PingedRoute { prefix: r.prefix.clone(), owner: r.owner.clone() })
}
#[cfg(test)]
mod tests {
use super::*;
use bgp_rib::{LocalRibPolicy, LocalRouteAttrs};
fn lab() -> Vec<IpPrefix> {
vec![IpPrefix::try_from("2a0d:d741::/40").unwrap()]
}
fn ping(src: &str, dst: &str, seq: u16) -> PingObservation {
PingObservation {
src: src.parse().unwrap(),
dst: dst.parse().unwrap(),
interface: Some("swissix0".into()),
hop_limit: Some(57),
ident: 7,
seq,
bytes: 64,
packet: vec![128, 0, 0, 0, 0, 7, (seq >> 8) as u8, seq as u8],
}
}
#[tokio::test(start_paused = true)]
async fn counts_flows_and_names_the_route_owner() {
let rib = LocalRib::spawn(LocalRibPolicy::default());
let prefix = IpPrefix::try_from("2a0d:d741:1a::/48").unwrap();
rib.announce(LocalRoute::new(prefix.clone(), "alice", LocalRouteAttrs::default())).await.unwrap();
let monitor = PingMonitor::without_socket(lab(), Some(rib.clone()));
let mut events = monitor.subscribe();
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1a::1", 0)).await;
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1a::1", 1)).await;
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:5::1", 0)).await;
let first = events.try_recv().unwrap();
assert!(first.new_burst);
assert_eq!(first.flow.route, Some(PingedRoute { prefix, owner: "alice".into() }));
let other = events.try_recv().unwrap();
assert_eq!(other.flow.route, None, "nobody announces 2a0d:d741:5::/48");
assert!(events.try_recv().is_err(), "the second packet of a flow is not an event of its own");
let flows = monitor.flows().await;
assert_eq!(flows.len(), 2);
let alice = flows.iter().find(|f| f.route.is_some()).unwrap();
assert_eq!((alice.count, alice.last.seq), (2, 1));
// The owner shown follows the routing table, not the first packet.
rib.withdraw(alice.route.as_ref().unwrap().prefix.clone(), None).await.unwrap();
assert!(monitor.flows().await.iter().all(|f| f.route.is_none()));
}
#[tokio::test(start_paused = true)]
async fn ignores_destinations_outside_the_watched_prefixes() {
let monitor = PingMonitor::without_socket(lab(), None);
monitor.record(ping("2001:db8::1", "2a0d:d740::1", 0)).await;
monitor.record(ping("2001:db8::1", "2001:7f8:24::c9", 0)).await;
assert!(monitor.flows().await.is_empty());
}
#[tokio::test(start_paused = true)]
async fn a_steady_ping_is_an_occasional_update_and_a_pause_starts_a_new_burst() {
let monitor = PingMonitor::without_socket(lab(), None);
let mut events = monitor.subscribe();
let mut seen = vec![];
for seq in 0..6 {
monitor.record(ping("2001:db8::1", "2a0d:d741:1::1", seq)).await;
while let Ok(ev) = events.try_recv() {
seen.push((ev.flow.count, ev.new_burst));
}
tokio::time::advance(Duration::from_secs(1)).await;
}
assert_eq!(seen, vec![(1, true), (3, false), (5, false)]);
tokio::time::advance(BURST_GAP).await;
monitor.record(ping("2001:db8::1", "2a0d:d741:1::1", 6)).await;
let ev = events.try_recv().unwrap();
assert_eq!((ev.flow.count, ev.new_burst), (7, true));
}
#[tokio::test(start_paused = true)]
async fn a_flood_of_spoofed_sources_is_bounded() {
let monitor = PingMonitor::without_socket(lab(), None);
let mut events = monitor.subscribe();
for n in 0..(MAX_FLOWS as u32 + 500) {
let src = Ipv6Addr::from(0x2001_0db8_0000_0000_0000_0000_0000_0000u128 + n as u128);
monitor.record(ping(&src.to_string(), "2a0d:d741:1::1", 0)).await;
}
assert_eq!(monitor.flows().await.len(), MAX_FLOWS);
let mut published = 0;
while events.try_recv().is_ok() {
published += 1;
}
assert_eq!(published, MAX_EVENTS_PER_SECOND, "time stood still, so this was all one second");
// A real student still gets through once the flood's second is over,
// even though the first attempt at announcing the burst was dropped.
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1::1", 0)).await;
assert!(events.try_recv().is_err());
tokio::time::advance(Duration::from_secs(1)).await;
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1::1", 1)).await;
let ev = events.try_recv().unwrap();
assert_eq!((ev.flow.count, ev.new_burst), (2, true));
}
}
+265
View File
@@ -0,0 +1,265 @@
//! The raw ICMPv6 socket behind the ping monitor.
//!
//! All the `unsafe` in the daemon lives here: the socket options and the
//! ancillary data we need (`ICMP6_FILTER`, `IPV6_PKTINFO`, `IPV6_HOPLIMIT`)
//! have no safe wrapper in the standard library or tokio.
use std::collections::HashMap;
use std::ffi::CStr;
use std::io;
use std::mem::{size_of, size_of_val, zeroed};
use std::net::Ipv6Addr;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use libc::{c_int, c_void};
use tokio::io::Interest;
use tokio::io::unix::AsyncFd;
use super::{MAX_PACKET_BYTES, PingObservation};
const ICMP6_ECHO_REQUEST: u8 = 128;
#[cfg(any(target_os = "linux", target_os = "android"))]
const ICMP6_FILTER: c_int = 1;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
const ICMP6_FILTER: c_int = 18;
/// `struct icmp6_filter`: one bit per ICMPv6 type.
type Icmp6Filter = [u32; 8];
/// A filter that lets only echo requests through. Linux and the BSDs agree on
/// the layout and disagree on the meaning: on Linux a set bit blocks the
/// type, on the BSDs (and macOS) a set bit passes it.
fn echo_request_only() -> Icmp6Filter {
let (word, bit) = ((ICMP6_ECHO_REQUEST >> 5) as usize, 1u32 << (ICMP6_ECHO_REQUEST & 31));
if cfg!(any(target_os = "linux", target_os = "android")) {
let mut filter = [u32::MAX; 8];
filter[word] &= !bit;
filter
} else {
let mut filter = [0u32; 8];
filter[word] |= bit;
filter
}
}
fn check(ret: c_int) -> io::Result<c_int> {
if ret < 0 { Err(io::Error::last_os_error()) } else { Ok(ret) }
}
fn setsockopt<T>(fd: RawFd, level: c_int, name: c_int, value: &T) -> io::Result<()> {
// SAFETY: `value` is a live reference and the length given is its size.
check(unsafe {
libc::setsockopt(fd, level, name, (value as *const T).cast::<c_void>(), size_of::<T>() as libc::socklen_t)
})?;
Ok(())
}
/// Receives a copy of every ICMPv6 echo request the kernel delivers to this
/// host. The kernel still answers them; we never send anything.
pub struct EchoSocket {
fd: AsyncFd<OwnedFd>,
/// Interface names by index. Looked up once: interfaces on a router do
/// not change name, and under a ping flood the lookup would be three
/// system calls per packet.
interfaces: HashMap<u32, Option<String>>,
}
impl EchoSocket {
/// Opens the socket. Needs CAP_NET_RAW on Linux. macOS also hands echo
/// requests to unprivileged ICMPv6 datagram sockets, which is enough to
/// try the monitor out on a laptop. (On Linux those only ever see the
/// replies to their own pings, so there is no such fallback there.)
pub fn open() -> io::Result<Self> {
Self::open_as(libc::SOCK_RAW).or_else(|e| {
if cfg!(target_os = "macos") { Self::open_as(libc::SOCK_DGRAM) } else { Err(e) }
})
}
fn open_as(socket_type: c_int) -> io::Result<Self> {
// SAFETY: plain system call; the result is checked before use.
let raw = check(unsafe { libc::socket(libc::AF_INET6, socket_type, libc::IPPROTO_ICMPV6) })?;
// SAFETY: `raw` is a descriptor we just opened and nobody else owns.
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
// SAFETY: fcntl on a descriptor we own, with integer arguments.
unsafe {
let flags = check(libc::fcntl(raw, libc::F_GETFL))?;
check(libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK))?;
check(libc::fcntl(raw, libc::F_SETFD, libc::FD_CLOEXEC))?;
}
setsockopt(raw, libc::IPPROTO_ICMPV6, ICMP6_FILTER, &echo_request_only())?;
let on: c_int = 1;
setsockopt(raw, libc::IPPROTO_IPV6, libc::IPV6_RECVPKTINFO, &on)?;
setsockopt(raw, libc::IPPROTO_IPV6, libc::IPV6_RECVHOPLIMIT, &on)?;
Ok(Self { fd: AsyncFd::with_interest(fd, Interest::READABLE)?, interfaces: HashMap::new() })
}
/// Waits for the next echo request.
pub async fn recv(&mut self) -> io::Result<PingObservation> {
loop {
let mut guard = self.fd.readable().await?;
let packet = match guard.try_io(|fd| recv_packet(fd.as_raw_fd())) {
Ok(result) => result?,
Err(_would_block) => continue,
};
let Some(packet) = packet else { continue };
let interface = self.interfaces.entry(packet.ifindex).or_insert_with(|| interface_name(packet.ifindex)).clone();
return Ok(PingObservation {
src: packet.src,
dst: packet.dst,
interface,
hop_limit: packet.hop_limit,
ident: packet.ident,
seq: packet.seq,
bytes: packet.bytes,
packet: packet.data,
});
}
}
}
struct Packet {
src: Ipv6Addr,
dst: Ipv6Addr,
ifindex: u32,
hop_limit: Option<u8>,
ident: u16,
seq: u16,
bytes: usize,
data: Vec<u8>,
}
/// Reads one packet. `Ok(None)` is a packet we cannot use (not an echo
/// request, or the kernel did not say where it was going).
fn recv_packet(fd: RawFd) -> io::Result<Option<Packet>> {
// A raw ICMPv6 socket delivers the ICMPv6 message without the IPv6
// header, so everything about the header comes from ancillary data.
let mut data = [0u8; 2048];
let mut control = [0u64; 16]; // u64 for the alignment cmsghdr needs
// SAFETY: all-zero is a valid value for these plain C structs.
let mut from: libc::sockaddr_in6 = unsafe { zeroed() };
let mut iov = libc::iovec { iov_base: data.as_mut_ptr().cast(), iov_len: data.len() };
// SAFETY: as above. msghdr has private padding on some targets, so it
// cannot be built with a struct literal.
let mut msg: libc::msghdr = unsafe { zeroed() };
msg.msg_name = (&mut from as *mut libc::sockaddr_in6).cast();
msg.msg_namelen = size_of::<libc::sockaddr_in6>() as _;
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1 as _;
msg.msg_control = control.as_mut_ptr().cast();
msg.msg_controllen = size_of_val(&control) as _;
// SAFETY: every pointer in `msg` refers to a local that outlives the call
// and the lengths given match the buffers.
let n = unsafe { libc::recvmsg(fd, &mut msg, 0) };
if n < 0 {
return Err(io::Error::last_os_error());
}
let n = n as usize;
if n < 8 || data[0] != ICMP6_ECHO_REQUEST {
return Ok(None);
}
let mut dst = None;
let mut hop_limit = None;
// SAFETY: the CMSG_* macros walk the control buffer the kernel just
// filled in, bounded by msg_controllen. Payloads are read unaligned.
unsafe {
let mut cmsg = libc::CMSG_FIRSTHDR(&msg);
while !cmsg.is_null() {
let header = &*cmsg;
if header.cmsg_level == libc::IPPROTO_IPV6 {
if header.cmsg_type == libc::IPV6_PKTINFO {
let info: libc::in6_pktinfo = std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast());
dst = Some((Ipv6Addr::from(info.ipi6_addr.s6_addr), info.ipi6_ifindex as u32));
} else if header.cmsg_type == libc::IPV6_HOPLIMIT {
let value: c_int = std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast());
hop_limit = u8::try_from(value).ok();
}
}
cmsg = libc::CMSG_NXTHDR(&msg, cmsg);
}
}
let Some((dst, ifindex)) = dst else { return Ok(None) };
Ok(Some(Packet {
src: Ipv6Addr::from(from.sin6_addr.s6_addr),
dst,
ifindex,
hop_limit,
ident: u16::from_be_bytes([data[4], data[5]]),
seq: u16::from_be_bytes([data[6], data[7]]),
bytes: n,
data: data[..n.min(MAX_PACKET_BYTES)].to_vec(),
}))
}
fn interface_name(index: u32) -> Option<String> {
let mut name = [0 as libc::c_char; libc::IF_NAMESIZE];
// SAFETY: the buffer is IF_NAMESIZE bytes, as if_indextoname requires,
// and on success holds a NUL-terminated string.
unsafe {
if libc::if_indextoname(index, name.as_mut_ptr()).is_null() {
return None;
}
Some(CStr::from_ptr(name.as_ptr()).to_string_lossy().into_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_passes_only_echo_requests() {
let filter = echo_request_only();
let blocked = |t: u8| {
let set = filter[(t >> 5) as usize] & (1 << (t & 31)) != 0;
if cfg!(any(target_os = "linux", target_os = "android")) { set } else { !set }
};
assert!(!blocked(128), "echo request passes");
for t in [1u8, 3, 129, 133, 134, 135, 136] {
assert!(blocked(t), "type {t} is dropped in the kernel");
}
}
/// Pings ::1 and expects to see the request. Skipped where the socket
/// cannot be opened, i.e. on Linux without CAP_NET_RAW.
#[tokio::test]
async fn sees_a_ping_to_loopback() {
let mut socket = match EchoSocket::open() {
Ok(socket) => socket,
Err(e) => return eprintln!("skipped: cannot open an ICMPv6 socket: {e}"),
};
let ping = if cfg!(target_os = "macos") { "ping6" } else { "ping" };
let Ok(mut child) = std::process::Command::new(ping)
.args(["-c", "1", "::1"])
.stdout(std::process::Stdio::null())
.spawn()
else {
return eprintln!("skipped: no {ping} command");
};
let seen = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let ping = socket.recv().await.unwrap();
if ping.dst == Ipv6Addr::LOCALHOST {
return ping;
}
}
})
.await;
let _ = child.wait();
let seen = seen.expect("echo request to ::1 was not seen");
assert_eq!(seen.src, Ipv6Addr::LOCALHOST);
assert_eq!(seen.hop_limit, Some(64));
assert!(seen.seq <= 1, "first sequence number is 0 (BSD ping6) or 1 (iputils)");
assert!(seen.interface.as_deref().is_some_and(|name| name.starts_with("lo")), "{:?}", seen.interface);
assert!(seen.bytes >= 8);
assert_eq!(seen.packet[..2], [128, 0], "echo request, code 0");
assert_eq!(seen.packet[4..8], [seen.ident.to_be_bytes(), seen.seq.to_be_bytes()].concat()[..]);
}
}
+46 -9
View File
@@ -7,11 +7,15 @@ use futures::stream::{FuturesUnordered, StreamExt};
use tokio::net::{TcpListener, TcpStream};
use tracing::{error, info};
use bgp_rib::AdjRibIn;
use bgp_rib::{AdjRibIn, LocalRib};
use crate::advertise::spawn_advertiser;
use crate::collision::{CollisionTable, Direction};
use crate::config::Config;
use crate::connection::handle_connection;
use crate::dialer::spawn_dialers;
use crate::fsm::SessionConfig;
use crate::session::{ConfiguredPeer, SessionRegistry};
pub struct BgpServerBuilder {
config: Config,
@@ -25,6 +29,10 @@ pub struct BgpServer {
/// Shared RIB handle — created at bind time so callers can clone it before
/// the accept loop starts (e.g. tests that want to introspect the RIB).
pub adj_rib_in: AdjRibIn,
/// Routes this router originates; announced to every established peer.
pub local_rib: LocalRib,
/// Live view of established sessions.
pub registry: SessionRegistry,
}
impl BgpServerBuilder {
@@ -46,9 +54,17 @@ impl BgpServerBuilder {
listeners.push((bound_addr, listener));
}
Ok(BgpServer {
config: self.config,
listeners,
adj_rib_in: AdjRibIn::spawn(),
local_rib: LocalRib::spawn(self.config.local_route_policy.clone()),
registry: SessionRegistry::with_configured(
self.config
.peers
.iter()
.map(|p| ConfiguredPeer { name: p.name.clone(), endpoint: p.endpoint, remote_asn: p.remote_asn })
.collect(),
),
config: self.config,
})
}
@@ -59,12 +75,17 @@ impl BgpServerBuilder {
}
impl BgpServer {
pub fn config(&self) -> &Config {
&self.config
}
/// The addresses actually bound (useful when the config uses port 0).
pub fn local_addrs(&self) -> Vec<SocketAddr> {
self.listeners.iter().map(|(addr, _)| *addr).collect()
}
pub async fn run(self) -> Result<()> {
crate::metrics::init();
info!(
router_id = %self.config.router_id,
local_asn = self.config.local_asn,
@@ -72,12 +93,24 @@ impl BgpServer {
"BGP server started"
);
let session_config = Arc::new(SessionConfig {
let session_config = SessionConfig {
router_id: self.config.router_id,
local_asn: self.config.local_asn,
hold_time_secs: self.config.hold_time_secs,
adj_rib_in: self.adj_rib_in.clone(),
});
registry: self.registry.clone(),
collisions: CollisionTable::new(self.config.collision_detect_established_state),
direction: Direction::Inbound,
peer_name: None,
expected_asn: None,
};
let _advertiser = spawn_advertiser(
self.local_rib.clone(),
self.registry.clone(),
self.config.local_asn,
);
let _dialers = spawn_dialers(&self.config.peers, &session_config, self.config.connect_retry);
let mut accepting: FuturesUnordered<_> = self
.listeners
@@ -88,11 +121,15 @@ impl BgpServer {
while let Some((listen_addr, result, listener)) = accepting.next().await {
match result {
Ok((stream, peer_addr)) => {
tokio::spawn(handle_connection(
stream,
peer_addr,
Arc::clone(&session_config),
));
// Inbound connections from a configured peer inherit its
// name, expected ASN and hold time.
let mut config = session_config.clone();
if let Some(peer) = self.config.peers.iter().find(|p| p.endpoint.ip() == peer_addr.ip()) {
config.peer_name = Some(peer.name.clone());
config.expected_asn = Some(peer.remote_asn);
config.hold_time_secs = peer.hold_time_secs;
}
tokio::spawn(handle_connection(stream, peer_addr, Arc::new(config)));
}
Err(e) => {
error!(%listen_addr, err = %e, "Accept error");
+233
View File
@@ -0,0 +1,233 @@
//! Registry of established BGP sessions.
//!
//! A session registers itself when it reaches Established and is removed when
//! its [`SessionGuard`] drops, so a session that errors out is always
//! deregistered. Other parts of the server (the advertiser, the HTTP API) use
//! the registry to enumerate live sessions and queue outbound messages.
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime};
use bgp_packet::errors::{BgpError, CeaseSubcode};
use bgp_packet::message::NotificationMessage;
use bgp_packet::message::Message;
use bgp_rib::PeerId;
use tokio::sync::{broadcast, mpsc};
use crate::collision::Direction;
use crate::metrics::SESSIONS_ESTABLISHED;
const EVENT_CHANNEL_CAPACITY: usize = 256;
/// Where the dialer is in bringing up a configured peer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DialState {
/// Not dialling: the peer connected to us, or the dialer has not started.
Idle,
Connecting,
/// The last attempt failed or the session ended; waiting to retry.
Backoff { until: SystemTime, last_error: String },
}
/// A peer from the configuration file, whether or not it is currently up.
#[derive(Debug, Clone)]
pub struct ConfiguredPeer {
pub name: String,
pub endpoint: SocketAddr,
pub remote_asn: u32,
}
/// Handle to one established session. Cloning is cheap.
#[derive(Debug, Clone)]
pub struct SessionHandle {
pub peer: PeerId,
/// Name from the configuration, if this peer is configured.
pub name: Option<String>,
/// Which side opened the TCP connection.
pub direction: Direction,
/// Our side of the TCP connection; used as the next hop for routes we
/// originate to this peer.
pub local_addr: SocketAddr,
pub established_at: SystemTime,
/// Whether the peer negotiated RFC 6793 four-octet ASNs. Determines how
/// AS_PATH is encoded on the wire for this session.
pub four_octet_asn: bool,
outbound: mpsc::Sender<Message>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("session with {0} has closed")]
pub struct SessionClosed(pub PeerId);
impl SessionHandle {
pub fn new(
peer: PeerId,
name: Option<String>,
direction: Direction,
local_addr: SocketAddr,
four_octet_asn: bool,
outbound: mpsc::Sender<Message>,
) -> Self {
Self { peer, name, direction, local_addr, established_at: SystemTime::now(), four_octet_asn, outbound }
}
/// Queues a message for transmission on this session. Waits for queue
/// space if the session's writer is behind.
pub async fn send(&self, msg: Message) -> Result<(), SessionClosed> {
self.outbound.send(msg).await.map_err(|_| SessionClosed(self.peer))
}
}
#[derive(Debug, Clone)]
pub enum SessionEvent {
Up(SessionHandle),
Down(PeerId),
/// The peer sent ROUTE-REFRESH (RFC 2918): it wants everything we
/// advertise to it sent again, typically because its import policy
/// changed.
RefreshRequested(SessionHandle),
}
#[derive(Clone)]
pub struct SessionRegistry {
inner: Arc<RwLock<HashMap<PeerId, SessionHandle>>>,
configured: Arc<Vec<ConfiguredPeer>>,
dial_states: Arc<RwLock<HashMap<String, DialState>>>,
shutting_down: Arc<AtomicBool>,
events: broadcast::Sender<SessionEvent>,
}
impl Default for SessionRegistry {
fn default() -> Self {
Self::new()
}
}
impl SessionRegistry {
pub fn new() -> Self {
Self::with_configured(vec![])
}
pub fn with_configured(configured: Vec<ConfiguredPeer>) -> Self {
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
let dial_states = configured.iter().map(|p| (p.name.clone(), DialState::Idle)).collect();
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
configured: Arc::new(configured),
dial_states: Arc::new(RwLock::new(dial_states)),
shutting_down: Arc::new(AtomicBool::new(false)),
events,
}
}
/// Peers from the configuration file, each with the dialer's current
/// state for it.
pub fn configured_peers(&self) -> Vec<(ConfiguredPeer, DialState)> {
let states = self.dial_states.read().expect("dial states poisoned");
self.configured
.iter()
.map(|p| (p.clone(), states.get(&p.name).cloned().unwrap_or(DialState::Idle)))
.collect()
}
/// The configured peer whose endpoint IP is `ip`, if any.
pub fn configured_by_ip(&self, ip: IpAddr) -> Option<&ConfiguredPeer> {
self.configured.iter().find(|p| p.endpoint.ip() == ip)
}
pub fn set_dial_state(&self, name: &str, state: DialState) {
self.dial_states.write().expect("dial states poisoned").insert(name.to_string(), state);
}
/// An established session with this remote IP and ASN, regardless of
/// which side opened the TCP connection.
pub fn find(&self, ip: IpAddr, asn: u32) -> Option<SessionHandle> {
self.inner
.read()
.expect("session registry poisoned")
.values()
.find(|h| h.peer.addr.ip() == ip && h.peer.asn == asn)
.cloned()
}
/// Adds a session and emits [`SessionEvent::Up`]. The returned guard
/// removes the session again when dropped.
pub fn register(&self, handle: SessionHandle) -> SessionGuard {
let peer = handle.peer;
self.inner.write().expect("session registry poisoned").insert(peer, handle.clone());
SESSIONS_ESTABLISHED.inc();
let _ = self.events.send(SessionEvent::Up(handle));
SessionGuard { registry: self.clone(), peer }
}
pub fn get(&self, peer: &PeerId) -> Option<SessionHandle> {
self.inner.read().expect("session registry poisoned").get(peer).cloned()
}
/// All currently established sessions, in no particular order.
pub fn list(&self) -> Vec<SessionHandle> {
self.inner.read().expect("session registry poisoned").values().cloned().collect()
}
/// Called by a session when its peer asks for a route refresh.
pub fn request_refresh(&self, peer: &PeerId) {
if let Some(handle) = self.get(peer) {
let _ = self.events.send(SessionEvent::RefreshRequested(handle));
}
}
/// True once [`shutdown`](Self::shutdown) has begun; dialers stop
/// reconnecting.
pub fn is_shutting_down(&self) -> bool {
self.shutting_down.load(Ordering::Relaxed)
}
/// Closes every session politely: each peer is sent Cease /
/// Administrative Shutdown (RFC 4486) so it can drop the session at once
/// instead of discovering a dead TCP connection. Waits up to `grace` for
/// the sessions to finish; returns how many were still open.
pub async fn shutdown(&self, grace: Duration) -> usize {
self.shutting_down.store(true, Ordering::Relaxed);
for session in self.list() {
let _ = session
.send(Message::Notification(NotificationMessage {
error: BgpError::Cease(CeaseSubcode::AdminShutdown),
data: vec![],
}))
.await;
}
let deadline = tokio::time::Instant::now() + grace;
while !self.list().is_empty() && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
self.list().len()
}
pub fn subscribe(&self) -> broadcast::Receiver<SessionEvent> {
self.events.subscribe()
}
fn remove(&self, peer: &PeerId) {
let removed = self.inner.write().expect("session registry poisoned").remove(peer);
if removed.is_some() {
SESSIONS_ESTABLISHED.dec();
let _ = self.events.send(SessionEvent::Down(*peer));
}
}
}
/// Removes the session from its registry on drop.
pub struct SessionGuard {
registry: SessionRegistry,
peer: PeerId,
}
impl Drop for SessionGuard {
fn drop(&mut self) {
self.registry.remove(&self.peer);
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ use tracing::Level;
use tracing_subscriber::EnvFilter;
/// Default targets that we enable logging for.
const DEFAULT_TARGETS: [&str; 1] = ["bgp_server"];
const DEFAULT_TARGETS: [&str; 3] = ["bgp", "bgp_server", "bgp_web"];
#[derive(Debug, Args)]
pub struct TracingArgs {
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "bgp-web"
description = "HTTP API, event stream and metrics for the BGP daemon"
edition.workspace = true
[dependencies]
axum.workspace = true
bgp-packet.workspace = true
bgp-primitives.workspace = true
bgp-rib.workspace = true
bgp-server.workspace = true
bytes.workspace = true
eyre.workspace = true
futures.workspace = true
prometheus.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tracing.workspace = true
[dev-dependencies]
reqwest.workspace = true
+587
View File
@@ -0,0 +1,587 @@
//! JSON handlers and their view types.
use std::time::{SystemTime, UNIX_EPOCH};
use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use bgp_packet::message::{Message, OriginPathAttribute, PathAttribute, UpdateMessage};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_primitives::parser::ParserContext;
use bgp_rib::{LocalRibError, LocalRoute, LocalRouteAttrs, SnapshotEntry};
use bgp_server::advertise::build_announce;
use bgp_server::ping::{PingFlow, PingMonitorStatus};
use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry};
use bytes::BytesMut;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::AppState;
use crate::community;
const MAX_OWNER_LEN: usize = 64;
const MAX_PREPEND: u8 = 16;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn bad_request(message: impl Into<String>) -> Self {
Self { status: StatusCode::BAD_REQUEST, message: message.into() }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, Json(json!({ "error": self.message }))).into_response()
}
}
impl From<LocalRibError> for ApiError {
fn from(e: LocalRibError) -> Self {
let status = match e {
LocalRibError::NotAllowed(_) => StatusCode::FORBIDDEN,
LocalRibError::OwnedByOther { .. } => StatusCode::CONFLICT,
LocalRibError::OwnerLimit { .. } => StatusCode::UNPROCESSABLE_ENTITY,
LocalRibError::Closed => StatusCode::SERVICE_UNAVAILABLE,
};
Self { status, message: e.to_string() }
}
}
// ---------------------------------------------------------------------------
// Views
// ---------------------------------------------------------------------------
fn unix_secs(t: SystemTime) -> u64 {
t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
#[derive(Debug, Serialize)]
pub struct StatusView {
pub router_id: String,
pub local_asn: u32,
pub version: String,
pub hostname: String,
pub started_at_unix: u64,
pub uptime_secs: u64,
pub sessions: usize,
pub local_routes: usize,
pub received_routes: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct PeerView {
/// Name from the configuration file; absent for unconfigured inbound peers.
pub name: Option<String>,
pub peer_addr: String,
pub peer_asn: u32,
/// `Established`, `Connecting`, `Backoff` or `Idle`.
pub state: String,
/// `inbound` or `outbound`: which side opened the live TCP connection.
pub direction: Option<String>,
pub local_addr: Option<String>,
pub established_at_unix: Option<u64>,
pub uptime_secs: Option<u64>,
pub four_octet_asn: Option<bool>,
/// Why the last connection attempt or session ended, while in Backoff.
pub last_error: Option<String>,
pub retry_at_unix: Option<u64>,
}
impl From<&SessionHandle> for PeerView {
fn from(s: &SessionHandle) -> Self {
Self {
name: s.name.clone(),
peer_addr: s.peer.addr.to_string(),
peer_asn: s.peer.asn,
state: "Established".into(),
direction: Some(s.direction.as_str().into()),
local_addr: Some(s.local_addr.to_string()),
established_at_unix: Some(unix_secs(s.established_at)),
uptime_secs: Some(s.established_at.elapsed().map(|d| d.as_secs()).unwrap_or(0)),
four_octet_asn: Some(s.four_octet_asn),
last_error: None,
retry_at_unix: None,
}
}
}
impl PeerView {
fn from_configured(peer: &ConfiguredPeer, state: &DialState) -> Self {
let (state_name, last_error, retry_at_unix) = match state {
DialState::Idle => ("Idle", None, None),
DialState::Connecting => ("Connecting", None, None),
DialState::Backoff { until, last_error } => ("Backoff", Some(last_error.clone()), Some(unix_secs(*until))),
};
Self {
name: Some(peer.name.clone()),
peer_addr: peer.endpoint.to_string(),
peer_asn: peer.remote_asn,
state: state_name.into(),
direction: None,
local_addr: None,
established_at_unix: None,
uptime_secs: None,
four_octet_asn: None,
last_error,
retry_at_unix,
}
}
}
/// Configured peers first (with their live session when established), then
/// any unconfigured inbound sessions.
pub fn peer_views(registry: &SessionRegistry) -> Vec<PeerView> {
let sessions = registry.list();
let mut claimed = std::collections::HashSet::new();
let mut views: Vec<PeerView> = registry
.configured_peers()
.iter()
.map(|(peer, state)| {
match sessions.iter().find(|s| s.peer.addr.ip() == peer.endpoint.ip() && s.peer.asn == peer.remote_asn) {
Some(session) => {
claimed.insert(session.peer);
PeerView { name: Some(peer.name.clone()), ..PeerView::from(session) }
}
None => PeerView::from_configured(peer, state),
}
})
.collect();
let mut rest: Vec<PeerView> =
sessions.iter().filter(|s| !claimed.contains(&s.peer)).map(PeerView::from).collect();
rest.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr));
views.extend(rest);
views
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalRouteView {
pub prefix: String,
pub owner: String,
pub origin: String,
pub prepend: u8,
pub med: Option<u32>,
pub communities: Vec<String>,
pub large_communities: Vec<String>,
pub announced_at_unix: u64,
}
impl From<&LocalRoute> for LocalRouteView {
fn from(r: &LocalRoute) -> Self {
Self {
prefix: r.prefix.to_string(),
owner: r.owner.clone(),
origin: origin_name(r.attrs.origin).to_string(),
prepend: r.attrs.prepend,
med: r.attrs.med,
communities: r.attrs.communities.iter().copied().map(community::format_standard).collect(),
large_communities: r.attrs.large_communities.iter().map(community::format_large).collect(),
announced_at_unix: unix_secs(r.announced_at),
}
}
}
/// Human-oriented digest of a path attribute list.
#[derive(Debug, Clone, Default, Serialize)]
pub struct AttrSummary {
pub origin: Option<String>,
/// AS_PATH flattened in order; AS_SETs are included in place.
pub as_path: Vec<u32>,
pub next_hop: Option<String>,
pub med: Option<u32>,
pub local_pref: Option<u32>,
pub communities: Vec<String>,
pub large_communities: Vec<String>,
}
pub fn summarize(attrs: &[PathAttribute]) -> AttrSummary {
let mut s = AttrSummary::default();
for attr in attrs {
match attr {
PathAttribute::Origin(o) => s.origin = Some(origin_name(*o).to_string()),
PathAttribute::AsPath(p) => {
s.as_path = p.segments.iter().flat_map(|seg| seg.path.iter().copied()).collect()
}
PathAttribute::NextHop(nh) => s.next_hop = Some(nh.0.to_string()),
PathAttribute::MpReachNlri(mp) => s.next_hop = Some(format!("{:?}", mp.next_hop)),
PathAttribute::MultiExitDisc(m) => s.med = Some(m.0),
PathAttribute::LocalPref(l) => s.local_pref = Some(l.0),
PathAttribute::Communities(c) => {
s.communities = c.0.iter().copied().map(community::format_standard).collect()
}
PathAttribute::LargeCommunities(l) => {
s.large_communities = l.communities.iter().map(community::format_large).collect()
}
_ => {}
}
}
s
}
#[derive(Debug, Clone, Serialize)]
pub struct ReceivedRouteView {
pub peer_addr: String,
pub peer_asn: u32,
pub prefix: String,
pub received_at_unix: u64,
pub summary: AttrSummary,
/// The full decoded attribute list, for the curious.
pub attributes: Vec<PathAttribute>,
}
impl From<&SnapshotEntry> for ReceivedRouteView {
fn from(e: &SnapshotEntry) -> Self {
Self {
peer_addr: e.peer.addr.to_string(),
peer_asn: e.peer.asn,
prefix: e.prefix.to_string(),
received_at_unix: unix_secs(e.entry.received_at),
summary: summarize(&e.entry.path_attributes),
attributes: e.entry.path_attributes.clone(),
}
}
}
/// The exact UPDATE a route produces on one session.
#[derive(Debug, Clone, Serialize)]
pub struct WireView {
pub peer_addr: String,
pub peer_asn: u32,
pub next_hop: String,
/// Whether AS numbers in AS_PATH are 4 bytes wide on this session.
pub four_octet_asn: bool,
/// Complete BGP message including the 19-byte header, as lowercase hex.
pub hex: String,
pub length: usize,
pub summary: AttrSummary,
pub update: UpdateMessage,
}
fn origin_name(o: OriginPathAttribute) -> &'static str {
match o {
OriginPathAttribute::IGP => "IGP",
OriginPathAttribute::EGP => "EGP",
OriginPathAttribute::INCOMPLETE => "INCOMPLETE",
}
}
fn parse_origin(s: &str) -> Result<OriginPathAttribute, ApiError> {
match s.trim().to_ascii_uppercase().as_str() {
"IGP" => Ok(OriginPathAttribute::IGP),
"EGP" => Ok(OriginPathAttribute::EGP),
"INCOMPLETE" => Ok(OriginPathAttribute::INCOMPLETE),
other => Err(ApiError::bad_request(format!("origin {other:?} must be IGP, EGP or INCOMPLETE"))),
}
}
fn parse_prefix(s: &str) -> Result<IpPrefix, ApiError> {
IpPrefix::try_from(s.trim()).map_err(|e| ApiError::bad_request(format!("invalid prefix {s:?}: {e}")))
}
fn validate_owner(owner: &str) -> Result<String, ApiError> {
let owner = owner.trim();
if owner.is_empty() {
return Err(ApiError::bad_request("owner must not be empty"));
}
if owner.chars().count() > MAX_OWNER_LEN {
return Err(ApiError::bad_request(format!("owner must be at most {MAX_OWNER_LEN} characters")));
}
Ok(owner.to_string())
}
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Deserialize)]
pub struct AnnounceRequest {
pub prefix: String,
pub owner: String,
#[serde(default)]
pub origin: Option<String>,
#[serde(default)]
pub prepend: u8,
#[serde(default)]
pub med: Option<u32>,
#[serde(default)]
pub communities: Vec<String>,
#[serde(default)]
pub large_communities: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WithdrawRequest {
pub prefix: String,
/// Omit to withdraw regardless of owner.
#[serde(default)]
pub owner: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WireQuery {
pub prefix: String,
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
pub async fn status(State(state): State<AppState>) -> Json<StatusView> {
Json(StatusView {
router_id: state.info.router_id.to_string(),
local_asn: state.info.local_asn,
version: state.info.version.clone(),
hostname: state.info.hostname.clone(),
started_at_unix: unix_secs(state.info.started_at),
uptime_secs: state.info.started.elapsed().as_secs(),
sessions: state.registry.list().len(),
local_routes: state.local_rib.snapshot().await.len(),
received_routes: state.adj_rib_in.snapshot().await.len(),
})
}
/// The configuration file the daemon was started with.
#[derive(Debug, Clone, Serialize)]
pub struct ConfigView {
pub path: Option<String>,
/// Raw file contents as read at startup; later edits to the file are not
/// reflected until the daemon restarts.
pub text: Option<String>,
}
pub async fn config(State(state): State<AppState>) -> Json<ConfigView> {
let (path, text) = state.info.config.clone().unzip();
Json(ConfigView { path, text })
}
pub async fn peers(State(state): State<AppState>) -> Json<Vec<PeerView>> {
Json(peer_views(&state.registry))
}
pub async fn local_routes(State(state): State<AppState>) -> Json<Vec<LocalRouteView>> {
let mut routes: Vec<LocalRouteView> =
state.local_rib.snapshot().await.iter().map(LocalRouteView::from).collect();
routes.sort_by(|a, b| a.prefix.cmp(&b.prefix));
Json(routes)
}
pub async fn announce(
State(state): State<AppState>,
Json(req): Json<AnnounceRequest>,
) -> Result<Json<LocalRouteView>, ApiError> {
let prefix = parse_prefix(&req.prefix)?;
let owner = validate_owner(&req.owner)?;
if req.prepend > MAX_PREPEND {
return Err(ApiError::bad_request(format!("prepend must be at most {MAX_PREPEND}")));
}
let attrs = LocalRouteAttrs {
origin: req.origin.as_deref().map(parse_origin).transpose()?.unwrap_or(OriginPathAttribute::IGP),
prepend: req.prepend,
med: req.med,
communities: req
.communities
.iter()
.map(|c| community::parse_standard(c))
.collect::<Result<_, _>>()
.map_err(ApiError::bad_request)?,
large_communities: req
.large_communities
.iter()
.map(|c| community::parse_large(c))
.collect::<Result<_, _>>()
.map_err(ApiError::bad_request)?,
};
let route = LocalRoute::new(prefix, owner, attrs);
state.local_rib.announce(route.clone()).await?;
Ok(Json(LocalRouteView::from(&route)))
}
pub async fn withdraw(
State(state): State<AppState>,
Json(req): Json<WithdrawRequest>,
) -> Result<Json<Option<LocalRouteView>>, ApiError> {
let prefix = parse_prefix(&req.prefix)?;
let owner = req.owner.as_deref().map(validate_owner).transpose()?;
let removed = state.local_rib.withdraw(prefix, owner.as_deref()).await?;
Ok(Json(removed.as_ref().map(LocalRouteView::from)))
}
/// Shows the UPDATE bytes a local route produces on each established session.
pub async fn wire(
State(state): State<AppState>,
Query(q): Query<WireQuery>,
) -> Result<Json<Vec<WireView>>, ApiError> {
let prefix = parse_prefix(&q.prefix)?;
let route = state
.local_rib
.snapshot()
.await
.into_iter()
.find(|r| r.prefix == prefix)
.ok_or_else(|| ApiError { status: StatusCode::NOT_FOUND, message: format!("{prefix} is not announced") })?;
let mut views = vec![];
for session in state.registry.list() {
let Some(update) = build_announce(&route, state.info.local_asn, session.local_addr.ip()) else {
continue;
};
let ctx = ParserContext { four_octet_asn: Some(session.four_octet_asn), address_family: None };
let mut buf = BytesMut::new();
Message::Update(update.clone())
.to_wire(&ctx, &mut buf)
.map_err(|e| ApiError { status: StatusCode::INTERNAL_SERVER_ERROR, message: e.to_string() })?;
views.push(WireView {
peer_addr: session.peer.addr.to_string(),
peer_asn: session.peer.asn,
next_hop: session.local_addr.ip().to_string(),
four_octet_asn: session.four_octet_asn,
hex: buf.iter().map(|b| format!("{b:02x}")).collect(),
length: buf.len(),
summary: summarize(&update.path_attributes),
update,
});
}
views.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr));
Ok(Json(views))
}
#[derive(Debug, Clone, Serialize)]
pub struct WellKnownCommunity {
pub name: &'static str,
pub value: String,
}
/// Names the API accepts in place of a numeric standard community.
pub async fn well_known_communities() -> Json<Vec<WellKnownCommunity>> {
Json(
community::WELL_KNOWN
.iter()
.map(|(name, value)| WellKnownCommunity { name, value: community::format_standard(*value) })
.collect(),
)
}
pub async fn received_routes(State(state): State<AppState>) -> Json<Vec<ReceivedRouteView>> {
let mut routes: Vec<ReceivedRouteView> =
state.adj_rib_in.snapshot().await.iter().map(ReceivedRouteView::from).collect();
routes.sort_by(|a, b| (&a.prefix, &a.peer_addr).cmp(&(&b.prefix, &b.peer_addr)));
Json(routes)
}
// ---------------------------------------------------------------------------
// Pings
// ---------------------------------------------------------------------------
/// Flows returned by `/api/pings` unless `?limit=` says otherwise.
const DEFAULT_PING_FLOWS: usize = 100;
/// All pings from one source to one destination.
#[derive(Debug, Clone, Serialize)]
pub struct PingView {
pub src: String,
pub dst: String,
/// The local route the destination belongs to and who announced it;
/// `null` when nobody announces it at the moment.
pub prefix: Option<String>,
pub owner: Option<String>,
/// Interface the most recent packet arrived on.
pub interface: Option<String>,
/// Hop limit left when the most recent packet arrived.
pub hop_limit: Option<u8>,
/// Routers the packet crossed, assuming the sender started at the usual
/// 64, 128 or 255.
pub hops: Option<u8>,
pub ident: u16,
pub seq: u16,
/// Size of the most recent ICMPv6 message.
pub bytes: usize,
/// That message as lowercase hex, from the ICMPv6 type byte on (no IPv6
/// header). Long packets are cut off, so this can be shorter than `bytes`.
pub hex: String,
pub count: u64,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
}
fn estimated_hops(hop_limit: u8) -> u8 {
let initial = [64u8, 128, 255].into_iter().find(|&start| start >= hop_limit).unwrap_or(255);
initial - hop_limit
}
impl From<&PingFlow> for PingView {
fn from(flow: &PingFlow) -> Self {
Self {
src: flow.last.src.to_string(),
dst: flow.last.dst.to_string(),
prefix: flow.route.as_ref().map(|r| r.prefix.to_string()),
owner: flow.route.as_ref().map(|r| r.owner.clone()),
interface: flow.last.interface.clone(),
hop_limit: flow.last.hop_limit,
hops: flow.last.hop_limit.map(estimated_hops),
ident: flow.last.ident,
seq: flow.last.seq,
bytes: flow.last.bytes,
hex: flow.last.packet.iter().map(|b| format!("{b:02x}")).collect(),
count: flow.count,
first_seen_unix: unix_secs(flow.first_seen),
last_seen_unix: unix_secs(flow.last_seen),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PingMonitorView {
/// `listening`, `disabled` (not configured) or `failed`.
pub status: &'static str,
/// Why the monitor failed to start.
pub detail: Option<String>,
/// Pings to destinations inside these prefixes are shown.
pub prefixes: Vec<String>,
/// Flows remembered; `flows` holds the most recently seen of them.
pub total_flows: usize,
pub flows: Vec<PingView>,
}
#[derive(Debug, Deserialize)]
pub struct PingQuery {
pub limit: Option<usize>,
}
pub async fn pings(State(state): State<AppState>, Query(query): Query<PingQuery>) -> Json<PingMonitorView> {
let (status, detail) = match state.pings.status() {
PingMonitorStatus::Listening => ("listening", None),
PingMonitorStatus::Disabled => ("disabled", None),
PingMonitorStatus::Failed(why) => ("failed", Some(why.clone())),
};
let flows = state.pings.flows().await;
Json(PingMonitorView {
status,
detail,
prefixes: state.pings.prefixes().iter().map(ToString::to_string).collect(),
total_flows: flows.len(),
flows: flows.iter().take(query.limit.unwrap_or(DEFAULT_PING_FLOWS)).map(PingView::from).collect(),
})
}
#[cfg(test)]
mod ping_tests {
use super::estimated_hops;
#[test]
fn hops_are_counted_from_the_nearest_usual_starting_value() {
assert_eq!(estimated_hops(64), 0);
assert_eq!(estimated_hops(57), 7);
assert_eq!(estimated_hops(118), 10);
assert_eq!(estimated_hops(243), 12);
assert_eq!(estimated_hops(255), 0);
}
}
+80
View File
@@ -0,0 +1,80 @@
//! Parsing and formatting of community values as they appear in the API.
//!
//! Standard communities are written `ASN:VALUE` (e.g. `65000:100`) or as one
//! of the well-known names from RFC 1997 / RFC 7999 / RFC 8326. Large
//! communities are written `GLOBAL:LOCAL1:LOCAL2` (RFC 8092).
use bgp_packet::message::LargeCommunity;
/// Well-known standard communities, by name.
pub const WELL_KNOWN: &[(&str, (u16, u16))] = &[
("GRACEFUL_SHUTDOWN", (0xFFFF, 0x0000)),
("BLACKHOLE", (0xFFFF, 0x029A)),
("NO_EXPORT", (0xFFFF, 0xFF01)),
("NO_ADVERTISE", (0xFFFF, 0xFF02)),
("NO_EXPORT_SUBCONFED", (0xFFFF, 0xFF03)),
("NOPEER", (0xFFFF, 0xFF04)),
];
pub fn parse_standard(s: &str) -> Result<(u16, u16), String> {
let s = s.trim();
if let Some((_, value)) = WELL_KNOWN.iter().find(|(name, _)| name.eq_ignore_ascii_case(s)) {
return Ok(*value);
}
let (high, low) = s
.split_once(':')
.ok_or_else(|| format!("community {s:?} must be ASN:VALUE or a well-known name"))?;
let parse = |part: &str| {
part.trim()
.parse::<u16>()
.map_err(|_| format!("community {s:?}: {part:?} is not a 16-bit number"))
};
Ok((parse(high)?, parse(low)?))
}
pub fn format_standard((high, low): (u16, u16)) -> String {
format!("{high}:{low}")
}
pub fn parse_large(s: &str) -> Result<LargeCommunity, String> {
let s = s.trim();
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 {
return Err(format!("large community {s:?} must be GLOBAL:LOCAL1:LOCAL2"));
}
let parse = |part: &str| {
part.trim()
.parse::<u32>()
.map_err(|_| format!("large community {s:?}: {part:?} is not a 32-bit number"))
};
Ok(LargeCommunity { global_admin: parse(parts[0])?, data_1: parse(parts[1])?, data_2: parse(parts[2])? })
}
pub fn format_large(c: &LargeCommunity) -> String {
format!("{}:{}:{}", c.global_admin, c.data_1, c.data_2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_roundtrip_and_names() {
assert_eq!(parse_standard("65000:100").unwrap(), (65000, 100));
assert_eq!(parse_standard(" no_export ").unwrap(), (65535, 65281));
assert_eq!(parse_standard("BLACKHOLE").unwrap(), (65535, 666));
assert_eq!(format_standard((65000, 100)), "65000:100");
assert!(parse_standard("65000").is_err());
assert!(parse_standard("70000:1").is_err());
assert!(parse_standard("bogus").is_err());
}
#[test]
fn large_roundtrip() {
let c = parse_large("65000:1:2").unwrap();
assert_eq!(c, LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 });
assert_eq!(format_large(&c), "65000:1:2");
assert!(parse_large("65000:1").is_err());
assert!(parse_large("a:b:c").is_err());
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Server-sent event stream merging session, local-route, received-route and
//! ping events into one feed the UI can follow.
use std::convert::Infallible;
use axum::extract::State;
use axum::response::sse::{Event, KeepAlive, Sse};
use bgp_rib::{LocalRibEvent, RibEvent};
use bgp_server::ping::PingEvent;
use bgp_server::session::SessionEvent;
use futures::stream::{Stream, StreamExt};
use serde::Serialize;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use crate::AppState;
use crate::api::{LocalRouteView, PeerView, PingView, ReceivedRouteView, summarize};
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WireEvent {
SessionUp { peer: PeerView },
SessionDown { peer_addr: String, peer_asn: u32 },
/// The peer asked us to send all our routes again (ROUTE-REFRESH).
RouteRefreshRequested { peer: PeerView },
LocalAnnounced { route: LocalRouteView },
LocalWithdrawn { prefix: String },
ReceivedAnnounced { route: ReceivedRouteView },
ReceivedWithdrawn { peer_addr: String, peer_asn: u32, prefix: String },
/// Somebody is pinging an address in the watched prefixes. `new_burst`
/// marks the start of a run of pings; the events that follow while it
/// continues only update the count.
PingSeen { ping: PingView, new_burst: bool },
/// The client fell behind and missed `missed` events on `stream`; it
/// should refetch the corresponding table.
Lagged { stream: &'static str, missed: u64 },
}
impl WireEvent {
fn from_session(ev: Result<SessionEvent, BroadcastStreamRecvError>) -> Self {
match ev {
Ok(SessionEvent::Up(h)) => Self::SessionUp { peer: PeerView::from(&h) },
Ok(SessionEvent::Down(p)) => Self::SessionDown { peer_addr: p.addr.to_string(), peer_asn: p.asn },
Ok(SessionEvent::RefreshRequested(h)) => Self::RouteRefreshRequested { peer: PeerView::from(&h) },
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "sessions", missed: n },
}
}
fn from_local(ev: Result<LocalRibEvent, BroadcastStreamRecvError>) -> Self {
match ev {
Ok(LocalRibEvent::Announced(r)) => Self::LocalAnnounced { route: LocalRouteView::from(&r) },
Ok(LocalRibEvent::Withdrawn { prefix }) => Self::LocalWithdrawn { prefix: prefix.to_string() },
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "local_routes", missed: n },
}
}
fn from_ping(ev: Result<PingEvent, BroadcastStreamRecvError>) -> Self {
match ev {
Ok(ev) => Self::PingSeen { ping: PingView::from(&ev.flow), new_burst: ev.new_burst },
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "pings", missed: n },
}
}
fn from_received(ev: Result<RibEvent, BroadcastStreamRecvError>) -> Self {
match ev {
Ok(RibEvent::Announced { peer, prefix, entry }) => Self::ReceivedAnnounced {
route: ReceivedRouteView {
peer_addr: peer.addr.to_string(),
peer_asn: peer.asn,
prefix: prefix.to_string(),
received_at_unix: entry
.received_at
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
summary: summarize(&entry.path_attributes),
attributes: entry.path_attributes,
},
},
Ok(RibEvent::Withdrawn { peer, prefix }) => Self::ReceivedWithdrawn {
peer_addr: peer.addr.to_string(),
peer_asn: peer.asn,
prefix: prefix.to_string(),
},
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "received_routes", missed: n },
}
}
}
pub async fn stream(
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let sessions = BroadcastStream::new(state.registry.subscribe()).map(WireEvent::from_session);
let local = BroadcastStream::new(state.local_rib.subscribe()).map(WireEvent::from_local);
let received = BroadcastStream::new(state.adj_rib_in.subscribe()).map(WireEvent::from_received);
let pings = BroadcastStream::new(state.pings.subscribe()).map(WireEvent::from_ping);
let routes = futures::stream::select(local, received);
let merged = futures::stream::select(futures::stream::select(sessions, pings), routes).map(|ev| {
Ok(Event::default()
.event("bgp")
.json_data(&ev)
.unwrap_or_else(|e| Event::default().event("error").data(e.to_string())))
});
Sse::new(merged).keep_alive(KeepAlive::default())
}
+132
View File
@@ -0,0 +1,132 @@
//! HTTP surface of the daemon: a JSON API for originating routes and
//! inspecting state, a server-sent event stream, and Prometheus metrics.
//!
//! Everything here is a thin layer over handles owned by the BGP server
//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`], [`PingMonitor`]); no state lives
//! in this crate.
mod api;
mod community;
mod events;
mod metrics;
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::{Instant, SystemTime};
use axum::Router;
use axum::http::header;
use axum::response::{Html, IntoResponse, Redirect};
use axum::routing::{delete, get, post};
use bgp_rib::{AdjRibIn, LocalRib};
use bgp_server::ping::PingMonitor;
use bgp_server::session::SessionRegistry;
use eyre::{Result, WrapErr};
use tokio::net::TcpListener;
use tracing::info;
pub use api::{
AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, PingMonitorView, PingView,
ReceivedRouteView, StatusView,
WellKnownCommunity, WireView, WithdrawRequest,
};
/// Static facts about the router shown in status output.
#[derive(Debug, Clone)]
pub struct RouterInfo {
pub router_id: Ipv4Addr,
pub local_asn: u32,
pub version: String,
pub started: Instant,
/// Wall-clock start time, for display.
pub started_at: SystemTime,
/// Name of the machine we run on, for display.
pub hostname: String,
/// The configuration file as read at startup (path and raw text), shown
/// on the status page. `None` when the server was configured in code.
pub config: Option<(String, String)>,
}
impl RouterInfo {
pub fn new(router_id: Ipv4Addr, local_asn: u32, version: impl Into<String>) -> Self {
Self {
router_id,
local_asn,
version: version.into(),
started: Instant::now(),
started_at: SystemTime::now(),
hostname: hostname(),
config: None,
}
}
/// Records the configuration file so the status page can show it.
pub fn with_config(mut self, path: impl Into<String>, text: impl Into<String>) -> Self {
self.config = Some((path.into(), text.into()));
self
}
}
/// Best-effort host name without pulling in a dependency: ask the `hostname`
/// utility, which exists on every platform we run on.
fn hostname() -> String {
std::process::Command::new("hostname")
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown host".into())
}
#[derive(Clone)]
pub struct AppState {
pub info: Arc<RouterInfo>,
pub registry: SessionRegistry,
pub local_rib: LocalRib,
pub adj_rib_in: AdjRibIn,
pub pings: PingMonitor,
}
/// The status page, embedded so the binary is self-contained.
const STATUSZ_HTML: &str = include_str!("../static/statusz.html");
async fn statusz() -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], Html(STATUSZ_HTML))
}
/// Builds the router. Exposed so tests can drive it without a socket.
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(|| async { Redirect::temporary("/statusz") }))
.route("/statusz", get(statusz))
.route("/api/status", get(api::status))
.route("/api/config", get(api::config))
.route("/api/peers", get(api::peers))
.route("/api/routes/local", get(api::local_routes).post(api::announce))
.route("/api/routes/local", delete(api::withdraw))
.route("/api/routes/local/withdraw", post(api::withdraw))
.route("/api/routes/local/wire", get(api::wire))
.route("/api/routes/received", get(api::received_routes))
.route("/api/pings", get(api::pings))
.route("/api/communities/well-known", get(api::well_known_communities))
.route("/api/events", get(events::stream))
.route("/metricz", get(metrics::metricz))
.with_state(state)
}
/// Binds `listen` and serves the API until the task is dropped.
pub async fn serve(state: AppState, listen: SocketAddr) -> Result<()> {
let listener = TcpListener::bind(listen)
.await
.wrap_err_with(|| format!("Failed to bind HTTP listener on {listen}"))?;
serve_on(state, listener).await
}
/// Serves the API on an already-bound listener (lets tests use port 0).
pub async fn serve_on(state: AppState, listener: TcpListener) -> Result<()> {
info!(addr = %listener.local_addr()?, "HTTP API listening");
axum::serve(listener, router(state)).await?;
Ok(())
}
+21
View File
@@ -0,0 +1,21 @@
//! Prometheus scrape endpoint. Counters are updated by the server crate as
//! messages flow; table-size gauges are refreshed at scrape time.
use axum::extract::State;
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
use bgp_server::metrics::{ADJ_RIB_IN_ROUTES, LOCAL_ROUTES};
use prometheus::{Encoder, TextEncoder};
use crate::AppState;
pub async fn metricz(State(state): State<AppState>) -> impl IntoResponse {
LOCAL_ROUTES.set(state.local_rib.snapshot().await.len() as i64);
ADJ_RIB_IN_ROUTES.set(state.adj_rib_in.snapshot().await.len() as i64);
let mut buf = Vec::new();
match TextEncoder::new().encode(&prometheus::gather(), &mut buf) {
Ok(()) => (StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; version=0.0.4")], buf).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
+622
View File
@@ -0,0 +1,622 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Status for ferrix_bgp</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23eef'/%3E%3Ctext x='8' y='12' font-size='11' font-family='sans-serif' font-weight='bold' fill='%2300c' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E">
<!--
Styled after the classic Google-internal /statusz pages as mirrored by
Vitess (go/vt/servenv/status.go) and golang.org/x/net/trace: plain
sans-serif HTML, default links and form controls, dense bordered tables,
and one full-width light blue (#eef) heading bar per section. Deliberately
no dark theme, no framework, no external resources.
-->
<style>
body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; }
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: #eef; margin: 14px 0 6px; padding: 2px 0; }
h1.top { font-size: 150%; margin-top: 0; }
.lefthand { float: left; width: 70%; }
.righthand { text-align: right; }
a { color: #00c; } a:visited { color: #551a8b; }
table.data { border-collapse: collapse; margin: 4px 0; }
table.data th, table.data td { border: 1px solid #99a; padding: 2px 6px; text-align: left; vertical-align: top; }
table.data th { background: #dde; font-weight: bold; }
.mono, table.data td.mono { font-family: monospace; }
table.data tr.loop td { background: #ffc; }
table.data tr.mine td.owner { font-weight: bold; }
.ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; }
.dim { color: #666; }
table.form td { padding: 2px 6px 2px 0; }
table.form td:first-child { width: 11em; }
select { max-width: 14em; }
pre.bytes { border: 1px solid #99a; background: #fff; padding: 4px; margin: 4px 0; font-size: 12px; white-space: pre-wrap; word-break: break-all; line-height: 1.6; }
.hh { background: #dde; } .hl { background: #fcc; } .ha { background: #cfc; } .ha2 { background: #ada; } .hn { background: #ffc; }
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
#event-log div { white-space: pre-wrap; }
pre.bytes span[data-kind] { cursor: help; }
pre.bytes span[data-kind]:hover, pre.bytes span[data-kind]:focus { outline: 1px solid #00c; }
#tip { position: absolute; z-index: 10; max-width: 46em; background: #fff; border: 1px solid #99a; font-size: 12px; }
#tip .tip-title { background: #eef; font-weight: bold; text-align: center; padding: 1px 6px; border-bottom: 1px solid #99a; }
#tip table { border-collapse: collapse; }
#tip td { padding: 1px 6px; vertical-align: top; }
#tip td:first-child { color: #666; white-space: nowrap; }
#tip td.mono { word-break: break-word; }
details { clear: both; margin: 8px 0 0; }
summary { cursor: pointer; color: #00c; }
summary:hover { text-decoration: underline; }
#config-text { border: 1px solid #99a; padding: 4px 6px; margin: 4px 0; font-size: 12px; overflow-x: auto; }
address { font-size: smaller; margin-top: 2em; color: #444; }
</style>
</head>
<body>
<h1 class="top">Status for ferrix_bgp</h1>
<div>
<div class="lefthand">
Started: <span id="s-started">...</span> (up <span id="s-uptime">...</span>)<br>
Version <span id="s-version">...</span><br>
Router ID <b id="s-router-id">...</b>, local AS <b id="s-asn">...</b><br>
</div>
<div class="righthand">
Running on <span id="s-hostname">...</span><br>
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">local routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/pings">pings json</a><br>
<span id="refresh-ctl"></span><br>
</div>
</div>
<details id="config-zippy" hidden>
<summary>Configuration <span class="dim" id="config-path"></span></summary>
<pre id="config-text"></pre>
</details>
<h1 id="peers">Peers (<span id="peers-count">...</span>)</h1>
<table class="data">
<thead><tr><th>name</th><th>peer</th><th>AS</th><th>state</th><th>opened by</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte AS</th><th>detail</th></tr></thead>
<tbody id="peers-body"></tbody>
</table>
<h1 id="announce">Announce a prefix</h1>
<form id="announce-form" autocomplete="off">
<table class="form">
<tr><td><label for="f-owner">Owner (your name):</label></td><td><input id="f-owner" size="20" maxlength="64" required></td>
<td><label for="f-prefix">Prefix:</label></td><td><input id="f-prefix" size="24" required placeholder="2a0d:d741:1::/48"></td></tr>
<tr><td><label for="f-origin">Origin:</label></td><td><select id="f-origin"><option>IGP</option><option>EGP</option><option>INCOMPLETE</option></select></td>
<td><label for="f-med">MED:</label></td><td><input id="f-med" type="number" min="0" max="4294967295" placeholder="none" style="width:8em"></td></tr>
</table>
<table class="form">
<tr><td><label for="f-communities">Communities:</label></td><td><input id="f-communities" size="50" placeholder="65000:100, NO_EXPORT">
<select id="f-wk" aria-label="Add a well-known community"><option value="">+ well-known...</option></select>
<span class="dim">comma separated, ASN:VALUE or a name</span></td></tr>
<tr><td><label for="f-large">Large communities:</label></td><td><input id="f-large" size="50" placeholder="210036:0:0, 210036:1:42476">
<select id="f-lc" aria-label="Add a large community preset"><option value="">+ presets...</option></select>
<span class="dim">GLOBAL:LOCAL1:LOCAL2</span></td></tr>
<tr><td></td><td><input type="submit" value="Announce"> <input type="button" id="f-clear" value="Clear">
&nbsp; <span id="announce-msg"></span></td></tr>
</table>
</form>
<h1 id="local">Local routes (<span id="local-count">...</span>)</h1>
<table class="data">
<thead><tr><th>prefix</th><th>owner</th><th>origin</th><th>AS path</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th><th>actions</th></tr></thead>
<tbody id="local-body"></tbody>
</table>
<div id="pings-section" hidden>
<h1 id="pings">Pings to announced addresses (<span id="pings-count">...</span>)</h1>
<div class="dim" id="pings-note"></div>
<table class="data">
<thead><tr><th>destination</th><th>route</th><th>owner</th><th>from</th><th>arrived on</th><th>hop limit</th><th>pings</th><th>last sequence</th><th>first seen</th><th>last seen</th><th>actions</th></tr></thead>
<tbody id="pings-body"></tbody>
</table>
</div>
<h1 id="received">Received routes, Adj-RIB-In (<span id="received-count">...</span>)</h1>
<table class="data">
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th></tr></thead>
<tbody id="received-body"></tbody>
</table>
<h1 id="events">Event log</h1>
<div id="log-state" class="dim"></div>
<div id="event-log"></div>
<div id="tip" hidden></div>
<address>ferrix_bgp &middot; <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a></address>
<script>
(() => {
// Large-community presets offered next to the field, grouped by who acts
// on them. "PEER" is replaced by an AS number the user is asked for.
// AS210036: docs/lab/prod/bird-ferrix.conf (lab_export_allowed). Every
// session there has a neighbour AS and a location; "never"
// (functions 0 and 2) beats "announce" (1 and 3) beats 0:0.
// SwissIX: https://www.swissix.ch/resources/action-bgp-communities/
const LOCATIONS = { 1: 'SwissIX', 2: 'NL-ix', 3: 'France-IX', 4: 'DE-CIX Frankfurt', 5: 'Free-IX Switzerland', 6: 'Community-IX.ch', 100: 'transit', 200: 'downstream customers' };
const LARGE_PRESET_GROUPS = [
['AS210036', 'AS210036: by neighbour', [
['210036:0:0', 'announce nowhere (then add where to)'],
['210036:1:42476', 'announce to the SwissIX route servers'],
['210036:1:PEER', 'announce to neighbour AS'],
['210036:0:PEER', 'never announce to neighbour AS'],
]],
['AS210036', 'AS210036: by location', Object.entries(LOCATIONS).filter(([id]) => id < 100).map(([id, name]) => [`210036:3:${id}`, `announce at ${name}`])
.concat([['210036:2:100', 'never announce to transit']])],
['SwissIX', 'SwissIX route servers (AS42476)', [
['42476:0:0', 'send to nobody'],
['42476:1:559', 'send to SWITCH (AS559)'],
['42476:1:PEER', 'send to peer AS'],
['42476:0:PEER', 'do not send to peer AS'],
['42476:1:0', 'send to all (default)'],
['42476:901:PEER', 'add NO_EXPORT towards peer AS'],
['42476:902:PEER', 'add NO_ADVERTISE towards peer AS'],
]],
];
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const state = { status: null, peers: [], local: [], received: [], pings: null, wellKnown: {}, openWire: new Set(), openPing: new Set(), wire: new Map() };
const DASH = '<span class="dim">-</span>';
// ---- helpers -----------------------------------------------------------
const fmtDur = s => {
s = Math.max(0, Math.floor(s));
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60), sec = s % 60;
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${sec}s` : `${sec}s`;
};
const now = () => Math.floor(Date.now() / 1000);
// Tables are only rewritten when their markup actually changes, so a
// refresh with unchanged data never touches the DOM (no flicker, and open
// rows, selections and hover state survive). Anything that changes with
// the clock is a placeholder that tick() fills in as plain text.
function setHTML(el, html) { if (el._html !== html) { el.innerHTML = html; el._html = html; tick(); } }
const age = unix => `<span data-since="${unix}"></span>`;
const until = unix => `<span data-until="${unix}"></span>`;
function tick() {
const t = now();
document.querySelectorAll('[data-since]').forEach(e => { const v = fmtDur(t - e.dataset.since); if (e.textContent !== v) e.textContent = v; });
document.querySelectorAll('[data-until]').forEach(e => { const v = fmtDur(e.dataset.until - t); if (e.textContent !== v) e.textContent = v; });
if (state.status) $('s-uptime').textContent = fmtDur(t - state.status.started_at_unix);
}
const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
const commList = (list, wk) => list.length ? list.map(c => {
const name = wk && state.wellKnown[c];
return name ? `<span title="${esc(c)}">${esc(name)}</span>` : esc(c);
}).join(' ') : DASH;
const asPath = p => p.length ? p.join(' ') : '<span class="dim">(empty)</span>';
const myOwner = () => $('f-owner').value.trim();
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`${url}: ${r.status}`);
return r.json();
}
// ---- renderers ---------------------------------------------------------
function renderStatus() {
const s = state.status; if (!s) return;
$('s-router-id').textContent = s.router_id; $('s-asn').textContent = s.local_asn;
$('s-version').textContent = s.version; $('s-hostname').textContent = s.hostname;
$('s-started').textContent = new Date(s.started_at_unix * 1000).toString().replace(/ \(.*\)$/, '');
tick();
}
const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'bad', Idle: 'dim' };
function renderPeers() {
const b = $('peers-body');
$('peers-count').textContent = `${state.peers.filter(p => p.state === 'Established').length} of ${state.peers.length} established`;
if (!state.peers.length) { setHTML(b, '<tr><td colspan="10" class="dim">No peers configured and none connected.</td></tr>'); return; }
setHTML(b, state.peers.map(p => {
const up = p.state === 'Established';
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
return `<tr>
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}</td>
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
<td class="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
<td>${up ? age(p.established_at_unix) : DASH}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH}</td><td>${detail}</td></tr>`;
}).join(''));
}
function renderLocal() {
const b = $('local-body'); $('local-count').textContent = state.local.length;
if (!state.local.length) { setHTML(b, '<tr><td colspan="9" class="dim">Nothing announced yet.</td></tr>'); return; }
const me = myOwner(), asn = state.status ? state.status.local_asn : '?', stale = [];
setHTML(b, state.local.map(r => {
const mine = r.owner === me, open = state.openWire.has(r.prefix), p = esc(r.prefix);
const withdraw = mine ? `<a href="#" data-act="withdraw" data-prefix="${p}">withdraw</a>`
: '<span class="dim" title="only the owner can withdraw">withdraw</span>';
return `<tr class="${mine ? 'mine' : ''}">
<td class="mono">${p}</td><td class="owner">${esc(r.owner)}</td><td>${esc(r.origin)}</td>
<td class="mono">${Array(1 + r.prepend).fill(asn).join(' ')}</td><td>${r.med ?? DASH}</td>
<td class="mono">${commList(r.communities, true)}</td><td class="mono">${commList(r.large_communities)}</td><td>${age(r.announced_at_unix)}</td>
<td><a href="#" data-act="wire" data-prefix="${p}">${open ? 'hide bytes' : 'bytes'}</a> | ${withdraw}</td></tr>` +
(open ? `<tr><td colspan="9">${wireHTML(r, stale)}</td></tr>` : '');
}).join(''));
stale.forEach(loadWire);
}
function renderReceived() {
const b = $('received-body'); $('received-count').textContent = state.received.length;
if (!state.received.length) { setHTML(b, '<tr><td colspan="9" class="dim">No routes received from peers.</td></tr>'); return; }
const localPrefixes = new Set(state.local.map(r => r.prefix));
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr class="${localPrefixes.has(r.prefix) ? 'loop' : ''}">
<td class="mono">${esc(r.prefix)}</td><td class="mono">${esc(r.peer_addr)} <span class="dim">AS${r.peer_asn}</span></td><td class="mono">${asPath(s.as_path)}</td>
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td><td>${s.med ?? DASH}</td>
<td class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(s.large_communities)}</td><td>${age(r.received_at_unix)}</td></tr>`; }).join(''));
}
// Pings are shown per source and destination pair. The router answers
// them itself; this table is a copy of what arrived.
function renderPings() {
const p = state.pings; if (!p || p.status === 'disabled') { $('pings-section').hidden = true; return; }
$('pings-section').hidden = false;
if (p.status === 'failed') {
$('pings-count').textContent = 'off';
$('pings-note').innerHTML = `<span class="bad">The ping monitor could not start:</span> ${esc(p.detail || 'unknown error')}`;
setHTML($('pings-body'), ''); return;
}
const more = p.total_flows > p.flows.length ? `, showing the ${p.flows.length} most recent` : '';
$('pings-count').textContent = `${p.total_flows} flow${p.total_flows === 1 ? '' : 's'}${more}`;
$('pings-note').innerHTML = '';
if (!p.flows.length) { setHTML($('pings-body'), '<tr><td colspan="11" class="dim">No pings seen yet.</td></tr>'); return; }
const me = myOwner();
setHTML($('pings-body'), p.flows.map(f => { const key = `${f.src}|${f.dst}`, open = state.openPing.has(key); return `<tr class="${f.owner && f.owner === me ? 'mine' : ''}">
<td class="mono">${esc(f.dst)}</td><td class="mono">${f.prefix ? esc(f.prefix) : '<span class="dim">not announced</span>'}</td><td class="owner">${f.owner ? esc(f.owner) : DASH}</td>
<td class="mono">${esc(f.src)}</td><td class="mono">${f.interface ? esc(f.interface) : DASH}</td>
<td>${f.hop_limit == null ? DASH : `${f.hop_limit} <span class="dim">(${f.hops} hop${f.hops === 1 ? '' : 's'} away)</span>`}</td>
<td>${f.count}</td><td>${f.seq}</td><td>${fmtTime(f.first_seen_unix)}</td><td>${age(f.last_seen_unix)} ago</td>
<td><a href="#" data-act="ping-bytes" data-key="${esc(key)}">${open ? 'hide bytes' : 'bytes'}</a></td></tr>` +
(open ? `<tr><td colspan="11">${pingBytesHTML(f)}</td></tr>` : ''); }).join(''));
}
// The last echo request of a flow, split into the fields of RFC 4443. It
// starts at the ICMPv6 header: the socket we listen on is never given the
// IPv6 header, whose interesting fields are the columns of the table.
function pingBytesHTML(f) {
const bytes = f.hex.match(/../g) || [], segs = [];
const take = (n, cls, kind) => { if (bytes.length) segs.push({ cls, kind, hex: bytes.splice(0, n).join(' ') }); };
take(1, 'hh', 'icmp-type'); take(1, 'hh', 'icmp-code'); take(2, 'hl', 'icmp-checksum');
take(2, 'ha', 'icmp-ident'); take(2, 'ha2', 'icmp-seq'); take(bytes.length, 'hn', 'icmp-payload');
const kept = f.hex.length / 2;
return `Last ICMPv6 message from <b>${esc(f.src)}</b>, ${f.bytes} bytes${kept < f.bytes ? ` (first ${kept} shown)` : ''}, IPv6 header not included &nbsp;
<span class="hh">&nbsp;type, code&nbsp;</span> <span class="hl">&nbsp;checksum&nbsp;</span> <span class="ha">&nbsp;identifier, sequence&nbsp;</span> <span class="hn">&nbsp;payload&nbsp;</span>
<span class="dim">(hover a segment to decode it)</span>
<pre class="bytes">${segs.map(x => `<span class="${x.cls}" tabindex="0" data-kind="${x.kind}" data-hex="${x.hex}">${x.hex}</span>`).join(' ')}</pre>`;
}
function decodePing(kind, b) {
switch (kind) {
case 'icmp-type': return { title: 'ICMPv6: type', rows: [['value', `${b[0]} = ${{128: 'Echo Request', 129: 'Echo Reply'}[b[0]] || 'other'}`], ['meaning', 'The router answers with type 129, Echo Reply, carrying the same identifier, sequence number and payload.']] };
case 'icmp-code': return { title: 'ICMPv6: code', rows: [['value', `${b[0]}`], ['meaning', 'Always 0 for echo messages.']] };
case 'icmp-checksum': return { title: 'ICMPv6: checksum', rows: [['value', `0x${u(b, 0, 2).toString(16).padStart(4, '0')}`], ['covers', 'This message and a pseudo-header holding the IPv6 source and destination addresses, so it differs between two otherwise equal pings to different addresses.']] };
case 'icmp-ident': return { title: 'Echo: identifier', rows: [['value', `${u(b, 0, 2)}`], ['meaning', 'Chosen by the sender so that several ping programs on one host can tell their replies apart.']] };
case 'icmp-seq': return { title: 'Echo: sequence number', rows: [['value', `${u(b, 0, 2)}`], ['meaning', 'Counts up with every request; a gap in the replies is a lost packet.']] };
default: {
const rows = [['length', `${b.length} bytes shown`], ['as text', b.map(x => x >= 32 && x < 127 ? String.fromCharCode(x) : '.').join('')]];
// ping puts the time of sending first so that the reply carries it
// back. Only claim that when the value is in fact close to now.
const near = secs => Math.abs(secs - now()) < 86400;
const le = b.length >= 16 && !b[4] && !b[5] && !b[6] && !b[7] ? b[0] + b[1] * 256 + b[2] * 65536 + b[3] * 16777216 : null;
const be = b.length >= 8 ? u(b, 0, 4) : null;
if (le != null && near(le)) rows.push(['starts with', `the sender's clock when it sent this: ${new Date(le * 1000).toLocaleTimeString()} (a 16-byte timeval, as Linux ping writes it)`]);
else if (be != null && near(be)) rows.push(['starts with', `the sender's clock when it sent this: ${new Date(be * 1000).toLocaleTimeString()} (an 8-byte timestamp, as BSD and macOS ping6 write it)`]);
rows.push(['meaning', 'Anything the sender likes; the reply must return it unchanged.']);
return { title: 'Echo: payload', rows };
}
}
}
// A ping event carries the whole row, so the table follows the stream
// without asking the server again.
function pingSeen(f) {
const p = state.pings; if (!p || p.status !== 'listening') return;
const at = p.flows.findIndex(x => x.src === f.src && x.dst === f.dst);
if (at >= 0) p.flows.splice(at, 1); else p.total_flows++;
p.flows.unshift(f); p.flows.length = Math.min(p.flows.length, 100);
renderPings();
}
// ---- wire view: split the UPDATE bytes into labelled fields ------------
const ATTR_NAMES = {1:'ORIGIN',2:'AS_PATH',3:'NEXT_HOP',4:'MED',5:'LOCAL_PREF',6:'ATOMIC_AGGREGATE',7:'AGGREGATOR',8:'COMMUNITIES',14:'MP_REACH_NLRI',15:'MP_UNREACH_NLRI',16:'EXT_COMMUNITIES',32:'LARGE_COMMUNITIES'};
function segmentUpdate(hex) {
const bytes = hex.match(/../g) || [], segs = [];
const take = (n, cls, kind) => { segs.push({ cls, kind, hex: bytes.splice(0, n).join(' ') }); };
take(16, 'hh', 'marker'); take(2, 'hh', 'length'); take(1, 'hh', 'type');
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'hl', 'wlen'); if (wlen) take(wlen, 'hn', 'withdrawn');
const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'hl', 'alen');
let rem = alen, i = 0;
while (rem > 0) {
const ext = parseInt(bytes[0], 16) & 0x10;
const hdr = ext ? 4 : 3, len = ext ? parseInt(bytes[2] + bytes[3], 16) : parseInt(bytes[2], 16);
take(hdr + len, i++ % 2 ? 'ha2' : 'ha', 'attr'); rem -= hdr + len;
}
if (bytes.length) take(bytes.length, 'hn', 'nlri');
return segs;
}
// ---- decoding a hovered segment into something readable ----------------
const u = (b, o, n) => { let v = 0; for (let k = 0; k < n; k++) v = v * 256 + b[o + k]; return v; };
const fmtV4 = b => b.slice(0, 4).join('.');
function fmtV6(b) {
const g = []; for (let k = 0; k < 16; k += 2) g.push(((b[k] << 8) | b[k + 1]).toString(16));
let best = -1, bestLen = 0;
for (let k = 0; k < 8; k++) { let n = 0; while (k + n < 8 && g[k + n] === '0') n++; if (n > bestLen && n > 1) { best = k; bestLen = n; } }
if (best < 0) return g.join(':');
return g.slice(0, best).join(':') + '::' + g.slice(best + bestLen).join(':');
}
function prefixList(b, afi) {
const out = []; let o = 0;
while (o < b.length) {
const len = b[o++], n = Math.ceil(len / 8), raw = b.slice(o, o + n); o += n;
const full = raw.concat(Array((afi === 2 ? 16 : 4) - raw.length).fill(0));
out.push((afi === 2 ? fmtV6(full) : fmtV4(full)) + '/' + len);
}
return out;
}
function largeLabel(c) {
// Our own scheme is decoded from its structure, so any value reads well,
// not only the ones in the menu.
const own = c.match(/^210036:([0-3]):(\d+)$/);
if (own) {
const [f, t] = [Number(own[1]), Number(own[2])], where = LOCATIONS[t] || `location ${t}`;
if (f === 0 && t === 0) return 'AS210036: announce nowhere, unless another community says where';
if (f === 0) return `AS210036: never announce to AS${t}`;
if (f === 1) return `AS210036: announce to AS${t}${t === 42476 ? ' (the SwissIX route servers)' : ''}`;
return `AS210036: ${f === 2 ? 'never announce' : 'announce'} ${t >= 100 ? 'to' : 'at'} ${where}`;
}
// Exact presets first, so 42476:1:559 reads "SWITCH" rather than "AS559".
for (const pass of [false, true]) for (const [who, , presets] of LARGE_PRESET_GROUPS) for (const [pattern, label] of presets) {
if (pattern.includes('PEER') !== pass) continue;
const m = c.match(new RegExp('^' + pattern.replace('PEER', '(\\d+)') + '$'));
if (m) return `${who}: ${label.replace(/(peer|neighbour) AS/, 'AS' + (m[1] || ''))}`;
}
return null;
}
const AFI = { 1: 'IPv4', 2: 'IPv6' }, SAFI = { 1: 'unicast', 2: 'multicast' };
function decodeAttr(b, w4) {
const flags = b[0], type = b[1], ext = flags & 0x10, hdr = ext ? 4 : 3, len = ext ? u(b, 2, 2) : b[2], v = b.slice(hdr);
const name = ATTR_NAMES[type] || `attribute ${type}`;
const rows = [
['flags', `0x${flags.toString(16)}: ${flags & 0x80 ? 'optional' : 'well-known'}, ${flags & 0x40 ? 'transitive' : 'non-transitive'}${flags & 0x20 ? ', partial' : ''}${ext ? ', 2-byte length' : ''}`],
['type code', `${type}`], ['value length', `${len} bytes`],
];
switch (type) {
case 1: rows.push(['origin', ['IGP (originated inside the AS)', 'EGP', 'INCOMPLETE (learned some other way)'][v[0]] || `unknown ${v[0]}`]); break;
case 2: {
const w = w4 ? 4 : 2; let o = 0, hops = 0;
while (o + 2 <= v.length) {
const st = v[o], n = v[o + 1], asns = []; o += 2;
for (let k = 0; k < n; k++, o += w) asns.push(u(v, o, w));
rows.push([st === 2 ? 'AS_SEQUENCE' : st === 1 ? 'AS_SET' : `segment ${st}`, asns.join(' ')]);
hops += st === 2 ? n : 1;
}
rows.push(['path length', `${hops} (what best-path selection compares)`], ['AS number width', w4 ? '4 bytes (RFC 6793)' : '2 bytes']);
break;
}
case 3: rows.push(['next hop', fmtV4(v)]); break;
case 4: rows.push(['MED', `${u(v, 0, 4)} (lower is preferred, compared between routes from the same neighbour AS)`]); break;
case 5: rows.push(['local preference', `${u(v, 0, 4)} (higher is preferred)`]); break;
case 8: for (let o = 0; o + 4 <= v.length; o += 4) { const c = `${u(v, o, 2)}:${u(v, o + 2, 2)}`; rows.push(['community', c + (state.wellKnown[c] ? ` = ${state.wellKnown[c]}` : '')]); } break;
case 32: for (let o = 0; o + 12 <= v.length; o += 12) { const c = `${u(v, o, 4)}:${u(v, o + 4, 4)}:${u(v, o + 8, 4)}`, l = largeLabel(c); rows.push(['large community', c + (l ? ` = ${l}` : '')]); } break;
case 14: {
const afi = u(v, 0, 2), nhLen = v[3], nh = v.slice(4, 4 + nhLen);
rows.push(['address family', `${AFI[afi] || afi} ${SAFI[v[2]] || v[2]}`]);
if (nhLen === 4) rows.push(['next hop', fmtV4(nh)]);
else if (nhLen === 16) rows.push(['next hop', fmtV6(nh)]);
else if (nhLen === 32) rows.push(['next hop', fmtV6(nh.slice(0, 16))], ['link-local next hop', fmtV6(nh.slice(16))]);
prefixList(v.slice(5 + nhLen), afi).forEach(p => rows.push(['announced prefix', p]));
break;
}
case 15: { const afi = u(v, 0, 2); rows.push(['address family', `${AFI[afi] || afi} ${SAFI[v[2]] || v[2]}`]); prefixList(v.slice(3), afi).forEach(p => rows.push(['withdrawn prefix', p])); break; }
default: rows.push(['value', v.map(x => x.toString(16).padStart(2, '0')).join(' ')]);
}
return { title: `Path attribute: ${name}`, rows };
}
function decodeSegment(kind, b, w4) {
switch (kind) {
case 'marker': return { title: 'Header: marker', rows: [['value', '16 bytes of 0xff'], ['meaning', 'Fixed filler. It once carried authentication data; now it only helps a receiver find message boundaries.']] };
case 'length': return { title: 'Header: message length', rows: [['value', `${u(b, 0, 2)} bytes, including this 19-byte header`], ['limits', '19 to 4096 bytes']] };
case 'type': return { title: 'Header: message type', rows: [['value', `${b[0]} = ${['', 'OPEN', 'UPDATE', 'NOTIFICATION', 'KEEPALIVE', 'ROUTE-REFRESH'][b[0]] || 'unknown'}`]] };
case 'wlen': return { title: 'Withdrawn routes length', rows: [['value', `${u(b, 0, 2)} bytes`], ['meaning', u(b, 0, 2) ? 'IPv4 prefixes being withdrawn follow.' : 'No IPv4 prefixes are withdrawn by this message.']] };
case 'withdrawn': return { title: 'Withdrawn routes', rows: prefixList(b, 1).map(p => ['withdrawn prefix', p]) };
case 'alen': return { title: 'Total path attribute length', rows: [['value', `${u(b, 0, 2)} bytes`], ['meaning', 'The path attributes follow; whatever remains after them is IPv4 NLRI.']] };
case 'nlri': return { title: 'NLRI: announced IPv4 prefixes', rows: prefixList(b, 1).map(p => ['announced prefix', p]).concat([['encoding', 'one length byte, then only as many address bytes as the length needs']]) };
default: return decodeAttr(b, w4);
}
}
function showTip(el) {
const b = el.dataset.hex.split(' ').map(x => parseInt(x, 16));
const kind = el.dataset.kind, tip = $('tip');
const d = kind.startsWith('icmp-') ? decodePing(kind, b) : decodeSegment(kind, b, el.dataset.w4 === '1');
tip.innerHTML = `<div class="tip-title">${esc(d.title)}</div><table>${d.rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td class="mono">${esc(v)}</td></tr>`).join('')}</table>`;
tip.hidden = false;
const rects = el.getClientRects(), first = rects[0], last = rects[rects.length - 1];
const left = Math.max(8, Math.min(first.left, window.innerWidth - tip.offsetWidth - 8));
tip.style.left = `${left + window.scrollX}px`; tip.style.top = `${last.bottom + window.scrollY + 4}px`;
}
const hideTip = () => { $('tip').hidden = true; };
const segOf = e => e.target.closest && e.target.closest('pre.bytes span[data-kind]');
document.addEventListener('mouseover', e => { const el = segOf(e); if (el) showTip(el); });
document.addEventListener('mouseout', e => { if (segOf(e)) hideTip(); });
document.addEventListener('focusin', e => { const el = segOf(e); if (el) showTip(el); });
document.addEventListener('focusout', e => { if (segOf(e)) hideTip(); });
// The bytes for a route depend only on the route itself and on which
// sessions are up, so they are fetched once per such combination and kept.
const wireSig = r => JSON.stringify(r) + '|' + state.peers.filter(p => p.state === 'Established').map(p => p.peer_addr + p.local_addr).join(',');
function wireHTML(route, stale) {
const cached = state.wire.get(route.prefix), sig = wireSig(route);
if (cached && cached.sig === sig) return cached.html;
stale.push(route);
// Keep showing the previous bytes while the new ones load.
return cached ? cached.html : '<span class="dim">loading...</span>';
}
async function loadWire(route) {
const sig = wireSig(route);
const pending = state.wire.get(route.prefix);
if (pending && pending.loading === sig) return;
state.wire.set(route.prefix, { ...(pending || { html: '<span class="dim">loading...</span>' }), loading: sig });
let html;
try {
const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(route.prefix)}`);
html = !views.length ? '<span class="dim">No established session to send this on.</span>' :
views.map(v => `UPDATE as sent to <b>${esc(v.peer_addr)}</b> AS${v.peer_asn}, ${v.length} bytes, next hop ${esc(v.next_hop)} &nbsp;
<span class="hh">&nbsp;header&nbsp;</span> <span class="hl">&nbsp;lengths&nbsp;</span> <span class="ha">&nbsp;path attributes&nbsp;</span> <span class="hn">&nbsp;NLRI&nbsp;</span>
<span class="dim">(hover a segment to decode it)</span>
<pre class="bytes">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" tabindex="0" data-kind="${s.kind}" data-w4="${v.four_octet_asn ? 1 : 0}" data-hex="${s.hex}">${s.hex}</span>`).join(' ')}</pre>`).join('');
} catch (e) { html = `<span class="bad">${esc(e.message)}</span>`; }
state.wire.set(route.prefix, { sig, html });
renderLocal();
}
// ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); }
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderLocal(); }
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); }
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); }
async function loadPings() { state.pings = await getJSON('/api/pings'); renderPings(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadPings()]); }
// ---- events ------------------------------------------------------------
function logEvent(html) {
const box = $('event-log'), d = document.createElement('div');
d.innerHTML = `${new Date().toLocaleTimeString()} ${html}`;
box.prepend(d); while (box.children.length > 200) box.lastChild.remove();
}
const names = list => list.map(c => state.wellKnown[c] || c).map(esc).join(' ');
function describe(ev) {
switch (ev.type) {
case 'session_up': return `<span class="ok">session UP</span> with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} (opened by ${ev.peer.direction === 'outbound' ? 'us' : 'peer'})`;
case 'route_refresh_requested': return `${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} asked for a route refresh; re-sent all local routes`;
case 'session_down': return `<span class="bad">session DOWN</span> with ${esc(ev.peer_addr)} AS${ev.peer_asn}`;
case 'local_announced': { const r = ev.route; return `announced ${esc(r.prefix)} by ${esc(r.owner)}` +
(r.communities.length ? ` [${names(r.communities)}]` : '') + (r.large_communities.length ? ` [${names(r.large_communities)}]` : '') +
(r.prepend ? ` prepend ${r.prepend}` : '') + (r.med != null ? ` med ${r.med}` : ''); }
case 'local_withdrawn': return `<span class="warn">withdrew</span> ${esc(ev.prefix)}`;
case 'received_announced': return `received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`;
case 'received_withdrawn': return `peer AS${ev.peer_asn} <span class="warn">withdrew</span> ${esc(ev.prefix)}`;
case 'ping_seen': { const f = ev.ping; return `ping to ${esc(f.dst)}${f.owner ? ` (${esc(f.owner)})` : ''} from ${esc(f.src)}` +
(f.interface ? ` via ${esc(f.interface)}` : '') + (f.hop_limit != null ? `, hop limit ${f.hop_limit}` : ''); }
case 'lagged': return `<span class="bad">missed ${ev.missed} events</span> on ${esc(ev.stream)}; reloading`;
default: return esc(JSON.stringify(ev));
}
}
function connect() {
const st = $('log-state');
const es = new EventSource('/api/events');
es.onopen = () => { st.textContent = 'Live: connected to the event stream.'; };
es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; };
// Events only feed the log. How often the tables reload is decided by
// ?refresh=, so the page never changes faster than the reader asked for.
// Pings are the exception: the reader causes them from a terminal, not
// from this page, so their table follows the stream. A steady ping is
// logged once, when it starts.
es.addEventListener('bgp', e => {
const ev = JSON.parse(e.data);
if (ev.type === 'ping_seen') { pingSeen(ev.ping); if (!ev.new_burst) return; }
logEvent(describe(ev));
});
}
// ---- auto-refresh, opt-in through ?refresh=<seconds> --------------------
const refreshParam = parseInt(new URLSearchParams(location.search).get('refresh'), 10);
const REFRESH = Number.isFinite(refreshParam) && refreshParam >= 1 ? Math.min(refreshParam, 3600) : 0;
function refreshLink(seconds, text) {
const url = new URL(location.href);
if (seconds) url.searchParams.set('refresh', seconds); else url.searchParams.delete('refresh');
return `<a href="${esc(url.pathname + url.search + url.hash)}">${text}</a>`;
}
function renderRefreshControl() {
$('refresh-ctl').innerHTML = (REFRESH
? `Auto-refresh: <b>every ${fmtDur(REFRESH)}</b> (${refreshLink(0, 'turn off')})`
: `Auto-refresh: off (turn on: ${[2, 5, 30].map(n => refreshLink(n, n + 's')).join(', ')})`) +
` &middot; <a href="#" id="reload-now">reload now</a>`;
$('reload-now').addEventListener('click', e => { e.preventDefault(); loadAll().catch(console.error); });
}
// ---- form --------------------------------------------------------------
// The reader's own action is not "auto" refresh: show its effect at once,
// and again shortly after for whatever the network sends back.
function reloadAfterAction() { loadAll().catch(console.error); setTimeout(() => loadAll().catch(console.error), 1500); }
const splitList = s => s.split(',').map(x => x.trim()).filter(Boolean);
function setMsg(text, cls) { const m = $('announce-msg'); m.className = cls || ''; m.textContent = text; }
function appendTo(id, value) {
const cur = splitList($(id).value); if (!cur.includes(value)) cur.push(value);
$(id).value = cur.join(', ');
}
$('announce-form').addEventListener('submit', async e => {
e.preventDefault();
const body = {
prefix: $('f-prefix').value.trim(), owner: myOwner(), origin: $('f-origin').value,
communities: splitList($('f-communities').value),
large_communities: splitList($('f-large').value),
};
if ($('f-med').value !== '') body.med = Number($('f-med').value);
try { localStorage.setItem('owner', body.owner); } catch (_) {}
setMsg('announcing...', 'dim');
const r = await fetch('/api/routes/local', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
const up = state.peers.filter(p => p.state === 'Established').length;
if (r.ok) { setMsg(`announced ${j.prefix} to ${up} peer(s)`, 'ok'); reloadAfterAction(); }
else setMsg(`${r.status}: ${j.error || 'request failed'}`, 'bad');
});
$('f-clear').addEventListener('click', () => { ['f-prefix','f-communities','f-large','f-med'].forEach(id => $(id).value = ''); $('f-origin').value = 'IGP'; setMsg(''); });
$('f-wk').addEventListener('change', e => { if (e.target.value) appendTo('f-communities', e.target.value); e.target.value = ''; });
$('f-lc').addEventListener('change', e => {
let v = e.target.value; e.target.value = '';
if (!v) return;
if (v.includes('PEER')) {
const peer = (prompt('AS number of the peer or neighbour?') || '').trim().replace(/^AS/i, '');
if (!/^\d+$/.test(peer)) { if (peer) setMsg(`"${peer}" is not an AS number`, 'bad'); return; }
v = v.replace('PEER', peer);
}
appendTo('f-large', v);
});
$('f-owner').addEventListener('input', () => { renderLocal(); renderPings(); });
$('pings-body').addEventListener('click', e => {
const link = e.target.closest('a[data-act="ping-bytes"]'); if (!link) return;
e.preventDefault();
const key = link.dataset.key;
state.openPing.has(key) ? state.openPing.delete(key) : state.openPing.add(key);
renderPings();
});
$('local-body').addEventListener('click', async e => {
const link = e.target.closest('a[data-act]'); if (!link) return;
e.preventDefault();
const prefix = link.dataset.prefix;
if (link.dataset.act === 'wire') { state.openWire.has(prefix) ? state.openWire.delete(prefix) : state.openWire.add(prefix); renderLocal(); return; }
if (!confirm(`Withdraw ${prefix}?`)) return;
const r = await fetch('/api/routes/local', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prefix, owner: myOwner() }) });
if (!r.ok) { const j = await r.json().catch(() => ({})); setMsg(`${r.status}: ${j.error || 'withdraw failed'}`, 'bad'); }
else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); state.wire.delete(prefix); reloadAfterAction(); }
});
// ---- boot --------------------------------------------------------------
try { $('f-owner').value = localStorage.getItem('owner') || ''; } catch (_) {}
LARGE_PRESET_GROUPS.forEach(([, title, presets]) => {
const g = document.createElement('optgroup'); g.label = title;
presets.forEach(([value, label]) => { const o = document.createElement('option'); o.value = value; o.textContent = `${value.replace('PEER', '<as>')} ${label}`; g.appendChild(o); });
$('f-lc').appendChild(g);
});
getJSON('/api/communities/well-known').then(list => {
list.forEach(c => { state.wellKnown[c.value] = c.name; const o = document.createElement('option'); o.value = c.name; o.textContent = `${c.name} (${c.value})`; $('f-wk').appendChild(o); });
renderLocal(); renderReceived();
}).catch(console.error);
getJSON('/api/config').then(c => {
if (!c.text) return;
$('config-path').textContent = c.path ? `(${c.path}, as read at startup)` : '';
$('config-text').textContent = c.text;
$('config-zippy').hidden = false;
}).catch(console.error);
renderRefreshControl();
loadAll().catch(e => logEvent(`<span class="bad">${esc(e.message)}</span>`));
if (REFRESH) {
connect();
setInterval(() => loadAll().catch(console.error), REFRESH * 1000);
setInterval(tick, 1000);
} else {
// A snapshot: nothing moves until the reader reloads or turns refresh on.
$('log-state').textContent = 'The event log is live only while auto-refresh is on.';
}
})();
</script>
</body>
</html>
+14 -4
View File
@@ -25,9 +25,17 @@ The base protocol specification.
| §4.4 | KEEPALIVE message | ✅ |
| §4.5 | NOTIFICATION message (all error codes and subcodes) | ✅ |
| §8 | FSM — passive (inbound) side: OpenSent → OpenConfirm → Established | ✅ |
| §8 | FSM — active (outbound) side: Idle → Connect → Active | ❌ |
| §8 | FSM — active (outbound) side: dial configured peers, exponential retry | ✅ |
| §6.2 | Bad Peer AS check against configured ASN | ✅ |
| §6.7 / RFC 4486 | Cease / Administrative Shutdown sent to every peer on SIGINT or SIGTERM | ✅ |
| §6.8 | Connection collision detection (BGP Identifier comparison, Cease / Connection Collision Resolution) | ✅ |
| §8.1.1 | Optional `CollisionDetectEstablishedState` (`collision_detect_established_state`) | ✅ |
| §6.2 | Bad BGP Identifier check (peer using our identifier, or 0.0.0.0) | ✅ |
| §9 | Decision process / best-path selection | ❌ |
| §9 | RIB (Adj-RIB-In, Loc-RIB, Adj-RIB-Out) | ❌ |
| §9 | Adj-RIB-In (per-peer table of received routes) | ✅ |
| §9 | Locally originated routes, advertised to all established peers | ✅ |
| §9 | Loc-RIB / Adj-RIB-Out for routes learned from peers (re-advertisement) | ❌ |
| §9.2 | Hold timer reset on any received message (not only KEEPALIVE) | ✅ |
---
@@ -59,7 +67,7 @@ https://datatracker.ietf.org/doc/html/rfc2918
| `RouteRefresh` capability advertisement | ✅ |
| `ROUTE-REFRESH` message type (5) framing | ✅ |
| AFI/SAFI payload parsing | ❌ |
| Acting on received ROUTE-REFRESH (re-advertising routes) | ❌ |
| Acting on received ROUTE-REFRESH (re-advertising all local routes, regardless of the AFI/SAFI asked for) | ✅ |
### RFC 7313 — Enhanced Route Refresh Capability for BGP-4
https://datatracker.ietf.org/doc/html/rfc7313
@@ -100,7 +108,7 @@ https://datatracker.ietf.org/doc/html/rfc2858
| Feature | Status |
|---------|--------|
| `MultiProtocol` capability advertisement (IPv4 unicast) | ✅ |
| `MultiProtocol` capability advertisement (IPv4 and IPv6 unicast) | ✅ |
| `MP_REACH_NLRI` path attribute (parse, serialise) | ✅ |
| `MP_UNREACH_NLRI` path attribute (parse, serialise) | ✅ |
| IPv4 and IPv6 next-hop encoding | ✅ |
@@ -122,6 +130,7 @@ https://datatracker.ietf.org/doc/html/rfc1997
| Feature | Status |
|---------|--------|
| `COMMUNITIES` path attribute (parse, serialise) | ✅ |
| Attach to locally originated routes | ✅ |
### RFC 4360 — BGP Extended Communities Attribute
https://datatracker.ietf.org/doc/html/rfc4360
@@ -137,6 +146,7 @@ https://datatracker.ietf.org/doc/html/rfc8092
| Feature | Status |
|---------|--------|
| `LARGE_COMMUNITIES` path attribute (parse, serialise) | ✅ |
| Attach to locally originated routes | ✅ |
---
+62
View File
@@ -0,0 +1,62 @@
# Announcing a prefix with BGP
You have a BGP router. It speaks to two neighbouring autonomous systems, and
whatever you announce will travel around a loop and come back to you, so you
can see exactly what the network did with it.
Open the router's status page (your instructor will give you the address,
usually `http://<host>:8080/statusz`). Everything on the page is live.
**Your prefix:** `10.100.<N>.0/24` where `N` is the number you were given.
Use your name as *owner*: only you can withdraw what you announce.
## The topology
```
you ──announce──▶ AS65000 (ferrix) ──▶ AS65001 (r1) ──▶ AS65002 (r2) ──▶ back to AS65000
```
Everything under **Received routes** came in from a neighbour. A yellow row
means it is one of the prefixes announced locally: it went around the loop.
## Exercises
1. **Plain announcement.** Announce your prefix with no options. Watch the
event log, then find your prefix in *Received routes*. What is its AS
path, and why is it that long? Which ASN appears first, and what does that
tell you about the direction the route travelled?
2. **Read the bytes.** Click *bytes* on your route under *Local routes*.
Hover the coloured segments. Find the 16 bytes of marker, the length, and
the AS_PATH attribute. How many bytes does your ASN take up in the path,
and why?
3. **NO_EXPORT.** Announce with the community `NO_EXPORT` (use the
well-known picker). Wait ten seconds. Does your prefix come back? Look at
the topology and explain which router stopped it and why it still
accepted it.
4. **Asking a neighbour for help.** Announce with the community
`65001:100`. r1 has been configured to act on that. What changed in the AS
path this time, and who changed it? This is how real operators ask an
upstream to prepend on their behalf; look up your ISP's "BGP communities"
page later.
5. **Blackholing.** Announce with `BLACKHOLE`. What happens? When would an
operator want this?
6. **Withdraw.** Withdraw your prefix and watch the event log. How many
messages did it take, and what did the withdrawal look like on the wire
compared to the announcement? (Click *bytes* before withdrawing to compare.)
## Things to notice
- Your router never sent anyone else's routes onward. Only the two FRR
routers re-advertise. What is a router that re-advertises called, and what
would happen to the AS path if ferrix did it too?
- The AS path is BGP's loop prevention. Your prefix came back with your own
ASN in the path and the router accepted it anyway. What should it have
done, and why is this lab allowed to cheat?
- Communities are just 32-bit tags with no built-in meaning except a few
well-known ones. Everything r1 did with `65001:100` was policy someone
wrote. Whose job is it to document what a community means?
+81
View File
@@ -0,0 +1,81 @@
# BGP teaching lab
A small topology in which a prefix announced from the ferrix_bgp web UI is
carried around a loop of two routers and arrives back, so students can watch
what their path attributes and communities do.
```
announce re-advertise
┌─────────┐ ──────▶ ┌────────┐ ──────▶ ┌────────┐
│ ferrix │ │ r1 │ │ r2 │
│ AS65000 │ │ AS65001│ │ AS65002│
│ web UI │ ◀──────────────────────────────────────┘
└─────────┘ comes back with AS path 65002 65001 65000
```
- **ferrix** (AS65000, `10.200.0.1`) dials r1 and r2 and announces whatever
students enter on `http://localhost:8080/statusz`. It does not forward
routes it learns, so the only routes it sends are the students'.
- **r1** (AS65001, FRR) applies policy to routes from ferrix based on the
communities they carry, then passes them to r2. It originates
`192.0.2.0/24`.
- **r2** (AS65002, FRR) refuses routes directly from ferrix, so it only learns
student prefixes via r1, and re-advertises everything to ferrix. It
originates `198.51.100.0/24`.
## Running it
From the repository root:
```
docker compose -f docs/lab/docker-compose.yml up --build
```
Then open <http://localhost:8080/statusz>. Both peers should show
`Established` within a few seconds and the received table should already
list the two prefixes r1 and r2 originate.
Useful on the routers:
```
docker exec r1 vtysh -c 'show bgp ipv4 unicast'
docker exec r2 vtysh -c 'show bgp ipv4 unicast 10.100.1.0/24'
docker exec r1 vtysh -c 'show bgp neighbors 10.200.0.1 advertised-routes'
```
## Policy on r1
| Students set | r1 does | Visible effect in ferrix |
|---------------------------|---------------------------------------------|-------------------------------------------------------|
| nothing | forwards to r2 | prefix returns, AS path `65002 65001 65000` |
| `NO_EXPORT` | keeps it, does not send to r2 (RFC 1997) | prefix does not return |
| `65001:100` | prepends `65001` three times | returns with path `65002 65001 65001 65001 65001 65000` |
| `BLACKHOLE` (65535:666) | drops it on import | prefix does not return |
## Without containers
`fake_peer` in `crates/integration-tests/examples` is enough for the core
loop on one machine: it echoes every prefix back with its own ASN prepended
and honours `NO_EXPORT`. See `bin/bgp/README.md`.
## Caveats
These container files were written without a container runtime to hand, so
they have not been executed. Things most likely to need a touch:
- the FRR image tag, and FRR's route-map / community-list syntax if your
version differs from 10.x;
- `no bgp ebgp-requires-policy` is required on FRR 7.4+ or eBGP routes are
silently dropped; it is present in both configs;
- ferrix binds port 179 inside its container, which requires no special
capability there. If you run it on the host instead, either run as root,
grant `CAP_NET_BIND_SERVICE`, or change `listen_endpoints` and the FRR
neighbor port.
- Both ferrix and the FRR routers dial each other, so connection collisions
(RFC 4271 §6.8) are normal at start-up: one of the two TCP connections is
closed with a Cease / Connection Collision Resolution NOTIFICATION and the
other carries the session. ferrix has the lowest BGP Identifier here, so
the connection the FRR router opened is the one that survives; the
"opened by" column on the status page shows which side that was.
- ferrix does not check AS paths for its own ASN. That is what lets the
looped route be accepted; a production BGP speaker would reject it.
+53
View File
@@ -0,0 +1,53 @@
# Teaching lab: ferrix_bgp plus two FRR routers forming a loop, so a prefix
# announced from the web UI travels ferrix -> r1 -> r2 -> ferrix and shows up
# in the "received routes" table with the AS path it collected on the way.
#
# AS65000 ferrix 10.200.0.1 web UI on http://localhost:8080/statusz
# AS65001 r1 10.200.0.2 applies community-driven policy
# AS65002 r2 10.200.0.3 learns only via r1, re-advertises to ferrix
#
# Usage (from the repository root):
# docker compose -f docs/lab/docker-compose.yml up --build
#
# NOTE: this topology has not been run by the author of this file on the
# machine it was written on (no container runtime was available). Expect to
# adjust image tags and FRR config syntax for the FRR version you pull.
services:
ferrix:
build:
context: ../..
container_name: ferrix
volumes:
- ./ferrix.textpb:/etc/ferrix/server.textpb:ro
ports:
- "8080:8080"
networks:
lab:
ipv4_address: 10.200.0.1
r1:
image: quay.io/frrouting/frr:10.2.1
container_name: r1
privileged: true
volumes:
- ./r1:/etc/frr:ro
networks:
lab:
ipv4_address: 10.200.0.2
r2:
image: quay.io/frrouting/frr:10.2.1
container_name: r2
privileged: true
volumes:
- ./r2:/etc/frr:ro
networks:
lab:
ipv4_address: 10.200.0.3
networks:
lab:
driver: bridge
ipam:
config:
- subnet: 10.200.0.0/24
+30
View File
@@ -0,0 +1,30 @@
router_id: "10.200.0.1"
local_asn: 65000
listen_endpoints { address: "0.0.0.0" port: 179 }
peers {
name: "r1"
endpoint { address: "10.200.0.2" port: 179 }
remote_asn: 65001
hold_time_seconds: 30
}
peers {
name: "r2"
endpoint { address: "10.200.0.3" port: 179 }
remote_asn: 65002
hold_time_seconds: 30
}
connect_retry_seconds: 5
# If r1 or r2 is restarted it will dial us again while we still believe the
# old session is up. Let that new connection replace the stale one straight
# away instead of refusing it until the hold timer (30 s here) expires.
collision_detect_established_state: true
# Students may only announce out of 10.100.0.0/16; one /24 each is plenty.
local_route_policy {
allowed_prefixes: "10.100.0.0/16"
max_routes_per_owner: 4
}
http_endpoint { address: "0.0.0.0" port: 8080 }
+8
View File
@@ -0,0 +1,8 @@
*.aux
*.log
*.out
*.fls
*.fdb_latexmk
*.synctex.gz
build.log
class.pdf
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""One exercise sheet per student, with their prefix filled in, in one PDF.
python3 build-class.py 100 # students 1..100 -> class.pdf
python3 build-class.py 100 -o out.pdf
python3 build-class.py 5 12 -o few.pdf # students 5..12
Student n gets 2a0d:d741:<n in hex>::/48; the lab /40 allows 1..255. Each
sheet is compiled separately and must come out to exactly two pages, so
the result prints double-sided with one student per leaf. Needs pdflatex
and pdfunite (poppler).
"""
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
def page_count(pdf):
out = subprocess.run(["pdfinfo", pdf], capture_output=True, text=True, check=True).stdout
return int(next(line.split()[-1] for line in out.splitlines() if line.startswith("Pages:")))
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("first", type=int, help="first student number, or the count if it is the only number")
ap.add_argument("last", type=int, nargs="?")
ap.add_argument("-o", "--output", default=os.path.join(HERE, "class.pdf"))
args = ap.parse_args()
first, last = (1, args.first) if args.last is None else (args.first, args.last)
if not 1 <= first <= last <= 255:
sys.exit("student numbers must be within 1..255: the lab /40 has 255 usable /48s")
with tempfile.TemporaryDirectory() as tmp:
for name in os.listdir(HERE):
if name.endswith((".tex", ".cls", ".sty", ".bst", ".bib")):
shutil.copy(os.path.join(HERE, name), tmp)
parts = []
for n in range(first, last + 1):
job = f"student-{n:03d}"
tex = f"\\def\\studentnum{{{n}}}\\def\\studenthex{{{n:x}}}\\input{{handout.tex}}"
for _ in range(2): # twice, for the figure and table references
r = subprocess.run(["pdflatex", "-interaction=nonstopmode", "-halt-on-error", "-jobname", job, tex],
cwd=tmp, capture_output=True, text=True)
if r.returncode != 0:
sys.exit(f"student {n}: pdflatex failed:\n{r.stdout[-2000:]}")
pdf = os.path.join(tmp, job + ".pdf")
pages = page_count(pdf)
if pages != 2:
sys.exit(f"student {n}: sheet is {pages} pages, not 2; double-sided printing would misalign")
parts.append(pdf)
print(f"\rstudent {n}: 2a0d:d741:{n:x}::/48", end="", flush=True)
print()
subprocess.run(["pdfunite", *parts, args.output], check=True)
print(f"wrote {args.output}: students {first}..{last}, {page_count(args.output)} pages")
if __name__ == "__main__":
main()
Binary file not shown.
+195
View File
@@ -0,0 +1,195 @@
% Exercise sheet for the "Hands-on BGP" lecture: two A4 pages, one sheet.
% Springer LNCS class (llncs.cls ships with TeX Live). Build: latexmk -pdf handout.tex
\documentclass[a4paper]{llncs}
\usepackage[a4paper,margin=1in]{geometry}
\usepackage[T1]{fontenc}
\usepackage[utf8]{inputenc}
\usepackage{microtype}
\usepackage{booktabs}
\usepackage{amssymb}
\usepackage{listings}
\usepackage{tikz}
\usepackage[bitstream-charter]{mathdesign}
\usetikzlibrary{positioning,arrows.meta}
\PassOptionsToPackage{hyphens}{url}
\usepackage[hidelinks]{hyperref}
\renewcommand\UrlFont{\ttfamily\small}
\pagestyle{empty}
% Floats sit in the text here, so they need less air than in a paper.
\setlength{\intextsep}{7pt}
\setlength{\abovecaptionskip}{4pt}
\setlength{\belowcaptionskip}{0pt}
% ---- things to change per class -------------------------------------------
\newcommand{\laburl}{https://bgplab.rayhaan.net/}
% build-class.py compiles one copy per student with \studentnum and
% \studenthex defined (e.g. 26 and 1a); without them this is the blank sheet.
\providecommand{\studentnum}{}
\providecommand{\studenthex}{}
\ifx\studentnum\empty
\newcommand{\myprefix}{\texttt{2a0d:d741:}\underline{\texttt{\,n\,}}\texttt{::/48}}
\newcommand{\nhex}{<n>}
\else
\newcommand{\myprefix}{\texttt{2a0d:d741:\studenthex::/48}}
\newcommand{\nhex}{\studenthex}
\fi
% ---- exercise machinery ------------------------------------------------------
\lstset{basicstyle=\ttfamily\small,columns=fullflexible,keepspaces=true,
aboveskip=0.5em,belowskip=0.3em}
\newcounter{task}[section]
\renewcommand{\thetask}{\thesection.\arabic{task}}
\newcommand{\task}[1]{\refstepcounter{task}\smallskip\noindent
\textbf{Task \thetask}\enspace\textit{#1}\enspace\ignorespaces}
\newcommand{\answer}[1][1]{\par\nobreak
\foreach \i in {1,...,#1}{\vspace{1.0em}\noindent\makebox[\linewidth]{\dotfill}\par}\smallskip}
% A labelled answer box: the label on its own line, then #1 dotted lines to
% write on (default 2). Used side by side by \fillintwo and \fillinthree.
\newcommand{\writelines}[1]{\foreach \i in {1,...,#1}{\vspace{1.0em}\makebox[\linewidth]{\dotfill}\par}}
\newcommand{\fillbox}[3][2]{\parbox[t]{#2}{#3\par\nopagebreak\writelines{#1}}}
\newcommand{\fillintwo}[3][2]{\par\nopagebreak\smallskip\noindent
\fillbox[#1]{0.485\linewidth}{#2}\hfill\fillbox[#1]{0.485\linewidth}{#3}\par\vspace{0.4em}}
\newcommand{\fillinthree}[4][2]{\par\nopagebreak\smallskip\noindent
\fillbox[#1]{0.32\linewidth}{#2}\hfill\fillbox[#1]{0.32\linewidth}{#3}\hfill\fillbox[#1]{0.32\linewidth}{#4}\par\vspace{0.4em}}
\newcommand{\cbox}{$\square$\;}
\newcommand{\reach}{\par\nopagebreak\smallskip\noindent
Reachable from:\hspace{1.2em}\cbox laptop on the university network\hspace{2em}\cbox phone on mobile data\par\smallskip}
\begin{document}
\title{Hands on BGP: Announcing and Steering an IPv6 Prefix}
\author{Rayhaan Jaufeerally}
\institute{Network Security, ETHZ\\ Lab page: \url{\laburl} (username ``netsec'', password ``perrig'')}
\maketitle
\medskip\noindent
\ifx\studentnum\empty
\parbox[t]{0.44\linewidth}{Name:~\dotfill}\hfill
\parbox[t]{0.27\linewidth}{Prefix: \myprefix}
\smallskip\noindent
Your prefix is \myprefix, where \texttt{n} is your number \emph{in
hexadecimal}.\par\noindent
Student 10 has \texttt{2a0d:d741:a::/48} and student 26 has
\texttt{2a0d:d741:1a::/48}.
\else
\parbox[t]{0.44\linewidth}{Name:~\dotfill}\hfill
\parbox[t]{0.5\linewidth}{Student \studentnum\quad Prefix: \myprefix}
\smallskip\noindent
Your prefix is \myprefix: student number \studentnum, written in
hexadecimal. Make sure to use this prefix in the exercise.
\fi
% ==============================================================================
\section{Who Announces the Address You Are Using?}
\task{Find your IPv6 address.}
Open \texttt{https://bgp.tools} in a browser: the top of the page shows the address
you are connecting from, the prefix it belongs to, and the network that
announces that prefix.
\fillinthree{Your IPv6 address:}{its prefix:}{announced by AS:}
\task{Ask the registry.}
The RIPE database records who holds address space
(\texttt{inet6num}, or \texttt{inetnum} for IPv4) and who may announce it
(\texttt{route6}, \texttt{route}). Look up your IPv6 address, then your lab prefix:
\begin{lstlisting}[escapeinside={(*}{*)}]
whois <your IPv6 address> whois 2a0d:d741:(*\nhex*)::/48
\end{lstlisting}
\fillintwo{Your IPv6 address: held by (\texttt{netname})}{\texttt{origin} AS:}
\fillintwo{Your lab prefix: \texttt{origin} AS}{is it announced right now?}
% ==============================================================================
\section{Announcing Your Prefix}
\begin{figure}[h]
\centering
\begin{tikzpicture}[font=\footnotesize, node distance=5mm and 11mm,
box/.style={draw, rounded corners=1.5pt, minimum height=7.5mm, inner xsep=4pt, align=center},
bgp/.style={-{Stealth[length=2mm]}, dashed}, web/.style={-{Stealth[length=2mm]}}]
\node[box] (you) {you\\[-1pt]\scriptsize browser};
\node[box, right=of you] (ferrix) {lab daemon\\[-1pt]\scriptsize AS4200000001};
\node[box, right=of ferrix] (pr) {\texttt{pr01.rue}\\[-1pt]\scriptsize AS210036};
\node[box, right=of pr] (rs) {SwissIX route servers\\[-1pt]\scriptsize AS42476};
\node[box, right=of rs] (sw) {SWITCH (ETH)\\[-1pt]\scriptsize AS559};
\node[box, below=4mm of rs] (other) {other exchanges, transit};
\draw[web] (you) -- node[above, inner sep=1.5pt]{\tiny HTTPS} (ferrix);
\draw[bgp] (ferrix) -- node[above, inner sep=1.5pt]{\tiny BGP} (pr);
\draw[bgp] (pr) -- (rs);
\draw[bgp] (rs) -- (sw);
\draw[bgp] (pr) |- (other);
\end{tikzpicture}
\caption{The lab. Dashed arrows are BGP sessions; everything right of
\texttt{pr01.rue} is the real Internet.}
\label{fig:lab}
\end{figure}
\task{Announce.}
Open \url{\laburl} (username ``netsec'' password ``perrig''). Enter your name as
\emph{owner} and your prefix, and press \emph{Announce}.
\task{Read what you sent.}
Click \emph{bytes} next to your route and hover over the coloured segments.
\fillintwo{AS number(s) in \texttt{AS\_PATH}:}{attribute carrying your prefix:}
\task{See it from outside.}
Wait a moment, then look your prefix up on \texttt{https://bgp.tools/super-lg}.
\fillintwo{Origin AS the world sees:}{an AS path you found:}
\noindent The lab daemon's AS4200000001 is missing from that path. Where did it go, and why?
\answer[2]
\task{Reach it.}
The router answers for every address in the lab space, so your prefix is
reachable exactly when your announcement got out:\quad
\texttt{ping -6 2a0d:d741:\nhex::1}
\reach
% ==============================================================================
\section{Steering Your Prefix with Large Communities}
A Large Community is a tag added to a route. It can have any meaning that the network
which issued it decides. In BGP large communities, the first part is the AS number
and the subsequent two integers are up to the interpretation of that network.
\begin{table}[h]
\centering
\begin{tabular}{@{}l@{\hspace{1.6em}}l@{\hspace{1.6em}}l@{}}
\toprule
Large community & Read by & Effect \\
\midrule
\texttt{210036:0:0} & \texttt{pr01.rue} & announce nowhere \ldots \\
\texttt{210036:1:42476} & \texttt{pr01.rue} & \ldots except to AS42476, the SwissIX route servers \\
\midrule
\texttt{42476:0:0} & SwissIX & send to no member \ldots \\
\texttt{42476:1:559} & SwissIX & \ldots except AS559, SWITCH, the network ETH is part of \\
\bottomrule
\end{tabular}
\end{table}
\task{One exchange only.}
Announce your prefix again with \texttt{210036:0:0} and
\texttt{210036:1:42476}; announcing a prefix you own replaces it. Before you
look: who do you expect to still see it?
\answer[2]
\noindent Wait a moment, then check on \texttt{https://bgp.tools/super-lg} and with \texttt{ping}.
\reach
\task{To SWITCH only.}
Add \texttt{42476:0:0} and \texttt{42476:1:559} to the two you already have and
announce again. Our router now tells only the route servers, and they tell
only SWITCH.
\reach
\noindent Is your prefix still visible on \texttt{https://bgp.tools/super-lg}? Explain what you see.
\answer[2]
\task{Use of large communities.}
You steered routing in two networks you do not operate, using nothing but
numbers attached to a route. What stops you from attaching a community that
one of \emph{their} upstreams acts on?
\answer[2]
\task{Clean up.} Withdraw your prefix and see how long it takes to go away.
\end{document}
+172
View File
@@ -0,0 +1,172 @@
# BIRD 2.x / 3.x: peer the production router (AS210036) with ferrix_bgp over
# WireGuard. ferrix is announce-only: we accept the lab /48s from it and send
# it nothing.
#
# Include from bird.conf: include "/etc/bird/bird-ferrix.conf";
# Check and apply: birdc configure check && birdc configure
# Inspect: birdc show protocols all ferrix
# birdc show route protocol ferrix all
define MY_ASN = 210036;
define FERRIX_ASN = 4200000001;
define LAB_PREFIXES = [ 2a0d:d741::/40{48,48} ];
filter ferrix_import
{
# Only the students' /48s, nothing shorter or longer, nothing else.
if !(net ~ LAB_PREFIXES) then reject;
# ferrix allows at most 16 prepends on top of its own ASN.
if bgp_path.len > 17 then reject;
if bgp_path.first != FERRIX_ASN then reject;
# Students type communities freely into a web form with no login, so
# only namespaces we have chosen to expose get through.
#
# Standard: just the two well-known ones BIRD itself honours, so
# NO_EXPORT / NO_ADVERTISE visibly work. Notably BLACKHOLE (65535:666),
# which SwissIX acts on, stays scrubbed.
bgp_community.filter([ (65535, 65281), (65535, 65282) ]);
# Large: the SwissIX route-server action communities, all of which live
# under their route-server ASN 42476
# (https://www.swissix.ch/resources/action-bgp-communities/):
# 42476:0:<peer-as> do not redistribute to peer 42476:0:0 to nobody
# 42476:1:<peer-as> redistribute to peer 42476:1:0 to all
# 42476:101|102|103:<peer-as or 0> prepend 1x / 2x / 3x
# 42476:901|902:<peer-as> add NO_EXPORT / NO_ADVERTISE
# Everything else is dropped, including the rest of our own
# (210036, *, *) space, so students cannot trigger anything else this
# network acts on. Add further namespaces here deliberately.
#
# And our own export-control communities, functions 0 to 3, evaluated by
# lab_export_allowed() below.
bgp_large_community.filter([ (42476, *, *), (MY_ASN, 0..3, *) ]);
bgp_ext_community.empty;
# Announce-only: there is nothing behind these prefixes. Do not forward
# their traffic into the tunnel; answer with "unreachable" instead.
dest = RTD_UNREACHABLE;
accept;
}
protocol bgp ferrix
{
description "ferrix_bgp teaching lab (announce only)";
local 2a0d:d747:1::1 as 210036;
neighbor 2a0d:d747:1:: as FERRIX_ASN;
direct;
# ferrix dials us. Staying passive avoids log noise from failed attempts
# towards a laptop that is asleep or firewalled.
passive on;
# ferrix supports none of these; leave them off.
# password "..."; (TCP MD5)
# ttl security on; (GTSM)
# bfd on;
ipv6 {
import filter ferrix_import;
# 128 students, one /48 each. Extra routes are ignored, the session
# stays up.
import limit 130 action block;
# Send nothing: ferrix keeps received routes in memory and renders
# every one of them on its status page.
export none;
};
}
# ---------------------------------------------------------------------------
# Export control for lab routes.
#
# BIRD export filters cannot see which neighbour they are exporting to, and
# filters take no arguments; functions do. So every eBGP session states who it
# talks to and where, by calling the shared export function with its neighbour
# AS and a location:
#
# ipv6 { export where peers_out(42476, AT_SWISSIX); ... };
#
# Students steer their prefix with large communities, in this precedence:
#
# 1. 210036:0:<as> never announce to that neighbour AS
# 210036:2:<location> never announce at that location
# 2. 210036:1:<as> announce to that neighbour AS
# 210036:3:<location> announce at that location
# 3. 210036:0:0 announce nowhere (unless rule 2 says otherwise)
# 4. nothing announce everywhere <- default
#
# A "never" always wins, so the two dimensions combine. The shape and
# precedence match the SwissIX route-server communities on purpose, so one
# idea is applied at two hops.
#
# only the SwissIX route servers: 210036:0:0 210036:1:42476
# ...and there only SWITCH: add 42476:0:0 42476:1:559
# every session at SwissIX: 210036:0:0 210036:3:1
# Cloudflare, but not at DE-CIX: 210036:0:0 210036:1:13335 210036:2:4
# ---------------------------------------------------------------------------
define AT_SWISSIX = 1;
define AT_NLIX = 2;
define AT_FRANCEIX = 3;
define AT_DECIX = 4;
define AT_FREEIX = 5;
define AT_COMMUNITYIX = 6;
define AT_TRANSIT = 100;
define AT_DOWNSTREAM = 200;
function lab_export_allowed(int peer_as; int location)
{
if (MY_ASN, 0, peer_as) ~ bgp_large_community then return false;
if (MY_ASN, 2, location) ~ bgp_large_community then return false;
if (MY_ASN, 1, peer_as) ~ bgp_large_community then return true;
if (MY_ASN, 3, location) ~ bgp_large_community then return true;
if (MY_ASN, 0, 0) ~ bgp_large_community then return false;
return true;
}
# Decides whether a lab route goes out on a session and, if so, makes it
# presentable: the origin must appear as AS210036 (matching the route6
# objects) and our action communities must not leak.
function lab_route_out(int peer_as; int location)
{
if !lab_export_allowed(peer_as, location) then return false;
bgp_path.delete([ 64512..65534, 4200000000..4294967294 ]);
bgp_large_community.delete([ (MY_ASN, 0..3, *) ]);
return true;
}
# The shared export function then ends its list of accepted routes with
#
# if proto = "ferrix" then return lab_route_out(peer_as, location);
# return false;
#
# and returns true for everything else it accepts. Sessions that export
# everything (downstream customers) need the same gate, or they receive lab
# routes regardless of what the student asked for:
#
# export filter {
# if proto = "ferrix" then {
# if lab_route_out(210400, AT_DOWNSTREAM) then accept;
# reject;
# }
# accept;
# };
#
# The ferrix branch only ever applies to routes learned from ferrix, so these
# communities cannot be used by anyone else to steer other exports.
#
# If this BIRD rejects "int peer_as; int location", it wants a comma there.
#
# Check what a session would send, without waiting for a looking glass:
# birdc show route 2a0d:d741:1::/48 export <protocol-name> all
#
# The status page no longer offers AS-path prepending or the SwissIX prepend
# communities (42476:101..103:*), to keep the exercise focused. The import
# filter still admits the whole 42476 namespace, so a student who types one
# by hand can use it; narrow the filter if that is not wanted.
#
# The session towards the SwissIX route servers must send large communities
# unmodified apart from the delete above; check nothing else clears them.
# ---------------------------------------------------------------------------
+20
View File
@@ -0,0 +1,20 @@
router_id: "10.255.0.2" # must differ from the production router's
local_asn: 4200000001 # private asn used for internal peering.
listen_endpoints { address: "2a0d:d747:1::" port: 9179 }
peers {
name: "pr01.rue.rayhaan.net"
endpoint { address: "2a0d:d747:1::1" port: 179 }
remote_asn: 210036
hold_time_seconds: 90
}
connect_retry_seconds: 5
collision_detect_established_state: true
local_route_policy {
allowed_prefixes: "2a0d:d741::/40"
max_routes_per_owner: 1
}
http_endpoint { address: "127.0.0.1" port: 8080 }
+20
View File
@@ -0,0 +1,20 @@
# /etc/systemd/network/05-lab-lo.network
# If a .network file already matches "lo" (e.g. the AnyIP one), merge these
# sections into it instead: networkd applies only the first match.
[Match]
Name=lo
# BIRD and ferrix peer over ::1, which needs no configuration. Should BIRD
# object to identical local and neighbour addresses, give them one each
# instead (and use those in bird.conf and ferrix.textpb):
#
# [Address]
# Address=fd00:179::1/128
#
# [Address]
# Address=fd00:179::2/128
# AnyIP: the router answers ping for every address in the lab space.
[Route]
Destination=2a0d:d741::/40
Type=local
+178
View File
@@ -0,0 +1,178 @@
# Running ferrix_bgp on the production router
ferrix and BIRD run side by side on pr01 and peer over `::1`. Students reach the status page through a reverse proxy that adds
TLS and a password. Nothing here has been run on the router; treat each step
as something to check.
## 1. Build a static Linux binary (on the Mac)
The `x86_64-unknown-linux-musl` target and Homebrew's musl cross-linker are
enough; the router needs no Rust toolchain and no `protoc`.
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
cargo build --release -p bgp --target x86_64-unknown-linux-musl
scp target/x86_64-unknown-linux-musl/release/bgp pr01:/tmp/ferrix-bgp
Check the router really is x86-64 first (`uname -m`).
## 2. Install on the router
install -m 0755 /tmp/ferrix-bgp /usr/local/bin/ferrix-bgp
install -d /etc/ferrix-bgp
install -m 0644 ferrix.textpb /etc/ferrix-bgp/ferrix.textpb
install -m 0644 ferrix-bgp.service /etc/systemd/system/ferrix-bgp.service
## 3. Loopback
Peering over `::1` needs no address setup. `05-lab-lo.network` only carries
the AnyIP route for the lab space; merge it into the networkd file that
matches `lo` if it is not there already, then `networkctl reload`.
## 4. BIRD
Replace the `protocol bgp ferrix` stanza with `bird-ferrix-local.conf`, then
`birdc configure check` and `birdc configure timeout 300` / `confirm`.
## 5. Start
systemctl daemon-reload
systemctl enable --now ferrix-bgp
journalctl -u ferrix-bgp -f
birdc show protocols all ferrix
The journal should say `Ping monitor listening for ICMPv6 echo requests`. If
it says the monitor is disabled, the unit is missing `CAP_NET_RAW`; everything
else works without it and the status page says why the table is off.
To check the monitor before the class, announce a prefix on the page, turn on
auto-refresh and ping an address inside it from somewhere else:
ping -c 3 2a0d:d741:1::1
The ping shows up under "Pings to announced addresses" with its source, the
interface it arrived on and the hop limit it arrived with. Check that
"arrived on" names the real ingress port (the exchange or transit interface)
and not `lo`.
## 6. Let students in
The API has no authentication and announces into a real AS, so do not expose
port 8080. Put a reverse proxy in front with TLS and a class password. The
event stream is long-lived, so the proxy must not buffer it.
Apache (tested against a local instance: 401 without the password, pages,
API and POSTs through the proxy, and events arriving within 50 ms):
<VirtualHost *:80>
ServerName lab.example.net
Redirect permanent "/" "https://lab.example.net/"
</VirtualHost>
<VirtualHost *:443>
ServerName lab.example.net
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/lab.example.net/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/lab.example.net/privkey.pem
# Everything behind one class password.
<Location "/">
AuthType Basic
AuthName "BGP lab"
AuthUserFile /etc/apache2/ferrix.htpasswd
Require valid-user
</Location>
# Use Apache's built-in error messages in this vhost. Servers with the
# multi-language error documents enabled fetch them from /error/,
# which this vhost would proxy to ferrix; a 401 then comes back as
# ferrix's empty 404: a blank page and no password prompt. With the
# built-in ones nothing is fetched, so nothing needs exempting from
# the login.
ErrorDocument 400 default
ErrorDocument 401 default
ErrorDocument 403 default
ErrorDocument 404 default
ErrorDocument 500 default
ErrorDocument 502 default
ErrorDocument 503 default
ErrorDocument 504 default
ProxyPreserveHost On
ProxyPass "/" "http://127.0.0.1:8080/"
ProxyPassReverse "/" "http://127.0.0.1:8080/"
# ferrix has no use for the password; do not pass it on.
RequestHeader unset Authorization
# The status page follows a long-lived event stream. If mod_deflate
# compresses it, events sit in the compressor and never arrive.
SetEnvIf Request_URI "^/api/events" no-gzip=1
</VirtualHost>
Modules: `proxy proxy_http ssl headers setenvif auth_basic authn_file`.
Create the password file with `htpasswd -c -B /etc/apache2/ferrix.htpasswd student`.
To also limit it to known networks, replace `Require valid-user` with
<RequireAll>
Require valid-user
Require ip 192.0.2.0/24 2001:db8::/32
</RequireAll>
Caddy:
lab.example.net {
basic_auth {
student <hash from `caddy hash-password`>
}
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
nginx, inside a TLS `server` block:
location / {
auth_basic "BGP lab";
auth_basic_user_file /etc/nginx/lab.htpasswd;
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 1h;
}
Restricting the proxy to the university's address ranges as well is cheap
and worth doing. Only the Apache example has been tested.
## 7. Load test before the class
`tools/loadtest.py` makes N simulated students announce their /48 at the same
instant, then verifies the table, ownership under load, the event stream and
the number of BGP UPDATEs actually sent, and withdraws everything again.
Python 3 standard library only.
tools/loadtest.py 128 --url https://lab.example.net --user student
Every test route carries `210036:0:0`, so BIRD accepts it but exports it to no
neighbour (apart from `home_v6`, which is outside the export control). While
it runs, `birdc show route protocol ferrix count` should rise to the number of
students and fall back to what it was. BIRD's `import limit 130` means more
than 130 lab routes at once are blocked there, by design.
## Things to know
- The ping monitor only listens. The kernel answers the pings, with or
without ferrix running, because of the AnyIP route. It sees echo requests
to the lab space from anyone on the Internet, so source addresses of
strangers will appear on the page next to the students' own. It remembers
at most 2048 source and destination pairs and publishes at most 50 events a
second, so a ping flood costs little; `ping_monitor_echo_requests_total` and
`ping_monitor_events_suppressed_total` on `/metricz` show what it saw.
- Announced routes live in memory. A restart of the service forgets them and
students have to announce again.
- Stopping the service sends BIRD a Cease first, so restarts reconnect at
once.
- ferrix's own safety net is `local_route_policy`; BIRD's import filter and
import limit are the second one. Keep both.
@@ -0,0 +1,23 @@
# Replaces "protocol bgp ferrix" in bird.conf when ferrix runs on this host.
# Only the addresses and "multihop" differ from the WireGuard version; the
# import filter and everything about export control stay as they are.
#
# Without "multihop" BIRD looks for the neighbour on a directly connected
# network, cannot find ::1 there, and disables the protocol with
# "Error: Invalid next hop". After fixing that, `birdc enable ferrix` if a
# reconfigure does not bring it back by itself.
protocol bgp ferrix
{
description "ferrix_bgp teaching lab (announce only, same host)";
local ::1 as 210036;
neighbor ::1 as FERRIX_ASN;
# Both ends are this machine, so there is no directly connected
# neighbour for "direct" (the eBGP default) to find.
multihop;
passive on;
ipv6 {
import filter ferrix_import;
import limit 130 action block;
export none;
};
}
@@ -0,0 +1,37 @@
[Unit]
Description=ferrix_bgp teaching BGP speaker
After=network-online.target bird.service
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/ferrix-bgp /etc/ferrix-bgp/ferrix.textpb --level info
# SIGTERM makes ferrix send Cease / Administrative Shutdown to BIRD first.
KillSignal=SIGTERM
TimeoutStopSec=10
Restart=on-failure
RestartSec=5
# It needs the network and nothing else: no root, no privileged port, no
# write access anywhere. The one capability, CAP_NET_RAW, is for the ping
# monitor's raw ICMPv6 socket; drop both lines if ping_monitor is not
# configured.
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
MemoryMax=256M
[Install]
WantedBy=multi-user.target
+32
View File
@@ -0,0 +1,32 @@
# ferrix_bgp running on the production router itself, next to BIRD.
router_id: "10.255.0.2"
local_asn: 4200000001
# Unused in practice (BIRD is passive and we dial it), but a listener is
# required. 179 belongs to BIRD, so take another port on our own address.
listen_endpoints { address: "::1" port: 9179 }
peers {
name: "pr01-bird"
endpoint { address: "::1" port: 179 }
# BIRD recognises its neighbour by source address, so pin ours.
local_address: "::1"
remote_asn: 210036
hold_time_seconds: 90
}
connect_retry_seconds: 5
collision_detect_established_state: true
local_route_policy {
allowed_prefixes: "2a0d:d741::/40"
max_routes_per_owner: 1
}
# Loopback only. Students reach it through the reverse proxy, which adds TLS
# and a password; the API itself has neither.
http_endpoint { address: "127.0.0.1" port: 8080 }
# Show who pings the lab addresses. The kernel answers the pings (AnyIP route
# in 05-lab-lo.network); this only listens. Watches allowed_prefixes above.
# Needs CAP_NET_RAW, which the systemd unit grants.
ping_monitor {}
File diff suppressed because it is too large. Load diff
+4
View File
@@ -0,0 +1,4 @@
bgpd=yes
zebra=yes
staticd=yes
vtysh_enable=yes
+44
View File
@@ -0,0 +1,44 @@
! r1: AS65001. Peers with ferrix (AS65000) and r2 (AS65002).
! Applies policy on routes from ferrix based on the communities students set:
! NO_EXPORT (65535:65281) standard: not sent to r2 (FRR honours it)
! 65001:100 r1 prepends its own ASN three times
! BLACKHOLE (65535:666) dropped at r1
frr defaults traditional
hostname r1
log stdout
!
ip route 192.0.2.0/24 Null0
!
bgp community-list standard PREPEND-ME permit 65001:100
bgp community-list standard BLACKHOLE permit 65535:666
!
route-map FROM-FERRIX deny 10
match community BLACKHOLE
!
route-map FROM-FERRIX permit 20
match community PREPEND-ME
set as-path prepend 65001 65001 65001
!
route-map FROM-FERRIX permit 30
!
route-map PERMIT-ALL permit 10
!
router bgp 65001
bgp router-id 10.200.0.2
no bgp ebgp-requires-policy
no bgp network import-check
neighbor 10.200.0.1 remote-as 65000
neighbor 10.200.0.1 description ferrix
neighbor 10.200.0.1 timers 10 30
neighbor 10.200.0.3 remote-as 65002
neighbor 10.200.0.3 description r2
neighbor 10.200.0.3 timers 10 30
!
address-family ipv4 unicast
network 192.0.2.0/24
neighbor 10.200.0.1 route-map FROM-FERRIX in
neighbor 10.200.0.1 route-map PERMIT-ALL out
neighbor 10.200.0.3 route-map PERMIT-ALL in
neighbor 10.200.0.3 route-map PERMIT-ALL out
exit-address-family
!
+1
View File
@@ -0,0 +1 @@
service integrated-vtysh-config
+4
View File
@@ -0,0 +1,4 @@
bgpd=yes
zebra=yes
staticd=yes
vtysh_enable=yes
+33
View File
@@ -0,0 +1,33 @@
! r2: AS65002. Peers with r1 (AS65001) and ferrix (AS65000).
! Accepts nothing directly from ferrix so every student prefix reaches it
! only via r1, then re-advertises everything to ferrix. That closes the loop:
! ferrix sees its own prefixes come back with AS path 65002 65001 65000.
frr defaults traditional
hostname r2
log stdout
!
ip route 198.51.100.0/24 Null0
!
route-map PERMIT-ALL permit 10
!
route-map DENY-ALL deny 10
!
router bgp 65002
bgp router-id 10.200.0.3
no bgp ebgp-requires-policy
no bgp network import-check
neighbor 10.200.0.2 remote-as 65001
neighbor 10.200.0.2 description r1
neighbor 10.200.0.2 timers 10 30
neighbor 10.200.0.1 remote-as 65000
neighbor 10.200.0.1 description ferrix
neighbor 10.200.0.1 timers 10 30
!
address-family ipv4 unicast
network 198.51.100.0/24
neighbor 10.200.0.2 route-map PERMIT-ALL in
neighbor 10.200.0.2 route-map PERMIT-ALL out
neighbor 10.200.0.1 route-map DENY-ALL in
neighbor 10.200.0.1 route-map PERMIT-ALL out
exit-address-family
!
+1
View File
@@ -0,0 +1 @@
service integrated-vtysh-config
+779
View File
@@ -0,0 +1,779 @@
inet6num: 2a0d:d741::/40
netname: CH-RAY-ETHZ-NETSEC-LAB-2026
descr: ETHZ Netsec 2026 BGP hands-on lab, one /48 per student
country: CH
org: ORG-RAJ2-RIPE
admin-c: RJ4890-RIPE
tech-c: RJ4890-RIPE
status: AGGREGATED-BY-LIR
assignment-size: 48
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1::/48
descr: ETHZ Netsec 2026 BGP lab - student 1
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2::/48
descr: ETHZ Netsec 2026 BGP lab - student 2
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3::/48
descr: ETHZ Netsec 2026 BGP lab - student 3
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4::/48
descr: ETHZ Netsec 2026 BGP lab - student 4
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5::/48
descr: ETHZ Netsec 2026 BGP lab - student 5
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6::/48
descr: ETHZ Netsec 2026 BGP lab - student 6
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7::/48
descr: ETHZ Netsec 2026 BGP lab - student 7
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:8::/48
descr: ETHZ Netsec 2026 BGP lab - student 8
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:9::/48
descr: ETHZ Netsec 2026 BGP lab - student 9
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:a::/48
descr: ETHZ Netsec 2026 BGP lab - student 10
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:b::/48
descr: ETHZ Netsec 2026 BGP lab - student 11
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:c::/48
descr: ETHZ Netsec 2026 BGP lab - student 12
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:d::/48
descr: ETHZ Netsec 2026 BGP lab - student 13
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:e::/48
descr: ETHZ Netsec 2026 BGP lab - student 14
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:f::/48
descr: ETHZ Netsec 2026 BGP lab - student 15
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:10::/48
descr: ETHZ Netsec 2026 BGP lab - student 16
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:11::/48
descr: ETHZ Netsec 2026 BGP lab - student 17
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:12::/48
descr: ETHZ Netsec 2026 BGP lab - student 18
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:13::/48
descr: ETHZ Netsec 2026 BGP lab - student 19
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:14::/48
descr: ETHZ Netsec 2026 BGP lab - student 20
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:15::/48
descr: ETHZ Netsec 2026 BGP lab - student 21
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:16::/48
descr: ETHZ Netsec 2026 BGP lab - student 22
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:17::/48
descr: ETHZ Netsec 2026 BGP lab - student 23
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:18::/48
descr: ETHZ Netsec 2026 BGP lab - student 24
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:19::/48
descr: ETHZ Netsec 2026 BGP lab - student 25
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1a::/48
descr: ETHZ Netsec 2026 BGP lab - student 26
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1b::/48
descr: ETHZ Netsec 2026 BGP lab - student 27
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1c::/48
descr: ETHZ Netsec 2026 BGP lab - student 28
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1d::/48
descr: ETHZ Netsec 2026 BGP lab - student 29
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1e::/48
descr: ETHZ Netsec 2026 BGP lab - student 30
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1f::/48
descr: ETHZ Netsec 2026 BGP lab - student 31
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:20::/48
descr: ETHZ Netsec 2026 BGP lab - student 32
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:21::/48
descr: ETHZ Netsec 2026 BGP lab - student 33
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:22::/48
descr: ETHZ Netsec 2026 BGP lab - student 34
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:23::/48
descr: ETHZ Netsec 2026 BGP lab - student 35
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:24::/48
descr: ETHZ Netsec 2026 BGP lab - student 36
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:25::/48
descr: ETHZ Netsec 2026 BGP lab - student 37
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:26::/48
descr: ETHZ Netsec 2026 BGP lab - student 38
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:27::/48
descr: ETHZ Netsec 2026 BGP lab - student 39
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:28::/48
descr: ETHZ Netsec 2026 BGP lab - student 40
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:29::/48
descr: ETHZ Netsec 2026 BGP lab - student 41
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2a::/48
descr: ETHZ Netsec 2026 BGP lab - student 42
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2b::/48
descr: ETHZ Netsec 2026 BGP lab - student 43
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2c::/48
descr: ETHZ Netsec 2026 BGP lab - student 44
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2d::/48
descr: ETHZ Netsec 2026 BGP lab - student 45
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2e::/48
descr: ETHZ Netsec 2026 BGP lab - student 46
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2f::/48
descr: ETHZ Netsec 2026 BGP lab - student 47
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:30::/48
descr: ETHZ Netsec 2026 BGP lab - student 48
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:31::/48
descr: ETHZ Netsec 2026 BGP lab - student 49
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:32::/48
descr: ETHZ Netsec 2026 BGP lab - student 50
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:33::/48
descr: ETHZ Netsec 2026 BGP lab - student 51
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:34::/48
descr: ETHZ Netsec 2026 BGP lab - student 52
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:35::/48
descr: ETHZ Netsec 2026 BGP lab - student 53
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:36::/48
descr: ETHZ Netsec 2026 BGP lab - student 54
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:37::/48
descr: ETHZ Netsec 2026 BGP lab - student 55
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:38::/48
descr: ETHZ Netsec 2026 BGP lab - student 56
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:39::/48
descr: ETHZ Netsec 2026 BGP lab - student 57
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3a::/48
descr: ETHZ Netsec 2026 BGP lab - student 58
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3b::/48
descr: ETHZ Netsec 2026 BGP lab - student 59
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3c::/48
descr: ETHZ Netsec 2026 BGP lab - student 60
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3d::/48
descr: ETHZ Netsec 2026 BGP lab - student 61
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3e::/48
descr: ETHZ Netsec 2026 BGP lab - student 62
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3f::/48
descr: ETHZ Netsec 2026 BGP lab - student 63
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:40::/48
descr: ETHZ Netsec 2026 BGP lab - student 64
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:41::/48
descr: ETHZ Netsec 2026 BGP lab - student 65
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:42::/48
descr: ETHZ Netsec 2026 BGP lab - student 66
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:43::/48
descr: ETHZ Netsec 2026 BGP lab - student 67
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:44::/48
descr: ETHZ Netsec 2026 BGP lab - student 68
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:45::/48
descr: ETHZ Netsec 2026 BGP lab - student 69
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:46::/48
descr: ETHZ Netsec 2026 BGP lab - student 70
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:47::/48
descr: ETHZ Netsec 2026 BGP lab - student 71
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:48::/48
descr: ETHZ Netsec 2026 BGP lab - student 72
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:49::/48
descr: ETHZ Netsec 2026 BGP lab - student 73
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4a::/48
descr: ETHZ Netsec 2026 BGP lab - student 74
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4b::/48
descr: ETHZ Netsec 2026 BGP lab - student 75
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4c::/48
descr: ETHZ Netsec 2026 BGP lab - student 76
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4d::/48
descr: ETHZ Netsec 2026 BGP lab - student 77
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4e::/48
descr: ETHZ Netsec 2026 BGP lab - student 78
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4f::/48
descr: ETHZ Netsec 2026 BGP lab - student 79
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:50::/48
descr: ETHZ Netsec 2026 BGP lab - student 80
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:51::/48
descr: ETHZ Netsec 2026 BGP lab - student 81
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:52::/48
descr: ETHZ Netsec 2026 BGP lab - student 82
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:53::/48
descr: ETHZ Netsec 2026 BGP lab - student 83
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:54::/48
descr: ETHZ Netsec 2026 BGP lab - student 84
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:55::/48
descr: ETHZ Netsec 2026 BGP lab - student 85
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:56::/48
descr: ETHZ Netsec 2026 BGP lab - student 86
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:57::/48
descr: ETHZ Netsec 2026 BGP lab - student 87
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:58::/48
descr: ETHZ Netsec 2026 BGP lab - student 88
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:59::/48
descr: ETHZ Netsec 2026 BGP lab - student 89
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5a::/48
descr: ETHZ Netsec 2026 BGP lab - student 90
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5b::/48
descr: ETHZ Netsec 2026 BGP lab - student 91
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5c::/48
descr: ETHZ Netsec 2026 BGP lab - student 92
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5d::/48
descr: ETHZ Netsec 2026 BGP lab - student 93
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5e::/48
descr: ETHZ Netsec 2026 BGP lab - student 94
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5f::/48
descr: ETHZ Netsec 2026 BGP lab - student 95
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:60::/48
descr: ETHZ Netsec 2026 BGP lab - student 96
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:61::/48
descr: ETHZ Netsec 2026 BGP lab - student 97
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:62::/48
descr: ETHZ Netsec 2026 BGP lab - student 98
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:63::/48
descr: ETHZ Netsec 2026 BGP lab - student 99
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:64::/48
descr: ETHZ Netsec 2026 BGP lab - student 100
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:65::/48
descr: ETHZ Netsec 2026 BGP lab - student 101
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:66::/48
descr: ETHZ Netsec 2026 BGP lab - student 102
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:67::/48
descr: ETHZ Netsec 2026 BGP lab - student 103
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:68::/48
descr: ETHZ Netsec 2026 BGP lab - student 104
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:69::/48
descr: ETHZ Netsec 2026 BGP lab - student 105
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6a::/48
descr: ETHZ Netsec 2026 BGP lab - student 106
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6b::/48
descr: ETHZ Netsec 2026 BGP lab - student 107
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6c::/48
descr: ETHZ Netsec 2026 BGP lab - student 108
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6d::/48
descr: ETHZ Netsec 2026 BGP lab - student 109
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6e::/48
descr: ETHZ Netsec 2026 BGP lab - student 110
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6f::/48
descr: ETHZ Netsec 2026 BGP lab - student 111
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:70::/48
descr: ETHZ Netsec 2026 BGP lab - student 112
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:71::/48
descr: ETHZ Netsec 2026 BGP lab - student 113
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:72::/48
descr: ETHZ Netsec 2026 BGP lab - student 114
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:73::/48
descr: ETHZ Netsec 2026 BGP lab - student 115
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:74::/48
descr: ETHZ Netsec 2026 BGP lab - student 116
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:75::/48
descr: ETHZ Netsec 2026 BGP lab - student 117
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:76::/48
descr: ETHZ Netsec 2026 BGP lab - student 118
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:77::/48
descr: ETHZ Netsec 2026 BGP lab - student 119
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:78::/48
descr: ETHZ Netsec 2026 BGP lab - student 120
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:79::/48
descr: ETHZ Netsec 2026 BGP lab - student 121
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7a::/48
descr: ETHZ Netsec 2026 BGP lab - student 122
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7b::/48
descr: ETHZ Netsec 2026 BGP lab - student 123
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7c::/48
descr: ETHZ Netsec 2026 BGP lab - student 124
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7d::/48
descr: ETHZ Netsec 2026 BGP lab - student 125
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7e::/48
descr: ETHZ Netsec 2026 BGP lab - student 126
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7f::/48
descr: ETHZ Netsec 2026 BGP lab - student 127
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:80::/48
descr: ETHZ Netsec 2026 BGP lab - student 128
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
+125
View File
@@ -0,0 +1,125 @@
# Slides
A reveal.js deck structured like Matt Godbolt's newer talks
([what-happens-beneath-accu-2026](https://github.com/mattgodbolt/what-happens-beneath-accu-2026)):
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.
What is ours: the palette, and the corner motif. His ring-and-thread became a
chain of nodes and links, an AS path.
## The talk
| Section | File |
|---|---|
| About the speaker | `sections/00-intro.md` |
| Internet numbering resources: addresses, prefixes, registries, AS numbers | `sections/01-resources.md` |
| BGP and the bytes of an UPDATE | `sections/03-bgp-update.md` |
| IP transit and Internet exchanges | `sections/04-transit-and-ix.md` |
| The lab: the lab speaker, pr01.rue and its exchanges | `sections/05-lab.md` |
The UPDATE in section 3 is `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), MED 0 and the `210036:10:2` tag, which is what pr01's export filter puts
on routes to peers. It was produced with the lab daemon's own encoder and parsed back
to the same message, not captured off the wire; a capture from pr01 may differ
in detail (BIRD can append a link-local next hop). The byte slides step through
it one part at a time (press down), and every number on them was checked
against the bytes when the slides were generated. To change the example,
regenerate rather than edit the hex by hand.
The deck deliberately has no fragments or stepped code: a slide shows
everything at once. Two code blocks keep a static highlight on one line.
| File | Holds |
|---|---|
| `theme.css` | the look: palette, type, panels, motif, title slide |
| `style.css` | components used by slides: columns, boxes, bytes, notes |
| `index.html` | section list, reveal.js loader and options, `bird` highlighter |
| `sections/*.md` | the slides |
## Make it yours
Everything brand-like is a variable at the top of `theme.css`. To compare
accents without editing, open `?accent=green`, `?accent=amber` or
`?accent=magenta` (blue is the default); copy the one you like into `:root`.
The wordmark bottom-left is the `<div class="wordmark">` in `index.html`.
For a full-bleed title image, empty the title `<section>` and give it
`data-background="images/title.png"`.
## Show it
The sections are loaded over HTTP, so open the deck through a server, not by
double-clicking `index.html`. Opened from disk, browsers block reveal.js and
the section files, and you would see a title and empty panels; the page now
says so instead:
python3 slides/serve.py 8000
(`python3 -m http.server` works too, but it lets the browser cache the
section files and the stylesheet, so edits may not show until a hard reload,
Cmd+Shift+R. `serve.py` is the same server with caching turned off.)
Then <http://localhost:8000>. Keys: arrows to move, `S` speaker view with
notes and a timer, `O` overview, `F` fullscreen, `B` blank the screen.
## Offline
slides/vendor-reveal.sh
copies reveal.js into `slides/reveal.js/` (ignored by git). The deck uses that
copy when present and the CDN otherwise. Do this before presenting anywhere
with uncertain Wi-Fi. IBM Plex still comes from Google Fonts; without a
network the deck falls back to Helvetica/Arial and the system monospace, so
install IBM Plex on the presenting machine if that matters.
## Write slides
Edit the files in `sections/`; add a section by adding a file and a
`<section data-markdown=...>` line in `index.html`.
----- next slide
--- next vertical slide (detail you can skip)
Notes: speaker notes from here to the end of the slide
Apply a class to the element above with `<!-- .element: class="..." -->`.
| Class | Effect |
|---|---|
| `big` | one large statement on the slide |
| `note` | quiet italic aside |
| `attribution` | small credit under an image or quote |
| `borders` | table with visible borders |
| `r-stretch` | image or iframe fills the remaining height |
| `blur-edges` | soft edge for photos |
Blocks: `<div class="cols">` two columns, `<div class="boxes">` labelled cards
(`us`, `ok`, `bad`), `<pre class="bytes">` BGP bytes in the colours of the lab
page (add `focus`, and `on` to the spans to keep lit, to dim the rest),
`<table class="decode">` for the explanation under them,
`<svg class="diagram">` for diagrams drawn in the theme's colours (`hot`,
`fabric`, `link`, `bgp`, `traffic`). Code fences take a language and optional line steps, e.g.
` ```bird [1-3|5-9] `; `bird` is a small highlighter defined in `index.html`.
`<!-- .slide: class="bare" -->` drops the panel, the motif and the wordmark
for one slide, e.g. a full-bleed image. The title slide does so by itself.
## Publish as one file
slides/vendor-reveal.sh # once, if slides/reveal.js is missing
python3 slides/build.py # writes slides/dist/hands-on-bgp.html
`build.py` inlines everything the page would otherwise fetch: reveal.js and
its plugins, the stylesheets, the favicon, our scripts and the Markdown
sections. The one file (about 1.3 MB) can be uploaded anywhere and also
opens straight from disk. It still uses the network for two things: the IBM
Plex fonts from Google Fonts (without them the browser's fallback fonts are
used) and the live DNS and RIPE lookups in section 1. Speaker view (`S`)
works from the bundle too. `slides/dist/` is not committed; rebuild after
editing.
## PDF
Open <http://localhost:8000/?print-pdf> in Chrome and print to PDF with
background graphics on.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Bundle the deck into one self-contained HTML file for uploading.
python3 slides/build.py # writes slides/dist/hands-on-bgp.html
Everything the page fetches at runtime is inlined: reveal.js and its plugins
(from the vendored copy, so run vendor-reveal.sh first), the stylesheets, the
favicon, our scripts and the Markdown sections. The result opens from any web
server and from disk. Two things still need a network: the IBM Plex fonts
(Google Fonts) and the live DNS and RIPE lookups.
"""
import base64
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REVEAL = os.path.join(HERE, "reveal.js", "dist")
OUT = os.path.join(HERE, "dist", "hands-on-bgp.html")
def read(*parts):
with open(os.path.join(HERE, *parts), encoding="utf-8") as f:
return f.read()
def main():
if not os.path.isdir(REVEAL):
sys.exit("reveal.js is not vendored; run slides/vendor-reveal.sh first")
html = read("index.html")
# Stylesheets: reveal's first, then ours, as the loader orders them.
css = "\n".join(read("reveal.js", "dist", p) for p in ["reset.css", "reveal.css", "plugin/highlight/monokai.css"])
css += "\n" + read("theme.css") + "\n" + read("style.css")
html = re.sub(r'\s*<link rel="stylesheet" href="theme\.css[^"]*" id="deck-style" />', "", html)
html = re.sub(r'\s*<link rel="stylesheet" href="style\.css[^"]*" />', f"\n <style>\n{css}\n </style>", html, count=1)
icon = "data:image/svg+xml;base64," + base64.b64encode(read("images", "favicon.svg").encode()).decode()
html = html.replace('href="images/favicon.svg"', f'href="{icon}"')
# Sections: the Markdown goes inline in a text/template script.
def inline_section(m):
attrs, path = m.group(1), m.group(2)
text = read(path)
if "</script" in text.lower():
sys.exit(f"{path} contains '</script', which cannot be inlined")
return f'<section data-markdown{attrs}>\n<script type="text/template">\n{text}\n</script>\n</section>'
html, n = re.subn(r'<section\s+data-markdown="([^"]+)"((?:\s+data-[a-z-]+="[^"]*")*)\s*></section>',
lambda m: inline_section(type("M", (), {"group": lambda self, i: (None, m.group(2), m.group(1))[i]})()), html)
if n == 0:
sys.exit("no data-markdown sections found in index.html")
# Our scripts.
for name in ["dns-lookup.js", "prefix-explorer.js"]:
html, k = re.subn(rf'<script src="{re.escape(name)}[^"]*"></script>', lambda m: f"<script>\n{read(name)}\n</script>", html)
if k != 1:
sys.exit(f"expected one <script src=\"{name}\"> tag")
# The help overlay and its watchdog exist to explain a page that could
# not fetch its parts; this one fetches nothing.
html = re.sub(r'\s*<div id="deck-help" hidden>.*?</div>', "", html, count=1, flags=re.S)
html = re.sub(r'\s*<!-- A classic script: it still runs.*?</script>', "", html, count=1, flags=re.S)
# The loader: the module script that picks a reveal.js location and
# imports it becomes reveal.js itself, its plugins, and the deck setup.
m = re.search(r'<script type="module">(.*?)</script>', html, flags=re.S)
if not m:
sys.exit("loader script not found")
loader = m.group(1)
setup_start = loader.index("// BIRD configuration")
setup = loader[setup_start:]
setup = setup.replace("await deck.initialize().catch(fail(\"reveal.js failed to start\"));", "deck.initialize().then(() => {")
setup = setup.replace("plugins: [Markdown, Highlight, Notes],", "plugins: [RevealMarkdown, RevealHighlight, RevealNotes],")
setup = setup.rstrip() + "\n });\n"
libs = "\n".join(read("reveal.js", "dist", p) for p in ["reveal.js", "plugin/markdown.js", "plugin/highlight.js", "plugin/notes.js"])
html = html[: m.start()] + f"<script>\n{libs}\n</script>\n <script>\n{setup} </script>" + html[m.end():]
if "await " in setup or "import(" in setup:
sys.exit("loader still contains module-only code")
html = html.replace("reveal.js is loaded from ./reveal.js/dist if present", "Built by build.py from index.html: everything is inlined. reveal.js was loaded from ./reveal.js/dist if present")
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as f:
f.write(html)
print(f"wrote {os.path.relpath(OUT)} ({os.path.getsize(OUT) // 1024} KB, {n} sections)")
if __name__ == "__main__":
main()
+140
View File
@@ -0,0 +1,140 @@
// Live lookups on slides.
//
// <form class="dns-lookup"> with <input name="domain"> and a .dns-result:
// asks Google Public DNS over HTTPS for the name's AAAA records
// (https://developers.google.com/speed/public-dns/docs/doh/json) and
// prints the answer the way dig would.
// <form class="ripe-lookup"> with <input name="query"> and a .dns-result:
// asks the RIPE database (https://apps.db.ripe.net/docs/) what it knows
// about an address, prefix or AS number, and prints the objects the way
// whois would.
(function () {
const TYPES = { 1: "A", 5: "CNAME", 28: "AAAA" };
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
async function resolve(name, type) {
const r = await fetch(`https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`, {
headers: { accept: "application/dns-json" },
});
if (!r.ok) throw new Error(`dns.google answered HTTP ${r.status}`);
return r.json();
}
const line = (rr) =>
`<span class="name">${esc(rr.name)}</span> <span class="ttl">${rr.TTL}</span> IN <span class="type">${esc(TYPES[rr.type] || rr.type)}</span> <span class="${rr.type === 28 ? "addr" : "data"}">${esc(rr.data)}</span>`;
async function lookup(form) {
const out = form.querySelector(".dns-result");
const name = form.elements.domain.value.trim().replace(/\.$/, "");
if (!name) return;
out.innerHTML = `<span class="dim">asking dns.google for ${esc(name)} AAAA ...</span>`;
try {
const v6 = await resolve(name, "AAAA");
if (v6.Status === 3) {
out.innerHTML = `<span class="bad">NXDOMAIN</span> <span class="dim">there is no such name</span>`;
return;
}
if (v6.Status !== 0) {
out.innerHTML = `<span class="bad">the resolver answered with status ${v6.Status}</span>`;
return;
}
const answers = v6.Answer || [];
const lines = answers.map(line);
if (!answers.some((rr) => rr.type === 28)) {
// No IPv6 address. Show that this is a real answer, not a failure,
// and what the name does have.
const v4 = await resolve(name, "A");
lines.push(`<span class="dim">no AAAA record: this name has no IPv6 address</span>`);
(v4.Answer || []).filter((rr) => rr.type === 1).forEach((rr) => lines.push(line(rr)));
}
out.innerHTML = lines.join("\n");
} catch (e) {
out.innerHTML = `<span class="bad">lookup failed:</span> ${esc(e.message)} <span class="dim">(is there a network?)</span>`;
}
}
// ---- RIPE database ----------------------------------------------------
// Objects as apps.db.ripe.net shows them: one card per object, one row per
// attribute, references in blue, rows the RIPE NCC manages shaded. The
// API does not say which rows those are, so they are the ones the website
// shades for an assignment: the key, org, status, source, sponsoring-org
// and the RIPE NCC's own maintainers.
const RIPE_MANAGED = new Set(["inetnum", "inet6num", "aut-num", "org", "status", "source", "sponsoring-org"]);
const managed = (a) => RIPE_MANAGED.has(a.name) || (a.name === "mnt-by" && /^RIPE-NCC-/.test(a.value));
async function ripe(form) {
const out = form.querySelector(".ripe-result");
const query = form.elements.query.value.trim();
if (!query) return;
out.innerHTML = `<div class="ripe-msg">Searching the RIPE database for ${esc(query)} ...</div>`;
try {
const url = `https://rest.db.ripe.net/search.json?query-string=${encodeURIComponent(query)}&flags=no-referenced&flags=no-irt&source=ripe`;
const r = await fetch(url, { headers: { accept: "application/json" } });
if (r.status === 404) {
out.innerHTML = `<div class="ripe-msg">No results found for ${esc(query)}.</div>`;
return;
}
if (!r.ok) throw new Error(`rest.db.ripe.net answered HTTP ${r.status}`);
const objects = ((await r.json()).objects || {}).object || [];
out.innerHTML = objects
.map((o) => {
const rows = o.attributes.attribute.map((a) => {
const value = a.link || a.name === o.type ? `<span class="ref">${esc(a.value)}</span>` : esc(a.value);
return `<tr class="${managed(a) ? "managed" : ""}"><th>${esc(a.name)}:</th><td>${value}${a.comment ? `<span class="comment"># ${esc(a.comment)}</span>` : ""}</td></tr>`;
});
return `<div class="ripe-card"><table>${rows.join("")}</table></div>`;
})
.join("");
out.scrollTop = 0;
} catch (e) {
out.innerHTML = `<div class="ripe-msg">Lookup failed: ${esc(e.message)}. Is there a network?</div>`;
}
}
// The answer changes the slide's height; have reveal centre it again.
const relayout = () => window.deck && window.deck.layout();
document.addEventListener("submit", async (e) => {
const form = e.target.closest && e.target.closest("form.dns-lookup, form.ripe-lookup");
if (!form) return;
e.preventDefault();
await (form.classList.contains("ripe-lookup") ? ripe(form) : lookup(form));
relayout();
});
})();
// Byte walks: hovering (or tabbing to) a field row lights its bytes in the
// dump above it, and hovering bytes in the dump lights their row and scrolls
// it into view, so the dump can be used as the index while presenting.
(function () {
const pair = (el) => {
const walk = el.closest(".bytewalk");
if (!walk) return [];
const i = el.dataset.i;
return [walk.querySelector(`.bw-dump span[data-i="${i}"]`), walk.querySelector(`.bw-row[data-i="${i}"]`)].filter(Boolean);
};
const target = (e) => e.target.closest && e.target.closest(".bw-row, .bw-dump span[data-i]");
const on = (e) => {
const el = target(e);
if (!el) return;
const both = pair(el);
both.forEach((x) => x.classList.add("on"));
if (el.matches(".bw-dump span")) {
// Scroll the box itself, and instantly: scrollIntoView would also
// move the slide, and Chrome drops smooth scrolls inside the
// transformed slide that reveal positions.
const row = both.find((x) => x.classList.contains("bw-row"));
const box = row && row.closest(".bw-rows");
if (box) box.scrollTop = row.offsetTop - box.clientHeight / 2 + row.offsetHeight / 2;
}
};
const off = (e) => {
const el = target(e);
if (el) pair(el).forEach((x) => x.classList.remove("on"));
};
document.addEventListener("mouseover", on);
document.addEventListener("focusin", on);
document.addEventListener("mouseout", off);
document.addEventListener("focusout", off);
})();
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#11131f"/>
<g fill="none" stroke="#5eb1ff" stroke-width="3">
<line x1="9" y1="16" x2="23" y2="16"/>
<circle cx="7" cy="16" r="4" fill="#5eb1ff"/>
<circle cx="25" cy="16" r="4" fill="#11131f"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 326 B

+230
View File
@@ -0,0 +1,230 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hands-on BGP</title>
<!--
Deck structure and look follow Matt Godbolt's newer talks (e.g.
github.com/mattgodbolt/what-happens-beneath-accu-2026): reveal.js with
no stock theme, a custom dark theme (theme.css), monokai code blocks,
16:9, fade transitions, and one Markdown file per section.
reveal.js is loaded from ./reveal.js/dist if present (run
./vendor-reveal.sh once; works offline), otherwise from the CDN. Its
stylesheets are inserted BEFORE ours by the loader below, so theme.css
and style.css always have the last word.
-->
<link rel="stylesheet" href="theme.css?v=3" id="deck-style" />
<link rel="stylesheet" href="style.css?v=18" />
<link rel="icon" href="images/favicon.svg" type="image/svg+xml" />
<link rel="shortcut icon" href="images/favicon.svg" />
</head>
<body>
<div class="reveal">
<div class="slides">
<!-- Title slide. For a full-bleed image instead, empty it and add
data-background="images/title.png" like Matt does. -->
<section class="title-slide">
<h1>Hands-on BGP</h1>
<h3>Insights into real world operations</h3>
<p class="byline">
Rayhaan Jaufeerally (rayhaan@rayhaan.ch) &middot;
Network Security &middot; ETH Z&uuml;rich, 2026
</p>
</section>
<!--
One Markdown file per section. Inside a file:
----- next horizontal slide
--- next vertical slide (goes down, for detail you may skip)
Notes: everything after this is speaker notes (press S)
-->
<section
data-markdown="sections/00-intro.md"
data-separator="^-----"
data-separator-vertical="^---"
data-separator-notes="^Notes:"
></section>
<section
data-markdown="sections/01-resources.md"
data-separator="^-----"
data-separator-vertical="^---"
data-separator-notes="^Notes:"
></section>
<section
data-markdown="sections/03-bgp-update.md"
data-separator="^-----"
data-separator-vertical="^---"
data-separator-notes="^Notes:"
></section>
<section
data-markdown="sections/04-transit-and-ix.md"
data-separator="^-----"
data-separator-vertical="^---"
data-separator-notes="^Notes:"
></section>
<section
data-markdown="sections/05-lab.md"
data-separator="^-----"
data-separator-vertical="^---"
data-separator-notes="^Notes:"
></section>
</div>
</div>
<!-- Sits where Matt's decks carry the employer logo. -->
<div class="wordmark"><b>AS210036</b> &middot; netsec 2026</div>
<div id="deck-help" hidden>
<h2>The slides did not load</h2>
<p id="deck-help-why"></p>
<pre>python3 -m http.server 8000 --directory slides</pre>
<p>
then open <code>http://localhost:8000</code>. Details in
<code>slides/README.md</code>.
</p>
</div>
<!-- A classic script: it still runs when the browser refuses to run the
module below, which is exactly the case it has to explain. -->
<script>
(function () {
function help(why) {
document.getElementById("deck-help-why").textContent = why;
document.getElementById("deck-help").hidden = false;
}
if (location.protocol === "file:") {
help(
"This page was opened straight from disk. Browsers block a page opened that way from loading reveal.js and the section files, so serve the folder over HTTP:",
);
return;
}
window.addEventListener("deck-failed", function (e) {
help(
e.detail +
" Serve the folder over HTTP, and run slides/vendor-reveal.sh if there is no network:",
);
});
setTimeout(function () {
if (!window.deck)
help(
"reveal.js has not started after 10 seconds. Without a network it needs the local copy from slides/vendor-reveal.sh. Serve the folder like this:",
);
}, 10000);
})();
</script>
<script src="dns-lookup.js?v=7"></script>
<script src="prefix-explorer.js?v=4"></script>
<script type="module">
const VERSION = "6.0.2";
const LOCAL = "./reveal.js/dist";
const CDN = `https://cdn.jsdelivr.net/npm/reveal.js@${VERSION}/dist`;
// Prefer the vendored copy so the deck works without a network.
async function pickBase() {
try {
const r = await fetch(`${LOCAL}/reveal.css`, {
method: "HEAD",
});
if (r.ok) return LOCAL;
} catch (_) {}
return CDN;
}
const base = await pickBase();
const fail = (what) => (e) => {
window.dispatchEvent(
new CustomEvent("deck-failed", {
detail: `${what} (${e && e.message ? e.message : e}).`,
}),
);
throw e;
};
const deckStyle = document.getElementById("deck-style");
await Promise.all(
["reset.css", "reveal.css", "plugin/highlight/monokai.css"].map(
(href) =>
new Promise((resolve) => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `${base}/${href}`;
link.onload = link.onerror = resolve;
deckStyle.before(link);
}),
),
);
const [
{ default: Reveal },
{ default: Markdown },
{ default: Highlight },
{ default: Notes },
] = await Promise.all(
[
"reveal.mjs",
"plugin/markdown.mjs",
"plugin/highlight.mjs",
"plugin/notes.mjs",
].map((m) => import(/* @vite-ignore */ `${base}/${m}`)),
).catch(fail(`Could not load reveal.js from ${base}`));
// BIRD configuration, so router config on slides is highlighted. Same
// trick Matt uses for the assembly dialects in his talks.
function bird() {
return {
name: "bird",
keywords: {
keyword:
"protocol template filter function define table if then else case return accept reject " +
"import export where none all limit action block neighbor local as direct multihop passive " +
"description ipv4 ipv6 bgp kernel device static pipe include router id",
built_in:
"net proto source dest bgp_path bgp_community bgp_large_community bgp_ext_community " +
"bgp_med bgp_local_pref bgp_next_hop",
literal: "true false on off RTD_UNREACHABLE RTS_BGP",
},
contains: [
{ className: "comment", begin: "#", end: "$" },
{ className: "string", begin: '"', end: '"' },
{
className: "number",
begin: "\\b[0-9a-fA-F:]*::?[0-9a-fA-F:]*(/[0-9]+)?(\\{[0-9]+,[0-9]+\\})?\\b",
},
{
className: "number",
begin: "\\b[0-9]+(\\.[0-9]+){3}(/[0-9]+)?\\b",
},
{ className: "number", begin: "\\b[0-9]+\\b" },
],
};
}
// ?accent=green|amber|magenta previews another accent; blue is the default.
const accent = new URLSearchParams(location.search).get("accent");
if (accent) document.documentElement.dataset.accent = accent;
// 16:9. Nudge scaleFactor to fit the projector, as Matt does.
const scaleFactor = 0.65;
const deck = new Reveal({
hash: true,
transition: "fade",
slideNumber: false,
margin: 0.15,
width: 1920 * scaleFactor,
height: 1080 * scaleFactor,
markdown: { smartypants: true },
highlight: {
beforeHighlight: (hljs) =>
hljs.registerLanguage("bird", bird),
},
plugins: [Markdown, Highlight, Notes],
});
await deck.initialize().catch(fail("reveal.js failed to start"));
window.deck = deck; // handy in the console: deck.slide(3), deck.getTotalSlides()
// The slides exist now: scripts that fill live boxes wait for this.
window.dispatchEvent(new Event("deck-ready"));
</script>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
// A live prefix calculator on a slide. Any <form class="prefix-explorer">
// with <input name="prefix"> and <input type="range" name="len"> gets its
// .px-bits, .px-range and .px-facts filled in as the reader types.
(function () {
const ALL = (1n << 128n) - 1n;
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
// "2001:db8::1" -> BigInt, or null.
function parse(text) {
const s = text.trim().toLowerCase();
if (!/^[0-9a-f:]+$/.test(s) || s.split("::").length > 2) return null;
const [left, right = ""] = s.split("::");
const l = left ? left.split(":") : [], r = right ? right.split(":") : [];
if (s.includes("::") ? l.length + r.length > 7 : l.length !== 8) return null;
const groups = [...l, ...Array(8 - l.length - r.length).fill("0"), ...r];
let v = 0n;
for (const g of groups) {
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
v = (v << 16n) | BigInt(parseInt(g, 16));
}
return v;
}
function groupsOf(v) {
const g = [];
for (let i = 7; i >= 0; i--) g.push(Number((v >> BigInt(i * 16)) & 0xffffn));
return g;
}
// Shortest form, RFC 5952 style.
function format(v) {
const g = groupsOf(v).map((x) => x.toString(16));
let best = -1, bestLen = 0;
for (let i = 0; i < 8; i++) {
let n = 0;
while (i + n < 8 && g[i + n] === "0") n++;
if (n > bestLen && n > 1) { best = i; bestLen = n; }
}
if (best < 0) return g.join(":");
return g.slice(0, best).join(":") + "::" + g.slice(best + bestLen).join(":");
}
const sup = (k) => String(k).replace(/\d/g, (d) => "⁰¹²³⁴⁵⁶⁷⁸⁹"[d]);
// 2^k as a number people can read: exact up to 2^50, else a power.
const pow2 = (k) => (k <= 50 ? Number(1n << BigInt(k)).toLocaleString("en") : `2${sup(k)}`);
function render(form) {
const input = form.elements.prefix, slider = form.elements.len;
const bits = form.querySelector(".px-bits"), range = form.querySelector(".px-range"), facts = form.querySelector(".px-facts");
let [addrText, lenText] = input.value.split("/");
let len = lenText === undefined ? Number(slider.value) : parseInt(lenText, 10);
const addr = parse(addrText || "");
if (addr === null || !(len >= 0 && len <= 128)) {
bits.innerHTML = '<span class="px-bad">not an IPv6 prefix: write address/length, e.g. 2001:db8::/32</span>';
range.innerHTML = ""; facts.textContent = "";
return;
}
slider.value = len;
const host = 128 - len;
const first = addr & ((ALL << BigInt(host)) & ALL);
const last = first | ((1n << BigInt(host)) - 1n);
// The 32 hex digits with the fixed part in the accent colour; a nibble
// that straddles the boundary is shown in binary.
const hex = groupsOf(first).map((x) => x.toString(16).padStart(4, "0")).join("");
const fullNibbles = Math.floor(len / 4), partBits = len % 4;
const withColons = (s, offset) => s.replace(/./g, (c, i) => ((offset + i) % 4 === 3 && offset + i < 31 ? c + ":" : c));
let html = `<span class="fixed">${withColons(hex.slice(0, fullNibbles), 0)}</span>`;
let rest = fullNibbles;
if (partBits) {
const nib = parseInt(hex[fullNibbles], 16).toString(2).padStart(4, "0");
// One hex digit, four bits, with the boundary inside it.
html += `<span class="part" title="this hex digit straddles the boundary, shown as its four bits">${nib.slice(0, partBits)}<i>${"x".repeat(4 - partBits)}</i></span>`;
rest++;
}
html += `<span class="free">${withColons("x".repeat(32 - rest), rest)}</span>`;
bits.innerHTML = html;
// Every shorter prefix contains this one; show three of them, at byte
// boundaries, with the range's position and share marked in each, so
// that no single one looks like "the" parent. Then the range itself.
const levels = [];
for (let pl = len === 0 ? -1 : 8 * Math.floor((len - 1) / 8); pl >= 0 && levels.length < 3; pl -= 8) {
const slots = 1n << BigInt(len - pl);
const index = (first >> BigInt(host)) & (slots - 1n);
const enclosing = first & ((ALL << BigInt(128 - pl)) & ALL);
// Position and width as fractions; the slice is kept visible.
const left = Number((index * 100000n) / slots) / 1000, width = Math.max(100 / Number(slots), 0.3);
levels.push(`<div class="px-level"><span class="px-level-name">${esc(format(enclosing))}/${pl}</span>
<span class="px-track"><i style="left:${left}%;width:${width}%"></i></span>
<span class="px-level-count">1 of ${pow2(len - pl)}</span></div>`);
}
range.innerHTML = `
<div class="px-levels">${levels.length ? `<div class="px-levels-title">every shorter prefix contains it; three of them:</div>${levels.join("")}` : '<div class="px-levels-title">nothing is shorter than /0: this is every address there is</div>'}</div>
<div class="px-bar"><span class="px-end px-first">${esc(format(first))}</span><span class="px-mid">${pow2(host)} addresses</span><span class="px-end px-last">${esc(format(last))}</span></div>`;
const parts = [`The first <b>${len}</b> bits are fixed; the other ${host} are for use within the network.`];
if (len < 64) parts.push(`That is <b>${pow2(64 - len)}</b> /64s, one per LAN.`);
else if (len === 64) parts.push(`Exactly one LAN.`);
else parts.push(`Smaller than a LAN: ${host === 0 ? "a single host" : `part of one /64`}.`);
if (len < 48) parts.push(`Or ${pow2(48 - len)} /48s, one per site.`);
facts.innerHTML = parts.join(" ");
}
document.addEventListener("input", (e) => {
const form = e.target.closest && e.target.closest("form.prefix-explorer");
if (!form) return;
if (e.target.name === "len") {
// The slider rewrites the length in the text field.
const addr = form.elements.prefix.value.split("/")[0];
form.elements.prefix.value = `${addr}/${e.target.value}`;
}
render(form);
if (window.deck) window.deck.layout();
});
document.addEventListener("submit", (e) => {
if (e.target.closest && e.target.closest("form.prefix-explorer")) e.preventDefault();
});
// Fill in the initial values once the slides exist.
const init = () => document.querySelectorAll("form.prefix-explorer").forEach(render);
window.addEventListener("deck-ready", init);
if (document.readyState === "complete") setTimeout(init, 500);
})();
+15
View File
@@ -0,0 +1,15 @@
## About the speaker
**Rayhaan Jaufeerally**
- Software engineer at a hyperscaler, working on security and reliability.
- Studied at ETH from 2016 to 2019, and took the network security course.
- Has been running **AS210036** since 2018, for personal use and research
<p class="note">Today we'll get to use some connectivity through this network.</p>
Notes:
- Working at a large company with some offices near the main train station
- Former ETH masters student and took the network security course
- After my degree I signed up to be a LIR at RIPE, and got my own Autonomous System and started peering, for the fun of running my own network.
+170
View File
@@ -0,0 +1,170 @@
# Internet Numbering Resources
Notes:
Before we look at what's going on in the internet, let's refresh on how the Internet is even addressed.
---
## Packets are sent to IP addresses
<form class="dns-lookup" autocomplete="off">
<input name="domain" value="ethz.ch" spellcheck="false" aria-label="domain name"><button type="submit">lookup AAAA</button>
<div class="dns-result"><span class="dim">Ask a resolver which IPv6 address this name stands for.</span></div>
</form>
IP packets are sent to IP addresses, not domain names. Every time you use a domain name, it is resolved into an IP address via DNS.
<!-- .element: class="note" -->
Notes:
Live: type a few names the students use. ethz.ch has an AAAA record;
github.com has none (only A), which shows that the two address families
are separate. The answer comes from Google Public DNS over HTTPS
(dns.google), so it needs the lecture-hall network.
---
## Who does the address belong to?
<form class="ripe-lookup" autocomplete="off">
<input name="query" value="2001:67c:10ec:254::216" spellcheck="false" aria-label="address, prefix or AS number"><button type="submit">whois</button>
<div class="ripe-result"><div class="ripe-msg">Paste the address from the last slide.</div></div>
</form>
The **inet6num** says who was assigned the block; the **route6** says which AS is allowed to originate it.
<!-- .element: class="note" -->
Notes:
Paste the AAAA answer from the previous slide. ETH's /48 is ASSIGNED PI
(provider independent, sponsored by SWITCH) and its route6 names AS559,
SWITCH. Try 2a0d:d741:1::1 as well: the lab prefix, origin AS210036. The
box takes AS numbers too (AS559). Same source as whois -h whois.ripe.net,
over the database's REST API.
---
## An IPv6 address is 128 bits
```text
2001:0db8:0001:0000:0000:0000:0000:0001 eight groups of 16 bits, in hex
2001:db8:1:0:0:0:0:1 drop leading zeros in a group
2001:db8:1::1 one run of zero groups becomes ::
```
`::` may appear once, or the address would be ambiguous. `2001:db8::/32` is reserved for documentation, so it never appears on the real Internet.
<!-- .element: class="note" -->
Notes:
Ask: why only once? Because 2001::1::1 could put the zeros in several places.
2001:db8::/32 is RFC 3849: set aside for examples, never routed.
---
## A prefix is a range of addresses
In BGP, the control plane for the internet, it's not viable to talk about single addresses, so we use prefix ranges.
<form class="prefix-explorer" autocomplete="off">
<input name="prefix" value="2001:db8:1::/48" spellcheck="false" aria-label="IPv6 prefix"><input type="range" name="len" min="0" max="128" value="48" aria-label="prefix length">
<p class="bits px-bits"></p>
<div class="px-range"></div>
<p class="note px-facts"></p>
</form>
Notes:
Live. Drag the slider: the fixed part grows and the range shrinks by half per
bit. Try /47 or /50 to see a hex digit split in two. The three rows above the
bar are three of the shorter prefixes that contain it, with its position and
share in each: there is no such thing as "the" parent prefix, only whatever
someone chose to allocate from.
2^80 addresses in one /48, about 1.2 million billion billion. The /n is a
count of leading bits, nothing more.
---
## The global IPv6 address space
<svg class="diagram" viewBox="0 0 1000 470" role="img" aria-label="Nested boxes: all of IPv6, inside it global unicast 2000::/3, inside that the documentation prefix 2001:db8::/32, inside that one /64 LAN with its hosts">
<g class="nest l0"><rect x="8" y="8" width="984" height="454"/></g>
<text x="28" y="42" class="left accent nest-label">::/0</text><text x="112" y="42" class="left nest-desc">every IPv6 address there is: 2¹²⁸ of them</text>
<g class="nest l1"><rect x="80" y="70" width="880" height="366"/></g>
<text x="100" y="104" class="left accent nest-label">2000::/3</text><text x="230" y="104" class="left nest-desc">global unicast: the eighth of the space that is routed on the Internet</text>
<g class="nest l2"><rect x="160" y="134" width="760" height="278"/></g>
<text x="180" y="168" class="left accent nest-label">2001:db8::/32</text><text x="376" y="168" class="left nest-desc">one provider's allocation (this one is reserved for documentation)</text>
<g class="nest l3"><rect x="260" y="200" width="580" height="188"/></g>
<text x="280" y="234" class="left accent nest-label">2001:db8:1:2::/64</text><text x="530" y="234" class="left nest-desc">one LAN: 2⁶⁴ addresses for its hosts</text>
<circle class="host" cx="330" cy="310" r="12"/><text x="330" y="352" class="host-addr">::1</text>
<circle class="host" cx="430" cy="310" r="12"/><text x="430" y="352" class="host-addr">::2</text>
<circle class="host" cx="530" cy="310" r="12"/><text x="530" y="352" class="host-addr">::3</text>
<text x="630" y="318" class="host-addr">…</text>
<circle class="host" cx="740" cy="310" r="12"/><text x="740" y="352" class="host-addr">::ffff:ffff:ffff:ffff</text>
<text x="976" y="452" class="small" style="text-anchor:end">not to scale</text>
</svg>
Notes:
Not to scale: a /64 is 2^-64 of the space. Only the first ring matters for
routing; the others are how the space is handed down, which is next.
---
## Who hands out the addresses
<svg class="diagram" viewBox="0 0 1000 470" role="img" aria-label="IANA at the top hands blocks to five regional registries; RIPE NCC hands smaller blocks to local registries such as SWITCH and an ISP, which assign to end users such as ETH Zurich and their customers">
<g class="hot"><rect x="400" y="14" width="200" height="56" rx="8"/></g><text x="500" y="48">IANA</text>
<text x="30" y="48" class="left small">the root: one pool for the whole world</text>
<line class="link" x1="500" y1="70" x2="96" y2="130"/><line class="link" x1="500" y1="70" x2="298" y2="130"/><line class="link" x1="500" y1="70" x2="500" y2="130"/><line class="link" x1="500" y1="70" x2="702" y2="130"/><line class="link" x1="500" y1="70" x2="904" y2="130"/>
<text x="30" y="108" class="left small">a /12 to each region</text>
<rect x="16" y="130" width="160" height="56" rx="8"/><text x="96" y="164">AFRINIC</text>
<rect x="218" y="130" width="160" height="56" rx="8"/><text x="298" y="164">APNIC</text>
<rect x="420" y="130" width="160" height="56" rx="8"/><text x="500" y="164">ARIN</text>
<rect x="622" y="130" width="160" height="56" rx="8"/><text x="702" y="164">LACNIC</text>
<g class="hot"><rect x="824" y="130" width="160" height="56" rx="8"/></g><text x="904" y="164" class="accent">RIPE NCC</text>
<text x="30" y="222" class="left small">RIRs: five regional registries, one per part of the world</text>
<line class="link" x1="904" y1="186" x2="690" y2="256"/><line class="link" x1="904" y1="186" x2="904" y2="256"/>
<text x="700" y="238" class="left small">/29 and up to a member</text>
<rect x="600" y="256" width="180" height="56" rx="8"/><text x="690" y="290">SWITCH</text>
<rect x="814" y="256" width="180" height="56" rx="8"/><text x="904" y="290">an ISP</text>
<text x="30" y="298" class="left small">LIRs: members of an RIR, usually providers</text>
<line class="link" x1="690" y1="312" x2="690" y2="382"/><line class="link" x1="904" y1="312" x2="904" y2="382"/>
<text x="700" y="360" class="left small">a /48 to a site</text>
<rect x="600" y="382" width="180" height="56" rx="8"/><text x="690" y="416">ETH Zürich</text>
<rect x="814" y="382" width="180" height="56" rx="8"/><text x="904" y="416">its customers</text>
<text x="30" y="424" class="left small">end users: companies, universities, you</text>
</svg>
Notes:
- The Internet Assigned Numbers Authority comes up with allocations of IP
addresses from the global space
- Blocks are assigned to Regional Internet Registries (RIRs), which allocate
them to Local Internet Registries (LIRs), who assign to end users
- The RIPE lookup two slides back showed exactly this chain: ETH's /48 is
ASSIGNED PI, sponsored by SWITCH (the LIR), registered at RIPE NCC (the
RIR). Nobody owns addresses; they are registered to you.
---
## AS numbers
- Used to identify networks under a single oeprator, and required for exchanging BGP traffic
- Managed by IANA and the RIRs
- 16 bits at first: `1` to `65535`
- 32 bits since RFC 6793: up to `4294967295`
- In order to have a smooth transition, `AS23456` is used as a placeholder when the AS number is > 16 bits
- 32 bit AS numbers are then exchanged using a BGP capability message to be backwards compatible
- Private use: `64512–65534` and `4200000000–4294967294`
AS numbers come down the same chain: IANA to the RIRs, an RIR to whoever runs a network. The lab daemon is `AS4200000001`, a private one, stripped before the Internet sees your route.
<!-- .element: class="note" -->
Notes:
Nobody "owns" numbers or addresses; they are registered to you. RFC 6996
reserves the private ranges. The upstream router removes the private AS from
the path, so the origin everybody else sees is AS210036.
---
## Handout Part 1
+329
View File
@@ -0,0 +1,329 @@
# BGP in detail
Notes:
We're going to look at some implementation details of the protocol
---
##
- Runs over TCP, port `179`, and is exchanged typically between peering routers
- Each side tells the other its best route per prefix
- Only _changes_ are sent: no periodic refresh
- If the session drops, after a timeout the routes learned over that session are dropped
Notes:
BGP is a very old protocol, and a lot of it's design predates the internet being something with adversarial participants.
Security and extensions have had to be bolted on, in perhaps strange ways.
---
## The four message types
| Type | Name | Says |
| ---- | ------------ | ------------------------------------------------------- |
| 1 | OPEN | AS number, router ID, capabilities |
| 2 | **UPDATE** | these prefixes are reachable, those no longer are |
| 3 | NOTIFICATION | errors and informational messages: e.g. rebooting |
| 4 | KEEPALIVE | Heartbeat to keep the session up when there's no update |
OPEN, OPEN, KEEPALIVE, KEEPALIVE, and then a stream of UPDATEs.
<!-- .element: class="note" -->
Notes:
There is actually a fifth type, ROUTE-REFRESH: "send me everything again, my policy changed".
---
## OPEN: a handshake between peers.
<svg class="diagram" viewBox="0 0 1000 340" role="img" aria-label="An OPEN message: the common header, then version, AS number, hold time, BGP identifier, and optional parameters that carry the capabilities">
<text x="20" y="22" class="left small pkt-title">header, the same on every message: 19 bytes</text>
<g class="pkt hh"><rect x="20" y="30" width="470" height="64" rx="6"/></g>
<text x="255" y="59" class="pkt-name">marker</text>
<text x="255" y="81" class="pkt-size">16 bytes, all ff</text>
<g class="pkt hh"><rect x="494" y="30" width="240" height="64" rx="6"/></g>
<text x="614" y="59" class="pkt-name">length</text>
<text x="614" y="81" class="pkt-size">2 bytes</text>
<g class="pkt hh"><rect x="738" y="30" width="242" height="64" rx="6"/></g>
<text x="859" y="59" class="pkt-name">type</text>
<text x="859" y="81" class="pkt-size">1 byte</text>
<text x="20" y="132" class="left small pkt-title">OPEN: the fixed part</text>
<g class="pkt hl"><rect x="20" y="140" width="150" height="64" rx="6"/></g>
<text x="95" y="169" class="pkt-name">version</text>
<text x="95" y="191" class="pkt-size">1 byte: 4</text>
<g class="pkt hl"><rect x="174" y="140" width="200" height="64" rx="6"/></g>
<text x="274" y="169" class="pkt-name">my AS</text>
<text x="274" y="191" class="pkt-size">2 bytes, or 23456</text>
<g class="pkt hl"><rect x="378" y="140" width="200" height="64" rx="6"/></g>
<text x="478" y="169" class="pkt-name">hold time</text>
<text x="478" y="191" class="pkt-size">2 bytes, seconds</text>
<g class="pkt hl"><rect x="582" y="140" width="240" height="64" rx="6"/></g>
<text x="702" y="169" class="pkt-name">BGP identifier</text>
<text x="702" y="191" class="pkt-size">4 bytes, the router ID</text>
<g class="pkt hl"><rect x="826" y="140" width="154" height="64" rx="6"/></g>
<text x="903" y="169" class="pkt-name">opt. length</text>
<text x="903" y="191" class="pkt-size">1 byte</text>
<path class="pkt-expand" d="M 826 204 L 20 250"/>
<path class="pkt-expand" d="M 980 204 L 980 250"/>
<text x="20" y="250" class="left small pkt-title">optional parameters: today always one, of type 2 = capabilities, each a (code, length, value)</text>
<g class="pkt ha"><rect x="20" y="258" width="120" height="64" rx="6"/></g>
<text x="80" y="287" class="pkt-name">type 2</text>
<text x="80" y="309" class="pkt-size">capabilities</text>
<g class="pkt ha"><rect x="144" y="258" width="110" height="64" rx="6"/></g>
<text x="199" y="287" class="pkt-name">length</text>
<text x="199" y="309" class="pkt-size">1 byte</text>
<g class="pkt ha2"><rect x="258" y="258" width="200" height="64" rx="6"/></g>
<text x="358" y="287" class="pkt-name">IPv6 unicast</text>
<text x="358" y="309" class="pkt-size">code 1: AFI 2, SAFI 1</text>
<g class="pkt ha2"><rect x="462" y="258" width="190" height="64" rx="6"/></g>
<text x="557" y="287" class="pkt-name">4-byte AS</text>
<text x="557" y="309" class="pkt-size">code 65: 210036</text>
<g class="pkt ha2"><rect x="656" y="258" width="170" height="64" rx="6"/></g>
<text x="741" y="287" class="pkt-name">route refresh</text>
<text x="741" y="309" class="pkt-size">code 2, empty</text>
<g class="pkt ha2"><rect x="830" y="258" width="150" height="64" rx="6"/></g>
<text x="905" y="287" class="pkt-name">…</text>
<text x="905" y="309" class="pkt-size">more capabilities</text>
</svg>
Notes:
Sent once, by each side, as soon as the TCP connection is up. The two sides
must agree: the AS must be the one configured for the neighbour, the hold
time becomes the smaller of the two, and only capabilities both announced are
used. A 4-byte AS does not fit in "my AS", so that field carries 23456 and the
real number rides in a capability, which is how our AS210036 arrives.
---
## BGP Open message
<div class="bytewalk">
<pre class="bytes bw-dump"><span class="hh" data-i="0">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh" data-i="1">00 37</span> <span class="hh" data-i="2">01</span> <span class="hl" data-i="3">04</span> <span class="hl" data-i="4">5b a0</span> <span class="hl" data-i="5">00 f0</span> <span class="hl" data-i="6">0a ff 00 01</span> <span class="hl" data-i="7">1a</span> <span class="ha" data-i="8">02</span> <span class="ha" data-i="9">18</span> <span class="ha2" data-i="10">01</span> <span class="ha2" data-i="11">04</span> <span class="ha2" data-i="12">00 02</span> <span class="ha2" data-i="13">00</span> <span class="ha2" data-i="14">01</span> <span class="ha2" data-i="15">02</span> <span class="ha2" data-i="16">00</span> <span class="ha2" data-i="17">06</span> <span class="ha2" data-i="18">00</span> <span class="ha2" data-i="19">40</span> <span class="ha2" data-i="20">02</span> <span class="ha2" data-i="21">00 00</span> <span class="ha2" data-i="22">41</span> <span class="ha2" data-i="23">04</span> <span class="ha2" data-i="24">00 03 34 74</span> <span class="ha2" data-i="25">46</span> <span class="ha2" data-i="26">00</span> <span class="ha2" data-i="27">47</span> <span class="ha2" data-i="28">00</span></pre>
<div class="bw-rows">
<div class="bw-group">BGP header: 19 bytes, on every message</div>
<div class="bw-row" data-i="0" tabindex="0"><code class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</code><b>marker</b><span>sixteen bytes of ff.</span></div>
<div class="bw-row" data-i="1" tabindex="0"><code class="hh">00 37</code><b>length</b><span>0x0037 = 55 bytes, header included. At most 4096, or 65535 with the extended-message capability</span></div>
<div class="bw-row" data-i="2" tabindex="0"><code class="hh">01</code><b>type</b><span>1 = OPEN (2 UPDATE, 3 NOTIFICATION, 4 KEEPALIVE, 5 ROUTE-REFRESH)</span></div>
<div class="bw-group">OPEN: the fixed part, 10 bytes</div>
<div class="bw-row" data-i="3" tabindex="0"><code class="hl">04</code><b>version</b><span>BGP version 4</span></div>
<div class="bw-row" data-i="4" tabindex="0"><code class="hl">5b a0</code><b>my AS</b><span>0x5ba0 = 23456, AS_TRANS: the ASN is longer than 16 bits so is encoded in a capability instead</span></div>
<div class="bw-row" data-i="5" tabindex="0"><code class="hl">00 f0</code><b>hold time</b><span>0x00f0 = 240 seconds. both peers use the minimum announced; a KEEPALIVE typically is sent every 1/3rd of this duration</span></div>
<div class="bw-row" data-i="6" tabindex="0"><code class="hl">0a ff 00 01</code><b>BGP identifier</b><span>0a ff 00 01 = 10.255.0.1, the router ID. Looks like an IPv4 address, just needs to be a unique identifier.</span></div>
<div class="bw-row" data-i="7" tabindex="0"><code class="hl">1a</code><b>optional parameters length</b><span>0x1a = 26 bytes follow</span></div>
<div class="bw-group">Optional parameter: 2 + 24 bytes</div>
<div class="bw-row" data-i="8" tabindex="0"><code class="ha">02</code><b>parameter type</b><span>2 = capabilities</span></div>
<div class="bw-row" data-i="9" tabindex="0"><code class="ha">18</code><b>parameter length</b><span>0x18 = 24 bytes of capabilities follow, each as type, length, value</span></div>
<div class="bw-group">Capability 1: multiprotocol (RFC 4760)</div>
<div class="bw-row" data-i="10" tabindex="0"><code class="ha2">01</code><b>code</b><span>1 = multiprotocol extensions</span></div>
<div class="bw-row" data-i="11" tabindex="0"><code class="ha2">04</code><b>length</b><span>4</span></div>
<div class="bw-row" data-i="12" tabindex="0"><code class="ha2">00 02</code><b>AFI</b><span>0x0002 = IPv6. Address Family Identifier</span></div>
<div class="bw-row" data-i="13" tabindex="0"><code class="ha2">00</code><b>reserved</b><span>0</span></div>
<div class="bw-row" data-i="14" tabindex="0"><code class="ha2">01</code><b>SAFI</b><span>1 = unicast. Subsequent Address Family Identifier.</span></div>
<div class="bw-group">Capability 2: route refresh (RFC 2918)</div>
<div class="bw-row" data-i="15" tabindex="0"><code class="ha2">02</code><b>code</b><span>2 = route refresh: “you may ask me to resend everything”</span></div>
<div class="bw-row" data-i="16" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
<div class="bw-group">Capability 6: extended message (RFC 8654)</div>
<div class="bw-row" data-i="17" tabindex="0"><code class="ha2">06</code><b>code</b><span>6 = messages up to 65535 bytes are supported</span></div>
<div class="bw-row" data-i="18" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
<div class="bw-group">Capability 64: graceful restart (RFC 4724)</div>
<div class="bw-row" data-i="19" tabindex="0"><code class="ha2">40</code><b>code</b><span>0x40 = 64, graceful restart</span></div>
<div class="bw-row" data-i="20" tabindex="0"><code class="ha2">02</code><b>length</b><span>2</span></div>
<div class="bw-row" data-i="21" tabindex="0"><code class="ha2">00 00</code><b>flags and restart time</b><span>0x0000: not restarting, restart time 0, and no address families listed.</span></div>
<div class="bw-group">Capability 65: four-octet AS (RFC 6793)</div>
<div class="bw-row" data-i="22" tabindex="0"><code class="ha2">41</code><b>code</b><span>0x41 = 65, four-octet AS number</span></div>
<div class="bw-row" data-i="23" tabindex="0"><code class="ha2">04</code><b>length</b><span>4</span></div>
<div class="bw-row" data-i="24" tabindex="0"><code class="ha2">00 03 34 74</code><b>AS number</b><span>00 03 34 74 = 210036: the real AS. Included here since it doesn't fit in the header</span></div>
<div class="bw-group">Capability 70: enhanced route refresh (RFC 7313)</div>
<div class="bw-row" data-i="25" tabindex="0"><code class="ha2">46</code><b>code</b><span>0x46 = 70</span></div>
<div class="bw-row" data-i="26" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
<div class="bw-group">Capability 71: long-lived graceful restart</div>
<div class="bw-row" data-i="27" tabindex="0"><code class="ha2">47</code><b>code</b><span>0x47 = 71</span></div>
<div class="bw-row" data-i="28" tabindex="0"><code class="ha2">00</code><b>length</b><span>0, no address families listed</span></div>
</div>
</div>
Notes:
What BIRD on our upstream router sends the lab daemon, byte for byte. Hold
time 240 is BIRD's default; keepalives go every third of it.
---
## Update message format
<svg class="diagram" viewBox="0 0 1000 460" role="img" aria-label="An UPDATE message: the common header, then withdrawn routes, path attributes and NLRI, with one attribute and one prefix expanded">
<text x="20" y="22" class="left small pkt-title">header: 19 bytes</text>
<g class="pkt hh"><rect x="20" y="30" width="470" height="64" rx="6"/></g>
<text x="255" y="59" class="pkt-name">marker</text>
<text x="255" y="81" class="pkt-size">16 bytes, all ff</text>
<g class="pkt hh"><rect x="494" y="30" width="240" height="64" rx="6"/></g>
<text x="614" y="59" class="pkt-name">length</text>
<text x="614" y="81" class="pkt-size">2 bytes</text>
<g class="pkt hh"><rect x="738" y="30" width="242" height="64" rx="6"/></g>
<text x="859" y="59" class="pkt-name">type</text>
<text x="859" y="81" class="pkt-size">1 byte</text>
<text x="20" y="132" class="left small pkt-title">UPDATE body: two lengths, three variable parts</text>
<g class="pkt hl"><rect x="20" y="140" width="170" height="64" rx="6"/></g>
<text x="105" y="169" class="pkt-name">withdrawn length</text>
<text x="105" y="191" class="pkt-size">2 bytes</text>
<g class="pkt hn"><rect x="194" y="140" width="200" height="64" rx="6"/></g>
<text x="294" y="169" class="pkt-name">withdrawn routes</text>
<text x="294" y="191" class="pkt-size">IPv4 prefixes gone</text>
<g class="pkt hl"><rect x="398" y="140" width="170" height="64" rx="6"/></g>
<text x="483" y="169" class="pkt-name">attributes length</text>
<text x="483" y="191" class="pkt-size">2 bytes</text>
<g class="pkt ha"><rect x="572" y="140" width="240" height="64" rx="6"/></g>
<text x="692" y="169" class="pkt-name">path attributes</text>
<text x="692" y="191" class="pkt-size">everything about the route</text>
<g class="pkt hn"><rect x="816" y="140" width="172" height="64" rx="6"/></g>
<text x="902" y="169" class="pkt-name">NLRI</text>
<text x="902" y="191" class="pkt-size">IPv4 prefixes, new</text>
<path class="pkt-expand" d="M 572 204 L 20 250"/>
<path class="pkt-expand" d="M 812 204 L 980 250"/>
<text x="20" y="250" class="left small pkt-title">one path attribute; repeated for multiple</text>
<g class="pkt ha"><rect x="20" y="258" width="150" height="64" rx="6"/></g>
<text x="95" y="287" class="pkt-name">flags</text>
<text x="95" y="309" class="pkt-size">1 byte</text>
<g class="pkt ha"><rect x="174" y="258" width="330" height="64" rx="6"/></g>
<text x="339" y="287" class="pkt-name">type</text>
<text x="339" y="309" class="pkt-size">1 byte: ORIGIN = 1, AS_PATH = 2 …</text>
<g class="pkt ha"><rect x="508" y="258" width="180" height="64" rx="6"/></g>
<text x="598" y="287" class="pkt-name">length</text>
<text x="598" y="309" class="pkt-size">1 or 2 bytes</text>
<g class="pkt ha2"><rect x="692" y="258" width="292" height="64" rx="6"/></g>
<text x="838" y="287" class="pkt-name">value</text>
<text x="838" y="309" class="pkt-size">as many bytes as it said</text>
<path class="pkt-expand" d="M 979 204 L 979 376"/>
<text x="20" y="368" class="left small pkt-title">one prefix, in NLRI and in withdrawn routes: a bit count, then only the bytes that count needs</text>
<g class="pkt hn"><rect x="20" y="376" width="300" height="64" rx="6"/></g>
<text x="170" y="405" class="pkt-name">length</text>
<text x="170" y="427" class="pkt-size">1 byte: 24 for a /24</text>
<g class="pkt hn"><rect x="324" y="376" width="656" height="64" rx="6"/></g>
<text x="652" y="405" class="pkt-name">prefix</text>
<text x="652" y="427" class="pkt-size">3 bytes for a /24, 6 for a /48 …</text>
</svg>
Notes:
One UPDATE carries one set of attributes, and every prefix that shares them.
The withdrawn and NLRI fields can only hold IPv4; the next slides show where
IPv6 goes. Lengths everywhere: a receiver can walk the message without
understanding any of it, which is what lets unknown attributes pass through.
---
## An example BGP update
<div class="legend"><span class="hh">header</span><span class="hl">lengths</span><span class="ha">path attributes</span><span class="hn">NLRI</span></div>
<pre class="bytes"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
`2a0d:d740::/48`, as AS210036 announces it to the SwissIX route servers.
<!-- .element: class="note" -->
Notes:
Built with the lab daemon's own encoder and checked by parsing it back, with
the attributes pr01 puts on routes it exports to peers. Press down to walk
through it part by part. Note there is nothing yellow: the NLRI field is
empty. Hold that thought.
---
## Header
<pre class="bytes focus"><span class="hh on">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh on">00 59</span> <span class="hh on">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>ff × 16</td><td>Start of message marker.</td></tr><tr><td>00 59</td><td>length of the whole message: 89 bytes (maximum 4096)</td></tr><tr><td>02</td><td>type 2 = UPDATE</td></tr></table>
---
## Two lengths
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl on">00 00</span> <span class="hl on">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>00 00</td><td>0 bytes of withdrawn routes: nothing is being taken back</td></tr><tr><td>00 42</td><td>66 bytes of path attributes follow</td></tr><tr><td></td><td>89 − 19 − 2 − 2 − 66 = 0 bytes left over for NLRI</td></tr></table>
---
## ORIGIN
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha on">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>40</td><td>flags: well-known, transitive</td></tr><tr><td>01</td><td>type 1 = ORIGIN</td></tr><tr><td>01</td><td>1 byte of value</td></tr><tr><td>00</td><td>IGP: the route was originated inside this AS</td></tr></table>
Every attribute is _flags, type, length, value_.
<!-- .element: class="note" -->
---
## AS_PATH
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2 on">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>40 02 06</td><td>well-known, transitive; type 2; 6 bytes</td></tr><tr><td>02</td><td>an AS_SEQUENCE, order matters</td></tr><tr><td>01</td><td>of 1 AS number</td></tr><tr><td>00 03 34 74</td><td>210036, the origin. Four bytes each (RFC 6793)</td></tr></table>
Every BGP router prepends its own AS number before announcing a route to an external peer, creating a map of which networks the announcement travelled through. This allows selecting the shortest path.
<!-- .element: class="note" -->
Notes:
One entry, because SwissIX is one hop from the origin and the route server
does not add itself. Seen from further away it reads e.g. 6939 210036.
---
## MULTI_EXIT_DISC
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha on">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>80 04 04</td><td>optional, non-transitive; type 4; 4 bytes</td></tr><tr><td>00 00 00 00</td><td>MED 0. Lower is preferred, among routes from the same neighbour AS</td></tr></table>
A hint to a neighbour about which path you prefer traffic to come in on. Non-transitive: it goes one hop and no further.
<!-- .element: class="note" -->
---
## MP_REACH_NLRI: announcing paths for non-IPv4 protocols
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2 on">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>80 0e 1c</td><td>optional, non-transitive; type 14; 28 bytes</td></tr><tr><td>00 02 · 01</td><td>address family IPv6, unicast</td></tr><tr><td>10 · 2001 07f8 0024 … 00c9</td><td>next hop, 16 bytes: 2001:7f8:24::c9, our port on the SwissIX peering LAN</td></tr><tr><td>00</td><td>reserved</td></tr><tr><td>30 · 2a 0d d7 40 00 00</td><td>48 bits of prefix: 2a0d:d740::/48</td></tr></table>
Notes:
BGP-4 was built for IPv4: its NLRI field cannot carry anything else. RFC 4760
bolts other address families on as an attribute, each with its own next hop.
A prefix is sent as a bit count plus only as many bytes as the count needs.
The next hop is an address on the exchange's shared LAN: whoever receives this
route sends packets straight to that address across the switch. Remember ::c9
for the exchange diagram in the next section.
Routers may also append their link-local address here, making it 32 bytes.
---
## LARGE_COMMUNITIES
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha on">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>c0 20 0c</td><td>optional, transitive; type 32; 12 bytes = one community</td></tr><tr><td>00033474 · 0000000a · 00000002</td><td>210036:10:2, a tag our router puts on everything it exports to peers</td></tr></table>
Three 32-bit numbers. They mean whatever the network named in the first one says they mean.
<!-- .element: class="note" -->
---
## The flags byte
| Bit | Set means | Clear means |
| ------ | ------------------------------------------------- | ------------------------------------------------ |
| `0x80` | optional | well-known: every BGP speaker must understand it |
| `0x40` | transitive: pass it on, even if not understood | drop it at the next hop |
| `0x20` | partial: someone on the way did not understand it | |
| `0x10` | length field is 2 bytes | 1 byte |
Path attribute transitivity allows adding instructions for networks far away from direct neighbours.
<!-- .element: class="note" -->
+105
View File
@@ -0,0 +1,105 @@
# Transit and exchanges
Notes:
Section 4. Two very different ways of getting routes, and what an Internet
exchange physically is.
---
## Two ways to reach other networks
<div class="cols">
<div>
### IP transit
- You **pay** a provider
- They give you routes to _everyone_
- They announce you to _everyone_
</div>
<div>
### Peering
- Usually **free**, by mutual benefit
- You exchange _your own_ routes, and your customers'
- Traffic between the locally connected parties only
</div>
</div>
Notes:
Transit is a customer-provider relationship; peering is between equals. A peer
does not carry your traffic to third parties. Most networks have both: transit
as the fallback, peering to keep traffic cheap and close.
---
## An exchange is a big Ethernet switch
<svg class="diagram" viewBox="0 0 1000 400" role="img" aria-label="Members attached to a shared Ethernet peering LAN, with a route server">
<g class="fabric"><rect x="60" y="175" width="880" height="56" rx="10"/></g>
<text x="500" y="200" class="accent">peering LAN: one Ethernet segment, one subnet</text>
<text x="500" y="220" class="small">SwissIX 2001:7f8:24::/64</text>
<g class="hot"><rect x="70" y="40" width="180" height="70" rx="8"/></g>
<text x="160" y="70">AS210036</text><text x="160" y="92" class="small">::c9</text>
<rect x="300" y="40" width="180" height="70" rx="8"/><text x="390" y="70">AS559 SWITCH</text><text x="390" y="92" class="small">::35</text>
<rect x="530" y="40" width="180" height="70" rx="8"/><text x="620" y="70">AS2906 Netflix</text><text x="620" y="92" class="small">::55</text>
<rect x="760" y="40" width="180" height="70" rx="8"/><text x="850" y="70">AS13335 Cloudflare</text><text x="850" y="92" class="small">::c0</text>
<line class="link" x1="160" y1="110" x2="160" y2="175"/><line class="link" x1="390" y1="110" x2="390" y2="175"/>
<line class="link" x1="620" y1="110" x2="620" y2="175"/><line class="link" x1="850" y1="110" x2="850" y2="175"/>
<rect x="385" y="300" width="230" height="70" rx="8"/><text x="500" y="330">route servers</text><text x="500" y="352" class="small">AS42476 ::a5ec:a:1</text>
<line class="link" x1="500" y1="231" x2="500" y2="300"/>
<path class="bgp" d="M 140 110 C 140 290, 300 335, 385 335"/>
<path class="bgp" d="M 410 110 C 410 250, 440 270, 470 300"/>
<path class="traffic" d="M 180 110 C 180 165, 370 165, 370 110"/>
<text x="275" y="138" class="small" style="fill:#4fd98a">traffic</text>
<text x="215" y="300" class="small accent">BGP</text>
</svg>
Notes:
Every member plugs one router port into the same layer-2 network and gets one
address in a shared subnet. No routing inside the exchange: just Ethernet.
Dashed blue is a BGP session, green is where the packets actually go.
---
## What that implies
- Peering participants in an exchange are all one hop away, found by neighbour discovery
- A route's next hop is the peer's LAN address, so traffic goes **directly** member to member
- The exchange is a simple layer 2 switch, allowing for a large capacity at low cost
- Strict(ish) rules on what traffic is allowed on the peering lan (e.g. no DHCP or broadcast packets)
Notes:
- IPv6 neighbour discovery and ARP are used to discover the MAC addresses of peers
- after which traffic can flow directly
---
## Route servers
Are a piece of shared infrastructure that simplifies peering.
- Most participants peer with the route server and annouce their prefixes there
- The route server can then distribute these routes to every other participant
- Simplifes from `n²` to `n` sessions
- Not in the traffic path and does not add its own AS into the path
- Allows controlling behaviour with communities, e.g. `42476:0:<as>` "not to `<as>`", `42476:1:<as>` "only to `<as>`"
Notes:
A route server is a BGP speaker that only redistributes. Because it is
transparent, the AS path looks exactly as if you peered with each member
directly. We will use those communities in the lab.
---
## Looking glasses
Are a useful tool to inspect the state of another network or internet exchange
- SwissIX https://lg.swissix.ch/
- NL-ix https://lg.nl-ix.net/
- BGP tools https://bgp.tools/
+90
View File
@@ -0,0 +1,90 @@
# The lab
### <a href="https://bgplab.rayhaan.net">bgplab.rayhaan.net</a>
Notes:
Section 5. What the students are about to touch, and what stands between a
typo and the Internet.
---
## What you are connected to
<svg class="diagram" viewBox="0 0 1000 430" role="img" aria-label="Browser to the lab BGP speaker to the BIRD router pr01.rue, which peers at four exchanges and buys transit">
<rect x="20" y="170" width="150" height="80" rx="8"/><text x="95" y="205">you</text><text x="95" y="228" class="small">a browser</text>
<g class="hot"><rect x="230" y="170" width="200" height="80" rx="8"/></g><text x="330" y="202" class="accent">lab speaker</text><text x="330" y="226" class="small">AS4200000001</text>
<rect x="490" y="170" width="200" height="80" rx="8"/><text x="590" y="202">pr01.rue (BIRD)</text><text x="590" y="226" class="small">AS210036</text>
<line class="link" x1="170" y1="210" x2="230" y2="210"/><text x="200" y="160" class="small">HTTPS</text>
<line class="bgp" x1="430" y1="210" x2="490" y2="210"/><text x="460" y="160" class="small accent">BGP</text>
<text x="330" y="280" class="small">web page + BGP speaker</text><text x="590" y="280" class="small">filters, then decides where</text>
<rect x="780" y="20" width="200" height="56" rx="8"/><text x="880" y="54">SwissIX</text>
<rect x="780" y="98" width="200" height="56" rx="8"/><text x="880" y="132">NL-ix</text>
<rect x="780" y="176" width="200" height="56" rx="8"/><text x="880" y="210">France-IX</text>
<rect x="780" y="254" width="200" height="56" rx="8"/><text x="880" y="288">DE-CIX</text>
<rect x="780" y="352" width="200" height="56" rx="8"/><text x="880" y="386">transit</text>
<path class="bgp" d="M 690 195 C 740 195, 730 48, 780 48"/><path class="bgp" d="M 690 203 C 740 203, 735 126, 780 126"/>
<path class="bgp" d="M 690 210 L 780 204"/><path class="bgp" d="M 690 217 C 740 217, 735 282, 780 282"/>
<path class="bgp" d="M 690 225 C 740 225, 730 380, 780 380"/>
</svg>
Notes:
The lab speaker is a small BGP daemon written for this course; it runs on
the same machine as the router. Both dashed hops are real BGP sessions. Everything to
the right of pr01 is the actual Internet.
---
## Your prefix
`2a0d:d741:<n>::/48`
- `<n>` is your number **in hex**: student 10 is `2a0d:d741:a::/48`
- Put your name in _owner_: only the owner can withdraw a route
- One prefix each
Notes:
Put the URL and the class password on the board. The page is a snapshot by
default; turn on auto-refresh at the top right to watch things change.
---
## What happens when you press _Announce_
1. The lab speaker builds an UPDATE. Click _bytes_ to see it, hover to decode it
2. pr01 checks it against its import filter
3. pr01 decides, per neighbour, whether to pass it on
4. Your announcement propagates throughout the whole internet!
5. `ping 2a0d:d741:<n>::1` answers, from anywhere
Notes:
Step 5: pr01 answers for every address in the lab space, so reachability of
your prefix depends only on whether your announcement got out.
---
## Steering your route
| You add | Who acts | Effect |
| ----------------------------- | -------- | --------------------------------- |
| nothing | | announced everywhere |
| `210036:0:0` | pr01 | announced nowhere |
| `210036:0:0` `210036:1:42476` | pr01 | only to the SwissIX route servers |
| + `42476:0:0` `42476:1:559` | SwissIX | …and from there only to SWITCH |
| `NO_EXPORT` | pr01 | accepted, never passed on |
Same idea at two hops: _never there_, _only there_, _nowhere_.
<!-- .element: class="note" -->
Notes:
210036:0:<as> never to that AS, 210036:1:<as> to that AS, 210036:2/3:<n> the
same by exchange (1 SwissIX, 2 NL-ix, 3 France-IX, 4 DE-CIX). A "never" always
wins. The preset menu next to the field has all of these.
---
## Handout Part 2 & 3
Notes:
Looking glasses: bgp.tools, RIPEstat, and the exchange's own. Global
visibility takes a minute or two to settle after each change.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Serve the deck for editing: like `python3 -m http.server`, but every
response says Cache-Control: no-store, so a plain reload always shows the
files as they are on disk. (The stock server sends Last-Modified and nothing
else, and browsers then keep the section files and the CSS for a while.)
python3 slides/serve.py # http://localhost:8765
python3 slides/serve.py 8000
"""
import os
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
class NoCacheHandler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Cache-Control", "no-store")
super().end_headers()
def log_message(self, fmt, *args):
pass # quiet; errors still go through log_error
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
os.chdir(os.path.dirname(os.path.abspath(__file__)))
print(f"serving {os.getcwd()} on http://localhost:{port}/")
ThreadingHTTPServer(("", port), NoCacheHandler).serve_forever()
+556
View File
@@ -0,0 +1,556 @@
/*
* Components and helpers used by the slides. The look (palette, type, panels,
* motif) is in theme.css; nothing here sets a brand colour directly.
*/
.hidden {
display: none;
}
/* ---- text -------------------------------------------------------------------- */
/* A quiet aside under the main point. */
.reveal .note {
font-style: italic;
font-size: 75%;
color: var(--muted);
}
/* Image and quote credits: <!-- .element: class="attribution" --> */
.reveal .attribution {
font-size: 45%;
font-style: italic;
color: var(--muted);
}
/* One big statement on an otherwise empty slide. */
.reveal .big {
font-size: 1.9em;
font-weight: 300;
line-height: 1.25;
color: var(--ink-strong);
}
/* ---- layout ------------------------------------------------------------------ */
.container {
display: flex;
justify-content: center;
align-items: center;
gap: 1em;
}
.flex-around {
display: flex;
justify-content: space-around;
align-items: flex-start;
gap: 1em;
}
/* Two equal columns: <div class="cols"><div>…</div><div>…</div></div> */
.cols {
display: flex;
gap: 1.2em;
align-items: center;
text-align: left;
}
.cols > * {
flex: 1 1 0;
min-width: 0;
}
.float-left {
float: left;
}
.float-right {
float: right;
}
.no-border {
background: none !important;
border: none !important;
box-shadow: none !important;
}
.reveal img.blur-edges {
border-radius: 12px;
box-shadow: 0 0 25px 8px rgba(20, 30, 60, 0.2);
}
.reveal table.borders td,
.reveal table.borders th {
border: 1px solid var(--rule);
}
/* ---- boxes: small labelled cards, e.g. one per AS or per community ----------- */
.boxes {
display: flex;
flex-wrap: wrap;
justify-content: center;
font-size: 70%;
margin: 0.6em 0;
}
.boxes > div {
padding: 0.35em 0.8em;
margin: 0.3em 0.5em;
border: 1px solid var(--rule);
border-radius: 10px;
background: var(--ground);
line-height: 1.35;
}
.boxes > div.us {
border-color: var(--accent);
box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.35);
color: var(--ink-strong);
}
.boxes > div.ok {
border-color: #1a8a4f;
}
.boxes > div.bad {
border-color: #d64545;
}
/* ---- BGP bytes, matching the four groups of the /statusz bytes view ---------- */
.reveal pre.bytes {
font-size: 0.5em;
line-height: 1.9;
white-space: pre-wrap;
word-break: break-all;
background: var(--code-ground);
border-radius: 6px;
padding: 15px 20px;
box-sizing: border-box;
color: var(--code-ink);
}
.bytes span,
.legend span {
border-radius: 3px;
padding: 1px 3px;
color: var(--code-ink); /* the fills are dark on any ground */
}
.bytes .hh,
.legend .hh { background: #34406e; }
.bytes .hl,
.legend .hl { background: #6e3440; }
.bytes .ha,
.legend .ha { background: #245c3a; }
.bytes .ha2 { background: #2f7a4b; }
.bytes .hn,
.legend .hn { background: #6b5a1f; }
.legend {
font-size: 50%;
font-family: var(--r-code-font);
}
.legend span {
padding: 1px 0.6em;
margin: 0 0.2em;
}
/* ---- live DNS and RIPE database lookups ------------------------------------ */
.reveal form.dns-lookup,
.reveal form.ripe-lookup {
margin: 0.4em auto 0;
max-width: 34em;
text-align: left;
font-family: var(--r-code-font);
}
.reveal .dns-lookup input,
.reveal .ripe-lookup input {
width: 62%;
padding: 0.3em 0.5em;
font: inherit;
font-size: 0.8em;
color: var(--r-main-color);
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
outline: none;
}
.reveal .dns-lookup input:focus,
.reveal .ripe-lookup input:focus {
border-color: var(--accent);
}
.reveal .dns-lookup button,
.reveal .ripe-lookup button {
margin-left: 0.4em;
padding: 0.3em 0.9em;
font: inherit;
font-size: 0.8em;
color: #fff;
background: var(--accent);
border: 0;
border-radius: 6px;
cursor: pointer;
}
.reveal .dns-result {
min-height: 3.2em;
max-height: 13em;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--rule) transparent;
margin-top: 0.5em;
padding: 0.5em 0.7em;
font-size: 0.7em;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
}
.reveal .dns-result .ttl,
.reveal .dns-result .type,
.reveal .dns-result .dim { color: var(--muted); }
.reveal .dns-result .addr { color: var(--accent); font-weight: 500; }
.reveal .dns-result .bad { color: #c62828; }
/* RIPE database objects, laid out like apps.db.ripe.net: a light page with
one card per object, one row per attribute, references in blue, rows the
RIPE NCC manages shaded. */
.reveal .ripe-result {
/* A fixed height, not a maximum: the box must not grow when the answer
arrives, or the slide overflows (reveal centres a slide only when it
is shown). */
height: 13.5em;
margin-top: 0.5em;
padding: 0.5em;
overflow-y: auto;
scrollbar-width: thin;
font-size: 0.62em;
text-align: left;
background: #f4f5f7;
border-radius: 6px;
}
.reveal .ripe-card {
margin-bottom: 0.7em;
padding: 0.2em 0.5em;
background: #fff;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
}
.reveal .ripe-card:last-child { margin-bottom: 0; }
.reveal .ripe-card table {
width: 100%;
margin: 0;
border-collapse: collapse;
font-family: var(--r-code-font);
font-size: inherit;
color: #1c1c28;
}
.reveal .ripe-card th,
.reveal .ripe-card td {
padding: 0.28em 0.6em;
border: 0;
font-weight: normal;
text-align: left;
vertical-align: top;
background: transparent;
line-height: 1.4;
}
.reveal .ripe-card table th {
width: 8.5em;
color: #1c1c28;
white-space: nowrap;
text-decoration: underline dotted #9a9aa6;
text-underline-offset: 0.2em;
}
.reveal .ripe-card td { word-break: break-all; }
.reveal .ripe-card tr.managed th,
.reveal .ripe-card tr.managed td { background: #dde5f0; }
.reveal .ripe-card .ref { color: #2f6fe4; }
.reveal .ripe-card .comment { margin-left: 0.4em; color: #6b6b78; }
.reveal .ripe-msg {
padding: 0.6em;
font-family: var(--r-code-font);
color: #1c1c28;
}
/* A framed live page on a slide. */
.reveal iframe.live {
border: 1px solid var(--rule);
border-radius: 8px;
background: #fff;
}
/* ---- bytes with one part in focus: everything not .on is dimmed --------------- */
.bytes.focus span:not(.on) {
opacity: 0.22;
}
.bytes.focus span.on {
box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.9);
}
/* Packet layouts: field boxes in the colours of the bytes view. */
.diagram .pkt rect { stroke: none; }
.diagram .pkt.hh rect { fill: #34406e; }
.diagram .pkt.hl rect { fill: #6e3440; }
.diagram .pkt.ha rect { fill: #245c3a; }
.diagram .pkt.ha2 rect { fill: #2f7a4b; }
.diagram .pkt.hn rect { fill: #6b5a1f; }
.diagram text.pkt-name { font-size: 18px; font-weight: 500; fill: #fff; }
.diagram text.pkt-size { font-size: 13px; font-family: "IBM Plex Mono", monospace; fill: #fff; opacity: 0.8; }
.diagram text.pkt-title { paint-order: stroke; stroke: var(--panel); stroke-width: 8px; stroke-linejoin: round; }
.diagram .pkt-expand { stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 4 5; fill: none; }
/* ---- byte walk: a hex dump and one row per field, in step on hover --------- */
.reveal .bytewalk { text-align: left; }
.reveal .bytewalk .bw-dump { margin-bottom: 8px; }
/* The lit bytes invert: a white block on the dark dump, visible from the back row. */
.reveal .bw-dump span.on { background: #fff; color: var(--code-ground); box-shadow: 0 0 0 3px var(--accent); }
.reveal .bw-rows {
position: relative; /* rows measure their offsetTop against the box */
height: 19em;
overflow-y: auto;
scrollbar-width: thin;
font-size: 46%;
line-height: 1.45;
border: 1px solid var(--rule);
border-radius: 6px;
background: rgba(var(--accent-rgb), 0.04);
}
.reveal .bw-group {
padding: 0.45em 0.8em 0.2em;
color: var(--accent);
font-weight: 700;
}
.reveal .bw-row {
display: grid;
grid-template-columns: 11em 12em 1fr;
gap: 0 1em;
padding: 0.18em 0.8em;
cursor: default;
outline: none;
}
.reveal .bw-row:hover,
.reveal .bw-row:focus,
.reveal .bw-row.on { background: rgba(var(--accent-rgb), 0.14); }
.reveal .bw-dump span[data-i] { cursor: pointer; }
.reveal .bw-row code.hh { background: rgba(52, 64, 110, 0.16); }
.reveal .bw-row code.hl { background: rgba(110, 52, 64, 0.16); }
.reveal .bw-row code.ha { background: rgba(36, 92, 58, 0.16); }
.reveal .bw-row code.ha2 { background: rgba(47, 122, 75, 0.16); }
.reveal .bw-row code.hn { background: rgba(107, 90, 31, 0.16); }
.reveal .bw-row code { display: block; color: var(--ink-strong); min-width: 0; max-width: 100%; justify-self: start; align-self: start; font-family: var(--r-code-font); border-radius: 3px; padding: 0 4px; white-space: normal; word-break: normal; }
.reveal .bw-row b { color: var(--ink-strong); font-weight: 500; }
.reveal .bw-row span { color: var(--ink); }
/* Small two-column explanation under a bytes block. */
.reveal table.decode {
font-size: 58%;
margin-top: 10px;
}
.reveal table.decode td:first-child {
font-family: var(--r-code-font);
color: var(--accent-soft);
white-space: nowrap;
}
/* ---- prefix explorer: type a prefix, see its range ------------------------ */
.reveal form.prefix-explorer {
margin: 0 auto;
max-width: 36em;
text-align: center;
}
.reveal .prefix-explorer input[name="prefix"] {
width: 15em;
padding: 0.25em 0.5em;
font: inherit;
font-family: var(--r-code-font);
font-size: 0.8em;
color: var(--r-main-color);
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
outline: none;
text-align: center;
}
.reveal .prefix-explorer input[name="prefix"]:focus { border-color: var(--accent); }
.reveal .prefix-explorer input[name="len"] {
width: 12em;
margin-left: 1em;
vertical-align: middle;
accent-color: var(--accent);
}
.reveal .prefix-explorer .px-bits { margin: 0.5em 0 0.4em; min-height: 1.6em; white-space: nowrap; }
.reveal .prefix-explorer .px-bad { color: #c62828; font-size: 70%; }
.reveal .px-range { font-family: var(--r-code-font); font-size: 50%; min-height: 10.5em; }
.reveal .px-levels { text-align: left; margin-bottom: 1em; }
.reveal .px-levels-title { color: var(--muted); margin-bottom: 0.35em; }
.reveal .px-level { display: flex; align-items: center; gap: 0.7em; margin-bottom: 0.3em; }
.reveal .px-level-name { width: 13em; text-align: right; color: var(--accent-soft); white-space: nowrap; }
.reveal .px-level-count { width: 8em; color: var(--muted); white-space: nowrap; }
.reveal .px-track { flex: 1; position: relative; height: 0.9em; background: var(--ground); border: 1px solid var(--rule); border-radius: 2px; }
.reveal .px-track i { position: absolute; top: 0; bottom: 0; min-width: 2px; background: var(--accent); }
.reveal .px-bar {
position: relative;
height: 2.6em;
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.45), rgba(var(--accent-rgb), 0.12));
border: 1px solid var(--accent);
border-radius: 6px;
}
.reveal .px-bar .px-mid {
position: absolute; left: 0; right: 0; top: 0.6em;
text-align: center; color: var(--ink-strong); font-weight: 500;
}
.reveal .px-bar .px-end { position: absolute; top: 3em; color: var(--accent-soft); }
.reveal .px-bar .px-first { left: 0; }
.reveal .px-bar .px-last { right: 0; }
.reveal .prefix-explorer .px-facts { margin-top: 2.6em; }
/* ---- bits of an address: fixed part vs. free part ------------------------------ */
.bits {
font-family: var(--r-code-font);
font-size: 80%;
letter-spacing: 0.02em;
}
.bits .fixed {
color: #fff;
background: var(--accent);
border-radius: 4px 0 0 4px;
padding: 2px 6px;
}
.bits .part {
color: var(--accent-soft);
background: var(--ground);
border: 1px solid var(--accent);
padding: 1px 4px;
margin: 0 2px;
font-size: 85%;
letter-spacing: 0.12em;
}
.bits .part::before { content: "₂"; color: var(--muted); letter-spacing: 0; margin-right: 2px; }
.bits .part i { font-style: normal; color: var(--muted); }
.bits .free {
color: var(--ink);
background: var(--ground);
border: 1px solid var(--rule);
border-left: none;
border-radius: 0 4px 4px 0;
padding: 1px 6px;
}
/* ---- diagrams: inline SVG drawn with the theme's colours ------------------------ */
.reveal svg.diagram {
width: 100%;
height: auto;
max-height: 470px;
display: block;
margin: 0 auto;
}
.diagram rect,
.diagram ellipse {
fill: var(--ground);
stroke: var(--rule);
stroke-width: 2;
}
.diagram .hot rect {
stroke: var(--accent);
}
.diagram .fabric rect {
fill: rgba(var(--accent-rgb), 0.18);
stroke: var(--accent);
}
.diagram text {
fill: var(--ink);
font-family: "IBM Plex Sans", sans-serif;
font-size: 19px;
text-anchor: middle;
}
.diagram text.small {
font-size: 14px;
fill: var(--muted);
font-family: "IBM Plex Mono", monospace;
}
.diagram text.accent {
fill: var(--accent);
}
.diagram .link {
stroke: var(--muted);
stroke-width: 2.5;
fill: none;
}
.diagram .bgp {
stroke: var(--accent);
stroke-width: 2.5;
stroke-dasharray: 7 6;
fill: none;
}
/* Nested address ranges: each level a little brighter than the one around it. */
.diagram .nest rect {
stroke: var(--accent);
rx: 14;
}
.diagram .nest.l0 rect { fill: rgba(var(--accent-rgb), 0.04); stroke-opacity: 0.35; }
.diagram .nest.l1 rect { fill: rgba(var(--accent-rgb), 0.09); stroke-opacity: 0.55; }
.diagram .nest.l2 rect { fill: rgba(var(--accent-rgb), 0.16); stroke-opacity: 0.8; }
.diagram .nest.l3 rect { fill: rgba(var(--accent-rgb), 0.28); stroke-opacity: 1; }
.diagram text.left { text-anchor: start; }
.diagram text.mono { font-family: "IBM Plex Mono", monospace; }
.diagram .nest-label { font-family: "IBM Plex Mono", monospace; font-size: 22px; font-weight: 500; }
.diagram .nest-desc { font-size: 17px; fill: var(--ink); opacity: 0.85; }
.diagram text.host-addr { font-size: 15px; fill: var(--accent-soft); font-family: "IBM Plex Mono", monospace; }
.diagram .host { fill: var(--accent-soft); stroke: none; }
.diagram .traffic {
stroke: #1a8a4f;
stroke-width: 4;
fill: none;
}
+430
View File
@@ -0,0 +1,430 @@
/*
* The look of the deck. Structure borrowed from Matt Godbolt's newer talks
* (hrt-theme.css in github.com/mattgodbolt/what-happens-beneath-accu-2026):
* every slide on a rounded panel, IBM Plex Sans with thin h1 and bold accent
* h2, IBM Plex Mono code, accent-coloured list markers, links and progress
* bar, glowing highlighted code lines.
*
* Made our own: a light palette (his is near-black; change it in :root
* below), and his "thread" corner motif becomes a chain of nodes and links,
* an AS path. Code blocks and byte dumps stay dark on the light page so the
* monokai colours and the byte palette keep working.
*
* Try accents without editing: index.html?accent=green (blue, green,
* amber, magenta). Make one permanent by copying its values into :root.
*/
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&display=swap");
:root {
--ground: #e9edf5; /* behind the panels */
--panel: #ffffff; /* the slide itself */
--panel-edge: rgba(31, 111, 235, 0.22);
--ink: #1f2333; /* body text */
--ink-strong: #0b0d17; /* h1, h3 */
--muted: #6b7088; /* h4, captions */
--rule: #c3c8d8; /* table rules, borders */
--accent: #1f6feb; /* h2, markers, links, motif */
--accent-soft: #0b4ea2; /* inline code, emphasis */
--accent-rgb: 31, 111, 235;
/* Code blocks and byte dumps keep a dark ground of their own. */
--code-ground: #1b1e33;
--code-ink: #eceff9;
--r-code-font: "IBM Plex Mono", "SF Mono", Menlo, Consolas, monospace;
--r-heading-text-shadow: none;
}
:root[data-accent="green"] {
--accent: #1a8a4f;
--accent-soft: #0f6238;
--accent-rgb: 26, 138, 79;
--panel-edge: rgba(26, 138, 79, 0.22);
}
:root[data-accent="amber"] {
--accent: #b86e00;
--accent-soft: #7a4a00;
--accent-rgb: 184, 110, 0;
--panel-edge: rgba(184, 110, 0, 0.22);
}
:root[data-accent="magenta"] {
--accent: #c2277f;
--accent-soft: #8a1a5a;
--accent-rgb: 194, 39, 127;
--panel-edge: rgba(194, 39, 127, 0.22);
}
/* ---- ground and panels ---------------------------------------------------- */
.reveal-viewport {
background: var(--ground);
}
.reveal {
font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
font-size: 40px;
font-weight: 400;
color: var(--ink);
}
.reveal .slides section:not(.stack):not(.title-slide):not(.bare) {
background-color: var(--panel);
border-radius: 12px;
box-shadow: 0 4px 30px rgba(20, 30, 60, 0.1);
border: 1px solid var(--panel-edge);
padding: 1em 0.8em;
box-sizing: border-box;
}
/* ---- type ------------------------------------------------------------------ */
.reveal h1,
.reveal h2,
.reveal h3,
.reveal h4 {
font-family: inherit;
text-transform: none;
text-shadow: none;
margin: 0 0 20px 0;
line-height: 1.25;
}
.reveal h1 {
font-weight: 300;
color: var(--ink-strong);
font-size: 2.2em;
line-height: 1.15;
}
.reveal h2 {
font-weight: 700;
color: var(--accent);
font-size: 1.5em;
}
.reveal h3 {
font-weight: 500;
color: var(--ink-strong);
font-size: 1.2em;
}
.reveal h4 {
font-weight: 500;
color: var(--muted);
font-size: 1em;
}
.reveal p {
margin: 20px 0;
line-height: 1.5;
}
.reveal strong,
.reveal b {
font-weight: 700;
color: var(--ink-strong);
}
.reveal em {
font-style: italic;
color: var(--accent-soft);
}
.reveal a {
color: var(--accent);
text-decoration: none;
transition: color 0.15s ease;
}
.reveal a:hover {
color: var(--accent-soft);
}
.reveal ul,
.reveal ol {
display: inline-block;
text-align: left;
margin: 0 0 0 1em;
}
.reveal ul {
list-style-type: square;
}
.reveal ul li,
.reveal ol li {
margin-bottom: 0.4em;
}
.reveal li::marker {
color: var(--accent);
}
.reveal blockquote {
background: var(--ground);
border-left: 4px solid var(--accent);
padding: 20px 30px;
font-style: italic;
width: 85%;
margin: 20px auto;
text-align: left;
}
.reveal img,
.reveal video,
.reveal iframe {
max-width: 95%;
max-height: 95%;
}
.reveal kbd {
font-family: var(--r-code-font);
font-size: 80%;
border: 1px solid var(--rule);
border-bottom-width: 3px;
border-radius: 5px;
padding: 0 0.35em;
}
/* ---- code ------------------------------------------------------------------ */
.reveal pre {
width: 100%;
margin: 20px auto;
box-shadow: none;
text-align: left;
position: relative;
font-size: 0.55em;
line-height: 1.4;
}
.reveal code {
font-family: var(--r-code-font);
color: var(--accent-soft);
}
.reveal :not(pre) > code {
white-space: nowrap;
font-size: 92%;
}
.reveal pre code {
display: block;
color: var(--code-ink);
background: var(--code-ground);
border-radius: 6px;
padding: 15px 20px;
max-height: 560px;
overflow: auto;
}
/* The lines being talked about glow; the rest dim (reveal handles the dim). */
.reveal .hljs.has-highlights tr.highlight-line {
text-shadow:
0 0 6px currentColor,
0 0 12px currentColor;
transition: text-shadow 0.4s ease;
}
/* ---- tables ---------------------------------------------------------------- */
.reveal table {
border-collapse: collapse;
border-spacing: 0;
margin: 20px auto;
font-size: 75%;
}
.reveal table th {
font-weight: 700;
color: var(--ink-strong);
}
.reveal table th,
.reveal table td {
text-align: left;
border-bottom: 1px solid var(--rule);
padding: 0.25em 0.6em;
}
.reveal table tr:last-child td {
border-bottom: none;
}
/* ---- reveal chrome ---------------------------------------------------------- */
.reveal .controls {
color: var(--accent);
bottom: 40px;
}
.reveal .progress {
background: var(--panel);
height: 4px;
}
.reveal .progress span {
background: var(--accent);
transition: width 800ms cubic-bezier(0.26, 0.86, 0.44, 0.985);
}
.reveal .slide-number {
color: #fff;
font-family: var(--r-code-font);
font-size: 18px;
font-weight: 500;
background: rgba(var(--accent-rgb), 0.9);
padding: 3px 9px;
border-radius: 4px;
right: 16px;
bottom: 16px;
}
::selection {
color: #fff;
background: var(--accent);
}
/* ---- the path motif ---------------------------------------------------------
* Matt's decks carry a ring with a line running off the edge. Ours is a short
* AS path: nodes joined by links, heading off the top-right corner, mirrored
* running up from the bottom-left. Drawn in the accent colour via a mask, so
* it follows --accent. Hidden on the title slide and on "bare" slides.
*/
.reveal::before,
.reveal-viewport::before {
content: "";
position: fixed;
background: var(--accent);
z-index: 0;
pointer-events: none;
opacity: 0.9;
}
.reveal::before {
top: 26px;
right: 0;
width: 38vw;
height: 22px;
-webkit-mask:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 190 22'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='11' r='8'/><line x1='19' y1='11' x2='67' y2='11'/><circle cx='75' cy='11' r='8'/><line x1='83' y1='11' x2='131' y2='11'/><circle cx='139' cy='11' r='8'/><line x1='147' y1='11' x2='190' y2='11'/></g></svg>")
left center / 190px 22px no-repeat,
linear-gradient(black, black) 189px center / calc(100% - 189px) 3px no-repeat;
mask:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 190 22'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='11' r='8'/><line x1='19' y1='11' x2='67' y2='11'/><circle cx='75' cy='11' r='8'/><line x1='83' y1='11' x2='131' y2='11'/><circle cx='139' cy='11' r='8'/><line x1='147' y1='11' x2='190' y2='11'/></g></svg>")
left center / 190px 22px no-repeat,
linear-gradient(black, black) 189px center / calc(100% - 189px) 3px no-repeat;
}
.reveal-viewport::before {
bottom: 64px;
left: 40px;
width: 22px;
height: 36vh;
-webkit-mask:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 126'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='115' r='8'/><line x1='11' y1='107' x2='11' y2='59'/><circle cx='11' cy='51' r='8'/><line x1='11' y1='43' x2='11' y2='0'/></g></svg>")
center bottom / 22px 126px no-repeat,
linear-gradient(black, black) center top / 3px calc(100% - 125px) no-repeat;
mask:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 126'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='115' r='8'/><line x1='11' y1='107' x2='11' y2='59'/><circle cx='11' cy='51' r='8'/><line x1='11' y1='43' x2='11' y2='0'/></g></svg>")
center bottom / 22px 126px no-repeat,
linear-gradient(black, black) center top / 3px calc(100% - 125px) no-repeat;
}
.reveal:has(> .slides > section.title-slide.present)::before,
.reveal:has(> .slides section.bare.present)::before {
display: none;
}
.reveal-viewport:has(.reveal > .slides > section.title-slide.present)::before,
.reveal-viewport:has(.reveal > .slides section.bare.present)::before {
display: none;
}
.reveal:has(section.title-slide.present) .controls {
display: none !important;
}
/* ---- wordmark, where his logo sits ------------------------------------------ */
.wordmark {
position: fixed;
left: 22px;
bottom: 18px;
z-index: 10;
font-family: var(--r-code-font);
font-size: 15px;
letter-spacing: 0.04em;
color: var(--muted);
pointer-events: none;
}
.wordmark b {
color: var(--accent);
font-weight: 500;
}
.reveal:has(section.title-slide.present) ~ .wordmark,
.reveal:has(section.bare.present) ~ .wordmark {
display: none;
}
/* ---- title slide -------------------------------------------------------------- */
.reveal section.title-slide {
text-align: left;
padding: 0 1.5em;
}
.reveal section.title-slide h1 {
font-size: 2.6em;
margin-bottom: 0.15em;
}
.reveal section.title-slide h3 {
font-weight: 400;
color: var(--accent);
margin-bottom: 2.2em;
}
.reveal section.title-slide .byline {
font-size: 60%;
color: var(--muted);
margin: 0;
}
/* ---- shown by index.html when the deck cannot start --------------------------- */
#deck-help {
position: fixed;
inset: 0;
z-index: 100;
padding: 8vh 10vw;
background: var(--ground);
color: var(--ink);
font: 20px/1.5 "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
}
#deck-help h2 {
color: var(--accent);
font-weight: 700;
}
#deck-help pre,
#deck-help code {
font-family: var(--r-code-font);
color: var(--accent-soft);
}
#deck-help pre {
background: var(--panel);
padding: 12px 16px;
border-radius: 6px;
}
/* ---- print ------------------------------------------------------------------ */
html.print-pdf .wordmark,
html.reveal-print .wordmark {
display: none;
}
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Copies reveal.js into ./reveal.js so the deck works with no network.
# index.html uses this copy when it exists and the CDN otherwise. Keep VERSION
# in step with the one in index.html.
set -eu
VERSION=6.0.2
cd "$(dirname "$0")"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
npm pack "reveal.js@$VERSION" --pack-destination "$tmp" --silent >/dev/null 2>&1
tar -xzf "$tmp/reveal.js-$VERSION.tgz" -C "$tmp"
rm -rf reveal.js
mkdir -p reveal.js
cp -R "$tmp/package/dist" reveal.js/dist
cp "$tmp/package/LICENSE" reveal.js/LICENSE
echo "reveal.js $VERSION vendored into $(pwd)/reveal.js ($(du -sh reveal.js | cut -f1))"
+20 -1
View File
@@ -5,6 +5,25 @@ listen_endpoints {
port: 179
}
listen_endpoints {
address: "[::1]"
address: "::1"
port: 179
}
peers {
name: "lab-r1"
endpoint { address: "127.0.0.1" port: 1791 }
remote_asn: 65001
hold_time_seconds: 30
}
connect_retry_seconds: 5
# Let a restarted peer replace a stale Established session immediately
# (RFC 4271 CollisionDetectEstablishedState). Default false.
collision_detect_established_state: false
local_route_policy {
allowed_prefixes: "10.100.0.0/16"
allowed_prefixes: "2001:db8:100::/48"
max_routes_per_owner: 4
}
http_endpoint {
address: "127.0.0.1"
port: 8080
}
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""Load test for the ferrix_bgp web API: N students announce at the same moment.
Each simulated student owns one prefix (student n gets the n-th /48, numbered
in hex like the lab's route6 objects) and announces it under its own owner
name. All students are released together by a barrier, so the announcements
really are concurrent. The script then checks that the daemon did the right
thing, not merely that it answered:
1. every announcement was accepted, with latency percentiles
2. the local table holds exactly these routes, under the right owners
3. every route was turned into an UPDATE for each established session
4. the event stream delivered one "announced" event per route, none lost
5. ownership holds under load: nobody could replace another's prefix
6. concurrent withdrawal leaves nothing behind
7. the daemon's own counters show the matching number of BGP UPDATEs sent,
for the announcements and again for the withdrawals
SAFETY. Pointed at a router that peers with the real Internet, this announces
real prefixes. By default every route therefore carries the large community
210036:0:0 ("announce nowhere"), so the production router accepts the routes
but exports them to nobody, and the script withdraws everything it announced
even when it fails half-way. Pass --announce-for-real only if you mean it.
Standard library only. Examples:
tools/loadtest.py 128
tools/loadtest.py 128 --url https://lab.example.net --user student
tools/loadtest.py 64 --rounds 5 --keep
"""
import argparse
import base64
import getpass
import json
import os
import ssl
import statistics
import sys
import threading
import time
import urllib.error
import urllib.request
QUIET_COMMUNITY = "210036:0:0"
OWNER_PREFIX = "loadtest-"
class Api:
def __init__(self, base, user=None, password=None, insecure=False, timeout=15):
self.base = base.rstrip("/")
self.timeout = timeout
self.headers = {"content-type": "application/json"}
if user:
token = base64.b64encode(f"{user}:{password}".encode()).decode()
self.headers["authorization"] = f"Basic {token}"
self.ctx = ssl._create_unverified_context() if insecure else None
def call(self, method, path, body=None):
"""Returns (status, parsed JSON or text, seconds)."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(self.base + path, data=data, method=method, headers=self.headers)
start = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
status, raw = resp.status, resp.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
except Exception as e: # connection refused, timeout, TLS failure, ...
return 0, f"{type(e).__name__}: {e}", time.perf_counter() - start
elapsed = time.perf_counter() - start
try:
return status, json.loads(raw) if raw else None, elapsed
except ValueError:
return status, raw.decode(errors="replace"), elapsed
def open_stream(self, path):
req = urllib.request.Request(self.base + path, headers={**self.headers, "accept": "text/event-stream"})
return urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx)
def updates_sent(api):
"""BGP UPDATE messages the daemon has sent so far, from /metricz; None if unavailable."""
status, text, _ = api.call("GET", "/metricz")
if status != 200 or not isinstance(text, str):
return None
for line in text.splitlines():
if line.startswith('bgp_messages_sent_total{type="update"}'):
return int(float(line.rsplit(" ", 1)[1]))
return 0 # the counter has no sample until the first UPDATE
def established(api):
status, peers, _ = api.call("GET", "/api/peers")
return [p for p in peers if p.get("state") == "Established"] if status == 200 else []
def check_wire(api, report, before, routes, sessions_before, what):
"""The advertiser works asynchronously, so give the counter a moment to settle."""
if before is None:
print(f" [skip] /metricz unavailable, cannot count UPDATEs for the {what}")
return
expected = routes * len(sessions_before)
deadline = time.time() + 5
while time.time() < deadline and (updates_sent(api) or 0) - before < expected:
time.sleep(0.1)
delta = (updates_sent(api) or 0) - before
if len(established(api)) != len(sessions_before):
print(f" [skip] sessions changed during the test; {delta} UPDATEs sent for the {what}")
else:
report.check(delta == expected, f"{delta} BGP UPDATEs sent for the {what} "
f"({routes} routes x {len(sessions_before)} session(s))", [f"expected {expected}"])
class EventCounter(threading.Thread):
"""Follows /api/events and records what it sees until stopped."""
def __init__(self, api):
super().__init__(daemon=True)
self.api, self.events, self.error = api, [], None
self.connected, self._stop = threading.Event(), threading.Event()
def run(self):
try:
stream = self.api.open_stream("/api/events")
except Exception as e:
self.error = f"{type(e).__name__}: {e}"
self.connected.set()
return
self.connected.set()
try:
for raw in stream:
if self._stop.is_set():
break
line = raw.decode(errors="replace").strip()
if line.startswith("data:"):
try:
self.events.append(json.loads(line[5:]))
except ValueError:
pass
except Exception as e:
if not self._stop.is_set():
self.error = f"{type(e).__name__}: {e}"
def stop(self):
self._stop.set()
def percentile(values, p):
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int(round(p / 100 * (len(ordered) - 1))))]
def latency_line(latencies):
if not latencies:
return "no samples"
ms = [v * 1000 for v in latencies]
return (f"min {min(ms):.0f} ms, median {statistics.median(ms):.0f} ms, "
f"p95 {percentile(ms, 95):.0f} ms, max {max(ms):.0f} ms")
def run_concurrently(jobs, worker):
"""Runs worker(job) for every job at the same instant; returns results in order."""
results = [None] * len(jobs)
barrier = threading.Barrier(len(jobs))
def target(i, job):
try:
barrier.wait(timeout=30)
except threading.BrokenBarrierError:
results[i] = (0, "barrier broken", 0.0)
return
results[i] = worker(job)
threads = [threading.Thread(target=target, args=(i, job)) for i, job in enumerate(jobs)]
wall = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
return results, time.perf_counter() - wall
class Report:
def __init__(self):
self.failures = 0
def check(self, ok, text, detail=None):
print(f" [{'ok' if ok else 'FAIL'}] {text}")
if not ok:
self.failures += 1
for line in (detail or [])[:8]:
print(f" {line}")
if detail and len(detail) > 8:
print(f" ... and {len(detail) - 8} more")
return ok
def one_round(api, args, students, report, round_no):
body_for = lambda s: {
"prefix": s["prefix"], "owner": s["owner"],
"communities": args.community, "large_communities": s["large"],
}
events = EventCounter(api)
if not args.no_events:
events.start()
events.connected.wait(timeout=10)
time.sleep(0.3) # let the subscription settle before the burst
# ---- 1. everybody announces at once -----------------------------------
print(f"\nRound {round_no}: {len(students)} students announce concurrently")
sent_before, sessions_before = updates_sent(api), established(api)
results, wall = run_concurrently(students, lambda s: api.call("POST", "/api/routes/local", body_for(s)))
accepted = [s for s, r in zip(students, results) if r[0] == 200]
rejected = [f"{s['owner']} {s['prefix']}: HTTP {r[0]} {str(r[1])[:160]}" for s, r in zip(students, results) if r[0] != 200]
report.check(not rejected, f"all {len(students)} announcements accepted in {wall:.2f} s "
f"({len(students) / wall:.0f}/s)", rejected)
print(f" latency: {latency_line([r[2] for r in results if r[0] == 200])}")
if not accepted:
print(" [skip] nothing was accepted, so there is nothing further to verify")
if not args.no_events:
events.stop()
return accepted
# ---- 2. the table holds exactly what was announced --------------------
status, table, _ = api.call("GET", "/api/routes/local")
table = table if status == 200 and isinstance(table, list) else []
by_prefix = {r["prefix"]: r for r in table}
missing = [f"{s['prefix']} missing" for s in accepted if s["prefix"] not in by_prefix]
wrong_owner = [f"{s['prefix']} owned by {by_prefix[s['prefix']]['owner']!r}, expected {s['owner']!r}"
for s in accepted if s["prefix"] in by_prefix and by_prefix[s["prefix"]]["owner"] != s["owner"]]
wrong_attrs = [f"{s['prefix']} large communities {by_prefix[s['prefix']]['large_communities']}"
for s in accepted if s["prefix"] in by_prefix
and sorted(by_prefix[s["prefix"]]["large_communities"]) != sorted(s["large"])]
report.check(not (missing or wrong_owner or wrong_attrs),
"local table holds every route with the right owner and communities",
missing + wrong_owner + wrong_attrs)
# ---- 3. each route became an UPDATE on every session -------------------
up = established(api)
if not up:
print(" [skip] no established BGP session, so nothing was put on the wire")
else:
sample = accepted if len(accepted) <= 32 else accepted[:: max(1, len(accepted) // 32)]
bad = []
for s in sample:
code, wire, _ = api.call("GET", "/api/routes/local/wire?prefix=" + urllib.request.quote(s["prefix"], safe=""))
if code != 200 or not wire:
bad.append(f"{s['prefix']}: HTTP {code}, {len(wire) if isinstance(wire, list) else 0} session(s)")
elif not all(w["hex"].startswith("ff" * 16) for w in wire):
bad.append(f"{s['prefix']}: malformed UPDATE")
report.check(not bad, f"UPDATE built for {len(up)} established session(s) "
f"(checked {len(sample)} of {len(accepted)} routes)", bad)
check_wire(api, report, sent_before, len(accepted), sessions_before, "announcements")
# ---- 4. the event stream lost nothing ----------------------------------
if not args.no_events:
deadline = time.time() + 5
want = {s["prefix"] for s in accepted}
seen = lambda: {e["route"]["prefix"] for e in events.events if e.get("type") == "local_announced"}
while time.time() < deadline and not want <= seen():
time.sleep(0.1)
lagged = [e for e in events.events if e.get("type") == "lagged"]
lost = sorted(want - seen())
detail = [f"no event for {p}" for p in lost] + [f"stream lagged: missed {e['missed']} on {e['stream']}" for e in lagged]
if events.error:
detail.insert(0, f"event stream: {events.error}")
report.check(not detail, f"event stream delivered all {len(want)} announcements", detail)
# ---- 5. ownership holds under load -------------------------------------
if len(accepted) >= 2 and not args.no_policy:
# Everyone tries, at once, to take over their neighbour's prefix.
thieves = [{**accepted[i], "prefix": accepted[(i + 1) % len(accepted)]["prefix"]} for i in range(len(accepted))]
results, _ = run_concurrently(thieves, lambda s: api.call("POST", "/api/routes/local", body_for(s)))
leaked = [f"{t['owner']} took {t['prefix']}: HTTP {r[0]}" for t, r in zip(thieves, results) if r[0] != 409]
status, table, _ = api.call("GET", "/api/routes/local")
owners = {r["prefix"]: r["owner"] for r in table} if status == 200 else {}
leaked += [f"{s['prefix']} now owned by {owners.get(s['prefix'])!r}" for s in accepted if owners.get(s["prefix"]) != s["owner"]]
report.check(not leaked, f"{len(thieves)} concurrent takeover attempts all refused (409), owners unchanged", leaked)
if not args.no_events:
events.stop()
return accepted
def withdraw_all(api, students, report, verify=True):
if not students:
return
sent_before, sessions_before = (updates_sent(api), established(api)) if verify else (None, [])
results, wall = run_concurrently(
students, lambda s: api.call("DELETE", "/api/routes/local", {"prefix": s["prefix"], "owner": s["owner"]}))
failed = [f"{s['prefix']}: HTTP {r[0]} {r[1]}" for s, r in zip(students, results) if r[0] != 200]
if not verify:
return
report.check(not failed, f"all {len(students)} withdrawals accepted in {wall:.2f} s", failed)
status, table, _ = api.call("GET", "/api/routes/local")
left = [r["prefix"] for r in table if r["owner"].startswith(OWNER_PREFIX)] if status == 200 else ["(could not read table)"]
report.check(not left, "nothing left behind by the load test", left)
if sessions_before:
check_wire(api, report, sent_before, len(students) - len(failed), sessions_before, "withdrawals")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("students", type=int, help="number of simulated students")
ap.add_argument("--url", default="http://127.0.0.1:8080", help="base URL of the status page (default %(default)s)")
ap.add_argument("--user", help="HTTP basic auth user; the password is read from $FERRIX_PASSWORD or prompted for")
ap.add_argument("--insecure", action="store_true", help="do not verify the TLS certificate")
ap.add_argument("--prefix-template", default="2a0d:d741:{n:x}::/48",
help="prefix of student n, a Python format string (default %(default)s)")
ap.add_argument("--first", type=int, default=1, help="number of the first student (default 1)")
ap.add_argument("--rounds", type=int, default=1, help="repeat announce/verify/withdraw this many times")
ap.add_argument("--community", action="append", default=[], help="standard community to attach (repeatable)")
ap.add_argument("--large-community", action="append", default=[], help="extra large community to attach (repeatable)")
ap.add_argument("--announce-for-real", action="store_true",
help=f"do NOT add {QUIET_COMMUNITY}; routes may be exported to the Internet")
ap.add_argument("--keep", action="store_true", help="leave the routes announced at the end")
ap.add_argument("--no-events", action="store_true", help="skip the event stream check")
ap.add_argument("--no-policy", action="store_true", help="skip the concurrent takeover check")
args = ap.parse_args()
if args.students < 1:
ap.error("need at least one student")
password = None
if args.user:
password = os.environ.get("FERRIX_PASSWORD") or getpass.getpass(f"Password for {args.user}: ")
api = Api(args.url, args.user, password, args.insecure)
large = list(args.large_community)
if not args.announce_for_real and QUIET_COMMUNITY not in large:
large.insert(0, QUIET_COMMUNITY)
students = [{"owner": f"{OWNER_PREFIX}{n}", "prefix": args.prefix_template.format(n=n), "large": large}
for n in range(args.first, args.first + args.students)]
# ---- preflight ----------------------------------------------------------
status, info, _ = api.call("GET", "/api/status")
if status == 401:
sys.exit(f"{args.url} wants a password: HTTP 401. Pass --user, and check the password.")
if status != 200:
sys.exit(f"Cannot reach {args.url}/api/status: HTTP {status} {str(info)[:200]}")
print(f"Target: {args.url} router-id {info['router_id']} AS{info['local_asn']} version {info['version']} "
f"on {info.get('hostname', '?')}")
print(f" {info['sessions']} established session(s), {info['local_routes']} local route(s) already present")
print(f"Prefixes {students[0]['prefix']} .. {students[-1]['prefix']}, large communities {large or 'none'}")
if args.announce_for_real:
print("WARNING: --announce-for-real: these routes may be exported to the Internet.")
status, table, _ = api.call("GET", "/api/routes/local")
stale = [r for r in table if r["owner"].startswith(OWNER_PREFIX)] if status == 200 else []
if stale:
print(f"Removing {len(stale)} route(s) left over from an earlier load test")
withdraw_all(api, [{"owner": r["owner"], "prefix": r["prefix"]} for r in stale], Report(), verify=False)
taken = {r["prefix"]: r["owner"] for r in table if not r["owner"].startswith(OWNER_PREFIX)} if status == 200 else {}
clash = [s for s in students if s["prefix"] in taken]
if clash:
print(f"Skipping {len(clash)} prefix(es) currently announced by real owners: "
+ ", ".join(f"{s['prefix']} ({taken[s['prefix']]})" for s in clash[:5]) + (" ..." if len(clash) > 5 else ""))
students = [s for s in students if s["prefix"] not in taken]
if not students:
sys.exit("Nothing left to test.")
report, announced = Report(), []
try:
for round_no in range(1, args.rounds + 1):
announced = one_round(api, args, students, report, round_no)
if round_no < args.rounds or not args.keep:
withdraw_all(api, announced, report)
announced = []
except KeyboardInterrupt:
print("\nInterrupted.")
report.failures += 1
finally:
if announced and not args.keep:
withdraw_all(api, announced, Report(), verify=False)
status, info, _ = api.call("GET", "/api/status")
if status == 200:
print(f"\nAfterwards: {info['sessions']} established session(s), {info['local_routes']} local route(s)")
print("RESULT: " + ("PASS" if report.failures == 0 else f"FAIL ({report.failures} check(s) failed)"))
sys.exit(0 if report.failures == 0 else 1)
if __name__ == "__main__":
main()