Verify AS paths with ASPAs, over RTR version 2
Origin validation says who may originate a prefix and nothing about the
rest of the path: it does not catch a route leak, nor a hijack that keeps
the right origin at the end of a made-up path. ASPAs, in which a network
lists its providers, do.
bgp-rpki:
- aspa: the ASPAs and the upstream and downstream verification procedures
of draft-ietf-sidrops-aspa-verification-24, tested against the draft's
wording as written.
- rtr: protocol version 2 (draft-ietf-sidrops-8210bis-23) with the ASPA
PDU and the new error codes.
- client: asks for version 2 and falls back to whatever older version the
cache speaks; keeps ASPAs with the VRPs, applied whole or not at all;
publishes which customers' ASPAs changed; tells the cache why it is
being left (Error Report); does not ask one version's serial of another.
Server:
- A peer has a role (provider, customer, peer, route server, route server
client), which decides how its paths are verified. Without one they are
not.
- A route whose path is invalid is refused ("ASPA invalid") and held, like
one with an invalid origin; accept_aspa_invalid exempts a peer.
- When ASPAs change, routes in the table whose path has a changed customer
on it are verified again, and held routes are released when they pass
both checks.
- The revalidator finds a route's peer by index instead of searching a
list for every route.
The status page and API show each peer's role, each route's path validity,
the number of ASPAs and the RTR version in use; bgp_rpki_aspas is on
/metricz.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
bdaf226a16
commit
8086aa994f
35 files changed
+2484
-359
No files matched your search
+42
-12
@@ -3,7 +3,6 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::Json;
|
||||
@@ -17,7 +16,9 @@ use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::table::{PeerIndex, RibTable, Route};
|
||||
use bgp_rib::{LocalRoute, RibStopped};
|
||||
use bgp_rpki::{CacheState, VrpTable};
|
||||
use bgp_rpki::{CacheState, Update};
|
||||
use bgp_server::policy::PeerRole;
|
||||
use bgp_server::rpki::PathCheck;
|
||||
use bgp_server::session::{ConfiguredPeer, DialState, PeerId, SessionHandle, SessionRegistry};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -76,8 +77,11 @@ pub struct PeerView {
|
||||
/// policy refused.
|
||||
pub rejected_routes: Option<u64>,
|
||||
/// How many of the peer's routes are held out of the table as invalid
|
||||
/// by route origin validation, to be taken if that changes.
|
||||
/// by the RPKI's data, to be taken if that changes.
|
||||
pub held_routes: Option<u64>,
|
||||
/// What the peer is to us, if configured: `provider`, `customer`,
|
||||
/// `peer`, `route server` or `route server client`.
|
||||
pub role: Option<&'static str>,
|
||||
/// Why the last connection attempt or session ended, while in Backoff.
|
||||
pub last_error: Option<String>,
|
||||
pub retry_at_unix: Option<u64>,
|
||||
@@ -101,6 +105,7 @@ impl From<&SessionHandle> for PeerView {
|
||||
routes: Some(s.counters.routes()),
|
||||
rejected_routes: Some(s.counters.rejected()),
|
||||
held_routes: Some(s.counters.held()),
|
||||
role: s.role.map(PeerRole::as_str),
|
||||
last_error: None,
|
||||
retry_at_unix: None,
|
||||
}
|
||||
@@ -132,6 +137,7 @@ impl PeerView {
|
||||
routes: None,
|
||||
rejected_routes: None,
|
||||
held_routes: None,
|
||||
role: peer.role.map(PeerRole::as_str),
|
||||
last_error,
|
||||
retry_at_unix,
|
||||
}
|
||||
@@ -274,6 +280,11 @@ pub struct ReceivedRouteView {
|
||||
/// peer's are taken regardless; then `invalid`.) Absent where
|
||||
/// validation is not configured.
|
||||
pub rpki: Option<&'static str>,
|
||||
/// What the ASPAs say of the route's path: `valid` or `unknown`
|
||||
/// (`invalid` only if the peer's are taken regardless). Absent where
|
||||
/// validation is not configured, for a peer whose role is not, and
|
||||
/// for one in our own AS.
|
||||
pub aspa: Option<&'static str>,
|
||||
pub summary: AttrSummary,
|
||||
/// The decoded attribute list. MP_REACH_NLRI is not in it: its next hop
|
||||
/// is in the summary and its prefixes are routes of their own.
|
||||
@@ -283,9 +294,9 @@ pub struct ReceivedRouteView {
|
||||
/// Who the routes in the table are from: the table knows peers by number,
|
||||
/// the sessions know which number is whose.
|
||||
pub(crate) struct PeerNames {
|
||||
peers: HashMap<PeerIndex, (PeerId, Option<String>)>,
|
||||
peers: HashMap<PeerIndex, (PeerId, Option<String>, Option<PathCheck>)>,
|
||||
/// What routes are validated against, where they are.
|
||||
vrps: Option<Arc<VrpTable>>,
|
||||
rpki: Option<Update>,
|
||||
}
|
||||
|
||||
impl PeerNames {
|
||||
@@ -295,9 +306,17 @@ impl PeerNames {
|
||||
.registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|session| (session.rib_peer, (session.peer, session.name)))
|
||||
.map(|session| {
|
||||
// How the peer's paths are verified, or would be if
|
||||
// its invalid routes were not taken regardless.
|
||||
let check = session
|
||||
.role
|
||||
.filter(|_| !session.ibgp)
|
||||
.map(|role| PathCheck::new(role, session.peer.asn));
|
||||
(session.rib_peer, (session.peer, session.name, check))
|
||||
})
|
||||
.collect(),
|
||||
vrps: state.rpki.as_ref().map(|rpki| rpki.current().table),
|
||||
rpki: state.rpki.as_ref().map(|rpki| rpki.current()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +325,7 @@ impl PeerNames {
|
||||
let address: Option<IpAddr> = wanted.parse().ok();
|
||||
self.peers
|
||||
.iter()
|
||||
.filter(|(_, (peer, name))| {
|
||||
.filter(|(_, (peer, name, _))| {
|
||||
name.as_deref() == Some(wanted) || address == Some(peer.addr.ip())
|
||||
})
|
||||
.map(|(index, _)| *index)
|
||||
@@ -316,7 +335,7 @@ impl PeerNames {
|
||||
/// A view of one route. `None` if the route's session has just ended:
|
||||
/// the route is on its way out of the table.
|
||||
pub(crate) fn view(&self, route: &Route) -> Option<ReceivedRouteView> {
|
||||
let (peer, name) = self.peers.get(&route.peer)?;
|
||||
let (peer, name, check) = self.peers.get(&route.peer)?;
|
||||
let origin = Some(route.attrs.key().origin_as).filter(|origin| *origin != 0);
|
||||
// What a session stored was accepted by the decoder, and parses.
|
||||
let (attributes, next_hop) = route.attrs.attributes().unwrap_or_default();
|
||||
@@ -328,9 +347,14 @@ impl PeerNames {
|
||||
path_id: route.path_id,
|
||||
best: route.best,
|
||||
rpki: self
|
||||
.vrps
|
||||
.rpki
|
||||
.as_ref()
|
||||
.map(|vrps| vrps.validate(&route.prefix, origin).as_str()),
|
||||
.map(|rpki| rpki.vrps.validate(&route.prefix, origin).as_str()),
|
||||
aspa: self.rpki.as_ref().zip(*check).map(|(rpki, check)| {
|
||||
let mut path = Vec::new();
|
||||
let has_set = route.attrs.as_path(&mut path);
|
||||
check.verify(&rpki.aspas, &path, has_set).as_str()
|
||||
}),
|
||||
summary: summarize(&attributes, next_hop.as_ref()),
|
||||
attributes,
|
||||
})
|
||||
@@ -568,7 +592,8 @@ pub async fn received_routes(
|
||||
Ok(Json(routes))
|
||||
}
|
||||
|
||||
/// Route origin validation: where the data comes from and how fresh it is.
|
||||
/// Validation with the RPKI: where the data comes from and how fresh it
|
||||
/// is.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RpkiView {
|
||||
/// Whether validation is configured at all.
|
||||
@@ -582,6 +607,9 @@ pub struct RpkiView {
|
||||
pub protocol_version: Option<u8>,
|
||||
pub vrps_ipv4: usize,
|
||||
pub vrps_ipv6: usize,
|
||||
/// How many ASes have an ASPA. A cache that speaks a version of the
|
||||
/// protocol before 2 has none to give.
|
||||
pub aspas: usize,
|
||||
pub updated_at_unix: Option<u64>,
|
||||
/// When the data is dropped unless a cache confirms it first.
|
||||
pub expires_at_unix: Option<u64>,
|
||||
@@ -599,6 +627,7 @@ pub async fn rpki(State(state): State<AppState>) -> Json<RpkiView> {
|
||||
protocol_version: None,
|
||||
vrps_ipv4: 0,
|
||||
vrps_ipv6: 0,
|
||||
aspas: 0,
|
||||
updated_at_unix: None,
|
||||
expires_at_unix: None,
|
||||
last_error: None,
|
||||
@@ -617,6 +646,7 @@ pub async fn rpki(State(state): State<AppState>) -> Json<RpkiView> {
|
||||
protocol_version: Some(status.protocol_version),
|
||||
vrps_ipv4: status.vrps_ipv4,
|
||||
vrps_ipv6: status.vrps_ipv6,
|
||||
aspas: status.aspas,
|
||||
updated_at_unix: status.updated_at.map(unix_secs),
|
||||
expires_at_unix: status.expires_at.map(unix_secs),
|
||||
last_error: status.last_error,
|
||||
|
||||
@@ -63,7 +63,7 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
||||
<tbody id="peers-body"></tbody>
|
||||
</table>
|
||||
|
||||
<h1 id="rpki">Route origin validation (RPKI)</h1>
|
||||
<h1 id="rpki">Route origin and path validation (RPKI)</h1>
|
||||
<div id="rpki-body">...</div>
|
||||
|
||||
<h1 id="local">Originated routes (<span id="local-count">...</span>)</h1>
|
||||
@@ -86,7 +86,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
<span id="lookup-note"></span>
|
||||
</form>
|
||||
<table class="data">
|
||||
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>local pref</th><th>communities</th><th>large communities</th><th>RPKI</th><th>best</th></tr></thead>
|
||||
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>local pref</th><th>communities</th><th>large communities</th><th>RPKI</th><th>ASPA</th><th>best</th></tr></thead>
|
||||
<tbody id="received-body"></tbody>
|
||||
</table>
|
||||
|
||||
@@ -175,13 +175,13 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
const up = p.state === 'Established';
|
||||
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
|
||||
return `<tr>
|
||||
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}</td>
|
||||
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}${p.role ? ` <span class="dim">${esc(p.role)}</span>` : ''}</td>
|
||||
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
|
||||
<td class="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
|
||||
<td>${up ? age(p.established_at_unix) : DASH}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH}</td>
|
||||
<td>${up ? esc((p.address_families || []).join(', ')) + addPath(p) : DASH}</td>
|
||||
<td>${up ? p.routes : DASH}</td>
|
||||
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#refused">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` <span class="dim" title="Refused as RPKI invalid and kept aside, to be taken if that changes">(${p.held_routes} held)</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
|
||||
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#refused">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` <span class="dim" title="Refused as RPKI or ASPA invalid and kept aside, to be taken if that changes">(${p.held_routes} held)</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
|
||||
}).join(''));
|
||||
renderPeerChoices();
|
||||
}
|
||||
@@ -250,27 +250,29 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
? `showing ${state.received.length} of ${st.received_routes}, for ${st.received_prefixes} prefixes`
|
||||
: `${state.received.length}${st ? `, for ${st.received_prefixes} prefixes` : ''}`;
|
||||
if (!state.received.length) {
|
||||
setHTML(b, `<tr><td colspan="11" class="dim">${looking ? 'No route matches.' : 'No routes received from peers.'}</td></tr>`); return;
|
||||
setHTML(b, `<tr><td colspan="12" class="dim">${looking ? 'No route matches.' : 'No routes received from peers.'}</td></tr>`); return;
|
||||
}
|
||||
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr>
|
||||
<td class="mono">${esc(r.prefix)}</td><td class="mono">${r.peer_name ? esc(r.peer_name) + ' ' : ''}${esc(r.peer_addr)} <span class="dim">AS${r.peer_asn}${r.path_id ? ` path ${r.path_id}` : ''}</span></td><td class="mono">${asPath(s.as_path)}</td>
|
||||
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td><td>${s.med ?? DASH}</td><td>${s.local_pref ?? DASH}</td>
|
||||
<td class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(s.large_communities)}</td>
|
||||
<td>${r.rpki ? `<span class="${RPKI_CLASS[r.rpki] || ''}">${esc(r.rpki)}</span>` : DASH}</td><td>${r.best ? '<span class="ok">best</span>' : ''}</td></tr>`; }).join(''));
|
||||
<td>${r.rpki ? `<span class="${RPKI_CLASS[r.rpki] || ''}">${esc(r.rpki)}</span>` : DASH}</td>
|
||||
<td>${r.aspa ? `<span class="${RPKI_CLASS[r.aspa] || ''}">${esc(r.aspa)}</span>` : DASH}</td><td>${r.best ? '<span class="ok">best</span>' : ''}</td></tr>`; }).join(''));
|
||||
}
|
||||
|
||||
const RPKI_CLASS = { valid: 'ok', invalid: 'bad', 'not found': 'dim' };
|
||||
const RPKI_CLASS = { valid: 'ok', invalid: 'bad', 'not found': 'dim', unknown: 'dim' };
|
||||
const RPKI_STATE_CLASS = { synchronised: 'ok', synchronising: 'warn', connecting: 'bad' };
|
||||
function renderRpki() {
|
||||
const r = state.rpki, b = $('rpki-body');
|
||||
if (!r) return;
|
||||
if (!r.enabled) { setHTML(b, '<span class="dim">Not configured: routes are not validated.</span>'); return; }
|
||||
const data = r.updated_at_unix
|
||||
? `${r.vrps_ipv4} IPv4 and ${r.vrps_ipv6} IPv6 VRPs at serial ${r.serial ?? '?'}, confirmed ${age(r.updated_at_unix)} ago` +
|
||||
? `${r.vrps_ipv4} IPv4 and ${r.vrps_ipv6} IPv6 VRPs and ${r.aspas} ASPAs at serial ${r.serial ?? '?'}, confirmed ${age(r.updated_at_unix)} ago` +
|
||||
(r.expires_at_unix ? `, dropped in ${until(r.expires_at_unix)} unless confirmed again` : '')
|
||||
: '<span class="warn">no data: every route is "not found" and none is refused as invalid</span>';
|
||||
setHTML(b, `Cache <span class="mono">${esc(r.cache || '?')}</span>: <span class="${RPKI_STATE_CLASS[r.state] || ''}">${esc(r.state)}</span>` +
|
||||
` <span class="dim">(RTR version ${r.protocol_version})</span>. ${data}.` +
|
||||
(r.updated_at_unix && r.protocol_version < 2 ? '<br><span class="warn">This cache speaks an RTR version without ASPAs: AS paths are not verified.</span>' : '') +
|
||||
(r.last_error ? `<br><span class="bad">Last error:</span> ${esc(r.last_error)}` : ''));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user