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

+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(''));
}