Status page: what moved, what is missing, and a dark room

The attention strip's names lead to the peer's row, which lights up for a
moment. The IPv4 and IPv6 sessions of one neighbour (NAME-v4, NAME-v6) are
listed together, joined down the side, the name said once in full. A route
count that moved by more than 2% between refreshes carries ▲ or ▼ for two
minutes; a row whose state or count changed while the tab was in the
background carries ● until it is clicked. A compact mode drops the next
hop and session columns, and is remembered by the browser. Colours are
variables, with a second set for prefers-color-scheme: dark.

A new per-peer `expected_routes` says about how many routes a peer sends
when all is well. The daemon only passes it to the API; the page lists an
established peer with under half of it as needing attention, a minute
after the session came up, with the expected count beside the actual one.
An established session with next to no routes looked, until now, exactly
like one that was fine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-07 23:16:01 +02:00
1 parent 7b6547d3c9
commit 277b973f1a
11 files changed
+305 -57

No files matched your search

+1
View File
@@ -56,6 +56,7 @@ pub fn peer_config(name: &str, endpoint: impl Into<Endpoint>, remote_asn: u32) -
groups: Vec::new(), groups: Vec::new(),
reflector_client: false, reflector_client: false,
passive: false, passive: false,
expected_routes: None,
} }
} }
+9
View File
@@ -384,6 +384,15 @@ message Peer {
// new peer whose side is not ready yet, or one that is to open the // new peer whose side is not ready yet, or one that is to open the
// connections itself. // connections itself.
bool passive = 16; bool passive = 16;
// About how many routes the peer sends when all is well: a full table,
// a few hundred from an exchange peer, one from a customer. Nothing is
// decided by it. The status page compares the routes the peer has in
// the table with it, and shows an established session with fewer than
// half as needing attention: a peer that is up but has sent next to
// nothing looks, without this, exactly like one that is fine. Zero, the
// default, compares nothing.
uint32 expected_routes = 17;
} }
// What is announced to a peer. // What is announced to a peer.
+22
View File
@@ -113,6 +113,9 @@ pub struct PeerConfig {
pub reflector_client: bool, pub reflector_client: bool,
/// Do not dial the peer: wait for it to connect. /// Do not dial the peer: wait for it to connect.
pub passive: bool, pub passive: bool,
/// About how many routes the peer sends when all is well, for the
/// status page to compare with; nothing here acts on it.
pub expected_routes: Option<u32>,
} }
impl Config { impl Config {
@@ -555,6 +558,7 @@ fn parse_peer(peer: Peer, defaults: &PeerDefaults) -> Result<PeerConfig, PeerPro
groups: peer.groups, groups: peer.groups,
reflector_client: peer.route_reflector_client, reflector_client: peer.route_reflector_client,
passive: peer.passive, passive: peer.passive,
expected_routes: Some(peer.expected_routes).filter(|n| *n > 0),
}) })
} }
@@ -1494,6 +1498,24 @@ mod tests {
)); ));
} }
/// What a peer is expected to send is kept for the status page; zero,
/// the default, is nothing to compare with.
#[test]
fn expected_routes_are_kept_when_given() {
let config = parse(&format!(
"{BASE}peers {{ name: \"a\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1
expected_routes: 900000 }}
peers {{ name: \"b\" endpoint {{ address: \"192.0.2.3\" }} remote_asn: 2 }}"
))
.unwrap();
let expected: Vec<Option<u32>> = config
.peers
.iter()
.map(|peer| peer.expected_routes)
.collect();
assert_eq!(expected, [Some(900_000), None]);
}
/// A peer is dialled unless it is passive. /// A peer is dialled unless it is passive.
#[test] #[test]
fn a_peer_can_be_left_to_connect() { fn a_peer_can_be_left_to_connect() {
+1
View File
@@ -127,6 +127,7 @@ impl BgpServer {
remote_asn: p.remote_asn, remote_asn: p.remote_asn,
role: p.role, role: p.role,
passive: p.passive, passive: p.passive,
expected_routes: p.expected_routes,
}) })
.collect(); .collect();
// The client starts now: with luck the data is there before the // The client starts now: with luck the data is there before the
+2
View File
@@ -75,6 +75,8 @@ pub struct ConfiguredPeer {
pub role: Option<PeerRole>, pub role: Option<PeerRole>,
/// Whether it is left to the peer to connect. /// Whether it is left to the peer to connect.
pub passive: bool, pub passive: bool,
/// About how many routes it sends when all is well, if that was said.
pub expected_routes: Option<u32>,
} }
/// Counts kept by a session as it goes, for whoever wants to look. /// Counts kept by a session as it goes, for whoever wants to look.
+6
View File
@@ -92,6 +92,9 @@ pub struct PeerView {
/// How many of the peer's routes are held out of the table as invalid /// How many of the peer's routes are held out of the table as invalid
/// by the RPKI's data, to be taken if that changes. /// by the RPKI's data, to be taken if that changes.
pub held_routes: Option<u64>, pub held_routes: Option<u64>,
/// About how many routes the peer sends when all is well, from the
/// configuration (`expected_routes`), for `routes` to be compared with.
pub expected_routes: Option<u32>,
/// What the peer is to us, if configured: `provider`, `customer`, /// What the peer is to us, if configured: `provider`, `customer`,
/// `peer`, `route server` or `route server client`. /// `peer`, `route server` or `route server client`.
pub role: Option<&'static str>, pub role: Option<&'static str>,
@@ -157,6 +160,7 @@ impl From<&SessionHandle> for PeerView {
routes: Some(s.counters.routes()), routes: Some(s.counters.routes()),
rejected_routes: Some(s.counters.rejected()), rejected_routes: Some(s.counters.rejected()),
held_routes: Some(s.counters.held()), held_routes: Some(s.counters.held()),
expected_routes: None,
role: s.role.map(PeerRole::as_str), role: s.role.map(PeerRole::as_str),
role_confirmed: s.role.map(|_| s.role_confirmed), role_confirmed: s.role.map(|_| s.role_confirmed),
last_error: None, last_error: None,
@@ -195,6 +199,7 @@ impl PeerView {
routes: None, routes: None,
rejected_routes: None, rejected_routes: None,
held_routes: None, held_routes: None,
expected_routes: peer.expected_routes,
role: peer.role.map(PeerRole::as_str), role: peer.role.map(PeerRole::as_str),
role_confirmed: None, role_confirmed: None,
last_error, last_error,
@@ -225,6 +230,7 @@ pub fn peer_views(registry: &SessionRegistry, restarts: &Restarts) -> Vec<PeerVi
claimed.insert(session.peer); claimed.insert(session.peer);
PeerView { PeerView {
name: Some(peer.name.clone()), name: Some(peer.name.clone()),
expected_routes: peer.expected_routes,
..PeerView::from(session) ..PeerView::from(session)
} }
} }
+215 -53
View File
@@ -3,13 +3,17 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Status for ferrix_bgp</title> <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"> <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 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 sans-serif HTML, default links, dense bordered tables, and one full-width
light blue (#eef) heading bar per section. Deliberately no dark theme, no light blue heading bar per section. No framework and no external
framework, no external resources. Everything shown comes from /api/*, 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 except the names of AS numbers: pointing at one asks RIPEstat for its
holder, from the browser (the daemon itself looks nothing up). holder, from the browser (the daemon itself looks nothing up).
@@ -21,50 +25,79 @@
(RPKI synchronised, kernel in step), set small. Grey is for what is (RPKI synchronised, kernel in step), set small. Grey is for what is
inactive or expected (a passive peer waiting, a rejection count). What inactive or expected (a passive peer waiting, a rejection count). What
repeats on every row is a glyph with a tooltip, not a sentence. 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> <style>
body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; } :root {
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: #eef; margin: 14px 0 6px; padding: 2px 0; } --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; } h1.top { font-size: 150%; margin-top: 0; }
.lefthand { float: left; width: 60%; } .lefthand { float: left; width: 60%; }
.righthand { text-align: right; } .righthand { text-align: right; }
a { color: #00c; } a:visited { color: #551a8b; } a { color: var(--link); } a:visited { color: var(--visited); }
table.data { border-collapse: collapse; margin: 4px 0; } table.data { border-collapse: collapse; margin: 4px 0; }
table.data th, table.data td { border: 1px solid #99a; padding: 2px 6px; text-align: left; vertical-align: top; } table.data th, table.data td { border: 1px solid var(--line); padding: 2px 6px; text-align: left; vertical-align: top; }
table.data th { background: #dde; font-weight: bold; position: sticky; top: 0; z-index: 2; } table.data th { background: var(--head); font-weight: bold; position: sticky; top: 0; z-index: 2; }
table.data > tbody > tr:hover > td { background: #f3f3ff; } 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 td.num, table.data th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
table.data tr.group > td { background: #eef; font-weight: bold; padding: 3px 6px; } table.data tr.group > td { background: var(--band); font-weight: bold; padding: 3px 6px; }
table.data tr.best > td { background: #f4faf4; } table.data tr.best > td { background: var(--best-bg); }
table.data tr.dim td { color: #666; } table.data tr.dim td { color: var(--dim); }
.asn { cursor: help; } .asn { cursor: help; }
.asn:hover { background: #dde; } .asn:hover { background: var(--head); }
#asn-tip { position: fixed; z-index: 10; max-width: 36em; background: #ffffe8; border: 1px solid #99a; padding: 2px 6px; font-size: 12px; pointer-events: none; } #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; } .mono, table.data td.mono { font-family: monospace; }
/* The colour grammar: see the note at the top. */ /* The colour grammar: see the note at the top. */
.red { color: #c00; font-weight: bold; } .red { color: var(--red); font-weight: bold; }
.amber { color: #a60; font-weight: bold; } .amber { color: var(--amber); font-weight: bold; }
.green { color: #080; } .green { color: var(--green); }
.dim { color: #666; } .dim { color: var(--dim); }
.nowrap { white-space: nowrap; } .nowrap { white-space: nowrap; }
/* Glyphs: small bordered badges for what repeats on every row. */ /* 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 #aab; border-radius: 3px; background: #f6f6fb; color: #223; font-family: sans-serif; font-weight: normal; white-space: nowrap; vertical-align: middle; cursor: help; } .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: #c00; background: #fff0f0; color: #c00; font-weight: bold; } .g.red { border-color: var(--red); background: var(--badge-red-bg); color: var(--red); font-weight: bold; }
.g.amber { border-color: #a60; background: #fff8e8; color: #a60; } .g.amber { border-color: var(--amber); background: var(--badge-amber-bg); color: var(--amber); }
.g.green { border-color: #080; background: #f0fff0; color: #080; } .g.green { border-color: var(--green); background: var(--badge-green-bg); color: var(--green); }
.g.dim { border-color: #ccd; background: #fafafa; color: #666; } .g.dim { border-color: var(--badge-dim-line); background: var(--badge-dim-bg); color: var(--dim); }
.g.mark { background: #eef4ff; border-color: #88a; } .g.mark { background: var(--mark-bg); border-color: var(--mark-line); }
.g.ctl { background: #fff4e8; border-color: #c96; } .g.ctl { background: var(--ctl-bg); border-color: var(--ctl-line); }
.g.wk { background: #f0f0f0; border-color: #999; font-weight: bold; } .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. */ /* Communities: one line per AS that defines them, each kept whole. */
.comm { white-space: nowrap; margin-right: 0.7em; } .comm { white-space: nowrap; margin-right: 0.7em; }
.comm[title] { border-bottom: 1px dotted #666; cursor: help; } .comm[title] { border-bottom: 1px dotted var(--dim); cursor: help; }
.comms > div { padding-left: 2em; text-indent: -2em; margin-bottom: 2px; } .comms > div { padding-left: 2em; text-indent: -2em; margin-bottom: 2px; }
.comms { min-width: 26em; } .comms { min-width: 26em; }
table.kv th { background: #eef; font-weight: normal; position: static; } table.kv th { background: var(--band); font-weight: normal; position: static; }
table.data td.path { min-width: 12em; } table.data td.path { min-width: 12em; }
/* An AS repeated along the path (prepended): once, with how many times. */ /* An AS repeated along the path (prepended): once, with how many times. */
.g.x { font-family: monospace; background: #eef4ff; border-color: #88a; margin-left: 1px; cursor: help; } .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. */ /* A cell with more in it than a few lines: clamped until opened. */
.clamp { max-height: 4.4em; overflow: hidden; } .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.tall:not(.open) { -webkit-mask-image: linear-gradient(#000 60%, transparent); mask-image: linear-gradient(#000 60%, transparent); }
@@ -75,23 +108,40 @@ table.data td.name { white-space: nowrap; }
/* Peer names are what a row is found by: bold, dark, and not underlined /* 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 until pointed at, so that they stand out from the addresses and numbers
around them rather than joining the blue of every other link. */ around them rather than joining the blue of every other link. */
table.data td.name a.peer-link { font-weight: bold; color: #112; text-decoration: none; font-size: 13.5px; } 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: #00c; } 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. */ /* Where the route is in path selection: the best, or the Nth to take over. */
.g.rank { font-family: sans-serif; color: #446; background: #f3f3f8; min-width: 1em; text-align: center; } .g.rank { font-family: sans-serif; color: var(--rank-fg); background: var(--rank-bg); min-width: 1em; text-align: center; }
/* The strip: what needs attention, if anything. */ /* A route count that moved since the last refresh, and a row that changed
#strip { position: sticky; top: 0; z-index: 3; background: #fff; border: 1px solid #99a; padding: 4px 8px; margin: 6px 0; } 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 .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. */ /* The breakdown of a peer's rejections, by reason. */
.bar { display: inline-flex; width: 16em; height: 10px; border: 1px solid #99a; vertical-align: middle; margin: 0 6px; background: #fff; } .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%; } .bar span { display: block; height: 100%; }
details { clear: both; margin: 8px 0 0; } details { clear: both; margin: 8px 0 0; }
summary { cursor: pointer; color: #00c; } summary { cursor: pointer; color: var(--link); }
summary:hover { text-decoration: underline; } summary:hover { text-decoration: underline; }
#config-text { border: 1px solid #99a; padding: 4px 6px; margin: 4px 0; font-size: 12px; overflow-x: auto; } #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: #444; } address { font-size: smaller; margin-top: 2em; color: var(--dim); }
form.lookup { margin: 4px 0; } form.lookup { margin: 4px 0; }
form.lookup input[type=text] { font-family: monospace; } form.lookup input[type=text] { font-family: monospace; }
input, select, button { color-scheme: light dark; }
.pager { margin: 4px 0; } .pager { margin: 4px 0; }
.pager a, .pager span { margin-right: 1em; } .pager a, .pager span { margin-right: 1em; }
.toolbar { margin: 4px 0; } .toolbar { margin: 4px 0; }
@@ -126,10 +176,12 @@ JSON: <a href="/api/status">status</a>, <a href="/api/peers">peers</a>, <a href=
<h1 id="peers">Peers (<span id="peers-count">...</span>)</h1> <h1 id="peers">Peers (<span id="peers-count">...</span>)</h1>
<div class="toolbar"> <div class="toolbar">
<label><input type="checkbox" id="peers-problems"> problems only</label> <label><input type="checkbox" id="peers-problems"> problems only</label>
<span class="dim">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> <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> </div>
<table class="data"> <table class="data">
<thead><tr><th>name</th><th>peer</th><th>state</th><th>up for</th><th>our address (next hop)</th><th>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> <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> <tbody id="peers-body"></tbody>
</table> </table>
@@ -353,13 +405,71 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
tick(); 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;
}
// Where a peer stands, for the strip and for sorting: 0 failed, 1 // Where a peer stands, for the strip and for sorting: 0 failed, 1
// degraded, 2 normal, 3 inactive and expected. // degraded, 2 normal, 3 inactive and expected.
function peerRank(p) { function peerRank(p) {
if (p.state === 'Backoff' || p.state === 'Connecting') return p.stale_until_unix ? 1 : 0; if (p.state === 'Backoff' || p.state === 'Connecting') return p.stale_until_unix ? 1 : 0;
if (p.state === 'Established') return p.stale_routes ? 1 : 2; if (p.state === 'Established') return p.stale_routes || belowExpected(p) ? 1 : 2;
return 3; 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 = { const ROLE_GLYPH = {
provider: ['↑', 'provider: we buy transit from it'], provider: ['↑', 'provider: we buy transit from it'],
customer: ['↓', 'customer: it buys transit from us'], customer: ['↓', 'customer: it buys transit from us'],
@@ -413,41 +523,62 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
$('peers-count').textContent = `${up} of ${peers.length} established` + (down ? `, ${down} down` : '') + (waiting ? `, ${waiting} waiting` : ''); $('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="9" class="dim">No peers configured and none connected.</td></tr>'); return; }
const problemsOnly = $('peers-problems').checked; const problemsOnly = $('peers-problems').checked;
const row = p => { // The IPv4 and IPv6 sessions of one neighbour, named <stem>-v4 and
// <stem>-v6, are one neighbour to the reader: listed together, the
// name once in full and once quietly, joined down the side.
const FAMILY_SUFFIX = /^(.*)-v([46])$/;
const row = (p, pair) => {
const up = p.state === 'Established'; const up = p.state === 'Established';
const name = p.name ? `<a href="#received" class="peer-link" data-peer="${esc(p.name)}" title="Look at this peer's routes">${esc(p.name)}</a>` : '<span class="dim">(inbound)</span>'; 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 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 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 stale = up && p.stale_routes ? ` ${glyph(`${fmtCount(p.stale_routes)} stale`, 'Kept from the peer’s last session, and not sent again yet', 'amber')}` : '';
return `<tr class="${peerRank(p) === 3 ? 'dim' : ''}"> const short = belowExpected(p);
<td class="name">${name}</td> 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 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><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="nowrap">${up ? `<span title="since ${esc(fmtWhen(p.established_at_unix))}">${age(p.established_at_unix)}</span>` : DASH}</td>
<td class="mono">${up ? esc(p.local_addr) : DASH}</td> <td class="mono c-opt">${up ? esc(p.local_addr) : DASH}</td>
<td>${up ? sessionGlyphs(p) : DASH}</td> <td class="c-opt">${up ? sessionGlyphs(p) : DASH}</td>
<td class="num">${up ? fmtCount(p.routes) + stale : DASH}</td> <td class="num">${up ? trendGlyph(p) + fmtCount(p.routes) + expected + stale : DASH}</td>
<td class="num">${rejected}${held}</td> <td class="num">${rejected}${held}</td>
<td>${peerDetail(p)}</td></tr>`; <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('');
const byName = (a, c) => (a.name || a.peer_addr).localeCompare(c.name || c.peer_addr); const byName = (a, c) => (a.name || a.peer_addr).localeCompare(c.name || c.peer_addr);
let html = ''; let html = '';
// What needs attention first, whoever it is; then everyone by what // What needs attention first, whoever it is; then everyone by what
// they are to us. // they are to us.
const problems = peers.filter(p => peerRank(p) <= 1).sort(byName); const problems = peers.filter(p => peerRank(p) <= 1).sort(byName);
if (problems.length) { if (problems.length) {
html += `<tr class="group"><td colspan="9"><span class="red">Needing attention</span> (${problems.length})</td></tr>` + problems.map(row).join(''); html += `<tr class="group"><td colspan="9"><span class="red">Needing attention</span> (${problems.length})</td></tr>` + rows(problems);
} }
if (!problemsOnly) { if (!problemsOnly) {
for (const group of ROLE_ORDER) { for (const group of ROLE_ORDER) {
const members = peers.filter(p => peerRank(p) >= 2 && peerGroup(p) === group).sort(byName); const members = peers.filter(p => peerRank(p) >= 2 && peerGroup(p) === group).sort(byName);
if (!members.length) continue; if (!members.length) continue;
html += `<tr class="group"><td colspan="9">${GROUP_TITLE[group]} (${members.length})</td></tr>` + members.map(row).join(''); html += `<tr class="group"><td colspan="9">${GROUP_TITLE[group]} (${members.length})</td></tr>` + rows(members);
} }
} else if (!problems.length) { } else if (!problems.length) {
html += '<tr><td colspan="9" class="dim">Nothing needs attention.</td></tr>'; html += '<tr><td colspan="9" class="dim">Nothing needs attention.</td></tr>';
} }
setHTML(b, html); 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(); renderPeerChoices();
} }
@@ -455,10 +586,14 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
function renderStrip() { function renderStrip() {
const items = []; const items = [];
const peers = state.peers, k = state.kernel, r = state.rpki; 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); 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 => `<b>${esc(p.name || p.peer_addr)}</b> <span class="dim">(${esc((p.last_error || p.state).replace(/;.*$/, ''))})</span>`).join(', ')}`); 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 restarting = peers.filter(p => peerRank(p) === 1); const short = peers.filter(belowExpected);
if (restarting.length) items.push(`<span class="amber">${restarting.length} restarting or stale:</span> ${restarting.map(p => `<b>${esc(p.name || p.peer_addr)}</b>`).join(', ')}`); 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 && k.enabled) {
if (!k.synced) items.push('<span class="amber">kernel catching up</span> with the routing table'); 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>`); if (k.refused) items.push(`<span class="red">kernel refused ${fmtCount(k.refused)} routes</span>`);
@@ -711,7 +846,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
// ---- data loading ------------------------------------------------------ // ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); renderReceived(); renderPeers(); renderStrip(); } async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); renderReceived(); renderPeers(); renderStrip(); }
async function loadPeers() { state.peers = await getJSON('/api/peers'); 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. // The way to the pages before and after this one, where there are any.
function renderPager() { function renderPager() {
const page = state.receivedPage, place = pagePlace(); const page = state.receivedPage, place = pagePlace();
@@ -843,6 +978,31 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
$('lookup').addEventListener('submit', e => { e.preventDefault(); submitLookup(); }); $('lookup').addEventListener('submit', e => { e.preventDefault(); submitLookup(); });
$('lookup-clear').addEventListener('click', e => { e.preventDefault(); setLookup(null); }); $('lookup-clear').addEventListener('click', e => { e.preventDefault(); setLookup(null); });
$('peers-problems').addEventListener('change', renderPeers); $('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('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(); } }); 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. // A peer's name anywhere on the page leads to its routes.
@@ -861,6 +1021,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
e.preventDefault(); setPage(JSON.parse(to.dataset.page)); 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>`)); loadAll().catch(e => logEvent(`<span class="red">${esc(e.message)}</span>`));
if (REFRESH) { if (REFRESH) {
connect(); connect();
+29
View File
@@ -233,6 +233,35 @@ message with the subcode Administrative Shutdown to every neighbor. If the
`kernel` statement is configured, it then removes its routes from the `kernel` statement is configured, it then removes its routes from the
kernel. See [Kernel Route Installation](@/docs/guides/kernel.md). kernel. See [Kernel Route Installation](@/docs/guides/kernel.md).
## Expected Route Counts
An established session with no routes, or with a small fraction of the
routes that the neighbor normally advertises, indicates a problem that the
session state does not show: a filter on the neighbor's side, an import
policy that rejects everything, or a neighbor that has not finished
sending its table. The `expected_routes` statement specifies the
approximate number of routes that the neighbor advertises in normal
operation:
```textproto
peers {
name: "transit-a"
endpoint { address: "192.0.2.1" }
remote_asn: 64496
role: PROVIDER
expected_routes: 1000000
import { max_prefixes: 1200000 }
}
```
The status page compares the number of routes accepted from the neighbor
with this value. An established session with fewer than half the expected
routes is listed under **Needing attention**, with the expected number
beside the actual one. The comparison starts one minute after the session
is established, so that a neighbor that is still sending its table is not
reported. The value does not affect route processing; `max_prefixes` is
the limit that Ferrix enforces.
## Verifying BGP Sessions ## Verifying BGP Sessions
To verify the state of each neighbor, use one of the following: To verify the state of each neighbor, use one of the following:
+17 -4
View File
@@ -31,7 +31,7 @@ neighbors that are restarting; the kernel not in step with the routing
table, or refusing routes; and RPKI data that is missing or about to table, or refusing routes; and RPKI data that is missing or about to
expire. When nothing needs attention, the line says so. A second line expire. When nothing needs attention, the line says so. A second line
summarizes the normal state: sessions established, routes installed, and summarizes the normal state: sessions established, routes installed, and
the RPKI state. the RPKI state. Each neighbor named in the line is a link to its row.
Below the status line, the page displays the following information: Below the status line, the page displays the following information:
@@ -39,8 +39,12 @@ Below the status line, the page displays the following information:
- Each neighbor, with its session state, time established, next hop, - Each neighbor, with its session state, time established, next hop,
session properties, number of accepted routes, rejected announcements, session properties, number of accepted routes, rejected announcements,
and held routes, and its last error. Neighbors that need attention are and held routes, and its last error. Neighbors that need attention are
listed first; the others are grouped by role. The **problems only** listed first; the others are grouped by role. The IPv4 and IPv6 sessions
option hides the neighbors that need no attention. of one neighbor, named `NAME-v4` and `NAME-v6`, are listed together and
joined by a bar. The **problems only** option hides the neighbors that
need no attention; the **compact** option removes the next hop and
session columns, for a small or distant screen, and is remembered by the
browser.
- RPKI validation: the cache, the RTR version, and the age of the data. - RPKI validation: the cache, the RTR version, and the age of the data.
- The originated routes. - The originated routes.
- Kernel route installation: the number of installed routes, the number of - Kernel route installation: the number of installed routes, the number of
@@ -60,7 +64,14 @@ action, such as a session that is down; amber indicates a degraded state,
such as held or stale routes; green indicates a function that is operating such as held or stale routes; green indicates a function that is operating
normally, such as the RPKI cache being synchronized; gray indicates an normally, such as the RPKI cache being synchronized; gray indicates an
inactive or expected state, such as a passive neighbor waiting for a inactive or expected state, such as a passive neighbor waiting for a
connection. Normal values are displayed in black. connection. Normal values are displayed in black. When the operating
system or browser requests a dark color scheme, the page uses the same
colors on a dark background.
An established neighbor whose route count is below half of its
`expected_routes` value is listed under **Needing attention**, with the
expected count beside the actual one. See [Expected Route
Counts](@/docs/guides/peers.md#expected-route-counts).
Properties that repeat on many rows are displayed as glyphs. Table 2 lists Properties that repeat on many rows are displayed as glyphs. Table 2 lists
them. Point to a glyph to display its meaning. them. Point to a glyph to display its meaning.
@@ -77,6 +88,8 @@ them. Point to a glyph to display its meaning.
| `×N` | After an AS number in an AS path: the AS appears N times in a row (prepending). | | `×N` | After an AS number in an AS path: the AS appears N times in a row (prepending). |
| `OTC`, `AA`, `AGG`, `AS4`, `OID`, `CL`, `EXT` | Path attributes without a column of their own: Only to Customer, ATOMIC_AGGREGATE, AGGREGATOR, AS4_PATH, ORIGINATOR_ID, CLUSTER_LIST, and extended communities. | | `OTC`, `AA`, `AGG`, `AS4`, `OID`, `CL`, `EXT` | Path attributes without a column of their own: Only to Customer, ATOMIC_AGGREGATE, AGGREGATOR, AS4_PATH, ORIGINATOR_ID, CLUSTER_LIST, and extended communities. |
| `✓`, `✗`, `–` | RPKI or ASPA validation state: valid, invalid, or not found. | | `✓`, `✗`, `–` | RPKI or ASPA validation state: valid, invalid, or not found. |
| `▲`, `▼` | The neighbor's route count rose or fell by more than 2% between the last two refreshes. Displayed for two minutes. |
| `●` | The row changed while the browser tab was in the background: the session state changed, or the route count moved by more than 2%. Click the row to clear the mark. |
An AS path or a list of communities that runs to more than a few lines is An AS path or a list of communities that runs to more than a few lines is
displayed shortened, with a **more** control. Click the control, or displayed shortened, with a **more** control. Click the control, or
@@ -77,6 +77,7 @@ Statements for a BGP neighbor.
| `role` <span class="type">[Role](#role)</span> | None | Specifies the BGP role of the neighbor (RFC 9234). Not permitted for IBGP neighbors. Required for every EBGP neighbor if any `export` statement enables `learned_routes`. | | `role` <span class="type">[Role](#role)</span> | None | Specifies the BGP role of the neighbor (RFC 9234). Not permitted for IBGP neighbors. Required for every EBGP neighbor if any `export` statement enables `learned_routes`. |
| `require_role` <span class="type">bool</span> | `false` | Rejects the session if the neighbor does not advertise a BGP role. Requires `role`. | | `require_role` <span class="type">bool</span> | `false` | Rejects the session if the neighbor does not advertise a BGP role. Requires `role`. |
| `passive` <span class="type">bool</span> | `false` | Prevents Ferrix from initiating connections to the neighbor. Ferrix accepts connections from the address of `endpoint`; the port of `endpoint` is not used. See [Passive Neighbors](@/docs/guides/peers.md#passive-neighbors). | | `passive` <span class="type">bool</span> | `false` | Prevents Ferrix from initiating connections to the neighbor. Ferrix accepts connections from the address of `endpoint`; the port of `endpoint` is not used. See [Passive Neighbors](@/docs/guides/peers.md#passive-neighbors). |
| `expected_routes` <span class="type">uint32</span> | 0 | Specifies the approximate number of routes that the neighbor advertises in normal operation. The status page marks an established session with fewer than half this number of routes as needing attention. The value does not affect route processing. 0 disables the comparison. See [Expected Route Counts](@/docs/guides/peers.md#expected-route-counts). |
| `export` <span class="type">[ExportPolicy](#exportpolicy)</span> | Top-level `export` | Specifies the export policy for the neighbor. Replaces the top-level `export` statement. | | `export` <span class="type">[ExportPolicy](#exportpolicy)</span> | Top-level `export` | Specifies the export policy for the neighbor. Replaces the top-level `export` statement. |
| `graceful_restart` <span class="type">[GracefulRestart](#gracefulrestart)</span> | Top-level `graceful_restart` | Specifies graceful restart for the neighbor. Each statement that is set replaces the corresponding top-level statement. | | `graceful_restart` <span class="type">[GracefulRestart](#gracefulrestart)</span> | Top-level `graceful_restart` | Specifies graceful restart for the neighbor. Each statement that is set replaces the corresponding top-level statement. |
| `route_reflector_client` <span class="type">bool</span> | `false` | Makes the IBGP neighbor a route reflector client: Ferrix advertises to it the routes learned from other IBGP neighbors, and advertises its routes to them. Permitted only for IBGP neighbors. See [Route Reflection](@/docs/guides/passing-routes-on.md#route-reflection). | | `route_reflector_client` <span class="type">bool</span> | `false` | Makes the IBGP neighbor a route reflector client: Ferrix advertises to it the routes learned from other IBGP neighbors, and advertises its routes to them. Permitted only for IBGP neighbors. See [Route Reflection](@/docs/guides/passing-routes-on.md#route-reflection). |
+2
View File
@@ -62,6 +62,7 @@ neighbor:
"routes": 1, "routes": 1,
"rejected_routes": 1, "rejected_routes": 1,
"held_routes": 0, "held_routes": 0,
"expected_routes": null,
"role": null, "role": null,
"role_confirmed": null, "role_confirmed": null,
"last_error": null, "last_error": null,
@@ -84,6 +85,7 @@ neighbor:
| `routes` | Number of the neighbor's routes in the routing table. | | `routes` | Number of the neighbor's routes in the routing table. |
| `rejected_routes` | Number of the neighbor's routes rejected by its import policy. | | `rejected_routes` | Number of the neighbor's routes rejected by its import policy. |
| `held_routes` | Number of held routes: routes rejected for a reason that can change without a new UPDATE, such as RPKI validity or next hop resolution. | | `held_routes` | Number of held routes: routes rejected for a reason that can change without a new UPDATE, such as RPKI validity or next hop resolution. |
| `expected_routes` | The `expected_routes` value from the configuration, or `null` if it is not set. |
| `role_confirmed` | Whether the neighbor advertised a BGP role that matches the configured role. | | `role_confirmed` | Whether the neighbor advertised a BGP role that matches the configured role. |
| `retry_at_unix` | For a neighbor without a session, the time of the next connection attempt. | | `retry_at_unix` | For a neighbor without a session, the time of the next connection attempt. |
| `graceful_restart` | Graceful restart, if both neighbors advertised it: the restart time of the neighbor, whether the N bit is in use (RFC 8538), the address families whose routes are retained, and those for which the neighbor preserved its forwarding state. | | `graceful_restart` | Graceful restart, if both neighbors advertised it: the restart time of the neighbor, whether the N bit is in use (RFC 8538), the address families whose routes are retained, and those for which the neighbor preserved its forwarding state. |