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>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-09-19 22:46:07 +02:00
1 parent 3f04152f19
commit eb77090d01
9 files changed
+69 -32

No files matched your search

+8 -5
View File
@@ -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(),
+4 -1
View File
@@ -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(),
@@ -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");
+14
View File
@@ -348,6 +348,20 @@ pub async fn status(State(state): State<AppState>) -> Json<StatusView> {
})
}
/// 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))
}
+12 -1
View File
@@ -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<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`
@@ -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))
+19 -11
View File
@@ -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; }
</style>
</head>
@@ -62,6 +66,11 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
</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>
@@ -74,14 +83,13 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
<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-prepend">AS-path prepends:</label></td><td><input id="f-prepend" type="number" min="0" max="16" value="0" style="width:4em">
&nbsp; <label for="f-med">MED:</label> <input id="f-med" type="number" min="0" max="4294967295" placeholder="none" style="width:8em"></td></tr>
<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="42476:101:0">
<tr><td><label for="f-large">Large communities:</label></td><td><input id="f-large" size="50" placeholder="42476:0:6939">
<select id="f-lc" aria-label="Add a large community preset"><option value="">+ SwissIX...</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">
@@ -119,12 +127,6 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
['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: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
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: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
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: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
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) {
+4 -9
View File
@@ -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.)
-1
View File
@@ -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
+4 -4
View File
@@ -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.