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>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-09-16 23:26:10 +02:00
1 parent e2de44ba78
commit a137160b48
16 files changed
+401 -9

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
target
.git
+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"]
@@ -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
// ---------------------------------------------------------------------------
+22 -6
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,19 +75,24 @@ 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
+9 -2
View File
@@ -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(
+4 -1
View File
@@ -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);
})();
</script>
</body>
+67
View File
@@ -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://<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. **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?
+76
View File
@@ -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 <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 |
| 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.
+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
+25
View File
@@ -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 }
+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