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(''));
}
+6 -4
View File
@@ -70,10 +70,12 @@ curl -s '127.0.0.1:8080/api/routes/received?address=193.0.0.1&best=true' | jq
Ferrix does not store rejected routes in the routing table. For each
neighbor, it records the number of rejected routes for each reason, and the
200 most recent rejected routes with their AS paths. The status page
displays this information under "Refused routes", and the HTTP endpoint
`/api/routes/rejected` returns it as JSON. To display the records for one
neighbor, add `?peer=NAME`.
200 most recent rejected routes with all the path attributes they were
received with. The status page displays this information under "Refused
routes"; to display the attributes of a rejected route, select **show** in
its row. The HTTP endpoint `/api/routes/rejected` returns the same
information as JSON. To display the records for one neighbor, add
`?peer=NAME`.
```json
[{"peer":"transit-a","total":3,"by_reason":{"RPKI invalid":2,"bogon prefix":1},
+19 -1
View File
@@ -143,11 +143,29 @@ routes. To return the records for one neighbor, add `?peer=NAME`.
"path_id": 0,
"reason": "own AS in path",
"as_path": [65001, 64500],
"at_unix": 1791020702
"at_unix": 1791020702,
"summary": {
"origin": "IGP",
"as_path": [65001, 64500],
"next_hop": "10.0.0.2",
"med": null,
"local_pref": null,
"communities": [],
"large_communities": []
},
"attributes": [
{ "Origin": 0 },
{ "AsPath": { "segments": [{ "ordered": true, "path": [65001, 64500] }] } },
{ "NextHop": "10.0.0.2" }
]
}]
}]
```
| Field | Description |
|---|---|
| `summary`, `attributes` | The path attributes of the rejected route, in the same format as for received routes. For a route rejected when RPKI data changed, the attributes as stored, including those added by the import policy. |
## Kernel
`/api/kernel` returns the state of kernel route installation: