Keep the path attributes of rejected routes, and show them

A rejected route was recorded with its prefix, reason and AS path, which
is not enough to tell why a neighbor sent it: whether a /32 is a
blackhole request, which communities a leaked route carries, which
network marked it Only to Customer.

Each recorded rejection now keeps the attributes of the UPDATE it came
in, without the NLRI of MP_REACH_NLRI and MP_UNREACH_NLRI, and its next
hop. The attributes are copied once per UPDATE, only when a route of it
is rejected, and shared by its rejected routes. A route rejected later,
when the RPKI data changes, has the attributes it was stored with.

/api/routes/rejected gives them as `summary` and `attributes`, as for
received routes, and the status page shows them under "show" in each
row of the refused routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Opus 5.5 committed 2026-10-04 01:03:16 +02:00
1 parent e1d819d629
commit bd674002a2
7 files changed
+166 -29

No files matched your search

@@ -250,6 +250,20 @@ async fn test_refused_routes_can_be_seen_and_counted() -> Result<()> {
row("10.0.0.0/8", "bogon prefix", "[3333]"),
]
);
// And with all its attributes, as for a route that was taken, to see
// what came along with it.
let latest = &record["recent"][0];
assert_eq!(latest["summary"]["next_hop"], "192.0.2.1");
assert_eq!(latest["summary"]["origin"], "IGP");
assert_eq!(
latest["summary"]["as_path"],
serde_json::json!([3333, 2121])
);
assert_eq!(
latest["attributes"].as_array().map(Vec::len),
Some(3),
"ORIGIN, AS_PATH and NEXT_HOP: {latest}"
);
// Asked for by peer, by name or by address.
for peer in ["refused-demo", "127.0.0.1"] {
let one: Value = get(&format!("/api/routes/rejected?peer={peer}"))
+54 -9
View File
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime};
use bgp_packet::message::{NlriNextHop, PathAttribute};
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
use prometheus::IntCounter;
use tracing::warn;
@@ -42,9 +43,25 @@ pub struct RefusedRoute {
pub reason: Rejection,
/// The AS path it came with.
pub as_path: Arc<[u32]>,
/// All its path attributes, without MP_REACH_NLRI and MP_UNREACH_NLRI:
/// their prefixes are routes of their own, and the next hop is below.
/// As the peer sent them; for a route refused on a second look (the
/// RPKI's data changed), as they were kept, with what the import policy
/// put on. Shared by the routes refused from one UPDATE.
pub attributes: Arc<[PathAttribute]>,
/// Its next hop, if it came in MP_REACH_NLRI. That of an IPv4 route in
/// the body of the UPDATE is in the NEXT_HOP attribute.
pub next_hop: Option<NlriNextHop>,
pub at: SystemTime,
}
/// What the routes of one UPDATE came with, for [`PeerRefusals::note`].
pub(crate) struct Came<'a> {
pub(crate) as_path: &'a Arc<[u32]>,
pub(crate) attributes: &'a Arc<[PathAttribute]>,
pub(crate) next_hop: Option<&'a NlriNextHop>,
}
/// The refusals of one peer.
pub struct PeerRefusals {
/// What the peer is called in the log and in metrics: its configured
@@ -78,9 +95,9 @@ impl PeerRefusals {
}
}
/// Notes that `routes`, which came with the AS path `as_path`, were
/// refused for `reason`.
pub(crate) fn note(&self, routes: &[Nlri], reason: Rejection, as_path: &Arc<[u32]>) {
/// Notes that `routes`, which came with what `came` says, were refused
/// for `reason`.
pub(crate) fn note(&self, routes: &[Nlri], reason: Rejection, came: &Came<'_>) {
let Some(latest) = routes.last() else {
return;
};
@@ -100,7 +117,9 @@ impl PeerRefusals {
prefix: route.prefix,
path_id: route.path_id,
reason,
as_path: Arc::clone(as_path),
as_path: Arc::clone(came.as_path),
attributes: Arc::clone(came.attributes),
next_hop: came.next_hop.copied(),
at,
});
}
@@ -118,7 +137,7 @@ impl PeerRefusals {
peer = %self.label,
prefix = %latest.prefix,
reason = reason.as_str(),
as_path = ?&as_path[..],
as_path = ?&came.as_path[..],
others_since_last_report = state.unlogged + count - 1,
"Refused a route"
);
@@ -193,6 +212,8 @@ impl RefusalLog {
#[cfg(test)]
mod tests {
use bgp_packet::message::{AsPathAttribute, OriginPathAttribute};
use super::*;
fn route(n: u32) -> Nlri {
@@ -210,8 +231,19 @@ mod tests {
assert!(log.peers().is_empty(), "nothing refused yet");
let path: Arc<[u32]> = vec![3333, 64512].into();
record.note(&[route(1), route(2)], Rejection::BogonAs, &path);
record.note(&[route(3)], Rejection::TooSpecific, &path);
let attributes: Arc<[PathAttribute]> = vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![3333, 64512]),
]
.into();
let next_hop = NlriNextHop::Ipv6("2001:db8::1".parse().unwrap());
let came = Came {
as_path: &path,
attributes: &attributes,
next_hop: Some(&next_hop),
};
record.note(&[route(1), route(2)], Rejection::BogonAs, &came);
record.note(&[route(3)], Rejection::TooSpecific, &came);
assert_eq!(record.total(), 3);
assert_eq!(
@@ -226,6 +258,13 @@ mod tests {
);
assert_eq!(recent[0].reason, Rejection::TooSpecific);
assert_eq!(&recent[2].as_path[..], [3333, 64512]);
// The whole of what the route came with is kept, to be looked at.
assert_eq!(recent[2].attributes, attributes);
assert_eq!(recent[2].next_hop, Some(next_hop));
assert!(
Arc::ptr_eq(&recent[0].attributes, &recent[2].attributes),
"routes from one UPDATE share their attributes"
);
// The peer's next session finds the same record.
let again = log.of_peer(address, "another-name");
@@ -240,10 +279,16 @@ mod tests {
fn only_so_many_refused_routes_are_kept() {
let record = PeerRefusals::new("refusals-test-2");
let path: Arc<[u32]> = vec![3333].into();
let attributes: Arc<[PathAttribute]> = Vec::new().into();
let came = Came {
as_path: &path,
attributes: &attributes,
next_hop: None,
};
let many: Vec<Nlri> = (0..1000).map(route).collect();
record.note(&many, Rejection::BogonPrefix, &path);
record.note(&many, Rejection::BogonPrefix, &came);
for n in 1000..1010 {
record.note(&[route(n)], Rejection::BogonPrefix, &path);
record.note(&[route(n)], Rejection::BogonPrefix, &came);
}
assert_eq!(record.total(), 1010);
+41 -11
View File
@@ -14,7 +14,7 @@ use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable};
use bgp_rpki::{PathValidity, Validity};
use crate::policy::{self, ImportPolicy, Rejection, RouteSource};
use crate::refusals::PeerRefusals;
use crate::refusals::{Came, PeerRefusals};
use crate::rpki::Checks;
/// The most routes of one peer that are held aside as invalid. Far more
@@ -127,6 +127,7 @@ pub(crate) fn apply(
.check_path(&update.path_attributes, &import.from),
path_invalid: OnceCell::new(),
as_path: OnceCell::new(),
refused_attributes: OnceCell::new(),
mark: only_to_customer_mark(import, update),
changed: OnceCell::new(),
};
@@ -177,9 +178,10 @@ struct Applying<'a> {
/// Whether the ASPAs find the path invalid: asked only if a route
/// gets as far as that mattering.
path_invalid: OnceCell<bool>,
/// The path the routes came with, for the record of what is refused:
/// made only if something is.
/// The path the routes came with, and all their attributes, for the
/// record of what is refused: made only if something is.
as_path: OnceCell<Arc<[u32]>>,
refused_attributes: OnceCell<Arc<[PathAttribute]>>,
/// See [`only_to_customer_mark`].
mark: Option<u32>,
/// The UPDATE's attributes as the policy changes them, if it does:
@@ -224,7 +226,7 @@ impl Applying<'_> {
let (origin, used_next_hop) = match self.judge_together(family, next_hop) {
Ok(judged) => judged,
Err(rejection) => {
self.refuse(nlri, rejection);
self.refuse(nlri, rejection, next_hop);
return Ok(());
}
};
@@ -251,7 +253,7 @@ impl Applying<'_> {
if rejection.may_pass() {
invalid.push(*route);
}
self.refuse(std::slice::from_ref(route), rejection);
self.refuse(std::slice::from_ref(route), rejection, next_hop);
}
}
}
@@ -327,11 +329,18 @@ impl Applying<'_> {
Ok(())
}
/// Notes that `refused` were not taken, and takes out what the peer
/// had announced for them before.
fn refuse(&mut self, refused: &[Nlri], rejection: Rejection) {
let as_path = self.as_path.get_or_init(|| path_of(self.update));
self.import.refusals.note(refused, rejection, as_path);
/// Notes that `refused`, whose next hop is `next_hop` if they came in
/// MP_REACH_NLRI, were not taken, and takes out what the peer had
/// announced for them before.
fn refuse(&mut self, refused: &[Nlri], rejection: Rejection, next_hop: Option<&NlriNextHop>) {
let came = Came {
as_path: self.as_path.get_or_init(|| path_of(self.update)),
attributes: self
.refused_attributes
.get_or_init(|| attributes_of(self.update)),
next_hop,
};
self.import.refusals.note(refused, rejection, &came);
self.applied.rejected += refused.len();
self.applied.outcome += self.table.withdraw(self.import.peer, refused);
}
@@ -398,6 +407,22 @@ fn path_is_invalid(import: &Import<'_>, update: &UpdateMessage) -> bool {
check.verify(aspas, &path, has_set) == PathValidity::Invalid
}
/// The UPDATE's path attributes without its prefixes: MP_REACH_NLRI and
/// MP_UNREACH_NLRI, which can hold thousands, are left out.
fn attributes_of(update: &UpdateMessage) -> Arc<[PathAttribute]> {
update
.path_attributes
.iter()
.filter(|attribute| {
!matches!(
attribute,
PathAttribute::MpReachNlri(_) | PathAttribute::MpUnreachNlri(_)
)
})
.cloned()
.collect()
}
/// The AS numbers of the UPDATE's AS_PATH.
fn path_of(update: &UpdateMessage) -> Arc<[u32]> {
let path = update
@@ -444,10 +469,15 @@ pub(crate) fn revalidate(
applied.outcome += table.withdraw(import.peer, std::slice::from_ref(suspect));
applied.rejected += 1;
route.attrs.as_path(&mut path);
let (attributes, next_hop) = route.attrs.attributes().unwrap_or_default();
import.refusals.note(
std::slice::from_ref(suspect),
rejection,
&Arc::from(&path[..]),
&Came {
as_path: &Arc::from(&path[..]),
attributes: &attributes.into(),
next_hop: next_hop.as_ref(),
},
);
held.hold(*suspect, &route.attrs);
}
+8
View File
@@ -950,6 +950,12 @@ pub struct RefusedRouteView {
pub reason: &'static str,
pub as_path: Vec<u32>,
pub at_unix: u64,
/// What the route came with, as for a received route: its next hop,
/// origin, MED, local preference and communities.
pub summary: AttrSummary,
/// The decoded attribute list, as for a received route. MP_REACH_NLRI
/// is not in it: its next hop is in the summary.
pub attributes: Vec<PathAttribute>,
}
#[derive(Debug, Default, Deserialize)]
@@ -992,6 +998,8 @@ pub async fn rejected_routes(
reason: route.reason.as_str(),
as_path: route.as_path.to_vec(),
at_unix: unix_secs(route.at),
summary: summarize(&route.attributes, route.next_hop.as_ref()),
attributes: route.attributes.to_vec(),
})
.collect(),
})
+24 -4
View File
@@ -27,6 +27,10 @@ table.data th { background: #dde; font-weight: bold; }
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
#event-log div { white-space: pre-wrap; }
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; }
@@ -100,7 +104,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
<h1 id="refused">Refused routes (<span id="refused-count">...</span>)</h1>
<div class="dim">Routes that peers announced and their import policies did not take. The counts are since the daemon started; the latest refusals of each peer are kept.</div>
<table class="data">
<thead><tr><th>peer</th><th>prefix</th><th>AS path</th><th>reason</th><th>when</th></tr></thead>
<thead><tr><th>peer</th><th>prefix</th><th>AS path</th><th>reason</th><th>when</th><th>attributes</th></tr></thead>
<tbody id="refused-body"></tbody>
</table>
@@ -325,21 +329,37 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
(interfaces ? `<br>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) => `<tr><td class="dim">${name}</td><td class="mono">${value}</td></tr>`;
return `<details class="refused-detail"><summary>show</summary><table>` +
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)) +
`</table><pre class="mono">${esc(r.attributes.map(a => JSON.stringify(a)).join('\n'))}</pre></details>`;
}
// For each peer a line that says how many and why, then its latest few.
const REFUSED_SHOWN = 20;
function renderRefused() {
const b = $('refused-body');
const total = state.refused.reduce((n, p) => n + p.total, 0);
$('refused-count').textContent = total ? `${total} from ${state.refused.length} peer${state.refused.length === 1 ? '' : 's'}` : '0';
if (!state.refused.length) { setHTML(b, '<tr><td colspan="5" class="dim">Nothing a peer announced has been refused.</td></tr>'); return; }
if (!state.refused.length) { setHTML(b, '<tr><td colspan="6" class="dim">Nothing a peer announced has been refused.</td></tr>'); 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
? ` <span class="dim">Latest ${REFUSED_SHOWN} shown; <a href="/api/routes/rejected?peer=${encodeURIComponent(p.peer)}">the latest ${p.recent.length} as json</a>.</span>` : '';
return `<tr><td colspan="5"><b>${esc(p.peer)}</b> <span class="dim">${esc(p.peer_addr)}</span>: <span class="bad">${p.total} refused</span> (${why}).${more}</td></tr>` +
return `<tr><td colspan="6"><b>${esc(p.peer)}</b> <span class="dim">${esc(p.peer_addr)}</span>: <span class="bad">${p.total} refused</span> (${why}).${more}</td></tr>` +
p.recent.slice(0, REFUSED_SHOWN).map(r => `<tr><td></td>
<td class="mono">${esc(r.prefix)}${r.path_id ? ` <span class="dim">path ${r.path_id}</span>` : ''}</td>
<td class="mono">${asPath(r.as_path)}</td><td>${esc(r.reason)}</td><td>${age(r.at_unix)} ago</td></tr>`).join('');
<td class="mono">${asPath(r.as_path)}</td><td>${esc(r.reason)}</td><td>${age(r.at_unix)} ago</td>
<td>${refusedDetail(r)}</td></tr>`).join('');
}).join(''));
}