Install best routes in the Linux kernel over netlink
New crate bgp-fib. It follows the routing table's best routes and keeps
the kernel's routing table the same, and learns the machine's interfaces
and addresses from the kernel, which is what says whether a next hop can
be reached.
- netlink: the rtnetlink messages used (routes, addresses, links, errors),
as bytes and back. No I/O; property tested and fuzzed.
- interfaces: what is directly reachable, and from which interface.
- plan: what to ask the kernel for, given the best routes and what the
kernel has. No I/O; tested against a model kernel.
- kernel (Linux only): the sockets and the task. Requests go in batches;
routes of ours found at startup are kept if still right and removed if
not; a change of interfaces has every route worked out again; a clean
stop removes our routes.
Only routes marked proto bgp with the configured table and metric are
ever touched. A best route is installed if its next hop is a neighbor;
next hops further away are not resolved through other routes yet, and
such prefixes are counted and shown instead.
Configured with `kernel { table metric }`; off by default. The daemon
needs CAP_NET_ADMIN and stops if the kernel refuses it. On shutdown it
closes sessions and then removes its routes.
/api/kernel and the status page show installed, unresolved and refused
counts, the last error and the interfaces; five bgp_kernel_* metrics.
Testing: scripts/kernel-tests.sh runs, in network namespaces of their
own and with only the capabilities the daemon needs, the installer
against `ip route`, the daemon with a peer, and two daemons that route
for each other with pings across over IPv4 and IPv6.
scripts/linux-vm.sh runs that, or anything else, in a Lima VM from
macOS. CI runs the kernel tests and fuzzes the netlink decoder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f84235e87
commit
1fc44798b7
38 files changed
+3745
-16
No files matched your search
@@ -26,6 +26,10 @@ jobs:
|
|||||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --workspace
|
run: cargo test --workspace
|
||||||
|
# The tests that change a kernel's routes, in a network namespace of
|
||||||
|
# their own; and two daemons that route for each other.
|
||||||
|
- name: Kernel tests
|
||||||
|
run: scripts/kernel-tests.sh
|
||||||
|
|
||||||
# A short run of each fuzz target: enough to notice a parser change that
|
# A short run of each fuzz target: enough to notice a parser change that
|
||||||
# breaks on input the seeds are close to. Long runs are done by hand; see
|
# breaks on input the seeds are close to. Long runs are done by hand; see
|
||||||
@@ -44,7 +48,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RUSTFLAGS: ""
|
RUSTFLAGS: ""
|
||||||
run: |
|
run: |
|
||||||
for target in message message_body prefix_text config rtr; do
|
for target in message message_body prefix_text config rtr netlink; do
|
||||||
mkdir -p "corpus/$target"
|
mkdir -p "corpus/$target"
|
||||||
cargo fuzz run "$target" "corpus/$target" "seeds/$target" -- -max_total_time=60
|
cargo fuzz run "$target" "corpus/$target" "seeds/$target" -- -max_total_time=60
|
||||||
done
|
done
|
||||||
Generated
+32
@@ -185,10 +185,27 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bgp-fib"
|
||||||
|
version = "0.0.2"
|
||||||
|
dependencies = [
|
||||||
|
"bgp-packet",
|
||||||
|
"bgp-primitives",
|
||||||
|
"bgp-rib",
|
||||||
|
"bytes",
|
||||||
|
"netlink-sys",
|
||||||
|
"prometheus",
|
||||||
|
"proptest",
|
||||||
|
"thiserror 2.0.12",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bgp-integration-tests"
|
name = "bgp-integration-tests"
|
||||||
version = "0.0.2"
|
version = "0.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bgp-fib",
|
||||||
"bgp-packet",
|
"bgp-packet",
|
||||||
"bgp-primitives",
|
"bgp-primitives",
|
||||||
"bgp-rib",
|
"bgp-rib",
|
||||||
@@ -266,6 +283,7 @@ dependencies = [
|
|||||||
name = "bgp-server"
|
name = "bgp-server"
|
||||||
version = "0.0.2"
|
version = "0.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bgp-fib",
|
||||||
"bgp-packet",
|
"bgp-packet",
|
||||||
"bgp-primitives",
|
"bgp-primitives",
|
||||||
"bgp-rib",
|
"bgp-rib",
|
||||||
@@ -289,6 +307,7 @@ name = "bgp-web"
|
|||||||
version = "0.0.2"
|
version = "0.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"bgp-fib",
|
||||||
"bgp-packet",
|
"bgp-packet",
|
||||||
"bgp-primitives",
|
"bgp-primitives",
|
||||||
"bgp-rib",
|
"bgp-rib",
|
||||||
@@ -961,6 +980,19 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "netlink-sys"
|
||||||
|
version = "0.8.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "8.0.0"
|
version = "8.0.0"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ unimplemented = "warn"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# --- Our crates ---
|
# --- Our crates ---
|
||||||
|
bgp-fib = { path = "crates/fib" }
|
||||||
bgp-packet = { path = "crates/packet" }
|
bgp-packet = { path = "crates/packet" }
|
||||||
bgp-primitives = { path = "crates/primitives" }
|
bgp-primitives = { path = "crates/primitives" }
|
||||||
bgp-rib = { path = "crates/rib" }
|
bgp-rib = { path = "crates/rib" }
|
||||||
|
|||||||
+67
-3
@@ -24,6 +24,7 @@ When `http_endpoint` is set the binary also serves:
|
|||||||
| `GET /api/routes/received` | Routes learned from peers; see "Finding a route" |
|
| `GET /api/routes/received` | Routes learned from peers; see "Finding a route" |
|
||||||
| `GET /api/routes/rejected` | Routes peers announced that were refused, by peer |
|
| `GET /api/routes/rejected` | Routes peers announced that were refused, by peer |
|
||||||
| `GET /api/rpki` | Validation with the RPKI: the cache, the data and its age |
|
| `GET /api/rpki` | Validation with the RPKI: the cache, the data and its age |
|
||||||
|
| `GET /api/kernel` | The routes in the kernel: how many, and why the others are not; the machine's interfaces |
|
||||||
| `GET /api/events` | Server-sent events for session and route changes |
|
| `GET /api/events` | Server-sent events for session and route changes |
|
||||||
| `GET /metricz` | Prometheus metrics |
|
| `GET /metricz` | Prometheus metrics |
|
||||||
|
|
||||||
@@ -34,9 +35,11 @@ put it behind a reverse proxy.
|
|||||||
|
|
||||||
It establishes sessions (both dialling configured peers and accepting
|
It establishes sessions (both dialling configured peers and accepting
|
||||||
connections, with RFC 4271 collision detection), announces its originated
|
connections, with RFC 4271 collision detection), announces its originated
|
||||||
routes to every peer with itself as next hop, and records what peers send in
|
routes to every peer with itself as next hop, judges what peers send by each
|
||||||
an Adj-RIB-In. It does not select best paths, re-advertise received routes or
|
peer's import policy, keeps what passes, chooses a best route for each
|
||||||
program a forwarding table, and it has no import or export policy.
|
prefix, and on Linux can put those best routes in the kernel so that the
|
||||||
|
machine forwards by them. It does not yet pass routes learned from one peer
|
||||||
|
on to another, and has no export policy.
|
||||||
|
|
||||||
## Who may connect
|
## Who may connect
|
||||||
|
|
||||||
@@ -165,6 +168,67 @@ peers {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Forwarding: routes in the kernel (Linux)
|
||||||
|
|
||||||
|
With `kernel {}` in the configuration the daemon puts the best route of every
|
||||||
|
prefix in the kernel's routing table, over netlink, and keeps it there as the
|
||||||
|
best routes change. Without it the daemon only speaks BGP and the machine's
|
||||||
|
forwarding is not touched.
|
||||||
|
|
||||||
|
```
|
||||||
|
kernel {
|
||||||
|
table: 254 # the main table; the default
|
||||||
|
metric: 20 # the default
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **What is installed.** A best route whose next hop is a neighbor: an
|
||||||
|
address in the network of one of the machine's interfaces that is up. For
|
||||||
|
an IPv6 route with a link-local next hop, the interface is the one the
|
||||||
|
peer is a neighbor on. A route whose next hop is further away (a route
|
||||||
|
from a peer in our own AS that did not set itself as next hop, say) is not
|
||||||
|
installed, and neither is whatever it replaced: looking up the way to such
|
||||||
|
a next hop through other routes is not done yet. The status page counts
|
||||||
|
these, by reason.
|
||||||
|
- **What is ours.** Routes are marked `proto bgp`. Every route with that
|
||||||
|
mark in the configured table with the configured metric is taken to be
|
||||||
|
this daemon's, and no other route is ever changed or removed. So do not
|
||||||
|
run two daemons that install BGP routes into the same table with the same
|
||||||
|
metric.
|
||||||
|
- **Starting and stopping.** At startup, routes of ours left by an earlier
|
||||||
|
run are kept if they are still right and removed if not. On SIGINT or
|
||||||
|
SIGTERM the daemon closes its sessions and then removes its routes. After
|
||||||
|
a crash they stay until the next start.
|
||||||
|
- **Interfaces.** The daemon learns the machine's interfaces and addresses
|
||||||
|
from the kernel and is told when they change; a second later it works all
|
||||||
|
routes out again, since any next hop may resolve differently.
|
||||||
|
- **Privileges.** Changing routes needs the `CAP_NET_ADMIN` capability, and
|
||||||
|
listening on port 179 needs `CAP_NET_BIND_SERVICE`; neither needs root.
|
||||||
|
Without the first the daemon stops as soon as the kernel refuses it.
|
||||||
|
|
||||||
|
```
|
||||||
|
setcap cap_net_admin,cap_net_bind_service+ep /usr/local/bin/bgp
|
||||||
|
```
|
||||||
|
|
||||||
|
`ip route show proto bgp` shows what is there. `/api/kernel` and the status
|
||||||
|
page show how many routes are installed, how many are not and why, the last
|
||||||
|
refusal by the kernel, and the interfaces; `/metricz` has `bgp_kernel_routes`,
|
||||||
|
`bgp_kernel_routes_unresolved`, `bgp_kernel_routes_refused`,
|
||||||
|
`bgp_kernel_route_changes_total{change}` and `bgp_kernel_errors_total`.
|
||||||
|
|
||||||
|
To forward packets for others the machine also needs
|
||||||
|
`net.ipv4.ip_forward=1` and `net.ipv6.conf.all.forwarding=1`; the daemon does
|
||||||
|
not set them.
|
||||||
|
|
||||||
|
### Testing against a real kernel
|
||||||
|
|
||||||
|
The tests that change a kernel's routes are not run by `cargo test`.
|
||||||
|
`scripts/kernel-tests.sh` runs them on Linux, each in a network namespace
|
||||||
|
made for it: the route installer against `ip route`, the daemon with a peer,
|
||||||
|
and two daemons that route for each other with pings across. From macOS,
|
||||||
|
`scripts/linux-vm.sh` runs it (or any command, such as `cargo test`) in a
|
||||||
|
Lima virtual machine that it creates on first use.
|
||||||
|
|
||||||
## Finding a route
|
## Finding a route
|
||||||
|
|
||||||
The status page has a look-up box over the received routes, and
|
The status page has a look-up box over the received routes, and
|
||||||
|
|||||||
+6
-3
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use bgp_server::config::Config;
|
use bgp_server::config::Config;
|
||||||
use bgp_server::proto::config::BgpConfig;
|
use bgp_server::proto::config::BgpConfig;
|
||||||
use bgp_server::server::{BgpServer, close_sessions};
|
use bgp_server::server::{BgpServer, shut_down};
|
||||||
use bgp_server::tracing::TracingArgs;
|
use bgp_server::tracing::TracingArgs;
|
||||||
use bgp_web::{AppState, RouterInfo};
|
use bgp_web::{AppState, RouterInfo};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
@@ -52,6 +52,7 @@ async fn main() -> Result<()> {
|
|||||||
rib: Arc::clone(&server.rib),
|
rib: Arc::clone(&server.rib),
|
||||||
refusals: server.refusals.clone(),
|
refusals: server.refusals.clone(),
|
||||||
rpki: server.rpki.clone(),
|
rpki: server.rpki.clone(),
|
||||||
|
kernel: server.kernel.clone(),
|
||||||
};
|
};
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = bgp_web::serve_on(state, listener).await {
|
if let Err(e) = bgp_web::serve_on(state, listener).await {
|
||||||
@@ -63,12 +64,14 @@ async fn main() -> Result<()> {
|
|||||||
// Leave politely: tell every peer we are going (Cease / Administrative
|
// Leave politely: tell every peer we are going (Cease / Administrative
|
||||||
// Shutdown) rather than letting them find a dead TCP connection, which
|
// Shutdown) rather than letting them find a dead TCP connection, which
|
||||||
// makes them slower to take us back.
|
// makes them slower to take us back.
|
||||||
let registry = server.registry.clone();
|
// And take our routes out of the kernel: with us gone nothing would
|
||||||
|
// keep them true.
|
||||||
|
let (registry, kernel) = (server.registry.clone(), server.kernel.clone());
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
result = server.run() => Ok(result?),
|
result = server.run() => Ok(result?),
|
||||||
signal = shutdown_signal() => {
|
signal = shutdown_signal() => {
|
||||||
info!(signal, "Shutting down, closing BGP sessions");
|
info!(signal, "Shutting down, closing BGP sessions");
|
||||||
close_sessions(®istry).await;
|
shut_down(®istry, kernel.as_ref()).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "bgp-fib"
|
||||||
|
authors.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bgp-packet.workspace = true
|
||||||
|
bgp-primitives.workspace = true
|
||||||
|
bgp-rib.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
prometheus.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
# The socket to the kernel. Only Linux has it; everything else in this
|
||||||
|
# crate builds, and is tested, anywhere.
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
netlink-sys = { version = "0.8", features = ["tokio_socket"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
//! The machine's interfaces and their addresses, as the kernel reports
|
||||||
|
//! them, and what they say about a next hop: whether it can be reached
|
||||||
|
//! directly, and from which interface.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use bgp_primitives::constants::IpVersion;
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
|
||||||
|
use crate::netlink::{Address, Link, Message};
|
||||||
|
|
||||||
|
/// The interface a neighbor is reached on.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Connected {
|
||||||
|
/// The interface's index.
|
||||||
|
pub interface: u32,
|
||||||
|
/// Our own address in the network the neighbor is in.
|
||||||
|
pub local: IpAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The interfaces there are and the addresses they have.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Interfaces {
|
||||||
|
links: HashMap<u32, Link>,
|
||||||
|
addresses: HashSet<Address>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `address` as the prefix that is only it.
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Interfaces {
|
||||||
|
/// Takes in what a message from the kernel says about links and
|
||||||
|
/// addresses. Returns whether anything is different for it.
|
||||||
|
pub fn apply(&mut self, message: &Message) -> bool {
|
||||||
|
match message {
|
||||||
|
Message::Link {
|
||||||
|
present: true,
|
||||||
|
link,
|
||||||
|
} => self.links.insert(link.index, link.clone()).as_ref() != Some(link),
|
||||||
|
Message::Link {
|
||||||
|
present: false,
|
||||||
|
link,
|
||||||
|
} => {
|
||||||
|
self.addresses
|
||||||
|
.retain(|address| address.interface != link.index);
|
||||||
|
self.links.remove(&link.index).is_some()
|
||||||
|
}
|
||||||
|
Message::Address {
|
||||||
|
present: true,
|
||||||
|
address,
|
||||||
|
} => self.addresses.insert(*address),
|
||||||
|
Message::Address {
|
||||||
|
present: false,
|
||||||
|
address,
|
||||||
|
} => self.addresses.remove(address),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The interfaces, by index.
|
||||||
|
pub fn links(&self) -> impl Iterator<Item = &Link> {
|
||||||
|
self.links.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link(&self, index: u32) -> Option<&Link> {
|
||||||
|
self.links.get(&index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The addresses of the interface with this index.
|
||||||
|
pub fn addresses_of(&self, interface: u32) -> impl Iterator<Item = &Address> {
|
||||||
|
self.addresses
|
||||||
|
.iter()
|
||||||
|
.filter(move |address| address.interface == interface)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `address` is one of this machine's own.
|
||||||
|
pub fn is_local(&self, address: IpAddr) -> bool {
|
||||||
|
self.addresses.iter().any(|ours| ours.local == address)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `neighbor` can be reached without a gateway: the interface
|
||||||
|
/// that is up and has an address in a network `neighbor` is in, the
|
||||||
|
/// most specific such network if there are several. `None` if there is
|
||||||
|
/// none, or if `neighbor` is this machine itself.
|
||||||
|
pub fn connected(&self, neighbor: IpAddr) -> Option<Connected> {
|
||||||
|
if self.is_local(neighbor) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let neighbor = host(neighbor);
|
||||||
|
self.addresses
|
||||||
|
.iter()
|
||||||
|
.filter(|address| address.network.contains(&neighbor))
|
||||||
|
.filter(|address| {
|
||||||
|
self.links
|
||||||
|
.get(&address.interface)
|
||||||
|
.is_some_and(|link| link.up)
|
||||||
|
})
|
||||||
|
.max_by_key(|address| address.network.length())
|
||||||
|
.map(|address| Connected {
|
||||||
|
interface: address.interface,
|
||||||
|
local: address.local,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn link(index: u32, name: &str, up: bool) -> Message {
|
||||||
|
Message::Link {
|
||||||
|
present: true,
|
||||||
|
link: Link {
|
||||||
|
index,
|
||||||
|
name: name.into(),
|
||||||
|
up,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn address(interface: u32, local: &str, network: &str) -> Address {
|
||||||
|
Address {
|
||||||
|
interface,
|
||||||
|
local: local.parse().unwrap(),
|
||||||
|
network: IpPrefix::try_from(network).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn present(address: Address) -> Message {
|
||||||
|
Message::Address {
|
||||||
|
present: true,
|
||||||
|
address,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn some() -> Interfaces {
|
||||||
|
let mut interfaces = Interfaces::default();
|
||||||
|
for message in [
|
||||||
|
link(1, "lo", true),
|
||||||
|
link(2, "eth0", true),
|
||||||
|
link(3, "eth1", true),
|
||||||
|
link(4, "eth2", false),
|
||||||
|
present(address(1, "127.0.0.1", "127.0.0.0/8")),
|
||||||
|
present(address(2, "192.0.2.1", "192.0.2.0/24")),
|
||||||
|
present(address(2, "2001:db8::1", "2001:db8::/64")),
|
||||||
|
// A more specific network inside eth0's, on eth1.
|
||||||
|
present(address(3, "192.0.2.129", "192.0.2.128/25")),
|
||||||
|
present(address(4, "198.51.100.1", "198.51.100.0/24")),
|
||||||
|
] {
|
||||||
|
assert!(interfaces.apply(&message));
|
||||||
|
}
|
||||||
|
interfaces
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connected(interfaces: &Interfaces, neighbor: &str) -> Option<(u32, String)> {
|
||||||
|
interfaces
|
||||||
|
.connected(neighbor.parse().unwrap())
|
||||||
|
.map(|c| (c.interface, c.local.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_neighbor_is_reached_from_the_interface_whose_network_it_is_in() {
|
||||||
|
let interfaces = some();
|
||||||
|
assert_eq!(
|
||||||
|
connected(&interfaces, "192.0.2.7"),
|
||||||
|
Some((2, "192.0.2.1".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
connected(&interfaces, "2001:db8::7"),
|
||||||
|
Some((2, "2001:db8::1".into()))
|
||||||
|
);
|
||||||
|
// The most specific network wins.
|
||||||
|
assert_eq!(
|
||||||
|
connected(&interfaces, "192.0.2.200"),
|
||||||
|
Some((3, "192.0.2.129".into()))
|
||||||
|
);
|
||||||
|
// Not in any network of ours.
|
||||||
|
assert_eq!(connected(&interfaces, "203.0.113.1"), None);
|
||||||
|
assert_eq!(connected(&interfaces, "2001:db8:1::1"), None);
|
||||||
|
// In the network of an interface that is down.
|
||||||
|
assert_eq!(connected(&interfaces, "198.51.100.7"), None);
|
||||||
|
// Ourselves.
|
||||||
|
assert_eq!(connected(&interfaces, "192.0.2.1"), None);
|
||||||
|
assert!(interfaces.is_local("192.0.2.1".parse().unwrap()));
|
||||||
|
assert!(!interfaces.is_local("192.0.2.7".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_the_kernel_reports_changes_what_is_reachable() {
|
||||||
|
let mut interfaces = some();
|
||||||
|
// Told again what is known: nothing changes.
|
||||||
|
assert!(!interfaces.apply(&link(2, "eth0", true)));
|
||||||
|
assert!(!interfaces.apply(&present(address(2, "192.0.2.1", "192.0.2.0/24"))));
|
||||||
|
|
||||||
|
// An interface comes up.
|
||||||
|
assert!(interfaces.apply(&link(4, "eth2", true)));
|
||||||
|
assert_eq!(
|
||||||
|
connected(&interfaces, "198.51.100.7"),
|
||||||
|
Some((4, "198.51.100.1".into()))
|
||||||
|
);
|
||||||
|
// An address goes.
|
||||||
|
assert!(interfaces.apply(&Message::Address {
|
||||||
|
present: false,
|
||||||
|
address: address(3, "192.0.2.129", "192.0.2.128/25"),
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
connected(&interfaces, "192.0.2.200"),
|
||||||
|
Some((2, "192.0.2.1".into()))
|
||||||
|
);
|
||||||
|
// An interface goes, and its addresses with it.
|
||||||
|
assert!(interfaces.apply(&Message::Link {
|
||||||
|
present: false,
|
||||||
|
link: Link {
|
||||||
|
index: 2,
|
||||||
|
name: "eth0".into(),
|
||||||
|
up: true
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
assert_eq!(connected(&interfaces, "192.0.2.7"), None);
|
||||||
|
assert_eq!(interfaces.addresses_of(2).count(), 0);
|
||||||
|
// What is not about interfaces changes nothing.
|
||||||
|
assert!(!interfaces.apply(&Message::Done));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
//! Talking to the kernel: the sockets, and the task that keeps the
|
||||||
|
//! kernel's routes in step with the routing table's best routes.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::io;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bgp_packet::message::NlriNextHop;
|
||||||
|
use bgp_primitives::constants::IpVersion;
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
use bgp_rib::table::{Event, PeerIndex, Reader, RibTable};
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use netlink_sys::protocols::NETLINK_ROUTE;
|
||||||
|
use netlink_sys::{AsyncSocket, AsyncSocketExt, SocketAddr, TokioSocket};
|
||||||
|
use prometheus::{
|
||||||
|
IntCounter, IntCounterVec, IntGauge, register_int_counter, register_int_counter_vec,
|
||||||
|
register_int_gauge,
|
||||||
|
};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tokio::time::{Instant, sleep_until};
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::interfaces::Interfaces;
|
||||||
|
use crate::netlink::{
|
||||||
|
self, Change, Dump, KIND_UNICAST, Message, PROTOCOL_BGP, Route, decode, group,
|
||||||
|
};
|
||||||
|
use crate::plan::{Op, Plan, Target, resolve};
|
||||||
|
use crate::{Fib, FibConfig, FibError, InterfaceStatus};
|
||||||
|
|
||||||
|
/// How many best routes are taken from the routing table, and how many
|
||||||
|
/// requests are sent to the kernel, at a time.
|
||||||
|
const BATCH: usize = 256;
|
||||||
|
|
||||||
|
/// The most a read from the kernel can return: it sends up to 32 KiB at a
|
||||||
|
/// time in answer to a dump.
|
||||||
|
const READ_BUFFER: usize = 256 * 1024;
|
||||||
|
|
||||||
|
/// How long after the machine's interfaces or addresses change the routes
|
||||||
|
/// are worked out again. Such changes come in bursts.
|
||||||
|
const SETTLE: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// The errors of the kernel that have a meaning here.
|
||||||
|
mod errno {
|
||||||
|
pub const EPERM: i32 = 1;
|
||||||
|
pub const ENOENT: i32 = 2;
|
||||||
|
pub const ESRCH: i32 = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||||
|
register_int_gauge!(
|
||||||
|
"bgp_kernel_routes",
|
||||||
|
"Routes of ours in the kernel's routing table"
|
||||||
|
)
|
||||||
|
.expect("metric registration")
|
||||||
|
});
|
||||||
|
|
||||||
|
static UNRESOLVED: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||||
|
register_int_gauge!(
|
||||||
|
"bgp_kernel_routes_unresolved",
|
||||||
|
"Prefixes whose best route is not in the kernel because its next hop is not usable"
|
||||||
|
)
|
||||||
|
.expect("metric registration")
|
||||||
|
});
|
||||||
|
|
||||||
|
static REFUSED: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||||
|
register_int_gauge!(
|
||||||
|
"bgp_kernel_routes_refused",
|
||||||
|
"Prefixes whose best route the kernel refused to take"
|
||||||
|
)
|
||||||
|
.expect("metric registration")
|
||||||
|
});
|
||||||
|
|
||||||
|
static CHANGES: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||||
|
register_int_counter_vec!(
|
||||||
|
"bgp_kernel_route_changes_total",
|
||||||
|
"Changes to its routing table the kernel was asked for, by kind",
|
||||||
|
&["change"]
|
||||||
|
)
|
||||||
|
.expect("metric registration")
|
||||||
|
});
|
||||||
|
|
||||||
|
static ERRORS: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||||
|
register_int_counter!("bgp_kernel_errors_total", "Requests the kernel refused")
|
||||||
|
.expect("metric registration")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A routing socket.
|
||||||
|
struct Socket {
|
||||||
|
socket: TokioSocket,
|
||||||
|
sequence: u32,
|
||||||
|
buf: BytesMut,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Socket {
|
||||||
|
/// A socket that is sent the notifications of `groups`.
|
||||||
|
fn open(groups: u32) -> io::Result<Self> {
|
||||||
|
let mut socket = TokioSocket::new(NETLINK_ROUTE)?;
|
||||||
|
socket.socket_mut().bind(&SocketAddr::new(0, groups))?;
|
||||||
|
Ok(Self {
|
||||||
|
socket,
|
||||||
|
sequence: 0,
|
||||||
|
buf: BytesMut::with_capacity(READ_BUFFER),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_sequence(&mut self) -> u32 {
|
||||||
|
self.sequence = self.sequence.wrapping_add(1);
|
||||||
|
self.sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits for the kernel to send something, and returns it.
|
||||||
|
async fn read(&mut self) -> io::Result<&[u8]> {
|
||||||
|
self.buf.clear();
|
||||||
|
self.buf.reserve(READ_BUFFER);
|
||||||
|
self.socket.recv(&mut self.buf).await?;
|
||||||
|
Ok(&self.buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks for all of `what` and gives each message of the answer to
|
||||||
|
/// `each`.
|
||||||
|
async fn dump(&mut self, what: Dump, mut each: impl FnMut(Message)) -> Result<(), FibError> {
|
||||||
|
let sequence = self.next_sequence();
|
||||||
|
let mut request = BytesMut::new();
|
||||||
|
netlink::dump_request(&mut request, sequence, what);
|
||||||
|
self.socket.send(&request).await?;
|
||||||
|
loop {
|
||||||
|
let mut rest = self.read().await?;
|
||||||
|
while !rest.is_empty() {
|
||||||
|
let (header, message, used) = decode(rest)?;
|
||||||
|
rest = &rest[used..];
|
||||||
|
if header.sequence != sequence {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match message {
|
||||||
|
Message::Done => return Ok(()),
|
||||||
|
Message::Outcome { errno } if errno != 0 => {
|
||||||
|
return Err(FibError::Dump {
|
||||||
|
what: match what {
|
||||||
|
Dump::Routes(_) => "its routes",
|
||||||
|
Dump::Addresses => "its addresses",
|
||||||
|
Dump::Links => "its interfaces",
|
||||||
|
},
|
||||||
|
errno,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message => each(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the kernel for `requests`, each a change to a route, and
|
||||||
|
/// returns which of them it refused: the place in `requests` and the
|
||||||
|
/// error's number.
|
||||||
|
async fn change(
|
||||||
|
&mut self,
|
||||||
|
requests: &[(Change, Route)],
|
||||||
|
) -> Result<Vec<(usize, i32)>, FibError> {
|
||||||
|
let mut refused = Vec::new();
|
||||||
|
for batch_start in (0..requests.len()).step_by(BATCH) {
|
||||||
|
let batch = &requests[batch_start..requests.len().min(batch_start + BATCH)];
|
||||||
|
// One write for all of them. The kernel answers a request only
|
||||||
|
// if it fails, except the last, which it answers either way:
|
||||||
|
// that answer says the batch is done.
|
||||||
|
let first = self.sequence.wrapping_add(1);
|
||||||
|
let mut wire = BytesMut::with_capacity(batch.len() * 80);
|
||||||
|
for (at, (change, route)) in batch.iter().enumerate() {
|
||||||
|
let sequence = self.next_sequence();
|
||||||
|
netlink::route_request(&mut wire, sequence, *change, at + 1 == batch.len(), route);
|
||||||
|
}
|
||||||
|
let last = self.sequence;
|
||||||
|
self.socket.send(&wire).await?;
|
||||||
|
let mut done = false;
|
||||||
|
while !done {
|
||||||
|
let mut rest = self.read().await?;
|
||||||
|
while !rest.is_empty() {
|
||||||
|
let (header, message, used) = decode(rest)?;
|
||||||
|
rest = &rest[used..];
|
||||||
|
let at = header.sequence.wrapping_sub(first) as usize;
|
||||||
|
let Message::Outcome { errno } = message else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if at >= batch.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if errno != 0 {
|
||||||
|
refused.push((batch_start + at, errno));
|
||||||
|
}
|
||||||
|
done |= header.sequence == last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(refused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sockets the task uses.
|
||||||
|
pub(crate) struct Sockets {
|
||||||
|
/// For asking, and the answers.
|
||||||
|
requests: Socket,
|
||||||
|
/// For what the kernel tells unasked: changes to the interfaces and
|
||||||
|
/// their addresses.
|
||||||
|
news: Socket,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sockets {
|
||||||
|
pub(crate) fn open() -> Result<Self, FibError> {
|
||||||
|
Ok(Self {
|
||||||
|
requests: Socket::open(0)?,
|
||||||
|
news: Socket::open(group::LINK | group::IPV4_ADDRESS | group::IPV6_ADDRESS)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A change to the best routes, as taken out of the routing table to be
|
||||||
|
/// dealt with once the table is let go of.
|
||||||
|
enum News {
|
||||||
|
Reset,
|
||||||
|
Best {
|
||||||
|
prefix: IpPrefix,
|
||||||
|
peer: PeerIndex,
|
||||||
|
next_hop: Option<NlriNextHop>,
|
||||||
|
},
|
||||||
|
Withdrawn(IpPrefix),
|
||||||
|
Synced,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Task {
|
||||||
|
rib: Arc<RibTable>,
|
||||||
|
fib: Fib,
|
||||||
|
config: FibConfig,
|
||||||
|
requests: Socket,
|
||||||
|
interfaces: Interfaces,
|
||||||
|
plan: Plan,
|
||||||
|
synced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Task {
|
||||||
|
fn route(&self, op: &Op) -> (Change, Route) {
|
||||||
|
let (change, target) = match op {
|
||||||
|
Op::Install { target, .. } => (Change::Replace, Some(target)),
|
||||||
|
Op::Remove { .. } => (Change::Remove, None),
|
||||||
|
};
|
||||||
|
let route = Route {
|
||||||
|
prefix: *op.prefix(),
|
||||||
|
table: self.config.table,
|
||||||
|
protocol: PROTOCOL_BGP,
|
||||||
|
metric: self.config.metric,
|
||||||
|
gateway: target.map(|target| target.gateway),
|
||||||
|
interface: target.map(|target| target.interface),
|
||||||
|
kind: KIND_UNICAST,
|
||||||
|
};
|
||||||
|
(change, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a route the kernel has is one of ours.
|
||||||
|
fn is_ours(&self, route: &Route) -> bool {
|
||||||
|
route.protocol == PROTOCOL_BGP
|
||||||
|
&& route.table == self.config.table
|
||||||
|
&& route.metric == self.config.metric
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Learns the machine's interfaces and addresses anew.
|
||||||
|
async fn learn_interfaces(&mut self) -> Result<(), FibError> {
|
||||||
|
let mut interfaces = Interfaces::default();
|
||||||
|
for what in [Dump::Links, Dump::Addresses] {
|
||||||
|
self.requests
|
||||||
|
.dump(what, |message| {
|
||||||
|
interfaces.apply(&message);
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
self.interfaces = interfaces;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the routes of ours that the kernel has from before.
|
||||||
|
async fn adopt_routes(&mut self) -> Result<(), FibError> {
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for version in [IpVersion::Ipv4, IpVersion::Ipv6] {
|
||||||
|
self.requests
|
||||||
|
.dump(Dump::Routes(version), |message| {
|
||||||
|
if let Message::Route {
|
||||||
|
present: true,
|
||||||
|
route,
|
||||||
|
} = message
|
||||||
|
{
|
||||||
|
found.push(route);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
for route in found {
|
||||||
|
if !self.is_ours(&route) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let target = match (route.gateway, route.interface) {
|
||||||
|
(Some(gateway), Some(interface)) if route.kind == KIND_UNICAST => {
|
||||||
|
Some(Target { gateway, interface })
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
self.plan.adopt(route.prefix, target);
|
||||||
|
}
|
||||||
|
if self.plan.installed() > 0 {
|
||||||
|
info!(
|
||||||
|
routes = self.plan.installed(),
|
||||||
|
"Found routes of ours in the kernel from an earlier run"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the kernel for `ops` and deals with what it refuses.
|
||||||
|
async fn apply(&mut self, ops: Vec<Op>) -> Result<(), FibError> {
|
||||||
|
let mut pending = VecDeque::from([ops]);
|
||||||
|
while let Some(ops) = pending.pop_front() {
|
||||||
|
if ops.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let requests: Vec<(Change, Route)> = ops.iter().map(|op| self.route(op)).collect();
|
||||||
|
for (change, _) in &requests {
|
||||||
|
let label = match change {
|
||||||
|
Change::Replace => "install",
|
||||||
|
Change::Remove => "remove",
|
||||||
|
};
|
||||||
|
CHANGES.with_label_values(&[label]).inc();
|
||||||
|
}
|
||||||
|
let mut instead = Vec::new();
|
||||||
|
for (at, errno) in self.requests.change(&requests).await? {
|
||||||
|
let op = &ops[at];
|
||||||
|
if errno == errno::EPERM {
|
||||||
|
return Err(FibError::NotPermitted);
|
||||||
|
}
|
||||||
|
// Removing what is not there is as good as done.
|
||||||
|
if matches!(op, Op::Remove { .. }) && matches!(errno, errno::ESRCH | errno::ENOENT)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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.interface_name(target.interface)
|
||||||
|
),
|
||||||
|
Op::Remove { prefix } => format!("remove {prefix}"),
|
||||||
|
};
|
||||||
|
// Counted always, said once in a while: a table's worth of
|
||||||
|
// refusals is a table's worth of lines.
|
||||||
|
ERRORS.inc();
|
||||||
|
let said = format!("the kernel refused to {what}: {error}");
|
||||||
|
debug!("{said}");
|
||||||
|
self.fib.update(|status| {
|
||||||
|
if status.errors == 0 || status.errors.is_power_of_two() {
|
||||||
|
warn!(refusals = status.errors + 1, "{said}");
|
||||||
|
}
|
||||||
|
status.errors += 1;
|
||||||
|
status.last_error = Some(said);
|
||||||
|
});
|
||||||
|
instead.extend(self.plan.refused_by_kernel(op));
|
||||||
|
}
|
||||||
|
pending.push_back(instead);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interface_name(&self, index: u32) -> String {
|
||||||
|
self.interfaces
|
||||||
|
.link(index)
|
||||||
|
.map_or_else(|| format!("interface {index}"), |link| link.name.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the next changes of best route from the routing table and
|
||||||
|
/// brings the kernel in step with them.
|
||||||
|
async fn follow(&mut self, reader: &mut Reader) -> Result<(), FibError> {
|
||||||
|
let mut news = Vec::new();
|
||||||
|
// Only what is needed is taken while the table is held.
|
||||||
|
reader.poll(BATCH, |event| {
|
||||||
|
news.push(match event {
|
||||||
|
Event::Reset => News::Reset,
|
||||||
|
Event::Synced => News::Synced,
|
||||||
|
Event::Best {
|
||||||
|
prefix,
|
||||||
|
peer,
|
||||||
|
attrs,
|
||||||
|
..
|
||||||
|
} => News::Best {
|
||||||
|
prefix,
|
||||||
|
peer,
|
||||||
|
next_hop: attrs.next_hop(),
|
||||||
|
},
|
||||||
|
Event::Withdrawn { prefix } => News::Withdrawn(prefix),
|
||||||
|
// Not given to a follower of the best routes.
|
||||||
|
Event::Routes { .. } => return,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let mut ops = Vec::new();
|
||||||
|
for item in news {
|
||||||
|
match item {
|
||||||
|
News::Reset => self.plan.reset(),
|
||||||
|
News::Synced => {
|
||||||
|
let stale = self.plan.synced();
|
||||||
|
if !self.synced || !stale.is_empty() {
|
||||||
|
info!(
|
||||||
|
installed = self.plan.installed(),
|
||||||
|
unresolved = self.plan.unresolved(),
|
||||||
|
removed_stale = stale.len(),
|
||||||
|
"The kernel's routes are in step with the routing table"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.synced = true;
|
||||||
|
ops.extend(stale);
|
||||||
|
}
|
||||||
|
News::Best {
|
||||||
|
prefix,
|
||||||
|
peer,
|
||||||
|
next_hop,
|
||||||
|
} => {
|
||||||
|
// Whose link a link-local next hop is on is asked only
|
||||||
|
// where there is one.
|
||||||
|
let peer_address =
|
||||||
|
|rib: &RibTable| -> Option<IpAddr> { Some(rib.peer(peer)?.address) };
|
||||||
|
let needs_peer = matches!(
|
||||||
|
next_hop,
|
||||||
|
Some(NlriNextHop::Ipv6(_) | NlriNextHop::Ipv6WithLl { .. })
|
||||||
|
);
|
||||||
|
let peer = needs_peer.then(|| peer_address(&self.rib)).flatten();
|
||||||
|
let target = resolve(&self.interfaces, &prefix, next_hop.as_ref(), peer);
|
||||||
|
ops.extend(self.plan.best(prefix, target));
|
||||||
|
}
|
||||||
|
News::Withdrawn(prefix) => ops.extend(self.plan.withdrawn(prefix)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.apply(ops).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Says how things stand, for whoever asks.
|
||||||
|
fn publish(&self, interfaces_changed: bool) {
|
||||||
|
ROUTES.set(self.plan.installed() as i64);
|
||||||
|
UNRESOLVED.set(self.plan.unresolved() as i64);
|
||||||
|
REFUSED.set(self.plan.refused() as i64);
|
||||||
|
let interfaces = interfaces_changed.then(|| {
|
||||||
|
let mut interfaces: Vec<InterfaceStatus> = self
|
||||||
|
.interfaces
|
||||||
|
.links()
|
||||||
|
.map(|link| {
|
||||||
|
let mut addresses: Vec<(IpAddr, u8)> = self
|
||||||
|
.interfaces
|
||||||
|
.addresses_of(link.index)
|
||||||
|
.map(|address| (address.local, address.network.length()))
|
||||||
|
.collect();
|
||||||
|
addresses.sort();
|
||||||
|
InterfaceStatus {
|
||||||
|
index: link.index,
|
||||||
|
name: link.name.clone(),
|
||||||
|
up: link.up,
|
||||||
|
addresses,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
interfaces.sort_by_key(|interface| interface.index);
|
||||||
|
interfaces
|
||||||
|
});
|
||||||
|
self.fib.update(|status| {
|
||||||
|
status.synced = self.synced;
|
||||||
|
status.installed = self.plan.installed();
|
||||||
|
status.refused = self.plan.refused();
|
||||||
|
status.unresolved = self
|
||||||
|
.plan
|
||||||
|
.unresolved_by_reason()
|
||||||
|
.map(|(reason, count)| (reason.as_str(), count))
|
||||||
|
.collect();
|
||||||
|
if let Some(interfaces) = interfaces {
|
||||||
|
status.interfaces = interfaces;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the kernel's routes in step with the best routes of `rib` until
|
||||||
|
/// told to stop, and then takes them out.
|
||||||
|
pub(crate) async fn run(
|
||||||
|
rib: Arc<RibTable>,
|
||||||
|
fib: Fib,
|
||||||
|
sockets: Sockets,
|
||||||
|
mut stopping: watch::Receiver<bool>,
|
||||||
|
) -> Result<(), FibError> {
|
||||||
|
let Sockets { requests, mut news } = sockets;
|
||||||
|
let mut task = Task {
|
||||||
|
config: fib.config(),
|
||||||
|
rib,
|
||||||
|
fib,
|
||||||
|
requests,
|
||||||
|
interfaces: Interfaces::default(),
|
||||||
|
plan: Plan::default(),
|
||||||
|
synced: false,
|
||||||
|
};
|
||||||
|
// The socket for news was bound before this: what changes while the
|
||||||
|
// interfaces are being learned is told there.
|
||||||
|
task.learn_interfaces().await?;
|
||||||
|
task.adopt_routes().await?;
|
||||||
|
task.publish(true);
|
||||||
|
info!(
|
||||||
|
table = task.config.table,
|
||||||
|
metric = task.config.metric,
|
||||||
|
interfaces = task.interfaces.links().count(),
|
||||||
|
"Installing best routes in the kernel"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A reader is first given the whole table, which is what brings the
|
||||||
|
// kernel in step; starting a new one does it over.
|
||||||
|
let mut reader = task.rib.reader();
|
||||||
|
let mut again_at: Option<Instant> = None;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
() = reader.changed() => {
|
||||||
|
task.follow(&mut reader).await?;
|
||||||
|
task.publish(false);
|
||||||
|
}
|
||||||
|
read = news.read() => {
|
||||||
|
let changed = match read {
|
||||||
|
Ok(mut rest) => {
|
||||||
|
let mut changed = false;
|
||||||
|
while !rest.is_empty() {
|
||||||
|
let (_, message, used) = decode(rest)?;
|
||||||
|
rest = &rest[used..];
|
||||||
|
changed |= task.interfaces.apply(&message);
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
// The kernel had more to say than the socket had room
|
||||||
|
// for (ENOBUFS): what was missed is asked for.
|
||||||
|
Err(e) => {
|
||||||
|
warn!(err = %e, "Missed news from the kernel; asking for the interfaces again");
|
||||||
|
task.learn_interfaces().await?;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if changed {
|
||||||
|
// What a next hop resolves to may be different now,
|
||||||
|
// for any route.
|
||||||
|
again_at = Some(Instant::now() + SETTLE);
|
||||||
|
task.publish(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
() = async { sleep_until(again_at.expect("checked by the guard")).await },
|
||||||
|
if again_at.is_some() =>
|
||||||
|
{
|
||||||
|
again_at = None;
|
||||||
|
info!("The machine's interfaces changed; working the kernel's routes out again");
|
||||||
|
reader = task.rib.reader();
|
||||||
|
}
|
||||||
|
_ = stopping.changed() => {
|
||||||
|
let ops = task.plan.clear();
|
||||||
|
info!(routes = ops.len(), "Taking our routes out of the kernel");
|
||||||
|
task.apply(ops).await?;
|
||||||
|
task.synced = false;
|
||||||
|
task.publish(false);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
//! Putting the best routes into the kernel's routing table, so that the
|
||||||
|
//! machine forwards packets by them (Linux, over rtnetlink).
|
||||||
|
//!
|
||||||
|
//! The routing table chooses a best route for each prefix. This crate
|
||||||
|
//! follows those choices and keeps the kernel's table the same: one route
|
||||||
|
//! for each prefix, to the best route's next hop. It also learns from the
|
||||||
|
//! kernel what interfaces and addresses the machine has, which is what
|
||||||
|
//! says whether a next hop can be reached at all.
|
||||||
|
//!
|
||||||
|
//! - [`netlink`] is the kernel's messages, as bytes and back.
|
||||||
|
//! - [`interfaces`] is the machine's interfaces and what is reachable on
|
||||||
|
//! them.
|
||||||
|
//! - [`plan`] works out what to ask the kernel for.
|
||||||
|
//! - The rest, which only Linux has, asks it.
|
||||||
|
//!
|
||||||
|
//! # What is and is not touched
|
||||||
|
//!
|
||||||
|
//! Our routes are marked as BGP's ([`netlink::PROTOCOL_BGP`]) and carry
|
||||||
|
//! the configured metric, in the configured table. Only routes with that
|
||||||
|
//! mark, metric and table are ever replaced or removed; and all such
|
||||||
|
//! routes are taken to be ours, so two daemons must not share them.
|
||||||
|
//! Routes left behind by a run that ended badly are found at the next
|
||||||
|
//! start, and removed unless they are still wanted. A run that ends in
|
||||||
|
//! good order removes its routes.
|
||||||
|
//!
|
||||||
|
//! # 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.
|
||||||
|
|
||||||
|
pub mod interfaces;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod kernel;
|
||||||
|
pub mod netlink;
|
||||||
|
pub mod plan;
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
|
|
||||||
|
use bgp_rib::table::RibTable;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
/// The metric our routes get unless configured otherwise: the one other
|
||||||
|
/// routing daemons give routes from external BGP peers, so that ours rank
|
||||||
|
/// against static and connected routes as an operator expects.
|
||||||
|
pub const DEFAULT_METRIC: u32 = 20;
|
||||||
|
|
||||||
|
/// Where our routes go in the kernel.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct FibConfig {
|
||||||
|
/// The routing table: [`netlink::TABLE_MAIN`] to forward by them.
|
||||||
|
pub table: u32,
|
||||||
|
/// The metric, which with the table says which routes are ours.
|
||||||
|
pub metric: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FibConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
table: netlink::TABLE_MAIN,
|
||||||
|
metric: DEFAULT_METRIC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why routes cannot be kept in the kernel.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum FibError {
|
||||||
|
#[error("installing routes in the kernel is only supported on Linux")]
|
||||||
|
Unsupported,
|
||||||
|
#[error("the kernel's routing socket failed: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("the kernel sent something that is not netlink: {0}")]
|
||||||
|
Malformed(#[from] netlink::NetlinkError),
|
||||||
|
#[error("the kernel refused to list {what}: error {errno}")]
|
||||||
|
Dump { what: &'static str, errno: i32 },
|
||||||
|
#[error(
|
||||||
|
"not permitted to change the kernel's routes: the daemon needs the CAP_NET_ADMIN \
|
||||||
|
capability (or root)"
|
||||||
|
)]
|
||||||
|
NotPermitted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One of the machine's interfaces, for showing.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct InterfaceStatus {
|
||||||
|
pub index: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub up: bool,
|
||||||
|
/// Its addresses, each with the length of the network it is in.
|
||||||
|
pub addresses: Vec<(IpAddr, u8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How things stand with the kernel, for showing.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Status {
|
||||||
|
/// Whether the kernel has been brought in step with the routing table
|
||||||
|
/// at least once since the start.
|
||||||
|
pub synced: bool,
|
||||||
|
/// How many routes of ours the kernel has.
|
||||||
|
pub installed: 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)>,
|
||||||
|
/// Prefixes whose best route the kernel refused.
|
||||||
|
pub refused: usize,
|
||||||
|
/// How many requests the kernel has refused in all.
|
||||||
|
pub errors: u64,
|
||||||
|
/// The last such refusal.
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub interfaces: Vec<InterfaceStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Shared {
|
||||||
|
config: FibConfig,
|
||||||
|
status: Mutex<Status>,
|
||||||
|
stop: watch::Sender<bool>,
|
||||||
|
/// Whether the task has ended.
|
||||||
|
ended: watch::Sender<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle to the task that keeps the kernel's routes. Cheap to clone.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Fib {
|
||||||
|
shared: Arc<Shared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fib {
|
||||||
|
/// Starts keeping the kernel's routes in step with the best routes of
|
||||||
|
/// `rib`, and returns a handle with the task that does it. The task
|
||||||
|
/// ends only by failing, or when [`Fib::stop`] is called.
|
||||||
|
///
|
||||||
|
/// Must be called from within a Tokio runtime. Fails at once where
|
||||||
|
/// there is no kernel to talk to in this way.
|
||||||
|
pub fn spawn(
|
||||||
|
rib: Arc<RibTable>,
|
||||||
|
config: FibConfig,
|
||||||
|
) -> Result<(Self, JoinHandle<Result<(), FibError>>), FibError> {
|
||||||
|
let (stop, stopping) = watch::channel(false);
|
||||||
|
let fib = Self {
|
||||||
|
shared: Arc::new(Shared {
|
||||||
|
config,
|
||||||
|
status: Mutex::default(),
|
||||||
|
stop,
|
||||||
|
ended: watch::channel(false).0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let task = start(rib, fib.clone(), stopping)?;
|
||||||
|
Ok((fib, task))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(&self) -> FibConfig {
|
||||||
|
self.shared.config
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> Status {
|
||||||
|
self.shared
|
||||||
|
.status
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Has the task take our routes out of the kernel and end, and waits
|
||||||
|
/// until it has. Returns at once if it has ended already, for whatever
|
||||||
|
/// reason; the task's own result says how.
|
||||||
|
pub async fn stop(&self) {
|
||||||
|
let mut ended = self.shared.ended.subscribe();
|
||||||
|
self.shared.stop.send_replace(true);
|
||||||
|
// Fails only if the handle that holds the sender is gone, and this
|
||||||
|
// is one.
|
||||||
|
let _ = ended.wait_for(|ended| *ended).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
|
fn update(&self, change: impl FnOnce(&mut Status)) {
|
||||||
|
change(
|
||||||
|
&mut self
|
||||||
|
.shared
|
||||||
|
.status
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn start(
|
||||||
|
rib: Arc<RibTable>,
|
||||||
|
fib: Fib,
|
||||||
|
stopping: watch::Receiver<bool>,
|
||||||
|
) -> Result<JoinHandle<Result<(), FibError>>, FibError> {
|
||||||
|
// Opened here, so that a machine that cannot is known of at once.
|
||||||
|
let sockets = kernel::Sockets::open()?;
|
||||||
|
Ok(tokio::spawn(async move {
|
||||||
|
let result = kernel::run(rib, fib.clone(), sockets, stopping).await;
|
||||||
|
fib.shared.ended.send_replace(true);
|
||||||
|
result
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn start(
|
||||||
|
_rib: Arc<RibTable>,
|
||||||
|
_fib: Fib,
|
||||||
|
_stopping: watch::Receiver<bool>,
|
||||||
|
) -> Result<JoinHandle<Result<(), FibError>>, FibError> {
|
||||||
|
Err(FibError::Unsupported)
|
||||||
|
}
|
||||||
@@ -0,0 +1,836 @@
|
|||||||
|
//! The messages of the kernel's routing socket (rtnetlink), as bytes and
|
||||||
|
//! back: the part of them this crate uses. No I/O here.
|
||||||
|
//!
|
||||||
|
//! Every message begins with a header of sixteen octets (`nlmsghdr`: length,
|
||||||
|
//! type, flags, sequence number, sender), then a fixed part that depends on
|
||||||
|
//! the type, then attributes: each a length, a type and a value, padded to
|
||||||
|
//! four octets. Numbers are in the byte order of the machine, the kernel
|
||||||
|
//! being on the same one; addresses are in network order as everywhere.
|
||||||
|
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
use bgp_primitives::constants::IpVersion;
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
use bytes::{BufMut, BytesMut};
|
||||||
|
|
||||||
|
/// The routing protocol that routes of ours are marked as having come
|
||||||
|
/// from (`RTPROT_BGP`). Routes with another mark are never touched.
|
||||||
|
pub const PROTOCOL_BGP: u8 = 186;
|
||||||
|
|
||||||
|
/// The table that forwarding uses unless rules say otherwise
|
||||||
|
/// (`RT_TABLE_MAIN`).
|
||||||
|
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.
|
||||||
|
pub const KIND_UNICAST: u8 = 1;
|
||||||
|
|
||||||
|
const HEADER_LEN: usize = 16;
|
||||||
|
|
||||||
|
mod message_type {
|
||||||
|
pub const ERROR: u16 = 2;
|
||||||
|
pub const DONE: u16 = 3;
|
||||||
|
pub const NEW_LINK: u16 = 16;
|
||||||
|
pub const DEL_LINK: u16 = 17;
|
||||||
|
pub const GET_LINK: u16 = 18;
|
||||||
|
pub const NEW_ADDRESS: u16 = 20;
|
||||||
|
pub const DEL_ADDRESS: u16 = 21;
|
||||||
|
pub const GET_ADDRESS: u16 = 22;
|
||||||
|
pub const NEW_ROUTE: u16 = 24;
|
||||||
|
pub const DEL_ROUTE: u16 = 25;
|
||||||
|
pub const GET_ROUTE: u16 = 26;
|
||||||
|
}
|
||||||
|
|
||||||
|
mod flag {
|
||||||
|
pub const REQUEST: u16 = 0x1;
|
||||||
|
pub const ACK: u16 = 0x4;
|
||||||
|
/// For a request to read: all of them (`NLM_F_ROOT | NLM_F_MATCH`).
|
||||||
|
pub const DUMP: u16 = 0x300;
|
||||||
|
/// For a request to add: take the place of the one there is.
|
||||||
|
pub const REPLACE: u16 = 0x100;
|
||||||
|
/// For a request to add: make it if there is none.
|
||||||
|
pub const CREATE: u16 = 0x400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The groups of notifications a socket can ask for when it is bound
|
||||||
|
/// (`RTMGRP_*`).
|
||||||
|
pub mod group {
|
||||||
|
pub const LINK: u32 = 0x1;
|
||||||
|
pub const IPV4_ADDRESS: u32 = 0x10;
|
||||||
|
pub const IPV6_ADDRESS: u32 = 0x100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AF_INET: u8 = 2;
|
||||||
|
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.
|
||||||
|
const TABLE_IN_ATTRIBUTE: u8 = 252;
|
||||||
|
const SCOPE_UNIVERSE: u8 = 0;
|
||||||
|
|
||||||
|
mod route_attribute {
|
||||||
|
pub const DESTINATION: u16 = 1;
|
||||||
|
pub const OUT_INTERFACE: u16 = 4;
|
||||||
|
pub const GATEWAY: u16 = 5;
|
||||||
|
pub const PRIORITY: u16 = 6;
|
||||||
|
pub const TABLE: u16 = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
mod address_attribute {
|
||||||
|
pub const ADDRESS: u16 = 1;
|
||||||
|
pub const LOCAL: u16 = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
mod link_attribute {
|
||||||
|
pub const NAME: u16 = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERFACE_UP: u32 = 0x1;
|
||||||
|
const INTERFACE_RUNNING: u32 = 0x40;
|
||||||
|
|
||||||
|
/// A route in one of the kernel's tables.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Route {
|
||||||
|
pub prefix: IpPrefix,
|
||||||
|
pub table: u32,
|
||||||
|
/// Who put it there; see [`PROTOCOL_BGP`].
|
||||||
|
pub protocol: u8,
|
||||||
|
/// The kernel's "priority": of several routes for a prefix, the one
|
||||||
|
/// with the lowest is used.
|
||||||
|
pub metric: u32,
|
||||||
|
pub gateway: Option<IpAddr>,
|
||||||
|
/// The index of the interface packets leave by.
|
||||||
|
pub interface: Option<u32>,
|
||||||
|
/// See [`KIND_UNICAST`].
|
||||||
|
pub kind: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An address of one of the machine's interfaces.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Address {
|
||||||
|
/// The index of the interface.
|
||||||
|
pub interface: u32,
|
||||||
|
/// The address itself.
|
||||||
|
pub local: IpAddr,
|
||||||
|
/// The network it is in: what can be reached on the interface without
|
||||||
|
/// a gateway.
|
||||||
|
pub network: IpPrefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One of the machine's interfaces.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Link {
|
||||||
|
pub index: u32,
|
||||||
|
pub name: String,
|
||||||
|
/// Whether it is switched on and working.
|
||||||
|
pub up: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What every message begins with, as far as it matters here.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Header {
|
||||||
|
pub flags: u16,
|
||||||
|
/// For an answer, the sequence number of the request it answers.
|
||||||
|
pub sequence: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message from the kernel.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Message {
|
||||||
|
/// A route there is (in answer to a dump, or newly) or was.
|
||||||
|
Route {
|
||||||
|
present: bool,
|
||||||
|
route: Route,
|
||||||
|
},
|
||||||
|
Address {
|
||||||
|
present: bool,
|
||||||
|
address: Address,
|
||||||
|
},
|
||||||
|
Link {
|
||||||
|
present: bool,
|
||||||
|
link: Link,
|
||||||
|
},
|
||||||
|
/// What became of a request: `errno` is zero if it was done, and
|
||||||
|
/// otherwise the error's number.
|
||||||
|
Outcome {
|
||||||
|
errno: i32,
|
||||||
|
},
|
||||||
|
/// The answer to a dump is complete.
|
||||||
|
Done,
|
||||||
|
/// Something this crate has no use for.
|
||||||
|
Other {
|
||||||
|
message_type: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes that are not a netlink message.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum NetlinkError {
|
||||||
|
#[error("a message of {claimed} octets in {available}")]
|
||||||
|
Length { claimed: usize, available: usize },
|
||||||
|
#[error("a message of type {message_type} too short for what it is")]
|
||||||
|
Truncated { message_type: u16 },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ne_u16(bytes: &[u8]) -> u16 {
|
||||||
|
u16::from_ne_bytes([bytes[0], bytes[1]])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ne_u32(bytes: &[u8]) -> u32 {
|
||||||
|
u32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rounds a length up to the four octets everything is aligned to.
|
||||||
|
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])> {
|
||||||
|
std::iter::from_fn(move || {
|
||||||
|
let header = bytes.first_chunk::<4>()?;
|
||||||
|
let length = usize::from(ne_u16(header));
|
||||||
|
let kind = ne_u16(&header[2..]);
|
||||||
|
let value = bytes.get(4..length)?;
|
||||||
|
bytes = bytes.get(aligned(length)..).unwrap_or(&[]);
|
||||||
|
Some((kind, value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_of(family: u8) -> Option<IpVersion> {
|
||||||
|
match family {
|
||||||
|
AF_INET => Some(IpVersion::Ipv4),
|
||||||
|
AF_INET6 => Some(IpVersion::Ipv6),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn family_of(version: IpVersion) -> u8 {
|
||||||
|
match version {
|
||||||
|
IpVersion::Ipv4 => AF_INET,
|
||||||
|
IpVersion::Ipv6 => AF_INET6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An address of `version` from exactly its octets.
|
||||||
|
fn address(version: IpVersion, bytes: &[u8]) -> Option<IpAddr> {
|
||||||
|
match version {
|
||||||
|
IpVersion::Ipv4 => Some(Ipv4Addr::from(<[u8; 4]>::try_from(bytes).ok()?).into()),
|
||||||
|
IpVersion::Ipv6 => Some(Ipv6Addr::from(<[u8; 16]>::try_from(bytes).ok()?).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the message at the front of `buf`, which is what a read from the
|
||||||
|
/// socket returned, or what is left of it: the header, the message, and
|
||||||
|
/// how many octets it took. A read can hold several messages.
|
||||||
|
pub fn decode(buf: &[u8]) -> Result<(Header, Message, usize), NetlinkError> {
|
||||||
|
let bad_length = |claimed| NetlinkError::Length {
|
||||||
|
claimed,
|
||||||
|
available: buf.len(),
|
||||||
|
};
|
||||||
|
let header = buf
|
||||||
|
.first_chunk::<HEADER_LEN>()
|
||||||
|
.ok_or(bad_length(HEADER_LEN))?;
|
||||||
|
let length = ne_u32(header) as usize;
|
||||||
|
let message_type = ne_u16(&header[4..]);
|
||||||
|
let body = buf
|
||||||
|
.get(HEADER_LEN..length)
|
||||||
|
.ok_or_else(|| bad_length(length))?;
|
||||||
|
let header = Header {
|
||||||
|
flags: ne_u16(&header[6..]),
|
||||||
|
sequence: ne_u32(&header[8..]),
|
||||||
|
};
|
||||||
|
let truncated = NetlinkError::Truncated { message_type };
|
||||||
|
let other = Message::Other { message_type };
|
||||||
|
|
||||||
|
let message = match message_type {
|
||||||
|
message_type::ERROR => Message::Outcome {
|
||||||
|
// The kernel gives the error's number negated.
|
||||||
|
errno: body
|
||||||
|
.first_chunk::<4>()
|
||||||
|
.map(|errno| i32::from_ne_bytes(*errno).wrapping_neg())
|
||||||
|
.ok_or(truncated)?,
|
||||||
|
},
|
||||||
|
message_type::DONE => Message::Done,
|
||||||
|
message_type::NEW_ROUTE | message_type::DEL_ROUTE => {
|
||||||
|
// rtmsg: family, destination length, source length, TOS,
|
||||||
|
// table, protocol, scope, type, and four octets of flags.
|
||||||
|
let (fixed, rest) = body.split_at_checked(12).ok_or(truncated)?;
|
||||||
|
match route(fixed, rest) {
|
||||||
|
Some(route) => Message::Route {
|
||||||
|
present: message_type == message_type::NEW_ROUTE,
|
||||||
|
route,
|
||||||
|
},
|
||||||
|
None => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message_type::NEW_ADDRESS | message_type::DEL_ADDRESS => {
|
||||||
|
// ifaddrmsg: family, prefix length, flags, scope, and the
|
||||||
|
// interface's index.
|
||||||
|
let (fixed, rest) = body.split_at_checked(8).ok_or(truncated)?;
|
||||||
|
match interface_address(fixed, rest) {
|
||||||
|
Some(address) => Message::Address {
|
||||||
|
present: message_type == message_type::NEW_ADDRESS,
|
||||||
|
address,
|
||||||
|
},
|
||||||
|
None => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message_type::NEW_LINK | message_type::DEL_LINK => {
|
||||||
|
// ifinfomsg: family, padding, device type, index, flags, and
|
||||||
|
// which flags changed.
|
||||||
|
let (fixed, rest) = body.split_at_checked(16).ok_or(truncated)?;
|
||||||
|
let flags = ne_u32(&fixed[8..]);
|
||||||
|
let name = attributes(rest)
|
||||||
|
.find(|(kind, _)| *kind == link_attribute::NAME)
|
||||||
|
.map(|(_, value)| {
|
||||||
|
// The name ends with a zero octet.
|
||||||
|
let name = value.split(|octet| *octet == 0).next().unwrap_or(&[]);
|
||||||
|
String::from_utf8_lossy(name).into_owned()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Message::Link {
|
||||||
|
present: message_type == message_type::NEW_LINK,
|
||||||
|
link: Link {
|
||||||
|
index: ne_u32(&fixed[4..]),
|
||||||
|
name,
|
||||||
|
up: flags & INTERFACE_UP != 0 && flags & INTERFACE_RUNNING != 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => other,
|
||||||
|
};
|
||||||
|
// The next message begins where this one ends, rounded up; the last
|
||||||
|
// need not be padded.
|
||||||
|
Ok((header, message, aligned(length).min(buf.len())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A route from the fixed part of its message and its attributes. `None`
|
||||||
|
/// if it is of a family, or a shape, this crate has no notion of.
|
||||||
|
fn route(fixed: &[u8], attributes_of_route: &[u8]) -> Option<Route> {
|
||||||
|
let version = version_of(fixed[0])?;
|
||||||
|
let destination_length = fixed[1];
|
||||||
|
let mut route = Route {
|
||||||
|
// Without a destination attribute: the default route.
|
||||||
|
prefix: IpPrefix::new(version, [0u8; 16], destination_length).ok()?,
|
||||||
|
table: fixed[4].into(),
|
||||||
|
protocol: fixed[5],
|
||||||
|
metric: 0,
|
||||||
|
gateway: None,
|
||||||
|
interface: None,
|
||||||
|
kind: fixed[7],
|
||||||
|
};
|
||||||
|
for (kind, value) in attributes(attributes_of_route) {
|
||||||
|
match kind {
|
||||||
|
route_attribute::DESTINATION => {
|
||||||
|
// The address in full, whatever the prefix's length.
|
||||||
|
address(version, value)?;
|
||||||
|
route.prefix = IpPrefix::new(version, value, destination_length).ok()?;
|
||||||
|
}
|
||||||
|
route_attribute::GATEWAY => route.gateway = Some(address(version, value)?),
|
||||||
|
route_attribute::OUT_INTERFACE => route.interface = Some(ne_u32(value.get(..4)?)),
|
||||||
|
route_attribute::PRIORITY => route.metric = ne_u32(value.get(..4)?),
|
||||||
|
route_attribute::TABLE => route.table = ne_u32(value.get(..4)?),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interface_address(fixed: &[u8], attributes_of_address: &[u8]) -> Option<Address> {
|
||||||
|
let version = version_of(fixed[0])?;
|
||||||
|
let prefix_length = fixed[1];
|
||||||
|
let (mut local, mut peer) = (None, None);
|
||||||
|
for (kind, value) in attributes(attributes_of_address) {
|
||||||
|
match kind {
|
||||||
|
address_attribute::LOCAL => local = Some(address(version, value)?),
|
||||||
|
address_attribute::ADDRESS => peer = Some(address(version, value)?),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// On an ordinary interface both are the interface's address, and IPv6
|
||||||
|
// gives only the one. On a point-to-point link the one is our end and
|
||||||
|
// the other the far end, which is what the prefix is of.
|
||||||
|
let local = local.or(peer)?;
|
||||||
|
let of_network = peer.unwrap_or(local);
|
||||||
|
let network = match of_network {
|
||||||
|
IpAddr::V4(v4) => IpPrefix::new(version, v4.octets(), prefix_length),
|
||||||
|
IpAddr::V6(v6) => IpPrefix::new(version, v6.octets(), prefix_length),
|
||||||
|
}
|
||||||
|
.ok()?;
|
||||||
|
Some(Address {
|
||||||
|
interface: ne_u32(&fixed[4..]),
|
||||||
|
local,
|
||||||
|
network,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to do with a route.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Change {
|
||||||
|
/// Put it there, in place of the route there is for the same prefix
|
||||||
|
/// in the same table with the same metric, if there is one.
|
||||||
|
Replace,
|
||||||
|
Remove,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to ask for all of.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Dump {
|
||||||
|
Routes(IpVersion),
|
||||||
|
Addresses,
|
||||||
|
Links,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a header and returns where the message began, for
|
||||||
|
/// [`finish`] to fill in the length.
|
||||||
|
fn begin(out: &mut BytesMut, message_type: u16, flags: u16, sequence: u32) -> usize {
|
||||||
|
let start = out.len();
|
||||||
|
out.put_u32_ne(0);
|
||||||
|
out.put_u16_ne(message_type);
|
||||||
|
out.put_u16_ne(flags | flag::REQUEST);
|
||||||
|
out.put_u32_ne(sequence);
|
||||||
|
// From us: the kernel fills in who that is.
|
||||||
|
out.put_u32_ne(0);
|
||||||
|
start
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(out: &mut BytesMut, start: usize) {
|
||||||
|
let length = (out.len() - start) as u32;
|
||||||
|
out[start..start + 4].copy_from_slice(&length.to_ne_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_attribute(out: &mut BytesMut, kind: u16, value: &[u8]) {
|
||||||
|
out.put_u16_ne((4 + value.len()) as u16);
|
||||||
|
out.put_u16_ne(kind);
|
||||||
|
out.put_slice(value);
|
||||||
|
out.put_bytes(0, aligned(value.len()) - value.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn octets(address: &IpAddr) -> ([u8; 16], usize) {
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
match address {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
bytes[..4].copy_from_slice(&v4.octets());
|
||||||
|
(bytes, 4)
|
||||||
|
}
|
||||||
|
IpAddr::V6(v6) => (v6.octets(), 16),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the request to make `change` to `route` to the end of `out`.
|
||||||
|
/// With `acknowledge` the kernel answers also when it has done it; it
|
||||||
|
/// always answers when it could not.
|
||||||
|
pub fn route_request(
|
||||||
|
out: &mut BytesMut,
|
||||||
|
sequence: u32,
|
||||||
|
change: Change,
|
||||||
|
acknowledge: bool,
|
||||||
|
route: &Route,
|
||||||
|
) {
|
||||||
|
let (message_type, mut flags) = match change {
|
||||||
|
Change::Replace => (message_type::NEW_ROUTE, flag::CREATE | flag::REPLACE),
|
||||||
|
Change::Remove => (message_type::DEL_ROUTE, 0),
|
||||||
|
};
|
||||||
|
if acknowledge {
|
||||||
|
flags |= flag::ACK;
|
||||||
|
}
|
||||||
|
let start = begin(out, message_type, flags, sequence);
|
||||||
|
let version = route.prefix.address_family();
|
||||||
|
out.put_u8(family_of(version));
|
||||||
|
out.put_u8(route.prefix.length());
|
||||||
|
// No source prefix, no TOS.
|
||||||
|
out.put_u8(0);
|
||||||
|
out.put_u8(0);
|
||||||
|
out.put_u8(u8::try_from(route.table).unwrap_or(TABLE_IN_ATTRIBUTE));
|
||||||
|
out.put_u8(route.protocol);
|
||||||
|
out.put_u8(SCOPE_UNIVERSE);
|
||||||
|
out.put_u8(route.kind);
|
||||||
|
out.put_u32_ne(0);
|
||||||
|
|
||||||
|
let mut destination = [0u8; 16];
|
||||||
|
destination[..route.prefix.bytes().len()].copy_from_slice(route.prefix.bytes());
|
||||||
|
let address_length = usize::from(version.bits() / 8);
|
||||||
|
put_attribute(
|
||||||
|
out,
|
||||||
|
route_attribute::DESTINATION,
|
||||||
|
&destination[..address_length],
|
||||||
|
);
|
||||||
|
put_attribute(out, route_attribute::TABLE, &route.table.to_ne_bytes());
|
||||||
|
put_attribute(out, route_attribute::PRIORITY, &route.metric.to_ne_bytes());
|
||||||
|
if let Some(gateway) = &route.gateway {
|
||||||
|
let (bytes, length) = octets(gateway);
|
||||||
|
put_attribute(out, route_attribute::GATEWAY, &bytes[..length]);
|
||||||
|
}
|
||||||
|
if let Some(interface) = route.interface {
|
||||||
|
put_attribute(
|
||||||
|
out,
|
||||||
|
route_attribute::OUT_INTERFACE,
|
||||||
|
&interface.to_ne_bytes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finish(out, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
let (message_type, family, fixed_length) = match what {
|
||||||
|
Dump::Routes(version) => (message_type::GET_ROUTE, family_of(version), 12),
|
||||||
|
Dump::Addresses => (message_type::GET_ADDRESS, 0, 8),
|
||||||
|
Dump::Links => (message_type::GET_LINK, 0, 16),
|
||||||
|
};
|
||||||
|
let start = begin(out, message_type, flag::DUMP, sequence);
|
||||||
|
// The fixed part of the kind asked for, empty but for the family.
|
||||||
|
out.put_u8(family);
|
||||||
|
out.put_bytes(0, fixed_length - 1);
|
||||||
|
finish(out, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn prefix(s: &str) -> IpPrefix {
|
||||||
|
IpPrefix::try_from(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the kernel sends back for a request is the request's fixed part
|
||||||
|
/// and attributes under another type: a route that is written reads
|
||||||
|
/// back as itself.
|
||||||
|
fn read_back(route: &Route, change: Change) -> (Header, Message) {
|
||||||
|
let mut wire = BytesMut::new();
|
||||||
|
route_request(&mut wire, 7, change, true, route);
|
||||||
|
let (header, message, used) = decode(&wire).unwrap();
|
||||||
|
assert_eq!(used, wire.len());
|
||||||
|
(header, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_route_request_is_laid_out_as_the_kernel_expects() {
|
||||||
|
let route = Route {
|
||||||
|
prefix: prefix("203.0.113.0/24"),
|
||||||
|
table: TABLE_MAIN,
|
||||||
|
protocol: PROTOCOL_BGP,
|
||||||
|
metric: 20,
|
||||||
|
gateway: Some("192.0.2.1".parse().unwrap()),
|
||||||
|
interface: Some(3),
|
||||||
|
kind: KIND_UNICAST,
|
||||||
|
};
|
||||||
|
let mut wire = BytesMut::new();
|
||||||
|
route_request(&mut wire, 7, Change::Replace, true, &route);
|
||||||
|
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
let ne16 = |v: u16| v.to_ne_bytes();
|
||||||
|
let ne32 = |v: u32| v.to_ne_bytes();
|
||||||
|
// nlmsghdr: length, RTM_NEWROUTE, REQUEST|ACK|REPLACE|CREATE,
|
||||||
|
// sequence, port.
|
||||||
|
expected.extend(ne32(16 + 12 + 5 * 8));
|
||||||
|
expected.extend(ne16(24));
|
||||||
|
expected.extend(ne16(0x1 | 0x4 | 0x100 | 0x400));
|
||||||
|
expected.extend(ne32(7));
|
||||||
|
expected.extend(ne32(0));
|
||||||
|
// rtmsg: AF_INET, /24, no source, no TOS, table main, proto BGP,
|
||||||
|
// scope universe, unicast, no flags.
|
||||||
|
expected.extend([2, 24, 0, 0, 254, 186, 0, 1]);
|
||||||
|
expected.extend(ne32(0));
|
||||||
|
// RTA_DST, RTA_TABLE, RTA_PRIORITY, RTA_GATEWAY, RTA_OIF.
|
||||||
|
for (kind, value) in [
|
||||||
|
(1u16, [203, 0, 113, 0]),
|
||||||
|
(15, ne32(254)),
|
||||||
|
(6, ne32(20)),
|
||||||
|
(5, [192, 0, 2, 1]),
|
||||||
|
(4, ne32(3)),
|
||||||
|
] {
|
||||||
|
expected.extend(ne16(8));
|
||||||
|
expected.extend(ne16(kind));
|
||||||
|
expected.extend(value);
|
||||||
|
}
|
||||||
|
assert_eq!(wire[..], expected[..]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read_back(&route, Change::Replace),
|
||||||
|
(
|
||||||
|
Header {
|
||||||
|
flags: 0x505,
|
||||||
|
sequence: 7
|
||||||
|
},
|
||||||
|
Message::Route {
|
||||||
|
present: true,
|
||||||
|
route
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_back(&route, Change::Remove).1,
|
||||||
|
Message::Route {
|
||||||
|
present: false,
|
||||||
|
route
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ipv6_route_in_a_high_table_reads_back() {
|
||||||
|
let route = Route {
|
||||||
|
prefix: prefix("2001:db8:1::/48"),
|
||||||
|
// Does not fit the octet in the fixed part.
|
||||||
|
table: 10_000,
|
||||||
|
protocol: PROTOCOL_BGP,
|
||||||
|
metric: 20,
|
||||||
|
gateway: Some("fe80::1".parse().unwrap()),
|
||||||
|
interface: Some(9),
|
||||||
|
kind: KIND_UNICAST,
|
||||||
|
};
|
||||||
|
let mut wire = BytesMut::new();
|
||||||
|
route_request(&mut wire, 1, Change::Replace, false, &route);
|
||||||
|
assert_eq!(wire[16], 10, "AF_INET6");
|
||||||
|
assert_eq!(wire[20], 252, "the table is in the attribute");
|
||||||
|
assert_eq!(
|
||||||
|
read_back(&route, Change::Replace).1,
|
||||||
|
Message::Route {
|
||||||
|
present: true,
|
||||||
|
route
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dump_requests_carry_the_fixed_part_of_what_they_ask_for() {
|
||||||
|
for (what, message_type, length, family) in [
|
||||||
|
(Dump::Routes(IpVersion::Ipv4), 26u16, 28u32, 2u8),
|
||||||
|
(Dump::Routes(IpVersion::Ipv6), 26, 28, 10),
|
||||||
|
(Dump::Addresses, 22, 24, 0),
|
||||||
|
(Dump::Links, 18, 32, 0),
|
||||||
|
] {
|
||||||
|
let mut wire = BytesMut::new();
|
||||||
|
dump_request(&mut wire, 3, what);
|
||||||
|
assert_eq!(wire.len(), length as usize, "{what:?}");
|
||||||
|
assert_eq!(wire[..4], length.to_ne_bytes(), "{what:?}");
|
||||||
|
assert_eq!(wire[4..6], message_type.to_ne_bytes(), "{what:?}");
|
||||||
|
assert_eq!(wire[6..8], 0x301u16.to_ne_bytes(), "{what:?}");
|
||||||
|
assert_eq!(wire[16], family, "{what:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
let mut out = BytesMut::new();
|
||||||
|
let start = begin(&mut out, message_type, 0, sequence);
|
||||||
|
out.put_slice(fixed);
|
||||||
|
for (kind, value) in attrs {
|
||||||
|
put_attribute(&mut out, *kind, value);
|
||||||
|
}
|
||||||
|
finish(&mut out, start);
|
||||||
|
out.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outcomes_addresses_and_links_are_read() {
|
||||||
|
// An error: ENETUNREACH is 101, negated; then the request's header.
|
||||||
|
let mut body = (-101i32).to_ne_bytes().to_vec();
|
||||||
|
body.extend([0u8; 16]);
|
||||||
|
let wire = message(2, 42, &body, &[]);
|
||||||
|
let (header, outcome, _) = decode(&wire).unwrap();
|
||||||
|
assert_eq!(header.sequence, 42);
|
||||||
|
assert_eq!(outcome, Message::Outcome { errno: 101 });
|
||||||
|
// An acknowledgement is an error of zero.
|
||||||
|
let wire = message(2, 43, &[0u8; 20], &[]);
|
||||||
|
assert_eq!(decode(&wire).unwrap().1, Message::Outcome { errno: 0 });
|
||||||
|
assert_eq!(
|
||||||
|
decode(&message(3, 1, &[0; 4], &[])).unwrap().1,
|
||||||
|
Message::Done
|
||||||
|
);
|
||||||
|
|
||||||
|
// 192.0.2.1/24 on interface 3: IFA_ADDRESS and IFA_LOCAL.
|
||||||
|
let mut fixed = vec![2u8, 24, 0, 0];
|
||||||
|
fixed.extend(3u32.to_ne_bytes());
|
||||||
|
let wire = message(20, 0, &fixed, &[(1, &[192, 0, 2, 1]), (2, &[192, 0, 2, 1])]);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&wire).unwrap().1,
|
||||||
|
Message::Address {
|
||||||
|
present: true,
|
||||||
|
address: Address {
|
||||||
|
interface: 3,
|
||||||
|
local: "192.0.2.1".parse().unwrap(),
|
||||||
|
network: prefix("192.0.2.0/24"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// A point-to-point link: our end, and the far end the prefix is
|
||||||
|
// of.
|
||||||
|
let mut fixed = vec![2u8, 32, 0, 0];
|
||||||
|
fixed.extend(4u32.to_ne_bytes());
|
||||||
|
let wire = message(21, 0, &fixed, &[(1, &[10, 0, 0, 2]), (2, &[10, 0, 0, 1])]);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&wire).unwrap().1,
|
||||||
|
Message::Address {
|
||||||
|
present: false,
|
||||||
|
address: Address {
|
||||||
|
interface: 4,
|
||||||
|
local: "10.0.0.1".parse().unwrap(),
|
||||||
|
network: prefix("10.0.0.2/32"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// IPv6 gives only IFA_ADDRESS.
|
||||||
|
let v6: Ipv6Addr = "2001:db8::1".parse().unwrap();
|
||||||
|
let mut fixed = vec![10u8, 64, 0, 0];
|
||||||
|
fixed.extend(5u32.to_ne_bytes());
|
||||||
|
let wire = message(20, 0, &fixed, &[(1, &v6.octets())]);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&wire).unwrap().1,
|
||||||
|
Message::Address {
|
||||||
|
present: true,
|
||||||
|
address: Address {
|
||||||
|
interface: 5,
|
||||||
|
local: v6.into(),
|
||||||
|
network: prefix("2001:db8::/64"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// A link that is up and running, and one that is only up.
|
||||||
|
for (flags, up) in [(0x41u32, true), (0x1, false), (0x40, false)] {
|
||||||
|
let mut fixed = vec![0u8, 0, 1, 0];
|
||||||
|
fixed.extend(7u32.to_ne_bytes());
|
||||||
|
fixed.extend(flags.to_ne_bytes());
|
||||||
|
fixed.extend(0u32.to_ne_bytes());
|
||||||
|
let wire = message(16, 0, &fixed, &[(3, b"eth0\0")]);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&wire).unwrap().1,
|
||||||
|
Message::Link {
|
||||||
|
present: true,
|
||||||
|
link: Link {
|
||||||
|
index: 7,
|
||||||
|
name: "eth0".into(),
|
||||||
|
up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn several_messages_in_one_read_are_read_one_after_another() {
|
||||||
|
let mut wire = message(2, 1, &[0u8; 20], &[]);
|
||||||
|
// An attribute whose value is not a multiple of four pads the
|
||||||
|
// message.
|
||||||
|
let mut fixed = vec![0u8, 0, 1, 0];
|
||||||
|
fixed.extend(7u32.to_ne_bytes());
|
||||||
|
fixed.extend([0u8; 8]);
|
||||||
|
wire.extend(message(16, 0, &fixed, &[(3, b"lo\0")]));
|
||||||
|
wire.extend(message(3, 2, &[0; 4], &[]));
|
||||||
|
|
||||||
|
let mut rest = &wire[..];
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
while !rest.is_empty() {
|
||||||
|
let (header, message, used) = decode(rest).unwrap();
|
||||||
|
seen.push((header.sequence, std::mem::discriminant(&message)));
|
||||||
|
rest = &rest[used..];
|
||||||
|
}
|
||||||
|
assert_eq!(seen.len(), 3);
|
||||||
|
assert_eq!(seen[0].0, 1);
|
||||||
|
assert_eq!(seen[2].0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_is_not_a_message_is_an_error() {
|
||||||
|
assert_eq!(
|
||||||
|
decode(&[0; 8]),
|
||||||
|
Err(NetlinkError::Length {
|
||||||
|
claimed: 16,
|
||||||
|
available: 8
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// A length past the end, and one shorter than a header.
|
||||||
|
for claimed in [64u32, 8] {
|
||||||
|
let mut wire = message(3, 1, &[], &[]);
|
||||||
|
wire[..4].copy_from_slice(&claimed.to_ne_bytes());
|
||||||
|
assert_eq!(
|
||||||
|
decode(&wire),
|
||||||
|
Err(NetlinkError::Length {
|
||||||
|
claimed: claimed as usize,
|
||||||
|
available: 16
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A route with half a fixed part.
|
||||||
|
assert_eq!(
|
||||||
|
decode(&message(24, 1, &[2, 24, 0, 0], &[])),
|
||||||
|
Err(NetlinkError::Truncated { message_type: 24 })
|
||||||
|
);
|
||||||
|
// Of a family that is not IP: nothing of ours.
|
||||||
|
let mut fixed = vec![28u8, 20, 0, 0, 254, 0, 0, 1];
|
||||||
|
fixed.extend([0u8; 4]);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&message(24, 1, &fixed, &[])).unwrap().1,
|
||||||
|
Message::Other { message_type: 24 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn any_route() -> impl Strategy<Value = Route> {
|
||||||
|
(
|
||||||
|
any::<bool>(),
|
||||||
|
any::<[u8; 16]>(),
|
||||||
|
0u8..=128,
|
||||||
|
any::<u32>(),
|
||||||
|
any::<u8>(),
|
||||||
|
any::<u32>(),
|
||||||
|
proptest::option::of(any::<[u8; 16]>()),
|
||||||
|
proptest::option::of(any::<u32>()),
|
||||||
|
)
|
||||||
|
.prop_map(
|
||||||
|
|(v6, bytes, length, table, protocol, metric, gateway, interface)| {
|
||||||
|
let version = if v6 { IpVersion::Ipv6 } else { IpVersion::Ipv4 };
|
||||||
|
let gateway = gateway.map(|bytes| match version {
|
||||||
|
IpVersion::Ipv4 => IpAddr::from(<[u8; 4]>::try_from(&bytes[..4]).unwrap()),
|
||||||
|
IpVersion::Ipv6 => IpAddr::from(bytes),
|
||||||
|
});
|
||||||
|
Route {
|
||||||
|
prefix: IpPrefix::new(version, bytes, length.min(version.bits())).unwrap(),
|
||||||
|
table,
|
||||||
|
protocol,
|
||||||
|
metric,
|
||||||
|
gateway,
|
||||||
|
interface,
|
||||||
|
kind: KIND_UNICAST,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn a_route_that_is_written_reads_back_equal(route in any_route(), remove: bool) {
|
||||||
|
let change = if remove { Change::Remove } else { Change::Replace };
|
||||||
|
let read = read_back(&route, change).1;
|
||||||
|
prop_assert_eq!(read, Message::Route { present: !remove, route });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whatever arrives, decoding returns: a message or an error. What
|
||||||
|
/// it reads is within what arrived, and it always moves on.
|
||||||
|
#[test]
|
||||||
|
fn noise_is_never_more_than_an_error(bytes in proptest::collection::vec(any::<u8>(), 0..120)) {
|
||||||
|
if let Ok((_, _, used)) = decode(&bytes) {
|
||||||
|
prop_assert!(used > 0 && used <= bytes.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Noise behind a header that could be a message's, to get past it.
|
||||||
|
#[test]
|
||||||
|
fn noise_behind_a_plausible_header_is_never_more_than_an_error(
|
||||||
|
message_type in 0u16..30,
|
||||||
|
body in proptest::collection::vec(any::<u8>(), 0..100),
|
||||||
|
) {
|
||||||
|
let wire = message(message_type, 1, &body, &[]);
|
||||||
|
if let Ok((_, _, used)) = decode(&wire) {
|
||||||
|
prop_assert!(used > 0 && used <= wire.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
//! What should be in the kernel's table, what is, and what to do about the
|
||||||
|
//! difference. No I/O here: this is told what the routing table's best
|
||||||
|
//! routes are and what the kernel said, and answers with the changes to
|
||||||
|
//! ask the kernel for.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::net::{IpAddr, Ipv6Addr};
|
||||||
|
|
||||||
|
use bgp_packet::message::NlriNextHop;
|
||||||
|
use bgp_primitives::constants::IpVersion;
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
|
||||||
|
use crate::interfaces::Interfaces;
|
||||||
|
|
||||||
|
/// Where the kernel is to send packets for a prefix.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Target {
|
||||||
|
pub gateway: IpAddr,
|
||||||
|
/// The index of the interface the gateway is on.
|
||||||
|
pub interface: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a route's next hop is not something the kernel can be given.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Unresolved {
|
||||||
|
/// The route has no next hop.
|
||||||
|
Missing,
|
||||||
|
/// The next hop is of the other IP version than the prefix.
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Unresolved {
|
||||||
|
pub const ALL: [Self; 4] = [
|
||||||
|
Self::Missing,
|
||||||
|
Self::OtherFamily,
|
||||||
|
Self::Ourselves,
|
||||||
|
Self::NotConnected,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Missing => "no next hop",
|
||||||
|
Self::OtherFamily => "next hop of the other IP version",
|
||||||
|
Self::Ourselves => "next hop is this machine",
|
||||||
|
Self::NotConnected => "next hop not directly connected",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_link_local(address: &Ipv6Addr) -> bool {
|
||||||
|
address.segments()[0] & 0xffc0 == 0xfe80
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Works out where the kernel is to send packets for a route to `prefix`
|
||||||
|
/// with the next hop `next_hop`, learned from the peer at `peer`.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
pub fn resolve(
|
||||||
|
interfaces: &Interfaces,
|
||||||
|
prefix: &IpPrefix,
|
||||||
|
next_hop: Option<&NlriNextHop>,
|
||||||
|
peer: Option<IpAddr>,
|
||||||
|
) -> Result<Target, Unresolved> {
|
||||||
|
let next_hop = next_hop.ok_or(Unresolved::Missing)?;
|
||||||
|
let version = match next_hop {
|
||||||
|
NlriNextHop::Ipv4(_) => IpVersion::Ipv4,
|
||||||
|
NlriNextHop::Ipv6(_) | NlriNextHop::Ipv6WithLl { .. } => IpVersion::Ipv6,
|
||||||
|
};
|
||||||
|
if version != prefix.address_family() {
|
||||||
|
return Err(Unresolved::OtherFamily);
|
||||||
|
}
|
||||||
|
let direct = |gateway: IpAddr| {
|
||||||
|
if interfaces.is_local(gateway) {
|
||||||
|
return Err(Unresolved::Ourselves);
|
||||||
|
}
|
||||||
|
interfaces
|
||||||
|
.connected(gateway)
|
||||||
|
.map(|connected| Target {
|
||||||
|
gateway,
|
||||||
|
interface: connected.interface,
|
||||||
|
})
|
||||||
|
.ok_or(Unresolved::NotConnected)
|
||||||
|
};
|
||||||
|
// The interface the peer is a neighbor on, for a link-local gateway.
|
||||||
|
let on_peers_link = |gateway: Ipv6Addr| {
|
||||||
|
peer.and_then(|peer| interfaces.connected(peer))
|
||||||
|
.map(|connected| Target {
|
||||||
|
gateway: gateway.into(),
|
||||||
|
interface: connected.interface,
|
||||||
|
})
|
||||||
|
.ok_or(Unresolved::NotConnected)
|
||||||
|
};
|
||||||
|
match *next_hop {
|
||||||
|
NlriNextHop::Ipv4(gateway) => direct(gateway.into()),
|
||||||
|
NlriNextHop::Ipv6(gateway) if is_link_local(&gateway) => on_peers_link(gateway),
|
||||||
|
NlriNextHop::Ipv6(gateway) => direct(gateway.into()),
|
||||||
|
// The global address if it is a neighbor; else the link-local one,
|
||||||
|
// which is there for exactly that case (RFC 2545 §3).
|
||||||
|
NlriNextHop::Ipv6WithLl { global, link_local } => match direct(global.into()) {
|
||||||
|
Err(Unresolved::NotConnected) => on_peers_link(link_local),
|
||||||
|
resolved => resolved,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A change to ask the kernel for.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Op {
|
||||||
|
/// Put this route there, in place of ours for the prefix if there is
|
||||||
|
/// one.
|
||||||
|
Install {
|
||||||
|
prefix: IpPrefix,
|
||||||
|
target: Target,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
prefix: IpPrefix,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Op {
|
||||||
|
pub fn prefix(&self) -> &IpPrefix {
|
||||||
|
match self {
|
||||||
|
Self::Install { prefix, .. } | Self::Remove { prefix } => prefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
/// What the kernel has; `None` for a route found there at startup that
|
||||||
|
/// is not of a shape we make.
|
||||||
|
target: Option<Target>,
|
||||||
|
/// Whether the prefix was heard of since the last reset.
|
||||||
|
confirmed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The routes of ours in the kernel, kept in step with the best routes.
|
||||||
|
///
|
||||||
|
/// It follows what a reader of the routing table is given: the whole
|
||||||
|
/// table between a reset and its completion, and changes after. Whatever
|
||||||
|
/// is in the kernel and was not confirmed by the end of a whole table is
|
||||||
|
/// removed: that is how routes left by an earlier run, or made stale by a
|
||||||
|
/// change of interfaces, are cleared out.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Plan {
|
||||||
|
/// What the kernel has been asked to have, and has not refused.
|
||||||
|
installed: HashMap<IpPrefix, Entry>,
|
||||||
|
/// Prefixes whose best route is not installed, and why.
|
||||||
|
unresolved: HashMap<IpPrefix, Unresolved>,
|
||||||
|
/// How many of them for each reason, in the order of
|
||||||
|
/// [`Unresolved::ALL`].
|
||||||
|
unresolved_counts: [usize; 4],
|
||||||
|
/// Prefixes whose route the kernel refused.
|
||||||
|
refused: HashSet<IpPrefix>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plan {
|
||||||
|
/// How many routes of ours the kernel has.
|
||||||
|
pub fn installed(&self) -> usize {
|
||||||
|
self.installed.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
self.unresolved.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, by reason, leaving out the reasons there are none of.
|
||||||
|
pub fn unresolved_by_reason(&self) -> impl Iterator<Item = (Unresolved, usize)> {
|
||||||
|
Unresolved::ALL
|
||||||
|
.into_iter()
|
||||||
|
.zip(self.unresolved_counts)
|
||||||
|
.filter(|(_, count)| *count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notes why the best route of `prefix` is not installed, or with
|
||||||
|
/// `None` that this is no longer so.
|
||||||
|
fn set_unresolved(&mut self, prefix: IpPrefix, reason: Option<Unresolved>) {
|
||||||
|
let before = match reason {
|
||||||
|
Some(reason) => self.unresolved.insert(prefix, reason),
|
||||||
|
None => self.unresolved.remove(&prefix),
|
||||||
|
};
|
||||||
|
for (reason, by) in [(before, -1isize), (reason, 1)] {
|
||||||
|
if let Some(reason) = reason {
|
||||||
|
let count = &mut self.unresolved_counts[reason as usize];
|
||||||
|
*count = count.wrapping_add_signed(by);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many prefixes have a best route that the kernel refused.
|
||||||
|
pub fn refused(&self) -> usize {
|
||||||
|
self.refused.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prefixes of the routes the kernel has.
|
||||||
|
pub fn installed_prefixes(&self) -> impl Iterator<Item = &IpPrefix> {
|
||||||
|
self.installed.keys()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notes a route of ours that the kernel already has, from before this
|
||||||
|
/// run. It stays only if the routing table turns out to have the same.
|
||||||
|
pub fn adopt(&mut self, prefix: IpPrefix, target: Option<Target>) {
|
||||||
|
self.installed.insert(
|
||||||
|
prefix,
|
||||||
|
Entry {
|
||||||
|
target,
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole table follows: nothing is known to be wanted until it is
|
||||||
|
/// heard of again.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
for entry in self.installed.values_mut() {
|
||||||
|
entry.confirmed = false;
|
||||||
|
}
|
||||||
|
self.unresolved.clear();
|
||||||
|
self.unresolved_counts = [0; 4];
|
||||||
|
self.refused.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The best route for `prefix` is now one that resolves as `target`.
|
||||||
|
pub fn best(&mut self, prefix: IpPrefix, target: Result<Target, Unresolved>) -> Option<Op> {
|
||||||
|
self.refused.remove(&prefix);
|
||||||
|
match target {
|
||||||
|
Ok(target) => {
|
||||||
|
self.set_unresolved(prefix, None);
|
||||||
|
let entry = self.installed.entry(prefix).or_insert(Entry {
|
||||||
|
target: None,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
|
entry.confirmed = true;
|
||||||
|
if entry.target == Some(target) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
entry.target = Some(target);
|
||||||
|
Some(Op::Install { prefix, target })
|
||||||
|
}
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `prefix` has no route any more.
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole table has been given: what was not in it goes.
|
||||||
|
pub fn synced(&mut self) -> Vec<Op> {
|
||||||
|
let stale: Vec<IpPrefix> = self
|
||||||
|
.installed
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, entry)| !entry.confirmed)
|
||||||
|
.map(|(prefix, _)| *prefix)
|
||||||
|
.collect();
|
||||||
|
stale
|
||||||
|
.into_iter()
|
||||||
|
.map(|prefix| {
|
||||||
|
self.installed.remove(&prefix);
|
||||||
|
Op::Remove { prefix }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kernel refused `op`. Returns what to ask of it instead, if
|
||||||
|
/// anything.
|
||||||
|
pub fn refused_by_kernel(&mut self, op: &Op) -> Option<Op> {
|
||||||
|
match op {
|
||||||
|
Op::Install { prefix, target } => {
|
||||||
|
// If the plan has moved on since, this is old news.
|
||||||
|
if self.installed.get(prefix)?.target != Some(*target) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.installed.remove(prefix);
|
||||||
|
self.refused.insert(*prefix);
|
||||||
|
// What the kernel refused to replace, it still has.
|
||||||
|
Some(Op::Remove { prefix: *prefix })
|
||||||
|
}
|
||||||
|
// It was not there to remove: as wanted.
|
||||||
|
Op::Remove { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything goes: the changes that empty the kernel of our routes.
|
||||||
|
pub fn clear(&mut self) -> Vec<Op> {
|
||||||
|
self.unresolved.clear();
|
||||||
|
self.unresolved_counts = [0; 4];
|
||||||
|
self.refused.clear();
|
||||||
|
self.installed
|
||||||
|
.drain()
|
||||||
|
.map(|(prefix, _)| Op::Remove { prefix })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::netlink::{Address, Link, Message};
|
||||||
|
|
||||||
|
fn prefix(s: &str) -> IpPrefix {
|
||||||
|
IpPrefix::try_from(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target(gateway: &str, interface: u32) -> Target {
|
||||||
|
Target {
|
||||||
|
gateway: gateway.parse().unwrap(),
|
||||||
|
interface,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interfaces() -> Interfaces {
|
||||||
|
let mut interfaces = Interfaces::default();
|
||||||
|
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 {
|
||||||
|
present: true,
|
||||||
|
link: Link {
|
||||||
|
index,
|
||||||
|
name: format!("eth{index}"),
|
||||||
|
up: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
interfaces.apply(&Message::Address {
|
||||||
|
present: true,
|
||||||
|
address: Address {
|
||||||
|
interface: index,
|
||||||
|
local: local.parse().unwrap(),
|
||||||
|
network: prefix(network),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
interfaces
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_next_hop_resolves_if_it_is_a_neighbor() {
|
||||||
|
let interfaces = interfaces();
|
||||||
|
let v4 = prefix("203.0.113.0/24");
|
||||||
|
let v6 = prefix("2001:db8:100::/48");
|
||||||
|
let peer = Some("192.0.2.2".parse().unwrap());
|
||||||
|
let v4_hop = |s: &str| NlriNextHop::Ipv4(s.parse().unwrap());
|
||||||
|
let v6_hop = |s: &str| NlriNextHop::Ipv6(s.parse().unwrap());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&interfaces, &v4, Some(&v4_hop("192.0.2.2")), peer),
|
||||||
|
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),
|
||||||
|
Ok(target("198.51.100.9", 3))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&interfaces, &v6, Some(&v6_hop("2001:db8::2")), peer),
|
||||||
|
Ok(target("2001:db8::2", 2))
|
||||||
|
);
|
||||||
|
for (hop, why) in [
|
||||||
|
(Some(v4_hop("10.9.9.9")), Unresolved::NotConnected),
|
||||||
|
(Some(v4_hop("192.0.2.1")), Unresolved::Ourselves),
|
||||||
|
(Some(v6_hop("2001:db8::2")), Unresolved::OtherFamily),
|
||||||
|
(None, Unresolved::Missing),
|
||||||
|
] {
|
||||||
|
assert_eq!(resolve(&interfaces, &v4, hop.as_ref(), peer), Err(why));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&interfaces, &v6, Some(&v4_hop("192.0.2.2")), peer),
|
||||||
|
Err(Unresolved::OtherFamily)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_local_next_hop_is_on_the_peers_link() {
|
||||||
|
let interfaces = interfaces();
|
||||||
|
let v6 = prefix("2001:db8:100::/48");
|
||||||
|
let peer = Some("2001:db8::2".parse().unwrap());
|
||||||
|
let link_local: Ipv6Addr = "fe80::2".parse().unwrap();
|
||||||
|
let both = |global: &str| NlriNextHop::Ipv6WithLl {
|
||||||
|
global: global.parse().unwrap(),
|
||||||
|
link_local,
|
||||||
|
};
|
||||||
|
// The global address is a neighbor: it is used.
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&interfaces, &v6, Some(&both("2001:db8::2")), peer),
|
||||||
|
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),
|
||||||
|
Ok(target("fe80::2", 2))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&interfaces, &v6, Some(&NlriNextHop::Ipv6(link_local)), peer),
|
||||||
|
Ok(target("fe80::2", 2))
|
||||||
|
);
|
||||||
|
// A peer that is not a neighbor says nothing about the link.
|
||||||
|
let far = Some("2001:db8:ffff::9".parse().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&interfaces, &v6, Some(&NlriNextHop::Ipv6(link_local)), far),
|
||||||
|
Err(Unresolved::NotConnected)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&interfaces, &v6, Some(&both("2001:db8::1")), peer),
|
||||||
|
Err(Unresolved::Ourselves)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_kernel_is_asked_only_for_what_changes() {
|
||||||
|
let mut plan = Plan::default();
|
||||||
|
let p = prefix("203.0.113.0/24");
|
||||||
|
let (a, b) = (target("192.0.2.2", 2), target("192.0.2.3", 2));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
plan.best(p, Ok(a)),
|
||||||
|
Some(Op::Install {
|
||||||
|
prefix: p,
|
||||||
|
target: a
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// The best route changes to one with the same next hop: nothing
|
||||||
|
// for the kernel.
|
||||||
|
assert_eq!(plan.best(p, Ok(a)), None);
|
||||||
|
assert_eq!(
|
||||||
|
plan.best(p, Ok(b)),
|
||||||
|
Some(Op::Install {
|
||||||
|
prefix: p,
|
||||||
|
target: b
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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)),
|
||||||
|
Some(Op::Remove { prefix: p })
|
||||||
|
);
|
||||||
|
assert_eq!((plan.installed(), plan.unresolved()), (0, 1));
|
||||||
|
assert_eq!(
|
||||||
|
plan.unresolved_by_reason().collect::<Vec<_>>(),
|
||||||
|
[(Unresolved::NotConnected, 1)]
|
||||||
|
);
|
||||||
|
assert_eq!(plan.withdrawn(p), None);
|
||||||
|
assert_eq!(plan.unresolved_by_reason().count(), 0);
|
||||||
|
assert_eq!(plan.unresolved(), 0);
|
||||||
|
|
||||||
|
plan.best(p, Ok(a));
|
||||||
|
assert_eq!(plan.withdrawn(p), Some(Op::Remove { prefix: p }));
|
||||||
|
assert_eq!(plan.withdrawn(p), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_a_whole_table_does_not_confirm_is_removed() {
|
||||||
|
let mut plan = Plan::default();
|
||||||
|
let (kept, changed, stale, odd) = (
|
||||||
|
prefix("203.0.113.0/24"),
|
||||||
|
prefix("198.18.0.0/15"),
|
||||||
|
prefix("192.88.99.0/24"),
|
||||||
|
prefix("2001:db8:5::/48"),
|
||||||
|
);
|
||||||
|
let a = target("192.0.2.2", 2);
|
||||||
|
// Left in the kernel by an earlier run.
|
||||||
|
plan.adopt(kept, Some(a));
|
||||||
|
plan.adopt(changed, Some(a));
|
||||||
|
plan.adopt(stale, Some(a));
|
||||||
|
plan.adopt(odd, None);
|
||||||
|
assert_eq!(plan.installed(), 4);
|
||||||
|
|
||||||
|
plan.reset();
|
||||||
|
// The same as the kernel has: nothing to do.
|
||||||
|
assert_eq!(plan.best(kept, Ok(a)), None);
|
||||||
|
let b = target("192.0.2.3", 2);
|
||||||
|
assert_eq!(
|
||||||
|
plan.best(changed, Ok(b)),
|
||||||
|
Some(Op::Install {
|
||||||
|
prefix: changed,
|
||||||
|
target: b
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let mut removed = plan.synced();
|
||||||
|
removed.sort_by_key(|op| *op.prefix());
|
||||||
|
let mut expected = vec![Op::Remove { prefix: stale }, Op::Remove { prefix: odd }];
|
||||||
|
expected.sort_by_key(|op| *op.prefix());
|
||||||
|
assert_eq!(removed, expected);
|
||||||
|
assert_eq!(plan.installed(), 2);
|
||||||
|
// A second completion finds nothing more.
|
||||||
|
assert_eq!(plan.synced(), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_route_the_kernel_refuses_is_not_counted_as_there() {
|
||||||
|
let mut plan = Plan::default();
|
||||||
|
let p = prefix("203.0.113.0/24");
|
||||||
|
let (a, b) = (target("192.0.2.2", 2), target("192.0.2.3", 2));
|
||||||
|
let install = plan.best(p, Ok(a)).unwrap();
|
||||||
|
// What it refused to put there may have left an older one of ours.
|
||||||
|
assert_eq!(
|
||||||
|
plan.refused_by_kernel(&install),
|
||||||
|
Some(Op::Remove { prefix: p })
|
||||||
|
);
|
||||||
|
assert_eq!((plan.installed(), plan.refused()), (0, 1));
|
||||||
|
// Refusing to remove what is not there is as wanted.
|
||||||
|
assert_eq!(plan.refused_by_kernel(&Op::Remove { prefix: p }), None);
|
||||||
|
// The next best route is tried afresh.
|
||||||
|
assert!(plan.best(p, Ok(b)).is_some());
|
||||||
|
assert_eq!((plan.installed(), plan.refused()), (1, 0));
|
||||||
|
// News of a refusal of what has since been replaced is ignored.
|
||||||
|
assert_eq!(plan.refused_by_kernel(&install), None);
|
||||||
|
assert_eq!(plan.installed(), 1);
|
||||||
|
|
||||||
|
let mut cleared = plan.clear();
|
||||||
|
assert_eq!(cleared.pop(), Some(Op::Remove { prefix: p }));
|
||||||
|
assert_eq!(plan.installed(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens to a table of best routes.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Step {
|
||||||
|
Best(u8, Option<u8>),
|
||||||
|
Withdrawn(u8),
|
||||||
|
/// A reader that starts over: the whole table again.
|
||||||
|
Resync,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn any_step() -> impl Strategy<Value = Step> {
|
||||||
|
prop_oneof![
|
||||||
|
4 => (0u8..12, proptest::option::weighted(0.8, 0u8..3)).prop_map(|(p, t)| Step::Best(p, t)),
|
||||||
|
2 => (0u8..12).prop_map(Step::Withdrawn),
|
||||||
|
1 => Just(Step::Resync),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
/// exactly the best routes that resolve.
|
||||||
|
#[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),
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
let mut kernel: HashMap<IpPrefix, Target> = HashMap::new();
|
||||||
|
let mut plan = Plan::default();
|
||||||
|
for (prefix, gateway) in left_behind {
|
||||||
|
kernel.insert(p(prefix), t(gateway));
|
||||||
|
plan.adopt(p(prefix), Some(t(gateway)));
|
||||||
|
}
|
||||||
|
// The best routes: the prefix's next hop, if it resolves.
|
||||||
|
let mut table: HashMap<u8, Option<u8>> = HashMap::new();
|
||||||
|
let apply = |kernel: &mut HashMap<IpPrefix, Target>, op: Option<Op>| match op {
|
||||||
|
Some(Op::Install { prefix, target }) => { kernel.insert(prefix, target); }
|
||||||
|
Some(Op::Remove { prefix }) => { kernel.remove(&prefix); }
|
||||||
|
None => {}
|
||||||
|
};
|
||||||
|
let resolved = |gateway: Option<u8>| gateway.map(t).ok_or(Unresolved::NotConnected);
|
||||||
|
// A new reader is first given the whole table.
|
||||||
|
let steps = std::iter::once(Step::Resync).chain(steps);
|
||||||
|
for step in steps {
|
||||||
|
match step {
|
||||||
|
Step::Best(prefix, gateway) => {
|
||||||
|
table.insert(prefix, gateway);
|
||||||
|
apply(&mut kernel, plan.best(p(prefix), resolved(gateway)));
|
||||||
|
}
|
||||||
|
Step::Withdrawn(prefix) => {
|
||||||
|
table.remove(&prefix);
|
||||||
|
apply(&mut kernel, plan.withdrawn(p(prefix)));
|
||||||
|
}
|
||||||
|
Step::Resync => {
|
||||||
|
plan.reset();
|
||||||
|
for (prefix, gateway) in &table {
|
||||||
|
apply(&mut kernel, plan.best(p(*prefix), resolved(*gateway)));
|
||||||
|
}
|
||||||
|
for op in plan.synced() {
|
||||||
|
apply(&mut kernel, Some(op));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let wanted: HashMap<IpPrefix, Target> = table
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(prefix, gateway)| Some((p(*prefix), t((*gateway)?))))
|
||||||
|
.collect();
|
||||||
|
prop_assert_eq!(&kernel, &wanted);
|
||||||
|
prop_assert_eq!(plan.installed(), wanted.len());
|
||||||
|
prop_assert_eq!(plan.unresolved(), table.len() - wanted.len());
|
||||||
|
prop_assert_eq!(
|
||||||
|
plan.unresolved_by_reason().map(|(_, count)| count).sum::<usize>(),
|
||||||
|
plan.unresolved()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
//! The kernel's routes against a real kernel: what `ip route` shows after
|
||||||
|
//! the routing table changes.
|
||||||
|
//!
|
||||||
|
//! These change the machine's interfaces and routes, so they are not run
|
||||||
|
//! by `cargo test`. `scripts/kernel-tests.sh` runs them in a network
|
||||||
|
//! namespace of their own, with the capability they need
|
||||||
|
//! (CAP_NET_ADMIN) and nothing more.
|
||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bgp_fib::{Fib, FibConfig, FibError};
|
||||||
|
use bgp_packet::message::{
|
||||||
|
AsPathAttribute, MultiExitDiscPathAttribute, NextHopPathAttribute, NlriNextHop,
|
||||||
|
OriginPathAttribute, PathAttribute,
|
||||||
|
};
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
use bgp_rib::table::{AttrSet, PeerIndex, PeerInfo, RibTable};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
fn prefix(s: &str) -> IpPrefix {
|
||||||
|
IpPrefix::try_from(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `ip` and returns what it printed. Panics if it fails.
|
||||||
|
#[track_caller]
|
||||||
|
fn ip(arguments: &str) -> String {
|
||||||
|
let output = Command::new("ip")
|
||||||
|
.args(arguments.split_whitespace())
|
||||||
|
.output()
|
||||||
|
.expect("the ip command is there");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"ip {arguments}: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8_lossy(&output.stdout).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a test has of the machine: an interface of its own with
|
||||||
|
/// 10.`n`.0.1/24 and 2001:db8:`n`::1/64 on it, and a routing table of its
|
||||||
|
/// own. Tests run side by side in one network namespace, and `n` keeps
|
||||||
|
/// them apart.
|
||||||
|
struct Machine {
|
||||||
|
n: u8,
|
||||||
|
rib: Arc<RibTable>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Machine {
|
||||||
|
fn new(n: u8) -> Self {
|
||||||
|
let machine = Self {
|
||||||
|
n,
|
||||||
|
rib: Arc::new(RibTable::new(4)),
|
||||||
|
};
|
||||||
|
ip(&format!("link add {} type dummy", machine.interface()));
|
||||||
|
machine.add_addresses();
|
||||||
|
ip(&format!("link set {} up", machine.interface()));
|
||||||
|
machine
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interface(&self) -> String {
|
||||||
|
format!("ferrix{}", self.n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_addresses(&self) {
|
||||||
|
let (n, interface) = (self.n, self.interface());
|
||||||
|
ip(&format!("addr add 10.{n}.0.1/24 dev {interface}"));
|
||||||
|
ip(&format!(
|
||||||
|
"-6 addr add 2001:db8:{n}::1/64 dev {interface} nodad"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table(&self) -> u32 {
|
||||||
|
1000 + u32::from(self.n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A neighbor on the test's interface.
|
||||||
|
fn neighbor(&self, host: u8) -> Ipv4Addr {
|
||||||
|
Ipv4Addr::new(10, self.n, 0, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&self) -> (Fib, JoinHandle<Result<(), FibError>>) {
|
||||||
|
Fib::spawn(
|
||||||
|
Arc::clone(&self.rib),
|
||||||
|
FibConfig {
|
||||||
|
table: self.table(),
|
||||||
|
metric: 20,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("the kernel can be talked to")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer(&self, host: u8) -> PeerIndex {
|
||||||
|
self.rib
|
||||||
|
.add_peer(PeerInfo {
|
||||||
|
asn: 65000 + u32::from(host),
|
||||||
|
router_id: self.neighbor(host),
|
||||||
|
address: self.neighbor(host).into(),
|
||||||
|
ibgp: false,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attributes with the next hop `next_hop` and, to say which of two
|
||||||
|
/// routes is the better, `med`.
|
||||||
|
fn via(&self, next_hop: IpAddr, med: u32) -> Arc<AttrSet> {
|
||||||
|
let mut attributes = vec![
|
||||||
|
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||||
|
AsPathAttribute::from_asns(vec![65001]),
|
||||||
|
PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(med)),
|
||||||
|
];
|
||||||
|
let mp_next_hop = match next_hop {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
attributes.push(PathAttribute::NextHop(NextHopPathAttribute(v4)));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
IpAddr::V6(v6) => Some(NlriNextHop::Ipv6(v6)),
|
||||||
|
};
|
||||||
|
self.rib
|
||||||
|
.attrs()
|
||||||
|
.intern(&attributes, mp_next_hop.as_ref())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The routes in the test's table, as `ip route` prints them.
|
||||||
|
fn kernel_routes(&self) -> BTreeSet<String> {
|
||||||
|
let table = self.table();
|
||||||
|
let mut routes = BTreeSet::new();
|
||||||
|
for family in ["-4", "-6"] {
|
||||||
|
// A table nothing was ever put in does not exist yet.
|
||||||
|
let output = Command::new("ip")
|
||||||
|
.args([family, "route", "show", "table", &table.to_string()])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
routes.extend(
|
||||||
|
String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||||
|
// IPv6 routes are shown with "pref medium".
|
||||||
|
.map(|line| line.replace(" pref medium", ""))
|
||||||
|
.filter(|line| !line.is_empty()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
routes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits until the kernel's table holds exactly `expected`.
|
||||||
|
#[track_caller]
|
||||||
|
fn kernel_becomes(&self, expected: &[String]) {
|
||||||
|
let expected: BTreeSet<String> = expected.iter().cloned().collect();
|
||||||
|
let start = Instant::now();
|
||||||
|
while self.kernel_routes() != expected {
|
||||||
|
assert!(
|
||||||
|
start.elapsed() < TIMEOUT,
|
||||||
|
"the kernel has {:#?}; expected {expected:#?}",
|
||||||
|
self.kernel_routes()
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How `ip route` shows a route of ours.
|
||||||
|
fn shown(&self, prefix: &str, gateway: impl std::fmt::Display) -> String {
|
||||||
|
format!(
|
||||||
|
"{prefix} via {gateway} dev {} proto bgp metric 20",
|
||||||
|
self.interface()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Machine {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = Command::new("ip")
|
||||||
|
.args(["link", "del", &self.interface()])
|
||||||
|
.output();
|
||||||
|
for family in ["-4", "-6"] {
|
||||||
|
let _ = Command::new("ip")
|
||||||
|
.args([family, "route", "flush", "table", &self.table().to_string()])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits for `condition` to hold of the status.
|
||||||
|
#[track_caller]
|
||||||
|
fn status_becomes(fib: &Fib, what: &str, condition: impl Fn(&bgp_fib::Status) -> bool) {
|
||||||
|
let start = Instant::now();
|
||||||
|
while !condition(&fib.status()) {
|
||||||
|
assert!(
|
||||||
|
start.elapsed() < TIMEOUT,
|
||||||
|
"{what}: the status is {:#?}",
|
||||||
|
fib.status()
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The best route of each prefix is in the kernel, and follows the table:
|
||||||
|
/// when the best route changes, when it goes, and when there is none whose
|
||||||
|
/// next hop can be reached.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
|
||||||
|
async fn the_kernel_has_the_best_route_of_each_prefix() {
|
||||||
|
let machine = Machine::new(1);
|
||||||
|
let (near, far) = (machine.peer(2), machine.peer(3));
|
||||||
|
let via_near = machine.via(machine.neighbor(2).into(), 50);
|
||||||
|
// What was there before the daemon started is installed too.
|
||||||
|
machine
|
||||||
|
.rib
|
||||||
|
.announce(near, &via_near, &[prefix("203.0.113.0/24")]);
|
||||||
|
let (fib, task) = machine.start();
|
||||||
|
machine.kernel_becomes(&[machine.shown("203.0.113.0/24", machine.neighbor(2))]);
|
||||||
|
status_becomes(&fib, "in step", |status| {
|
||||||
|
status.synced && status.installed == 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// More routes, IPv6 among them.
|
||||||
|
let v6_neighbor: IpAddr = "2001:db8:1::2".parse().unwrap();
|
||||||
|
machine.rib.announce(
|
||||||
|
near,
|
||||||
|
&machine.via(v6_neighbor, 50),
|
||||||
|
&[prefix("2001:db8:100::/48")],
|
||||||
|
);
|
||||||
|
machine
|
||||||
|
.rib
|
||||||
|
.announce(near, &via_near, &[prefix("198.18.0.0/15")]);
|
||||||
|
machine.kernel_becomes(&[
|
||||||
|
machine.shown("203.0.113.0/24", machine.neighbor(2)),
|
||||||
|
machine.shown("198.18.0.0/15", machine.neighbor(2)),
|
||||||
|
machine.shown("2001:db8:100::/48", v6_neighbor),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A better route from another peer takes the first one's place.
|
||||||
|
let via_far = machine.via(machine.neighbor(3).into(), 10);
|
||||||
|
machine
|
||||||
|
.rib
|
||||||
|
.announce(far, &via_far, &[prefix("203.0.113.0/24")]);
|
||||||
|
machine.kernel_becomes(&[
|
||||||
|
machine.shown("203.0.113.0/24", machine.neighbor(3)),
|
||||||
|
machine.shown("198.18.0.0/15", machine.neighbor(2)),
|
||||||
|
machine.shown("2001:db8:100::/48", v6_neighbor),
|
||||||
|
]);
|
||||||
|
// It goes: the first is the best again.
|
||||||
|
machine.rib.withdraw(far, &[prefix("203.0.113.0/24")]);
|
||||||
|
machine.kernel_becomes(&[
|
||||||
|
machine.shown("203.0.113.0/24", machine.neighbor(2)),
|
||||||
|
machine.shown("198.18.0.0/15", machine.neighbor(2)),
|
||||||
|
machine.shown("2001:db8:100::/48", v6_neighbor),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A best route whose next hop is not a neighbor is not installed, and
|
||||||
|
// the route it replaced does not stay.
|
||||||
|
let unreachable = machine.via("172.31.9.9".parse().unwrap(), 5);
|
||||||
|
machine
|
||||||
|
.rib
|
||||||
|
.announce(far, &unreachable, &[prefix("198.18.0.0/15")]);
|
||||||
|
machine.kernel_becomes(&[
|
||||||
|
machine.shown("203.0.113.0/24", machine.neighbor(2)),
|
||||||
|
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)]
|
||||||
|
});
|
||||||
|
|
||||||
|
// A session ends: its routes go.
|
||||||
|
machine.rib.remove_peer(near);
|
||||||
|
machine.kernel_becomes(&[]);
|
||||||
|
status_becomes(&fib, "nothing installed", |status| status.installed == 0);
|
||||||
|
|
||||||
|
fib.stop().await;
|
||||||
|
task.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes of ours left by a run that ended badly are removed at the next
|
||||||
|
/// start unless they are still wanted. Routes that are not ours are left
|
||||||
|
/// alone, whatever they are for.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
|
||||||
|
async fn routes_left_behind_are_cleared_and_others_are_not_touched() {
|
||||||
|
let machine = Machine::new(2);
|
||||||
|
let (table, interface) = (machine.table(), machine.interface());
|
||||||
|
let add = |route: &str| ip(&format!("route add {route} dev {interface} table {table}"));
|
||||||
|
// Ours, from before: one that is still right, one that has another
|
||||||
|
// next hop now, one that nobody announces any more.
|
||||||
|
add("203.0.113.0/24 via 10.2.0.2 proto bgp metric 20");
|
||||||
|
add("198.18.0.0/15 via 10.2.0.9 proto bgp metric 20");
|
||||||
|
add("192.88.99.0/24 via 10.2.0.2 proto bgp metric 20");
|
||||||
|
// Not ours: someone's static route, and a BGP route with another
|
||||||
|
// metric, which is how another daemon's would differ.
|
||||||
|
add("192.0.2.0/24 via 10.2.0.7 proto static metric 20");
|
||||||
|
add("192.88.99.0/24 via 10.2.0.7 proto bgp metric 30");
|
||||||
|
let others = [
|
||||||
|
format!("192.0.2.0/24 via 10.2.0.7 dev {interface} proto static metric 20"),
|
||||||
|
format!("192.88.99.0/24 via 10.2.0.7 dev {interface} proto bgp metric 30"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let peer = machine.peer(2);
|
||||||
|
let via = machine.via(machine.neighbor(2).into(), 0);
|
||||||
|
machine.rib.announce(
|
||||||
|
peer,
|
||||||
|
&via,
|
||||||
|
&[prefix("203.0.113.0/24"), prefix("198.18.0.0/15")],
|
||||||
|
);
|
||||||
|
let (fib, task) = machine.start();
|
||||||
|
machine.kernel_becomes(&[
|
||||||
|
machine.shown("203.0.113.0/24", machine.neighbor(2)),
|
||||||
|
machine.shown("198.18.0.0/15", machine.neighbor(2)),
|
||||||
|
others[0].clone(),
|
||||||
|
others[1].clone(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A run that ends in good order takes its routes with it, and only
|
||||||
|
// its own.
|
||||||
|
fib.stop().await;
|
||||||
|
task.await.unwrap().unwrap();
|
||||||
|
machine.kernel_becomes(&others);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a next hop resolves to depends on the machine's interfaces. When
|
||||||
|
/// they change, the routes are worked out again.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
|
||||||
|
async fn routes_follow_the_machines_interfaces() {
|
||||||
|
let machine = Machine::new(3);
|
||||||
|
let interface = machine.interface();
|
||||||
|
// The interface has no addresses yet: nothing is a neighbor.
|
||||||
|
ip(&format!("addr flush dev {interface}"));
|
||||||
|
let peer = machine.peer(2);
|
||||||
|
let via = machine.via(machine.neighbor(2).into(), 0);
|
||||||
|
machine
|
||||||
|
.rib
|
||||||
|
.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)]
|
||||||
|
});
|
||||||
|
assert!(machine.kernel_routes().is_empty());
|
||||||
|
|
||||||
|
// It gets its address: the next hop is a neighbor now.
|
||||||
|
machine.add_addresses();
|
||||||
|
machine.kernel_becomes(&[machine.shown("203.0.113.0/24", machine.neighbor(2))]);
|
||||||
|
status_becomes(&fib, "the interface is shown", |status| {
|
||||||
|
status.unresolved.is_empty()
|
||||||
|
&& status.interfaces.iter().any(|shown| {
|
||||||
|
shown.name == interface
|
||||||
|
&& shown.up
|
||||||
|
&& shown
|
||||||
|
.addresses
|
||||||
|
.contains(&(IpAddr::from(machine.neighbor(1)), 24))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// It loses it. The kernel drops the route by itself; what matters is
|
||||||
|
// that we know it has.
|
||||||
|
ip(&format!("addr del 10.3.0.1/24 dev {interface}"));
|
||||||
|
machine.kernel_becomes(&[]);
|
||||||
|
status_becomes(&fib, "unresolved again", |status| {
|
||||||
|
status.installed == 0 && status.unresolved.len() == 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// And gets it back, with the interface having been down meanwhile.
|
||||||
|
ip(&format!("link set {interface} down"));
|
||||||
|
ip(&format!("addr add 10.3.0.1/24 dev {interface}"));
|
||||||
|
ip(&format!("link set {interface} up"));
|
||||||
|
machine.kernel_becomes(&[machine.shown("203.0.113.0/24", machine.neighbor(2))]);
|
||||||
|
|
||||||
|
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"]
|
||||||
|
async fn many_routes_are_installed_and_removed() {
|
||||||
|
const ROUTES: usize = 200_000;
|
||||||
|
let machine = Machine::new(4);
|
||||||
|
let peer = machine.peer(2);
|
||||||
|
let via = machine.via(machine.neighbor(2).into(), 0);
|
||||||
|
let prefixes: Vec<IpPrefix> = (0..ROUTES as u32)
|
||||||
|
.map(|n| {
|
||||||
|
let [_, a, b, c] = n.to_be_bytes();
|
||||||
|
IpPrefix::new(
|
||||||
|
bgp_primitives::constants::IpVersion::Ipv4,
|
||||||
|
[100 + a, b, c, 0],
|
||||||
|
24,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
machine.rib.announce(peer, &via, &prefixes);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let (fib, task) = machine.start();
|
||||||
|
status_becomes(&fib, "all installed", |status| {
|
||||||
|
status.synced && status.installed == ROUTES
|
||||||
|
});
|
||||||
|
let installed_in = started.elapsed();
|
||||||
|
assert_eq!(machine.kernel_routes().len(), ROUTES);
|
||||||
|
assert_eq!(fib.status().errors, 0, "{:?}", fib.status().last_error);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
fib.stop().await;
|
||||||
|
task.await.unwrap().unwrap();
|
||||||
|
let removed_in = started.elapsed();
|
||||||
|
assert!(machine.kernel_routes().is_empty());
|
||||||
|
println!(
|
||||||
|
"{ROUTES} routes: installed in {installed_in:?} ({:.0}/s), removed in {removed_in:?}",
|
||||||
|
ROUTES as f64 / installed_in.as_secs_f64()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ version.workspace = true
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
bgp-fib.workspace = true
|
||||||
bgp-packet.workspace = true
|
bgp-packet.workspace = true
|
||||||
bgp-primitives.workspace = true
|
bgp-primitives.workspace = true
|
||||||
bgp-rib.workspace = true
|
bgp-rib.workspace = true
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ impl TestPeer {
|
|||||||
match self.recv().await? {
|
match self.recv().await? {
|
||||||
Message::Update(u) => return Ok(u),
|
Message::Update(u) => return Ok(u),
|
||||||
Message::KeepAlive => {}
|
Message::KeepAlive => {}
|
||||||
other => bail!("Expected UPDATE, got {:?}", other),
|
other => return Err(eyre::eyre!("Expected UPDATE, got {:?}", other)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ impl TestPeer {
|
|||||||
self.ctx.four_octet_asn = four_byte && self.offered_four_octet_asn;
|
self.ctx.four_octet_asn = four_byte && self.offered_four_octet_asn;
|
||||||
Ok(o)
|
Ok(o)
|
||||||
}
|
}
|
||||||
other => bail!("Expected OPEN, got {:?}", other),
|
other => Err(eyre::eyre!("Expected OPEN, got {:?}", other)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +189,12 @@ impl TestPeer {
|
|||||||
// Expect server KEEPALIVE
|
// Expect server KEEPALIVE
|
||||||
match self.recv().await? {
|
match self.recv().await? {
|
||||||
Message::KeepAlive => {}
|
Message::KeepAlive => {}
|
||||||
other => bail!("Expected KEEPALIVE from server, got {:?}", other),
|
other => {
|
||||||
|
return Err(eyre::eyre!(
|
||||||
|
"Expected KEEPALIVE from server, got {:?}",
|
||||||
|
other
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Send our KEEPALIVE
|
// Send our KEEPALIVE
|
||||||
self.send(&Message::KeepAlive).await?;
|
self.send(&Message::KeepAlive).await?;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bgp_fib::Fib;
|
||||||
use bgp_packet::message::{NlriNextHop, PathAttribute};
|
use bgp_packet::message::{NlriNextHop, PathAttribute};
|
||||||
use bgp_primitives::ip_prefix::IpPrefix;
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
use bgp_rib::LocalRib;
|
use bgp_rib::LocalRib;
|
||||||
@@ -32,6 +33,8 @@ pub struct TestServer {
|
|||||||
pub refusals: RefusalLog,
|
pub refusals: RefusalLog,
|
||||||
/// The data for route origin validation, if the server validates.
|
/// The data for route origin validation, if the server validates.
|
||||||
pub rpki: Option<Rpki>,
|
pub rpki: Option<Rpki>,
|
||||||
|
/// What keeps the best routes in the kernel, if the server does.
|
||||||
|
pub kernel: Option<Fib>,
|
||||||
pub local_asn: u32,
|
pub local_asn: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ impl TestServer {
|
|||||||
originate: vec![],
|
originate: vec![],
|
||||||
http_endpoint: None,
|
http_endpoint: None,
|
||||||
rpki: None,
|
rpki: None,
|
||||||
|
kernel: None,
|
||||||
};
|
};
|
||||||
customize(&mut config);
|
customize(&mut config);
|
||||||
|
|
||||||
@@ -77,6 +81,7 @@ impl TestServer {
|
|||||||
let registry = bound.registry.clone();
|
let registry = bound.registry.clone();
|
||||||
let refusals = bound.refusals.clone();
|
let refusals = bound.refusals.clone();
|
||||||
let rpki = bound.rpki.clone();
|
let rpki = bound.rpki.clone();
|
||||||
|
let kernel = bound.kernel.clone();
|
||||||
|
|
||||||
let handle = tokio::spawn(bound.run());
|
let handle = tokio::spawn(bound.run());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -87,6 +92,7 @@ impl TestServer {
|
|||||||
registry,
|
registry,
|
||||||
refusals,
|
refusals,
|
||||||
rpki,
|
rpki,
|
||||||
|
kernel,
|
||||||
local_asn,
|
local_asn,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -206,6 +212,7 @@ impl TestServer {
|
|||||||
rib: Arc::clone(&self.rib),
|
rib: Arc::clone(&self.rib),
|
||||||
refusals: self.refusals.clone(),
|
refusals: self.refusals.clone(),
|
||||||
rpki: self.rpki.clone(),
|
rpki: self.rpki.clone(),
|
||||||
|
kernel: self.kernel.clone(),
|
||||||
};
|
};
|
||||||
let handle = tokio::spawn(bgp_web::serve_on(state, listener));
|
let handle = tokio::spawn(bgp_web::serve_on(state, listener));
|
||||||
Ok((base_url, HttpGuard { handle }))
|
Ok((base_url, HttpGuard { handle }))
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! The whole daemon against a real kernel: a peer announces routes over a
|
||||||
|
//! session, and `ip route` shows them.
|
||||||
|
//!
|
||||||
|
//! This changes the machine's interfaces and routes, so it is not run by
|
||||||
|
//! `cargo test`; `scripts/kernel-tests.sh` runs it in a network namespace
|
||||||
|
//! of its own.
|
||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bgp_fib::FibConfig;
|
||||||
|
use bgp_integration_tests::test_peer::TestPeer;
|
||||||
|
use bgp_integration_tests::test_server::TestServer;
|
||||||
|
use bgp_packet::message::{
|
||||||
|
AsPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute, PathAttribute,
|
||||||
|
UpdateMessage,
|
||||||
|
};
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
use eyre::Result;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
const TABLE: u32 = 1100;
|
||||||
|
const INTERFACE: &str = "ferrix100";
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn ip(arguments: &str) {
|
||||||
|
let output = Command::new("ip")
|
||||||
|
.args(arguments.split_whitespace())
|
||||||
|
.output()
|
||||||
|
.expect("the ip command is there");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"ip {arguments}: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kernel_routes() -> BTreeSet<String> {
|
||||||
|
let output = Command::new("ip")
|
||||||
|
.args(["route", "show", "table", &TABLE.to_string()])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn kernel_becomes(expected: &[&str]) {
|
||||||
|
let expected: BTreeSet<String> = expected.iter().map(|route| route.to_string()).collect();
|
||||||
|
let start = Instant::now();
|
||||||
|
while kernel_routes() != expected {
|
||||||
|
assert!(
|
||||||
|
start.elapsed() < TIMEOUT,
|
||||||
|
"the kernel has {:#?}; expected {expected:#?}",
|
||||||
|
kernel_routes()
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the test's interface, and its routes with it, when dropped.
|
||||||
|
struct Interface;
|
||||||
|
|
||||||
|
impl Drop for Interface {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = Command::new("ip").args(["link", "del", INTERFACE]).output();
|
||||||
|
let _ = Command::new("ip")
|
||||||
|
.args(["route", "flush", "table", &TABLE.to_string()])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn announce(peer: &mut TestPeer, prefixes: &[&str], next_hop: Ipv4Addr) -> Result<()> {
|
||||||
|
peer.send(&Message::Update(UpdateMessage {
|
||||||
|
withdrawn_routes: vec![],
|
||||||
|
path_attributes: vec![
|
||||||
|
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||||
|
AsPathAttribute::from_asns(vec![65001]),
|
||||||
|
PathAttribute::NextHop(NextHopPathAttribute(next_hop)),
|
||||||
|
],
|
||||||
|
announced_routes: prefixes
|
||||||
|
.iter()
|
||||||
|
.map(|p| IpPrefix::try_from(*p).unwrap().into())
|
||||||
|
.collect(),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A peer's routes go from the session to the kernel, and out of it again
|
||||||
|
/// when they are withdrawn and when the session ends.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
|
||||||
|
async fn test_routes_from_a_session_reach_the_kernel() -> Result<()> {
|
||||||
|
ip(&format!("link add {INTERFACE} type dummy"));
|
||||||
|
let _interface = Interface;
|
||||||
|
ip(&format!("addr add 10.100.0.1/24 dev {INTERFACE}"));
|
||||||
|
ip(&format!("link set {INTERFACE} up"));
|
||||||
|
|
||||||
|
let server = TestServer::start_with(65000, |config| {
|
||||||
|
config.kernel = Some(FibConfig {
|
||||||
|
table: TABLE,
|
||||||
|
metric: 20,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let (base, _http) = server.serve_http().await?;
|
||||||
|
let mut peer = TestPeer::connect(server.addr).await?;
|
||||||
|
peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 2), 90)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// The peer speaks from the loopback and names a router on the test's
|
||||||
|
// interface as next hop, as a route server would.
|
||||||
|
let neighbor = Ipv4Addr::new(10, 100, 0, 2);
|
||||||
|
announce(&mut peer, &["203.0.113.0/24", "198.18.0.0/15"], neighbor).await?;
|
||||||
|
// One by way of a router that is not a neighbor.
|
||||||
|
announce(&mut peer, &["192.88.99.0/24"], Ipv4Addr::new(172, 31, 0, 1)).await?;
|
||||||
|
kernel_becomes(&[
|
||||||
|
"203.0.113.0/24 via 10.100.0.2 dev ferrix100 proto bgp metric 20",
|
||||||
|
"198.18.0.0/15 via 10.100.0.2 dev ferrix100 proto bgp metric 20",
|
||||||
|
]);
|
||||||
|
|
||||||
|
let kernel: Value = reqwest::get(format!("{base}/api/kernel"))
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(kernel["enabled"], true);
|
||||||
|
assert_eq!(kernel["table"], TABLE);
|
||||||
|
assert_eq!(kernel["installed"], 2);
|
||||||
|
assert_eq!(
|
||||||
|
kernel["unresolved"],
|
||||||
|
serde_json::json!({"next hop not directly connected": 1})
|
||||||
|
);
|
||||||
|
let shown = kernel["interfaces"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|interface| interface["name"] == INTERFACE)
|
||||||
|
.expect("the test's interface is listed");
|
||||||
|
assert_eq!(shown["up"], true);
|
||||||
|
// Its own address, beside the link-local one the kernel gave it.
|
||||||
|
assert!(
|
||||||
|
shown["addresses"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&serde_json::json!("10.100.0.1/24")),
|
||||||
|
"{shown}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// One is withdrawn.
|
||||||
|
peer.send(&Message::Update(UpdateMessage {
|
||||||
|
withdrawn_routes: vec![IpPrefix::try_from("198.18.0.0/15").unwrap().into()],
|
||||||
|
path_attributes: vec![],
|
||||||
|
announced_routes: vec![],
|
||||||
|
}))
|
||||||
|
.await?;
|
||||||
|
kernel_becomes(&["203.0.113.0/24 via 10.100.0.2 dev ferrix100 proto bgp metric 20"]);
|
||||||
|
|
||||||
|
// The session ends: packets must not go on following its routes.
|
||||||
|
drop(peer);
|
||||||
|
kernel_becomes(&[]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -171,6 +171,28 @@ impl AttrSet {
|
|||||||
&self.wire
|
&self.wire
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
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::NEXT_HOP => plain = NlriNextHop::from_bytes(attribute.value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
wire = rest;
|
||||||
|
}
|
||||||
|
plain
|
||||||
|
}
|
||||||
|
|
||||||
/// Puts the AS numbers of the route's path in `out`, in place of what
|
/// 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
|
/// 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
|
/// origin's last, sets and all. Returns whether the path has a set in
|
||||||
@@ -511,6 +533,30 @@ mod tests {
|
|||||||
assert_eq!(store.intern(&attrs, None).unwrap().key().med, Some(7));
|
assert_eq!(store.intern(&attrs, None).unwrap().key().med, Some(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_next_hop_is_read_off_a_set() {
|
||||||
|
let store = AttrStore::new();
|
||||||
|
let plain = store.intern(&attributes(vec![3333]), None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
plain.next_hop(),
|
||||||
|
Some(NlriNextHop::Ipv4(Ipv4Addr::new(192, 0, 2, 1)))
|
||||||
|
);
|
||||||
|
// The one that came with the routes counts, where there is one.
|
||||||
|
let with_link_local = NlriNextHop::Ipv6WithLl {
|
||||||
|
global: "2001:db8::1".parse().unwrap(),
|
||||||
|
link_local: "fe80::1".parse().unwrap(),
|
||||||
|
};
|
||||||
|
let v6 = store
|
||||||
|
.intern(&attributes(vec![3333]), Some(&with_link_local))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v6.next_hop(), Some(with_link_local));
|
||||||
|
// A route of our own making may have none.
|
||||||
|
let none = store
|
||||||
|
.intern(&[PathAttribute::Origin(OriginPathAttribute::IGP)], None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(none.next_hop(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_path_is_read_off_a_set_as_it_really_is() {
|
fn the_path_is_read_off_a_set_as_it_really_is() {
|
||||||
use bgp_packet::message::{AsPathAttribute, AsPathSegment};
|
use bgp_packet::message::{AsPathAttribute, AsPathSegment};
|
||||||
|
|||||||
@@ -352,6 +352,13 @@ impl RibTable {
|
|||||||
/// with it, and may then be given to another. Whoever was given it must
|
/// with it, and may then be given to another. Whoever was given it must
|
||||||
/// not use it after that: the table cannot tell a late announcement
|
/// not use it after that: the table cannot tell a late announcement
|
||||||
/// from one of the number's next owner.
|
/// from one of the number's next owner.
|
||||||
|
/// What is known of the peer with this number, if it is in use. Not to
|
||||||
|
/// be called from a visitor that is given routes with a shard held.
|
||||||
|
pub fn peer(&self, index: PeerIndex) -> Option<PeerInfo> {
|
||||||
|
let peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
peers.get(index.as_usize()).copied().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_peer(&self, info: PeerInfo) -> Result<PeerIndex, TooManyPeers> {
|
pub fn add_peer(&self, info: PeerInfo) -> Result<PeerIndex, TooManyPeers> {
|
||||||
let mut peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner);
|
let mut peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
let index = match peers.iter().position(Option::is_none) {
|
let index = match peers.iter().position(Option::is_none) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ version.workspace = true
|
|||||||
bgp-packet.workspace = true
|
bgp-packet.workspace = true
|
||||||
bgp-primitives.workspace = true
|
bgp-primitives.workspace = true
|
||||||
bgp-rib.workspace = true
|
bgp-rib.workspace = true
|
||||||
|
bgp-fib.workspace = true
|
||||||
bgp-rpki.workspace = true
|
bgp-rpki.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
|||||||
@@ -48,6 +48,30 @@ message BgpConfig {
|
|||||||
// Route origin validation (RFC 6811) and AS path verification with the
|
// Route origin validation (RFC 6811) and AS path verification with the
|
||||||
// RPKI. Left out, routes are not validated.
|
// RPKI. Left out, routes are not validated.
|
||||||
Rpki rpki = 10;
|
Rpki rpki = 10;
|
||||||
|
|
||||||
|
// Put the best route of every prefix in the kernel's routing table, so
|
||||||
|
// that the machine forwards packets by them. Left out, the daemon only
|
||||||
|
// speaks BGP and the machine's forwarding is not touched.
|
||||||
|
Kernel kernel = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installing routes in the kernel. Linux only; the daemon needs the
|
||||||
|
// CAP_NET_ADMIN capability.
|
||||||
|
//
|
||||||
|
// The best route of a prefix is installed if its next hop is a neighbor:
|
||||||
|
// an address in the network of one of the machine's interfaces. Routes
|
||||||
|
// are marked as BGP's ("proto bgp"), and every route with that mark, the
|
||||||
|
// table and the metric below is taken to be this daemon's: what it finds
|
||||||
|
// at startup and no longer wants, it removes. When the daemon stops in
|
||||||
|
// good order it removes its routes.
|
||||||
|
message Kernel {
|
||||||
|
// The routing table. Defaults to 254, the main table, which is the one
|
||||||
|
// packets are forwarded by unless rules say otherwise.
|
||||||
|
uint32 table = 1;
|
||||||
|
|
||||||
|
// The metric of the routes. Defaults to 20. Of several routes for a
|
||||||
|
// prefix the kernel uses the one with the lowest metric.
|
||||||
|
uint32 metric = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where to get the validated ROA payloads that say which AS may originate
|
// Where to get the validated ROA payloads that say which AS may originate
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bgp_fib::FibConfig;
|
||||||
use bgp_packet::community;
|
use bgp_packet::community;
|
||||||
use bgp_packet::message::OriginPathAttribute;
|
use bgp_packet::message::OriginPathAttribute;
|
||||||
use bgp_packet::open::AddPathSendRecv;
|
use bgp_packet::open::AddPathSendRecv;
|
||||||
@@ -43,6 +44,8 @@ pub struct Config {
|
|||||||
pub originate: Vec<LocalRoute>,
|
pub originate: Vec<LocalRoute>,
|
||||||
/// Address for the HTTP API; `None` disables it.
|
/// Address for the HTTP API; `None` disables it.
|
||||||
pub http_endpoint: Option<SocketAddr>,
|
pub http_endpoint: Option<SocketAddr>,
|
||||||
|
/// Where to install the best routes in the kernel, if at all.
|
||||||
|
pub kernel: Option<FibConfig>,
|
||||||
/// Where route origin validation gets its data; `None` for no
|
/// Where route origin validation gets its data; `None` for no
|
||||||
/// validation.
|
/// validation.
|
||||||
pub rpki: Option<RpkiConfig>,
|
pub rpki: Option<RpkiConfig>,
|
||||||
@@ -174,6 +177,14 @@ impl Config {
|
|||||||
accept_unconfigured_peers: proto.accept_unconfigured_peers,
|
accept_unconfigured_peers: proto.accept_unconfigured_peers,
|
||||||
originate,
|
originate,
|
||||||
http_endpoint,
|
http_endpoint,
|
||||||
|
kernel: proto.kernel.as_ref().map(|kernel| {
|
||||||
|
let defaults = FibConfig::default();
|
||||||
|
let or = |value: u32, default: u32| if value == 0 { default } else { value };
|
||||||
|
FibConfig {
|
||||||
|
table: or(kernel.table, defaults.table),
|
||||||
|
metric: or(kernel.metric, defaults.metric),
|
||||||
|
}
|
||||||
|
}),
|
||||||
rpki,
|
rpki,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -725,6 +736,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_kernel_is_left_alone_unless_asked_for() {
|
||||||
|
assert_eq!(parse(BASE).unwrap().kernel, None);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&format!("{BASE}kernel {{}}")).unwrap().kernel,
|
||||||
|
Some(FibConfig {
|
||||||
|
table: 254,
|
||||||
|
metric: 20
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&format!("{BASE}kernel {{ table: 1000 metric: 150 }}"))
|
||||||
|
.unwrap()
|
||||||
|
.kernel,
|
||||||
|
Some(FibConfig {
|
||||||
|
table: 1000,
|
||||||
|
metric: 150
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn route_origin_validation_is_off_unless_caches_are_given() {
|
fn route_origin_validation_is_off_unless_caches_are_given() {
|
||||||
assert!(parse(BASE).unwrap().rpki.is_none());
|
assert!(parse(BASE).unwrap().rpki.is_none());
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ pub enum ServerError {
|
|||||||
/// they panic.
|
/// they panic.
|
||||||
#[error("the {task} task stopped unexpectedly: {reason}")]
|
#[error("the {task} task stopped unexpectedly: {reason}")]
|
||||||
TaskStopped { task: &'static str, reason: String },
|
TaskStopped { task: &'static str, reason: String },
|
||||||
|
/// Routes were to be installed in the kernel, and cannot be.
|
||||||
|
#[error(transparent)]
|
||||||
|
Kernel(#[from] bgp_fib::FibError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a configuration was rejected.
|
/// Why a configuration was rejected.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use tokio::net::{TcpListener, TcpSocket, TcpStream};
|
|||||||
use tokio::task::{JoinError, JoinHandle};
|
use tokio::task::{JoinError, JoinHandle};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use bgp_fib::{Fib, FibError};
|
||||||
use bgp_rib::table::RibTable;
|
use bgp_rib::table::RibTable;
|
||||||
use bgp_rib::{LocalRib, RibStopped};
|
use bgp_rib::{LocalRib, RibStopped};
|
||||||
use bgp_rpki::Rpki;
|
use bgp_rpki::Rpki;
|
||||||
@@ -34,6 +35,10 @@ use crate::sync::IgnorePoison;
|
|||||||
/// How long sessions get to send their Cease when the server stops.
|
/// How long sessions get to send their Cease when the server stops.
|
||||||
const SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
|
const SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// How long taking our routes out of the kernel may take at shutdown. A
|
||||||
|
/// full table is some seconds.
|
||||||
|
const KERNEL_GRACE: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// How long to wait before accepting again after an accept failed. What
|
/// How long to wait before accepting again after an accept failed. What
|
||||||
/// makes it fail (no file descriptors left, usually) does not pass at once,
|
/// makes it fail (no file descriptors left, usually) does not pass at once,
|
||||||
/// and trying again in a loop would only fill the log.
|
/// and trying again in a loop would only fill the log.
|
||||||
@@ -77,6 +82,9 @@ pub struct BgpServer {
|
|||||||
pub rpki: Option<Rpki>,
|
pub rpki: Option<Rpki>,
|
||||||
/// The task that keeps that data up to date.
|
/// The task that keeps that data up to date.
|
||||||
rpki_client: Option<JoinHandle<()>>,
|
rpki_client: Option<JoinHandle<()>>,
|
||||||
|
/// What keeps the best routes in the kernel, where that is configured.
|
||||||
|
pub kernel: Option<Fib>,
|
||||||
|
kernel_task: Option<JoinHandle<Result<(), FibError>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BgpServer {
|
impl BgpServer {
|
||||||
@@ -110,11 +118,21 @@ impl BgpServer {
|
|||||||
// The client starts now: with luck the data is there before the
|
// The client starts now: with luck the data is there before the
|
||||||
// first routes are.
|
// first routes are.
|
||||||
let (rpki, rpki_client) = config.rpki.clone().map(Rpki::spawn).unzip();
|
let (rpki, rpki_client) = config.rpki.clone().map(Rpki::spawn).unzip();
|
||||||
|
let rib = Arc::new(RibTable::new(table_shards()));
|
||||||
|
// 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 {
|
||||||
|
Some(kernel) => Some(Fib::spawn(Arc::clone(&rib), kernel)?),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
.unzip();
|
||||||
Ok(BgpServer {
|
Ok(BgpServer {
|
||||||
rpki,
|
rpki,
|
||||||
rpki_client,
|
rpki_client,
|
||||||
|
kernel,
|
||||||
|
kernel_task,
|
||||||
listeners,
|
listeners,
|
||||||
rib: Arc::new(RibTable::new(table_shards())),
|
rib,
|
||||||
local_rib,
|
local_rib,
|
||||||
registry: SessionRegistry::with_configured(configured),
|
registry: SessionRegistry::with_configured(configured),
|
||||||
refusals: RefusalLog::default(),
|
refusals: RefusalLog::default(),
|
||||||
@@ -193,6 +211,7 @@ impl BgpServer {
|
|||||||
tasks.push(named("dialer", dialer));
|
tasks.push(named("dialer", dialer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut kernel_task = self.kernel_task;
|
||||||
let connections = ConnectionCounts::default();
|
let connections = ConnectionCounts::default();
|
||||||
let mut refusals = Refusals::default();
|
let mut refusals = Refusals::default();
|
||||||
let mut accepting: FuturesUnordered<_> = self
|
let mut accepting: FuturesUnordered<_> = self
|
||||||
@@ -205,6 +224,11 @@ impl BgpServer {
|
|||||||
let (listen_addr, result, listener) = tokio::select! {
|
let (listen_addr, result, listener) = tokio::select! {
|
||||||
Some(accepted) = accepting.next() => accepted,
|
Some(accepted) = accepting.next() => accepted,
|
||||||
failure = failed(&self.local_rib, &mut tasks) => {
|
failure = failed(&self.local_rib, &mut tasks) => {
|
||||||
|
error!(err = %failure, "Shutting down: the server cannot continue");
|
||||||
|
shut_down(&self.registry, self.kernel.as_ref()).await;
|
||||||
|
return Err(failure);
|
||||||
|
}
|
||||||
|
failure = kernel_failed(&mut kernel_task) => {
|
||||||
error!(err = %failure, "Shutting down: the server cannot continue");
|
error!(err = %failure, "Shutting down: the server cannot continue");
|
||||||
close_sessions(&self.registry).await;
|
close_sessions(&self.registry).await;
|
||||||
return Err(failure);
|
return Err(failure);
|
||||||
@@ -233,6 +257,43 @@ impl BgpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves when the task that keeps the kernel's routes has ended, which
|
||||||
|
/// while the server runs it does only by failing. Never, if there is no
|
||||||
|
/// such task.
|
||||||
|
async fn kernel_failed(task: &mut Option<JoinHandle<Result<(), FibError>>>) -> ServerError {
|
||||||
|
let Some(running) = task else {
|
||||||
|
return std::future::pending().await;
|
||||||
|
};
|
||||||
|
let ended = running.await;
|
||||||
|
*task = None;
|
||||||
|
match ended {
|
||||||
|
Ok(Err(e)) => e.into(),
|
||||||
|
Ok(Ok(())) => ServerError::TaskStopped {
|
||||||
|
task: "kernel routes",
|
||||||
|
reason: "it was stopped".into(),
|
||||||
|
},
|
||||||
|
Err(e) => ServerError::TaskStopped {
|
||||||
|
task: "kernel routes",
|
||||||
|
reason: e.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leaves in good order: closes every session (see [`close_sessions`]),
|
||||||
|
/// and then takes our routes out of the kernel, if we put any there. In
|
||||||
|
/// that order: a peer goes on sending us packets for our routes until it
|
||||||
|
/// is told we are going.
|
||||||
|
pub async fn shut_down(registry: &SessionRegistry, kernel: Option<&Fib>) {
|
||||||
|
close_sessions(registry).await;
|
||||||
|
if let Some(kernel) = kernel
|
||||||
|
&& tokio::time::timeout(KERNEL_GRACE, kernel.stop())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
warn!("Our routes were not all taken out of the kernel in time");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Closes every session politely and waits a moment for them to go: each
|
/// Closes every session politely and waits a moment for them to go: each
|
||||||
/// peer is told we are going (Cease / Administrative Shutdown), so that it
|
/// peer is told we are going (Cease / Administrative Shutdown), so that it
|
||||||
/// stops using our routes now and not when its hold timer expires.
|
/// stops using our routes now and not when its hold timer expires.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ version.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
|
bgp-fib.workspace = true
|
||||||
bgp-packet.workspace = true
|
bgp-packet.workspace = true
|
||||||
bgp-primitives.workspace = true
|
bgp-primitives.workspace = true
|
||||||
bgp-rib.workspace = true
|
bgp-rib.workspace = true
|
||||||
|
|||||||
@@ -853,6 +853,73 @@ pub async fn rpki(State(state): State<AppState>) -> Json<RpkiView> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The best routes in the kernel's routing table: how many are there, and
|
||||||
|
/// why the others are not.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
pub struct KernelView {
|
||||||
|
/// Whether routes are installed in the kernel at all.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// The routing table and metric our routes have there.
|
||||||
|
pub table: Option<u32>,
|
||||||
|
pub metric: Option<u32>,
|
||||||
|
/// Whether the kernel has been brought in step with the routing table
|
||||||
|
/// since the daemon started.
|
||||||
|
pub synced: bool,
|
||||||
|
/// How many routes of ours the kernel has.
|
||||||
|
pub installed: 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>,
|
||||||
|
/// Prefixes whose best route the kernel refused.
|
||||||
|
pub refused: usize,
|
||||||
|
/// How many requests the kernel has refused in all, and the last.
|
||||||
|
pub errors: u64,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
/// The machine's interfaces, as the kernel reports them.
|
||||||
|
pub interfaces: Vec<InterfaceView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct InterfaceView {
|
||||||
|
pub name: String,
|
||||||
|
pub index: u32,
|
||||||
|
pub up: bool,
|
||||||
|
/// In CIDR notation, e.g. `192.0.2.1/24`.
|
||||||
|
pub addresses: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn kernel(State(state): State<AppState>) -> Json<KernelView> {
|
||||||
|
let Some(kernel) = &state.kernel else {
|
||||||
|
return Json(KernelView::default());
|
||||||
|
};
|
||||||
|
let (config, status) = (kernel.config(), kernel.status());
|
||||||
|
Json(KernelView {
|
||||||
|
enabled: true,
|
||||||
|
table: Some(config.table),
|
||||||
|
metric: Some(config.metric),
|
||||||
|
synced: status.synced,
|
||||||
|
installed: status.installed,
|
||||||
|
unresolved: status.unresolved.into_iter().collect(),
|
||||||
|
refused: status.refused,
|
||||||
|
errors: status.errors,
|
||||||
|
last_error: status.last_error,
|
||||||
|
interfaces: status
|
||||||
|
.interfaces
|
||||||
|
.into_iter()
|
||||||
|
.map(|interface| InterfaceView {
|
||||||
|
name: interface.name,
|
||||||
|
index: interface.index,
|
||||||
|
up: interface.up,
|
||||||
|
addresses: interface
|
||||||
|
.addresses
|
||||||
|
.iter()
|
||||||
|
.map(|(address, length)| format!("{address}/{length}"))
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// What one peer announced that was refused.
|
/// What one peer announced that was refused.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct RefusalsView {
|
pub struct RefusalsView {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use axum::Router;
|
|||||||
use axum::http::header;
|
use axum::http::header;
|
||||||
use axum::response::{Html, IntoResponse, Redirect};
|
use axum::response::{Html, IntoResponse, Redirect};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
|
use bgp_fib::Fib;
|
||||||
use bgp_rib::LocalRib;
|
use bgp_rib::LocalRib;
|
||||||
use bgp_rib::table::RibTable;
|
use bgp_rib::table::RibTable;
|
||||||
use bgp_rpki::Rpki;
|
use bgp_rpki::Rpki;
|
||||||
@@ -26,7 +27,7 @@ use tokio::net::TcpListener;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
pub use api::{
|
pub use api::{
|
||||||
AttrSummary, ConfigView, LocalRouteView, PeerView, ReceivedRouteView, RefusalsView,
|
AttrSummary, ConfigView, KernelView, LocalRouteView, PeerView, ReceivedRouteView, RefusalsView,
|
||||||
RefusedRouteView, RoutesPage, RpkiView, StatusView,
|
RefusedRouteView, RoutesPage, RpkiView, StatusView,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +91,8 @@ pub struct AppState {
|
|||||||
pub refusals: RefusalLog,
|
pub refusals: RefusalLog,
|
||||||
/// The data for route origin validation, where that is configured.
|
/// The data for route origin validation, where that is configured.
|
||||||
pub rpki: Option<Rpki>,
|
pub rpki: Option<Rpki>,
|
||||||
|
/// What keeps the best routes in the kernel, where that is configured.
|
||||||
|
pub kernel: Option<Fib>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The status page, embedded so the binary is self-contained.
|
/// The status page, embedded so the binary is self-contained.
|
||||||
@@ -114,6 +117,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/routes/received", get(api::received_routes))
|
.route("/api/routes/received", get(api::received_routes))
|
||||||
.route("/api/routes/rejected", get(api::rejected_routes))
|
.route("/api/routes/rejected", get(api::rejected_routes))
|
||||||
.route("/api/rpki", get(api::rpki))
|
.route("/api/rpki", get(api::rpki))
|
||||||
|
.route("/api/kernel", get(api::kernel))
|
||||||
.route("/api/events", get(events::stream))
|
.route("/api/events", get(events::stream))
|
||||||
.route("/metricz", get(metrics::metricz))
|
.route("/metricz", get(metrics::metricz))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Router ID <b id="s-router-id">...</b>, local AS <b id="s-asn">...</b><br>
|
|||||||
</div>
|
</div>
|
||||||
<div class="righthand">
|
<div class="righthand">
|
||||||
Running on <span id="s-hostname">...</span><br>
|
Running on <span id="s-hostname">...</span><br>
|
||||||
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">originated routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/routes/rejected">refused routes json</a>, <a href="/api/rpki">rpki json</a><br>
|
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">originated routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/routes/rejected">refused routes json</a>, <a href="/api/rpki">rpki json</a>, <a href="/api/kernel">kernel json</a><br>
|
||||||
<span id="refresh-ctl"></span><br>
|
<span id="refresh-ctl"></span><br>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,6 +74,9 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
|||||||
<tbody id="local-body"></tbody>
|
<tbody id="local-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<h1 id="kernel">Kernel routes</h1>
|
||||||
|
<div id="kernel-body">...</div>
|
||||||
|
|
||||||
<h1 id="received">Received routes (<span id="received-count">...</span>)</h1>
|
<h1 id="received">Received routes (<span id="received-count">...</span>)</h1>
|
||||||
<form class="lookup" id="lookup">
|
<form class="lookup" id="lookup">
|
||||||
Look up <input type="text" id="lookup-q" size="30" placeholder="193.0.0.0/21 or 193.0.0.1" title="A prefix, or an address to find every prefix that covers it">
|
Look up <input type="text" id="lookup-q" size="30" placeholder="193.0.0.0/21 or 193.0.0.1" title="A prefix, or an address to find every prefix that covers it">
|
||||||
@@ -118,7 +121,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
|||||||
|
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
const state = { status: null, peers: [], local: [], received: [], receivedPage: {}, refused: [], rpki: null };
|
const state = { status: null, peers: [], local: [], received: [], receivedPage: {}, refused: [], rpki: null, kernel: null };
|
||||||
const DASH = '<span class="dim">-</span>';
|
const DASH = '<span class="dim">-</span>';
|
||||||
|
|
||||||
// ---- helpers -----------------------------------------------------------
|
// ---- helpers -----------------------------------------------------------
|
||||||
@@ -305,6 +308,22 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
|||||||
(r.last_error ? `<br><span class="bad">Last error:</span> ${esc(r.last_error)}` : ''));
|
(r.last_error ? `<br><span class="bad">Last error:</span> ${esc(r.last_error)}` : ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderKernel() {
|
||||||
|
const k = state.kernel, b = $('kernel-body');
|
||||||
|
if (!k) return;
|
||||||
|
if (!k.enabled) { setHTML(b, '<span class="dim">Not configured: the best routes are not put in the kernel, and this machine does not forward by them.</span>'); return; }
|
||||||
|
const unresolved = Object.entries(k.unresolved).map(([why, n]) => `${n} ${esc(why)}`).join(', ');
|
||||||
|
const interfaces = k.interfaces.map(i =>
|
||||||
|
`<span class="mono">${esc(i.name)}</span> <span class="${i.up ? 'ok' : 'bad'}">${i.up ? 'up' : 'down'}</span>` +
|
||||||
|
(i.addresses.length ? ` <span class="mono dim">${i.addresses.map(esc).join(' ')}</span>` : '')).join(' · ');
|
||||||
|
setHTML(b, `<span class="${k.synced ? 'ok' : 'warn'}">${k.synced ? 'In step' : 'Catching up'}</span> with the routing table: ` +
|
||||||
|
`<b>${k.installed}</b> routes installed <span class="dim">(table ${k.table}, metric ${k.metric}, proto bgp)</span>` +
|
||||||
|
(unresolved ? `; <span class="warn">not installed:</span> ${unresolved}` : '') +
|
||||||
|
(k.refused ? `; <span class="bad">${k.refused} refused by the kernel</span>` : '') + '.' +
|
||||||
|
(k.last_error ? `<br><span class="bad">Last error</span> (${k.errors} in all): ${esc(k.last_error)}` : '') +
|
||||||
|
(interfaces ? `<br>Interfaces: ${interfaces}` : ''));
|
||||||
|
}
|
||||||
|
|
||||||
// For each peer a line that says how many and why, then its latest few.
|
// For each peer a line that says how many and why, then its latest few.
|
||||||
const REFUSED_SHOWN = 20;
|
const REFUSED_SHOWN = 20;
|
||||||
function renderRefused() {
|
function renderRefused() {
|
||||||
@@ -356,7 +375,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
|||||||
}
|
}
|
||||||
async function loadRefused() { state.refused = await getJSON('/api/routes/rejected'); renderRefused(); }
|
async function loadRefused() { state.refused = await getJSON('/api/routes/rejected'); renderRefused(); }
|
||||||
async function loadRpki() { state.rpki = await getJSON('/api/rpki'); renderRpki(); }
|
async function loadRpki() { state.rpki = await getJSON('/api/rpki'); renderRpki(); }
|
||||||
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadRefused(), loadRpki()]); }
|
async function loadKernel() { state.kernel = await getJSON('/api/kernel'); renderKernel(); }
|
||||||
|
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadRefused(), loadRpki(), loadKernel()]); }
|
||||||
|
|
||||||
// ---- events ------------------------------------------------------------
|
// ---- events ------------------------------------------------------------
|
||||||
function logEvent(html) {
|
function logEvent(html) {
|
||||||
|
|||||||
+3
-1
@@ -31,7 +31,9 @@ The base protocol specification.
|
|||||||
| §6.8 | Connection collision detection (BGP Identifier comparison, Cease / Connection Collision Resolution) | ✅ |
|
| §6.8 | Connection collision detection (BGP Identifier comparison, Cease / Connection Collision Resolution) | ✅ |
|
||||||
| §8.1.1 | Optional `CollisionDetectEstablishedState` (`collision_detect_established_state`) | ✅ |
|
| §8.1.1 | Optional `CollisionDetectEstablishedState` (`collision_detect_established_state`) | ✅ |
|
||||||
| §6.2 | Bad BGP Identifier check (peer using our identifier, or 0.0.0.0) | ✅ |
|
| §6.2 | Bad BGP Identifier check (peer using our identifier, or 0.0.0.0) | ✅ |
|
||||||
| §9 | Decision process / best-path selection | ❌ |
|
| §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.1 | Routes with an unresolvable next hop excluded from selection; next hops resolved through other routes | ❌ (such a route can be chosen, and is then not installed) |
|
||||||
| §9 | Adj-RIB-In (per-peer table of received routes) | ✅ |
|
| §9 | Adj-RIB-In (per-peer table of received routes) | ✅ |
|
||||||
| §9 | Locally originated routes, advertised to all established peers | ✅ |
|
| §9 | Locally originated routes, advertised to all established peers | ✅ |
|
||||||
| §9 | Loc-RIB / Adj-RIB-Out for routes learned from peers (re-advertisement) | ❌ |
|
| §9 | Loc-RIB / Adj-RIB-Out for routes learned from peers (re-advertisement) | ❌ |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ cargo-fuzz = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
bgp-packet = { path = "../crates/packet" }
|
bgp-packet = { path = "../crates/packet" }
|
||||||
bgp-primitives = { path = "../crates/primitives" }
|
bgp-primitives = { path = "../crates/primitives" }
|
||||||
|
bgp-fib = { path = "../crates/fib" }
|
||||||
bgp-rpki = { path = "../crates/rpki" }
|
bgp-rpki = { path = "../crates/rpki" }
|
||||||
bgp-server = { path = "../crates/server" }
|
bgp-server = { path = "../crates/server" }
|
||||||
bytes = "1.10.1"
|
bytes = "1.10.1"
|
||||||
@@ -60,3 +61,10 @@ path = "fuzz_targets/rtr.rs"
|
|||||||
test = false
|
test = false
|
||||||
doc = false
|
doc = false
|
||||||
bench = false
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "netlink"
|
||||||
|
path = "fuzz_targets/netlink.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
@@ -13,6 +13,7 @@ you leave them running.
|
|||||||
| `prefix_text` | A prefix as text |
|
| `prefix_text` | A prefix as text |
|
||||||
| `config` | The configuration file |
|
| `config` | The configuration file |
|
||||||
| `rtr` | A stream of RTR PDUs, as an RPKI cache sends them |
|
| `rtr` | A stream of RTR PDUs, as an RPKI cache sends them |
|
||||||
|
| `netlink` | Messages from the kernel's routing socket |
|
||||||
|
|
||||||
For the two message targets a finding is either a panic, or a message the
|
For the two message targets a finding is either a panic, or a message the
|
||||||
decoder accepted that cannot be written and read back as itself.
|
decoder accepted that cannot be written and read back as itself.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! Bytes from the kernel's routing socket. The kernel is trusted, but what
|
||||||
|
//! it sends is read with code of ours, and a daemon that dies on an
|
||||||
|
//! unusual route stops routing.
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use bgp_fib::netlink::{Change, Message, decode, route_request};
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let mut rest = data;
|
||||||
|
// As the daemon reads: one message after another until one is wrong.
|
||||||
|
while !rest.is_empty() {
|
||||||
|
let Ok((_, message, used)) = decode(rest) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert!(used > 0 && used <= rest.len());
|
||||||
|
rest = &rest[used..];
|
||||||
|
let _ = format!("{message:?}");
|
||||||
|
// A route that was read can be asked for, and what is asked for
|
||||||
|
// reads back as itself.
|
||||||
|
if let Message::Route { present, route } = message {
|
||||||
|
let change = if present {
|
||||||
|
Change::Replace
|
||||||
|
} else {
|
||||||
|
Change::Remove
|
||||||
|
};
|
||||||
|
let mut out = BytesMut::new();
|
||||||
|
route_request(&mut out, 1, change, false, &route);
|
||||||
|
let (_, again, _) = decode(&out).expect("what is written can be read");
|
||||||
|
assert_eq!(again, Message::Route { present, route });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs the tests that need a real Linux kernel to change the routes of:
|
||||||
|
# those of the bgp-fib crate that `cargo test` skips, and the end-to-end
|
||||||
|
# test of two daemons that route for each other.
|
||||||
|
#
|
||||||
|
# They run in a network namespace made for the purpose, so the machine's
|
||||||
|
# own interfaces and routes are out of their reach, and with the one
|
||||||
|
# capability the daemon needs, CAP_NET_ADMIN, rather than as root.
|
||||||
|
#
|
||||||
|
# Run it on Linux, as a user who can sudo. On another system,
|
||||||
|
# scripts/linux-vm.sh runs it in a virtual machine.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
[ "$(uname -s)" = Linux ] || { echo "this needs Linux; see scripts/linux-vm.sh" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Built as the user, so that nothing in the target directory is root's.
|
||||||
|
cargo build --locked -p bgp
|
||||||
|
cargo test --locked -p bgp-fib --test kernel --no-run
|
||||||
|
cargo test --locked -p bgp-integration-tests --test kernel --no-run
|
||||||
|
|
||||||
|
namespace="ferrix-test-$$"
|
||||||
|
sudo modprobe dummy 2>/dev/null || true
|
||||||
|
sudo ip netns add "$namespace"
|
||||||
|
trap 'sudo ip netns del "$namespace"' EXIT
|
||||||
|
sudo ip netns exec "$namespace" ip link set lo up
|
||||||
|
|
||||||
|
# In the namespace, as the user, with CAP_NET_ADMIN and nothing else.
|
||||||
|
in_namespace() {
|
||||||
|
sudo ip netns exec "$namespace" \
|
||||||
|
setpriv --reuid "$(id -u)" --regid "$(id -g)" --init-groups \
|
||||||
|
--inh-caps +net_admin --ambient-caps +net_admin \
|
||||||
|
env PATH="$PATH" HOME="$HOME" CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
in_namespace cargo test --locked -p bgp-fib --test kernel -- --include-ignored --nocapture "$@"
|
||||||
|
in_namespace cargo test --locked -p bgp-integration-tests --test kernel -- --include-ignored --nocapture "$@"
|
||||||
|
scripts/two-routers.sh
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs a command in a Linux virtual machine with this repository mounted
|
||||||
|
# at the same path: for the tests that need a Linux kernel, from a machine
|
||||||
|
# that is not one. Without arguments it runs scripts/kernel-tests.sh.
|
||||||
|
#
|
||||||
|
# scripts/linux-vm.sh the kernel tests
|
||||||
|
# scripts/linux-vm.sh cargo test the whole test suite, on Linux
|
||||||
|
# scripts/linux-vm.sh bash a shell
|
||||||
|
#
|
||||||
|
# The machine is made on first use with Lima (https://lima-vm.io; on macOS
|
||||||
|
# `brew install lima`): Ubuntu, with a Rust toolchain. It sees the
|
||||||
|
# repository read-only and builds into a directory of its own.
|
||||||
|
# `limactl stop ferrix` stops it and `limactl delete ferrix` removes it.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
vm=ferrix
|
||||||
|
repository="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
command -v limactl >/dev/null || { echo "limactl not found: install Lima first" >&2; exit 1; }
|
||||||
|
|
||||||
|
if ! limactl list --quiet | grep -qx "$vm"; then
|
||||||
|
limactl start --name="$vm" --cpus=8 --memory=8 --disk=30 --tty=false template://ubuntu-lts
|
||||||
|
limactl shell "$vm" bash -c '
|
||||||
|
set -eu
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq build-essential iproute2 iputils-ping curl
|
||||||
|
curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal -q
|
||||||
|
'
|
||||||
|
elif [ "$(limactl list --format '{{.Status}}' "$vm")" != Running ]; then
|
||||||
|
limactl start "$vm"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ $# -gt 0 ] || set -- scripts/kernel-tests.sh
|
||||||
|
limactl shell --workdir "$repository" "$vm" \
|
||||||
|
bash -lc 'export CARGO_TARGET_DIR="$HOME/target"; exec "$@"' -- "$@"
|
||||||
Executable
+144
@@ -0,0 +1,144 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Two daemons that route for each other, on a real kernel: the proof that
|
||||||
|
# what BGP learns ends up as forwarding.
|
||||||
|
#
|
||||||
|
# namespace a namespace b
|
||||||
|
# lan 10.1.0.1/24 lan 10.2.0.1/24
|
||||||
|
# 2001:db8:a::1/48 2001:db8:b::1/48
|
||||||
|
# a0 10.0.0.1/24 ------ veth ------ b0 10.0.0.2/24
|
||||||
|
# 2001:db8::1/64 2001:db8::2/64
|
||||||
|
#
|
||||||
|
# Each originates its own "lan" prefixes and installs what it learns in
|
||||||
|
# its kernel. The test passes when each kernel has the other's prefixes as
|
||||||
|
# BGP routes and a ping from one lan address to the other gets through,
|
||||||
|
# over IPv4 and IPv6; and when the routes go again as a daemon is stopped.
|
||||||
|
#
|
||||||
|
# Run it on Linux as a user who can sudo, after `cargo build -p bgp`;
|
||||||
|
# scripts/kernel-tests.sh does both.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
bgp="${CARGO_TARGET_DIR:-target}/debug/bgp"
|
||||||
|
[ -x "$bgp" ] || { echo "$bgp not found: cargo build -p bgp" >&2; exit 1; }
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
a="ferrix-a-$$"
|
||||||
|
b="ferrix-b-$$"
|
||||||
|
pids=""
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
for pid in $pids; do sudo kill "$pid" 2>/dev/null || true; done
|
||||||
|
sudo ip netns del "$a" 2>/dev/null || true
|
||||||
|
sudo ip netns del "$b" 2>/dev/null || true
|
||||||
|
if [ "$status" -ne 0 ]; then
|
||||||
|
echo "--- FAILED; the daemons said:" >&2
|
||||||
|
tail -n 30 "$work"/*.log >&2 || true
|
||||||
|
fi
|
||||||
|
rm -rf "$work"
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
in_a() { sudo ip netns exec "$a" "$@"; }
|
||||||
|
in_b() { sudo ip netns exec "$b" "$@"; }
|
||||||
|
|
||||||
|
sudo modprobe dummy 2>/dev/null || true
|
||||||
|
sudo ip netns add "$a"
|
||||||
|
sudo ip netns add "$b"
|
||||||
|
sudo ip link add a0 netns "$a" type veth peer name b0 netns "$b"
|
||||||
|
for side in a b; do
|
||||||
|
case $side in a) n=1; run=in_a ;; b) n=2; run=in_b ;; esac
|
||||||
|
$run ip link set lo up
|
||||||
|
$run ip addr add "10.0.0.$n/24" dev "${side}0"
|
||||||
|
$run ip -6 addr add "2001:db8::$n/64" dev "${side}0" nodad
|
||||||
|
$run ip link set "${side}0" up
|
||||||
|
$run ip link add lan type dummy
|
||||||
|
$run ip addr add "10.$n.0.1/24" dev lan
|
||||||
|
$run ip -6 addr add "2001:db8:$side::1/48" dev lan nodad
|
||||||
|
$run ip link set lan up
|
||||||
|
done
|
||||||
|
|
||||||
|
# One session for each IP version: routes are sent with the session's own
|
||||||
|
# address as next hop, which has to be of the routes' version.
|
||||||
|
config() {
|
||||||
|
side=$1; n=$2; other=$3
|
||||||
|
cat > "$work/$side.textpb" <<END
|
||||||
|
router_id: "10.0.0.$n"
|
||||||
|
local_asn: 6500$n
|
||||||
|
listen_endpoints { address: "10.0.0.$n" port: 179 }
|
||||||
|
listen_endpoints { address: "2001:db8::$n" port: 179 }
|
||||||
|
peers {
|
||||||
|
name: "v4" endpoint { address: "10.0.0.$other" } remote_asn: 6500$other
|
||||||
|
address_families: IPV4_UNICAST
|
||||||
|
import { accept_bogons: true max_prefixes: 100 }
|
||||||
|
}
|
||||||
|
peers {
|
||||||
|
name: "v6" endpoint { address: "2001:db8::$other" } remote_asn: 6500$other
|
||||||
|
address_families: IPV6_UNICAST
|
||||||
|
import { accept_bogons: true max_prefixes: 100 }
|
||||||
|
}
|
||||||
|
connect_retry_seconds: 1
|
||||||
|
originate { prefix: "10.$n.0.0/24" }
|
||||||
|
originate { prefix: "2001:db8:$side::/48" }
|
||||||
|
kernel {}
|
||||||
|
END
|
||||||
|
}
|
||||||
|
config a 1 2
|
||||||
|
config b 2 1
|
||||||
|
|
||||||
|
# As the user, with what the daemon needs and no more: CAP_NET_ADMIN for
|
||||||
|
# the routes, CAP_NET_BIND_SERVICE for port 179.
|
||||||
|
start() {
|
||||||
|
namespace=$1; side=$2
|
||||||
|
sudo ip netns exec "$namespace" \
|
||||||
|
setpriv --reuid "$(id -u)" --regid "$(id -g)" --init-groups \
|
||||||
|
--inh-caps +net_admin,+net_bind_service --ambient-caps +net_admin,+net_bind_service \
|
||||||
|
"$bgp" "$work/$side.textpb" > "$work/$side.log" 2>&1 &
|
||||||
|
pids="$pids $!"
|
||||||
|
}
|
||||||
|
start "$a" a
|
||||||
|
pid_a=$!
|
||||||
|
start "$b" b
|
||||||
|
|
||||||
|
# Waits until `ip route` in a namespace shows, or no longer shows, a route.
|
||||||
|
expect() {
|
||||||
|
run=$1; family=$2; want=$3; route=$4
|
||||||
|
for _ in $(seq 100); do
|
||||||
|
if $run ip "$family" route show proto bgp | sed 's/ */ /g; s/ pref medium//; s/ *$//' | grep -qxF "$route"; then
|
||||||
|
[ "$want" = present ] && return 0
|
||||||
|
else
|
||||||
|
[ "$want" = absent ] && return 0
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
echo "expected the route \"$route\" to be $want; the kernel's BGP routes are:" >&2
|
||||||
|
$run ip "$family" route show proto bgp >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect in_a -4 present "10.2.0.0/24 via 10.0.0.2 dev a0 metric 20"
|
||||||
|
expect in_b -4 present "10.1.0.0/24 via 10.0.0.1 dev b0 metric 20"
|
||||||
|
expect in_a -6 present "2001:db8:b::/48 via 2001:db8::2 dev a0 metric 20"
|
||||||
|
expect in_b -6 present "2001:db8:a::/48 via 2001:db8::1 dev b0 metric 20"
|
||||||
|
echo "ok: each kernel has the other's prefixes as BGP routes"
|
||||||
|
|
||||||
|
# From one lan address to the other: there and back by the routes BGP put
|
||||||
|
# there.
|
||||||
|
in_a ping -q -c 2 -W 2 -I 10.1.0.1 10.2.0.1 > /dev/null
|
||||||
|
in_b ping -q -c 2 -W 2 -I 10.2.0.1 10.1.0.1 > /dev/null
|
||||||
|
in_a ping -q -c 2 -W 2 -I 2001:db8:a::1 2001:db8:b::1 > /dev/null
|
||||||
|
echo "ok: packets get through, over IPv4 and IPv6"
|
||||||
|
|
||||||
|
# One daemon is stopped in good order. It takes its routes out of its
|
||||||
|
# kernel; the other, told the session is over, does the same with what it
|
||||||
|
# learned.
|
||||||
|
sudo ip netns pids "$a" | xargs sudo kill -TERM
|
||||||
|
expect in_a -4 absent "10.2.0.0/24 via 10.0.0.2 dev a0 metric 20"
|
||||||
|
expect in_a -6 absent "2001:db8:b::/48 via 2001:db8::2 dev a0 metric 20"
|
||||||
|
expect in_b -4 absent "10.1.0.0/24 via 10.0.0.1 dev b0 metric 20"
|
||||||
|
expect in_b -6 absent "2001:db8:a::/48 via 2001:db8::1 dev b0 metric 20"
|
||||||
|
if in_b ping -q -c 1 -W 1 -I 10.2.0.1 10.1.0.1 > /dev/null 2>&1; then
|
||||||
|
echo "packets still get through with the routes gone" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ok: the routes go when a daemon stops"
|
||||||
Vendored
+5
@@ -66,6 +66,11 @@ originate {
|
|||||||
# whose AS path is a leak. Point it at a validator's RTR server.
|
# whose AS path is a leak. Point it at a validator's RTR server.
|
||||||
# rpki { caches { address: "127.0.0.1" port: 3323 } }
|
# rpki { caches { address: "127.0.0.1" port: 3323 } }
|
||||||
|
|
||||||
|
# Put the best route of every prefix in the kernel's routing table, so that
|
||||||
|
# this machine forwards by them (Linux; needs CAP_NET_ADMIN). Routes are
|
||||||
|
# marked "proto bgp"; table and metric default to 254 (main) and 20.
|
||||||
|
# kernel {}
|
||||||
|
|
||||||
# Read-only status page, JSON API and metrics. It has no authentication:
|
# Read-only status page, JSON API and metrics. It has no authentication:
|
||||||
# keep it on loopback or behind a reverse proxy.
|
# keep it on loopback or behind a reverse proxy.
|
||||||
http_endpoint { address: "127.0.0.1" port: 8080 }
|
http_endpoint { address: "127.0.0.1" port: 8080 }
|
||||||
Reference in new issue
Block a user