Compare commits
34
Commits
3a1e42e594
...
main
No files matched your search
Generated
+31
-10
@@ -157,7 +157,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bgp"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"bgp-server",
|
||||
"bgp-web",
|
||||
@@ -172,7 +172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bgp-bench"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"bgp-integration-tests",
|
||||
"bgp-packet",
|
||||
@@ -182,12 +182,14 @@ dependencies = [
|
||||
"bytes",
|
||||
"flate2",
|
||||
"stats_alloc",
|
||||
"tikv-jemallocator",
|
||||
"tokio",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bgp-fib"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"bgp-packet",
|
||||
"bgp-primitives",
|
||||
@@ -205,7 +207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bgp-integration-tests"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"bgp-fib",
|
||||
"bgp-packet",
|
||||
@@ -224,7 +226,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bgp-packet"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"bgp-primitives",
|
||||
"bitfield",
|
||||
@@ -240,7 +242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bgp-primitives"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"nom",
|
||||
@@ -252,7 +254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bgp-rib"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"bgp-packet",
|
||||
"bgp-primitives",
|
||||
@@ -267,7 +269,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bgp-rpki"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"bgp-primitives",
|
||||
"bytes",
|
||||
@@ -280,7 +282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bgp-server"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"bgp-fib",
|
||||
"bgp-packet",
|
||||
@@ -288,6 +290,7 @@ dependencies = [
|
||||
"bgp-rib",
|
||||
"bgp-rpki",
|
||||
"bytes",
|
||||
"foldhash",
|
||||
"futures",
|
||||
"nix",
|
||||
"prometheus",
|
||||
@@ -302,7 +305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bgp-web"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"bgp-fib",
|
||||
@@ -315,6 +318,7 @@ dependencies = [
|
||||
"nix",
|
||||
"prometheus",
|
||||
"serde",
|
||||
"tikv-jemalloc-ctl",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -1098,6 +1102,12 @@ dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1679,6 +1689,17 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tikv-jemalloc-ctl"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a184c43b8ab2f41df2733b55556e3f5f632f4aeaa205b1bb018f574b7f5f142"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"paste",
|
||||
"tikv-jemalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tikv-jemalloc-sys"
|
||||
version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8"
|
||||
|
||||
+12
-3
@@ -12,7 +12,7 @@ edition = "2024"
|
||||
homepage = "https://rayhaan.ch"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://git.rayhaan.net/rayhaan/ferrix"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
|
||||
# Every crate opts in with `[lints] workspace = true`. CI turns warnings into
|
||||
# errors, so a lint added here is enforced.
|
||||
@@ -63,5 +63,14 @@ hashbrown = "0.17"
|
||||
axum = "0.8.9"
|
||||
tokio-stream = { version = "0.1.19", features = ["sync"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["json"] }
|
||||
# The daemon's memory allocator; see bin/bgp/src/main.rs.
|
||||
tikv-jemallocator = "0.7.0"
|
||||
# The daemon's memory allocator; see bin/bgp/src/main.rs. With its
|
||||
# statistics, which the status page and the metrics show.
|
||||
tikv-jemallocator = { version = "0.7.0", features = ["stats"] }
|
||||
tikv-jemalloc-ctl = { version = "0.7.0", features = ["stats"] }
|
||||
|
||||
# `cargo build --profile profiling`: the release build, with the line tables
|
||||
# a profiler needs to name functions. See scripts/profile/.
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
debug = "line-tables-only"
|
||||
strip = "none"
|
||||
@@ -40,7 +40,6 @@ What it does:
|
||||
- TCP-AO, TCP MD5, and GTSM
|
||||
- Long-Lived Graceful Restart, BFD, and configuration changes without a
|
||||
restart
|
||||
- Recursive resolution of next hops that are not directly connected
|
||||
- Address families other than IPv4 and IPv6 unicast
|
||||
|
||||
## Building
|
||||
@@ -89,9 +88,11 @@ privileges, the systemd unit, and upgrading without withdrawing routes.
|
||||
## Documentation
|
||||
|
||||
The documentation is a website built from [`docs/`](docs/) with
|
||||
[Zola](https://www.getzola.org) (`zola --root docs serve`, then
|
||||
<http://127.0.0.1:1111>). The pages are plain Markdown and can be read
|
||||
in the repository:
|
||||
[Zola](https://www.getzola.org): `zola --root docs serve`, then open
|
||||
<http://127.0.0.1:1111>. The sources are Markdown with Zola front matter,
|
||||
shortcodes and internal links, so the forge shows them with those parts
|
||||
unrendered; [`docs/README.md`](docs/README.md) explains what each is. The
|
||||
pages:
|
||||
|
||||
- [Overview](docs/content/docs/getting-started/introduction.md),
|
||||
[installation](docs/content/docs/getting-started/installation.md) and
|
||||
|
||||
@@ -23,5 +23,16 @@ tracing-subscriber.workspace = true
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
tikv-jemallocator.workspace = true
|
||||
|
||||
# jemalloc's background thread returns freed memory to the kernel on time
|
||||
# (see the memory figures on the status page); jemalloc has it on Linux
|
||||
# only, and warns at every start elsewhere if asked for it.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
tikv-jemallocator = { workspace = true, features = ["background_threads"] }
|
||||
|
||||
[features]
|
||||
# jemalloc's heap profiler, for scripts/profile/full-table.sh with
|
||||
# HEAP=1: not for a production build, where it costs memory and time.
|
||||
heap-profiling = ["tikv-jemallocator/profiling"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -16,11 +16,15 @@ bytes.workspace = true
|
||||
flate2 = "1"
|
||||
stats_alloc = { version = "0.1.10", optional = true }
|
||||
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
tikv-jemallocator.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
bgp-integration-tests.workspace = true
|
||||
bgp-rib.workspace = true
|
||||
bgp-server.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[features]
|
||||
# Counts every allocation, to report how much memory a table holds. The
|
||||
@@ -42,3 +46,7 @@ harness = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "mesh"
|
||||
harness = false
|
||||
@@ -3,7 +3,8 @@
|
||||
//!
|
||||
//! Run with `cargo bench -p bgp-bench --bench full_table`. Set
|
||||
//! `BENCH_PEERS` (default 4) and `BENCH_SCALE_DOWN` (default 1; 10 makes the
|
||||
//! table ten times smaller) to change the size. The table is synthetic (see
|
||||
//! table ten times smaller) to change the size. `BENCH_PRESIZE=0` leaves
|
||||
//! the table to grow as it fills, as it does without `expected_routes`. The table is synthetic (see
|
||||
//! the crate documentation); `real_table` runs on a real one.
|
||||
//!
|
||||
//! This is a stopwatch around whole operations at full size, not a
|
||||
@@ -17,6 +18,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bgp_bench::memory::{Region, describe};
|
||||
use bgp_bench::report::{mib, per_second, row};
|
||||
use bgp_bench::{Prefixes, Shape, announcements, encoded, env, wire_context};
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
@@ -25,23 +27,6 @@ use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::table::{PeerIndex, PeerInfo, RibTable};
|
||||
use bytes::BytesMut;
|
||||
|
||||
fn mib(bytes: usize) -> f64 {
|
||||
bytes as f64 / (1024.0 * 1024.0)
|
||||
}
|
||||
|
||||
fn per_second(count: usize, elapsed: Duration) -> String {
|
||||
let rate = count as f64 / elapsed.as_secs_f64();
|
||||
if rate >= 1e6 {
|
||||
format!("{:.2} M/s", rate / 1e6)
|
||||
} else {
|
||||
format!("{:.0} k/s", rate / 1e3)
|
||||
}
|
||||
}
|
||||
|
||||
fn row(what: &str, elapsed: Duration, detail: String) {
|
||||
println!("{what:<44} {:>9.3} s {detail}", elapsed.as_secs_f64());
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let peers = env("BENCH_PEERS", 4) as u32;
|
||||
let shape = Shape::INTERNET.scaled_down(env("BENCH_SCALE_DOWN", 1));
|
||||
@@ -109,6 +94,8 @@ fn main() {
|
||||
.handshake(64512 + peer, Ipv4Addr::new(10, 0, 0, peer as u8), 90)
|
||||
.await
|
||||
.expect("a session");
|
||||
// The same bytes `table_stream` would make, from the messages
|
||||
// already encoded for the decoding above.
|
||||
let stream: Vec<u8> = messages.iter().flat_map(|m| m.iter().copied()).collect();
|
||||
senders.push((session, stream));
|
||||
}
|
||||
@@ -148,6 +135,9 @@ fn main() {
|
||||
fn into_table(wire: &[Vec<BytesMut>], routes: usize, threads: usize) {
|
||||
let region = Region::begin();
|
||||
let table = RibTable::new(threads * 16);
|
||||
if env("BENCH_PRESIZE", 1) == 1 {
|
||||
table.expect_prefixes(routes / wire.len());
|
||||
}
|
||||
let peers: Vec<PeerIndex> = (0..wire.len())
|
||||
.map(|peer| {
|
||||
table
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
//! Two routers of one AS, each taking in full tables from its own peers
|
||||
//! and sending the other every path (ADD-PATH both ways, `all_paths`), as
|
||||
//! a mesh of routers that each hold the network's whole view do. How
|
||||
//! long until each router has the other's routes is what this measures:
|
||||
//! the exporter reading the table's journal while sessions fill the
|
||||
//! table, which nothing else exercises at this size.
|
||||
//!
|
||||
//! Then a new peer takes the first router's table, as a collector or a
|
||||
//! router coming back does: how soon its first UPDATE arrives and how
|
||||
//! long the table takes are measured too. It is a peer of the routers'
|
||||
//! AS over IPv4, so what it gets is the first router's IPv4 routes from
|
||||
//! its own peers: the other router's are not reflected to it, and IPv6
|
||||
//! routes need an IPv6 session.
|
||||
//!
|
||||
//! Run with `cargo bench -p bgp-bench --bench mesh`. `BENCH_PEERS`
|
||||
//! (default 4) feed the first router and `BENCH_PEERS_B` (default 2) the
|
||||
//! second; `BENCH_SCALE_DOWN` shrinks the table. The routers peer over
|
||||
//! IPv6 loopback and the feeders come over IPv4 loopback; the new peer
|
||||
//! comes from 127.0.0.2, which Linux has and macOS does not, so that
|
||||
//! part is skipped on macOS. With `BENCH_RECEIVER_EARLY=1` the new peer
|
||||
//! comes as the feeders start instead, so that its exporter goes
|
||||
//! through the table while the table is being filled, as a router's
|
||||
//! do after a restart. `BENCH_COLLECTORS` (default 0) more peers of the
|
||||
//! first router, from 127.0.1.0/24, are peers in the sense of RFC 9234
|
||||
//! told of learned routes, and the feeders' routes are marked as having
|
||||
//! come across from a peer, so none of them goes: as many exporters
|
||||
//! following the table while it fills and passing nearly all of it
|
||||
//! over, as a router with many peers at exchanges has.
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bgp_bench::report::row;
|
||||
use bgp_bench::{Prefixes, Shape, announcements, encoded, env, table_stream};
|
||||
use bgp_integration_tests::peer_config;
|
||||
use bgp_integration_tests::test_peer::{TestPeer, collector_capabilities};
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_packet::attributes::PathAttribute;
|
||||
use bgp_packet::message::msg_type;
|
||||
use bgp_packet::open::SendReceive;
|
||||
use bgp_server::config::PeerConfig;
|
||||
use bgp_server::family::SUPPORTED;
|
||||
use bgp_server::policy::{ExportPolicy, ImportPolicy, PeerRole};
|
||||
use bgp_server::session::SessionRegistry;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::tcp::OwnedWriteHalf;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// An AS number that is in none of the synthetic paths.
|
||||
const ASN: u32 = 4_200_000_000;
|
||||
|
||||
/// Where the peer that takes the whole table connects from.
|
||||
const RECEIVER: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 2);
|
||||
|
||||
/// Where the collectors connect from: this and the addresses after it.
|
||||
fn collector_address(n: u32) -> Ipv4Addr {
|
||||
Ipv4Addr::new(127, 0, 1, n as u8)
|
||||
}
|
||||
|
||||
/// The collectors' AS numbers start here.
|
||||
const COLLECTOR_ASN: u32 = 65000;
|
||||
|
||||
/// How often the tables are looked at while they fill.
|
||||
const POLL: Duration = Duration::from_millis(5);
|
||||
|
||||
/// What the routers send each other, and the receiver: every path.
|
||||
fn every_path() -> Arc<ExportPolicy> {
|
||||
Arc::new(ExportPolicy {
|
||||
learned_routes: true,
|
||||
all_paths: true,
|
||||
..ExportPolicy::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn free_port() -> u16 {
|
||||
std::net::TcpListener::bind((Ipv6Addr::LOCALHOST, 0))
|
||||
.expect("a port")
|
||||
.local_addr()
|
||||
.expect("an address")
|
||||
.port()
|
||||
}
|
||||
|
||||
/// A router of the mesh, listening for feeders on IPv4 loopback and for
|
||||
/// the other router on IPv6 loopback at `port`, which it dials at
|
||||
/// `other_port`, knowing it as `other_name`.
|
||||
async fn router(
|
||||
other_name: &'static str,
|
||||
id: u8,
|
||||
port: u16,
|
||||
other_port: u16,
|
||||
with_receiver: bool,
|
||||
collectors: u32,
|
||||
) -> TestServer {
|
||||
TestServer::start_with(ASN, |c| {
|
||||
// Two routers of one AS must not share a BGP identifier.
|
||||
c.router_id = Ipv4Addr::new(192, 0, 2, id);
|
||||
c.cluster_id = c.router_id;
|
||||
c.listen_endpoints = vec![
|
||||
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0),
|
||||
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), port),
|
||||
];
|
||||
c.peers = vec![PeerConfig {
|
||||
add_path: Some(SendReceive::Both),
|
||||
import: ImportPolicy::permissive(),
|
||||
export: every_path(),
|
||||
..peer_config(
|
||||
other_name,
|
||||
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), other_port),
|
||||
ASN,
|
||||
)
|
||||
}];
|
||||
if with_receiver {
|
||||
c.peers.push(PeerConfig {
|
||||
add_path: Some(SendReceive::Send),
|
||||
export: every_path(),
|
||||
..peer_config("receiver", SocketAddr::new(RECEIVER.into(), 179), ASN)
|
||||
});
|
||||
}
|
||||
for n in 1..=collectors {
|
||||
c.peers.push(PeerConfig {
|
||||
role: Some(PeerRole::Peer),
|
||||
export: Arc::new(ExportPolicy {
|
||||
learned_routes: true,
|
||||
all_paths: false,
|
||||
..ExportPolicy::default()
|
||||
}),
|
||||
..peer_config(
|
||||
"collector",
|
||||
SocketAddr::new(collector_address(n).into(), 179),
|
||||
COLLECTOR_ASN + n,
|
||||
)
|
||||
});
|
||||
}
|
||||
c.connect_retry = Duration::from_millis(200);
|
||||
})
|
||||
.await
|
||||
.expect("a router")
|
||||
}
|
||||
|
||||
/// Connects `count` feeders to `router`, numbered from `first`, and
|
||||
/// returns their sessions with the bytes each is to send.
|
||||
async fn feeders(
|
||||
router: &TestServer,
|
||||
prefixes: &Prefixes,
|
||||
shape: Shape,
|
||||
first: u32,
|
||||
count: u32,
|
||||
marked: bool,
|
||||
) -> Vec<(TestPeer, Vec<u8>)> {
|
||||
let mut sessions = Vec::new();
|
||||
for peer in first..first + count {
|
||||
let stream = if marked {
|
||||
// As having come across from a peer (RFC 9234): not for the
|
||||
// collectors, which are peers too.
|
||||
let mut updates = announcements(prefixes, shape, peer);
|
||||
for update in &mut updates {
|
||||
update
|
||||
.path_attributes
|
||||
.push(PathAttribute::OnlyToCustomer(64512 + peer));
|
||||
}
|
||||
encoded(&updates)
|
||||
.iter()
|
||||
.flat_map(|m| m.iter().copied())
|
||||
.collect()
|
||||
} else {
|
||||
table_stream(prefixes, shape, peer)
|
||||
};
|
||||
let mut session = TestPeer::connect(router.addr).await.expect("a connection");
|
||||
// No hold timer: a feeder has nothing to say after its table.
|
||||
session
|
||||
.handshake(64512 + peer, Ipv4Addr::new(10, 0, 0, peer as u8), 0)
|
||||
.await
|
||||
.expect("a session");
|
||||
sessions.push((session, stream));
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
/// Every session of `registry`, one line each, for the panic when a
|
||||
/// deadline passes: where the routes have got to, and what each session
|
||||
/// was to be sent.
|
||||
fn sessions_report(registry: &SessionRegistry) -> Vec<String> {
|
||||
registry
|
||||
.list()
|
||||
.iter()
|
||||
.map(|s| {
|
||||
format!(
|
||||
"{} {:?} export {:?} add_path_send {:?} routes {} rejected {} advertised {}",
|
||||
s.peer,
|
||||
s.name,
|
||||
s.export,
|
||||
s.add_path_send,
|
||||
s.counters.routes(),
|
||||
s.counters.rejected(),
|
||||
s.counters.advertised()
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How big a run is, from the environment.
|
||||
struct Settings {
|
||||
peers_a: u32,
|
||||
peers_b: u32,
|
||||
shape: Shape,
|
||||
collectors: u32,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
fn from_env() -> Self {
|
||||
Self {
|
||||
peers_a: env("BENCH_PEERS", 4) as u32,
|
||||
peers_b: env("BENCH_PEERS_B", 2) as u32,
|
||||
shape: Shape::INTERNET.scaled_down(env("BENCH_SCALE_DOWN", 1)),
|
||||
collectors: env("BENCH_COLLECTORS", 0) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many routes A's own feeders bring, and B's.
|
||||
fn own_routes(&self) -> (usize, usize) {
|
||||
let per_peer = self.shape.prefixes();
|
||||
(
|
||||
per_peer * self.peers_a as usize,
|
||||
per_peer * self.peers_b as usize,
|
||||
)
|
||||
}
|
||||
|
||||
/// How many routes the receiver is to be sent: A's IPv4 routes from
|
||||
/// its own peers.
|
||||
fn receiver_routes(&self) -> usize {
|
||||
self.shape.ipv4_prefixes * self.peers_a as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// The two routers, up and peered, with the collectors' sessions once
|
||||
/// they are connected.
|
||||
struct Mesh {
|
||||
settings: Settings,
|
||||
prefixes: Prefixes,
|
||||
a: TestServer,
|
||||
b: TestServer,
|
||||
/// The collectors' connections, drained, held until the end.
|
||||
collectors: Vec<(OwnedWriteHalf, JoinHandle<()>)>,
|
||||
}
|
||||
|
||||
/// The feeders sending, from when they began.
|
||||
struct Feeding {
|
||||
started: Instant,
|
||||
/// Each hands its session back once its table is written.
|
||||
sending: Vec<JoinHandle<TestPeer>>,
|
||||
}
|
||||
|
||||
/// When each router had what: its own peers' routes, and the other's too.
|
||||
struct Tables {
|
||||
a_own: Duration,
|
||||
b_own: Duration,
|
||||
a_all: Duration,
|
||||
b_all: Duration,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Starts the routers, with the collectors and receiver configured at
|
||||
/// A, and waits for them to peer.
|
||||
async fn start(settings: Settings, prefixes: Prefixes) -> Self {
|
||||
let (port_a, port_b) = (free_port(), free_port());
|
||||
let a = router("b", 1, port_a, port_b, true, settings.collectors).await;
|
||||
let b = router("a", 2, port_b, port_a, false, 0).await;
|
||||
a.wait_for_sessions(1).await.expect("the mesh session at A");
|
||||
b.wait_for_sessions(1).await.expect("the mesh session at B");
|
||||
Self {
|
||||
settings,
|
||||
prefixes,
|
||||
a,
|
||||
b,
|
||||
collectors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects the collectors to A. They are there before the feeders, as
|
||||
/// a router's peers are before its tables arrive after a restart. An
|
||||
/// address that is not this machine's ends the connecting, with a
|
||||
/// word.
|
||||
async fn connect_collectors(&mut self) {
|
||||
for n in 1..=self.settings.collectors {
|
||||
let socket = tokio::net::TcpSocket::new_v4().expect("a socket");
|
||||
if let Err(e) = socket.bind(SocketAddr::new(collector_address(n).into(), 0)) {
|
||||
println!(
|
||||
"collectors: skipped, {} is not ours ({e})",
|
||||
collector_address(n)
|
||||
);
|
||||
break;
|
||||
}
|
||||
let stream = socket.connect(self.a.addr).await.expect("A listens");
|
||||
let mut session = TestPeer::from_stream(stream);
|
||||
session
|
||||
.handshake(COLLECTOR_ASN + n, Ipv4Addr::new(192, 0, 3, n as u8), 0)
|
||||
.await
|
||||
.expect("A takes a collector's session");
|
||||
self.collectors.push(session.drain());
|
||||
}
|
||||
if !self.collectors.is_empty() {
|
||||
self.a
|
||||
.wait_for_sessions(1 + self.collectors.len())
|
||||
.await
|
||||
.expect("the collectors' sessions at A");
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects the feeders to both routers and starts them sending.
|
||||
async fn feed(&self) -> Feeding {
|
||||
let Settings {
|
||||
peers_a,
|
||||
peers_b,
|
||||
shape,
|
||||
..
|
||||
} = self.settings;
|
||||
let marked = !self.collectors.is_empty();
|
||||
let feed_a = feeders(&self.a, &self.prefixes, shape, 1, peers_a, marked).await;
|
||||
let feed_b = feeders(&self.b, &self.prefixes, shape, 1 + peers_a, peers_b, false).await;
|
||||
let started = Instant::now();
|
||||
let sending = feed_a
|
||||
.into_iter()
|
||||
.chain(feed_b)
|
||||
.map(|(mut session, stream)| {
|
||||
tokio::spawn(async move {
|
||||
session.send_raw(&stream).await.expect("the router reads");
|
||||
session
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Feeding { started, sending }
|
||||
}
|
||||
|
||||
/// The receiver taking A's table while it fills, if `BENCH_RECEIVER_EARLY`
|
||||
/// asks for that: as the feeders start (1), or once A has its own
|
||||
/// peers' routes and B's are still coming (2), as a router's sessions
|
||||
/// come up after a restart while its peers' tables arrive.
|
||||
fn early_receiver(&self, deadline: Duration) -> Option<JoinHandle<()>> {
|
||||
let when = env("BENCH_RECEIVER_EARLY", 0);
|
||||
if when == 0 {
|
||||
return None;
|
||||
}
|
||||
let (own_a, _) = self.settings.own_routes();
|
||||
let routes = self.settings.receiver_routes();
|
||||
let (addr, registry, rib) = (
|
||||
self.a.addr,
|
||||
self.a.registry.clone(),
|
||||
Arc::clone(&self.a.rib),
|
||||
);
|
||||
Some(tokio::spawn(async move {
|
||||
while when == 2 && rib.len() < own_a {
|
||||
tokio::time::sleep(POLL).await;
|
||||
}
|
||||
resync(addr, registry, routes, deadline).await
|
||||
}))
|
||||
}
|
||||
|
||||
/// Waits until both routers have every route, noting when each had
|
||||
/// what; or panics once `deadline` has passed since `started`.
|
||||
async fn wait_for_tables(&self, started: Instant, deadline: Duration) -> Tables {
|
||||
let (own_a, own_b) = self.settings.own_routes();
|
||||
let mut a_own = None;
|
||||
let mut b_own = None;
|
||||
let mut a_all = None;
|
||||
let mut b_all = None;
|
||||
let (a_all, b_all) = loop {
|
||||
tokio::time::sleep(POLL).await;
|
||||
let (at_a, at_b) = (self.a.rib.len(), self.b.rib.len());
|
||||
let now = started.elapsed();
|
||||
if a_own.is_none() && at_a >= own_a {
|
||||
a_own = Some(now);
|
||||
}
|
||||
if b_own.is_none() && at_b >= own_b {
|
||||
b_own = Some(now);
|
||||
}
|
||||
if a_all.is_none() && at_a >= own_a + own_b {
|
||||
a_all = Some(now);
|
||||
}
|
||||
if b_all.is_none() && at_b >= own_a + own_b {
|
||||
b_all = Some(now);
|
||||
}
|
||||
if let (Some(a_all), Some(b_all)) = (a_all, b_all) {
|
||||
break (a_all, b_all);
|
||||
}
|
||||
if now > deadline {
|
||||
panic!(
|
||||
"not all routes arrived: A has {at_a} {:?}, B has {at_b} {:?}",
|
||||
sessions_report(&self.a.registry),
|
||||
sessions_report(&self.b.registry)
|
||||
);
|
||||
}
|
||||
};
|
||||
Tables {
|
||||
a_own: a_own.unwrap_or_default(),
|
||||
b_own: b_own.unwrap_or_default(),
|
||||
a_all,
|
||||
b_all,
|
||||
}
|
||||
}
|
||||
|
||||
/// One row for each of the four moments of `tables`, with the rate the
|
||||
/// routes that made the moment came at.
|
||||
fn report(&self, tables: &Tables) {
|
||||
let (own_a, own_b) = self.settings.own_routes();
|
||||
let rate = |when: Duration, routes: usize| {
|
||||
format!("{:.2} M routes/s", routes as f64 / when.as_secs_f64() / 1e6)
|
||||
};
|
||||
row(
|
||||
"A has its own peers' routes",
|
||||
tables.a_own,
|
||||
rate(tables.a_own, own_a),
|
||||
);
|
||||
row(
|
||||
"B has its own peers' routes",
|
||||
tables.b_own,
|
||||
rate(tables.b_own, own_b),
|
||||
);
|
||||
row(
|
||||
"A has B's routes too (B's exporter, A's session)",
|
||||
tables.a_all,
|
||||
rate(tables.a_all, own_b),
|
||||
);
|
||||
row(
|
||||
"B has A's routes too (A's exporter, B's session)",
|
||||
tables.b_all,
|
||||
rate(tables.b_all, own_a),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Feeding {
|
||||
/// Waits for every feeder to have written its table, and returns the
|
||||
/// sessions, which are to stay up until the end: a feeder going would
|
||||
/// have its routes withdrawn from both tables meanwhile.
|
||||
async fn finished(self) -> Vec<TestPeer> {
|
||||
let mut feeders = Vec::new();
|
||||
for session in self.sending {
|
||||
feeders.push(session.await.expect("a feeder is done"));
|
||||
}
|
||||
feeders
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// The routers' logs, with `BENCH_LOG` as a `RUST_LOG` filter.
|
||||
if let Ok(filter) = std::env::var("BENCH_LOG") {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
}
|
||||
let settings = Settings::from_env();
|
||||
let deadline = Duration::from_secs(env("BENCH_DEADLINE", 600) as u64);
|
||||
println!(
|
||||
"== Two routers of AS{ASN}, every path both ways: A fed by {} peers, B by {}, {} prefixes each",
|
||||
settings.peers_a,
|
||||
settings.peers_b,
|
||||
settings.shape.prefixes()
|
||||
);
|
||||
let prefixes = Prefixes::generate(settings.shape);
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("a runtime");
|
||||
runtime.block_on(async {
|
||||
let mut mesh = Mesh::start(settings, prefixes).await;
|
||||
mesh.connect_collectors().await;
|
||||
let early_receiver = mesh.early_receiver(deadline);
|
||||
let feeding = mesh.feed().await;
|
||||
let tables = mesh.wait_for_tables(feeding.started, deadline).await;
|
||||
mesh.report(&tables);
|
||||
let feeders = feeding.finished().await;
|
||||
match early_receiver {
|
||||
Some(receiver) => receiver.await.expect("the receiver is done"),
|
||||
None => {
|
||||
resync(
|
||||
mesh.a.addr,
|
||||
mesh.a.registry.clone(),
|
||||
mesh.settings.receiver_routes(),
|
||||
deadline,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
drop(feeders);
|
||||
drop(mesh);
|
||||
});
|
||||
}
|
||||
|
||||
/// The new peer taking a router's table: counting the UPDATEs it is sent
|
||||
/// while keeping the session alive.
|
||||
struct Receiver {
|
||||
started: Instant,
|
||||
/// When the first UPDATE arrived, in microseconds after `started`; 0
|
||||
/// until it has.
|
||||
first: Arc<AtomicU64>,
|
||||
updates: Arc<AtomicU64>,
|
||||
counting: JoinHandle<()>,
|
||||
keepalives: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Receiver {
|
||||
/// A session with the router at `addr` from [`RECEIVER`], as a
|
||||
/// collector of every path; or `None`, with a word, where that address
|
||||
/// is not this machine's to connect from.
|
||||
async fn connect(addr: SocketAddr) -> Option<TestPeer> {
|
||||
let socket = tokio::net::TcpSocket::new_v4().expect("a socket");
|
||||
if let Err(e) = socket.bind(SocketAddr::new(RECEIVER.into(), 0)) {
|
||||
println!("A new peer taking A's routes: skipped, {RECEIVER} is not ours ({e})");
|
||||
return None;
|
||||
}
|
||||
let stream = socket.connect(addr).await.expect("A listens");
|
||||
let mut session = TestPeer::from_stream(stream);
|
||||
session
|
||||
.handshake_with(
|
||||
ASN,
|
||||
Ipv4Addr::new(192, 0, 2, 9),
|
||||
90,
|
||||
collector_capabilities(ASN, &SUPPORTED, false),
|
||||
)
|
||||
.await
|
||||
.expect("A takes the receiver's session");
|
||||
Some(session)
|
||||
}
|
||||
|
||||
/// Starts counting what `session` is sent, from now. Every millionth
|
||||
/// UPDATE is noted, as is a NOTIFICATION or the connection closing:
|
||||
/// what went wrong, if the table never all arrives.
|
||||
fn count_updates(session: TestPeer) -> Self {
|
||||
let started = Instant::now();
|
||||
let (reader, mut writer) = session.into_stream().into_split();
|
||||
// KEEPALIVEs, so that the session outlives the hold time.
|
||||
let keepalives = tokio::spawn(async move {
|
||||
let keepalive = TestPeer::keepalive_bytes();
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(20)).await;
|
||||
if writer.write_all(&keepalive).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let first = Arc::new(AtomicU64::new(0));
|
||||
let updates = Arc::new(AtomicU64::new(0));
|
||||
let counting = tokio::spawn({
|
||||
let (first, updates) = (Arc::clone(&first), Arc::clone(&updates));
|
||||
async move {
|
||||
let mut reader = BufReader::with_capacity(1 << 20, reader);
|
||||
let mut header = [0u8; 19];
|
||||
let mut body = vec![0u8; 4096];
|
||||
while reader.read_exact(&mut header).await.is_ok() {
|
||||
let length = usize::from(u16::from_be_bytes([header[16], header[17]]));
|
||||
if reader.read_exact(&mut body[..length - 19]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
match header[18] {
|
||||
msg_type::UPDATE => {
|
||||
let _ = first.compare_exchange(
|
||||
0,
|
||||
started.elapsed().as_micros() as u64,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
let n = updates.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if n % 1_000_000 == 0 {
|
||||
println!(
|
||||
" the receiver has read {n} UPDATEs after {:.2} s",
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
msg_type::NOTIFICATION => println!(
|
||||
"the receiver was sent a NOTIFICATION, error {} subcode {}, after {} UPDATEs",
|
||||
body[0],
|
||||
body[1],
|
||||
updates.load(Ordering::Relaxed)
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"the receiver's connection closed after {} UPDATEs",
|
||||
updates.load(Ordering::Relaxed)
|
||||
);
|
||||
}
|
||||
});
|
||||
Self {
|
||||
started,
|
||||
first,
|
||||
updates,
|
||||
counting,
|
||||
keepalives,
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the router whose sessions are in `registry` has
|
||||
/// advertised `routes` routes to the receiver, and returns how many it
|
||||
/// has; or panics once `deadline` has passed.
|
||||
async fn wait_for_advertised(
|
||||
&self,
|
||||
registry: &SessionRegistry,
|
||||
routes: usize,
|
||||
deadline: Duration,
|
||||
) -> u64 {
|
||||
loop {
|
||||
tokio::time::sleep(POLL).await;
|
||||
let advertised = registry
|
||||
.list()
|
||||
.iter()
|
||||
.find(|s| s.peer.addr.ip() == RECEIVER)
|
||||
.map_or(0, |s| s.counters.advertised());
|
||||
if advertised as usize >= routes {
|
||||
return advertised;
|
||||
}
|
||||
if self.started.elapsed() > deadline {
|
||||
panic!(
|
||||
"the receiver has {advertised} of {routes} routes and read {} UPDATEs; sessions at A: {:#?}",
|
||||
self.updates.load(Ordering::Relaxed),
|
||||
sessions_report(registry)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prints the row for the table being all there, `advertised` routes
|
||||
/// of it, and stops counting.
|
||||
fn report(self, advertised: u64) {
|
||||
let took = self.started.elapsed();
|
||||
self.counting.abort();
|
||||
self.keepalives.abort();
|
||||
let first = Duration::from_micros(self.first.load(Ordering::Relaxed));
|
||||
row(
|
||||
"A new peer has A's IPv4 routes (A's exporter)",
|
||||
took,
|
||||
format!(
|
||||
"{:.2} M routes/s first UPDATE after {:.0} ms, {} UPDATEs",
|
||||
advertised as f64 / took.as_secs_f64() / 1e6,
|
||||
first.as_secs_f64() * 1e3,
|
||||
self.updates.load(Ordering::Relaxed),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new peer takes the table of the router at `addr`, whose sessions
|
||||
/// are in `registry`: `routes` routes with every path. How long until
|
||||
/// the first UPDATE, and until all of it is sent.
|
||||
async fn resync(addr: SocketAddr, registry: SessionRegistry, routes: usize, deadline: Duration) {
|
||||
let Some(session) = Receiver::connect(addr).await else {
|
||||
return;
|
||||
};
|
||||
let receiver = Receiver::count_updates(session);
|
||||
let advertised = receiver
|
||||
.wait_for_advertised(®istry, routes, deadline)
|
||||
.await;
|
||||
receiver.report(advertised);
|
||||
}
|
||||
@@ -11,30 +11,20 @@ use std::net::Ipv4Addr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use bgp_bench::memory::Region;
|
||||
use bgp_bench::mrt::{Dump, parse_attributes};
|
||||
use bgp_bench::report::{mib, per_second, row};
|
||||
use bgp_packet::message::{NlriNextHop, PathAttribute};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::table::{Event, PeerIndex, PeerInfo, RibTable};
|
||||
|
||||
/// Bytes allocated and not freed since `region` began; zero when memory
|
||||
/// is not being counted (see `bgp_bench::memory`).
|
||||
fn retained(region: &Region) -> i64 {
|
||||
region.held().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn mib(bytes: i64) -> f64 {
|
||||
bytes as f64 / (1024.0 * 1024.0)
|
||||
}
|
||||
|
||||
fn per_second(count: usize, elapsed: Duration) -> String {
|
||||
format!("{:.2} M/s", count as f64 / elapsed.as_secs_f64() / 1e6)
|
||||
}
|
||||
|
||||
fn row(what: &str, elapsed: Duration, detail: String) {
|
||||
println!("{what:<44} {:>9.3} s {detail}", elapsed.as_secs_f64());
|
||||
/// is not being counted (see `bgp_bench::memory`), and zero too in the
|
||||
/// odd case of more freed than allocated, which is nothing held.
|
||||
fn retained(region: &Region) -> usize {
|
||||
usize::try_from(region.held().unwrap_or(0)).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn dump_path() -> Option<PathBuf> {
|
||||
|
||||
@@ -42,6 +42,7 @@ fn run(
|
||||
let server = TestServer::start_with(4_200_000_000, |config| {
|
||||
config.export = Arc::new(ExportPolicy {
|
||||
learned_routes: !matches!(passing, Passing::Not),
|
||||
all_paths: false,
|
||||
marks: match passing {
|
||||
Passing::Dressed => Marks::parse(&[], &["64601:7".to_string()], &[]).unwrap(),
|
||||
_ => Marks::default(),
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Feeds the synthetic Internet routing table (see the crate documentation)
|
||||
//! to a running daemon over TCP, as one or more peers would, and holds the
|
||||
//! sessions open afterwards. This is the load for profiling the daemon as
|
||||
//! it takes in a full table; `scripts/profile/full-table.sh` runs it under
|
||||
//! `perf`.
|
||||
//!
|
||||
//! Settings, from the environment:
|
||||
//!
|
||||
//! - `FEED_TO`: where the daemon listens (default `127.0.0.1:1790`).
|
||||
//! - `FEED_PEERS`: how many peers send the table (default 1).
|
||||
//! - `FEED_SCALE_DOWN`: as `BENCH_SCALE_DOWN`; 10 makes the table ten
|
||||
//! times smaller (default 1).
|
||||
//! - `FEED_FROM`: the first three octets of an IPv4 /24, "192.0.2". Peer N
|
||||
//! connects from .N, which is also the next hop of its IPv4 routes.
|
||||
//! Without it, peers connect from wherever the system puts them.
|
||||
//! - `FEED_ASN`: the first peer's AS number (default 64513); the others
|
||||
//! follow on from it.
|
||||
//! - `FEED_HOLD_SECS`: how long the sessions stay open after the table is
|
||||
//! sent (default 30).
|
||||
//! - `FEED_COLLECTOR=1`: before the peers, one more session, as a route
|
||||
//! collector would have: over IPv6, to `FEED_COLLECTOR_TO` (default
|
||||
//! `[::1]:1790`) from `FEED_COLLECTOR_FROM` if set, offering to receive
|
||||
//! several routes for a prefix (ADD-PATH) for both families and IPv4
|
||||
//! routes with IPv6 next hops, and reading and dropping whatever it is
|
||||
//! sent. Its AS number is 212232.
|
||||
//!
|
||||
//! What is printed: when the sessions are up and how long the writes took.
|
||||
//! The writes end when the kernel's socket buffers have taken the last
|
||||
//! bytes, which is before the daemon has read them: when the table is in
|
||||
//! is for the daemon's API to say.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bgp_bench::report::mib;
|
||||
use bgp_bench::{Prefixes, Shape, env, table_stream};
|
||||
use bgp_integration_tests::test_peer::{TestPeer, collector_capabilities};
|
||||
use bgp_primitives::constants::AfiSafi;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpSocket;
|
||||
use tokio::net::tcp::OwnedWriteHalf;
|
||||
|
||||
/// How often KEEPALIVEs go out while the sessions are held open: well
|
||||
/// inside the 90 s hold time.
|
||||
const KEEPALIVE_EVERY: Duration = Duration::from_secs(20);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let to: SocketAddr = std::env::var("FEED_TO")
|
||||
.unwrap_or_else(|_| "127.0.0.1:1790".into())
|
||||
.parse()
|
||||
.expect("FEED_TO is an address and port");
|
||||
let peers = env("FEED_PEERS", 1) as u32;
|
||||
let shape = Shape::INTERNET.scaled_down(env("FEED_SCALE_DOWN", 1));
|
||||
let from = std::env::var("FEED_FROM").ok();
|
||||
let first_asn = env("FEED_ASN", 64513) as u32;
|
||||
let hold = Duration::from_secs(env("FEED_HOLD_SECS", 30) as u64);
|
||||
|
||||
let started = Instant::now();
|
||||
let prefixes = Prefixes::generate(shape);
|
||||
let tables: Vec<Vec<u8>> = (1..=peers)
|
||||
.map(|peer| table_stream(&prefixes, shape, peer))
|
||||
.collect();
|
||||
let routes = shape.prefixes() * peers as usize;
|
||||
let bytes: usize = tables.iter().map(Vec::len).sum();
|
||||
println!(
|
||||
"table: {} IPv4 + {} IPv6 prefixes from each of {peers} peer(s): {routes} routes, {:.1} MiB on the wire, made in {:.2} s",
|
||||
shape.ipv4_prefixes,
|
||||
shape.ipv6_prefixes,
|
||||
mib(bytes),
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
// The collector first, so that it is there as the table arrives, as a
|
||||
// router's collector session is after a restart.
|
||||
let collector = env("FEED_COLLECTOR", 0) == 1;
|
||||
let mut draining = Vec::new();
|
||||
let mut collector_writer = None;
|
||||
if collector {
|
||||
let to: SocketAddr = std::env::var("FEED_COLLECTOR_TO")
|
||||
.unwrap_or_else(|_| "[::1]:1790".into())
|
||||
.parse()
|
||||
.expect("FEED_COLLECTOR_TO is an IPv6 address and port");
|
||||
let socket = TcpSocket::new_v6().expect("a socket");
|
||||
if let Ok(from) = std::env::var("FEED_COLLECTOR_FROM") {
|
||||
let address: std::net::Ipv6Addr = from.parse().expect("an IPv6 address");
|
||||
socket
|
||||
.bind(SocketAddr::new(address.into(), 0))
|
||||
.expect("the collector's address is ours to connect from");
|
||||
}
|
||||
let stream = socket.connect(to).await.expect("the daemon listens");
|
||||
let mut session = TestPeer::from_stream(stream);
|
||||
session
|
||||
.handshake_with(
|
||||
212_232,
|
||||
Ipv4Addr::new(10, 0, 0, 200),
|
||||
90,
|
||||
collector_capabilities(
|
||||
212_232,
|
||||
&[AfiSafi::IPV4_UNICAST, AfiSafi::IPV6_UNICAST],
|
||||
true,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("the daemon takes the collector's session");
|
||||
let (writer, sink) = session.drain();
|
||||
draining.push(sink);
|
||||
println!("collector session established");
|
||||
collector_writer = Some(writer);
|
||||
}
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for peer in 1..=peers {
|
||||
let socket = TcpSocket::new_v4().expect("a socket");
|
||||
if let Some(from) = &from {
|
||||
let address: Ipv4Addr = format!("{from}.{peer}")
|
||||
.parse()
|
||||
.expect("FEED_FROM is the first three octets of an IPv4 address");
|
||||
socket
|
||||
.bind(SocketAddr::new(address.into(), 0))
|
||||
.expect("the peer's address is ours to connect from");
|
||||
}
|
||||
let stream = socket.connect(to).await.expect("the daemon listens");
|
||||
let mut session = TestPeer::from_stream(stream);
|
||||
session
|
||||
.handshake(
|
||||
first_asn + peer - 1,
|
||||
Ipv4Addr::new(10, 0, 0, peer as u8),
|
||||
90,
|
||||
)
|
||||
.await
|
||||
.expect("the daemon takes the session");
|
||||
// Whatever the daemon sends back, KEEPALIVEs as a rule, is read
|
||||
// and dropped, so that the writer is never held up.
|
||||
let (writer, sink) = session.drain();
|
||||
draining.push(sink);
|
||||
sessions.push(writer);
|
||||
}
|
||||
println!("{peers} session(s) established; sending");
|
||||
|
||||
let started = Instant::now();
|
||||
let senders: Vec<_> = sessions
|
||||
.into_iter()
|
||||
.zip(tables)
|
||||
.map(|(mut writer, table)| {
|
||||
tokio::spawn(async move {
|
||||
let started = Instant::now();
|
||||
writer.write_all(&table).await.expect("the daemon reads");
|
||||
(writer, started.elapsed())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut writers: Vec<OwnedWriteHalf> = collector_writer.into_iter().collect();
|
||||
for (index, sender) in senders.into_iter().enumerate() {
|
||||
let (writer, took) = sender.await.expect("the sender runs");
|
||||
println!(
|
||||
"peer {}: table written in {:.3} s",
|
||||
index + 1,
|
||||
took.as_secs_f64()
|
||||
);
|
||||
writers.push(writer);
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
println!(
|
||||
"all {routes} routes written in {:.3} s: {:.2} M routes/s into the sockets",
|
||||
elapsed.as_secs_f64(),
|
||||
routes as f64 / elapsed.as_secs_f64() / 1e6
|
||||
);
|
||||
|
||||
let keepalive = TestPeer::keepalive_bytes();
|
||||
let deadline = Instant::now() + hold;
|
||||
while Instant::now() < deadline {
|
||||
tokio::time::sleep(KEEPALIVE_EVERY.min(deadline - Instant::now())).await;
|
||||
for writer in &mut writers {
|
||||
// A session the daemon closed is left alone.
|
||||
let _ = writer.write_all(&keepalive).await;
|
||||
}
|
||||
}
|
||||
println!("sessions closed after {} s", hold.as_secs());
|
||||
drop(draining);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! How many UPDATEs, and how many octets, a table takes when the routes
|
||||
//! that share attributes are grouped only within slices of the table
|
||||
//! taken in shard order, for a few slice sizes: what streaming a table
|
||||
//! out in slices costs against grouping it whole.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{BuildHasher, RandomState};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bgp_bench::mrt::Dump;
|
||||
|
||||
fn main() {
|
||||
let path = std::env::var("BENCH_MRT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| "crates/bench/data/rrc03-bview.20260915.0000.gz".into());
|
||||
let dump = Dump::read(&path).expect("a readable dump");
|
||||
let peers = dump.largest_peers(usize::MAX);
|
||||
let full: Vec<usize> = peers
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&p| dump.routes[p].len() > 800_000)
|
||||
.collect();
|
||||
println!("{} peers with a full table", full.len());
|
||||
let hasher = RandomState::new();
|
||||
for (what, chosen) in [
|
||||
("one full table", vec![full[0]]),
|
||||
("every full table, every path", full.clone()),
|
||||
] {
|
||||
// (hash of prefix, attribute set, prefix octets) in shard order.
|
||||
let mut routes: Vec<(u64, u32, usize)> = chosen
|
||||
.iter()
|
||||
.flat_map(|&p| dump.routes[p].iter())
|
||||
.map(|(prefix, set)| {
|
||||
(
|
||||
hasher.hash_one(prefix),
|
||||
*set,
|
||||
1 + usize::from(prefix.length()).div_ceil(8) + 4,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
routes.sort_unstable_by_key(|r| r.0);
|
||||
println!("== {what}: {} routes", routes.len());
|
||||
for slice in [4_096usize, 16_384, 65_536, 262_144, usize::MAX] {
|
||||
let mut updates = 0usize;
|
||||
let mut octets = 0usize;
|
||||
for chunk in routes.chunks(slice.min(routes.len())) {
|
||||
let mut groups: HashMap<u32, usize> = HashMap::new();
|
||||
for &(_, set, prefix_octets) in chunk {
|
||||
*groups.entry(set).or_insert(0) += prefix_octets;
|
||||
}
|
||||
for (set, prefix_octets) in groups {
|
||||
// One UPDATE per 4096 octets of prefixes, roughly.
|
||||
let attrs = dump.attribute_sets[set as usize].len();
|
||||
let messages = prefix_octets.div_ceil(4000 - attrs.min(3000)).max(1);
|
||||
updates += messages;
|
||||
octets += messages * (23 + attrs) + prefix_octets;
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" slice {:>9}: {:>9} UPDATEs, {:>5.1} MB on the wire",
|
||||
if slice == usize::MAX {
|
||||
"whole".to_string()
|
||||
} else {
|
||||
slice.to_string()
|
||||
},
|
||||
updates,
|
||||
octets as f64 / 1e6
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
pub mod memory;
|
||||
pub mod mrt;
|
||||
pub mod report;
|
||||
|
||||
// The benchmarks allocate as the daemon does (bin/bgp/src/main.rs), with
|
||||
// jemalloc; the C library's allocator would put its own locks in the
|
||||
// profile. Not when memory is counted: that needs the counting one.
|
||||
#[cfg(all(not(feature = "count-memory"), not(target_env = "msvc")))]
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
@@ -259,6 +267,16 @@ pub fn announcements(prefixes: &Prefixes, shape: Shape, peer: u32) -> Vec<Update
|
||||
updates
|
||||
}
|
||||
|
||||
/// The whole table as the peer numbered `peer` sends it, one stream of
|
||||
/// bytes: [`announcements`], [`encoded`] and run together, for a session
|
||||
/// that writes the table in one go.
|
||||
pub fn table_stream(prefixes: &Prefixes, shape: Shape, peer: u32) -> Vec<u8> {
|
||||
encoded(&announcements(prefixes, shape, peer))
|
||||
.iter()
|
||||
.flat_map(|message| message.iter().copied())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `updates` as they are on the wire, on a session with four-octet AS
|
||||
/// numbers.
|
||||
pub fn encoded(updates: &[UpdateMessage]) -> Vec<BytesMut> {
|
||||
@@ -293,3 +311,68 @@ pub fn env(name: &str, default: usize) -> usize {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A small table, enough for every prefix length and attribute to
|
||||
/// come up.
|
||||
fn shape() -> Shape {
|
||||
Shape::INTERNET.scaled_down(1000)
|
||||
}
|
||||
|
||||
/// The benchmarks are compared from one run to the next, so the table
|
||||
/// they are fed must not change between runs: the generator is
|
||||
/// seeded, and nothing in it depends on where things are in memory.
|
||||
#[test]
|
||||
fn the_table_is_the_same_every_run() {
|
||||
let shape = shape();
|
||||
let once = Prefixes::generate(shape);
|
||||
let again = Prefixes::generate(shape);
|
||||
assert_eq!(once.ipv4, again.ipv4);
|
||||
assert_eq!(once.ipv6, again.ipv6);
|
||||
assert_eq!(once.ipv4.len(), shape.ipv4_prefixes);
|
||||
assert_eq!(once.ipv6.len(), shape.ipv6_prefixes);
|
||||
for peer in [1, 2] {
|
||||
assert_eq!(
|
||||
table_stream(&once, shape, peer),
|
||||
table_stream(&again, shape, peer)
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
table_stream(&once, shape, 1),
|
||||
table_stream(&once, shape, 2),
|
||||
"each peer has attributes of its own"
|
||||
);
|
||||
}
|
||||
|
||||
/// What a peer of the benchmarks sends is well formed, and says what
|
||||
/// it was meant to: every UPDATE reads back as it was made.
|
||||
#[test]
|
||||
fn every_announcement_decodes_to_itself() {
|
||||
let shape = shape();
|
||||
let prefixes = Prefixes::generate(shape);
|
||||
let updates = announcements(&prefixes, shape, 1);
|
||||
let ctx = wire_context();
|
||||
let announced: usize = updates
|
||||
.iter()
|
||||
.map(|update| {
|
||||
update.announced_routes.len()
|
||||
+ update
|
||||
.path_attributes
|
||||
.iter()
|
||||
.map(|attribute| match attribute {
|
||||
PathAttribute::MpReachNlri(reach) => reach.prefixes.len(),
|
||||
_ => 0,
|
||||
})
|
||||
.sum::<usize>()
|
||||
})
|
||||
.sum();
|
||||
assert_eq!(announced, shape.prefixes());
|
||||
for (update, wire) in updates.iter().zip(encoded(&updates)) {
|
||||
let decoded = Message::decode(&ctx, &wire).expect("well formed");
|
||||
assert_eq!(decoded, Message::Update(update.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-26
@@ -10,7 +10,7 @@ use std::io::{self, BufReader, Read};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::path::Path;
|
||||
|
||||
use bgp_packet::message::{NlriNextHop, PathAttribute};
|
||||
use bgp_packet::message::{NlriNextHop, PathAttribute, attr_type, split_attribute};
|
||||
use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use flate2::read::MultiGzDecoder;
|
||||
@@ -21,7 +21,6 @@ const TABLE_DUMP_V2: u16 = 13;
|
||||
const PEER_INDEX_TABLE: u16 = 1;
|
||||
const RIB_IPV4_UNICAST: u16 = 2;
|
||||
const RIB_IPV6_UNICAST: u16 = 4;
|
||||
const MP_REACH_NLRI: u8 = 14;
|
||||
|
||||
/// A peer of the collector.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -205,32 +204,23 @@ pub fn parse_attributes(mut wire: &[u8]) -> Option<(Vec<PathAttribute>, Option<N
|
||||
let mut attributes = Vec::new();
|
||||
let mut next_hop = None;
|
||||
while !wire.is_empty() {
|
||||
if wire.get(1) == Some(&MP_REACH_NLRI) {
|
||||
let extended = wire[0] & 0x10 != 0;
|
||||
let (length, header) = if extended {
|
||||
(
|
||||
usize::from(u16::from_be_bytes([*wire.get(2)?, *wire.get(3)?])),
|
||||
4,
|
||||
)
|
||||
} else {
|
||||
(usize::from(*wire.get(2)?), 3)
|
||||
};
|
||||
let value = wire.get(header..header + length)?;
|
||||
let address = |bytes: &[u8]| Some(Ipv6Addr::from(<[u8; 16]>::try_from(bytes).ok()?));
|
||||
next_hop = match value {
|
||||
[4, a, b, c, d] => Some(NlriNextHop::Ipv4(Ipv4Addr::new(*a, *b, *c, *d))),
|
||||
[16, rest @ ..] => Some(NlriNextHop::Ipv6(address(rest)?)),
|
||||
[32, rest @ ..] if rest.len() == 32 => Some(NlriNextHop::Ipv6WithLl {
|
||||
global: address(&rest[..16])?,
|
||||
link_local: address(&rest[16..])?,
|
||||
}),
|
||||
_ => return None,
|
||||
};
|
||||
wire = &wire[header + length..];
|
||||
continue;
|
||||
let (raw, rest) = split_attribute(wire).ok()?;
|
||||
if raw.type_code == attr_type::MP_REACH_NLRI {
|
||||
// The value is the next hop's length and the next hop, and
|
||||
// nothing of the family, reserved octet and prefixes that
|
||||
// follow in an UPDATE, so the attribute's own parser would
|
||||
// refuse it.
|
||||
let (length, address) = raw.value.split_first()?;
|
||||
if usize::from(*length) != address.len() {
|
||||
return None;
|
||||
}
|
||||
let (rest, attribute) = PathAttribute::from_wire(&ctx, wire).ok()?;
|
||||
next_hop = Some(NlriNextHop::from_bytes(address)?);
|
||||
} else {
|
||||
let ([], attribute) = PathAttribute::from_wire(&ctx, raw.wire).ok()? else {
|
||||
return None;
|
||||
};
|
||||
attributes.push(attribute);
|
||||
}
|
||||
wire = rest;
|
||||
}
|
||||
Some((attributes, next_hop))
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! How the benchmarks print what they measured: one row a measurement,
|
||||
//! what was measured in a column wide enough for the longest of them, the
|
||||
//! time, and whatever else there is to say about it. The rows are what a
|
||||
//! run is compared against, so their shape is settled here once.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// How wide the column that says what was measured is. The widest row is
|
||||
/// the mesh benchmark's, so that is what every benchmark lines up to.
|
||||
pub const WIDTH: usize = 52;
|
||||
|
||||
/// `bytes` in mebibytes.
|
||||
pub fn mib(bytes: usize) -> f64 {
|
||||
bytes as f64 / (1024.0 * 1024.0)
|
||||
}
|
||||
|
||||
/// `count` things in `elapsed`, as a rate: millions a second where there
|
||||
/// are that many, thousands otherwise.
|
||||
pub fn per_second(count: usize, elapsed: Duration) -> String {
|
||||
let rate = count as f64 / elapsed.as_secs_f64();
|
||||
if rate >= 1e6 {
|
||||
format!("{:.2} M/s", rate / 1e6)
|
||||
} else {
|
||||
format!("{:.0} k/s", rate / 1e3)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prints one row: `what` took `elapsed`, and `detail` says more.
|
||||
pub fn row(what: &str, elapsed: Duration, detail: impl std::fmt::Display) {
|
||||
println!("{what:<WIDTH$} {:>8.3} s {detail}", elapsed.as_secs_f64());
|
||||
}
|
||||
+278
-90
@@ -24,15 +24,21 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use crate::interfaces::Interfaces;
|
||||
use crate::netlink::{
|
||||
self, Change, Dump, KIND_UNICAST, Message, PROTOCOL_BGP, Route, decode, group,
|
||||
self, Change, Dump, KIND_BLACKHOLE, KIND_UNICAST, KIND_UNSPEC, Message, PROTOCOL_BGP, Route,
|
||||
decode, group,
|
||||
};
|
||||
use crate::plan::{
|
||||
HashMap as FastHashMap, Neighbor, Op, Plan, Target, Unresolved, resolve_next_hop,
|
||||
};
|
||||
use crate::routes::KernelRoutes;
|
||||
use crate::topology::Topology;
|
||||
use crate::{Fib, FibConfig, FibError, Stop};
|
||||
|
||||
/// How many best routes are taken from the routing table at a time.
|
||||
const BATCH: usize = 1024;
|
||||
/// How many best routes are taken from the routing table at a time. A
|
||||
/// poll of the table costs two binary searches and a lock in every shard,
|
||||
/// however few events it brings back; with a few hundred shards, a batch
|
||||
/// of a thousand had that overhead for every eight events a shard.
|
||||
const BATCH: usize = 16384;
|
||||
|
||||
/// How many requests are sent to the kernel in one write.
|
||||
const WRITE_BATCH: usize = 512;
|
||||
@@ -70,6 +76,11 @@ const RECEIVE_BUFFER: usize = 8 * 1024 * 1024;
|
||||
/// not at all.
|
||||
const ANSWER_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How much room the socket that hints at route changes has: enough for
|
||||
/// a few hundred messages, and no more, because what it hears is never
|
||||
/// read. See `Sockets::hints`.
|
||||
const HINT_BUFFER: usize = 64 * 1024;
|
||||
|
||||
/// The errors of the kernel that have a meaning here.
|
||||
mod errno {
|
||||
pub const EPERM: i32 = 1;
|
||||
@@ -128,20 +139,21 @@ struct Socket {
|
||||
impl Socket {
|
||||
/// A socket that is sent the notifications of `groups`.
|
||||
fn open(groups: u32) -> io::Result<Self> {
|
||||
Self::open_with_room(groups, RECEIVE_BUFFER)
|
||||
}
|
||||
|
||||
/// Opens a socket with `room` octets of receive buffer, as far as the
|
||||
/// kernel allows.
|
||||
fn open_with_room(groups: u32, room: usize) -> io::Result<Self> {
|
||||
let mut socket = TokioSocket::new(NETLINK_ROUTE)?;
|
||||
socket.socket_mut().bind(&SocketAddr::new(0, groups))?;
|
||||
// Room for the answers to a whole write, and answers that do not
|
||||
// repeat the request. Both are for the better and neither is
|
||||
// needed: what is lost for want of room is noticed and made good.
|
||||
let raw = socket.socket_ref();
|
||||
if nix::sys::socket::setsockopt(
|
||||
raw,
|
||||
nix::sys::socket::sockopt::RcvBufForce,
|
||||
&RECEIVE_BUFFER,
|
||||
)
|
||||
.is_err()
|
||||
if nix::sys::socket::setsockopt(raw, nix::sys::socket::sockopt::RcvBufForce, &room).is_err()
|
||||
{
|
||||
let _ = raw.set_rx_buf_sz(RECEIVE_BUFFER);
|
||||
let _ = raw.set_rx_buf_sz(room);
|
||||
}
|
||||
let _ = raw.set_cap_ack(true);
|
||||
Ok(Self {
|
||||
@@ -220,7 +232,7 @@ impl Socket {
|
||||
0 => Ok(()),
|
||||
errno => Err(FibError::Dump {
|
||||
what: match what {
|
||||
Dump::Routes(_) => "its routes",
|
||||
Dump::Routes(_) | Dump::RoutesIn { .. } => "its routes",
|
||||
Dump::Addresses => "its addresses",
|
||||
Dump::Links => "its interfaces",
|
||||
Dump::Nexthops => "its nexthop objects",
|
||||
@@ -307,6 +319,17 @@ pub(crate) struct Sockets {
|
||||
/// For what the kernel tells unasked: changes to the interfaces,
|
||||
/// their addresses, and nexthop objects.
|
||||
news: Socket,
|
||||
/// For asking which routes the resolution table has, and only that:
|
||||
/// with strict checking on, the kernel answers with that table alone
|
||||
/// rather than with every table it has, which may be a million routes
|
||||
/// of ours. `None` on a kernel from before it could (Linux 4.20).
|
||||
resolution: Option<Socket>,
|
||||
/// Told of every change to any route, as a hint that the resolution
|
||||
/// table may have changed: what it says is never read, only that it
|
||||
/// said something. The kernel tells of our own routes going in too, a
|
||||
/// million at a time, so this socket has little room and is expected
|
||||
/// to overflow; an overflow is as good a hint as a message.
|
||||
hints: Socket,
|
||||
}
|
||||
|
||||
impl Sockets {
|
||||
@@ -318,10 +341,18 @@ impl Sockets {
|
||||
.socket
|
||||
.socket_ref()
|
||||
.add_membership(netlink::GROUP_NEXTHOP);
|
||||
let resolution = Socket::open(0)?;
|
||||
let strict = resolution
|
||||
.socket
|
||||
.socket_ref()
|
||||
.set_netlink_get_strict_chk(true)
|
||||
.is_ok();
|
||||
Ok(Self {
|
||||
control: Socket::open(0)?,
|
||||
routes: Socket::open(0)?,
|
||||
news,
|
||||
resolution: strict.then_some(resolution),
|
||||
hints: Socket::open_with_room(group::IPV4_ROUTE | group::IPV6_ROUTE, HINT_BUFFER)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -417,6 +448,8 @@ enum News {
|
||||
prefix: IpPrefix,
|
||||
peer: PeerIndex,
|
||||
next_hop: Option<NlriNextHop>,
|
||||
/// Whether the route asks for its packets to be dropped.
|
||||
blackhole: bool,
|
||||
},
|
||||
Withdrawn(IpPrefix),
|
||||
Synced,
|
||||
@@ -427,17 +460,22 @@ struct Task {
|
||||
fib: Fib,
|
||||
config: FibConfig,
|
||||
control: Socket,
|
||||
/// See `Sockets::resolution`.
|
||||
resolution: Option<Socket>,
|
||||
/// Where changes to routes go to be written; `None` once they have
|
||||
/// all been handed over, at the end.
|
||||
queue: Option<mpsc::Sender<Batch>>,
|
||||
writer: JoinHandle<Result<(), FibError>>,
|
||||
interfaces: Interfaces,
|
||||
/// The kernel's view of the machine, as far as next hops are
|
||||
/// concerned: interfaces, the routes that say the way to a next hop,
|
||||
/// and nexthop objects.
|
||||
topology: Topology,
|
||||
/// What each next hop seen so far resolves to, by the next hop, the
|
||||
/// IP version of the routes, and for a link-local one the peer.
|
||||
/// There are as many next hops as neighbors, and a million routes
|
||||
/// share them; looking through the interfaces for each route would
|
||||
/// take as long as there are addresses. Emptied when the interfaces
|
||||
/// change.
|
||||
/// share them; looking through the interfaces and routes for each
|
||||
/// route would take as long as there are of those. Emptied when the
|
||||
/// topology changes.
|
||||
resolved: FastHashMap<Resolving, Result<Target, Unresolved>>,
|
||||
plan: Plan,
|
||||
/// The kernel's nexthop objects that our routes name, by where they
|
||||
@@ -460,9 +498,53 @@ struct Task {
|
||||
/// them means looking at every route, and is not done once there are
|
||||
/// none to find.
|
||||
keeping: bool,
|
||||
/// What `follow` takes out of the table, and what it makes of it. Kept
|
||||
/// from one wake to the next so that their room is not found anew each
|
||||
/// time the table has something, which is many times a second while
|
||||
/// it fills.
|
||||
news: Vec<News>,
|
||||
ops: Vec<Op>,
|
||||
}
|
||||
|
||||
impl Task {
|
||||
/// A task that is to keep the kernel in step with `rib`, talking to it
|
||||
/// over `control` and `resolution` and writing routes over `routes`
|
||||
/// from a task of its own. Returned with it is where that task
|
||||
/// reports what became of the routes.
|
||||
fn new(
|
||||
rib: Arc<RibTable>,
|
||||
fib: Fib,
|
||||
control: Socket,
|
||||
routes: Socket,
|
||||
resolution: Option<Socket>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Report>) {
|
||||
let (queue, batches) = mpsc::channel(QUEUE);
|
||||
let (reports, heard) = mpsc::unbounded_channel();
|
||||
let config = fib.config();
|
||||
let task = Self {
|
||||
config,
|
||||
rib,
|
||||
fib,
|
||||
control,
|
||||
resolution,
|
||||
queue: Some(queue),
|
||||
writer: tokio::spawn(write(routes, batches, reports)),
|
||||
topology: Topology::new(config.resolution_table, config.resolve_via_default),
|
||||
resolved: FastHashMap::default(),
|
||||
plan: Plan::default(),
|
||||
nexthops: config.nexthop_objects.then(FastHashMap::default),
|
||||
owned: HashSet::new(),
|
||||
awaiting: None,
|
||||
marks: 0,
|
||||
synced: false,
|
||||
mended_at: None,
|
||||
keeping: false,
|
||||
news: Vec::with_capacity(BATCH),
|
||||
ops: Vec::with_capacity(BATCH),
|
||||
};
|
||||
(task, heard)
|
||||
}
|
||||
|
||||
/// Whether a route the kernel has is one of ours.
|
||||
fn is_ours(&self, route: &Route) -> bool {
|
||||
route.protocol == PROTOCOL_BGP
|
||||
@@ -470,7 +552,9 @@ impl Task {
|
||||
&& route.metric == self.config.metric
|
||||
}
|
||||
|
||||
/// Learns the machine's interfaces and addresses anew.
|
||||
/// Learns the machine's interfaces and addresses anew. The routes
|
||||
/// and nexthop objects are learned where the kernel is asked for them
|
||||
/// anyway: `kernel_routes` and `kernel_nexthops`.
|
||||
async fn learn_interfaces(&mut self) -> Result<(), FibError> {
|
||||
let mut interfaces = Interfaces::default();
|
||||
for what in [Dump::Links, Dump::Addresses] {
|
||||
@@ -480,18 +564,18 @@ impl Task {
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
self.interfaces = interfaces;
|
||||
self.topology.interfaces = interfaces;
|
||||
self.resolved.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Asks which nexthop objects of BGP's the kernel has, and returns
|
||||
/// those that lead somewhere, by number.
|
||||
/// Asks which nexthop objects the kernel has. Every one goes into
|
||||
/// the topology, where a route of the resolution table may name it;
|
||||
/// those of BGP's that lead somewhere are returned by number, for
|
||||
/// finding our routes among the kernel's.
|
||||
async fn kernel_nexthops(&mut self) -> Result<HashMap<u32, Target>, FibError> {
|
||||
let mut by_id = HashMap::new();
|
||||
if self.nexthops.is_none() {
|
||||
return Ok(by_id);
|
||||
}
|
||||
let mut all = FastHashMap::default();
|
||||
let asked = self
|
||||
.control
|
||||
.dump(Dump::Nexthops, |message| {
|
||||
@@ -499,10 +583,14 @@ impl Task {
|
||||
present: true,
|
||||
nexthop,
|
||||
} = message
|
||||
&& nexthop.protocol == PROTOCOL_BGP
|
||||
&& let (Some(gateway), Some(interface)) = (nexthop.gateway, nexthop.interface)
|
||||
{
|
||||
by_id.insert(nexthop.id, Target { gateway, interface });
|
||||
all.insert(nexthop.id, nexthop);
|
||||
if nexthop.protocol == PROTOCOL_BGP
|
||||
&& let (Some(gateway), Some(interface)) =
|
||||
(nexthop.gateway, nexthop.interface)
|
||||
{
|
||||
by_id.insert(nexthop.id, Target::Via { gateway, interface });
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -510,14 +598,18 @@ impl Task {
|
||||
Ok(()) => {}
|
||||
// A kernel from before it had them (Linux 5.3).
|
||||
Err(FibError::Dump { errno, .. }) => {
|
||||
if self.nexthops.is_some() {
|
||||
info!(
|
||||
errno,
|
||||
"The kernel has no nexthop objects; routes will carry their own gateway"
|
||||
);
|
||||
}
|
||||
self.nexthops = None;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
self.topology.nexthops = all;
|
||||
self.resolved.clear();
|
||||
Ok(by_id)
|
||||
}
|
||||
|
||||
@@ -553,6 +645,49 @@ impl Task {
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Asks the kernel for the routes of the resolution table anew, and
|
||||
/// returns whether they differ from those known. Asked of the kernel
|
||||
/// with strict checking where it has that, which gives the one table;
|
||||
/// otherwise every table is asked for and all but the one is thrown
|
||||
/// away.
|
||||
async fn learn_routes(&mut self) -> Result<bool, FibError> {
|
||||
let table = self.topology.routes.table();
|
||||
let mut routes = KernelRoutes::new(table);
|
||||
for version in [IpVersion::Ipv4, IpVersion::Ipv6] {
|
||||
let each = |message: Message| {
|
||||
routes.apply(&message);
|
||||
};
|
||||
match &mut self.resolution {
|
||||
Some(socket) => socket.dump(Dump::RoutesIn { version, table }, each).await?,
|
||||
None => self.control.dump(Dump::Routes(version), each).await?,
|
||||
}
|
||||
}
|
||||
if routes == self.topology.routes {
|
||||
return Ok(false);
|
||||
}
|
||||
debug!(
|
||||
table,
|
||||
routes = routes.len(),
|
||||
"The routes that say the way to next hops changed"
|
||||
);
|
||||
self.topology.routes = routes;
|
||||
self.resolved.clear();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Learns what the kernel has that bears on our routes: the routes
|
||||
/// that say the way to next hops, its nexthop objects, and the routes
|
||||
/// of ours it has from before, which are kept for the peers to announce
|
||||
/// again if `keep` says so. Done at the start, and again whenever what
|
||||
/// the kernel has is no longer known.
|
||||
async fn learn(&mut self, keep: bool) -> Result<(), FibError> {
|
||||
self.learn_routes().await?;
|
||||
let nexthops = self.kernel_nexthops().await?;
|
||||
self.adopt_routes(&nexthops, keep).await?;
|
||||
self.use_nexthops(&nexthops);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finds the routes of ours that the kernel has from before.
|
||||
async fn adopt_routes(
|
||||
&mut self,
|
||||
@@ -570,8 +705,9 @@ impl Task {
|
||||
nexthops.get(&id).copied()
|
||||
}
|
||||
(None, Some(gateway), Some(interface)) if route.kind == KIND_UNICAST => {
|
||||
Some(Target { gateway, interface })
|
||||
Some(Target::Via { gateway, interface })
|
||||
}
|
||||
(None, None, _) if route.kind == KIND_BLACKHOLE => Some(Target::Discard),
|
||||
_ => None,
|
||||
};
|
||||
self.plan.adopt(route.prefix, target, keep);
|
||||
@@ -589,6 +725,9 @@ impl Task {
|
||||
if let Some(id) = known.get(&target) {
|
||||
return Ok(Ok(*id));
|
||||
}
|
||||
let Target::Via { gateway, interface } = target else {
|
||||
unreachable!("a route that discards names no nexthop object");
|
||||
};
|
||||
// One at a time, and waited for: there is one for each neighbor,
|
||||
// not for each route, and a route must not name one that is not
|
||||
// there yet.
|
||||
@@ -597,7 +736,7 @@ impl Task {
|
||||
.control
|
||||
.ask(
|
||||
|out, sequence| {
|
||||
netlink::nexthop_request(out, sequence, &target.gateway, target.interface);
|
||||
netlink::nexthop_request(out, sequence, &gateway, interface);
|
||||
},
|
||||
|message| {
|
||||
if let Message::Nexthop {
|
||||
@@ -612,7 +751,7 @@ impl Task {
|
||||
.await?;
|
||||
Ok(match (errno, made) {
|
||||
(0, Some(id)) => {
|
||||
debug!(id, gateway = %target.gateway, "Made a nexthop object");
|
||||
debug!(id, %gateway, "Made a nexthop object");
|
||||
self.owned.insert(id);
|
||||
if let Some(known) = &mut self.nexthops {
|
||||
known.insert(target, id);
|
||||
@@ -639,16 +778,29 @@ impl Task {
|
||||
kind: KIND_UNICAST,
|
||||
};
|
||||
let change = match op {
|
||||
Op::Remove { .. } => Change::Remove,
|
||||
// Whatever kind of route of ours there is for the prefix: the
|
||||
// kernel removes a route of the kind asked for only, and a
|
||||
// blackhole is not a unicast route.
|
||||
Op::Remove { .. } => {
|
||||
route.kind = KIND_UNSPEC;
|
||||
Change::Remove
|
||||
}
|
||||
Op::Install {
|
||||
target: Target::Discard,
|
||||
..
|
||||
} => {
|
||||
route.kind = KIND_BLACKHOLE;
|
||||
Change::Replace
|
||||
}
|
||||
Op::Install { target, .. } => {
|
||||
if self.nexthops.is_some() {
|
||||
match self.nexthop(*target).await? {
|
||||
Ok(id) => route.nexthop = Some(id),
|
||||
Err(errno) => return Ok(Err(errno)),
|
||||
}
|
||||
} else {
|
||||
route.gateway = Some(target.gateway);
|
||||
route.interface = Some(target.interface);
|
||||
} else if let Target::Via { gateway, interface } = target {
|
||||
route.gateway = Some(*gateway);
|
||||
route.interface = Some(*interface);
|
||||
}
|
||||
Change::Replace
|
||||
}
|
||||
@@ -656,17 +808,21 @@ impl Task {
|
||||
Ok(Ok((change, route)))
|
||||
}
|
||||
|
||||
/// Hands `ops` over to be written to the kernel, in order. With
|
||||
/// `whole_table` they complete a whole table, and the kernel is in
|
||||
/// step once it has done them.
|
||||
async fn send(&mut self, ops: Vec<Op>, whole_table: bool) -> Result<(), FibError> {
|
||||
/// Hands `ops` over to be written to the kernel, in order, and leaves
|
||||
/// the vector empty for the caller to fill again. With `whole_table`
|
||||
/// they complete a whole table, and the kernel is in step once it has
|
||||
/// done them.
|
||||
async fn send(&mut self, ops: &mut Vec<Op>, whole_table: bool) -> Result<(), FibError> {
|
||||
let mut batch = Batch {
|
||||
ops: Vec::with_capacity(ops.len()),
|
||||
requests: Vec::with_capacity(ops.len()),
|
||||
mark: None,
|
||||
};
|
||||
let mut ops = std::collections::VecDeque::from(ops);
|
||||
while let Some(op) = ops.pop_front() {
|
||||
// By place rather than by draining: what takes a refused op's
|
||||
// place is put at the end, and is dealt with in its turn.
|
||||
let mut at = 0;
|
||||
while let Some(op) = ops.get(at).copied() {
|
||||
at += 1;
|
||||
match self.request(&op).await? {
|
||||
Ok(request) => {
|
||||
CHANGES
|
||||
@@ -682,6 +838,7 @@ impl Task {
|
||||
Err(errno) => ops.extend(self.refused(&op, errno)?),
|
||||
}
|
||||
}
|
||||
ops.clear();
|
||||
if whole_table {
|
||||
self.marks += 1;
|
||||
batch.mark = Some(self.marks);
|
||||
@@ -717,14 +874,20 @@ impl Task {
|
||||
}
|
||||
let error = io::Error::from_raw_os_error(errno);
|
||||
let what = match op {
|
||||
Op::Install { prefix, target } => format!(
|
||||
"install {prefix} via {} on {}",
|
||||
target.gateway,
|
||||
self.interfaces.link(target.interface).map_or_else(
|
||||
|| format!("interface {}", target.interface),
|
||||
Op::Install {
|
||||
prefix,
|
||||
target: Target::Via { gateway, interface },
|
||||
} => format!(
|
||||
"install {prefix} via {gateway} on {}",
|
||||
self.topology.interfaces.link(*interface).map_or_else(
|
||||
|| format!("interface {interface}"),
|
||||
|link| link.name.clone()
|
||||
)
|
||||
),
|
||||
Op::Install {
|
||||
prefix,
|
||||
target: Target::Discard,
|
||||
} => format!("install a blackhole for {prefix}"),
|
||||
Op::Remove { prefix } => format!("remove {prefix}"),
|
||||
};
|
||||
// Counted always, said out loud once in a while: a table's worth
|
||||
@@ -749,7 +912,12 @@ impl Task {
|
||||
/// hands over what brings the kernel in step with them. Returns whether
|
||||
/// the reader has read everything the table has for it.
|
||||
async fn follow(&mut self, reader: &mut Reader) -> Result<bool, FibError> {
|
||||
let mut news = Vec::with_capacity(BATCH);
|
||||
// Taken out of `self` while they are worked through, since working
|
||||
// through them is what the rest of `self` is for.
|
||||
let mut news = std::mem::take(&mut self.news);
|
||||
let mut ops = std::mem::take(&mut self.ops);
|
||||
news.clear();
|
||||
ops.clear();
|
||||
// Only what is needed is taken while the table is held.
|
||||
let progress = reader.poll(BATCH, |event| {
|
||||
news.push(match event {
|
||||
@@ -764,15 +932,15 @@ impl Task {
|
||||
prefix,
|
||||
peer,
|
||||
next_hop: attrs.next_hop(),
|
||||
blackhole: attrs.is_blackhole(),
|
||||
},
|
||||
Event::Withdrawn { prefix } => News::Withdrawn(prefix),
|
||||
// Not given to a follower of the best routes.
|
||||
Event::Routes { .. } => return,
|
||||
});
|
||||
});
|
||||
let mut ops = Vec::with_capacity(news.len());
|
||||
let mut whole_table = false;
|
||||
for item in news {
|
||||
for item in news.drain(..) {
|
||||
match item {
|
||||
News::Reset => {
|
||||
self.plan.reset();
|
||||
@@ -786,6 +954,7 @@ impl Task {
|
||||
prefix,
|
||||
peer,
|
||||
next_hop,
|
||||
blackhole,
|
||||
} => {
|
||||
// Whose link a link-local next hop is on is asked only
|
||||
// where there is one.
|
||||
@@ -800,18 +969,20 @@ impl Task {
|
||||
address: peer.address,
|
||||
interface: peer.interface,
|
||||
});
|
||||
let interfaces = &self.interfaces;
|
||||
let topology = &self.topology;
|
||||
// Asked of every route, and not remembered with the
|
||||
// next hop: it is about the prefix.
|
||||
let target = if interfaces.has_network_around(&prefix) {
|
||||
let target = if topology.interfaces.has_network_around(&prefix) {
|
||||
Err(Unresolved::OwnNetwork)
|
||||
} else if blackhole {
|
||||
Ok(Target::Discard)
|
||||
} else {
|
||||
*self
|
||||
.resolved
|
||||
.entry((next_hop, prefix.address_family(), peer))
|
||||
.or_insert_with(|| {
|
||||
resolve_next_hop(
|
||||
interfaces,
|
||||
topology,
|
||||
prefix.address_family(),
|
||||
next_hop.as_ref(),
|
||||
peer,
|
||||
@@ -823,7 +994,10 @@ impl Task {
|
||||
News::Withdrawn(prefix) => ops.extend(self.plan.withdrawn(prefix)),
|
||||
}
|
||||
}
|
||||
self.send(ops, whole_table).await?;
|
||||
let sent = self.send(&mut ops, whole_table).await;
|
||||
self.news = news;
|
||||
self.ops = ops;
|
||||
sent?;
|
||||
Ok(progress.caught_up)
|
||||
}
|
||||
|
||||
@@ -852,7 +1026,7 @@ impl Task {
|
||||
}
|
||||
Report::Refused { op, errno } => {
|
||||
let instead = self.refused(&op, errno)?;
|
||||
self.send(instead.into_iter().collect(), false).await?;
|
||||
self.send(&mut instead.into_iter().collect(), false).await?;
|
||||
// A refused route may have named a nexthop object that
|
||||
// went away. Working the routes out again mends that.
|
||||
let due = matches!(op, Op::Install { .. })
|
||||
@@ -886,7 +1060,11 @@ impl Task {
|
||||
while !rest.is_empty() {
|
||||
let (_, message, used) = decode(rest)?;
|
||||
rest = &rest[used..];
|
||||
if self.interfaces.apply(&message) {
|
||||
// A nexthop object of BGP's is one of ours, or another
|
||||
// daemon's: never the way to a next hop, and made by the
|
||||
// hundred as a table goes in. Only the others matter here.
|
||||
let of_bgp = matches!(&message, Message::Nexthop { nexthop, .. } if nexthop.protocol == PROTOCOL_BGP);
|
||||
if self.topology.apply(&message) && !of_bgp {
|
||||
self.resolved.clear();
|
||||
changed = true;
|
||||
}
|
||||
@@ -917,17 +1095,14 @@ impl Task {
|
||||
self.flush(heard).await?;
|
||||
self.synced = false;
|
||||
self.plan = Plan::default();
|
||||
let nexthops = self.kernel_nexthops().await?;
|
||||
// While routes of an earlier run are being kept for the peers to
|
||||
// announce again, they are kept through this too.
|
||||
self.adopt_routes(&nexthops, self.keeping).await?;
|
||||
self.use_nexthops(&nexthops);
|
||||
Ok(())
|
||||
self.learn(self.keeping).await
|
||||
}
|
||||
|
||||
/// Waits until the kernel has done everything handed over so far.
|
||||
async fn flush(&mut self, heard: &mut mpsc::UnboundedReceiver<Report>) -> Result<(), FibError> {
|
||||
self.send(vec![], true).await?;
|
||||
self.send(&mut Vec::new(), true).await?;
|
||||
while let Some(mark) = self.awaiting {
|
||||
match heard.recv().await {
|
||||
Some(Report::Done(done)) if done == mark => self.awaiting = None,
|
||||
@@ -980,8 +1155,8 @@ impl Task {
|
||||
self.plan = Plan::default();
|
||||
self.adopt_routes(&HashMap::new(), false).await?;
|
||||
}
|
||||
let ops = self.plan.clear();
|
||||
self.send(ops, false).await?;
|
||||
let mut ops = self.plan.clear();
|
||||
self.send(&mut ops, false).await?;
|
||||
self.finish_writer().await
|
||||
}
|
||||
|
||||
@@ -991,13 +1166,14 @@ impl Task {
|
||||
UNRESOLVED.set(self.plan.unresolved() as i64);
|
||||
REFUSED.set(self.plan.refused() as i64);
|
||||
let interfaces = interfaces_changed.then(|| {
|
||||
self.fib.publish_interfaces(&self.interfaces);
|
||||
self.interfaces.status()
|
||||
self.fib.publish_topology(&self.topology);
|
||||
self.topology.interfaces.status()
|
||||
});
|
||||
self.fib.update(|status| {
|
||||
status.synced = self.synced;
|
||||
status.installed = self.plan.installed();
|
||||
status.kept = if self.keeping { self.plan.kept() } else { 0 };
|
||||
status.blackholes = self.plan.blackholes();
|
||||
status.refused = self.plan.refused();
|
||||
status.unresolved = self
|
||||
.plan
|
||||
@@ -1039,37 +1215,18 @@ pub(crate) async fn run(
|
||||
control,
|
||||
routes,
|
||||
mut news,
|
||||
resolution,
|
||||
mut hints,
|
||||
} = sockets;
|
||||
let (queue, batches) = mpsc::channel(QUEUE);
|
||||
let (reports, mut heard) = mpsc::unbounded_channel();
|
||||
let config = fib.config();
|
||||
let mut task = Task {
|
||||
config,
|
||||
rib,
|
||||
fib,
|
||||
control,
|
||||
queue: Some(queue),
|
||||
writer: tokio::spawn(write(routes, batches, reports)),
|
||||
interfaces: Interfaces::default(),
|
||||
resolved: FastHashMap::default(),
|
||||
plan: Plan::default(),
|
||||
nexthops: config.nexthop_objects.then(FastHashMap::default),
|
||||
owned: HashSet::new(),
|
||||
awaiting: None,
|
||||
marks: 0,
|
||||
synced: false,
|
||||
mended_at: None,
|
||||
keeping: false,
|
||||
};
|
||||
let (mut task, mut heard) = Task::new(rib, fib, control, routes, resolution);
|
||||
let config = task.config;
|
||||
// The socket for news was bound before this: what changes while the
|
||||
// interfaces are being learned is told there.
|
||||
task.learn_interfaces().await?;
|
||||
let nexthops = task.kernel_nexthops().await?;
|
||||
// Kept for a while, if there is to be any keeping: our table is empty
|
||||
// until the peers have sent theirs again.
|
||||
let keep = !config.stale_time.is_zero();
|
||||
task.adopt_routes(&nexthops, keep).await?;
|
||||
task.use_nexthops(&nexthops);
|
||||
task.learn(keep).await?;
|
||||
let mut release_at = None;
|
||||
if task.plan.installed() > 0 {
|
||||
info!(
|
||||
@@ -1090,7 +1247,10 @@ pub(crate) async fn run(
|
||||
table = config.table,
|
||||
metric = config.metric,
|
||||
nexthop_objects = task.nexthops.is_some(),
|
||||
interfaces = task.interfaces.links().count(),
|
||||
interfaces = task.topology.interfaces.links().count(),
|
||||
resolution_table = config.resolution_table,
|
||||
routes_to_next_hops = task.topology.routes.len(),
|
||||
filtered_dumps = task.resolution.is_some(),
|
||||
"Installing best routes in the kernel"
|
||||
);
|
||||
|
||||
@@ -1098,17 +1258,44 @@ pub(crate) async fn run(
|
||||
// kernel in step; starting a new one does it over.
|
||||
let mut reader = task.rib.reader();
|
||||
let mut again = Again::default();
|
||||
// When the routes that say the way to next hops are next to be asked
|
||||
// for again, if they are.
|
||||
let mut routes_again = Again::default();
|
||||
loop {
|
||||
tokio::select! {
|
||||
hint = hints.read() => {
|
||||
match hint {
|
||||
// Something about some route changed, or so much did
|
||||
// that the socket overflowed: either way, ask.
|
||||
Ok(_) => routes_again.soon(),
|
||||
Err(e) if e.raw_os_error() == Some(errno::ENOBUFS) => routes_again.soon(),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
() = at(routes_again.due()), if routes_again.due().is_some() => {
|
||||
routes_again = Again::default();
|
||||
if task.learn_routes().await? {
|
||||
// The way to a next hop may be another now: every
|
||||
// route is worked out again, which asks the kernel
|
||||
// only for what comes out different.
|
||||
info!(
|
||||
routes = task.topology.routes.len(),
|
||||
"The kernel's routes changed; working out next hops again"
|
||||
);
|
||||
reader = task.rib.reader();
|
||||
task.publish(true);
|
||||
}
|
||||
}
|
||||
() = reader.changed() => {
|
||||
task.follow(&mut reader).await?;
|
||||
task.publish(false);
|
||||
}
|
||||
Some(report) = heard.recv() => {
|
||||
if task.hear(report).await? {
|
||||
let changed = task.hear(report).await?;
|
||||
if changed {
|
||||
again.soon();
|
||||
}
|
||||
task.publish(false);
|
||||
task.publish(changed);
|
||||
}
|
||||
read = news.read() => {
|
||||
if task.on_news(read).await? {
|
||||
@@ -1123,6 +1310,7 @@ pub(crate) async fn run(
|
||||
info!("Working the kernel's routes out again");
|
||||
task.relearn(&mut heard).await?;
|
||||
reader = task.rib.reader();
|
||||
task.publish(true);
|
||||
}
|
||||
_ = async {
|
||||
tokio::select! {
|
||||
@@ -1138,12 +1326,12 @@ pub(crate) async fn run(
|
||||
// taken out only to be put back.
|
||||
while !task.follow(&mut reader).await? {}
|
||||
task.keeping = false;
|
||||
let stale = task.plan.release();
|
||||
let mut stale = task.plan.release();
|
||||
info!(
|
||||
removed = stale.len(),
|
||||
"Routes of an earlier run that were not announced again are removed"
|
||||
);
|
||||
task.send(stale, false).await?;
|
||||
task.send(&mut stale, false).await?;
|
||||
task.publish(false);
|
||||
}
|
||||
ended = &mut task.writer => return Err(writer_error(ended)),
|
||||
|
||||
+40
-18
@@ -9,7 +9,9 @@
|
||||
//!
|
||||
//! - [`netlink`] is the kernel's messages, as bytes and back.
|
||||
//! - [`interfaces`] is the machine's interfaces and what is reachable on
|
||||
//! them.
|
||||
//! them; [`routes`] the kernel's own routes, which say the way to what
|
||||
//! is not; [`topology`] the two together with the kernel's nexthop
|
||||
//! objects, as one view.
|
||||
//! - [`plan`] works out what to ask the kernel for.
|
||||
//! - The rest, which only Linux has, asks it.
|
||||
//!
|
||||
@@ -25,17 +27,24 @@
|
||||
//!
|
||||
//! # What is installed
|
||||
//!
|
||||
//! A best route whose next hop is a neighbor: an address in the network
|
||||
//! of one of the machine's interfaces. A route whose next hop is further
|
||||
//! away than that, as with routes from a peer in our own AS that did not
|
||||
//! set itself as next hop, would need the way to the next hop looked up in
|
||||
//! turn, which is not done: the prefix then has no route in the kernel.
|
||||
//! A best route whose next hop can be reached: a neighbor, which is an
|
||||
//! address in the network of one of the machine's interfaces, or an
|
||||
//! address the kernel's own routes know the way to. In the second case
|
||||
//! the route goes into the kernel with the gateway and interface of the
|
||||
//! route that knows the way, since the kernel does not look a gateway up
|
||||
//! in turn: a route whose next hop is a router of our own AS, reached over
|
||||
//! a tunnel by a static route, is installed as a route into the tunnel.
|
||||
//! Which of the kernel's routes count for this, and which never do, is the
|
||||
//! business of [`routes`].
|
||||
|
||||
pub mod interfaces;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod kernel;
|
||||
pub mod netlink;
|
||||
pub mod plan;
|
||||
pub mod routes;
|
||||
pub mod sockdiag;
|
||||
pub mod topology;
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
@@ -71,6 +80,12 @@ pub struct FibConfig {
|
||||
/// everything at once would stop the machine forwarding meanwhile.
|
||||
/// After this long, what has not been announced again goes.
|
||||
pub stale_time: Duration,
|
||||
/// The kernel's table whose routes say the way to a next hop that is
|
||||
/// not a neighbor: normally `main`, with the connected and static
|
||||
/// routes. BGP's own routes in it never count; see [`routes`].
|
||||
pub resolution_table: u32,
|
||||
/// Whether the default route counts as the way to a next hop.
|
||||
pub resolve_via_default: bool,
|
||||
}
|
||||
|
||||
impl Default for FibConfig {
|
||||
@@ -80,6 +95,8 @@ impl Default for FibConfig {
|
||||
metric: DEFAULT_METRIC,
|
||||
nexthop_objects: true,
|
||||
stale_time: DEFAULT_STALE_TIME,
|
||||
resolution_table: netlink::TABLE_MAIN,
|
||||
resolve_via_default: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +140,9 @@ pub struct Status {
|
||||
/// How many of them are kept from an earlier run though the routing
|
||||
/// table does not have them (yet); see [`FibConfig::stale_time`].
|
||||
pub kept: usize,
|
||||
/// How many of them are blackhole routes, which drop the packets for
|
||||
/// their prefix.
|
||||
pub blackholes: usize,
|
||||
/// Prefixes whose best route is not in the kernel because its next
|
||||
/// hop is not usable, by why.
|
||||
pub unresolved: Vec<(&'static str, usize)>,
|
||||
@@ -135,9 +155,10 @@ pub struct Status {
|
||||
pub interfaces: Vec<InterfaceStatus>,
|
||||
}
|
||||
|
||||
/// The machine's interfaces as the kernel last described them; `None`
|
||||
/// until it has. Whoever holds one is told when they change.
|
||||
pub type InterfacesWatch = watch::Receiver<Option<Arc<interfaces::Interfaces>>>;
|
||||
/// The machine's interfaces, routes and nexthop objects as the kernel
|
||||
/// last described them, as far as next hops are concerned; `None` until
|
||||
/// it has. Whoever holds one is told when they change.
|
||||
pub type TopologyWatch = watch::Receiver<Option<Arc<topology::Topology>>>;
|
||||
|
||||
/// How the task that keeps the kernel's routes is to end.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -151,7 +172,7 @@ enum Stop {
|
||||
struct Shared {
|
||||
config: FibConfig,
|
||||
status: Mutex<Status>,
|
||||
interfaces: watch::Sender<Option<Arc<interfaces::Interfaces>>>,
|
||||
topology: watch::Sender<Option<Arc<topology::Topology>>>,
|
||||
stop: watch::Sender<Option<Stop>>,
|
||||
/// How many routes of an earlier run were found in the kernel at the
|
||||
/// start, once that has been looked at.
|
||||
@@ -185,7 +206,7 @@ impl Fib {
|
||||
shared: Arc::new(Shared {
|
||||
config,
|
||||
status: Mutex::default(),
|
||||
interfaces: watch::channel(None).0,
|
||||
topology: watch::channel(None).0,
|
||||
stop,
|
||||
adopted: watch::channel(None).0,
|
||||
release: watch::channel(false).0,
|
||||
@@ -200,17 +221,18 @@ impl Fib {
|
||||
self.shared.config
|
||||
}
|
||||
|
||||
/// The machine's interfaces, for those who want to know whether a
|
||||
/// next hop can be reached before the kernel is asked to use it.
|
||||
pub fn interfaces(&self) -> InterfacesWatch {
|
||||
self.shared.interfaces.subscribe()
|
||||
/// The kernel's view of the machine, for those who want to know
|
||||
/// whether a next hop can be reached before the kernel is asked to use
|
||||
/// it.
|
||||
pub fn topology(&self) -> TopologyWatch {
|
||||
self.shared.topology.subscribe()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn publish_interfaces(&self, interfaces: &interfaces::Interfaces) {
|
||||
fn publish_topology(&self, topology: &topology::Topology) {
|
||||
self.shared
|
||||
.interfaces
|
||||
.send_replace(Some(Arc::new(interfaces.clone())));
|
||||
.topology
|
||||
.send_replace(Some(Arc::new(topology.clone())));
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Status {
|
||||
|
||||
@@ -23,11 +23,19 @@ pub const TABLE_MAIN: u32 = 254;
|
||||
|
||||
/// A route that forwards to a gateway or out of an interface
|
||||
/// (`RTN_UNICAST`), as opposed to one that drops, say.
|
||||
/// `RTN_UNSPEC`: in a request to remove a route, a route of any kind.
|
||||
pub const KIND_UNSPEC: u8 = 0;
|
||||
pub const KIND_UNICAST: u8 = 1;
|
||||
/// `RTN_BLACKHOLE`: a route that discards the packets for its prefix.
|
||||
pub const KIND_BLACKHOLE: u8 = 6;
|
||||
|
||||
const HEADER_LEN: usize = 16;
|
||||
/// The netlink header: length, type, flags, sequence number and the
|
||||
/// sender's port, four octets each but for the two-octet type and flags.
|
||||
/// The socket diagnostics family (`sockdiag`) frames its messages the
|
||||
/// same way.
|
||||
pub(crate) const HEADER_LEN: usize = 16;
|
||||
|
||||
mod message_type {
|
||||
pub(crate) mod message_type {
|
||||
pub const ERROR: u16 = 2;
|
||||
pub const DONE: u16 = 3;
|
||||
pub const NEW_LINK: u16 = 16;
|
||||
@@ -44,7 +52,7 @@ mod message_type {
|
||||
pub const GET_NEXTHOP: u16 = 106;
|
||||
}
|
||||
|
||||
mod flag {
|
||||
pub(crate) mod flag {
|
||||
pub const REQUEST: u16 = 0x1;
|
||||
pub const ACK: u16 = 0x4;
|
||||
/// Send the sender what everyone else is told of the change.
|
||||
@@ -62,7 +70,9 @@ mod flag {
|
||||
pub mod group {
|
||||
pub const LINK: u32 = 0x1;
|
||||
pub const IPV4_ADDRESS: u32 = 0x10;
|
||||
pub const IPV4_ROUTE: u32 = 0x40;
|
||||
pub const IPV6_ADDRESS: u32 = 0x100;
|
||||
pub const IPV6_ROUTE: u32 = 0x400;
|
||||
}
|
||||
|
||||
/// The number of the group that is told of changes to nexthop objects
|
||||
@@ -70,8 +80,8 @@ pub mod group {
|
||||
/// joined once the socket is bound.
|
||||
pub const GROUP_NEXTHOP: u32 = 32;
|
||||
|
||||
const AF_INET: u8 = 2;
|
||||
const AF_INET6: u8 = 10;
|
||||
pub(crate) const AF_INET: u8 = 2;
|
||||
pub(crate) const AF_INET6: u8 = 10;
|
||||
|
||||
/// `RT_TABLE_COMPAT`: the table's number is in an attribute, being too
|
||||
/// large for the octet the fixed part has for it.
|
||||
@@ -213,22 +223,31 @@ pub enum NetlinkError {
|
||||
Truncated { message_type: u16 },
|
||||
}
|
||||
|
||||
fn ne_u16(bytes: &[u8]) -> u16 {
|
||||
// The kernel writes its numbers in the machine's byte order. Each reader
|
||||
// takes the first octets of what it is given, so that a caller can pass
|
||||
// the rest of a buffer rather than cut the field out first.
|
||||
pub(crate) fn ne_u16(bytes: &[u8]) -> u16 {
|
||||
u16::from_ne_bytes([bytes[0], bytes[1]])
|
||||
}
|
||||
|
||||
fn ne_u32(bytes: &[u8]) -> u32 {
|
||||
pub(crate) fn ne_u32(bytes: &[u8]) -> u32 {
|
||||
u32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||
}
|
||||
|
||||
pub(crate) fn ne_u64(bytes: &[u8]) -> u64 {
|
||||
u64::from_ne_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
])
|
||||
}
|
||||
|
||||
/// Rounds a length up to the four octets everything is aligned to.
|
||||
fn aligned(length: usize) -> usize {
|
||||
pub(crate) fn aligned(length: usize) -> usize {
|
||||
(length + 3) & !3
|
||||
}
|
||||
|
||||
/// The attributes in `bytes`: each its type and value. Stops at what is
|
||||
/// not an attribute.
|
||||
fn attributes(mut bytes: &[u8]) -> impl Iterator<Item = (u16, &[u8])> {
|
||||
pub(crate) fn attributes(mut bytes: &[u8]) -> impl Iterator<Item = (u16, &[u8])> {
|
||||
std::iter::from_fn(move || {
|
||||
let header = bytes.first_chunk::<4>()?;
|
||||
let length = usize::from(ne_u16(header));
|
||||
@@ -467,7 +486,15 @@ pub enum Change {
|
||||
/// What to ask for all of.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Dump {
|
||||
/// Every route of the IP version, of every table.
|
||||
Routes(IpVersion),
|
||||
/// The routes of one table. The kernel honours the table only for a
|
||||
/// socket with strict checking on (`NETLINK_GET_STRICT_CHK`, Linux
|
||||
/// 4.20 and later); otherwise it answers as for [`Dump::Routes`].
|
||||
RoutesIn {
|
||||
version: IpVersion,
|
||||
table: u32,
|
||||
},
|
||||
Addresses,
|
||||
Links,
|
||||
Nexthops,
|
||||
@@ -619,8 +646,24 @@ pub fn nexthop_removal(out: &mut BytesMut, sequence: u32, id: u32) {
|
||||
/// Writes the request for all of `what` to the end of `out`. The answer is
|
||||
/// a message for each, and then [`Message::Done`].
|
||||
pub fn dump_request(out: &mut BytesMut, sequence: u32, what: Dump) {
|
||||
if let Dump::RoutesIn { version, table } = what {
|
||||
let start = begin(out, message_type::GET_ROUTE, flag::DUMP, sequence);
|
||||
// rtmsg: family, destination length, source length, TOS, table,
|
||||
// protocol, scope, type, and four octets of flags; the table in
|
||||
// its octet if it fits, else as an attribute.
|
||||
out.put_u8(family_of(version));
|
||||
out.put_bytes(0, 3);
|
||||
out.put_u8(u8::try_from(table).unwrap_or(TABLE_IN_ATTRIBUTE));
|
||||
out.put_bytes(0, 7);
|
||||
if u8::try_from(table).is_err() {
|
||||
put_attribute(out, route_attribute::TABLE, &table.to_ne_bytes());
|
||||
}
|
||||
finish(out, start);
|
||||
return;
|
||||
}
|
||||
let (message_type, family, fixed_length) = match what {
|
||||
Dump::Routes(version) => (message_type::GET_ROUTE, family_of(version), 12),
|
||||
Dump::RoutesIn { .. } => unreachable!("dealt with above"),
|
||||
Dump::Addresses => (message_type::GET_ADDRESS, 0, 8),
|
||||
Dump::Links => (message_type::GET_LINK, 0, 16),
|
||||
Dump::Nexthops => (message_type::GET_NEXTHOP, 0, 8),
|
||||
@@ -883,6 +926,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dump of one table names the table in its octet, or, for a number
|
||||
/// the octet cannot hold, in an attribute.
|
||||
#[test]
|
||||
fn a_dump_of_one_table_names_the_table() {
|
||||
let mut wire = BytesMut::new();
|
||||
dump_request(
|
||||
&mut wire,
|
||||
3,
|
||||
Dump::RoutesIn {
|
||||
version: IpVersion::Ipv4,
|
||||
table: TABLE_MAIN,
|
||||
},
|
||||
);
|
||||
assert_eq!(wire.len(), 28);
|
||||
assert_eq!(wire[16], 2, "AF_INET");
|
||||
assert_eq!(wire[20], TABLE_MAIN as u8);
|
||||
|
||||
let mut wire = BytesMut::new();
|
||||
dump_request(
|
||||
&mut wire,
|
||||
3,
|
||||
Dump::RoutesIn {
|
||||
version: IpVersion::Ipv6,
|
||||
table: 1000,
|
||||
},
|
||||
);
|
||||
// The fixed part, then an attribute of four octets with its
|
||||
// four-octet header.
|
||||
assert_eq!(wire.len(), 36);
|
||||
assert_eq!(wire[16], 10, "AF_INET6");
|
||||
assert_eq!(wire[20], TABLE_IN_ATTRIBUTE);
|
||||
assert_eq!(wire[28..30], 8u16.to_ne_bytes());
|
||||
assert_eq!(wire[30..32], route_attribute::TABLE.to_ne_bytes());
|
||||
assert_eq!(wire[32..36], 1000u32.to_ne_bytes());
|
||||
}
|
||||
|
||||
/// A message as the kernel would send it: header, fixed part,
|
||||
/// attributes.
|
||||
fn message(message_type: u16, sequence: u32, fixed: &[u8], attrs: &[(u16, &[u8])]) -> Vec<u8> {
|
||||
|
||||
+497
-76
@@ -9,7 +9,8 @@ use bgp_packet::message::NlriNextHop;
|
||||
use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
|
||||
use crate::interfaces::Interfaces;
|
||||
use crate::netlink::KIND_UNICAST;
|
||||
use crate::topology::Topology;
|
||||
|
||||
// Asked for every route: a quick hash matters here.
|
||||
pub(crate) type HashMap<K, V> = std::collections::HashMap<K, V, foldhash::fast::RandomState>;
|
||||
@@ -17,10 +18,16 @@ pub(crate) type HashSet<K> = std::collections::HashSet<K, foldhash::fast::Random
|
||||
|
||||
/// Where the kernel is to send packets for a prefix.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct Target {
|
||||
pub gateway: IpAddr,
|
||||
pub enum Target {
|
||||
/// To a gateway, out of an interface.
|
||||
Via {
|
||||
gateway: IpAddr,
|
||||
/// The index of the interface the gateway is on.
|
||||
pub interface: u32,
|
||||
interface: u32,
|
||||
},
|
||||
/// Nowhere: the packets are dropped. For a route that asks for that
|
||||
/// with the BLACKHOLE community (RFC 7999).
|
||||
Discard,
|
||||
}
|
||||
|
||||
/// Why a route's next hop is not something the kernel can be given.
|
||||
@@ -32,10 +39,17 @@ pub enum Unresolved {
|
||||
OtherFamily,
|
||||
/// The next hop is one of this machine's own addresses.
|
||||
Ourselves,
|
||||
/// The next hop is not in the network of any interface that is up.
|
||||
/// Finding the way to such a next hop through other routes is not
|
||||
/// done.
|
||||
NotConnected,
|
||||
/// The next hop is not in the network of any interface that is up,
|
||||
/// and none of the kernel's routes that count says the way to it
|
||||
/// (see `crate::routes`).
|
||||
NoRoute,
|
||||
/// The only route to the next hop is the default route, which is not
|
||||
/// taken as the way to a next hop unless asked for
|
||||
/// (`Topology::via_default`).
|
||||
OnlyDefault,
|
||||
/// The route to the next hop is one that discards packets: a
|
||||
/// blackhole, unreachable or prohibit route.
|
||||
Discarded,
|
||||
/// The route is for a network this machine is on itself, or for part
|
||||
/// of one. The kernel reaches that directly; a route from a peer that
|
||||
/// said otherwise would send the neighbors' traffic, and the sessions
|
||||
@@ -44,11 +58,13 @@ pub enum Unresolved {
|
||||
}
|
||||
|
||||
impl Unresolved {
|
||||
pub const ALL: [Self; 5] = [
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Missing,
|
||||
Self::OtherFamily,
|
||||
Self::Ourselves,
|
||||
Self::NotConnected,
|
||||
Self::NoRoute,
|
||||
Self::OnlyDefault,
|
||||
Self::Discarded,
|
||||
Self::OwnNetwork,
|
||||
];
|
||||
|
||||
@@ -57,7 +73,9 @@ impl Unresolved {
|
||||
Self::Missing => "no next hop",
|
||||
Self::OtherFamily => "IPv4 next hop for an IPv6 route",
|
||||
Self::Ourselves => "next hop is this machine",
|
||||
Self::NotConnected => "next hop not directly connected",
|
||||
Self::NoRoute => "no route to the next hop",
|
||||
Self::OnlyDefault => "next hop reachable only by the default route",
|
||||
Self::Discarded => "the route to the next hop discards packets",
|
||||
Self::OwnNetwork => "inside a network of this machine",
|
||||
}
|
||||
}
|
||||
@@ -93,21 +111,76 @@ impl From<IpAddr> for Neighbor {
|
||||
}
|
||||
|
||||
/// Works out where the kernel is to send packets for a route to `prefix`
|
||||
/// with the next hop `next_hop`, learned from `peer`.
|
||||
/// with the next hop `next_hop`, learned from `peer`. `blackhole` says
|
||||
/// the route asks for its packets to be dropped (the BLACKHOLE community,
|
||||
/// RFC 7999): then they go nowhere, and the next hop is not looked at.
|
||||
///
|
||||
/// Only a next hop that is a neighbor on one of our interfaces is
|
||||
/// resolved. A link-local next hop means something only on one link, and
|
||||
/// that is taken to be the link the peer itself is on.
|
||||
/// A next hop that is a neighbor on one of our interfaces is where the
|
||||
/// packets go. One that is not is looked up in the kernel's own routes,
|
||||
/// and the packets go where the route that covers it sends them: to its
|
||||
/// gateway, or onto its interface. A link-local next hop means something
|
||||
/// only on one link, and that is taken to be the link the peer itself is
|
||||
/// on.
|
||||
pub fn resolve(
|
||||
interfaces: &Interfaces,
|
||||
topology: &Topology,
|
||||
prefix: &IpPrefix,
|
||||
next_hop: Option<&NlriNextHop>,
|
||||
peer: Option<Neighbor>,
|
||||
blackhole: bool,
|
||||
) -> Result<Target, Unresolved> {
|
||||
if interfaces.has_network_around(prefix) {
|
||||
if topology.interfaces.has_network_around(prefix) {
|
||||
return Err(Unresolved::OwnNetwork);
|
||||
}
|
||||
resolve_next_hop(interfaces, prefix.address_family(), next_hop, peer)
|
||||
if blackhole {
|
||||
return Ok(Target::Discard);
|
||||
}
|
||||
resolve_next_hop(topology, prefix.address_family(), next_hop, peer)
|
||||
}
|
||||
|
||||
/// The way to `gateway`, which is not a neighbor, by the kernel's routes
|
||||
/// that count: where the route that covers it sends packets. One step
|
||||
/// only: the kernel sees to it that a route's own gateway is a neighbor.
|
||||
fn through_routes(
|
||||
topology: &Topology,
|
||||
routes: IpVersion,
|
||||
gateway: IpAddr,
|
||||
) -> Result<Target, Unresolved> {
|
||||
let route = topology.routes.lookup(gateway).ok_or(Unresolved::NoRoute)?;
|
||||
if route.prefix.length() == 0 && !topology.via_default {
|
||||
return Err(Unresolved::OnlyDefault);
|
||||
}
|
||||
if route.kind != KIND_UNICAST {
|
||||
return Err(Unresolved::Discarded);
|
||||
}
|
||||
let (hop, interface) = match route.nexthop {
|
||||
Some(id) => {
|
||||
// A group of nexthops has no gateway or interface of its own;
|
||||
// which of its members packets take is the kernel's to say,
|
||||
// and nothing to give it as a route.
|
||||
let nexthop = topology.nexthops.get(&id).ok_or(Unresolved::NoRoute)?;
|
||||
(
|
||||
nexthop.gateway,
|
||||
nexthop.interface.ok_or(Unresolved::NoRoute)?,
|
||||
)
|
||||
}
|
||||
None => (route.gateway, route.interface.ok_or(Unresolved::NoRoute)?),
|
||||
};
|
||||
if !topology
|
||||
.interfaces
|
||||
.link(interface)
|
||||
.is_some_and(|link| link.up)
|
||||
{
|
||||
return Err(Unresolved::NoRoute);
|
||||
}
|
||||
// A route without a gateway puts the destination on its interface:
|
||||
// the next hop itself is the neighbor there.
|
||||
let gateway = hop.unwrap_or(gateway);
|
||||
// An IPv4 route may go by an IPv6 gateway (RFC 8950, and the kernel
|
||||
// allows it); an IPv6 route by an IPv4 gateway is nothing.
|
||||
if routes == IpVersion::Ipv6 && gateway.is_ipv4() {
|
||||
return Err(Unresolved::NoRoute);
|
||||
}
|
||||
Ok(Target::Via { gateway, interface })
|
||||
}
|
||||
|
||||
/// The part of [`resolve`] that is the same for every route of IP version
|
||||
@@ -115,11 +188,12 @@ pub fn resolve(
|
||||
/// those who ask it of many such routes, and ask of each only whether it
|
||||
/// is for one of the machine's own networks.
|
||||
pub fn resolve_next_hop(
|
||||
interfaces: &Interfaces,
|
||||
topology: &Topology,
|
||||
routes: IpVersion,
|
||||
next_hop: Option<&NlriNextHop>,
|
||||
peer: Option<Neighbor>,
|
||||
) -> Result<Target, Unresolved> {
|
||||
let interfaces = &topology.interfaces;
|
||||
let next_hop = next_hop.ok_or(Unresolved::Missing)?;
|
||||
let version = match next_hop {
|
||||
NlriNextHop::Ipv4(_) => IpVersion::Ipv4,
|
||||
@@ -135,17 +209,17 @@ pub fn resolve_next_hop(
|
||||
if interfaces.is_local(gateway) {
|
||||
return Err(Unresolved::Ourselves);
|
||||
}
|
||||
interfaces
|
||||
.connected(gateway)
|
||||
.map(|connected| Target {
|
||||
match interfaces.connected(gateway) {
|
||||
Some(connected) => Ok(Target::Via {
|
||||
gateway,
|
||||
interface: connected.interface,
|
||||
})
|
||||
.ok_or(Unresolved::NotConnected)
|
||||
}),
|
||||
None => through_routes(topology, routes, gateway),
|
||||
}
|
||||
};
|
||||
// The interface the peer is a neighbor on, for a link-local gateway.
|
||||
let on_peers_link = |gateway: Ipv6Addr| {
|
||||
let interface = match peer.ok_or(Unresolved::NotConnected)? {
|
||||
let interface = match peer.ok_or(Unresolved::NoRoute)? {
|
||||
// The session is on a link-local address itself, and so says
|
||||
// which link: every interface has fe80::/64, and looking the
|
||||
// peer up there would find any of them.
|
||||
@@ -157,14 +231,14 @@ pub fn resolve_next_hop(
|
||||
.connected(address)
|
||||
.map(|connected| connected.interface),
|
||||
}
|
||||
.ok_or(Unresolved::NotConnected)?;
|
||||
.ok_or(Unresolved::NoRoute)?;
|
||||
if interfaces
|
||||
.addresses_of(interface)
|
||||
.any(|ours| ours.local == IpAddr::V6(gateway))
|
||||
{
|
||||
return Err(Unresolved::Ourselves);
|
||||
}
|
||||
Ok(Target {
|
||||
Ok(Target::Via {
|
||||
gateway: gateway.into(),
|
||||
interface,
|
||||
})
|
||||
@@ -182,8 +256,11 @@ pub fn resolve_next_hop(
|
||||
{
|
||||
on_peers_link(link_local)
|
||||
}
|
||||
// Where there is no way to the global one, the link-local one is
|
||||
// tried; a way that is there and is bad (ourselves, a blackhole)
|
||||
// is an answer as it stands.
|
||||
NlriNextHop::Ipv6WithLl { global, link_local } => match direct(global.into()) {
|
||||
Err(Unresolved::NotConnected) => on_peers_link(link_local),
|
||||
Err(Unresolved::NoRoute | Unresolved::OnlyDefault) => on_peers_link(link_local),
|
||||
resolved => resolved,
|
||||
},
|
||||
}
|
||||
@@ -240,6 +317,14 @@ pub struct Plan {
|
||||
unresolved_counts: [usize; Unresolved::ALL.len()],
|
||||
/// Prefixes whose route the kernel refused.
|
||||
refused: HashSet<IpPrefix>,
|
||||
/// How many entries of `installed` are blackhole routes, and how many
|
||||
/// are kept from an earlier run without having been confirmed. Kept in
|
||||
/// step with the map by [`Plan::put`] and [`Plan::take`], so that
|
||||
/// asking costs nothing however large the table: the kernel task asks
|
||||
/// after every batch of changes, and a scan of a million entries each
|
||||
/// time was a third of its work.
|
||||
blackholes: usize,
|
||||
kept_unconfirmed: usize,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
@@ -248,6 +333,42 @@ impl Plan {
|
||||
self.installed.len()
|
||||
}
|
||||
|
||||
/// How many of the routes the kernel has drop their packets.
|
||||
pub fn blackholes(&self) -> usize {
|
||||
self.blackholes
|
||||
}
|
||||
|
||||
/// What `entry` counts for: one if it is a blackhole route, and one if
|
||||
/// it is kept from an earlier run without having been confirmed.
|
||||
fn weigh(entry: &Entry) -> (usize, usize) {
|
||||
(
|
||||
usize::from(entry.target == Some(Target::Discard)),
|
||||
usize::from(entry.kept && !entry.confirmed),
|
||||
)
|
||||
}
|
||||
|
||||
/// Puts `entry` in as the route of `prefix`, in place of what was
|
||||
/// there, keeping the counts in step.
|
||||
fn put(&mut self, prefix: IpPrefix, entry: Entry) {
|
||||
let (blackhole, kept) = Self::weigh(&entry);
|
||||
self.blackholes += blackhole;
|
||||
self.kept_unconfirmed += kept;
|
||||
if let Some(old) = self.installed.insert(prefix, entry) {
|
||||
let (blackhole, kept) = Self::weigh(&old);
|
||||
self.blackholes -= blackhole;
|
||||
self.kept_unconfirmed -= kept;
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the route of `prefix` out, keeping the counts in step.
|
||||
fn take(&mut self, prefix: &IpPrefix) -> Option<Entry> {
|
||||
let old = self.installed.remove(prefix)?;
|
||||
let (blackhole, kept) = Self::weigh(&old);
|
||||
self.blackholes -= blackhole;
|
||||
self.kept_unconfirmed -= kept;
|
||||
Some(old)
|
||||
}
|
||||
|
||||
/// How many prefixes have a best route that could not be given to the
|
||||
/// kernel for want of a usable next hop.
|
||||
pub fn unresolved(&self) -> usize {
|
||||
@@ -298,7 +419,7 @@ impl Plan {
|
||||
/// have sent theirs again, and to remove everything meanwhile would
|
||||
/// stop the machine forwarding for as long as that takes.
|
||||
pub fn adopt(&mut self, prefix: IpPrefix, target: Option<Target>, keep: bool) {
|
||||
self.installed.insert(
|
||||
self.put(
|
||||
prefix,
|
||||
Entry {
|
||||
target,
|
||||
@@ -311,32 +432,36 @@ impl Plan {
|
||||
/// How many routes are being kept from an earlier run without the
|
||||
/// routing table having confirmed them.
|
||||
pub fn kept(&self) -> usize {
|
||||
self.installed
|
||||
.values()
|
||||
.filter(|entry| entry.kept && !entry.confirmed)
|
||||
.count()
|
||||
self.kept_unconfirmed
|
||||
}
|
||||
|
||||
/// The time for keeping unconfirmed routes of an earlier run is over:
|
||||
/// those the routing table still does not have go.
|
||||
pub fn release(&mut self) -> Vec<Op> {
|
||||
let mut stale = Vec::new();
|
||||
let mut blackholes_gone = 0;
|
||||
self.installed.retain(|prefix, entry| {
|
||||
let goes = entry.kept && !entry.confirmed;
|
||||
entry.kept = false;
|
||||
if goes {
|
||||
stale.push(Op::Remove { prefix: *prefix });
|
||||
blackholes_gone += usize::from(entry.target == Some(Target::Discard));
|
||||
}
|
||||
!goes
|
||||
});
|
||||
// What stays was confirmed, and is now not kept either.
|
||||
self.kept_unconfirmed = 0;
|
||||
self.blackholes -= blackholes_gone;
|
||||
stale
|
||||
}
|
||||
|
||||
/// The whole table follows: nothing is known to be wanted until it is
|
||||
/// heard of again.
|
||||
pub fn reset(&mut self) {
|
||||
self.kept_unconfirmed = 0;
|
||||
for entry in self.installed.values_mut() {
|
||||
entry.confirmed = false;
|
||||
self.kept_unconfirmed += usize::from(entry.kept);
|
||||
}
|
||||
self.forget_unresolved_and_refused();
|
||||
}
|
||||
@@ -357,20 +482,26 @@ impl Plan {
|
||||
confirmed: true,
|
||||
kept: false,
|
||||
});
|
||||
let before = Self::weigh(entry);
|
||||
entry.confirmed = true;
|
||||
if entry.target == Some(target) {
|
||||
return None;
|
||||
}
|
||||
let op = if entry.target == Some(target) {
|
||||
None
|
||||
} else {
|
||||
entry.target = Some(target);
|
||||
Some(Op::Install { prefix, target })
|
||||
};
|
||||
let after = Self::weigh(entry);
|
||||
self.blackholes += after.0;
|
||||
self.blackholes -= before.0;
|
||||
self.kept_unconfirmed += after.1;
|
||||
self.kept_unconfirmed -= before.1;
|
||||
op
|
||||
}
|
||||
Err(reason) => {
|
||||
self.set_unresolved(prefix, Some(reason));
|
||||
// Packets must not go on following a route that is no
|
||||
// longer the best.
|
||||
self.installed
|
||||
.remove(&prefix)
|
||||
.map(|_| Op::Remove { prefix })
|
||||
self.take(&prefix).map(|_| Op::Remove { prefix })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,9 +510,7 @@ impl Plan {
|
||||
pub fn withdrawn(&mut self, prefix: IpPrefix) -> Option<Op> {
|
||||
self.set_unresolved(prefix, None);
|
||||
self.refused.remove(&prefix);
|
||||
self.installed
|
||||
.remove(&prefix)
|
||||
.map(|_| Op::Remove { prefix })
|
||||
self.take(&prefix).map(|_| Op::Remove { prefix })
|
||||
}
|
||||
|
||||
/// The whole table has been given: what was not in it goes.
|
||||
@@ -395,7 +524,7 @@ impl Plan {
|
||||
stale
|
||||
.into_iter()
|
||||
.map(|prefix| {
|
||||
self.installed.remove(&prefix);
|
||||
self.take(&prefix);
|
||||
Op::Remove { prefix }
|
||||
})
|
||||
.collect()
|
||||
@@ -410,7 +539,7 @@ impl Plan {
|
||||
if self.installed.get(prefix)?.target != Some(*target) {
|
||||
return None;
|
||||
}
|
||||
self.installed.remove(prefix);
|
||||
self.take(prefix);
|
||||
self.refused.insert(*prefix);
|
||||
// What the kernel refused to replace, it still has.
|
||||
Some(Op::Remove { prefix: *prefix })
|
||||
@@ -423,6 +552,8 @@ impl Plan {
|
||||
/// Everything goes: the changes that empty the kernel of our routes.
|
||||
pub fn clear(&mut self) -> Vec<Op> {
|
||||
self.forget_unresolved_and_refused();
|
||||
self.blackholes = 0;
|
||||
self.kept_unconfirmed = 0;
|
||||
self.installed
|
||||
.drain()
|
||||
.map(|(prefix, _)| Op::Remove { prefix })
|
||||
@@ -435,27 +566,27 @@ mod tests {
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::*;
|
||||
use crate::netlink::{Address, Link, Message};
|
||||
use crate::netlink::{Address, Link, Message, Nexthop, PROTOCOL_BGP, Route, TABLE_MAIN};
|
||||
|
||||
fn prefix(s: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(s).unwrap()
|
||||
}
|
||||
|
||||
fn target(gateway: &str, interface: u32) -> Target {
|
||||
Target {
|
||||
Target::Via {
|
||||
gateway: gateway.parse().unwrap(),
|
||||
interface,
|
||||
}
|
||||
}
|
||||
|
||||
fn interfaces() -> Interfaces {
|
||||
let mut interfaces = Interfaces::default();
|
||||
fn interfaces() -> Topology {
|
||||
let mut topology = Topology::new(TABLE_MAIN, false);
|
||||
for (index, local, network) in [
|
||||
(2, "192.0.2.1", "192.0.2.0/24"),
|
||||
(2, "2001:db8::1", "2001:db8::/64"),
|
||||
(3, "198.51.100.1", "198.51.100.0/24"),
|
||||
] {
|
||||
interfaces.apply(&Message::Link {
|
||||
topology.apply(&Message::Link {
|
||||
present: true,
|
||||
link: Link {
|
||||
index,
|
||||
@@ -463,7 +594,7 @@ mod tests {
|
||||
up: true,
|
||||
},
|
||||
});
|
||||
interfaces.apply(&Message::Address {
|
||||
topology.apply(&Message::Address {
|
||||
present: true,
|
||||
address: Address {
|
||||
interface: index,
|
||||
@@ -472,7 +603,28 @@ mod tests {
|
||||
},
|
||||
});
|
||||
}
|
||||
interfaces
|
||||
topology
|
||||
}
|
||||
|
||||
/// A route of the kernel's main table, as a static route is.
|
||||
fn kernel_route(prefix_text: &str, gateway: Option<&str>, interface: Option<u32>) -> Route {
|
||||
Route {
|
||||
prefix: prefix(prefix_text),
|
||||
table: TABLE_MAIN,
|
||||
protocol: 4,
|
||||
metric: 0,
|
||||
gateway: gateway.map(|g| g.parse().unwrap()),
|
||||
interface,
|
||||
nexthop: None,
|
||||
kind: KIND_UNICAST,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_route(topology: &mut Topology, route: Route) {
|
||||
assert!(topology.apply(&Message::Route {
|
||||
present: true,
|
||||
route,
|
||||
}));
|
||||
}
|
||||
|
||||
fn neighbor(address: &str) -> Neighbor {
|
||||
@@ -489,27 +641,30 @@ mod tests {
|
||||
let v6_hop = |s: &str| NlriNextHop::Ipv6(s.parse().unwrap());
|
||||
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v4, Some(&v4_hop("192.0.2.2")), peer),
|
||||
resolve(&interfaces, &v4, Some(&v4_hop("192.0.2.2")), peer, false),
|
||||
Ok(target("192.0.2.2", 2))
|
||||
);
|
||||
// A neighbor other than the peer: a route server's routes.
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v4, Some(&v4_hop("198.51.100.9")), peer),
|
||||
resolve(&interfaces, &v4, Some(&v4_hop("198.51.100.9")), peer, false),
|
||||
Ok(target("198.51.100.9", 3))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&v6_hop("2001:db8::2")), peer),
|
||||
resolve(&interfaces, &v6, Some(&v6_hop("2001:db8::2")), peer, false),
|
||||
Ok(target("2001:db8::2", 2))
|
||||
);
|
||||
for (hop, why) in [
|
||||
(Some(v4_hop("10.9.9.9")), Unresolved::NotConnected),
|
||||
(Some(v4_hop("10.9.9.9")), Unresolved::NoRoute),
|
||||
(Some(v4_hop("192.0.2.1")), Unresolved::Ourselves),
|
||||
(None, Unresolved::Missing),
|
||||
] {
|
||||
assert_eq!(resolve(&interfaces, &v4, hop.as_ref(), peer), Err(why));
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v4, hop.as_ref(), peer, false),
|
||||
Err(why)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&v4_hop("192.0.2.2")), peer),
|
||||
resolve(&interfaces, &v6, Some(&v4_hop("192.0.2.2")), peer, false),
|
||||
Err(Unresolved::OtherFamily)
|
||||
);
|
||||
// A route for part of a network we are on is not for the kernel,
|
||||
@@ -526,7 +681,7 @@ mod tests {
|
||||
IpVersion::Ipv6 => v6_hop("2001:db8::2"),
|
||||
};
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &ours, Some(&hop), peer),
|
||||
resolve(&interfaces, &ours, Some(&hop), peer, false),
|
||||
Err(Unresolved::OwnNetwork),
|
||||
"{ours}"
|
||||
);
|
||||
@@ -536,14 +691,15 @@ mod tests {
|
||||
&interfaces,
|
||||
&prefix("192.0.0.0/22"),
|
||||
Some(&v4_hop("192.0.2.2")),
|
||||
peer
|
||||
peer,
|
||||
false
|
||||
),
|
||||
Ok(target("192.0.2.2", 2))
|
||||
);
|
||||
// An IPv4 route by way of an IPv6 neighbor: a link with only IPv6
|
||||
// addresses (RFC 8950).
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v4, Some(&v6_hop("2001:db8::2")), peer),
|
||||
resolve(&interfaces, &v4, Some(&v6_hop("2001:db8::2")), peer, false),
|
||||
Ok(target("2001:db8::2", 2))
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -555,6 +711,7 @@ mod tests {
|
||||
link_local: "fe80::2".parse().unwrap(),
|
||||
}),
|
||||
Some(neighbor("2001:db8::2")),
|
||||
false
|
||||
),
|
||||
Ok(target("fe80::2", 2))
|
||||
);
|
||||
@@ -572,26 +729,44 @@ mod tests {
|
||||
};
|
||||
// The global address is a neighbor: it is used.
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&both("2001:db8::2")), peer),
|
||||
resolve(&interfaces, &v6, Some(&both("2001:db8::2")), peer, false),
|
||||
Ok(target("2001:db8::2", 2))
|
||||
);
|
||||
// It is not: the link-local one, on the interface the peer is on.
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&both("2001:db8:ffff::2")), peer),
|
||||
resolve(
|
||||
&interfaces,
|
||||
&v6,
|
||||
Some(&both("2001:db8:ffff::2")),
|
||||
peer,
|
||||
false
|
||||
),
|
||||
Ok(target("fe80::2", 2))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&NlriNextHop::Ipv6(link_local)), peer),
|
||||
resolve(
|
||||
&interfaces,
|
||||
&v6,
|
||||
Some(&NlriNextHop::Ipv6(link_local)),
|
||||
peer,
|
||||
false
|
||||
),
|
||||
Ok(target("fe80::2", 2))
|
||||
);
|
||||
// A peer that is not a neighbor says nothing about the link.
|
||||
let far = Some(neighbor("2001:db8:ffff::9"));
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&NlriNextHop::Ipv6(link_local)), far),
|
||||
Err(Unresolved::NotConnected)
|
||||
resolve(
|
||||
&interfaces,
|
||||
&v6,
|
||||
Some(&NlriNextHop::Ipv6(link_local)),
|
||||
far,
|
||||
false
|
||||
),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&both("2001:db8::1")), peer),
|
||||
resolve(&interfaces, &v6, Some(&both("2001:db8::1")), peer, false),
|
||||
Err(Unresolved::Ourselves)
|
||||
);
|
||||
}
|
||||
@@ -636,7 +811,7 @@ mod tests {
|
||||
for interface in [2, 3] {
|
||||
for route in [&v4, &v6] {
|
||||
assert_eq!(
|
||||
resolve(&interfaces, route, Some(&next_hop), on(interface)),
|
||||
resolve(&interfaces, route, Some(&next_hop), on(interface), false),
|
||||
Ok(target("fe80::2", interface)),
|
||||
"{next_hop:?} on {interface}"
|
||||
);
|
||||
@@ -644,28 +819,220 @@ mod tests {
|
||||
}
|
||||
// An interface that is not there, or is down.
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&next_hop), on(9)),
|
||||
Err(Unresolved::NotConnected)
|
||||
resolve(&interfaces, &v6, Some(&next_hop), on(9), false),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
}
|
||||
// Our own address on that link, which on another is a neighbor's.
|
||||
let ours = NlriNextHop::Ipv6("fe80::a".parse().unwrap());
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&ours), on(2)),
|
||||
resolve(&interfaces, &v6, Some(&ours), on(2), false),
|
||||
Err(Unresolved::Ourselves)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&ours), on(3)),
|
||||
resolve(&interfaces, &v6, Some(&ours), on(3), false),
|
||||
Ok(target("fe80::a", 3))
|
||||
);
|
||||
// A global next hop on such a session is looked up as any other.
|
||||
let global = NlriNextHop::Ipv6("2001:db8::2".parse().unwrap());
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&global), on(3)),
|
||||
resolve(&interfaces, &v6, Some(&global), on(3), false),
|
||||
Ok(target("2001:db8::2", 2))
|
||||
);
|
||||
}
|
||||
|
||||
/// A next hop that is no neighbor is reached the way the kernel's
|
||||
/// own routes say, and the route goes into the kernel with that way's
|
||||
/// gateway and interface.
|
||||
#[test]
|
||||
fn a_far_next_hop_resolves_by_the_kernels_routes() {
|
||||
let mut topology = interfaces();
|
||||
let v4 = prefix("203.0.113.0/24");
|
||||
let v6 = prefix("2001:db8:100::/48");
|
||||
let peer = Some(neighbor("192.0.2.2"));
|
||||
let v4_hop = |s: &str| NlriNextHop::Ipv4(s.parse().unwrap());
|
||||
let v6_hop = |s: &str| NlriNextHop::Ipv6(s.parse().unwrap());
|
||||
let far = v4_hop("10.9.9.9");
|
||||
|
||||
// Nothing says the way.
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
// The default route does not count unless asked for.
|
||||
with_route(
|
||||
&mut topology,
|
||||
kernel_route("0.0.0.0/0", Some("192.0.2.254"), Some(2)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Err(Unresolved::OnlyDefault)
|
||||
);
|
||||
topology.via_default = true;
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Ok(target("192.0.2.254", 2))
|
||||
);
|
||||
topology.via_default = false;
|
||||
// A static route with a gateway: the gateway is where packets go.
|
||||
with_route(
|
||||
&mut topology,
|
||||
kernel_route("10.9.0.0/16", Some("198.51.100.9"), Some(3)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Ok(target("198.51.100.9", 3))
|
||||
);
|
||||
// A more specific route onto an interface, as a tunnel's is: the
|
||||
// next hop itself is the neighbor there.
|
||||
topology.apply(&Message::Link {
|
||||
present: true,
|
||||
link: Link {
|
||||
index: 7,
|
||||
name: "wg0".into(),
|
||||
up: true,
|
||||
},
|
||||
});
|
||||
with_route(&mut topology, kernel_route("10.9.9.0/24", None, Some(7)));
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Ok(target("10.9.9.9", 7))
|
||||
);
|
||||
// The tunnel goes down: the kernel keeps such routes, we do not
|
||||
// use them.
|
||||
topology.apply(&Message::Link {
|
||||
present: true,
|
||||
link: Link {
|
||||
index: 7,
|
||||
name: "wg0".into(),
|
||||
up: false,
|
||||
},
|
||||
});
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
// A route that names a nexthop object goes where the object says.
|
||||
with_route(
|
||||
&mut topology,
|
||||
Route {
|
||||
nexthop: Some(77),
|
||||
..kernel_route("10.9.9.9/32", None, None)
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Err(Unresolved::NoRoute),
|
||||
"an object the kernel has not told of"
|
||||
);
|
||||
topology.apply(&Message::Nexthop {
|
||||
present: true,
|
||||
nexthop: Nexthop {
|
||||
id: 77,
|
||||
protocol: 4,
|
||||
gateway: Some("192.0.2.77".parse().unwrap()),
|
||||
interface: Some(2),
|
||||
},
|
||||
});
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Ok(target("192.0.2.77", 2))
|
||||
);
|
||||
// A route that discards packets is a way to nowhere.
|
||||
with_route(
|
||||
&mut topology,
|
||||
Route {
|
||||
kind: 6, // blackhole
|
||||
..kernel_route("10.9.9.9/32", None, None)
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Err(Unresolved::Discarded)
|
||||
);
|
||||
// Routes of BGP's never say the way, be the prefix ever so long.
|
||||
assert!(!topology.apply(&Message::Route {
|
||||
present: true,
|
||||
route: Route {
|
||||
protocol: PROTOCOL_BGP,
|
||||
metric: 20,
|
||||
..kernel_route("10.9.9.9/32", Some("198.51.100.9"), Some(3))
|
||||
},
|
||||
}));
|
||||
// An IPv6 next hop by an IPv4 gateway is no way for an IPv6 route,
|
||||
// but is one for an IPv4 route (RFC 8950 the other way about is
|
||||
// what the kernel allows).
|
||||
with_route(
|
||||
&mut topology,
|
||||
kernel_route("2001:db8:9::/48", Some("198.51.100.9"), Some(3)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v6, Some(&v6_hop("2001:db8:9::9")), peer, false),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
with_route(
|
||||
&mut topology,
|
||||
kernel_route("2001:db8:9::/48", Some("2001:db8::9"), Some(2)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v6, Some(&v6_hop("2001:db8:9::9")), peer, false),
|
||||
Ok(target("2001:db8::9", 2))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&v6_hop("2001:db8:9::9")), peer, false),
|
||||
Ok(target("2001:db8::9", 2))
|
||||
);
|
||||
// A neighbor is a neighbor, whatever the routes say.
|
||||
with_route(
|
||||
&mut topology,
|
||||
kernel_route("192.0.2.2/32", Some("198.51.100.9"), Some(3)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&v4_hop("192.0.2.2")), peer, false),
|
||||
Ok(target("192.0.2.2", 2))
|
||||
);
|
||||
}
|
||||
|
||||
/// A route that asks for its packets to be dropped goes nowhere,
|
||||
/// whatever its next hop; one for a network of ours does not.
|
||||
#[test]
|
||||
fn a_blackhole_route_resolves_to_nowhere() {
|
||||
let topology = interfaces();
|
||||
let far = NlriNextHop::Ipv4("10.9.9.9".parse().unwrap());
|
||||
let peer = Some(neighbor("192.0.2.2"));
|
||||
assert_eq!(
|
||||
resolve(
|
||||
&topology,
|
||||
&prefix("203.0.113.9/32"),
|
||||
Some(&far),
|
||||
peer,
|
||||
false
|
||||
),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &prefix("203.0.113.9/32"), Some(&far), peer, true),
|
||||
Ok(Target::Discard)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &prefix("203.0.113.9/32"), None, peer, true),
|
||||
Ok(Target::Discard)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &prefix("192.0.2.9/32"), Some(&far), peer, true),
|
||||
Err(Unresolved::OwnNetwork)
|
||||
);
|
||||
let mut plan = Plan::default();
|
||||
assert_eq!(
|
||||
plan.best(prefix("203.0.113.9/32"), Ok(Target::Discard)),
|
||||
Some(Op::Install {
|
||||
prefix: prefix("203.0.113.9/32"),
|
||||
target: Target::Discard
|
||||
})
|
||||
);
|
||||
assert_eq!((plan.installed(), plan.blackholes()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kernel_is_asked_only_for_what_changes() {
|
||||
let mut plan = Plan::default();
|
||||
@@ -692,13 +1059,13 @@ mod tests {
|
||||
assert_eq!(plan.installed(), 1);
|
||||
// A best route that cannot be installed takes the old one out.
|
||||
assert_eq!(
|
||||
plan.best(p, Err(Unresolved::NotConnected)),
|
||||
plan.best(p, Err(Unresolved::NoRoute)),
|
||||
Some(Op::Remove { prefix: p })
|
||||
);
|
||||
assert_eq!((plan.installed(), plan.unresolved()), (0, 1));
|
||||
assert_eq!(
|
||||
plan.unresolved_by_reason().collect::<Vec<_>>(),
|
||||
[(Unresolved::NotConnected, 1)]
|
||||
[(Unresolved::NoRoute, 1)]
|
||||
);
|
||||
assert_eq!(plan.withdrawn(p), None);
|
||||
assert_eq!(plan.unresolved_by_reason().count(), 0);
|
||||
@@ -842,6 +1209,60 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// What the counts would be if the entries were scanned.
|
||||
fn scanned(plan: &Plan) -> (usize, usize) {
|
||||
plan.installed
|
||||
.values()
|
||||
.map(Plan::weigh)
|
||||
.fold((0, 0), |(b, k), (eb, ek)| (b + eb, k + ek))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_counts_keep_step_with_the_routes() {
|
||||
let mut plan = Plan::default();
|
||||
let p = |n: u8| prefix(&format!("203.0.113.{n}/32"));
|
||||
let via = target("192.0.2.2", 2);
|
||||
let check = |plan: &Plan| assert_eq!((plan.blackholes(), plan.kept()), scanned(plan));
|
||||
|
||||
// Adopted from an earlier run: two kept, one a blackhole.
|
||||
plan.adopt(p(1), Some(Target::Discard), true);
|
||||
plan.adopt(p(2), Some(via), true);
|
||||
plan.adopt(p(3), Some(via), false);
|
||||
check(&plan);
|
||||
// Heard of again, as a blackhole and as a route by way of a
|
||||
// gateway; one changes kind.
|
||||
plan.best(p(1), Ok(Target::Discard));
|
||||
plan.best(p(2), Ok(Target::Discard));
|
||||
plan.best(p(4), Ok(Target::Discard));
|
||||
check(&plan);
|
||||
plan.best(p(4), Ok(via));
|
||||
check(&plan);
|
||||
// The whole table again: everything unconfirmed, then confirmed.
|
||||
plan.reset();
|
||||
check(&plan);
|
||||
plan.best(p(1), Ok(Target::Discard));
|
||||
plan.synced();
|
||||
check(&plan);
|
||||
plan.reset();
|
||||
plan.release();
|
||||
check(&plan);
|
||||
// Gone for want of a next hop, withdrawn, refused, adopted over.
|
||||
plan.best(p(1), Err(Unresolved::Missing));
|
||||
plan.best(p(5), Ok(Target::Discard));
|
||||
plan.withdrawn(p(5));
|
||||
plan.best(p(6), Ok(Target::Discard));
|
||||
plan.refused_by_kernel(&Op::Install {
|
||||
prefix: p(6),
|
||||
target: Target::Discard,
|
||||
});
|
||||
plan.adopt(p(7), Some(Target::Discard), true);
|
||||
plan.adopt(p(7), Some(via), true);
|
||||
check(&plan);
|
||||
let had = plan.installed();
|
||||
assert_eq!(plan.clear().len(), had);
|
||||
assert_eq!((plan.blackholes(), plan.kept()), (0, 0));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Whatever happens to the best routes, and whatever an earlier
|
||||
/// run left behind, a kernel that does as it is asked ends up with
|
||||
@@ -849,7 +1270,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_kernel_ends_up_with_the_best_routes_that_resolve(
|
||||
left_behind in proptest::collection::vec((0u8..16, 0u8..3), 0..6),
|
||||
steps in proptest::collection::vec(any_step(), 0..60),
|
||||
steps in proptest::collection::vec(any_step(), 0..60)
|
||||
) {
|
||||
let p = |n: u8| IpPrefix::new(IpVersion::Ipv4, [10, n, 0, 0], 16).unwrap();
|
||||
let t = |n: u8| target(&format!("192.0.2.{}", n + 2), 2);
|
||||
@@ -866,7 +1287,7 @@ mod tests {
|
||||
Some(Op::Remove { prefix }) => { kernel.remove(&prefix); }
|
||||
None => {}
|
||||
};
|
||||
let resolved = |gateway: Option<u8>| gateway.map(t).ok_or(Unresolved::NotConnected);
|
||||
let resolved = |gateway: Option<u8>| gateway.map(t).ok_or(Unresolved::NoRoute);
|
||||
// A new reader is first given the whole table.
|
||||
let steps = std::iter::once(Step::Resync).chain(steps);
|
||||
for step in steps {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! The kernel's own routes, as far as next hops are concerned: the way to
|
||||
//! a next hop that is not a neighbor on any of our interfaces.
|
||||
//!
|
||||
//! Only the routes of one table count, the resolution table, which is
|
||||
//! normally `main`: the connected and static routes, and an IGP's if there
|
||||
//! is one. Routes put there by BGP, ours or another daemon's, never count:
|
||||
//! a next hop that is reached only by way of a BGP route is reached by way
|
||||
//! of something that may itself depend on a next hop, and a table that
|
||||
//! leans on itself like that is one that falls over in a way nobody can
|
||||
//! predict. The default route counts only if asked for: on a router, the
|
||||
//! default points at a transit provider, and a next hop that is "reached"
|
||||
//! only by sending the packets to the Internet at large is not reached.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
|
||||
use crate::netlink::{Message, PROTOCOL_BGP, Route};
|
||||
use crate::plan::HashMap;
|
||||
|
||||
/// The routes of the resolution table that may say the way to a next hop.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KernelRoutes {
|
||||
table: u32,
|
||||
/// By prefix, the routes there are for it: usually one, but the kernel
|
||||
/// keeps one for each metric.
|
||||
routes: HashMap<IpPrefix, Vec<Route>>,
|
||||
/// How many prefixes there are of each length, by IP version: the
|
||||
/// lengths a lookup has to try.
|
||||
lengths: [[u32; 129]; 2],
|
||||
}
|
||||
|
||||
/// `address` as the prefix that is only it.
|
||||
pub(crate) fn host(address: IpAddr) -> IpPrefix {
|
||||
match address {
|
||||
IpAddr::V4(v4) => IpPrefix::new(IpVersion::Ipv4, v4.octets(), 32),
|
||||
IpAddr::V6(v6) => IpPrefix::new(IpVersion::Ipv6, v6.octets(), 128),
|
||||
}
|
||||
.expect("an address is a prefix of its own length")
|
||||
}
|
||||
|
||||
fn version_index(version: IpVersion) -> usize {
|
||||
match version {
|
||||
IpVersion::Ipv4 => 0,
|
||||
IpVersion::Ipv6 => 1,
|
||||
}
|
||||
}
|
||||
|
||||
impl KernelRoutes {
|
||||
pub fn new(table: u32) -> Self {
|
||||
Self {
|
||||
table,
|
||||
routes: HashMap::default(),
|
||||
lengths: [[0; 129]; 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// The table the routes are of.
|
||||
pub fn table(&self) -> u32 {
|
||||
self.table
|
||||
}
|
||||
|
||||
/// Whether `route` is one that may say the way to a next hop.
|
||||
fn counts(&self, route: &Route) -> bool {
|
||||
route.table == self.table && route.protocol != PROTOCOL_BGP
|
||||
}
|
||||
|
||||
/// Takes in what a message from the kernel says about routes. Returns
|
||||
/// whether anything is different for it.
|
||||
pub fn apply(&mut self, message: &Message) -> bool {
|
||||
let Message::Route { present, route } = message else {
|
||||
return false;
|
||||
};
|
||||
if !self.counts(route) {
|
||||
return false;
|
||||
}
|
||||
let version = version_index(route.prefix.address_family());
|
||||
let length = usize::from(route.prefix.length());
|
||||
if *present {
|
||||
let routes = self.routes.entry(route.prefix).or_default();
|
||||
// A route for a prefix with a metric there is one for already
|
||||
// takes that one's place: the kernel keeps one per metric.
|
||||
match routes.iter_mut().find(|known| known.metric == route.metric) {
|
||||
Some(known) if known == route => false,
|
||||
Some(known) => {
|
||||
*known = *route;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
if routes.is_empty() {
|
||||
self.lengths[version][length] += 1;
|
||||
}
|
||||
// In order of metric, so that two tables with the
|
||||
// same routes compare equal however they were told.
|
||||
let at = routes.partition_point(|known| known.metric < route.metric);
|
||||
routes.insert(at, *route);
|
||||
true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Some(routes) = self.routes.get_mut(&route.prefix) else {
|
||||
return false;
|
||||
};
|
||||
let before = routes.len();
|
||||
routes.retain(|known| known.metric != route.metric);
|
||||
let (after, none_left) = (routes.len(), routes.is_empty());
|
||||
if none_left {
|
||||
self.routes.remove(&route.prefix);
|
||||
self.lengths[version][length] -= 1;
|
||||
}
|
||||
before != after
|
||||
}
|
||||
}
|
||||
|
||||
/// How many prefixes have a route.
|
||||
pub fn len(&self) -> usize {
|
||||
self.routes.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.routes.is_empty()
|
||||
}
|
||||
|
||||
/// The route the kernel would send packets for `address` by, from
|
||||
/// this table: the one for the longest prefix that covers it, and of
|
||||
/// several for that prefix the one with the lowest metric. The
|
||||
/// default route too, if there is one and nothing longer covers the
|
||||
/// address.
|
||||
pub fn lookup(&self, address: IpAddr) -> Option<&Route> {
|
||||
let whole = host(address);
|
||||
let lengths = &self.lengths[version_index(whole.address_family())];
|
||||
(0..=whole.length())
|
||||
.rev()
|
||||
.filter(|length| lengths[usize::from(*length)] > 0)
|
||||
.find_map(|length| self.routes.get(&whole.shortened(length)))
|
||||
.and_then(|routes| routes.iter().min_by_key(|route| route.metric))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::netlink::{KIND_UNICAST, TABLE_MAIN};
|
||||
|
||||
fn prefix(s: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(s).unwrap()
|
||||
}
|
||||
|
||||
fn route(prefix_text: &str, gateway: Option<&str>, interface: u32, metric: u32) -> Route {
|
||||
Route {
|
||||
prefix: prefix(prefix_text),
|
||||
table: TABLE_MAIN,
|
||||
protocol: 4, // static
|
||||
metric,
|
||||
gateway: gateway.map(|g| g.parse().unwrap()),
|
||||
interface: Some(interface),
|
||||
nexthop: None,
|
||||
kind: KIND_UNICAST,
|
||||
}
|
||||
}
|
||||
|
||||
fn present(route: Route) -> Message {
|
||||
Message::Route {
|
||||
present: true,
|
||||
route,
|
||||
}
|
||||
}
|
||||
|
||||
fn gone(route: Route) -> Message {
|
||||
Message::Route {
|
||||
present: false,
|
||||
route,
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(routes: &KernelRoutes, address: &str) -> Option<(String, Option<String>, u32)> {
|
||||
routes.lookup(address.parse().unwrap()).map(|route| {
|
||||
(
|
||||
route.prefix.to_string(),
|
||||
route.gateway.map(|g| g.to_string()),
|
||||
route.metric,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_longest_prefix_with_the_lowest_metric_is_the_way() {
|
||||
let mut routes = KernelRoutes::new(TABLE_MAIN);
|
||||
for r in [
|
||||
route("0.0.0.0/0", Some("192.0.2.1"), 2, 100),
|
||||
route("10.0.0.0/8", Some("192.0.2.9"), 2, 0),
|
||||
route("10.1.0.0/16", None, 7, 0),
|
||||
route("10.1.0.0/16", Some("198.51.100.1"), 3, 50),
|
||||
route("2001:db8::/32", Some("2001:db8::1"), 2, 1024),
|
||||
] {
|
||||
assert!(routes.apply(&present(r)));
|
||||
}
|
||||
assert_eq!(routes.len(), 4);
|
||||
assert_eq!(
|
||||
lookup(&routes, "10.2.3.4"),
|
||||
Some(("10.0.0.0/8".into(), Some("192.0.2.9".into()), 0))
|
||||
);
|
||||
// Two routes for the prefix: the lower metric wins.
|
||||
assert_eq!(
|
||||
lookup(&routes, "10.1.2.3"),
|
||||
Some(("10.1.0.0/16".into(), None, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(&routes, "203.0.113.1"),
|
||||
Some(("0.0.0.0/0".into(), Some("192.0.2.1".into()), 100))
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(&routes, "2001:db8:1::1"),
|
||||
Some(("2001:db8::/32".into(), Some("2001:db8::1".into()), 1024))
|
||||
);
|
||||
assert_eq!(lookup(&routes, "2001:db9::1"), None);
|
||||
|
||||
// Told again what is known: nothing changes.
|
||||
assert!(!routes.apply(&present(route("10.0.0.0/8", Some("192.0.2.9"), 2, 0))));
|
||||
// The same prefix and metric with another gateway: replaced.
|
||||
assert!(routes.apply(&present(route("10.0.0.0/8", Some("192.0.2.8"), 2, 0))));
|
||||
assert_eq!(
|
||||
lookup(&routes, "10.2.3.4"),
|
||||
Some(("10.0.0.0/8".into(), Some("192.0.2.8".into()), 0))
|
||||
);
|
||||
// The better of the two for 10.1/16 goes: the other is the way.
|
||||
assert!(routes.apply(&gone(route("10.1.0.0/16", None, 7, 0))));
|
||||
assert_eq!(
|
||||
lookup(&routes, "10.1.2.3"),
|
||||
Some(("10.1.0.0/16".into(), Some("198.51.100.1".into()), 50))
|
||||
);
|
||||
assert!(routes.apply(&gone(route("10.1.0.0/16", Some("198.51.100.1"), 3, 50))));
|
||||
assert_eq!(
|
||||
lookup(&routes, "10.1.2.3"),
|
||||
Some(("10.0.0.0/8".into(), Some("192.0.2.8".into()), 0))
|
||||
);
|
||||
// Gone already: nothing changes.
|
||||
assert!(!routes.apply(&gone(route("10.1.0.0/16", None, 7, 0))));
|
||||
assert_eq!(routes.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_of_other_tables_and_of_bgp_do_not_count() {
|
||||
let mut routes = KernelRoutes::new(TABLE_MAIN);
|
||||
let other_table = Route {
|
||||
table: 200,
|
||||
..route("10.0.0.0/8", Some("192.0.2.9"), 2, 0)
|
||||
};
|
||||
let bgp = Route {
|
||||
protocol: PROTOCOL_BGP,
|
||||
..route("10.0.0.0/8", Some("192.0.2.9"), 2, 20)
|
||||
};
|
||||
assert!(!routes.apply(&present(other_table)));
|
||||
assert!(!routes.apply(&present(bgp)));
|
||||
assert!(routes.is_empty());
|
||||
assert_eq!(lookup(&routes, "10.2.3.4"), None);
|
||||
// What is not about routes changes nothing either.
|
||||
assert!(!routes.apply(&Message::Done));
|
||||
|
||||
let mut table_200 = KernelRoutes::new(200);
|
||||
assert!(table_200.apply(&present(other_table)));
|
||||
assert_eq!(table_200.table(), 200);
|
||||
assert_eq!(table_200.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
//! What the kernel knows about one of our TCP connections: the round-trip
|
||||
//! time, the windows, the queues and what has held the connection up,
|
||||
//! asked over the socket diagnostics netlink family (`NETLINK_SOCK_DIAG`,
|
||||
//! as `ss -ti` asks). For the status page, when someone wants to know
|
||||
//! why a session is slow.
|
||||
|
||||
use std::io;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use crate::netlink::{
|
||||
AF_INET, AF_INET6, HEADER_LEN, attributes, flag, message_type, ne_u16, ne_u32, ne_u64,
|
||||
};
|
||||
|
||||
/// A TCP connection as the kernel sees it. Times are in microseconds,
|
||||
/// sizes in octets, rates in octets per second, unless said otherwise.
|
||||
/// Fields the running kernel does not report are zero.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct TcpConnection {
|
||||
/// The TCP state, by its number (1 is ESTABLISHED); see
|
||||
/// [`TcpConnection::state_name`].
|
||||
pub state: u8,
|
||||
/// The congestion control algorithm, if the kernel named it.
|
||||
pub congestion: Option<String>,
|
||||
/// Octets received and not yet read by us.
|
||||
pub recv_queue: u32,
|
||||
/// Octets written by us and not yet acknowledged by the peer.
|
||||
pub send_queue: u32,
|
||||
pub rtt: u32,
|
||||
pub rtt_variance: u32,
|
||||
pub min_rtt: u32,
|
||||
pub rto: u32,
|
||||
pub mss: u32,
|
||||
pub pmtu: u32,
|
||||
/// The congestion window, in segments.
|
||||
pub cwnd: u32,
|
||||
/// The slow start threshold, in segments; `None` if there is none.
|
||||
pub ssthresh: Option<u32>,
|
||||
/// What the peer lets us send: its receive window.
|
||||
pub send_window: u32,
|
||||
/// What we let the peer send.
|
||||
pub receive_window: u32,
|
||||
pub receive_space: u32,
|
||||
pub receive_ssthresh: u32,
|
||||
pub send_window_scale: u8,
|
||||
pub receive_window_scale: u8,
|
||||
/// Segments sent and not acknowledged.
|
||||
pub unacked: u32,
|
||||
/// Segments retransmitted and still out.
|
||||
pub retrans: u32,
|
||||
pub lost: u32,
|
||||
pub total_retrans: u32,
|
||||
pub bytes_retrans: u64,
|
||||
/// Octets in the socket's buffer that are not sent yet.
|
||||
pub not_sent: u32,
|
||||
pub pacing_rate: u64,
|
||||
pub delivery_rate: u64,
|
||||
pub bytes_sent: u64,
|
||||
pub bytes_acked: u64,
|
||||
pub bytes_received: u64,
|
||||
pub segments_out: u32,
|
||||
pub segments_in: u32,
|
||||
/// How long the connection had something to send.
|
||||
pub busy: u64,
|
||||
/// Of that, how long it was held up by the peer's receive window.
|
||||
pub limited_by_receive_window: u64,
|
||||
/// And how long by our own send buffer being full.
|
||||
pub limited_by_send_buffer: u64,
|
||||
pub delivered: u32,
|
||||
pub reordering: u32,
|
||||
/// Milliseconds since data was last sent, received, and since the
|
||||
/// last acknowledgement came.
|
||||
pub last_data_sent_ms: u32,
|
||||
pub last_data_received_ms: u32,
|
||||
pub last_ack_received_ms: u32,
|
||||
}
|
||||
|
||||
impl TcpConnection {
|
||||
pub fn state_name(&self) -> &'static str {
|
||||
match self.state {
|
||||
1 => "established",
|
||||
2 => "syn-sent",
|
||||
3 => "syn-received",
|
||||
4 => "fin-wait-1",
|
||||
5 => "fin-wait-2",
|
||||
6 => "time-wait",
|
||||
7 => "close",
|
||||
8 => "close-wait",
|
||||
9 => "last-ack",
|
||||
10 => "listen",
|
||||
11 => "closing",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SOCK_DIAG_BY_FAMILY: u16 = 20;
|
||||
const IPPROTO_TCP: u8 = 6;
|
||||
const INET_DIAG_INFO: u16 = 2;
|
||||
const INET_DIAG_CONG: u16 = 4;
|
||||
/// `idiag_ext` bits asking for those two.
|
||||
const EXTENSIONS: u8 = (1 << (INET_DIAG_INFO - 1)) | (1 << (INET_DIAG_CONG - 1));
|
||||
const NO_COOKIE: u32 = u32::MAX;
|
||||
const REQUEST_LEN: usize = HEADER_LEN + 56;
|
||||
const DIAG_MSG_LEN: usize = 72;
|
||||
|
||||
/// The request for the connection from `local` to `peer`, with `sequence`.
|
||||
pub fn request(local: SocketAddr, peer: SocketAddr, sequence: u32) -> Vec<u8> {
|
||||
let family = match local {
|
||||
SocketAddr::V4(_) => AF_INET,
|
||||
SocketAddr::V6(_) => AF_INET6,
|
||||
};
|
||||
let mut buf = Vec::with_capacity(REQUEST_LEN);
|
||||
buf.extend_from_slice(&(REQUEST_LEN as u32).to_ne_bytes());
|
||||
buf.extend_from_slice(&SOCK_DIAG_BY_FAMILY.to_ne_bytes());
|
||||
buf.extend_from_slice(&flag::REQUEST.to_ne_bytes());
|
||||
buf.extend_from_slice(&sequence.to_ne_bytes());
|
||||
buf.extend_from_slice(&0u32.to_ne_bytes());
|
||||
// inet_diag_req_v2
|
||||
buf.extend_from_slice(&[family, IPPROTO_TCP, EXTENSIONS, 0]);
|
||||
buf.extend_from_slice(&u32::MAX.to_ne_bytes()); // any state
|
||||
// inet_diag_sockid: ports in network order, addresses as 16 octets.
|
||||
buf.extend_from_slice(&local.port().to_be_bytes());
|
||||
buf.extend_from_slice(&peer.port().to_be_bytes());
|
||||
buf.extend_from_slice(&address_octets(local.ip()));
|
||||
buf.extend_from_slice(&address_octets(peer.ip()));
|
||||
// The interface, which a socket on a link-local address is bound to
|
||||
// and is found by.
|
||||
let interface = match local {
|
||||
SocketAddr::V6(v6) => v6.scope_id(),
|
||||
SocketAddr::V4(_) => 0,
|
||||
};
|
||||
buf.extend_from_slice(&interface.to_ne_bytes());
|
||||
buf.extend_from_slice(&NO_COOKIE.to_ne_bytes());
|
||||
buf.extend_from_slice(&NO_COOKIE.to_ne_bytes());
|
||||
debug_assert_eq!(buf.len(), REQUEST_LEN);
|
||||
buf
|
||||
}
|
||||
|
||||
fn address_octets(address: IpAddr) -> [u8; 16] {
|
||||
let mut octets = [0u8; 16];
|
||||
match address {
|
||||
IpAddr::V4(v4) => octets[..4].copy_from_slice(&v4.octets()),
|
||||
IpAddr::V6(v6) => octets = v6.octets(),
|
||||
}
|
||||
octets
|
||||
}
|
||||
|
||||
/// What the kernel answered.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
Connection(Box<TcpConnection>),
|
||||
/// No such connection.
|
||||
None,
|
||||
/// The kernel refused, with this error number.
|
||||
Refused(i32),
|
||||
}
|
||||
|
||||
/// Reads the kernel's answer to a [`request`].
|
||||
pub fn parse(buf: &[u8]) -> io::Result<Answer> {
|
||||
let header = buf
|
||||
.get(..HEADER_LEN)
|
||||
.ok_or_else(|| io::Error::other("short netlink answer"))?;
|
||||
let length = ne_u32(&header[0..4]) as usize;
|
||||
let kind = ne_u16(&header[4..6]);
|
||||
let message = buf
|
||||
.get(HEADER_LEN..length.min(buf.len()))
|
||||
.ok_or_else(|| io::Error::other("short netlink answer"))?;
|
||||
if kind == message_type::ERROR {
|
||||
let errno = -(message
|
||||
.first_chunk::<4>()
|
||||
.map_or(0, |e| i32::from_ne_bytes(*e)));
|
||||
// ENOENT: no such socket.
|
||||
return Ok(if errno == 2 {
|
||||
Answer::None
|
||||
} else {
|
||||
Answer::Refused(errno)
|
||||
});
|
||||
}
|
||||
if kind != SOCK_DIAG_BY_FAMILY || message.len() < DIAG_MSG_LEN {
|
||||
return Err(io::Error::other("not a socket diagnostic"));
|
||||
}
|
||||
let mut connection = TcpConnection {
|
||||
state: message[1],
|
||||
recv_queue: ne_u32(&message[56..60]),
|
||||
send_queue: ne_u32(&message[60..64]),
|
||||
..TcpConnection::default()
|
||||
};
|
||||
for (kind, value) in attributes(&message[DIAG_MSG_LEN..]) {
|
||||
match kind {
|
||||
INET_DIAG_INFO => fill_from_tcp_info(&mut connection, value),
|
||||
INET_DIAG_CONG => {
|
||||
let name = value.split(|b| *b == 0).next().unwrap_or(&[]);
|
||||
connection.congestion = Some(String::from_utf8_lossy(name).into_owned());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Answer::Connection(Box::new(connection)))
|
||||
}
|
||||
|
||||
/// Fills in what `tcp_info` (linux/tcp.h) has, as far as the kernel sent
|
||||
/// it: the structure has grown over the years, and a field that is not
|
||||
/// there is left at zero.
|
||||
fn fill_from_tcp_info(c: &mut TcpConnection, info: &[u8]) {
|
||||
let u32_at = |at: usize| info.get(at..at + 4).map_or(0, ne_u32);
|
||||
let u64_at = |at: usize| info.get(at..at + 8).map_or(0, ne_u64);
|
||||
c.send_window_scale = info.get(6).map_or(0, |b| b & 0x0f);
|
||||
c.receive_window_scale = info.get(6).map_or(0, |b| b >> 4);
|
||||
c.rto = u32_at(8);
|
||||
c.mss = u32_at(16);
|
||||
c.unacked = u32_at(24);
|
||||
c.lost = u32_at(32);
|
||||
c.retrans = u32_at(36);
|
||||
c.last_data_sent_ms = u32_at(44);
|
||||
c.last_data_received_ms = u32_at(52);
|
||||
c.last_ack_received_ms = u32_at(56);
|
||||
c.pmtu = u32_at(60);
|
||||
c.receive_ssthresh = u32_at(64);
|
||||
c.rtt = u32_at(68);
|
||||
c.rtt_variance = u32_at(72);
|
||||
c.ssthresh = Some(u32_at(76)).filter(|t| *t < 0x7fff_ffff);
|
||||
c.cwnd = u32_at(80);
|
||||
c.reordering = u32_at(88);
|
||||
c.receive_space = u32_at(96);
|
||||
c.total_retrans = u32_at(100);
|
||||
c.pacing_rate = u64_at(104);
|
||||
c.bytes_acked = u64_at(120);
|
||||
c.bytes_received = u64_at(128);
|
||||
c.segments_out = u32_at(136);
|
||||
c.segments_in = u32_at(140);
|
||||
c.not_sent = u32_at(144);
|
||||
c.min_rtt = u32_at(148);
|
||||
c.delivery_rate = u64_at(160);
|
||||
c.busy = u64_at(168);
|
||||
c.limited_by_receive_window = u64_at(176);
|
||||
c.limited_by_send_buffer = u64_at(184);
|
||||
c.delivered = u32_at(192);
|
||||
c.bytes_sent = u64_at(200);
|
||||
c.bytes_retrans = u64_at(208);
|
||||
c.send_window = u32_at(228);
|
||||
c.receive_window = u32_at(232);
|
||||
}
|
||||
|
||||
/// Asks the kernel about the TCP connection from `local` to `peer`.
|
||||
/// `Ok(None)` if there is no such connection, or on an operating system
|
||||
/// that cannot be asked.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn tcp_connection(
|
||||
local: SocketAddr,
|
||||
peer: SocketAddr,
|
||||
) -> io::Result<Option<TcpConnection>> {
|
||||
use netlink_sys::protocols::NETLINK_SOCK_DIAG;
|
||||
use netlink_sys::{AsyncSocket, AsyncSocketExt, TokioSocket};
|
||||
|
||||
let mut socket = TokioSocket::new(NETLINK_SOCK_DIAG)?;
|
||||
socket
|
||||
.socket_mut()
|
||||
.bind(&netlink_sys::SocketAddr::new(0, 0))?;
|
||||
let sequence = 1;
|
||||
socket.send(&request(local, peer, sequence)).await?;
|
||||
let mut buf = bytes::BytesMut::with_capacity(4096);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), socket.recv(&mut buf))
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "the kernel did not answer"))??;
|
||||
match parse(&buf)? {
|
||||
Answer::Connection(connection) => Ok(Some(*connection)),
|
||||
Answer::None => Ok(None),
|
||||
Answer::Refused(errno) => Err(io::Error::from_raw_os_error(errno)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub async fn tcp_connection(
|
||||
_local: SocketAddr,
|
||||
_peer: SocketAddr,
|
||||
) -> io::Result<Option<TcpConnection>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A reply as the kernel writes it, for an established connection,
|
||||
/// with the first `info_len` octets of a `tcp_info` of the full
|
||||
/// modern length: an older kernel sends a shorter one.
|
||||
fn reply(info_len: usize) -> Vec<u8> {
|
||||
let mut info = vec![0u8; 248];
|
||||
info[0] = 1; // established
|
||||
info[6] = 0x7a; // snd_wscale 10, rcv_wscale 7
|
||||
let put32 = |info: &mut Vec<u8>, at: usize, v: u32| {
|
||||
info[at..at + 4].copy_from_slice(&v.to_ne_bytes())
|
||||
};
|
||||
let put64 = |info: &mut Vec<u8>, at: usize, v: u64| {
|
||||
info[at..at + 8].copy_from_slice(&v.to_ne_bytes())
|
||||
};
|
||||
put32(&mut info, 16, 1448); // mss
|
||||
put32(&mut info, 24, 12); // unacked
|
||||
put32(&mut info, 68, 2300); // rtt
|
||||
put32(&mut info, 76, 0x7fff_ffff); // no ssthresh
|
||||
put32(&mut info, 80, 42); // cwnd
|
||||
put32(&mut info, 144, 65536); // notsent
|
||||
put64(&mut info, 176, 900_000); // rwnd limited
|
||||
put64(&mut info, 168, 1_000_000); // busy
|
||||
put32(&mut info, 228, 131072); // snd_wnd
|
||||
put32(&mut info, 232, 262144); // rcv_wnd
|
||||
info.truncate(info_len);
|
||||
let cong = b"cubic\0";
|
||||
let mut message = vec![0u8; DIAG_MSG_LEN];
|
||||
message[1] = 1;
|
||||
message[60..64].copy_from_slice(&80_000u32.to_ne_bytes()); // wqueue
|
||||
// attributes: INFO, then CONG
|
||||
let mut attrs = Vec::new();
|
||||
attrs.extend_from_slice(&((4 + info.len()) as u16).to_ne_bytes());
|
||||
attrs.extend_from_slice(&INET_DIAG_INFO.to_ne_bytes());
|
||||
attrs.extend_from_slice(&info);
|
||||
attrs.extend_from_slice(&((4 + cong.len()) as u16).to_ne_bytes());
|
||||
attrs.extend_from_slice(&INET_DIAG_CONG.to_ne_bytes());
|
||||
attrs.extend_from_slice(cong);
|
||||
while attrs.len() % 4 != 0 {
|
||||
attrs.push(0);
|
||||
}
|
||||
let length = HEADER_LEN + message.len() + attrs.len();
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&(length as u32).to_ne_bytes());
|
||||
buf.extend_from_slice(&SOCK_DIAG_BY_FAMILY.to_ne_bytes());
|
||||
buf.extend_from_slice(&0u16.to_ne_bytes()); // flags
|
||||
buf.extend_from_slice(&1u32.to_ne_bytes()); // sequence
|
||||
buf.extend_from_slice(&0u32.to_ne_bytes()); // port
|
||||
buf.extend_from_slice(&message);
|
||||
buf.extend_from_slice(&attrs);
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_is_read_field_by_field() {
|
||||
let Answer::Connection(c) = parse(&reply(248)).unwrap() else {
|
||||
panic!("a connection");
|
||||
};
|
||||
assert_eq!(c.state_name(), "established");
|
||||
assert_eq!(c.congestion.as_deref(), Some("cubic"));
|
||||
assert_eq!(c.send_queue, 80_000);
|
||||
assert_eq!((c.send_window_scale, c.receive_window_scale), (10, 7));
|
||||
assert_eq!((c.mss, c.unacked, c.rtt, c.cwnd), (1448, 12, 2300, 42));
|
||||
assert_eq!(c.ssthresh, None);
|
||||
assert_eq!(c.not_sent, 65536);
|
||||
assert_eq!((c.busy, c.limited_by_receive_window), (1_000_000, 900_000));
|
||||
assert_eq!((c.send_window, c.receive_window), (131072, 262144));
|
||||
}
|
||||
|
||||
/// Linux 4.x sends 104 octets of `tcp_info`, up to `tcpi_total_retrans`:
|
||||
/// what is there is read, and what came later is left at zero rather
|
||||
/// than read from the attribute after it.
|
||||
#[test]
|
||||
fn a_short_tcp_info_from_an_old_kernel_leaves_later_fields_zero() {
|
||||
let Answer::Connection(c) = parse(&reply(104)).unwrap() else {
|
||||
panic!("a connection");
|
||||
};
|
||||
assert_eq!(c.congestion.as_deref(), Some("cubic"));
|
||||
assert_eq!((c.mss, c.rtt, c.cwnd), (1448, 2300, 42));
|
||||
assert_eq!((c.busy, c.send_window), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_such_connection_and_a_refusal_are_told_apart() {
|
||||
let error = |errno: i32| {
|
||||
let mut buf = vec![0u8; 20];
|
||||
buf[0..4].copy_from_slice(&20u32.to_ne_bytes());
|
||||
buf[4..6].copy_from_slice(&message_type::ERROR.to_ne_bytes());
|
||||
buf[16..20].copy_from_slice(&(-errno).to_ne_bytes());
|
||||
buf
|
||||
};
|
||||
assert_eq!(parse(&error(2)).unwrap(), Answer::None);
|
||||
assert_eq!(parse(&error(13)).unwrap(), Answer::Refused(13));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_names_the_connection() {
|
||||
let local: SocketAddr = "[2001:db8::1]:179".parse().unwrap();
|
||||
let peer: SocketAddr = "[2001:db8::2]:40000".parse().unwrap();
|
||||
let request = request(local, peer, 7);
|
||||
assert_eq!(request.len(), REQUEST_LEN);
|
||||
assert_eq!(request[16], AF_INET6);
|
||||
assert_eq!(&request[24..26], &179u16.to_be_bytes());
|
||||
assert_eq!(&request[26..28], &40000u16.to_be_bytes());
|
||||
assert_eq!(
|
||||
&request[28..44],
|
||||
&"2001:db8::1"
|
||||
.parse::<std::net::Ipv6Addr>()
|
||||
.unwrap()
|
||||
.octets()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn the_kernel_tells_of_a_connection_of_ours() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut client = tokio::net::TcpStream::connect(listener.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let (_server, _) = listener.accept().await.unwrap();
|
||||
client.write_all(&[0u8; 1000]).await.unwrap();
|
||||
let connection = tcp_connection(client.local_addr().unwrap(), client.peer_addr().unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("the kernel knows it");
|
||||
assert_eq!(connection.state_name(), "established");
|
||||
assert!(connection.mss > 0 && connection.cwnd > 0);
|
||||
assert!(connection.bytes_sent >= 1000 || connection.bytes_acked >= 1000);
|
||||
let none = tcp_connection(
|
||||
"127.0.0.1:1".parse().unwrap(),
|
||||
"127.0.0.1:2".parse().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(none.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! What the kernel knows that says whether, and how, a next hop can be
|
||||
//! reached: the machine's interfaces and addresses, the routes of the
|
||||
//! resolution table, and the kernel's nexthop objects that those routes
|
||||
//! may name. One value, replaced as a whole when any of it changes, so
|
||||
//! that everyone judging next hops judges them by one moment's view.
|
||||
|
||||
use crate::interfaces::Interfaces;
|
||||
use crate::netlink::{Message, Nexthop};
|
||||
use crate::plan::HashMap;
|
||||
use crate::routes::KernelRoutes;
|
||||
|
||||
/// The kernel's view of the machine, as far as next hops are concerned.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Topology {
|
||||
pub interfaces: Interfaces,
|
||||
/// The routes a next hop that is not a neighbor is reached by.
|
||||
pub routes: KernelRoutes,
|
||||
/// Every nexthop object the kernel has, by number, whoever made it:
|
||||
/// a route of the resolution table may name one instead of carrying
|
||||
/// its own gateway.
|
||||
pub nexthops: HashMap<u32, Nexthop>,
|
||||
/// Whether the default route counts as the way to a next hop.
|
||||
pub via_default: bool,
|
||||
}
|
||||
|
||||
impl Topology {
|
||||
/// Nothing known yet, with next hops to be resolved by the routes of
|
||||
/// `resolution_table`.
|
||||
pub fn new(resolution_table: u32, via_default: bool) -> Self {
|
||||
Self {
|
||||
interfaces: Interfaces::default(),
|
||||
routes: KernelRoutes::new(resolution_table),
|
||||
nexthops: HashMap::default(),
|
||||
via_default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes in what a message from the kernel says. Returns whether
|
||||
/// anything is different for it: then what any next hop resolves to
|
||||
/// may be different too.
|
||||
pub fn apply(&mut self, message: &Message) -> bool {
|
||||
match message {
|
||||
Message::Nexthop {
|
||||
present: true,
|
||||
nexthop,
|
||||
} => self.nexthops.insert(nexthop.id, *nexthop) != Some(*nexthop),
|
||||
Message::Nexthop {
|
||||
present: false,
|
||||
nexthop,
|
||||
} => self.nexthops.remove(&nexthop.id).is_some(),
|
||||
Message::Route { .. } => self.routes.apply(message),
|
||||
_ => self.interfaces.apply(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::netlink::{KIND_UNICAST, Link, PROTOCOL_BGP, Route, TABLE_MAIN};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
|
||||
#[test]
|
||||
fn each_kind_of_news_goes_where_it_belongs() {
|
||||
let mut topology = Topology::new(TABLE_MAIN, false);
|
||||
let nexthop = Nexthop {
|
||||
id: 7,
|
||||
protocol: 4,
|
||||
gateway: Some("192.0.2.1".parse().unwrap()),
|
||||
interface: Some(2),
|
||||
};
|
||||
let present = |nexthop| Message::Nexthop {
|
||||
present: true,
|
||||
nexthop,
|
||||
};
|
||||
assert!(topology.apply(&present(nexthop)));
|
||||
assert!(!topology.apply(&present(nexthop)));
|
||||
assert!(topology.apply(&present(Nexthop {
|
||||
interface: Some(3),
|
||||
..nexthop
|
||||
})));
|
||||
assert_eq!(topology.nexthops[&7].interface, Some(3));
|
||||
assert!(topology.apply(&Message::Nexthop {
|
||||
present: false,
|
||||
nexthop,
|
||||
}));
|
||||
assert!(topology.nexthops.is_empty());
|
||||
|
||||
let route = Route {
|
||||
prefix: IpPrefix::try_from("10.0.0.0/8").unwrap(),
|
||||
table: TABLE_MAIN,
|
||||
protocol: 4,
|
||||
metric: 0,
|
||||
gateway: Some("192.0.2.1".parse().unwrap()),
|
||||
interface: Some(2),
|
||||
nexthop: None,
|
||||
kind: KIND_UNICAST,
|
||||
};
|
||||
assert!(topology.apply(&Message::Route {
|
||||
present: true,
|
||||
route,
|
||||
}));
|
||||
assert_eq!(topology.routes.len(), 1);
|
||||
// BGP's routes are not the way to anything.
|
||||
assert!(!topology.apply(&Message::Route {
|
||||
present: true,
|
||||
route: Route {
|
||||
protocol: PROTOCOL_BGP,
|
||||
..route
|
||||
},
|
||||
}));
|
||||
assert!(topology.apply(&Message::Link {
|
||||
present: true,
|
||||
link: Link {
|
||||
index: 2,
|
||||
name: "eth0".into(),
|
||||
up: true,
|
||||
},
|
||||
}));
|
||||
assert!(topology.interfaces.link(2).is_some());
|
||||
assert!(!topology.apply(&Message::Done));
|
||||
}
|
||||
}
|
||||
+146
-4
@@ -15,8 +15,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use bgp_fib::{Fib, FibConfig, FibError};
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, MultiExitDiscPathAttribute, NextHopPathAttribute, NlriNextHop,
|
||||
OriginPathAttribute, PathAttribute,
|
||||
AsPathAttribute, CommunitiesPathAttribute, MultiExitDiscPathAttribute, NextHopPathAttribute,
|
||||
NlriNextHop, OriginPathAttribute, PathAttribute,
|
||||
};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::table::{AttrSet, PeerIndex, PeerInfo, RibTable};
|
||||
@@ -109,6 +109,7 @@ impl Machine {
|
||||
metric: 20,
|
||||
nexthop_objects: self.nexthop_objects,
|
||||
stale_time: self.stale_time,
|
||||
..FibConfig::default()
|
||||
},
|
||||
)
|
||||
.expect("the kernel can be talked to")
|
||||
@@ -294,7 +295,7 @@ async fn the_kernel_has_the_best_route_of_each_prefix() {
|
||||
machine.shown("2001:db8:100::/48", v6_neighbor),
|
||||
]);
|
||||
status_becomes(&fib, "one unresolved", |status| {
|
||||
status.installed == 2 && status.unresolved == [("next hop not directly connected", 1)]
|
||||
status.installed == 2 && status.unresolved == [("no route to the next hop", 1)]
|
||||
});
|
||||
|
||||
// A session ends: its routes go.
|
||||
@@ -367,7 +368,7 @@ async fn routes_follow_the_machines_interfaces() {
|
||||
.announce(peer, &via, &[prefix("203.0.113.0/24")]);
|
||||
let (fib, task) = machine.start();
|
||||
status_becomes(&fib, "unresolved", |status| {
|
||||
status.synced && status.unresolved == [("next hop not directly connected", 1)]
|
||||
status.synced && status.unresolved == [("no route to the next hop", 1)]
|
||||
});
|
||||
assert!(machine.kernel_routes().is_empty());
|
||||
|
||||
@@ -416,6 +417,147 @@ async fn routes_follow_the_machines_interfaces() {
|
||||
machine.kernel_becomes(&[]);
|
||||
}
|
||||
|
||||
/// A next hop that is no neighbor goes the way the kernel's own routes
|
||||
/// say: a static route's gateway, a tunnel's interface, a nexthop object.
|
||||
/// When those routes change, so do ours; a route that discards packets is
|
||||
/// the way to nowhere.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
|
||||
async fn a_far_next_hop_goes_the_way_the_kernels_routes_say() {
|
||||
let machine = Machine::new(10);
|
||||
let interface = machine.interface();
|
||||
let peer = machine.peer(2);
|
||||
// Next hops that are not in any network of ours.
|
||||
let far = machine.via("172.31.10.9".parse().unwrap(), 0);
|
||||
let farther = machine.via("172.31.11.9".parse().unwrap(), 0);
|
||||
machine
|
||||
.rib
|
||||
.announce(peer, &far, &[prefix("203.0.113.0/24")]);
|
||||
machine
|
||||
.rib
|
||||
.announce(peer, &farther, &[prefix("198.18.0.0/15")]);
|
||||
let (fib, task) = machine.start();
|
||||
status_becomes(&fib, "no way to either", |status| {
|
||||
status.synced && status.unresolved == [("no route to the next hop", 2)]
|
||||
});
|
||||
assert!(machine.kernel_routes().is_empty());
|
||||
|
||||
// A static route with a gateway says the way to one: the route goes
|
||||
// in with that gateway.
|
||||
ip(&format!(
|
||||
"route add 172.31.10.0/24 via 10.10.0.9 dev {interface}"
|
||||
));
|
||||
machine.kernel_becomes(&[machine.shown("203.0.113.0/24", "10.10.0.9")]);
|
||||
// A route onto the interface, as a tunnel's is, says the way to the
|
||||
// other: the next hop itself is the neighbor there.
|
||||
ip(&format!("route add 172.31.11.0/24 dev {interface}"));
|
||||
machine.kernel_becomes(&[
|
||||
machine.shown("203.0.113.0/24", "10.10.0.9"),
|
||||
machine.shown("198.18.0.0/15", "172.31.11.9"),
|
||||
]);
|
||||
status_becomes(&fib, "both resolved", |status| {
|
||||
status.unresolved.is_empty() && status.installed == 2
|
||||
});
|
||||
|
||||
// The way changes: so does the route. Then it goes, and so does the
|
||||
// route.
|
||||
ip(&format!(
|
||||
"route replace 172.31.10.0/24 via 10.10.0.8 dev {interface}"
|
||||
));
|
||||
machine.kernel_becomes(&[
|
||||
machine.shown("203.0.113.0/24", "10.10.0.8"),
|
||||
machine.shown("198.18.0.0/15", "172.31.11.9"),
|
||||
]);
|
||||
ip("route del 172.31.10.0/24");
|
||||
machine.kernel_becomes(&[machine.shown("198.18.0.0/15", "172.31.11.9")]);
|
||||
status_becomes(&fib, "one unresolved again", |status| {
|
||||
status.unresolved == [("no route to the next hop", 1)]
|
||||
});
|
||||
|
||||
// A route that names a nexthop object goes where the object says.
|
||||
ip(&format!(
|
||||
"nexthop add id 1010 via 10.10.0.7 dev {interface}"
|
||||
));
|
||||
ip("route add 172.31.10.0/24 nhid 1010");
|
||||
machine.kernel_becomes(&[
|
||||
machine.shown("203.0.113.0/24", "10.10.0.7"),
|
||||
machine.shown("198.18.0.0/15", "172.31.11.9"),
|
||||
]);
|
||||
|
||||
// The tunnel's route becomes a blackhole: the way to nowhere.
|
||||
ip("route replace blackhole 172.31.11.0/24");
|
||||
machine.kernel_becomes(&[machine.shown("203.0.113.0/24", "10.10.0.7")]);
|
||||
status_becomes(&fib, "discarded", |status| {
|
||||
status.unresolved == [("the route to the next hop discards packets", 1)]
|
||||
});
|
||||
|
||||
ip("route del 172.31.10.0/24");
|
||||
ip("route del blackhole 172.31.11.0/24");
|
||||
ip("nexthop del id 1010");
|
||||
fib.stop().await;
|
||||
task.await.unwrap().unwrap();
|
||||
machine.kernel_becomes(&[]);
|
||||
}
|
||||
|
||||
/// A route that asks for its packets to be dropped goes into the kernel
|
||||
/// as a blackhole route, whatever its next hop, and comes out again with
|
||||
/// the request.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
|
||||
async fn a_blackhole_request_becomes_a_blackhole_route() {
|
||||
let machine = Machine::new(13);
|
||||
let peer = machine.peer(2);
|
||||
// Asked for from a next hop nothing is a neighbor of, and with one
|
||||
// ordinary route beside it to show the two apart.
|
||||
let mut attributes = vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![65002]),
|
||||
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(172, 31, 11, 9))),
|
||||
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65002, 1), (0xFFFF, 0x029A)])),
|
||||
];
|
||||
let blackhole = machine.rib.attrs().intern(&attributes, None).unwrap();
|
||||
attributes.pop();
|
||||
attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(vec![
|
||||
(65002, 1),
|
||||
])));
|
||||
let plain = machine.rib.attrs().intern(&attributes, None).unwrap();
|
||||
machine.rib.announce(
|
||||
peer,
|
||||
&blackhole,
|
||||
&[prefix("203.0.113.9/32"), prefix("2001:db8:100::9/128")],
|
||||
);
|
||||
machine
|
||||
.rib
|
||||
.announce(peer, &plain, &[prefix("198.18.0.0/15")]);
|
||||
let (fib, task) = machine.start();
|
||||
// The kernel puts an IPv6 blackhole route on lo, and says so.
|
||||
machine.kernel_becomes(&[
|
||||
"blackhole 203.0.113.9 proto bgp metric 20".into(),
|
||||
"blackhole 2001:db8:100::9 dev lo proto bgp metric 20".into(),
|
||||
]);
|
||||
status_becomes(&fib, "two blackholes, one unresolved", |status| {
|
||||
status.synced
|
||||
&& status.installed == 2
|
||||
&& status.blackholes == 2
|
||||
&& status.unresolved == [("no route to the next hop", 1)]
|
||||
});
|
||||
|
||||
// The request is withdrawn: the blackhole goes.
|
||||
machine.rib.withdraw(peer, &[prefix("203.0.113.9/32")]);
|
||||
machine.kernel_becomes(&["blackhole 2001:db8:100::9 dev lo proto bgp metric 20".into()]);
|
||||
|
||||
// A blackhole left by an earlier run is found and kept like any other.
|
||||
fib.leave().await;
|
||||
task.await.unwrap().unwrap();
|
||||
let (fib, task) = machine.start();
|
||||
status_becomes(&fib, "the blackhole is adopted", |status| {
|
||||
status.synced && status.installed == 1 && status.blackholes == 1
|
||||
});
|
||||
fib.stop().await;
|
||||
task.await.unwrap().unwrap();
|
||||
machine.kernel_becomes(&[]);
|
||||
}
|
||||
|
||||
/// A table's worth of routes goes in, and comes out, in reasonable time.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
|
||||
|
||||
@@ -23,6 +23,7 @@ pub const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub fn default_policy() -> bgp_server::policy::ImportPolicy {
|
||||
bgp_server::policy::ImportPolicy {
|
||||
accept_third_party_next_hop: true,
|
||||
accept_blackholes: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -56,6 +57,7 @@ pub fn peer_config(name: &str, endpoint: impl Into<Endpoint>, remote_asn: u32) -
|
||||
groups: Vec::new(),
|
||||
reflector_client: false,
|
||||
passive: false,
|
||||
expected_routes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,55 @@ use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_packet::message::{BGP_HEADER_LEN, Message, NotificationMessage, UpdateMessage};
|
||||
use bgp_packet::open::{Capability, OpenMessage, OpenOption};
|
||||
use bgp_packet::open::{
|
||||
AddPathEntry, Capability, ExtendedNextHopEntry, OpenMessage, OpenOption, SendReceive,
|
||||
};
|
||||
use bgp_primitives::constants::{Afi, AfiSafi, Safi};
|
||||
use bgp_primitives::parser::ParserContext;
|
||||
use bytes::BytesMut;
|
||||
use eyre::{Result, bail, eyre};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::tcp::OwnedWriteHalf;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::TIMEOUT;
|
||||
|
||||
/// The capabilities a route collector offers in its OPEN: four-octet AS
|
||||
/// numbers as `asn`, each of `families`, and to be sent several routes
|
||||
/// for a prefix (ADD-PATH, RFC 7911) in each of them; with
|
||||
/// `ipv4_over_ipv6`, IPv4 routes with IPv6 next hops too (RFC 8950), as a
|
||||
/// collector on an IPv6-only session wants. For the tests and benchmarks
|
||||
/// that play a collector taking a router's whole table.
|
||||
pub fn collector_capabilities(
|
||||
asn: u32,
|
||||
families: &[AfiSafi],
|
||||
ipv4_over_ipv6: bool,
|
||||
) -> Vec<Capability> {
|
||||
let mut capabilities = vec![Capability::FourByteAsn { asn }];
|
||||
capabilities.extend(families.iter().copied().map(Capability::multiprotocol));
|
||||
capabilities.push(Capability::AddPath {
|
||||
entries: families
|
||||
.iter()
|
||||
.map(|family| AddPathEntry {
|
||||
afi: family.afi,
|
||||
safi: family.safi,
|
||||
send_recv: SendReceive::Receive,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
if ipv4_over_ipv6 {
|
||||
capabilities.push(Capability::ExtendedNextHop {
|
||||
entries: vec![ExtendedNextHopEntry {
|
||||
afi: Afi::IPV4,
|
||||
safi: Safi::UNICAST,
|
||||
nh_afi: Afi::IPV6,
|
||||
}],
|
||||
});
|
||||
}
|
||||
capabilities
|
||||
}
|
||||
|
||||
/// A minimal BGP peer for use in tests. Connects over TCP and can exchange
|
||||
/// framed BGP messages using the `bgp-packet` wire format.
|
||||
pub struct TestPeer {
|
||||
@@ -51,6 +90,38 @@ impl TestPeer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection itself, for a test or a tool that goes on without
|
||||
/// the framing, once the handshake is done.
|
||||
pub fn into_stream(self) -> TcpStream {
|
||||
self.stream
|
||||
}
|
||||
|
||||
/// The connection with a task reading and dropping whatever the server
|
||||
/// sends over it, and the half that is left for writing: for a peer
|
||||
/// that has nothing to hear, as one feeding a table or taking one by
|
||||
/// the megabyte has not, and whose writer must never be held up by
|
||||
/// what it has not read. The task ends with the connection, or when
|
||||
/// its handle is dropped.
|
||||
pub fn drain(self) -> (OwnedWriteHalf, JoinHandle<()>) {
|
||||
let (mut reader, writer) = self.stream.into_split();
|
||||
let sink = tokio::spawn(async move {
|
||||
let mut sink = vec![0u8; 1 << 16];
|
||||
while matches!(reader.read(&mut sink).await, Ok(n) if n > 0) {}
|
||||
});
|
||||
(writer, sink)
|
||||
}
|
||||
|
||||
/// A KEEPALIVE as it goes on the wire, for whoever writes to the
|
||||
/// connection without the framing and has to keep the session alive
|
||||
/// past its hold time.
|
||||
pub fn keepalive_bytes() -> Vec<u8> {
|
||||
let mut wire = BytesMut::new();
|
||||
Message::KeepAlive
|
||||
.to_wire(&ParserContext::default(), &mut wire)
|
||||
.expect("a KEEPALIVE is nineteen octets");
|
||||
wire.to_vec()
|
||||
}
|
||||
|
||||
/// Our end of the connection: what the server sees the peer as.
|
||||
pub fn local_addr(&self) -> Result<SocketAddr> {
|
||||
Ok(self.stream.local_addr()?)
|
||||
@@ -86,7 +157,7 @@ impl TestPeer {
|
||||
pub async fn recv_within(&mut self, wait: Duration) -> Result<Message> {
|
||||
match tokio::time::timeout(wait, self.recv_untimed()).await {
|
||||
Ok(message) => message,
|
||||
Err(_) => bail!("no message from the server within {wait:?}"),
|
||||
Err(_) => Err(eyre::eyre!("no message from the server within {wait:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +201,7 @@ impl TestPeer {
|
||||
match self.recv().await? {
|
||||
Message::Notification(n) => return Ok(n),
|
||||
Message::KeepAlive => {}
|
||||
other => bail!("expected NOTIFICATION, got {other:?}"),
|
||||
other => return Err(eyre::eyre!("expected NOTIFICATION, got {other:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +210,9 @@ impl TestPeer {
|
||||
pub async fn recv_open(&mut self) -> Result<OpenMessage> {
|
||||
match self.recv().await? {
|
||||
Message::Open(o) => {
|
||||
// Enable 4-octet ASN parsing if the peer advertised it.
|
||||
// AS numbers are four octets wide only if both sides
|
||||
// offered it (RFC 6793 §4.1): the server's offer is read
|
||||
// here, ours was noted when the OPEN went out.
|
||||
let four_byte = o.options.iter().any(|opt| {
|
||||
matches!(opt, OpenOption::Capabilities(caps)
|
||||
if caps.iter().any(|c| matches!(c, Capability::FourByteAsn { .. })))
|
||||
@@ -237,7 +310,8 @@ impl TestPeer {
|
||||
|
||||
async fn finish_handshake(&mut self) -> Result<OpenMessage> {
|
||||
let server_open = self.recv_open().await?;
|
||||
// Expect server KEEPALIVE
|
||||
// The server's KEEPALIVE says it accepted our OPEN; anything else
|
||||
// is its refusal, or a server out of step.
|
||||
match self.recv().await? {
|
||||
Message::KeepAlive => {}
|
||||
other => {
|
||||
@@ -247,12 +321,15 @@ impl TestPeer {
|
||||
));
|
||||
}
|
||||
}
|
||||
// Send our KEEPALIVE
|
||||
// Ours says the same of its OPEN, and the session is established.
|
||||
self.send(&Message::KeepAlive).await?;
|
||||
Ok(server_open)
|
||||
}
|
||||
|
||||
/// Send raw bytes without framing — useful for testing invalid input.
|
||||
/// Writes `bytes` as they are, with no framing of its own: for a test
|
||||
/// that sends what no message would be, and for the benchmarks, which
|
||||
/// send a table encoded once as one stream rather than message by
|
||||
/// message.
|
||||
pub async fn send_raw(&mut self, bytes: &[u8]) -> Result<()> {
|
||||
self.stream.write_all(bytes).await?;
|
||||
Ok(())
|
||||
|
||||
@@ -29,6 +29,9 @@ pub struct TestServer {
|
||||
handle: JoinHandle<Result<(), ServerError>>,
|
||||
/// The single IPv4 loopback address the server is bound to.
|
||||
pub addr: SocketAddr,
|
||||
/// Every address it is bound to, in the order configured: `addr`
|
||||
/// first.
|
||||
pub addrs: Vec<SocketAddr>,
|
||||
/// The server's routing table.
|
||||
pub rib: Arc<RibTable>,
|
||||
/// Handle to the server's locally originated routes.
|
||||
@@ -85,11 +88,8 @@ impl TestServer {
|
||||
customize(&mut config);
|
||||
|
||||
let bound = BgpServer::bind(config).await?;
|
||||
let addr = bound
|
||||
.local_addrs()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("at least one listener");
|
||||
let addrs = bound.local_addrs();
|
||||
let addr = *addrs.first().expect("at least one listener");
|
||||
let rib = Arc::clone(&bound.rib);
|
||||
let local_rib = bound.local_rib.clone();
|
||||
let registry = bound.registry.clone();
|
||||
@@ -102,6 +102,7 @@ impl TestServer {
|
||||
Ok(Self {
|
||||
handle,
|
||||
addr,
|
||||
addrs,
|
||||
rib,
|
||||
local_rib,
|
||||
registry,
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
//! End-to-end tests of ADD-PATH (RFC 7911): a peer sending several routes
|
||||
//! for one prefix, told apart by path identifiers.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::announcement::{Announcement, withdrawal};
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_integration_tests::test_peer::{TestPeer, collector_capabilities};
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_integration_tests::{peer_config, prefix, wait_until};
|
||||
use bgp_packet::message::PathAttribute;
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, Message, MpReachNlriPathAttribute, NlriNextHop, OriginPathAttribute,
|
||||
PathAttribute, UpdateMessage,
|
||||
};
|
||||
use bgp_packet::open::{AddPathEntry, Capability, OpenMessage, SendReceive};
|
||||
use bgp_primitives::constants::AfiSafi;
|
||||
use bgp_primitives::ip_prefix::Nlri;
|
||||
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
||||
use bgp_server::config::PeerConfig;
|
||||
use bgp_server::family::SUPPORTED;
|
||||
use bgp_server::policy::ImportPolicy;
|
||||
use bgp_server::policy::{ExportPolicy, ImportPolicy};
|
||||
use eyre::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -242,3 +247,323 @@ async fn test_prefixes_are_sent_with_path_identifiers_when_agreed() -> Result<()
|
||||
assert_eq!(held(&server), [(0, vec![PEER_ASN], true)]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const COLLECTOR_ASN: u32 = 65100;
|
||||
|
||||
/// What a collector holds: for each prefix and path identifier, the
|
||||
/// route's AS path.
|
||||
type Held = BTreeMap<(String, u32), Vec<u32>>;
|
||||
|
||||
/// Reads what the collector is told, into `held`, until `done` says so.
|
||||
async fn told(
|
||||
collector: &mut TestPeer,
|
||||
held: &mut Held,
|
||||
done: impl Fn(&Held) -> bool,
|
||||
) -> Result<()> {
|
||||
while !done(held) {
|
||||
let update = collector.recv_update().await?;
|
||||
let path = update
|
||||
.path_attributes
|
||||
.iter()
|
||||
.find_map(|attribute| match attribute {
|
||||
PathAttribute::AsPath(path) => Some(path.segments[0].path.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for nlri in &update.withdrawn_routes {
|
||||
held.remove(&(nlri.prefix.to_string(), nlri.path_id));
|
||||
}
|
||||
for nlri in &update.announced_routes {
|
||||
held.insert((nlri.prefix.to_string(), nlri.path_id), path.clone());
|
||||
}
|
||||
// And what came and went in the multiprotocol attributes.
|
||||
for attribute in &update.path_attributes {
|
||||
match attribute {
|
||||
PathAttribute::MpReachNlri(reach) => {
|
||||
for nlri in &reach.prefixes {
|
||||
held.insert((nlri.prefix.to_string(), nlri.path_id), path.clone());
|
||||
}
|
||||
}
|
||||
PathAttribute::MpUnreachNlri(unreach) => {
|
||||
for nlri in &unreach.prefixes {
|
||||
held.remove(&(nlri.prefix.to_string(), nlri.path_id));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The routes held for 203.0.113.0/24, by path identifier.
|
||||
fn paths(held: &Held) -> Vec<(u32, Vec<u32>)> {
|
||||
held.iter()
|
||||
.map(|((prefix, id), path)| {
|
||||
assert_eq!(prefix, "203.0.113.0/24");
|
||||
(*id, path.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A collector that agreed to be sent several routes for a prefix, and
|
||||
/// whose export policy says to send it every route, is told of every
|
||||
/// peer's route for a prefix, each with a path identifier of its own that
|
||||
/// stays with the route; a route that goes is withdrawn by its identifier.
|
||||
#[tokio::test]
|
||||
async fn test_every_route_goes_to_a_collector_told_apart_by_path_identifiers() -> Result<()> {
|
||||
let server = TestServer::start_with(LOCAL_ASN, |c| {
|
||||
// The collector is the configured peer at the IPv4 loopback
|
||||
// address; the feeders come over IPv6 loopback as peers of no
|
||||
// account.
|
||||
c.listen_endpoints
|
||||
.push(SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 0));
|
||||
c.peers = vec![PeerConfig {
|
||||
add_path: Some(SendReceive::Send),
|
||||
import: ImportPolicy::permissive(),
|
||||
export: Arc::new(ExportPolicy {
|
||||
learned_routes: true,
|
||||
all_paths: true,
|
||||
..ExportPolicy::default()
|
||||
}),
|
||||
..peer_config(
|
||||
"collector",
|
||||
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1),
|
||||
COLLECTOR_ASN,
|
||||
)
|
||||
}];
|
||||
c.connect_retry = Duration::from_secs(3600);
|
||||
})
|
||||
.await?;
|
||||
let route = |path: &[u32]| Announcement::of([prefix("203.0.113.0/24").into()], path);
|
||||
let feeder = async |n: u32| -> Result<TestPeer> {
|
||||
let mut peer = TestPeer::connect(server.addrs[1]).await?;
|
||||
peer.handshake(PEER_ASN + n, Ipv4Addr::new(10, 0, 0, n as u8 + 1), 90)
|
||||
.await?;
|
||||
Ok(peer)
|
||||
};
|
||||
let mut a = feeder(0).await?;
|
||||
let mut b = feeder(1).await?;
|
||||
server.wait_for_sessions(2).await?;
|
||||
a.announce(route(&[PEER_ASN, 64510])).await?;
|
||||
b.announce(route(&[PEER_ASN + 1, 64510])).await?;
|
||||
server.wait_for_routes(2).await?;
|
||||
|
||||
let mut collector = TestPeer::connect(server.addr).await?;
|
||||
collector
|
||||
.handshake_with(
|
||||
COLLECTOR_ASN,
|
||||
Ipv4Addr::new(10, 0, 0, 100),
|
||||
90,
|
||||
collector_capabilities(COLLECTOR_ASN, &SUPPORTED, false),
|
||||
)
|
||||
.await?;
|
||||
collector.ctx.add_path = SUPPORTED.to_vec();
|
||||
|
||||
// Both routes, told apart.
|
||||
let mut held = Held::new();
|
||||
told(&mut collector, &mut held, |held| held.len() == 2).await?;
|
||||
assert_eq!(
|
||||
paths(&held),
|
||||
[
|
||||
(1, vec![LOCAL_ASN, PEER_ASN, 64510]),
|
||||
(2, vec![LOCAL_ASN, PEER_ASN + 1, 64510]),
|
||||
]
|
||||
);
|
||||
|
||||
// The second peer's route goes: withdrawn by its identifier, the
|
||||
// first's untouched.
|
||||
b.announce(withdrawal([prefix("203.0.113.0/24").into()]))
|
||||
.await?;
|
||||
told(&mut collector, &mut held, |held| held.len() == 1).await?;
|
||||
assert_eq!(paths(&held), [(1, vec![LOCAL_ASN, PEER_ASN, 64510])]);
|
||||
|
||||
// The first peer's route changes: it goes out again with the same
|
||||
// identifier. A third peer's route gets the lowest free one.
|
||||
a.announce(route(&[PEER_ASN, 64511, 64510])).await?;
|
||||
let mut c = feeder(2).await?;
|
||||
server.wait_for_sessions(4).await?;
|
||||
c.announce(route(&[PEER_ASN + 2, 64510])).await?;
|
||||
let changed = |held: &Held| {
|
||||
held.len() == 2
|
||||
&& held.get(&("203.0.113.0/24".to_string(), 1))
|
||||
== Some(&vec![LOCAL_ASN, PEER_ASN, 64511, 64510])
|
||||
};
|
||||
told(&mut collector, &mut held, changed).await?;
|
||||
assert_eq!(
|
||||
paths(&held),
|
||||
[
|
||||
(1, vec![LOCAL_ASN, PEER_ASN, 64511, 64510]),
|
||||
(2, vec![LOCAL_ASN, PEER_ASN + 2, 64510]),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A collector that is connected before the table arrives is told the
|
||||
/// whole table, however large: the walk of the table that a new follower
|
||||
/// starts with takes many batches with a table of some size, and goes on
|
||||
/// while the feeders keep sending.
|
||||
#[tokio::test]
|
||||
async fn test_a_large_table_reaches_a_collector_that_was_there_first() -> Result<()> {
|
||||
const PREFIXES: u32 = 40_000;
|
||||
let server = TestServer::start_with(LOCAL_ASN, |c| {
|
||||
c.listen_endpoints
|
||||
.push(SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 0));
|
||||
c.peers = vec![PeerConfig {
|
||||
add_path: Some(SendReceive::Send),
|
||||
import: ImportPolicy::permissive(),
|
||||
export: Arc::new(ExportPolicy {
|
||||
learned_routes: true,
|
||||
all_paths: true,
|
||||
..ExportPolicy::default()
|
||||
}),
|
||||
..peer_config(
|
||||
"collector",
|
||||
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1),
|
||||
COLLECTOR_ASN,
|
||||
)
|
||||
}];
|
||||
c.connect_retry = Duration::from_secs(3600);
|
||||
})
|
||||
.await?;
|
||||
let mut collector = TestPeer::connect(server.addr).await?;
|
||||
collector
|
||||
.handshake_with(
|
||||
COLLECTOR_ASN,
|
||||
Ipv4Addr::new(10, 0, 0, 100),
|
||||
90,
|
||||
collector_capabilities(COLLECTOR_ASN, &SUPPORTED, false),
|
||||
)
|
||||
.await?;
|
||||
collector.ctx.add_path = SUPPORTED.to_vec();
|
||||
server.wait_for_sessions(1).await?;
|
||||
|
||||
// Two feeders, each sending the same prefixes a hundred to an UPDATE.
|
||||
let nth = |n: u32| {
|
||||
let [_, b, c, _] = (0x0a00_0000u32 + n * 256).to_be_bytes();
|
||||
prefix(&format!("10.{b}.{c}.0/24"))
|
||||
};
|
||||
let mut feeders = Vec::new();
|
||||
for n in 0..2u32 {
|
||||
let mut peer = TestPeer::connect(server.addrs[1]).await?;
|
||||
peer.handshake(PEER_ASN + n, Ipv4Addr::new(10, 0, 0, n as u8 + 1), 90)
|
||||
.await?;
|
||||
feeders.push(peer);
|
||||
}
|
||||
server.wait_for_sessions(3).await?;
|
||||
for start in (0..PREFIXES).step_by(100) {
|
||||
for (n, feeder) in feeders.iter_mut().enumerate() {
|
||||
feeder
|
||||
.announce(Announcement::of(
|
||||
(start..start + 100).map(|i| nth(i).into()),
|
||||
&[PEER_ASN + n as u32, 64510],
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let mut held = Held::new();
|
||||
told(&mut collector, &mut held, |held| {
|
||||
held.len() == 2 * PREFIXES as usize
|
||||
})
|
||||
.await?;
|
||||
let ids: std::collections::BTreeSet<u32> = held.keys().map(|(_, id)| *id).collect();
|
||||
assert_eq!(ids.into_iter().collect::<Vec<_>>(), [1, 2]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// As a router at an exchange feeds a collector: one IPv6 session that
|
||||
/// carries both families, with ADD-PATH for both and IPv4 routes with
|
||||
/// IPv6 next hops (RFC 8950), and a table of some size from two feeders.
|
||||
#[tokio::test]
|
||||
async fn test_both_families_reach_a_collector_over_one_ipv6_session() -> Result<()> {
|
||||
const V4: u32 = 20_000;
|
||||
const V6: u32 = 1_000;
|
||||
let server = TestServer::start_with(LOCAL_ASN, |c| {
|
||||
c.listen_endpoints
|
||||
.push(SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 0));
|
||||
c.peers = vec![PeerConfig {
|
||||
add_path: Some(SendReceive::Send),
|
||||
import: ImportPolicy::permissive(),
|
||||
export: Arc::new(ExportPolicy {
|
||||
learned_routes: true,
|
||||
all_paths: true,
|
||||
..ExportPolicy::default()
|
||||
}),
|
||||
..peer_config(
|
||||
"collector",
|
||||
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 1),
|
||||
COLLECTOR_ASN,
|
||||
)
|
||||
}];
|
||||
c.connect_retry = Duration::from_secs(3600);
|
||||
})
|
||||
.await?;
|
||||
let mut collector = TestPeer::connect(server.addrs[1]).await?;
|
||||
collector
|
||||
.handshake_with(
|
||||
COLLECTOR_ASN,
|
||||
Ipv4Addr::new(10, 0, 0, 100),
|
||||
90,
|
||||
collector_capabilities(COLLECTOR_ASN, &SUPPORTED, true),
|
||||
)
|
||||
.await?;
|
||||
collector.ctx.add_path = SUPPORTED.to_vec();
|
||||
server.wait_for_sessions(1).await?;
|
||||
let session = &server.registry.list()[0];
|
||||
assert!(session.extended_next_hop, "RFC 8950 agreed");
|
||||
assert_eq!(session.add_path_send.iter().count(), 2);
|
||||
|
||||
let v4 = |n: u32| {
|
||||
let [_, b, c, _] = (0x0a00_0000u32 + n * 256).to_be_bytes();
|
||||
prefix(&format!("10.{b}.{c}.0/24"))
|
||||
};
|
||||
let v6 = |n: u32| prefix(&format!("2001:db8:{:x}::/48", 0x1000 + n));
|
||||
let mut feeders = Vec::new();
|
||||
for n in 0..2u32 {
|
||||
let mut peer = TestPeer::connect(server.addr).await?;
|
||||
peer.handshake(PEER_ASN + n, Ipv4Addr::new(10, 0, 0, n as u8 + 1), 90)
|
||||
.await?;
|
||||
feeders.push(peer);
|
||||
}
|
||||
server.wait_for_sessions(3).await?;
|
||||
for start in (0..V4).step_by(100) {
|
||||
for (n, feeder) in feeders.iter_mut().enumerate() {
|
||||
feeder
|
||||
.announce(Announcement::of(
|
||||
(start..start + 100).map(|i| v4(i).into()),
|
||||
&[PEER_ASN + n as u32, 64510],
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
for start in (0..V6).step_by(100) {
|
||||
for (n, feeder) in feeders.iter_mut().enumerate() {
|
||||
let update = UpdateMessage {
|
||||
withdrawn_routes: vec![],
|
||||
path_attributes: vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![PEER_ASN + n as u32, 64510]),
|
||||
PathAttribute::MpReachNlri(MpReachNlriPathAttribute {
|
||||
family: AfiSafi::IPV6_UNICAST,
|
||||
next_hop: NlriNextHop::Ipv6("2001:db8::9".parse().unwrap()),
|
||||
prefixes: (start..start + 100).map(|i| v6(i).into()).collect(),
|
||||
}),
|
||||
],
|
||||
announced_routes: vec![],
|
||||
};
|
||||
feeder.send(&Message::Update(update)).await?;
|
||||
}
|
||||
}
|
||||
let mut held = Held::new();
|
||||
told(&mut collector, &mut held, |held| {
|
||||
held.len() == 2 * (V4 + V6) as usize
|
||||
})
|
||||
.await?;
|
||||
let v6_routes = held
|
||||
.keys()
|
||||
.filter(|(prefix, _)| prefix.contains(':'))
|
||||
.count();
|
||||
assert_eq!(v6_routes, 2 * V6 as usize);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Blackhole requests (RFC 7999): refused by default, and with
|
||||
//! `accept_blackholes` taken for one address inside what the peer may
|
||||
//! announce, whatever their next hop.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::announcement::announcement;
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_integration_tests::test_server::{ServerWithPeer, TestServer};
|
||||
use bgp_integration_tests::{default_policy, prefix, wait_until};
|
||||
use bgp_server::policy::{ImportPolicy, PrefixSet};
|
||||
use eyre::Result;
|
||||
|
||||
const LOCAL_ASN: u32 = 1299;
|
||||
const PEER_ASN: u32 = 3333;
|
||||
const PEER_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2);
|
||||
const BLACKHOLE: (u16, u16) = (0xFFFF, 0x029A);
|
||||
|
||||
async fn server_with(import: ImportPolicy) -> Result<ServerWithPeer> {
|
||||
TestServer::with_peer(LOCAL_ASN, PEER_ASN, |c, peer| {
|
||||
c.connect_retry = Duration::from_millis(50);
|
||||
peer.import = import;
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Waits until the server has taken `taken` routes of the session's and
|
||||
/// refused `refused`, and returns the prefixes in its table.
|
||||
async fn table_after(server: &TestServer, taken: u64, refused: u64) -> Result<BTreeSet<String>> {
|
||||
let counters = server.registry.list()[0].counters.clone();
|
||||
wait_until("the routes are dealt with", || {
|
||||
(counters.routes(), counters.rejected()) == (taken, refused)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
eyre::eyre!(
|
||||
"the server took {} routes and refused {}; expected {taken} and {refused}",
|
||||
counters.routes(),
|
||||
counters.rejected()
|
||||
)
|
||||
})?;
|
||||
Ok(server
|
||||
.received_routes()
|
||||
.iter()
|
||||
.map(|route| route.prefix.to_string())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The policy that lets the peer announce 193.0.0.0/21 and nothing
|
||||
/// longer than a /24 inside it, as a customer's would.
|
||||
fn customer_policy(accept_blackholes: bool) -> ImportPolicy {
|
||||
let mut prefixes = PrefixSet::default();
|
||||
prefixes.allow(prefix("193.0.0.0/21"), 24);
|
||||
ImportPolicy {
|
||||
prefixes: Some(prefixes),
|
||||
accept_blackholes,
|
||||
..default_policy()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_blackhole_requests_are_refused_unless_accepted() -> Result<()> {
|
||||
let with_peer = server_with(customer_policy(false)).await?;
|
||||
let server = &with_peer.server;
|
||||
let mut peer: TestPeer = server.established(PEER_ASN, PEER_ID).await?;
|
||||
peer.announce(announcement(&["193.0.0.9/32"], &[PEER_ASN]).with_communities(&[BLACKHOLE]))
|
||||
.await?;
|
||||
peer.announce(announcement(&["193.0.0.0/24"], &[PEER_ASN]))
|
||||
.await?;
|
||||
assert_eq!(table_after(server, 1, 1).await?, set(&["193.0.0.0/24"]));
|
||||
let refused = server.rejections.peers();
|
||||
assert_eq!(refused.len(), 1);
|
||||
assert_eq!(
|
||||
refused[0]
|
||||
.1
|
||||
.by_reason()
|
||||
.iter()
|
||||
.map(|(reason, n)| (reason.as_str(), *n))
|
||||
.collect::<Vec<_>>(),
|
||||
[("blackhole request", 1)]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_a_blackhole_request_is_taken_for_one_address_in_the_peers_space() -> Result<()> {
|
||||
let with_peer = server_with(customer_policy(true)).await?;
|
||||
let server = &with_peer.server;
|
||||
let mut peer: TestPeer = server.established(PEER_ASN, PEER_ID).await?;
|
||||
// One address inside the peer's prefix, from a next hop that is no
|
||||
// neighbor and would not pass as an ordinary route's.
|
||||
peer.announce(
|
||||
announcement(&["193.0.0.9/32"], &[PEER_ASN])
|
||||
.with_next_hop(Ipv4Addr::new(172, 31, 0, 9))
|
||||
.with_communities(&[BLACKHOLE]),
|
||||
)
|
||||
.await?;
|
||||
// More than one address: not a blackhole request that is taken.
|
||||
peer.announce(announcement(&["193.0.0.0/25"], &[PEER_ASN]).with_communities(&[BLACKHOLE]))
|
||||
.await?;
|
||||
// One address outside the peer's space.
|
||||
peer.announce(announcement(&["193.0.8.9/32"], &[PEER_ASN]).with_communities(&[BLACKHOLE]))
|
||||
.await?;
|
||||
// And an ordinary route, taken as ever.
|
||||
peer.announce(announcement(&["193.0.0.0/24"], &[PEER_ASN]))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
table_after(server, 2, 2).await?,
|
||||
set(&["193.0.0.0/24", "193.0.0.9/32"])
|
||||
);
|
||||
let refused = server.rejections.peers();
|
||||
let mut reasons: Vec<(&str, u64)> = refused[0]
|
||||
.1
|
||||
.by_reason()
|
||||
.iter()
|
||||
.map(|(reason, n)| (reason.as_str(), *n))
|
||||
.collect();
|
||||
reasons.sort();
|
||||
assert_eq!(
|
||||
reasons,
|
||||
[
|
||||
("blackhole for more than one address", 1),
|
||||
("prefix not allowed", 1)
|
||||
]
|
||||
);
|
||||
// The route is in the table with its request on it, for the kernel
|
||||
// and for whoever it goes on to.
|
||||
let routes = server.received_routes();
|
||||
let blackhole = routes
|
||||
.iter()
|
||||
.find(|route| route.prefix == prefix("193.0.0.9/32"))
|
||||
.expect("the blackhole route is in the table");
|
||||
assert!(ImportPolicy::is_blackhole_request(&blackhole.attributes));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set(prefixes: &[&str]) -> BTreeSet<String> {
|
||||
prefixes.iter().map(|p| p.to_string()).collect()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! A peer that sends us a table while not reading what we send it must
|
||||
//! not stop us reading it: two routers sending each other their tables at
|
||||
//! once would otherwise each wait on the other for ever.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::announcement::Announcement;
|
||||
use bgp_integration_tests::prefix;
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_server::policy::ExportPolicy;
|
||||
use eyre::Result;
|
||||
|
||||
const LOCAL_ASN: u32 = 64601;
|
||||
const ROUTES: u32 = 100_000;
|
||||
|
||||
fn nth(n: u32) -> bgp_primitives::ip_prefix::IpPrefix {
|
||||
let [a, b, c, _] = (0x0a00_0000u32 + n * 256).to_be_bytes();
|
||||
prefix(&format!("{a}.{b}.{c}.0/24"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_peer_that_does_not_read_is_still_read() -> Result<()> {
|
||||
let server = TestServer::start_with(LOCAL_ASN, |c| {
|
||||
c.export = Arc::new(ExportPolicy {
|
||||
learned_routes: true,
|
||||
all_paths: false,
|
||||
..ExportPolicy::default()
|
||||
});
|
||||
})
|
||||
.await?;
|
||||
// One peer's table, for the server to pass on.
|
||||
let mut first = server
|
||||
.established(64701, Ipv4Addr::new(10, 0, 0, 1))
|
||||
.await?;
|
||||
// A route to an UPDATE, each with attributes of its own, as a real
|
||||
// table has them: passing it on is then megabytes, not a few hundred
|
||||
// UPDATEs that the socket buffers would take whole.
|
||||
for i in 0..ROUTES {
|
||||
first
|
||||
.announce(
|
||||
Announcement::of([nth(i).into()], &[64701, 64510])
|
||||
.with_communities(&[((i >> 16) as u16 + 1, i as u16)]),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
server.wait_for_routes(ROUTES as usize).await?;
|
||||
|
||||
// A second peer, which the server starts sending that table to as
|
||||
// soon as it is up, sends a table of its own and reads nothing back:
|
||||
// the server's writes to it fill its socket and stop. The server has
|
||||
// to go on reading it all the same. The peer's receive buffer is
|
||||
// small, as a real link's window would be, so that this happens with
|
||||
// the first few hundred kilobytes and not after the loopback's
|
||||
// megabytes.
|
||||
let socket = tokio::net::TcpSocket::new_v4()?;
|
||||
socket.set_recv_buffer_size(16 * 1024)?;
|
||||
let stream = socket.connect(server.addr).await?;
|
||||
let mut second = TestPeer::from_stream(stream);
|
||||
second
|
||||
.handshake(64702, Ipv4Addr::new(10, 0, 0, 2), 90)
|
||||
.await?;
|
||||
server.wait_for_sessions(2).await?;
|
||||
// Sent over a couple of seconds, so that the server's export to the
|
||||
// peer is well under way while the peer is still sending.
|
||||
let sending = async {
|
||||
for start in (0..ROUTES).step_by(100) {
|
||||
second
|
||||
.announce(Announcement::of(
|
||||
(start..start + 100).map(|i| nth(i).into()),
|
||||
&[64702, 64510],
|
||||
))
|
||||
.await?;
|
||||
if start % 2000 == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
}
|
||||
}
|
||||
Ok::<_, eyre::Report>(())
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(30), sending)
|
||||
.await
|
||||
.expect("the server reads the peer's table though the peer reads nothing")?;
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
server.wait_for_routes(2 * ROUTES as usize),
|
||||
)
|
||||
.await
|
||||
.expect("every route is in the table")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -52,6 +52,27 @@ async fn test_status_and_peers() -> Result<()> {
|
||||
peers[0]["address_families"],
|
||||
serde_json::json!(["IPv4 unicast", "IPv6 unicast"])
|
||||
);
|
||||
|
||||
// The session's TCP connection, by the address the peer list gives.
|
||||
// What the kernel says of it depends on the platform; that it was
|
||||
// asked, and why it had nothing to say if it had not, does not.
|
||||
let peer_addr = peers[0]["peer_addr"].as_str().unwrap();
|
||||
let tcp = get_json(&client, &format!("{base}/api/tcp?peer={peer_addr}")).await?;
|
||||
let available = tcp["available"].as_bool().expect("available is a bool");
|
||||
if available {
|
||||
assert!(tcp["connection"].is_object(), "{tcp}");
|
||||
} else {
|
||||
assert!(tcp["reason"].is_string(), "{tcp}");
|
||||
}
|
||||
// The address alone names the peer too, as it does elsewhere.
|
||||
let by_address = get_json(&client, &format!("{base}/api/tcp?peer=127.0.0.1")).await?;
|
||||
assert_eq!(by_address["available"], tcp["available"]);
|
||||
// A peer there is no session with is a bad request, not an answer.
|
||||
let nobody = client
|
||||
.get(format!("{base}/api/tcp?peer=nobody"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(nobody.status(), 400);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ async fn transit(customize: impl FnOnce(&mut bgp_server::config::Config)) -> Res
|
||||
TestServer::start_with(LOCAL_ASN, |config| {
|
||||
config.export = Arc::new(ExportPolicy {
|
||||
learned_routes: true,
|
||||
all_paths: false,
|
||||
..ExportPolicy::default()
|
||||
});
|
||||
customize(config);
|
||||
@@ -264,6 +265,7 @@ async fn test_communities_say_who_is_told() -> Result<()> {
|
||||
});
|
||||
config.export = Arc::new(ExportPolicy {
|
||||
learned_routes: true,
|
||||
all_paths: false,
|
||||
marks: Marks::parse(&[], &["64601:7".to_string()], &[]).unwrap(),
|
||||
prepend: 1,
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ use bgp_primitives::constants::{AfiSafi, IpVersion, Safi};
|
||||
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
|
||||
use bgp_primitives::parser::{BgpParserError, EncodeError, ParserContext};
|
||||
|
||||
use crate::message::write_nlri;
|
||||
use crate::wire::{
|
||||
Parsed, list, malformed, parse_afi_safi, parse_asn, put_afi_safi, put_asn, put_length, records,
|
||||
whole,
|
||||
@@ -833,6 +832,19 @@ pub(crate) fn parse_nlri_list(
|
||||
list(buf, |buf| parse_nlri(version, with_path_id, buf))
|
||||
}
|
||||
|
||||
/// Writes prefixes as `parse_nlri_list` reads them: for each, its path
|
||||
/// identifier if the session uses them for the family (RFC 7911 §3), its
|
||||
/// length in bits, and the bytes that length covers.
|
||||
pub fn put_nlri_list(nlri: &[Nlri], with_path_id: bool, out: &mut BytesMut) {
|
||||
for n in nlri {
|
||||
if with_path_id {
|
||||
out.put_u32(n.path_id);
|
||||
}
|
||||
out.put_u8(n.prefix.length());
|
||||
out.put(n.prefix.bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// The IP version of the prefixes that make up the NLRI of `family`.
|
||||
///
|
||||
/// Any family can be named in MP_REACH_NLRI and MP_UNREACH_NLRI, but only
|
||||
@@ -897,7 +909,7 @@ impl MpReachNlriPathAttribute {
|
||||
out.put_u8(self.next_hop.wire_len());
|
||||
self.next_hop.to_wire(out);
|
||||
out.put_u8(0);
|
||||
write_nlri(&self.prefixes, ctx.has_add_path(self.family), out);
|
||||
put_nlri_list(&self.prefixes, ctx.has_add_path(self.family), out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -919,6 +931,6 @@ impl MpUnreachNlriPathAttribute {
|
||||
|
||||
pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) {
|
||||
put_afi_safi(self.family, out);
|
||||
write_nlri(&self.prefixes, ctx.has_add_path(self.family), out);
|
||||
put_nlri_list(&self.prefixes, ctx.has_add_path(self.family), out);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use crate::errors::DecodeError;
|
||||
use crate::errors::MsgHeaderSubcode;
|
||||
use crate::errors::OpenMsgSubcode;
|
||||
use crate::open::OpenMessage;
|
||||
use crate::wire::{fill_length, put_length};
|
||||
use crate::wire::{body_has_path_id, fill_length, put_length};
|
||||
|
||||
/// A sentinel AS number used to denote that the actual AS number is provided as
|
||||
/// a 4 byte value in the capabilities instead.
|
||||
@@ -30,10 +30,15 @@ pub const BGP_HEADER_LEN: u16 = 19;
|
||||
/// Maximum BGP message length per RFC 4271 §4.1.
|
||||
pub const BGP_MAX_MSG_LEN: u16 = 4096;
|
||||
|
||||
const BGP_MARKER: [u8; 16] = [0xFF; 16];
|
||||
/// The sixteen octets of ones that open every message (RFC 4271 §4.1).
|
||||
/// Whoever frames a message by hand wants [`begin_message`] and
|
||||
/// [`finish_message`] rather than this.
|
||||
pub const BGP_MARKER: [u8; 16] = [0xFF; 16];
|
||||
|
||||
/// BGP message type codes (RFC 4271 §4.1, RFC 2918).
|
||||
mod msg_type {
|
||||
/// BGP message type codes (RFC 4271 §4.1, RFC 2918), for whoever frames
|
||||
/// a message with [`begin_message`] or tells messages apart by their
|
||||
/// header.
|
||||
pub mod msg_type {
|
||||
pub const OPEN: u8 = 1;
|
||||
pub const UPDATE: u8 = 2;
|
||||
pub const NOTIFICATION: u8 = 3;
|
||||
@@ -162,23 +167,10 @@ impl Message {
|
||||
/// cannot be encoded, `out` is left as it was.
|
||||
pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> {
|
||||
let start = out.len();
|
||||
out.put_slice(&BGP_MARKER);
|
||||
// The length, filled in once the body is written. It counts the
|
||||
// header too.
|
||||
let length_at = out.len();
|
||||
out.put_u16(0);
|
||||
out.put_u8(self.type_code());
|
||||
let written = self.body_to_wire(ctx, out).and_then(|()| {
|
||||
let length = out.len() - start;
|
||||
put_length(
|
||||
out,
|
||||
length_at,
|
||||
2,
|
||||
length,
|
||||
BGP_MAX_MSG_LEN.into(),
|
||||
"BGP message",
|
||||
)
|
||||
});
|
||||
let length_at = begin_message(out, self.type_code());
|
||||
let written = self
|
||||
.body_to_wire(ctx, out)
|
||||
.and_then(|()| finish_message(out, length_at));
|
||||
if let Err(e) = written {
|
||||
out.truncate(start);
|
||||
return Err(e);
|
||||
@@ -215,6 +207,35 @@ impl Message {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the header of a message of type `kind` to the end of `out`,
|
||||
/// with its length left at zero, and returns where the length goes. The
|
||||
/// body follows, and then [`finish_message`] fills the length in: for
|
||||
/// whoever builds a message out of pieces that are not a [`Message`], as
|
||||
/// an exporter streaming a table does.
|
||||
pub fn begin_message(out: &mut BytesMut, kind: u8) -> usize {
|
||||
out.put_slice(&BGP_MARKER);
|
||||
let length_at = out.len();
|
||||
out.put_u16(0);
|
||||
out.put_u8(kind);
|
||||
length_at
|
||||
}
|
||||
|
||||
/// Fills in the length of the message whose header [`begin_message`] put
|
||||
/// at `length_at`, counting the header too, once the body is written. A
|
||||
/// message longer than [`BGP_MAX_MSG_LEN`] cannot be sent, and is an
|
||||
/// error; `out` is left for the caller to cut back.
|
||||
pub fn finish_message(out: &mut BytesMut, length_at: usize) -> Result<(), EncodeError> {
|
||||
let length = out.len() - length_at + BGP_MARKER.len();
|
||||
put_length(
|
||||
out,
|
||||
length_at,
|
||||
2,
|
||||
length,
|
||||
BGP_MAX_MSG_LEN.into(),
|
||||
"BGP message",
|
||||
)
|
||||
}
|
||||
|
||||
/// Decodes the body of an OPEN, with the NOTIFICATION that reports a
|
||||
/// failure (RFC 4271 §6.2).
|
||||
fn decode_open(body: &[u8]) -> Result<OpenMessage, DecodeError> {
|
||||
@@ -256,7 +277,10 @@ pub struct Decoded {
|
||||
pub message: Message,
|
||||
/// The errors in an UPDATE that were handled without resetting the
|
||||
/// session (RFC 7606). Empty for a well-formed UPDATE and for every
|
||||
/// other message.
|
||||
/// other message. These are
|
||||
/// [`DecodedUpdate::errors`](crate::update_decode::DecodedUpdate::errors),
|
||||
/// named here for the one message that can have them, since this is
|
||||
/// the result for any message.
|
||||
pub update_errors: Vec<UpdateError>,
|
||||
/// See [`crate::update_decode::DecodedUpdate::attributes_wire`].
|
||||
pub attributes_wire: Option<Vec<u8>>,
|
||||
@@ -332,14 +356,13 @@ impl UpdateMessage {
|
||||
|
||||
/// Writes the body of the UPDATE (what follows the 19-octet header).
|
||||
pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> {
|
||||
// The prefixes in the body are IPv4 unicast (RFC 4271 §4.3).
|
||||
let with_path_id = ctx.has_add_path(AfiSafi::IPV4_UNICAST);
|
||||
let with_path_id = body_has_path_id(ctx);
|
||||
|
||||
// Each field's two-octet length is filled in once the field is
|
||||
// written.
|
||||
let withdrawn = out.len();
|
||||
out.put_u16(0);
|
||||
write_nlri(&self.withdrawn_routes, with_path_id, out);
|
||||
put_nlri_list(&self.withdrawn_routes, with_path_id, out);
|
||||
fill_length(out, withdrawn, 2, u16::MAX.into(), "withdrawn routes")?;
|
||||
|
||||
let attributes = out.len();
|
||||
@@ -350,23 +373,14 @@ impl UpdateMessage {
|
||||
fill_length(out, attributes, 2, u16::MAX.into(), "path attributes")?;
|
||||
|
||||
// The NLRI take up the rest of the message.
|
||||
write_nlri(&self.announced_routes, with_path_id, out);
|
||||
put_nlri_list(&self.announced_routes, with_path_id, out);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes prefixes as an UPDATE names them: for each, its path identifier
|
||||
/// if the session uses them for the family (RFC 7911 §3), its length in
|
||||
/// bits, and the bytes that length covers.
|
||||
pub(crate) fn write_nlri(nlri: &[Nlri], with_path_id: bool, out: &mut BytesMut) {
|
||||
for n in nlri {
|
||||
if with_path_id {
|
||||
out.put_u32(n.path_id);
|
||||
}
|
||||
out.put_u8(n.prefix.length());
|
||||
out.put(n.prefix.bytes());
|
||||
}
|
||||
}
|
||||
/// The name [`put_nlri_list`] went by before it moved next to the parser
|
||||
/// it is the pair of; the exporter still uses it.
|
||||
pub use crate::attributes::put_nlri_list as write_nlri;
|
||||
|
||||
/// A parser failure as text for a log line.
|
||||
pub(crate) fn describe(e: &nom::Err<BgpParserError<&[u8]>>) -> String {
|
||||
@@ -409,7 +423,7 @@ mod tests {
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use bytes::BytesMut;
|
||||
use bytes::{BufMut, BytesMut};
|
||||
use eyre::{Result, eyre};
|
||||
|
||||
use crate::message::{
|
||||
@@ -1133,6 +1147,31 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A message framed by hand, as the exporter frames an UPDATE it
|
||||
/// builds from pieces, is the message `to_wire` writes; and one that
|
||||
/// grows past the maximum is refused with its length filled in.
|
||||
#[test]
|
||||
fn a_message_framed_by_hand_is_the_one_to_wire_writes() {
|
||||
use crate::message::{begin_message, finish_message, msg_type};
|
||||
let ctx = default_v6_context();
|
||||
let mut whole = BytesMut::new();
|
||||
Message::Update(UpdateMessage::end_of_rib(AfiSafi::IPV4_UNICAST))
|
||||
.to_wire(&ctx, &mut whole)
|
||||
.unwrap();
|
||||
|
||||
let mut by_hand = BytesMut::new();
|
||||
let length_at = begin_message(&mut by_hand, msg_type::UPDATE);
|
||||
assert_eq!(length_at, 16);
|
||||
by_hand.put_u32(0);
|
||||
finish_message(&mut by_hand, length_at).unwrap();
|
||||
assert_eq!(by_hand, whole);
|
||||
|
||||
let mut too_long = BytesMut::new();
|
||||
let length_at = begin_message(&mut too_long, msg_type::UPDATE);
|
||||
too_long.put_bytes(0, 4096);
|
||||
assert!(finish_message(&mut too_long, length_at).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_rejects_bad_marker() {
|
||||
let ctx = default_v6_context();
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
//! be acted on, with treat-as-withdraw and attribute discard already carried
|
||||
//! out, together with a report of what was wrong, for the log.
|
||||
|
||||
use bgp_primitives::constants::{AfiSafi, IpVersion};
|
||||
use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::Nlri;
|
||||
use bgp_primitives::parser::ParserContext;
|
||||
|
||||
@@ -29,6 +29,7 @@ use crate::message::{
|
||||
MpUnreachNlriPathAttribute, NotAnAttribute, PathAttribute, RawAttribute, UpdateMessage,
|
||||
defined_flags, describe, parse_nlri_list, split_attribute,
|
||||
};
|
||||
use crate::wire::body_has_path_id;
|
||||
|
||||
/// How an error in an UPDATE was handled, short of resetting the session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -62,7 +63,9 @@ pub struct UpdateError {
|
||||
pub struct DecodedUpdate {
|
||||
/// The UPDATE after treat-as-withdraw and attribute discard.
|
||||
pub update: UpdateMessage,
|
||||
/// Empty when the UPDATE was well formed.
|
||||
/// What was wrong, in the order met; empty when the UPDATE was well
|
||||
/// formed. Where a message of any type was decoded this is
|
||||
/// [`Decoded::update_errors`](crate::message::Decoded::update_errors).
|
||||
pub errors: Vec<UpdateError>,
|
||||
/// The attributes in `update`, as they were on the wire, for a table
|
||||
/// that keeps attributes in that form and need not write them out
|
||||
@@ -102,9 +105,7 @@ fn reset(subcode: UpdateMsgSubcode, data: Vec<u8>, detail: String) -> DecodeErro
|
||||
/// error here cannot be recovered from: the prefixes are what
|
||||
/// treat-as-withdraw would act on.
|
||||
fn prefixes(ctx: &ParserContext, field: &str, buf: &[u8]) -> Result<Vec<Nlri>, DecodeError> {
|
||||
// The prefixes in the body are IPv4 unicast (RFC 4271 §4.3).
|
||||
let with_path_id = ctx.has_add_path(AfiSafi::IPV4_UNICAST);
|
||||
match parse_nlri_list(IpVersion::Ipv4, with_path_id, buf) {
|
||||
match parse_nlri_list(IpVersion::Ipv4, body_has_path_id(ctx), buf) {
|
||||
Ok((_, prefixes)) => Ok(prefixes),
|
||||
Err(e) => Err(reset(
|
||||
UpdateMsgSubcode::InvalidNetworkField,
|
||||
@@ -234,7 +235,10 @@ struct AttributeReader<'a> {
|
||||
attributes: Vec<PathAttribute>,
|
||||
errors: Vec<UpdateError>,
|
||||
/// The bytes of the attributes that are kept and are a property of a
|
||||
/// route: all but MP_REACH_NLRI and MP_UNREACH_NLRI.
|
||||
/// route: all but MP_REACH_NLRI and MP_UNREACH_NLRI. Left empty on a
|
||||
/// session with two-octet AS numbers, whose bytes no table keeps (see
|
||||
/// [`DecodedUpdate::attributes_wire`]), so that nothing is copied or
|
||||
/// reserved for them.
|
||||
kept_wire: Vec<u8>,
|
||||
/// The type codes met so far, one bit each.
|
||||
seen: [u64; 4],
|
||||
@@ -246,7 +250,7 @@ impl<'a> AttributeReader<'a> {
|
||||
ctx,
|
||||
attributes: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
kept_wire: Vec::with_capacity(wire_len),
|
||||
kept_wire: Vec::with_capacity(if ctx.four_octet_asn { wire_len } else { 0 }),
|
||||
seen: [0; 4],
|
||||
}
|
||||
}
|
||||
@@ -291,7 +295,7 @@ impl<'a> AttributeReader<'a> {
|
||||
route_property,
|
||||
} = self.judge(&raw)?
|
||||
{
|
||||
if route_property {
|
||||
if route_property && self.ctx.four_octet_asn {
|
||||
self.kept_wire.extend_from_slice(raw.wire);
|
||||
}
|
||||
self.attributes.push(attribute);
|
||||
@@ -494,6 +498,7 @@ mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::message::PathAttributeFlags;
|
||||
use bgp_primitives::constants::AfiSafi;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -9,7 +9,7 @@ use nom::IResult;
|
||||
use nom::number::complete::{be_u8, be_u16, be_u32};
|
||||
|
||||
use bgp_primitives::constants::{Afi, AfiSafi, Safi};
|
||||
use bgp_primitives::parser::{BgpParserError, EncodeError};
|
||||
use bgp_primitives::parser::{BgpParserError, EncodeError, ParserContext};
|
||||
|
||||
use crate::message::AS_TRANS;
|
||||
|
||||
@@ -91,6 +91,15 @@ pub(crate) fn put_asn(four_octet: bool, asn: u32, out: &mut BytesMut) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the prefixes in the body of an UPDATE carry path identifiers
|
||||
/// on a session with `ctx`. The body holds IPv4 unicast routes and nothing
|
||||
/// else (RFC 4271 §4.3), so that is the family whose ADD-PATH agreement
|
||||
/// decides it; every other family's prefixes are in MP_REACH_NLRI and
|
||||
/// MP_UNREACH_NLRI, which name their family.
|
||||
pub(crate) fn body_has_path_id(ctx: &ParserContext) -> bool {
|
||||
ctx.has_add_path(AfiSafi::IPV4_UNICAST)
|
||||
}
|
||||
|
||||
/// Parses an address family as the multiprotocol attributes and most
|
||||
/// capabilities name it: a two-octet AFI and a one-octet SAFI.
|
||||
pub(crate) fn parse_afi_safi(buf: &[u8]) -> Parsed<'_, AfiSafi> {
|
||||
|
||||
+142
-25
@@ -13,7 +13,7 @@
|
||||
use std::hash::BuildHasher;
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use bgp_packet::message::{NlriNextHop, PathAttribute, attr_type, split_attribute};
|
||||
use bgp_packet::message::{NlriNextHop, PathAttribute, RawAttribute, attr_type, split_attribute};
|
||||
use bgp_primitives::parser::{EncodeError, ParserContext};
|
||||
use bytes::{BufMut, BytesMut};
|
||||
use foldhash::fast::RandomState;
|
||||
@@ -21,6 +21,10 @@ use hashbrown::HashTable;
|
||||
|
||||
const OPTIONAL: u8 = 0x80;
|
||||
|
||||
/// The type of an AS_PATH segment whose AS numbers are in order; the other
|
||||
/// type, AS_SET, is an unordered handful (RFC 4271 §4.3).
|
||||
const AS_SEQUENCE: u8 = 2;
|
||||
|
||||
/// What route selection reads from a set of attributes (RFC 4271 §9.1.2.2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SelectionKey {
|
||||
@@ -116,7 +120,6 @@ impl SelectionKey {
|
||||
|
||||
/// Reads an AS_PATH value with four-octet AS numbers.
|
||||
fn scan_as_path(mut segments: &[u8]) -> Option<PathScan> {
|
||||
const AS_SEQUENCE: u8 = 2;
|
||||
let mut hops = 0usize;
|
||||
let (mut first, mut last) = (0, 0);
|
||||
let mut at_start = true;
|
||||
@@ -150,7 +153,6 @@ fn scan_as_path(mut segments: &[u8]) -> Option<PathScan> {
|
||||
/// AS numbers to `out`, and returns whether any segment is not a sequence.
|
||||
/// Stops at what is not a segment.
|
||||
fn read_as_path(mut segments: &[u8], out: &mut Vec<u32>) -> bool {
|
||||
const AS_SEQUENCE: u8 = 2;
|
||||
let mut has_set = false;
|
||||
while let [kind, count, rest @ ..] = segments {
|
||||
let Some((asns, rest)) = rest.split_at_checked(usize::from(*count) * 4) else {
|
||||
@@ -187,28 +189,60 @@ impl AttrSet {
|
||||
&self.wire
|
||||
}
|
||||
|
||||
/// The attributes one by one, as they lie in the stored bytes. The
|
||||
/// bytes were checked when the set was made, so this is all of them;
|
||||
/// it stops at the first that does not split as an attribute all the
|
||||
/// same, rather than go on from the middle of one.
|
||||
fn raw_attributes(&self) -> impl Iterator<Item = RawAttribute<'_>> {
|
||||
let mut wire = &*self.wire;
|
||||
std::iter::from_fn(move || {
|
||||
let (attribute, rest) = split_attribute(wire).ok()?;
|
||||
wire = rest;
|
||||
Some(attribute)
|
||||
})
|
||||
}
|
||||
|
||||
/// The value of the attribute of type `type_code`, if the set has one.
|
||||
fn attribute(&self, type_code: u8) -> Option<&[u8]> {
|
||||
self.raw_attributes()
|
||||
.find(|attribute| attribute.type_code == type_code)
|
||||
.map(|attribute| attribute.value)
|
||||
}
|
||||
|
||||
/// The route's next hop: the one that came in MP_REACH_NLRI, or else
|
||||
/// the NEXT_HOP attribute. Read off the stored bytes without building
|
||||
/// anything else, for those who ask it of every route.
|
||||
pub fn next_hop(&self) -> Option<NlriNextHop> {
|
||||
let mut plain = None;
|
||||
let mut wire = &*self.wire;
|
||||
while let Ok((attribute, rest)) = split_attribute(wire) {
|
||||
for attribute in self.raw_attributes() {
|
||||
match attribute.type_code {
|
||||
attr_type::MP_REACH_NLRI => {
|
||||
// Kept as the next hop's length and the next hop.
|
||||
let (length, address) = attribute.value.split_first()?;
|
||||
return NlriNextHop::from_bytes(address)
|
||||
.filter(|_| usize::from(*length) == address.len());
|
||||
}
|
||||
attr_type::MP_REACH_NLRI => return decode_next_hop(attribute.value),
|
||||
attr_type::NEXT_HOP => plain = NlriNextHop::from_bytes(attribute.value),
|
||||
_ => {}
|
||||
}
|
||||
wire = rest;
|
||||
}
|
||||
plain
|
||||
}
|
||||
|
||||
/// Whether the route carries the OTC attribute (RFC 9234): it came
|
||||
/// down from a provider or across from a peer, and is to go on down
|
||||
/// only. Read off the stored bytes without building anything else,
|
||||
/// for those who ask it of every route: a session to a peer or a
|
||||
/// provider asks it of most of the table, to pass over them.
|
||||
pub fn is_only_to_customer(&self) -> bool {
|
||||
self.attribute(attr_type::ONLY_TO_CUSTOMER).is_some()
|
||||
}
|
||||
|
||||
/// Whether the route carries the BLACKHOLE community (RFC 7999), which
|
||||
/// asks for the packets to its prefix to be dropped. Read off the
|
||||
/// stored bytes without building anything else, for those who ask it
|
||||
/// of every route.
|
||||
pub fn is_blackhole(&self) -> bool {
|
||||
const BLACKHOLE: [u8; 4] = [0xFF, 0xFF, 0x02, 0x9A];
|
||||
self.attribute(attr_type::COMMUNITIES)
|
||||
.is_some_and(|communities| communities.as_chunks::<4>().0.contains(&BLACKHOLE))
|
||||
}
|
||||
|
||||
/// Puts the AS numbers of the route's path in `out`, in place of what
|
||||
/// was there: in the order of the path, the neighbor's first and the
|
||||
/// origin's last, sets and all. Returns whether the path has a set in
|
||||
@@ -220,14 +254,10 @@ impl AttrSet {
|
||||
/// else, for those who ask it of every route.
|
||||
pub fn as_path(&self, out: &mut Vec<u32>) -> bool {
|
||||
out.clear();
|
||||
let mut wire = &*self.wire;
|
||||
while let Ok((attribute, rest)) = split_attribute(wire) {
|
||||
if attribute.type_code == attr_type::AS_PATH {
|
||||
return read_as_path(attribute.value, out);
|
||||
match self.attribute(attr_type::AS_PATH) {
|
||||
Some(segments) => read_as_path(segments, out),
|
||||
None => false,
|
||||
}
|
||||
wire = rest;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The attributes, parsed, and the next hop if it came in
|
||||
@@ -274,7 +304,8 @@ fn encode(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes a next hop as an MP_REACH_NLRI that holds nothing else.
|
||||
/// Writes a next hop as an MP_REACH_NLRI that holds nothing else: the
|
||||
/// next hop's length, and the next hop.
|
||||
fn encode_next_hop(next_hop: &NlriNextHop, out: &mut BytesMut) {
|
||||
out.put_u8(OPTIONAL);
|
||||
out.put_u8(attr_type::MP_REACH_NLRI);
|
||||
@@ -283,6 +314,13 @@ fn encode_next_hop(next_hop: &NlriNextHop, out: &mut BytesMut) {
|
||||
next_hop.to_wire(out);
|
||||
}
|
||||
|
||||
/// Reads the next hop out of the value of an MP_REACH_NLRI as
|
||||
/// [`encode_next_hop`] writes it. `None` if the value is not one.
|
||||
fn decode_next_hop(value: &[u8]) -> Option<NlriNextHop> {
|
||||
let (length, address) = value.split_first()?;
|
||||
NlriNextHop::from_bytes(address).filter(|_| usize::from(*length) == address.len())
|
||||
}
|
||||
|
||||
/// Parses path attributes in the form [`AttrSet`] keeps and table dumps
|
||||
/// use (RFC 6396 §4.3.4): four-octet AS numbers, and an MP_REACH_NLRI that
|
||||
/// holds only a next hop. `None` if they are malformed.
|
||||
@@ -293,11 +331,7 @@ fn parse(mut wire: &[u8]) -> Option<(Vec<PathAttribute>, Option<NlriNextHop>)> {
|
||||
while !wire.is_empty() {
|
||||
let (raw, rest) = split_attribute(wire).ok()?;
|
||||
if raw.type_code == attr_type::MP_REACH_NLRI {
|
||||
let (length, address) = raw.value.split_first()?;
|
||||
if usize::from(*length) != address.len() {
|
||||
return None;
|
||||
}
|
||||
next_hop = Some(NlriNextHop::from_bytes(address)?);
|
||||
next_hop = Some(decode_next_hop(raw.value)?);
|
||||
} else {
|
||||
let (_, attribute) = PathAttribute::from_wire(&ctx, raw.wire).ok()?;
|
||||
attributes.push(attribute);
|
||||
@@ -595,6 +629,89 @@ mod tests {
|
||||
assert!(path.is_empty());
|
||||
}
|
||||
|
||||
/// The BLACKHOLE community is read off the stored bytes.
|
||||
#[test]
|
||||
fn a_blackhole_request_is_told_from_the_bytes() {
|
||||
let store = AttrStore::new();
|
||||
let with = |communities: Vec<(u16, u16)>| {
|
||||
let mut attributes = attributes(vec![3333]);
|
||||
attributes.retain(|attribute| !matches!(attribute, PathAttribute::Communities(_)));
|
||||
attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(
|
||||
communities,
|
||||
)));
|
||||
store.intern(&attributes, None).unwrap()
|
||||
};
|
||||
assert!(with(vec![(3333, 100), (0xFFFF, 0x029A)]).is_blackhole());
|
||||
assert!(!with(vec![(3333, 666), (0xFFFF, 0xFF01)]).is_blackhole());
|
||||
assert!(
|
||||
!store
|
||||
.intern(&attributes(vec![3333]), None)
|
||||
.unwrap()
|
||||
.is_blackhole()
|
||||
);
|
||||
}
|
||||
|
||||
/// The OTC attribute is read off the stored bytes, wherever in them it
|
||||
/// lies.
|
||||
#[test]
|
||||
fn otc_is_told_from_the_bytes() {
|
||||
let store = AttrStore::new();
|
||||
let without = store.intern(&attributes(vec![3333]), None).unwrap();
|
||||
assert!(!without.is_only_to_customer());
|
||||
|
||||
let mut with = attributes(vec![3333]);
|
||||
with.insert(1, PathAttribute::OnlyToCustomer(3333));
|
||||
assert!(store.intern(&with, None).unwrap().is_only_to_customer());
|
||||
|
||||
// After COMMUNITIES, which is the last of the usual attributes.
|
||||
let mut after = attributes(vec![3333]);
|
||||
after.push(PathAttribute::OnlyToCustomer(3333));
|
||||
assert!(store.intern(&after, None).unwrap().is_only_to_customer());
|
||||
}
|
||||
|
||||
/// What a session received, with attributes of our own added after it,
|
||||
/// is one set with the parsed form of the whole; the bytes are not
|
||||
/// parsed to find that out, so the bytes must say the same thing.
|
||||
#[test]
|
||||
fn received_attributes_with_ours_added_are_the_same_set_as_the_parsed_form() {
|
||||
let store = AttrStore::new();
|
||||
let ctx = four_octet();
|
||||
let received = attributes(vec![65001, 65002]);
|
||||
let mut wire = BytesMut::new();
|
||||
encode(&received, None, &mut wire).unwrap();
|
||||
let ours = [
|
||||
PathAttribute::OnlyToCustomer(65001),
|
||||
PathAttribute::LocalPref(LocalPrefPathAttribute(150)),
|
||||
];
|
||||
let mut added = BytesMut::new();
|
||||
for attribute in &ours {
|
||||
attribute.to_wire(&ctx, &mut added).unwrap();
|
||||
}
|
||||
let next_hop = NlriNextHop::Ipv6("2001:db8::1".parse().unwrap());
|
||||
|
||||
let mut whole = received.clone();
|
||||
whole.extend(ours.iter().cloned());
|
||||
let parsed = store.intern(&whole, Some(&next_hop)).unwrap();
|
||||
let from_bytes = store
|
||||
.intern_received_with(&wire, &added, Some(&next_hop))
|
||||
.unwrap();
|
||||
assert!(Arc::ptr_eq(&parsed, &from_bytes));
|
||||
assert!(from_bytes.is_only_to_customer());
|
||||
assert_eq!(from_bytes.next_hop(), Some(next_hop));
|
||||
|
||||
// Without a next hop too.
|
||||
let parsed = store.intern(&whole, None).unwrap();
|
||||
let from_bytes = store.intern_received_with(&wire, &added, None).unwrap();
|
||||
assert!(Arc::ptr_eq(&parsed, &from_bytes));
|
||||
|
||||
// An attribute of ours of a type the peer sent is not a set.
|
||||
let mut repeated = BytesMut::new();
|
||||
PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(1))
|
||||
.to_wire(&ctx, &mut repeated)
|
||||
.unwrap();
|
||||
assert!(store.intern_received_with(&wire, &repeated, None).is_none());
|
||||
}
|
||||
|
||||
/// Two of the same attribute are not a set: its readers could each
|
||||
/// take a different one.
|
||||
#[test]
|
||||
|
||||
+254
-176
@@ -95,7 +95,7 @@ struct Entry {
|
||||
}
|
||||
|
||||
/// One journal of one shard.
|
||||
struct Journal {
|
||||
pub(super) struct Journal {
|
||||
kind: Kind,
|
||||
/// The sequence number of the latest change. Counts every change this
|
||||
/// journal is for, whether or not anyone was reading.
|
||||
@@ -171,25 +171,35 @@ impl Journal {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets that `entry`'s prefix had gone, if `entry` is what said so.
|
||||
fn drop_entry(gone: &mut HashMap<IpPrefix, u64, RandomState>, entry: &Entry) {
|
||||
/// Voids the note that `entry`'s prefix had gone, if `entry` is what
|
||||
/// said so: an entry that is dropped must not leave the note behind,
|
||||
/// or the prefix would be given out as gone on the strength of a later
|
||||
/// entry that says something else.
|
||||
fn void_gone_note(gone: &mut HashMap<IpPrefix, u64, RandomState>, entry: &Entry) {
|
||||
if gone.get(&entry.prefix) == Some(&entry.seq) {
|
||||
gone.remove(&entry.prefix);
|
||||
}
|
||||
}
|
||||
|
||||
/// What `entry` says, if it still says something: it is the latest
|
||||
/// change of its prefix. The row, or `None` for a prefix that has gone.
|
||||
fn live<'a>(
|
||||
&self,
|
||||
entry: &Entry,
|
||||
rows: &'a HashTable<Row>,
|
||||
hasher: &RandomState,
|
||||
) -> Option<Option<&'a Row>> {
|
||||
/// What `entry` says: something only if it is the latest change of its
|
||||
/// prefix.
|
||||
fn says<'a>(&self, entry: &Entry, rows: &'a HashTable<Row>, hasher: &RandomState) -> Says<'a> {
|
||||
let hash = hasher.hash_one(entry.prefix);
|
||||
match rows.find(hash, |row| row.prefix == entry.prefix) {
|
||||
Some(row) => (row.seqs.of(self.kind) == entry.seq).then_some(Some(row)),
|
||||
None => (self.gone.get(&entry.prefix) == Some(&entry.seq)).then_some(None),
|
||||
Some(row) if *row.seqs.of(self.kind) == entry.seq => Says::Row(row),
|
||||
None if self.gone.get(&entry.prefix) == Some(&entry.seq) => Says::Gone,
|
||||
_ => Says::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a reader whose cursor is `cursor` is to read from.
|
||||
fn place_of(&self, cursor: u64) -> Place {
|
||||
if cursor < self.floor {
|
||||
Place::Lagged
|
||||
} else if cursor == self.seq {
|
||||
Place::Nothing
|
||||
} else {
|
||||
Place::From(self.entries.partition_point(|entry| entry.seq <= cursor))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +209,26 @@ impl Journal {
|
||||
if self.entries.len() <= self.limits.tidy_at {
|
||||
return;
|
||||
}
|
||||
// What every reader has passed can go.
|
||||
self.drop_passed();
|
||||
// Entries for prefixes that have a row are at most as many as the
|
||||
// rows once the dead ones are out. Entries for prefixes that have
|
||||
// gone are not counted in: they are what a reader that has stopped
|
||||
// reading makes pile up. Twice that, so that the pass over every
|
||||
// entry that finding the dead ones takes is made only once the
|
||||
// journal has doubled, and the cost per change stays small.
|
||||
let limit = 2 * (rows.len() + self.limits.slack);
|
||||
if self.entries.len() <= limit {
|
||||
return;
|
||||
}
|
||||
self.drop_dead(rows, hasher);
|
||||
if self.entries.len() <= limit {
|
||||
return;
|
||||
}
|
||||
self.drop_oldest_half();
|
||||
}
|
||||
|
||||
/// Drops what every reader has passed.
|
||||
fn drop_passed(&mut self) {
|
||||
let passed = self
|
||||
.cursors
|
||||
.iter()
|
||||
@@ -210,40 +239,57 @@ impl Journal {
|
||||
if front.seq > passed {
|
||||
break;
|
||||
}
|
||||
Self::drop_entry(&mut self.gone, front);
|
||||
Self::void_gone_note(&mut self.gone, front);
|
||||
self.entries.pop_front();
|
||||
}
|
||||
|
||||
// Entries for prefixes that have a row are at most as many as the
|
||||
// rows once the dead ones are out. Entries for prefixes that have
|
||||
// gone are not counted in: they are what a reader that has stopped
|
||||
// reading makes pile up.
|
||||
let limit = 2 * (rows.len() + self.limits.slack);
|
||||
if self.entries.len() <= limit {
|
||||
return;
|
||||
}
|
||||
// Dead entries: their prefix has changed again since. This looks
|
||||
// every entry up, but only once the journal has doubled, so the
|
||||
// cost per change stays small.
|
||||
|
||||
/// Drops the dead entries: those whose prefix has changed again since,
|
||||
/// so that a later entry says what they said. This looks every entry
|
||||
/// up.
|
||||
fn drop_dead(&mut self, rows: &HashTable<Row>, hasher: &RandomState) {
|
||||
let mut entries = std::mem::take(&mut self.entries);
|
||||
entries.retain(|entry| self.live(entry, rows, hasher).is_some());
|
||||
entries.retain(|entry| !matches!(self.says(entry, rows, hasher), Says::Nothing));
|
||||
self.entries = entries;
|
||||
|
||||
if self.entries.len() <= limit {
|
||||
return;
|
||||
}
|
||||
// Still too long: a reader is not reading while prefixes come and
|
||||
// go, or more prefixes went at once than are left. The older half
|
||||
// goes, and a reader that had not read it loses its place. Half,
|
||||
// so that readers that are only a little behind keep theirs, and
|
||||
// so that getting a long journal down to size takes few passes.
|
||||
|
||||
/// Drops the older half of the entries without regard to the readers,
|
||||
/// for a journal that is too long even without its dead entries: a
|
||||
/// reader is not reading while prefixes come and go, or more prefixes
|
||||
/// went at once than are left. A reader that had not read what goes
|
||||
/// loses its place. Half, so that readers that are only a little
|
||||
/// behind keep theirs, and so that getting a long journal down to size
|
||||
/// takes few passes.
|
||||
fn drop_oldest_half(&mut self) {
|
||||
for entry in self.entries.drain(..self.entries.len() / 2) {
|
||||
Self::drop_entry(&mut self.gone, &entry);
|
||||
Self::void_gone_note(&mut self.gone, &entry);
|
||||
self.floor = entry.seq;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an entry says; see [`Journal::says`].
|
||||
enum Says<'a> {
|
||||
/// The prefix's row, whose latest change the entry is.
|
||||
Row(&'a Row),
|
||||
/// The prefix has no route any more, and the entry is what says so.
|
||||
Gone,
|
||||
/// Nothing: the prefix has changed again since, and a later entry has
|
||||
/// it.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Where a reader is to read a journal from; see [`Journal::place_of`].
|
||||
enum Place {
|
||||
/// Entries the reader had not read were dropped; it has lost its
|
||||
/// place.
|
||||
Lagged,
|
||||
/// The reader has read everything up to the latest change.
|
||||
Nothing,
|
||||
/// The index of the first entry the reader has not read.
|
||||
From(usize),
|
||||
}
|
||||
|
||||
/// Which of a shard's two journals.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Kind {
|
||||
@@ -253,29 +299,36 @@ pub(super) enum Kind {
|
||||
All,
|
||||
}
|
||||
|
||||
/// The sequence numbers a row remembers, one for each journal: of the
|
||||
/// latest change of its best route, and of any of its routes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct Seqs {
|
||||
best: u64,
|
||||
all: u64,
|
||||
/// One of something for each [`Kind`] of journal.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub(super) struct ByKind<T> {
|
||||
best: T,
|
||||
all: T,
|
||||
}
|
||||
|
||||
impl Seqs {
|
||||
fn of(self, kind: Kind) -> u64 {
|
||||
impl<T> ByKind<T> {
|
||||
pub(super) fn of(&self, kind: Kind) -> &T {
|
||||
match kind {
|
||||
Kind::Best => self.best,
|
||||
Kind::All => self.all,
|
||||
Kind::Best => &self.best,
|
||||
Kind::All => &self.all,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn of_mut(&mut self, kind: Kind) -> &mut T {
|
||||
match kind {
|
||||
Kind::Best => &mut self.best,
|
||||
Kind::All => &mut self.all,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sequence numbers a row remembers, one for each journal: of the
|
||||
/// latest change of its best route, and of any of its routes.
|
||||
pub(super) type Seqs = ByKind<u64>;
|
||||
|
||||
/// The two journals of a shard, written together: every change to a row is
|
||||
/// a change of one of its routes, and some are a change of its best route.
|
||||
pub(super) struct Journals {
|
||||
best: Journal,
|
||||
all: Journal,
|
||||
}
|
||||
pub(super) type Journals = ByKind<Journal>;
|
||||
|
||||
impl Journals {
|
||||
pub(super) fn new(limits: Limits) -> Self {
|
||||
@@ -285,18 +338,13 @@ impl Journals {
|
||||
}
|
||||
}
|
||||
|
||||
fn of(&self, kind: Kind) -> &Journal {
|
||||
match kind {
|
||||
Kind::Best => &self.best,
|
||||
Kind::All => &self.all,
|
||||
}
|
||||
}
|
||||
|
||||
fn of_mut(&mut self, kind: Kind) -> &mut Journal {
|
||||
match kind {
|
||||
Kind::Best => &mut self.best,
|
||||
Kind::All => &mut self.all,
|
||||
}
|
||||
/// Copies the journals' sequence numbers to `latest`, which the table
|
||||
/// keeps outside the shard's lock for readers to look at
|
||||
/// ([`RibTable::latest_seq`]). Called with the lock held, after every
|
||||
/// change to the shard.
|
||||
pub(super) fn publish(&self, latest: &ByKind<AtomicU64>) {
|
||||
latest.best.store(self.best.seq, Ordering::Release);
|
||||
latest.all.store(self.all.seq, Ordering::Release);
|
||||
}
|
||||
|
||||
/// The time by the table's clock that the changes made from now on
|
||||
@@ -362,18 +410,11 @@ pub(super) struct Mark(u64, u64);
|
||||
pub struct Routes<'a>(&'a Row);
|
||||
|
||||
impl<'a> Routes<'a> {
|
||||
/// The routes: at least one, since a prefix with none has no row, and
|
||||
/// is given out as [`Event::Withdrawn`] instead.
|
||||
pub fn iter(&self) -> impl Iterator<Item = RouteRef<'a>> + use<'a> {
|
||||
self.0.routes()
|
||||
}
|
||||
|
||||
/// How many there are: at least one.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.paths.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Routes<'_> {
|
||||
@@ -424,19 +465,15 @@ pub struct Progress {
|
||||
}
|
||||
|
||||
enum Phase {
|
||||
/// Giving out the whole table; `next` is the shard to do next, and
|
||||
/// `announced` whether [`Event::Reset`] has been given. `upto` is the
|
||||
/// time on the table's clock the table is given out as of: the shards
|
||||
/// are gone through one at a time while the table changes, and the
|
||||
/// place taken in each shard's journal is as of that one time, so that
|
||||
/// what follows is consistent from shard to shard. A change after it
|
||||
/// that a later shard's rows already show is read again from the
|
||||
/// Giving out the whole table; `next` is the shard to do next. `upto`
|
||||
/// is the time on the table's clock the table is given out as of, and
|
||||
/// is taken when [`Event::Reset`] is given, so `None` until then: the
|
||||
/// shards are gone through one at a time while the table changes, and
|
||||
/// the place taken in each shard's journal is as of that one time, so
|
||||
/// that what follows is consistent from shard to shard. A change after
|
||||
/// it that a later shard's rows already show is read again from the
|
||||
/// journal; that is harmless.
|
||||
Resync {
|
||||
next: usize,
|
||||
announced: bool,
|
||||
upto: u64,
|
||||
},
|
||||
Resync { next: usize, upto: Option<u64> },
|
||||
/// Following the journals; `next` is the shard to read next, and
|
||||
/// `upto` the time by the table's clock up to which this pass reads
|
||||
/// (see [`Entry::stamp`]).
|
||||
@@ -447,9 +484,11 @@ impl Phase {
|
||||
/// The beginning of a resynchronisation.
|
||||
const RESYNC: Self = Self::Resync {
|
||||
next: 0,
|
||||
announced: false,
|
||||
upto: 0,
|
||||
upto: None,
|
||||
};
|
||||
|
||||
/// The beginning of a pass over the journals.
|
||||
const FOLLOW: Self = Self::Follow { next: 0, upto: 0 };
|
||||
}
|
||||
|
||||
/// Follows the routes of a table: the best of each prefix, or all of them.
|
||||
@@ -466,7 +505,10 @@ pub struct Reader {
|
||||
/// is for the shard's benefit.
|
||||
cursors: Vec<u64>,
|
||||
phase: Phase,
|
||||
/// The table's version when the reader was last found caught up.
|
||||
/// The table's version as it was when the current pass over the
|
||||
/// journals began. [`Reader::changed`] waits while the version is still
|
||||
/// this one: whatever was written since the pass began has moved it
|
||||
/// on, and is there to be read.
|
||||
seen: u64,
|
||||
caught_up: bool,
|
||||
}
|
||||
@@ -482,7 +524,7 @@ impl RibTable {
|
||||
/// those who want the news and not the table. If it falls too far
|
||||
/// behind it is reset like any other, and is then given the table.
|
||||
pub fn reader_from_now(self: &Arc<Self>) -> Reader {
|
||||
let mut reader = self.register(Kind::Best, Phase::Follow { next: 0, upto: 0 });
|
||||
let mut reader = self.register(Kind::Best, Phase::FOLLOW);
|
||||
for (index, shard) in self.shards.iter().enumerate() {
|
||||
let shard = read(shard);
|
||||
reader.set_cursor(index, &shard, shard.journals.best.seq);
|
||||
@@ -598,107 +640,128 @@ impl Reader {
|
||||
pub fn poll(&mut self, budget: usize, mut visit: impl FnMut(Event<'_>)) -> Progress {
|
||||
// A call that may give nothing out would never get anywhere.
|
||||
let budget = budget.max(1);
|
||||
let shards = self.table.shards.len();
|
||||
// Held once for the call: a count taken on the table for every
|
||||
// shard of every pass, by every reader, was the most of what a
|
||||
// router with many peers did.
|
||||
let table = Arc::clone(&self.table);
|
||||
let mut events = 0;
|
||||
loop {
|
||||
match self.phase {
|
||||
Phase::Resync {
|
||||
next,
|
||||
announced,
|
||||
upto,
|
||||
} => {
|
||||
let upto = if announced {
|
||||
upto
|
||||
} else {
|
||||
self.forget_place();
|
||||
visit(Event::Reset);
|
||||
events += 1;
|
||||
self.table.settled()
|
||||
};
|
||||
if next == shards {
|
||||
visit(Event::Synced);
|
||||
events += 1;
|
||||
self.phase = Phase::Follow { next: 0, upto: 0 };
|
||||
continue;
|
||||
}
|
||||
events += self.scan(next, upto, &mut visit);
|
||||
self.phase = Phase::Resync {
|
||||
next: next + 1,
|
||||
announced: true,
|
||||
upto,
|
||||
};
|
||||
if events >= budget {
|
||||
return self.progress(events, false);
|
||||
}
|
||||
let done = match self.phase {
|
||||
Phase::Resync { next, upto } => {
|
||||
self.resync_step(&table, next, upto, budget, &mut events, &mut visit)
|
||||
}
|
||||
Phase::Follow { next, upto } => {
|
||||
// Read before the pass: a change made during it shows as
|
||||
// a newer version, and `changed` then returns at once.
|
||||
// And the pass reads up to one time in every shard
|
||||
// alike, chosen so that about the budget is read.
|
||||
self.follow_pass(&table, next, upto, budget, &mut events, &mut visit)
|
||||
}
|
||||
};
|
||||
if let Some(progress) = done {
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One step of a resynchronisation: the shard `next`, or the end of it
|
||||
/// once every shard has been given out. Returns what to tell the
|
||||
/// caller if the budget is spent, and `None` to go on.
|
||||
fn resync_step(
|
||||
&mut self,
|
||||
table: &RibTable,
|
||||
next: usize,
|
||||
upto: Option<u64>,
|
||||
budget: usize,
|
||||
events: &mut usize,
|
||||
visit: &mut impl FnMut(Event<'_>),
|
||||
) -> Option<Progress> {
|
||||
let upto = upto.unwrap_or_else(|| {
|
||||
self.forget_place();
|
||||
visit(Event::Reset);
|
||||
*events += 1;
|
||||
table.settled()
|
||||
});
|
||||
if next == table.shards.len() {
|
||||
visit(Event::Synced);
|
||||
*events += 1;
|
||||
self.phase = Phase::FOLLOW;
|
||||
return None;
|
||||
}
|
||||
*events += self.scan(table, next, upto, visit);
|
||||
self.phase = Phase::Resync {
|
||||
next: next + 1,
|
||||
upto: Some(upto),
|
||||
};
|
||||
(*events >= budget).then(|| self.progress(*events, false))
|
||||
}
|
||||
|
||||
/// A pass over the journals from shard `next` on, or the rest of one
|
||||
/// that an earlier call cut short. Returns what to tell the caller if
|
||||
/// the pass is to be the call's last, and `None` to go round again.
|
||||
fn follow_pass(
|
||||
&mut self,
|
||||
table: &RibTable,
|
||||
next: usize,
|
||||
upto: u64,
|
||||
budget: usize,
|
||||
events: &mut usize,
|
||||
visit: &mut impl FnMut(Event<'_>),
|
||||
) -> Option<Progress> {
|
||||
// Read before the pass: a change made during it shows as a newer
|
||||
// version, and `changed` then returns at once. And the pass reads
|
||||
// up to one time in every shard alike, chosen so that about the
|
||||
// budget is read.
|
||||
let (version, upto) = if next == 0 {
|
||||
(
|
||||
self.table.version.load(Ordering::Acquire),
|
||||
self.time_for(budget - events),
|
||||
table.version.load(Ordering::Acquire),
|
||||
self.time_for(budget - *events),
|
||||
)
|
||||
} else {
|
||||
(self.seen, upto)
|
||||
};
|
||||
self.seen = version;
|
||||
let mut shard = next;
|
||||
let mut lagged = false;
|
||||
let mut consumed_any = false;
|
||||
let mut more_somewhere = false;
|
||||
while shard < shards {
|
||||
if events >= budget {
|
||||
for shard in next..table.shards.len() {
|
||||
if *events >= budget {
|
||||
self.phase = Phase::Follow { next: shard, upto };
|
||||
return self.progress(events, false);
|
||||
return Some(self.progress(*events, false));
|
||||
}
|
||||
match self.drain(shard, upto, &mut visit) {
|
||||
// Nothing new in this shard, by the number that is read
|
||||
// without its lock: it is left alone, and so are the sessions
|
||||
// writing to it.
|
||||
if self.cursors[shard] == table.latest_seq(shard, self.kind) {
|
||||
continue;
|
||||
}
|
||||
match self.drain(table, shard, upto, visit) {
|
||||
Drained::Lagged => {
|
||||
lagged = true;
|
||||
break;
|
||||
self.phase = Phase::RESYNC;
|
||||
return None;
|
||||
}
|
||||
Drained::Read {
|
||||
events: read,
|
||||
consumed,
|
||||
more,
|
||||
} => {
|
||||
events += read;
|
||||
*events += read;
|
||||
consumed_any |= consumed;
|
||||
more_somewhere |= more;
|
||||
}
|
||||
}
|
||||
shard += 1;
|
||||
}
|
||||
if lagged {
|
||||
self.phase = Phase::RESYNC;
|
||||
continue;
|
||||
}
|
||||
self.phase = Phase::Follow { next: 0, upto: 0 };
|
||||
self.phase = Phase::FOLLOW;
|
||||
if next == 0 && !consumed_any && more_somewhere {
|
||||
// A whole pass got nowhere, though there is more:
|
||||
// it is all beyond the earliest change still under
|
||||
// way, which no pass can read past until it ends.
|
||||
// Asking again at once would spin until then. The
|
||||
// change wakes the readers when it ends.
|
||||
return self.progress(events, true);
|
||||
// A whole pass got nowhere, though there is more: it is all
|
||||
// beyond the earliest change still under way, which no pass
|
||||
// can read past until it ends. Asking again at once would spin
|
||||
// until then. The change wakes the readers when it ends.
|
||||
return Some(self.progress(*events, true));
|
||||
}
|
||||
if next != 0 || more_somewhere {
|
||||
// This pass took up where an earlier call stopped,
|
||||
// so the shards before that point have not been
|
||||
// looked at in this call; or some shard had more
|
||||
// than its share, or changes later than this
|
||||
// pass's time. Go round again from the first.
|
||||
if events >= budget {
|
||||
return self.progress(events, false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return self.progress(events, true);
|
||||
}
|
||||
}
|
||||
// This pass took up where an earlier call stopped, so the
|
||||
// shards before that point have not been looked at in this
|
||||
// call; or some shard had more than its share, or changes
|
||||
// later than this pass's time. Go round again from the first.
|
||||
return (*events >= budget).then(|| self.progress(*events, false));
|
||||
}
|
||||
Some(self.progress(*events, true))
|
||||
}
|
||||
|
||||
fn progress(&mut self, events: usize, caught_up: bool) -> Progress {
|
||||
@@ -706,6 +769,11 @@ impl Reader {
|
||||
Progress { events, caught_up }
|
||||
}
|
||||
|
||||
/// Whether the last [`Reader::poll`] read everything there was.
|
||||
pub fn caught_up(&self) -> bool {
|
||||
self.caught_up
|
||||
}
|
||||
|
||||
/// Waits until there may be something to read. Returns at once if the
|
||||
/// last [`Reader::poll`] did not catch up.
|
||||
pub async fn changed(&mut self) {
|
||||
@@ -760,8 +828,13 @@ impl Reader {
|
||||
/// as of `upto` on the table's clock, in one hold of the shard: what
|
||||
/// changed after that time is in the journal after that place, whether
|
||||
/// or not the rows given out show it already.
|
||||
fn scan(&mut self, index: usize, upto: u64, visit: &mut impl FnMut(Event<'_>)) -> usize {
|
||||
let table = Arc::clone(&self.table);
|
||||
fn scan(
|
||||
&mut self,
|
||||
table: &RibTable,
|
||||
index: usize,
|
||||
upto: u64,
|
||||
visit: &mut impl FnMut(Event<'_>),
|
||||
) -> usize {
|
||||
let shard = read(&table.shards[index]);
|
||||
for row in &shard.rows {
|
||||
visit(self.event_for(row));
|
||||
@@ -797,13 +870,15 @@ impl Reader {
|
||||
// many changes were made with it.
|
||||
let mut earliest = None;
|
||||
for (index, shard) in self.table.shards.iter().enumerate() {
|
||||
let shard = read(shard);
|
||||
let journal = shard.journals.of(self.kind);
|
||||
let cursor = self.cursors[index];
|
||||
if cursor < journal.floor || cursor == journal.seq {
|
||||
if cursor == self.table.latest_seq(index, self.kind) {
|
||||
continue;
|
||||
}
|
||||
let start = journal.entries.partition_point(|entry| entry.seq <= cursor);
|
||||
let shard = read(shard);
|
||||
let journal = shard.journals.of(self.kind);
|
||||
let Place::From(start) = journal.place_of(cursor) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(next) = journal.entries.get(start) {
|
||||
earliest = Some(earliest.map_or(next.stamp, |e: u64| e.min(next.stamp)));
|
||||
}
|
||||
@@ -817,23 +892,27 @@ impl Reader {
|
||||
|
||||
/// Reads a shard's journal from this reader's place, no further than
|
||||
/// the changes made by `upto` on the table's clock.
|
||||
fn drain(&mut self, index: usize, upto: u64, visit: &mut impl FnMut(Event<'_>)) -> Drained {
|
||||
let table = Arc::clone(&self.table);
|
||||
fn drain(
|
||||
&mut self,
|
||||
table: &RibTable,
|
||||
index: usize,
|
||||
upto: u64,
|
||||
visit: &mut impl FnMut(Event<'_>),
|
||||
) -> Drained {
|
||||
let shard = read(&table.shards[index]);
|
||||
let journal = shard.journals.of(self.kind);
|
||||
let mut cursor = self.cursors[index];
|
||||
if cursor < journal.floor {
|
||||
return Drained::Lagged;
|
||||
}
|
||||
if cursor == journal.seq {
|
||||
let start = match journal.place_of(cursor) {
|
||||
Place::Lagged => return Drained::Lagged,
|
||||
Place::Nothing => {
|
||||
return Drained::Read {
|
||||
events: 0,
|
||||
consumed: false,
|
||||
more: false,
|
||||
};
|
||||
}
|
||||
|
||||
let start = journal.entries.partition_point(|entry| entry.seq <= cursor);
|
||||
Place::From(start) => start,
|
||||
};
|
||||
let mut events = 0;
|
||||
let mut more = false;
|
||||
for entry in journal.entries.range(start..) {
|
||||
@@ -842,13 +921,12 @@ impl Reader {
|
||||
break;
|
||||
}
|
||||
cursor = entry.seq;
|
||||
let event = match journal.live(entry, &shard.rows, &shard.hasher) {
|
||||
Some(Some(row)) => self.event_for(row),
|
||||
Some(None) => Event::Withdrawn {
|
||||
let event = match journal.says(entry, &shard.rows, &shard.hasher) {
|
||||
Says::Row(row) => self.event_for(row),
|
||||
Says::Gone => Event::Withdrawn {
|
||||
prefix: entry.prefix,
|
||||
},
|
||||
// The prefix has changed again; a later entry has it.
|
||||
None => continue,
|
||||
Says::Nothing => continue,
|
||||
};
|
||||
visit(event);
|
||||
events += 1;
|
||||
|
||||
@@ -8,7 +8,7 @@ use proptest::prelude::*;
|
||||
|
||||
use super::super::tests::{Fixture, Route, external, internal, prefix, route};
|
||||
use super::super::{AttrSet, PeerIndex, PeerInfo, RibTable};
|
||||
use super::{Event, Limits, Reader};
|
||||
use super::{Event, Kind, Limits, Reader, read};
|
||||
|
||||
/// What a follower would keep: its own copy of the best routes, brought
|
||||
/// up to date from the events it is given.
|
||||
@@ -53,7 +53,10 @@ impl Mirror {
|
||||
}
|
||||
Event::Routes { prefix, routes } => {
|
||||
self.routes_events += 1;
|
||||
assert!(!routes.is_empty(), "a prefix with no routes is withdrawn");
|
||||
assert!(
|
||||
routes.iter().next().is_some(),
|
||||
"a prefix with no routes is withdrawn"
|
||||
);
|
||||
self.routes.insert(
|
||||
prefix,
|
||||
sorted(
|
||||
@@ -115,9 +118,6 @@ impl Mirror {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
/// For the mirror of a follower of all routes: it holds every route in
|
||||
/// the table, and knows which is best.
|
||||
#[track_caller]
|
||||
@@ -213,6 +213,107 @@ fn a_reader_from_now_is_given_the_changes_and_not_the_table() {
|
||||
assert_eq!((best, withdrawn), (vec![new], vec![old]));
|
||||
}
|
||||
|
||||
/// The number a reader checks without a shard's lock must be the journal's
|
||||
/// own after every kind of change, whether or not anyone is reading: a
|
||||
/// reader skips a shard on the strength of it, and a shard with news that
|
||||
/// said it had none would never be read until the next change.
|
||||
#[test]
|
||||
fn the_lock_free_number_is_the_journals_after_every_kind_of_change() {
|
||||
let mut fixture = Fixture::new(&[external(1), external(2)]);
|
||||
let prefixes: Vec<IpPrefix> = (0..16).map(nth_prefix).collect();
|
||||
let agree = |table: &RibTable| {
|
||||
for (index, shard) in table.shards.iter().enumerate() {
|
||||
let shard = read(shard);
|
||||
for kind in [Kind::Best, Kind::All] {
|
||||
assert_eq!(
|
||||
table.latest_seq(index, kind),
|
||||
shard.journals.of(kind).seq,
|
||||
"shard {index}, {kind:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Without a reader, with one of each kind, and with both.
|
||||
for readers in 0..4 {
|
||||
let _best = (readers & 1 != 0).then(|| fixture.table.reader());
|
||||
let _all = (readers & 2 != 0).then(|| fixture.table.reader_of_all_routes());
|
||||
agree(&fixture.table);
|
||||
|
||||
for p in &prefixes {
|
||||
fixture.announce(0, p, &Route::via(&[1]));
|
||||
}
|
||||
agree(&fixture.table);
|
||||
// The same prefixes again, with other attributes.
|
||||
for p in &prefixes {
|
||||
fixture.announce(0, p, &Route::via(&[1, 2]));
|
||||
}
|
||||
agree(&fixture.table);
|
||||
// A second route for each, then the last route of one taken away.
|
||||
for p in &prefixes {
|
||||
fixture.announce(1, p, &Route::via(&[2, 3]));
|
||||
}
|
||||
agree(&fixture.table);
|
||||
fixture.withdraw(0, &prefixes[0]);
|
||||
agree(&fixture.table);
|
||||
fixture.withdraw(1, &prefixes[0]);
|
||||
agree(&fixture.table);
|
||||
|
||||
fixture.table.remove_peer(fixture.peers[1]);
|
||||
agree(&fixture.table);
|
||||
fixture.peers[1] = fixture.table.add_peer(external(2)).unwrap();
|
||||
|
||||
fixture
|
||||
.table
|
||||
.mark_stale(fixture.peers[0], &[IpVersion::Ipv4]);
|
||||
agree(&fixture.table);
|
||||
fixture
|
||||
.table
|
||||
.sweep_stale(fixture.peers[0], &[IpVersion::Ipv4]);
|
||||
agree(&fixture.table);
|
||||
assert!(
|
||||
fixture.table.is_empty(),
|
||||
"round {readers} left routes behind"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A shard that had nothing new for a reader is passed over without its
|
||||
/// lock. The next change to it must bring the reader back.
|
||||
#[test]
|
||||
fn a_shard_passed_over_is_read_again_once_it_has_news() {
|
||||
let fixture = Fixture::on(RibTable::new(1), &[external(1)]);
|
||||
let mut reader = fixture.table.reader();
|
||||
let mut mirror = Mirror::default();
|
||||
mirror.catch_up(&mut reader, 100);
|
||||
assert_eq!(reader.poll(100, |_| panic!("no event expected")).events, 0);
|
||||
|
||||
fixture.announce(0, &prefix("10.0.0.0/8"), &Route::via(&[1]));
|
||||
let mut events = Vec::new();
|
||||
let progress = reader.poll(100, |event| events.push(format!("{event:?}")));
|
||||
assert!(progress.caught_up);
|
||||
assert_eq!(events.len(), 1, "{events:?}");
|
||||
assert!(events[0].starts_with("Best"), "{events:?}");
|
||||
}
|
||||
|
||||
/// As [`a_shard_passed_over_is_read_again_once_it_has_news`], for a reader
|
||||
/// that begins where the journals are rather than with the table.
|
||||
#[test]
|
||||
fn a_shard_passed_over_by_a_reader_from_now_is_read_again_once_it_has_news() {
|
||||
let fixture = Fixture::on(RibTable::new(1), &[external(1)]);
|
||||
fixture.announce(0, &prefix("192.0.2.0/24"), &Route::via(&[1]));
|
||||
let mut reader = fixture.table.reader_from_now();
|
||||
assert!(reader.poll(100, |_| panic!("no event expected")).caught_up);
|
||||
assert_eq!(reader.poll(100, |_| panic!("no event expected")).events, 0);
|
||||
|
||||
fixture.announce(0, &prefix("10.0.0.0/8"), &Route::via(&[1]));
|
||||
let mut events = Vec::new();
|
||||
let progress = reader.poll(100, |event| events.push(format!("{event:?}")));
|
||||
assert!(progress.caught_up);
|
||||
assert_eq!(events.len(), 1, "{events:?}");
|
||||
assert!(events[0].starts_with("Best"), "{events:?}");
|
||||
}
|
||||
|
||||
/// A change to a route that is not the best is nothing to a follower of
|
||||
/// the best routes, and is what a follower of all routes is there for.
|
||||
#[test]
|
||||
|
||||
+88
-28
@@ -193,13 +193,16 @@ impl Row {
|
||||
.position(|(index, path)| path.peer() == peer && self.path_id(index) == path_id)
|
||||
}
|
||||
|
||||
fn push(&mut self, path: Path, path_id: u32) {
|
||||
/// Adds a route. `peers` is how many peers the table has: a prefix
|
||||
/// that one of them announces, the others mostly announce too, so a
|
||||
/// row is given room for that many at its first growth.
|
||||
fn push(&mut self, path: Path, path_id: u32, peers: usize) {
|
||||
if path_id != 0 && self.path_ids.is_empty() {
|
||||
self.path_ids = vec![0; self.paths.len()];
|
||||
}
|
||||
push_tight(&mut self.paths, path);
|
||||
push_tight(&mut self.paths, path, peers);
|
||||
if !self.path_ids.is_empty() {
|
||||
push_tight(&mut self.path_ids, path_id);
|
||||
push_tight(&mut self.path_ids, path_id, peers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,10 +313,21 @@ struct Shard {
|
||||
journals: journal::Journals,
|
||||
}
|
||||
|
||||
/// The routing table. Shared between threads by reference; every method
|
||||
/// takes `&self`.
|
||||
/// A shard, and beside its lock what is read without taking it.
|
||||
struct ShardSlot {
|
||||
lock: RwLock<Shard>,
|
||||
/// The sequence number of each of the shard's journals as of the last
|
||||
/// change to the shard, stored by the change while it still holds the
|
||||
/// lock ([`RibTable::change`]). A reader looks at it first, and passes
|
||||
/// over a shard with nothing new for it without stopping the shard's
|
||||
/// writers.
|
||||
latest: journal::ByKind<AtomicU64>,
|
||||
}
|
||||
|
||||
/// The routing table. Shared between threads by reference: nothing takes
|
||||
/// `&mut self`, and the readers ([`Reader`]) keep an `Arc` of their own.
|
||||
pub struct RibTable {
|
||||
shards: Box<[RwLock<Shard>]>,
|
||||
shards: Box<[ShardSlot]>,
|
||||
hasher: RandomState,
|
||||
attrs: AttrStore,
|
||||
/// Which peer numbers are in use. Changing the peer list also takes
|
||||
@@ -354,15 +368,16 @@ impl RibTable {
|
||||
let hasher = RandomState::default();
|
||||
Self {
|
||||
shards: (0..shards.max(1))
|
||||
.map(|_| {
|
||||
RwLock::new(Shard {
|
||||
.map(|_| ShardSlot {
|
||||
lock: RwLock::new(Shard {
|
||||
rows: HashTable::new(),
|
||||
routes: 0,
|
||||
peers: Vec::new(),
|
||||
hasher: hasher.clone(),
|
||||
scratch: select::Scratch::default(),
|
||||
journals: journal::Journals::new(limits),
|
||||
})
|
||||
}),
|
||||
latest: journal::ByKind::default(),
|
||||
})
|
||||
.collect(),
|
||||
hasher,
|
||||
@@ -513,7 +528,7 @@ impl RibTable {
|
||||
/// How many routes of `peer` are stale; see [`RibTable::mark_stale`].
|
||||
/// Looks at every prefix in the table.
|
||||
#[cfg(test)]
|
||||
pub(super) fn count_stale(&self, peer: PeerIndex) -> usize {
|
||||
fn count_stale(&self, peer: PeerIndex) -> usize {
|
||||
self.shards
|
||||
.iter()
|
||||
.map(|shard| {
|
||||
@@ -613,18 +628,22 @@ impl RibTable {
|
||||
/// journal one of them follows was written to.
|
||||
fn change<'a, T>(
|
||||
&'a self,
|
||||
parts: impl IntoIterator<Item = (&'a RwLock<Shard>, T)>,
|
||||
parts: impl IntoIterator<Item = (&'a ShardSlot, T)>,
|
||||
mut apply: impl FnMut(&mut Shard, T),
|
||||
) {
|
||||
let change = self.begin_change();
|
||||
let mut news = false;
|
||||
for (shard, part) in parts {
|
||||
let mut shard = write(shard);
|
||||
for (slot, part) in parts {
|
||||
let mut shard = write(slot);
|
||||
shard.journals.set_stamp(change.stamp);
|
||||
let before = shard.journals.mark();
|
||||
apply(&mut shard, part);
|
||||
shard.tidy_journals();
|
||||
news |= shard.journals.has_news_since(before);
|
||||
// Once per change, not once per route; and before the lock is
|
||||
// let go, so that a reader that sees the new number and takes
|
||||
// the lock finds the entries behind it.
|
||||
shard.journals.publish(&slot.latest);
|
||||
}
|
||||
drop(change);
|
||||
if news {
|
||||
@@ -648,9 +667,18 @@ impl RibTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sequence number of shard `shard`'s journal of `kind` as of the
|
||||
/// last change to the shard, without the shard's lock.
|
||||
fn latest_seq(&self, shard: usize, kind: journal::Kind) -> u64 {
|
||||
self.shards[shard]
|
||||
.latest
|
||||
.of(kind)
|
||||
.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// The time on the clock up to which every change is complete in every
|
||||
/// shard: everything before the earliest one still under way.
|
||||
pub(super) fn settled(&self) -> u64 {
|
||||
fn settled(&self) -> u64 {
|
||||
let in_progress = self
|
||||
.in_progress
|
||||
.lock()
|
||||
@@ -670,17 +698,31 @@ impl RibTable {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Makes room for `prefixes` prefixes, spread over the shards, so that
|
||||
/// a table of that size goes in without the shards growing as it
|
||||
/// does. Growing a shard moves every row it has into a bigger table;
|
||||
/// over a full table that was a seventh of what a session spent
|
||||
/// taking it in. Room that is already there is kept.
|
||||
pub fn expect_prefixes(&self, prefixes: usize) {
|
||||
let each = prefixes.div_ceil(self.shards.len());
|
||||
for shard in &self.shards {
|
||||
let mut shard = write(shard);
|
||||
let hasher = shard.hasher.clone();
|
||||
let additional = each.saturating_sub(shard.rows.len());
|
||||
shard
|
||||
.rows
|
||||
.reserve(additional, |row| hasher.hash_one(row.prefix));
|
||||
}
|
||||
}
|
||||
|
||||
/// How many prefixes have at least one route.
|
||||
pub fn prefix_count(&self) -> usize {
|
||||
self.shards.iter().map(|shard| read(shard).rows.len()).sum()
|
||||
}
|
||||
|
||||
/// The best route for `prefix`: who it is from, and its attributes.
|
||||
pub fn best(&self, prefix: &IpPrefix) -> Option<(PeerIndex, Arc<AttrSet>)> {
|
||||
self.with_row(prefix, |row| {
|
||||
let best = &row.paths[row.best()];
|
||||
(best.peer(), Arc::clone(&best.attrs))
|
||||
})
|
||||
pub fn best(&self, prefix: &IpPrefix) -> Option<Route> {
|
||||
self.with_row(prefix, |row| row.route(row.best()).to_owned())
|
||||
}
|
||||
|
||||
/// Every route for `prefix`.
|
||||
@@ -778,14 +820,14 @@ impl RibTable {
|
||||
}
|
||||
}
|
||||
|
||||
fn read(shard: &RwLock<Shard>) -> RwLockReadGuard<'_, Shard> {
|
||||
fn read(shard: &ShardSlot) -> RwLockReadGuard<'_, Shard> {
|
||||
// A panic under the lock cannot leave a row half changed in a way that
|
||||
// is unsafe to read; the poison is ignored, as for the server's tables.
|
||||
shard.read().unwrap_or_else(PoisonError::into_inner)
|
||||
shard.lock.read().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn write(shard: &RwLock<Shard>) -> RwLockWriteGuard<'_, Shard> {
|
||||
shard.write().unwrap_or_else(PoisonError::into_inner)
|
||||
fn write(shard: &ShardSlot) -> RwLockWriteGuard<'_, Shard> {
|
||||
shard.lock.write().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A change to the table that is under way; see [`RibTable::begin_change`].
|
||||
@@ -838,12 +880,30 @@ fn spread(hash: u64, count: usize) -> usize {
|
||||
((stirred * count as u64) >> 32) as usize
|
||||
}
|
||||
|
||||
/// The most room a row is given at its first growth, however many peers
|
||||
/// there are: a route server's clients do not all announce every prefix.
|
||||
const FIRST_ROOM: usize = 8;
|
||||
|
||||
/// Adds to a vector without the doubling that `push` does: a row of a few
|
||||
/// hundred routes should not hold room for as many again. Grows by a
|
||||
/// quarter, which keeps adding cheap on average.
|
||||
fn push_tight<T>(vec: &mut Vec<T>, value: T) {
|
||||
/// hundred routes should not hold room for as many again. The first
|
||||
/// growth is to `expected` (at most [`FIRST_ROOM`]), since with several
|
||||
/// full tables nearly every row gets that many routes, and growing a row
|
||||
/// one route at a time was a twelfth of what taking in four tables cost.
|
||||
/// After that it grows by a quarter, which keeps adding cheap on average.
|
||||
fn push_tight<T>(vec: &mut Vec<T>, value: T, expected: usize) {
|
||||
if vec.len() == vec.capacity() {
|
||||
vec.reserve_exact((vec.len() / 4).max(1));
|
||||
let first = expected.min(FIRST_ROOM);
|
||||
let more = if vec.capacity() < first {
|
||||
first - vec.len()
|
||||
} else if vec.capacity() < FIRST_ROOM {
|
||||
// A peer that sends several routes for a prefix (ADD-PATH)
|
||||
// takes a row past the peers' count: doubled while small,
|
||||
// and from there by a quarter.
|
||||
vec.capacity()
|
||||
} else {
|
||||
(vec.len() / 4).max(1)
|
||||
};
|
||||
vec.reserve_exact(more);
|
||||
}
|
||||
vec.push(value);
|
||||
}
|
||||
@@ -885,7 +945,7 @@ impl Shard {
|
||||
|
||||
let (outcome, best_changed) = match row.position(peer, path_id) {
|
||||
None => {
|
||||
row.push(path, path_id);
|
||||
row.push(path, path_id, self.peers.len());
|
||||
self.routes += 1;
|
||||
let outcome = Outcome {
|
||||
added: 1,
|
||||
|
||||
@@ -162,7 +162,7 @@ impl Fixture {
|
||||
|
||||
/// Which peer (by order added) the best route for `prefix` is from.
|
||||
fn best(&self, prefix: &IpPrefix) -> Option<usize> {
|
||||
let (peer, _) = self.table.best(prefix)?;
|
||||
let peer = self.table.best(prefix)?.peer;
|
||||
self.peers.iter().position(|p| *p == peer)
|
||||
}
|
||||
}
|
||||
@@ -957,13 +957,13 @@ fn a_row_can_hold_more_routes_than_a_sixteen_bit_index_counts() {
|
||||
let mut scratch = select::Scratch::default();
|
||||
let count = usize::from(u16::MAX) + 10;
|
||||
for path_id in 2..=count as u32 {
|
||||
row.push(path(&worse), path_id);
|
||||
row.push(path(&worse), path_id, peers.len());
|
||||
}
|
||||
// Equal routes from one peer: the lowest identifier is the best.
|
||||
assert!(!select::after_add(&mut row, &peers, &mut scratch));
|
||||
assert_eq!(row.best(), 0);
|
||||
|
||||
row.push(path(&better), count as u32 + 1);
|
||||
row.push(path(&better), count as u32 + 1, peers.len());
|
||||
assert!(select::after_add(&mut row, &peers, &mut scratch));
|
||||
assert_eq!(row.best(), count);
|
||||
assert_eq!(row.route(row.best()).path_id, count as u32 + 1);
|
||||
@@ -1002,3 +1002,31 @@ fn routes_can_be_picked_out_of_the_table() {
|
||||
);
|
||||
assert!(table.routes_where(0, |_| true).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_told_what_to_expect_has_the_room_before_the_routes_come() {
|
||||
let table = RibTable::new(8);
|
||||
table.expect_prefixes(100_000);
|
||||
let room: usize = table
|
||||
.shards
|
||||
.iter()
|
||||
.map(|shard| read(shard).rows.capacity())
|
||||
.sum();
|
||||
assert!(room >= 100_000, "room for {room} prefixes");
|
||||
// Asking again for less, or for the same, takes nothing away.
|
||||
table.expect_prefixes(10);
|
||||
let still: usize = table
|
||||
.shards
|
||||
.iter()
|
||||
.map(|shard| read(shard).rows.capacity())
|
||||
.sum();
|
||||
assert_eq!(still, room);
|
||||
// And it is a table like any other.
|
||||
let peer = table.add_peer(external(1)).unwrap();
|
||||
let attrs = table
|
||||
.attrs()
|
||||
.intern(&Route::via(&[64512]).attributes(), None)
|
||||
.unwrap();
|
||||
table.announce(peer, &attrs, &[Nlri::from(prefix("192.0.2.0/24"))]);
|
||||
assert_eq!(table.prefix_count(), 1);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ repository.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
foldhash.workspace = true
|
||||
bgp-packet.workspace = true
|
||||
bgp-primitives.workspace = true
|
||||
bgp-rib.workspace = true
|
||||
|
||||
@@ -220,6 +220,22 @@ message Kernel {
|
||||
// was not announced again is removed.
|
||||
// Zero removes them at once.
|
||||
optional uint32 stale_seconds = 4;
|
||||
|
||||
// The kernel's table whose routes say how a next hop that is not a
|
||||
// neighbour is reached: its connected and static routes, and an IGP's
|
||||
// if there is one. Defaults to 254, the main table. A route whose next
|
||||
// hop is covered by such a route is installed with that route's gateway
|
||||
// and interface, the kernel not looking gateways up in turn. Routes put
|
||||
// in the table by BGP, this daemon's or another's, never count: a next
|
||||
// hop reached only by way of a BGP route rests on what may itself rest
|
||||
// on a next hop.
|
||||
uint32 resolution_table = 5;
|
||||
|
||||
// Whether the default route in that table counts as the way to a next
|
||||
// hop. Off by default: on a router the default route points at a
|
||||
// transit provider, and a next hop "reached" only by sending the
|
||||
// packets to the Internet at large is not reached.
|
||||
bool resolve_via_default_route = 6;
|
||||
}
|
||||
|
||||
// Where to get the validated ROA payloads that say which AS may originate
|
||||
@@ -384,6 +400,17 @@ message Peer {
|
||||
// new peer whose side is not ready yet, or one that is to open the
|
||||
// connections itself.
|
||||
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. The routing table is also made with room
|
||||
// for the largest of these from the start, so that it need not grow,
|
||||
// and so use twice its memory for a moment, while the tables come in.
|
||||
uint32 expected_routes = 17;
|
||||
}
|
||||
|
||||
// What is announced to a peer.
|
||||
@@ -415,6 +442,14 @@ message ExportPolicy {
|
||||
// Pass on the best routes learned from other peers.
|
||||
bool learned_routes = 1;
|
||||
|
||||
// Pass on every route learned from other peers, not only the best of
|
||||
// each prefix, each with a path identifier (ADD-PATH, RFC 7911). For a
|
||||
// route collector, which wants to see every path. Needs learned_routes,
|
||||
// and the peer's add_path to offer sending; for a family the peer did
|
||||
// not agree to receive several routes for, the best goes as usual. The
|
||||
// export policy and the limits apply to every route alike.
|
||||
bool all_paths = 6;
|
||||
|
||||
// Communities put on every route announced to the peer: which router
|
||||
// it went through, say. "64601:100", or a well-known name.
|
||||
repeated string add_communities = 2;
|
||||
@@ -547,6 +582,18 @@ message ImportPolicy {
|
||||
// looked at. A rule sees the route as the statements above leave it:
|
||||
// with our communities taken off and ours put on.
|
||||
repeated ImportRule rules = 20;
|
||||
|
||||
// By default a route with the BLACKHOLE community (65535:666, RFC
|
||||
// 7999), which asks for the traffic to its prefix to be dropped, is
|
||||
// refused ("blackhole request"). True takes such a route if it is for
|
||||
// one address (a /32 or /128), whatever the longest prefix taken
|
||||
// otherwise, and inside what the peer may announce; its next hop is
|
||||
// not looked at. The traffic to the address is then dropped by this
|
||||
// router (a blackhole route in the kernel), and the route goes on to
|
||||
// the peers the export policy sends it to, community and all, for them
|
||||
// to do the same. For a customer under attack, and for a route server
|
||||
// whose blackhole routes are to be honoured.
|
||||
bool accept_blackholes = 21;
|
||||
}
|
||||
|
||||
// What is done to the routes with some communities, or for some prefixes:
|
||||
|
||||
@@ -54,6 +54,14 @@ impl From<IpAddr> for PeerAddress {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `wanted`, as someone typed it, names the peer at `address`
|
||||
/// that is configured as `name`, if it is: by that name, or by the address
|
||||
/// as [`PeerAddress::is_called`] reads it. The one rule for every place a
|
||||
/// peer is asked for by name.
|
||||
pub fn names_peer(wanted: &str, name: Option<&str>, address: PeerAddress) -> bool {
|
||||
name == Some(wanted) || address.is_called(wanted)
|
||||
}
|
||||
|
||||
/// The address, and the interface by name if there is one: `fe80::1%eth0`.
|
||||
/// An interface that is no longer there goes by its number.
|
||||
impl fmt::Display for PeerAddress {
|
||||
@@ -325,6 +333,12 @@ mod tests {
|
||||
assert!(there.is_called("fe80::1"));
|
||||
assert!(!there.is_called("fe80::1%other"));
|
||||
assert!(!there.is_called("fe80::2"));
|
||||
// By its configured name as well, where it has one.
|
||||
assert!(names_peer("upstream", Some("upstream"), there));
|
||||
assert!(names_peer("fe80::1", Some("upstream"), there));
|
||||
assert!(names_peer("fe80::1", None, there));
|
||||
assert!(!names_peer("upstream", None, there));
|
||||
assert!(!names_peer("other", Some("upstream"), there));
|
||||
assert_eq!(
|
||||
socket_text(peer.to_dial().unwrap()),
|
||||
format!("[fe80::1%{name}]:179")
|
||||
|
||||
+1270
-321
File diff suppressed because it is too large.
Load diff
+199
-122
@@ -11,6 +11,7 @@ use bgp_primitives::constants::{AfiSafi, IpVersion};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
||||
use bgp_rpki::RpkiConfig;
|
||||
use protobuf::EnumOrUnknown;
|
||||
|
||||
use crate::address::{Endpoint, PeerAddress};
|
||||
use crate::error::{ConfigError, ExportProblem, ImportProblem, OriginateProblem, PeerProblem};
|
||||
@@ -113,6 +114,9 @@ pub struct PeerConfig {
|
||||
pub reflector_client: bool,
|
||||
/// Do not dial the peer: wait for it to connect.
|
||||
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 {
|
||||
@@ -138,20 +142,28 @@ impl Config {
|
||||
let listen_endpoints = parse_listen_endpoints(&proto.listen_endpoints)?;
|
||||
|
||||
let defaults = PeerDefaults {
|
||||
export: Arc::new(match proto.export.as_ref() {
|
||||
Some(export) => parse_export(export)?,
|
||||
None => ExportPolicy::default(),
|
||||
}),
|
||||
export_limits: match proto.export_limits.as_ref() {
|
||||
Some(limits) => parse_export_limits(limits)
|
||||
export: Arc::new(
|
||||
proto
|
||||
.export
|
||||
.as_ref()
|
||||
.map(parse_export)
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
export_limits: proto
|
||||
.export_limits
|
||||
.as_ref()
|
||||
.map(parse_export_limits)
|
||||
.transpose()
|
||||
.map_err(ConfigError::ExportLimits)?
|
||||
.flatten()
|
||||
.map(Arc::new),
|
||||
None => None,
|
||||
},
|
||||
graceful_restart: match proto.graceful_restart.as_ref() {
|
||||
Some(own) => GracefulRestartConfig::default().overridden_by(own)?,
|
||||
None => GracefulRestartConfig::default(),
|
||||
},
|
||||
graceful_restart: proto
|
||||
.graceful_restart
|
||||
.as_ref()
|
||||
.map(|own| GracefulRestartConfig::default().overridden_by(own))
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let mut peers = parse_peers(proto.peers, proto.local_asn, &defaults)?;
|
||||
check_roles(
|
||||
@@ -176,16 +188,7 @@ impl Config {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let rpki = match proto.rpki.as_ref() {
|
||||
Some(rpki) => Some(parse_rpki(rpki)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let connect_retry = if proto.connect_retry_seconds == 0 {
|
||||
DEFAULT_CONNECT_RETRY
|
||||
} else {
|
||||
Duration::from_secs(proto.connect_retry_seconds as u64)
|
||||
};
|
||||
let rpki = proto.rpki.as_ref().map(parse_rpki).transpose()?;
|
||||
|
||||
Ok(Self {
|
||||
router_id,
|
||||
@@ -194,7 +197,7 @@ impl Config {
|
||||
hold_time_secs: DEFAULT_HOLD_TIME_SECS,
|
||||
listen_endpoints,
|
||||
peers,
|
||||
connect_retry,
|
||||
connect_retry: seconds(proto.connect_retry_seconds).unwrap_or(DEFAULT_CONNECT_RETRY),
|
||||
collision_detect_established_state: proto.collision_detect_established_state,
|
||||
accept_unconfigured_peers: proto.accept_unconfigured_peers,
|
||||
originate,
|
||||
@@ -395,16 +398,38 @@ fn parse_identifier(text: &str) -> Option<Ipv4Addr> {
|
||||
.filter(|id| !id.is_unspecified())
|
||||
}
|
||||
|
||||
/// A number the configuration has as zero is a field left out: the
|
||||
/// protocol buffer has no way of telling the two apart, and nothing
|
||||
/// configured here is meaningfully zero.
|
||||
fn given(n: u32) -> Option<u32> {
|
||||
(n > 0).then_some(n)
|
||||
}
|
||||
|
||||
/// A time given in seconds, if it was given.
|
||||
fn seconds(n: u32) -> Option<Duration> {
|
||||
given(n).map(|n| Duration::from_secs(n.into()))
|
||||
}
|
||||
|
||||
/// A list of prefixes, if any are listed: an empty list leaves the setting
|
||||
/// out rather than allowing nothing.
|
||||
fn parse_prefixes_if_any(
|
||||
ranges: &[proto::PrefixRange],
|
||||
) -> Result<Option<PrefixSet>, ImportProblem> {
|
||||
if ranges.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
parse_prefixes(ranges).map(Some)
|
||||
}
|
||||
|
||||
fn parse_kernel(kernel: &proto::Kernel) -> FibConfig {
|
||||
let defaults = FibConfig::default();
|
||||
FibConfig {
|
||||
// The kernel has no table 0 to put routes in.
|
||||
table: match kernel.table {
|
||||
0 => defaults.table,
|
||||
table => table,
|
||||
},
|
||||
table: given(kernel.table).unwrap_or(defaults.table),
|
||||
metric: kernel.metric.unwrap_or(defaults.metric),
|
||||
nexthop_objects: !kernel.without_nexthop_objects,
|
||||
resolution_table: given(kernel.resolution_table).unwrap_or(defaults.resolution_table),
|
||||
resolve_via_default: kernel.resolve_via_default_route,
|
||||
stale_time: kernel.stale_seconds.map_or(defaults.stale_time, |seconds| {
|
||||
Duration::from_secs(seconds.into())
|
||||
}),
|
||||
@@ -420,8 +445,12 @@ fn parse_prepend(prepend: u32) -> Option<u8> {
|
||||
}
|
||||
|
||||
fn parse_export(export: &proto::ExportPolicy) -> Result<ExportPolicy, ExportProblem> {
|
||||
if export.all_paths && !export.learned_routes {
|
||||
return Err(ExportProblem::AllPathsWithoutLearnedRoutes);
|
||||
}
|
||||
Ok(ExportPolicy {
|
||||
learned_routes: export.learned_routes,
|
||||
all_paths: export.all_paths,
|
||||
marks: Marks::parse(
|
||||
&export.remove_communities,
|
||||
&export.add_communities,
|
||||
@@ -440,12 +469,8 @@ fn parse_export_limits(
|
||||
limits: &proto::ExportLimits,
|
||||
) -> Result<Option<ExportLimits>, ImportProblem> {
|
||||
let limits = ExportLimits {
|
||||
prefixes: if limits.prefixes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parse_prefixes(&limits.prefixes)?)
|
||||
},
|
||||
max_prefixes: Some(limits.max_prefixes).filter(|limit| *limit > 0),
|
||||
prefixes: parse_prefixes_if_any(&limits.prefixes)?,
|
||||
max_prefixes: given(limits.max_prefixes),
|
||||
};
|
||||
Ok((limits != ExportLimits::default()).then_some(limits))
|
||||
}
|
||||
@@ -460,38 +485,17 @@ fn parse_peer(peer: Peer, defaults: &PeerDefaults) -> Result<PeerConfig, PeerPro
|
||||
return Err(PeerProblem::RemoteAsn);
|
||||
}
|
||||
|
||||
let hold_time_secs = match peer.hold_time_seconds {
|
||||
None => DEFAULT_HOLD_TIME_SECS,
|
||||
// RFC 4271 §4.2: zero, or at least three seconds.
|
||||
Some(1 | 2) => return Err(PeerProblem::HoldTime),
|
||||
Some(secs) => u16::try_from(secs).map_err(|_| PeerProblem::HoldTime)?,
|
||||
};
|
||||
|
||||
let local_address = if peer.local_address.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let ip = IpAddr::from_str(&peer.local_address)
|
||||
.map_err(|_| PeerProblem::LocalAddress(peer.local_address.clone()))?;
|
||||
if ip.is_ipv4() != endpoint.ip.is_ipv4() {
|
||||
let hold_time_secs = parse_hold_time(peer.hold_time_seconds)?;
|
||||
let local_address = (!peer.local_address.is_empty())
|
||||
.then(|| {
|
||||
IpAddr::from_str(&peer.local_address)
|
||||
.map_err(|_| PeerProblem::LocalAddress(peer.local_address.clone()))
|
||||
})
|
||||
.transpose()?;
|
||||
if local_address.is_some_and(|ip| ip.is_ipv4() != endpoint.ip.is_ipv4()) {
|
||||
return Err(PeerProblem::AddressFamilyMismatch);
|
||||
}
|
||||
Some(ip)
|
||||
};
|
||||
|
||||
let families = if peer.address_families.is_empty() {
|
||||
FamilySet::supported()
|
||||
} else {
|
||||
peer.address_families
|
||||
.iter()
|
||||
.map(|family| match family.enum_value() {
|
||||
Ok(proto::AddressFamily::IPV4_UNICAST) => Ok(AfiSafi::IPV4_UNICAST),
|
||||
Ok(proto::AddressFamily::IPV6_UNICAST) => Ok(AfiSafi::IPV6_UNICAST),
|
||||
Ok(proto::AddressFamily::ADDRESS_FAMILY_UNSPECIFIED) | Err(_) => {
|
||||
Err(PeerProblem::AddressFamily)
|
||||
}
|
||||
})
|
||||
.collect::<Result<_, _>>()?
|
||||
};
|
||||
let families = parse_families(&peer.address_families)?;
|
||||
|
||||
let add_path = match peer.add_path.enum_value() {
|
||||
Ok(proto::AddPath::ADD_PATH_OFF) => None,
|
||||
@@ -501,20 +505,14 @@ fn parse_peer(peer: Peer, defaults: &PeerDefaults) -> Result<PeerConfig, PeerPro
|
||||
Err(_) => return Err(PeerProblem::AddPath),
|
||||
};
|
||||
|
||||
let import = match peer.import.as_ref() {
|
||||
Some(import) => parse_import(import)?,
|
||||
None => ImportPolicy::default(),
|
||||
};
|
||||
let import = peer
|
||||
.import
|
||||
.as_ref()
|
||||
.map(parse_import)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let role = match peer.role.enum_value() {
|
||||
Ok(proto::Role::ROLE_UNSPECIFIED) => None,
|
||||
Ok(proto::Role::PROVIDER) => Some(PeerRole::Provider),
|
||||
Ok(proto::Role::CUSTOMER) => Some(PeerRole::Customer),
|
||||
Ok(proto::Role::PEER) => Some(PeerRole::Peer),
|
||||
Ok(proto::Role::ROUTE_SERVER) => Some(PeerRole::RouteServer),
|
||||
Ok(proto::Role::ROUTE_SERVER_CLIENT) => Some(PeerRole::RouteServerClient),
|
||||
Err(_) => return Err(PeerProblem::Role),
|
||||
};
|
||||
let role = parse_role(peer.role)?;
|
||||
if peer.require_role && role.is_none() {
|
||||
return Err(PeerProblem::RequireRoleWithoutRole);
|
||||
}
|
||||
@@ -538,23 +536,74 @@ fn parse_peer(peer: Peer, defaults: &PeerDefaults) -> Result<PeerConfig, PeerPro
|
||||
import,
|
||||
role,
|
||||
require_role: peer.require_role,
|
||||
export: match peer.export.as_ref() {
|
||||
Some(own) => Arc::new(parse_export(own)?),
|
||||
None => Arc::clone(&defaults.export),
|
||||
},
|
||||
export_limits: match peer.export_limits.as_ref() {
|
||||
Some(own) => parse_export_limits(own)
|
||||
export: peer
|
||||
.export
|
||||
.as_ref()
|
||||
.map(parse_export)
|
||||
.transpose()?
|
||||
.map_or_else(|| Arc::clone(&defaults.export), Arc::new),
|
||||
// A block of the peer's own takes the place of the top-level one,
|
||||
// an empty block included: that lifts the limits.
|
||||
export_limits: peer
|
||||
.export_limits
|
||||
.as_ref()
|
||||
.map(parse_export_limits)
|
||||
.transpose()
|
||||
.map_err(PeerProblem::ExportLimits)?
|
||||
.map(Arc::new),
|
||||
None => defaults.export_limits.clone(),
|
||||
},
|
||||
graceful_restart: match peer.graceful_restart.as_ref() {
|
||||
Some(own) => defaults.graceful_restart.overridden_by(own)?,
|
||||
None => defaults.graceful_restart,
|
||||
},
|
||||
.map_or_else(|| defaults.export_limits.clone(), |own| own.map(Arc::new)),
|
||||
graceful_restart: peer
|
||||
.graceful_restart
|
||||
.as_ref()
|
||||
.map(|own| defaults.graceful_restart.overridden_by(own))
|
||||
.transpose()?
|
||||
.unwrap_or(defaults.graceful_restart),
|
||||
groups: peer.groups,
|
||||
reflector_client: peer.route_reflector_client,
|
||||
passive: peer.passive,
|
||||
expected_routes: given(peer.expected_routes),
|
||||
})
|
||||
}
|
||||
|
||||
/// The hold time a peer is offered: the default where none is given.
|
||||
fn parse_hold_time(seconds: Option<u32>) -> Result<u16, PeerProblem> {
|
||||
match seconds {
|
||||
None => Ok(DEFAULT_HOLD_TIME_SECS),
|
||||
// RFC 4271 §4.2: zero, or at least three seconds.
|
||||
Some(1 | 2) => Err(PeerProblem::HoldTime),
|
||||
Some(secs) => u16::try_from(secs).map_err(|_| PeerProblem::HoldTime),
|
||||
}
|
||||
}
|
||||
|
||||
/// The address families offered to a peer: every one there is, unless
|
||||
/// some are listed.
|
||||
fn parse_families(
|
||||
families: &[EnumOrUnknown<proto::AddressFamily>],
|
||||
) -> Result<FamilySet, PeerProblem> {
|
||||
if families.is_empty() {
|
||||
return Ok(FamilySet::supported());
|
||||
}
|
||||
families
|
||||
.iter()
|
||||
.map(|family| match family.enum_value() {
|
||||
Ok(proto::AddressFamily::IPV4_UNICAST) => Ok(AfiSafi::IPV4_UNICAST),
|
||||
Ok(proto::AddressFamily::IPV6_UNICAST) => Ok(AfiSafi::IPV6_UNICAST),
|
||||
Ok(proto::AddressFamily::ADDRESS_FAMILY_UNSPECIFIED) | Err(_) => {
|
||||
Err(PeerProblem::AddressFamily)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What the peer is to us, if that was said.
|
||||
fn parse_role(role: EnumOrUnknown<proto::Role>) -> Result<Option<PeerRole>, PeerProblem> {
|
||||
Ok(match role.enum_value() {
|
||||
Ok(proto::Role::ROLE_UNSPECIFIED) => None,
|
||||
Ok(proto::Role::PROVIDER) => Some(PeerRole::Provider),
|
||||
Ok(proto::Role::CUSTOMER) => Some(PeerRole::Customer),
|
||||
Ok(proto::Role::PEER) => Some(PeerRole::Peer),
|
||||
Ok(proto::Role::ROUTE_SERVER) => Some(PeerRole::RouteServer),
|
||||
Ok(proto::Role::ROUTE_SERVER_CLIENT) => Some(PeerRole::RouteServerClient),
|
||||
Err(_) => return Err(PeerProblem::Role),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -571,7 +620,6 @@ fn parse_rpki(rpki: &proto::Rpki) -> Result<RpkiConfig, ConfigError> {
|
||||
port => parse_endpoint(&cache.address, port),
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let seconds = |seconds: u32| (seconds > 0).then(|| Duration::from_secs(seconds.into()));
|
||||
Ok(RpkiConfig {
|
||||
caches,
|
||||
refresh: seconds(rpki.refresh_seconds),
|
||||
@@ -582,20 +630,20 @@ fn parse_rpki(rpki: &proto::Rpki) -> Result<RpkiConfig, ConfigError> {
|
||||
|
||||
fn parse_import(import: &proto::ImportPolicy) -> Result<ImportPolicy, ImportProblem> {
|
||||
let defaults = ImportPolicy::default();
|
||||
// A length of zero is the field left out.
|
||||
let longest = |field, value: u32, version: IpVersion, default: u8| match value {
|
||||
0 => Ok(default),
|
||||
value => u8::try_from(value)
|
||||
let longest = |field, value: u32, version: IpVersion, default: u8| {
|
||||
given(value).map_or(Ok(default), |value| {
|
||||
u8::try_from(value)
|
||||
.ok()
|
||||
.filter(|length| *length <= version.bits())
|
||||
.ok_or(ImportProblem::PrefixLength {
|
||||
field,
|
||||
value,
|
||||
longest: version.bits(),
|
||||
}),
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let prefixes = parse_prefixes(&import.prefixes)?;
|
||||
let prefixes = parse_prefixes_if_any(&import.prefixes)?;
|
||||
let rules = import
|
||||
.rules
|
||||
.iter()
|
||||
@@ -609,11 +657,8 @@ fn parse_import(import: &proto::ImportPolicy) -> Result<ImportPolicy, ImportProb
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ImportPolicy {
|
||||
max_prefixes: Some(import.max_prefixes).filter(|limit| *limit > 0),
|
||||
hold_down: match import.hold_down_seconds {
|
||||
0 => defaults.hold_down,
|
||||
seconds => Duration::from_secs(seconds.into()),
|
||||
},
|
||||
max_prefixes: given(import.max_prefixes),
|
||||
hold_down: seconds(import.hold_down_seconds).unwrap_or(defaults.hold_down),
|
||||
accept_bogons: import.accept_bogons,
|
||||
accept_default_route: import.accept_default_route,
|
||||
max_ipv4_length: longest(
|
||||
@@ -633,6 +678,8 @@ fn parse_import(import: &proto::ImportPolicy) -> Result<ImportPolicy, ImportProb
|
||||
accept_aspa_invalid: import.accept_aspa_invalid,
|
||||
accept_as_sets: import.accept_as_sets,
|
||||
accept_third_party_next_hop: import.accept_third_party_next_hop,
|
||||
accept_own_host_next_hop: false,
|
||||
accept_blackholes: import.accept_blackholes,
|
||||
own_prefixes: Arc::default(),
|
||||
originated: Arc::default(),
|
||||
accept_control_communities: import.accept_control_communities,
|
||||
@@ -645,13 +692,13 @@ fn parse_import(import: &proto::ImportPolicy) -> Result<ImportPolicy, ImportProb
|
||||
local_pref: import.local_pref,
|
||||
rules,
|
||||
reflection: None,
|
||||
max_as_path_length: match import.max_as_path_length {
|
||||
0 => defaults.max_as_path_length,
|
||||
length => u16::try_from(length).map_err(|_| ImportProblem::PathLength(length))?,
|
||||
},
|
||||
prefixes: Some(prefixes).filter(|prefixes| !prefixes.is_empty()),
|
||||
origin_asns: Some(import.origin_asns.iter().copied().collect())
|
||||
.filter(|asns: &std::collections::HashSet<u32>| !asns.is_empty()),
|
||||
max_as_path_length: given(import.max_as_path_length)
|
||||
.map(|length| u16::try_from(length).map_err(|_| ImportProblem::PathLength(length)))
|
||||
.transpose()?
|
||||
.unwrap_or(defaults.max_as_path_length),
|
||||
prefixes,
|
||||
origin_asns: (!import.origin_asns.is_empty())
|
||||
.then(|| import.origin_asns.iter().copied().collect()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -666,17 +713,17 @@ fn parse_prefixes(ranges: &[proto::PrefixRange]) -> Result<PrefixSet, ImportProb
|
||||
problem,
|
||||
})?;
|
||||
let longest = prefix.address_family().bits();
|
||||
let max_length = match range.max_length {
|
||||
0 => prefix.length(),
|
||||
max_length => u8::try_from(max_length)
|
||||
// Left out, the prefix itself is the longest allowed.
|
||||
let max_length = given(range.max_length).map_or(Ok(prefix.length()), |max_length| {
|
||||
u8::try_from(max_length)
|
||||
.ok()
|
||||
.filter(|max| (prefix.length()..=longest).contains(max))
|
||||
.ok_or(ImportProblem::MaxLength {
|
||||
prefix: range.prefix.clone(),
|
||||
max_length,
|
||||
longest,
|
||||
})?,
|
||||
};
|
||||
})
|
||||
})?;
|
||||
prefixes.allow(prefix, max_length);
|
||||
}
|
||||
Ok(prefixes)
|
||||
@@ -685,21 +732,16 @@ fn parse_prefixes(ranges: &[proto::PrefixRange]) -> Result<PrefixSet, ImportProb
|
||||
fn parse_rule(rule: &proto::ImportRule) -> Result<ImportRule, ImportProblem> {
|
||||
Ok(ImportRule {
|
||||
communities: Patterns::parse(&rule.match_communities)?,
|
||||
prefixes: if rule.match_prefixes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parse_prefixes(&rule.match_prefixes)?)
|
||||
},
|
||||
prefixes: parse_prefixes_if_any(&rule.match_prefixes)?,
|
||||
local_pref: rule.local_pref,
|
||||
marks: Marks::parse(&[], &rule.add_communities, &rule.add_large_communities)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_port(port: u32) -> Result<u16, ConfigError> {
|
||||
match port {
|
||||
0 => Ok(DEFAULT_BGP_PORT),
|
||||
port => u16::try_from(port).map_err(|_| ConfigError::Port(port)),
|
||||
}
|
||||
given(port).map_or(Ok(DEFAULT_BGP_PORT), |port| {
|
||||
u16::try_from(port).map_err(|_| ConfigError::Port(port))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_endpoint(address: &str, port: u32) -> Result<SocketAddr, ConfigError> {
|
||||
@@ -1286,6 +1328,8 @@ mod tests {
|
||||
metric: 20,
|
||||
nexthop_objects: true,
|
||||
stale_time: Duration::from_secs(120),
|
||||
resolution_table: 254,
|
||||
resolve_via_default: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1300,6 +1344,8 @@ mod tests {
|
||||
metric: 150,
|
||||
nexthop_objects: false,
|
||||
stale_time: Duration::from_secs(30),
|
||||
resolution_table: 254,
|
||||
resolve_via_default: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1494,6 +1540,37 @@ 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 all_paths_needs_learned_routes() {
|
||||
let without = "export { all_paths: true }";
|
||||
let error = rejected(&format!("{BASE}\n{without}"));
|
||||
assert!(
|
||||
error.to_string().contains("all_paths needs learned_routes"),
|
||||
"{error}"
|
||||
);
|
||||
let with = "export { learned_routes: true all_paths: true }";
|
||||
let config = parse(&format!("{BASE}\n{with}")).expect("in order");
|
||||
assert!(config.export.all_paths);
|
||||
}
|
||||
|
||||
#[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.
|
||||
#[test]
|
||||
fn a_peer_can_be_left_to_connect() {
|
||||
|
||||
+619
-163
File diff suppressed because it is too large.
Load diff
+10
-16
@@ -18,7 +18,7 @@ use tracing::{info, warn};
|
||||
|
||||
use crate::collision::Direction;
|
||||
use crate::config::PeerConfig;
|
||||
use crate::connection::{CLOSED_AT_OUR_REQUEST, PeerSettings, ServerContext, run_session};
|
||||
use crate::connection::{PeerSettings, ServerContext, run_session};
|
||||
use crate::session::DialState;
|
||||
|
||||
/// Upper bound for exponential backoff between connection attempts.
|
||||
@@ -102,7 +102,9 @@ async fn dial_loop(peer: PeerConfig, server: Arc<ServerContext>, connect_retry:
|
||||
registry.set_dial_state(&peer.name, DialState::Connecting);
|
||||
info!(peer = %peer.name, endpoint = %peer.endpoint, "Connecting to peer");
|
||||
|
||||
let ended = match tokio::time::timeout(CONNECT_TIMEOUT, connect(&peer)).await {
|
||||
// Why the attempt ended, and whether that is a failure worth a
|
||||
// line in the log.
|
||||
let (reason, failed) = match tokio::time::timeout(CONNECT_TIMEOUT, connect(&peer)).await {
|
||||
Ok(Ok(stream)) => match stream.peer_addr() {
|
||||
Ok(peer_addr) => {
|
||||
info!(peer = %peer.name, endpoint = %peer.endpoint, "TCP connected, starting session");
|
||||
@@ -121,25 +123,17 @@ async fn dial_loop(peer: PeerConfig, server: Arc<ServerContext>, connect_retry:
|
||||
if end.established {
|
||||
backoff = connect_retry;
|
||||
}
|
||||
if end.result.is_ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(end.reason())
|
||||
let failed = end.result.is_err();
|
||||
(end.reason(), failed)
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("connection lost at once: {e}")),
|
||||
Err(e) => (format!("connection lost at once: {e}"), true),
|
||||
},
|
||||
Ok(Err(e)) => Err(format!("connect failed: {e}")),
|
||||
Err(_) => Err(format!("connect timed out after {CONNECT_TIMEOUT:?}")),
|
||||
Ok(Err(e)) => (format!("connect failed: {e}"), true),
|
||||
Err(_) => (format!("connect timed out after {CONNECT_TIMEOUT:?}"), true),
|
||||
};
|
||||
|
||||
let reason = match ended {
|
||||
Ok(()) => CLOSED_AT_OUR_REQUEST.to_string(),
|
||||
Err(reason) => {
|
||||
if failed {
|
||||
warn!(peer = %peer.name, endpoint = %peer.endpoint, err = %reason, "Session attempt ended");
|
||||
reason
|
||||
}
|
||||
};
|
||||
|
||||
// Not all at the same moment, when many peers went away together.
|
||||
let wait = jittered(backoff);
|
||||
|
||||
@@ -41,6 +41,9 @@ pub enum SessionError {
|
||||
Decode(#[from] DecodeError),
|
||||
#[error("could not encode a message: {0}")]
|
||||
Encode(#[from] EncodeError),
|
||||
/// A received UPDATE's attributes could not be kept in the table.
|
||||
#[error(transparent)]
|
||||
Unstorable(#[from] crate::routes::Unstorable),
|
||||
/// The peer sent nothing for as long as the pre-negotiation hold time.
|
||||
#[error("timed out waiting for a message in the {0} state")]
|
||||
Timeout(State),
|
||||
@@ -101,9 +104,10 @@ impl SessionError {
|
||||
pub fn notification(&self) -> Option<NotificationMessage> {
|
||||
let error = match self {
|
||||
Self::Io(_) | Self::SendHoldTimerExpired(_) | Self::NotifiedByPeer(_) => return None,
|
||||
// Something of ours could not be written out, which is a bug
|
||||
// here: as for a panic, the session is reset.
|
||||
Self::Encode(_) => BgpError::Cease(CeaseSubcode::AdminReset),
|
||||
// Something of ours could not be written out, or the peer's
|
||||
// routes could not be kept, which is a bug here: as for a
|
||||
// panic, the session is reset.
|
||||
Self::Encode(_) | Self::Unstorable(_) => BgpError::Cease(CeaseSubcode::AdminReset),
|
||||
Self::Decode(e) => {
|
||||
return Some(NotificationMessage {
|
||||
error: e.notification.clone(),
|
||||
@@ -336,6 +340,8 @@ pub enum ExportProblem {
|
||||
Marks(#[from] crate::marks::MarksProblem),
|
||||
#[error("as_path_prepend of {value} is more than {max}")]
|
||||
Prepend { value: u32, max: u32 },
|
||||
#[error("all_paths needs learned_routes")]
|
||||
AllPathsWithoutLearnedRoutes,
|
||||
}
|
||||
|
||||
/// What is wrong with one `originate` entry.
|
||||
|
||||
+289
-8
@@ -35,10 +35,13 @@ use std::net::{IpAddr, Ipv4Addr};
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, AsPathSegment, LocalPrefPathAttribute, MpReachNlriPathAttribute,
|
||||
MpUnreachNlriPathAttribute, NextHopPathAttribute, NlriNextHop, PathAttribute, UpdateMessage,
|
||||
attr_type, begin_message, finish_message, msg_type, put_nlri_list,
|
||||
};
|
||||
use bgp_primitives::constants::{AfiSafi, IpVersion};
|
||||
use bgp_primitives::ip_prefix::Nlri;
|
||||
use bgp_primitives::parser::ParserContext;
|
||||
use bgp_rib::table::{DEFAULT_LOCAL_PREF, PeerIndex};
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
|
||||
use crate::advertise::{Towards, ipv6_next_hop};
|
||||
use crate::marks::{self, Control, Named, communities_of, well_known_allow};
|
||||
@@ -47,7 +50,7 @@ use crate::policy::{ExportPolicy, PeerRole};
|
||||
/// The session a route may go out on, as far as that decides whether it
|
||||
/// does and as what.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Egress<'a> {
|
||||
pub(crate) struct Egress<'a> {
|
||||
pub towards: Towards,
|
||||
pub peer_asn: u32,
|
||||
/// The peer's number in the routing table: what its own routes there
|
||||
@@ -66,7 +69,7 @@ pub struct Egress<'a> {
|
||||
|
||||
/// Route reflection (RFC 4456), as far as one session goes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Reflection {
|
||||
pub(crate) struct Reflection {
|
||||
/// The cluster this router reflects routes for.
|
||||
pub cluster_id: Ipv4Addr,
|
||||
/// Whether the peer is a route reflector client of ours.
|
||||
@@ -78,7 +81,7 @@ pub struct Reflection {
|
||||
|
||||
/// Where a learned route came from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct Source {
|
||||
pub(crate) struct Source {
|
||||
pub peer: PeerIndex,
|
||||
/// Whether that peer is in our own AS.
|
||||
pub ibgp: bool,
|
||||
@@ -92,7 +95,7 @@ pub struct Source {
|
||||
/// A route ready to be announced on a session: its attributes, but for
|
||||
/// the ones that depend on which prefixes go with them.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outgoing {
|
||||
pub(crate) struct Outgoing {
|
||||
pub attributes: Vec<PathAttribute>,
|
||||
pub next_hop: NlriNextHop,
|
||||
}
|
||||
@@ -393,15 +396,155 @@ pub(crate) fn finish(attributes: &mut Vec<PathAttribute>, towards: &Towards) {
|
||||
/// lengths every UPDATE has.
|
||||
const UPDATE_ROOM: usize = 4096 - 19 - 4;
|
||||
|
||||
/// The most octets a route's attributes may take on the wire for the
|
||||
/// route to go out at all. A message is 4,096 octets at most (RFC 4271
|
||||
/// §4.1) and must hold the attributes, the next hop and at least one
|
||||
/// prefix; three quarters of it leaves [`UPDATE_ROOM`] a thousand octets
|
||||
/// for the next hop and the prefixes, which is some forty IPv6 prefixes
|
||||
/// with path identifiers a message, enough to keep a table's UPDATEs few.
|
||||
pub(crate) const ATTRIBUTES_AT_MOST: usize = 3072;
|
||||
|
||||
/// The most octets a prefix takes in an UPDATE: a path identifier, a
|
||||
/// length, and the address.
|
||||
fn nlri_octets(routes: IpVersion) -> usize {
|
||||
4 + 1 + usize::from(routes.bits()) / 8
|
||||
}
|
||||
|
||||
/// A route's attributes as they go out on one session, encoded once and
|
||||
/// kept: what every UPDATE announcing a route with them carries, less the
|
||||
/// next hop, which goes with the prefixes. `at_next_hop` and
|
||||
/// `at_mp_reach` are where in `bytes` a NEXT_HOP (type 3) and an
|
||||
/// MP_REACH_NLRI (type 14) go, so that the attributes stay in order of
|
||||
/// type code.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct EncodedAttributes {
|
||||
bytes: Bytes,
|
||||
at_next_hop: usize,
|
||||
at_mp_reach: usize,
|
||||
}
|
||||
|
||||
impl EncodedAttributes {
|
||||
/// Encodes `attributes`, which are `finish`ed, for a session with
|
||||
/// `four_octet_asn`. `None` if they cannot be encoded.
|
||||
pub(crate) fn encode(attributes: &[PathAttribute], four_octet_asn: bool) -> Option<Self> {
|
||||
let ctx = ParserContext {
|
||||
four_octet_asn,
|
||||
ibgp: false,
|
||||
add_path: Vec::new(),
|
||||
};
|
||||
let mut out = BytesMut::with_capacity(256);
|
||||
let mut at_next_hop = None;
|
||||
let mut at_mp_reach = None;
|
||||
for attribute in attributes {
|
||||
let code = attribute.type_code();
|
||||
if code > attr_type::NEXT_HOP && at_next_hop.is_none() {
|
||||
at_next_hop = Some(out.len());
|
||||
}
|
||||
if code > attr_type::MP_REACH_NLRI && at_mp_reach.is_none() {
|
||||
at_mp_reach = Some(out.len());
|
||||
}
|
||||
attribute.to_wire(&ctx, &mut out).ok()?;
|
||||
}
|
||||
Some(Self {
|
||||
at_next_hop: at_next_hop.unwrap_or(out.len()),
|
||||
at_mp_reach: at_mp_reach.unwrap_or(out.len()),
|
||||
bytes: out.freeze(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn octets(&self) -> usize {
|
||||
self.bytes.len()
|
||||
}
|
||||
|
||||
/// The UPDATE messages, complete with their headers, that announce
|
||||
/// `routes`, all of IP version `version`, with these attributes and
|
||||
/// `next_hop`: as few as they fit in, in pieces of [`ENCODED_PIECE`]
|
||||
/// octets or so, each of whole messages. `ctx` is the session's, and
|
||||
/// says whether path identifiers go with the family's prefixes
|
||||
/// (RFC 7911).
|
||||
///
|
||||
/// What `announcements`, kept under test as the reference, builds as
|
||||
/// messages, built as bytes: the attributes are not parsed, cloned or
|
||||
/// encoded again for each message.
|
||||
pub(crate) fn announcements(
|
||||
&self,
|
||||
next_hop: NlriNextHop,
|
||||
version: IpVersion,
|
||||
routes: &[Nlri],
|
||||
ctx: &ParserContext,
|
||||
) -> Vec<BytesMut> {
|
||||
// Attributes that would leave no room for a prefix do not go out
|
||||
// at all; see `Learned::dressed`.
|
||||
debug_assert!(self.octets() < ATTRIBUTES_AT_MOST);
|
||||
let room = UPDATE_ROOM.saturating_sub(self.octets() + NEXT_HOP_ROOM);
|
||||
let at_once = (room / nlri_octets(version)).max(1);
|
||||
let in_body = matches!(next_hop, NlriNextHop::Ipv4(_)) && version == IpVersion::Ipv4;
|
||||
let with_path_id = ctx.add_path.contains(&AfiSafi::unicast(version));
|
||||
let mut pieces = Vec::new();
|
||||
let mut out = BytesMut::with_capacity(ENCODED_PIECE.min(routes.len() * 64 + 4096));
|
||||
for routes in routes.chunks(at_once) {
|
||||
{
|
||||
let length_at = begin_message(&mut out, msg_type::UPDATE);
|
||||
// No withdrawn routes.
|
||||
out.put_u16(0);
|
||||
let attributes_at = out.len();
|
||||
out.put_u16(0);
|
||||
if in_body {
|
||||
let NlriNextHop::Ipv4(next_hop) = next_hop else {
|
||||
unreachable!("in_body means an IPv4 next hop");
|
||||
};
|
||||
out.put_slice(&self.bytes[..self.at_next_hop]);
|
||||
PathAttribute::NextHop(NextHopPathAttribute(next_hop))
|
||||
.to_wire(ctx, &mut out)
|
||||
.expect("a next hop encodes");
|
||||
out.put_slice(&self.bytes[self.at_next_hop..]);
|
||||
} else {
|
||||
out.put_slice(&self.bytes[..self.at_mp_reach]);
|
||||
PathAttribute::MpReachNlri(MpReachNlriPathAttribute {
|
||||
family: AfiSafi::unicast(version),
|
||||
next_hop,
|
||||
prefixes: routes.to_vec(),
|
||||
})
|
||||
.to_wire(ctx, &mut out)
|
||||
.expect("a chunk that fits encodes");
|
||||
out.put_slice(&self.bytes[self.at_mp_reach..]);
|
||||
}
|
||||
let attributes_len = out.len() - attributes_at - 2;
|
||||
out[attributes_at..attributes_at + 2]
|
||||
.copy_from_slice(&(attributes_len as u16).to_be_bytes());
|
||||
if in_body {
|
||||
put_nlri_list(routes, with_path_id, &mut out);
|
||||
}
|
||||
// As many prefixes went in as fit beside the attributes.
|
||||
finish_message(&mut out, length_at).expect("a chunk that fits is a message");
|
||||
}
|
||||
if out.len() >= ENCODED_PIECE {
|
||||
pieces.push(out.split());
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
pieces.push(out);
|
||||
}
|
||||
pieces
|
||||
}
|
||||
}
|
||||
|
||||
/// What the next hop takes, in its own attribute or with the prefixes,
|
||||
/// and a little to spare.
|
||||
const NEXT_HOP_ROOM: usize = 64;
|
||||
|
||||
/// How many octets of UPDATEs go to a session in one piece: what the
|
||||
/// session hands its writer at a time. A piece holds whole messages,
|
||||
/// so it runs over by at most one.
|
||||
pub(crate) const ENCODED_PIECE: usize = 64 * 1024;
|
||||
|
||||
/// The UPDATEs that announce `routes`, all of IP version `version`, as
|
||||
/// `outgoing`: as few as they fit in. `attributes_octets` is how long
|
||||
/// `outgoing`'s attributes are on this session's wire.
|
||||
///
|
||||
/// What [`EncodedAttributes::announcements`] does, built as messages:
|
||||
/// kept as what its bytes are checked against.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn announcements(
|
||||
outgoing: &Outgoing,
|
||||
version: IpVersion,
|
||||
@@ -409,9 +552,6 @@ pub(crate) fn announcements(
|
||||
towards: &Towards,
|
||||
attributes_octets: usize,
|
||||
) -> Vec<UpdateMessage> {
|
||||
// What the next hop takes, in its own attribute or with the prefixes,
|
||||
// and a little to spare.
|
||||
const NEXT_HOP_ROOM: usize = 64;
|
||||
let room = UPDATE_ROOM.saturating_sub(attributes_octets + NEXT_HOP_ROOM);
|
||||
let at_once = (room / nlri_octets(version)).max(1);
|
||||
let in_body = matches!(outgoing.next_hop, NlriNextHop::Ipv4(_)) && version == IpVersion::Ipv4;
|
||||
@@ -484,7 +624,7 @@ mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use bgp_packet::message::{
|
||||
CommunitiesPathAttribute, LargeCommunitiesPathAttribute, LargeCommunity,
|
||||
CommunitiesPathAttribute, LargeCommunitiesPathAttribute, LargeCommunity, Message,
|
||||
MultiExitDiscPathAttribute, OriginPathAttribute, PathAttributeFlags,
|
||||
};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
@@ -529,6 +669,7 @@ mod tests {
|
||||
fn all_routes() -> ExportPolicy {
|
||||
ExportPolicy {
|
||||
learned_routes: true,
|
||||
all_paths: false,
|
||||
..ExportPolicy::default()
|
||||
}
|
||||
}
|
||||
@@ -548,6 +689,145 @@ mod tests {
|
||||
PathAttribute::Communities(CommunitiesPathAttribute(communities.to_vec()))
|
||||
}
|
||||
|
||||
/// What `EncodedAttributes::announcements` writes is, byte for byte,
|
||||
/// what the messages `announcements` builds encode to: with the next
|
||||
/// hop in its own attribute (IPv4) and in MP_REACH_NLRI (IPv6, and
|
||||
/// IPv4 with an IPv6 next hop, RFC 8950), with and without path
|
||||
/// identifiers, to two-octet and four-octet AS speakers (so with the
|
||||
/// attributes around NEXT_HOP, MP_REACH_NLRI and AS4_PATH in order),
|
||||
/// and over as many messages when the routes do not fit in one.
|
||||
#[test]
|
||||
fn encoded_announcements_are_the_messages_on_the_wire() {
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
// The routes' IP version, their next hop, and whether the session
|
||||
// takes IPv4 routes with IPv6 next hops.
|
||||
let layouts = [
|
||||
(
|
||||
IpVersion::Ipv4,
|
||||
NlriNextHop::Ipv4(Ipv4Addr::new(192, 0, 2, 1)),
|
||||
false,
|
||||
),
|
||||
(
|
||||
IpVersion::Ipv6,
|
||||
NlriNextHop::Ipv6WithLl {
|
||||
global: Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1),
|
||||
link_local: Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1),
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
IpVersion::Ipv4,
|
||||
NlriNextHop::Ipv6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)),
|
||||
true,
|
||||
),
|
||||
];
|
||||
for four_octet_asn in [true, false] {
|
||||
for with_path_id in [false, true] {
|
||||
for (version, next_hop, extended_next_hop) in layouts {
|
||||
let towards = Towards {
|
||||
four_octet_asn,
|
||||
extended_next_hop,
|
||||
..towards(None, false)
|
||||
};
|
||||
let attributes = vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![4_200_000_000, 3356, 64700]),
|
||||
PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(10)),
|
||||
communities(&[(3356, 100), (64700, 1)]),
|
||||
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
|
||||
communities: vec![LargeCommunity {
|
||||
global_admin: 64700,
|
||||
data_1: 1,
|
||||
data_2: 2,
|
||||
}],
|
||||
}),
|
||||
];
|
||||
let routes: Vec<Nlri> = (0..30_000u32)
|
||||
.map(|i| Nlri {
|
||||
prefix: match version {
|
||||
IpVersion::Ipv4 => IpPrefix::new(
|
||||
version,
|
||||
Ipv4Addr::from(0x0a00_0000 + i * 256).octets(),
|
||||
24,
|
||||
),
|
||||
IpVersion::Ipv6 => IpPrefix::new(
|
||||
version,
|
||||
Ipv6Addr::new(0x2001, 0xdb8, i as u16, 0, 0, 0, 0, 0).octets(),
|
||||
48,
|
||||
),
|
||||
}
|
||||
.unwrap(),
|
||||
path_id: if with_path_id { i + 1 } else { 0 },
|
||||
})
|
||||
.collect();
|
||||
|
||||
let ctx = ParserContext {
|
||||
four_octet_asn,
|
||||
ibgp: false,
|
||||
add_path: if with_path_id {
|
||||
vec![AfiSafi::unicast(version)]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
};
|
||||
let mut finished = attributes.clone();
|
||||
finish(&mut finished, &towards);
|
||||
let encoded = EncodedAttributes::encode(&finished, four_octet_asn).unwrap();
|
||||
let bytes = encoded.announcements(next_hop, version, &routes, &ctx);
|
||||
|
||||
let outgoing = Outgoing {
|
||||
attributes,
|
||||
next_hop,
|
||||
};
|
||||
let messages =
|
||||
announcements(&outgoing, version, &routes, &towards, encoded.octets());
|
||||
let expected: Vec<BytesMut> = messages
|
||||
.into_iter()
|
||||
.map(|update| {
|
||||
let mut out = BytesMut::new();
|
||||
Message::Update(update).to_wire(&ctx, &mut out).unwrap();
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
|
||||
let what = format!(
|
||||
"{version:?} with next hop {next_hop:?}, path ids {with_path_id}, \
|
||||
four-octet {four_octet_asn}"
|
||||
);
|
||||
assert!(expected.len() > 1, "{what}: more than one message");
|
||||
for update in &expected {
|
||||
assert!(update.len() <= 4096, "{what}: a message fits");
|
||||
}
|
||||
// In pieces of whole messages, each of about the size
|
||||
// meant, all but the last at least that.
|
||||
assert!(bytes.len() > 1, "{what}: more than one piece");
|
||||
for (n, piece) in bytes.iter().enumerate() {
|
||||
assert!(
|
||||
n == bytes.len() - 1
|
||||
|| (ENCODED_PIECE..ENCODED_PIECE + 4096).contains(&piece.len()),
|
||||
"{what}: piece {n} is {} octets",
|
||||
piece.len()
|
||||
);
|
||||
let mut at = 0;
|
||||
while at < piece.len() {
|
||||
let length =
|
||||
usize::from(u16::from_be_bytes([piece[at + 16], piece[at + 17]]));
|
||||
at += length;
|
||||
}
|
||||
assert_eq!(at, piece.len(), "{what}: piece {n} is of whole messages");
|
||||
}
|
||||
let got: Vec<u8> = bytes
|
||||
.iter()
|
||||
.flat_map(|piece| piece.iter().copied())
|
||||
.collect();
|
||||
let want: Vec<u8> = expected.iter().flat_map(|m| m.iter().copied()).collect();
|
||||
assert_eq!(got, want, "{what}: the same octets");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Case<'a> {
|
||||
policy: &'a ExportPolicy,
|
||||
role: Option<PeerRole>,
|
||||
@@ -922,6 +1202,7 @@ mod tests {
|
||||
|texts: &[&str]| -> Vec<String> { texts.iter().map(|text| text.to_string()).collect() };
|
||||
let policy = ExportPolicy {
|
||||
learned_routes: true,
|
||||
all_paths: false,
|
||||
marks: Marks::parse(&strings(&["64601:900"]), &strings(&["64601:5"]), &[]).unwrap(),
|
||||
prepend: 2,
|
||||
};
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
|
||||
use bgp_packet::community::{CommunityError, parse_large, parse_standard};
|
||||
use bgp_packet::message::{
|
||||
CommunitiesPathAttribute, LargeCommunitiesPathAttribute, LargeCommunity, PathAttribute,
|
||||
CommunitiesPathAttribute, LargeCommunitiesPathAttribute, LargeCommunity,
|
||||
LocalPrefPathAttribute, PathAttribute,
|
||||
};
|
||||
|
||||
/// A standard community, or all those with one half or both left open:
|
||||
@@ -186,6 +187,19 @@ impl Marks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks `attributes` as a policy or one of its rules says: with `marks`
|
||||
/// on their communities, and with `local_pref` as their LOCAL_PREF if one
|
||||
/// is given, in place of whatever they had.
|
||||
pub fn mark(attributes: &mut Vec<PathAttribute>, marks: &Marks, local_pref: Option<u32>) {
|
||||
marks.apply(attributes);
|
||||
if let Some(local_pref) = local_pref {
|
||||
put(
|
||||
attributes,
|
||||
PathAttribute::LocalPref(LocalPrefPathAttribute(local_pref)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the standard communities out of `attributes`.
|
||||
fn take_communities(attributes: &mut Vec<PathAttribute>) -> Vec<(u16, u16)> {
|
||||
let at = attributes
|
||||
|
||||
+343
-218
@@ -2,235 +2,296 @@
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use bgp_fib::sockdiag::TcpConnection;
|
||||
use bgp_packet::message::Message;
|
||||
#[cfg(test)]
|
||||
use prometheus::core::Collector;
|
||||
use prometheus::{
|
||||
IntCounter, IntCounterVec, IntGauge, IntGaugeVec, register_int_counter,
|
||||
register_int_counter_vec, register_int_gauge, register_int_gauge_vec,
|
||||
GaugeVec, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, register_gauge_vec,
|
||||
register_int_counter, register_int_counter_vec, register_int_gauge, register_int_gauge_vec,
|
||||
};
|
||||
|
||||
pub static MESSAGES_SENT: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_messages_sent_total",
|
||||
"BGP messages sent, by type",
|
||||
&["type"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
/// What a declared metric puts on the registry: one collector for a
|
||||
/// metric, and one for each gauge of a group of them.
|
||||
#[cfg(test)]
|
||||
trait Collectors {
|
||||
fn collectors(&self) -> Vec<Box<dyn Collector>>;
|
||||
}
|
||||
|
||||
pub static MESSAGES_RECEIVED: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_messages_received_total",
|
||||
"BGP messages received, by type",
|
||||
&["type"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
#[cfg(test)]
|
||||
impl<T: Collector + Clone + 'static> Collectors for T {
|
||||
fn collectors(&self) -> Vec<Box<dyn Collector>> {
|
||||
vec![Box::new(self.clone())]
|
||||
}
|
||||
}
|
||||
|
||||
pub static UPDATE_ERRORS: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_update_errors_total",
|
||||
"Errors in received UPDATE messages that did not reset the session, by how they were handled (RFC 7606)",
|
||||
&["handling"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
/// Declares the metrics: a static for each, made on first use, and
|
||||
/// [`init`], which forces every one of them so that nothing is left out
|
||||
/// of a scrape for not having been used yet. Each is declared once, here,
|
||||
/// so that the two cannot drift apart.
|
||||
macro_rules! metrics {
|
||||
($(
|
||||
$(#[$meta:meta])*
|
||||
$name:ident: $type:ty = $register:expr;
|
||||
)*) => {
|
||||
$(
|
||||
$(#[$meta])*
|
||||
pub static $name: LazyLock<$type> =
|
||||
LazyLock::new(|| $register.expect("metric registration"));
|
||||
)*
|
||||
|
||||
pub static SESSIONS_ESTABLISHED_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!(
|
||||
"bgp_sessions_established_total",
|
||||
"Sessions that reached Established"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
/// Every metric declared above, as the collectors the registry
|
||||
/// holds, for the tests to check it against.
|
||||
#[cfg(test)]
|
||||
fn declared() -> Vec<Box<dyn Collector>> {
|
||||
let mut all = Vec::new();
|
||||
$(all.extend($name.collectors());)*
|
||||
all
|
||||
}
|
||||
|
||||
pub static SESSIONS_ESTABLISHED: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!(
|
||||
"bgp_sessions_established",
|
||||
"Sessions currently in Established"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static ROUTES_REJECTED: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_routes_rejected_total",
|
||||
"Routes announced by a peer and refused by its import policy, by why",
|
||||
&["peer", "reason"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static PEER_ROUTES: LazyLock<IntGaugeVec> = LazyLock::new(|| {
|
||||
register_int_gauge_vec!(
|
||||
"bgp_peer_routes",
|
||||
"Routes of an established peer that are in the routing table",
|
||||
&["peer"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static PEER_MAX_PREFIXES: LazyLock<IntGaugeVec> = LazyLock::new(|| {
|
||||
register_int_gauge_vec!(
|
||||
"bgp_peer_max_prefixes",
|
||||
"The most routes a configured peer may have (import.max_prefixes), where there is a limit",
|
||||
&["peer"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static PREFIX_LIMIT_EXCEEDED: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_prefix_limit_exceeded_total",
|
||||
"Sessions ended because the peer went over its limit of routes",
|
||||
&["peer"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static EXPORT_LIMIT_EXCEEDED: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_export_limit_exceeded_total",
|
||||
"Sessions ended because more routes were to be announced to the peer than its export limit allows",
|
||||
&["peer"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static RPKI_VRPS: LazyLock<IntGaugeVec> = LazyLock::new(|| {
|
||||
register_int_gauge_vec!(
|
||||
"bgp_rpki_vrps",
|
||||
"Validated ROA payloads in use for route origin validation, by address family",
|
||||
&["family"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static RPKI_ASPAS: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!(
|
||||
"bgp_rpki_aspas",
|
||||
"Customer ASes with an ASPA in use for AS path verification"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static RPKI_UPDATES: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!("bgp_rpki_updates_total", "Times the VRPs or ASPAs changed")
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static CONNECTIONS_REFUSED: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_connections_refused_total",
|
||||
"Incoming connections closed without a session, by why",
|
||||
&["reason"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static COLLISIONS: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_connection_collisions_total",
|
||||
"Connection collisions resolved, by which connection was closed",
|
||||
&["closed"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static LOCAL_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!("bgp_local_routes", "Locally originated routes")
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static RIB_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!(
|
||||
"bgp_rib_routes",
|
||||
"Routes learned from peers, counting each peer's separately"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static RIB_PREFIXES: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!(
|
||||
"bgp_rib_prefixes",
|
||||
"Prefixes with at least one route, and so with a best route"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static RIB_ATTRIBUTE_SETS: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!(
|
||||
"bgp_rib_attribute_sets",
|
||||
"Distinct sets of path attributes held for the routes"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static RIB_JOURNAL_ENTRIES: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!(
|
||||
"bgp_rib_journal_entries",
|
||||
"Changes of best route waiting for a follower to read them"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static ROUTES_EXPORTED: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_routes_exported_total",
|
||||
"Routes learned from one peer and announced to, or withdrawn from, another",
|
||||
&["change"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
/// Whether each configured peer's session is up: what to alert on when a
|
||||
/// peer goes away, which a count of sessions cannot say.
|
||||
pub static PEER_UP: LazyLock<IntGaugeVec> = LazyLock::new(|| {
|
||||
register_int_gauge_vec!(
|
||||
"bgp_peer_up",
|
||||
"Whether the session with a configured peer is Established (1) or not (0)",
|
||||
&["peer"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static BUILD_INFO: LazyLock<IntGaugeVec> = LazyLock::new(|| {
|
||||
register_int_gauge_vec!(
|
||||
"bgp_build_info",
|
||||
"Always 1; the version of the daemon is in the label",
|
||||
&["version"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
/// Forces registration of every metric so they appear in scrapes before
|
||||
/// their first observation: a counter that is not there until it first
|
||||
/// counts hides that first count from `increase()`.
|
||||
pub fn init() {
|
||||
LazyLock::force(&MESSAGES_SENT);
|
||||
LazyLock::force(&MESSAGES_RECEIVED);
|
||||
LazyLock::force(&UPDATE_ERRORS);
|
||||
LazyLock::force(&SESSIONS_ESTABLISHED_TOTAL);
|
||||
LazyLock::force(&SESSIONS_ESTABLISHED);
|
||||
LazyLock::force(&ROUTES_REJECTED);
|
||||
LazyLock::force(&PEER_ROUTES);
|
||||
LazyLock::force(&PEER_MAX_PREFIXES);
|
||||
LazyLock::force(&PREFIX_LIMIT_EXCEEDED);
|
||||
LazyLock::force(&EXPORT_LIMIT_EXCEEDED);
|
||||
LazyLock::force(&RPKI_VRPS);
|
||||
LazyLock::force(&RPKI_ASPAS);
|
||||
LazyLock::force(&RPKI_UPDATES);
|
||||
LazyLock::force(&CONNECTIONS_REFUSED);
|
||||
LazyLock::force(&COLLISIONS);
|
||||
LazyLock::force(&LOCAL_ROUTES);
|
||||
LazyLock::force(&RIB_ROUTES);
|
||||
LazyLock::force(&RIB_PREFIXES);
|
||||
LazyLock::force(&RIB_ATTRIBUTE_SETS);
|
||||
LazyLock::force(&RIB_JOURNAL_ENTRIES);
|
||||
LazyLock::force(&ROUTES_EXPORTED);
|
||||
LazyLock::force(&PEER_UP);
|
||||
/// Forces registration of every metric so they appear in scrapes
|
||||
/// before their first observation: a counter that is not there
|
||||
/// until it first counts hides that first count from `increase()`.
|
||||
pub fn init() {
|
||||
$(LazyLock::force(&$name);)*
|
||||
BUILD_INFO
|
||||
.with_label_values(&[env!("CARGO_PKG_VERSION")])
|
||||
.set(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
metrics! {
|
||||
MESSAGES_SENT: IntCounterVec = register_int_counter_vec!(
|
||||
"bgp_messages_sent_total",
|
||||
"BGP messages sent, by type",
|
||||
&["type"]
|
||||
);
|
||||
MESSAGES_RECEIVED: IntCounterVec = register_int_counter_vec!(
|
||||
"bgp_messages_received_total",
|
||||
"BGP messages received, by type",
|
||||
&["type"]
|
||||
);
|
||||
UPDATE_ERRORS: IntCounterVec = register_int_counter_vec!(
|
||||
"bgp_update_errors_total",
|
||||
"Errors in received UPDATE messages that did not reset the session, by how they were handled (RFC 7606)",
|
||||
&["handling"]
|
||||
);
|
||||
SESSIONS_ESTABLISHED_TOTAL: IntCounter = register_int_counter!(
|
||||
"bgp_sessions_established_total",
|
||||
"Sessions that reached Established"
|
||||
);
|
||||
SESSIONS_ESTABLISHED: IntGauge = register_int_gauge!(
|
||||
"bgp_sessions_established",
|
||||
"Sessions currently in Established"
|
||||
);
|
||||
ROUTES_REJECTED: IntCounterVec = register_int_counter_vec!(
|
||||
"bgp_routes_rejected_total",
|
||||
"Routes announced by a peer and refused by its import policy, by why",
|
||||
&["peer", "reason"]
|
||||
);
|
||||
PEER_ROUTES: IntGaugeVec = register_int_gauge_vec!(
|
||||
"bgp_peer_routes",
|
||||
"Routes of an established peer that are in the routing table",
|
||||
&["peer"]
|
||||
);
|
||||
PEER_ADVERTISED: IntGaugeVec = register_int_gauge_vec!(
|
||||
"bgp_peer_advertised_routes",
|
||||
"Routes announced to an established peer and not withdrawn since",
|
||||
&["peer"]
|
||||
);
|
||||
PEER_TCP: PeerTcp = PeerTcp::new();
|
||||
/// The allocator's account of the daemon's memory, by `kind`: allocated,
|
||||
/// active, metadata, resident, mapped, retained (jemalloc's `stats.*`).
|
||||
MEMORY: IntGaugeVec = register_int_gauge_vec!(
|
||||
"bgp_memory_bytes",
|
||||
"The daemon's memory as its allocator accounts for it, by kind",
|
||||
&["kind"]
|
||||
);
|
||||
PEER_MAX_PREFIXES: IntGaugeVec = register_int_gauge_vec!(
|
||||
"bgp_peer_max_prefixes",
|
||||
"The most routes a configured peer may have (import.max_prefixes), where there is a limit",
|
||||
&["peer"]
|
||||
);
|
||||
PREFIX_LIMIT_EXCEEDED: IntCounterVec = register_int_counter_vec!(
|
||||
"bgp_prefix_limit_exceeded_total",
|
||||
"Sessions ended because the peer went over its limit of routes",
|
||||
&["peer"]
|
||||
);
|
||||
EXPORT_LIMIT_EXCEEDED: IntCounterVec = register_int_counter_vec!(
|
||||
"bgp_export_limit_exceeded_total",
|
||||
"Sessions ended because more routes were to be announced to the peer than its export limit allows",
|
||||
&["peer"]
|
||||
);
|
||||
RPKI_VRPS: IntGaugeVec = register_int_gauge_vec!(
|
||||
"bgp_rpki_vrps",
|
||||
"Validated ROA payloads in use for route origin validation, by address family",
|
||||
&["family"]
|
||||
);
|
||||
RPKI_ASPAS: IntGauge = register_int_gauge!(
|
||||
"bgp_rpki_aspas",
|
||||
"Customer ASes with an ASPA in use for AS path verification"
|
||||
);
|
||||
RPKI_UPDATES: IntCounter = register_int_counter!(
|
||||
"bgp_rpki_updates_total",
|
||||
"Times the VRPs or ASPAs changed"
|
||||
);
|
||||
CONNECTIONS_REFUSED: IntCounterVec = register_int_counter_vec!(
|
||||
"bgp_connections_refused_total",
|
||||
"Incoming connections closed without a session, by why",
|
||||
&["reason"]
|
||||
);
|
||||
COLLISIONS: IntCounterVec = register_int_counter_vec!(
|
||||
"bgp_connection_collisions_total",
|
||||
"Connection collisions resolved, by which connection was closed",
|
||||
&["closed"]
|
||||
);
|
||||
LOCAL_ROUTES: IntGauge = register_int_gauge!(
|
||||
"bgp_local_routes",
|
||||
"Locally originated routes"
|
||||
);
|
||||
RIB_ROUTES: IntGauge = register_int_gauge!(
|
||||
"bgp_rib_routes",
|
||||
"Routes learned from peers, counting each peer's separately"
|
||||
);
|
||||
RIB_PREFIXES: IntGauge = register_int_gauge!(
|
||||
"bgp_rib_prefixes",
|
||||
"Prefixes with at least one route, and so with a best route"
|
||||
);
|
||||
RIB_ATTRIBUTE_SETS: IntGauge = register_int_gauge!(
|
||||
"bgp_rib_attribute_sets",
|
||||
"Distinct sets of path attributes held for the routes"
|
||||
);
|
||||
RIB_JOURNAL_ENTRIES: IntGauge = register_int_gauge!(
|
||||
"bgp_rib_journal_entries",
|
||||
"Changes of best route waiting for a follower to read them"
|
||||
);
|
||||
ROUTES_EXPORTED: IntCounterVec = register_int_counter_vec!(
|
||||
"bgp_routes_exported_total",
|
||||
"Routes learned from one peer and announced to, or withdrawn from, another",
|
||||
&["change"]
|
||||
);
|
||||
/// Whether each configured peer's session is up: what to alert on when a
|
||||
/// peer goes away, which a count of sessions cannot say.
|
||||
PEER_UP: IntGaugeVec = register_int_gauge_vec!(
|
||||
"bgp_peer_up",
|
||||
"Whether the session with a configured peer is Established (1) or not (0)",
|
||||
&["peer"]
|
||||
);
|
||||
BUILD_INFO: IntGaugeVec = register_int_gauge_vec!(
|
||||
"bgp_build_info",
|
||||
"Always 1; the version of the daemon is in the label",
|
||||
&["version"]
|
||||
);
|
||||
}
|
||||
|
||||
/// The type of a gauge of [`PeerTcp`] by what it holds: a time, in
|
||||
/// seconds, or a count.
|
||||
macro_rules! tcp_gauge {
|
||||
(seconds) => {
|
||||
GaugeVec
|
||||
};
|
||||
(count) => {
|
||||
IntGaugeVec
|
||||
};
|
||||
}
|
||||
|
||||
/// Registers a gauge of [`PeerTcp`], with the peer as its one label.
|
||||
macro_rules! register_tcp_gauge {
|
||||
(seconds, $name:expr, $help:expr) => {
|
||||
register_gauge_vec!($name, $help, &["peer"])
|
||||
};
|
||||
(count, $name:expr, $help:expr) => {
|
||||
register_int_gauge_vec!($name, $help, &["peer"])
|
||||
};
|
||||
}
|
||||
|
||||
/// What the kernel's figure becomes on the gauge: microseconds become
|
||||
/// seconds, and a count is clamped into what a gauge holds.
|
||||
macro_rules! tcp_gauge_value {
|
||||
(seconds, $microseconds:expr) => {
|
||||
$microseconds as f64 / 1e6
|
||||
};
|
||||
(count, $count:expr) => {
|
||||
i64::try_from($count).unwrap_or(i64::MAX)
|
||||
};
|
||||
}
|
||||
|
||||
/// Declares the gauges of [`PeerTcp`], each named once with its help text
|
||||
/// and the field of [`TcpConnection`] it is set from: the struct, how it is
|
||||
/// made, and what sets and removes a peer's series all follow from the one
|
||||
/// list. The gauge `name` is `bgp_peer_tcp_name`.
|
||||
macro_rules! peer_tcp {
|
||||
($connection:ident; $(
|
||||
$name:ident: $kind:ident from $source:ident = $help:literal,
|
||||
)*) => {
|
||||
/// A session's TCP connection as the kernel reports it (`TCP_INFO`),
|
||||
/// sampled when the metrics are scraped. Seconds, octets, segments, and
|
||||
/// octets per second; the busy, limited and byte figures are cumulative
|
||||
/// over the connection's life.
|
||||
pub struct PeerTcp {
|
||||
$(pub $name: tcp_gauge!($kind),)*
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Collectors for PeerTcp {
|
||||
fn collectors(&self) -> Vec<Box<dyn Collector>> {
|
||||
vec![$(Box::new(self.$name.clone()),)*]
|
||||
}
|
||||
}
|
||||
|
||||
impl PeerTcp {
|
||||
fn new() -> prometheus::Result<Self> {
|
||||
Ok(Self {
|
||||
$($name: register_tcp_gauge!(
|
||||
$kind,
|
||||
concat!("bgp_peer_tcp_", stringify!($name)),
|
||||
$help
|
||||
)?,)*
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets the peer's gauges from what the kernel reports of its
|
||||
/// connection.
|
||||
pub fn set(&self, label: &str, $connection: &TcpConnection) {
|
||||
let label = [label];
|
||||
$(
|
||||
self.$name
|
||||
.with_label_values(&label)
|
||||
.set(tcp_gauge_value!($kind, u64::from($connection.$source)));
|
||||
)*
|
||||
}
|
||||
|
||||
/// Forgets the peer's series, when its session has ended.
|
||||
pub fn remove(&self, label: &str) {
|
||||
$(let _ = self.$name.remove_label_values(&[label]);)*
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
peer_tcp! {
|
||||
connection;
|
||||
rtt_seconds: seconds from rtt = "Smoothed round-trip time of the session's TCP connection",
|
||||
min_rtt_seconds: seconds from min_rtt = "Least round-trip time seen on the connection",
|
||||
rtt_variance_seconds: seconds from rtt_variance = "Round-trip time variance of the connection",
|
||||
cwnd_segments: count from cwnd = "Congestion window, in segments",
|
||||
mss_bytes: count from mss = "Maximum segment size",
|
||||
send_window_bytes: count from send_window = "The peer's receive window: what it lets us send",
|
||||
receive_window_bytes: count from receive_window = "Our receive window: what we let the peer send",
|
||||
send_queue_bytes: count from send_queue = "Octets written and not yet acknowledged by the peer",
|
||||
not_sent_bytes: count from not_sent = "Octets written and not yet sent",
|
||||
receive_queue_bytes: count from recv_queue = "Octets received and not yet read",
|
||||
unacked_segments: count from unacked = "Segments sent and not yet acknowledged",
|
||||
retransmitted_segments: count from total_retrans = "Segments retransmitted over the connection's life",
|
||||
lost_segments: count from lost = "Segments thought lost now",
|
||||
busy_seconds: seconds from busy = "Time the connection had something to send, over its life",
|
||||
receive_window_limited_seconds: seconds from limited_by_receive_window = "Of the busy time, time held up by the peer's receive window",
|
||||
send_buffer_limited_seconds: seconds from limited_by_send_buffer = "Of the busy time, time held up by our send buffer being full",
|
||||
sent_bytes: count from bytes_sent = "Octets sent over the connection's life",
|
||||
received_bytes: count from bytes_received = "Octets received over the connection's life",
|
||||
delivery_rate_bytes_per_second: count from delivery_rate = "Delivery rate the kernel measured most recently",
|
||||
pacing_rate_bytes_per_second: count from pacing_rate = "Rate the kernel paces the connection at",
|
||||
}
|
||||
|
||||
pub fn message_type_name(msg: &Message) -> &'static str {
|
||||
@@ -242,3 +303,67 @@ pub fn message_type_name(msg: &Message) -> &'static str {
|
||||
Message::RouteRefresh(_) => "route_refresh",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Every metric family the declaration lists is on the registry once
|
||||
/// `init` has run, each under a name of its own: the registry refuses
|
||||
/// a second collector for any of them. The registry is the process's,
|
||||
/// so what else is on it is not looked at.
|
||||
#[test]
|
||||
fn init_registers_every_declared_metric_family() {
|
||||
init();
|
||||
let declared = declared();
|
||||
let names: HashSet<String> = declared
|
||||
.iter()
|
||||
.flat_map(|collector| collector.desc())
|
||||
.map(|desc| desc.fq_name.clone())
|
||||
.collect();
|
||||
assert_eq!(names.len(), declared.len(), "a name is declared twice");
|
||||
for collector in declared {
|
||||
assert!(matches!(
|
||||
prometheus::register(collector),
|
||||
Err(prometheus::Error::AlreadyReg)
|
||||
));
|
||||
}
|
||||
// A metric without labels is in a scrape from the start, which is
|
||||
// what `init` is for; one with labels is there once it has one.
|
||||
let scraped: HashSet<String> = prometheus::gather()
|
||||
.iter()
|
||||
.map(|family| family.name().to_string())
|
||||
.collect();
|
||||
for name in [
|
||||
"bgp_sessions_established",
|
||||
"bgp_rib_routes",
|
||||
"bgp_build_info",
|
||||
] {
|
||||
assert!(scraped.contains(name), "{name} is not in a scrape");
|
||||
}
|
||||
}
|
||||
|
||||
/// The kernel's microseconds become seconds, and its counts are set as
|
||||
/// they are; a peer whose session ended loses every series.
|
||||
#[test]
|
||||
fn a_connection_sets_and_a_removal_clears_every_gauge() {
|
||||
let tcp = &*PEER_TCP;
|
||||
let connection = TcpConnection {
|
||||
rtt: 1500,
|
||||
cwnd: 10,
|
||||
bytes_sent: 1 << 40,
|
||||
..TcpConnection::default()
|
||||
};
|
||||
tcp.set("metrics-test", &connection);
|
||||
let label = ["metrics-test"];
|
||||
assert_eq!(tcp.rtt_seconds.with_label_values(&label).get(), 0.0015);
|
||||
assert_eq!(tcp.cwnd_segments.with_label_values(&label).get(), 10);
|
||||
assert_eq!(tcp.sent_bytes.with_label_values(&label).get(), 1 << 40);
|
||||
tcp.remove("metrics-test");
|
||||
// There is nothing left to remove.
|
||||
assert!(tcp.rtt_seconds.remove_label_values(&label).is_err());
|
||||
assert!(tcp.sent_bytes.remove_label_values(&label).is_err());
|
||||
}
|
||||
}
|
||||
+151
-37
@@ -29,9 +29,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_packet::message::{
|
||||
AS_TRANS, AsPathAttribute, LocalPrefPathAttribute, NlriNextHop, PathAttribute,
|
||||
};
|
||||
use bgp_packet::message::{AS_TRANS, AsPathAttribute, NlriNextHop, PathAttribute};
|
||||
use bgp_packet::open::BgpRole;
|
||||
use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
@@ -91,6 +89,13 @@ pub struct ImportPolicy {
|
||||
/// who never asked for it. A route server's routes always have its
|
||||
/// clients as next hops, and are taken either way.
|
||||
pub accept_third_party_next_hop: bool,
|
||||
/// Take a next hop that is on this host: a loopback address, or one of
|
||||
/// our own. For routers that share a host, as those of a test do. Not
|
||||
/// a setting; only [`ImportPolicy::permissive`] has it.
|
||||
pub accept_own_host_next_hop: bool,
|
||||
/// Take routes with the BLACKHOLE community for single addresses, and
|
||||
/// have the kernel drop the traffic to them (RFC 7999).
|
||||
pub accept_blackholes: bool,
|
||||
/// The prefixes this router originates. Those, and what is inside
|
||||
/// them, are ours and not taken from another network, unless
|
||||
/// `prefixes` says in so many words that this peer may announce them:
|
||||
@@ -167,13 +172,7 @@ impl ImportRule {
|
||||
}
|
||||
|
||||
pub fn apply(&self, attributes: &mut Vec<PathAttribute>) {
|
||||
self.marks.apply(attributes);
|
||||
if let Some(local_pref) = self.local_pref {
|
||||
marks::put(
|
||||
attributes,
|
||||
PathAttribute::LocalPref(LocalPrefPathAttribute(local_pref)),
|
||||
);
|
||||
}
|
||||
marks::mark(attributes, &self.marks, self.local_pref);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +195,8 @@ impl Default for ImportPolicy {
|
||||
accept_aspa_invalid: false,
|
||||
accept_as_sets: false,
|
||||
accept_third_party_next_hop: false,
|
||||
accept_own_host_next_hop: false,
|
||||
accept_blackholes: false,
|
||||
own_prefixes: Arc::default(),
|
||||
originated: Arc::default(),
|
||||
accept_control_communities: false,
|
||||
@@ -224,6 +225,8 @@ impl ImportPolicy {
|
||||
accept_aspa_invalid: true,
|
||||
accept_as_sets: true,
|
||||
accept_third_party_next_hop: true,
|
||||
accept_own_host_next_hop: true,
|
||||
accept_blackholes: false,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
@@ -245,13 +248,7 @@ impl ImportPolicy {
|
||||
if let Some(scrub) = self.scrub {
|
||||
scrub.apply(attributes);
|
||||
}
|
||||
self.marks.apply(attributes);
|
||||
if let Some(local_pref) = self.local_pref {
|
||||
marks::put(
|
||||
attributes,
|
||||
PathAttribute::LocalPref(LocalPrefPathAttribute(local_pref)),
|
||||
);
|
||||
}
|
||||
marks::mark(attributes, &self.marks, self.local_pref);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +258,11 @@ pub struct ExportPolicy {
|
||||
/// Pass on the best routes learned from other peers, and not only
|
||||
/// what we originate.
|
||||
pub learned_routes: bool,
|
||||
/// Pass on every route learned from other peers, not only the best
|
||||
/// of each prefix, told apart by path identifiers (ADD-PATH, RFC
|
||||
/// 7911), for the families the peer agreed to receive them for; a
|
||||
/// route collector asks for this. For other families the best goes.
|
||||
pub all_paths: bool,
|
||||
/// Communities taken off and put on every route announced.
|
||||
pub marks: Marks,
|
||||
/// How many more times our AS goes on the path.
|
||||
@@ -336,10 +338,14 @@ pub enum Rejection {
|
||||
/// The path has an AS_SET in it (RFC 9774).
|
||||
AsSet,
|
||||
/// The route asks for the traffic to its prefix to be dropped: it has
|
||||
/// the BLACKHOLE community (RFC 7999). Such requests are not acted on,
|
||||
/// and the route is not taken as an ordinary one either, which would
|
||||
/// send the traffic to wherever its next hop drops it.
|
||||
/// the BLACKHOLE community (RFC 7999), and the policy does not take
|
||||
/// such requests (`accept_blackholes`). The route is not taken as an
|
||||
/// ordinary one either, which would send the traffic to wherever its
|
||||
/// next hop drops it.
|
||||
Blackhole,
|
||||
/// A blackhole request for more than one address: only a /32 or a
|
||||
/// /128 is taken as one.
|
||||
BlackholeNotHost,
|
||||
/// The next hop is no address packets could be sent to, or is our
|
||||
/// own, or is an IPv6 address for an IPv4 route on a session that did
|
||||
/// not agree to that (RFC 8950).
|
||||
@@ -371,7 +377,7 @@ impl Rejection {
|
||||
)
|
||||
}
|
||||
|
||||
pub const ALL: [Self; 20] = [
|
||||
pub const ALL: [Self; 21] = [
|
||||
Self::BogonPrefix,
|
||||
Self::TooShort,
|
||||
Self::TooSpecific,
|
||||
@@ -386,6 +392,7 @@ impl Rejection {
|
||||
Self::Leak,
|
||||
Self::AsSet,
|
||||
Self::Blackhole,
|
||||
Self::BlackholeNotHost,
|
||||
Self::NextHopUnusable,
|
||||
Self::NextHopNotPeer,
|
||||
Self::OwnPrefix,
|
||||
@@ -415,6 +422,7 @@ impl Rejection {
|
||||
Self::Leak => "route leak (Only to Customer)",
|
||||
Self::AsSet => "AS_SET in path",
|
||||
Self::Blackhole => "blackhole request",
|
||||
Self::BlackholeNotHost => "blackhole for more than one address",
|
||||
Self::NextHopUnusable => "next hop unusable",
|
||||
Self::NextHopNotPeer => "next hop is not the peer",
|
||||
Self::OwnPrefix => "prefix is our own",
|
||||
@@ -568,11 +576,7 @@ impl ImportPolicy {
|
||||
if facts.has_local_asn {
|
||||
return Err(Rejection::OwnAs);
|
||||
}
|
||||
let blackhole = attributes.iter().any(|attribute| {
|
||||
matches!(attribute, PathAttribute::Communities(communities)
|
||||
if communities.0.contains(&BLACKHOLE))
|
||||
});
|
||||
if blackhole {
|
||||
if !self.accept_blackholes && Self::is_blackhole_request(attributes) {
|
||||
return Err(Rejection::Blackhole);
|
||||
}
|
||||
// RFC 9234 §5, ingress, 1 and 2. A route that is only to go to
|
||||
@@ -642,15 +646,52 @@ impl ImportPolicy {
|
||||
return Err(Rejection::BogonPrefix);
|
||||
}
|
||||
}
|
||||
self.listed_or_ours(prefix, PrefixSet::allows)
|
||||
}
|
||||
|
||||
/// Whether `prefix` is one the peer may announce by its list, where it
|
||||
/// has one, and else not one of ours. `inside` says what being in a
|
||||
/// set means: [`PrefixSet::allows`] for a route, which is bound by the
|
||||
/// lengths allowed, [`PrefixSet::covers`] for a blackhole request,
|
||||
/// which is for one address whatever the lengths.
|
||||
fn listed_or_ours(
|
||||
&self,
|
||||
prefix: &IpPrefix,
|
||||
inside: fn(&PrefixSet, &IpPrefix) -> bool,
|
||||
) -> Result<(), Rejection> {
|
||||
match &self.prefixes {
|
||||
Some(allowed) if !allowed.allows(prefix) => Err(Rejection::PrefixNotAllowed),
|
||||
Some(allowed) if !inside(allowed, prefix) => Err(Rejection::PrefixNotAllowed),
|
||||
// Said to be this peer's to announce, ours or not.
|
||||
Some(_) => Ok(()),
|
||||
None if self.own_prefixes.allows(prefix) => Err(Rejection::OwnPrefix),
|
||||
None if inside(&self.own_prefixes, prefix) => Err(Rejection::OwnPrefix),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the routes with these attributes ask for the traffic to
|
||||
/// them to be dropped: the BLACKHOLE community (RFC 7999).
|
||||
pub fn is_blackhole_request(attributes: &[PathAttribute]) -> bool {
|
||||
attributes.iter().any(|attribute| {
|
||||
matches!(attribute, PathAttribute::Communities(communities)
|
||||
if communities.0.contains(&BLACKHOLE))
|
||||
})
|
||||
}
|
||||
|
||||
/// Judges the prefix of a blackhole request that the policy takes
|
||||
/// (`accept_blackholes`): one address, and inside what the peer may
|
||||
/// announce. The longest prefix taken otherwise does not apply, nor
|
||||
/// does the longest allowed inside a listed prefix: a request is for
|
||||
/// one address by its nature.
|
||||
pub fn check_blackhole_prefix(&self, prefix: &IpPrefix) -> Result<(), Rejection> {
|
||||
if prefix.length() != prefix.address_family().bits() {
|
||||
return Err(Rejection::BlackholeNotHost);
|
||||
}
|
||||
if !self.accept_bogons && is_bogon_prefix(prefix) {
|
||||
return Err(Rejection::BogonPrefix);
|
||||
}
|
||||
self.listed_or_ours(prefix, PrefixSet::covers)
|
||||
}
|
||||
|
||||
/// Judges the next hop of routes of IP version `routes`.
|
||||
///
|
||||
/// It has to be an address packets can be sent to and not our own
|
||||
@@ -686,10 +727,10 @@ impl ImportPolicy {
|
||||
let mut is_peer = false;
|
||||
for address in named {
|
||||
let usable = !address.is_unspecified()
|
||||
&& !address.is_loopback()
|
||||
&& !address.is_multicast()
|
||||
&& address != IpAddr::V4(Ipv4Addr::BROADCAST)
|
||||
&& address != from.local_address;
|
||||
&& (self.accept_own_host_next_hop
|
||||
|| (!address.is_loopback() && address != from.local_address));
|
||||
if !usable {
|
||||
return Err(Rejection::NextHopUnusable);
|
||||
}
|
||||
@@ -882,6 +923,15 @@ impl PrefixSet {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `prefix` is inside some entry, whatever length the entry
|
||||
/// allows inside it.
|
||||
pub fn covers(&self, prefix: &IpPrefix) -> bool {
|
||||
self.lengths[Self::version_index(prefix)]
|
||||
.iter()
|
||||
.take_while(|length| **length <= prefix.length())
|
||||
.any(|length| self.entries.contains_key(&prefix.shortened(*length)))
|
||||
}
|
||||
|
||||
/// Whether `prefix` is allowed by some entry.
|
||||
pub fn allows(&self, prefix: &IpPrefix) -> bool {
|
||||
// An entry that covers it is the prefix cut to the entry's length,
|
||||
@@ -1362,6 +1412,62 @@ mod tests {
|
||||
assert_eq!(lenient.check_path(&with_set, &EXTERNAL), Ok(None));
|
||||
}
|
||||
|
||||
/// With accept_blackholes, a request to drop traffic is taken for one
|
||||
/// address inside what the peer may announce, however long a prefix
|
||||
/// the policy takes otherwise; and for nothing else.
|
||||
#[test]
|
||||
fn a_blackhole_request_is_taken_for_one_address_where_allowed() {
|
||||
let mut attributes = path(&[3333, 2121]);
|
||||
attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(vec![
|
||||
(0xFFFF, 0x029A),
|
||||
])));
|
||||
assert!(ImportPolicy::is_blackhole_request(&attributes));
|
||||
assert!(!ImportPolicy::is_blackhole_request(&path(&[3333])));
|
||||
let mut policy = ImportPolicy {
|
||||
accept_blackholes: true,
|
||||
..ImportPolicy::default()
|
||||
};
|
||||
// The path passes as any other's would.
|
||||
assert_eq!(policy.check_path(&attributes, &EXTERNAL), Ok(Some(2121)));
|
||||
|
||||
// Without a prefix list: any one routable address but ours.
|
||||
let check = |policy: &ImportPolicy, p: &str| policy.check_blackhole_prefix(&prefix(p));
|
||||
assert_eq!(check(&policy, "193.0.0.9/32"), Ok(()));
|
||||
assert_eq!(check(&policy, "2001:67c:2e8::9/128"), Ok(()));
|
||||
assert_eq!(
|
||||
check(&policy, "193.0.0.0/24"),
|
||||
Err(Rejection::BlackholeNotHost)
|
||||
);
|
||||
assert_eq!(
|
||||
check(&policy, "2001:67c:2e8::/64"),
|
||||
Err(Rejection::BlackholeNotHost)
|
||||
);
|
||||
assert_eq!(
|
||||
check(&policy, "10.0.0.1/32"),
|
||||
Err(Rejection::BogonPrefix),
|
||||
"a bogon is a bogon, one address or not"
|
||||
);
|
||||
let mut ours = PrefixSet::default();
|
||||
ours.allow(prefix("193.0.0.0/21"), 24);
|
||||
policy.own_prefixes = Arc::new(ours);
|
||||
assert_eq!(check(&policy, "193.0.0.9/32"), Err(Rejection::OwnPrefix));
|
||||
|
||||
// With one: inside a listed prefix, whatever length the list
|
||||
// allows inside it, and ours or not.
|
||||
let mut allowed = PrefixSet::default();
|
||||
allowed.allow(prefix("193.0.0.0/21"), 24);
|
||||
policy.prefixes = Some(allowed);
|
||||
assert_eq!(check(&policy, "193.0.0.9/32"), Ok(()));
|
||||
assert_eq!(
|
||||
check(&policy, "193.0.8.9/32"),
|
||||
Err(Rejection::PrefixNotAllowed)
|
||||
);
|
||||
assert_eq!(
|
||||
Rejection::BlackholeNotHost.as_str(),
|
||||
"blackhole for more than one address"
|
||||
);
|
||||
}
|
||||
|
||||
/// A request to drop traffic is neither acted on nor taken as an
|
||||
/// ordinary route, whose next hop would drop the traffic all the same.
|
||||
/// It is told apart from a prefix that is merely too long, which is
|
||||
@@ -1442,13 +1548,7 @@ mod tests {
|
||||
);
|
||||
// Nowhere at all, whatever the policy.
|
||||
let lenient = ImportPolicy::permissive();
|
||||
for nowhere in [
|
||||
"0.0.0.0",
|
||||
"127.0.0.1",
|
||||
"224.0.0.5",
|
||||
"255.255.255.255",
|
||||
"192.0.2.1",
|
||||
] {
|
||||
for nowhere in ["0.0.0.0", "224.0.0.5", "255.255.255.255"] {
|
||||
for policy in [&policy, &lenient] {
|
||||
assert_eq!(
|
||||
check(policy, &v4(nowhere), &EXTERNAL),
|
||||
@@ -1457,6 +1557,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
// This machine: nowhere for traffic to go, unless the policy is
|
||||
// the permissive one, for routers that share a host.
|
||||
for ourselves in ["127.0.0.1", "192.0.2.1"] {
|
||||
assert_eq!(
|
||||
check(&policy, &v4(ourselves), &EXTERNAL),
|
||||
Err(Rejection::NextHopUnusable),
|
||||
"{ourselves}"
|
||||
);
|
||||
assert_eq!(
|
||||
check(&lenient, &v4(ourselves), &EXTERNAL),
|
||||
Ok(()),
|
||||
"{ourselves}"
|
||||
);
|
||||
}
|
||||
// Where it may be someone else: said so, a route server, our own AS.
|
||||
assert_eq!(check(&lenient, &v4("192.0.2.3"), &EXTERNAL), Ok(()));
|
||||
for from in [
|
||||
|
||||
+76
-83
@@ -9,10 +9,11 @@
|
||||
//! aside like a route the RPKI finds invalid, and looked at again when
|
||||
//! the interfaces change (see `crate::routes::Held`).
|
||||
//!
|
||||
//! Only next hops that are neighbors on one of the machine's interfaces
|
||||
//! count as reachable: the way to any other is not looked up. Routes from
|
||||
//! a peer in our own AS that did not set itself as next hop are therefore
|
||||
//! held, where they used to be chosen and then not installed.
|
||||
//! A next hop that is a neighbor on one of the machine's interfaces can
|
||||
//! be reached, and so can one that the kernel's own routes know the way
|
||||
//! to; see `bgp_fib::routes` for which routes count. Routes from a peer
|
||||
//! in our own AS that did not set itself as next hop are taken if a static
|
||||
//! route or an IGP's says how to reach it, and held otherwise.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
@@ -20,17 +21,17 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_fib::InterfacesWatch;
|
||||
use bgp_fib::interfaces::Interfaces;
|
||||
use bgp_fib::TopologyWatch;
|
||||
use bgp_fib::plan::{Neighbor, resolve, resolve_next_hop};
|
||||
use bgp_fib::topology::Topology;
|
||||
use bgp_packet::message::NlriNextHop;
|
||||
use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
|
||||
use bgp_rib::table::{PeerIndex, RibTable};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::address::PeerAddress;
|
||||
use crate::rpki::{by_rib_peer, group_by_peer, revalidate_all};
|
||||
use crate::session::SessionRegistry;
|
||||
|
||||
/// How long after the interfaces change the table is looked through:
|
||||
@@ -38,19 +39,32 @@ use crate::session::SessionRegistry;
|
||||
/// judged it by the interfaces as they were.
|
||||
const SETTLE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// The interfaces to judge one peer's next hops by.
|
||||
/// The kernel's view of the machine to judge one peer's next hops by.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct NextHops<'a> {
|
||||
pub(crate) interfaces: &'a Interfaces,
|
||||
pub(crate) topology: &'a Topology,
|
||||
/// The peer: where a link-local next hop is.
|
||||
pub(crate) neighbor: Neighbor,
|
||||
}
|
||||
|
||||
impl NextHops<'_> {
|
||||
/// Whether the kernel can be given a route for `prefix` by way of
|
||||
/// `next_hop`.
|
||||
pub(crate) fn reaches(&self, prefix: &IpPrefix, next_hop: Option<&NlriNextHop>) -> bool {
|
||||
resolve(self.interfaces, prefix, next_hop, Some(self.neighbor)).is_ok()
|
||||
/// `next_hop`; or, for a route that asks for its packets to be
|
||||
/// dropped (`blackhole`), as a route to nowhere.
|
||||
pub(crate) fn reaches(
|
||||
&self,
|
||||
prefix: &IpPrefix,
|
||||
next_hop: Option<&NlriNextHop>,
|
||||
blackhole: bool,
|
||||
) -> bool {
|
||||
resolve(
|
||||
self.topology,
|
||||
prefix,
|
||||
next_hop,
|
||||
Some(self.neighbor),
|
||||
blackhole,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// The two halves of [`NextHops::reaches`], for an UPDATE's worth of
|
||||
@@ -58,52 +72,52 @@ impl NextHops<'_> {
|
||||
/// is asked once, and whether a prefix is one the machine reaches by
|
||||
/// itself, which is asked of each.
|
||||
pub(crate) fn next_hop_is_near(&self, routes: IpVersion, next_hop: &NlriNextHop) -> bool {
|
||||
resolve_next_hop(self.interfaces, routes, Some(next_hop), Some(self.neighbor)).is_ok()
|
||||
resolve_next_hop(self.topology, routes, Some(next_hop), Some(self.neighbor)).is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn is_own_network(&self, prefix: &IpPrefix) -> bool {
|
||||
self.interfaces.has_network_around(prefix)
|
||||
self.topology.interfaces.has_network_around(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
/// A session's view of the interfaces: those in use, replaced when the
|
||||
/// kernel says they have changed. Asking whether they have costs the load
|
||||
/// of one number, which is done for every UPDATE.
|
||||
/// A session's view of the topology: the one in use, replaced when the
|
||||
/// kernel says it has changed. Asking whether it has costs the load of
|
||||
/// one number, which is done for every UPDATE.
|
||||
pub(crate) struct Reach {
|
||||
watch: InterfacesWatch,
|
||||
watch: TopologyWatch,
|
||||
neighbor: Neighbor,
|
||||
interfaces: Option<Arc<Interfaces>>,
|
||||
topology: Option<Arc<Topology>>,
|
||||
}
|
||||
|
||||
impl Reach {
|
||||
pub(crate) fn new(watch: InterfacesWatch, neighbor: Neighbor) -> Self {
|
||||
let interfaces = watch.borrow().clone();
|
||||
pub(crate) fn new(watch: TopologyWatch, neighbor: Neighbor) -> Self {
|
||||
let topology = watch.borrow().clone();
|
||||
Self {
|
||||
watch,
|
||||
neighbor,
|
||||
interfaces,
|
||||
topology,
|
||||
}
|
||||
}
|
||||
|
||||
/// What to judge next hops by now; nothing until the kernel has said
|
||||
/// what interfaces there are.
|
||||
/// what interfaces and routes there are.
|
||||
pub(crate) fn next_hops(&mut self) -> Option<NextHops<'_>> {
|
||||
if self.watch.has_changed().unwrap_or(false) {
|
||||
self.interfaces = self.watch.borrow_and_update().clone();
|
||||
self.topology = self.watch.borrow_and_update().clone();
|
||||
}
|
||||
self.interfaces.as_deref().map(|interfaces| NextHops {
|
||||
interfaces,
|
||||
self.topology.as_deref().map(|topology| NextHops {
|
||||
topology,
|
||||
neighbor: self.neighbor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the task that, whenever the machine's interfaces change, finds
|
||||
/// the routes in the table whose next hop can no longer be reached and has
|
||||
/// their sessions take them out, and has every session look again at the
|
||||
/// routes it holds.
|
||||
/// Starts the task that, whenever the machine's interfaces or routes
|
||||
/// change, finds the routes in the table whose next hop can no longer be
|
||||
/// reached and has their sessions take them out, and has every session
|
||||
/// look again at the routes it holds.
|
||||
pub(crate) fn spawn_revalidator(
|
||||
mut watch: InterfacesWatch,
|
||||
mut watch: TopologyWatch,
|
||||
rib: Arc<RibTable>,
|
||||
registry: SessionRegistry,
|
||||
) -> JoinHandle<()> {
|
||||
@@ -112,42 +126,22 @@ pub(crate) fn spawn_revalidator(
|
||||
// which the server notices for itself.
|
||||
while watch.changed().await.is_ok() {
|
||||
tokio::time::sleep(SETTLE).await;
|
||||
let Some(interfaces) = watch.borrow_and_update().clone() else {
|
||||
let Some(topology) = watch.borrow_and_update().clone() else {
|
||||
continue;
|
||||
};
|
||||
let sessions = registry.list();
|
||||
// By peer, for looking up as often as there are routes.
|
||||
let mut neighbors: Vec<Option<Neighbor>> = Vec::new();
|
||||
for session in &sessions {
|
||||
let at = session.rib_peer.as_usize();
|
||||
if neighbors.len() <= at {
|
||||
neighbors.resize(at + 1, None);
|
||||
}
|
||||
neighbors[at] = Some(neighbor_at(session.peer.addr));
|
||||
}
|
||||
let table = Arc::clone(&rib);
|
||||
// Looks at every route: not for a thread that sessions run on.
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
now_unreachable(&table, &interfaces, &neighbors)
|
||||
})
|
||||
.await;
|
||||
let mut suspects = match found {
|
||||
Ok(suspects) => suspects,
|
||||
Err(e) => {
|
||||
warn!(err = %e, "Looking for routes whose next hop is gone failed");
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
info!(
|
||||
now_unreachable = suspects.values().map(Vec::len).sum::<usize>(),
|
||||
sessions = sessions.len(),
|
||||
"The machine's interfaces changed; looking at next hops again"
|
||||
let neighbors = by_rib_peer(
|
||||
sessions
|
||||
.iter()
|
||||
.map(|session| (session.rib_peer, neighbor_at(session.peer.addr))),
|
||||
);
|
||||
// Every session, also those with nothing found: each has its
|
||||
// held routes to look at again.
|
||||
for session in sessions {
|
||||
session.revalidate(suspects.remove(&session.rib_peer).unwrap_or_default());
|
||||
}
|
||||
revalidate_all(
|
||||
sessions,
|
||||
&rib,
|
||||
"The machine's interfaces or routes changed; looking at next hops again",
|
||||
move |rib| now_unreachable(rib, &topology, &neighbors),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -162,11 +156,11 @@ pub(crate) fn neighbor_at(address: SocketAddr) -> Neighbor {
|
||||
}
|
||||
|
||||
/// The routes in the table that the kernel could not be given, as the
|
||||
/// interfaces are now, by peer. `neighbors` says, for each peer by its
|
||||
/// machine is now, by peer. `neighbors` says, for each peer by its
|
||||
/// number in the table, where it is.
|
||||
fn now_unreachable(
|
||||
rib: &RibTable,
|
||||
interfaces: &Interfaces,
|
||||
topology: &Topology,
|
||||
neighbors: &[Option<Neighbor>],
|
||||
) -> HashMap<PeerIndex, Vec<Nlri>> {
|
||||
// There are as many next hops as neighbors, and a table's worth of
|
||||
@@ -177,36 +171,35 @@ fn now_unreachable(
|
||||
let Some(Some(neighbor)) = neighbors.get(route.peer.as_usize()) else {
|
||||
return false;
|
||||
};
|
||||
if interfaces.has_network_around(&route.prefix) {
|
||||
if topology.interfaces.has_network_around(&route.prefix) {
|
||||
return true;
|
||||
}
|
||||
// A blackhole resolves whatever its next hop: nothing of it can
|
||||
// have become unreachable.
|
||||
if route.attrs.is_blackhole() {
|
||||
return false;
|
||||
}
|
||||
let next_hop = route.attrs.next_hop();
|
||||
let key = (next_hop, route.prefix.address_family(), route.peer);
|
||||
!*resolved.borrow_mut().entry(key).or_insert_with(|| {
|
||||
resolve(
|
||||
interfaces,
|
||||
topology,
|
||||
&route.prefix,
|
||||
next_hop.as_ref(),
|
||||
Some(*neighbor),
|
||||
false,
|
||||
)
|
||||
.is_ok()
|
||||
})
|
||||
});
|
||||
let mut by_peer: HashMap<PeerIndex, Vec<Nlri>> = HashMap::new();
|
||||
for route in routes {
|
||||
by_peer.entry(route.peer).or_default().push(Nlri {
|
||||
prefix: route.prefix,
|
||||
path_id: route.path_id,
|
||||
});
|
||||
}
|
||||
by_peer
|
||||
group_by_peer(routes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use bgp_fib::netlink::{Address, Link, Message};
|
||||
use bgp_fib::netlink::{Address, Link, Message, TABLE_MAIN};
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage,
|
||||
};
|
||||
@@ -224,8 +217,8 @@ mod tests {
|
||||
|
||||
/// A machine with eth2 (192.0.2.1/24), and eth3 (198.51.100.1/24) if
|
||||
/// `both`.
|
||||
fn interfaces(both: bool) -> Interfaces {
|
||||
let mut interfaces = Interfaces::default();
|
||||
fn interfaces(both: bool) -> Topology {
|
||||
let mut interfaces = Topology::new(TABLE_MAIN, false);
|
||||
let all = [
|
||||
(2, "192.0.2.1", "192.0.2.0/24"),
|
||||
(3, "198.51.100.1", "198.51.100.0/24"),
|
||||
@@ -298,7 +291,7 @@ mod tests {
|
||||
fn with_import<T>(
|
||||
&self,
|
||||
peer: &mut Peer,
|
||||
interfaces: &Interfaces,
|
||||
interfaces: &Topology,
|
||||
work: impl FnOnce(&RibTable, &Import<'_>, &mut Held) -> T,
|
||||
) -> T {
|
||||
let address = IpAddr::V4(peer.address);
|
||||
@@ -317,7 +310,7 @@ mod tests {
|
||||
policy: &self.policy,
|
||||
checks: Checks {
|
||||
next_hops: Some(NextHops {
|
||||
interfaces,
|
||||
topology: interfaces,
|
||||
neighbor: address.into(),
|
||||
}),
|
||||
..Checks::NONE
|
||||
@@ -329,7 +322,7 @@ mod tests {
|
||||
|
||||
/// The peer announces `route` with a path of `hops` AS numbers and
|
||||
/// itself as next hop.
|
||||
fn announce(&self, peer: &mut Peer, interfaces: &Interfaces, route: &str, hops: usize) {
|
||||
fn announce(&self, peer: &mut Peer, interfaces: &Topology, route: &str, hops: usize) {
|
||||
let update = UpdateMessage {
|
||||
withdrawn_routes: vec![],
|
||||
path_attributes: vec![
|
||||
@@ -344,14 +337,14 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
fn revalidate(&self, peer: &mut Peer, interfaces: &Interfaces, suspects: &[Nlri]) {
|
||||
fn revalidate(&self, peer: &mut Peer, interfaces: &Topology, suspects: &[Nlri]) {
|
||||
self.with_import(peer, interfaces, |table, import, held| {
|
||||
routes::revalidate(table, import, held, suspects)
|
||||
});
|
||||
}
|
||||
|
||||
fn best(&self, route: &str) -> Option<PeerIndex> {
|
||||
self.table.best(&prefix(route)).map(|(peer, _)| peer)
|
||||
self.table.best(&prefix(route)).map(|best| best.peer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -244,6 +244,30 @@ pub struct Kept {
|
||||
pub until: SystemTime,
|
||||
}
|
||||
|
||||
impl Kept {
|
||||
/// What was kept of a peer: `routes` routes of the IP versions
|
||||
/// `versions`, under `rib_peer`, of the peer at `addr` called `name`.
|
||||
/// Which restart they are of and until when they are kept are the
|
||||
/// table's to say, in [`Restarts::keep`].
|
||||
pub(crate) fn new(
|
||||
addr: SocketAddr,
|
||||
name: Option<String>,
|
||||
rib_peer: PeerIndex,
|
||||
versions: Vec<IpVersion>,
|
||||
routes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: 0,
|
||||
addr,
|
||||
rib_peer,
|
||||
name,
|
||||
versions,
|
||||
routes,
|
||||
until: SystemTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What there is of graceful restart while the server runs: the routes
|
||||
/// kept of peers that are restarting, and how far the peers are with
|
||||
/// sending their routes after this daemon started.
|
||||
@@ -298,33 +322,14 @@ impl Restarts {
|
||||
})
|
||||
}
|
||||
|
||||
/// Notes that the routes of `peer` under `rib_peer` have been kept
|
||||
/// (`routes` of them, of the IP versions `versions`) until `for_at_most`
|
||||
/// from now, and has them removed then unless the peer is back.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn keep(
|
||||
self: &Arc<Self>,
|
||||
peer: PeerKey,
|
||||
addr: SocketAddr,
|
||||
name: Option<String>,
|
||||
rib_peer: PeerIndex,
|
||||
versions: Vec<IpVersion>,
|
||||
routes: usize,
|
||||
for_at_most: Duration,
|
||||
) {
|
||||
/// Notes that `kept` says what routes of `peer` have been kept, until
|
||||
/// `for_at_most` from now, and has them removed then unless the peer
|
||||
/// is back.
|
||||
pub(crate) fn keep(self: &Arc<Self>, peer: PeerKey, mut kept: Kept, for_at_most: Duration) {
|
||||
let id = self.ids.fetch_add(1, Ordering::Relaxed);
|
||||
self.kept.locked().insert(
|
||||
peer,
|
||||
Kept {
|
||||
id,
|
||||
addr,
|
||||
rib_peer,
|
||||
name,
|
||||
versions,
|
||||
routes,
|
||||
until: SystemTime::now() + for_at_most,
|
||||
},
|
||||
);
|
||||
kept.id = id;
|
||||
kept.until = SystemTime::now() + for_at_most;
|
||||
self.kept.locked().insert(peer, kept);
|
||||
let restarts = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(for_at_most).await;
|
||||
|
||||
+278
-3
@@ -21,6 +21,14 @@ use crate::rpki::Checks;
|
||||
/// than a peer sending the whole Internet has; a bound all the same.
|
||||
const MAX_HELD: usize = 200_000;
|
||||
|
||||
/// Why an UPDATE could not be applied: the attributes its routes were to
|
||||
/// be kept with could not be written in the form the table keeps them in.
|
||||
/// Attributes that were decoded from a message always can be, so this is
|
||||
/// a bug here, and not anything the peer did.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("could not keep the routes' attributes: {0}")]
|
||||
pub struct Unstorable(#[from] pub(crate) EncodeError);
|
||||
|
||||
/// What applying an UPDATE did.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(crate) struct Applied {
|
||||
@@ -113,7 +121,7 @@ pub(crate) fn apply(
|
||||
held: &mut Held,
|
||||
update: &UpdateMessage,
|
||||
attributes_wire: Option<&[u8]>,
|
||||
) -> Result<Applied, EncodeError> {
|
||||
) -> Result<Applied, Unstorable> {
|
||||
let mut applying = Applying {
|
||||
table,
|
||||
import,
|
||||
@@ -129,6 +137,8 @@ pub(crate) fn apply(
|
||||
as_path: OnceCell::new(),
|
||||
refused_attributes: OnceCell::new(),
|
||||
mark: only_to_customer_mark(import, update),
|
||||
blackhole: import.policy.accept_blackholes
|
||||
&& ImportPolicy::is_blackhole_request(&update.path_attributes),
|
||||
changed: OnceCell::new(),
|
||||
rules_holding: OnceCell::new(),
|
||||
};
|
||||
@@ -176,6 +186,10 @@ struct Applying<'a> {
|
||||
/// What the policy makes of the path, and the AS the routes originate
|
||||
/// from if it passes.
|
||||
path_verdict: Result<Option<u32>, Rejection>,
|
||||
/// Whether the routes ask for their traffic to be dropped, and the
|
||||
/// policy takes such requests: then the next hop is nothing to them,
|
||||
/// and the prefix is judged as a request's.
|
||||
blackhole: bool,
|
||||
/// Whether the ASPAs find the path invalid: asked only if a route
|
||||
/// gets as far as that mattering.
|
||||
path_invalid: OnceCell<bool>,
|
||||
@@ -235,8 +249,10 @@ impl Applying<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// Asked once for all of them: they have the one next hop.
|
||||
let near = self
|
||||
// Asked once for all of them: they have the one next hop. For a
|
||||
// blackhole it is not asked at all: the packets go nowhere.
|
||||
let near = self.blackhole
|
||||
|| self
|
||||
.import
|
||||
.checks
|
||||
.next_hops
|
||||
@@ -374,9 +390,13 @@ impl Applying<'_> {
|
||||
.copied()
|
||||
.or_else(in_attributes)
|
||||
.ok_or(Rejection::NextHopUnusable)?;
|
||||
// The traffic to a blackholed prefix goes nowhere: where the next
|
||||
// hop would have taken it does not matter.
|
||||
if !self.blackhole {
|
||||
self.import
|
||||
.policy
|
||||
.check_next_hop(&next_hop, routes, &self.import.from)?;
|
||||
}
|
||||
Ok((origin, next_hop))
|
||||
}
|
||||
|
||||
@@ -385,7 +405,11 @@ impl Applying<'_> {
|
||||
/// comes before what may pass.
|
||||
fn judge(&self, route: &Nlri, origin: Option<u32>, near: bool) -> Result<(), Rejection> {
|
||||
let import = self.import;
|
||||
if self.blackhole {
|
||||
import.policy.check_blackhole_prefix(&route.prefix)?;
|
||||
} else {
|
||||
import.policy.check_prefix(&route.prefix)?;
|
||||
}
|
||||
let origin_invalid = import
|
||||
.checks
|
||||
.vrps
|
||||
@@ -582,3 +606,254 @@ pub(crate) fn revalidate(
|
||||
}
|
||||
applied
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use bgp_packet::message::{AsPathAttribute, NextHopPathAttribute, OriginPathAttribute};
|
||||
use bgp_rib::table::PeerInfo;
|
||||
use bgp_rpki::{Vrp, VrpTable};
|
||||
|
||||
use super::*;
|
||||
use crate::rejections::RejectionLog;
|
||||
|
||||
const PEER_ASN: u32 = 65001;
|
||||
const PEER_ADDRESS: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1);
|
||||
|
||||
fn prefix(text: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(text).unwrap()
|
||||
}
|
||||
|
||||
fn nlri(text: &str) -> Nlri {
|
||||
prefix(text).into()
|
||||
}
|
||||
|
||||
fn attrs_of(route: &str) -> Arc<AttrSet> {
|
||||
RibTable::new(1)
|
||||
.attrs()
|
||||
.intern(&announcement(route).path_attributes, None)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// An UPDATE as the peer announces `route`, originating it itself.
|
||||
fn announcement(route: &str) -> UpdateMessage {
|
||||
UpdateMessage {
|
||||
withdrawn_routes: vec![],
|
||||
path_attributes: vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![PEER_ASN]),
|
||||
PathAttribute::NextHop(NextHopPathAttribute(PEER_ADDRESS)),
|
||||
],
|
||||
announced_routes: vec![nlri(route)],
|
||||
}
|
||||
}
|
||||
|
||||
/// VRPs that allow `route` to be originated by `asn` and nobody else.
|
||||
fn vrps(route: &str, asn: u32) -> VrpTable {
|
||||
let prefix = prefix(route);
|
||||
VrpTable::new([Vrp {
|
||||
prefix,
|
||||
max_length: prefix.length(),
|
||||
asn,
|
||||
}])
|
||||
}
|
||||
|
||||
/// A table with the peer in it, and what is needed to apply its
|
||||
/// UPDATEs by a permissive policy, checking only against `vrps`.
|
||||
struct Fixture {
|
||||
table: RibTable,
|
||||
peer: PeerIndex,
|
||||
policy: ImportPolicy,
|
||||
rejections: RejectionLog,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let table = RibTable::new(1);
|
||||
let peer = table
|
||||
.add_peer(PeerInfo {
|
||||
asn: PEER_ASN,
|
||||
router_id: Ipv4Addr::new(10, 0, 0, 2),
|
||||
address: PEER_ADDRESS.into(),
|
||||
interface: 0,
|
||||
ibgp: false,
|
||||
reflector_client: false,
|
||||
})
|
||||
.unwrap();
|
||||
Self {
|
||||
table,
|
||||
peer,
|
||||
policy: ImportPolicy::permissive(),
|
||||
rejections: RejectionLog::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_import<T>(
|
||||
&self,
|
||||
vrps: Option<&VrpTable>,
|
||||
held: &mut Held,
|
||||
work: impl FnOnce(&RibTable, &Import<'_>, &mut Held) -> T,
|
||||
) -> T {
|
||||
let import = Import {
|
||||
peer: self.peer,
|
||||
from: RouteSource {
|
||||
peer_asn: PEER_ASN,
|
||||
local_asn: 65000,
|
||||
ibgp: false,
|
||||
four_octet_asn: true,
|
||||
role: None,
|
||||
peer_address: PEER_ADDRESS.into(),
|
||||
local_address: Ipv4Addr::new(192, 0, 2, 9).into(),
|
||||
extended_next_hop: false,
|
||||
},
|
||||
policy: &self.policy,
|
||||
checks: Checks {
|
||||
vrps,
|
||||
..Checks::NONE
|
||||
},
|
||||
rejections: &self
|
||||
.rejections
|
||||
.of_peer(IpAddr::from(PEER_ADDRESS).into(), "test"),
|
||||
};
|
||||
work(&self.table, &import, held)
|
||||
}
|
||||
|
||||
fn announce(&self, vrps: Option<&VrpTable>, held: &mut Held, route: &str) -> Applied {
|
||||
self.with_import(vrps, held, |table, import, held| {
|
||||
apply(table, import, held, &announcement(route), None).unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
fn revalidate(
|
||||
&self,
|
||||
vrps: Option<&VrpTable>,
|
||||
held: &mut Held,
|
||||
suspects: &[Nlri],
|
||||
) -> Applied {
|
||||
self.with_import(vrps, held, |table, import, held| {
|
||||
revalidate(table, import, held, suspects)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Past the bound, a new route is dropped and the overflow noted, so
|
||||
/// that the peer can be asked for it later; one already held is still
|
||||
/// replaced, since that takes no more room.
|
||||
#[test]
|
||||
fn holding_stops_at_the_bound_and_says_so() {
|
||||
let mut held = Held::default();
|
||||
let attrs = attrs_of("198.51.100.0/24");
|
||||
for n in 0..MAX_HELD as u32 {
|
||||
let [a, b, c, d] = (0x0a00_0000 + n).to_be_bytes();
|
||||
held.hold(
|
||||
IpPrefix::new(IpVersion::Ipv4, [a, b, c, d], 32)
|
||||
.unwrap()
|
||||
.into(),
|
||||
&attrs,
|
||||
);
|
||||
}
|
||||
assert_eq!(held.len(), MAX_HELD);
|
||||
assert!(!held.take_overflowed());
|
||||
|
||||
held.hold(nlri("198.51.100.0/24"), &attrs);
|
||||
assert_eq!(held.len(), MAX_HELD);
|
||||
assert!(held.take_overflowed());
|
||||
assert!(
|
||||
!held.take_overflowed(),
|
||||
"noted once, until it happens again"
|
||||
);
|
||||
|
||||
let other = attrs_of("203.0.113.0/24");
|
||||
held.hold(nlri("10.0.0.0/32"), &other);
|
||||
assert_eq!(held.len(), MAX_HELD);
|
||||
assert!(!held.take_overflowed());
|
||||
assert!(Arc::ptr_eq(&held.routes[&nlri("10.0.0.0/32")], &other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_takes_out_what_is_named_and_nothing_else() {
|
||||
let mut held = Held::default();
|
||||
let attrs = attrs_of("198.51.100.0/24");
|
||||
held.hold(nlri("198.51.100.0/24"), &attrs);
|
||||
held.hold(nlri("203.0.113.0/24"), &attrs);
|
||||
|
||||
held.forget(&[nlri("198.51.100.0/24"), nlri("192.0.2.0/24")]);
|
||||
assert_eq!(held.len(), 1);
|
||||
assert!(held.routes.contains_key(&nlri("203.0.113.0/24")));
|
||||
|
||||
held.forget(&[nlri("203.0.113.0/24")]);
|
||||
held.forget(&[nlri("203.0.113.0/24")]);
|
||||
assert_eq!(held.len(), 0);
|
||||
}
|
||||
|
||||
/// A route the VRPs find invalid stays out of the table but is not
|
||||
/// lost: when the VRPs change in its favour it goes in, without the
|
||||
/// peer having to send it again.
|
||||
#[test]
|
||||
fn a_held_route_is_released_once_the_checks_pass_it() {
|
||||
let fixture = Fixture::new();
|
||||
let mut held = Held::default();
|
||||
let route = "198.51.100.0/24";
|
||||
let against = vrps(route, PEER_ASN + 1);
|
||||
let applied = fixture.announce(Some(&against), &mut held, route);
|
||||
assert_eq!(applied.rejected, 1);
|
||||
assert_eq!(applied.outcome.added, 0);
|
||||
assert!(fixture.table.is_empty());
|
||||
assert_eq!(held.len(), 1);
|
||||
|
||||
// Nothing has changed in the route's favour yet.
|
||||
let applied = fixture.revalidate(Some(&against), &mut held, &[]);
|
||||
assert_eq!(applied, Applied::default());
|
||||
assert_eq!(held.len(), 1);
|
||||
|
||||
let for_it = vrps(route, PEER_ASN);
|
||||
let applied = fixture.revalidate(Some(&for_it), &mut held, &[]);
|
||||
assert_eq!(applied.outcome.added, 1);
|
||||
assert_eq!(applied.rejected, 0);
|
||||
assert_eq!(held.len(), 0);
|
||||
assert_eq!(fixture.table.routes(&prefix(route)).len(), 1);
|
||||
}
|
||||
|
||||
/// The other way round: a route in the table that the VRPs come to
|
||||
/// find invalid is taken out and held, and comes back once they
|
||||
/// change again.
|
||||
#[test]
|
||||
fn a_route_turned_invalid_is_held_and_comes_back() {
|
||||
let fixture = Fixture::new();
|
||||
let mut held = Held::default();
|
||||
let route = "198.51.100.0/24";
|
||||
let for_it = vrps(route, PEER_ASN);
|
||||
let applied = fixture.announce(Some(&for_it), &mut held, route);
|
||||
assert_eq!(applied.outcome.added, 1);
|
||||
assert_eq!(fixture.table.len(), 1);
|
||||
|
||||
let against = vrps(route, PEER_ASN + 1);
|
||||
let applied = fixture.revalidate(Some(&against), &mut held, &[nlri(route)]);
|
||||
assert_eq!(applied.rejected, 1);
|
||||
assert_eq!(applied.outcome.removed, 1);
|
||||
assert!(fixture.table.is_empty());
|
||||
assert_eq!(held.len(), 1);
|
||||
|
||||
let applied = fixture.revalidate(Some(&for_it), &mut held, &[]);
|
||||
assert_eq!(applied.outcome.added, 1);
|
||||
assert_eq!(held.len(), 0);
|
||||
assert_eq!(fixture.table.len(), 1);
|
||||
}
|
||||
|
||||
/// Without anything to check against there is nothing to look at
|
||||
/// again: what is held stays held, and what is in the table stays.
|
||||
#[test]
|
||||
fn revalidation_without_checks_changes_nothing() {
|
||||
let fixture = Fixture::new();
|
||||
let mut held = Held::default();
|
||||
let route = "198.51.100.0/24";
|
||||
fixture.announce(Some(&vrps(route, PEER_ASN + 1)), &mut held, route);
|
||||
assert_eq!(held.len(), 1);
|
||||
|
||||
let applied = fixture.revalidate(None, &mut held, &[nlri(route)]);
|
||||
assert_eq!(applied, Applied::default());
|
||||
assert_eq!(held.len(), 1);
|
||||
assert!(fixture.table.is_empty());
|
||||
}
|
||||
}
|
||||
+193
-56
@@ -22,7 +22,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
|
||||
use bgp_rib::table::{AttrSet, PeerIndex, RibTable};
|
||||
use bgp_rib::table::{AttrSet, PeerIndex, RibTable, Route};
|
||||
use bgp_rpki::{Algorithm, AspaTable, Changed, PathValidity, Rpki, Update, Validity, VrpTable};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
@@ -162,7 +162,7 @@ impl<'a> Checks<'a> {
|
||||
if next_hops.is_own_network(prefix) {
|
||||
return Some(Rejection::OwnNetwork);
|
||||
}
|
||||
if !next_hops.reaches(prefix, attrs.next_hop().as_ref()) {
|
||||
if !next_hops.reaches(prefix, attrs.next_hop().as_ref(), attrs.is_blackhole()) {
|
||||
return Some(Rejection::NextHopUnreachable);
|
||||
}
|
||||
}
|
||||
@@ -239,50 +239,90 @@ pub(crate) fn spawn_revalidator(
|
||||
RPKI_UPDATES.inc();
|
||||
|
||||
tokio::time::sleep(SETTLE).await;
|
||||
let sessions: Vec<(SessionHandle, Subject)> = registry
|
||||
// The sessions whose routes are subject to the checks at all.
|
||||
let sessions: Vec<SessionHandle> = registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.filter_map(|session| {
|
||||
let subject = session.validated()?;
|
||||
Some((session, subject))
|
||||
})
|
||||
.filter(|session| session.validated().is_some())
|
||||
.collect();
|
||||
// By peer, for looking up as often as there are routes.
|
||||
let mut subjects: Vec<Option<Subject>> = Vec::new();
|
||||
for (session, subject) in &sessions {
|
||||
let at = session.rib_peer.as_usize();
|
||||
if subjects.len() <= at {
|
||||
subjects.resize(at + 1, None);
|
||||
let subjects = by_rib_peer(
|
||||
sessions
|
||||
.iter()
|
||||
.filter_map(|session| Some((session.rib_peer, session.validated()?))),
|
||||
);
|
||||
let what = format!(
|
||||
"RPKI data changed, to {} VRPs and {} ASPAs; revalidating routes",
|
||||
update.vrps.len(),
|
||||
update.aspas.len()
|
||||
);
|
||||
revalidate_all(sessions, &rib, &what, move |rib| {
|
||||
newly_invalid(rib, &update, &subjects)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
subjects[at] = Some(*subject);
|
||||
})
|
||||
}
|
||||
|
||||
/// `what` of each session, by its peer's number in the table, for looking
|
||||
/// up as often as there are routes; `None` where no session has that
|
||||
/// number.
|
||||
pub(crate) fn by_rib_peer<T>(sessions: impl IntoIterator<Item = (PeerIndex, T)>) -> Vec<Option<T>> {
|
||||
let mut by_peer: Vec<Option<T>> = Vec::new();
|
||||
for (peer, what) in sessions {
|
||||
let at = peer.as_usize();
|
||||
if by_peer.len() <= at {
|
||||
by_peer.resize_with(at + 1, || None);
|
||||
}
|
||||
let (vrps, aspas) = (update.vrps.len(), update.aspas.len());
|
||||
let rib = Arc::clone(&rib);
|
||||
// Looks at every route, or at every prefix at least: not for a
|
||||
// thread that sessions run on.
|
||||
let found =
|
||||
tokio::task::spawn_blocking(move || newly_invalid(&rib, &update, &subjects)).await;
|
||||
by_peer[at] = Some(what);
|
||||
}
|
||||
by_peer
|
||||
}
|
||||
|
||||
/// `routes` as what each session is to take out of the table, by peer.
|
||||
pub(crate) fn group_by_peer(
|
||||
routes: impl IntoIterator<Item = Route>,
|
||||
) -> HashMap<PeerIndex, Vec<Nlri>> {
|
||||
let mut by_peer: HashMap<PeerIndex, Vec<Nlri>> = HashMap::new();
|
||||
for route in routes {
|
||||
by_peer.entry(route.peer).or_default().push(Nlri {
|
||||
prefix: route.prefix,
|
||||
path_id: route.path_id,
|
||||
});
|
||||
}
|
||||
by_peer
|
||||
}
|
||||
|
||||
/// One round of revalidation, for whichever data changed: `find` looks
|
||||
/// through the table for the routes that are to go, by peer, and every one
|
||||
/// of `sessions` is told of its own, also those with nothing found, since
|
||||
/// each has its held routes to look at again. `what` is for the log.
|
||||
pub(crate) async fn revalidate_all<F>(
|
||||
sessions: Vec<SessionHandle>,
|
||||
rib: &Arc<RibTable>,
|
||||
what: &str,
|
||||
find: F,
|
||||
) where
|
||||
F: FnOnce(&RibTable) -> HashMap<PeerIndex, Vec<Nlri>> + Send + 'static,
|
||||
{
|
||||
let table = Arc::clone(rib);
|
||||
// Looks at every route, or at every prefix at least: not for a thread
|
||||
// that sessions run on.
|
||||
let found = tokio::task::spawn_blocking(move || find(&table)).await;
|
||||
let mut suspects = match found {
|
||||
Ok(suspects) => suspects,
|
||||
Err(e) => {
|
||||
warn!(err = %e, "Looking for routes that are now invalid failed");
|
||||
warn!(err = %e, "Looking through the table for routes to take out failed");
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
info!(
|
||||
vrps,
|
||||
aspas,
|
||||
now_invalid = suspects.values().map(Vec::len).sum::<usize>(),
|
||||
found = suspects.values().map(Vec::len).sum::<usize>(),
|
||||
sessions = sessions.len(),
|
||||
"RPKI data changed; revalidating routes"
|
||||
"{what}"
|
||||
);
|
||||
// Every session, also those with nothing found: each has its
|
||||
// held routes to look at again.
|
||||
for (session, _) in sessions {
|
||||
for session in sessions {
|
||||
session.revalidate(suspects.remove(&session.rib_peer).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The routes in the table that `data` finds invalid, by peer. `subjects`
|
||||
@@ -306,42 +346,54 @@ fn newly_invalid(
|
||||
path.iter().any(|asn| changed.aspa_customers.contains(asn))
|
||||
};
|
||||
let path = RefCell::new(Vec::new());
|
||||
// What each peer's routes are checked against: the data, for the
|
||||
// checks its routes are subject to.
|
||||
let checks: Vec<Option<Checks<'_>>> = subjects
|
||||
.iter()
|
||||
.map(|subject| {
|
||||
subject.map(|subject| Checks {
|
||||
vrps: subject.origin.then_some(&*data.vrps),
|
||||
aspas: subject.path.map(|check| (&*data.aspas, check)),
|
||||
next_hops: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let routes = rib.routes_where(usize::MAX, |route| {
|
||||
let Some(Some(subject)) = subjects.get(route.peer.as_usize()) else {
|
||||
let Some(Some(checks)) = checks.get(route.peer.as_usize()) else {
|
||||
return false;
|
||||
};
|
||||
if subject.origin
|
||||
&& vrps_may_matter
|
||||
&& changed.is_none_or(|changed| changed.vrps.covers(&route.prefix))
|
||||
{
|
||||
let origin = route.attrs.key().origin();
|
||||
if data.vrps.validate(&route.prefix, origin) == Validity::Invalid {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(check) = subject.path
|
||||
&& aspas_may_matter
|
||||
{
|
||||
let path = &mut *path.borrow_mut();
|
||||
let has_set = route.attrs.as_path(path);
|
||||
return changed.is_none_or(|changed| on_changed_path(changed, path))
|
||||
&& check.verify(&data.aspas, path, has_set) == PathValidity::Invalid;
|
||||
}
|
||||
false
|
||||
// Only the checks whose verdict the change can have altered for
|
||||
// this route are made.
|
||||
let checks = Checks {
|
||||
vrps: checks.vrps.filter(|_| {
|
||||
vrps_may_matter && changed.is_none_or(|changed| changed.vrps.covers(&route.prefix))
|
||||
}),
|
||||
aspas: checks.aspas.filter(|_| {
|
||||
aspas_may_matter
|
||||
&& changed.is_none_or(|changed| {
|
||||
route.attrs.as_path(path);
|
||||
on_changed_path(changed, path)
|
||||
})
|
||||
}),
|
||||
next_hops: None,
|
||||
};
|
||||
checks.rejection(&route.prefix, route.attrs, path).is_some()
|
||||
});
|
||||
let mut by_peer: HashMap<PeerIndex, Vec<Nlri>> = HashMap::new();
|
||||
for route in routes {
|
||||
by_peer.entry(route.peer).or_default().push(Nlri {
|
||||
prefix: route.prefix,
|
||||
path_id: route.path_id,
|
||||
});
|
||||
}
|
||||
by_peer
|
||||
group_by_peer(routes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, NextHopPathAttribute, OriginPathAttribute, PathAttribute,
|
||||
};
|
||||
use bgp_rib::table::PeerInfo;
|
||||
use bgp_rpki::Vrp;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn aspas() -> AspaTable {
|
||||
@@ -463,4 +515,89 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn prefix(s: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(s).unwrap()
|
||||
}
|
||||
|
||||
/// Where it is known which VRPs came or went, only the routes those
|
||||
/// cover are looked at: the others are as valid or invalid as they
|
||||
/// were, and were dealt with then. Without that knowledge every route
|
||||
/// is looked at; and a peer whose routes are not subject to the check
|
||||
/// has none found.
|
||||
#[test]
|
||||
fn only_routes_a_change_can_have_made_invalid_are_found() {
|
||||
let table = RibTable::new(2);
|
||||
let peer = table
|
||||
.add_peer(PeerInfo {
|
||||
asn: 65001,
|
||||
router_id: Ipv4Addr::new(10, 0, 0, 1),
|
||||
address: Ipv4Addr::new(192, 0, 2, 1).into(),
|
||||
interface: 0,
|
||||
ibgp: false,
|
||||
reflector_client: false,
|
||||
})
|
||||
.unwrap();
|
||||
let announce = |route: &str, origin: u32| {
|
||||
let attrs = table
|
||||
.attrs()
|
||||
.intern(
|
||||
&[
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![65001, origin]),
|
||||
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
table.announce(peer, &attrs, &[prefix(route)]);
|
||||
};
|
||||
announce("10.0.0.0/24", 65010);
|
||||
announce("10.1.0.0/24", 65011);
|
||||
// Both prefixes are someone else's to originate.
|
||||
let vrp = |covered: &str| Vrp {
|
||||
prefix: prefix(covered),
|
||||
max_length: 24,
|
||||
asn: 65009,
|
||||
};
|
||||
let update = Update {
|
||||
version: 2,
|
||||
vrps: Arc::new(VrpTable::new([vrp("10.0.0.0/24"), vrp("10.1.0.0/24")])),
|
||||
aspas: Arc::new(AspaTable::default()),
|
||||
changed: Some(Changed {
|
||||
vrps: Arc::new(VrpTable::new([vrp("10.1.0.0/24")])),
|
||||
aspa_customers: Arc::default(),
|
||||
}),
|
||||
};
|
||||
let origins = by_rib_peer([(
|
||||
peer,
|
||||
Subject {
|
||||
origin: true,
|
||||
path: None,
|
||||
},
|
||||
)]);
|
||||
let found = newly_invalid(&table, &update, &origins);
|
||||
assert_eq!(
|
||||
found.get(&peer).map(Vec::as_slice),
|
||||
Some(&[Nlri::from(prefix("10.1.0.0/24"))][..])
|
||||
);
|
||||
|
||||
let whole = Update {
|
||||
changed: None,
|
||||
..update
|
||||
};
|
||||
let found = newly_invalid(&table, &whole, &origins);
|
||||
let routes = found.get(&peer).expect("the peer's routes");
|
||||
assert_eq!(routes.len(), 2);
|
||||
assert!(routes.contains(&Nlri::from(prefix("10.0.0.0/24"))));
|
||||
|
||||
let paths_only = by_rib_peer([(
|
||||
peer,
|
||||
Subject {
|
||||
origin: false,
|
||||
path: None,
|
||||
},
|
||||
)]);
|
||||
assert!(newly_invalid(&table, &whole, &paths_only).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ impl BgpServer {
|
||||
for route in &config.originate {
|
||||
local_rib.announce(route.clone()).await?;
|
||||
}
|
||||
let configured = config
|
||||
let configured: Vec<ConfiguredPeer> = config
|
||||
.peers
|
||||
.iter()
|
||||
.map(|p| ConfiguredPeer {
|
||||
@@ -127,12 +127,18 @@ impl BgpServer {
|
||||
remote_asn: p.remote_asn,
|
||||
role: p.role,
|
||||
passive: p.passive,
|
||||
expected_routes: p.expected_routes,
|
||||
})
|
||||
.collect();
|
||||
// The client starts now: with luck the data is there before the
|
||||
// first routes are.
|
||||
let (rpki, rpki_client) = config.rpki.clone().map(Rpki::spawn).unzip();
|
||||
let rib = Arc::new(RibTable::new(table_shards()));
|
||||
// Sized for the largest table a peer is expected to send: the
|
||||
// prefixes of the others are mostly the same ones.
|
||||
if let Some(expected) = configured.iter().filter_map(|p| p.expected_routes).max() {
|
||||
rib.expect_prefixes(expected as usize);
|
||||
}
|
||||
// Started now, so that a machine on which it cannot be done stops
|
||||
// the daemon from starting. There is nothing to install yet.
|
||||
let (kernel, kernel_task) = match config.kernel {
|
||||
@@ -191,7 +197,7 @@ impl BgpServer {
|
||||
hold_downs: HoldDowns::default(),
|
||||
rejections: self.rejections.clone(),
|
||||
rpki: self.rpki.clone(),
|
||||
interfaces: self.kernel.as_ref().map(Fib::interfaces),
|
||||
topology: self.kernel.as_ref().map(Fib::topology),
|
||||
restarts: Arc::clone(&self.restarts),
|
||||
});
|
||||
// What an earlier run left in the kernel and no peer has announced
|
||||
@@ -231,7 +237,7 @@ impl BgpServer {
|
||||
};
|
||||
match result {
|
||||
Ok((stream, peer_addr)) => {
|
||||
match admit(&self.config, &context, peer_addr.into(), &connections) {
|
||||
match screen(&self.config, &context, peer_addr.into(), &connections) {
|
||||
Ok((peer, slot)) => {
|
||||
let context = Arc::clone(&context);
|
||||
tokio::spawn(serve(stream, peer_addr, context, peer, slot));
|
||||
@@ -276,10 +282,10 @@ impl BgpServer {
|
||||
spawn_revalidator(rpki, Arc::clone(&self.rib), self.registry.clone()),
|
||||
));
|
||||
}
|
||||
if let Some(interfaces) = context.interfaces.clone() {
|
||||
if let Some(topology) = context.topology.clone() {
|
||||
tasks.push(named(
|
||||
"next hop revalidator",
|
||||
reach::spawn_revalidator(interfaces, Arc::clone(&self.rib), self.registry.clone()),
|
||||
reach::spawn_revalidator(topology, Arc::clone(&self.rib), self.registry.clone()),
|
||||
));
|
||||
}
|
||||
for peer in self.config.peers.iter().filter(|peer| peer.passive) {
|
||||
@@ -455,9 +461,10 @@ fn listen(addr: SocketAddr) -> io::Result<TcpListener> {
|
||||
socket.listen(LISTEN_BACKLOG)
|
||||
}
|
||||
|
||||
/// Decides whether to take a connection from `address`, and if so with what
|
||||
/// settings.
|
||||
fn admit(
|
||||
/// Screens a connection from `address`: whether to take it, and if so
|
||||
/// with what settings. Not the collision check, which is
|
||||
/// [`crate::collision::CollisionTable::admit`].
|
||||
fn screen(
|
||||
config: &Config,
|
||||
context: &ServerContext,
|
||||
address: PeerAddress,
|
||||
|
||||
@@ -17,6 +17,7 @@ use std::time::{Duration, SystemTime};
|
||||
use bgp_packet::message::Message;
|
||||
use bgp_primitives::ip_prefix::Nlri;
|
||||
use bgp_rib::table::PeerIndex;
|
||||
use bytes::BytesMut;
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
|
||||
use crate::address::{Endpoint, PeerAddress, socket_text};
|
||||
@@ -75,6 +76,8 @@ pub struct ConfiguredPeer {
|
||||
pub role: Option<PeerRole>,
|
||||
/// Whether it is left to the peer to connect.
|
||||
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.
|
||||
@@ -91,6 +94,10 @@ pub struct SessionCounters {
|
||||
/// Routes of the peer's kept from its last session, which it has not
|
||||
/// sent again yet (graceful restart).
|
||||
stale: AtomicU64,
|
||||
/// Routes announced to the peer and not withdrawn since: what it
|
||||
/// holds from us, every route counted where it is sent several for
|
||||
/// a prefix.
|
||||
advertised: AtomicU64,
|
||||
}
|
||||
|
||||
impl SessionCounters {
|
||||
@@ -110,6 +117,14 @@ impl SessionCounters {
|
||||
self.held.store(held as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn advertised(&self) -> u64 {
|
||||
self.advertised.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) fn set_advertised(&self, advertised: usize) {
|
||||
self.advertised.store(advertised as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn stale(&self) -> u64 {
|
||||
self.stale.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -185,7 +200,7 @@ pub struct SessionHandle {
|
||||
pub reflector_client: bool,
|
||||
/// The peer's BGP Identifier.
|
||||
pub peer_router_id: Ipv4Addr,
|
||||
outbound: mpsc::Sender<Message>,
|
||||
outbound: mpsc::Sender<Outbound>,
|
||||
/// Where the session is asked to end, if it listens for that.
|
||||
ending: Option<mpsc::UnboundedSender<SessionError>>,
|
||||
/// Which of the RPKI's checks the peer's routes are subject to, if
|
||||
@@ -205,7 +220,7 @@ impl SessionHandle {
|
||||
local_addr: SocketAddr,
|
||||
negotiated: &Negotiated,
|
||||
rib_peer: PeerIndex,
|
||||
outbound: mpsc::Sender<Message>,
|
||||
outbound: mpsc::Sender<Outbound>,
|
||||
) -> Self {
|
||||
Self {
|
||||
peer,
|
||||
@@ -280,6 +295,14 @@ impl SessionHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the peer goes by in the metrics: its configured name, or its
|
||||
/// address.
|
||||
pub fn metric_label(&self) -> String {
|
||||
self.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| crate::address::PeerAddress::from(self.peer.addr).to_string())
|
||||
}
|
||||
|
||||
/// Whether `other` is a handle to this same session, and not to another
|
||||
/// session with the same peer.
|
||||
pub fn is_same_session(&self, other: &SessionHandle) -> bool {
|
||||
@@ -290,10 +313,28 @@ impl SessionHandle {
|
||||
/// space if the session's writer is behind.
|
||||
pub async fn send(&self, msg: Message) -> Result<(), SessionClosed> {
|
||||
self.outbound
|
||||
.send(msg)
|
||||
.send(Outbound::Message(msg))
|
||||
.await
|
||||
.map_err(|_| SessionClosed(self.peer))
|
||||
}
|
||||
|
||||
/// Sends the peer a piece of one or more UPDATEs that are already
|
||||
/// encoded, headers and all, as the exporter builds them.
|
||||
pub async fn send_encoded(&self, piece: BytesMut) -> Result<(), SessionClosed> {
|
||||
self.outbound
|
||||
.send(Outbound::Encoded(piece))
|
||||
.await
|
||||
.map_err(|_| SessionClosed(self.peer))
|
||||
}
|
||||
}
|
||||
|
||||
/// What the rest of the server gives a session to send: a message, or a
|
||||
/// piece of one or more UPDATEs already encoded, headers and all, which
|
||||
/// goes to the peer as it is once the session is established.
|
||||
#[derive(Debug)]
|
||||
pub enum Outbound {
|
||||
Message(Message),
|
||||
Encoded(BytesMut),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -419,10 +460,6 @@ impl SessionRegistry {
|
||||
self.shutting_down.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Closes every session politely: each peer is sent Cease /
|
||||
/// Administrative Shutdown (RFC 4486) so it can drop the session at once
|
||||
/// instead of discovering a dead TCP connection. Waits up to `grace` for
|
||||
/// the sessions to finish; returns how many were still open.
|
||||
/// Closes every connection because the daemon is going, as `leave`
|
||||
/// says: for good, or to restart (see [`Leave`]). Waits up to `grace`
|
||||
/// for the sessions to finish; returns how many were still open.
|
||||
|
||||
@@ -24,5 +24,10 @@ tokio-stream.workspace = true
|
||||
tracing.workspace = true
|
||||
nix = { workspace = true, features = ["hostname"] }
|
||||
|
||||
# The allocator's own account of memory, for the status page and the
|
||||
# metrics. The allocator itself is the daemon's choice (bin/bgp).
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
tikv-jemalloc-ctl.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+259
-31
@@ -9,6 +9,7 @@ use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use bgp_fib::sockdiag::TcpConnection;
|
||||
use bgp_packet::community;
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, ExtendedCommunity, NlriNextHop, OriginPathAttribute, PathAttribute,
|
||||
@@ -18,7 +19,8 @@ use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::table::{AttrSet, PeerIndex, RibTable, Route, RouteRef, SelectionKey};
|
||||
use bgp_rib::{LocalRoute, RibStopped};
|
||||
use bgp_rpki::{CacheState, Update};
|
||||
use bgp_server::address::{PeerAddress, socket_text};
|
||||
use bgp_server::address::{names_peer, socket_text};
|
||||
use bgp_server::family::FamilySet;
|
||||
use bgp_server::policy::PeerRole;
|
||||
use bgp_server::restart::{Agreed, Restarts};
|
||||
use bgp_server::rpki::PathCheck;
|
||||
@@ -39,6 +41,9 @@ fn unix_secs(t: SystemTime) -> u64 {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StatusView {
|
||||
/// The allocator's account of the daemon's memory; `None` where the
|
||||
/// allocator keeps none.
|
||||
pub memory: Option<crate::memory::MemoryUse>,
|
||||
pub router_id: String,
|
||||
pub local_asn: u32,
|
||||
pub version: String,
|
||||
@@ -89,9 +94,14 @@ pub struct PeerView {
|
||||
/// How many routes the peer announced on this session that its import
|
||||
/// policy refused.
|
||||
pub rejected_routes: Option<u64>,
|
||||
/// Routes announced to the peer and not withdrawn since.
|
||||
pub advertised_routes: Option<u64>,
|
||||
/// 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.
|
||||
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`,
|
||||
/// `peer`, `route server` or `route server client`.
|
||||
pub role: Option<&'static str>,
|
||||
@@ -140,49 +150,46 @@ impl From<&Agreed> for GracefulRestartView {
|
||||
|
||||
impl From<&SessionHandle> for PeerView {
|
||||
fn from(s: &SessionHandle) -> Self {
|
||||
let families =
|
||||
|families: &FamilySet| Some(families.iter().map(|f| f.to_string()).collect());
|
||||
Self {
|
||||
name: s.name.clone(),
|
||||
peer_addr: socket_text(s.peer.addr),
|
||||
peer_asn: s.peer.asn,
|
||||
state: "Established".into(),
|
||||
direction: Some(s.direction.as_str().into()),
|
||||
local_addr: Some(socket_text(s.local_addr)),
|
||||
established_at_unix: Some(unix_secs(s.established_at)),
|
||||
uptime_secs: Some(s.established_at.elapsed().map(|d| d.as_secs()).unwrap_or(0)),
|
||||
four_octet_asn: Some(s.four_octet_asn),
|
||||
extended_next_hop: Some(s.extended_next_hop),
|
||||
address_families: Some(s.families.iter().map(|f| f.to_string()).collect()),
|
||||
add_path_receive: Some(s.add_path_receive.iter().map(|f| f.to_string()).collect()),
|
||||
add_path_send: Some(s.add_path_send.iter().map(|f| f.to_string()).collect()),
|
||||
address_families: families(&s.families),
|
||||
add_path_receive: families(&s.add_path_receive),
|
||||
add_path_send: families(&s.add_path_send),
|
||||
routes: Some(s.counters.routes()),
|
||||
rejected_routes: Some(s.counters.rejected()),
|
||||
advertised_routes: Some(s.counters.advertised()),
|
||||
held_routes: Some(s.counters.held()),
|
||||
role: s.role.map(PeerRole::as_str),
|
||||
role_confirmed: s.role.map(|_| s.role_confirmed),
|
||||
last_error: None,
|
||||
retry_at_unix: None,
|
||||
graceful_restart: s.graceful_restart.as_ref().map(GracefulRestartView::from),
|
||||
stale_routes: Some(s.counters.stale()).filter(|stale| *stale > 0),
|
||||
stale_until_unix: None,
|
||||
..Self::known(
|
||||
s.name.clone(),
|
||||
socket_text(s.peer.addr),
|
||||
s.peer.asn,
|
||||
"Established",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PeerView {
|
||||
fn from_configured(peer: &ConfiguredPeer, state: &DialState) -> Self {
|
||||
let (state_name, last_error, retry_at_unix) = match state {
|
||||
DialState::Idle => ("Idle", None, None),
|
||||
DialState::Connecting => ("Connecting", None, None),
|
||||
DialState::Backoff { until, last_error } => {
|
||||
("Backoff", Some(last_error.clone()), Some(unix_secs(*until)))
|
||||
}
|
||||
DialState::Passive { last_error } => ("Passive", last_error.clone(), None),
|
||||
};
|
||||
/// What is known of any peer, with or without a session: who it is
|
||||
/// and where it stands. Everything else is `None`, for the caller to
|
||||
/// fill in what it knows besides.
|
||||
fn known(name: Option<String>, peer_addr: String, peer_asn: u32, state: &str) -> Self {
|
||||
Self {
|
||||
name: Some(peer.name.clone()),
|
||||
peer_addr: peer.endpoint.with_port(),
|
||||
peer_asn: peer.remote_asn,
|
||||
state: state_name.into(),
|
||||
name,
|
||||
peer_addr,
|
||||
peer_asn,
|
||||
state: state.into(),
|
||||
direction: None,
|
||||
local_addr: None,
|
||||
established_at_unix: None,
|
||||
@@ -194,16 +201,41 @@ impl PeerView {
|
||||
add_path_send: None,
|
||||
routes: None,
|
||||
rejected_routes: None,
|
||||
advertised_routes: None,
|
||||
held_routes: None,
|
||||
role: peer.role.map(PeerRole::as_str),
|
||||
expected_routes: None,
|
||||
role: None,
|
||||
role_confirmed: None,
|
||||
last_error,
|
||||
retry_at_unix,
|
||||
last_error: None,
|
||||
retry_at_unix: None,
|
||||
graceful_restart: None,
|
||||
stale_routes: None,
|
||||
stale_until_unix: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_configured(peer: &ConfiguredPeer, state: &DialState) -> Self {
|
||||
let (state_name, last_error, retry_at_unix) = match state {
|
||||
DialState::Idle => ("Idle", None, None),
|
||||
DialState::Connecting => ("Connecting", None, None),
|
||||
DialState::Backoff { until, last_error } => {
|
||||
("Backoff", Some(last_error.clone()), Some(unix_secs(*until)))
|
||||
}
|
||||
DialState::Passive { last_error } => ("Passive", last_error.clone(), None),
|
||||
};
|
||||
Self {
|
||||
expected_routes: peer.expected_routes,
|
||||
role: peer.role.map(PeerRole::as_str),
|
||||
last_error,
|
||||
retry_at_unix,
|
||||
..Self::known(
|
||||
Some(peer.name.clone()),
|
||||
peer.endpoint.with_port(),
|
||||
peer.remote_asn,
|
||||
state_name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured peers first (with their live session when established), then
|
||||
@@ -225,6 +257,7 @@ pub fn peer_views(registry: &SessionRegistry, restarts: &Restarts) -> Vec<PeerVi
|
||||
claimed.insert(session.peer);
|
||||
PeerView {
|
||||
name: Some(peer.name.clone()),
|
||||
expected_routes: peer.expected_routes,
|
||||
..PeerView::from(session)
|
||||
}
|
||||
}
|
||||
@@ -528,9 +561,7 @@ impl PeerNames {
|
||||
fn matching(&self, wanted: &str) -> Vec<PeerIndex> {
|
||||
self.peers
|
||||
.iter()
|
||||
.filter(|(_, (peer, name, _))| {
|
||||
name.as_deref() == Some(wanted) || PeerAddress::from(peer.addr).is_called(wanted)
|
||||
})
|
||||
.filter(|(_, (peer, name, _))| names_peer(wanted, name.as_deref(), peer.addr.into()))
|
||||
.map(|(index, _)| *index)
|
||||
.collect()
|
||||
}
|
||||
@@ -596,6 +627,7 @@ impl IntoResponse for Unavailable {
|
||||
|
||||
pub async fn status(State(state): State<AppState>) -> Result<Json<StatusView>, Unavailable> {
|
||||
Ok(Json(StatusView {
|
||||
memory: crate::memory::memory_use(),
|
||||
router_id: state.info.router_id.to_string(),
|
||||
local_asn: state.info.local_asn,
|
||||
version: state.info.version.clone(),
|
||||
@@ -1105,6 +1137,9 @@ pub struct KernelView {
|
||||
/// How many of them are kept from before the daemon started though no
|
||||
/// peer has announced them again; they go when `stale_seconds` is up.
|
||||
pub kept: usize,
|
||||
/// How many of them are blackhole routes, which drop the packets for
|
||||
/// their prefix (RFC 7999).
|
||||
pub blackholes: usize,
|
||||
/// Prefixes whose best route is not in the kernel because its next hop
|
||||
/// cannot be used, by why.
|
||||
pub unresolved: BTreeMap<&'static str, usize>,
|
||||
@@ -1138,6 +1173,7 @@ pub async fn kernel(State(state): State<AppState>) -> Json<KernelView> {
|
||||
synced: status.synced,
|
||||
installed: status.installed,
|
||||
kept: status.kept,
|
||||
blackholes: status.blackholes,
|
||||
unresolved: status.unresolved.into_iter().collect(),
|
||||
refused: status.refused,
|
||||
errors: status.errors,
|
||||
@@ -1208,7 +1244,7 @@ pub async fn rejected_routes(
|
||||
query
|
||||
.peer
|
||||
.as_deref()
|
||||
.is_none_or(|wanted| record.label() == wanted || address.is_called(wanted))
|
||||
.is_none_or(|wanted| names_peer(wanted, Some(record.label()), *address))
|
||||
})
|
||||
.map(|(address, record)| RejectionsView {
|
||||
peer: record.label().to_string(),
|
||||
@@ -1238,6 +1274,168 @@ pub async fn rejected_routes(
|
||||
Json(peers)
|
||||
}
|
||||
|
||||
/// `GET /api/tcp?peer=NAME`: what the kernel knows of the TCP connection of
|
||||
/// the session with that peer.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TcpQuery {
|
||||
/// The peer whose session it is: its configured name, or its address.
|
||||
/// The address with its port, as `peer_addr` of `/api/peers` has it,
|
||||
/// names it too.
|
||||
pub peer: String,
|
||||
}
|
||||
|
||||
/// A session's TCP connection, as the kernel reports it (Linux only).
|
||||
/// Times in milliseconds, sizes in octets, rates in octets per second.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TcpView {
|
||||
pub state: &'static str,
|
||||
pub congestion: Option<String>,
|
||||
pub rtt_ms: f64,
|
||||
pub rtt_variance_ms: f64,
|
||||
pub min_rtt_ms: f64,
|
||||
pub rto_ms: f64,
|
||||
pub mss: u32,
|
||||
pub pmtu: u32,
|
||||
pub cwnd_segments: u32,
|
||||
pub ssthresh_segments: Option<u32>,
|
||||
/// The peer's receive window: what it lets us send.
|
||||
pub send_window: u32,
|
||||
/// Our receive window: what we let the peer send.
|
||||
pub receive_window: u32,
|
||||
pub receive_space: u32,
|
||||
pub send_window_scale: u8,
|
||||
pub receive_window_scale: u8,
|
||||
/// Octets written by us and not yet acknowledged, and of those the
|
||||
/// octets not even sent yet.
|
||||
pub send_queue: u32,
|
||||
pub not_sent: u32,
|
||||
pub recv_queue: u32,
|
||||
pub unacked_segments: u32,
|
||||
pub retrans_segments: u32,
|
||||
pub lost_segments: u32,
|
||||
pub total_retrans: u32,
|
||||
pub bytes_retrans: u64,
|
||||
pub pacing_rate: u64,
|
||||
pub delivery_rate: u64,
|
||||
pub bytes_sent: u64,
|
||||
pub bytes_acked: u64,
|
||||
pub bytes_received: u64,
|
||||
pub segments_out: u32,
|
||||
pub segments_in: u32,
|
||||
/// How long the connection had something to send, and of that how
|
||||
/// long it was held up by the peer's window and by our send buffer.
|
||||
pub busy_ms: f64,
|
||||
pub limited_by_receive_window_ms: f64,
|
||||
pub limited_by_send_buffer_ms: f64,
|
||||
/// The same two, as shares of the busy time, 0 to 1.
|
||||
pub limited_by_receive_window_share: f64,
|
||||
pub limited_by_send_buffer_share: f64,
|
||||
pub last_data_sent_ms: u32,
|
||||
pub last_data_received_ms: u32,
|
||||
pub last_ack_received_ms: u32,
|
||||
}
|
||||
|
||||
impl From<&TcpConnection> for TcpView {
|
||||
fn from(c: &TcpConnection) -> Self {
|
||||
// The kernel's times are in microseconds.
|
||||
let ms = |us: u64| us as f64 / 1000.0;
|
||||
// A connection that was never busy was never held up: a share of
|
||||
// nothing is nothing, not a division by zero.
|
||||
let share = |part: u64| {
|
||||
if c.busy == 0 {
|
||||
0.0
|
||||
} else {
|
||||
part as f64 / c.busy as f64
|
||||
}
|
||||
};
|
||||
Self {
|
||||
state: c.state_name(),
|
||||
congestion: c.congestion.clone(),
|
||||
rtt_ms: ms(c.rtt.into()),
|
||||
rtt_variance_ms: ms(c.rtt_variance.into()),
|
||||
min_rtt_ms: ms(c.min_rtt.into()),
|
||||
rto_ms: ms(c.rto.into()),
|
||||
mss: c.mss,
|
||||
pmtu: c.pmtu,
|
||||
cwnd_segments: c.cwnd,
|
||||
ssthresh_segments: c.ssthresh,
|
||||
send_window: c.send_window,
|
||||
receive_window: c.receive_window,
|
||||
receive_space: c.receive_space,
|
||||
send_window_scale: c.send_window_scale,
|
||||
receive_window_scale: c.receive_window_scale,
|
||||
send_queue: c.send_queue,
|
||||
not_sent: c.not_sent,
|
||||
recv_queue: c.recv_queue,
|
||||
unacked_segments: c.unacked,
|
||||
retrans_segments: c.retrans,
|
||||
lost_segments: c.lost,
|
||||
total_retrans: c.total_retrans,
|
||||
bytes_retrans: c.bytes_retrans,
|
||||
pacing_rate: c.pacing_rate,
|
||||
delivery_rate: c.delivery_rate,
|
||||
bytes_sent: c.bytes_sent,
|
||||
bytes_acked: c.bytes_acked,
|
||||
bytes_received: c.bytes_received,
|
||||
segments_out: c.segments_out,
|
||||
segments_in: c.segments_in,
|
||||
busy_ms: ms(c.busy),
|
||||
limited_by_receive_window_ms: ms(c.limited_by_receive_window),
|
||||
limited_by_send_buffer_ms: ms(c.limited_by_send_buffer),
|
||||
limited_by_receive_window_share: share(c.limited_by_receive_window),
|
||||
limited_by_send_buffer_share: share(c.limited_by_send_buffer),
|
||||
last_data_sent_ms: c.last_data_sent_ms,
|
||||
last_data_received_ms: c.last_data_received_ms,
|
||||
last_ack_received_ms: c.last_ack_received_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The answer to `/api/tcp`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TcpAnswer {
|
||||
/// Whether the kernel could be asked and knew the connection.
|
||||
pub available: bool,
|
||||
/// Why not, if not.
|
||||
pub reason: Option<String>,
|
||||
pub connection: Option<TcpView>,
|
||||
}
|
||||
|
||||
pub async fn tcp(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<TcpQuery>,
|
||||
) -> Result<Json<TcpAnswer>, QueryError> {
|
||||
let session = state
|
||||
.registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.find(|s| {
|
||||
names_peer(&query.peer, s.name.as_deref(), s.peer.addr.into())
|
||||
|| socket_text(s.peer.addr) == query.peer
|
||||
})
|
||||
.ok_or_else(|| QueryError::Bad(format!("no session with {}", query.peer)))?;
|
||||
let unavailable = |reason: String| TcpAnswer {
|
||||
available: false,
|
||||
reason: Some(reason),
|
||||
connection: None,
|
||||
};
|
||||
let answer =
|
||||
match bgp_fib::sockdiag::tcp_connection(session.local_addr, session.peer.addr).await {
|
||||
Ok(Some(c)) => TcpAnswer {
|
||||
available: true,
|
||||
reason: None,
|
||||
connection: Some(TcpView::from(&c)),
|
||||
},
|
||||
Ok(None) => unavailable(if cfg!(target_os = "linux") {
|
||||
"the kernel has no such connection".to_string()
|
||||
} else {
|
||||
"only on Linux".to_string()
|
||||
}),
|
||||
Err(e) => unavailable(format!("the kernel could not be asked: {e}")),
|
||||
};
|
||||
Ok(Json(answer))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -1562,4 +1760,34 @@ mod tests {
|
||||
.map(|(name, value)| (name.to_string(), value.to_string()));
|
||||
assert_eq!(other, expected);
|
||||
}
|
||||
|
||||
/// The kernel's microseconds are shown as milliseconds, and the time
|
||||
/// held up as a share of the busy time; a connection that was never
|
||||
/// busy has a share of zero, not of NaN.
|
||||
#[test]
|
||||
fn a_tcp_view_has_milliseconds_and_shares_of_the_busy_time() {
|
||||
let idle = TcpConnection {
|
||||
rtt: 1500,
|
||||
busy: 0,
|
||||
limited_by_receive_window: 0,
|
||||
..TcpConnection::default()
|
||||
};
|
||||
let view = TcpView::from(&idle);
|
||||
assert_eq!(view.rtt_ms, 1.5);
|
||||
assert_eq!(view.busy_ms, 0.0);
|
||||
assert_eq!(view.limited_by_receive_window_share, 0.0);
|
||||
assert_eq!(view.limited_by_send_buffer_share, 0.0);
|
||||
|
||||
let busy = TcpConnection {
|
||||
busy: 4_000_000,
|
||||
limited_by_receive_window: 1_000_000,
|
||||
limited_by_send_buffer: 3_000_000,
|
||||
..TcpConnection::default()
|
||||
};
|
||||
let view = TcpView::from(&busy);
|
||||
assert_eq!(view.busy_ms, 4000.0);
|
||||
assert_eq!(view.limited_by_receive_window_ms, 1000.0);
|
||||
assert_eq!(view.limited_by_receive_window_share, 0.25);
|
||||
assert_eq!(view.limited_by_send_buffer_share, 0.75);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
mod api;
|
||||
mod events;
|
||||
mod memory;
|
||||
mod metrics;
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -27,11 +28,6 @@ use serde::Serialize;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
pub use api::{
|
||||
AttrSummary, ConfigView, KernelView, LocalRouteView, PeerView, ReceivedRouteView,
|
||||
RejectedRouteView, RejectionsView, RoutesPage, RpkiView, StatusView,
|
||||
};
|
||||
|
||||
/// Static facts about the router shown in status output.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouterInfo {
|
||||
@@ -97,8 +93,8 @@ impl RouterInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort host name without pulling in a dependency: ask the `hostname`
|
||||
/// utility, which exists on every platform we run on.
|
||||
/// The machine's host name as the kernel has it, for display; a machine
|
||||
/// without one, or with one that is not text, is "unknown host".
|
||||
fn hostname() -> String {
|
||||
nix::unistd::gethostname()
|
||||
.ok()
|
||||
@@ -144,6 +140,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/routes/rejected", get(api::rejected_routes))
|
||||
.route("/api/rpki", get(api::rpki))
|
||||
.route("/api/kernel", get(api::kernel))
|
||||
.route("/api/tcp", get(api::tcp))
|
||||
.route("/api/events", get(events::stream))
|
||||
.route("/metricz", get(metrics::metricz))
|
||||
.with_state(state)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! What the allocator says the daemon holds: jemalloc's own statistics,
|
||||
//! for the status page and the metrics, where the kernel's resident size
|
||||
//! alone would not say what is in use and what the allocator keeps back.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// jemalloc's account of memory, in octets.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct MemoryUse {
|
||||
/// Octets in allocations the program holds.
|
||||
pub allocated: u64,
|
||||
/// Octets in the pages those allocations are in: `allocated` plus
|
||||
/// the fragmentation within pages.
|
||||
pub active: u64,
|
||||
/// Octets of the allocator's own bookkeeping.
|
||||
pub metadata: u64,
|
||||
/// Octets in pages mapped and physically resident, as far as the
|
||||
/// allocator knows: `active`, the metadata, and pages freed but not
|
||||
/// yet returned to the kernel.
|
||||
pub resident: u64,
|
||||
/// Octets mapped, resident or not.
|
||||
pub mapped: u64,
|
||||
/// Octets of address space kept back after being unmapped, which
|
||||
/// cost nothing physical.
|
||||
pub retained: u64,
|
||||
}
|
||||
|
||||
#[cfg(not(target_env = "msvc"))]
|
||||
pub fn memory_use() -> Option<MemoryUse> {
|
||||
use tikv_jemalloc_ctl::{epoch, stats};
|
||||
// The statistics are refreshed only when the epoch is advanced.
|
||||
epoch::advance().ok()?;
|
||||
Some(MemoryUse {
|
||||
allocated: stats::allocated::read().ok()? as u64,
|
||||
active: stats::active::read().ok()? as u64,
|
||||
metadata: stats::metadata::read().ok()? as u64,
|
||||
resident: stats::resident::read().ok()? as u64,
|
||||
mapped: stats::mapped::read().ok()? as u64,
|
||||
retained: stats::retained::read().ok()? as u64,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_env = "msvc")]
|
||||
pub fn memory_use() -> Option<MemoryUse> {
|
||||
None
|
||||
}
|
||||
@@ -5,7 +5,8 @@ use axum::extract::State;
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
use bgp_server::metrics::{
|
||||
LOCAL_ROUTES, RIB_ATTRIBUTE_SETS, RIB_JOURNAL_ENTRIES, RIB_PREFIXES, RIB_ROUTES,
|
||||
LOCAL_ROUTES, MEMORY, PEER_TCP, RIB_ATTRIBUTE_SETS, RIB_JOURNAL_ENTRIES, RIB_PREFIXES,
|
||||
RIB_ROUTES,
|
||||
};
|
||||
use prometheus::{Encoder, TextEncoder};
|
||||
|
||||
@@ -24,6 +25,20 @@ pub async fn metricz(State(state): State<AppState>) -> impl IntoResponse {
|
||||
RIB_PREFIXES.set(gauge(state.rib.prefix_count()));
|
||||
RIB_ATTRIBUTE_SETS.set(gauge(state.rib.attrs().len()));
|
||||
RIB_JOURNAL_ENTRIES.set(gauge(state.rib.journal_len()));
|
||||
sample_tcp(&state).await;
|
||||
if let Some(m) = crate::memory::memory_use() {
|
||||
let int = |n: u64| i64::try_from(n).unwrap_or(i64::MAX);
|
||||
for (kind, octets) in [
|
||||
("allocated", m.allocated),
|
||||
("active", m.active),
|
||||
("metadata", m.metadata),
|
||||
("resident", m.resident),
|
||||
("mapped", m.mapped),
|
||||
("retained", m.retained),
|
||||
] {
|
||||
MEMORY.with_label_values(&[kind]).set(int(octets));
|
||||
}
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let encoder = TextEncoder::new();
|
||||
@@ -37,3 +52,20 @@ pub async fn metricz(State(state): State<AppState>) -> impl IntoResponse {
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the kernel about every established session's TCP connection and
|
||||
/// sets the peer's gauges: one netlink round trip a session, at scrape
|
||||
/// time, so that there is history to look back on. A session the kernel
|
||||
/// has no connection for any more loses its series.
|
||||
async fn sample_tcp(state: &AppState) {
|
||||
let tcp = &*PEER_TCP;
|
||||
for session in state.registry.list() {
|
||||
let label = session.metric_label();
|
||||
let connection =
|
||||
bgp_fib::sockdiag::tcp_connection(session.local_addr, session.peer.addr).await;
|
||||
match connection {
|
||||
Ok(Some(c)) => tcp.set(&label, &c),
|
||||
_ => tcp.remove(&label),
|
||||
}
|
||||
}
|
||||
}
|
||||
+387
-69
@@ -3,13 +3,17 @@
|
||||
<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 (#eef) heading bar per section. Deliberately no dark theme, no
|
||||
framework, no external resources. Everything shown comes from /api/*,
|
||||
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).
|
||||
|
||||
@@ -21,50 +25,90 @@
|
||||
(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>
|
||||
body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; }
|
||||
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: #eef; margin: 14px 0 6px; padding: 2px 0; }
|
||||
: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: #00c; } a:visited { color: #551a8b; }
|
||||
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 #99a; 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 > tbody > tr:hover > td { background: #f3f3ff; }
|
||||
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: #eef; font-weight: bold; padding: 3px 6px; }
|
||||
table.data tr.best > td { background: #f4faf4; }
|
||||
table.data tr.dim td { color: #666; }
|
||||
table.data tr.group > td { background: var(--band); font-weight: bold; padding: 3px 6px; }
|
||||
/* A row of detail under a row of the table: a session's TCP connection, a
|
||||
route in full. The route's is indented past the rank column. */
|
||||
table.data tr.detail > td { background: var(--band); padding: 6px 10px 8px 28px; }
|
||||
table.data tr.route-detail > td { padding-left: 40px; }
|
||||
table.data td.attrs { cursor: pointer; }
|
||||
table.data td.attrs:hover { background: var(--hover); }
|
||||
/* Names and values, two columns, as the detail rows show them. */
|
||||
.kv-grid { display: grid; grid-template-columns: max-content 1fr; gap: 1px 16px; font-size: 12px; }
|
||||
.kv-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; }
|
||||
.asn:hover { background: #dde; }
|
||||
#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: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: #c00; font-weight: bold; }
|
||||
.amber { color: #a60; font-weight: bold; }
|
||||
.green { color: #080; }
|
||||
.dim { color: #666; }
|
||||
.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 #aab; border-radius: 3px; background: #f6f6fb; color: #223; 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.amber { border-color: #a60; background: #fff8e8; color: #a60; }
|
||||
.g.green { border-color: #080; background: #f0fff0; color: #080; }
|
||||
.g.dim { border-color: #ccd; background: #fafafa; color: #666; }
|
||||
.g.mark { background: #eef4ff; border-color: #88a; }
|
||||
.g.ctl { background: #fff4e8; border-color: #c96; }
|
||||
.g.wk { background: #f0f0f0; border-color: #999; font-weight: bold; }
|
||||
.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 #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 { 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; }
|
||||
/* 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. */
|
||||
.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); }
|
||||
@@ -75,23 +119,40 @@ 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: #112; 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 { 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: #446; background: #f3f3f8; min-width: 1em; text-align: center; }
|
||||
/* The strip: what needs attention, if anything. */
|
||||
#strip { position: sticky; top: 0; z-index: 3; background: #fff; border: 1px solid #99a; padding: 4px 8px; margin: 6px 0; }
|
||||
.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 #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%; }
|
||||
details { clear: both; margin: 8px 0 0; }
|
||||
summary { cursor: pointer; color: #00c; }
|
||||
summary { cursor: pointer; color: var(--link); }
|
||||
summary:hover { text-decoration: underline; }
|
||||
#config-text { border: 1px solid #99a; padding: 4px 6px; margin: 4px 0; font-size: 12px; overflow-x: auto; }
|
||||
address { font-size: smaller; margin-top: 2em; color: #444; }
|
||||
#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; }
|
||||
@@ -107,7 +168,7 @@ form.lookup input[type=text] { font-family: monospace; }
|
||||
<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>
|
||||
Started <span id="s-started">...</span>, up <span id="s-uptime">...</span><span id="s-memory"></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">
|
||||
@@ -126,10 +187,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>
|
||||
<div class="toolbar">
|
||||
<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>
|
||||
<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="Routes announced to the peer and not withdrawn since; with ADD-PATH, every route sent counts">sent</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>
|
||||
|
||||
@@ -189,7 +252,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.
|
||||
@@ -216,6 +279,17 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
if (state.status) $('s-uptime').textContent = fmtDur(t - state.status.started_at_unix);
|
||||
}
|
||||
const fmtWhen = unix => new Date(unix * 1000).toLocaleString();
|
||||
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`;
|
||||
// A cell across every column of the table that `body` is the body of,
|
||||
// and a row of one: for a heading, a note or a detail row. The width is
|
||||
// the table's header's, so a column added there is covered here.
|
||||
const fullCell = (body, html, cls = '') => `<td colspan="${body.closest('table').querySelectorAll('thead th').length}"${cls ? ` class="${cls}"` : ''}>${html}</td>`;
|
||||
const fullRow = (body, html, rowClass = '', cellClass = '') => `<tr${rowClass ? ` class="${rowClass}"` : ''}>${fullCell(body, html, cellClass)}</tr>`;
|
||||
// Names and values in two columns, for a detail row.
|
||||
const kvGrid = rows => `<div class="kv-grid mono">${rows.map(([k, v]) => `<div>${k}</div><div>${v}</div>`).join('')}</div>`;
|
||||
|
||||
// What a community of ours asks for where control communities are on
|
||||
// (control_communities), or ''. They are acted on where a route leaves
|
||||
@@ -350,16 +424,87 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
$('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(/ \(.*\)$/, '');
|
||||
const m = s.memory;
|
||||
setHTML($('s-memory'), m ? ` · memory <span class="mono" title="jemalloc: allocated / active / resident / mapped / retained">${fmtOctets(m.allocated)} allocated, ${fmtOctets(m.resident)} resident</span>` : '');
|
||||
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 ? 1 : 2;
|
||||
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'],
|
||||
@@ -367,8 +512,10 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
'route server': ['RS', 'route server of an Internet exchange'],
|
||||
'route server client': ['RSC', 'client of a route server we run'],
|
||||
};
|
||||
// A router of our own AS, by its AS number.
|
||||
const isIbgp = p => p.peer_asn === (state.status || {}).local_asn;
|
||||
function roleGlyph(p) {
|
||||
if (p.peer_asn === (state.status || {}).local_asn) return glyph('iBGP', 'a router of our own AS');
|
||||
if (isIbgp(p)) 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' : '';
|
||||
@@ -400,7 +547,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
}
|
||||
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';
|
||||
if (isIbgp(p)) 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' };
|
||||
@@ -411,54 +558,145 @@ 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, fullRow(b, 'No peers configured and none connected.', '', 'dim')); return; }
|
||||
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, IPv6
|
||||
// first, the name once in full and once quietly, joined down the side.
|
||||
const row = (p, pair) => {
|
||||
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 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')}` : '';
|
||||
return `<tr class="${peerRank(p) === 3 ? 'dim' : ''}">
|
||||
<td class="name">${name}</td>
|
||||
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)}" 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>
|
||||
<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>${up ? sessionGlyphs(p) : DASH}</td>
|
||||
<td class="num">${up ? fmtCount(p.routes) + stale : 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">${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>`;
|
||||
};
|
||||
const byName = (a, c) => (a.name || a.peer_addr).localeCompare(c.name || c.peer_addr);
|
||||
// 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>` + problems.map(row).join('');
|
||||
html += fullRow(b, `<span class="red">Needing attention</span> (${problems.length})`, 'group') + 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>` + members.map(row).join('');
|
||||
html += fullRow(b, `${GROUP_TITLE[group]} (${members.length})`, 'group') + rows(members);
|
||||
}
|
||||
} else if (!problems.length) {
|
||||
html += '<tr><td colspan="9" class="dim">Nothing needs attention.</td></tr>';
|
||||
html += fullRow(b, 'Nothing needs attention.', '', 'dim');
|
||||
}
|
||||
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>`;
|
||||
}
|
||||
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 kvGrid(rows);
|
||||
}
|
||||
async function renderTcpRows() {
|
||||
const b = $('peers-body');
|
||||
for (const addr of state.tcpOpen) {
|
||||
const tr = b.querySelector(`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 = 'detail tcp-row';
|
||||
row.innerHTML = fullCell(b, '<span class="dim">asking the kernel…</span>');
|
||||
tr.after(row);
|
||||
}
|
||||
try {
|
||||
const answer = await getJSON(`/api/tcp?peer=${encodeURIComponent(addr)}`);
|
||||
row.innerHTML = fullCell(b, tcpDetail(answer));
|
||||
} catch (e) {
|
||||
row.innerHTML = fullCell(b, `<span class="red">${esc(String(e))}</span>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = [];
|
||||
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 => `<b>${esc(p.name || p.peer_addr)}</b> <span class="dim">(${esc((p.last_error || p.state).replace(/;.*$/, ''))})</span>`).join(', ')}`);
|
||||
const restarting = peers.filter(p => peerRank(p) === 1);
|
||||
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 (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>`);
|
||||
@@ -526,7 +764,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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();
|
||||
.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(''));
|
||||
@@ -565,7 +803,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
|
||||
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; }
|
||||
if (!state.local.length) { setHTML(b, fullRow(b, 'This router originates no routes.', '', 'dim')); 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>
|
||||
@@ -601,20 +839,73 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
: `${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, fullRow(b, looking ? 'No route matches.' : 'No routes received from peers.', '', 'dim')); 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 kvGrid(rows);
|
||||
}
|
||||
function renderRouteDetails() {
|
||||
const b = $('received-body');
|
||||
for (const key of state.routeOpen) {
|
||||
const r = state.received.find(r => routeKey(r) === key);
|
||||
const tr = b.querySelector(`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 = 'detail route-detail';
|
||||
row.innerHTML = fullCell(b, routeDetail(r));
|
||||
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');
|
||||
@@ -638,7 +929,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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>`) +
|
||||
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>`) : '') +
|
||||
@@ -684,7 +975,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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; }
|
||||
if (!peers.length) { setHTML(b, fullRow(b, chosen ? `Nothing ${esc(chosen)} announced has been rejected.` : 'Nothing a peer announced has been rejected.', '', 'dim')); return; }
|
||||
setHTML(b, peers.map(p => {
|
||||
// Alike: the same prefix, for the same reason, by the same path.
|
||||
const alike = new Map();
|
||||
@@ -697,7 +988,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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>` +
|
||||
return fullRow(b, `<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}`, 'group') +
|
||||
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>
|
||||
@@ -711,7 +1002,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
|
||||
// ---- data loading ------------------------------------------------------
|
||||
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.
|
||||
function renderPager() {
|
||||
const page = state.receivedPage, place = pagePlace();
|
||||
@@ -843,6 +1134,31 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
$('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.
|
||||
@@ -861,6 +1177,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Documentation sources
|
||||
|
||||
This directory holds the sources of the Ferrix documentation site, built
|
||||
with [Zola](https://www.getzola.org):
|
||||
|
||||
```bash
|
||||
zola --root docs serve # http://127.0.0.1:1111, rebuilt on every change
|
||||
zola --root docs build # into docs/public
|
||||
```
|
||||
|
||||
The pages under `content/` are Markdown with Zola additions, which a code
|
||||
forge does not render:
|
||||
|
||||
- The block between `+++` lines at the top of each page is its front
|
||||
matter: the title, description and position in the navigation.
|
||||
- `{{ example(name="...") }}` and `{{ file(name="...") }}` insert a file
|
||||
from [`examples/`](examples/), whole, at that point. Open the file to
|
||||
read it.
|
||||
- Links of the form `@/docs/...` are internal links that Zola resolves and
|
||||
checks at build time. On the forge they do not work; the target is the
|
||||
path after `@/`, under `content/`.
|
||||
|
||||
To read the documentation as intended, build the site, or read it where
|
||||
it is published (`base_url` in [`config.toml`](config.toml)).
|
||||
|
||||
`STYLE.md` is the writing guide. CI builds the site on every push and fails
|
||||
on a link to a page or an anchor that does not exist; `zola --root docs
|
||||
check` also checks external links.
|
||||
@@ -163,9 +163,12 @@ UPDATE message, and waits when the session's output queue is full.
|
||||
### Kernel Route Installation
|
||||
|
||||
The `fib` crate reads best path changes and maintains the kernel routing
|
||||
table over rtnetlink, with one route for each prefix through the next hop
|
||||
of its best path. It also receives the router's interfaces and addresses
|
||||
from the kernel, which determine whether a next hop is resolvable.
|
||||
table over rtnetlink, with one route for each prefix through the resolved
|
||||
next hop of its best path. It also receives the router's interfaces,
|
||||
addresses and the routes of the resolution table from the kernel, which
|
||||
determine whether and how a next hop is resolvable; the import policy
|
||||
consults the same view, so that a route with an unresolvable next hop is
|
||||
held rather than selected.
|
||||
|
||||
### HTTP Interface
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ test phases.
|
||||
| `hijacks` | FRR advertises a local prefix, a route with BIRD as third-party next hop, and the exchange LAN prefix; all are rejected |
|
||||
| `nexthops` | A preferred route from FRR with an unresolvable next hop is held, and the route from BIRD is selected and installed; the selection changes when the next hop becomes resolvable, and reverts when it becomes unresolvable |
|
||||
| `transit` | FRR as customer and BIRD as provider: FRR's routes are advertised to BIRD with the local AS prepended; BIRD's routes are advertised to FRR with the OTC attribute; a control community prevents the advertisement of one FRR route to BIRD; traffic is forwarded between their networks |
|
||||
| `collector` | FRR as a route collector with ADD-PATH and `all_paths`: BIRD peers twice, FRR receives both of BIRD's routes for a prefix with distinct path identifiers, and one is withdrawn by its identifier when its session is disabled |
|
||||
| `v6only` | Exchange LAN without IPv4 addresses: one IPv6 session with each router for both address families; IPv4 routes with IPv6 next hops (RFC 8950) in BGP and in the kernel; IPv4 forwarding verified with `ping` |
|
||||
| `linklocal` | Exchange LAN with link-local addresses only, with the same addresses on a second interface: sessions with FRR (by interface, with radvd) and BIRD at `fe80::…%ix0`; both address families in BGP and in the kernel; forwarding verified with `ping`; interface disabled and enabled |
|
||||
|
||||
@@ -172,11 +173,87 @@ performance: approximately 1 million IPv4 prefixes, mostly /24, and
|
||||
in UPDATE messages; AS paths of several hops; and communities on most
|
||||
routes.
|
||||
|
||||
Table 5 lists results on an Apple M-series computer with a release build.
|
||||
Table 5 lists `full_table` results on an Apple M-series computer with a
|
||||
release build: 1.23 million prefixes from each of four neighbors, 4.92
|
||||
million routes.
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---|
|
||||
| 1.23 million routes from one neighbor, accepted into the routing table | 0.47 seconds |
|
||||
| The same routes received by a second neighbor (`transit`) | 2.1 seconds, approximately 4 routes per UPDATE message |
|
||||
| 1 million IPv4 routes installed in the kernel (Linux virtual machine) | Slightly more than 1 second |
|
||||
| The same routes removed from the kernel | 0.5 seconds |
|
||||
| Decoding the UPDATE messages, one thread | 0.43 seconds, 11 million routes per second |
|
||||
| Decoding, interning attributes, inserting and selecting, one thread | 1.9 seconds, 2.5 million routes per second |
|
||||
| The same, 12 threads | 0.7 seconds, 6.9 million routes per second |
|
||||
| Four neighbors send the table over TCP to the in-process server | 0.9 seconds, 5.2 million routes per second |
|
||||
| Memory per route, attribute sets included (`--features count-memory`) | 77 bytes |
|
||||
|
||||
## Profiling
|
||||
|
||||
The `scripts/profile/full-table.sh` script measures the daemon itself, as
|
||||
built for production, while one or more neighbors send it a full routing
|
||||
table, and records where its CPU time goes. It requires Linux with `perf`,
|
||||
`inferno` (`cargo install inferno`), `curl`, `jq`, and `sudo`. On another
|
||||
operating system, run it in the Linux virtual machine:
|
||||
|
||||
```bash
|
||||
scripts/linux-vm.sh scripts/profile/full-table.sh
|
||||
```
|
||||
|
||||
The script builds the daemon with the `profiling` Cargo profile, which is
|
||||
the release profile with line tables, and with frame pointers, so that
|
||||
`perf` can attribute samples to functions. It starts the daemon in a
|
||||
network namespace of its own, connected by a virtual Ethernet pair to a
|
||||
second namespace in which the load generator
|
||||
(`crates/bench/examples/feed.rs`) connects as the neighbors and sends the
|
||||
synthetic table. Routes that the daemon installs go into the first
|
||||
namespace's routing table and do not affect the host. Table 6 lists the
|
||||
environment variables that control the run.
|
||||
|
||||
| Variable | Effect | Default |
|
||||
|---|---|---|
|
||||
| `PEERS` | The number of neighbors that send the table | 1 |
|
||||
| `KERNEL` | `1` installs the best routes in the kernel; `0` keeps them in the routing table only | 1 |
|
||||
| `SCALE_DOWN` | Divides the size of the table | 1 |
|
||||
| `HOLD_SECS` | How long the sessions stay open after the table is sent | 30 |
|
||||
| `FREQ` | The sampling frequency in hertz | 1999 |
|
||||
| `COLLECTOR` | `1` adds a route collector session that is sent every path (`all_paths`) | 0 |
|
||||
| `HEAP` | `1` profiles memory instead: the daemon is built with jemalloc's heap profiler, and `heap.svg` and `heap-top.txt` show where the allocations are | 0 |
|
||||
| `OUT` | The directory for the results | `~/profile` |
|
||||
| `JEMALLOC_CONF` | jemalloc options for the daemon (its `MALLOC_CONF`), for example `thp:always` | none |
|
||||
|
||||
When run through `scripts/linux-vm.sh`, pass the variables with `env`,
|
||||
because the virtual machine does not receive the host's environment:
|
||||
|
||||
```bash
|
||||
scripts/linux-vm.sh env PEERS=4 KERNEL=0 scripts/profile/full-table.sh
|
||||
```
|
||||
|
||||
The script prints how long the routes take to arrive in the routing table
|
||||
and in the kernel, and the daemon's resident memory. It writes the
|
||||
following files to the results directory:
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `flame.svg` | An interactive flame graph of the daemon's threads |
|
||||
| `top-self.txt` | Functions by the time spent in their own code |
|
||||
| `top-children.txt` | Functions by the time spent in them and everything they call |
|
||||
| `stacks.folded` | The collapsed stacks the flame graph is drawn from |
|
||||
| `perf.data`, `daemon.log`, `feed.log` | The raw samples and the logs |
|
||||
|
||||
Table 7 lists results in the Linux virtual machine (8 virtual CPUs on an
|
||||
Apple M-series computer) with the synthetic table of 1.23 million
|
||||
prefixes.
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---|
|
||||
| One neighbor; routes in the routing table | 0.8 seconds, 1.5 million routes per second |
|
||||
| One neighbor; the best routes installed in the kernel | 2.0 seconds after the first route |
|
||||
| One neighbor, without the kernel; routes in the routing table | 0.6 seconds, 2.0 million routes per second |
|
||||
| Four neighbors; 4.92 million routes in the routing table | 1.1 seconds, 4.4 million routes per second |
|
||||
| Four neighbors; the best routes installed in the kernel | 2.2 seconds after the first route |
|
||||
| Four neighbors; routes the kernel was asked to install | 1.25 million, for 1.23 million prefixes |
|
||||
| Resident memory with one neighbor's table installed | 440 MB |
|
||||
| Resident memory with four neighbors' tables installed | 630 MB |
|
||||
|
||||
A session is processed on one thread, so one neighbor's table arrives at
|
||||
the rate of one CPU core, and several neighbors' tables arrive in
|
||||
parallel. Installing routes in the kernel takes longer than receiving
|
||||
them, and continues after the last route has arrived.
|
||||
@@ -55,7 +55,7 @@ To build and install the daemon:
|
||||
bgp --version
|
||||
```
|
||||
|
||||
The command displays the installed version, for example `bgp 0.2.5`.
|
||||
The command displays the installed version, for example `bgp 0.2.6`.
|
||||
|
||||
## Building a Static Executable
|
||||
|
||||
|
||||
@@ -55,11 +55,11 @@ Ferrix provides the following capabilities:
|
||||
## Release Status
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Ferrix 0.2.5 is an early release, in production use on one small
|
||||
> Ferrix 0.2.6 is an early release, in production use on one small
|
||||
> network. Ferrix is tested against FRR, BIRD, and Routinator in an
|
||||
> automated interoperability lab and against real Linux kernels.
|
||||
|
||||
Ferrix 0.2.5 does not support the following features:
|
||||
Ferrix 0.2.6 does not support the following features:
|
||||
|
||||
- Per-client best path selection for route servers, and ADD-PATH for
|
||||
advertised routes.
|
||||
@@ -67,7 +67,6 @@ Ferrix 0.2.5 does not support the following features:
|
||||
Generalized TTL Security Mechanism (GTSM).
|
||||
- Long-Lived Graceful Restart, Bidirectional Forwarding Detection (BFD),
|
||||
and configuration changes without a restart.
|
||||
- Recursive resolution of next hops that are not directly connected.
|
||||
|
||||
For the implementation status of each RFC, see
|
||||
[Supported Standards](@/docs/reference/rfcs.md).
|
||||
|
||||
@@ -112,6 +112,6 @@ this metric, see [Monitoring Ferrix](@/docs/operations/monitoring.md#alerting-ru
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuring BGP Neighbors](@/docs/guides/peers.md)
|
||||
- [BGP Neighbors](@/docs/guides/peers.md)
|
||||
- [Import Policy](@/docs/guides/import-policy.md)
|
||||
- [Configuration Examples](@/docs/guides/examples.md)
|
||||
@@ -77,7 +77,7 @@ When the daemon stops in response to SIGTERM, it ends each session for
|
||||
good. On a session on which the N bit is in use, it sends a Cease
|
||||
NOTIFICATION message with the subcode Hard Reset.
|
||||
|
||||
## When a Neighbor Restarts
|
||||
## Neighbor Restart
|
||||
|
||||
Ferrix retains the routes of a neighbor whose session ends in one of the
|
||||
following ways, if graceful restart is in use on the session:
|
||||
|
||||
@@ -62,23 +62,71 @@ disabled.
|
||||
| Route from an EBGP neighbor whose AS path contains the local AS number | `own AS in path` |
|
||||
| Route whose next hop is unusable: unspecified, loopback, multicast, or an address of the local router | `next hop unusable` |
|
||||
| Route with the Only to Customer attribute received from the wrong direction (see [BGP Roles](@/docs/guides/roles.md)) | `route leak (Only to Customer)` |
|
||||
| Route from an EBGP neighbor with the BLACKHOLE community (RFC 7999) | `blackhole request` |
|
||||
| Route from an EBGP neighbor with the BLACKHOLE community (RFC 7999), unless `accept_blackholes` is set | `blackhole request` |
|
||||
| Route with the BLACKHOLE community that is not a host route (/32 or /128), with `accept_blackholes` set | `blackhole for more than one address` |
|
||||
|
||||
### Blackhole Requests
|
||||
### Blackhole Routes
|
||||
|
||||
A network under a denial-of-service attack can advertise a route for the
|
||||
attacked address with the BLACKHOLE community (65535:666), to request that
|
||||
other networks discard traffic to the address. At an Internet exchange, the
|
||||
route server distributes such routes to its members with the exchange's
|
||||
blackhole address as the next hop. These routes are usually host routes
|
||||
(/32 or /128).
|
||||
other networks discard traffic to the address (RFC 7999). At an Internet
|
||||
exchange, the route server distributes such routes to its members with
|
||||
the exchange's blackhole address as the next hop. These routes are host
|
||||
routes (/32 or /128).
|
||||
|
||||
Ferrix does not support blackhole routing. It rejects every route with the
|
||||
BLACKHOLE community from an EBGP neighbor, with the reason
|
||||
`blackhole request`, regardless of the prefix length. A route with the
|
||||
BLACKHOLE community that is accepted as an ordinary route directs traffic
|
||||
to the blackhole next hop, which discards it, so Ferrix does not accept
|
||||
these routes as ordinary routes either.
|
||||
By default, Ferrix rejects every route with the BLACKHOLE community from
|
||||
an EBGP neighbor, with the reason `blackhole request`, regardless of the
|
||||
prefix length. Ferrix does not accept such a route as an ordinary route
|
||||
either, because that would direct traffic to the blackhole next hop,
|
||||
which discards it.
|
||||
|
||||
To honor blackhole requests from a neighbor, set `accept_blackholes` in
|
||||
its import policy:
|
||||
|
||||
```textproto
|
||||
peers {
|
||||
name: "customer-a"
|
||||
endpoint { address: "192.0.2.10" }
|
||||
remote_asn: 64501
|
||||
role: CUSTOMER
|
||||
import {
|
||||
prefixes { prefix: "203.0.113.0/24" }
|
||||
accept_blackholes: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With `accept_blackholes` set, Ferrix accepts a route with the BLACKHOLE
|
||||
community if all of the following conditions are met:
|
||||
|
||||
- The prefix is a single address: a /32 or a /128. The
|
||||
`max_ipv4_prefix_length` and `max_ipv6_prefix_length` limits do not
|
||||
apply to blackhole routes. A blackhole route for a longer prefix is
|
||||
rejected with the reason `blackhole for more than one address`.
|
||||
- The address is within a prefix that the neighbor may announce, if
|
||||
`prefixes` is configured. The `max_length` of the entry does not apply.
|
||||
- The route passes the AS path, origin, and RPKI checks that apply to
|
||||
every route.
|
||||
|
||||
The next hop of a blackhole route is not checked, and need not be
|
||||
reachable: Ferrix never forwards traffic to it. If the route is the active
|
||||
route for its prefix, which a host route is unless a longer-matching
|
||||
route exists, Ferrix installs a blackhole route in the kernel, and the
|
||||
router discards traffic to the address. See [Blackhole
|
||||
Routes](@/docs/guides/kernel.md#blackhole-routes) in the kernel guide.
|
||||
|
||||
Ferrix advertises an accepted blackhole route like any other route, with
|
||||
the BLACKHOLE community, to the neighbors that the export policy and the
|
||||
export limits permit. A provider that honors the community then discards
|
||||
the traffic before it reaches the local network. To keep a blackhole
|
||||
route from being advertised, the originator adds the NO_EXPORT or
|
||||
NO_ADVERTISE community, as RFC 7999 recommends.
|
||||
|
||||
> [!NOTE]
|
||||
> Ferrix does not install a blackhole route for a prefix that it
|
||||
> originates with the BLACKHOLE community. To discard traffic locally for
|
||||
> such a prefix, add a kernel blackhole route, for example
|
||||
> `ip route add blackhole 203.0.113.9/32`.
|
||||
|
||||
### Next Hop Check
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ kernel {
|
||||
| `metric` | Metric of the installed routes. The kernel prefers the route with the lowest metric. | 20 |
|
||||
| `without_nexthop_objects` | Installs each route with its own gateway instead of a nexthop object | Disabled |
|
||||
| `stale_seconds` | Time for which routes from a previous run are retained at startup | 120 seconds |
|
||||
| `resolution_table` | Kernel routing table whose routes resolve next hops that are not directly connected | 254 (`main`) |
|
||||
| `resolve_via_default_route` | Permits the default route to resolve a next hop | Disabled |
|
||||
|
||||
Ferrix requires the `CAP_NET_ADMIN` capability to modify the kernel routing
|
||||
table. If the kernel rejects a request for lack of privileges, the daemon
|
||||
@@ -44,15 +46,55 @@ exits. See [Granting Privileges](@/docs/getting-started/installation.md#granting
|
||||
|
||||
## Next Hop Resolution
|
||||
|
||||
Ferrix installs a route only if its next hop is directly connected: the
|
||||
next hop must be an address in a subnet of an interface that is up. For an
|
||||
IPv6 route with a link-local next hop, the outgoing interface is the
|
||||
interface on which the neighbor's session is established.
|
||||
Ferrix resolves the next hop of each active route to a gateway and an
|
||||
outgoing interface before it installs the route:
|
||||
|
||||
Ferrix does not resolve next hops recursively through other routes.
|
||||
Therefore, a route whose next hop is not directly connected, such as a
|
||||
route from an IBGP neighbor that does not set itself as the next hop, is
|
||||
not installed.
|
||||
- A next hop that is directly connected, that is, an address in a subnet
|
||||
of an interface that is up, is the gateway, and the interface is that
|
||||
interface. For an IPv6 route with a link-local next hop, the outgoing
|
||||
interface is the interface on which the neighbor's session is
|
||||
established.
|
||||
- A next hop that is not directly connected is resolved through the
|
||||
routes of the resolution table (`resolution_table`, by default the
|
||||
`main` table). Ferrix selects the route with the longest prefix that
|
||||
contains the next hop, and of several routes for that prefix the one
|
||||
with the lowest metric. If the route has a gateway, the gateway and the
|
||||
route's interface are used. If the route has no gateway, as a route to a
|
||||
point-to-point interface or a tunnel does not, the next hop itself is
|
||||
the gateway and the route's interface is used. A route that names a
|
||||
kernel nexthop object is resolved through that object.
|
||||
|
||||
The Linux kernel does not resolve gateways recursively, so Ferrix installs
|
||||
the route with the resolved gateway and interface rather than with the
|
||||
BGP next hop. For example, a route from an IBGP neighbor with the next hop
|
||||
`192.0.2.9`, when the kernel has the static route
|
||||
`192.0.2.0/24 via 10.0.0.2 dev wg0`, is installed as a route
|
||||
`via 10.0.0.2 dev wg0`. When the static route changes or is removed,
|
||||
Ferrix resolves the next hop again and updates or removes the installed
|
||||
route.
|
||||
|
||||
The following routes do not resolve a next hop:
|
||||
|
||||
- Routes with protocol `bgp`, whether installed by Ferrix or by another
|
||||
BGP daemon. A next hop that is reachable only through a BGP route would
|
||||
depend on another next hop, and such dependencies between routes are not
|
||||
predictable.
|
||||
- The default route, unless `resolve_via_default_route` is `true`. On a
|
||||
router, the default route typically points to a transit provider, and a
|
||||
next hop that is reachable only through it is not a usable next hop.
|
||||
- Blackhole, unreachable and prohibit routes. A next hop covered by such a
|
||||
route is unresolvable.
|
||||
- Routes whose interface is down.
|
||||
|
||||
Ferrix learns the routes of the resolution table from the kernel at
|
||||
startup and is notified of changes. Changes to the routes take effect
|
||||
within a few seconds.
|
||||
|
||||
> [!NOTE]
|
||||
> Routes from an IBGP neighbor that does not set itself as the next hop,
|
||||
> such as a route reflector that preserves the next hop, require a route to
|
||||
> the next hop in the resolution table: a static route, a route to the
|
||||
> tunnel on which the next hop is reached, or a route from an IGP.
|
||||
|
||||
### Exclusion of Unresolvable Routes
|
||||
|
||||
@@ -70,6 +112,18 @@ next hop becomes unresolvable is also moved to the held state.
|
||||
If the `kernel` statement is not configured, Ferrix has no information about
|
||||
next hop reachability and accepts all routes.
|
||||
|
||||
## Blackhole Routes
|
||||
|
||||
Ferrix installs the active route for a prefix as a kernel blackhole route
|
||||
(`ip route` displays it as `blackhole PREFIX proto bgp`) if the route
|
||||
carries the BLACKHOLE community (RFC 7999) and the import policy accepted
|
||||
it with `accept_blackholes`. The kernel discards traffic to the prefix.
|
||||
The next hop of the route is not resolved, and the route is installed
|
||||
even if the next hop is unreachable. The kernel section of the status
|
||||
page and `/api/kernel` report the number of installed blackhole routes.
|
||||
See [Blackhole Routes](@/docs/guides/import-policy.md#blackhole-routes)
|
||||
in the import policy guide.
|
||||
|
||||
## Connected Networks
|
||||
|
||||
Ferrix never installs a route for a subnet of a local interface, or for a
|
||||
|
||||
@@ -367,10 +367,12 @@ not affected.
|
||||
|
||||
Ferrix advertises a reflected route with the local address of the session
|
||||
as next hop, as it does every route. A standard route reflector does not
|
||||
change the next hop. Ferrix does, because it does not resolve next hops
|
||||
that are not directly connected: a client that is not connected to the
|
||||
router that learned the route can then still use it, and sends the
|
||||
traffic through the route reflector.
|
||||
change the next hop. Ferrix does, so that a client needs no route to the
|
||||
router that learned the route: the client sends the traffic through the
|
||||
route reflector, which forwards it. Preserving the next hop on reflection
|
||||
is not yet configurable; clients that have a route to the originating
|
||||
router, for example over a tunnel, would then forward to it directly. See
|
||||
[Next Hop Resolution](@/docs/guides/kernel.md#next-hop-resolution).
|
||||
|
||||
### Loop Prevention
|
||||
|
||||
@@ -396,12 +398,48 @@ Ferrix advertised (`announced`) and withdrew (`withdrawn`). Ferrix does not
|
||||
provide a view of the routes advertised to a neighbor (Adj-RIB-Out); to
|
||||
display them, use the neighbor's own tools.
|
||||
|
||||
## Feeding a Route Collector
|
||||
|
||||
A route collector, such as a looking glass or a measurement project, wants
|
||||
every route that a router has accepted, not only the best path for each
|
||||
prefix. ADD-PATH (RFC 7911) lets one session carry several routes for a
|
||||
prefix, each with a path identifier. To advertise every accepted route to
|
||||
a neighbor, configure ADD-PATH in the sending direction and enable
|
||||
`all_paths` in the neighbor's export policy:
|
||||
|
||||
```textproto
|
||||
peers {
|
||||
name: "collector"
|
||||
endpoint { address: "203.0.113.10" }
|
||||
remote_asn: 64496
|
||||
role: PEER
|
||||
add_path: ADD_PATH_SEND
|
||||
export {
|
||||
learned_routes: true
|
||||
all_paths: true
|
||||
}
|
||||
export_limits {}
|
||||
}
|
||||
```
|
||||
|
||||
Ferrix advertises every route for a prefix only for the address families
|
||||
for which the neighbor negotiated ADD-PATH receive. For other families it
|
||||
advertises the best path, as without `all_paths`. Each route is advertised
|
||||
with a path identifier that stays with the route for as long as it is
|
||||
advertised; a route that is withdrawn is withdrawn by its identifier, and
|
||||
a route whose attributes change is advertised again with the same
|
||||
identifier. The export policy, the BGP role rules, the well-known
|
||||
communities and the export limits apply to every route alike, and
|
||||
`max_prefixes` in `export_limits` counts every route advertised.
|
||||
|
||||
A session with `all_paths` keeps a record of every route advertised and
|
||||
its identifier, which takes about 50 bytes for each route. For a full
|
||||
Internet table from several neighbors, this is some tens of megabytes.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Per-client best path selection for route servers is not supported. Every
|
||||
client receives the same best path.
|
||||
- ADD-PATH is not supported for advertisement; only the best path is
|
||||
advertised.
|
||||
- Best path selection does not use ORIGINATOR_ID and CLUSTER_LIST as tie
|
||||
breakers (RFC 4456 Section 9).
|
||||
- UPDATE messages are generated separately for each session, even when
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
+++
|
||||
title = "Configuring BGP Neighbors"
|
||||
title = "BGP Neighbors"
|
||||
description = "Describes how to configure BGP neighbors, address families, link-local neighbors, ADD-PATH, and session timers, and how Ferrix filters inbound connections."
|
||||
weight = 1
|
||||
|
||||
@@ -193,8 +193,11 @@ add_path: ADD_PATH_RECEIVE
|
||||
```
|
||||
|
||||
ADD-PATH is enabled for each address family and direction only if the
|
||||
neighbor also supports it. Ferrix negotiates `ADD_PATH_SEND` and
|
||||
`ADD_PATH_BOTH`, but it advertises only one path for each prefix.
|
||||
neighbor also supports it. With `ADD_PATH_SEND` or `ADD_PATH_BOTH`,
|
||||
Ferrix advertises routes with path identifiers. By default it still
|
||||
advertises one path for each prefix; to advertise every accepted route
|
||||
for a prefix, enable `all_paths` in the neighbor's export policy. See
|
||||
[Feeding a Route Collector](@/docs/guides/passing-routes-on.md#feeding-a-route-collector).
|
||||
|
||||
## Hold Timer
|
||||
|
||||
@@ -233,6 +236,38 @@ message with the subcode Administrative Shutdown to every neighbor. If the
|
||||
`kernel` statement is configured, it then removes its routes from the
|
||||
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 which routes are accepted;
|
||||
`max_prefixes` is the limit that Ferrix enforces. Ferrix also sizes the
|
||||
routing table at startup for the largest `expected_routes` value among
|
||||
the neighbors, so that the table does not grow while the tables are
|
||||
received.
|
||||
|
||||
## Verifying BGP Sessions
|
||||
|
||||
To verify the state of each neighbor, use one of the following:
|
||||
|
||||
@@ -31,16 +31,27 @@ 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
|
||||
expire. When nothing needs attention, the line says so. A second line
|
||||
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:
|
||||
|
||||
- The configuration that was read at startup.
|
||||
- Each neighbor, with its session state, time established, next hop,
|
||||
session properties, number of accepted routes, rejected announcements,
|
||||
and held routes, and its last error. Neighbors that need attention are
|
||||
listed first; the others are grouped by role. The **problems only**
|
||||
option hides the neighbors that need no attention.
|
||||
session properties, number of accepted routes, number of routes
|
||||
advertised to it (**sent**), rejected announcements, and held routes,
|
||||
and its last error. The **tcp** link on an established session expands
|
||||
a row with the connection as the kernel reports it: round-trip time,
|
||||
congestion window, the neighbor's receive window and Ferrix's, queues,
|
||||
retransmissions, throughput, and the share of time the connection was
|
||||
held up by the neighbor's window or by the local send buffer. This
|
||||
shows whether a slow advertisement of a table is paced by the neighbor.
|
||||
Linux only. Neighbors that need attention are
|
||||
listed first; the others are grouped by role. The IPv4 and IPv6 sessions
|
||||
of one neighbor, named `NAME-v4` and `NAME-v6`, are listed together,
|
||||
IPv6 first, 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.
|
||||
- The originated routes.
|
||||
- Kernel route installation: the number of installed routes, the number of
|
||||
@@ -60,7 +71,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
|
||||
normally, such as the RPKI cache being synchronized; gray indicates an
|
||||
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
|
||||
them. Point to a glyph to display its meaning.
|
||||
@@ -77,6 +95,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). |
|
||||
| `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. |
|
||||
| `▲`, `▼` | 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
|
||||
displayed shortened, with a **more** control. Click the control, or
|
||||
@@ -139,6 +159,12 @@ advertise the same routes can therefore have different counts. The number
|
||||
of routes currently held, because they are RPKI or ASPA invalid or their
|
||||
next hop is unreachable, is displayed in the neighbor table as **held**.
|
||||
|
||||
In the received routes table, the **attrs** column shows the attributes
|
||||
that have no column of their own as badges. Clicking the cell expands a
|
||||
row below the route that lists every attribute of the route by its name,
|
||||
with the path identifier, the selection rank, and the RPKI and ASPA
|
||||
states.
|
||||
|
||||
```json
|
||||
[{"peer":"transit-a","total":3,"by_reason":{"RPKI invalid":2,"bogon prefix":1},
|
||||
"recent":[{"prefix":"203.0.113.0/24","reason":"RPKI invalid","as_path":[64496,64511]}]}]
|
||||
@@ -225,6 +251,11 @@ Table 3 lists queries for a monitoring dashboard.
|
||||
|---|---|
|
||||
| Established sessions | `sum(bgp_peer_up)`, of `count(bgp_peer_up)` |
|
||||
| Routes by neighbor | `bgp_peer_routes` |
|
||||
| Routes advertised to each neighbor | `bgp_peer_advertised_routes` |
|
||||
| Memory in use and resident | `bgp_memory_bytes{kind="allocated"}`, `bgp_memory_bytes{kind="resident"}` |
|
||||
| Share of time a session is held up by the neighbor's receive window | `rate(bgp_peer_tcp_receive_window_limited_seconds[5m]) / rate(bgp_peer_tcp_busy_seconds[5m])` |
|
||||
| Round-trip time to each neighbor | `bgp_peer_tcp_rtt_seconds` |
|
||||
| Octets queued towards a neighbor | `bgp_peer_tcp_send_queue_bytes` |
|
||||
| Prefixes with a best path | `bgp_rib_prefixes` |
|
||||
| Rejected routes by reason | `sum by (reason) (rate(bgp_routes_rejected_total[5m]))` |
|
||||
| UPDATE messages received and sent | `rate(bgp_messages_received_total{type="update"}[5m])`, `rate(bgp_messages_sent_total{type="update"}[5m])` |
|
||||
|
||||
@@ -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`. |
|
||||
| `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). |
|
||||
| `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. Ferrix also allocates the routing table with room for the largest `expected_routes` value among the neighbors at startup, which avoids the table growing, and briefly using twice its memory, while the tables are received. The value does not affect which routes are accepted. 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. |
|
||||
| `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). |
|
||||
@@ -102,6 +103,7 @@ accepted. See [Import Policy](@/docs/guides/import-policy.md).
|
||||
| `max_prefixes` <span class="type">uint32</span> | 0 (no limit) | Specifies the maximum number of routes that the neighbor can have in the routing table. If the limit is exceeded, Ferrix tears down the session and places the neighbor in hold-down. |
|
||||
| `hold_down_seconds` <span class="type">uint32</span> | 600 | Specifies the hold-down time, in seconds, after the neighbor exceeds `max_prefixes`, or after the routes to be advertised to it exceed `export_limits.max_prefixes`. |
|
||||
| `accept_bogons` <span class="type">bool</span> | `false` | Accepts bogon prefixes, and AS paths that contain private, reserved, or documentation AS numbers. |
|
||||
| `accept_blackholes` <span class="type">bool</span> | `false` | Accepts routes with the BLACKHOLE community (RFC 7999) for single addresses, and installs them as blackhole routes. See [Blackhole Routes](@/docs/guides/import-policy.md#blackhole-routes). |
|
||||
| `accept_default_route` <span class="type">bool</span> | `false` | Accepts the default routes `0.0.0.0/0` and `::/0`. |
|
||||
| `max_ipv4_prefix_length` <span class="type">uint32</span> | 24 | Specifies the longest IPv4 prefix accepted. |
|
||||
| `max_ipv6_prefix_length` <span class="type">uint32</span> | 48 | Specifies the longest IPv6 prefix accepted. |
|
||||
@@ -152,6 +154,7 @@ Statements that determine which routes are advertised to a neighbor. See
|
||||
| Statement | Default | Description |
|
||||
|---|---|---|
|
||||
| `learned_routes` <span class="type">bool</span> | `false` | Enables the advertisement of learned routes, subject to BGP roles and communities. |
|
||||
| `all_paths` <span class="type">bool</span> | `false` | Advertises every accepted route for a prefix, not only the best path, each with a path identifier (ADD-PATH, RFC 7911). Requires `learned_routes`, and takes effect for an address family only if the neighbor negotiated ADD-PATH receive for it; otherwise the best path is advertised. Export policy and export limits apply to every route. See [Feeding a Route Collector](@/docs/guides/passing-routes-on.md#feeding-a-route-collector). |
|
||||
| `add_communities` <span class="type">string, repeated</span> | None | Specifies standard communities added to advertised routes. |
|
||||
| `add_large_communities` <span class="type">string, repeated</span> | None | Specifies large communities added to advertised routes. |
|
||||
| `remove_communities` <span class="type">string, repeated</span> | None | Specifies patterns of communities removed from advertised routes. Applied before communities are added. |
|
||||
@@ -213,6 +216,8 @@ Statements for kernel route installation. See
|
||||
| `metric` <span class="type">uint32, optional</span> | 20 | Specifies the metric of installed routes. |
|
||||
| `without_nexthop_objects` <span class="type">bool</span> | `false` | Installs each route with its own gateway instead of a nexthop object. |
|
||||
| `stale_seconds` <span class="type">uint32, optional</span> | 120 | Specifies the time, in seconds, for which routes installed by a previous run are retained at startup. A value of 0 removes them immediately. |
|
||||
| `resolution_table` <span class="type">uint32</span> | 254 (`main`) | Specifies the kernel routing table whose routes resolve next hops that are not directly connected. Routes with protocol `bgp` in this table are not used. See [Next Hop Resolution](@/docs/guides/kernel.md#next-hop-resolution). |
|
||||
| `resolve_via_default_route` <span class="type">bool</span> | `false` | Permits the default route of the resolution table to resolve a next hop. |
|
||||
|
||||
## GracefulRestart
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ server returns the following error statuses:
|
||||
|---|---|
|
||||
| `/` | Redirects to `/statusz`. |
|
||||
| `/statusz` | Status page. |
|
||||
| `/api/status` | Router ID, AS number, uptime, table sizes, and whether control communities are enabled (`control_communities`). |
|
||||
| `/api/status` | Router ID, AS number, uptime, table sizes, memory use (`memory`, as jemalloc accounts for it: `allocated`, `active`, `metadata`, `resident`, `mapped`, `retained` octets), and whether control communities are enabled (`control_communities`). |
|
||||
| `/api/config` | The configuration that was read at startup. |
|
||||
| `/api/peers` | Configured neighbors and their sessions. |
|
||||
| `/api/routes/local` | Originated routes. |
|
||||
@@ -37,6 +37,7 @@ server returns the following error statuses:
|
||||
| `/api/routes/rejected` | Rejected routes, by neighbor. |
|
||||
| `/api/rpki` | RPKI cache, data, and data age. |
|
||||
| `/api/kernel` | Kernel route installation state and interfaces. |
|
||||
| `/api/tcp` | The TCP connection of one session, as the kernel reports it. |
|
||||
| `/api/events` | Session and route changes, as server-sent events. |
|
||||
| `/metricz` | Prometheus metrics. See [Metrics Reference](@/docs/reference/metrics.md). |
|
||||
|
||||
@@ -61,7 +62,9 @@ neighbor:
|
||||
"add_path_send": [],
|
||||
"routes": 1,
|
||||
"rejected_routes": 1,
|
||||
"advertised_routes": 2,
|
||||
"held_routes": 0,
|
||||
"expected_routes": null,
|
||||
"role": null,
|
||||
"role_confirmed": null,
|
||||
"last_error": null,
|
||||
@@ -83,7 +86,9 @@ neighbor:
|
||||
| `direction` | `inbound` if the neighbor initiated the connection, `outbound` otherwise. |
|
||||
| `routes` | Number of the neighbor's routes in the routing table. |
|
||||
| `rejected_routes` | Number of the neighbor's routes rejected by its import policy. |
|
||||
| `advertised_routes` | Number of routes advertised to the neighbor and not withdrawn since. With ADD-PATH and `all_paths`, every route advertised counts. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -204,6 +209,7 @@ routes. To return the records for one neighbor, add `?peer=NAME`.
|
||||
"synced": true,
|
||||
"installed": 0,
|
||||
"kept": 0,
|
||||
"blackholes": 0,
|
||||
"unresolved": {},
|
||||
"refused": 0,
|
||||
"errors": 0,
|
||||
@@ -220,10 +226,81 @@ routes. To return the records for one neighbor, add `?peer=NAME`.
|
||||
| `synced` | Whether the kernel routing table has been synchronized with the routing table since the daemon started. |
|
||||
| `installed` | Number of routes installed. |
|
||||
| `kept` | Number of routes from a previous run that are retained during startup. |
|
||||
| `blackholes` | Number of installed routes that are blackhole routes, which discard traffic for their prefix (RFC 7999). |
|
||||
| `unresolved` | Number of prefixes whose active route is not installed, by reason. |
|
||||
| `refused` | Number of prefixes whose active route the kernel rejected. |
|
||||
| `errors` | Number of requests that the kernel rejected. |
|
||||
|
||||
## TCP Connection
|
||||
|
||||
`/api/tcp?peer=PEER` returns what the kernel reports about the TCP
|
||||
connection of the session with the neighbor `PEER`, named as for
|
||||
`/api/routes/received`: its configured name, or its address, with or
|
||||
without the port as `peer_addr` in `/api/peers` shows it. The report
|
||||
covers the round-trip time, the congestion
|
||||
and receive windows, the queues, the retransmissions, and how long the
|
||||
connection has been held up by the neighbor's receive window or by the
|
||||
local send buffer. Linux only; on other systems, and for a session the
|
||||
kernel has no connection for, `available` is `false` and `reason` says
|
||||
why.
|
||||
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
"reason": null,
|
||||
"connection": {
|
||||
"state": "established",
|
||||
"congestion": "cubic",
|
||||
"rtt_ms": 0.62,
|
||||
"rtt_variance_ms": 0.11,
|
||||
"min_rtt_ms": 0.4,
|
||||
"rto_ms": 201,
|
||||
"mss": 1428,
|
||||
"pmtu": 1500,
|
||||
"cwnd_segments": 10,
|
||||
"ssthresh_segments": null,
|
||||
"send_window": 1048576,
|
||||
"receive_window": 3145728,
|
||||
"receive_space": 65536,
|
||||
"send_window_scale": 7,
|
||||
"receive_window_scale": 10,
|
||||
"send_queue": 0,
|
||||
"not_sent": 0,
|
||||
"recv_queue": 0,
|
||||
"unacked_segments": 0,
|
||||
"retrans_segments": 0,
|
||||
"lost_segments": 0,
|
||||
"total_retrans": 0,
|
||||
"bytes_retrans": 0,
|
||||
"pacing_rate": 2309920000,
|
||||
"delivery_rate": 184860000,
|
||||
"bytes_sent": 312993,
|
||||
"bytes_acked": 312994,
|
||||
"bytes_received": 190,
|
||||
"segments_out": 230,
|
||||
"segments_in": 118,
|
||||
"busy_ms": 41,
|
||||
"limited_by_receive_window_ms": 0,
|
||||
"limited_by_send_buffer_ms": 0,
|
||||
"limited_by_receive_window_share": 0,
|
||||
"limited_by_send_buffer_share": 0,
|
||||
"last_data_sent_ms": 1234,
|
||||
"last_data_received_ms": 1300,
|
||||
"last_ack_received_ms": 1234
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Times are in milliseconds, sizes in octets, rates in octets per second.
|
||||
`send_window` is the neighbor's receive window, which bounds what Ferrix
|
||||
can send; `receive_window` is what Ferrix lets the neighbor send. The two
|
||||
`_share` values are the fractions of the busy time, 0 to 1, in which the
|
||||
connection was held up by the neighbor's window and by the local send
|
||||
buffer. A session that advertises a table to a neighbor that reads it
|
||||
slowly shows a high `limited_by_receive_window_share`. The status page
|
||||
shows the same information under the **tcp** link of each established
|
||||
session.
|
||||
|
||||
## Events
|
||||
|
||||
`/api/events` is a stream of
|
||||
|
||||
@@ -29,6 +29,21 @@ alerting rules based on these metrics, see
|
||||
| Metric | Type | Description |
|
||||
|---|---|---|
|
||||
| `bgp_peer_routes{peer}` | Gauge | Number of the neighbor's routes in the routing table. |
|
||||
| `bgp_peer_advertised_routes{peer}` | Gauge | Number of routes advertised to the neighbor and not withdrawn since. |
|
||||
| `bgp_memory_bytes{kind}` | Gauge | The daemon's memory as jemalloc accounts for it. `kind` is `allocated` (octets in live allocations), `active` (the pages they occupy), `metadata` (the allocator's own), `resident` (active, metadata and freed pages not yet returned to the kernel), `mapped`, or `retained` (address space kept back, which costs nothing physical). |
|
||||
| `bgp_peer_tcp_rtt_seconds{peer}` | Gauge | Smoothed round-trip time of the session's TCP connection. Also `bgp_peer_tcp_min_rtt_seconds` and `bgp_peer_tcp_rtt_variance_seconds`. Linux only; sampled from the kernel at each scrape, as are all `bgp_peer_tcp_*` metrics. |
|
||||
| `bgp_peer_tcp_cwnd_segments{peer}` | Gauge | Congestion window, in segments. `bgp_peer_tcp_mss_bytes` is the segment size. |
|
||||
| `bgp_peer_tcp_send_window_bytes{peer}` | Gauge | The neighbor's receive window: what it lets Ferrix send. Zero while the neighbor is not reading. |
|
||||
| `bgp_peer_tcp_receive_window_bytes{peer}` | Gauge | Ferrix's receive window: what it lets the neighbor send. |
|
||||
| `bgp_peer_tcp_send_queue_bytes{peer}` | Gauge | Octets written and not yet acknowledged by the neighbor; `bgp_peer_tcp_not_sent_bytes` is the part not yet sent. |
|
||||
| `bgp_peer_tcp_receive_queue_bytes{peer}` | Gauge | Octets received and not yet read. |
|
||||
| `bgp_peer_tcp_unacked_segments{peer}` | Gauge | Segments in flight. |
|
||||
| `bgp_peer_tcp_retransmitted_segments{peer}` | Gauge | Segments retransmitted over the connection's life. `bgp_peer_tcp_lost_segments` are those thought lost now. |
|
||||
| `bgp_peer_tcp_busy_seconds{peer}` | Gauge | Time the connection had data to send, over its life. |
|
||||
| `bgp_peer_tcp_receive_window_limited_seconds{peer}` | Gauge | Of the busy time, time the connection was held up by the neighbor's receive window. |
|
||||
| `bgp_peer_tcp_send_buffer_limited_seconds{peer}` | Gauge | Of the busy time, time the connection was held up by the local send buffer being full. |
|
||||
| `bgp_peer_tcp_sent_bytes{peer}` | Gauge | Octets sent over the connection's life. `bgp_peer_tcp_received_bytes` likewise. |
|
||||
| `bgp_peer_tcp_delivery_rate_bytes_per_second{peer}` | Gauge | Delivery rate the kernel measured most recently; `bgp_peer_tcp_pacing_rate_bytes_per_second` is the rate it paces the connection at. |
|
||||
| `bgp_peer_max_prefixes{peer}` | Gauge | Prefix limit of the neighbor (`import.max_prefixes`), if configured. |
|
||||
| `bgp_prefix_limit_exceeded_total{peer}` | Counter | Number of sessions torn down because the neighbor exceeded its prefix limit. |
|
||||
| `bgp_export_limit_exceeded_total{peer}` | Counter | Number of sessions torn down because the routes to be advertised to the neighbor exceeded its export limit (`export_limits.max_prefixes`). |
|
||||
|
||||
@@ -38,9 +38,9 @@ The base protocol specification.
|
||||
| §8.1.1 | Optional `CollisionDetectEstablishedState` (`collision_detect_established_state`) | ✅ |
|
||||
| §6.2 | Bad BGP Identifier check (neighbor uses the local identifier, or 0.0.0.0) | ✅ |
|
||||
| §9 | Decision process / best-path selection | ✅ |
|
||||
| §9.1.2 | Best routes installed for forwarding (Linux kernel, over netlink), where the next hop is directly connected | ✅ |
|
||||
| §9.1.2 | Best routes installed for forwarding (Linux kernel, over netlink) | ✅ |
|
||||
| §9.1.2.1 | Routes with an unresolvable next hop excluded from selection | ✅ where routes are installed in the kernel: such a route is held out of the table until its next hop can be reached |
|
||||
| §9.1.2.1 | Next hops resolved through other routes | ❌ (only next hops that are neighbors count as reachable) |
|
||||
| §9.1.2.1 | Next hops resolved through other routes | ✅ (through the kernel's connected, static and IGP routes; not through BGP routes) |
|
||||
| §9 | Adj-RIB-In (per-peer table of received routes) | ✅ |
|
||||
| §9 | Locally originated routes, advertised to all established peers | ✅ |
|
||||
| §9 | Advertisement of the best path for each prefix to neighbors (`export.learned_routes`), with the local AS prepended and the local address as next hop; MED and LOCAL_PREF not sent to EBGP neighbors; LOCAL_PREF sent to IBGP neighbors (§5.1.4, §5.1.5, §9.1.3) | ✅ |
|
||||
@@ -116,7 +116,7 @@ The base protocol specification.
|
||||
| ORIGINATOR_ID and CLUSTER_LIST added on reflection, not sent to EBGP neighbors | ✅ |
|
||||
| Routes with the local router ID as ORIGINATOR_ID, or the local cluster ID in CLUSTER_LIST, rejected | ✅ |
|
||||
| Configurable cluster ID | ✅ |
|
||||
| Next hop unchanged on reflection | ❌ The route reflector is the next hop: next hops that are not directly connected are not resolved |
|
||||
| Next hop unchanged on reflection | ❌ The route reflector sets itself as the next hop |
|
||||
| ORIGINATOR_ID and CLUSTER_LIST length as best path tie breakers (§9) | ❌ |
|
||||
|
||||
### RFC 8538 — Notification Message Support for BGP Graceful Restart
|
||||
@@ -203,8 +203,11 @@ The base protocol specification.
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| `BLACKHOLE` accepted as a community name in configuration | ✅ |
|
||||
| Routes with BLACKHOLE from EBGP neighbors rejected on import (`blackhole request`) | ✅ |
|
||||
| Blackhole routing: discarding traffic for prefixes with BLACKHOLE | ❌ |
|
||||
| Routes with BLACKHOLE from EBGP neighbors rejected on import by default (`blackhole request`) | ✅ |
|
||||
| Blackhole routes for single addresses accepted with `accept_blackholes` (§3.2: prefix length limits relaxed, next hop not checked) | ✅ |
|
||||
| Blackhole routing: a kernel blackhole route discards traffic for the prefix | ✅ |
|
||||
| Blackhole routes advertised with the community, for providers to honor (§3.3) | ✅ |
|
||||
| A blackhole route for a prefix this router originates | ❌ (add a kernel blackhole route by hand) |
|
||||
|
||||
## Other Capabilities (Wire Format Only)
|
||||
|
||||
@@ -303,7 +306,7 @@ Version 2 is implemented from version 23 of the draft.
|
||||
|---------|--------|
|
||||
| Capability negotiation per family and direction | ✅ |
|
||||
| Receiving several routes for a prefix | ✅ |
|
||||
| Sending several routes for a prefix | ❌ (path identifiers are sent; one route each) |
|
||||
| Sending several routes for a prefix | ✅ with `all_paths` in the export policy; the best path only otherwise |
|
||||
|
||||
### RFC 4271 §9.1.2 / RFC 4486 / operational practice — import policy
|
||||
|
||||
|
||||
+60
-3
@@ -29,7 +29,7 @@ cd "$(dirname "$0")/../.."
|
||||
bgp="${CARGO_TARGET_DIR:-target}/release/bgp"
|
||||
work=/tmp/ferrix-lab
|
||||
pathspace=ferrixlab
|
||||
all_phases="basics roles rpki refresh flaps restart graceful scale limits hijacks nexthops transit leaks v6only linklocal"
|
||||
all_phases="basics roles rpki refresh flaps restart graceful scale limits hijacks nexthops transit collector leaks v6only linklocal"
|
||||
phases="${PHASES:-$all_phases}"
|
||||
scale_routes="${SCALE_ROUTES:-200000}"
|
||||
|
||||
@@ -319,9 +319,9 @@ frr_down() {
|
||||
|
||||
# --- BIRD -----------------------------------------------------------------------
|
||||
|
||||
# bird_config [extra-static-routes-file]
|
||||
# bird_config [extra-static-routes-file] [more-configuration]
|
||||
bird_config() {
|
||||
local extra=${1:-}
|
||||
local extra=${1:-} more=${2:-}
|
||||
cat > "$work/bird.conf" <<END
|
||||
log "$work/bird.log" all;
|
||||
router id 10.9.0.3;
|
||||
@@ -355,6 +355,7 @@ protocol bgp dut6 from dut {
|
||||
source address 2001:db8:9::3;
|
||||
ipv6 { import all; export where source = RTS_STATIC; };
|
||||
}
|
||||
$more
|
||||
END
|
||||
}
|
||||
|
||||
@@ -488,6 +489,13 @@ phase_basics() {
|
||||
in_ns dut ping -q -c 2 -W 2 -I 2001:db8:1::1 2001:db8:2::1 >/dev/null || fail "no IPv6 ping to FRR's lan"
|
||||
in_ns dut ping -q -c 2 -W 2 -I 2001:db8:1::1 2001:db8:3::1 >/dev/null || fail "no IPv6 ping to BIRD's lan"
|
||||
ok "packets get through to both, over IPv4 and IPv6"
|
||||
|
||||
# The kernel's view of a session's connection, as the page asks for it.
|
||||
frr4_addr=$(api /api/peers | jq -r '.[] | select(.name == "frr4") | .peer_addr')
|
||||
api "/api/tcp?peer=$frr4_addr" | jq -e '.available and .connection.state == "established" and .connection.mss > 0' >/dev/null \
|
||||
|| fail "no TCP details for frr4: $(api "/api/tcp?peer=$frr4_addr")"
|
||||
api /metricz | grep -q "^bgp_peer_tcp_rtt_seconds{peer=\"frr4\"}" || fail "no TCP metrics for frr4"
|
||||
ok "the kernel tells of a session's TCP connection"
|
||||
}
|
||||
|
||||
# RFC 9234: each end says what it is to the other, and routes are marked
|
||||
@@ -959,6 +967,55 @@ control_communities: true'
|
||||
ok "back to normal"
|
||||
}
|
||||
|
||||
# FRR is a route collector of ours, told every route with its path
|
||||
# identifier (ADD-PATH). BIRD talks to us from a second address too, so
|
||||
# that there are two routes for each of its prefixes; FRR is told both,
|
||||
# and holds them apart. One of BIRD's sessions goes, and so does one of
|
||||
# the two.
|
||||
phase_collector() {
|
||||
dut_down TERM
|
||||
in_ns bird ip addr add 10.9.0.4/24 dev ix0
|
||||
dut_config CUSTOMER 2000000 "" 'add_path: ADD_PATH_SEND
|
||||
export { learned_routes: true all_paths: true }' "" 'peers {
|
||||
name: "bird4b" endpoint { address: "10.9.0.4" } remote_asn: 4200000003
|
||||
address_families: IPV4_UNICAST
|
||||
role: PROVIDER
|
||||
import { accept_bogons: true max_prefixes: 2000000 }
|
||||
}'
|
||||
bird_config "" 'protocol bgp dut4b from dut {
|
||||
neighbor 10.9.0.1 as 64601;
|
||||
source address 10.9.0.4;
|
||||
ipv4 { import all; export where source = RTS_STATIC; };
|
||||
}'
|
||||
bird_ctl configure >/dev/null
|
||||
frr_configure "router bgp 64602" "neighbor 10.9.0.1 local-role customer"
|
||||
dut_up
|
||||
wait_for "five sessions" established 5
|
||||
wait_for "BIRD's route from both its sessions" bash -c "sudo ip netns exec lab-dut curl -sf 'http://127.0.0.1:8080/api/routes/received?prefix=10.3.0.0/24' | jq -e '.routes | length == 2'"
|
||||
peer_is frr4 '.add_path_send | index("IPv4 unicast") != null' >/dev/null \
|
||||
|| fail "ADD-PATH was not agreed with FRR: $(peer_is frr4 .add_path_send)"
|
||||
wait_for "both routes at FRR, told apart" bash -c "sudo ip netns exec lab-frr vtysh -N $pathspace -c 'show bgp ipv4 unicast 10.3.0.0/24 json' 2>/dev/null | jq -e '.paths | length == 2 and all(.aspath.string == \"64601 4200000003\")'"
|
||||
# Its two prefixes twice, and our own route: what the page and the
|
||||
# metrics say it holds from us.
|
||||
wait_for "the count of routes the collector holds" peer_is frr4 '.advertised_routes == 5'
|
||||
ok "the collector is told both of BIRD's routes for a prefix, with path identifiers"
|
||||
|
||||
bird_ctl disable dut4b >/dev/null
|
||||
wait_for "one route left at FRR" bash -c "sudo ip netns exec lab-frr vtysh -N $pathspace -c 'show bgp ipv4 unicast 10.3.0.0/24 json' 2>/dev/null | jq -e '.paths | length == 1'"
|
||||
wait_for "the count of routes the collector holds" peer_is frr4 '.advertised_routes == 3'
|
||||
ok "a route that goes is withdrawn from the collector by its identifier, the other stays"
|
||||
|
||||
frr_configure "router bgp 64602" "neighbor 10.9.0.1 local-role peer"
|
||||
bird_config
|
||||
bird_ctl configure >/dev/null
|
||||
in_ns bird ip addr del 10.9.0.4/24 dev ix0
|
||||
dut_down TERM
|
||||
dut_config
|
||||
dut_up
|
||||
converged
|
||||
ok "back to normal"
|
||||
}
|
||||
|
||||
# A customer leaks, and the export limits hold. FRR, now our customer,
|
||||
# announces a route outside its address space and then more routes than
|
||||
# BIRD, our provider, is to be told of. A customer's routes go to
|
||||
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/bin/bash
|
||||
# Profiles the daemon as it takes in a full Internet routing table, on a
|
||||
# Linux machine: a flame graph and a flat profile of where the CPU time
|
||||
# goes, and how long the table takes to arrive and to reach the kernel.
|
||||
#
|
||||
# scripts/profile/full-table.sh one peer sends the table
|
||||
# PEERS=4 scripts/profile/full-table.sh four peers send it at once
|
||||
# KERNEL=0 scripts/profile/full-table.sh the routes stay in the table
|
||||
# SCALE_DOWN=10 scripts/profile/full-table.sh a tenth of the table
|
||||
# COLLECTOR=1 scripts/profile/full-table.sh a route collector is sent every path
|
||||
# HEAP=1 scripts/profile/full-table.sh where the memory goes, not the time
|
||||
#
|
||||
# From a machine that is not Linux: scripts/linux-vm.sh scripts/profile/full-table.sh
|
||||
#
|
||||
# The daemon runs in a network namespace of its own, so that nothing it
|
||||
# installs touches the machine's own routing, joined by a veth pair to a
|
||||
# second namespace where the load generator (crates/bench/examples/feed.rs)
|
||||
# plays the peers: neighbours on the link, as real peers would be. `perf`
|
||||
# samples the daemon's threads meanwhile. Needs perf, inferno (cargo
|
||||
# install inferno), curl and jq, and sudo for the namespaces and the
|
||||
# kernel's routing table.
|
||||
#
|
||||
# Results go to $OUT (default ~/profile): flame.svg, top-self.txt (functions
|
||||
# by their own time), top-children.txt (by the time of everything they
|
||||
# call), perf.data, and the daemon's and the load generator's logs.
|
||||
set -eu
|
||||
|
||||
peers=${PEERS:-1}
|
||||
scale=${SCALE_DOWN:-1}
|
||||
kernel=${KERNEL:-1}
|
||||
collector=${COLLECTOR:-0}
|
||||
heap=${HEAP:-0}
|
||||
hold=${HOLD_SECS:-30}
|
||||
level=${LOG_LEVEL:-warn}
|
||||
freq=${FREQ:-1999}
|
||||
out=${OUT:-$HOME/profile}
|
||||
ns=ferrix-profile
|
||||
ns_peer=ferrix-profile-peer
|
||||
|
||||
repository="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
target="${CARGO_TARGET_DIR:-$repository/target}"
|
||||
daemon_bin="$target/profiling/bgp"
|
||||
feed_bin="$target/profiling/examples/feed"
|
||||
|
||||
fail() { echo "error: $*" >&2; exit 1; }
|
||||
for tool in perf inferno-collapse-perf inferno-flamegraph curl jq; do
|
||||
command -v "$tool" >/dev/null || fail "$tool not found"
|
||||
done
|
||||
|
||||
# Release code with line tables, and frame pointers for perf to walk the
|
||||
# stacks by (cheaper and more complete than DWARF unwinding here; the
|
||||
# allocator is built with them too).
|
||||
cd "$repository"
|
||||
export RUSTFLAGS="-C force-frame-pointers=yes" CFLAGS="-fno-omit-frame-pointer"
|
||||
# With HEAP=1 the daemon is built with jemalloc's heap profiler and told
|
||||
# to sample allocations and to write a profile at exit; jeprof turns the
|
||||
# profile into collapsed stacks for a flame graph of where the memory is.
|
||||
features=()
|
||||
[ "$heap" = 1 ] && features=(--features heap-profiling)
|
||||
cargo build --profile profiling --locked -p bgp --bin bgp "${features[@]}"
|
||||
cargo build --profile profiling --locked -p bgp-bench --example feed
|
||||
|
||||
mkdir -p "$out"
|
||||
run() { sudo ip netns exec "$ns" "$@"; }
|
||||
peer() { sudo ip netns exec "$ns_peer" "$@"; }
|
||||
cleanup() {
|
||||
sudo pkill -x bgp 2>/dev/null || true
|
||||
sudo pkill -x feed 2>/dev/null || true
|
||||
sudo ip netns del "$ns" 2>/dev/null || true
|
||||
sudo ip netns del "$ns_peer" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
sudo ip netns add "$ns"
|
||||
sudo ip netns add "$ns_peer"
|
||||
sudo ip link add bench0 netns "$ns" type veth peer name peer0 netns "$ns_peer"
|
||||
run ip link set lo up
|
||||
run ip addr add 192.0.2.254/24 dev bench0
|
||||
run ip -6 addr add 2001:db8::ffff/64 dev bench0 nodad
|
||||
run ip link set bench0 up
|
||||
peer ip link set lo up
|
||||
for n in $(seq "$((peers + collector))"); do
|
||||
peer ip addr add "192.0.2.$n/24" dev peer0
|
||||
peer ip -6 addr add "2001:db8::$n/64" dev peer0 nodad
|
||||
done
|
||||
peer ip link set peer0 up
|
||||
peer ping -c 1 -W 2 192.0.2.254 > /dev/null || fail "the namespaces do not reach each other"
|
||||
|
||||
# An AS number that is in none of the synthetic paths: a route with the
|
||||
# daemon's own AS in its path is refused as a loop.
|
||||
prefixes=$(( 1000000 / scale + 230000 / scale ))
|
||||
routes=$(( prefixes * peers ))
|
||||
cat > "$out/daemon.textpb" <<CONFIG
|
||||
router_id: "192.0.2.254"
|
||||
local_asn: 4200000000
|
||||
listen_endpoints { address: "192.0.2.254" port: 1790 }
|
||||
listen_endpoints { address: "2001:db8::ffff" port: 1790 }
|
||||
http_endpoint { address: "127.0.0.1" port: 8080 }
|
||||
CONFIG
|
||||
# The peers are configured as a router's would be, with how many routes
|
||||
# to expect of each. They have private AS numbers and the synthetic table
|
||||
# has a few bogon prefixes, so bogons are taken. With a collector, every
|
||||
# peer has a role, since the collector is told what they send.
|
||||
for n in $(seq "$peers"); do
|
||||
cat >> "$out/daemon.textpb" <<CONFIG
|
||||
peers {
|
||||
name: "peer$n"
|
||||
endpoint { address: "192.0.2.$n" port: 179 }
|
||||
remote_asn: $(( 64512 + n ))
|
||||
expected_routes: $prefixes
|
||||
role: PEER
|
||||
import { accept_bogons: true }
|
||||
}
|
||||
CONFIG
|
||||
done
|
||||
if [ "$collector" = 1 ]; then
|
||||
cat >> "$out/daemon.textpb" <<CONFIG
|
||||
peers {
|
||||
name: "collector"
|
||||
endpoint { address: "2001:db8::$((peers + 1))" port: 179 }
|
||||
remote_asn: 212232
|
||||
role: CUSTOMER
|
||||
add_path: ADD_PATH_SEND
|
||||
import { max_prefixes: 1 }
|
||||
export { learned_routes: true all_paths: true }
|
||||
}
|
||||
CONFIG
|
||||
fi
|
||||
if [ "$kernel" = 1 ]; then
|
||||
echo 'kernel { stale_seconds: 0 }' >> "$out/daemon.textpb"
|
||||
fi
|
||||
|
||||
# `JEMALLOC_CONF`, if set, is jemalloc's MALLOC_CONF for the daemon:
|
||||
# "thp:always", say. The symbols are prefixed, hence the variable's name.
|
||||
malloc_conf=${JEMALLOC_CONF:-}
|
||||
if [ "$heap" = 1 ]; then
|
||||
# One sample every 512 KiB allocated, and a profile every 1 GiB
|
||||
# allocated: the last of them is the nearest to the table being in.
|
||||
rm -f "$out"/heap.*.heap
|
||||
malloc_conf="prof:true,prof_active:true,lg_prof_sample:19,lg_prof_interval:30,prof_prefix:$out/heap${malloc_conf:+,$malloc_conf}"
|
||||
fi
|
||||
run env _RJEM_MALLOC_CONF="$malloc_conf" "$daemon_bin" "$out/daemon.textpb" --level "$level" > "$out/daemon.log" 2>&1 &
|
||||
for _ in $(seq 100); do
|
||||
run curl -sf http://127.0.0.1:8080/api/status > /dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
daemon_pid=$(pgrep -nx bgp) || fail "the daemon did not start; see $out/daemon.log"
|
||||
status() { run curl -sf "http://127.0.0.1:8080/api/$1" | jq -r "$2"; }
|
||||
now() { date +%s.%N; }
|
||||
since() { echo "$(now) - $1" | bc; }
|
||||
|
||||
sudo perf record -F "$freq" -g -o "$out/perf.data" -p "$daemon_pid" > "$out/perf.log" 2>&1 &
|
||||
perf_pid=$!
|
||||
sleep 1
|
||||
|
||||
peer env FEED_TO=192.0.2.254:1790 FEED_PEERS="$peers" FEED_SCALE_DOWN="$scale" \
|
||||
FEED_FROM=192.0.2 FEED_HOLD_SECS="$hold" FEED_COLLECTOR="$collector" \
|
||||
FEED_COLLECTOR_TO="[2001:db8::ffff]:1790" FEED_COLLECTOR_FROM="2001:db8::$((peers + 1))" "$feed_bin" > "$out/feed.log" 2>&1 &
|
||||
feed_pid=$!
|
||||
feeding() { kill -0 "$feed_pid" 2>/dev/null || fail "the load generator stopped; see $out/feed.log"; }
|
||||
|
||||
# The clock starts at the first route in, not when the load generator
|
||||
# starts: making the table takes it a while.
|
||||
while [ "$(status status .received_routes)" = 0 ]; do feeding; sleep 0.02; done
|
||||
start=$(now)
|
||||
# Routes the policy refuses (the synthetic table has a few bogons in it)
|
||||
# count as dealt with.
|
||||
dealt_with() {
|
||||
status status .received_routes
|
||||
status peers '[.[] | .rejected_routes // 0] | add // 0'
|
||||
}
|
||||
while [ "$(dealt_with | paste -sd+ | bc)" -lt "$routes" ]; do feeding; sleep 0.05; done
|
||||
arrived=$(since "$start")
|
||||
received=$(status status .received_routes)
|
||||
refused=$(status peers '[.[] | .rejected_routes // 0] | add // 0')
|
||||
echo "$routes routes dealt with after $arrived s ($(echo "$routes / $arrived / 1000000" | bc -l | cut -c1-4) M routes/s): $received taken, $refused refused"
|
||||
[ "$refused" = 0 ] || run curl -sf http://127.0.0.1:8080/api/rejections | jq -c '.' | cut -c1-400
|
||||
if [ "$kernel" = 1 ]; then
|
||||
prefixes=$(status status .received_prefixes)
|
||||
while [ "$(status kernel '.synced and .installed >= '"$prefixes")" != true ]; do feeding; sleep 0.05; done
|
||||
installed=$(since "$start")
|
||||
echo "$prefixes routes in the kernel after $installed s ($(echo "$prefixes / $installed / 1000" | bc -l | cut -c1-3) k routes/s)"
|
||||
fi
|
||||
if [ "$collector" = 1 ]; then
|
||||
# Nearly every taken route goes to the collector: not one with its AS
|
||||
# on the path or a well-known community that says not to. The count
|
||||
# has settled when it has not moved for a few seconds.
|
||||
advertised() { status peers '[.[] | select(.name == "collector") | .advertised_routes] | add // 0'; }
|
||||
last=-1
|
||||
while :; do
|
||||
feeding; sleep 3
|
||||
now_held=$(advertised)
|
||||
echo " collector holds $now_held of $received after $(since "$start") s"
|
||||
[ "$now_held" -gt 0 ] && [ "$now_held" = "$last" ] && break
|
||||
last=$now_held
|
||||
done
|
||||
echo "collector: $(advertised) routes advertised after $(since "$start") s"
|
||||
collector_addr=$(status peers '.[] | select(.name == "collector") | .peer_addr')
|
||||
run curl -sf "http://127.0.0.1:8080/api/tcp?peer=$collector_addr" \
|
||||
| jq -r '.connection | "collector tcp: busy \(.busy_ms / 1000 | . * 10 | round / 10) s, rwnd-limited \(.limited_by_receive_window_share * 100 | round)%, rtt \(.rtt_ms) ms, cwnd \(.cwnd_segments), sent \(.bytes_sent / 1048576 | round) MiB in \(.segments_out) segments"'
|
||||
fi
|
||||
memory_report() {
|
||||
rss=$(awk '/VmRSS/ { printf "%.0f", $2 / 1024 }' "/proc/$daemon_pid/status")
|
||||
echo "daemon: $rss MiB resident $1"
|
||||
status status '.memory | to_entries | map("\(.key) \(.value / 1048576 | floor) MiB") | join(", ")' | sed 's/^/jemalloc: /'
|
||||
}
|
||||
memory_report "with the table in"
|
||||
# And once the allocator has had its decay time to give back what the
|
||||
# load allocated and freed: the figure a router shows for the long run.
|
||||
sleep 30
|
||||
memory_report "30 s later"
|
||||
run curl -sf http://127.0.0.1:8080/metricz | grep '^bgp_kernel_route_changes_total' | sed 's/^/kernel: /'
|
||||
sleep 1
|
||||
sudo kill -INT "$perf_pid"
|
||||
wait "$perf_pid" || true
|
||||
cat "$out/feed.log"
|
||||
|
||||
sudo chown "$(id -u)" "$out/perf.data"
|
||||
if [ "$heap" = 1 ]; then
|
||||
sudo chown "$(id -u)" "$out"/heap.*.heap 2>/dev/null || true
|
||||
# jeprof comes with jemalloc's sources as a template; the prefix the
|
||||
# allocator's symbols carry is filled in.
|
||||
template=$(find "$HOME/.cargo/registry/src" -path "*tikv-jemalloc-sys*" -name jeprof.in | head -1)
|
||||
[ -n "$template" ] || fail "jeprof.in not found in the cargo registry"
|
||||
sed -e 's/@JEMALLOC_PREFIX@/_rjem_/g' -e 's/@jemalloc_version@/5.3/' "$template" > "$out/jeprof"
|
||||
# The latest profile is the one nearest to the table being in.
|
||||
profile=$(ls -t "$out"/heap.*.heap | head -1)
|
||||
perl "$out/jeprof" --collapsed "$daemon_bin" "$profile" > "$out/heap.folded" 2> "$out/jeprof.log" \
|
||||
|| fail "jeprof failed; see $out/jeprof.log"
|
||||
inferno-flamegraph --title "ferrix heap: $peers peer(s), $prefixes prefixes each$([ "$collector" = 1 ] && echo ", a collector")" --countname bytes --colors mem --width 1600 "$out/heap.folded" > "$out/heap.svg"
|
||||
perl "$out/jeprof" --text "$daemon_bin" "$profile" 2>/dev/null | head -40 > "$out/heap-top.txt"
|
||||
echo "heap profile $profile: heap.svg heap-top.txt"
|
||||
fi
|
||||
# Without the period field, so that each sample counts one whatever event
|
||||
# perf sampled by (cpu-clock on a machine without performance counters).
|
||||
perf script -i "$out/perf.data" -F comm,pid,tid,time,event,ip,sym,dso 2> /dev/null \
|
||||
| inferno-collapse-perf > "$out/stacks.folded"
|
||||
title="ferrix: $peers peer(s) send $prefixes prefixes each"
|
||||
[ "$kernel" = 1 ] && title="$title, into the kernel"
|
||||
inferno-flamegraph --title "$title" --countname samples --width 1600 "$out/stacks.folded" > "$out/flame.svg"
|
||||
perf report -i "$out/perf.data" --no-children --stdio --sort symbol --percent-limit 0.5 -g none 2> /dev/null \
|
||||
| grep -v '^#' | grep -v '^$' | sed 's/ *$//' | head -80 > "$out/top-self.txt"
|
||||
perf report -i "$out/perf.data" --children --stdio --sort symbol --percent-limit 1 -g none 2> /dev/null \
|
||||
| grep -v '^#' | grep -v '^$' | sed 's/ *$//' | head -80 > "$out/top-children.txt"
|
||||
echo "samples: $(awk '{ s += $NF } END { print s }' "$out/stacks.folded")"
|
||||
echo "results in $out: flame.svg top-self.txt top-children.txt"
|
||||
Reference in new issue
Block a user