Compare commits
6
Commits
3a1e42e594
...
6d8f89e509
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8f89e509 | ||
|
|
01ac3e2cd8 | ||
|
|
3608897245 | ||
|
|
430addcf20 | ||
|
|
277b973f1a | ||
|
|
7b6547d3c9 |
No files matched your search
@@ -40,7 +40,6 @@ What it does:
|
||||
- TCP-AO, TCP MD5, and GTSM
|
||||
- Long-Lived Graceful Restart, BFD, and configuration changes without a
|
||||
restart
|
||||
- Recursive resolution of next hops that are not directly connected
|
||||
- Address families other than IPv4 and IPv6 unicast
|
||||
|
||||
## Building
|
||||
@@ -89,9 +88,11 @@ privileges, the systemd unit, and upgrading without withdrawing routes.
|
||||
## Documentation
|
||||
|
||||
The documentation is a website built from [`docs/`](docs/) with
|
||||
[Zola](https://www.getzola.org) (`zola --root docs serve`, then
|
||||
<http://127.0.0.1:1111>). The pages are plain Markdown and can be read
|
||||
in the repository:
|
||||
[Zola](https://www.getzola.org): `zola --root docs serve`, then open
|
||||
<http://127.0.0.1:1111>. The sources are Markdown with Zola front matter,
|
||||
shortcodes and internal links, so the forge shows them with those parts
|
||||
unrendered; [`docs/README.md`](docs/README.md) explains what each is. The
|
||||
pages:
|
||||
|
||||
- [Overview](docs/content/docs/getting-started/introduction.md),
|
||||
[installation](docs/content/docs/getting-started/installation.md) and
|
||||
|
||||
+188
-45
@@ -24,11 +24,14 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use crate::interfaces::Interfaces;
|
||||
use crate::netlink::{
|
||||
self, Change, Dump, KIND_UNICAST, Message, PROTOCOL_BGP, Route, decode, group,
|
||||
self, Change, Dump, KIND_BLACKHOLE, KIND_UNICAST, KIND_UNSPEC, Message, PROTOCOL_BGP, Route,
|
||||
decode, group,
|
||||
};
|
||||
use crate::plan::{
|
||||
HashMap as FastHashMap, Neighbor, Op, Plan, Target, Unresolved, resolve_next_hop,
|
||||
};
|
||||
use crate::routes::KernelRoutes;
|
||||
use crate::topology::Topology;
|
||||
use crate::{Fib, FibConfig, FibError, Stop};
|
||||
|
||||
/// How many best routes are taken from the routing table at a time.
|
||||
@@ -70,6 +73,11 @@ const RECEIVE_BUFFER: usize = 8 * 1024 * 1024;
|
||||
/// not at all.
|
||||
const ANSWER_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How much room the socket that hints at route changes has: enough for
|
||||
/// a few hundred messages, and no more, because what it hears is never
|
||||
/// read. See `Sockets::hints`.
|
||||
const HINT_BUFFER: usize = 64 * 1024;
|
||||
|
||||
/// The errors of the kernel that have a meaning here.
|
||||
mod errno {
|
||||
pub const EPERM: i32 = 1;
|
||||
@@ -128,20 +136,21 @@ struct Socket {
|
||||
impl Socket {
|
||||
/// A socket that is sent the notifications of `groups`.
|
||||
fn open(groups: u32) -> io::Result<Self> {
|
||||
Self::open_with_room(groups, RECEIVE_BUFFER)
|
||||
}
|
||||
|
||||
/// Opens a socket with `room` octets of receive buffer, as far as the
|
||||
/// kernel allows.
|
||||
fn open_with_room(groups: u32, room: usize) -> io::Result<Self> {
|
||||
let mut socket = TokioSocket::new(NETLINK_ROUTE)?;
|
||||
socket.socket_mut().bind(&SocketAddr::new(0, groups))?;
|
||||
// Room for the answers to a whole write, and answers that do not
|
||||
// repeat the request. Both are for the better and neither is
|
||||
// needed: what is lost for want of room is noticed and made good.
|
||||
let raw = socket.socket_ref();
|
||||
if nix::sys::socket::setsockopt(
|
||||
raw,
|
||||
nix::sys::socket::sockopt::RcvBufForce,
|
||||
&RECEIVE_BUFFER,
|
||||
)
|
||||
.is_err()
|
||||
if nix::sys::socket::setsockopt(raw, nix::sys::socket::sockopt::RcvBufForce, &room).is_err()
|
||||
{
|
||||
let _ = raw.set_rx_buf_sz(RECEIVE_BUFFER);
|
||||
let _ = raw.set_rx_buf_sz(room);
|
||||
}
|
||||
let _ = raw.set_cap_ack(true);
|
||||
Ok(Self {
|
||||
@@ -220,7 +229,7 @@ impl Socket {
|
||||
0 => Ok(()),
|
||||
errno => Err(FibError::Dump {
|
||||
what: match what {
|
||||
Dump::Routes(_) => "its routes",
|
||||
Dump::Routes(_) | Dump::RoutesIn { .. } => "its routes",
|
||||
Dump::Addresses => "its addresses",
|
||||
Dump::Links => "its interfaces",
|
||||
Dump::Nexthops => "its nexthop objects",
|
||||
@@ -307,6 +316,17 @@ pub(crate) struct Sockets {
|
||||
/// For what the kernel tells unasked: changes to the interfaces,
|
||||
/// their addresses, and nexthop objects.
|
||||
news: Socket,
|
||||
/// For asking which routes the resolution table has, and only that:
|
||||
/// with strict checking on, the kernel answers with that table alone
|
||||
/// rather than with every table it has, which may be a million routes
|
||||
/// of ours. `None` on a kernel from before it could (Linux 4.20).
|
||||
resolution: Option<Socket>,
|
||||
/// Told of every change to any route, as a hint that the resolution
|
||||
/// table may have changed: what it says is never read, only that it
|
||||
/// said something. The kernel tells of our own routes going in too, a
|
||||
/// million at a time, so this socket has little room and is expected
|
||||
/// to overflow; an overflow is as good a hint as a message.
|
||||
hints: Socket,
|
||||
}
|
||||
|
||||
impl Sockets {
|
||||
@@ -318,10 +338,18 @@ impl Sockets {
|
||||
.socket
|
||||
.socket_ref()
|
||||
.add_membership(netlink::GROUP_NEXTHOP);
|
||||
let resolution = Socket::open(0)?;
|
||||
let strict = resolution
|
||||
.socket
|
||||
.socket_ref()
|
||||
.set_netlink_get_strict_chk(true)
|
||||
.is_ok();
|
||||
Ok(Self {
|
||||
control: Socket::open(0)?,
|
||||
routes: Socket::open(0)?,
|
||||
news,
|
||||
resolution: strict.then_some(resolution),
|
||||
hints: Socket::open_with_room(group::IPV4_ROUTE | group::IPV6_ROUTE, HINT_BUFFER)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -417,6 +445,8 @@ enum News {
|
||||
prefix: IpPrefix,
|
||||
peer: PeerIndex,
|
||||
next_hop: Option<NlriNextHop>,
|
||||
/// Whether the route asks for its packets to be dropped.
|
||||
blackhole: bool,
|
||||
},
|
||||
Withdrawn(IpPrefix),
|
||||
Synced,
|
||||
@@ -427,17 +457,22 @@ struct Task {
|
||||
fib: Fib,
|
||||
config: FibConfig,
|
||||
control: Socket,
|
||||
/// See `Sockets::resolution`.
|
||||
resolution: Option<Socket>,
|
||||
/// Where changes to routes go to be written; `None` once they have
|
||||
/// all been handed over, at the end.
|
||||
queue: Option<mpsc::Sender<Batch>>,
|
||||
writer: JoinHandle<Result<(), FibError>>,
|
||||
interfaces: Interfaces,
|
||||
/// The kernel's view of the machine, as far as next hops are
|
||||
/// concerned: interfaces, the routes that say the way to a next hop,
|
||||
/// and nexthop objects.
|
||||
topology: Topology,
|
||||
/// What each next hop seen so far resolves to, by the next hop, the
|
||||
/// IP version of the routes, and for a link-local one the peer.
|
||||
/// There are as many next hops as neighbors, and a million routes
|
||||
/// share them; looking through the interfaces for each route would
|
||||
/// take as long as there are addresses. Emptied when the interfaces
|
||||
/// change.
|
||||
/// share them; looking through the interfaces and routes for each
|
||||
/// route would take as long as there are of those. Emptied when the
|
||||
/// topology changes.
|
||||
resolved: FastHashMap<Resolving, Result<Target, Unresolved>>,
|
||||
plan: Plan,
|
||||
/// The kernel's nexthop objects that our routes name, by where they
|
||||
@@ -470,7 +505,9 @@ impl Task {
|
||||
&& route.metric == self.config.metric
|
||||
}
|
||||
|
||||
/// Learns the machine's interfaces and addresses anew.
|
||||
/// Learns the machine's interfaces and addresses anew. The routes
|
||||
/// and nexthop objects are learned where the kernel is asked for them
|
||||
/// anyway: `kernel_routes` and `kernel_nexthops`.
|
||||
async fn learn_interfaces(&mut self) -> Result<(), FibError> {
|
||||
let mut interfaces = Interfaces::default();
|
||||
for what in [Dump::Links, Dump::Addresses] {
|
||||
@@ -480,18 +517,18 @@ impl Task {
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
self.interfaces = interfaces;
|
||||
self.topology.interfaces = interfaces;
|
||||
self.resolved.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Asks which nexthop objects of BGP's the kernel has, and returns
|
||||
/// those that lead somewhere, by number.
|
||||
/// Asks which nexthop objects the kernel has. Every one goes into
|
||||
/// the topology, where a route of the resolution table may name it;
|
||||
/// those of BGP's that lead somewhere are returned by number, for
|
||||
/// finding our routes among the kernel's.
|
||||
async fn kernel_nexthops(&mut self) -> Result<HashMap<u32, Target>, FibError> {
|
||||
let mut by_id = HashMap::new();
|
||||
if self.nexthops.is_none() {
|
||||
return Ok(by_id);
|
||||
}
|
||||
let mut all = FastHashMap::default();
|
||||
let asked = self
|
||||
.control
|
||||
.dump(Dump::Nexthops, |message| {
|
||||
@@ -499,10 +536,14 @@ impl Task {
|
||||
present: true,
|
||||
nexthop,
|
||||
} = message
|
||||
&& nexthop.protocol == PROTOCOL_BGP
|
||||
&& let (Some(gateway), Some(interface)) = (nexthop.gateway, nexthop.interface)
|
||||
{
|
||||
by_id.insert(nexthop.id, Target { gateway, interface });
|
||||
all.insert(nexthop.id, nexthop);
|
||||
if nexthop.protocol == PROTOCOL_BGP
|
||||
&& let (Some(gateway), Some(interface)) =
|
||||
(nexthop.gateway, nexthop.interface)
|
||||
{
|
||||
by_id.insert(nexthop.id, Target::Via { gateway, interface });
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -510,14 +551,18 @@ impl Task {
|
||||
Ok(()) => {}
|
||||
// A kernel from before it had them (Linux 5.3).
|
||||
Err(FibError::Dump { errno, .. }) => {
|
||||
if self.nexthops.is_some() {
|
||||
info!(
|
||||
errno,
|
||||
"The kernel has no nexthop objects; routes will carry their own gateway"
|
||||
);
|
||||
}
|
||||
self.nexthops = None;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
self.topology.nexthops = all;
|
||||
self.resolved.clear();
|
||||
Ok(by_id)
|
||||
}
|
||||
|
||||
@@ -553,6 +598,36 @@ impl Task {
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Asks the kernel for the routes of the resolution table anew, and
|
||||
/// returns whether they differ from those known. Asked of the kernel
|
||||
/// with strict checking where it has that, which gives the one table;
|
||||
/// otherwise every table is asked for and all but the one is thrown
|
||||
/// away.
|
||||
async fn learn_routes(&mut self) -> Result<bool, FibError> {
|
||||
let table = self.topology.routes.table();
|
||||
let mut routes = KernelRoutes::new(table);
|
||||
for version in [IpVersion::Ipv4, IpVersion::Ipv6] {
|
||||
let each = |message: Message| {
|
||||
routes.apply(&message);
|
||||
};
|
||||
match &mut self.resolution {
|
||||
Some(socket) => socket.dump(Dump::RoutesIn { version, table }, each).await?,
|
||||
None => self.control.dump(Dump::Routes(version), each).await?,
|
||||
}
|
||||
}
|
||||
if routes == self.topology.routes {
|
||||
return Ok(false);
|
||||
}
|
||||
debug!(
|
||||
table,
|
||||
routes = routes.len(),
|
||||
"The routes that say the way to next hops changed"
|
||||
);
|
||||
self.topology.routes = routes;
|
||||
self.resolved.clear();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Finds the routes of ours that the kernel has from before.
|
||||
async fn adopt_routes(
|
||||
&mut self,
|
||||
@@ -570,8 +645,9 @@ impl Task {
|
||||
nexthops.get(&id).copied()
|
||||
}
|
||||
(None, Some(gateway), Some(interface)) if route.kind == KIND_UNICAST => {
|
||||
Some(Target { gateway, interface })
|
||||
Some(Target::Via { gateway, interface })
|
||||
}
|
||||
(None, None, _) if route.kind == KIND_BLACKHOLE => Some(Target::Discard),
|
||||
_ => None,
|
||||
};
|
||||
self.plan.adopt(route.prefix, target, keep);
|
||||
@@ -589,6 +665,9 @@ impl Task {
|
||||
if let Some(id) = known.get(&target) {
|
||||
return Ok(Ok(*id));
|
||||
}
|
||||
let Target::Via { gateway, interface } = target else {
|
||||
unreachable!("a route that discards names no nexthop object");
|
||||
};
|
||||
// One at a time, and waited for: there is one for each neighbor,
|
||||
// not for each route, and a route must not name one that is not
|
||||
// there yet.
|
||||
@@ -597,7 +676,7 @@ impl Task {
|
||||
.control
|
||||
.ask(
|
||||
|out, sequence| {
|
||||
netlink::nexthop_request(out, sequence, &target.gateway, target.interface);
|
||||
netlink::nexthop_request(out, sequence, &gateway, interface);
|
||||
},
|
||||
|message| {
|
||||
if let Message::Nexthop {
|
||||
@@ -612,7 +691,7 @@ impl Task {
|
||||
.await?;
|
||||
Ok(match (errno, made) {
|
||||
(0, Some(id)) => {
|
||||
debug!(id, gateway = %target.gateway, "Made a nexthop object");
|
||||
debug!(id, %gateway, "Made a nexthop object");
|
||||
self.owned.insert(id);
|
||||
if let Some(known) = &mut self.nexthops {
|
||||
known.insert(target, id);
|
||||
@@ -639,16 +718,29 @@ impl Task {
|
||||
kind: KIND_UNICAST,
|
||||
};
|
||||
let change = match op {
|
||||
Op::Remove { .. } => Change::Remove,
|
||||
// Whatever kind of route of ours there is for the prefix: the
|
||||
// kernel removes a route of the kind asked for only, and a
|
||||
// blackhole is not a unicast route.
|
||||
Op::Remove { .. } => {
|
||||
route.kind = KIND_UNSPEC;
|
||||
Change::Remove
|
||||
}
|
||||
Op::Install {
|
||||
target: Target::Discard,
|
||||
..
|
||||
} => {
|
||||
route.kind = KIND_BLACKHOLE;
|
||||
Change::Replace
|
||||
}
|
||||
Op::Install { target, .. } => {
|
||||
if self.nexthops.is_some() {
|
||||
match self.nexthop(*target).await? {
|
||||
Ok(id) => route.nexthop = Some(id),
|
||||
Err(errno) => return Ok(Err(errno)),
|
||||
}
|
||||
} else {
|
||||
route.gateway = Some(target.gateway);
|
||||
route.interface = Some(target.interface);
|
||||
} else if let Target::Via { gateway, interface } = target {
|
||||
route.gateway = Some(*gateway);
|
||||
route.interface = Some(*interface);
|
||||
}
|
||||
Change::Replace
|
||||
}
|
||||
@@ -717,14 +809,20 @@ impl Task {
|
||||
}
|
||||
let error = io::Error::from_raw_os_error(errno);
|
||||
let what = match op {
|
||||
Op::Install { prefix, target } => format!(
|
||||
"install {prefix} via {} on {}",
|
||||
target.gateway,
|
||||
self.interfaces.link(target.interface).map_or_else(
|
||||
|| format!("interface {}", target.interface),
|
||||
Op::Install {
|
||||
prefix,
|
||||
target: Target::Via { gateway, interface },
|
||||
} => format!(
|
||||
"install {prefix} via {gateway} on {}",
|
||||
self.topology.interfaces.link(*interface).map_or_else(
|
||||
|| format!("interface {interface}"),
|
||||
|link| link.name.clone()
|
||||
)
|
||||
),
|
||||
Op::Install {
|
||||
prefix,
|
||||
target: Target::Discard,
|
||||
} => format!("install a blackhole for {prefix}"),
|
||||
Op::Remove { prefix } => format!("remove {prefix}"),
|
||||
};
|
||||
// Counted always, said out loud once in a while: a table's worth
|
||||
@@ -764,6 +862,7 @@ impl Task {
|
||||
prefix,
|
||||
peer,
|
||||
next_hop: attrs.next_hop(),
|
||||
blackhole: attrs.is_blackhole(),
|
||||
},
|
||||
Event::Withdrawn { prefix } => News::Withdrawn(prefix),
|
||||
// Not given to a follower of the best routes.
|
||||
@@ -786,6 +885,7 @@ impl Task {
|
||||
prefix,
|
||||
peer,
|
||||
next_hop,
|
||||
blackhole,
|
||||
} => {
|
||||
// Whose link a link-local next hop is on is asked only
|
||||
// where there is one.
|
||||
@@ -800,18 +900,20 @@ impl Task {
|
||||
address: peer.address,
|
||||
interface: peer.interface,
|
||||
});
|
||||
let interfaces = &self.interfaces;
|
||||
let topology = &self.topology;
|
||||
// Asked of every route, and not remembered with the
|
||||
// next hop: it is about the prefix.
|
||||
let target = if interfaces.has_network_around(&prefix) {
|
||||
let target = if topology.interfaces.has_network_around(&prefix) {
|
||||
Err(Unresolved::OwnNetwork)
|
||||
} else if blackhole {
|
||||
Ok(Target::Discard)
|
||||
} else {
|
||||
*self
|
||||
.resolved
|
||||
.entry((next_hop, prefix.address_family(), peer))
|
||||
.or_insert_with(|| {
|
||||
resolve_next_hop(
|
||||
interfaces,
|
||||
topology,
|
||||
prefix.address_family(),
|
||||
next_hop.as_ref(),
|
||||
peer,
|
||||
@@ -886,7 +988,11 @@ impl Task {
|
||||
while !rest.is_empty() {
|
||||
let (_, message, used) = decode(rest)?;
|
||||
rest = &rest[used..];
|
||||
if self.interfaces.apply(&message) {
|
||||
// A nexthop object of BGP's is one of ours, or another
|
||||
// daemon's: never the way to a next hop, and made by the
|
||||
// hundred as a table goes in. Only the others matter here.
|
||||
let of_bgp = matches!(&message, Message::Nexthop { nexthop, .. } if nexthop.protocol == PROTOCOL_BGP);
|
||||
if self.topology.apply(&message) && !of_bgp {
|
||||
self.resolved.clear();
|
||||
changed = true;
|
||||
}
|
||||
@@ -917,6 +1023,7 @@ impl Task {
|
||||
self.flush(heard).await?;
|
||||
self.synced = false;
|
||||
self.plan = Plan::default();
|
||||
self.learn_routes().await?;
|
||||
let nexthops = self.kernel_nexthops().await?;
|
||||
// While routes of an earlier run are being kept for the peers to
|
||||
// announce again, they are kept through this too.
|
||||
@@ -991,13 +1098,14 @@ impl Task {
|
||||
UNRESOLVED.set(self.plan.unresolved() as i64);
|
||||
REFUSED.set(self.plan.refused() as i64);
|
||||
let interfaces = interfaces_changed.then(|| {
|
||||
self.fib.publish_interfaces(&self.interfaces);
|
||||
self.interfaces.status()
|
||||
self.fib.publish_topology(&self.topology);
|
||||
self.topology.interfaces.status()
|
||||
});
|
||||
self.fib.update(|status| {
|
||||
status.synced = self.synced;
|
||||
status.installed = self.plan.installed();
|
||||
status.kept = if self.keeping { self.plan.kept() } else { 0 };
|
||||
status.blackholes = self.plan.blackholes();
|
||||
status.refused = self.plan.refused();
|
||||
status.unresolved = self
|
||||
.plan
|
||||
@@ -1039,6 +1147,8 @@ pub(crate) async fn run(
|
||||
control,
|
||||
routes,
|
||||
mut news,
|
||||
resolution,
|
||||
mut hints,
|
||||
} = sockets;
|
||||
let (queue, batches) = mpsc::channel(QUEUE);
|
||||
let (reports, mut heard) = mpsc::unbounded_channel();
|
||||
@@ -1048,9 +1158,10 @@ pub(crate) async fn run(
|
||||
rib,
|
||||
fib,
|
||||
control,
|
||||
resolution,
|
||||
queue: Some(queue),
|
||||
writer: tokio::spawn(write(routes, batches, reports)),
|
||||
interfaces: Interfaces::default(),
|
||||
topology: Topology::new(config.resolution_table, config.resolve_via_default),
|
||||
resolved: FastHashMap::default(),
|
||||
plan: Plan::default(),
|
||||
nexthops: config.nexthop_objects.then(FastHashMap::default),
|
||||
@@ -1064,6 +1175,7 @@ pub(crate) async fn run(
|
||||
// The socket for news was bound before this: what changes while the
|
||||
// interfaces are being learned is told there.
|
||||
task.learn_interfaces().await?;
|
||||
task.learn_routes().await?;
|
||||
let nexthops = task.kernel_nexthops().await?;
|
||||
// Kept for a while, if there is to be any keeping: our table is empty
|
||||
// until the peers have sent theirs again.
|
||||
@@ -1090,7 +1202,10 @@ pub(crate) async fn run(
|
||||
table = config.table,
|
||||
metric = config.metric,
|
||||
nexthop_objects = task.nexthops.is_some(),
|
||||
interfaces = task.interfaces.links().count(),
|
||||
interfaces = task.topology.interfaces.links().count(),
|
||||
resolution_table = config.resolution_table,
|
||||
routes_to_next_hops = task.topology.routes.len(),
|
||||
filtered_dumps = task.resolution.is_some(),
|
||||
"Installing best routes in the kernel"
|
||||
);
|
||||
|
||||
@@ -1098,17 +1213,44 @@ pub(crate) async fn run(
|
||||
// kernel in step; starting a new one does it over.
|
||||
let mut reader = task.rib.reader();
|
||||
let mut again = Again::default();
|
||||
// When the routes that say the way to next hops are next to be asked
|
||||
// for again, if they are.
|
||||
let mut routes_again = Again::default();
|
||||
loop {
|
||||
tokio::select! {
|
||||
hint = hints.read() => {
|
||||
match hint {
|
||||
// Something about some route changed, or so much did
|
||||
// that the socket overflowed: either way, ask.
|
||||
Ok(_) => routes_again.soon(),
|
||||
Err(e) if e.raw_os_error() == Some(errno::ENOBUFS) => routes_again.soon(),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
() = at(routes_again.due()), if routes_again.due().is_some() => {
|
||||
routes_again = Again::default();
|
||||
if task.learn_routes().await? {
|
||||
// The way to a next hop may be another now: every
|
||||
// route is worked out again, which asks the kernel
|
||||
// only for what comes out different.
|
||||
info!(
|
||||
routes = task.topology.routes.len(),
|
||||
"The kernel's routes changed; working out next hops again"
|
||||
);
|
||||
reader = task.rib.reader();
|
||||
task.publish(true);
|
||||
}
|
||||
}
|
||||
() = reader.changed() => {
|
||||
task.follow(&mut reader).await?;
|
||||
task.publish(false);
|
||||
}
|
||||
Some(report) = heard.recv() => {
|
||||
if task.hear(report).await? {
|
||||
let changed = task.hear(report).await?;
|
||||
if changed {
|
||||
again.soon();
|
||||
}
|
||||
task.publish(false);
|
||||
task.publish(changed);
|
||||
}
|
||||
read = news.read() => {
|
||||
if task.on_news(read).await? {
|
||||
@@ -1123,6 +1265,7 @@ pub(crate) async fn run(
|
||||
info!("Working the kernel's routes out again");
|
||||
task.relearn(&mut heard).await?;
|
||||
reader = task.rib.reader();
|
||||
task.publish(true);
|
||||
}
|
||||
_ = async {
|
||||
tokio::select! {
|
||||
|
||||
+39
-18
@@ -9,7 +9,9 @@
|
||||
//!
|
||||
//! - [`netlink`] is the kernel's messages, as bytes and back.
|
||||
//! - [`interfaces`] is the machine's interfaces and what is reachable on
|
||||
//! them.
|
||||
//! them; [`routes`] the kernel's own routes, which say the way to what
|
||||
//! is not; [`topology`] the two together with the kernel's nexthop
|
||||
//! objects, as one view.
|
||||
//! - [`plan`] works out what to ask the kernel for.
|
||||
//! - The rest, which only Linux has, asks it.
|
||||
//!
|
||||
@@ -25,17 +27,23 @@
|
||||
//!
|
||||
//! # What is installed
|
||||
//!
|
||||
//! A best route whose next hop is a neighbor: an address in the network
|
||||
//! of one of the machine's interfaces. A route whose next hop is further
|
||||
//! away than that, as with routes from a peer in our own AS that did not
|
||||
//! set itself as next hop, would need the way to the next hop looked up in
|
||||
//! turn, which is not done: the prefix then has no route in the kernel.
|
||||
//! A best route whose next hop can be reached: a neighbor, which is an
|
||||
//! address in the network of one of the machine's interfaces, or an
|
||||
//! address the kernel's own routes know the way to. In the second case
|
||||
//! the route goes into the kernel with the gateway and interface of the
|
||||
//! route that knows the way, since the kernel does not look a gateway up
|
||||
//! in turn: a route whose next hop is a router of our own AS, reached over
|
||||
//! a tunnel by a static route, is installed as a route into the tunnel.
|
||||
//! Which of the kernel's routes count for this, and which never do, is the
|
||||
//! business of [`routes`].
|
||||
|
||||
pub mod interfaces;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod kernel;
|
||||
pub mod netlink;
|
||||
pub mod plan;
|
||||
pub mod routes;
|
||||
pub mod topology;
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
@@ -71,6 +79,12 @@ pub struct FibConfig {
|
||||
/// everything at once would stop the machine forwarding meanwhile.
|
||||
/// After this long, what has not been announced again goes.
|
||||
pub stale_time: Duration,
|
||||
/// The kernel's table whose routes say the way to a next hop that is
|
||||
/// not a neighbor: normally `main`, with the connected and static
|
||||
/// routes. BGP's own routes in it never count; see [`routes`].
|
||||
pub resolution_table: u32,
|
||||
/// Whether the default route counts as the way to a next hop.
|
||||
pub resolve_via_default: bool,
|
||||
}
|
||||
|
||||
impl Default for FibConfig {
|
||||
@@ -80,6 +94,8 @@ impl Default for FibConfig {
|
||||
metric: DEFAULT_METRIC,
|
||||
nexthop_objects: true,
|
||||
stale_time: DEFAULT_STALE_TIME,
|
||||
resolution_table: netlink::TABLE_MAIN,
|
||||
resolve_via_default: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +139,9 @@ pub struct Status {
|
||||
/// How many of them are kept from an earlier run though the routing
|
||||
/// table does not have them (yet); see [`FibConfig::stale_time`].
|
||||
pub kept: usize,
|
||||
/// How many of them are blackhole routes, which drop the packets for
|
||||
/// their prefix.
|
||||
pub blackholes: usize,
|
||||
/// Prefixes whose best route is not in the kernel because its next
|
||||
/// hop is not usable, by why.
|
||||
pub unresolved: Vec<(&'static str, usize)>,
|
||||
@@ -135,9 +154,10 @@ pub struct Status {
|
||||
pub interfaces: Vec<InterfaceStatus>,
|
||||
}
|
||||
|
||||
/// The machine's interfaces as the kernel last described them; `None`
|
||||
/// until it has. Whoever holds one is told when they change.
|
||||
pub type InterfacesWatch = watch::Receiver<Option<Arc<interfaces::Interfaces>>>;
|
||||
/// The machine's interfaces, routes and nexthop objects as the kernel
|
||||
/// last described them, as far as next hops are concerned; `None` until
|
||||
/// it has. Whoever holds one is told when they change.
|
||||
pub type TopologyWatch = watch::Receiver<Option<Arc<topology::Topology>>>;
|
||||
|
||||
/// How the task that keeps the kernel's routes is to end.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -151,7 +171,7 @@ enum Stop {
|
||||
struct Shared {
|
||||
config: FibConfig,
|
||||
status: Mutex<Status>,
|
||||
interfaces: watch::Sender<Option<Arc<interfaces::Interfaces>>>,
|
||||
topology: watch::Sender<Option<Arc<topology::Topology>>>,
|
||||
stop: watch::Sender<Option<Stop>>,
|
||||
/// How many routes of an earlier run were found in the kernel at the
|
||||
/// start, once that has been looked at.
|
||||
@@ -185,7 +205,7 @@ impl Fib {
|
||||
shared: Arc::new(Shared {
|
||||
config,
|
||||
status: Mutex::default(),
|
||||
interfaces: watch::channel(None).0,
|
||||
topology: watch::channel(None).0,
|
||||
stop,
|
||||
adopted: watch::channel(None).0,
|
||||
release: watch::channel(false).0,
|
||||
@@ -200,17 +220,18 @@ impl Fib {
|
||||
self.shared.config
|
||||
}
|
||||
|
||||
/// The machine's interfaces, for those who want to know whether a
|
||||
/// next hop can be reached before the kernel is asked to use it.
|
||||
pub fn interfaces(&self) -> InterfacesWatch {
|
||||
self.shared.interfaces.subscribe()
|
||||
/// The kernel's view of the machine, for those who want to know
|
||||
/// whether a next hop can be reached before the kernel is asked to use
|
||||
/// it.
|
||||
pub fn topology(&self) -> TopologyWatch {
|
||||
self.shared.topology.subscribe()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn publish_interfaces(&self, interfaces: &interfaces::Interfaces) {
|
||||
fn publish_topology(&self, topology: &topology::Topology) {
|
||||
self.shared
|
||||
.interfaces
|
||||
.send_replace(Some(Arc::new(interfaces.clone())));
|
||||
.topology
|
||||
.send_replace(Some(Arc::new(topology.clone())));
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Status {
|
||||
|
||||
@@ -23,7 +23,11 @@ pub const TABLE_MAIN: u32 = 254;
|
||||
|
||||
/// A route that forwards to a gateway or out of an interface
|
||||
/// (`RTN_UNICAST`), as opposed to one that drops, say.
|
||||
/// `RTN_UNSPEC`: in a request to remove a route, a route of any kind.
|
||||
pub const KIND_UNSPEC: u8 = 0;
|
||||
pub const KIND_UNICAST: u8 = 1;
|
||||
/// `RTN_BLACKHOLE`: a route that discards the packets for its prefix.
|
||||
pub const KIND_BLACKHOLE: u8 = 6;
|
||||
|
||||
const HEADER_LEN: usize = 16;
|
||||
|
||||
@@ -62,7 +66,9 @@ mod flag {
|
||||
pub mod group {
|
||||
pub const LINK: u32 = 0x1;
|
||||
pub const IPV4_ADDRESS: u32 = 0x10;
|
||||
pub const IPV4_ROUTE: u32 = 0x40;
|
||||
pub const IPV6_ADDRESS: u32 = 0x100;
|
||||
pub const IPV6_ROUTE: u32 = 0x400;
|
||||
}
|
||||
|
||||
/// The number of the group that is told of changes to nexthop objects
|
||||
@@ -467,7 +473,15 @@ pub enum Change {
|
||||
/// What to ask for all of.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Dump {
|
||||
/// Every route of the IP version, of every table.
|
||||
Routes(IpVersion),
|
||||
/// The routes of one table. The kernel honours the table only for a
|
||||
/// socket with strict checking on (`NETLINK_GET_STRICT_CHK`, Linux
|
||||
/// 4.20 and later); otherwise it answers as for [`Dump::Routes`].
|
||||
RoutesIn {
|
||||
version: IpVersion,
|
||||
table: u32,
|
||||
},
|
||||
Addresses,
|
||||
Links,
|
||||
Nexthops,
|
||||
@@ -619,8 +633,24 @@ pub fn nexthop_removal(out: &mut BytesMut, sequence: u32, id: u32) {
|
||||
/// Writes the request for all of `what` to the end of `out`. The answer is
|
||||
/// a message for each, and then [`Message::Done`].
|
||||
pub fn dump_request(out: &mut BytesMut, sequence: u32, what: Dump) {
|
||||
if let Dump::RoutesIn { version, table } = what {
|
||||
let start = begin(out, message_type::GET_ROUTE, flag::DUMP, sequence);
|
||||
// rtmsg: family, destination length, source length, TOS, table,
|
||||
// protocol, scope, type, and four octets of flags; the table in
|
||||
// its octet if it fits, else as an attribute.
|
||||
out.put_u8(family_of(version));
|
||||
out.put_bytes(0, 3);
|
||||
out.put_u8(u8::try_from(table).unwrap_or(TABLE_IN_ATTRIBUTE));
|
||||
out.put_bytes(0, 7);
|
||||
if u8::try_from(table).is_err() {
|
||||
put_attribute(out, route_attribute::TABLE, &table.to_ne_bytes());
|
||||
}
|
||||
finish(out, start);
|
||||
return;
|
||||
}
|
||||
let (message_type, family, fixed_length) = match what {
|
||||
Dump::Routes(version) => (message_type::GET_ROUTE, family_of(version), 12),
|
||||
Dump::RoutesIn { .. } => unreachable!("dealt with above"),
|
||||
Dump::Addresses => (message_type::GET_ADDRESS, 0, 8),
|
||||
Dump::Links => (message_type::GET_LINK, 0, 16),
|
||||
Dump::Nexthops => (message_type::GET_NEXTHOP, 0, 8),
|
||||
@@ -883,6 +913,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dump of one table names the table in its octet, or, for a number
|
||||
/// the octet cannot hold, in an attribute.
|
||||
#[test]
|
||||
fn a_dump_of_one_table_names_the_table() {
|
||||
let mut wire = BytesMut::new();
|
||||
dump_request(
|
||||
&mut wire,
|
||||
3,
|
||||
Dump::RoutesIn {
|
||||
version: IpVersion::Ipv4,
|
||||
table: TABLE_MAIN,
|
||||
},
|
||||
);
|
||||
assert_eq!(wire.len(), 28);
|
||||
assert_eq!(wire[16], 2, "AF_INET");
|
||||
assert_eq!(wire[20], TABLE_MAIN as u8);
|
||||
|
||||
let mut wire = BytesMut::new();
|
||||
dump_request(
|
||||
&mut wire,
|
||||
3,
|
||||
Dump::RoutesIn {
|
||||
version: IpVersion::Ipv6,
|
||||
table: 1000,
|
||||
},
|
||||
);
|
||||
// The fixed part, then an attribute of four octets with its
|
||||
// four-octet header.
|
||||
assert_eq!(wire.len(), 36);
|
||||
assert_eq!(wire[16], 10, "AF_INET6");
|
||||
assert_eq!(wire[20], TABLE_IN_ATTRIBUTE);
|
||||
assert_eq!(wire[28..30], 8u16.to_ne_bytes());
|
||||
assert_eq!(wire[30..32], route_attribute::TABLE.to_ne_bytes());
|
||||
assert_eq!(wire[32..36], 1000u32.to_ne_bytes());
|
||||
}
|
||||
|
||||
/// A message as the kernel would send it: header, fixed part,
|
||||
/// attributes.
|
||||
fn message(message_type: u16, sequence: u32, fixed: &[u8], attrs: &[(u16, &[u8])]) -> Vec<u8> {
|
||||
|
||||
+381
-60
@@ -9,7 +9,8 @@ use bgp_packet::message::NlriNextHop;
|
||||
use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
|
||||
use crate::interfaces::Interfaces;
|
||||
use crate::netlink::KIND_UNICAST;
|
||||
use crate::topology::Topology;
|
||||
|
||||
// Asked for every route: a quick hash matters here.
|
||||
pub(crate) type HashMap<K, V> = std::collections::HashMap<K, V, foldhash::fast::RandomState>;
|
||||
@@ -17,10 +18,16 @@ pub(crate) type HashSet<K> = std::collections::HashSet<K, foldhash::fast::Random
|
||||
|
||||
/// Where the kernel is to send packets for a prefix.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct Target {
|
||||
pub gateway: IpAddr,
|
||||
pub enum Target {
|
||||
/// To a gateway, out of an interface.
|
||||
Via {
|
||||
gateway: IpAddr,
|
||||
/// The index of the interface the gateway is on.
|
||||
pub interface: u32,
|
||||
interface: u32,
|
||||
},
|
||||
/// Nowhere: the packets are dropped. For a route that asks for that
|
||||
/// with the BLACKHOLE community (RFC 7999).
|
||||
Discard,
|
||||
}
|
||||
|
||||
/// Why a route's next hop is not something the kernel can be given.
|
||||
@@ -32,10 +39,17 @@ pub enum Unresolved {
|
||||
OtherFamily,
|
||||
/// The next hop is one of this machine's own addresses.
|
||||
Ourselves,
|
||||
/// The next hop is not in the network of any interface that is up.
|
||||
/// Finding the way to such a next hop through other routes is not
|
||||
/// done.
|
||||
NotConnected,
|
||||
/// The next hop is not in the network of any interface that is up,
|
||||
/// and none of the kernel's routes that count says the way to it
|
||||
/// (see `crate::routes`).
|
||||
NoRoute,
|
||||
/// The only route to the next hop is the default route, which is not
|
||||
/// taken as the way to a next hop unless asked for
|
||||
/// (`Topology::via_default`).
|
||||
OnlyDefault,
|
||||
/// The route to the next hop is one that discards packets: a
|
||||
/// blackhole, unreachable or prohibit route.
|
||||
Discarded,
|
||||
/// The route is for a network this machine is on itself, or for part
|
||||
/// of one. The kernel reaches that directly; a route from a peer that
|
||||
/// said otherwise would send the neighbors' traffic, and the sessions
|
||||
@@ -44,11 +58,13 @@ pub enum Unresolved {
|
||||
}
|
||||
|
||||
impl Unresolved {
|
||||
pub const ALL: [Self; 5] = [
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Missing,
|
||||
Self::OtherFamily,
|
||||
Self::Ourselves,
|
||||
Self::NotConnected,
|
||||
Self::NoRoute,
|
||||
Self::OnlyDefault,
|
||||
Self::Discarded,
|
||||
Self::OwnNetwork,
|
||||
];
|
||||
|
||||
@@ -57,7 +73,9 @@ impl Unresolved {
|
||||
Self::Missing => "no next hop",
|
||||
Self::OtherFamily => "IPv4 next hop for an IPv6 route",
|
||||
Self::Ourselves => "next hop is this machine",
|
||||
Self::NotConnected => "next hop not directly connected",
|
||||
Self::NoRoute => "no route to the next hop",
|
||||
Self::OnlyDefault => "next hop reachable only by the default route",
|
||||
Self::Discarded => "the route to the next hop discards packets",
|
||||
Self::OwnNetwork => "inside a network of this machine",
|
||||
}
|
||||
}
|
||||
@@ -93,21 +111,76 @@ impl From<IpAddr> for Neighbor {
|
||||
}
|
||||
|
||||
/// Works out where the kernel is to send packets for a route to `prefix`
|
||||
/// with the next hop `next_hop`, learned from `peer`.
|
||||
/// with the next hop `next_hop`, learned from `peer`. `blackhole` says
|
||||
/// the route asks for its packets to be dropped (the BLACKHOLE community,
|
||||
/// RFC 7999): then they go nowhere, and the next hop is not looked at.
|
||||
///
|
||||
/// Only a next hop that is a neighbor on one of our interfaces is
|
||||
/// resolved. A link-local next hop means something only on one link, and
|
||||
/// that is taken to be the link the peer itself is on.
|
||||
/// A next hop that is a neighbor on one of our interfaces is where the
|
||||
/// packets go. One that is not is looked up in the kernel's own routes,
|
||||
/// and the packets go where the route that covers it sends them: to its
|
||||
/// gateway, or onto its interface. A link-local next hop means something
|
||||
/// only on one link, and that is taken to be the link the peer itself is
|
||||
/// on.
|
||||
pub fn resolve(
|
||||
interfaces: &Interfaces,
|
||||
topology: &Topology,
|
||||
prefix: &IpPrefix,
|
||||
next_hop: Option<&NlriNextHop>,
|
||||
peer: Option<Neighbor>,
|
||||
blackhole: bool,
|
||||
) -> Result<Target, Unresolved> {
|
||||
if interfaces.has_network_around(prefix) {
|
||||
if topology.interfaces.has_network_around(prefix) {
|
||||
return Err(Unresolved::OwnNetwork);
|
||||
}
|
||||
resolve_next_hop(interfaces, prefix.address_family(), next_hop, peer)
|
||||
if blackhole {
|
||||
return Ok(Target::Discard);
|
||||
}
|
||||
resolve_next_hop(topology, prefix.address_family(), next_hop, peer)
|
||||
}
|
||||
|
||||
/// The way to `gateway`, which is not a neighbor, by the kernel's routes
|
||||
/// that count: where the route that covers it sends packets. One step
|
||||
/// only: the kernel sees to it that a route's own gateway is a neighbor.
|
||||
fn through_routes(
|
||||
topology: &Topology,
|
||||
routes: IpVersion,
|
||||
gateway: IpAddr,
|
||||
) -> Result<Target, Unresolved> {
|
||||
let route = topology.routes.lookup(gateway).ok_or(Unresolved::NoRoute)?;
|
||||
if route.prefix.length() == 0 && !topology.via_default {
|
||||
return Err(Unresolved::OnlyDefault);
|
||||
}
|
||||
if route.kind != KIND_UNICAST {
|
||||
return Err(Unresolved::Discarded);
|
||||
}
|
||||
let (hop, interface) = match route.nexthop {
|
||||
Some(id) => {
|
||||
// A group of nexthops has no gateway or interface of its own;
|
||||
// which of its members packets take is the kernel's to say,
|
||||
// and nothing to give it as a route.
|
||||
let nexthop = topology.nexthops.get(&id).ok_or(Unresolved::NoRoute)?;
|
||||
(
|
||||
nexthop.gateway,
|
||||
nexthop.interface.ok_or(Unresolved::NoRoute)?,
|
||||
)
|
||||
}
|
||||
None => (route.gateway, route.interface.ok_or(Unresolved::NoRoute)?),
|
||||
};
|
||||
if !topology
|
||||
.interfaces
|
||||
.link(interface)
|
||||
.is_some_and(|link| link.up)
|
||||
{
|
||||
return Err(Unresolved::NoRoute);
|
||||
}
|
||||
// A route without a gateway puts the destination on its interface:
|
||||
// the next hop itself is the neighbor there.
|
||||
let gateway = hop.unwrap_or(gateway);
|
||||
// An IPv4 route may go by an IPv6 gateway (RFC 8950, and the kernel
|
||||
// allows it); an IPv6 route by an IPv4 gateway is nothing.
|
||||
if routes == IpVersion::Ipv6 && gateway.is_ipv4() {
|
||||
return Err(Unresolved::NoRoute);
|
||||
}
|
||||
Ok(Target::Via { gateway, interface })
|
||||
}
|
||||
|
||||
/// The part of [`resolve`] that is the same for every route of IP version
|
||||
@@ -115,11 +188,12 @@ pub fn resolve(
|
||||
/// those who ask it of many such routes, and ask of each only whether it
|
||||
/// is for one of the machine's own networks.
|
||||
pub fn resolve_next_hop(
|
||||
interfaces: &Interfaces,
|
||||
topology: &Topology,
|
||||
routes: IpVersion,
|
||||
next_hop: Option<&NlriNextHop>,
|
||||
peer: Option<Neighbor>,
|
||||
) -> Result<Target, Unresolved> {
|
||||
let interfaces = &topology.interfaces;
|
||||
let next_hop = next_hop.ok_or(Unresolved::Missing)?;
|
||||
let version = match next_hop {
|
||||
NlriNextHop::Ipv4(_) => IpVersion::Ipv4,
|
||||
@@ -135,17 +209,17 @@ pub fn resolve_next_hop(
|
||||
if interfaces.is_local(gateway) {
|
||||
return Err(Unresolved::Ourselves);
|
||||
}
|
||||
interfaces
|
||||
.connected(gateway)
|
||||
.map(|connected| Target {
|
||||
match interfaces.connected(gateway) {
|
||||
Some(connected) => Ok(Target::Via {
|
||||
gateway,
|
||||
interface: connected.interface,
|
||||
})
|
||||
.ok_or(Unresolved::NotConnected)
|
||||
}),
|
||||
None => through_routes(topology, routes, gateway),
|
||||
}
|
||||
};
|
||||
// The interface the peer is a neighbor on, for a link-local gateway.
|
||||
let on_peers_link = |gateway: Ipv6Addr| {
|
||||
let interface = match peer.ok_or(Unresolved::NotConnected)? {
|
||||
let interface = match peer.ok_or(Unresolved::NoRoute)? {
|
||||
// The session is on a link-local address itself, and so says
|
||||
// which link: every interface has fe80::/64, and looking the
|
||||
// peer up there would find any of them.
|
||||
@@ -157,14 +231,14 @@ pub fn resolve_next_hop(
|
||||
.connected(address)
|
||||
.map(|connected| connected.interface),
|
||||
}
|
||||
.ok_or(Unresolved::NotConnected)?;
|
||||
.ok_or(Unresolved::NoRoute)?;
|
||||
if interfaces
|
||||
.addresses_of(interface)
|
||||
.any(|ours| ours.local == IpAddr::V6(gateway))
|
||||
{
|
||||
return Err(Unresolved::Ourselves);
|
||||
}
|
||||
Ok(Target {
|
||||
Ok(Target::Via {
|
||||
gateway: gateway.into(),
|
||||
interface,
|
||||
})
|
||||
@@ -182,8 +256,11 @@ pub fn resolve_next_hop(
|
||||
{
|
||||
on_peers_link(link_local)
|
||||
}
|
||||
// Where there is no way to the global one, the link-local one is
|
||||
// tried; a way that is there and is bad (ourselves, a blackhole)
|
||||
// is an answer as it stands.
|
||||
NlriNextHop::Ipv6WithLl { global, link_local } => match direct(global.into()) {
|
||||
Err(Unresolved::NotConnected) => on_peers_link(link_local),
|
||||
Err(Unresolved::NoRoute | Unresolved::OnlyDefault) => on_peers_link(link_local),
|
||||
resolved => resolved,
|
||||
},
|
||||
}
|
||||
@@ -248,6 +325,14 @@ impl Plan {
|
||||
self.installed.len()
|
||||
}
|
||||
|
||||
/// How many of the routes the kernel has drop their packets.
|
||||
pub fn blackholes(&self) -> usize {
|
||||
self.installed
|
||||
.values()
|
||||
.filter(|entry| entry.target == Some(Target::Discard))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -435,27 +520,27 @@ mod tests {
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::*;
|
||||
use crate::netlink::{Address, Link, Message};
|
||||
use crate::netlink::{Address, Link, Message, Nexthop, PROTOCOL_BGP, Route, TABLE_MAIN};
|
||||
|
||||
fn prefix(s: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(s).unwrap()
|
||||
}
|
||||
|
||||
fn target(gateway: &str, interface: u32) -> Target {
|
||||
Target {
|
||||
Target::Via {
|
||||
gateway: gateway.parse().unwrap(),
|
||||
interface,
|
||||
}
|
||||
}
|
||||
|
||||
fn interfaces() -> Interfaces {
|
||||
let mut interfaces = Interfaces::default();
|
||||
fn interfaces() -> Topology {
|
||||
let mut topology = Topology::new(TABLE_MAIN, false);
|
||||
for (index, local, network) in [
|
||||
(2, "192.0.2.1", "192.0.2.0/24"),
|
||||
(2, "2001:db8::1", "2001:db8::/64"),
|
||||
(3, "198.51.100.1", "198.51.100.0/24"),
|
||||
] {
|
||||
interfaces.apply(&Message::Link {
|
||||
topology.apply(&Message::Link {
|
||||
present: true,
|
||||
link: Link {
|
||||
index,
|
||||
@@ -463,7 +548,7 @@ mod tests {
|
||||
up: true,
|
||||
},
|
||||
});
|
||||
interfaces.apply(&Message::Address {
|
||||
topology.apply(&Message::Address {
|
||||
present: true,
|
||||
address: Address {
|
||||
interface: index,
|
||||
@@ -472,7 +557,28 @@ mod tests {
|
||||
},
|
||||
});
|
||||
}
|
||||
interfaces
|
||||
topology
|
||||
}
|
||||
|
||||
/// A route of the kernel's main table, as a static route is.
|
||||
fn kernel_route(prefix_text: &str, gateway: Option<&str>, interface: Option<u32>) -> Route {
|
||||
Route {
|
||||
prefix: prefix(prefix_text),
|
||||
table: TABLE_MAIN,
|
||||
protocol: 4,
|
||||
metric: 0,
|
||||
gateway: gateway.map(|g| g.parse().unwrap()),
|
||||
interface,
|
||||
nexthop: None,
|
||||
kind: KIND_UNICAST,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_route(topology: &mut Topology, route: Route) {
|
||||
assert!(topology.apply(&Message::Route {
|
||||
present: true,
|
||||
route,
|
||||
}));
|
||||
}
|
||||
|
||||
fn neighbor(address: &str) -> Neighbor {
|
||||
@@ -489,27 +595,30 @@ mod tests {
|
||||
let v6_hop = |s: &str| NlriNextHop::Ipv6(s.parse().unwrap());
|
||||
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v4, Some(&v4_hop("192.0.2.2")), peer),
|
||||
resolve(&interfaces, &v4, Some(&v4_hop("192.0.2.2")), peer, false),
|
||||
Ok(target("192.0.2.2", 2))
|
||||
);
|
||||
// A neighbor other than the peer: a route server's routes.
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v4, Some(&v4_hop("198.51.100.9")), peer),
|
||||
resolve(&interfaces, &v4, Some(&v4_hop("198.51.100.9")), peer, false),
|
||||
Ok(target("198.51.100.9", 3))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&v6_hop("2001:db8::2")), peer),
|
||||
resolve(&interfaces, &v6, Some(&v6_hop("2001:db8::2")), peer, false),
|
||||
Ok(target("2001:db8::2", 2))
|
||||
);
|
||||
for (hop, why) in [
|
||||
(Some(v4_hop("10.9.9.9")), Unresolved::NotConnected),
|
||||
(Some(v4_hop("10.9.9.9")), Unresolved::NoRoute),
|
||||
(Some(v4_hop("192.0.2.1")), Unresolved::Ourselves),
|
||||
(None, Unresolved::Missing),
|
||||
] {
|
||||
assert_eq!(resolve(&interfaces, &v4, hop.as_ref(), peer), Err(why));
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v4, hop.as_ref(), peer, false),
|
||||
Err(why)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&v4_hop("192.0.2.2")), peer),
|
||||
resolve(&interfaces, &v6, Some(&v4_hop("192.0.2.2")), peer, false),
|
||||
Err(Unresolved::OtherFamily)
|
||||
);
|
||||
// A route for part of a network we are on is not for the kernel,
|
||||
@@ -526,7 +635,7 @@ mod tests {
|
||||
IpVersion::Ipv6 => v6_hop("2001:db8::2"),
|
||||
};
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &ours, Some(&hop), peer),
|
||||
resolve(&interfaces, &ours, Some(&hop), peer, false),
|
||||
Err(Unresolved::OwnNetwork),
|
||||
"{ours}"
|
||||
);
|
||||
@@ -536,14 +645,15 @@ mod tests {
|
||||
&interfaces,
|
||||
&prefix("192.0.0.0/22"),
|
||||
Some(&v4_hop("192.0.2.2")),
|
||||
peer
|
||||
peer,
|
||||
false
|
||||
),
|
||||
Ok(target("192.0.2.2", 2))
|
||||
);
|
||||
// An IPv4 route by way of an IPv6 neighbor: a link with only IPv6
|
||||
// addresses (RFC 8950).
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v4, Some(&v6_hop("2001:db8::2")), peer),
|
||||
resolve(&interfaces, &v4, Some(&v6_hop("2001:db8::2")), peer, false),
|
||||
Ok(target("2001:db8::2", 2))
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -555,6 +665,7 @@ mod tests {
|
||||
link_local: "fe80::2".parse().unwrap(),
|
||||
}),
|
||||
Some(neighbor("2001:db8::2")),
|
||||
false
|
||||
),
|
||||
Ok(target("fe80::2", 2))
|
||||
);
|
||||
@@ -572,26 +683,44 @@ mod tests {
|
||||
};
|
||||
// The global address is a neighbor: it is used.
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&both("2001:db8::2")), peer),
|
||||
resolve(&interfaces, &v6, Some(&both("2001:db8::2")), peer, false),
|
||||
Ok(target("2001:db8::2", 2))
|
||||
);
|
||||
// It is not: the link-local one, on the interface the peer is on.
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&both("2001:db8:ffff::2")), peer),
|
||||
resolve(
|
||||
&interfaces,
|
||||
&v6,
|
||||
Some(&both("2001:db8:ffff::2")),
|
||||
peer,
|
||||
false
|
||||
),
|
||||
Ok(target("fe80::2", 2))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&NlriNextHop::Ipv6(link_local)), peer),
|
||||
resolve(
|
||||
&interfaces,
|
||||
&v6,
|
||||
Some(&NlriNextHop::Ipv6(link_local)),
|
||||
peer,
|
||||
false
|
||||
),
|
||||
Ok(target("fe80::2", 2))
|
||||
);
|
||||
// A peer that is not a neighbor says nothing about the link.
|
||||
let far = Some(neighbor("2001:db8:ffff::9"));
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&NlriNextHop::Ipv6(link_local)), far),
|
||||
Err(Unresolved::NotConnected)
|
||||
resolve(
|
||||
&interfaces,
|
||||
&v6,
|
||||
Some(&NlriNextHop::Ipv6(link_local)),
|
||||
far,
|
||||
false
|
||||
),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&both("2001:db8::1")), peer),
|
||||
resolve(&interfaces, &v6, Some(&both("2001:db8::1")), peer, false),
|
||||
Err(Unresolved::Ourselves)
|
||||
);
|
||||
}
|
||||
@@ -636,7 +765,7 @@ mod tests {
|
||||
for interface in [2, 3] {
|
||||
for route in [&v4, &v6] {
|
||||
assert_eq!(
|
||||
resolve(&interfaces, route, Some(&next_hop), on(interface)),
|
||||
resolve(&interfaces, route, Some(&next_hop), on(interface), false),
|
||||
Ok(target("fe80::2", interface)),
|
||||
"{next_hop:?} on {interface}"
|
||||
);
|
||||
@@ -644,28 +773,220 @@ mod tests {
|
||||
}
|
||||
// An interface that is not there, or is down.
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&next_hop), on(9)),
|
||||
Err(Unresolved::NotConnected)
|
||||
resolve(&interfaces, &v6, Some(&next_hop), on(9), false),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
}
|
||||
// Our own address on that link, which on another is a neighbor's.
|
||||
let ours = NlriNextHop::Ipv6("fe80::a".parse().unwrap());
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&ours), on(2)),
|
||||
resolve(&interfaces, &v6, Some(&ours), on(2), false),
|
||||
Err(Unresolved::Ourselves)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&ours), on(3)),
|
||||
resolve(&interfaces, &v6, Some(&ours), on(3), false),
|
||||
Ok(target("fe80::a", 3))
|
||||
);
|
||||
// A global next hop on such a session is looked up as any other.
|
||||
let global = NlriNextHop::Ipv6("2001:db8::2".parse().unwrap());
|
||||
assert_eq!(
|
||||
resolve(&interfaces, &v6, Some(&global), on(3)),
|
||||
resolve(&interfaces, &v6, Some(&global), on(3), false),
|
||||
Ok(target("2001:db8::2", 2))
|
||||
);
|
||||
}
|
||||
|
||||
/// A next hop that is no neighbor is reached the way the kernel's
|
||||
/// own routes say, and the route goes into the kernel with that way's
|
||||
/// gateway and interface.
|
||||
#[test]
|
||||
fn a_far_next_hop_resolves_by_the_kernels_routes() {
|
||||
let mut topology = interfaces();
|
||||
let v4 = prefix("203.0.113.0/24");
|
||||
let v6 = prefix("2001:db8:100::/48");
|
||||
let peer = Some(neighbor("192.0.2.2"));
|
||||
let v4_hop = |s: &str| NlriNextHop::Ipv4(s.parse().unwrap());
|
||||
let v6_hop = |s: &str| NlriNextHop::Ipv6(s.parse().unwrap());
|
||||
let far = v4_hop("10.9.9.9");
|
||||
|
||||
// Nothing says the way.
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
// The default route does not count unless asked for.
|
||||
with_route(
|
||||
&mut topology,
|
||||
kernel_route("0.0.0.0/0", Some("192.0.2.254"), Some(2)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Err(Unresolved::OnlyDefault)
|
||||
);
|
||||
topology.via_default = true;
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Ok(target("192.0.2.254", 2))
|
||||
);
|
||||
topology.via_default = false;
|
||||
// A static route with a gateway: the gateway is where packets go.
|
||||
with_route(
|
||||
&mut topology,
|
||||
kernel_route("10.9.0.0/16", Some("198.51.100.9"), Some(3)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Ok(target("198.51.100.9", 3))
|
||||
);
|
||||
// A more specific route onto an interface, as a tunnel's is: the
|
||||
// next hop itself is the neighbor there.
|
||||
topology.apply(&Message::Link {
|
||||
present: true,
|
||||
link: Link {
|
||||
index: 7,
|
||||
name: "wg0".into(),
|
||||
up: true,
|
||||
},
|
||||
});
|
||||
with_route(&mut topology, kernel_route("10.9.9.0/24", None, Some(7)));
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Ok(target("10.9.9.9", 7))
|
||||
);
|
||||
// The tunnel goes down: the kernel keeps such routes, we do not
|
||||
// use them.
|
||||
topology.apply(&Message::Link {
|
||||
present: true,
|
||||
link: Link {
|
||||
index: 7,
|
||||
name: "wg0".into(),
|
||||
up: false,
|
||||
},
|
||||
});
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
// A route that names a nexthop object goes where the object says.
|
||||
with_route(
|
||||
&mut topology,
|
||||
Route {
|
||||
nexthop: Some(77),
|
||||
..kernel_route("10.9.9.9/32", None, None)
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Err(Unresolved::NoRoute),
|
||||
"an object the kernel has not told of"
|
||||
);
|
||||
topology.apply(&Message::Nexthop {
|
||||
present: true,
|
||||
nexthop: Nexthop {
|
||||
id: 77,
|
||||
protocol: 4,
|
||||
gateway: Some("192.0.2.77".parse().unwrap()),
|
||||
interface: Some(2),
|
||||
},
|
||||
});
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Ok(target("192.0.2.77", 2))
|
||||
);
|
||||
// A route that discards packets is a way to nowhere.
|
||||
with_route(
|
||||
&mut topology,
|
||||
Route {
|
||||
kind: 6, // blackhole
|
||||
..kernel_route("10.9.9.9/32", None, None)
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&far), peer, false),
|
||||
Err(Unresolved::Discarded)
|
||||
);
|
||||
// Routes of BGP's never say the way, be the prefix ever so long.
|
||||
assert!(!topology.apply(&Message::Route {
|
||||
present: true,
|
||||
route: Route {
|
||||
protocol: PROTOCOL_BGP,
|
||||
metric: 20,
|
||||
..kernel_route("10.9.9.9/32", Some("198.51.100.9"), Some(3))
|
||||
},
|
||||
}));
|
||||
// An IPv6 next hop by an IPv4 gateway is no way for an IPv6 route,
|
||||
// but is one for an IPv4 route (RFC 8950 the other way about is
|
||||
// what the kernel allows).
|
||||
with_route(
|
||||
&mut topology,
|
||||
kernel_route("2001:db8:9::/48", Some("198.51.100.9"), Some(3)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v6, Some(&v6_hop("2001:db8:9::9")), peer, false),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
with_route(
|
||||
&mut topology,
|
||||
kernel_route("2001:db8:9::/48", Some("2001:db8::9"), Some(2)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v6, Some(&v6_hop("2001:db8:9::9")), peer, false),
|
||||
Ok(target("2001:db8::9", 2))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&v6_hop("2001:db8:9::9")), peer, false),
|
||||
Ok(target("2001:db8::9", 2))
|
||||
);
|
||||
// A neighbor is a neighbor, whatever the routes say.
|
||||
with_route(
|
||||
&mut topology,
|
||||
kernel_route("192.0.2.2/32", Some("198.51.100.9"), Some(3)),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &v4, Some(&v4_hop("192.0.2.2")), peer, false),
|
||||
Ok(target("192.0.2.2", 2))
|
||||
);
|
||||
}
|
||||
|
||||
/// A route that asks for its packets to be dropped goes nowhere,
|
||||
/// whatever its next hop; one for a network of ours does not.
|
||||
#[test]
|
||||
fn a_blackhole_route_resolves_to_nowhere() {
|
||||
let topology = interfaces();
|
||||
let far = NlriNextHop::Ipv4("10.9.9.9".parse().unwrap());
|
||||
let peer = Some(neighbor("192.0.2.2"));
|
||||
assert_eq!(
|
||||
resolve(
|
||||
&topology,
|
||||
&prefix("203.0.113.9/32"),
|
||||
Some(&far),
|
||||
peer,
|
||||
false
|
||||
),
|
||||
Err(Unresolved::NoRoute)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &prefix("203.0.113.9/32"), Some(&far), peer, true),
|
||||
Ok(Target::Discard)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &prefix("203.0.113.9/32"), None, peer, true),
|
||||
Ok(Target::Discard)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&topology, &prefix("192.0.2.9/32"), Some(&far), peer, true),
|
||||
Err(Unresolved::OwnNetwork)
|
||||
);
|
||||
let mut plan = Plan::default();
|
||||
assert_eq!(
|
||||
plan.best(prefix("203.0.113.9/32"), Ok(Target::Discard)),
|
||||
Some(Op::Install {
|
||||
prefix: prefix("203.0.113.9/32"),
|
||||
target: Target::Discard
|
||||
})
|
||||
);
|
||||
assert_eq!((plan.installed(), plan.blackholes()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kernel_is_asked_only_for_what_changes() {
|
||||
let mut plan = Plan::default();
|
||||
@@ -692,13 +1013,13 @@ mod tests {
|
||||
assert_eq!(plan.installed(), 1);
|
||||
// A best route that cannot be installed takes the old one out.
|
||||
assert_eq!(
|
||||
plan.best(p, Err(Unresolved::NotConnected)),
|
||||
plan.best(p, Err(Unresolved::NoRoute)),
|
||||
Some(Op::Remove { prefix: p })
|
||||
);
|
||||
assert_eq!((plan.installed(), plan.unresolved()), (0, 1));
|
||||
assert_eq!(
|
||||
plan.unresolved_by_reason().collect::<Vec<_>>(),
|
||||
[(Unresolved::NotConnected, 1)]
|
||||
[(Unresolved::NoRoute, 1)]
|
||||
);
|
||||
assert_eq!(plan.withdrawn(p), None);
|
||||
assert_eq!(plan.unresolved_by_reason().count(), 0);
|
||||
@@ -849,7 +1170,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_kernel_ends_up_with_the_best_routes_that_resolve(
|
||||
left_behind in proptest::collection::vec((0u8..16, 0u8..3), 0..6),
|
||||
steps in proptest::collection::vec(any_step(), 0..60),
|
||||
steps in proptest::collection::vec(any_step(), 0..60)
|
||||
) {
|
||||
let p = |n: u8| IpPrefix::new(IpVersion::Ipv4, [10, n, 0, 0], 16).unwrap();
|
||||
let t = |n: u8| target(&format!("192.0.2.{}", n + 2), 2);
|
||||
@@ -866,7 +1187,7 @@ mod tests {
|
||||
Some(Op::Remove { prefix }) => { kernel.remove(&prefix); }
|
||||
None => {}
|
||||
};
|
||||
let resolved = |gateway: Option<u8>| gateway.map(t).ok_or(Unresolved::NotConnected);
|
||||
let resolved = |gateway: Option<u8>| gateway.map(t).ok_or(Unresolved::NoRoute);
|
||||
// A new reader is first given the whole table.
|
||||
let steps = std::iter::once(Step::Resync).chain(steps);
|
||||
for step in steps {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! The kernel's own routes, as far as next hops are concerned: the way to
|
||||
//! a next hop that is not a neighbor on any of our interfaces.
|
||||
//!
|
||||
//! Only the routes of one table count, the resolution table, which is
|
||||
//! normally `main`: the connected and static routes, and an IGP's if there
|
||||
//! is one. Routes put there by BGP, ours or another daemon's, never count:
|
||||
//! a next hop that is reached only by way of a BGP route is reached by way
|
||||
//! of something that may itself depend on a next hop, and a table that
|
||||
//! leans on itself like that is one that falls over in a way nobody can
|
||||
//! predict. The default route counts only if asked for: on a router, the
|
||||
//! default points at a transit provider, and a next hop that is "reached"
|
||||
//! only by sending the packets to the Internet at large is not reached.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
|
||||
use crate::netlink::{Message, PROTOCOL_BGP, Route};
|
||||
use crate::plan::HashMap;
|
||||
|
||||
/// The routes of the resolution table that may say the way to a next hop.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KernelRoutes {
|
||||
table: u32,
|
||||
/// By prefix, the routes there are for it: usually one, but the kernel
|
||||
/// keeps one for each metric.
|
||||
routes: HashMap<IpPrefix, Vec<Route>>,
|
||||
/// How many prefixes there are of each length, by IP version: the
|
||||
/// lengths a lookup has to try.
|
||||
lengths: [[u32; 129]; 2],
|
||||
}
|
||||
|
||||
/// `address` as the prefix that is only it.
|
||||
pub(crate) fn host(address: IpAddr) -> IpPrefix {
|
||||
match address {
|
||||
IpAddr::V4(v4) => IpPrefix::new(IpVersion::Ipv4, v4.octets(), 32),
|
||||
IpAddr::V6(v6) => IpPrefix::new(IpVersion::Ipv6, v6.octets(), 128),
|
||||
}
|
||||
.expect("an address is a prefix of its own length")
|
||||
}
|
||||
|
||||
fn version_index(version: IpVersion) -> usize {
|
||||
match version {
|
||||
IpVersion::Ipv4 => 0,
|
||||
IpVersion::Ipv6 => 1,
|
||||
}
|
||||
}
|
||||
|
||||
impl KernelRoutes {
|
||||
pub fn new(table: u32) -> Self {
|
||||
Self {
|
||||
table,
|
||||
routes: HashMap::default(),
|
||||
lengths: [[0; 129]; 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// The table the routes are of.
|
||||
pub fn table(&self) -> u32 {
|
||||
self.table
|
||||
}
|
||||
|
||||
/// Whether `route` is one that may say the way to a next hop.
|
||||
fn counts(&self, route: &Route) -> bool {
|
||||
route.table == self.table && route.protocol != PROTOCOL_BGP
|
||||
}
|
||||
|
||||
/// Takes in what a message from the kernel says about routes. Returns
|
||||
/// whether anything is different for it.
|
||||
pub fn apply(&mut self, message: &Message) -> bool {
|
||||
let Message::Route { present, route } = message else {
|
||||
return false;
|
||||
};
|
||||
if !self.counts(route) {
|
||||
return false;
|
||||
}
|
||||
let version = version_index(route.prefix.address_family());
|
||||
let length = usize::from(route.prefix.length());
|
||||
if *present {
|
||||
let routes = self.routes.entry(route.prefix).or_default();
|
||||
// A route for a prefix with a metric there is one for already
|
||||
// takes that one's place: the kernel keeps one per metric.
|
||||
match routes.iter_mut().find(|known| known.metric == route.metric) {
|
||||
Some(known) if known == route => false,
|
||||
Some(known) => {
|
||||
*known = *route;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
if routes.is_empty() {
|
||||
self.lengths[version][length] += 1;
|
||||
}
|
||||
// In order of metric, so that two tables with the
|
||||
// same routes compare equal however they were told.
|
||||
let at = routes.partition_point(|known| known.metric < route.metric);
|
||||
routes.insert(at, *route);
|
||||
true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Some(routes) = self.routes.get_mut(&route.prefix) else {
|
||||
return false;
|
||||
};
|
||||
let before = routes.len();
|
||||
routes.retain(|known| known.metric != route.metric);
|
||||
let (after, none_left) = (routes.len(), routes.is_empty());
|
||||
if none_left {
|
||||
self.routes.remove(&route.prefix);
|
||||
self.lengths[version][length] -= 1;
|
||||
}
|
||||
before != after
|
||||
}
|
||||
}
|
||||
|
||||
/// How many prefixes have a route.
|
||||
pub fn len(&self) -> usize {
|
||||
self.routes.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.routes.is_empty()
|
||||
}
|
||||
|
||||
/// The route the kernel would send packets for `address` by, from
|
||||
/// this table: the one for the longest prefix that covers it, and of
|
||||
/// several for that prefix the one with the lowest metric. The
|
||||
/// default route too, if there is one and nothing longer covers the
|
||||
/// address.
|
||||
pub fn lookup(&self, address: IpAddr) -> Option<&Route> {
|
||||
let whole = host(address);
|
||||
let lengths = &self.lengths[version_index(whole.address_family())];
|
||||
(0..=whole.length())
|
||||
.rev()
|
||||
.filter(|length| lengths[usize::from(*length)] > 0)
|
||||
.find_map(|length| self.routes.get(&whole.shortened(length)))
|
||||
.and_then(|routes| routes.iter().min_by_key(|route| route.metric))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::netlink::{KIND_UNICAST, TABLE_MAIN};
|
||||
|
||||
fn prefix(s: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(s).unwrap()
|
||||
}
|
||||
|
||||
fn route(prefix_text: &str, gateway: Option<&str>, interface: u32, metric: u32) -> Route {
|
||||
Route {
|
||||
prefix: prefix(prefix_text),
|
||||
table: TABLE_MAIN,
|
||||
protocol: 4, // static
|
||||
metric,
|
||||
gateway: gateway.map(|g| g.parse().unwrap()),
|
||||
interface: Some(interface),
|
||||
nexthop: None,
|
||||
kind: KIND_UNICAST,
|
||||
}
|
||||
}
|
||||
|
||||
fn present(route: Route) -> Message {
|
||||
Message::Route {
|
||||
present: true,
|
||||
route,
|
||||
}
|
||||
}
|
||||
|
||||
fn gone(route: Route) -> Message {
|
||||
Message::Route {
|
||||
present: false,
|
||||
route,
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(routes: &KernelRoutes, address: &str) -> Option<(String, Option<String>, u32)> {
|
||||
routes.lookup(address.parse().unwrap()).map(|route| {
|
||||
(
|
||||
route.prefix.to_string(),
|
||||
route.gateway.map(|g| g.to_string()),
|
||||
route.metric,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_longest_prefix_with_the_lowest_metric_is_the_way() {
|
||||
let mut routes = KernelRoutes::new(TABLE_MAIN);
|
||||
for r in [
|
||||
route("0.0.0.0/0", Some("192.0.2.1"), 2, 100),
|
||||
route("10.0.0.0/8", Some("192.0.2.9"), 2, 0),
|
||||
route("10.1.0.0/16", None, 7, 0),
|
||||
route("10.1.0.0/16", Some("198.51.100.1"), 3, 50),
|
||||
route("2001:db8::/32", Some("2001:db8::1"), 2, 1024),
|
||||
] {
|
||||
assert!(routes.apply(&present(r)));
|
||||
}
|
||||
assert_eq!(routes.len(), 4);
|
||||
assert_eq!(
|
||||
lookup(&routes, "10.2.3.4"),
|
||||
Some(("10.0.0.0/8".into(), Some("192.0.2.9".into()), 0))
|
||||
);
|
||||
// Two routes for the prefix: the lower metric wins.
|
||||
assert_eq!(
|
||||
lookup(&routes, "10.1.2.3"),
|
||||
Some(("10.1.0.0/16".into(), None, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(&routes, "203.0.113.1"),
|
||||
Some(("0.0.0.0/0".into(), Some("192.0.2.1".into()), 100))
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(&routes, "2001:db8:1::1"),
|
||||
Some(("2001:db8::/32".into(), Some("2001:db8::1".into()), 1024))
|
||||
);
|
||||
assert_eq!(lookup(&routes, "2001:db9::1"), None);
|
||||
|
||||
// Told again what is known: nothing changes.
|
||||
assert!(!routes.apply(&present(route("10.0.0.0/8", Some("192.0.2.9"), 2, 0))));
|
||||
// The same prefix and metric with another gateway: replaced.
|
||||
assert!(routes.apply(&present(route("10.0.0.0/8", Some("192.0.2.8"), 2, 0))));
|
||||
assert_eq!(
|
||||
lookup(&routes, "10.2.3.4"),
|
||||
Some(("10.0.0.0/8".into(), Some("192.0.2.8".into()), 0))
|
||||
);
|
||||
// The better of the two for 10.1/16 goes: the other is the way.
|
||||
assert!(routes.apply(&gone(route("10.1.0.0/16", None, 7, 0))));
|
||||
assert_eq!(
|
||||
lookup(&routes, "10.1.2.3"),
|
||||
Some(("10.1.0.0/16".into(), Some("198.51.100.1".into()), 50))
|
||||
);
|
||||
assert!(routes.apply(&gone(route("10.1.0.0/16", Some("198.51.100.1"), 3, 50))));
|
||||
assert_eq!(
|
||||
lookup(&routes, "10.1.2.3"),
|
||||
Some(("10.0.0.0/8".into(), Some("192.0.2.8".into()), 0))
|
||||
);
|
||||
// Gone already: nothing changes.
|
||||
assert!(!routes.apply(&gone(route("10.1.0.0/16", None, 7, 0))));
|
||||
assert_eq!(routes.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_of_other_tables_and_of_bgp_do_not_count() {
|
||||
let mut routes = KernelRoutes::new(TABLE_MAIN);
|
||||
let other_table = Route {
|
||||
table: 200,
|
||||
..route("10.0.0.0/8", Some("192.0.2.9"), 2, 0)
|
||||
};
|
||||
let bgp = Route {
|
||||
protocol: PROTOCOL_BGP,
|
||||
..route("10.0.0.0/8", Some("192.0.2.9"), 2, 20)
|
||||
};
|
||||
assert!(!routes.apply(&present(other_table)));
|
||||
assert!(!routes.apply(&present(bgp)));
|
||||
assert!(routes.is_empty());
|
||||
assert_eq!(lookup(&routes, "10.2.3.4"), None);
|
||||
// What is not about routes changes nothing either.
|
||||
assert!(!routes.apply(&Message::Done));
|
||||
|
||||
let mut table_200 = KernelRoutes::new(200);
|
||||
assert!(table_200.apply(&present(other_table)));
|
||||
assert_eq!(table_200.table(), 200);
|
||||
assert_eq!(table_200.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! What the kernel knows that says whether, and how, a next hop can be
|
||||
//! reached: the machine's interfaces and addresses, the routes of the
|
||||
//! resolution table, and the kernel's nexthop objects that those routes
|
||||
//! may name. One value, replaced as a whole when any of it changes, so
|
||||
//! that everyone judging next hops judges them by one moment's view.
|
||||
|
||||
use crate::interfaces::Interfaces;
|
||||
use crate::netlink::{Message, Nexthop};
|
||||
use crate::plan::HashMap;
|
||||
use crate::routes::KernelRoutes;
|
||||
|
||||
/// The kernel's view of the machine, as far as next hops are concerned.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Topology {
|
||||
pub interfaces: Interfaces,
|
||||
/// The routes a next hop that is not a neighbor is reached by.
|
||||
pub routes: KernelRoutes,
|
||||
/// Every nexthop object the kernel has, by number, whoever made it:
|
||||
/// a route of the resolution table may name one instead of carrying
|
||||
/// its own gateway.
|
||||
pub nexthops: HashMap<u32, Nexthop>,
|
||||
/// Whether the default route counts as the way to a next hop.
|
||||
pub via_default: bool,
|
||||
}
|
||||
|
||||
impl Topology {
|
||||
/// Nothing known yet, with next hops to be resolved by the routes of
|
||||
/// `resolution_table`.
|
||||
pub fn new(resolution_table: u32, via_default: bool) -> Self {
|
||||
Self {
|
||||
interfaces: Interfaces::default(),
|
||||
routes: KernelRoutes::new(resolution_table),
|
||||
nexthops: HashMap::default(),
|
||||
via_default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes in what a message from the kernel says. Returns whether
|
||||
/// anything is different for it: then what any next hop resolves to
|
||||
/// may be different too.
|
||||
pub fn apply(&mut self, message: &Message) -> bool {
|
||||
match message {
|
||||
Message::Nexthop {
|
||||
present: true,
|
||||
nexthop,
|
||||
} => self.nexthops.insert(nexthop.id, *nexthop) != Some(*nexthop),
|
||||
Message::Nexthop {
|
||||
present: false,
|
||||
nexthop,
|
||||
} => self.nexthops.remove(&nexthop.id).is_some(),
|
||||
Message::Route { .. } => self.routes.apply(message),
|
||||
_ => self.interfaces.apply(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::netlink::{KIND_UNICAST, Link, PROTOCOL_BGP, Route, TABLE_MAIN};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
|
||||
#[test]
|
||||
fn each_kind_of_news_goes_where_it_belongs() {
|
||||
let mut topology = Topology::new(TABLE_MAIN, false);
|
||||
let nexthop = Nexthop {
|
||||
id: 7,
|
||||
protocol: 4,
|
||||
gateway: Some("192.0.2.1".parse().unwrap()),
|
||||
interface: Some(2),
|
||||
};
|
||||
let present = |nexthop| Message::Nexthop {
|
||||
present: true,
|
||||
nexthop,
|
||||
};
|
||||
assert!(topology.apply(&present(nexthop)));
|
||||
assert!(!topology.apply(&present(nexthop)));
|
||||
assert!(topology.apply(&present(Nexthop {
|
||||
interface: Some(3),
|
||||
..nexthop
|
||||
})));
|
||||
assert_eq!(topology.nexthops[&7].interface, Some(3));
|
||||
assert!(topology.apply(&Message::Nexthop {
|
||||
present: false,
|
||||
nexthop,
|
||||
}));
|
||||
assert!(topology.nexthops.is_empty());
|
||||
|
||||
let route = Route {
|
||||
prefix: IpPrefix::try_from("10.0.0.0/8").unwrap(),
|
||||
table: TABLE_MAIN,
|
||||
protocol: 4,
|
||||
metric: 0,
|
||||
gateway: Some("192.0.2.1".parse().unwrap()),
|
||||
interface: Some(2),
|
||||
nexthop: None,
|
||||
kind: KIND_UNICAST,
|
||||
};
|
||||
assert!(topology.apply(&Message::Route {
|
||||
present: true,
|
||||
route,
|
||||
}));
|
||||
assert_eq!(topology.routes.len(), 1);
|
||||
// BGP's routes are not the way to anything.
|
||||
assert!(!topology.apply(&Message::Route {
|
||||
present: true,
|
||||
route: Route {
|
||||
protocol: PROTOCOL_BGP,
|
||||
..route
|
||||
},
|
||||
}));
|
||||
assert!(topology.apply(&Message::Link {
|
||||
present: true,
|
||||
link: Link {
|
||||
index: 2,
|
||||
name: "eth0".into(),
|
||||
up: true,
|
||||
},
|
||||
}));
|
||||
assert!(topology.interfaces.link(2).is_some());
|
||||
assert!(!topology.apply(&Message::Done));
|
||||
}
|
||||
}
|
||||
+146
-4
@@ -15,8 +15,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use bgp_fib::{Fib, FibConfig, FibError};
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, MultiExitDiscPathAttribute, NextHopPathAttribute, NlriNextHop,
|
||||
OriginPathAttribute, PathAttribute,
|
||||
AsPathAttribute, CommunitiesPathAttribute, MultiExitDiscPathAttribute, NextHopPathAttribute,
|
||||
NlriNextHop, OriginPathAttribute, PathAttribute,
|
||||
};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::table::{AttrSet, PeerIndex, PeerInfo, RibTable};
|
||||
@@ -109,6 +109,7 @@ impl Machine {
|
||||
metric: 20,
|
||||
nexthop_objects: self.nexthop_objects,
|
||||
stale_time: self.stale_time,
|
||||
..FibConfig::default()
|
||||
},
|
||||
)
|
||||
.expect("the kernel can be talked to")
|
||||
@@ -294,7 +295,7 @@ async fn the_kernel_has_the_best_route_of_each_prefix() {
|
||||
machine.shown("2001:db8:100::/48", v6_neighbor),
|
||||
]);
|
||||
status_becomes(&fib, "one unresolved", |status| {
|
||||
status.installed == 2 && status.unresolved == [("next hop not directly connected", 1)]
|
||||
status.installed == 2 && status.unresolved == [("no route to the next hop", 1)]
|
||||
});
|
||||
|
||||
// A session ends: its routes go.
|
||||
@@ -367,7 +368,7 @@ async fn routes_follow_the_machines_interfaces() {
|
||||
.announce(peer, &via, &[prefix("203.0.113.0/24")]);
|
||||
let (fib, task) = machine.start();
|
||||
status_becomes(&fib, "unresolved", |status| {
|
||||
status.synced && status.unresolved == [("next hop not directly connected", 1)]
|
||||
status.synced && status.unresolved == [("no route to the next hop", 1)]
|
||||
});
|
||||
assert!(machine.kernel_routes().is_empty());
|
||||
|
||||
@@ -416,6 +417,147 @@ async fn routes_follow_the_machines_interfaces() {
|
||||
machine.kernel_becomes(&[]);
|
||||
}
|
||||
|
||||
/// A next hop that is no neighbor goes the way the kernel's own routes
|
||||
/// say: a static route's gateway, a tunnel's interface, a nexthop object.
|
||||
/// When those routes change, so do ours; a route that discards packets is
|
||||
/// the way to nowhere.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
|
||||
async fn a_far_next_hop_goes_the_way_the_kernels_routes_say() {
|
||||
let machine = Machine::new(10);
|
||||
let interface = machine.interface();
|
||||
let peer = machine.peer(2);
|
||||
// Next hops that are not in any network of ours.
|
||||
let far = machine.via("172.31.10.9".parse().unwrap(), 0);
|
||||
let farther = machine.via("172.31.11.9".parse().unwrap(), 0);
|
||||
machine
|
||||
.rib
|
||||
.announce(peer, &far, &[prefix("203.0.113.0/24")]);
|
||||
machine
|
||||
.rib
|
||||
.announce(peer, &farther, &[prefix("198.18.0.0/15")]);
|
||||
let (fib, task) = machine.start();
|
||||
status_becomes(&fib, "no way to either", |status| {
|
||||
status.synced && status.unresolved == [("no route to the next hop", 2)]
|
||||
});
|
||||
assert!(machine.kernel_routes().is_empty());
|
||||
|
||||
// A static route with a gateway says the way to one: the route goes
|
||||
// in with that gateway.
|
||||
ip(&format!(
|
||||
"route add 172.31.10.0/24 via 10.10.0.9 dev {interface}"
|
||||
));
|
||||
machine.kernel_becomes(&[machine.shown("203.0.113.0/24", "10.10.0.9")]);
|
||||
// A route onto the interface, as a tunnel's is, says the way to the
|
||||
// other: the next hop itself is the neighbor there.
|
||||
ip(&format!("route add 172.31.11.0/24 dev {interface}"));
|
||||
machine.kernel_becomes(&[
|
||||
machine.shown("203.0.113.0/24", "10.10.0.9"),
|
||||
machine.shown("198.18.0.0/15", "172.31.11.9"),
|
||||
]);
|
||||
status_becomes(&fib, "both resolved", |status| {
|
||||
status.unresolved.is_empty() && status.installed == 2
|
||||
});
|
||||
|
||||
// The way changes: so does the route. Then it goes, and so does the
|
||||
// route.
|
||||
ip(&format!(
|
||||
"route replace 172.31.10.0/24 via 10.10.0.8 dev {interface}"
|
||||
));
|
||||
machine.kernel_becomes(&[
|
||||
machine.shown("203.0.113.0/24", "10.10.0.8"),
|
||||
machine.shown("198.18.0.0/15", "172.31.11.9"),
|
||||
]);
|
||||
ip("route del 172.31.10.0/24");
|
||||
machine.kernel_becomes(&[machine.shown("198.18.0.0/15", "172.31.11.9")]);
|
||||
status_becomes(&fib, "one unresolved again", |status| {
|
||||
status.unresolved == [("no route to the next hop", 1)]
|
||||
});
|
||||
|
||||
// A route that names a nexthop object goes where the object says.
|
||||
ip(&format!(
|
||||
"nexthop add id 1010 via 10.10.0.7 dev {interface}"
|
||||
));
|
||||
ip("route add 172.31.10.0/24 nhid 1010");
|
||||
machine.kernel_becomes(&[
|
||||
machine.shown("203.0.113.0/24", "10.10.0.7"),
|
||||
machine.shown("198.18.0.0/15", "172.31.11.9"),
|
||||
]);
|
||||
|
||||
// The tunnel's route becomes a blackhole: the way to nowhere.
|
||||
ip("route replace blackhole 172.31.11.0/24");
|
||||
machine.kernel_becomes(&[machine.shown("203.0.113.0/24", "10.10.0.7")]);
|
||||
status_becomes(&fib, "discarded", |status| {
|
||||
status.unresolved == [("the route to the next hop discards packets", 1)]
|
||||
});
|
||||
|
||||
ip("route del 172.31.10.0/24");
|
||||
ip("route del blackhole 172.31.11.0/24");
|
||||
ip("nexthop del id 1010");
|
||||
fib.stop().await;
|
||||
task.await.unwrap().unwrap();
|
||||
machine.kernel_becomes(&[]);
|
||||
}
|
||||
|
||||
/// A route that asks for its packets to be dropped goes into the kernel
|
||||
/// as a blackhole route, whatever its next hop, and comes out again with
|
||||
/// the request.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
|
||||
async fn a_blackhole_request_becomes_a_blackhole_route() {
|
||||
let machine = Machine::new(13);
|
||||
let peer = machine.peer(2);
|
||||
// Asked for from a next hop nothing is a neighbor of, and with one
|
||||
// ordinary route beside it to show the two apart.
|
||||
let mut attributes = vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![65002]),
|
||||
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(172, 31, 11, 9))),
|
||||
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65002, 1), (0xFFFF, 0x029A)])),
|
||||
];
|
||||
let blackhole = machine.rib.attrs().intern(&attributes, None).unwrap();
|
||||
attributes.pop();
|
||||
attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(vec![
|
||||
(65002, 1),
|
||||
])));
|
||||
let plain = machine.rib.attrs().intern(&attributes, None).unwrap();
|
||||
machine.rib.announce(
|
||||
peer,
|
||||
&blackhole,
|
||||
&[prefix("203.0.113.9/32"), prefix("2001:db8:100::9/128")],
|
||||
);
|
||||
machine
|
||||
.rib
|
||||
.announce(peer, &plain, &[prefix("198.18.0.0/15")]);
|
||||
let (fib, task) = machine.start();
|
||||
// The kernel puts an IPv6 blackhole route on lo, and says so.
|
||||
machine.kernel_becomes(&[
|
||||
"blackhole 203.0.113.9 proto bgp metric 20".into(),
|
||||
"blackhole 2001:db8:100::9 dev lo proto bgp metric 20".into(),
|
||||
]);
|
||||
status_becomes(&fib, "two blackholes, one unresolved", |status| {
|
||||
status.synced
|
||||
&& status.installed == 2
|
||||
&& status.blackholes == 2
|
||||
&& status.unresolved == [("no route to the next hop", 1)]
|
||||
});
|
||||
|
||||
// The request is withdrawn: the blackhole goes.
|
||||
machine.rib.withdraw(peer, &[prefix("203.0.113.9/32")]);
|
||||
machine.kernel_becomes(&["blackhole 2001:db8:100::9 dev lo proto bgp metric 20".into()]);
|
||||
|
||||
// A blackhole left by an earlier run is found and kept like any other.
|
||||
fib.leave().await;
|
||||
task.await.unwrap().unwrap();
|
||||
let (fib, task) = machine.start();
|
||||
status_becomes(&fib, "the blackhole is adopted", |status| {
|
||||
status.synced && status.installed == 1 && status.blackholes == 1
|
||||
});
|
||||
fib.stop().await;
|
||||
task.await.unwrap().unwrap();
|
||||
machine.kernel_becomes(&[]);
|
||||
}
|
||||
|
||||
/// A table's worth of routes goes in, and comes out, in reasonable time.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
|
||||
|
||||
@@ -23,6 +23,7 @@ pub const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub fn default_policy() -> bgp_server::policy::ImportPolicy {
|
||||
bgp_server::policy::ImportPolicy {
|
||||
accept_third_party_next_hop: true,
|
||||
accept_blackholes: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -56,6 +57,7 @@ pub fn peer_config(name: &str, endpoint: impl Into<Endpoint>, remote_asn: u32) -
|
||||
groups: Vec::new(),
|
||||
reflector_client: false,
|
||||
passive: false,
|
||||
expected_routes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ impl TestPeer {
|
||||
pub async fn recv_within(&mut self, wait: Duration) -> Result<Message> {
|
||||
match tokio::time::timeout(wait, self.recv_untimed()).await {
|
||||
Ok(message) => message,
|
||||
Err(_) => bail!("no message from the server within {wait:?}"),
|
||||
Err(_) => Err(eyre::eyre!("no message from the server within {wait:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ impl TestPeer {
|
||||
match self.recv().await? {
|
||||
Message::Notification(n) => return Ok(n),
|
||||
Message::KeepAlive => {}
|
||||
other => bail!("expected NOTIFICATION, got {other:?}"),
|
||||
other => return Err(eyre::eyre!("expected NOTIFICATION, got {other:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Blackhole requests (RFC 7999): refused by default, and with
|
||||
//! `accept_blackholes` taken for one address inside what the peer may
|
||||
//! announce, whatever their next hop.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::announcement::announcement;
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_integration_tests::test_server::{ServerWithPeer, TestServer};
|
||||
use bgp_integration_tests::{default_policy, prefix, wait_until};
|
||||
use bgp_server::policy::{ImportPolicy, PrefixSet};
|
||||
use eyre::Result;
|
||||
|
||||
const LOCAL_ASN: u32 = 1299;
|
||||
const PEER_ASN: u32 = 3333;
|
||||
const PEER_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2);
|
||||
const BLACKHOLE: (u16, u16) = (0xFFFF, 0x029A);
|
||||
|
||||
async fn server_with(import: ImportPolicy) -> Result<ServerWithPeer> {
|
||||
TestServer::with_peer(LOCAL_ASN, PEER_ASN, |c, peer| {
|
||||
c.connect_retry = Duration::from_millis(50);
|
||||
peer.import = import;
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Waits until the server has taken `taken` routes of the session's and
|
||||
/// refused `refused`, and returns the prefixes in its table.
|
||||
async fn table_after(server: &TestServer, taken: u64, refused: u64) -> Result<BTreeSet<String>> {
|
||||
let counters = server.registry.list()[0].counters.clone();
|
||||
wait_until("the routes are dealt with", || {
|
||||
(counters.routes(), counters.rejected()) == (taken, refused)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
eyre::eyre!(
|
||||
"the server took {} routes and refused {}; expected {taken} and {refused}",
|
||||
counters.routes(),
|
||||
counters.rejected()
|
||||
)
|
||||
})?;
|
||||
Ok(server
|
||||
.received_routes()
|
||||
.iter()
|
||||
.map(|route| route.prefix.to_string())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The policy that lets the peer announce 193.0.0.0/21 and nothing
|
||||
/// longer than a /24 inside it, as a customer's would.
|
||||
fn customer_policy(accept_blackholes: bool) -> ImportPolicy {
|
||||
let mut prefixes = PrefixSet::default();
|
||||
prefixes.allow(prefix("193.0.0.0/21"), 24);
|
||||
ImportPolicy {
|
||||
prefixes: Some(prefixes),
|
||||
accept_blackholes,
|
||||
..default_policy()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_blackhole_requests_are_refused_unless_accepted() -> Result<()> {
|
||||
let with_peer = server_with(customer_policy(false)).await?;
|
||||
let server = &with_peer.server;
|
||||
let mut peer: TestPeer = server.established(PEER_ASN, PEER_ID).await?;
|
||||
peer.announce(announcement(&["193.0.0.9/32"], &[PEER_ASN]).with_communities(&[BLACKHOLE]))
|
||||
.await?;
|
||||
peer.announce(announcement(&["193.0.0.0/24"], &[PEER_ASN]))
|
||||
.await?;
|
||||
assert_eq!(table_after(server, 1, 1).await?, set(&["193.0.0.0/24"]));
|
||||
let refused = server.rejections.peers();
|
||||
assert_eq!(refused.len(), 1);
|
||||
assert_eq!(
|
||||
refused[0]
|
||||
.1
|
||||
.by_reason()
|
||||
.iter()
|
||||
.map(|(reason, n)| (reason.as_str(), *n))
|
||||
.collect::<Vec<_>>(),
|
||||
[("blackhole request", 1)]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_a_blackhole_request_is_taken_for_one_address_in_the_peers_space() -> Result<()> {
|
||||
let with_peer = server_with(customer_policy(true)).await?;
|
||||
let server = &with_peer.server;
|
||||
let mut peer: TestPeer = server.established(PEER_ASN, PEER_ID).await?;
|
||||
// One address inside the peer's prefix, from a next hop that is no
|
||||
// neighbor and would not pass as an ordinary route's.
|
||||
peer.announce(
|
||||
announcement(&["193.0.0.9/32"], &[PEER_ASN])
|
||||
.with_next_hop(Ipv4Addr::new(172, 31, 0, 9))
|
||||
.with_communities(&[BLACKHOLE]),
|
||||
)
|
||||
.await?;
|
||||
// More than one address: not a blackhole request that is taken.
|
||||
peer.announce(announcement(&["193.0.0.0/25"], &[PEER_ASN]).with_communities(&[BLACKHOLE]))
|
||||
.await?;
|
||||
// One address outside the peer's space.
|
||||
peer.announce(announcement(&["193.0.8.9/32"], &[PEER_ASN]).with_communities(&[BLACKHOLE]))
|
||||
.await?;
|
||||
// And an ordinary route, taken as ever.
|
||||
peer.announce(announcement(&["193.0.0.0/24"], &[PEER_ASN]))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
table_after(server, 2, 2).await?,
|
||||
set(&["193.0.0.0/24", "193.0.0.9/32"])
|
||||
);
|
||||
let refused = server.rejections.peers();
|
||||
let mut reasons: Vec<(&str, u64)> = refused[0]
|
||||
.1
|
||||
.by_reason()
|
||||
.iter()
|
||||
.map(|(reason, n)| (reason.as_str(), *n))
|
||||
.collect();
|
||||
reasons.sort();
|
||||
assert_eq!(
|
||||
reasons,
|
||||
[
|
||||
("blackhole for more than one address", 1),
|
||||
("prefix not allowed", 1)
|
||||
]
|
||||
);
|
||||
// The route is in the table with its request on it, for the kernel
|
||||
// and for whoever it goes on to.
|
||||
let routes = server.received_routes();
|
||||
let blackhole = routes
|
||||
.iter()
|
||||
.find(|route| route.prefix == prefix("193.0.0.9/32"))
|
||||
.expect("the blackhole route is in the table");
|
||||
assert!(ImportPolicy::is_blackhole_request(&blackhole.attributes));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set(prefixes: &[&str]) -> BTreeSet<String> {
|
||||
prefixes.iter().map(|p| p.to_string()).collect()
|
||||
}
|
||||
@@ -209,6 +209,23 @@ impl AttrSet {
|
||||
plain
|
||||
}
|
||||
|
||||
/// Whether the route carries the BLACKHOLE community (RFC 7999), which
|
||||
/// asks for the packets to its prefix to be dropped. Read off the
|
||||
/// stored bytes without building anything else, for those who ask it
|
||||
/// of every route.
|
||||
pub fn is_blackhole(&self) -> bool {
|
||||
const BLACKHOLE: [u8; 4] = [0xFF, 0xFF, 0x02, 0x9A];
|
||||
let mut wire = &*self.wire;
|
||||
while let Ok((attribute, rest)) = split_attribute(wire) {
|
||||
if attribute.type_code == attr_type::COMMUNITIES {
|
||||
let (communities, _) = attribute.value.as_chunks::<4>();
|
||||
return communities.contains(&BLACKHOLE);
|
||||
}
|
||||
wire = rest;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Puts the AS numbers of the route's path in `out`, in place of what
|
||||
/// was there: in the order of the path, the neighbor's first and the
|
||||
/// origin's last, sets and all. Returns whether the path has a set in
|
||||
@@ -595,6 +612,28 @@ mod tests {
|
||||
assert!(path.is_empty());
|
||||
}
|
||||
|
||||
/// The BLACKHOLE community is read off the stored bytes.
|
||||
#[test]
|
||||
fn a_blackhole_request_is_told_from_the_bytes() {
|
||||
let store = AttrStore::new();
|
||||
let with = |communities: Vec<(u16, u16)>| {
|
||||
let mut attributes = attributes(vec![3333]);
|
||||
attributes.retain(|attribute| !matches!(attribute, PathAttribute::Communities(_)));
|
||||
attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(
|
||||
communities,
|
||||
)));
|
||||
store.intern(&attributes, None).unwrap()
|
||||
};
|
||||
assert!(with(vec![(3333, 100), (0xFFFF, 0x029A)]).is_blackhole());
|
||||
assert!(!with(vec![(3333, 666), (0xFFFF, 0xFF01)]).is_blackhole());
|
||||
assert!(
|
||||
!store
|
||||
.intern(&attributes(vec![3333]), None)
|
||||
.unwrap()
|
||||
.is_blackhole()
|
||||
);
|
||||
}
|
||||
|
||||
/// Two of the same attribute are not a set: its readers could each
|
||||
/// take a different one.
|
||||
#[test]
|
||||
|
||||
@@ -220,6 +220,22 @@ message Kernel {
|
||||
// was not announced again is removed.
|
||||
// Zero removes them at once.
|
||||
optional uint32 stale_seconds = 4;
|
||||
|
||||
// The kernel's table whose routes say how a next hop that is not a
|
||||
// neighbour is reached: its connected and static routes, and an IGP's
|
||||
// if there is one. Defaults to 254, the main table. A route whose next
|
||||
// hop is covered by such a route is installed with that route's gateway
|
||||
// and interface, the kernel not looking gateways up in turn. Routes put
|
||||
// in the table by BGP, this daemon's or another's, never count: a next
|
||||
// hop reached only by way of a BGP route rests on what may itself rest
|
||||
// on a next hop.
|
||||
uint32 resolution_table = 5;
|
||||
|
||||
// Whether the default route in that table counts as the way to a next
|
||||
// hop. Off by default: on a router the default route points at a
|
||||
// transit provider, and a next hop "reached" only by sending the
|
||||
// packets to the Internet at large is not reached.
|
||||
bool resolve_via_default_route = 6;
|
||||
}
|
||||
|
||||
// Where to get the validated ROA payloads that say which AS may originate
|
||||
@@ -384,6 +400,15 @@ message Peer {
|
||||
// new peer whose side is not ready yet, or one that is to open the
|
||||
// connections itself.
|
||||
bool passive = 16;
|
||||
|
||||
// About how many routes the peer sends when all is well: a full table,
|
||||
// a few hundred from an exchange peer, one from a customer. Nothing is
|
||||
// decided by it. The status page compares the routes the peer has in
|
||||
// the table with it, and shows an established session with fewer than
|
||||
// half as needing attention: a peer that is up but has sent next to
|
||||
// nothing looks, without this, exactly like one that is fine. Zero, the
|
||||
// default, compares nothing.
|
||||
uint32 expected_routes = 17;
|
||||
}
|
||||
|
||||
// What is announced to a peer.
|
||||
@@ -547,6 +572,18 @@ message ImportPolicy {
|
||||
// looked at. A rule sees the route as the statements above leave it:
|
||||
// with our communities taken off and ours put on.
|
||||
repeated ImportRule rules = 20;
|
||||
|
||||
// By default a route with the BLACKHOLE community (65535:666, RFC
|
||||
// 7999), which asks for the traffic to its prefix to be dropped, is
|
||||
// refused ("blackhole request"). True takes such a route if it is for
|
||||
// one address (a /32 or /128), whatever the longest prefix taken
|
||||
// otherwise, and inside what the peer may announce; its next hop is
|
||||
// not looked at. The traffic to the address is then dropped by this
|
||||
// router (a blackhole route in the kernel), and the route goes on to
|
||||
// the peers the export policy sends it to, community and all, for them
|
||||
// to do the same. For a customer under attack, and for a route server
|
||||
// whose blackhole routes are to be honoured.
|
||||
bool accept_blackholes = 21;
|
||||
}
|
||||
|
||||
// What is done to the routes with some communities, or for some prefixes:
|
||||
|
||||
@@ -113,6 +113,9 @@ pub struct PeerConfig {
|
||||
pub reflector_client: bool,
|
||||
/// Do not dial the peer: wait for it to connect.
|
||||
pub passive: bool,
|
||||
/// About how many routes the peer sends when all is well, for the
|
||||
/// status page to compare with; nothing here acts on it.
|
||||
pub expected_routes: Option<u32>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -405,6 +408,11 @@ fn parse_kernel(kernel: &proto::Kernel) -> FibConfig {
|
||||
},
|
||||
metric: kernel.metric.unwrap_or(defaults.metric),
|
||||
nexthop_objects: !kernel.without_nexthop_objects,
|
||||
resolution_table: match kernel.resolution_table {
|
||||
0 => defaults.resolution_table,
|
||||
table => table,
|
||||
},
|
||||
resolve_via_default: kernel.resolve_via_default_route,
|
||||
stale_time: kernel.stale_seconds.map_or(defaults.stale_time, |seconds| {
|
||||
Duration::from_secs(seconds.into())
|
||||
}),
|
||||
@@ -555,6 +563,7 @@ fn parse_peer(peer: Peer, defaults: &PeerDefaults) -> Result<PeerConfig, PeerPro
|
||||
groups: peer.groups,
|
||||
reflector_client: peer.route_reflector_client,
|
||||
passive: peer.passive,
|
||||
expected_routes: Some(peer.expected_routes).filter(|n| *n > 0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -633,6 +642,7 @@ fn parse_import(import: &proto::ImportPolicy) -> Result<ImportPolicy, ImportProb
|
||||
accept_aspa_invalid: import.accept_aspa_invalid,
|
||||
accept_as_sets: import.accept_as_sets,
|
||||
accept_third_party_next_hop: import.accept_third_party_next_hop,
|
||||
accept_blackholes: import.accept_blackholes,
|
||||
own_prefixes: Arc::default(),
|
||||
originated: Arc::default(),
|
||||
accept_control_communities: import.accept_control_communities,
|
||||
@@ -1286,6 +1296,8 @@ mod tests {
|
||||
metric: 20,
|
||||
nexthop_objects: true,
|
||||
stale_time: Duration::from_secs(120),
|
||||
resolution_table: 254,
|
||||
resolve_via_default: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1300,6 +1312,8 @@ mod tests {
|
||||
metric: 150,
|
||||
nexthop_objects: false,
|
||||
stale_time: Duration::from_secs(30),
|
||||
resolution_table: 254,
|
||||
resolve_via_default: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1494,6 +1508,24 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// What a peer is expected to send is kept for the status page; zero,
|
||||
/// the default, is nothing to compare with.
|
||||
#[test]
|
||||
fn expected_routes_are_kept_when_given() {
|
||||
let config = parse(&format!(
|
||||
"{BASE}peers {{ name: \"a\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1
|
||||
expected_routes: 900000 }}
|
||||
peers {{ name: \"b\" endpoint {{ address: \"192.0.2.3\" }} remote_asn: 2 }}"
|
||||
))
|
||||
.unwrap();
|
||||
let expected: Vec<Option<u32>> = config
|
||||
.peers
|
||||
.iter()
|
||||
.map(|peer| peer.expected_routes)
|
||||
.collect();
|
||||
assert_eq!(expected, [Some(900_000), None]);
|
||||
}
|
||||
|
||||
/// A peer is dialled unless it is passive.
|
||||
#[test]
|
||||
fn a_peer_can_be_left_to_connect() {
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_fib::InterfacesWatch;
|
||||
use bgp_fib::TopologyWatch;
|
||||
use bgp_packet::errors::{BgpError, DecodeError, MsgHeaderSubcode};
|
||||
use bgp_packet::message::RouteRefreshMessage;
|
||||
use bgp_packet::message::{
|
||||
@@ -87,8 +87,9 @@ pub struct ServerContext {
|
||||
pub rejections: RejectionLog,
|
||||
/// The data for route origin validation, where that is configured.
|
||||
pub rpki: Option<Rpki>,
|
||||
/// The machine's interfaces, where routes are installed in the kernel.
|
||||
pub interfaces: Option<InterfacesWatch>,
|
||||
/// The kernel's view of the machine, where routes are installed in
|
||||
/// the kernel: what says whether a next hop can be reached.
|
||||
pub topology: Option<TopologyWatch>,
|
||||
/// The routes kept of peers that are restarting, and how far the peers
|
||||
/// are with sending theirs since the start (graceful restart).
|
||||
pub restarts: Arc<Restarts>,
|
||||
@@ -1078,9 +1079,9 @@ impl Session {
|
||||
.filter(|_| self.server.rpki.is_some());
|
||||
let reach = self
|
||||
.server
|
||||
.interfaces
|
||||
.topology
|
||||
.clone()
|
||||
.map(|interfaces| Reach::new(interfaces, neighbor_at(self.peer_addr)));
|
||||
.map(|topology| Reach::new(topology, neighbor_at(self.peer_addr)));
|
||||
Validator::new(self.server.rpki.clone().zip(subject), reach).map(|validator| Validation {
|
||||
validator,
|
||||
changes: handle.validate(subject),
|
||||
@@ -1605,7 +1606,7 @@ mod tests {
|
||||
registry: SessionRegistry::new(),
|
||||
collisions: CollisionTable::new(false),
|
||||
hold_downs: HoldDowns::default(),
|
||||
interfaces: None,
|
||||
topology: None,
|
||||
rejections: RejectionLog::default(),
|
||||
rpki: None,
|
||||
restarts: Restarts::new(rib, true, [], 120),
|
||||
@@ -1656,7 +1657,7 @@ mod tests {
|
||||
registry: SessionRegistry::new(),
|
||||
collisions: CollisionTable::new(false),
|
||||
hold_downs: HoldDowns::default(),
|
||||
interfaces: None,
|
||||
topology: None,
|
||||
rejections: RejectionLog::default(),
|
||||
rpki: None,
|
||||
restarts: Restarts::new(rib, true, [], 120),
|
||||
|
||||
+110
-9
@@ -91,6 +91,9 @@ pub struct ImportPolicy {
|
||||
/// who never asked for it. A route server's routes always have its
|
||||
/// clients as next hops, and are taken either way.
|
||||
pub accept_third_party_next_hop: bool,
|
||||
/// Take routes with the BLACKHOLE community for single addresses, and
|
||||
/// have the kernel drop the traffic to them (RFC 7999).
|
||||
pub accept_blackholes: bool,
|
||||
/// The prefixes this router originates. Those, and what is inside
|
||||
/// them, are ours and not taken from another network, unless
|
||||
/// `prefixes` says in so many words that this peer may announce them:
|
||||
@@ -196,6 +199,7 @@ impl Default for ImportPolicy {
|
||||
accept_aspa_invalid: false,
|
||||
accept_as_sets: false,
|
||||
accept_third_party_next_hop: false,
|
||||
accept_blackholes: false,
|
||||
own_prefixes: Arc::default(),
|
||||
originated: Arc::default(),
|
||||
accept_control_communities: false,
|
||||
@@ -224,6 +228,7 @@ impl ImportPolicy {
|
||||
accept_aspa_invalid: true,
|
||||
accept_as_sets: true,
|
||||
accept_third_party_next_hop: true,
|
||||
accept_blackholes: false,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
@@ -336,10 +341,14 @@ pub enum Rejection {
|
||||
/// The path has an AS_SET in it (RFC 9774).
|
||||
AsSet,
|
||||
/// The route asks for the traffic to its prefix to be dropped: it has
|
||||
/// the BLACKHOLE community (RFC 7999). Such requests are not acted on,
|
||||
/// and the route is not taken as an ordinary one either, which would
|
||||
/// send the traffic to wherever its next hop drops it.
|
||||
/// the BLACKHOLE community (RFC 7999), and the policy does not take
|
||||
/// such requests (`accept_blackholes`). The route is not taken as an
|
||||
/// ordinary one either, which would send the traffic to wherever its
|
||||
/// next hop drops it.
|
||||
Blackhole,
|
||||
/// A blackhole request for more than one address: only a /32 or a
|
||||
/// /128 is taken as one.
|
||||
BlackholeNotHost,
|
||||
/// The next hop is no address packets could be sent to, or is our
|
||||
/// own, or is an IPv6 address for an IPv4 route on a session that did
|
||||
/// not agree to that (RFC 8950).
|
||||
@@ -371,7 +380,7 @@ impl Rejection {
|
||||
)
|
||||
}
|
||||
|
||||
pub const ALL: [Self; 20] = [
|
||||
pub const ALL: [Self; 21] = [
|
||||
Self::BogonPrefix,
|
||||
Self::TooShort,
|
||||
Self::TooSpecific,
|
||||
@@ -386,6 +395,7 @@ impl Rejection {
|
||||
Self::Leak,
|
||||
Self::AsSet,
|
||||
Self::Blackhole,
|
||||
Self::BlackholeNotHost,
|
||||
Self::NextHopUnusable,
|
||||
Self::NextHopNotPeer,
|
||||
Self::OwnPrefix,
|
||||
@@ -415,6 +425,7 @@ impl Rejection {
|
||||
Self::Leak => "route leak (Only to Customer)",
|
||||
Self::AsSet => "AS_SET in path",
|
||||
Self::Blackhole => "blackhole request",
|
||||
Self::BlackholeNotHost => "blackhole for more than one address",
|
||||
Self::NextHopUnusable => "next hop unusable",
|
||||
Self::NextHopNotPeer => "next hop is not the peer",
|
||||
Self::OwnPrefix => "prefix is our own",
|
||||
@@ -568,11 +579,7 @@ impl ImportPolicy {
|
||||
if facts.has_local_asn {
|
||||
return Err(Rejection::OwnAs);
|
||||
}
|
||||
let blackhole = attributes.iter().any(|attribute| {
|
||||
matches!(attribute, PathAttribute::Communities(communities)
|
||||
if communities.0.contains(&BLACKHOLE))
|
||||
});
|
||||
if blackhole {
|
||||
if !self.accept_blackholes && Self::is_blackhole_request(attributes) {
|
||||
return Err(Rejection::Blackhole);
|
||||
}
|
||||
// RFC 9234 §5, ingress, 1 and 2. A route that is only to go to
|
||||
@@ -651,6 +658,35 @@ impl ImportPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the routes with these attributes ask for the traffic to
|
||||
/// them to be dropped: the BLACKHOLE community (RFC 7999).
|
||||
pub fn is_blackhole_request(attributes: &[PathAttribute]) -> bool {
|
||||
attributes.iter().any(|attribute| {
|
||||
matches!(attribute, PathAttribute::Communities(communities)
|
||||
if communities.0.contains(&BLACKHOLE))
|
||||
})
|
||||
}
|
||||
|
||||
/// Judges the prefix of a blackhole request that the policy takes
|
||||
/// (`accept_blackholes`): one address, and inside what the peer may
|
||||
/// announce. The longest prefix taken otherwise does not apply, nor
|
||||
/// does the longest allowed inside a listed prefix: a request is for
|
||||
/// one address by its nature.
|
||||
pub fn check_blackhole_prefix(&self, prefix: &IpPrefix) -> Result<(), Rejection> {
|
||||
if prefix.length() != prefix.address_family().bits() {
|
||||
return Err(Rejection::BlackholeNotHost);
|
||||
}
|
||||
if !self.accept_bogons && is_bogon_prefix(prefix) {
|
||||
return Err(Rejection::BogonPrefix);
|
||||
}
|
||||
match &self.prefixes {
|
||||
Some(allowed) if !allowed.covers(prefix) => Err(Rejection::PrefixNotAllowed),
|
||||
Some(_) => Ok(()),
|
||||
None if self.own_prefixes.covers(prefix) => Err(Rejection::OwnPrefix),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Judges the next hop of routes of IP version `routes`.
|
||||
///
|
||||
/// It has to be an address packets can be sent to and not our own
|
||||
@@ -882,6 +918,15 @@ impl PrefixSet {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `prefix` is inside some entry, whatever length the entry
|
||||
/// allows inside it.
|
||||
pub fn covers(&self, prefix: &IpPrefix) -> bool {
|
||||
self.lengths[Self::version_index(prefix)]
|
||||
.iter()
|
||||
.take_while(|length| **length <= prefix.length())
|
||||
.any(|length| self.entries.contains_key(&prefix.shortened(*length)))
|
||||
}
|
||||
|
||||
/// Whether `prefix` is allowed by some entry.
|
||||
pub fn allows(&self, prefix: &IpPrefix) -> bool {
|
||||
// An entry that covers it is the prefix cut to the entry's length,
|
||||
@@ -1362,6 +1407,62 @@ mod tests {
|
||||
assert_eq!(lenient.check_path(&with_set, &EXTERNAL), Ok(None));
|
||||
}
|
||||
|
||||
/// With accept_blackholes, a request to drop traffic is taken for one
|
||||
/// address inside what the peer may announce, however long a prefix
|
||||
/// the policy takes otherwise; and for nothing else.
|
||||
#[test]
|
||||
fn a_blackhole_request_is_taken_for_one_address_where_allowed() {
|
||||
let mut attributes = path(&[3333, 2121]);
|
||||
attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(vec![
|
||||
(0xFFFF, 0x029A),
|
||||
])));
|
||||
assert!(ImportPolicy::is_blackhole_request(&attributes));
|
||||
assert!(!ImportPolicy::is_blackhole_request(&path(&[3333])));
|
||||
let mut policy = ImportPolicy {
|
||||
accept_blackholes: true,
|
||||
..ImportPolicy::default()
|
||||
};
|
||||
// The path passes as any other's would.
|
||||
assert_eq!(policy.check_path(&attributes, &EXTERNAL), Ok(Some(2121)));
|
||||
|
||||
// Without a prefix list: any one routable address but ours.
|
||||
let check = |policy: &ImportPolicy, p: &str| policy.check_blackhole_prefix(&prefix(p));
|
||||
assert_eq!(check(&policy, "193.0.0.9/32"), Ok(()));
|
||||
assert_eq!(check(&policy, "2001:67c:2e8::9/128"), Ok(()));
|
||||
assert_eq!(
|
||||
check(&policy, "193.0.0.0/24"),
|
||||
Err(Rejection::BlackholeNotHost)
|
||||
);
|
||||
assert_eq!(
|
||||
check(&policy, "2001:67c:2e8::/64"),
|
||||
Err(Rejection::BlackholeNotHost)
|
||||
);
|
||||
assert_eq!(
|
||||
check(&policy, "10.0.0.1/32"),
|
||||
Err(Rejection::BogonPrefix),
|
||||
"a bogon is a bogon, one address or not"
|
||||
);
|
||||
let mut ours = PrefixSet::default();
|
||||
ours.allow(prefix("193.0.0.0/21"), 24);
|
||||
policy.own_prefixes = Arc::new(ours);
|
||||
assert_eq!(check(&policy, "193.0.0.9/32"), Err(Rejection::OwnPrefix));
|
||||
|
||||
// With one: inside a listed prefix, whatever length the list
|
||||
// allows inside it, and ours or not.
|
||||
let mut allowed = PrefixSet::default();
|
||||
allowed.allow(prefix("193.0.0.0/21"), 24);
|
||||
policy.prefixes = Some(allowed);
|
||||
assert_eq!(check(&policy, "193.0.0.9/32"), Ok(()));
|
||||
assert_eq!(
|
||||
check(&policy, "193.0.8.9/32"),
|
||||
Err(Rejection::PrefixNotAllowed)
|
||||
);
|
||||
assert_eq!(
|
||||
Rejection::BlackholeNotHost.as_str(),
|
||||
"blackhole for more than one address"
|
||||
);
|
||||
}
|
||||
|
||||
/// A request to drop traffic is neither acted on nor taken as an
|
||||
/// ordinary route, whose next hop would drop the traffic all the same.
|
||||
/// It is told apart from a prefix that is merely too long, which is
|
||||
|
||||
+65
-46
@@ -9,10 +9,11 @@
|
||||
//! aside like a route the RPKI finds invalid, and looked at again when
|
||||
//! the interfaces change (see `crate::routes::Held`).
|
||||
//!
|
||||
//! Only next hops that are neighbors on one of the machine's interfaces
|
||||
//! count as reachable: the way to any other is not looked up. Routes from
|
||||
//! a peer in our own AS that did not set itself as next hop are therefore
|
||||
//! held, where they used to be chosen and then not installed.
|
||||
//! A next hop that is a neighbor on one of the machine's interfaces can
|
||||
//! be reached, and so can one that the kernel's own routes know the way
|
||||
//! to; see `bgp_fib::routes` for which routes count. Routes from a peer
|
||||
//! in our own AS that did not set itself as next hop are taken if a static
|
||||
//! route or an IGP's says how to reach it, and held otherwise.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
@@ -20,9 +21,9 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_fib::InterfacesWatch;
|
||||
use bgp_fib::interfaces::Interfaces;
|
||||
use bgp_fib::TopologyWatch;
|
||||
use bgp_fib::plan::{Neighbor, resolve, resolve_next_hop};
|
||||
use bgp_fib::topology::Topology;
|
||||
use bgp_packet::message::NlriNextHop;
|
||||
use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
|
||||
@@ -38,19 +39,32 @@ use crate::session::SessionRegistry;
|
||||
/// judged it by the interfaces as they were.
|
||||
const SETTLE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// The interfaces to judge one peer's next hops by.
|
||||
/// The kernel's view of the machine to judge one peer's next hops by.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct NextHops<'a> {
|
||||
pub(crate) interfaces: &'a Interfaces,
|
||||
pub(crate) topology: &'a Topology,
|
||||
/// The peer: where a link-local next hop is.
|
||||
pub(crate) neighbor: Neighbor,
|
||||
}
|
||||
|
||||
impl NextHops<'_> {
|
||||
/// Whether the kernel can be given a route for `prefix` by way of
|
||||
/// `next_hop`.
|
||||
pub(crate) fn reaches(&self, prefix: &IpPrefix, next_hop: Option<&NlriNextHop>) -> bool {
|
||||
resolve(self.interfaces, prefix, next_hop, Some(self.neighbor)).is_ok()
|
||||
/// `next_hop`; or, for a route that asks for its packets to be
|
||||
/// dropped (`blackhole`), as a route to nowhere.
|
||||
pub(crate) fn reaches(
|
||||
&self,
|
||||
prefix: &IpPrefix,
|
||||
next_hop: Option<&NlriNextHop>,
|
||||
blackhole: bool,
|
||||
) -> bool {
|
||||
resolve(
|
||||
self.topology,
|
||||
prefix,
|
||||
next_hop,
|
||||
Some(self.neighbor),
|
||||
blackhole,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// The two halves of [`NextHops::reaches`], for an UPDATE's worth of
|
||||
@@ -58,52 +72,52 @@ impl NextHops<'_> {
|
||||
/// is asked once, and whether a prefix is one the machine reaches by
|
||||
/// itself, which is asked of each.
|
||||
pub(crate) fn next_hop_is_near(&self, routes: IpVersion, next_hop: &NlriNextHop) -> bool {
|
||||
resolve_next_hop(self.interfaces, routes, Some(next_hop), Some(self.neighbor)).is_ok()
|
||||
resolve_next_hop(self.topology, routes, Some(next_hop), Some(self.neighbor)).is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn is_own_network(&self, prefix: &IpPrefix) -> bool {
|
||||
self.interfaces.has_network_around(prefix)
|
||||
self.topology.interfaces.has_network_around(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
/// A session's view of the interfaces: those in use, replaced when the
|
||||
/// kernel says they have changed. Asking whether they have costs the load
|
||||
/// of one number, which is done for every UPDATE.
|
||||
/// A session's view of the topology: the one in use, replaced when the
|
||||
/// kernel says it has changed. Asking whether it has costs the load of
|
||||
/// one number, which is done for every UPDATE.
|
||||
pub(crate) struct Reach {
|
||||
watch: InterfacesWatch,
|
||||
watch: TopologyWatch,
|
||||
neighbor: Neighbor,
|
||||
interfaces: Option<Arc<Interfaces>>,
|
||||
topology: Option<Arc<Topology>>,
|
||||
}
|
||||
|
||||
impl Reach {
|
||||
pub(crate) fn new(watch: InterfacesWatch, neighbor: Neighbor) -> Self {
|
||||
let interfaces = watch.borrow().clone();
|
||||
pub(crate) fn new(watch: TopologyWatch, neighbor: Neighbor) -> Self {
|
||||
let topology = watch.borrow().clone();
|
||||
Self {
|
||||
watch,
|
||||
neighbor,
|
||||
interfaces,
|
||||
topology,
|
||||
}
|
||||
}
|
||||
|
||||
/// What to judge next hops by now; nothing until the kernel has said
|
||||
/// what interfaces there are.
|
||||
/// what interfaces and routes there are.
|
||||
pub(crate) fn next_hops(&mut self) -> Option<NextHops<'_>> {
|
||||
if self.watch.has_changed().unwrap_or(false) {
|
||||
self.interfaces = self.watch.borrow_and_update().clone();
|
||||
self.topology = self.watch.borrow_and_update().clone();
|
||||
}
|
||||
self.interfaces.as_deref().map(|interfaces| NextHops {
|
||||
interfaces,
|
||||
self.topology.as_deref().map(|topology| NextHops {
|
||||
topology,
|
||||
neighbor: self.neighbor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the task that, whenever the machine's interfaces change, finds
|
||||
/// the routes in the table whose next hop can no longer be reached and has
|
||||
/// their sessions take them out, and has every session look again at the
|
||||
/// routes it holds.
|
||||
/// Starts the task that, whenever the machine's interfaces or routes
|
||||
/// change, finds the routes in the table whose next hop can no longer be
|
||||
/// reached and has their sessions take them out, and has every session
|
||||
/// look again at the routes it holds.
|
||||
pub(crate) fn spawn_revalidator(
|
||||
mut watch: InterfacesWatch,
|
||||
mut watch: TopologyWatch,
|
||||
rib: Arc<RibTable>,
|
||||
registry: SessionRegistry,
|
||||
) -> JoinHandle<()> {
|
||||
@@ -112,7 +126,7 @@ pub(crate) fn spawn_revalidator(
|
||||
// which the server notices for itself.
|
||||
while watch.changed().await.is_ok() {
|
||||
tokio::time::sleep(SETTLE).await;
|
||||
let Some(interfaces) = watch.borrow_and_update().clone() else {
|
||||
let Some(topology) = watch.borrow_and_update().clone() else {
|
||||
continue;
|
||||
};
|
||||
let sessions = registry.list();
|
||||
@@ -127,9 +141,8 @@ pub(crate) fn spawn_revalidator(
|
||||
}
|
||||
let table = Arc::clone(&rib);
|
||||
// Looks at every route: not for a thread that sessions run on.
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
now_unreachable(&table, &interfaces, &neighbors)
|
||||
})
|
||||
let found =
|
||||
tokio::task::spawn_blocking(move || now_unreachable(&table, &topology, &neighbors))
|
||||
.await;
|
||||
let mut suspects = match found {
|
||||
Ok(suspects) => suspects,
|
||||
@@ -141,7 +154,7 @@ pub(crate) fn spawn_revalidator(
|
||||
info!(
|
||||
now_unreachable = suspects.values().map(Vec::len).sum::<usize>(),
|
||||
sessions = sessions.len(),
|
||||
"The machine's interfaces changed; looking at next hops again"
|
||||
"The machine's interfaces or routes changed; looking at next hops again"
|
||||
);
|
||||
// Every session, also those with nothing found: each has its
|
||||
// held routes to look at again.
|
||||
@@ -162,11 +175,11 @@ pub(crate) fn neighbor_at(address: SocketAddr) -> Neighbor {
|
||||
}
|
||||
|
||||
/// The routes in the table that the kernel could not be given, as the
|
||||
/// interfaces are now, by peer. `neighbors` says, for each peer by its
|
||||
/// machine is now, by peer. `neighbors` says, for each peer by its
|
||||
/// number in the table, where it is.
|
||||
fn now_unreachable(
|
||||
rib: &RibTable,
|
||||
interfaces: &Interfaces,
|
||||
topology: &Topology,
|
||||
neighbors: &[Option<Neighbor>],
|
||||
) -> HashMap<PeerIndex, Vec<Nlri>> {
|
||||
// There are as many next hops as neighbors, and a table's worth of
|
||||
@@ -177,17 +190,23 @@ fn now_unreachable(
|
||||
let Some(Some(neighbor)) = neighbors.get(route.peer.as_usize()) else {
|
||||
return false;
|
||||
};
|
||||
if interfaces.has_network_around(&route.prefix) {
|
||||
if topology.interfaces.has_network_around(&route.prefix) {
|
||||
return true;
|
||||
}
|
||||
// A blackhole resolves whatever its next hop: nothing of it can
|
||||
// have become unreachable.
|
||||
if route.attrs.is_blackhole() {
|
||||
return false;
|
||||
}
|
||||
let next_hop = route.attrs.next_hop();
|
||||
let key = (next_hop, route.prefix.address_family(), route.peer);
|
||||
!*resolved.borrow_mut().entry(key).or_insert_with(|| {
|
||||
resolve(
|
||||
interfaces,
|
||||
topology,
|
||||
&route.prefix,
|
||||
next_hop.as_ref(),
|
||||
Some(*neighbor),
|
||||
false,
|
||||
)
|
||||
.is_ok()
|
||||
})
|
||||
@@ -206,7 +225,7 @@ fn now_unreachable(
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use bgp_fib::netlink::{Address, Link, Message};
|
||||
use bgp_fib::netlink::{Address, Link, Message, TABLE_MAIN};
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage,
|
||||
};
|
||||
@@ -224,8 +243,8 @@ mod tests {
|
||||
|
||||
/// A machine with eth2 (192.0.2.1/24), and eth3 (198.51.100.1/24) if
|
||||
/// `both`.
|
||||
fn interfaces(both: bool) -> Interfaces {
|
||||
let mut interfaces = Interfaces::default();
|
||||
fn interfaces(both: bool) -> Topology {
|
||||
let mut interfaces = Topology::new(TABLE_MAIN, false);
|
||||
let all = [
|
||||
(2, "192.0.2.1", "192.0.2.0/24"),
|
||||
(3, "198.51.100.1", "198.51.100.0/24"),
|
||||
@@ -298,7 +317,7 @@ mod tests {
|
||||
fn with_import<T>(
|
||||
&self,
|
||||
peer: &mut Peer,
|
||||
interfaces: &Interfaces,
|
||||
interfaces: &Topology,
|
||||
work: impl FnOnce(&RibTable, &Import<'_>, &mut Held) -> T,
|
||||
) -> T {
|
||||
let address = IpAddr::V4(peer.address);
|
||||
@@ -317,7 +336,7 @@ mod tests {
|
||||
policy: &self.policy,
|
||||
checks: Checks {
|
||||
next_hops: Some(NextHops {
|
||||
interfaces,
|
||||
topology: interfaces,
|
||||
neighbor: address.into(),
|
||||
}),
|
||||
..Checks::NONE
|
||||
@@ -329,7 +348,7 @@ mod tests {
|
||||
|
||||
/// The peer announces `route` with a path of `hops` AS numbers and
|
||||
/// itself as next hop.
|
||||
fn announce(&self, peer: &mut Peer, interfaces: &Interfaces, route: &str, hops: usize) {
|
||||
fn announce(&self, peer: &mut Peer, interfaces: &Topology, route: &str, hops: usize) {
|
||||
let update = UpdateMessage {
|
||||
withdrawn_routes: vec![],
|
||||
path_attributes: vec![
|
||||
@@ -344,7 +363,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
fn revalidate(&self, peer: &mut Peer, interfaces: &Interfaces, suspects: &[Nlri]) {
|
||||
fn revalidate(&self, peer: &mut Peer, interfaces: &Topology, suspects: &[Nlri]) {
|
||||
self.with_import(peer, interfaces, |table, import, held| {
|
||||
routes::revalidate(table, import, held, suspects)
|
||||
});
|
||||
|
||||
@@ -129,6 +129,8 @@ pub(crate) fn apply(
|
||||
as_path: OnceCell::new(),
|
||||
refused_attributes: OnceCell::new(),
|
||||
mark: only_to_customer_mark(import, update),
|
||||
blackhole: import.policy.accept_blackholes
|
||||
&& ImportPolicy::is_blackhole_request(&update.path_attributes),
|
||||
changed: OnceCell::new(),
|
||||
rules_holding: OnceCell::new(),
|
||||
};
|
||||
@@ -176,6 +178,10 @@ struct Applying<'a> {
|
||||
/// What the policy makes of the path, and the AS the routes originate
|
||||
/// from if it passes.
|
||||
path_verdict: Result<Option<u32>, Rejection>,
|
||||
/// Whether the routes ask for their traffic to be dropped, and the
|
||||
/// policy takes such requests: then the next hop is nothing to them,
|
||||
/// and the prefix is judged as a request's.
|
||||
blackhole: bool,
|
||||
/// Whether the ASPAs find the path invalid: asked only if a route
|
||||
/// gets as far as that mattering.
|
||||
path_invalid: OnceCell<bool>,
|
||||
@@ -235,8 +241,10 @@ impl Applying<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// Asked once for all of them: they have the one next hop.
|
||||
let near = self
|
||||
// Asked once for all of them: they have the one next hop. For a
|
||||
// blackhole it is not asked at all: the packets go nowhere.
|
||||
let near = self.blackhole
|
||||
|| self
|
||||
.import
|
||||
.checks
|
||||
.next_hops
|
||||
@@ -374,9 +382,13 @@ impl Applying<'_> {
|
||||
.copied()
|
||||
.or_else(in_attributes)
|
||||
.ok_or(Rejection::NextHopUnusable)?;
|
||||
// The traffic to a blackholed prefix goes nowhere: where the next
|
||||
// hop would have taken it does not matter.
|
||||
if !self.blackhole {
|
||||
self.import
|
||||
.policy
|
||||
.check_next_hop(&next_hop, routes, &self.import.from)?;
|
||||
}
|
||||
Ok((origin, next_hop))
|
||||
}
|
||||
|
||||
@@ -385,7 +397,11 @@ impl Applying<'_> {
|
||||
/// comes before what may pass.
|
||||
fn judge(&self, route: &Nlri, origin: Option<u32>, near: bool) -> Result<(), Rejection> {
|
||||
let import = self.import;
|
||||
if self.blackhole {
|
||||
import.policy.check_blackhole_prefix(&route.prefix)?;
|
||||
} else {
|
||||
import.policy.check_prefix(&route.prefix)?;
|
||||
}
|
||||
let origin_invalid = import
|
||||
.checks
|
||||
.vrps
|
||||
|
||||
@@ -162,7 +162,7 @@ impl<'a> Checks<'a> {
|
||||
if next_hops.is_own_network(prefix) {
|
||||
return Some(Rejection::OwnNetwork);
|
||||
}
|
||||
if !next_hops.reaches(prefix, attrs.next_hop().as_ref()) {
|
||||
if !next_hops.reaches(prefix, attrs.next_hop().as_ref(), attrs.is_blackhole()) {
|
||||
return Some(Rejection::NextHopUnreachable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ impl BgpServer {
|
||||
remote_asn: p.remote_asn,
|
||||
role: p.role,
|
||||
passive: p.passive,
|
||||
expected_routes: p.expected_routes,
|
||||
})
|
||||
.collect();
|
||||
// The client starts now: with luck the data is there before the
|
||||
@@ -191,7 +192,7 @@ impl BgpServer {
|
||||
hold_downs: HoldDowns::default(),
|
||||
rejections: self.rejections.clone(),
|
||||
rpki: self.rpki.clone(),
|
||||
interfaces: self.kernel.as_ref().map(Fib::interfaces),
|
||||
topology: self.kernel.as_ref().map(Fib::topology),
|
||||
restarts: Arc::clone(&self.restarts),
|
||||
});
|
||||
// What an earlier run left in the kernel and no peer has announced
|
||||
@@ -276,10 +277,10 @@ impl BgpServer {
|
||||
spawn_revalidator(rpki, Arc::clone(&self.rib), self.registry.clone()),
|
||||
));
|
||||
}
|
||||
if let Some(interfaces) = context.interfaces.clone() {
|
||||
if let Some(topology) = context.topology.clone() {
|
||||
tasks.push(named(
|
||||
"next hop revalidator",
|
||||
reach::spawn_revalidator(interfaces, Arc::clone(&self.rib), self.registry.clone()),
|
||||
reach::spawn_revalidator(topology, Arc::clone(&self.rib), self.registry.clone()),
|
||||
));
|
||||
}
|
||||
for peer in self.config.peers.iter().filter(|peer| peer.passive) {
|
||||
|
||||
@@ -75,6 +75,8 @@ pub struct ConfiguredPeer {
|
||||
pub role: Option<PeerRole>,
|
||||
/// Whether it is left to the peer to connect.
|
||||
pub passive: bool,
|
||||
/// About how many routes it sends when all is well, if that was said.
|
||||
pub expected_routes: Option<u32>,
|
||||
}
|
||||
|
||||
/// Counts kept by a session as it goes, for whoever wants to look.
|
||||
|
||||
@@ -92,6 +92,9 @@ pub struct PeerView {
|
||||
/// How many of the peer's routes are held out of the table as invalid
|
||||
/// by the RPKI's data, to be taken if that changes.
|
||||
pub held_routes: Option<u64>,
|
||||
/// About how many routes the peer sends when all is well, from the
|
||||
/// configuration (`expected_routes`), for `routes` to be compared with.
|
||||
pub expected_routes: Option<u32>,
|
||||
/// What the peer is to us, if configured: `provider`, `customer`,
|
||||
/// `peer`, `route server` or `route server client`.
|
||||
pub role: Option<&'static str>,
|
||||
@@ -157,6 +160,7 @@ impl From<&SessionHandle> for PeerView {
|
||||
routes: Some(s.counters.routes()),
|
||||
rejected_routes: Some(s.counters.rejected()),
|
||||
held_routes: Some(s.counters.held()),
|
||||
expected_routes: None,
|
||||
role: s.role.map(PeerRole::as_str),
|
||||
role_confirmed: s.role.map(|_| s.role_confirmed),
|
||||
last_error: None,
|
||||
@@ -195,6 +199,7 @@ impl PeerView {
|
||||
routes: None,
|
||||
rejected_routes: None,
|
||||
held_routes: None,
|
||||
expected_routes: peer.expected_routes,
|
||||
role: peer.role.map(PeerRole::as_str),
|
||||
role_confirmed: None,
|
||||
last_error,
|
||||
@@ -225,6 +230,7 @@ pub fn peer_views(registry: &SessionRegistry, restarts: &Restarts) -> Vec<PeerVi
|
||||
claimed.insert(session.peer);
|
||||
PeerView {
|
||||
name: Some(peer.name.clone()),
|
||||
expected_routes: peer.expected_routes,
|
||||
..PeerView::from(session)
|
||||
}
|
||||
}
|
||||
@@ -1105,6 +1111,9 @@ pub struct KernelView {
|
||||
/// How many of them are kept from before the daemon started though no
|
||||
/// peer has announced them again; they go when `stale_seconds` is up.
|
||||
pub kept: usize,
|
||||
/// How many of them are blackhole routes, which drop the packets for
|
||||
/// their prefix (RFC 7999).
|
||||
pub blackholes: usize,
|
||||
/// Prefixes whose best route is not in the kernel because its next hop
|
||||
/// cannot be used, by why.
|
||||
pub unresolved: BTreeMap<&'static str, usize>,
|
||||
@@ -1138,6 +1147,7 @@ pub async fn kernel(State(state): State<AppState>) -> Json<KernelView> {
|
||||
synced: status.synced,
|
||||
installed: status.installed,
|
||||
kept: status.kept,
|
||||
blackholes: status.blackholes,
|
||||
unresolved: status.unresolved.into_iter().collect(),
|
||||
refused: status.refused,
|
||||
errors: status.errors,
|
||||
|
||||
+227
-56
@@ -3,13 +3,17 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Status for ferrix_bgp</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23eef'/%3E%3Ctext x='8' y='12' font-size='11' font-family='sans-serif' font-weight='bold' fill='%2300c' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E">
|
||||
<!--
|
||||
A read-only status page in the manner of the classic /statusz pages: plain
|
||||
sans-serif HTML, default links, dense bordered tables, and one full-width
|
||||
light blue (#eef) heading bar per section. Deliberately no dark theme, no
|
||||
framework, no external resources. Everything shown comes from /api/*,
|
||||
light blue heading bar per section. No framework and no external
|
||||
resources. Colours are variables, with a second set for a dark room
|
||||
(prefers-color-scheme): the same grammar on an inverted background, so
|
||||
the page can sit beside a terminal without being the brightest thing in
|
||||
the room. Everything shown comes from /api/*,
|
||||
except the names of AS numbers: pointing at one asks RIPEstat for its
|
||||
holder, from the browser (the daemon itself looks nothing up).
|
||||
|
||||
@@ -21,50 +25,79 @@
|
||||
(RPKI synchronised, kernel in step), set small. Grey is for what is
|
||||
inactive or expected (a passive peer waiting, a rejection count). What
|
||||
repeats on every row is a glyph with a tooltip, not a sentence.
|
||||
|
||||
The page also keeps a little memory between refreshes, in the browser
|
||||
only: the route count of each peer last time, to show a count that moved
|
||||
(▲ ▼); and what each peer looked like when the tab was last in the
|
||||
foreground, to mark (●) what changed while nobody was looking.
|
||||
-->
|
||||
<style>
|
||||
body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; }
|
||||
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: #eef; margin: 14px 0 6px; padding: 2px 0; }
|
||||
:root {
|
||||
--bg: #fff; --fg: #000; --fg-soft: #223; --dim: #666; --line: #99a; --faint-line: #dde;
|
||||
--band: #eef; --head: #dde; --hover: #f3f3ff; --best-bg: #f4faf4; --tip-bg: #ffffe8;
|
||||
--link: #00c; --visited: #551a8b; --name: #112;
|
||||
--red: #c00; --amber: #a60; --green: #080;
|
||||
--badge-bg: #f6f6fb; --badge-line: #aab;
|
||||
--badge-red-bg: #fff0f0; --badge-amber-bg: #fff8e8; --badge-green-bg: #f0fff0;
|
||||
--badge-dim-bg: #fafafa; --badge-dim-line: #ccd;
|
||||
--mark-bg: #eef4ff; --mark-line: #88a; --ctl-bg: #fff4e8; --ctl-line: #c96; --wk-bg: #f0f0f0; --wk-line: #999;
|
||||
--rank-fg: #446; --rank-bg: #f3f3f8; --flash: #ffe9a8;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #16181d; --fg: #e4e4e8; --fg-soft: #d8dae6; --dim: #9a9aa6; --line: #4a4d5c; --faint-line: #2c2f3a;
|
||||
--band: #23273a; --head: #2a2e40; --hover: #1e2230; --best-bg: #1a2a1c; --tip-bg: #2c2c22;
|
||||
--link: #8db4ff; --visited: #c3a4e8; --name: #f0f0f6;
|
||||
--red: #ff6b6b; --amber: #e8a846; --green: #62cf72;
|
||||
--badge-bg: #22263a; --badge-line: #4a4d5c;
|
||||
--badge-red-bg: #3a1e1e; --badge-amber-bg: #3a2e18; --badge-green-bg: #1a3320;
|
||||
--badge-dim-bg: #1c1e26; --badge-dim-line: #3a3d4a;
|
||||
--mark-bg: #1f2a44; --mark-line: #5a6aa0; --ctl-bg: #3a2a18; --ctl-line: #a0703a; --wk-bg: #2a2a2e; --wk-line: #666;
|
||||
--rank-fg: #b8bce0; --rank-bg: #22263a; --flash: #6a5a20;
|
||||
}
|
||||
}
|
||||
body { font-family: sans-serif; font-size: 13px; color: var(--fg); background: var(--bg); margin: 8px; }
|
||||
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: var(--band); margin: 14px 0 6px; padding: 2px 0; }
|
||||
h1.top { font-size: 150%; margin-top: 0; }
|
||||
.lefthand { float: left; width: 60%; }
|
||||
.righthand { text-align: right; }
|
||||
a { color: #00c; } a:visited { color: #551a8b; }
|
||||
a { color: var(--link); } a:visited { color: var(--visited); }
|
||||
table.data { border-collapse: collapse; margin: 4px 0; }
|
||||
table.data th, table.data td { border: 1px solid #99a; padding: 2px 6px; text-align: left; vertical-align: top; }
|
||||
table.data th { background: #dde; font-weight: bold; position: sticky; top: 0; z-index: 2; }
|
||||
table.data > tbody > tr:hover > td { background: #f3f3ff; }
|
||||
table.data th, table.data td { border: 1px solid var(--line); padding: 2px 6px; text-align: left; vertical-align: top; }
|
||||
table.data th { background: var(--head); font-weight: bold; position: sticky; top: 0; z-index: 2; }
|
||||
table.data > tbody > tr:hover > td { background: var(--hover); }
|
||||
table.data td.num, table.data th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
table.data tr.group > td { background: #eef; font-weight: bold; padding: 3px 6px; }
|
||||
table.data tr.best > td { background: #f4faf4; }
|
||||
table.data tr.dim td { color: #666; }
|
||||
table.data tr.group > td { background: var(--band); font-weight: bold; padding: 3px 6px; }
|
||||
table.data tr.best > td { background: var(--best-bg); }
|
||||
table.data tr.dim td { color: var(--dim); }
|
||||
.asn { cursor: help; }
|
||||
.asn:hover { background: #dde; }
|
||||
#asn-tip { position: fixed; z-index: 10; max-width: 36em; background: #ffffe8; border: 1px solid #99a; padding: 2px 6px; font-size: 12px; pointer-events: none; }
|
||||
.asn:hover { background: var(--head); }
|
||||
#asn-tip { position: fixed; z-index: 10; max-width: 36em; background: var(--tip-bg); border: 1px solid var(--line); padding: 2px 6px; font-size: 12px; pointer-events: none; }
|
||||
.mono, table.data td.mono { font-family: monospace; }
|
||||
/* The colour grammar: see the note at the top. */
|
||||
.red { color: #c00; font-weight: bold; }
|
||||
.amber { color: #a60; font-weight: bold; }
|
||||
.green { color: #080; }
|
||||
.dim { color: #666; }
|
||||
.red { color: var(--red); font-weight: bold; }
|
||||
.amber { color: var(--amber); font-weight: bold; }
|
||||
.green { color: var(--green); }
|
||||
.dim { color: var(--dim); }
|
||||
.nowrap { white-space: nowrap; }
|
||||
/* Glyphs: small bordered badges for what repeats on every row. */
|
||||
.g { display: inline-block; font-size: 10.5px; line-height: 1.2; padding: 0 4px; margin: 0 2px 1px 0; border: 1px solid #aab; border-radius: 3px; background: #f6f6fb; color: #223; font-family: sans-serif; font-weight: normal; white-space: nowrap; vertical-align: middle; cursor: help; }
|
||||
.g.red { border-color: #c00; background: #fff0f0; color: #c00; font-weight: bold; }
|
||||
.g.amber { border-color: #a60; background: #fff8e8; color: #a60; }
|
||||
.g.green { border-color: #080; background: #f0fff0; color: #080; }
|
||||
.g.dim { border-color: #ccd; background: #fafafa; color: #666; }
|
||||
.g.mark { background: #eef4ff; border-color: #88a; }
|
||||
.g.ctl { background: #fff4e8; border-color: #c96; }
|
||||
.g.wk { background: #f0f0f0; border-color: #999; font-weight: bold; }
|
||||
.g { display: inline-block; font-size: 10.5px; line-height: 1.2; padding: 0 4px; margin: 0 2px 1px 0; border: 1px solid var(--badge-line); border-radius: 3px; background: var(--badge-bg); color: var(--fg-soft); font-family: sans-serif; font-weight: normal; white-space: nowrap; vertical-align: middle; cursor: help; }
|
||||
.g.red { border-color: var(--red); background: var(--badge-red-bg); color: var(--red); font-weight: bold; }
|
||||
.g.amber { border-color: var(--amber); background: var(--badge-amber-bg); color: var(--amber); }
|
||||
.g.green { border-color: var(--green); background: var(--badge-green-bg); color: var(--green); }
|
||||
.g.dim { border-color: var(--badge-dim-line); background: var(--badge-dim-bg); color: var(--dim); }
|
||||
.g.mark { background: var(--mark-bg); border-color: var(--mark-line); }
|
||||
.g.ctl { background: var(--ctl-bg); border-color: var(--ctl-line); }
|
||||
.g.wk { background: var(--wk-bg); border-color: var(--wk-line); font-weight: bold; }
|
||||
/* Communities: one line per AS that defines them, each kept whole. */
|
||||
.comm { white-space: nowrap; margin-right: 0.7em; }
|
||||
.comm[title] { border-bottom: 1px dotted #666; cursor: help; }
|
||||
.comm[title] { border-bottom: 1px dotted var(--dim); cursor: help; }
|
||||
.comms > div { padding-left: 2em; text-indent: -2em; margin-bottom: 2px; }
|
||||
.comms { min-width: 26em; }
|
||||
table.kv th { background: #eef; font-weight: normal; position: static; }
|
||||
table.kv th { background: var(--band); font-weight: normal; position: static; }
|
||||
table.data td.path { min-width: 12em; }
|
||||
/* An AS repeated along the path (prepended): once, with how many times. */
|
||||
.g.x { font-family: monospace; background: #eef4ff; border-color: #88a; margin-left: 1px; cursor: help; }
|
||||
.g.x { font-family: monospace; background: var(--mark-bg); border-color: var(--mark-line); margin-left: 1px; cursor: help; }
|
||||
/* A cell with more in it than a few lines: clamped until opened. */
|
||||
.clamp { max-height: 4.4em; overflow: hidden; }
|
||||
.clamp.tall:not(.open) { -webkit-mask-image: linear-gradient(#000 60%, transparent); mask-image: linear-gradient(#000 60%, transparent); }
|
||||
@@ -75,23 +108,40 @@ table.data td.name { white-space: nowrap; }
|
||||
/* Peer names are what a row is found by: bold, dark, and not underlined
|
||||
until pointed at, so that they stand out from the addresses and numbers
|
||||
around them rather than joining the blue of every other link. */
|
||||
table.data td.name a.peer-link { font-weight: bold; color: #112; text-decoration: none; font-size: 13.5px; }
|
||||
table.data td.name a.peer-link:hover { text-decoration: underline; color: #00c; }
|
||||
table.data td.name a.peer-link { font-weight: bold; color: var(--name); text-decoration: none; font-size: 13.5px; }
|
||||
table.data td.name a.peer-link:hover { text-decoration: underline; color: var(--link); }
|
||||
/* The IPv4 and IPv6 sessions of one neighbour: a bar down the side joins
|
||||
them, the line between them fades, and the second says the name quietly. */
|
||||
table.data tr.pair-a > td.name, table.data tr.pair-b > td.name { border-left: 3px solid var(--mark-line); }
|
||||
table.data tr.pair-a > td { border-bottom-color: var(--faint-line); }
|
||||
table.data tr.pair-b > td.name a.peer-link { font-weight: normal; color: var(--dim); }
|
||||
/* Where the route is in path selection: the best, or the Nth to take over. */
|
||||
.g.rank { font-family: sans-serif; color: #446; background: #f3f3f8; min-width: 1em; text-align: center; }
|
||||
/* The strip: what needs attention, if anything. */
|
||||
#strip { position: sticky; top: 0; z-index: 3; background: #fff; border: 1px solid #99a; padding: 4px 8px; margin: 6px 0; }
|
||||
.g.rank { font-family: sans-serif; color: var(--rank-fg); background: var(--rank-bg); min-width: 1em; text-align: center; }
|
||||
/* A route count that moved since the last refresh, and a row that changed
|
||||
while the tab was in the background. */
|
||||
.g.trend { font-family: sans-serif; cursor: help; }
|
||||
.g.since { color: var(--amber); border-color: var(--amber); background: var(--badge-amber-bg); cursor: pointer; }
|
||||
/* The strip: what needs attention, if anything. Its names lead to the row. */
|
||||
#strip { position: sticky; top: 0; z-index: 3; background: var(--bg); border: 1px solid var(--line); padding: 4px 8px; margin: 6px 0; }
|
||||
#strip .item { display: inline-block; margin-right: 1.5em; white-space: nowrap; }
|
||||
#strip a.to-peer { color: inherit; font-weight: bold; text-decoration: none; border-bottom: 1px dotted currentColor; }
|
||||
#strip a.to-peer:hover { border-bottom-style: solid; }
|
||||
@keyframes flash { from { background: var(--flash); } to { background: transparent; } }
|
||||
table.data > tbody > tr.flash > td { animation: flash 1.6s ease-out; }
|
||||
/* Compact: the columns a wall screen or a phone can do without. */
|
||||
body.compact .c-opt, body.compact .legend { display: none; }
|
||||
body.compact table.data th, body.compact table.data td { padding: 1px 4px; }
|
||||
/* The breakdown of a peer's rejections, by reason. */
|
||||
.bar { display: inline-flex; width: 16em; height: 10px; border: 1px solid #99a; vertical-align: middle; margin: 0 6px; background: #fff; }
|
||||
.bar { display: inline-flex; width: 16em; height: 10px; border: 1px solid var(--line); vertical-align: middle; margin: 0 6px; background: var(--bg); }
|
||||
.bar span { display: block; height: 100%; }
|
||||
details { clear: both; margin: 8px 0 0; }
|
||||
summary { cursor: pointer; color: #00c; }
|
||||
summary { cursor: pointer; color: var(--link); }
|
||||
summary:hover { text-decoration: underline; }
|
||||
#config-text { border: 1px solid #99a; padding: 4px 6px; margin: 4px 0; font-size: 12px; overflow-x: auto; }
|
||||
address { font-size: smaller; margin-top: 2em; color: #444; }
|
||||
#config-text { border: 1px solid var(--line); padding: 4px 6px; margin: 4px 0; font-size: 12px; overflow-x: auto; }
|
||||
address { font-size: smaller; margin-top: 2em; color: var(--dim); }
|
||||
form.lookup { margin: 4px 0; }
|
||||
form.lookup input[type=text] { font-family: monospace; }
|
||||
input, select, button { color-scheme: light dark; }
|
||||
.pager { margin: 4px 0; }
|
||||
.pager a, .pager span { margin-right: 1em; }
|
||||
.toolbar { margin: 4px 0; }
|
||||
@@ -126,10 +176,12 @@ JSON: <a href="/api/status">status</a>, <a href="/api/peers">peers</a>, <a href=
|
||||
<h1 id="peers">Peers (<span id="peers-count">...</span>)</h1>
|
||||
<div class="toolbar">
|
||||
<label><input type="checkbox" id="peers-problems"> problems only</label>
|
||||
<span class="dim">Glyphs: <span class="g">v4</span><span class="g">v6</span> address families · <span class="g">GR</span> graceful restart · <span class="g">ENH</span> IPv4 with IPv6 next hops · <span class="g">AP</span> ADD-PATH · <span class="g amber">2B</span> two-octet AS numbers only · <span class="g">↑</span> provider <span class="g">↓</span> customer <span class="g">↔</span> peer <span class="g">RS</span> route server <span class="g">RSC</span> its client <span class="g">iBGP</span> our AS. Point at one for its meaning; click a peer's name to look at its routes.</span>
|
||||
<label title="Fewer columns and tighter rows, for a wall screen or a phone"><input type="checkbox" id="compact"> compact</label>
|
||||
<span id="since-ctl"></span>
|
||||
<span class="dim legend">Glyphs: <span class="g">v4</span><span class="g">v6</span> address families · <span class="g">GR</span> graceful restart · <span class="g">ENH</span> IPv4 with IPv6 next hops · <span class="g">AP</span> ADD-PATH · <span class="g amber">2B</span> two-octet AS numbers only · <span class="g">↑</span> provider <span class="g">↓</span> customer <span class="g">↔</span> peer <span class="g">RS</span> route server <span class="g">RSC</span> its client <span class="g">iBGP</span> our AS. Point at one for its meaning; click a peer's name to look at its routes.</span>
|
||||
</div>
|
||||
<table class="data">
|
||||
<thead><tr><th>name</th><th>peer</th><th>state</th><th>up for</th><th>our address (next hop)</th><th>session</th><th class="num">routes</th><th class="num" title="Announcements rejected by the import policy since the daemon started, and routes held out of the table as invalid or unreachable now">rejected · held</th><th>detail</th></tr></thead>
|
||||
<thead><tr><th>name</th><th>peer</th><th>state</th><th>up for</th><th class="c-opt">our address (next hop)</th><th class="c-opt">session</th><th class="num">routes</th><th class="num" title="Announcements rejected by the import policy since the daemon started, and routes held out of the table as invalid or unreachable now">rejected · held</th><th>detail</th></tr></thead>
|
||||
<tbody id="peers-body"></tbody>
|
||||
</table>
|
||||
|
||||
@@ -353,13 +405,82 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
tick();
|
||||
}
|
||||
|
||||
// An established peer with under half the routes it is expected to send
|
||||
// (expected_routes), once it has had a minute to send them.
|
||||
const SETTLING_SECS = 60;
|
||||
function belowExpected(p) {
|
||||
return p.state === 'Established' && p.expected_routes > 0 && p.routes != null
|
||||
&& p.routes < p.expected_routes / 2 && now() - p.established_at_unix > SETTLING_SECS;
|
||||
}
|
||||
// Peers in name order, except that the IPv6 session of a neighbour
|
||||
// (<stem>-v6) comes before its IPv4 one: IPv6 is the network's present,
|
||||
// IPv4 what is still carried along.
|
||||
const FAMILY_SUFFIX = /^(.*)-v([46])$/;
|
||||
function nameOrder(a, c) {
|
||||
const [sa, fa] = FAMILY_SUFFIX.exec(a)?.slice(1) || [a, ''];
|
||||
const [sc, fc] = FAMILY_SUFFIX.exec(c)?.slice(1) || [c, ''];
|
||||
return sa.localeCompare(sc) || fc.localeCompare(fa);
|
||||
}
|
||||
const byName = (a, c) => nameOrder(a.name || a.peer_addr, c.name || c.peer_addr);
|
||||
|
||||
// Where a peer stands, for the strip and for sorting: 0 failed, 1
|
||||
// degraded, 2 normal, 3 inactive and expected.
|
||||
function peerRank(p) {
|
||||
if (p.state === 'Backoff' || p.state === 'Connecting') return p.stale_until_unix ? 1 : 0;
|
||||
if (p.state === 'Established') return p.stale_routes ? 1 : 2;
|
||||
if (p.state === 'Established') return p.stale_routes || belowExpected(p) ? 1 : 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
// ---- what moved: between refreshes, and while nobody was looking --------
|
||||
// A route count that moved by more than a little since the last refresh
|
||||
// is shown with an arrow for a while: a peer flapping or draining shows
|
||||
// here before anything else says so.
|
||||
const TREND_MIN_SHARE = 0.02, TREND_MIN_ROUTES = 10, TREND_SHOWN_SECS = 120;
|
||||
const trends = new Map();
|
||||
function noteTrends(before, after) {
|
||||
const was = new Map(before.filter(p => p.name).map(p => [p.name, p]));
|
||||
for (const p of after) {
|
||||
const old = p.name && was.get(p.name);
|
||||
if (!old || p.state !== 'Established' || old.state !== 'Established' || p.routes == null || old.routes == null) continue;
|
||||
const moved = p.routes - old.routes;
|
||||
if (Math.abs(moved) >= TREND_MIN_ROUTES && Math.abs(moved) / Math.max(old.routes, 1) >= TREND_MIN_SHARE) {
|
||||
trends.set(p.name, { from: old.routes, to: p.routes, at: now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
function trendGlyph(p) {
|
||||
const t = p.name && trends.get(p.name);
|
||||
if (!t || now() - t.at > TREND_SHOWN_SECS) return '';
|
||||
const up = t.to > t.from, share = Math.round(100 * Math.abs(t.to - t.from) / Math.max(t.from, 1));
|
||||
return glyph(up ? '▲' : '▼', `${up ? 'Up' : 'Down'} from ${fmtCount(t.from)} to ${fmtCount(t.to)} (${share}%) ${fmtDur(now() - t.at)} ago`, 'trend amber');
|
||||
}
|
||||
// What each peer looked like when the tab last went into the background.
|
||||
// On coming back, a row whose state changed, or whose routes moved as
|
||||
// above, carries a mark until it is clicked.
|
||||
let lastLooked = null;
|
||||
const glance = () => new Map(state.peers.filter(p => p.name).map(p => [p.name, { state: p.state, routes: p.routes }]));
|
||||
function changedSince(p) {
|
||||
const then = lastLooked && p.name && lastLooked.get(p.name);
|
||||
if (!then) return null;
|
||||
if (then.state !== p.state) return `was ${then.state}`;
|
||||
if (then.routes != null && p.routes != null) {
|
||||
const moved = p.routes - then.routes;
|
||||
if (Math.abs(moved) >= TREND_MIN_ROUTES && Math.abs(moved) / Math.max(then.routes, 1) >= TREND_MIN_SHARE) return `had ${fmtCount(then.routes)} routes`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function sinceGlyph(p) {
|
||||
const change = changedSince(p);
|
||||
return change ? glyph('●', `Changed since you last looked at this tab: ${change}. Click to clear.`, 'since') : '';
|
||||
}
|
||||
// Clears the mark on one row, or on all.
|
||||
function lookedAt(name) {
|
||||
if (!lastLooked) return;
|
||||
const seen = glance();
|
||||
if (name) { if (seen.has(name)) lastLooked.set(name, seen.get(name)); } else lastLooked = seen;
|
||||
renderPeers();
|
||||
}
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) lastLooked = glance(); });
|
||||
const ROLE_GLYPH = {
|
||||
provider: ['↑', 'provider: we buy transit from it'],
|
||||
customer: ['↓', 'customer: it buys transit from us'],
|
||||
@@ -413,41 +534,60 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
$('peers-count').textContent = `${up} of ${peers.length} established` + (down ? `, ${down} down` : '') + (waiting ? `, ${waiting} waiting` : '');
|
||||
if (!peers.length) { setHTML(b, '<tr><td colspan="9" class="dim">No peers configured and none connected.</td></tr>'); return; }
|
||||
const problemsOnly = $('peers-problems').checked;
|
||||
const row = p => {
|
||||
// The IPv4 and IPv6 sessions of one neighbour, named <stem>-v4 and
|
||||
// <stem>-v6, are one neighbour to the reader: listed together, IPv6
|
||||
// first, the name once in full and once quietly, joined down the side.
|
||||
const row = (p, pair) => {
|
||||
const up = p.state === 'Established';
|
||||
const name = p.name ? `<a href="#received" class="peer-link" data-peer="${esc(p.name)}" title="Look at this peer's routes">${esc(p.name)}</a>` : '<span class="dim">(inbound)</span>';
|
||||
const family = p.name && FAMILY_SUFFIX.exec(p.name);
|
||||
const shown = pair && family ? `${esc(family[1])}<span class="dim">-v${family[2]}</span>` : esc(p.name || '');
|
||||
const name = p.name ? `<a href="#received" class="peer-link" data-peer="${esc(p.name)}" title="Look at this peer's routes">${shown}</a>` : '<span class="dim">(inbound)</span>';
|
||||
const rejected = up ? `<span class="dim">${fmtCount(p.rejected_routes || 0)}</span>` : DASH;
|
||||
const held = up && p.held_routes ? ` · <span class="amber" title="Routes rejected as RPKI or ASPA invalid, or for a next hop that cannot be reached, and kept aside, to be taken if that changes">${fmtCount(p.held_routes)} held</span>` : '';
|
||||
const stale = up && p.stale_routes ? ` ${glyph(`${fmtCount(p.stale_routes)} stale`, 'Kept from the peer’s last session, and not sent again yet', 'amber')}` : '';
|
||||
return `<tr class="${peerRank(p) === 3 ? 'dim' : ''}">
|
||||
<td class="name">${name}</td>
|
||||
const short = belowExpected(p);
|
||||
const expected = up && p.expected_routes > 0
|
||||
? ` <span class="${short ? 'amber' : 'dim'}" title="${short ? 'Well under' : 'About'} the ${fmtCount(p.expected_routes)} routes this peer is expected to send (expected_routes)">of ~${fmtCount(p.expected_routes)}</span>` : '';
|
||||
const classes = [peerRank(p) === 3 ? 'dim' : '', pair || ''].filter(Boolean).join(' ');
|
||||
return `<tr class="${classes}" data-peer-row="${esc(p.name || p.peer_addr)}">
|
||||
<td class="name">${sinceGlyph(p)}${name}</td>
|
||||
<td class="mono">${esc(p.peer_addr)} <span class="dim">AS${asn(p.peer_asn)}</span> ${roleGlyph(p)}</td>
|
||||
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td>
|
||||
<td class="nowrap">${up ? `<span title="since ${esc(fmtWhen(p.established_at_unix))}">${age(p.established_at_unix)}</span>` : DASH}</td>
|
||||
<td class="mono">${up ? esc(p.local_addr) : DASH}</td>
|
||||
<td>${up ? sessionGlyphs(p) : DASH}</td>
|
||||
<td class="num">${up ? fmtCount(p.routes) + stale : DASH}</td>
|
||||
<td class="mono c-opt">${up ? esc(p.local_addr) : DASH}</td>
|
||||
<td class="c-opt">${up ? sessionGlyphs(p) : DASH}</td>
|
||||
<td class="num">${up ? trendGlyph(p) + fmtCount(p.routes) + expected + stale : DASH}</td>
|
||||
<td class="num">${rejected}${held}</td>
|
||||
<td>${peerDetail(p)}</td></tr>`;
|
||||
};
|
||||
const byName = (a, c) => (a.name || a.peer_addr).localeCompare(c.name || c.peer_addr);
|
||||
// Rows for a sorted list, with the v4/v6 pairs in it marked.
|
||||
const rows = list => list.map((p, i) => {
|
||||
const stem = p.name && FAMILY_SUFFIX.exec(p.name);
|
||||
const sameStem = q => q && q.name && FAMILY_SUFFIX.exec(q.name)?.[1] === stem[1];
|
||||
if (!stem) return row(p);
|
||||
if (sameStem(list[i + 1])) return row(p, 'pair-a');
|
||||
if (sameStem(list[i - 1])) return row(p, 'pair-b');
|
||||
return row(p);
|
||||
}).join('');
|
||||
let html = '';
|
||||
// What needs attention first, whoever it is; then everyone by what
|
||||
// they are to us.
|
||||
const problems = peers.filter(p => peerRank(p) <= 1).sort(byName);
|
||||
if (problems.length) {
|
||||
html += `<tr class="group"><td colspan="9"><span class="red">Needing attention</span> (${problems.length})</td></tr>` + problems.map(row).join('');
|
||||
html += `<tr class="group"><td colspan="9"><span class="red">Needing attention</span> (${problems.length})</td></tr>` + rows(problems);
|
||||
}
|
||||
if (!problemsOnly) {
|
||||
for (const group of ROLE_ORDER) {
|
||||
const members = peers.filter(p => peerRank(p) >= 2 && peerGroup(p) === group).sort(byName);
|
||||
if (!members.length) continue;
|
||||
html += `<tr class="group"><td colspan="9">${GROUP_TITLE[group]} (${members.length})</td></tr>` + members.map(row).join('');
|
||||
html += `<tr class="group"><td colspan="9">${GROUP_TITLE[group]} (${members.length})</td></tr>` + rows(members);
|
||||
}
|
||||
} else if (!problems.length) {
|
||||
html += '<tr><td colspan="9" class="dim">Nothing needs attention.</td></tr>';
|
||||
}
|
||||
setHTML(b, html);
|
||||
const marked = peers.filter(changedSince).length;
|
||||
setHTML($('since-ctl'), marked ? `${glyph('●', 'Rows that changed while this tab was in the background', 'since')} <span class="dim">${marked} changed since you last looked ·</span> <a href="#" id="since-clear">clear</a>` : '');
|
||||
renderPeerChoices();
|
||||
}
|
||||
|
||||
@@ -455,10 +595,14 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
function renderStrip() {
|
||||
const items = [];
|
||||
const peers = state.peers, k = state.kernel, r = state.rpki;
|
||||
// A name in the strip leads to the peer's row.
|
||||
const toPeer = p => `<a href="#peers" class="to-peer" data-peer="${esc(p.name || p.peer_addr)}" title="Go to this peer's row">${esc(p.name || p.peer_addr)}</a>`;
|
||||
const down = peers.filter(p => peerRank(p) === 0);
|
||||
if (down.length) items.push(`<span class="red">${down.length} session${down.length === 1 ? '' : 's'} down:</span> ${down.map(p => `<b>${esc(p.name || p.peer_addr)}</b> <span class="dim">(${esc((p.last_error || p.state).replace(/;.*$/, ''))})</span>`).join(', ')}`);
|
||||
const restarting = peers.filter(p => peerRank(p) === 1);
|
||||
if (restarting.length) items.push(`<span class="amber">${restarting.length} restarting or stale:</span> ${restarting.map(p => `<b>${esc(p.name || p.peer_addr)}</b>`).join(', ')}`);
|
||||
if (down.length) items.push(`<span class="red">${down.length} session${down.length === 1 ? '' : 's'} down:</span> ${down.map(p => `${toPeer(p)} <span class="dim">(${esc((p.last_error || p.state).replace(/;.*$/, ''))})</span>`).join(', ')}`);
|
||||
const short = peers.filter(belowExpected);
|
||||
if (short.length) items.push(`<span class="amber">${short.length} sending far fewer routes than expected:</span> ${short.map(p => `${toPeer(p)} <span class="dim">(${fmtCount(p.routes)} of ~${fmtCount(p.expected_routes)})</span>`).join(', ')}`);
|
||||
const restarting = peers.filter(p => peerRank(p) === 1 && !belowExpected(p));
|
||||
if (restarting.length) items.push(`<span class="amber">${restarting.length} restarting or stale:</span> ${restarting.map(toPeer).join(', ')}`);
|
||||
if (k && k.enabled) {
|
||||
if (!k.synced) items.push('<span class="amber">kernel catching up</span> with the routing table');
|
||||
if (k.refused) items.push(`<span class="red">kernel refused ${fmtCount(k.refused)} routes</span>`);
|
||||
@@ -526,7 +670,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
function renderPeerChoices() {
|
||||
const chosen = lookup().peer;
|
||||
const names = [...new Set(state.peers.filter(p => p.state === 'Established')
|
||||
.map(p => p.name || p.peer_addr.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')))].sort();
|
||||
.map(p => p.name || p.peer_addr.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')))].sort(nameOrder);
|
||||
if (chosen && !names.includes(chosen)) names.push(chosen);
|
||||
setHTML($('lookup-peer'), '<option value="">any peer</option>' +
|
||||
names.map(n => `<option value="${esc(n)}"${n === chosen ? ' selected' : ''}>${esc(n)}</option>`).join(''));
|
||||
@@ -638,7 +782,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
const unresolved = Object.entries(k.unresolved).map(([why, n]) => `${fmtCount(n)} ${esc(why)}`).join(', ');
|
||||
const summary = '<table class="data kv">' +
|
||||
row('state', `<span class="${k.synced ? 'green' : 'amber'}">${k.synced ? 'in step' : 'catching up'}</span> with the routing table`) +
|
||||
row('routes installed', `<b>${fmtCount(k.installed)}</b>`) +
|
||||
row('routes installed', `<b>${fmtCount(k.installed)}</b>${k.blackholes ? ` <span class="dim">of which ${fmtCount(k.blackholes)} ${k.blackholes === 1 ? 'is a blackhole' : 'are blackholes'}, dropping the packets for their prefix</span>` : ''}`) +
|
||||
row('installed as', `<span class="mono">table ${k.table} metric ${k.metric} proto bgp</span>`) +
|
||||
(k.kept ? row('kept from before the restart', `<span class="amber">${fmtCount(k.kept)}</span>, not announced again yet`) : '') +
|
||||
(unresolved ? row('not installed', `<span class="amber">${unresolved}</span>`) : '') +
|
||||
@@ -711,7 +855,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
|
||||
// ---- data loading ------------------------------------------------------
|
||||
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); renderReceived(); renderPeers(); renderStrip(); }
|
||||
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderStrip(); }
|
||||
async function loadPeers() { const fresh = await getJSON('/api/peers'); noteTrends(state.peers, fresh); state.peers = fresh; renderPeers(); renderStrip(); }
|
||||
// The way to the pages before and after this one, where there are any.
|
||||
function renderPager() {
|
||||
const page = state.receivedPage, place = pagePlace();
|
||||
@@ -843,6 +987,31 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
$('lookup').addEventListener('submit', e => { e.preventDefault(); submitLookup(); });
|
||||
$('lookup-clear').addEventListener('click', e => { e.preventDefault(); setLookup(null); });
|
||||
$('peers-problems').addEventListener('change', renderPeers);
|
||||
// Compact is remembered in this browser; nothing else is.
|
||||
const COMPACT_KEY = 'statusz.compact';
|
||||
try { $('compact').checked = localStorage.getItem(COMPACT_KEY) === '1'; } catch (e) { /* no storage: a session's choice only */ }
|
||||
const applyCompact = () => document.body.classList.toggle('compact', $('compact').checked);
|
||||
applyCompact();
|
||||
$('compact').addEventListener('change', () => { applyCompact(); try { localStorage.setItem(COMPACT_KEY, $('compact').checked ? '1' : '0'); } catch (e) { /* as above */ } });
|
||||
// A name in the strip goes to the peer's row and lights it for a moment.
|
||||
document.addEventListener('click', e => {
|
||||
const link = e.target.closest('a.to-peer');
|
||||
if (!link) return;
|
||||
e.preventDefault();
|
||||
$('peers-problems').checked = false; renderPeers();
|
||||
const tr = document.querySelector(`tr[data-peer-row="${CSS.escape(link.dataset.peer)}"]`);
|
||||
if (!tr) return;
|
||||
tr.scrollIntoView({ block: 'center' });
|
||||
tr.classList.remove('flash'); void tr.offsetWidth; tr.classList.add('flash');
|
||||
tr.addEventListener('animationend', () => tr.classList.remove('flash'), { once: true });
|
||||
});
|
||||
// The marks of what changed while nobody looked: one goes when its row
|
||||
// is clicked, all when asked.
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.closest('#since-clear')) { e.preventDefault(); lookedAt(null); return; }
|
||||
const tr = e.target.closest('tr[data-peer-row]');
|
||||
if (tr && !e.target.closest('a')) lookedAt(tr.dataset.peerRow);
|
||||
});
|
||||
document.addEventListener('click', e => { if (e.target.closest('.clamp-more')) toggleClamp(e.target.closest('td')); });
|
||||
document.addEventListener('dblclick', e => { const td = e.target.closest('td'); if (td && td.querySelector('.clamp')) { toggleClamp(td); getSelection().removeAllRanges(); } });
|
||||
// A peer's name anywhere on the page leads to its routes.
|
||||
@@ -861,6 +1030,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
e.preventDefault(); setPage(JSON.parse(to.dataset.page));
|
||||
});
|
||||
}
|
||||
// For poking at the page from the console; nothing on the page uses it.
|
||||
window.statusz = { state, noteTrends, renderPeers, renderStrip, lookedAt };
|
||||
loadAll().catch(e => logEvent(`<span class="red">${esc(e.message)}</span>`));
|
||||
if (REFRESH) {
|
||||
connect();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Documentation sources
|
||||
|
||||
This directory holds the sources of the Ferrix documentation site, built
|
||||
with [Zola](https://www.getzola.org):
|
||||
|
||||
```bash
|
||||
zola --root docs serve # http://127.0.0.1:1111, rebuilt on every change
|
||||
zola --root docs build # into docs/public
|
||||
```
|
||||
|
||||
The pages under `content/` are Markdown with Zola additions, which a code
|
||||
forge does not render:
|
||||
|
||||
- The block between `+++` lines at the top of each page is its front
|
||||
matter: the title, description and position in the navigation.
|
||||
- `{{ example(name="...") }}` and `{{ file(name="...") }}` insert a file
|
||||
from [`examples/`](examples/), whole, at that point. Open the file to
|
||||
read it.
|
||||
- Links of the form `@/docs/...` are internal links that Zola resolves and
|
||||
checks at build time. On the forge they do not work; the target is the
|
||||
path after `@/`, under `content/`.
|
||||
|
||||
To read the documentation as intended, build the site, or read it where
|
||||
it is published (`base_url` in [`config.toml`](config.toml)).
|
||||
|
||||
`STYLE.md` is the writing guide. CI builds the site on every push and fails
|
||||
on a link to a page or an anchor that does not exist; `zola --root docs
|
||||
check` also checks external links.
|
||||
@@ -163,9 +163,12 @@ UPDATE message, and waits when the session's output queue is full.
|
||||
### Kernel Route Installation
|
||||
|
||||
The `fib` crate reads best path changes and maintains the kernel routing
|
||||
table over rtnetlink, with one route for each prefix through the next hop
|
||||
of its best path. It also receives the router's interfaces and addresses
|
||||
from the kernel, which determine whether a next hop is resolvable.
|
||||
table over rtnetlink, with one route for each prefix through the resolved
|
||||
next hop of its best path. It also receives the router's interfaces,
|
||||
addresses and the routes of the resolution table from the kernel, which
|
||||
determine whether and how a next hop is resolvable; the import policy
|
||||
consults the same view, so that a route with an unresolvable next hop is
|
||||
held rather than selected.
|
||||
|
||||
### HTTP Interface
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ Ferrix 0.2.5 does not support the following features:
|
||||
Generalized TTL Security Mechanism (GTSM).
|
||||
- Long-Lived Graceful Restart, Bidirectional Forwarding Detection (BFD),
|
||||
and configuration changes without a restart.
|
||||
- Recursive resolution of next hops that are not directly connected.
|
||||
|
||||
For the implementation status of each RFC, see
|
||||
[Supported Standards](@/docs/reference/rfcs.md).
|
||||
|
||||
@@ -112,6 +112,6 @@ this metric, see [Monitoring Ferrix](@/docs/operations/monitoring.md#alerting-ru
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuring BGP Neighbors](@/docs/guides/peers.md)
|
||||
- [BGP Neighbors](@/docs/guides/peers.md)
|
||||
- [Import Policy](@/docs/guides/import-policy.md)
|
||||
- [Configuration Examples](@/docs/guides/examples.md)
|
||||
@@ -77,7 +77,7 @@ When the daemon stops in response to SIGTERM, it ends each session for
|
||||
good. On a session on which the N bit is in use, it sends a Cease
|
||||
NOTIFICATION message with the subcode Hard Reset.
|
||||
|
||||
## When a Neighbor Restarts
|
||||
## Neighbor Restart
|
||||
|
||||
Ferrix retains the routes of a neighbor whose session ends in one of the
|
||||
following ways, if graceful restart is in use on the session:
|
||||
|
||||
@@ -62,23 +62,71 @@ disabled.
|
||||
| Route from an EBGP neighbor whose AS path contains the local AS number | `own AS in path` |
|
||||
| Route whose next hop is unusable: unspecified, loopback, multicast, or an address of the local router | `next hop unusable` |
|
||||
| Route with the Only to Customer attribute received from the wrong direction (see [BGP Roles](@/docs/guides/roles.md)) | `route leak (Only to Customer)` |
|
||||
| Route from an EBGP neighbor with the BLACKHOLE community (RFC 7999) | `blackhole request` |
|
||||
| Route from an EBGP neighbor with the BLACKHOLE community (RFC 7999), unless `accept_blackholes` is set | `blackhole request` |
|
||||
| Route with the BLACKHOLE community that is not a host route (/32 or /128), with `accept_blackholes` set | `blackhole for more than one address` |
|
||||
|
||||
### Blackhole Requests
|
||||
### Blackhole Routes
|
||||
|
||||
A network under a denial-of-service attack can advertise a route for the
|
||||
attacked address with the BLACKHOLE community (65535:666), to request that
|
||||
other networks discard traffic to the address. At an Internet exchange, the
|
||||
route server distributes such routes to its members with the exchange's
|
||||
blackhole address as the next hop. These routes are usually host routes
|
||||
(/32 or /128).
|
||||
other networks discard traffic to the address (RFC 7999). At an Internet
|
||||
exchange, the route server distributes such routes to its members with
|
||||
the exchange's blackhole address as the next hop. These routes are host
|
||||
routes (/32 or /128).
|
||||
|
||||
Ferrix does not support blackhole routing. It rejects every route with the
|
||||
BLACKHOLE community from an EBGP neighbor, with the reason
|
||||
`blackhole request`, regardless of the prefix length. A route with the
|
||||
BLACKHOLE community that is accepted as an ordinary route directs traffic
|
||||
to the blackhole next hop, which discards it, so Ferrix does not accept
|
||||
these routes as ordinary routes either.
|
||||
By default, Ferrix rejects every route with the BLACKHOLE community from
|
||||
an EBGP neighbor, with the reason `blackhole request`, regardless of the
|
||||
prefix length. Ferrix does not accept such a route as an ordinary route
|
||||
either, because that would direct traffic to the blackhole next hop,
|
||||
which discards it.
|
||||
|
||||
To honor blackhole requests from a neighbor, set `accept_blackholes` in
|
||||
its import policy:
|
||||
|
||||
```textproto
|
||||
peers {
|
||||
name: "customer-a"
|
||||
endpoint { address: "192.0.2.10" }
|
||||
remote_asn: 64501
|
||||
role: CUSTOMER
|
||||
import {
|
||||
prefixes { prefix: "203.0.113.0/24" }
|
||||
accept_blackholes: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With `accept_blackholes` set, Ferrix accepts a route with the BLACKHOLE
|
||||
community if all of the following conditions are met:
|
||||
|
||||
- The prefix is a single address: a /32 or a /128. The
|
||||
`max_ipv4_prefix_length` and `max_ipv6_prefix_length` limits do not
|
||||
apply to blackhole routes. A blackhole route for a longer prefix is
|
||||
rejected with the reason `blackhole for more than one address`.
|
||||
- The address is within a prefix that the neighbor may announce, if
|
||||
`prefixes` is configured. The `max_length` of the entry does not apply.
|
||||
- The route passes the AS path, origin, and RPKI checks that apply to
|
||||
every route.
|
||||
|
||||
The next hop of a blackhole route is not checked, and need not be
|
||||
reachable: Ferrix never forwards traffic to it. If the route is the active
|
||||
route for its prefix, which a host route is unless a longer-matching
|
||||
route exists, Ferrix installs a blackhole route in the kernel, and the
|
||||
router discards traffic to the address. See [Blackhole
|
||||
Routes](@/docs/guides/kernel.md#blackhole-routes) in the kernel guide.
|
||||
|
||||
Ferrix advertises an accepted blackhole route like any other route, with
|
||||
the BLACKHOLE community, to the neighbors that the export policy and the
|
||||
export limits permit. A provider that honors the community then discards
|
||||
the traffic before it reaches the local network. To keep a blackhole
|
||||
route from being advertised, the originator adds the NO_EXPORT or
|
||||
NO_ADVERTISE community, as RFC 7999 recommends.
|
||||
|
||||
> [!NOTE]
|
||||
> Ferrix does not install a blackhole route for a prefix that it
|
||||
> originates with the BLACKHOLE community. To discard traffic locally for
|
||||
> such a prefix, add a kernel blackhole route, for example
|
||||
> `ip route add blackhole 203.0.113.9/32`.
|
||||
|
||||
### Next Hop Check
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ kernel {
|
||||
| `metric` | Metric of the installed routes. The kernel prefers the route with the lowest metric. | 20 |
|
||||
| `without_nexthop_objects` | Installs each route with its own gateway instead of a nexthop object | Disabled |
|
||||
| `stale_seconds` | Time for which routes from a previous run are retained at startup | 120 seconds |
|
||||
| `resolution_table` | Kernel routing table whose routes resolve next hops that are not directly connected | 254 (`main`) |
|
||||
| `resolve_via_default_route` | Permits the default route to resolve a next hop | Disabled |
|
||||
|
||||
Ferrix requires the `CAP_NET_ADMIN` capability to modify the kernel routing
|
||||
table. If the kernel rejects a request for lack of privileges, the daemon
|
||||
@@ -44,15 +46,55 @@ exits. See [Granting Privileges](@/docs/getting-started/installation.md#granting
|
||||
|
||||
## Next Hop Resolution
|
||||
|
||||
Ferrix installs a route only if its next hop is directly connected: the
|
||||
next hop must be an address in a subnet of an interface that is up. For an
|
||||
IPv6 route with a link-local next hop, the outgoing interface is the
|
||||
interface on which the neighbor's session is established.
|
||||
Ferrix resolves the next hop of each active route to a gateway and an
|
||||
outgoing interface before it installs the route:
|
||||
|
||||
Ferrix does not resolve next hops recursively through other routes.
|
||||
Therefore, a route whose next hop is not directly connected, such as a
|
||||
route from an IBGP neighbor that does not set itself as the next hop, is
|
||||
not installed.
|
||||
- A next hop that is directly connected, that is, an address in a subnet
|
||||
of an interface that is up, is the gateway, and the interface is that
|
||||
interface. For an IPv6 route with a link-local next hop, the outgoing
|
||||
interface is the interface on which the neighbor's session is
|
||||
established.
|
||||
- A next hop that is not directly connected is resolved through the
|
||||
routes of the resolution table (`resolution_table`, by default the
|
||||
`main` table). Ferrix selects the route with the longest prefix that
|
||||
contains the next hop, and of several routes for that prefix the one
|
||||
with the lowest metric. If the route has a gateway, the gateway and the
|
||||
route's interface are used. If the route has no gateway, as a route to a
|
||||
point-to-point interface or a tunnel does not, the next hop itself is
|
||||
the gateway and the route's interface is used. A route that names a
|
||||
kernel nexthop object is resolved through that object.
|
||||
|
||||
The Linux kernel does not resolve gateways recursively, so Ferrix installs
|
||||
the route with the resolved gateway and interface rather than with the
|
||||
BGP next hop. For example, a route from an IBGP neighbor with the next hop
|
||||
`192.0.2.9`, when the kernel has the static route
|
||||
`192.0.2.0/24 via 10.0.0.2 dev wg0`, is installed as a route
|
||||
`via 10.0.0.2 dev wg0`. When the static route changes or is removed,
|
||||
Ferrix resolves the next hop again and updates or removes the installed
|
||||
route.
|
||||
|
||||
The following routes do not resolve a next hop:
|
||||
|
||||
- Routes with protocol `bgp`, whether installed by Ferrix or by another
|
||||
BGP daemon. A next hop that is reachable only through a BGP route would
|
||||
depend on another next hop, and such dependencies between routes are not
|
||||
predictable.
|
||||
- The default route, unless `resolve_via_default_route` is `true`. On a
|
||||
router, the default route typically points to a transit provider, and a
|
||||
next hop that is reachable only through it is not a usable next hop.
|
||||
- Blackhole, unreachable and prohibit routes. A next hop covered by such a
|
||||
route is unresolvable.
|
||||
- Routes whose interface is down.
|
||||
|
||||
Ferrix learns the routes of the resolution table from the kernel at
|
||||
startup and is notified of changes. Changes to the routes take effect
|
||||
within a few seconds.
|
||||
|
||||
> [!NOTE]
|
||||
> Routes from an IBGP neighbor that does not set itself as the next hop,
|
||||
> such as a route reflector that preserves the next hop, require a route to
|
||||
> the next hop in the resolution table: a static route, a route to the
|
||||
> tunnel on which the next hop is reached, or a route from an IGP.
|
||||
|
||||
### Exclusion of Unresolvable Routes
|
||||
|
||||
@@ -70,6 +112,18 @@ next hop becomes unresolvable is also moved to the held state.
|
||||
If the `kernel` statement is not configured, Ferrix has no information about
|
||||
next hop reachability and accepts all routes.
|
||||
|
||||
## Blackhole Routes
|
||||
|
||||
Ferrix installs the active route for a prefix as a kernel blackhole route
|
||||
(`ip route` displays it as `blackhole PREFIX proto bgp`) if the route
|
||||
carries the BLACKHOLE community (RFC 7999) and the import policy accepted
|
||||
it with `accept_blackholes`. The kernel discards traffic to the prefix.
|
||||
The next hop of the route is not resolved, and the route is installed
|
||||
even if the next hop is unreachable. The kernel section of the status
|
||||
page and `/api/kernel` report the number of installed blackhole routes.
|
||||
See [Blackhole Routes](@/docs/guides/import-policy.md#blackhole-routes)
|
||||
in the import policy guide.
|
||||
|
||||
## Connected Networks
|
||||
|
||||
Ferrix never installs a route for a subnet of a local interface, or for a
|
||||
|
||||
@@ -367,10 +367,12 @@ not affected.
|
||||
|
||||
Ferrix advertises a reflected route with the local address of the session
|
||||
as next hop, as it does every route. A standard route reflector does not
|
||||
change the next hop. Ferrix does, because it does not resolve next hops
|
||||
that are not directly connected: a client that is not connected to the
|
||||
router that learned the route can then still use it, and sends the
|
||||
traffic through the route reflector.
|
||||
change the next hop. Ferrix does, so that a client needs no route to the
|
||||
router that learned the route: the client sends the traffic through the
|
||||
route reflector, which forwards it. Preserving the next hop on reflection
|
||||
is not yet configurable; clients that have a route to the originating
|
||||
router, for example over a tunnel, would then forward to it directly. See
|
||||
[Next Hop Resolution](@/docs/guides/kernel.md#next-hop-resolution).
|
||||
|
||||
### Loop Prevention
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
+++
|
||||
title = "Configuring BGP Neighbors"
|
||||
title = "BGP Neighbors"
|
||||
description = "Describes how to configure BGP neighbors, address families, link-local neighbors, ADD-PATH, and session timers, and how Ferrix filters inbound connections."
|
||||
weight = 1
|
||||
|
||||
@@ -233,6 +233,35 @@ message with the subcode Administrative Shutdown to every neighbor. If the
|
||||
`kernel` statement is configured, it then removes its routes from the
|
||||
kernel. See [Kernel Route Installation](@/docs/guides/kernel.md).
|
||||
|
||||
## Expected Route Counts
|
||||
|
||||
An established session with no routes, or with a small fraction of the
|
||||
routes that the neighbor normally advertises, indicates a problem that the
|
||||
session state does not show: a filter on the neighbor's side, an import
|
||||
policy that rejects everything, or a neighbor that has not finished
|
||||
sending its table. The `expected_routes` statement specifies the
|
||||
approximate number of routes that the neighbor advertises in normal
|
||||
operation:
|
||||
|
||||
```textproto
|
||||
peers {
|
||||
name: "transit-a"
|
||||
endpoint { address: "192.0.2.1" }
|
||||
remote_asn: 64496
|
||||
role: PROVIDER
|
||||
expected_routes: 1000000
|
||||
import { max_prefixes: 1200000 }
|
||||
}
|
||||
```
|
||||
|
||||
The status page compares the number of routes accepted from the neighbor
|
||||
with this value. An established session with fewer than half the expected
|
||||
routes is listed under **Needing attention**, with the expected number
|
||||
beside the actual one. The comparison starts one minute after the session
|
||||
is established, so that a neighbor that is still sending its table is not
|
||||
reported. The value does not affect route processing; `max_prefixes` is
|
||||
the limit that Ferrix enforces.
|
||||
|
||||
## Verifying BGP Sessions
|
||||
|
||||
To verify the state of each neighbor, use one of the following:
|
||||
|
||||
@@ -31,7 +31,7 @@ neighbors that are restarting; the kernel not in step with the routing
|
||||
table, or refusing routes; and RPKI data that is missing or about to
|
||||
expire. When nothing needs attention, the line says so. A second line
|
||||
summarizes the normal state: sessions established, routes installed, and
|
||||
the RPKI state.
|
||||
the RPKI state. Each neighbor named in the line is a link to its row.
|
||||
|
||||
Below the status line, the page displays the following information:
|
||||
|
||||
@@ -39,8 +39,12 @@ Below the status line, the page displays the following information:
|
||||
- Each neighbor, with its session state, time established, next hop,
|
||||
session properties, number of accepted routes, rejected announcements,
|
||||
and held routes, and its last error. Neighbors that need attention are
|
||||
listed first; the others are grouped by role. The **problems only**
|
||||
option hides the neighbors that need no attention.
|
||||
listed first; the others are grouped by role. The IPv4 and IPv6 sessions
|
||||
of one neighbor, named `NAME-v4` and `NAME-v6`, are listed together,
|
||||
IPv6 first, and joined by a bar. The **problems only** option hides the neighbors that
|
||||
need no attention; the **compact** option removes the next hop and
|
||||
session columns, for a small or distant screen, and is remembered by the
|
||||
browser.
|
||||
- RPKI validation: the cache, the RTR version, and the age of the data.
|
||||
- The originated routes.
|
||||
- Kernel route installation: the number of installed routes, the number of
|
||||
@@ -60,7 +64,14 @@ action, such as a session that is down; amber indicates a degraded state,
|
||||
such as held or stale routes; green indicates a function that is operating
|
||||
normally, such as the RPKI cache being synchronized; gray indicates an
|
||||
inactive or expected state, such as a passive neighbor waiting for a
|
||||
connection. Normal values are displayed in black.
|
||||
connection. Normal values are displayed in black. When the operating
|
||||
system or browser requests a dark color scheme, the page uses the same
|
||||
colors on a dark background.
|
||||
|
||||
An established neighbor whose route count is below half of its
|
||||
`expected_routes` value is listed under **Needing attention**, with the
|
||||
expected count beside the actual one. See [Expected Route
|
||||
Counts](@/docs/guides/peers.md#expected-route-counts).
|
||||
|
||||
Properties that repeat on many rows are displayed as glyphs. Table 2 lists
|
||||
them. Point to a glyph to display its meaning.
|
||||
@@ -77,6 +88,8 @@ them. Point to a glyph to display its meaning.
|
||||
| `×N` | After an AS number in an AS path: the AS appears N times in a row (prepending). |
|
||||
| `OTC`, `AA`, `AGG`, `AS4`, `OID`, `CL`, `EXT` | Path attributes without a column of their own: Only to Customer, ATOMIC_AGGREGATE, AGGREGATOR, AS4_PATH, ORIGINATOR_ID, CLUSTER_LIST, and extended communities. |
|
||||
| `✓`, `✗`, `–` | RPKI or ASPA validation state: valid, invalid, or not found. |
|
||||
| `▲`, `▼` | The neighbor's route count rose or fell by more than 2% between the last two refreshes. Displayed for two minutes. |
|
||||
| `●` | The row changed while the browser tab was in the background: the session state changed, or the route count moved by more than 2%. Click the row to clear the mark. |
|
||||
|
||||
An AS path or a list of communities that runs to more than a few lines is
|
||||
displayed shortened, with a **more** control. Click the control, or
|
||||
|
||||
@@ -77,6 +77,7 @@ Statements for a BGP neighbor.
|
||||
| `role` <span class="type">[Role](#role)</span> | None | Specifies the BGP role of the neighbor (RFC 9234). Not permitted for IBGP neighbors. Required for every EBGP neighbor if any `export` statement enables `learned_routes`. |
|
||||
| `require_role` <span class="type">bool</span> | `false` | Rejects the session if the neighbor does not advertise a BGP role. Requires `role`. |
|
||||
| `passive` <span class="type">bool</span> | `false` | Prevents Ferrix from initiating connections to the neighbor. Ferrix accepts connections from the address of `endpoint`; the port of `endpoint` is not used. See [Passive Neighbors](@/docs/guides/peers.md#passive-neighbors). |
|
||||
| `expected_routes` <span class="type">uint32</span> | 0 | Specifies the approximate number of routes that the neighbor advertises in normal operation. The status page marks an established session with fewer than half this number of routes as needing attention. The value does not affect route processing. 0 disables the comparison. See [Expected Route Counts](@/docs/guides/peers.md#expected-route-counts). |
|
||||
| `export` <span class="type">[ExportPolicy](#exportpolicy)</span> | Top-level `export` | Specifies the export policy for the neighbor. Replaces the top-level `export` statement. |
|
||||
| `graceful_restart` <span class="type">[GracefulRestart](#gracefulrestart)</span> | Top-level `graceful_restart` | Specifies graceful restart for the neighbor. Each statement that is set replaces the corresponding top-level statement. |
|
||||
| `route_reflector_client` <span class="type">bool</span> | `false` | Makes the IBGP neighbor a route reflector client: Ferrix advertises to it the routes learned from other IBGP neighbors, and advertises its routes to them. Permitted only for IBGP neighbors. See [Route Reflection](@/docs/guides/passing-routes-on.md#route-reflection). |
|
||||
@@ -102,6 +103,7 @@ accepted. See [Import Policy](@/docs/guides/import-policy.md).
|
||||
| `max_prefixes` <span class="type">uint32</span> | 0 (no limit) | Specifies the maximum number of routes that the neighbor can have in the routing table. If the limit is exceeded, Ferrix tears down the session and places the neighbor in hold-down. |
|
||||
| `hold_down_seconds` <span class="type">uint32</span> | 600 | Specifies the hold-down time, in seconds, after the neighbor exceeds `max_prefixes`, or after the routes to be advertised to it exceed `export_limits.max_prefixes`. |
|
||||
| `accept_bogons` <span class="type">bool</span> | `false` | Accepts bogon prefixes, and AS paths that contain private, reserved, or documentation AS numbers. |
|
||||
| `accept_blackholes` <span class="type">bool</span> | `false` | Accepts routes with the BLACKHOLE community (RFC 7999) for single addresses, and installs them as blackhole routes. See [Blackhole Routes](@/docs/guides/import-policy.md#blackhole-routes). |
|
||||
| `accept_default_route` <span class="type">bool</span> | `false` | Accepts the default routes `0.0.0.0/0` and `::/0`. |
|
||||
| `max_ipv4_prefix_length` <span class="type">uint32</span> | 24 | Specifies the longest IPv4 prefix accepted. |
|
||||
| `max_ipv6_prefix_length` <span class="type">uint32</span> | 48 | Specifies the longest IPv6 prefix accepted. |
|
||||
@@ -213,6 +215,8 @@ Statements for kernel route installation. See
|
||||
| `metric` <span class="type">uint32, optional</span> | 20 | Specifies the metric of installed routes. |
|
||||
| `without_nexthop_objects` <span class="type">bool</span> | `false` | Installs each route with its own gateway instead of a nexthop object. |
|
||||
| `stale_seconds` <span class="type">uint32, optional</span> | 120 | Specifies the time, in seconds, for which routes installed by a previous run are retained at startup. A value of 0 removes them immediately. |
|
||||
| `resolution_table` <span class="type">uint32</span> | 254 (`main`) | Specifies the kernel routing table whose routes resolve next hops that are not directly connected. Routes with protocol `bgp` in this table are not used. See [Next Hop Resolution](@/docs/guides/kernel.md#next-hop-resolution). |
|
||||
| `resolve_via_default_route` <span class="type">bool</span> | `false` | Permits the default route of the resolution table to resolve a next hop. |
|
||||
|
||||
## GracefulRestart
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ neighbor:
|
||||
"routes": 1,
|
||||
"rejected_routes": 1,
|
||||
"held_routes": 0,
|
||||
"expected_routes": null,
|
||||
"role": null,
|
||||
"role_confirmed": null,
|
||||
"last_error": null,
|
||||
@@ -84,6 +85,7 @@ neighbor:
|
||||
| `routes` | Number of the neighbor's routes in the routing table. |
|
||||
| `rejected_routes` | Number of the neighbor's routes rejected by its import policy. |
|
||||
| `held_routes` | Number of held routes: routes rejected for a reason that can change without a new UPDATE, such as RPKI validity or next hop resolution. |
|
||||
| `expected_routes` | The `expected_routes` value from the configuration, or `null` if it is not set. |
|
||||
| `role_confirmed` | Whether the neighbor advertised a BGP role that matches the configured role. |
|
||||
| `retry_at_unix` | For a neighbor without a session, the time of the next connection attempt. |
|
||||
| `graceful_restart` | Graceful restart, if both neighbors advertised it: the restart time of the neighbor, whether the N bit is in use (RFC 8538), the address families whose routes are retained, and those for which the neighbor preserved its forwarding state. |
|
||||
@@ -204,6 +206,7 @@ routes. To return the records for one neighbor, add `?peer=NAME`.
|
||||
"synced": true,
|
||||
"installed": 0,
|
||||
"kept": 0,
|
||||
"blackholes": 0,
|
||||
"unresolved": {},
|
||||
"refused": 0,
|
||||
"errors": 0,
|
||||
@@ -220,6 +223,7 @@ routes. To return the records for one neighbor, add `?peer=NAME`.
|
||||
| `synced` | Whether the kernel routing table has been synchronized with the routing table since the daemon started. |
|
||||
| `installed` | Number of routes installed. |
|
||||
| `kept` | Number of routes from a previous run that are retained during startup. |
|
||||
| `blackholes` | Number of installed routes that are blackhole routes, which discard traffic for their prefix (RFC 7999). |
|
||||
| `unresolved` | Number of prefixes whose active route is not installed, by reason. |
|
||||
| `refused` | Number of prefixes whose active route the kernel rejected. |
|
||||
| `errors` | Number of requests that the kernel rejected. |
|
||||
|
||||
@@ -38,9 +38,9 @@ The base protocol specification.
|
||||
| §8.1.1 | Optional `CollisionDetectEstablishedState` (`collision_detect_established_state`) | ✅ |
|
||||
| §6.2 | Bad BGP Identifier check (neighbor uses the local identifier, or 0.0.0.0) | ✅ |
|
||||
| §9 | Decision process / best-path selection | ✅ |
|
||||
| §9.1.2 | Best routes installed for forwarding (Linux kernel, over netlink), where the next hop is directly connected | ✅ |
|
||||
| §9.1.2 | Best routes installed for forwarding (Linux kernel, over netlink) | ✅ |
|
||||
| §9.1.2.1 | Routes with an unresolvable next hop excluded from selection | ✅ where routes are installed in the kernel: such a route is held out of the table until its next hop can be reached |
|
||||
| §9.1.2.1 | Next hops resolved through other routes | ❌ (only next hops that are neighbors count as reachable) |
|
||||
| §9.1.2.1 | Next hops resolved through other routes | ✅ (through the kernel's connected, static and IGP routes; not through BGP routes) |
|
||||
| §9 | Adj-RIB-In (per-peer table of received routes) | ✅ |
|
||||
| §9 | Locally originated routes, advertised to all established peers | ✅ |
|
||||
| §9 | Advertisement of the best path for each prefix to neighbors (`export.learned_routes`), with the local AS prepended and the local address as next hop; MED and LOCAL_PREF not sent to EBGP neighbors; LOCAL_PREF sent to IBGP neighbors (§5.1.4, §5.1.5, §9.1.3) | ✅ |
|
||||
@@ -116,7 +116,7 @@ The base protocol specification.
|
||||
| ORIGINATOR_ID and CLUSTER_LIST added on reflection, not sent to EBGP neighbors | ✅ |
|
||||
| Routes with the local router ID as ORIGINATOR_ID, or the local cluster ID in CLUSTER_LIST, rejected | ✅ |
|
||||
| Configurable cluster ID | ✅ |
|
||||
| Next hop unchanged on reflection | ❌ The route reflector is the next hop: next hops that are not directly connected are not resolved |
|
||||
| Next hop unchanged on reflection | ❌ The route reflector sets itself as the next hop |
|
||||
| ORIGINATOR_ID and CLUSTER_LIST length as best path tie breakers (§9) | ❌ |
|
||||
|
||||
### RFC 8538 — Notification Message Support for BGP Graceful Restart
|
||||
@@ -203,8 +203,11 @@ The base protocol specification.
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| `BLACKHOLE` accepted as a community name in configuration | ✅ |
|
||||
| Routes with BLACKHOLE from EBGP neighbors rejected on import (`blackhole request`) | ✅ |
|
||||
| Blackhole routing: discarding traffic for prefixes with BLACKHOLE | ❌ |
|
||||
| Routes with BLACKHOLE from EBGP neighbors rejected on import by default (`blackhole request`) | ✅ |
|
||||
| Blackhole routes for single addresses accepted with `accept_blackholes` (§3.2: prefix length limits relaxed, next hop not checked) | ✅ |
|
||||
| Blackhole routing: a kernel blackhole route discards traffic for the prefix | ✅ |
|
||||
| Blackhole routes advertised with the community, for providers to honor (§3.3) | ✅ |
|
||||
| A blackhole route for a prefix this router originates | ❌ (add a kernel blackhole route by hand) |
|
||||
|
||||
## Other Capabilities (Wire Format Only)
|
||||
|
||||
|
||||
Reference in new issue
Block a user