status: a session's TCP connection as the kernel sees it; a route in full
When a session advertised a full table to a route collector at two thousand routes a second, nothing on the status page said whether the daemon or the peer set that pace. The answer is in the kernel: the peer's receive window, the queues, and how long the connection was held up by the window or by the send buffer, which `ss -ti` shows. This change asks the kernel the same way `ss` does, over the socket diagnostics netlink family (`NETLINK_SOCK_DIAG`, `SOCK_DIAG_BY_FAMILY` with the connection's addresses and ports), and reads `tcp_info` and the congestion control name from the answer. No external program is run and no unsafe code is needed: the request and the answer are byte layouts of the kernel's ABI, read field by field, with fields an older kernel does not send left at zero. A socket on a link-local address is found by its interface, which the request names. * `bgp_fib::sockdiag`: the request, the parser, `TcpConnection`, and `tcp_connection(local, peer)`; `Ok(None)` where there is no such connection or the system is not Linux. * `GET /api/tcp?peer=ADDRESS` in the HTTP API: the connection of the session with that peer, with times in milliseconds, the two windows named by what they allow, and the held-up times also as shares of the busy time. * The status page: a "tcp" link on each established session, which expands a row below it with the figures, re-asked on every refresh while open. The group rows' span is corrected for the "sent" column. Also on the status page: the attributes of a received route that have no column of their own were badges with their value in a tooltip, which is hard to read. Clicking the attrs cell now expands a row under the route with every attribute by its name (ORIGIN, AS_PATH, NEXT_HOP, MED, LOCAL_PREF, COMMUNITIES, LARGE_COMMUNITIES, AGGREGATOR and the rest), the path identifier, the selection rank and the RPKI and ASPA states; the badges stay. Both expansions use the terms of `ss` and the RFCs rather than sentences. Documentation: the endpoint in the HTTP API reference, and the link and the expansion in the monitoring guide. Testing: unit tests of the request and of the parser on a reply built field by field, and of a "no such socket" and a refused answer; a Linux-only test that asks the kernel about a loopback connection of the test's own and about one that does not exist; the lab's basics phase checks the endpoint for the FRR session; the page was checked by hand against the daemon in the Linux VM. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
efde277fd8
commit
99a47da2af
8 files changed
+807
-10
No files matched your search
@@ -68,6 +68,14 @@ table.data th { background: var(--head); font-weight: bold; position: sticky; to
|
||||
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.tcp-row > td { background: var(--band); padding: 6px 10px 8px 28px; }
|
||||
table.data td.attrs { cursor: pointer; }
|
||||
table.data td.attrs:hover { background: var(--hover); }
|
||||
table.data tr.route-detail > td { background: var(--band); padding: 6px 10px 8px 40px; }
|
||||
.tcp-grid { display: grid; grid-template-columns: max-content 1fr; gap: 1px 16px; font-size: 12px; }
|
||||
.tcp-grid > div:nth-child(odd) { color: var(--fg-soft); white-space: nowrap; }
|
||||
a.tcp-link { color: var(--dim); font-size: 11.5px; text-decoration: none; border: 1px solid var(--line); border-radius: 3px; padding: 0 4px; margin-left: 4px; }
|
||||
a.tcp-link:hover, a.tcp-link.open { color: var(--link); border-color: var(--link); }
|
||||
table.data tr.best > td { background: var(--best-bg); }
|
||||
table.data tr.dim td { color: var(--dim); }
|
||||
.asn { cursor: help; }
|
||||
@@ -241,7 +249,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const state = { status: null, peers: [], local: [], received: [], receivedPage: {}, rejected: [], rpki: null, kernel: null };
|
||||
const state = { status: null, peers: [], local: [], received: [], receivedPage: {}, rejected: [], rpki: null, kernel: null, tcpOpen: new Set(), routeOpen: new Set() };
|
||||
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.
|
||||
@@ -532,7 +540,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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; }
|
||||
if (!peers.length) { setHTML(b, '<tr><td colspan="10" 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
|
||||
@@ -549,7 +557,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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)}">
|
||||
return `<tr class="${classes}" data-peer-row="${esc(p.name || p.peer_addr)}" data-peer-addr="${esc(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>
|
||||
@@ -559,7 +567,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
<td class="num">${up ? trendGlyph(p) + fmtCount(p.routes) + expected + stale : DASH}</td>
|
||||
<td class="num">${up ? fmtCount(p.advertised_routes) : DASH}</td>
|
||||
<td class="num">${rejected}${held}</td>
|
||||
<td>${peerDetail(p)}</td></tr>`;
|
||||
<td>${peerDetail(p)}${up ? tcpLink(p) : ''}</td></tr>`;
|
||||
};
|
||||
// Rows for a sorted list, with the v4/v6 pairs in it marked.
|
||||
const rows = list => list.map((p, i) => {
|
||||
@@ -575,23 +583,93 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
// 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);
|
||||
html += `<tr class="group"><td colspan="10"><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);
|
||||
html += `<tr class="group"><td colspan="10">${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>';
|
||||
html += '<tr><td colspan="10" class="dim">Nothing needs attention.</td></tr>';
|
||||
}
|
||||
setHTML(b, html);
|
||||
renderTcpRows();
|
||||
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 TCP connection of a session, as the kernel sees it: a link on the
|
||||
// row, and a row below it while it is open, asked for afresh on every
|
||||
// refresh.
|
||||
function tcpLink(p) {
|
||||
const open = state.tcpOpen.has(p.peer_addr);
|
||||
return `<a href="#" class="tcp-link${open ? ' open' : ''}" data-peer="${esc(p.peer_addr)}" title="TCP_INFO for this session's socket">tcp</a>`;
|
||||
}
|
||||
const fmtOctets = n => n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`;
|
||||
const fmtBits = n => { const b = n * 8; return b >= 1e9 ? `${(b / 1e9).toFixed(2)} Gbit/s` : b >= 1e6 ? `${(b / 1e6).toFixed(1)} Mbit/s` : `${(b / 1e3).toFixed(0)} kbit/s`; };
|
||||
const fmtMs = n => n >= 1000 ? `${(n / 1000).toFixed(1)} s` : `${n.toFixed(n < 10 ? 2 : 0)} ms`;
|
||||
const ago = ms => ms >= 60000 ? `${Math.round(ms / 60000)}m` : ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
|
||||
function tcpDetail(a) {
|
||||
if (!a.available) return `<span class="dim">${esc(a.reason || 'n/a')}</span>`;
|
||||
const c = a.connection;
|
||||
const pct = share => `${(share * 100).toFixed(0)}%`;
|
||||
const held = share => share > 0.5 ? 'red' : share > 0.1 ? 'amber' : '';
|
||||
const kv = (k, v) => `<span class="dim">${k}</span> ${v}`;
|
||||
const rows = [
|
||||
['state', `${esc(c.state.toUpperCase())} ${c.congestion ? esc(c.congestion) : ''}`],
|
||||
['rtt', `${fmtMs(c.rtt_ms)} ${kv('minrtt', fmtMs(c.min_rtt_ms))} ${kv('rttvar', fmtMs(c.rtt_variance_ms))} ${kv('rto', fmtMs(c.rto_ms))}`],
|
||||
['cwnd', `${fmtCount(c.cwnd_segments)} ${kv('mss', `${c.mss}`)} ${kv('=', fmtOctets(c.cwnd_segments * c.mss))} ${kv('ssthresh', c.ssthresh_segments == null ? '∞' : fmtCount(c.ssthresh_segments))} ${kv('pmtu', `${c.pmtu}`)}`],
|
||||
['snd_wnd', `${fmtOctets(c.send_window)} ${kv('wscale', `${c.send_window_scale}`)}`],
|
||||
['rcv_wnd', `${fmtOctets(c.receive_window)} ${kv('rcv_space', fmtOctets(c.receive_space))} ${kv('wscale', `${c.receive_window_scale}`)}`],
|
||||
['send-q', `${fmtOctets(c.send_queue)} ${kv('notsent', fmtOctets(c.not_sent))} ${kv('unacked', fmtCount(c.unacked_segments))}`],
|
||||
['recv-q', fmtOctets(c.recv_queue)],
|
||||
['retrans', `${fmtCount(c.retrans_segments)} ${kv('lost', fmtCount(c.lost_segments))} ${kv('total', `${fmtCount(c.total_retrans)} / ${fmtOctets(c.bytes_retrans)}`)}`],
|
||||
['rate', `${kv('delivery', fmtBits(c.delivery_rate))} ${kv('pacing', fmtBits(c.pacing_rate))}`],
|
||||
['limited', `${kv('busy', fmtMs(c.busy_ms))} <span class="${held(c.limited_by_receive_window_share)}">${kv('rwnd', pct(c.limited_by_receive_window_share))}</span> <span class="${held(c.limited_by_send_buffer_share)}">${kv('sndbuf', pct(c.limited_by_send_buffer_share))}</span>`],
|
||||
['bytes', `${kv('sent', fmtOctets(c.bytes_sent))} ${kv('acked', fmtOctets(c.bytes_acked))} ${kv('received', fmtOctets(c.bytes_received))} ${kv('segs', `${fmtCount(c.segments_out)}↑ ${fmtCount(c.segments_in)}↓`)}`],
|
||||
['last', `${kv('snd', ago(c.last_data_sent_ms))} ${kv('rcv', ago(c.last_data_received_ms))} ${kv('ack', ago(c.last_ack_received_ms))}`],
|
||||
];
|
||||
return `<div class="tcp-grid mono">${rows.map(([k, v]) => `<div>${k}</div><div>${v}</div>`).join('')}</div>`;
|
||||
}
|
||||
async function renderTcpRows() {
|
||||
for (const addr of state.tcpOpen) {
|
||||
const tr = document.querySelector(`#peers-body tr[data-peer-addr="${CSS.escape(addr)}"]`);
|
||||
if (!tr) continue;
|
||||
let row = tr.nextElementSibling;
|
||||
if (!row || !row.classList.contains('tcp-row')) {
|
||||
row = document.createElement('tr');
|
||||
row.className = 'tcp-row';
|
||||
row.innerHTML = '<td colspan="10"><span class="dim">asking the kernel…</span></td>';
|
||||
tr.after(row);
|
||||
}
|
||||
try {
|
||||
const answer = await getJSON(`/api/tcp?peer=${encodeURIComponent(addr)}`);
|
||||
row.innerHTML = `<td colspan="10">${tcpDetail(answer)}</td>`;
|
||||
} catch (e) {
|
||||
row.innerHTML = `<td colspan="10"><span class="red">${esc(String(e))}</span></td>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const link = e.target.closest('a.tcp-link');
|
||||
if (!link) return;
|
||||
e.preventDefault();
|
||||
const addr = link.dataset.peer;
|
||||
if (state.tcpOpen.has(addr)) {
|
||||
state.tcpOpen.delete(addr);
|
||||
link.classList.remove('open');
|
||||
const tr = link.closest('tr'), next = tr && tr.nextElementSibling;
|
||||
if (next && next.classList.contains('tcp-row')) next.remove();
|
||||
} else {
|
||||
state.tcpOpen.add(addr);
|
||||
link.classList.add('open');
|
||||
renderTcpRows();
|
||||
}
|
||||
});
|
||||
|
||||
// The strip: what needs attention, in one line, or that nothing does.
|
||||
function renderStrip() {
|
||||
const items = [];
|
||||
@@ -748,18 +826,70 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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' : ''}">
|
||||
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr class="${r.best ? 'best' : ''}" data-route="${esc(routeKey(r))}">
|
||||
<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>${clamp(commList(s.communities))}</td><td>${clamp(commList(s.large_communities, true))}</td><td class="attrs" title="all attributes">${otherList(s.other)}</td>
|
||||
<td class="nowrap">${validity('RPKI', r.rpki)} ${validity('ASPA', r.aspa)}</td></tr>`; }).join(''));
|
||||
markTall(b);
|
||||
renderRouteDetails();
|
||||
}
|
||||
|
||||
// A route, in full, under its row: every attribute by its name, as the
|
||||
// RFCs write it, opened by a click on the attrs cell.
|
||||
const routeKey = r => `${r.prefix}|${r.peer_addr}|${r.path_id}`;
|
||||
const ATTR_NAMES = {
|
||||
'atomic aggregate': 'ATOMIC_AGGREGATE', 'aggregator': 'AGGREGATOR', 'extended communities': 'EXTENDED_COMMUNITIES',
|
||||
'only to customer': 'OTC', 'originator ID': 'ORIGINATOR_ID', 'cluster list': 'CLUSTER_LIST',
|
||||
};
|
||||
function routeDetail(r) {
|
||||
const s = r.summary;
|
||||
const rows = [
|
||||
['ORIGIN', s.origin ? esc(s.origin) : DASH],
|
||||
['AS_PATH', s.as_path.length ? s.as_path.map(asn).join(' ') : '<span class="dim">(empty)</span>'],
|
||||
['NEXT_HOP', s.next_hop ? esc(s.next_hop) : DASH],
|
||||
['MED', s.med ?? DASH],
|
||||
['LOCAL_PREF', s.local_pref ?? DASH],
|
||||
['COMMUNITIES', s.communities.length ? s.communities.map(esc).join(' ') : DASH],
|
||||
['LARGE_COMMUNITIES', s.large_communities.length ? s.large_communities.map(esc).join(' ') : DASH],
|
||||
...(s.other || []).map(a => [esc(ATTR_NAMES[a.name] || a.name), esc(a.value) || 'present']),
|
||||
];
|
||||
if (r.path_id) rows.push(['PATH_ID', `${r.path_id}`]);
|
||||
rows.push(['selection', `${r.best ? 'best' : `rank ${r.rank}`}${r.stale ? ', stale' : ''}`]);
|
||||
rows.push(['rpki', r.rpki ? esc(r.rpki) : DASH], ['aspa', r.aspa ? esc(r.aspa) : DASH]);
|
||||
return `<div class="tcp-grid mono">${rows.map(([k, v]) => `<div>${k}</div><div>${v}</div>`).join('')}</div>`;
|
||||
}
|
||||
function renderRouteDetails() {
|
||||
for (const key of state.routeOpen) {
|
||||
const r = state.received.find(r => routeKey(r) === key);
|
||||
const tr = document.querySelector(`#received-body tr[data-route="${CSS.escape(key)}"]`);
|
||||
if (!r || !tr) continue;
|
||||
const next = tr.nextElementSibling;
|
||||
if (next && next.classList.contains('route-detail')) continue;
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'route-detail';
|
||||
row.innerHTML = `<td colspan="12">${routeDetail(r)}</td>`;
|
||||
tr.after(row);
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const cell = e.target.closest('#received-body td.attrs');
|
||||
if (!cell || e.target.closest('a')) return;
|
||||
const tr = cell.closest('tr'), key = tr.dataset.route;
|
||||
if (state.routeOpen.has(key)) {
|
||||
state.routeOpen.delete(key);
|
||||
const next = tr.nextElementSibling;
|
||||
if (next && next.classList.contains('route-detail')) next.remove();
|
||||
} else {
|
||||
state.routeOpen.add(key);
|
||||
renderRouteDetails();
|
||||
}
|
||||
});
|
||||
|
||||
const RPKI_STATE_CLASS = { synchronised: 'green', synchronising: 'amber', connecting: 'red' };
|
||||
function renderRpki() {
|
||||
const r = state.rpki, b = $('rpki-body');
|
||||
|
||||
Reference in new issue
Block a user