Look routes up, and see and alert on the ones that were refused

Two things were hard to find out from the status page and the API: what
the table has for a given prefix or address, and what a peer announced
that was refused and why.

Looking up. /api/routes/received takes a query, and the status page has
a box for it:
- prefix: every route for that prefix, the best first;
- address: every prefix that covers the address, most specific first;
- within: a prefix and everything inside it;
- peer (by name or address) and best narrow any of these.
A query that makes no sense is answered 400 with the reason, which the
page shows. Results come in address order. Routes are shown with the
peer's configured name.

Refused routes. A refused route is not stored, so until now there was
only a count. For each peer the server now keeps how many routes were
refused for each reason, and the latest 200 with the AS path they came
with. The record is kept by the peer's address and outlives the
session, so that a peer that was cut off can still be looked into. It is
at /api/routes/rejected and in a new section of the status page; the
peers table links to it.

Alerting. bgp_routes_rejected_total is now by peer as well as reason.
New: bgp_peer_routes and bgp_peer_max_prefixes, to see a peer nearing
its limit, and bgp_prefix_limit_exceeded_total. The README has rules to
start from. The log has a line for a peer's first refused route and
then at most one a minute, with the count of the others.

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:14:41 +02:00
1 parent e2b1979e38
commit 599cd0ad8b
16 files changed
+1110 -49

No files matched your search

+117 -9
View File
@@ -31,6 +31,8 @@ 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; }
address { font-size: smaller; margin-top: 2em; color: #444; }
form.lookup { margin: 4px 0; }
form.lookup input[type=text] { font-family: monospace; }
</style>
</head>
<body>
@@ -45,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><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>
<span id="refresh-ctl"></span><br>
</div>
</div>
@@ -68,11 +70,30 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
</table>
<h1 id="received">Received routes (<span id="received-count">...</span>)</h1>
<form class="lookup" id="lookup">
Look up <input type="text" id="lookup-q" size="30" placeholder="193.0.0.0/21 or 193.0.0.1" title="A prefix, or an address to find every prefix that covers it">
<select id="lookup-mode" title="What to find for a prefix">
<option value="exact">routes for this prefix or address</option>
<option value="within">this prefix and everything inside it</option>
</select>
from <select id="lookup-peer"><option value="">any peer</option></select>
<label><input type="checkbox" id="lookup-best"> best only</label>
<button type="submit">look up</button>
<a href="#" id="lookup-clear">show all</a>
<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>
<tbody id="received-body"></tbody>
</table>
<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>
<tbody id="refused-body"></tbody>
</table>
<h1 id="events">Event log</h1>
<div id="log-state" class="dim"></div>
<div id="event-log"></div>
@@ -90,7 +111,7 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
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: [] };
const state = { status: null, peers: [], local: [], received: [], refused: [] };
const DASH = '<span class="dim">-</span>';
// ---- helpers -----------------------------------------------------------
@@ -156,8 +177,55 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
<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 : DASH}</td><td>${detail}</td></tr>`;
<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>`;
}).join(''));
renderPeerChoices();
}
// ---- looking routes up -------------------------------------------------
// The query lives in the page's address, so that it survives a reload and
// auto-refresh, and can be passed on.
const QUERY_KEYS = ['q', 'mode', 'peer', 'best'];
function lookup() {
const here = new URLSearchParams(location.search);
return { q: (here.get('q') || '').trim(), mode: here.get('mode') || 'exact', peer: here.get('peer') || '', best: here.get('best') === '1' };
}
function isLookup() { const l = lookup(); return !!(l.q || l.peer || l.best); }
// What to ask the API for: an address finds every prefix that covers it.
function receivedUrl() {
const l = lookup(), ask = new URLSearchParams();
if (l.q) ask.set(l.mode === 'within' ? 'within' : l.q.includes('/') ? 'prefix' : 'address', l.q);
if (l.peer) ask.set('peer', l.peer);
if (l.best) ask.set('best', 'true');
const query = ask.toString();
return '/api/routes/received' + (query ? `?${query}` : '');
}
function renderPeerChoices() {
const chosen = lookup().peer;
const names = [...new Set(state.peers.filter(p => p.state === 'Established')
.map(p => p.name || p.peer_addr.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')))];
if (chosen && !names.includes(chosen)) names.push(chosen);
setHTML($('lookup-peer'), '<option value="">any peer</option>' +
names.map(n => `<option value="${esc(n)}"${n === chosen ? ' selected' : ''}>${esc(n)}</option>`).join(''));
}
function showLookup() {
const l = lookup();
$('lookup-q').value = l.q; $('lookup-mode').value = l.mode; $('lookup-best').checked = l.best;
renderPeerChoices();
}
function setLookup(l) {
const url = new URL(location.href);
QUERY_KEYS.forEach(k => url.searchParams.delete(k));
if (l) {
if (l.q) url.searchParams.set('q', l.q);
if (l.q && l.mode === 'within') url.searchParams.set('mode', 'within');
if (l.peer) url.searchParams.set('peer', l.peer);
if (l.best) url.searchParams.set('best', '1');
}
history.replaceState(null, '', url.pathname + url.search + '#received');
showLookup(); renderRefreshControl();
loadReceived().catch(console.error);
}
function renderLocal() {
@@ -172,23 +240,57 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
}
function renderReceived() {
const b = $('received-body'), st = state.status;
$('received-count').textContent = st && st.received_routes > state.received.length
const b = $('received-body'), st = state.status, looking = isLookup();
$('received-count').textContent = looking
? `${state.received.length} found${st ? ` among ${st.received_routes}` : ''}`
: st && st.received_routes > state.received.length
? `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">No routes received from peers.</td></tr>'); return; }
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, state.received.map(r => { const s = r.summary; return `<tr>
<td class="mono">${esc(r.prefix)}</td><td class="mono">${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">${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(''));
}
// 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; }
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>` +
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('');
}).join(''));
}
// ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); renderReceived(); }
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); }
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); }
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
async function loadReceived() {
const note = $('lookup-note');
try {
state.received = await getJSON(receivedUrl());
note.textContent = '';
} catch (e) {
// A query the server could not make sense of: say what it said.
state.received = [];
note.innerHTML = `<span class="bad">${esc(e.message.replace(/^[^:]*: \d+ /, ''))}</span>`;
}
renderReceived();
}
async function loadRefused() { state.refused = await getJSON('/api/routes/rejected'); renderRefused(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadRefused()]); }
// ---- events ------------------------------------------------------------
function logEvent(html) {
@@ -246,6 +348,12 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
$('config-zippy').hidden = false;
}).catch(console.error);
renderRefreshControl();
showLookup();
$('lookup').addEventListener('submit', e => {
e.preventDefault();
setLookup({ q: $('lookup-q').value.trim(), mode: $('lookup-mode').value, peer: $('lookup-peer').value, best: $('lookup-best').checked });
});
$('lookup-clear').addEventListener('click', e => { e.preventDefault(); setLookup(null); });
loadAll().catch(e => logEvent(`<span class="bad">${esc(e.message)}</span>`));
if (REFRESH) {
connect();