Files
ferrix/crates/web/static/statusz.html
T
Rayhaan JaufeerallyandClaude Fable 5.1 6d8f89e509
ci / check (push) Canceled after 0s
ci / docs (push) Canceled after 0s
ci / interop (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s
policy, fib: blackhole routing for routes with the BLACKHOLE community
Before this change, a route carrying the BLACKHOLE community (65535:666,
RFC 7999) from an EBGP neighbor was always rejected with the reason
"blackhole request", and nothing discarded traffic. A customer under a
denial-of-service attack had no way to ask this router to drop traffic
to an address.

This change adds `accept_blackholes` to the import policy. When it is
set, a route with the BLACKHOLE community is accepted if:

* its prefix is a single address, a /32 or a /128 (otherwise it is
  rejected with the new reason "blackhole for more than one address");
  the `max_ipv4_prefix_length` and `max_ipv6_prefix_length` limits do
  not apply, as RFC 7999 section 3.2 recommends;
* the address is inside a prefix the neighbor may announce, if
  `prefixes` is configured, regardless of the entry's `max_length`
  (new `PrefixSet::covers`); without a prefix list, an address inside
  the local AS's own prefixes is rejected as before ("prefix is our
  own");
* the AS path, origin and RPKI checks that apply to every route pass.

The next hop of an accepted blackhole route is not checked for
third-party addresses and is not resolved, because traffic is never
forwarded to it. The route takes part in best path selection as usual;
a host route is normally the active route for its address.

The kernel task installs the active route as a kernel blackhole route
(`RTN_BLACKHOLE`, shown by `ip route` as `blackhole PREFIX proto bgp`).
Blackhole routes left by an earlier run are adopted at startup like
other routes of ours. The route is advertised to other neighbors as the
export policy and export limits permit, with the community intact, so a
provider that honors BLACKHOLE can discard the traffic upstream.

Implementation:

* `plan::Target` becomes an enum: `Via { gateway, interface }` or
  `Discard`. `plan::resolve` takes a `blackhole` flag and returns
  `Discard` for such routes without looking at the next hop (the
  own-network check still applies). No nexthop object is made for a
  discard target.
* `AttrSet::is_blackhole` reads the community off the stored attribute
  bytes, like `next_hop` and `as_path`, so the kernel task, the
  revalidator and the RPKI revalidation can ask it of every route.
* `ImportPolicy::is_blackhole_request` and `check_blackhole_prefix`
  carry the import side; the import flow (`routes.rs`) computes once per
  UPDATE whether its routes are an accepted blackhole request and skips
  the next hop checks for them.
* `Status::blackholes` and the `blackholes` field of `/api/kernel` count
  the installed blackhole routes; the status page shows the count next
  to the installed routes.

Not covered: a prefix this router originates with the BLACKHOLE
community is advertised but not installed as a blackhole route. The
documentation says to add a kernel blackhole route by hand for that
case.

Documentation: the "Blackhole Routes" section of the import policy
guide replaces "Blackhole Requests", the kernel guide gains a "Blackhole
Routes" section, the configuration reference describes
`accept_blackholes`, the HTTP API reference describes `blackholes`, and
the RFC 7999 rows of the standards reference are updated.

Testing: unit tests for the attribute check, the prefix checks and the
plan; integration tests that a blackhole request is refused by default
and, with `accept_blackholes`, taken for one address inside the peer's
space and refused otherwise, with the recorded reasons; a kernel test
that a blackhole request becomes a kernel blackhole route whatever its
next hop, is removed on withdrawal and is adopted after a restart. The
existing kernel tests, the interoperability lab and the workspace tests
pass.

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

1048 lines
65 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<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 heading bar per section. No framework and no external
resources. Colours are variables, with a second set for a dark room
(prefers-color-scheme): the same grammar on an inverted background, so
the page can sit beside a terminal without being the brightest thing in
the room. 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).
How colour is used, and not used: what is normal is black on white, and
draws no attention. Red is for what has failed and needs a hand (a
session down, the kernel refusing routes). Amber is for what is degraded
or wants watching (routes held or stale, a peer restarting, RPKI data
about to expire). Green is for a system that is actively doing its job
(RPKI synchronised, kernel in step), set small. Grey is for what is
inactive or expected (a passive peer waiting, a rejection count). What
repeats on every row is a glyph with a tooltip, not a sentence.
The page also keeps a little memory between refreshes, in the browser
only: the route count of each peer last time, to show a count that moved
(▲ ▼); and what each peer looked like when the tab was last in the
foreground, to mark (●) what changed while nobody was looking.
-->
<style>
:root {
--bg: #fff; --fg: #000; --fg-soft: #223; --dim: #666; --line: #99a; --faint-line: #dde;
--band: #eef; --head: #dde; --hover: #f3f3ff; --best-bg: #f4faf4; --tip-bg: #ffffe8;
--link: #00c; --visited: #551a8b; --name: #112;
--red: #c00; --amber: #a60; --green: #080;
--badge-bg: #f6f6fb; --badge-line: #aab;
--badge-red-bg: #fff0f0; --badge-amber-bg: #fff8e8; --badge-green-bg: #f0fff0;
--badge-dim-bg: #fafafa; --badge-dim-line: #ccd;
--mark-bg: #eef4ff; --mark-line: #88a; --ctl-bg: #fff4e8; --ctl-line: #c96; --wk-bg: #f0f0f0; --wk-line: #999;
--rank-fg: #446; --rank-bg: #f3f3f8; --flash: #ffe9a8;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16181d; --fg: #e4e4e8; --fg-soft: #d8dae6; --dim: #9a9aa6; --line: #4a4d5c; --faint-line: #2c2f3a;
--band: #23273a; --head: #2a2e40; --hover: #1e2230; --best-bg: #1a2a1c; --tip-bg: #2c2c22;
--link: #8db4ff; --visited: #c3a4e8; --name: #f0f0f6;
--red: #ff6b6b; --amber: #e8a846; --green: #62cf72;
--badge-bg: #22263a; --badge-line: #4a4d5c;
--badge-red-bg: #3a1e1e; --badge-amber-bg: #3a2e18; --badge-green-bg: #1a3320;
--badge-dim-bg: #1c1e26; --badge-dim-line: #3a3d4a;
--mark-bg: #1f2a44; --mark-line: #5a6aa0; --ctl-bg: #3a2a18; --ctl-line: #a0703a; --wk-bg: #2a2a2e; --wk-line: #666;
--rank-fg: #b8bce0; --rank-bg: #22263a; --flash: #6a5a20;
}
}
body { font-family: sans-serif; font-size: 13px; color: var(--fg); background: var(--bg); margin: 8px; }
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: var(--band); margin: 14px 0 6px; padding: 2px 0; }
h1.top { font-size: 150%; margin-top: 0; }
.lefthand { float: left; width: 60%; }
.righthand { text-align: right; }
a { color: var(--link); } a:visited { color: var(--visited); }
table.data { border-collapse: collapse; margin: 4px 0; }
table.data th, table.data td { border: 1px solid var(--line); padding: 2px 6px; text-align: left; vertical-align: top; }
table.data th { background: var(--head); font-weight: bold; position: sticky; top: 0; z-index: 2; }
table.data > tbody > tr:hover > td { background: var(--hover); }
table.data td.num, table.data th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
table.data tr.group > td { background: var(--band); font-weight: bold; padding: 3px 6px; }
table.data tr.best > td { background: var(--best-bg); }
table.data tr.dim td { color: var(--dim); }
.asn { cursor: help; }
.asn:hover { background: var(--head); }
#asn-tip { position: fixed; z-index: 10; max-width: 36em; background: var(--tip-bg); border: 1px solid var(--line); padding: 2px 6px; font-size: 12px; pointer-events: none; }
.mono, table.data td.mono { font-family: monospace; }
/* The colour grammar: see the note at the top. */
.red { color: var(--red); font-weight: bold; }
.amber { color: var(--amber); font-weight: bold; }
.green { color: var(--green); }
.dim { color: var(--dim); }
.nowrap { white-space: nowrap; }
/* Glyphs: small bordered badges for what repeats on every row. */
.g { display: inline-block; font-size: 10.5px; line-height: 1.2; padding: 0 4px; margin: 0 2px 1px 0; border: 1px solid var(--badge-line); border-radius: 3px; background: var(--badge-bg); color: var(--fg-soft); font-family: sans-serif; font-weight: normal; white-space: nowrap; vertical-align: middle; cursor: help; }
.g.red { border-color: var(--red); background: var(--badge-red-bg); color: var(--red); font-weight: bold; }
.g.amber { border-color: var(--amber); background: var(--badge-amber-bg); color: var(--amber); }
.g.green { border-color: var(--green); background: var(--badge-green-bg); color: var(--green); }
.g.dim { border-color: var(--badge-dim-line); background: var(--badge-dim-bg); color: var(--dim); }
.g.mark { background: var(--mark-bg); border-color: var(--mark-line); }
.g.ctl { background: var(--ctl-bg); border-color: var(--ctl-line); }
.g.wk { background: var(--wk-bg); border-color: var(--wk-line); font-weight: bold; }
/* 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 var(--dim); cursor: help; }
.comms > div { padding-left: 2em; text-indent: -2em; margin-bottom: 2px; }
.comms { min-width: 26em; }
table.kv th { background: var(--band); font-weight: normal; position: static; }
table.data td.path { min-width: 12em; }
/* An AS repeated along the path (prepended): once, with how many times. */
.g.x { font-family: monospace; background: var(--mark-bg); border-color: var(--mark-line); margin-left: 1px; cursor: help; }
/* A cell with more in it than a few lines: clamped until opened. */
.clamp { max-height: 4.4em; overflow: hidden; }
.clamp.tall:not(.open) { -webkit-mask-image: linear-gradient(#000 60%, transparent); mask-image: linear-gradient(#000 60%, transparent); }
.clamp.open { max-height: none; }
.clamp-more { cursor: pointer; display: none; margin-top: 2px; }
.tall + .clamp-more { display: inline-block; }
table.data td.name { white-space: nowrap; }
/* Peer names are what a row is found by: bold, dark, and not underlined
until pointed at, so that they stand out from the addresses and numbers
around them rather than joining the blue of every other link. */
table.data td.name a.peer-link { font-weight: bold; color: var(--name); text-decoration: none; font-size: 13.5px; }
table.data td.name a.peer-link:hover { text-decoration: underline; color: var(--link); }
/* The IPv4 and IPv6 sessions of one neighbour: a bar down the side joins
them, the line between them fades, and the second says the name quietly. */
table.data tr.pair-a > td.name, table.data tr.pair-b > td.name { border-left: 3px solid var(--mark-line); }
table.data tr.pair-a > td { border-bottom-color: var(--faint-line); }
table.data tr.pair-b > td.name a.peer-link { font-weight: normal; color: var(--dim); }
/* Where the route is in path selection: the best, or the Nth to take over. */
.g.rank { font-family: sans-serif; color: var(--rank-fg); background: var(--rank-bg); min-width: 1em; text-align: center; }
/* A route count that moved since the last refresh, and a row that changed
while the tab was in the background. */
.g.trend { font-family: sans-serif; cursor: help; }
.g.since { color: var(--amber); border-color: var(--amber); background: var(--badge-amber-bg); cursor: pointer; }
/* The strip: what needs attention, if anything. Its names lead to the row. */
#strip { position: sticky; top: 0; z-index: 3; background: var(--bg); border: 1px solid var(--line); padding: 4px 8px; margin: 6px 0; }
#strip .item { display: inline-block; margin-right: 1.5em; white-space: nowrap; }
#strip a.to-peer { color: inherit; font-weight: bold; text-decoration: none; border-bottom: 1px dotted currentColor; }
#strip a.to-peer:hover { border-bottom-style: solid; }
@keyframes flash { from { background: var(--flash); } to { background: transparent; } }
table.data > tbody > tr.flash > td { animation: flash 1.6s ease-out; }
/* Compact: the columns a wall screen or a phone can do without. */
body.compact .c-opt, body.compact .legend { display: none; }
body.compact table.data th, body.compact table.data td { padding: 1px 4px; }
/* The breakdown of a peer's rejections, by reason. */
.bar { display: inline-flex; width: 16em; height: 10px; border: 1px solid var(--line); vertical-align: middle; margin: 0 6px; background: var(--bg); }
.bar span { display: block; height: 100%; }
details { clear: both; margin: 8px 0 0; }
summary { cursor: pointer; color: var(--link); }
summary:hover { text-decoration: underline; }
#config-text { border: 1px solid var(--line); padding: 4px 6px; margin: 4px 0; font-size: 12px; overflow-x: auto; }
address { font-size: smaller; margin-top: 2em; color: var(--dim); }
form.lookup { margin: 4px 0; }
form.lookup input[type=text] { font-family: monospace; }
input, select, button { color-scheme: light dark; }
.pager { margin: 4px 0; }
.pager a, .pager span { margin-right: 1em; }
.toolbar { margin: 4px 0; }
.toolbar label { margin-right: 1em; }
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
#event-log div { white-space: pre-wrap; }
</style>
</head>
<body>
<h1 class="top">Status for ferrix_bgp</h1>
<div>
<div class="lefthand">
<b id="s-hostname">...</b>: router ID <b id="s-router-id">...</b>, AS <b id="s-asn">...</b>, Ferrix <span id="s-version">...</span><br>
Started <span id="s-started">...</span>, up <span id="s-uptime">...</span><br>
<span class="dim">On this page: <a href="#peers">peers</a> · <a href="#rpki">RPKI</a> · <a href="#local">originated</a> · <a href="#kernel">kernel</a> · <a href="#received">received, and looking routes up</a> · <a href="#rejected">rejected</a> · <a href="#events">events</a></span><br>
</div>
<div class="righthand">
<span id="refresh-ctl"></span><br>
JSON: <a href="/api/status">status</a>, <a href="/api/peers">peers</a>, <a href="/api/routes/local">originated</a>, <a href="/api/routes/received">received</a>, <a href="/api/routes/rejected">rejected</a>, <a href="/api/rpki">rpki</a>, <a href="/api/kernel">kernel</a>; <a href="/metricz">metrics</a><br>
</div>
</div>
<div id="strip" style="clear: both">...</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>
<div class="toolbar">
<label><input type="checkbox" id="peers-problems"> problems only</label>
<label title="Fewer columns and tighter rows, for a wall screen or a phone"><input type="checkbox" id="compact"> compact</label>
<span id="since-ctl"></span>
<span class="dim legend">Glyphs: <span class="g">v4</span><span class="g">v6</span> address families · <span class="g">GR</span> graceful restart · <span class="g">ENH</span> IPv4 with IPv6 next hops · <span class="g">AP</span> ADD-PATH · <span class="g amber">2B</span> two-octet AS numbers only · <span class="g">↑</span> provider <span class="g">↓</span> customer <span class="g">↔</span> peer <span class="g">RS</span> route server <span class="g">RSC</span> its client <span class="g">iBGP</span> our AS. Point at one for its meaning; click a peer's name to look at its routes.</span>
</div>
<table class="data">
<thead><tr><th>name</th><th>peer</th><th>state</th><th>up for</th><th class="c-opt">our address (next hop)</th><th class="c-opt">session</th><th class="num">routes</th><th class="num" title="Announcements rejected by the import policy since the daemon started, and routes held out of the table as invalid or unreachable now">rejected · held</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 class="num">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="prefix, address, AS number, or peer" title="A prefix; an address, to find every prefix that covers it; an AS number, to find every route it originates; or a peer's name">
<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></th><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th class="num">MED</th><th class="num">local pref</th><th>communities</th><th>large communities</th><th>attrs</th><th title="Route origin validation (RPKI) and AS path verification (ASPA)">RPKI · ASPA</th></tr></thead>
<tbody id="received-body"></tbody>
</table>
<div class="pager" id="received-pager-below"></div>
<h1 id="rejected">Rejected routes (<span id="rejected-count">...</span>)</h1>
<div class="dim">Routes that peers announced and their import policies did not take. The counts are of announcements since the daemon started: a route announced again and rejected again counts again. Of each peer's latest rejections, those alike are one line, with how many and when the last was.</div>
<table class="data">
<thead><tr><th>peer</th><th>prefix</th><th>AS path</th><th>reason</th><th class="num">times</th><th>last</th><th>next hop</th><th>origin</th><th>communities</th><th>large communities</th><th>attrs</th></tr></thead>
<tbody id="rejected-body"></tbody>
</table>
<h1 id="events">Event log</h1>
<div id="log-state" class="dim"></div>
<div id="event-log"></div>
<address>ferrix_bgp &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: {}, rejected: [], rpki: null, kernel: null };
const DASH = '<span class="dim">–</span>';
const fmtCount = n => Number(n).toLocaleString('en-US');
// A glyph: a small badge with its meaning as the tooltip.
const glyph = (text, title, cls = '') => `<span class="g ${cls}" title="${esc(title)}">${esc(text)}</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 fmtWhen = unix => new Date(unix * 1000).toLocaleString();
// What a community of ours asks for where control communities are on
// (control_communities), or ''. They are acted on where a route leaves
// our AS.
const TIMES = { 1: 'once', 2: 'twice', 3: 'three times' };
function controlMeaning(c, large) {
const s = state.status;
if (!s || !s.control_communities) return '';
const L = s.local_asn, p = c.split(':').map(Number);
if (!large) {
const [high, low] = p;
if (high === 0) return low === L ? 'to no other network' : `not to AS${low}`;
if (high === L) return low === L ? 'to every network' : `only to AS${low} (and others named so)`;
return '';
}
const [admin, func, arg] = p;
if (admin !== L) return '';
if (func === 0) return arg ? `not to AS${arg}` : 'to no other network';
if (func === 1) return `only to AS${arg} (and others named so)`;
if (func >= 11 && func <= 13) return `our AS ${TIMES[func - 10]} more on the path to ${arg ? `AS${arg}` : 'every network'}`;
if (func === 20) return `not to group ${arg}`;
if (func === 21) return `only to group ${arg} (and others named so)`;
if (func >= 31 && func <= 33) return `our AS ${TIMES[func - 30]} more on the path to group ${arg}`;
return '';
}
// The configured name for a community (community_names): an exact one
// first, then a pattern, whose open part is shown after the name.
function communityName(c) {
// An older daemon's status has no names.
const names = (state.status && state.status.community_names) || [];
const parts = c.split(':');
let fallback = null;
for (const entry of names) {
const pattern = entry.community.split(':');
if (pattern.length !== parts.length) continue;
if (entry.community === c) return entry.name;
if (!fallback && pattern.every((p, i) => p === '*' || p === parts[i])) {
const open = parts.filter((_, i) => pattern[i] === '*').join(':');
fallback = `${entry.name} ${open}`;
}
}
return fallback;
}
// Communities, one line for each AS that defines them, in the order the
// route has them. Well-known ones by name; ours that ask for something
// with what they ask as a tooltip; those with a configured name as a
// badge with that name, the number as the tooltip. Pointing at a line
// names its AS.
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 wk = !large && WELL_KNOWN[c];
if (wk) return glyph(wk, c, 'wk');
const name = communityName(c);
const meaning = controlMeaning(c, large);
if (name) return glyph(name, meaning ? `${c}: ${meaning}` : c, meaning ? 'ctl' : 'mark');
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, as glyphs.
const OTHER_GLYPHS = {
'atomic aggregate': ['AA', 'ATOMIC_AGGREGATE: the route is an aggregate that lost information'],
'aggregator': ['AGG', 'AGGREGATOR: the AS and router that made the aggregate'],
'extended communities': ['EXT', 'Extended communities'],
'AS4_PATH': ['AS4', 'AS4_PATH: the path with four-octet AS numbers, from a two-octet session'],
'only to customer': ['OTC', 'Only to Customer (RFC 9234): the route came down or across from this AS, and may only go on to customers'],
'originator ID': ['OID', 'ORIGINATOR_ID: the router of our AS that learned the route from outside'],
'cluster list': ['CL', 'CLUSTER_LIST: the route reflector clusters the route has passed through'],
};
const otherList = list => list && list.length
? list.map(a => {
const [text, meaning] = OTHER_GLYPHS[a.name] || [a.name, a.name];
return glyph(text, `${meaning}: ${a.value}`);
}).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>`;
// A path, with an AS that repeats (prepending) once, and how many times.
function asPath(p) {
if (!p.length) return '<span class="dim">(empty)</span>';
const runs = [];
for (const n of p) {
const last = runs[runs.length - 1];
if (last && last.asn === n) last.times++; else runs.push({ asn: n, times: 1 });
}
return runs.map(r => r.times === 1 ? asn(r.asn)
: `<span class="nowrap">${asn(r.asn)}${glyph(`×${r.times}`, `AS${r.asn} ${r.times} times in a row: prepended ${r.times - 1} times`, 'x')}</span>`).join(' ');
}
// Wraps what can run to many lines: shown clamped, with a way to open
// it, if it does. See markTall.
const clamp = html => `<div class="clamp">${html}</div><span class="g dim clamp-more">▾ more</span>`;
// After a render: finds the clamped cells with more than fits.
function markTall(root) {
for (const c of root.querySelectorAll('.clamp')) c.classList.toggle('tall', c.scrollHeight > c.clientHeight + 1);
}
function toggleClamp(cell) {
const c = cell.querySelector('.clamp.tall'), more = cell.querySelector('.clamp-more');
if (!c) return;
const open = c.classList.toggle('open');
more.textContent = open ? '▴ less' : '▾ more';
}
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();
}
// An established peer with under half the routes it is expected to send
// (expected_routes), once it has had a minute to send them.
const SETTLING_SECS = 60;
function belowExpected(p) {
return p.state === 'Established' && p.expected_routes > 0 && p.routes != null
&& p.routes < p.expected_routes / 2 && now() - p.established_at_unix > SETTLING_SECS;
}
// Peers in name order, except that the IPv6 session of a neighbour
// (<stem>-v6) comes before its IPv4 one: IPv6 is the network's present,
// IPv4 what is still carried along.
const FAMILY_SUFFIX = /^(.*)-v([46])$/;
function nameOrder(a, c) {
const [sa, fa] = FAMILY_SUFFIX.exec(a)?.slice(1) || [a, ''];
const [sc, fc] = FAMILY_SUFFIX.exec(c)?.slice(1) || [c, ''];
return sa.localeCompare(sc) || fc.localeCompare(fa);
}
const byName = (a, c) => nameOrder(a.name || a.peer_addr, c.name || c.peer_addr);
// Where a peer stands, for the strip and for sorting: 0 failed, 1
// degraded, 2 normal, 3 inactive and expected.
function peerRank(p) {
if (p.state === 'Backoff' || p.state === 'Connecting') return p.stale_until_unix ? 1 : 0;
if (p.state === 'Established') return p.stale_routes || belowExpected(p) ? 1 : 2;
return 3;
}
// ---- what moved: between refreshes, and while nobody was looking --------
// A route count that moved by more than a little since the last refresh
// is shown with an arrow for a while: a peer flapping or draining shows
// here before anything else says so.
const TREND_MIN_SHARE = 0.02, TREND_MIN_ROUTES = 10, TREND_SHOWN_SECS = 120;
const trends = new Map();
function noteTrends(before, after) {
const was = new Map(before.filter(p => p.name).map(p => [p.name, p]));
for (const p of after) {
const old = p.name && was.get(p.name);
if (!old || p.state !== 'Established' || old.state !== 'Established' || p.routes == null || old.routes == null) continue;
const moved = p.routes - old.routes;
if (Math.abs(moved) >= TREND_MIN_ROUTES && Math.abs(moved) / Math.max(old.routes, 1) >= TREND_MIN_SHARE) {
trends.set(p.name, { from: old.routes, to: p.routes, at: now() });
}
}
}
function trendGlyph(p) {
const t = p.name && trends.get(p.name);
if (!t || now() - t.at > TREND_SHOWN_SECS) return '';
const up = t.to > t.from, share = Math.round(100 * Math.abs(t.to - t.from) / Math.max(t.from, 1));
return glyph(up ? '▲' : '▼', `${up ? 'Up' : 'Down'} from ${fmtCount(t.from)} to ${fmtCount(t.to)} (${share}%) ${fmtDur(now() - t.at)} ago`, 'trend amber');
}
// What each peer looked like when the tab last went into the background.
// On coming back, a row whose state changed, or whose routes moved as
// above, carries a mark until it is clicked.
let lastLooked = null;
const glance = () => new Map(state.peers.filter(p => p.name).map(p => [p.name, { state: p.state, routes: p.routes }]));
function changedSince(p) {
const then = lastLooked && p.name && lastLooked.get(p.name);
if (!then) return null;
if (then.state !== p.state) return `was ${then.state}`;
if (then.routes != null && p.routes != null) {
const moved = p.routes - then.routes;
if (Math.abs(moved) >= TREND_MIN_ROUTES && Math.abs(moved) / Math.max(then.routes, 1) >= TREND_MIN_SHARE) return `had ${fmtCount(then.routes)} routes`;
}
return null;
}
function sinceGlyph(p) {
const change = changedSince(p);
return change ? glyph('●', `Changed since you last looked at this tab: ${change}. Click to clear.`, 'since') : '';
}
// Clears the mark on one row, or on all.
function lookedAt(name) {
if (!lastLooked) return;
const seen = glance();
if (name) { if (seen.has(name)) lastLooked.set(name, seen.get(name)); } else lastLooked = seen;
renderPeers();
}
document.addEventListener('visibilitychange', () => { if (document.hidden) lastLooked = glance(); });
const ROLE_GLYPH = {
provider: ['↑', 'provider: we buy transit from it'],
customer: ['↓', 'customer: it buys transit from us'],
peer: ['↔', 'peer: we exchange our own and our customers’ routes'],
'route server': ['RS', 'route server of an Internet exchange'],
'route server client': ['RSC', 'client of a route server we run'],
};
function roleGlyph(p) {
if (p.peer_asn === (state.status || {}).local_asn) return glyph('iBGP', 'a router of our own AS');
const r = ROLE_GLYPH[p.role];
if (!r) return glyph('?', 'no role configured', 'amber');
const confirmed = p.role_confirmed ? '; the peer says so too (RFC 9234)' : p.state === 'Established' ? '; by our configuration, the peer has not said what it is' : '';
return glyph(r[0], r[1] + confirmed);
}
// The session's capabilities as glyphs.
function sessionGlyphs(p) {
const out = [];
const families = p.address_families || [];
if (families.some(f => f.startsWith('IPv4'))) out.push(glyph('v4', 'IPv4 unicast'));
if (families.some(f => f.startsWith('IPv6'))) out.push(glyph('v6', 'IPv6 unicast'));
if (p.extended_next_hop) out.push(glyph('ENH', 'IPv4 routes go with IPv6 next hops (RFC 8950)'));
const gr = p.graceful_restart;
if (gr) out.push(glyph('GR', `Graceful restart (RFC 4724): the peer says a restart takes ${gr.restart_time_secs}s${gr.notification ? '; NOTIFICATIONs other than Hard Reset count as restarts (RFC 8538)' : ''}`));
const rx = p.add_path_receive || [], tx = p.add_path_send || [];
if (rx.length || tx.length) out.push(glyph(`AP${rx.length ? '↓' : ''}${tx.length ? '↑' : ''}`, `ADD-PATH (RFC 7911): ${rx.length ? `we receive several paths for ${rx.join(', ')}` : ''}${rx.length && tx.length ? '; ' : ''}${tx.length ? `we send several for ${tx.join(', ')}` : ''}`));
if (p.four_octet_asn === false) out.push(glyph('2B', 'The peer handles two-octet AS numbers only (no RFC 6793): wide AS numbers go as AS_TRANS', 'amber'));
if (p.direction) out.push(glyph(p.direction === 'outbound' ? '⇢' : '⇠', p.direction === 'outbound' ? 'the connection was opened by us' : 'the connection was opened by the peer', 'dim'));
return out.join('');
}
const STATE_CLASS = { Established: '', Connecting: 'amber', Backoff: 'red', Idle: 'dim', Passive: 'dim' };
function peerDetail(p) {
const parts = [];
if (p.state !== 'Established' && p.stale_until_unix)
parts.push(`<span class="amber">restarting</span>: ${fmtCount(p.stale_routes)} routes kept for ${until(p.stale_until_unix)}`);
if (p.state === 'Backoff') parts.push(`${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}`);
if (p.state === 'Passive') parts.push(`waiting for the peer to connect${p.last_error ? `; last session: ${esc(p.last_error)}` : ''}`);
return parts.join('; ');
}
const ROLE_ORDER = ['iBGP', 'provider', 'route server', 'peer', 'customer', 'route server client', 'none'];
function peerGroup(p) {
if (p.peer_asn === (state.status || {}).local_asn) return 'iBGP';
return p.role || 'none';
}
const GROUP_TITLE = { iBGP: 'Our own routers', provider: 'Providers', 'route server': 'Route servers', peer: 'Peers', customer: 'Customers', 'route server client': 'Route server clients', none: 'Without a role' };
function renderPeers() {
const b = $('peers-body');
const peers = state.peers;
const up = peers.filter(p => p.state === 'Established').length;
const down = peers.filter(p => peerRank(p) === 0).length;
const waiting = peers.filter(p => p.state === 'Passive').length;
$('peers-count').textContent = `${up} of ${peers.length} established` + (down ? `, ${down} down` : '') + (waiting ? `, ${waiting} waiting` : '');
if (!peers.length) { setHTML(b, '<tr><td colspan="9" class="dim">No peers configured and none connected.</td></tr>'); return; }
const problemsOnly = $('peers-problems').checked;
// The IPv4 and IPv6 sessions of one neighbour, named <stem>-v4 and
// <stem>-v6, are one neighbour to the reader: listed together, IPv6
// first, the name once in full and once quietly, joined down the side.
const row = (p, pair) => {
const up = p.state === 'Established';
const family = p.name && FAMILY_SUFFIX.exec(p.name);
const shown = pair && family ? `${esc(family[1])}<span class="dim">-v${family[2]}</span>` : esc(p.name || '');
const name = p.name ? `<a href="#received" class="peer-link" data-peer="${esc(p.name)}" title="Look at this peer's routes">${shown}</a>` : '<span class="dim">(inbound)</span>';
const rejected = up ? `<span class="dim">${fmtCount(p.rejected_routes || 0)}</span>` : DASH;
const held = up && p.held_routes ? ` · <span class="amber" 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">${fmtCount(p.held_routes)} held</span>` : '';
const stale = up && p.stale_routes ? ` ${glyph(`${fmtCount(p.stale_routes)} stale`, 'Kept from the peer’s last session, and not sent again yet', 'amber')}` : '';
const short = belowExpected(p);
const expected = up && p.expected_routes > 0
? ` <span class="${short ? 'amber' : 'dim'}" title="${short ? 'Well under' : 'About'} the ${fmtCount(p.expected_routes)} routes this peer is expected to send (expected_routes)">of ~${fmtCount(p.expected_routes)}</span>` : '';
const classes = [peerRank(p) === 3 ? 'dim' : '', pair || ''].filter(Boolean).join(' ');
return `<tr class="${classes}" data-peer-row="${esc(p.name || p.peer_addr)}">
<td class="name">${sinceGlyph(p)}${name}</td>
<td class="mono">${esc(p.peer_addr)} <span class="dim">AS${asn(p.peer_asn)}</span> ${roleGlyph(p)}</td>
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td>
<td class="nowrap">${up ? `<span title="since ${esc(fmtWhen(p.established_at_unix))}">${age(p.established_at_unix)}</span>` : DASH}</td>
<td class="mono c-opt">${up ? esc(p.local_addr) : DASH}</td>
<td class="c-opt">${up ? sessionGlyphs(p) : DASH}</td>
<td class="num">${up ? trendGlyph(p) + fmtCount(p.routes) + expected + stale : DASH}</td>
<td class="num">${rejected}${held}</td>
<td>${peerDetail(p)}</td></tr>`;
};
// Rows for a sorted list, with the v4/v6 pairs in it marked.
const rows = list => list.map((p, i) => {
const stem = p.name && FAMILY_SUFFIX.exec(p.name);
const sameStem = q => q && q.name && FAMILY_SUFFIX.exec(q.name)?.[1] === stem[1];
if (!stem) return row(p);
if (sameStem(list[i + 1])) return row(p, 'pair-a');
if (sameStem(list[i - 1])) return row(p, 'pair-b');
return row(p);
}).join('');
let html = '';
// What needs attention first, whoever it is; then everyone by what
// they are to us.
const problems = peers.filter(p => peerRank(p) <= 1).sort(byName);
if (problems.length) {
html += `<tr class="group"><td colspan="9"><span class="red">Needing attention</span> (${problems.length})</td></tr>` + rows(problems);
}
if (!problemsOnly) {
for (const group of ROLE_ORDER) {
const members = peers.filter(p => peerRank(p) >= 2 && peerGroup(p) === group).sort(byName);
if (!members.length) continue;
html += `<tr class="group"><td colspan="9">${GROUP_TITLE[group]} (${members.length})</td></tr>` + rows(members);
}
} else if (!problems.length) {
html += '<tr><td colspan="9" class="dim">Nothing needs attention.</td></tr>';
}
setHTML(b, html);
const marked = peers.filter(changedSince).length;
setHTML($('since-ctl'), marked ? `${glyph('●', 'Rows that changed while this tab was in the background', 'since')} <span class="dim">${marked} changed since you last looked ·</span> <a href="#" id="since-clear">clear</a>` : '');
renderPeerChoices();
}
// The strip: what needs attention, in one line, or that nothing does.
function renderStrip() {
const items = [];
const peers = state.peers, k = state.kernel, r = state.rpki;
// A name in the strip leads to the peer's row.
const toPeer = p => `<a href="#peers" class="to-peer" data-peer="${esc(p.name || p.peer_addr)}" title="Go to this peer's row">${esc(p.name || p.peer_addr)}</a>`;
const down = peers.filter(p => peerRank(p) === 0);
if (down.length) items.push(`<span class="red">${down.length} session${down.length === 1 ? '' : 's'} down:</span> ${down.map(p => `${toPeer(p)} <span class="dim">(${esc((p.last_error || p.state).replace(/;.*$/, ''))})</span>`).join(', ')}`);
const short = peers.filter(belowExpected);
if (short.length) items.push(`<span class="amber">${short.length} sending far fewer routes than expected:</span> ${short.map(p => `${toPeer(p)} <span class="dim">(${fmtCount(p.routes)} of ~${fmtCount(p.expected_routes)})</span>`).join(', ')}`);
const restarting = peers.filter(p => peerRank(p) === 1 && !belowExpected(p));
if (restarting.length) items.push(`<span class="amber">${restarting.length} restarting or stale:</span> ${restarting.map(toPeer).join(', ')}`);
if (k && k.enabled) {
if (!k.synced) items.push('<span class="amber">kernel catching up</span> with the routing table');
if (k.refused) items.push(`<span class="red">kernel refused ${fmtCount(k.refused)} routes</span>`);
const unresolved = Object.values(k.unresolved || {}).reduce((a, n) => a + n, 0);
if (unresolved) items.push(`<span class="amber">${fmtCount(unresolved)} best routes not installed</span> (next hop unusable)`);
}
if (r && r.enabled) {
if (r.state !== 'synchronised') items.push(`<span class="red">RPKI ${esc(r.state)}</span>${r.last_error ? ` <span class="dim">(${esc(r.last_error)})</span>` : ''}`);
else if (!r.updated_at_unix) items.push('<span class="amber">RPKI has no data</span>: nothing is refused as invalid');
else if (r.expires_at_unix && r.expires_at_unix - now() < 3600) items.push(`<span class="amber">RPKI data expires in ${until(r.expires_at_unix)}</span> unless confirmed`);
}
const held = peers.reduce((a, p) => a + (p.held_routes || 0), 0);
const normal = [];
const up = peers.filter(p => p.state === 'Established').length;
normal.push(`${up}/${peers.length} sessions up`);
if (k && k.enabled) normal.push(`${fmtCount(k.installed)} routes in the kernel${k.synced ? ', <span class="green">in step</span>' : ''}`);
if (r && r.enabled && r.state === 'synchronised' && r.updated_at_unix) normal.push(`RPKI <span class="green">current</span>`);
if (held) normal.push(`<span class="dim">${fmtCount(held)} routes held</span>`);
const waiting = peers.filter(p => p.state === 'Passive').length;
if (waiting) normal.push(`<span class="dim">${waiting} waiting to connect</span>`);
const head = items.length ? items.map(i => `<span class="item">${i}</span>`).join('') : '<span class="item"><span class="dim">Nothing needs attention.</span></span>';
setHTML($('strip'), head + `<br><span class="dim">${normal.join(' · ')}</span>`);
}
// ---- 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', 'origin', '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') || '', origin: here.get('origin') || '', 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.origin || 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.origin) ask.set('origin', l.origin);
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, '')))].sort(nameOrder);
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.origin ? `AS${l.origin}` : 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.origin) url.searchParams.set('origin', l.origin);
if (l.best) url.searchParams.set('best', '1');
}
history.replaceState(null, '', url.pathname + url.search + '#received');
showLookup(); renderRefreshControl();
loadReceived().catch(console.error);
renderRejected();
}
// What was typed is a peer's name if a peer has it: then it is the peer
// to look at, not a prefix. A number, with or without AS in front, is
// an AS: the routes it originates.
function submitLookup() {
const typed = $('lookup-q').value.trim();
const isPeer = state.peers.some(p => p.name === typed);
const asn = /^(?:AS)?(\d+)$/i.exec(typed);
setLookup({ q: isPeer || asn ? '' : typed, mode: $('lookup-mode').value, peer: isPeer ? typed : $('lookup-peer').value,
origin: asn ? asn[1] : '', best: $('lookup-best').checked });
}
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 class="num">${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(''));
}
const VALIDITY = {
valid: ['✓', 'green'], invalid: ['✗', 'red'], 'not found': ['–', 'dim'], unknown: ['–', 'dim'],
};
function validity(what, value) {
if (!value) return `<span class="dim" title="${esc(what)}: not checked">·</span>`;
const [mark, cls] = VALIDITY[value] || [value, ''];
return `<span class="${cls}" title="${esc(what)}: ${esc(value)}">${mark}</span>`;
}
// Where a route stands in path selection: the best, or the one that
// would take over were those ranked before it gone.
const ORDINAL = n => n + (n % 10 === 2 && n % 100 !== 12 ? 'nd' : n % 10 === 3 && n % 100 !== 13 ? 'rd' : 'th');
function rankGlyph(r) {
if (r.best) return glyph('★', 'The best route for this prefix: the one chosen, installed and passed on', 'green');
if (!r.rank) return '';
return glyph(String(r.rank), `${ORDINAL(r.rank)} in path selection: the route that would be chosen were the ${r.rank - 1} before it gone`, 'rank');
}
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 ? `${fmtCount(state.received.length)} on this page` : fmtCount(state.received.length);
$('received-count').textContent = looking
? `${shown}${paged ? '' : ' found'}${st ? ` among ${fmtCount(st.received_routes)}` : ''}`
: paged && st
? `${fmtCount(st.received_routes)}, for ${fmtCount(st.received_prefixes)} prefixes; ${shown}`
: `${shown}${st ? `, for ${fmtCount(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 class="${r.best ? 'best' : ''}">
<td class="nowrap">${rankGlyph(r)}${r.stale ? glyph('stale', 'Kept from a session of the peer’s that ended (graceful restart), and not sent again since', 'amber') : ''}</td>
<td class="mono">${esc(r.prefix)}</td>
<td class="mono">${r.peer_name ? `<a href="#received" class="peer-link" data-peer="${esc(r.peer_name)}">${esc(r.peer_name)}</a> ` : ''}<span class="dim">${esc(r.peer_addr)} AS${asn(r.peer_asn)}${r.path_id ? ` path ${r.path_id}` : ''}</span></td>
<td class="mono path">${clamp(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 class="num">${s.med ?? DASH}</td><td class="num">${s.local_pref ?? DASH}</td>
<td>${clamp(commList(s.communities))}</td><td>${clamp(commList(s.large_communities, true))}</td><td>${otherList(s.other)}</td>
<td class="nowrap">${validity('RPKI', r.rpki)} ${validity('ASPA', r.aspa)}</td></tr>`; }).join(''));
markTall(b);
}
const RPKI_STATE_CLASS = { synchronised: 'green', synchronising: 'amber', connecting: 'red' };
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
? `${fmtCount(r.vrps_ipv4)} IPv4 and ${fmtCount(r.vrps_ipv6)} IPv6 VRPs and ${fmtCount(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="amber">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="amber">This cache speaks an RTR version without ASPAs: AS paths are not verified.</span>' : '') +
(r.last_error ? `<br><span class="red">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 ? 'green' : 'amber'}">${k.synced ? 'in step' : 'catching up'}</span> with the routing table`) +
row('routes installed', `<b>${fmtCount(k.installed)}</b>${k.blackholes ? ` <span class="dim">of which ${fmtCount(k.blackholes)} ${k.blackholes === 1 ? 'is a blackhole' : 'are blackholes'}, dropping the packets for their prefix</span>` : ''}`) +
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="amber">${fmtCount(k.kept)}</span>, not announced again yet`) : '') +
(unresolved ? row('not installed', `<span class="amber">${unresolved}</span>`) : '') +
(k.refused ? row('refused by the kernel', `<span class="red">${fmtCount(k.refused)}</span>`) : '') +
(k.last_error ? row('last error', `<span class="red">${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>${i.up ? glyph('▲ up', 'the interface is up', 'green') : glyph('▼ down', 'the interface is down', 'red')}</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);
}
// The breakdown of a peer's rejections by reason: a bar, each reason a
// segment, with the counts after it.
const REASON_COLOURS = ['#88a', '#a60', '#6a6', '#c66', '#69c', '#996', '#a6a', '#999'];
function reasonBar(byReason, total) {
const reasons = Object.entries(byReason).sort((a, c) => c[1] - a[1]);
const bar = reasons.map(([reason, n], i) =>
`<span style="width: ${100 * n / total}%; background: ${REASON_COLOURS[i % REASON_COLOURS.length]}" title="${esc(reason)}: ${fmtCount(n)}"></span>`).join('');
const legend = reasons.map(([reason, n], i) =>
`<span class="nowrap"><span style="display: inline-block; width: 8px; height: 8px; background: ${REASON_COLOURS[i % REASON_COLOURS.length]}"></span> ${fmtCount(n)} ${esc(reason)}</span>`).join(', ');
return `<span class="bar">${bar}</span>${legend}`;
}
// For each peer a line that says how many and why, then its latest
// rejections with those alike as one line.
const REJECTED_SHOWN = 20;
function renderRejected() {
const b = $('rejected-body');
const chosen = lookup().peer;
const peers = chosen ? state.rejected.filter(p => p.peer === chosen) : state.rejected;
const total = state.rejected.reduce((n, p) => n + p.total, 0);
const announcements = n => `${fmtCount(n)} announcement${n === 1 ? '' : 's'}`;
$('rejected-count').textContent = total ? `${announcements(total)} from ${state.rejected.length} peer${state.rejected.length === 1 ? '' : 's'}${chosen ? `; showing ${chosen}` : ''}` : '0';
if (!peers.length) { setHTML(b, `<tr><td colspan="11" class="dim">${chosen ? `Nothing ${esc(chosen)} announced has been rejected.` : 'Nothing a peer announced has been rejected.'}</td></tr>`); return; }
setHTML(b, peers.map(p => {
// Alike: the same prefix, for the same reason, by the same path.
const alike = new Map();
for (const r of p.recent) {
const key = `${r.prefix}|${r.reason}|${r.as_path.join(' ')}`;
const seen = alike.get(key);
if (seen) { seen.times += 1; seen.first = Math.min(seen.first, r.at_unix); }
else alike.set(key, { ...r, times: 1, first: r.at_unix });
}
const lines = [...alike.values()].sort((a, c) => c.at_unix - a.at_unix).slice(0, REJECTED_SHOWN);
const more = alike.size > REJECTED_SHOWN
? ` <span class="dim">${REJECTED_SHOWN} of ${alike.size} shown; <a href="/api/routes/rejected?peer=${encodeURIComponent(p.peer)}">the latest ${p.recent.length} as json</a>.</span>` : '';
return `<tr class="group"><td colspan="11"><a href="#received" class="peer-link" data-peer="${esc(p.peer)}">${esc(p.peer)}</a> <span class="dim" style="font-weight: normal">${esc(p.peer_addr)}</span>: <span style="font-weight: normal">${announcements(p.total)} rejected</span> ${reasonBar(p.by_reason, p.total)}${more}</td></tr>` +
lines.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">${clamp(asPath(r.as_path))}</td><td>${esc(r.reason)}</td>
<td class="num">${r.times > 1 ? `×${r.times}` : ''}</td>
<td class="nowrap"><span title="${esc(fmtWhen(r.at_unix))}${r.times > 1 ? `; the first of these ${esc(fmtWhen(r.first))}` : ''}">${age(r.at_unix)} ago</span></td>
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td>
<td>${clamp(commList(s.communities))}</td><td>${clamp(commList(s.large_communities, true))}</td><td>${otherList(s.other)}</td></tr>`; }).join('');
}).join(''));
markTall(b);
}
// ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); renderReceived(); renderPeers(); renderStrip(); }
async function loadPeers() { const fresh = await getJSON('/api/peers'); noteTrends(state.peers, fresh); state.peers = fresh; renderPeers(); renderStrip(); }
// 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="red">${esc(e.message.replace(/^[^:]*: \d+ /, ''))}</span>`;
}
renderReceived();
}
async function loadRejected() { state.rejected = await getJSON('/api/routes/rejected'); renderRejected(); }
async function loadRpki() { state.rpki = await getJSON('/api/rpki'); renderRpki(); renderStrip(); }
async function loadKernel() { state.kernel = await getJSON('/api/kernel'); renderKernel(); renderStrip(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadRejected(), 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="green">session UP</span> with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} (opened by ${ev.peer.direction === 'outbound' ? 'us' : 'peer'})`;
case 'route_refresh_requested': return `${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} asked for a route refresh; re-sent its routes`;
case 'session_down': return `<span class="red">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="amber">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="amber">no route</span> any more`;
case 'lagged': return `<span class="red">missed ${ev.missed == null ? '' : ev.missed + ' '}events</span> on ${esc(ev.stream)}`;
default: return esc(JSON.stringify(ev));
}
}
function connect() {
const st = $('log-state');
const es = new EventSource('/api/events');
es.onopen = () => { st.textContent = 'Live: connected to the event stream.'; };
es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; };
// Events only feed the log. How often the tables reload is decided by
// ?refresh=, so the page never changes faster than the reader asked for.
es.addEventListener('bgp', e => logEvent(describe(JSON.parse(e.data))));
}
// ---- auto-refresh, opt-in through ?refresh=<seconds> --------------------
const refreshParam = parseInt(new URLSearchParams(location.search).get('refresh'), 10);
const REFRESH = Number.isFinite(refreshParam) && refreshParam >= 1 ? Math.min(refreshParam, 3600) : 0;
function refreshLink(seconds, text) {
const url = new URL(location.href);
if (seconds) url.searchParams.set('refresh', seconds); else url.searchParams.delete('refresh');
return `<a href="${esc(url.pathname + url.search + url.hash)}">${text}</a>`;
}
function renderRefreshControl() {
$('refresh-ctl').innerHTML = (REFRESH
? `Auto-refresh: <b>every ${fmtDur(REFRESH)}</b> (${refreshLink(0, 'turn off')})`
: `Auto-refresh: off (turn on: ${[2, 5, 30].map(n => refreshLink(n, n + 's')).join(', ')})`) +
` &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(); submitLookup(); });
$('lookup-clear').addEventListener('click', e => { e.preventDefault(); setLookup(null); });
$('peers-problems').addEventListener('change', renderPeers);
// Compact is remembered in this browser; nothing else is.
const COMPACT_KEY = 'statusz.compact';
try { $('compact').checked = localStorage.getItem(COMPACT_KEY) === '1'; } catch (e) { /* no storage: a session's choice only */ }
const applyCompact = () => document.body.classList.toggle('compact', $('compact').checked);
applyCompact();
$('compact').addEventListener('change', () => { applyCompact(); try { localStorage.setItem(COMPACT_KEY, $('compact').checked ? '1' : '0'); } catch (e) { /* as above */ } });
// A name in the strip goes to the peer's row and lights it for a moment.
document.addEventListener('click', e => {
const link = e.target.closest('a.to-peer');
if (!link) return;
e.preventDefault();
$('peers-problems').checked = false; renderPeers();
const tr = document.querySelector(`tr[data-peer-row="${CSS.escape(link.dataset.peer)}"]`);
if (!tr) return;
tr.scrollIntoView({ block: 'center' });
tr.classList.remove('flash'); void tr.offsetWidth; tr.classList.add('flash');
tr.addEventListener('animationend', () => tr.classList.remove('flash'), { once: true });
});
// The marks of what changed while nobody looked: one goes when its row
// is clicked, all when asked.
document.addEventListener('click', e => {
if (e.target.closest('#since-clear')) { e.preventDefault(); lookedAt(null); return; }
const tr = e.target.closest('tr[data-peer-row]');
if (tr && !e.target.closest('a')) lookedAt(tr.dataset.peerRow);
});
document.addEventListener('click', e => { if (e.target.closest('.clamp-more')) toggleClamp(e.target.closest('td')); });
document.addEventListener('dblclick', e => { const td = e.target.closest('td'); if (td && td.querySelector('.clamp')) { toggleClamp(td); getSelection().removeAllRanges(); } });
// A peer's name anywhere on the page leads to its routes.
document.addEventListener('click', e => {
const link = e.target.closest('a.peer-link');
if (!link) return;
e.preventDefault();
const l = lookup();
setLookup({ q: '', mode: l.mode, peer: link.dataset.peer, best: false });
$('received').scrollIntoView();
});
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));
});
}
// For poking at the page from the console; nothing on the page uses it.
window.statusz = { state, noteTrends, renderPeers, renderStrip, lookedAt };
loadAll().catch(e => logEvent(`<span class="red">${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>