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