From eb77090d01123910445cc3a12485e791af3ade87 Mon Sep 17 00:00:00 2001 From: Rayhaan Jaufeerally Date: Sat, 19 Sep 2026 22:46:07 +0200 Subject: [PATCH] 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 --- bin/bgp/src/main.rs | 13 +++++---- crates/integration-tests/src/test_server.rs | 5 +++- crates/integration-tests/tests/http_api.rs | 4 +++ crates/web/src/api.rs | 14 ++++++++++ crates/web/src/lib.rs | 13 ++++++++- crates/web/static/statusz.html | 30 +++++++++++++-------- docs/lab/HANDOUT.md | 13 +++------ docs/lab/README.md | 1 - docs/lab/prod/bird-ferrix.conf | 8 +++--- 9 files changed, 69 insertions(+), 32 deletions(-) diff --git a/bin/bgp/src/main.rs b/bin/bgp/src/main.rs index 14d96cb..b4befc2 100644 --- a/bin/bgp/src/main.rs +++ b/bin/bgp/src/main.rs @@ -33,11 +33,14 @@ async fn main() -> Result<()> { 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"), - )), + 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(), diff --git a/crates/integration-tests/src/test_server.rs b/crates/integration-tests/src/test_server.rs index 47382fe..90c05fe 100644 --- a/crates/integration-tests/src/test_server.rs +++ b/crates/integration-tests/src/test_server.rs @@ -87,7 +87,10 @@ impl TestServer { 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")), + 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(), diff --git a/crates/integration-tests/tests/http_api.rs b/crates/integration-tests/tests/http_api.rs index fd88d69..707c6aa 100644 --- a/crates/integration-tests/tests/http_api.rs +++ b/crates/integration-tests/tests/http_api.rs @@ -45,6 +45,10 @@ async fn test_status_and_peers() -> Result<()> { 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"); diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index e1dc1fd..ad32d94 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -348,6 +348,20 @@ pub async fn status(State(state): State) -> Json { }) } +/// The configuration file the daemon was started with. +#[derive(Debug, Clone, Serialize)] +pub struct ConfigView { + pub path: Option, + /// Raw file contents as read at startup; later edits to the file are not + /// reflected until the daemon restarts. + pub text: Option, +} + +pub async fn config(State(state): State) -> Json { + let (path, text) = state.info.config.clone().unzip(); + Json(ConfigView { path, text }) +} + pub async fn peers(State(state): State) -> Json> { Json(peer_views(&state.registry)) } diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 7659077..61a127e 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -25,7 +25,7 @@ use tokio::net::TcpListener; use tracing::info; pub use api::{ - AnnounceRequest, AttrSummary, LocalRouteView, PeerView, ReceivedRouteView, StatusView, + AnnounceRequest, ConfigView, AttrSummary, LocalRouteView, PeerView, ReceivedRouteView, StatusView, WellKnownCommunity, WireView, WithdrawRequest, }; @@ -40,6 +40,9 @@ pub struct RouterInfo { 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 { @@ -51,8 +54,15 @@ impl RouterInfo { 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, text: impl Into) -> Self { + self.config = Some((path.into(), text.into())); + self + } } /// Best-effort host name without pulling in a dependency: ask the `hostname` @@ -89,6 +99,7 @@ pub fn router(state: AppState) -> Router { .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)) diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html index 2437351..6339b43 100644 --- a/crates/web/static/statusz.html +++ b/crates/web/static/statusz.html @@ -42,6 +42,10 @@ pre.bytes span[data-kind]:hover, pre.bytes span[data-kind]:focus { outline: 1px #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; } @@ -62,6 +66,11 @@ View: variables, status json, < + +

Peers (...)

@@ -74,14 +83,13 @@ View: variables, status json, < - +
namepeerASstateopened bylocal address (next hop)establisheduptime4-byte ASdetail
-  
-
comma separated, ASN:VALUE or a name
+
GLOBAL:LOCAL1:LOCAL2
@@ -119,12 +127,6 @@ View: variables, status json, < ['42476:1:PEER', 'send to peer AS'], ['42476:0:0', 'send to nobody'], ['42476:1:0', 'send to all (default)'], - ['42476:101:0', 'prepend 1x to all'], - ['42476:102:0', 'prepend 2x to all'], - ['42476:103:0', 'prepend 3x to all'], - ['42476:101:PEER', 'prepend 1x to peer AS'], - ['42476:102:PEER', 'prepend 2x to peer AS'], - ['42476:103:PEER', 'prepend 3x to peer AS'], ['42476:901:PEER', 'add NO_EXPORT towards peer AS'], ['42476:902:PEER', 'add NO_ADVERTISE towards peer AS'], ]; @@ -429,7 +431,7 @@ View: variables, status json, < e.preventDefault(); const body = { prefix: $('f-prefix').value.trim(), owner: myOwner(), origin: $('f-origin').value, - prepend: Number($('f-prepend').value || 0), communities: splitList($('f-communities').value), + communities: splitList($('f-communities').value), large_communities: splitList($('f-large').value), }; if ($('f-med').value !== '') body.med = Number($('f-med').value); @@ -441,7 +443,7 @@ View: variables, status json, < 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-prepend').value = 0; $('f-origin').value = 'IGP'; setMsg(''); }); + $('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 = ''; @@ -472,6 +474,12 @@ View: variables, status json, < 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(`${esc(e.message)}`)); if (REFRESH) { diff --git a/docs/lab/HANDOUT.md b/docs/lab/HANDOUT.md index 4cc6879..229a22c 100644 --- a/docs/lab/HANDOUT.md +++ b/docs/lab/HANDOUT.md @@ -31,26 +31,21 @@ means it is one of the prefixes announced locally: it went around the loop. 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 +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. -5. **Asking a neighbour for help.** Announce with the community +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. -6. **Blackholing.** Announce with `BLACKHOLE`. What happens? When would an +5. **Blackholing.** Announce with `BLACKHOLE`. What happens? When would an operator want this? -7. **Withdraw.** Withdraw your prefix and watch the event log. How many +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.) diff --git a/docs/lab/README.md b/docs/lab/README.md index 770af42..6056a48 100644 --- a/docs/lab/README.md +++ b/docs/lab/README.md @@ -51,7 +51,6 @@ docker exec r1 vtysh -c 'show bgp neighbors 10.200.0.1 advertised-routes' | `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 diff --git a/docs/lab/prod/bird-ferrix.conf b/docs/lab/prod/bird-ferrix.conf index 2ddb503..9a88701 100644 --- a/docs/lab/prod/bird-ferrix.conf +++ b/docs/lab/prod/bird-ferrix.conf @@ -86,10 +86,10 @@ protocol bgp ferrix # accept; # } # -# Consequence: AS-path prepends set by students are made of the private ASN -# and are stripped here too, so prepending in ferrix has no effect beyond -# this router. The SwissIX prepend communities (42476:101..103:*) do work: -# the route server prepends AS210036 itself. +# 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 above 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 also send large # communities unmodified; check that its export filter does not clear them.