The API, the metrics, the status page and the documentation say a route is "rejected" (/api/routes/rejected, rejected_routes, bgp_routes_rejected_total, "Rejected routes"), and the reason type is Rejection. The code that keeps the record said "refused": RefusalLog, PeerRefusals, RefusedRoute, RefusalsView, RefusedRouteView, the refusals module. Those are RejectionLog, PeerRejections, RejectedRoute, RejectionsView, RejectedRouteView and rejections now, and the log line says "Rejected a route". Nothing the API or the metrics expose changes. Refusing stays what it was: a connection turned away at the door (server::Refusal), a kernel that would not take a route, an OPEN we would not have. The two aliases of SendReceive, AddPathSendRecv and OrfSendRecv, are gone; it is one type. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
608 lines
36 KiB
HTML
608 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Status for ferrix_bgp</title>
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23eef'/%3E%3Ctext x='8' y='12' font-size='11' font-family='sans-serif' font-weight='bold' fill='%2300c' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E">
|
|
<!--
|
|
A read-only status page in the manner of the classic /statusz pages: plain
|
|
sans-serif HTML, default links, dense bordered tables, and one full-width
|
|
light blue (#eef) heading bar per section. Deliberately no dark theme, no
|
|
framework, no external resources. Everything shown comes from /api/*,
|
|
except the names of AS numbers: pointing at one asks RIPEstat for its
|
|
holder, from the browser (the daemon itself looks nothing up).
|
|
-->
|
|
<style>
|
|
body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; }
|
|
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: #eef; margin: 14px 0 6px; padding: 2px 0; }
|
|
h1.top { font-size: 150%; margin-top: 0; }
|
|
.lefthand { float: left; width: 70%; }
|
|
.righthand { text-align: right; }
|
|
a { color: #00c; } a:visited { color: #551a8b; }
|
|
table.data { border-collapse: collapse; margin: 4px 0; }
|
|
table.data th, table.data td { border: 1px solid #99a; padding: 2px 6px; text-align: left; vertical-align: top; }
|
|
table.data th { background: #dde; font-weight: bold; }
|
|
table.data > tbody > tr:hover > td { background: #f3f3ff; }
|
|
.asn { cursor: help; }
|
|
.asn:hover { background: #dde; }
|
|
#asn-tip { position: fixed; z-index: 10; max-width: 36em; background: #ffffe8; border: 1px solid #99a; padding: 2px 6px; font-size: 12px; pointer-events: none; }
|
|
.mono, table.data td.mono { font-family: monospace; }
|
|
.ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; }
|
|
.dim { color: #666; }
|
|
.nowrap { white-space: nowrap; }
|
|
table.data td.path { min-width: 12em; }
|
|
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
|
|
#event-log div { white-space: pre-wrap; }
|
|
/* Communities: one line per AS that defines them, each kept whole. */
|
|
.comm { white-space: nowrap; margin-right: 0.7em; }
|
|
.comm[title] { border-bottom: 1px dotted #666; cursor: help; }
|
|
/* A line that wraps carries on indented, so each AS's line stays apart. */
|
|
.comms > div { padding-left: 2em; text-indent: -2em; margin-bottom: 2px; }
|
|
.comms { min-width: 30em; }
|
|
table.kv th { background: #eef; font-weight: normal; }
|
|
table.data tr.dim td { color: #666; }
|
|
details { clear: both; margin: 8px 0 0; }
|
|
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; }
|
|
.pager { margin: 4px 0; }
|
|
.pager a, .pager span { margin-right: 1em; }
|
|
form.lookup input[type=text] { font-family: monospace; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1 class="top">Status for ferrix_bgp</h1>
|
|
|
|
<div>
|
|
<div class="lefthand">
|
|
Started: <span id="s-started">...</span> (up <span id="s-uptime">...</span>)<br>
|
|
Version <span id="s-version">...</span><br>
|
|
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">rejected routes json</a>, <a href="/api/rpki">rpki json</a>, <a href="/api/kernel">kernel json</a><br>
|
|
<span id="refresh-ctl"></span><br>
|
|
</div>
|
|
</div>
|
|
|
|
<details id="config-zippy" hidden>
|
|
<summary>Configuration <span class="dim" id="config-path"></span></summary>
|
|
<pre id="config-text"></pre>
|
|
</details>
|
|
|
|
<h1 id="peers">Peers (<span id="peers-count">...</span>)</h1>
|
|
<table class="data">
|
|
<thead><tr><th>name</th><th>peer</th><th>AS</th><th>state</th><th>opened by</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte AS</th><th>address families</th><th>routes</th><th title="Announcements rejected by the import policy since the daemon started. A route that is announced again and rejected again counts again.">rejected announcements</th><th>detail</th></tr></thead>
|
|
<tbody id="peers-body"></tbody>
|
|
</table>
|
|
|
|
<h1 id="rpki">Route origin and path 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>
|
|
<tbody id="local-body"></tbody>
|
|
</table>
|
|
|
|
<h1 id="kernel">Kernel routes</h1>
|
|
<div id="kernel-body">...</div>
|
|
|
|
<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>
|
|
<div class="pager" id="received-pager"></div>
|
|
<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>other attributes</th><th>RPKI</th><th>ASPA</th><th>best</th></tr></thead>
|
|
<tbody id="received-body"></tbody>
|
|
</table>
|
|
<div class="pager" id="received-pager-below"></div>
|
|
|
|
<h1 id="rejected">Rejected routes (<span id="rejected-count">...</span>)</h1>
|
|
<div class="dim">Routes that peers announced and their import policies did not take. The counts are of announcements since the daemon started: a route that a peer announces again, and that is rejected again, counts again. The latest rejected routes 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><th>next hop</th><th>origin</th><th>MED</th><th>local pref</th><th>communities</th><th>large communities</th><th>other attributes</th></tr></thead>
|
|
<tbody id="rejected-body"></tbody>
|
|
</table>
|
|
|
|
<h1 id="events">Event log</h1>
|
|
<div id="log-state" class="dim"></div>
|
|
<div id="event-log"></div>
|
|
|
|
<address>ferrix_bgp · <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a></address>
|
|
|
|
<script>
|
|
(() => {
|
|
// Well-known standard communities (RFC 1997, RFC 7999, RFC 8326), shown by
|
|
// name with the number as a tooltip.
|
|
const WELL_KNOWN = {
|
|
'65535:0': 'GRACEFUL_SHUTDOWN', '65535:666': 'BLACKHOLE', '65535:65281': 'NO_EXPORT',
|
|
'65535:65282': 'NO_ADVERTISE', '65535:65283': 'NO_EXPORT_SUBCONFED', '65535:65284': 'NOPEER',
|
|
};
|
|
|
|
const $ = id => document.getElementById(id);
|
|
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
const state = { status: null, peers: [], local: [], received: [], receivedPage: {}, rejected: [], rpki: null, kernel: null };
|
|
const DASH = '<span class="dim">-</span>';
|
|
|
|
// ---- helpers -----------------------------------------------------------
|
|
const fmtDur = s => {
|
|
s = Math.max(0, Math.floor(s));
|
|
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60), sec = s % 60;
|
|
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${sec}s` : `${sec}s`;
|
|
};
|
|
const now = () => Math.floor(Date.now() / 1000);
|
|
// Tables are only rewritten when their markup actually changes, so a
|
|
// refresh with unchanged data never touches the DOM (no flicker, and
|
|
// selections survive). Anything that changes with the clock is a
|
|
// placeholder that tick() fills in as plain text.
|
|
function setHTML(el, html) { if (el._html !== html) { el.innerHTML = html; el._html = html; tick(); } }
|
|
const age = unix => `<span data-since="${unix}"></span>`;
|
|
const until = unix => `<span data-until="${unix}"></span>`;
|
|
function tick() {
|
|
const t = now();
|
|
document.querySelectorAll('[data-since]').forEach(e => { const v = fmtDur(t - e.dataset.since); if (e.textContent !== v) e.textContent = v; });
|
|
document.querySelectorAll('[data-until]').forEach(e => { const v = fmtDur(e.dataset.until - t); if (e.textContent !== v) e.textContent = v; });
|
|
if (state.status) $('s-uptime').textContent = fmtDur(t - state.status.started_at_unix);
|
|
}
|
|
const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
|
|
const fmtCount = n => Number(n).toLocaleString('en-US');
|
|
|
|
// What a community of ours asks for where control communities are on
|
|
// (control_communities), or ''. They are acted on where a route leaves
|
|
// our AS.
|
|
const TIMES = { 1: 'once', 2: 'twice', 3: 'three times' };
|
|
function controlMeaning(c, large) {
|
|
const s = state.status;
|
|
if (!s || !s.control_communities) return '';
|
|
const L = s.local_asn, p = c.split(':').map(Number);
|
|
if (!large) {
|
|
const [high, low] = p;
|
|
if (high === 0) return low === L ? 'to no other network' : `not to AS${low}`;
|
|
if (high === L) return low === L ? 'to every network' : `only to AS${low} (and others named so)`;
|
|
return '';
|
|
}
|
|
const [admin, func, arg] = p;
|
|
if (admin !== L) return '';
|
|
if (func === 0) return arg ? `not to AS${arg}` : 'to no other network';
|
|
if (func === 1) return `only to AS${arg} (and others named so)`;
|
|
if (func >= 11 && func <= 13) return `our AS ${TIMES[func - 10]} more on the path to ${arg ? `AS${arg}` : 'every network'}`;
|
|
if (func === 20) return `not to group ${arg}`;
|
|
if (func === 21) return `only to group ${arg} (and others named so)`;
|
|
if (func >= 31 && func <= 33) return `our AS ${TIMES[func - 30]} more on the path to group ${arg}`;
|
|
return '';
|
|
}
|
|
|
|
// Communities, one line for each AS that defines them, in the order the
|
|
// route has them; each whole, so it can be copied. Pointing at a line
|
|
// names its AS. Well-known ones by name, and ours that ask for something
|
|
// with what they ask.
|
|
function commList(list, large) {
|
|
if (!list.length) return DASH;
|
|
const owners = new Map();
|
|
for (const c of list) {
|
|
const owner = c.split(':')[0];
|
|
if (!owners.has(owner)) owners.set(owner, []);
|
|
owners.get(owner).push(c);
|
|
}
|
|
const local = state.status ? String(state.status.local_asn) : null;
|
|
const lines = [...owners].map(([owner, cs]) => {
|
|
const items = cs.map(c => {
|
|
const name = !large && WELL_KNOWN[c];
|
|
if (name) return `<span class="comm" title="${esc(c)}">${name}</span>`;
|
|
const meaning = controlMeaning(c, large);
|
|
return meaning
|
|
? `<span class="comm" title="${esc(`${meaning}: a control community, acted on where the route leaves AS${state.status.local_asn}`)}">${esc(c)}</span>`
|
|
: `<span class="comm">${esc(c)}</span>`;
|
|
}).join(' ');
|
|
// Not for our own AS, nor for 0 and 65535, which are nobody's.
|
|
const named = owner !== local && owner !== '0' && owner !== '65535';
|
|
return named ? `<div class="asn" data-asn="${esc(owner)}">${items}</div>` : `<div>${items}</div>`;
|
|
});
|
|
return `<div class="comms mono">${lines.join('')}</div>`;
|
|
}
|
|
|
|
// The attributes that have no column of their own.
|
|
const otherList = list => list && list.length
|
|
? list.map(a => `<div><span class="dim">${esc(a.name)}</span> <span class="mono">${esc(a.value)}</span></div>`).join('')
|
|
: DASH;
|
|
|
|
// An AS number, whose holder's name is shown when it is pointed at.
|
|
const asn = n => `<span class="asn" data-asn="${esc(n)}">${esc(n)}</span>`;
|
|
const asPath = p => p.length ? p.map(asn).join(' ') : '<span class="dim">(empty)</span>';
|
|
|
|
async function getJSON(url) {
|
|
const r = await fetch(url);
|
|
if (!r.ok) throw new Error(`${url}: ${r.status} ${await r.text()}`);
|
|
return r.json();
|
|
}
|
|
|
|
// ---- renderers ---------------------------------------------------------
|
|
function renderStatus() {
|
|
const s = state.status; if (!s) return;
|
|
$('s-router-id').textContent = s.router_id; $('s-asn').textContent = s.local_asn;
|
|
$('s-asn').className = 'asn'; $('s-asn').dataset.asn = s.local_asn;
|
|
$('s-version').textContent = s.version; $('s-hostname').textContent = s.hostname;
|
|
$('s-started').textContent = new Date(s.started_at_unix * 1000).toString().replace(/ \(.*\)$/, '');
|
|
tick();
|
|
}
|
|
|
|
const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'bad', Idle: 'dim', Passive: 'dim' };
|
|
// Where several routes for a prefix are exchanged (ADD-PATH), and which way.
|
|
function addPath(p) {
|
|
const rx = p.add_path_receive || [], tx = p.add_path_send || [];
|
|
if (!rx.length && !tx.length) return '';
|
|
const parts = [];
|
|
if (rx.length) parts.push(`receive ${esc(rx.join(', '))}`);
|
|
if (tx.length) parts.push(`send ${esc(tx.join(', '))}`);
|
|
return ` <span class="dim">· add-path: ${parts.join('; ')}</span>`;
|
|
}
|
|
function renderPeers() {
|
|
const b = $('peers-body');
|
|
$('peers-count').textContent = `${state.peers.filter(p => p.state === 'Established').length} of ${state.peers.length} established`;
|
|
if (!state.peers.length) { setHTML(b, '<tr><td colspan="11" class="dim">No peers configured and none connected.</td></tr>'); return; }
|
|
setHTML(b, state.peers.map(p => {
|
|
const up = p.state === 'Established';
|
|
const restarting = !up && p.stale_until_unix
|
|
? `<span class="warn" title="Graceful restart: the peer's routes are kept, and used, until it is back and has sent them again">restarting: ${p.stale_routes} routes kept for ${until(p.stale_until_unix)}</span>` : '';
|
|
const detail = [restarting,
|
|
p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '',
|
|
p.state === 'Passive' ? `waiting for the peer to connect${p.last_error ? `; last session: ${esc(p.last_error)}` : ''}` : '']
|
|
.filter(Boolean).join('; ');
|
|
const gr = p.graceful_restart;
|
|
const graceful = gr ? ` · <span title="Graceful restart (RFC 4724): the peer says a restart takes ${gr.restart_time_secs}s${gr.notification ? ', and NOTIFICATIONs other than Hard Reset count as restarts (RFC 8538)' : ''}">graceful restart</span>` : '';
|
|
const stale = p.stale_routes ? ` <span class="warn" title="Kept from the peer's last session, and not sent again yet">(${p.stale_routes} stale)</span>` : '';
|
|
return `<tr>
|
|
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${asn(p.peer_asn)}${p.role ? ` <span class="dim" title="${p.role_confirmed ? 'The peer says so too (RFC 9234)' : 'By our configuration; the peer has not said what it is'}">${esc(p.role)}${p.role_confirmed ? ' ✓' : ''}</span>` : ''}</td>
|
|
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
|
|
<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) + (p.extended_next_hop ? ' · IPv4 with IPv6 next hops' : '') + graceful : DASH}</td>
|
|
<td>${up ? p.routes + stale : DASH}</td>
|
|
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#rejected">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` · <span title="Routes rejected as RPKI or ASPA invalid, or for a next hop that cannot be reached, and kept aside, to be taken if that changes">${p.held_routes} held now</span>` : '') : 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'];
|
|
// Which page of the listing is shown lives there too: the prefix the
|
|
// page comes after, or the one it comes before.
|
|
const PAGE_KEYS = ['after', 'before'];
|
|
// About this many routes to a page. A page holds whole prefixes.
|
|
const PAGE_ROUTES = 100;
|
|
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 pagePlace() {
|
|
const here = new URLSearchParams(location.search);
|
|
return { after: here.get('after') || '', before: here.get('before') || '' };
|
|
}
|
|
// Goes to another page: {after: prefix}, {before: prefix}, or nothing
|
|
// for the first.
|
|
function setPage(place) {
|
|
const url = new URL(location.href);
|
|
PAGE_KEYS.forEach(k => url.searchParams.delete(k));
|
|
PAGE_KEYS.forEach(k => { if (place && place[k]) url.searchParams.set(k, place[k]); });
|
|
history.replaceState(null, '', url.pathname + url.search + '#received');
|
|
renderRefreshControl();
|
|
loadReceived().catch(console.error);
|
|
}
|
|
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 place = pagePlace();
|
|
if (place.after) ask.set('after', place.after); else if (place.before) ask.set('before', place.before);
|
|
ask.set('limit', PAGE_ROUTES);
|
|
return `/api/routes/received?${ask}`;
|
|
}
|
|
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);
|
|
// Another question starts at its first page.
|
|
QUERY_KEYS.concat(PAGE_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() {
|
|
const b = $('local-body'); $('local-count').textContent = state.local.length;
|
|
if (!state.local.length) { setHTML(b, '<tr><td colspan="7" class="dim">This router originates no routes.</td></tr>'); return; }
|
|
const local = state.status ? state.status.local_asn : '?';
|
|
setHTML(b, state.local.map(r => `<tr>
|
|
<td class="mono">${esc(r.prefix)}</td><td>${esc(r.origin)}</td>
|
|
<td class="mono path">${asPath(Array(1 + r.prepend).fill(local))}</td><td>${r.med ?? DASH}</td>
|
|
<td>${commList(r.communities)}</td><td>${commList(r.large_communities, true)}</td>
|
|
<td>${age(r.announced_at_unix)}</td></tr>`).join(''));
|
|
}
|
|
|
|
function renderReceived() {
|
|
const b = $('received-body'), st = state.status, looking = isLookup();
|
|
const page = state.receivedPage, paged = !!(page.next_after || page.prev_before);
|
|
const shown = paged ? `${state.received.length} on this page` : `${state.received.length}`;
|
|
$('received-count').textContent = looking
|
|
? `${shown}${paged ? '' : ' found'}${st ? ` among ${st.received_routes}` : ''}`
|
|
: paged && st
|
|
? `${st.received_routes}, for ${st.received_prefixes} prefixes; ${shown}`
|
|
: `${shown}${st ? `, for ${st.received_prefixes} prefixes` : ''}`;
|
|
renderPager();
|
|
if (!state.received.length) {
|
|
setHTML(b, `<tr><td colspan="13" 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${asn(r.peer_asn)}${r.path_id ? ` path ${r.path_id}` : ''}</span></td><td class="mono path">${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>${commList(s.communities)}</td><td>${commList(s.large_communities, true)}</td><td>${otherList(s.other)}</td>
|
|
<td>${r.rpki ? `<span class="${RPKI_CLASS[r.rpki] || ''}">${esc(r.rpki)}</span>` : DASH}</td>
|
|
<td>${r.aspa ? `<span class="${RPKI_CLASS[r.aspa] || ''}">${esc(r.aspa)}</span>` : DASH}</td><td>${r.best ? '<span class="ok">best</span>' : ''}${r.stale ? ` <span class="warn" title="Kept from a session of the peer's that ended (graceful restart), and not sent again since">stale</span>` : ''}</td></tr>`; }).join(''));
|
|
}
|
|
|
|
const RPKI_CLASS = { valid: 'ok', invalid: 'bad', 'not found': 'dim', unknown: '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 and ${r.aspas} ASPAs 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 rejected 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.updated_at_unix && r.protocol_version < 2 ? '<br><span class="warn">This cache speaks an RTR version without ASPAs: AS paths are not verified.</span>' : '') +
|
|
(r.last_error ? `<br><span class="bad">Last error:</span> ${esc(r.last_error)}` : ''));
|
|
}
|
|
|
|
function renderKernel() {
|
|
const k = state.kernel, b = $('kernel-body');
|
|
if (!k) return;
|
|
if (!k.enabled) { setHTML(b, '<span class="dim">Not configured: the best routes are not put in the kernel, and this machine does not forward by them.</span>'); return; }
|
|
const row = (name, value) => `<tr><th>${name}</th><td>${value}</td></tr>`;
|
|
const unresolved = Object.entries(k.unresolved).map(([why, n]) => `${fmtCount(n)} ${esc(why)}`).join(', ');
|
|
const summary = '<table class="data kv">' +
|
|
row('state', `<span class="${k.synced ? 'ok' : 'warn'}">${k.synced ? 'in step' : 'catching up'}</span> with the routing table`) +
|
|
row('routes installed', `<b>${fmtCount(k.installed)}</b>`) +
|
|
row('installed as', `<span class="mono">table ${k.table} metric ${k.metric} proto bgp</span>`) +
|
|
(k.kept ? row('kept from before the restart', `<span class="warn">${fmtCount(k.kept)}</span>, not announced again yet`) : '') +
|
|
(unresolved ? row('not installed', `<span class="warn">${unresolved}</span>`) : '') +
|
|
(k.rejected ? row('rejected by the kernel', `<span class="bad">${fmtCount(k.rejected)}</span>`) : '') +
|
|
(k.last_error ? row('last error', `<span class="bad">${esc(k.last_error)}</span> (${fmtCount(k.errors)} errors in all)`) : '') +
|
|
'</table>';
|
|
// Interfaces that are up or have addresses get a row each; the others
|
|
// (unused ports, mostly) one line between them.
|
|
const v4 = a => !a.includes(':'), linkLocal = a => /^fe80:/i.test(a);
|
|
const lines = list => list.length ? list.map(esc).join('<br>') : DASH;
|
|
const listed = k.interfaces.filter(i => i.up || i.addresses.length);
|
|
const idle = k.interfaces.filter(i => !i.up && !i.addresses.length);
|
|
const interfaces = !k.interfaces.length ? '' :
|
|
'<table class="data"><thead><tr><th>interface</th><th>state</th><th>IPv4</th><th>IPv6</th><th>IPv6 link-local</th></tr></thead><tbody>' +
|
|
listed.map(i => `<tr${i.up ? '' : ' class="dim"'}><td class="mono">${esc(i.name)}</td>
|
|
<td><span class="${i.up ? 'ok' : 'bad'}">${i.up ? 'up' : 'down'}</span></td>
|
|
<td class="mono">${lines(i.addresses.filter(v4))}</td>
|
|
<td class="mono">${lines(i.addresses.filter(a => !v4(a) && !linkLocal(a)))}</td>
|
|
<td class="mono dim">${lines(i.addresses.filter(linkLocal))}</td></tr>`).join('') +
|
|
(idle.length ? `<tr class="dim"><td colspan="5">Down and without addresses: <span class="mono">${idle.map(i => esc(i.name)).join(' ')}</span></td></tr>` : '') +
|
|
'</tbody></table>';
|
|
setHTML(b, summary + interfaces);
|
|
}
|
|
|
|
// For each peer a line that says how many and why, then its latest few.
|
|
const REJECTED_SHOWN = 20;
|
|
function renderRefused() {
|
|
const b = $('rejected-body');
|
|
const total = state.rejected.reduce((n, p) => n + p.total, 0);
|
|
const announcements = n => `${n} announcement${n === 1 ? '' : 's'}`;
|
|
$('rejected-count').textContent = total ? `${announcements(total)} from ${state.rejected.length} peer${state.rejected.length === 1 ? '' : 's'}` : '0';
|
|
if (!state.rejected.length) { setHTML(b, '<tr><td colspan="12" class="dim">Nothing a peer announced has been rejected.</td></tr>'); return; }
|
|
setHTML(b, state.rejected.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 > REJECTED_SHOWN
|
|
? ` <span class="dim">Latest ${REJECTED_SHOWN} shown; <a href="/api/routes/rejected?peer=${encodeURIComponent(p.peer)}">the latest ${p.recent.length} as json</a>.</span>` : '';
|
|
return `<tr><td colspan="12"><b>${esc(p.peer)}</b> <span class="dim">${esc(p.peer_addr)}</span>: <span class="bad">${announcements(p.total)} rejected</span> (${why}).${more}</td></tr>` +
|
|
p.recent.slice(0, REJECTED_SHOWN).map(r => { const s = r.summary; return `<tr><td></td>
|
|
<td class="mono">${esc(r.prefix)}${r.path_id ? ` <span class="dim">path ${r.path_id}</span>` : ''}</td>
|
|
<td class="mono path">${asPath(r.as_path)}</td><td>${esc(r.reason)}</td><td class="nowrap">${age(r.at_unix)} ago</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>${commList(s.communities)}</td><td>${commList(s.large_communities, true)}</td><td>${otherList(s.other)}</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(); }
|
|
// The way to the pages before and after this one, where there are any.
|
|
function renderPager() {
|
|
const page = state.receivedPage, place = pagePlace();
|
|
const paged = page.next_after || page.prev_before;
|
|
const link = (label, to, there) => there ? `<a href="#" data-page='${esc(JSON.stringify(to))}'>${label}</a>` : `<span class="dim">${label}</span>`;
|
|
const range = state.received.length
|
|
? `<span class="mono">${esc(state.received[0].prefix)} – ${esc(state.received[state.received.length - 1].prefix)}</span>` : '';
|
|
const html = !paged && !place.after && !place.before ? '' :
|
|
link('« first', {}, page.prev_before || (!state.received.length && (place.after || place.before))) +
|
|
link('‹ previous', { before: page.prev_before }, page.prev_before) +
|
|
link('next ›', { after: page.next_after }, page.next_after) + range;
|
|
for (const id of ['received-pager', 'received-pager-below']) setHTML($(id), html);
|
|
}
|
|
|
|
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); }
|
|
async function loadReceived() {
|
|
const note = $('lookup-note');
|
|
try {
|
|
const page = await getJSON(receivedUrl());
|
|
state.received = page.routes; state.receivedPage = page;
|
|
note.textContent = '';
|
|
} catch (e) {
|
|
// A query the server could not make sense of: say what it said.
|
|
state.received = []; state.receivedPage = {};
|
|
note.innerHTML = `<span class="bad">${esc(e.message.replace(/^[^:]*: \d+ /, ''))}</span>`;
|
|
}
|
|
renderReceived();
|
|
}
|
|
async function loadRefused() { state.rejected = await getJSON('/api/routes/rejected'); renderRefused(); }
|
|
async function loadRpki() { state.rpki = await getJSON('/api/rpki'); renderRpki(); }
|
|
async function loadKernel() { state.kernel = await getJSON('/api/kernel'); renderKernel(); }
|
|
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadRefused(), loadRpki(), loadKernel()]); }
|
|
|
|
// ---- events ------------------------------------------------------------
|
|
function logEvent(html) {
|
|
const box = $('event-log'), d = document.createElement('div');
|
|
d.innerHTML = `${new Date().toLocaleTimeString()} ${html}`;
|
|
box.prepend(d); while (box.children.length > 200) box.lastChild.remove();
|
|
}
|
|
const names = list => list.map(c => WELL_KNOWN[c] || c).map(esc).join(' ');
|
|
function describe(ev) {
|
|
switch (ev.type) {
|
|
case 'session_up': return `<span class="ok">session UP</span> with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} (opened by ${ev.peer.direction === 'outbound' ? 'us' : 'peer'})`;
|
|
case 'route_refresh_requested': return `${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} asked for a route refresh; re-sent its routes`;
|
|
case 'session_down': return `<span class="bad">session DOWN</span> with ${esc(ev.peer_addr)} AS${ev.peer_asn}`;
|
|
case 'local_announced': { const r = ev.route; return `originating ${esc(r.prefix)}` +
|
|
(r.communities.length ? ` [${names(r.communities)}]` : '') + (r.large_communities.length ? ` [${names(r.large_communities)}]` : '') +
|
|
(r.prepend ? ` prepend ${r.prepend}` : '') + (r.med != null ? ` med ${r.med}` : ''); }
|
|
case 'local_withdrawn': return `<span class="warn">no longer originating</span> ${esc(ev.prefix)}`;
|
|
case 'best_changed': return `best route for ${esc(ev.route.prefix)} is now from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`;
|
|
case 'best_withdrawn': return `${esc(ev.prefix)} has <span class="warn">no route</span> any more`;
|
|
case 'lagged': return `<span class="bad">missed ${ev.missed == null ? '' : ev.missed + ' '}events</span> on ${esc(ev.stream)}`;
|
|
default: return esc(JSON.stringify(ev));
|
|
}
|
|
}
|
|
function connect() {
|
|
const st = $('log-state');
|
|
const es = new EventSource('/api/events');
|
|
es.onopen = () => { st.textContent = 'Live: connected to the event stream.'; };
|
|
es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; };
|
|
// Events only feed the log. How often the tables reload is decided by
|
|
// ?refresh=, so the page never changes faster than the reader asked for.
|
|
es.addEventListener('bgp', e => logEvent(describe(JSON.parse(e.data))));
|
|
}
|
|
|
|
// ---- auto-refresh, opt-in through ?refresh=<seconds> --------------------
|
|
const refreshParam = parseInt(new URLSearchParams(location.search).get('refresh'), 10);
|
|
const REFRESH = Number.isFinite(refreshParam) && refreshParam >= 1 ? Math.min(refreshParam, 3600) : 0;
|
|
function refreshLink(seconds, text) {
|
|
const url = new URL(location.href);
|
|
if (seconds) url.searchParams.set('refresh', seconds); else url.searchParams.delete('refresh');
|
|
return `<a href="${esc(url.pathname + url.search + url.hash)}">${text}</a>`;
|
|
}
|
|
function renderRefreshControl() {
|
|
$('refresh-ctl').innerHTML = (REFRESH
|
|
? `Auto-refresh: <b>every ${fmtDur(REFRESH)}</b> (${refreshLink(0, 'turn off')})`
|
|
: `Auto-refresh: off (turn on: ${[2, 5, 30].map(n => refreshLink(n, n + 's')).join(', ')})`) +
|
|
` · <a href="#" id="reload-now">reload now</a>`;
|
|
$('reload-now').addEventListener('click', e => { e.preventDefault(); loadAll().catch(console.error); });
|
|
}
|
|
|
|
// ---- AS names ----------------------------------------------------------
|
|
// The holder of an AS number, from RIPEstat, which has every number
|
|
// (reserved ones too) and lets browsers ask it. Asked from here, once per
|
|
// number and page, only for a number that is pointed at for a moment.
|
|
const asNames = new Map();
|
|
function asName(n) {
|
|
if (!asNames.has(n)) {
|
|
asNames.set(n, fetch(`https://stat.ripe.net/data/as-overview/data.json?resource=AS${n}&sourceapp=ferrix_bgp`)
|
|
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
|
|
.then(j => j.data.holder || (j.data.block && j.data.block.desc) || 'no holder registered')
|
|
// Asked again next time: the network may be back.
|
|
.catch(e => { asNames.delete(n); return `name not available (${e.message})`; }));
|
|
}
|
|
return asNames.get(n);
|
|
}
|
|
const tip = document.createElement('div');
|
|
tip.id = 'asn-tip'; tip.hidden = true; document.body.append(tip);
|
|
let pointedAt = null, asking = 0;
|
|
function placeTip(el) {
|
|
const box = el.getBoundingClientRect();
|
|
tip.style.left = `${Math.max(0, Math.min(box.left, innerWidth - tip.offsetWidth - 4))}px`;
|
|
const below = box.bottom + 4;
|
|
tip.style.top = `${below + tip.offsetHeight > innerHeight ? box.top - tip.offsetHeight - 4 : below}px`;
|
|
}
|
|
document.addEventListener('mouseover', e => {
|
|
const el = e.target.closest('.asn');
|
|
if (el === pointedAt) return;
|
|
pointedAt = el; clearTimeout(asking);
|
|
const n = el && el.dataset.asn;
|
|
if (!n || !/^\d+$/.test(n)) { tip.hidden = true; return; }
|
|
// Passing over a path on the way somewhere else asks for nothing.
|
|
asking = setTimeout(() => {
|
|
tip.textContent = `AS${n}: ...`; tip.hidden = false; placeTip(el);
|
|
asName(n).then(name => { if (pointedAt === el) { tip.textContent = `AS${n}: ${name}`; placeTip(el); } });
|
|
}, 150);
|
|
});
|
|
addEventListener('scroll', () => { tip.hidden = true; pointedAt = null; clearTimeout(asking); }, { passive: true });
|
|
|
|
// ---- boot --------------------------------------------------------------
|
|
getJSON('/api/config').then(c => {
|
|
if (!c.text) return;
|
|
$('config-path').textContent = c.path ? `(${c.path}, as read at startup)` : '';
|
|
$('config-text').textContent = c.text;
|
|
$('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); });
|
|
for (const id of ['received-pager', 'received-pager-below']) {
|
|
$(id).addEventListener('click', e => {
|
|
const to = e.target.closest('a[data-page]');
|
|
if (!to) return;
|
|
e.preventDefault(); setPage(JSON.parse(to.dataset.page));
|
|
});
|
|
}
|
|
loadAll().catch(e => logEvent(`<span class="bad">${esc(e.message)}</span>`));
|
|
if (REFRESH) {
|
|
connect();
|
|
setInterval(() => loadAll().catch(console.error), REFRESH * 1000);
|
|
setInterval(tick, 1000);
|
|
} else {
|
|
// A snapshot: nothing moves until the reader reloads or turns refresh on.
|
|
$('log-state').textContent = 'The event log is live only while auto-refresh is on.';
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|