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 },