From 17045a36349217bf54919126d749986c3361da69 Mon Sep 17 00:00:00 2001 From: Rayhaan Jaufeerally Date: Mon, 5 Oct 2026 23:09:02 +0200 Subject: [PATCH] Status page: tables for the kernel, communities by AS, attributes as text The kernel section was one run-on line of interfaces and addresses. It is now a small table of the installation state, and a table of interfaces with their IPv4, IPv6 and link-local addresses one per line; ports that are down and have no addresses share one line at the bottom. Communities ran together into one block that wrapped anywhere. Each AS's communities are now a line of their own, kept whole so that they copy, with a hanging indent where a line wraps; pointing at a line names the AS. Where control communities are on (now in /api/status), those of ours that ask for something say what as a tooltip. A rejected route's details were a fold-out that repeated the summary and then dumped the decoded attributes as JSON. Rejected routes now have the same columns as received ones, and both have an "other attributes" column for what the summary had no field for, as text: aggregator, extended communities (route targets and origins by name), AS4_PATH with its sets in braces, originator ID, cluster list, and unknown attributes in hex. The API has them as summary.other; the JSON stays in `attributes`. Co-Authored-By: Claude Opus 5.5 --- bin/bgp/src/main.rs | 3 +- crates/web/src/api.rs | 205 +++++++++++++++++++++++- crates/web/src/lib.rs | 10 ++ crates/web/static/statusz.html | 156 +++++++++++++----- docs/content/docs/reference/http-api.md | 9 +- 5 files changed, 332 insertions(+), 51 deletions(-) diff --git a/bin/bgp/src/main.rs b/bin/bgp/src/main.rs index 0a78cb2..12f8d71 100644 --- a/bin/bgp/src/main.rs +++ b/bin/bgp/src/main.rs @@ -79,7 +79,8 @@ async fn main() -> Result { server.config().local_asn, env!("CARGO_PKG_VERSION"), ) - .with_config(args.config.to_string_lossy(), &config_string), + .with_config(args.config.to_string_lossy(), &config_string) + .with_control_communities(server.config().control.is_some()), ), registry: server.registry.clone(), local_rib: server.local_rib.clone(), diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index 0cc43e0..aff7add 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -2,7 +2,7 @@ //! daemon is configured through its configuration file. use std::collections::{BTreeMap, BinaryHeap, HashMap}; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr}; use std::time::{SystemTime, UNIX_EPOCH}; use axum::Json; @@ -11,7 +11,9 @@ use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use bgp_packet::community; -use bgp_packet::message::{NlriNextHop, OriginPathAttribute, PathAttribute}; +use bgp_packet::message::{ + AsPathAttribute, ExtendedCommunity, NlriNextHop, OriginPathAttribute, PathAttribute, +}; use bgp_primitives::constants::IpVersion; use bgp_primitives::ip_prefix::IpPrefix; use bgp_rib::table::{PeerIndex, RibTable, Route, RouteRef}; @@ -50,6 +52,9 @@ pub struct StatusView { pub received_routes: usize, /// Prefixes with at least one such route, and so with a best route. pub received_prefixes: usize, + /// Whether large communities of our AS ask for things here + /// (`control_communities`). + pub control_communities: bool, } #[derive(Debug, Clone, Serialize)] @@ -289,6 +294,130 @@ pub struct AttrSummary { pub local_pref: Option, pub communities: Vec, pub large_communities: Vec, + /// The attributes not shown above, in the order the route has them, + /// as text: `aggregator`, `AS64500 192.0.2.1`. + pub other: Vec, +} + +/// An attribute of a route that the summary has no field for. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OtherAttribute { + pub name: String, + pub value: String, +} + +/// An AS path as it is usually written: AS_SETs in braces. +fn as_path_text(path: &AsPathAttribute) -> String { + let segments = path.segments.iter().map(|segment| { + let asns = segment + .path + .iter() + .map(u32::to_string) + .collect::>() + .join(" "); + if segment.ordered { + asns + } else { + format!("{{{asns}}}") + } + }); + segments.collect::>().join(" ") +} + +/// An extended community (RFC 4360) as text. Route targets and route +/// origins, of the three kinds of global administrator, by name; the rest +/// as their eight octets. +fn extended_community_text(community: &ExtendedCommunity) -> String { + let b = community.0; + let kind = match b[1] { + 0x02 => "rt", + 0x03 => "ro", + _ => { + return format!( + "0x{}", + b.iter().map(|o| format!("{o:02x}")).collect::() + ); + } + }; + let u16_at = |at: usize| u16::from_be_bytes([b[at], b[at + 1]]); + let u32_at = |at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]); + match b[0] { + 0x00 => format!("{kind} {}:{}", u16_at(2), u32_at(4)), + 0x01 => format!("{kind} {}:{}", Ipv4Addr::from(u32_at(2)), u16_at(6)), + 0x02 => format!("{kind} {}:{}", u32_at(2), u16_at(6)), + _ => format!( + "0x{}", + b.iter().map(|o| format!("{o:02x}")).collect::() + ), + } +} + +/// The name and value of an attribute the summary has no field for, or +/// `None` for one it has. +fn other_attribute(attribute: &PathAttribute) -> Option { + let (name, value) = match attribute { + PathAttribute::Origin(_) + | PathAttribute::AsPath(_) + | PathAttribute::NextHop(_) + | PathAttribute::MultiExitDisc(_) + | PathAttribute::LocalPref(_) + | PathAttribute::Communities(_) + | PathAttribute::LargeCommunities(_) + | PathAttribute::MpReachNlri(_) + | PathAttribute::MpUnreachNlri(_) => return None, + PathAttribute::AtomicAggregate(_) => ("atomic aggregate".to_string(), "set".to_string()), + PathAttribute::Aggregator(a) => ("aggregator".to_string(), format!("AS{} {}", a.asn, a.ip)), + PathAttribute::ExtendedCommunities(e) => ( + "extended communities".to_string(), + e.extended_communities + .iter() + .map(extended_community_text) + .collect::>() + .join(", "), + ), + PathAttribute::As4Path(p) => ("AS4_PATH".to_string(), as_path_text(p)), + PathAttribute::OnlyToCustomer(asn) => ("only to customer".to_string(), format!("AS{asn}")), + PathAttribute::OriginatorId(id) => ("originator ID".to_string(), id.to_string()), + PathAttribute::ClusterList(ids) => ( + "cluster list".to_string(), + ids.iter() + .map(Ipv4Addr::to_string) + .collect::>() + .join(" "), + ), + PathAttribute::UnknownPathAttribute { + flags, + type_code, + payload, + } => { + let kind = [ + (flags.optional(), "optional"), + (flags.transitive(), "transitive"), + (flags.partial(), "partial"), + ] + .into_iter() + .filter_map(|(set, word)| set.then_some(word)) + .collect::>(); + let octets = payload + .iter() + .map(|o| format!("{o:02x}")) + .collect::(); + ( + format!("attribute {type_code}"), + format!( + "{}{} octets{}{octets}", + if kind.is_empty() { + String::new() + } else { + format!("{}, ", kind.join(" ")) + }, + payload.len(), + if payload.is_empty() { "" } else { ": " }, + ), + ) + } + }; + Some(OtherAttribute { name, value }) } pub fn summarize(attributes: &[PathAttribute], next_hop: Option<&NlriNextHop>) -> AttrSummary { @@ -321,7 +450,7 @@ pub fn summarize(attributes: &[PathAttribute], next_hop: Option<&NlriNextHop>) - PathAttribute::LargeCommunities(l) => { s.large_communities = l.communities.iter().map(community::format_large).collect() } - _ => {} + other => s.other.extend(other_attribute(other)), } } s @@ -474,6 +603,7 @@ pub async fn status(State(state): State) -> Result, U local_routes: state.local_rib.len().await?, received_routes: state.rib.len(), received_prefixes: state.rib.prefix_count(), + control_communities: state.info.control_communities, })) } @@ -1259,4 +1389,73 @@ mod tests { assert_eq!(second.prefixes[0], in_order[at + 1]); assert!(second.more_before); } + + /// The attributes without a field in the summary are listed as text, + /// in the order the route has them. + #[test] + fn other_attributes_as_text() { + use bgp_packet::message::{ + AggregatorPathAttribute, AsPathSegment, AtomicAggregatePathAttribute, + ExtendedCommunitiesPathAttribute, PathAttributeFlags, + }; + let attributes = [ + PathAttribute::AsPath(AsPathAttribute { segments: vec![] }), + PathAttribute::AtomicAggregate(AtomicAggregatePathAttribute {}), + PathAttribute::Aggregator(AggregatorPathAttribute { + asn: 64500, + ip: Ipv4Addr::new(192, 0, 2, 1), + }), + PathAttribute::ExtendedCommunities(ExtendedCommunitiesPathAttribute { + extended_communities: vec![ + ExtendedCommunity([0x00, 0x02, 0xfd, 0xe8, 0, 0, 0, 100]), + ExtendedCommunity([0x01, 0x03, 192, 0, 2, 1, 0, 7]), + ExtendedCommunity([0x02, 0x02, 0, 3, 0x0d, 0x44, 0, 5]), + ExtendedCommunity([0x03, 0x0c, 1, 2, 3, 4, 5, 6]), + ], + }), + PathAttribute::As4Path(AsPathAttribute { + segments: vec![ + AsPathSegment { + ordered: true, + path: vec![64500, 64501], + }, + AsPathSegment { + ordered: false, + path: vec![64502, 64503], + }, + ], + }), + PathAttribute::OnlyToCustomer(64500), + PathAttribute::OriginatorId(Ipv4Addr::new(192, 0, 2, 9)), + PathAttribute::ClusterList(vec![ + Ipv4Addr::new(10, 0, 0, 1), + Ipv4Addr::new(10, 0, 0, 2), + ]), + PathAttribute::UnknownPathAttribute { + flags: PathAttributeFlags::new(true, true, false, false), + type_code: 99, + payload: vec![0xab, 0x01], + }, + ]; + let other: Vec<(String, String)> = summarize(&attributes, None) + .other + .into_iter() + .map(|a| (a.name, a.value)) + .collect(); + let expected = [ + ("atomic aggregate", "set"), + ("aggregator", "AS64500 192.0.2.1"), + ( + "extended communities", + "rt 65000:100, ro 192.0.2.1:7, rt 200004:5, 0x030c010203040506", + ), + ("AS4_PATH", "64500 64501 {64502 64503}"), + ("only to customer", "AS64500"), + ("originator ID", "192.0.2.9"), + ("cluster list", "10.0.0.1 10.0.0.2"), + ("attribute 99", "optional transitive, 2 octets: ab01"), + ] + .map(|(name, value)| (name.to_string(), value.to_string())); + assert_eq!(other, expected); + } } diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 70755a1..91c6f10 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -46,6 +46,9 @@ pub struct RouterInfo { /// 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)>, + /// Whether large communities of our AS ask for things here + /// (`control_communities`), so that the status page can say what. + pub control_communities: bool, } impl RouterInfo { @@ -58,9 +61,16 @@ impl RouterInfo { started_at: SystemTime::now(), hostname: hostname(), config: None, + control_communities: false, } } + /// Records whether control communities are acted on. + pub fn with_control_communities(mut self, on: bool) -> Self { + self.control_communities = on; + self + } + /// 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())); diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html index 97c5193..d2d33ce 100644 --- a/crates/web/static/statusz.html +++ b/crates/web/static/statusz.html @@ -30,13 +30,19 @@ table.data > tbody > tr:hover > td { background: #f3f3ff; } .mono, table.data td.mono { font-family: monospace; } .ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; } .dim { color: #666; } +.nowrap { white-space: nowrap; } +table.data td.path { min-width: 12em; } #event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; } #event-log div { white-space: pre-wrap; } +/* Communities: one line per AS that defines them, each kept whole. */ +.comm { white-space: nowrap; margin-right: 0.7em; } +.comm[title] { border-bottom: 1px dotted #666; cursor: help; } +/* A line that wraps carries on indented, so each AS's line stays apart. */ +.comms > div { padding-left: 2em; text-indent: -2em; margin-bottom: 2px; } +.comms { min-width: 30em; } +table.kv th { background: #eef; font-weight: normal; } +table.data tr.dim td { color: #666; } details { clear: both; margin: 8px 0 0; } -details.refused-detail { margin: 0; } -details.refused-detail summary { cursor: pointer; } -details.refused-detail table td { padding: 1px 8px 1px 0; border: 0; } -details.refused-detail pre { max-height: 20em; overflow: auto; margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; } 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; } @@ -102,7 +108,7 @@ from
- +
prefixfrom peerAS pathnext hoporiginMEDlocal prefcommunitieslarge communitiesRPKIASPAbest
prefixfrom peerAS pathnext hoporiginMEDlocal prefcommunitieslarge communitiesother attributesRPKIASPAbest
@@ -110,7 +116,7 @@ from

Rejected routes (...)

Routes that peers announced and their import policies did not take. The counts are of announcements since the daemon started: a route that a peer announces again, and that is rejected again, counts again. The latest rejected routes of each peer are kept.
- +
peerprefixAS pathreasonwhenattributes
peerprefixAS pathreasonwhennext hoporiginMEDlocal prefcommunitieslarge communitiesother attributes
@@ -155,8 +161,67 @@ from if (state.status) $('s-uptime').textContent = fmtDur(t - state.status.started_at_unix); } const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString(); - const commList = (list, named) => list.length ? list.map(c => - named && WELL_KNOWN[c] ? `${WELL_KNOWN[c]}` : esc(c)).join(' ') : DASH; + const fmtCount = n => Number(n).toLocaleString('en-US'); + + // What a community of ours asks for where control communities are on + // (control_communities), or ''. They are acted on where a route leaves + // our AS. + const TIMES = { 1: 'once', 2: 'twice', 3: 'three times' }; + function controlMeaning(c, large) { + const s = state.status; + if (!s || !s.control_communities) return ''; + const L = s.local_asn, p = c.split(':').map(Number); + if (!large) { + const [high, low] = p; + if (high === 0) return low === L ? 'to no other network' : `not to AS${low}`; + if (high === L) return low === L ? 'to every network' : `only to AS${low} (and others named so)`; + return ''; + } + const [admin, fn, arg] = p; + if (admin !== L) return ''; + if (fn === 0) return arg ? `not to AS${arg}` : 'to no other network'; + if (fn === 1) return `only to AS${arg} (and others named so)`; + if (fn >= 11 && fn <= 13) return `our AS ${TIMES[fn - 10]} more on the path to ${arg ? `AS${arg}` : 'every network'}`; + if (fn === 20) return `not to group ${arg}`; + if (fn === 21) return `only to group ${arg} (and others named so)`; + if (fn >= 31 && fn <= 33) return `our AS ${TIMES[fn - 30]} more on the path to group ${arg}`; + return ''; + } + + // Communities, one line for each AS that defines them, in the order the + // route has them; each whole, so it can be copied. Pointing at a line + // names its AS. Well-known ones by name, and ours that ask for something + // with what they ask. + function commList(list, large) { + if (!list.length) return DASH; + const owners = new Map(); + for (const c of list) { + const owner = c.split(':')[0]; + if (!owners.has(owner)) owners.set(owner, []); + owners.get(owner).push(c); + } + const local = state.status ? String(state.status.local_asn) : null; + const lines = [...owners].map(([owner, cs]) => { + const items = cs.map(c => { + const name = !large && WELL_KNOWN[c]; + if (name) return `${name}`; + const meaning = controlMeaning(c, large); + return meaning + ? `${esc(c)}` + : `${esc(c)}`; + }).join(' '); + // Not for our own AS, nor for 0 and 65535, which are nobody's. + const named = owner !== local && owner !== '0' && owner !== '65535'; + return named ? `
${items}
` : `
${items}
`; + }); + return `
${lines.join('')}
`; + } + + // The attributes that have no column of their own. + const otherList = list => list && list.length + ? list.map(a => `
${esc(a.name)} ${esc(a.value)}
`).join('') + : DASH; + // An AS number, whose holder's name is shown when it is pointed at. const asn = n => `${esc(n)}`; const asPath = p => p.length ? p.map(asn).join(' ') : '(empty)'; @@ -285,8 +350,8 @@ from const local = state.status ? state.status.local_asn : '?'; setHTML(b, state.local.map(r => ` ${esc(r.prefix)}${esc(r.origin)} - ${asPath(Array(1 + r.prepend).fill(local))}${r.med ?? DASH} - ${commList(r.communities, true)}${commList(r.large_communities)} + ${asPath(Array(1 + r.prepend).fill(local))}${r.med ?? DASH} + ${commList(r.communities)}${commList(r.large_communities, true)} ${age(r.announced_at_unix)}`).join('')); } @@ -301,12 +366,12 @@ from : `${shown}${st ? `, for ${st.received_prefixes} prefixes` : ''}`; renderPager(); if (!state.received.length) { - setHTML(b, `${looking ? 'No route matches.' : 'No routes received from peers.'}`); return; + setHTML(b, `${looking ? 'No route matches.' : 'No routes received from peers.'}`); return; } setHTML(b, state.received.map(r => { const s = r.summary; return ` - ${esc(r.prefix)}${r.peer_name ? esc(r.peer_name) + ' ' : ''}${esc(r.peer_addr)} AS${asn(r.peer_asn)}${r.path_id ? ` path ${r.path_id}` : ''}${asPath(s.as_path)} + ${esc(r.prefix)}${r.peer_name ? esc(r.peer_name) + ' ' : ''}${esc(r.peer_addr)} AS${asn(r.peer_asn)}${r.path_id ? ` path ${r.path_id}` : ''}${asPath(s.as_path)} ${s.next_hop ? esc(s.next_hop) : DASH}${s.origin ? esc(s.origin) : DASH}${s.med ?? DASH}${s.local_pref ?? DASH} - ${commList(s.communities, true)}${commList(s.large_communities)} + ${commList(s.communities)}${commList(s.large_communities, true)}${otherList(s.other)} ${r.rpki ? `${esc(r.rpki)}` : DASH} ${r.aspa ? `${esc(r.aspa)}` : DASH}${r.best ? 'best' : ''}${r.stale ? ` stale` : ''}`; }).join('')); } @@ -331,32 +396,33 @@ from const k = state.kernel, b = $('kernel-body'); if (!k) return; if (!k.enabled) { setHTML(b, 'Not configured: the best routes are not put in the kernel, and this machine does not forward by them.'); return; } - const unresolved = Object.entries(k.unresolved).map(([why, n]) => `${n} ${esc(why)}`).join(', '); - const interfaces = k.interfaces.map(i => - `${esc(i.name)} ${i.up ? 'up' : 'down'}` + - (i.addresses.length ? ` ${i.addresses.map(esc).join(' ')}` : '')).join(' · '); - setHTML(b, `${k.synced ? 'In step' : 'Catching up'} with the routing table: ` + - `${k.installed} routes installed (table ${k.table}, metric ${k.metric}, proto bgp)` + - (k.kept ? `; ${k.kept} kept from before the restart, not announced again yet` : '') + - (unresolved ? `; not installed: ${unresolved}` : '') + - (k.refused ? `; ${k.refused} refused by the kernel` : '') + '.' + - (k.last_error ? `
Last error (${k.errors} in all): ${esc(k.last_error)}` : '') + - (interfaces ? `
Interfaces: ${interfaces}` : '')); - } - - // Everything a refused route came with, folded away: the fields of the - // received routes' table, then the attributes as decoded. - function refusedDetail(r) { - const s = r.summary; - const field = (name, value) => `${name}${value}`; - return `
show` + - field('next hop', s.next_hop ? esc(s.next_hop) : DASH) + - field('origin', s.origin ? esc(s.origin) : DASH) + - field('MED', s.med ?? DASH) + - field('local pref', s.local_pref ?? DASH) + - field('communities', commList(s.communities, true)) + - field('large communities', commList(s.large_communities)) + - `
${esc(r.attributes.map(a => JSON.stringify(a)).join('\n'))}
`; + const row = (name, value) => `${name}${value}`; + const unresolved = Object.entries(k.unresolved).map(([why, n]) => `${fmtCount(n)} ${esc(why)}`).join(', '); + const summary = '' + + row('state', `${k.synced ? 'in step' : 'catching up'} with the routing table`) + + row('routes installed', `${fmtCount(k.installed)}`) + + row('installed as', `table ${k.table} metric ${k.metric} proto bgp`) + + (k.kept ? row('kept from before the restart', `${fmtCount(k.kept)}, not announced again yet`) : '') + + (unresolved ? row('not installed', `${unresolved}`) : '') + + (k.refused ? row('refused by the kernel', `${fmtCount(k.refused)}`) : '') + + (k.last_error ? row('last error', `${esc(k.last_error)} (${fmtCount(k.errors)} errors in all)`) : '') + + '
'; + // Interfaces that are up or have addresses get a row each; the others + // (unused ports, mostly) one line between them. + const v4 = a => !a.includes(':'), linkLocal = a => /^fe80:/i.test(a); + const lines = list => list.length ? list.map(esc).join('
') : DASH; + const listed = k.interfaces.filter(i => i.up || i.addresses.length); + const idle = k.interfaces.filter(i => !i.up && !i.addresses.length); + const interfaces = !k.interfaces.length ? '' : + '' + + listed.map(i => ` + + + + `).join('') + + (idle.length ? `` : '') + + '
interfacestateIPv4IPv6IPv6 link-local
${esc(i.name)}${i.up ? 'up' : 'down'}${lines(i.addresses.filter(v4))}${lines(i.addresses.filter(a => !v4(a) && !linkLocal(a)))}${lines(i.addresses.filter(linkLocal))}
Down and without addresses: ${idle.map(i => esc(i.name)).join(' ')}
'; + setHTML(b, summary + interfaces); } // For each peer a line that says how many and why, then its latest few. @@ -366,16 +432,18 @@ from const total = state.refused.reduce((n, p) => n + p.total, 0); const announcements = n => `${n} announcement${n === 1 ? '' : 's'}`; $('refused-count').textContent = total ? `${announcements(total)} from ${state.refused.length} peer${state.refused.length === 1 ? '' : 's'}` : '0'; - if (!state.refused.length) { setHTML(b, 'Nothing a peer announced has been rejected.'); return; } + if (!state.refused.length) { setHTML(b, 'Nothing a peer announced has been rejected.'); return; } setHTML(b, state.refused.map(p => { const why = Object.entries(p.by_reason).sort((a, c) => c[1] - a[1]).map(([reason, n]) => `${n} ${esc(reason)}`).join(', '); const more = p.recent.length > REFUSED_SHOWN ? ` Latest ${REFUSED_SHOWN} shown; the latest ${p.recent.length} as json.` : ''; - return `${esc(p.peer)} ${esc(p.peer_addr)}: ${announcements(p.total)} rejected (${why}).${more}` + - p.recent.slice(0, REFUSED_SHOWN).map(r => ` + return `${esc(p.peer)} ${esc(p.peer_addr)}: ${announcements(p.total)} rejected (${why}).${more}` + + p.recent.slice(0, REFUSED_SHOWN).map(r => { const s = r.summary; return ` ${esc(r.prefix)}${r.path_id ? ` path ${r.path_id}` : ''} - ${asPath(r.as_path)}${esc(r.reason)}${age(r.at_unix)} ago - ${refusedDetail(r)}`).join(''); + ${asPath(r.as_path)}${esc(r.reason)}${age(r.at_unix)} ago + ${s.next_hop ? esc(s.next_hop) : DASH}${s.origin ? esc(s.origin) : DASH} + ${s.med ?? DASH}${s.local_pref ?? DASH} + ${commList(s.communities)}${commList(s.large_communities, true)}${otherList(s.other)}`; }).join(''); }).join('')); } diff --git a/docs/content/docs/reference/http-api.md b/docs/content/docs/reference/http-api.md index 49e2fcc..ef3c16c 100644 --- a/docs/content/docs/reference/http-api.md +++ b/docs/content/docs/reference/http-api.md @@ -22,7 +22,7 @@ with a description of the error. |---|---| | `/` | Redirects to `/statusz`. | | `/statusz` | Status page. | -| `/api/status` | Router ID, AS number, uptime, and table sizes. | +| `/api/status` | Router ID, AS number, uptime, table sizes, and whether control communities are enabled (`control_communities`). | | `/api/config` | The configuration that was read at startup. | | `/api/peers` | Configured neighbors and their sessions. | | `/api/routes/local` | Originated routes. | @@ -122,7 +122,8 @@ Prefixes are sorted by address, with IPv4 prefixes first. "med": null, "local_pref": null, "communities": ["65001:1"], - "large_communities": [] + "large_communities": [], + "other": [{ "name": "aggregator", "value": "AS65001 10.0.0.2" }] }, "attributes": [ ... ] }], @@ -137,6 +138,7 @@ Prefixes are sorted by address, with IPv4 prefixes first. | `best` | Whether the route is the best path for the prefix. | | `stale` | Whether the route is retained from a previous session of the neighbor and has not been advertised again (graceful restart). | | `rpki`, `aspa` | Route origin validation and ASPA verification state, if RPKI validation is configured. | +| `summary` | The path attributes as text. `other` lists, in received order, the attributes that have no field of their own: atomic aggregate, aggregator, extended communities, AS4_PATH, only to customer, originator ID, cluster list, and unrecognized attributes. | | `attributes` | All path attributes, as received. | ## Rejected Routes @@ -164,7 +166,8 @@ routes. To return the records for one neighbor, add `?peer=NAME`. "med": null, "local_pref": null, "communities": [], - "large_communities": [] + "large_communities": [], + "other": [] }, "attributes": [ { "Origin": 0 },