A peer that restarts keeps its routes with us, and we keep ours with our peers while we restart, so that a restart withdraws nothing across the network and the machine goes on forwarding. On by default for every peer; `graceful_restart` sets the restart time we give, caps how long a peer's routes are kept, or turns it off. When a peer restarts: its routes are marked stale in the routing table, and chosen as before, for at most its restart time. When it is back, what it sends again is fresh, and the stale routes of a family go at its End-of-RIB marker, or after the cap; at once for a family whose forwarding it did not keep. Only a connection that failed counts as a restart, and, with RFC 8538 agreed, an expired hold timer or a NOTIFICATION other than a Hard Reset. The stale mark is a bit of the route's word that selection never looks at. When we restart: SIGUSR1 leaves the kernel's routes where they are, closes the sessions without a word where graceful restart is agreed, and exits with status 75, on which the systemd unit starts us again. The next start finds the routes in the kernel, says so in its OPENs (R, and F where forwarding was kept), and has them go once every peer has sent its End-of-RIB. Our own End-of-RIB markers for learned routes wait for that too. SIGTERM still stops for good, now with a Hard Reset where RFC 8538 is agreed, as are errors of ours. The status page and API show what was agreed with each peer, the routes kept while a peer restarts, and which routes are stale. Tested against FRR and BIRD in a new phase of the interoperability lab, and with routes kept in a real kernel over a restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
538 lines
32 KiB
HTML
538 lines
32 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; }
|
|
#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; }
|
|
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>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="refused">Rejected routes (<span id="refused-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>attributes</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>
|
|
|
|
<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: {}, refused: [], 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 commList = (list, named) => list.length ? list.map(c =>
|
|
named && WELL_KNOWN[c] ? `<span title="${esc(c)}">${WELL_KNOWN[c]}</span>` : esc(c)).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' };
|
|
// 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)}` : '']
|
|
.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="#refused">${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">${asPath(Array(1 + r.prepend).fill(local))}</td><td>${r.med ?? DASH}</td>
|
|
<td class="mono">${commList(r.communities, true)}</td><td class="mono">${commList(r.large_communities)}</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="12" 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">${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.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 refused 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 unresolved = Object.entries(k.unresolved).map(([why, n]) => `${n} ${esc(why)}`).join(', ');
|
|
const interfaces = k.interfaces.map(i =>
|
|
`<span class="mono">${esc(i.name)}</span> <span class="${i.up ? 'ok' : 'bad'}">${i.up ? 'up' : 'down'}</span>` +
|
|
(i.addresses.length ? ` <span class="mono dim">${i.addresses.map(esc).join(' ')}</span>` : '')).join(' · ');
|
|
setHTML(b, `<span class="${k.synced ? 'ok' : 'warn'}">${k.synced ? 'In step' : 'Catching up'}</span> with the routing table: ` +
|
|
`<b>${k.installed}</b> routes installed <span class="dim">(table ${k.table}, metric ${k.metric}, proto bgp)</span>` +
|
|
(k.kept ? `; <span class="warn">${k.kept} kept from before the restart</span>, not announced again yet` : '') +
|
|
(unresolved ? `; <span class="warn">not installed:</span> ${unresolved}` : '') +
|
|
(k.refused ? `; <span class="bad">${k.refused} refused by the kernel</span>` : '') + '.' +
|
|
(k.last_error ? `<br><span class="bad">Last error</span> (${k.errors} in all): ${esc(k.last_error)}` : '') +
|
|
(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);
|
|
const announcements = n => `${n} announcement${n === 1 ? '' : 's'}`;
|
|
$('refused-count').textContent = total ? `${announcements(total)} from ${state.refused.length} peer${state.refused.length === 1 ? '' : 's'}` : '0';
|
|
if (!state.refused.length) { setHTML(b, '<tr><td colspan="6" class="dim">Nothing a peer announced has been rejected.</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="6"><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, 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>
|
|
<td>${refusedDetail(r)}</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.refused = 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 all originated 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} 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>
|