The OPEN used to offer IPv4 and IPv6 unicast unconditionally, and what the peer offered was ignored apart from its four-octet AS number. Address families (RFC 4760): - A peer's `address_families` in the configuration says which to offer it; all of them by default. - A session carries the families both OPENs offered. A peer that sends no Multiprotocol capability is a plain RFC 4271 speaker: IPv4 unicast. - Routes of a family that was not negotiated are not sent, and are ignored when received; the rest of the UPDATE is applied and the session stays up. - A session with no family in common is refused with Unsupported Capability, naming the capabilities we needed (RFC 5492). - The negotiated families are shown on /statusz and in /api/peers. Bugs found on the way: - A capability naming an address family we have no constant for (EVPN, VPNv4, ...) failed the whole OPEN, so such a peer could never establish. It is now kept as an unknown capability and ignored. - An UPDATE could not be encoded for a peer without four-octet AS support, which ended the session on the first route. AS_PATH is now written two octets wide with AS_TRANS where needed, and AS4_PATH carries the real numbers (RFC 6793). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
249 lines
14 KiB
HTML
249 lines
14 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/*.
|
|
-->
|
|
<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; }
|
|
.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; }
|
|
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; }
|
|
</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><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>detail</th></tr></thead>
|
|
<tbody id="peers-body"></tbody>
|
|
</table>
|
|
|
|
<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="received">Received routes, Adj-RIB-In (<span id="received-count">...</span>)</h1>
|
|
<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>age</th></tr></thead>
|
|
<tbody id="received-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: [] };
|
|
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;
|
|
const asPath = p => p.length ? p.join(' ') : '<span class="dim">(empty)</span>';
|
|
|
|
async function getJSON(url) {
|
|
const r = await fetch(url);
|
|
if (!r.ok) throw new Error(`${url}: ${r.status}`);
|
|
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-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' };
|
|
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 detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
|
|
return `<tr>
|
|
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}</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(', ')) : DASH}</td><td>${detail}</td></tr>`;
|
|
}).join(''));
|
|
}
|
|
|
|
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 asn = 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">${Array(1 + r.prepend).fill(asn).join(' ')}</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'); $('received-count').textContent = state.received.length;
|
|
if (!state.received.length) { setHTML(b, '<tr><td colspan="10" class="dim">No routes received from peers.</td></tr>'); return; }
|
|
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr>
|
|
<td class="mono">${esc(r.prefix)}</td><td class="mono">${esc(r.peer_addr)} <span class="dim">AS${r.peer_asn}</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>${age(r.received_at_unix)}</td></tr>`; }).join(''));
|
|
}
|
|
|
|
// ---- data loading ------------------------------------------------------
|
|
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); }
|
|
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); }
|
|
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); }
|
|
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); }
|
|
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
|
|
|
|
// ---- 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 'received_announced': return `received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`;
|
|
case 'received_withdrawn': return `peer AS${ev.peer_asn} <span class="warn">withdrew</span> ${esc(ev.prefix)}`;
|
|
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); });
|
|
}
|
|
|
|
// ---- 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();
|
|
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>
|