From a137160b48fc4b77baae60b4118f123446bff5c3 Mon Sep 17 00:00:00 2001 From: Rayhaan Jaufeerally Date: Wed, 16 Sep 2026 23:26:10 +0200 Subject: [PATCH] feature: lab topology, student handout; drop peer routes on session end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 2 + Dockerfile | 10 +++ crates/integration-tests/tests/announce.rs | 46 +++++++++++++ crates/rib/src/adj_rib_in.rs | 30 +++++++-- crates/server/src/fsm.rs | 11 +++- crates/web/static/statusz.html | 5 +- docs/lab/HANDOUT.md | 67 +++++++++++++++++++ docs/lab/README.md | 76 ++++++++++++++++++++++ docs/lab/docker-compose.yml | 53 +++++++++++++++ docs/lab/ferrix.textpb | 25 +++++++ docs/lab/r1/daemons | 4 ++ docs/lab/r1/frr.conf | 44 +++++++++++++ docs/lab/r1/vtysh.conf | 1 + docs/lab/r2/daemons | 4 ++ docs/lab/r2/frr.conf | 33 ++++++++++ docs/lab/r2/vtysh.conf | 1 + 16 files changed, 402 insertions(+), 10 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docs/lab/HANDOUT.md create mode 100644 docs/lab/README.md create mode 100644 docs/lab/docker-compose.yml create mode 100644 docs/lab/ferrix.textpb create mode 100644 docs/lab/r1/daemons create mode 100644 docs/lab/r1/frr.conf create mode 100644 docs/lab/r1/vtysh.conf create mode 100644 docs/lab/r2/daemons create mode 100644 docs/lab/r2/frr.conf create mode 100644 docs/lab/r2/vtysh.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c7f83c2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +target +.git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55e685d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/crates/integration-tests/tests/announce.rs b/crates/integration-tests/tests/announce.rs index eca0aea..222effa 100644 --- a/crates/integration-tests/tests/announce.rs +++ b/crates/integration-tests/tests/announce.rs @@ -227,6 +227,52 @@ async fn test_keepalives_continue_alongside_updates() -> Result<()> { 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(()) +} + // --------------------------------------------------------------------------- // Policy // --------------------------------------------------------------------------- diff --git a/crates/rib/src/adj_rib_in.rs b/crates/rib/src/adj_rib_in.rs index 4f7cf53..ea9e56c 100644 --- a/crates/rib/src/adj_rib_in.rs +++ b/crates/rib/src/adj_rib_in.rs @@ -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 }, Snapshot { reply: oneshot::Sender> }, } @@ -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 { let (reply_tx, reply_rx) = oneshot::channel(); @@ -64,20 +75,25 @@ async fn run_actor(mut rx: mpsc::Receiver, 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 = + 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() diff --git a/crates/server/src/fsm.rs b/crates/server/src/fsm.rs index 0fb582a..a56964e 100644 --- a/crates/server/src/fsm.rs +++ b/crates/server/src/fsm.rs @@ -170,7 +170,7 @@ pub async fn run_session( outbound_tx, )); - established_loop( + let result = established_loop( &mut reader, &mut writer, &ctx, @@ -179,7 +179,14 @@ pub async fn run_session( &config.adj_rib_in, &mut outbound_rx, ) - .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 } async fn established_loop( diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html index 95c5a2e..ba9534d 100644 --- a/crates/web/static/statusz.html +++ b/crates/web/static/statusz.html @@ -320,7 +320,10 @@ }).catch(console.error); loadAll().catch(e => logEvent(esc(e.message), 'err')); connect(); - setInterval(() => { renderStatus(); renderPeers(); renderLocal(); renderReceived(); }, 10000); + // Dial-state changes (Connecting → Backoff → …) carry no event, so poll + // the peers table; everything else is event-driven and only re-rendered + // here to keep ages ticking. + setInterval(() => { loadPeers().catch(console.error); renderStatus(); renderLocal(); renderReceived(); }, 5000); })(); diff --git a/docs/lab/HANDOUT.md b/docs/lab/HANDOUT.md new file mode 100644 index 0000000..4cc6879 --- /dev/null +++ b/docs/lab/HANDOUT.md @@ -0,0 +1,67 @@ +# 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://:8080/statusz`). Everything on the page is live. + +**Your prefix:** `10.100..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. **Prepending.** Withdraw, then announce again with *AS-path prepends* set + to 3. Compare the AS path you get back with exercise 1. Where in the path + did the extra entries appear? What would a router in AS65002 that has two + routes to your prefix conclude? + +4. **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. + +5. **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. + +6. **Blackholing.** Announce with `BLACKHOLE`. What happens? When would an + operator want this? + +7. **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? diff --git a/docs/lab/README.md b/docs/lab/README.md new file mode 100644 index 0000000..0a834a1 --- /dev/null +++ b/docs/lab/README.md @@ -0,0 +1,76 @@ +# 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 . 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 | +| AS-path prepends on ferrix| nothing special | returns with extra `65000`s in the path | + +## 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. +- 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. diff --git a/docs/lab/docker-compose.yml b/docs/lab/docker-compose.yml new file mode 100644 index 0000000..e0bdeb7 --- /dev/null +++ b/docs/lab/docker-compose.yml @@ -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 diff --git a/docs/lab/ferrix.textpb b/docs/lab/ferrix.textpb new file mode 100644 index 0000000..95bada8 --- /dev/null +++ b/docs/lab/ferrix.textpb @@ -0,0 +1,25 @@ +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 + +# 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 } diff --git a/docs/lab/r1/daemons b/docs/lab/r1/daemons new file mode 100644 index 0000000..b89af75 --- /dev/null +++ b/docs/lab/r1/daemons @@ -0,0 +1,4 @@ +bgpd=yes +zebra=yes +staticd=yes +vtysh_enable=yes diff --git a/docs/lab/r1/frr.conf b/docs/lab/r1/frr.conf new file mode 100644 index 0000000..1e3c3f2 --- /dev/null +++ b/docs/lab/r1/frr.conf @@ -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 +! diff --git a/docs/lab/r1/vtysh.conf b/docs/lab/r1/vtysh.conf new file mode 100644 index 0000000..e0ab9cb --- /dev/null +++ b/docs/lab/r1/vtysh.conf @@ -0,0 +1 @@ +service integrated-vtysh-config diff --git a/docs/lab/r2/daemons b/docs/lab/r2/daemons new file mode 100644 index 0000000..b89af75 --- /dev/null +++ b/docs/lab/r2/daemons @@ -0,0 +1,4 @@ +bgpd=yes +zebra=yes +staticd=yes +vtysh_enable=yes diff --git a/docs/lab/r2/frr.conf b/docs/lab/r2/frr.conf new file mode 100644 index 0000000..f8f8430 --- /dev/null +++ b/docs/lab/r2/frr.conf @@ -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 +! diff --git a/docs/lab/r2/vtysh.conf b/docs/lab/r2/vtysh.conf new file mode 100644 index 0000000..e0ab9cb --- /dev/null +++ b/docs/lab/r2/vtysh.conf @@ -0,0 +1 @@ +service integrated-vtysh-config