Files
ferrix/crates/web/static/statusz.html
T
Rayhaan JaufeerallyandClaude Opus 5.5 c9751883c4 Passive peers: configured, and left to connect
A session could only be configured for a peer we would then dial, every
few seconds to two minutes, for as long as it was not up. A new peer
whose side was not ready yet filled the log with failed attempts, and a
peer that opens its sessions itself was dialled for nothing.

A peer with `passive: true` is not dialled. A connection from its address
is taken as for any configured peer, and the session comes up with all
that is configured for it: role, import policy, export limits,
hold-downs. Until then the status page and /api/peers show it as
`Passive`, waiting for the peer to connect; after a session, with how
that session ended, which nothing else would say since nobody dials it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:05:54 +02:00

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="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>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="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 &middot; <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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 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, fn, arg] = p;
if (admin !== L) return '';
if (fn === 0) return arg ? `not to AS${arg}` : 'to no other network';
if (fn === 1) return `only to AS${arg} (and others named so)`;
if (fn >= 11 && fn <= 13) return `our AS ${TIMES[fn - 10]} more on the path to ${arg ? `AS${arg}` : 'every network'}`;
if (fn === 20) return `not to group ${arg}`;
if (fn === 21) return `only to group ${arg} (and others named so)`;
if (fn >= 31 && fn <= 33) return `our AS ${TIMES[fn - 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 ? ` &middot; <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 ? ' &check;' : ''}</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 ? ' &middot; 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 ? ` &middot; <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 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 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.refused ? row('refused by the kernel', `<span class="bad">${fmtCount(k.refused)}</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 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="12" 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="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, REFUSED_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)} &ndash; ${esc(state.received[state.received.length - 1].prefix)}</span>` : '';
const html = !paged && !place.after && !place.before ? '' :
link('&laquo; first', {}, page.prev_before || (!state.received.length && (place.after || place.before))) +
link('&lsaquo; previous', { before: page.prev_before }, page.prev_before) +
link('next &rsaquo;', { 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(', ')})`) +
` &middot; <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>