Validate route origins against the RPKI and refuse invalid routes

With rpki { caches { ... } } in the configuration the server keeps the
validated ROA payloads of a validator and validates every route from a
peer outside its own AS (RFC 6811). An invalid route is refused, unless
its peer has accept_rpki_invalid. Valid and not-found routes are taken;
validity does not otherwise enter route selection.

Validity is not settled when a route arrives: the data changes every few
minutes. The routes follow, without the peer sending anything again.

- A route refused as invalid is held by its session, out of the table.
  When the data changes the session looks at what it holds, and what is
  no longer invalid goes into the table. A held route is the peer's to
  withdraw or replace like any other.
- A route in the table that becomes invalid is found by a task that
  looks through the table when the data changes, at the prefixes the
  changed VRPs cover, and is taken out and held by its session.
- If the data expires because no cache confirms it, nothing is invalid
  any more and everything held is taken.
- A session holds at most 200,000 routes; past that it asks the peer for
  a route refresh when the data changes.

So that the table can be looked through without parsing attributes, an
attribute set's key now has the route's origin AS. Memory per route is
unchanged at 52 bytes on the real table.

Shown: /api/rpki and a section of the status page with the cache, its
state and the age of the data; each route's validity; each peer's count
of held routes. Refusals are recorded under "RPKI invalid". Metrics:
bgp_rpki_vrps and bgp_rpki_updates_total.

Also: Negotiated says whether the peer supports route refresh, and
docs/RFCs.md is brought up to date.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 13:39:40 +02:00
1 parent 1f89fb4b13
commit bdaf226a16
30 files changed
+1486 -109

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ axum.workspace = true
bgp-packet.workspace = true
bgp-primitives.workspace = true
bgp-rib.workspace = true
bgp-rpki.workspace = true
bgp-server.workspace = true
futures.workspace = true
prometheus.workspace = true
+86 -8
View File
@@ -3,6 +3,7 @@
use std::collections::{BTreeMap, HashMap};
use std::net::IpAddr;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::Json;
@@ -16,6 +17,7 @@ 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_server::session::{ConfiguredPeer, DialState, PeerId, SessionHandle, SessionRegistry};
use serde::{Deserialize, Serialize};
@@ -73,6 +75,9 @@ pub struct PeerView {
/// How many routes the peer announced on this session that its import
/// 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.
pub held_routes: Option<u64>,
/// Why the last connection attempt or session ended, while in Backoff.
pub last_error: Option<String>,
pub retry_at_unix: Option<u64>,
@@ -95,6 +100,7 @@ impl From<&SessionHandle> for PeerView {
add_path_send: Some(s.add_path_send.iter().map(|f| f.to_string()).collect()),
routes: Some(s.counters.routes()),
rejected_routes: Some(s.counters.rejected()),
held_routes: Some(s.counters.held()),
last_error: None,
retry_at_unix: None,
}
@@ -125,6 +131,7 @@ impl PeerView {
add_path_send: None,
routes: None,
rejected_routes: None,
held_routes: None,
last_error,
retry_at_unix,
}
@@ -262,6 +269,11 @@ pub struct ReceivedRouteView {
pub path_id: u32,
/// Whether this is the route chosen for its prefix.
pub best: bool,
/// What route origin validation says of the route: `valid` or
/// `not found`. (An invalid route is not in the table, unless its
/// peer's are taken regardless; then `invalid`.) Absent where
/// validation is not configured.
pub rpki: 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.
@@ -270,23 +282,29 @@ 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(HashMap<PeerIndex, (PeerId, Option<String>)>);
pub(crate) struct PeerNames {
peers: HashMap<PeerIndex, (PeerId, Option<String>)>,
/// What routes are validated against, where they are.
vrps: Option<Arc<VrpTable>>,
}
impl PeerNames {
pub(crate) fn of(registry: &SessionRegistry) -> Self {
Self(
registry
pub(crate) fn of(state: &AppState) -> Self {
Self {
peers: state
.registry
.list()
.into_iter()
.map(|session| (session.rib_peer, (session.peer, session.name)))
.collect(),
)
vrps: state.rpki.as_ref().map(|rpki| rpki.current().table),
}
}
/// The peers that `wanted` names: by configured name, or by address.
fn matching(&self, wanted: &str) -> Vec<PeerIndex> {
let address: Option<IpAddr> = wanted.parse().ok();
self.0
self.peers
.iter()
.filter(|(_, (peer, name))| {
name.as_deref() == Some(wanted) || address == Some(peer.addr.ip())
@@ -298,7 +316,8 @@ 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.0.get(&route.peer)?;
let (peer, name) = 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();
Some(ReceivedRouteView {
@@ -308,6 +327,10 @@ impl PeerNames {
prefix: route.prefix.to_string(),
path_id: route.path_id,
best: route.best,
rpki: self
.vrps
.as_ref()
.map(|vrps| vrps.validate(&route.prefix, origin).as_str()),
summary: summarize(&attributes, next_hop.as_ref()),
attributes,
})
@@ -534,7 +557,7 @@ pub async fn received_routes(
.unwrap_or(DEFAULT_ROUTE_LIMIT)
.min(MAX_ROUTE_LIMIT);
let scope = Scope::of(&query)?;
let names = PeerNames::of(&state.registry);
let names = PeerNames::of(&state);
// Looking through the table, copying, parsing and sorting many routes
// takes long enough to keep off the threads that sessions run on.
let routes =
@@ -545,6 +568,61 @@ pub async fn received_routes(
Ok(Json(routes))
}
/// Route origin validation: where the data comes from and how fresh it is.
#[derive(Debug, Clone, Serialize)]
pub struct RpkiView {
/// Whether validation is configured at all.
pub enabled: bool,
/// The cache in use, or last tried.
pub cache: Option<String>,
/// `connecting`, `synchronising` or `synchronised`.
pub state: Option<&'static str>,
pub session_id: Option<u16>,
pub serial: Option<u32>,
pub protocol_version: Option<u8>,
pub vrps_ipv4: usize,
pub vrps_ipv6: usize,
pub updated_at_unix: Option<u64>,
/// When the data is dropped unless a cache confirms it first.
pub expires_at_unix: Option<u64>,
pub last_error: Option<String>,
}
pub async fn rpki(State(state): State<AppState>) -> Json<RpkiView> {
let Some(status) = state.rpki.as_ref().map(|rpki| rpki.status()) else {
return Json(RpkiView {
enabled: false,
cache: None,
state: None,
session_id: None,
serial: None,
protocol_version: None,
vrps_ipv4: 0,
vrps_ipv6: 0,
updated_at_unix: None,
expires_at_unix: None,
last_error: None,
});
};
Json(RpkiView {
enabled: true,
cache: status.cache.map(|cache| cache.to_string()),
state: Some(match status.state {
CacheState::Connecting => "connecting",
CacheState::Synchronising => "synchronising",
CacheState::Synchronised => "synchronised",
}),
session_id: status.session_id,
serial: status.serial,
protocol_version: Some(status.protocol_version),
vrps_ipv4: status.vrps_ipv4,
vrps_ipv6: status.vrps_ipv6,
updated_at_unix: status.updated_at.map(unix_secs),
expires_at_unix: status.expires_at.map(unix_secs),
last_error: status.last_error,
})
}
/// What one peer announced that was refused.
#[derive(Debug, Clone, Serialize)]
pub struct RefusalsView {
+2 -2
View File
@@ -97,7 +97,7 @@ impl WireEvent {
/// own, so one that reads slowly holds nobody else up.
fn best_routes(state: &AppState) -> impl Stream<Item = WireEvent> + use<> {
let (events, stream) = mpsc::channel(BEST_ROUTE_BUFFER);
let registry = state.registry.clone();
let state_for_views = state.clone();
// Taken before this returns, so that nothing that happens after the
// client is answered is missed.
let mut reader = state.rib.reader_from_now();
@@ -140,7 +140,7 @@ fn best_routes(state: &AppState) -> impl Stream<Item = WireEvent> + use<> {
}
_ => {}
});
let names = PeerNames::of(&registry);
let names = PeerNames::of(&state_for_views);
for change in changes {
let event = match change {
Change::Best(route) => match names.view(&route) {
+5 -1
View File
@@ -19,6 +19,7 @@ use axum::response::{Html, IntoResponse, Redirect};
use axum::routing::get;
use bgp_rib::LocalRib;
use bgp_rib::table::RibTable;
use bgp_rpki::Rpki;
use bgp_server::refusals::RefusalLog;
use bgp_server::session::SessionRegistry;
use tokio::net::TcpListener;
@@ -26,7 +27,7 @@ use tracing::info;
pub use api::{
AttrSummary, ConfigView, LocalRouteView, PeerView, ReceivedRouteView, RefusalsView,
RefusedRouteView, StatusView,
RefusedRouteView, RpkiView, StatusView,
};
/// Static facts about the router shown in status output.
@@ -87,6 +88,8 @@ pub struct AppState {
pub rib: Arc<RibTable>,
/// What peers announced that was not taken.
pub refusals: RefusalLog,
/// The data for route origin validation, where that is configured.
pub rpki: Option<Rpki>,
}
/// The status page, embedded so the binary is self-contained.
@@ -110,6 +113,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/routes/local", get(api::local_routes))
.route("/api/routes/received", get(api::received_routes))
.route("/api/routes/rejected", get(api::rejected_routes))
.route("/api/rpki", get(api::rpki))
.route("/api/events", get(events::stream))
.route("/metricz", get(metrics::metricz))
.with_state(state)
+27 -7
View File
@@ -47,7 +47,7 @@ Router ID <b id="s-router-id">...</b>, local AS <b id="s-asn">...</b><br>
</div>
<div class="righthand">
Running on <span id="s-hostname">...</span><br>
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">originated routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/routes/rejected">refused routes json</a><br>
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">originated routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/routes/rejected">refused routes json</a>, <a href="/api/rpki">rpki json</a><br>
<span id="refresh-ctl"></span><br>
</div>
</div>
@@ -63,6 +63,9 @@ 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>
<div id="rpki-body">...</div>
<h1 id="local">Originated routes (<span id="local-count">...</span>)</h1>
<table class="data">
<thead><tr><th>prefix</th><th>origin</th><th>AS path</th><th>MED</th><th>communities</th><th>large communities</th><th>since</th></tr></thead>
@@ -83,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>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>best</th></tr></thead>
<tbody id="received-body"></tbody>
</table>
@@ -111,7 +114,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const state = { status: null, peers: [], local: [], received: [], refused: [] };
const state = { status: null, peers: [], local: [], received: [], refused: [], rpki: null };
const DASH = '<span class="dim">-</span>';
// ---- helpers -----------------------------------------------------------
@@ -178,7 +181,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
<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) : 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 invalid and kept aside, to be taken if that changes">(${p.held_routes} held)</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
}).join(''));
renderPeerChoices();
}
@@ -247,12 +250,28 @@ 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="10" class="dim">${looking ? 'No route matches.' : 'No routes received from peers.'}</td></tr>`); return;
setHTML(b, `<tr><td colspan="11" 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.best ? '<span class="ok">best</span>' : ''}</td></tr>`; }).join(''));
<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(''));
}
const RPKI_CLASS = { valid: 'ok', invalid: 'bad', 'not found': '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.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.last_error ? `<br><span class="bad">Last error:</span> ${esc(r.last_error)}` : ''));
}
// For each peer a line that says how many and why, then its latest few.
@@ -290,7 +309,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
renderReceived();
}
async function loadRefused() { state.refused = await getJSON('/api/routes/rejected'); renderRefused(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadRefused()]); }
async function loadRpki() { state.rpki = await getJSON('/api/rpki'); renderRpki(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadRefused(), loadRpki()]); }
// ---- events ------------------------------------------------------------
function logEvent(html) {