Files
ferrix/crates/web/static/statusz.html
T
Rayhaan JaufeerallyandClaude Fable 5.1 e2de44ba78 feature: active dialer for configured peers
Phase 4 of the teaching demo: the daemon now brings up its own sessions.

- New dialer task per configured peer: connect with a timeout, run the
  same FSM as the passive side, back off exponentially (from
  connect_retry_seconds, capped at two minutes) and retry forever. Stays
  idle while a session with the same remote IP and ASN already exists.
- The peer's OPEN must carry the configured ASN, otherwise the session is
  refused with a Bad Peer AS notification. Inbound connections from a
  configured peer's address get the same check plus the configured name
  and hold time.
- SessionRegistry tracks configured peers and their dial state
  (Idle / Connecting / Backoff with last error and retry time).
- /api/peers merges configured peers with live sessions so the page shows
  peers that are down, why, and when the next attempt is; unconfigured
  inbound sessions are listed after them.
- fake_peer gains --listen so it can stand in for a router the daemon
  dials.
- Integration tests: dial and establish, reconnect after the peer closes,
  wrong ASN refused, inbound from a configured address checked and named.

Not implemented: RFC 4271 §6.8 collision detection; a real router on the
other end resolves the rare race by closing one connection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:22:08 +02:00

328 lines
21 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ferrix_bgp /statusz</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%231a73e8'/%3E%3Ctext x='8' y='12' font-size='10' font-family='monospace' fill='white' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E">
<style>
:root {
--bg: #ffffff; --fg: #202124; --muted: #5f6368; --line: #dadce0;
--head: #f1f3f4; --accent: #1a73e8; --ok: #188038; --warn: #b06000; --err: #c5221f;
--hi: #fef7e0; --mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--hex-hdr:#e8f0fe; --hex-len:#fce8e6; --hex-attr:#e6f4ea; --hex-nlri:#fef7e0;
}
* { box-sizing: border-box; }
body { margin: 0; font: 13px/1.45 var(--mono); color: var(--fg); background: var(--bg); }
a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }
header { padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--head); display: flex; flex-wrap: wrap; gap: 4px 24px; align-items: baseline; }
header h1 { font-size: 15px; margin: 0 8px 0 0; font-weight: bold; }
header .kv { color: var(--muted); } header .kv b { color: var(--fg); font-weight: normal; }
nav { padding: 4px 16px; border-bottom: 1px solid var(--line); color: var(--muted); }
nav a { margin-right: 14px; }
main { padding: 0 16px 40px; max-width: 1400px; }
section { margin-top: 22px; }
h2 { font-size: 13px; margin: 0 0 6px; padding-bottom: 3px; border-bottom: 1px solid var(--line); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
h2 .count { float: right; text-transform: none; letter-spacing: 0; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 3px 8px; border-bottom: 1px solid var(--line); vertical-align: top; white-space: nowrap; }
th { background: var(--head); font-weight: normal; color: var(--muted); }
td.wrap { white-space: normal; }
tr.mine { background: var(--hi); }
tr.wire td { background: #fafafa; white-space: normal; }
.empty { color: var(--muted); padding: 6px 8px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--err); margin-right: 4px; vertical-align: middle; }
.dot.on { background: var(--ok); }
.tag { display: inline-block; padding: 0 5px; border: 1px solid var(--line); border-radius: 3px; margin: 1px 2px 1px 0; background: #fff; }
.tag.wk { border-color: var(--warn); color: var(--warn); }
.muted { color: var(--muted); } .ok { color: var(--ok); } .err { color: var(--err); } .warn { color: var(--warn); }
form.announce { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 8px 12px; align-items: end; padding: 10px; border: 1px solid var(--line); background: #fafafa; }
form.announce label { display: flex; flex-direction: column; gap: 2px; color: var(--muted); }
form.announce .span2 { grid-column: span 2; }
input, select, button { font: inherit; padding: 3px 6px; border: 1px solid var(--line); border-radius: 3px; background: #fff; color: var(--fg); }
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
button { cursor: pointer; } button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.small { padding: 0 6px; line-height: 18px; } button:disabled { opacity: .5; cursor: default; }
.row { display: flex; gap: 6px; align-items: center; }
#announce-msg { grid-column: 1 / -1; min-height: 1.4em; }
.hex { font-family: var(--mono); word-break: break-all; line-height: 1.7; }
.hex span { padding: 1px 0; } .hex .h { background: var(--hex-hdr); } .hex .l { background: var(--hex-len); } .hex .a { background: var(--hex-attr); } .hex .n { background: var(--hex-nlri); }
.hex .a:nth-of-type(odd) { filter: brightness(.96); }
.legend span { padding: 0 6px; margin-right: 8px; }
.wirebox { margin: 4px 0 8px; padding: 6px 8px; border-left: 3px solid var(--line); }
#event-log { max-height: 260px; overflow-y: auto; border: 1px solid var(--line); padding: 4px 8px; background: #fafafa; }
#event-log div { white-space: nowrap; } #event-log .t { color: var(--muted); margin-right: 8px; }
footer { padding: 10px 16px; color: var(--muted); border-top: 1px solid var(--line); margin-top: 30px; }
@media (prefers-color-scheme: dark) {
:root { --bg:#202124; --fg:#e8eaed; --muted:#9aa0a6; --line:#3c4043; --head:#292a2d; --accent:#8ab4f8; --ok:#81c995; --warn:#fdd663; --err:#f28b82; --hi:#3b3419;
--hex-hdr:#28344b; --hex-len:#4a2a28; --hex-attr:#26382b; --hex-nlri:#3b3419; }
input, select, button, .tag { background: #303134; color: var(--fg); }
tr.wire td, form.announce, #event-log { background: #292a2d; }
}
</style>
</head>
<body>
<header>
<h1>ferrix_bgp</h1>
<span class="kv">router-id <b id="s-router-id">…</b></span>
<span class="kv">AS <b id="s-asn">…</b></span>
<span class="kv">version <b id="s-version">…</b></span>
<span class="kv">uptime <b id="s-uptime">…</b></span>
<span class="kv">sessions <b id="s-sessions">…</b></span>
<span class="kv">local <b id="s-local">…</b></span>
<span class="kv">received <b id="s-received">…</b></span>
<span class="kv"><span class="dot" id="live-dot"></span><span id="live-text">connecting</span></span>
</header>
<nav>
<a href="#peers">peers</a><a href="#announce">announce</a><a href="#local">local routes</a><a href="#received">received routes</a><a href="#events">events</a>
<a href="/metricz">/metricz</a><a href="/api/status">/api/status</a>
</nav>
<main>
<section id="peers">
<h2>Peers <span class="count" id="peers-count"></span></h2>
<table>
<thead><tr><th>name</th><th>peer</th><th>ASN</th><th>state</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte ASN</th><th>detail</th></tr></thead>
<tbody id="peers-body"></tbody>
</table>
</section>
<section id="announce">
<h2>Announce a prefix</h2>
<form class="announce" id="announce-form" autocomplete="off">
<label>owner (your name)<input name="owner" id="f-owner" required maxlength="64" placeholder="alice"></label>
<label>prefix<input name="prefix" id="f-prefix" required placeholder="10.100.1.0/24"></label>
<label>origin<select name="origin" id="f-origin"><option>IGP</option><option>EGP</option><option>INCOMPLETE</option></select></label>
<label>AS-path prepends<input name="prepend" id="f-prepend" type="number" min="0" max="16" value="0"></label>
<label>MED (optional)<input name="med" id="f-med" type="number" min="0" max="4294967295" placeholder="none"></label>
<label class="span2">communities <span class="muted">(comma separated, ASN:VALUE or name)</span>
<div class="row"><input name="communities" id="f-communities" placeholder="65000:100, NO_EXPORT" style="flex:1">
<select id="f-wk"><option value="">+ well-known…</option></select></div>
</label>
<label class="span2">large communities <span class="muted">(GLOBAL:LOCAL1:LOCAL2)</span><input name="large_communities" id="f-large" placeholder="65000:1:2"></label>
<div class="row"><button type="submit" class="primary">Announce</button><button type="button" id="f-clear">Clear</button></div>
<div id="announce-msg"></div>
</form>
</section>
<section id="local">
<h2>Local routes <span class="count" id="local-count"></span></h2>
<table>
<thead><tr><th>prefix</th><th>owner</th><th>origin</th><th>AS path</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th><th></th></tr></thead>
<tbody id="local-body"></tbody>
</table>
</section>
<section id="received">
<h2>Received routes (Adj-RIB-In) <span class="count" id="received-count"></span></h2>
<p class="muted" style="margin:0 0 6px">Rows highlighted in yellow carry a prefix that is also announced locally: a route that has travelled through the lab and come back.</p>
<table>
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th></tr></thead>
<tbody id="received-body"></tbody>
</table>
</section>
<section id="events">
<h2>Event log</h2>
<div id="event-log"></div>
</section>
</main>
<footer>ferrix_bgp · Adj-RIB-In and local routes are live via <a href="/api/events">/api/events</a> · tables refresh on every event.</footer>
<script>
(() => {
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set() };
// ---- 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);
const age = unix => fmtDur(now() - unix);
const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
const tags = (list, wk) => list.length ? list.map(c => {
const name = wk && state.wellKnown[c];
return `<span class="tag${name ? ' wk' : ''}" title="${esc(c)}">${esc(name ? name : c)}</span>`;
}).join('') : '<span class="muted">—</span>';
const asPath = p => p.length ? p.join(' ') : '<span class="muted">(empty)</span>';
const myOwner = () => $('f-owner').value.trim();
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-uptime').textContent = fmtDur(s.uptime_secs + (now() - s._at));
$('s-sessions').textContent = state.peers.filter(p => p.state === 'Established').length; $('s-local').textContent = state.local.length;
$('s-received').textContent = state.received.length;
}
const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'err', Idle: 'muted' };
function renderPeers() {
const b = $('peers-body'); $('peers-count').textContent = `${state.peers.filter(p => p.state === 'Established').length} up / ${state.peers.length}`;
if (!state.peers.length) { b.innerHTML = '<tr><td colspan="9" class="empty">No peers configured and none connected.</td></tr>'; return; }
b.innerHTML = state.peers.map(p => {
const up = p.state === 'Established';
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${fmtDur(p.retry_at_unix - now())}` : '';
return `<tr>
<td>${esc(p.name ?? '')}${p.name ? '' : '<span class="muted">(inbound)</span>'}</td><td>${esc(p.peer_addr)}</td><td>${p.peer_asn}</td>
<td class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</td><td>${up ? esc(p.local_addr) : '—'}</td>
<td>${up ? fmtTime(p.established_at_unix) : '—'}</td><td>${up ? age(p.established_at_unix) : '—'}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : '—'}</td>
<td class="wrap muted">${detail}</td></tr>`; }).join('');
}
function renderLocal() {
const b = $('local-body'); $('local-count').textContent = state.local.length;
if (!state.local.length) { b.innerHTML = '<tr><td colspan="9" class="empty">Nothing announced yet.</td></tr>'; return; }
const me = myOwner(), asn = state.status ? state.status.local_asn : '?';
b.innerHTML = state.local.map(r => {
const mine = r.owner === me;
const path = Array(1 + r.prepend).fill(asn).join(' ');
const open = state.openWire.has(r.prefix);
return `<tr class="${mine ? 'mine' : ''}" data-prefix="${esc(r.prefix)}">
<td>${esc(r.prefix)}</td><td>${esc(r.owner)}</td><td>${esc(r.origin)}</td><td>${path}</td>
<td>${r.med ?? '<span class="muted">—</span>'}</td><td class="wrap">${tags(r.communities, true)}</td><td class="wrap">${tags(r.large_communities)}</td>
<td>${age(r.announced_at_unix)}</td>
<td><button class="small" data-act="wire" data-prefix="${esc(r.prefix)}">${open ? 'hide bytes' : 'bytes'}</button>
<button class="small" data-act="withdraw" data-prefix="${esc(r.prefix)}" ${mine ? '' : 'disabled title="only the owner can withdraw"'}>withdraw</button></td></tr>` +
(open ? `<tr class="wire"><td colspan="9" id="wire-${esc(r.prefix)}"><span class="muted">loading…</span></td></tr>` : '');
}).join('');
state.openWire.forEach(loadWire);
}
function renderReceived() {
const b = $('received-body'); $('received-count').textContent = state.received.length;
if (!state.received.length) { b.innerHTML = '<tr><td colspan="9" class="empty">No routes received from peers.</td></tr>'; return; }
const localPrefixes = new Set(state.local.map(r => r.prefix));
b.innerHTML = state.received.map(r => { const s = r.summary; return `<tr class="${localPrefixes.has(r.prefix) ? 'mine' : ''}">
<td>${esc(r.prefix)}</td><td>${esc(r.peer_addr)} <span class="muted">AS${r.peer_asn}</span></td><td>${asPath(s.as_path)}</td>
<td>${esc(s.next_hop ?? '—')}</td><td>${esc(s.origin ?? '—')}</td><td>${s.med ?? '<span class="muted">—</span>'}</td>
<td class="wrap">${tags(s.communities, true)}</td><td class="wrap">${tags(s.large_communities)}</td><td>${age(r.received_at_unix)}</td></tr>`; }).join('');
}
// ---- wire view: split the UPDATE bytes into labelled fields ------------
const ATTR_NAMES = {1:'ORIGIN',2:'AS_PATH',3:'NEXT_HOP',4:'MED',5:'LOCAL_PREF',6:'ATOMIC_AGGREGATE',7:'AGGREGATOR',8:'COMMUNITIES',14:'MP_REACH_NLRI',15:'MP_UNREACH_NLRI',16:'EXT_COMMUNITIES',32:'LARGE_COMMUNITIES'};
function segmentUpdate(hex) {
const bytes = hex.match(/../g) || [], segs = [];
const take = (n, cls, label) => { segs.push({ cls, label, hex: bytes.splice(0, n).join(' ') }); };
take(16, 'h', 'marker'); take(2, 'h', 'length'); take(1, 'h', 'type=UPDATE');
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'l', `withdrawn len=${wlen}`); if (wlen) take(wlen, 'n', 'withdrawn routes');
const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'l', `attr len=${alen}`);
let rem = alen;
while (rem > 0) {
const flags = parseInt(bytes[0], 16), type = parseInt(bytes[1], 16), ext = flags & 0x10;
const hdr = ext ? 4 : 3, len = ext ? parseInt(bytes[2] + bytes[3], 16) : parseInt(bytes[2], 16);
take(hdr + len, 'a', `${ATTR_NAMES[type] || 'attr ' + type} (flags 0x${flags.toString(16)}, len ${len})`); rem -= hdr + len;
}
if (bytes.length) take(bytes.length, 'n', 'NLRI');
return segs;
}
async function loadWire(prefix) {
const cell = $(`wire-${prefix}`); if (!cell) return;
try {
const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(prefix)}`);
if (!views.length) { cell.innerHTML = '<span class="muted">No established session to send this on.</span>'; return; }
cell.innerHTML = `<div class="legend muted">UPDATE as sent: <span class="h">header</span><span class="l">lengths</span><span class="a">path attributes</span><span class="n">NLRI</span></div>` +
views.map(v => `<div class="wirebox"><div>to <b>${esc(v.peer_addr)}</b> AS${v.peer_asn} · ${v.length} bytes · next hop ${esc(v.next_hop)} · AS path ${asPath(v.summary.as_path)}</div>
<div class="hex">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" title="${esc(s.label)}">${s.hex}</span>`).join(' ')}</div></div>`).join('');
} catch (e) { cell.innerHTML = `<span class="err">${esc(e.message)}</span>`; }
}
// ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); state.status._at = now(); renderStatus(); }
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderStatus(); }
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); renderStatus(); }
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); renderStatus(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
// ---- events ------------------------------------------------------------
function logEvent(text, cls) {
const box = $('event-log'), d = document.createElement('div');
d.innerHTML = `<span class="t">${new Date().toLocaleTimeString()}</span><span class="${cls || ''}">${text}</span>`;
box.prepend(d); while (box.children.length > 200) box.lastChild.remove();
}
function describe(ev) {
switch (ev.type) {
case 'session_up': return [`session UP with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn}`, 'ok'];
case 'session_down': return [`session DOWN with ${esc(ev.peer_addr)} AS${ev.peer_asn}`, 'err'];
case 'local_announced': return [`announced ${esc(ev.route.prefix)} by ${esc(ev.route.owner)} [${ev.route.communities.map(c => state.wellKnown[c] || c).map(esc).join(' ')}]`, ''];
case 'local_withdrawn': return [`withdrew ${esc(ev.prefix)}`, 'warn'];
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} withdrew ${esc(ev.prefix)}`, 'warn'];
case 'lagged': return [`missed ${ev.missed} events on ${ev.stream}; reloading`, 'err'];
default: return [esc(JSON.stringify(ev)), ''];
}
}
function connect() {
const es = new EventSource('/api/events');
es.onopen = () => { $('live-dot').classList.add('on'); $('live-text').textContent = 'live'; loadAll().catch(console.error); };
es.onerror = () => { $('live-dot').classList.remove('on'); $('live-text').textContent = 'reconnecting'; };
es.addEventListener('bgp', e => {
const ev = JSON.parse(e.data); const [text, cls] = describe(ev); logEvent(text, cls);
const t = ev.type;
if (t.startsWith('session')) loadPeers().catch(console.error);
else if (t.startsWith('local')) loadLocal().catch(console.error);
else if (t.startsWith('received')) loadReceived().catch(console.error);
else loadAll().catch(console.error);
});
}
// ---- form --------------------------------------------------------------
const splitList = s => s.split(',').map(x => x.trim()).filter(Boolean);
function setMsg(text, cls) { const m = $('announce-msg'); m.className = cls || ''; m.textContent = text; }
$('announce-form').addEventListener('submit', async e => {
e.preventDefault();
const body = {
prefix: $('f-prefix').value.trim(), owner: myOwner(), origin: $('f-origin').value,
prepend: Number($('f-prepend').value || 0), communities: splitList($('f-communities').value),
large_communities: splitList($('f-large').value),
};
if ($('f-med').value !== '') body.med = Number($('f-med').value);
try { localStorage.setItem('owner', body.owner); } catch (_) {}
setMsg('announcing…', 'muted');
const r = await fetch('/api/routes/local', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (r.ok) { setMsg(`announced ${j.prefix} to ${state.peers.length} peer(s)`, 'ok'); loadLocal().catch(console.error); }
else setMsg(`${r.status}: ${j.error || 'request failed'}`, 'err');
});
$('f-clear').addEventListener('click', () => { ['f-prefix','f-communities','f-large','f-med'].forEach(id => $(id).value = ''); $('f-prepend').value = 0; $('f-origin').value = 'IGP'; setMsg(''); });
$('f-wk').addEventListener('change', e => {
if (!e.target.value) return;
const cur = splitList($('f-communities').value); if (!cur.includes(e.target.value)) cur.push(e.target.value);
$('f-communities').value = cur.join(', '); e.target.value = '';
});
$('f-owner').addEventListener('input', renderLocal);
$('local-body').addEventListener('click', async e => {
const btn = e.target.closest('button[data-act]'); if (!btn) return;
const prefix = btn.dataset.prefix;
if (btn.dataset.act === 'wire') { state.openWire.has(prefix) ? state.openWire.delete(prefix) : state.openWire.add(prefix); renderLocal(); return; }
if (!confirm(`Withdraw ${prefix}?`)) return;
const r = await fetch('/api/routes/local', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prefix, owner: myOwner() }) });
if (!r.ok) { const j = await r.json().catch(() => ({})); setMsg(`${r.status}: ${j.error || 'withdraw failed'}`, 'err'); }
else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); loadLocal().catch(console.error); }
});
// ---- boot --------------------------------------------------------------
try { $('f-owner').value = localStorage.getItem('owner') || ''; } catch (_) {}
getJSON('/api/communities/well-known').then(list => {
list.forEach(c => { state.wellKnown[c.value] = c.name; const o = document.createElement('option'); o.value = c.name; o.textContent = `${c.name} (${c.value})`; $('f-wk').appendChild(o); });
renderLocal(); renderReceived();
}).catch(console.error);
loadAll().catch(e => logEvent(esc(e.message), 'err'));
connect();
setInterval(() => { renderStatus(); renderPeers(); renderLocal(); renderReceived(); }, 10000);
})();
</script>
</body>
</html>