A new crate, bgp-policy, runs functions of a script on routes: on their
way in, after the general checks of a peer's import and before the
checks that can change (RPKI, next hop reachability); and on their way
out to a peer. A function is given the route and the peer, may refuse
the route, and may change its communities, large communities,
LOCAL_PREF and MED, and on the way out prepend our AS:
fn from_peer(route, peer) {
if route.has_community("65535:666") { return false; }
route.add_community(LEARNED_FROM[peer.role]);
route.local_pref = PREFERENCE[peer.role];
true
}
Configuration: policy { path | source, import_function, export_function }
and, for a peer, import { function } and export { function }. The script
is compiled at startup; `bgp --check` reads it and says which function
is whose. A function that fails on a route has refused it. Scripts have
no files, network or clock, and are stopped after 200,000 steps.
Route selection now goes by a LOCAL_PREF on a route from an external
peer: the decoder never lets a peer's own through, so one that is there
was set by policy.
What it costs was measured before it was built on (benches/policy.rs):
a call that tests a few communities and changes two takes 1 to 3
microseconds, some two hundred times what Rust would. So a function is
asked once for the routes of an UPDATE, which share their attributes,
unless it asks for the prefix. One peer sending 1.23 M routes over a
session: 0.37 s without a function, 0.80 s with one that marks and
ranks every route, 3.4 s if it also looks at the prefix.
Export applies to what is announced, which today is what the daemon
originates; learned routes are not passed on yet.
Tested by unit tests of the crate, sessions end to end, a fuzz target on
the example script (testdata/policy.rhai), and a lab phase in which a
script changes which of FRR's and BIRD's route is chosen and what each
of them is told.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1597 lines
58 KiB
Rust
1597 lines
58 KiB
Rust
//! Import policy: which of a peer's routes are taken.
|
|
//!
|
|
//! A peer can announce anything. What it announces by mistake or on purpose
|
|
//! that is not its to announce becomes, if we take it, a route we use and
|
|
//! pass on. So every route is judged before it goes into the routing table,
|
|
//! by rules that are part of each peer's configuration:
|
|
//!
|
|
//! - **Checks that hold for any peer**, on unless turned off: no prefixes
|
|
//! that are not routed on the Internet, none more specific than is routed
|
|
//! between networks, no reserved AS numbers in the path, a path that
|
|
//! begins with the peer's own AS and is not absurdly long.
|
|
//! - **What this peer in particular may announce**, if configured: a list
|
|
//! of prefixes, and the AS numbers routes may originate from. This is
|
|
//! what is generated from the routing registries for each customer or
|
|
//! exchange member.
|
|
//! - **How many routes** the peer may have at a time. One more ends the
|
|
//! session, and it is kept down for a while: a peer that suddenly sends
|
|
//! a great many more routes than it should is leaking someone else's.
|
|
//!
|
|
//! A route with our own AS number in its path is never taken, whatever is
|
|
//! configured: it has been through this AS already (RFC 4271 §9.1.2).
|
|
//!
|
|
//! A route that is refused is not kept. If the peer had announced the same
|
|
//! route before in a form that was taken, that one goes: the peer has
|
|
//! replaced it, and the replacement is not acceptable.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
|
use std::sync::{Arc, LazyLock, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use bgp_packet::message::{AS_TRANS, AsPathAttribute, NlriNextHop, PathAttribute};
|
|
use bgp_packet::open::BgpRole;
|
|
use bgp_primitives::constants::IpVersion;
|
|
use bgp_primitives::ip_prefix::IpPrefix;
|
|
|
|
use crate::address::PeerAddress;
|
|
use crate::sync::IgnorePoison;
|
|
|
|
/// The longest prefixes routed between networks: anything more specific is
|
|
/// filtered nearly everywhere, and is usually a leak of internal routes.
|
|
const LONGEST_IPV4: u8 = 24;
|
|
const LONGEST_IPV6: u8 = 48;
|
|
|
|
/// The shortest prefixes anyone announces, short of a default route.
|
|
const SHORTEST_IPV4: u8 = 8;
|
|
const SHORTEST_IPV6: u8 = 16;
|
|
|
|
/// A path longer than this is not one traffic would be sent along; it is a
|
|
/// mistake or an attempt to exhaust something.
|
|
const DEFAULT_MAX_AS_PATH_LENGTH: u16 = 64;
|
|
|
|
/// How long a peer that went over its limit is kept down, unless
|
|
/// configured otherwise.
|
|
const DEFAULT_HOLD_DOWN: Duration = Duration::from_secs(600);
|
|
|
|
/// What is taken from one peer.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ImportPolicy {
|
|
/// The most routes the peer may have at a time; `None` for no limit.
|
|
pub max_prefixes: Option<u32>,
|
|
/// How long the peer is kept down after going over the limit.
|
|
pub hold_down: Duration,
|
|
/// Take prefixes and AS numbers that are reserved, private or for
|
|
/// documentation.
|
|
pub accept_bogons: bool,
|
|
pub accept_default_route: bool,
|
|
/// The longest IPv4 and IPv6 prefixes taken.
|
|
pub max_ipv4_length: u8,
|
|
pub max_ipv6_length: u8,
|
|
/// Take routes whose path does not begin with the peer's AS. A route
|
|
/// server does not put its own AS on the path (RFC 7947 §2.2.2.1).
|
|
pub accept_any_first_as: bool,
|
|
/// The longest AS path taken, counted in AS numbers.
|
|
pub max_as_path_length: u16,
|
|
/// The prefixes the peer may announce; `None` for any.
|
|
pub prefixes: Option<PrefixSet>,
|
|
/// The AS numbers the peer's routes may originate from; `None` for
|
|
/// any.
|
|
pub origin_asns: Option<HashSet<u32>>,
|
|
/// Take routes that route origin validation finds invalid (RFC 6811).
|
|
pub accept_rpki_invalid: bool,
|
|
/// Take routes whose path the ASPAs find invalid.
|
|
pub accept_aspa_invalid: bool,
|
|
/// Take routes whose path has an AS_SET in it. They are deprecated
|
|
/// (RFC 9774): with one, nobody can say where a route comes from.
|
|
pub accept_as_sets: bool,
|
|
/// Take routes whose next hop is not the peer itself. Without this a
|
|
/// peer can only draw our traffic to itself, not send it to someone
|
|
/// 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,
|
|
/// 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:
|
|
/// a customer with a part of our space. Empty for a peer in our own
|
|
/// AS.
|
|
pub own_prefixes: Arc<PrefixSet>,
|
|
}
|
|
|
|
impl Default for ImportPolicy {
|
|
/// What holds for a peer with nothing configured: every general check,
|
|
/// no list of its own, no limit.
|
|
fn default() -> Self {
|
|
Self {
|
|
max_prefixes: None,
|
|
hold_down: DEFAULT_HOLD_DOWN,
|
|
accept_bogons: false,
|
|
accept_default_route: false,
|
|
max_ipv4_length: LONGEST_IPV4,
|
|
max_ipv6_length: LONGEST_IPV6,
|
|
accept_any_first_as: false,
|
|
max_as_path_length: DEFAULT_MAX_AS_PATH_LENGTH,
|
|
prefixes: None,
|
|
origin_asns: None,
|
|
accept_rpki_invalid: false,
|
|
accept_aspa_invalid: false,
|
|
accept_as_sets: false,
|
|
accept_third_party_next_hop: false,
|
|
own_prefixes: Arc::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ImportPolicy {
|
|
/// Takes everything but routes that have been through our own AS. For
|
|
/// a closed test network, where the prefixes and AS numbers are the
|
|
/// private and documentation ones a real policy refuses.
|
|
pub fn permissive() -> Self {
|
|
Self {
|
|
accept_bogons: true,
|
|
accept_default_route: true,
|
|
max_ipv4_length: IpVersion::Ipv4.bits(),
|
|
max_ipv6_length: IpVersion::Ipv6.bits(),
|
|
accept_any_first_as: true,
|
|
max_as_path_length: u16::MAX,
|
|
accept_rpki_invalid: true,
|
|
accept_aspa_invalid: true,
|
|
accept_as_sets: true,
|
|
accept_third_party_next_hop: true,
|
|
..Self::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why a route was not taken.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum Rejection {
|
|
/// The prefix is reserved, private or for documentation.
|
|
BogonPrefix,
|
|
/// A default route, or a prefix too short to be anyone's.
|
|
TooShort,
|
|
/// The prefix is more specific than is routed between networks.
|
|
TooSpecific,
|
|
/// The prefix is not one the peer is allowed to announce.
|
|
PrefixNotAllowed,
|
|
/// The path holds our own AS number: the route has been here before.
|
|
OwnAs,
|
|
/// The path holds an AS number that is reserved, private or for
|
|
/// documentation.
|
|
BogonAs,
|
|
/// The path does not begin with the peer's AS number.
|
|
FirstAs,
|
|
PathTooLong,
|
|
/// The route does not originate from an AS the peer may announce
|
|
/// routes of.
|
|
OriginNotAllowed,
|
|
/// The holder of the addresses has said who may originate routes for
|
|
/// them, and it is not this route's origin (RFC 6811).
|
|
RpkiInvalid,
|
|
/// A network on the path has said who its providers are, and the path
|
|
/// does not go by them: a route leak, or a path that was made up.
|
|
AspaInvalid,
|
|
/// The route is marked as one that may only be passed down to
|
|
/// customers, and it did not come from above: someone has leaked it
|
|
/// (RFC 9234).
|
|
Leak,
|
|
/// The path has an AS_SET in it (RFC 9774).
|
|
AsSet,
|
|
/// 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).
|
|
NextHopUnusable,
|
|
/// The next hop is not the peer.
|
|
NextHopNotPeer,
|
|
/// The prefix is one of ours, or inside one.
|
|
OwnPrefix,
|
|
/// The next hop cannot be reached from this machine as its interfaces
|
|
/// are now, so the kernel could not be given the route.
|
|
NextHopUnreachable,
|
|
/// The operator's policy function for the peer refused the route.
|
|
Policy,
|
|
/// The operator's policy function failed on the route, which is as
|
|
/// good as refusing it.
|
|
PolicyFailed,
|
|
}
|
|
|
|
impl Rejection {
|
|
/// Whether the reason may pass by itself, with the route unchanged:
|
|
/// the RPKI's data changes, and so do the machine's interfaces. A
|
|
/// route refused for such a reason is held, to be taken when it does.
|
|
pub(crate) fn may_pass(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::RpkiInvalid | Self::AspaInvalid | Self::NextHopUnreachable
|
|
)
|
|
}
|
|
|
|
pub const ALL: [Self; 19] = [
|
|
Self::BogonPrefix,
|
|
Self::TooShort,
|
|
Self::TooSpecific,
|
|
Self::PrefixNotAllowed,
|
|
Self::OwnAs,
|
|
Self::BogonAs,
|
|
Self::FirstAs,
|
|
Self::PathTooLong,
|
|
Self::OriginNotAllowed,
|
|
Self::RpkiInvalid,
|
|
Self::AspaInvalid,
|
|
Self::Leak,
|
|
Self::AsSet,
|
|
Self::NextHopUnusable,
|
|
Self::NextHopNotPeer,
|
|
Self::OwnPrefix,
|
|
Self::NextHopUnreachable,
|
|
Self::Policy,
|
|
Self::PolicyFailed,
|
|
];
|
|
|
|
/// Where the reason is in [`Rejection::ALL`].
|
|
pub(crate) fn index(self) -> usize {
|
|
self as usize
|
|
}
|
|
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::BogonPrefix => "bogon prefix",
|
|
Self::TooShort => "prefix too short",
|
|
Self::TooSpecific => "prefix too specific",
|
|
Self::PrefixNotAllowed => "prefix not allowed",
|
|
Self::OwnAs => "own AS in path",
|
|
Self::BogonAs => "bogon AS in path",
|
|
Self::FirstAs => "path does not begin with the peer's AS",
|
|
Self::PathTooLong => "path too long",
|
|
Self::OriginNotAllowed => "origin AS not allowed",
|
|
Self::RpkiInvalid => "RPKI invalid",
|
|
Self::AspaInvalid => "ASPA invalid",
|
|
Self::Leak => "route leak (Only to Customer)",
|
|
Self::AsSet => "AS_SET in path",
|
|
Self::NextHopUnusable => "next hop unusable",
|
|
Self::NextHopNotPeer => "next hop is not the peer",
|
|
Self::OwnPrefix => "prefix is our own",
|
|
Self::NextHopUnreachable => "next hop unreachable",
|
|
Self::Policy => "refused by policy function",
|
|
Self::PolicyFailed => "policy function failed",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What a peer is to us, in the business between networks. It decides
|
|
/// what its routes may look like: a customer's routes have come up to us
|
|
/// through its own customers only, while a provider's may have been
|
|
/// anywhere.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum PeerRole {
|
|
/// We buy transit from the peer.
|
|
Provider,
|
|
/// The peer buys transit from us.
|
|
Customer,
|
|
/// The peer and we exchange our own and our customers' routes.
|
|
Peer,
|
|
/// The peer is an Internet exchange's route server, and we are one of
|
|
/// its clients.
|
|
RouteServer,
|
|
/// We are a route server, and the peer is one of our clients.
|
|
RouteServerClient,
|
|
}
|
|
|
|
impl PeerRole {
|
|
/// The role the peer has to announce in its OPEN: its own (RFC 9234
|
|
/// §4.1).
|
|
pub fn theirs(self) -> BgpRole {
|
|
match self {
|
|
Self::Provider => BgpRole::Provider,
|
|
Self::Customer => BgpRole::Customer,
|
|
Self::Peer => BgpRole::Peer,
|
|
Self::RouteServer => BgpRole::RouteServer,
|
|
Self::RouteServerClient => BgpRole::RouteServerClient,
|
|
}
|
|
}
|
|
|
|
/// The role we announce in ours: what we are to the peer, which is
|
|
/// the other half of the pair (RFC 9234 §4.2).
|
|
pub fn ours(self) -> BgpRole {
|
|
match self {
|
|
Self::Provider => BgpRole::Customer,
|
|
Self::Customer => BgpRole::Provider,
|
|
Self::Peer => BgpRole::Peer,
|
|
Self::RouteServer => BgpRole::RouteServerClient,
|
|
Self::RouteServerClient => BgpRole::RouteServer,
|
|
}
|
|
}
|
|
|
|
/// Whether the peer is above or beside us, and not below: a provider,
|
|
/// a peer, or a route server. A route that came from there may only
|
|
/// go on to customers.
|
|
fn is_above_or_beside(self) -> bool {
|
|
matches!(self, Self::Provider | Self::Peer | Self::RouteServer)
|
|
}
|
|
|
|
/// The Only to Customer attribute to add to a route received from the
|
|
/// peer, whose AS number is `peer_asn`, if the route does not have
|
|
/// one (RFC 9234 §5, ingress, 3).
|
|
pub fn only_to_customer_on_import(self, peer_asn: u32) -> Option<u32> {
|
|
self.is_above_or_beside().then_some(peer_asn)
|
|
}
|
|
|
|
/// The Only to Customer attribute to add to a route sent to the peer
|
|
/// if the route does not have one (RFC 9234 §5, egress, 1): the route
|
|
/// is going down or across, and may not come up again.
|
|
pub fn only_to_customer_on_export(self, local_asn: u32) -> Option<u32> {
|
|
matches!(self, Self::Customer | Self::Peer | Self::RouteServerClient).then_some(local_asn)
|
|
}
|
|
|
|
/// Whether a route with an Only to Customer attribute may be sent to
|
|
/// the peer: not upwards or across (RFC 9234 §5, egress, 2).
|
|
pub fn may_be_sent_only_to_customer_routes(self) -> bool {
|
|
!self.is_above_or_beside()
|
|
}
|
|
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Provider => "provider",
|
|
Self::Customer => "customer",
|
|
Self::Peer => "peer",
|
|
Self::RouteServer => "route server",
|
|
Self::RouteServerClient => "route server client",
|
|
}
|
|
}
|
|
|
|
/// The role as a policy script has it in `peer.role`: one word.
|
|
pub fn in_scripts(self) -> &'static str {
|
|
match self {
|
|
Self::Provider => "provider",
|
|
Self::Customer => "customer",
|
|
Self::Peer => "peer",
|
|
Self::RouteServer => "route_server",
|
|
Self::RouteServerClient => "route_server_client",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whose routes are being judged.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct RouteSource {
|
|
pub peer_asn: u32,
|
|
pub local_asn: u32,
|
|
/// Whether the peer is in our own AS. The checks on the path are about
|
|
/// routes coming in from other networks, and are not made then.
|
|
pub ibgp: bool,
|
|
/// Whether AS numbers are four octets wide on the session. If not, a
|
|
/// path may hold AS_TRANS where a wider number belongs.
|
|
pub four_octet_asn: bool,
|
|
/// What the peer is to us, if that is known.
|
|
pub role: Option<PeerRole>,
|
|
/// The two ends of the session.
|
|
pub peer_address: IpAddr,
|
|
pub local_address: IpAddr,
|
|
/// Whether IPv4 routes may come with IPv6 next hops (RFC 8950).
|
|
pub extended_next_hop: bool,
|
|
}
|
|
|
|
impl ImportPolicy {
|
|
/// Judges what the routes of one UPDATE have in common: their path.
|
|
/// If it passes, returns the AS the routes originate from: the last AS
|
|
/// of the path, or `None` if the path is empty or ends in a set.
|
|
pub fn check_path(
|
|
&self,
|
|
attributes: &[PathAttribute],
|
|
from: &RouteSource,
|
|
) -> Result<Option<u32>, Rejection> {
|
|
let path = attributes.iter().find_map(|attribute| match attribute {
|
|
PathAttribute::AsPath(path) => Some(path),
|
|
_ => None,
|
|
});
|
|
let facts = PathFacts::of(path, from);
|
|
// The origin is the last AS of the path. If the path ends in a
|
|
// set, there is no telling which of its members that is.
|
|
let ends_in_sequence = path
|
|
.and_then(|path| path.segments.last())
|
|
.is_some_and(|segment| segment.ordered);
|
|
let origin = facts.last.filter(|_| ends_in_sequence);
|
|
if from.ibgp {
|
|
return Ok(origin);
|
|
}
|
|
|
|
if facts.has_local_asn {
|
|
return Err(Rejection::OwnAs);
|
|
}
|
|
// RFC 9234 §5, ingress, 1 and 2. A route that is only to go to
|
|
// customers has been down or across once already. From a customer
|
|
// it is coming up again; from a peer it is coming across again,
|
|
// unless it was the peer itself that marked it, on its way to us.
|
|
let only_to_customer = attributes.iter().find_map(|attribute| match attribute {
|
|
PathAttribute::OnlyToCustomer(asn) => Some(*asn),
|
|
_ => None,
|
|
});
|
|
if let (Some(marked_by), Some(role)) = (only_to_customer, from.role) {
|
|
let leak = match role {
|
|
PeerRole::Customer | PeerRole::RouteServerClient => true,
|
|
PeerRole::Peer => marked_by != from.peer_asn,
|
|
PeerRole::Provider | PeerRole::RouteServer => false,
|
|
};
|
|
if leak {
|
|
return Err(Rejection::Leak);
|
|
}
|
|
}
|
|
if !self.accept_any_first_as {
|
|
// The first AS of the first segment, which has to be a
|
|
// sequence: a set has no first.
|
|
let first = path
|
|
.and_then(|path| path.segments.first())
|
|
.filter(|segment| segment.ordered)
|
|
.and_then(|segment| segment.path.first());
|
|
if first != Some(&from.peer_asn) {
|
|
return Err(Rejection::FirstAs);
|
|
}
|
|
}
|
|
if facts.length > usize::from(self.max_as_path_length) {
|
|
return Err(Rejection::PathTooLong);
|
|
}
|
|
if !self.accept_as_sets && facts.has_set {
|
|
return Err(Rejection::AsSet);
|
|
}
|
|
if !self.accept_bogons && facts.has_bogon_asn {
|
|
return Err(Rejection::BogonAs);
|
|
}
|
|
if let Some(allowed) = &self.origin_asns
|
|
&& !origin.is_some_and(|origin| allowed.contains(&origin))
|
|
{
|
|
return Err(Rejection::OriginNotAllowed);
|
|
}
|
|
Ok(origin)
|
|
}
|
|
|
|
/// Judges one prefix.
|
|
pub fn check_prefix(&self, prefix: &IpPrefix) -> Result<(), Rejection> {
|
|
let (shortest, longest) = match prefix.address_family() {
|
|
IpVersion::Ipv4 => (SHORTEST_IPV4, self.max_ipv4_length),
|
|
IpVersion::Ipv6 => (SHORTEST_IPV6, self.max_ipv6_length),
|
|
};
|
|
if prefix.length() > longest {
|
|
return Err(Rejection::TooSpecific);
|
|
}
|
|
if prefix.length() == 0 {
|
|
if !self.accept_default_route {
|
|
return Err(Rejection::TooShort);
|
|
}
|
|
} else if !self.accept_bogons {
|
|
if prefix.length() < shortest {
|
|
return Err(Rejection::TooShort);
|
|
}
|
|
if is_bogon_prefix(prefix) {
|
|
return Err(Rejection::BogonPrefix);
|
|
}
|
|
}
|
|
match &self.prefixes {
|
|
Some(allowed) if !allowed.allows(prefix) => Err(Rejection::PrefixNotAllowed),
|
|
// Said to be this peer's to announce, ours or not.
|
|
Some(_) => Ok(()),
|
|
None if self.own_prefixes.allows(prefix) => Err(Rejection::OwnPrefix),
|
|
None => 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
|
|
/// (RFC 4271 §5.1.3), and of a kind the session agreed on. From
|
|
/// another network it also has to be the peer itself, unless the
|
|
/// policy says otherwise: a next hop is where our traffic will go,
|
|
/// and a peer is not to send it to a third party, whether another
|
|
/// member of an exchange or a machine on some other network of ours.
|
|
pub fn check_next_hop(
|
|
&self,
|
|
next_hop: &NlriNextHop,
|
|
routes: IpVersion,
|
|
from: &RouteSource,
|
|
) -> Result<(), Rejection> {
|
|
let (first, link_local): (IpAddr, Option<Ipv6Addr>) = match *next_hop {
|
|
NlriNextHop::Ipv4(address) => (address.into(), None),
|
|
NlriNextHop::Ipv6(address) => (address.into(), None),
|
|
NlriNextHop::Ipv6WithLl { global, link_local } => (global.into(), Some(link_local)),
|
|
};
|
|
match (routes, first) {
|
|
(IpVersion::Ipv4, IpAddr::V4(_)) | (IpVersion::Ipv6, IpAddr::V6(_)) => {}
|
|
(IpVersion::Ipv4, IpAddr::V6(_)) if from.extended_next_hop => {}
|
|
_ => return Err(Rejection::NextHopUnusable),
|
|
}
|
|
// With a link-local address after it, the first may be nothing at
|
|
// all, or the same once more, from a peer that has no global one.
|
|
let filler = link_local
|
|
.is_some_and(|link_local| first.is_unspecified() || first == IpAddr::V6(link_local));
|
|
let named = (!filler)
|
|
.then_some(first)
|
|
.into_iter()
|
|
.chain(link_local.map(IpAddr::V6));
|
|
let mut is_peer = false;
|
|
for address in named {
|
|
let usable = !address.is_unspecified()
|
|
&& !address.is_loopback()
|
|
&& !address.is_multicast()
|
|
&& address != IpAddr::V4(Ipv4Addr::BROADCAST)
|
|
&& address != from.local_address;
|
|
if !usable {
|
|
return Err(Rejection::NextHopUnusable);
|
|
}
|
|
is_peer |= address == from.peer_address;
|
|
}
|
|
if link_local.is_some_and(|link_local| !link_local.is_unicast_link_local()) {
|
|
return Err(Rejection::NextHopUnusable);
|
|
}
|
|
// Whether the next hop is the peer can be told only if both are
|
|
// addresses of the same kind: IPv6 routes over an IPv4 session
|
|
// have a next hop the session knows nothing of.
|
|
let comparable = first.is_ipv4() == from.peer_address.is_ipv4();
|
|
let third_party_allowed = from.ibgp
|
|
|| self.accept_third_party_next_hop
|
|
|| from.role == Some(PeerRole::RouteServer);
|
|
if comparable && !is_peer && !third_party_allowed {
|
|
return Err(Rejection::NextHopNotPeer);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// What the checks need to know of a path's AS numbers, found in one pass
|
|
/// over them and without copying the path: this is done for every UPDATE.
|
|
struct PathFacts {
|
|
/// How many AS numbers the path holds, sets and all.
|
|
length: usize,
|
|
has_local_asn: bool,
|
|
has_bogon_asn: bool,
|
|
has_set: bool,
|
|
last: Option<u32>,
|
|
}
|
|
|
|
impl PathFacts {
|
|
/// Reads the path. It is the whole path: what a session with
|
|
/// two-octet AS numbers brought in AS4_PATH the decoder has put in its
|
|
/// place (RFC 6793 §4.2.3).
|
|
fn of(path: Option<&AsPathAttribute>, from: &RouteSource) -> Self {
|
|
let mut facts = Self {
|
|
length: 0,
|
|
has_local_asn: false,
|
|
has_bogon_asn: false,
|
|
has_set: path.is_some_and(|path| path.segments.iter().any(|segment| !segment.ordered)),
|
|
last: None,
|
|
};
|
|
for asn in asns(path) {
|
|
facts.length += 1;
|
|
facts.has_local_asn |= asn == from.local_asn;
|
|
facts.has_bogon_asn |= is_bogon_asn(asn, from.four_octet_asn);
|
|
facts.last = Some(asn);
|
|
}
|
|
facts
|
|
}
|
|
}
|
|
|
|
/// Puts the AS numbers of the path that `attributes` describe in `out`,
|
|
/// in place of what was there, as [`PathFacts`] reads it: the neighbor's
|
|
/// first, the origin's last, sets and all. Returns whether the path has a
|
|
/// set in it.
|
|
pub(crate) fn effective_path(attributes: &[PathAttribute], out: &mut Vec<u32>) -> bool {
|
|
let path = attributes.iter().find_map(|attribute| match attribute {
|
|
PathAttribute::AsPath(path) => Some(path),
|
|
_ => None,
|
|
});
|
|
out.clear();
|
|
out.extend(asns(path));
|
|
path.into_iter()
|
|
.flat_map(|path| &path.segments)
|
|
.any(|segment| !segment.ordered)
|
|
}
|
|
|
|
/// The AS numbers of a path in order, sets and all.
|
|
pub(crate) fn asns(path: Option<&AsPathAttribute>) -> impl Iterator<Item = u32> + '_ {
|
|
path.into_iter()
|
|
.flat_map(|path| &path.segments)
|
|
.flat_map(|segment| segment.path.iter().copied())
|
|
}
|
|
|
|
/// AS numbers that are not anyone's on the Internet: reserved, private use
|
|
/// (RFC 6996), for documentation (RFC 5398), or AS_TRANS where a real
|
|
/// number should be.
|
|
fn is_bogon_asn(asn: u32, four_octet_asn: bool) -> bool {
|
|
// On a two-octet session AS_TRANS stands for a number that did not
|
|
// fit, and is in the path by rights.
|
|
if asn == u32::from(AS_TRANS) {
|
|
return four_octet_asn;
|
|
}
|
|
matches!(
|
|
asn,
|
|
0 | 64496..=131071 | 4_200_000_000..=u32::MAX
|
|
)
|
|
}
|
|
|
|
/// Address space that is not routed on the Internet. A prefix inside any of
|
|
/// these is a bogon.
|
|
static BOGONS: LazyLock<Vec<IpPrefix>> = LazyLock::new(|| {
|
|
[
|
|
// IPv4 (RFC 6890 and its successors).
|
|
"0.0.0.0/8",
|
|
"10.0.0.0/8",
|
|
"100.64.0.0/10",
|
|
"127.0.0.0/8",
|
|
"169.254.0.0/16",
|
|
"172.16.0.0/12",
|
|
"192.0.0.0/24",
|
|
"192.0.2.0/24",
|
|
"192.168.0.0/16",
|
|
"198.18.0.0/15",
|
|
"198.51.100.0/24",
|
|
"203.0.113.0/24",
|
|
"224.0.0.0/4",
|
|
"240.0.0.0/4",
|
|
// IPv6 inside the global unicast space.
|
|
"2001::/32",
|
|
"2001:2::/48",
|
|
"2001:10::/28",
|
|
"2001:20::/28",
|
|
"2001:db8::/32",
|
|
"2002::/16",
|
|
"3ffe::/16",
|
|
"3fff::/20",
|
|
]
|
|
.into_iter()
|
|
.map(|prefix| IpPrefix::try_from(prefix).expect("the bogon list is written correctly"))
|
|
.collect()
|
|
});
|
|
|
|
/// The only IPv6 space that is routed: global unicast.
|
|
static GLOBAL_UNICAST_V6: LazyLock<IpPrefix> =
|
|
LazyLock::new(|| IpPrefix::try_from("2000::/3").expect("a prefix"));
|
|
|
|
fn is_bogon_prefix(prefix: &IpPrefix) -> bool {
|
|
// Nearly every prefix is nowhere near a bogon, and this is asked for
|
|
// every route: the address's first octets say whether the list needs
|
|
// looking at. A prefix too short to have them is looked up in full.
|
|
let may_be_listed = match (prefix.address_family(), prefix.bytes()) {
|
|
(IpVersion::Ipv4, [first, ..]) => {
|
|
matches!(
|
|
first,
|
|
0 | 10 | 100 | 127 | 169 | 172 | 192 | 198 | 203 | 224..=255
|
|
)
|
|
}
|
|
(IpVersion::Ipv6, [first, second, ..]) => {
|
|
if first & 0xe0 != 0x20 {
|
|
// Outside 2000::/3.
|
|
return true;
|
|
}
|
|
matches!((first, second), (0x20, 0x01 | 0x02) | (0x3f, 0xfe | 0xff))
|
|
}
|
|
_ => true,
|
|
};
|
|
may_be_listed && is_listed_bogon(prefix)
|
|
}
|
|
|
|
/// Whether `prefix` is in space that is not routed, by looking through the
|
|
/// whole list.
|
|
fn is_listed_bogon(prefix: &IpPrefix) -> bool {
|
|
if prefix.address_family() == IpVersion::Ipv6 && !GLOBAL_UNICAST_V6.contains(prefix) {
|
|
return true;
|
|
}
|
|
BOGONS.iter().any(|bogon| bogon.contains(prefix))
|
|
}
|
|
|
|
/// A list of prefixes a peer may announce. Each entry allows the prefix
|
|
/// itself and, if it says so, the more specific prefixes inside it up to a
|
|
/// length.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct PrefixSet {
|
|
/// For each prefix listed, the longest prefix inside it that is
|
|
/// allowed.
|
|
entries: HashMap<IpPrefix, u8>,
|
|
/// The lengths of the prefixes listed, by IP version: the only lengths
|
|
/// at which a covering entry can be.
|
|
lengths: [Vec<u8>; 2],
|
|
}
|
|
|
|
impl PrefixSet {
|
|
/// Allows `prefix` and every prefix inside it of at most `max_length`
|
|
/// bits.
|
|
pub fn allow(&mut self, prefix: IpPrefix, max_length: u8) {
|
|
let longest = self.entries.entry(prefix).or_insert(max_length);
|
|
*longest = (*longest).max(max_length);
|
|
let lengths = &mut self.lengths[Self::version_index(&prefix)];
|
|
if let Err(at) = lengths.binary_search(&prefix.length()) {
|
|
lengths.insert(at, prefix.length());
|
|
}
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.entries.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.entries.is_empty()
|
|
}
|
|
|
|
/// 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,
|
|
// so there is one place to look for each length there is.
|
|
self.lengths[Self::version_index(prefix)]
|
|
.iter()
|
|
.take_while(|length| **length <= prefix.length())
|
|
.any(|length| {
|
|
self.entries
|
|
.get(&prefix.shortened(*length))
|
|
.is_some_and(|longest| prefix.length() <= *longest)
|
|
})
|
|
}
|
|
|
|
fn version_index(prefix: &IpPrefix) -> usize {
|
|
match prefix.address_family() {
|
|
IpVersion::Ipv4 => 0,
|
|
IpVersion::Ipv6 => 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why a peer is being kept down.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum HeldDown {
|
|
/// It went over its limit of routes.
|
|
OverLimit,
|
|
/// Its sessions keep coming up and failing.
|
|
Flapping,
|
|
}
|
|
|
|
impl HeldDown {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::OverLimit => "held down after going over its limit of routes",
|
|
Self::Flapping => "held down because its sessions keep failing",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A session that fails sooner than this after coming up counts as a
|
|
/// flap.
|
|
const FLAP_WITHIN: Duration = Duration::from_secs(60);
|
|
/// This many flaps in a row are let pass: a router being restarted, a
|
|
/// cable put back.
|
|
const FLAPS_ALLOWED: u32 = 2;
|
|
/// How long a peer is kept down after the first flap too many; twice as
|
|
/// long after each one more, up to `FLAP_HOLD_MAX`.
|
|
const FLAP_HOLD: Duration = Duration::from_secs(1);
|
|
const FLAP_HOLD_MAX: Duration = Duration::from_secs(300);
|
|
|
|
/// Peers that are being kept down, and until when: those that went over
|
|
/// their limit, and those whose sessions keep failing. Shared by the
|
|
/// sessions, which put peers here, and by the dialer and the listeners,
|
|
/// which leave them alone meanwhile.
|
|
///
|
|
/// Every session that comes up and goes costs a look at every route in
|
|
/// the table, and tells every other peer of the changes. A peer that
|
|
/// does so over and over, by fault or by design, is slowed down.
|
|
#[derive(Debug, Default)]
|
|
pub struct HoldDowns {
|
|
held: Mutex<HashMap<PeerAddress, (Instant, HeldDown)>>,
|
|
/// How many times in a row each peer's session has failed soon after
|
|
/// coming up.
|
|
flaps: Mutex<HashMap<PeerAddress, u32>>,
|
|
}
|
|
|
|
impl HoldDowns {
|
|
/// Keeps the peer at `address` down for `duration` from now.
|
|
pub fn hold(&self, address: PeerAddress, duration: Duration, why: HeldDown) {
|
|
let until = Instant::now() + duration;
|
|
let mut held = self.held.locked();
|
|
// Not for less long than it already is.
|
|
if held
|
|
.get(&address)
|
|
.is_none_or(|(already, _)| *already < until)
|
|
{
|
|
held.insert(address, (until, why));
|
|
}
|
|
}
|
|
|
|
/// How much longer the peer at `address` is kept down, if it is, and
|
|
/// why.
|
|
pub fn remaining(&self, address: PeerAddress) -> Option<(Duration, HeldDown)> {
|
|
let mut held = self.held.locked();
|
|
let (until, why) = *held.get(&address)?;
|
|
let remaining = until.saturating_duration_since(Instant::now());
|
|
if remaining.is_zero() {
|
|
held.remove(&address);
|
|
return None;
|
|
}
|
|
Some((remaining, why))
|
|
}
|
|
|
|
/// A session with the peer at `address` that had come up has ended,
|
|
/// `lasted` after it did; `failed` if not at our own wish. Returns for
|
|
/// how long the peer is kept down for it, if it is.
|
|
pub fn session_ended(
|
|
&self,
|
|
address: PeerAddress,
|
|
lasted: Duration,
|
|
failed: bool,
|
|
) -> Option<Duration> {
|
|
let mut flaps = self.flaps.locked();
|
|
if !failed || lasted >= FLAP_WITHIN {
|
|
flaps.remove(&address);
|
|
return None;
|
|
}
|
|
let count = flaps.entry(address).or_insert(0);
|
|
*count += 1;
|
|
let over = count.checked_sub(FLAPS_ALLOWED + 1)?;
|
|
let hold = FLAP_HOLD
|
|
.checked_mul(1u32.checked_shl(over).unwrap_or(u32::MAX))
|
|
.map_or(FLAP_HOLD_MAX, |hold| hold.min(FLAP_HOLD_MAX));
|
|
drop(flaps);
|
|
self.hold(address, hold, HeldDown::Flapping);
|
|
Some(hold)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use bgp_packet::message::AsPathSegment;
|
|
use proptest::prelude::*;
|
|
|
|
use super::*;
|
|
|
|
fn prefix(s: &str) -> IpPrefix {
|
|
IpPrefix::try_from(s).unwrap()
|
|
}
|
|
|
|
const EXTERNAL: RouteSource = RouteSource {
|
|
peer_asn: 3333,
|
|
local_asn: 1299,
|
|
ibgp: false,
|
|
four_octet_asn: true,
|
|
role: None,
|
|
peer_address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)),
|
|
local_address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
|
|
extended_next_hop: false,
|
|
};
|
|
|
|
fn path(asns: &[u32]) -> Vec<PathAttribute> {
|
|
vec![AsPathAttribute::from_asns(asns.to_vec())]
|
|
}
|
|
|
|
#[test]
|
|
fn prefixes_that_are_not_routed_on_the_internet_are_refused() {
|
|
let policy = ImportPolicy::default();
|
|
for bogon in [
|
|
"10.1.0.0/16",
|
|
"192.168.1.0/24",
|
|
"100.64.0.0/10",
|
|
"192.0.2.0/24",
|
|
"224.0.0.0/8",
|
|
"2001:db8::/32",
|
|
"2001:db8:1::/48",
|
|
"fc00::/16",
|
|
"fe80::/16",
|
|
"2002::/16",
|
|
] {
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix(bogon)),
|
|
Err(Rejection::BogonPrefix),
|
|
"{bogon}"
|
|
);
|
|
}
|
|
for routed in [
|
|
"193.0.0.0/21",
|
|
"8.8.8.0/24",
|
|
"2001:67c:2e8::/48",
|
|
"2a00::/16",
|
|
] {
|
|
assert_eq!(policy.check_prefix(&prefix(routed)), Ok(()), "{routed}");
|
|
}
|
|
// The space around a bogon is not one.
|
|
assert_eq!(policy.check_prefix(&prefix("192.0.3.0/24")), Ok(()));
|
|
assert_eq!(policy.check_prefix(&prefix("11.0.0.0/8")), Ok(()));
|
|
}
|
|
|
|
#[test]
|
|
fn prefixes_too_long_or_too_short_are_refused() {
|
|
let policy = ImportPolicy::default();
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix("193.0.0.0/25")),
|
|
Err(Rejection::TooSpecific)
|
|
);
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix("2001:67c:2e8::/49")),
|
|
Err(Rejection::TooSpecific)
|
|
);
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix("0.0.0.0/0")),
|
|
Err(Rejection::TooShort)
|
|
);
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix("::/0")),
|
|
Err(Rejection::TooShort)
|
|
);
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix("128.0.0.0/1")),
|
|
Err(Rejection::TooShort)
|
|
);
|
|
|
|
// Each can be allowed for a peer it is expected from.
|
|
let upstream = ImportPolicy {
|
|
accept_default_route: true,
|
|
max_ipv4_length: 32,
|
|
..ImportPolicy::default()
|
|
};
|
|
assert_eq!(upstream.check_prefix(&prefix("0.0.0.0/0")), Ok(()));
|
|
assert_eq!(upstream.check_prefix(&prefix("193.0.0.1/32")), Ok(()));
|
|
// And nothing else is let through with them.
|
|
assert_eq!(
|
|
upstream.check_prefix(&prefix("10.0.0.1/32")),
|
|
Err(Rejection::BogonPrefix)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_peer_with_a_prefix_list_may_announce_only_what_is_on_it() {
|
|
let mut allowed = PrefixSet::default();
|
|
allowed.allow(prefix("193.0.0.0/21"), 21);
|
|
allowed.allow(prefix("2001:67c::/32"), 48);
|
|
let policy = ImportPolicy {
|
|
prefixes: Some(allowed),
|
|
..ImportPolicy::default()
|
|
};
|
|
assert_eq!(policy.check_prefix(&prefix("193.0.0.0/21")), Ok(()));
|
|
// More specific than the entry allows.
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix("193.0.0.0/24")),
|
|
Err(Rejection::PrefixNotAllowed)
|
|
);
|
|
// Less specific than the entry.
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix("193.0.0.0/20")),
|
|
Err(Rejection::PrefixNotAllowed)
|
|
);
|
|
assert_eq!(policy.check_prefix(&prefix("2001:67c:2e8::/48")), Ok(()));
|
|
assert_eq!(policy.check_prefix(&prefix("2001:67c::/32")), Ok(()));
|
|
// Someone else's.
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix("8.8.8.0/24")),
|
|
Err(Rejection::PrefixNotAllowed)
|
|
);
|
|
// The general checks still hold for what is on the list.
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix("2001:67c:2e8:1::/64")),
|
|
Err(Rejection::TooSpecific)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_route_that_has_been_through_our_as_is_never_taken() {
|
|
// Not even by a policy that takes everything else.
|
|
for policy in [ImportPolicy::default(), ImportPolicy::permissive()] {
|
|
assert_eq!(
|
|
policy.check_path(&path(&[3333, 1299, 2914]), &EXTERNAL),
|
|
Err(Rejection::OwnAs)
|
|
);
|
|
}
|
|
// From a peer in our own AS the path says nothing of the kind.
|
|
let internal = RouteSource {
|
|
peer_asn: 1299,
|
|
ibgp: true,
|
|
..EXTERNAL
|
|
};
|
|
assert_eq!(
|
|
ImportPolicy::default()
|
|
.check_path(&path(&[]), &internal)
|
|
.map(drop),
|
|
Ok(())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_path_must_begin_with_the_peers_as_unless_it_is_a_route_server() {
|
|
let policy = ImportPolicy::default();
|
|
assert_eq!(
|
|
policy.check_path(&path(&[3333, 2914]), &EXTERNAL).map(drop),
|
|
Ok(())
|
|
);
|
|
assert_eq!(
|
|
policy.check_path(&path(&[2914, 3333]), &EXTERNAL),
|
|
Err(Rejection::FirstAs)
|
|
);
|
|
assert_eq!(
|
|
policy.check_path(&path(&[]), &EXTERNAL),
|
|
Err(Rejection::FirstAs)
|
|
);
|
|
// A set has no first AS.
|
|
let set = vec![PathAttribute::AsPath(AsPathAttribute {
|
|
segments: vec![AsPathSegment {
|
|
ordered: false,
|
|
path: vec![3333],
|
|
}],
|
|
})];
|
|
assert_eq!(policy.check_path(&set, &EXTERNAL), Err(Rejection::FirstAs));
|
|
|
|
let route_server = ImportPolicy {
|
|
accept_any_first_as: true,
|
|
..ImportPolicy::default()
|
|
};
|
|
assert_eq!(
|
|
route_server
|
|
.check_path(&path(&[2914, 174]), &EXTERNAL)
|
|
.map(drop),
|
|
Ok(())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reserved_and_private_as_numbers_in_the_path_are_refused() {
|
|
let policy = ImportPolicy::default();
|
|
for bogon in [
|
|
0,
|
|
23456,
|
|
64496,
|
|
64512,
|
|
65000,
|
|
65535,
|
|
65536,
|
|
131071,
|
|
4_200_000_000,
|
|
u32::MAX,
|
|
] {
|
|
assert_eq!(
|
|
policy.check_path(&path(&[3333, bogon]), &EXTERNAL),
|
|
Err(Rejection::BogonAs),
|
|
"AS{bogon}"
|
|
);
|
|
}
|
|
for real in [174, 64495, 131072, 210036, 4_199_999_999] {
|
|
assert_eq!(
|
|
policy.check_path(&path(&[3333, real]), &EXTERNAL).map(drop),
|
|
Ok(()),
|
|
"AS{real}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// RFC 9234 §5, the ingress procedure.
|
|
#[test]
|
|
fn a_route_only_for_customers_is_a_leak_from_below_or_from_a_second_peer() {
|
|
let policy = ImportPolicy::default();
|
|
let marked_by = |asn: u32| {
|
|
let mut attributes = path(&[3333, 2121]);
|
|
attributes.push(PathAttribute::OnlyToCustomer(asn));
|
|
attributes
|
|
};
|
|
let from = |role| RouteSource {
|
|
role: Some(role),
|
|
..EXTERNAL
|
|
};
|
|
// From below it is always a leak.
|
|
for role in [PeerRole::Customer, PeerRole::RouteServerClient] {
|
|
for asn in [3333, 2121] {
|
|
assert_eq!(
|
|
policy.check_path(&marked_by(asn), &from(role)),
|
|
Err(Rejection::Leak),
|
|
"{role:?}"
|
|
);
|
|
}
|
|
}
|
|
// From a peer, unless the peer marked it itself.
|
|
assert_eq!(
|
|
policy.check_path(&marked_by(2121), &from(PeerRole::Peer)),
|
|
Err(Rejection::Leak)
|
|
);
|
|
assert_eq!(
|
|
policy.check_path(&marked_by(3333), &from(PeerRole::Peer)),
|
|
Ok(Some(2121))
|
|
);
|
|
// From above it is where it should be.
|
|
for role in [PeerRole::Provider, PeerRole::RouteServer] {
|
|
assert_eq!(
|
|
policy.check_path(&marked_by(2121), &from(role)),
|
|
Ok(Some(2121)),
|
|
"{role:?}"
|
|
);
|
|
}
|
|
// Without the mark nothing is known, whoever it is from; and
|
|
// without knowing what the peer is, the mark says nothing.
|
|
for role in [PeerRole::Customer, PeerRole::Peer, PeerRole::Provider] {
|
|
assert_eq!(
|
|
policy.check_path(&path(&[3333, 2121]), &from(role)),
|
|
Ok(Some(2121))
|
|
);
|
|
}
|
|
assert_eq!(
|
|
policy.check_path(&marked_by(2121), &EXTERNAL),
|
|
Ok(Some(2121))
|
|
);
|
|
// Not even a policy that takes everything else takes a leak.
|
|
assert_eq!(
|
|
ImportPolicy::permissive().check_path(&marked_by(2121), &from(PeerRole::Customer)),
|
|
Err(Rejection::Leak)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn roles_pair_up_and_decide_where_the_mark_is_put() {
|
|
use PeerRole::*;
|
|
for role in [Provider, Customer, Peer, RouteServer, RouteServerClient] {
|
|
// What we are to the peer is what the peer is to us, seen
|
|
// from the other side.
|
|
let mirrored = [Provider, Customer, Peer, RouteServer, RouteServerClient]
|
|
.into_iter()
|
|
.find(|other| other.theirs() == role.ours())
|
|
.unwrap();
|
|
assert_eq!(mirrored.ours(), role.theirs(), "{role:?}");
|
|
}
|
|
assert_eq!(Provider.ours(), BgpRole::Customer);
|
|
assert_eq!(RouteServer.ours(), BgpRole::RouteServerClient);
|
|
assert_eq!(Peer.ours(), BgpRole::Peer);
|
|
|
|
// Marked on the way in from above or beside; on the way out
|
|
// downwards or across.
|
|
assert_eq!(Provider.only_to_customer_on_import(3333), Some(3333));
|
|
assert_eq!(Peer.only_to_customer_on_import(3333), Some(3333));
|
|
assert_eq!(RouteServer.only_to_customer_on_import(3333), Some(3333));
|
|
assert_eq!(Customer.only_to_customer_on_import(3333), None);
|
|
assert_eq!(RouteServerClient.only_to_customer_on_import(3333), None);
|
|
assert_eq!(Customer.only_to_customer_on_export(1299), Some(1299));
|
|
assert_eq!(Peer.only_to_customer_on_export(1299), Some(1299));
|
|
assert_eq!(
|
|
RouteServerClient.only_to_customer_on_export(1299),
|
|
Some(1299)
|
|
);
|
|
assert_eq!(Provider.only_to_customer_on_export(1299), None);
|
|
assert_eq!(RouteServer.only_to_customer_on_export(1299), None);
|
|
assert!(Customer.may_be_sent_only_to_customer_routes());
|
|
assert!(!Peer.may_be_sent_only_to_customer_routes());
|
|
}
|
|
|
|
#[test]
|
|
fn the_path_is_read_with_its_sets() {
|
|
let mut out = vec![99];
|
|
assert!(!effective_path(&path(&[3333, 1299]), &mut out));
|
|
assert_eq!(out, [3333, 1299]);
|
|
|
|
let sequence = |path: Vec<u32>| AsPathSegment {
|
|
ordered: true,
|
|
path,
|
|
};
|
|
let with_set = vec![PathAttribute::AsPath(AsPathAttribute {
|
|
segments: vec![
|
|
sequence(vec![3333]),
|
|
AsPathSegment {
|
|
ordered: false,
|
|
path: vec![64500, 64501],
|
|
},
|
|
],
|
|
})];
|
|
assert!(effective_path(&with_set, &mut out));
|
|
assert_eq!(out, [3333, 64500, 64501]);
|
|
|
|
assert!(!effective_path(&[], &mut out));
|
|
assert!(out.is_empty());
|
|
}
|
|
|
|
/// The counts of refusals are kept by a reason's place in the list.
|
|
#[test]
|
|
fn every_reason_is_at_its_own_place_in_the_list() {
|
|
for (at, rejection) in Rejection::ALL.iter().enumerate() {
|
|
assert_eq!(rejection.index(), at, "{rejection:?}");
|
|
}
|
|
let names: HashSet<_> = Rejection::ALL.iter().map(|r| r.as_str()).collect();
|
|
assert_eq!(names.len(), Rejection::ALL.len());
|
|
}
|
|
|
|
/// RFC 9774: nobody can say where a route with a set in its path
|
|
/// comes from.
|
|
#[test]
|
|
fn a_path_with_a_set_is_refused() {
|
|
let with_set = vec![PathAttribute::AsPath(AsPathAttribute {
|
|
segments: vec![
|
|
AsPathSegment {
|
|
ordered: true,
|
|
path: vec![3333, 6939],
|
|
},
|
|
AsPathSegment {
|
|
ordered: false,
|
|
path: vec![2121, 2122],
|
|
},
|
|
],
|
|
})];
|
|
assert_eq!(
|
|
ImportPolicy::default().check_path(&with_set, &EXTERNAL),
|
|
Err(Rejection::AsSet)
|
|
);
|
|
let lenient = ImportPolicy {
|
|
accept_as_sets: true,
|
|
..ImportPolicy::default()
|
|
};
|
|
assert_eq!(lenient.check_path(&with_set, &EXTERNAL), Ok(None));
|
|
}
|
|
|
|
/// What we originate is ours: a more specific route for part of it
|
|
/// from another network would draw our own traffic away.
|
|
#[test]
|
|
fn our_own_prefixes_are_not_taken_from_others() {
|
|
let mut own = PrefixSet::default();
|
|
own.allow(prefix("193.0.0.0/21"), 32);
|
|
let policy = ImportPolicy {
|
|
own_prefixes: Arc::new(own),
|
|
..ImportPolicy::default()
|
|
};
|
|
for ours in ["193.0.0.0/21", "193.0.4.0/22", "193.0.7.0/24"] {
|
|
assert_eq!(
|
|
policy.check_prefix(&prefix(ours)),
|
|
Err(Rejection::OwnPrefix),
|
|
"{ours}"
|
|
);
|
|
}
|
|
// What covers it, or is beside it, is someone else's.
|
|
for theirs in ["193.0.0.0/20", "193.0.8.0/24"] {
|
|
assert_eq!(policy.check_prefix(&prefix(theirs)), Ok(()), "{theirs}");
|
|
}
|
|
// A customer that has a part of it, and is listed as having it.
|
|
let mut listed = PrefixSet::default();
|
|
listed.allow(prefix("193.0.4.0/24"), 24);
|
|
let customer = ImportPolicy {
|
|
prefixes: Some(listed),
|
|
..policy
|
|
};
|
|
assert_eq!(customer.check_prefix(&prefix("193.0.4.0/24")), Ok(()));
|
|
assert_eq!(
|
|
customer.check_prefix(&prefix("193.0.5.0/24")),
|
|
Err(Rejection::PrefixNotAllowed)
|
|
);
|
|
}
|
|
|
|
/// RFC 4271 §5.1.3 and more: the next hop is where our traffic goes.
|
|
#[test]
|
|
fn the_next_hop_has_to_be_the_peer() {
|
|
let policy = ImportPolicy::default();
|
|
let v4 = |s: &str| NlriNextHop::Ipv4(s.parse().unwrap());
|
|
let check = |policy: &ImportPolicy, next_hop: &NlriNextHop, from: &RouteSource| {
|
|
policy.check_next_hop(next_hop, IpVersion::Ipv4, from)
|
|
};
|
|
assert_eq!(check(&policy, &v4("192.0.2.2"), &EXTERNAL), Ok(()));
|
|
// Another machine on the same network, or anywhere.
|
|
assert_eq!(
|
|
check(&policy, &v4("192.0.2.3"), &EXTERNAL),
|
|
Err(Rejection::NextHopNotPeer)
|
|
);
|
|
// Nowhere at all, whatever the policy.
|
|
let lenient = ImportPolicy::permissive();
|
|
for nowhere in [
|
|
"0.0.0.0",
|
|
"127.0.0.1",
|
|
"224.0.0.5",
|
|
"255.255.255.255",
|
|
"192.0.2.1",
|
|
] {
|
|
for policy in [&policy, &lenient] {
|
|
assert_eq!(
|
|
check(policy, &v4(nowhere), &EXTERNAL),
|
|
Err(Rejection::NextHopUnusable),
|
|
"{nowhere}"
|
|
);
|
|
}
|
|
}
|
|
// Where it may be someone else: said so, a route server, our own AS.
|
|
assert_eq!(check(&lenient, &v4("192.0.2.3"), &EXTERNAL), Ok(()));
|
|
for from in [
|
|
RouteSource {
|
|
role: Some(PeerRole::RouteServer),
|
|
..EXTERNAL
|
|
},
|
|
RouteSource {
|
|
ibgp: true,
|
|
..EXTERNAL
|
|
},
|
|
] {
|
|
assert_eq!(check(&policy, &v4("192.0.2.3"), &from), Ok(()));
|
|
}
|
|
// A provider is not a route server.
|
|
let provider = RouteSource {
|
|
role: Some(PeerRole::Provider),
|
|
..EXTERNAL
|
|
};
|
|
assert_eq!(
|
|
check(&policy, &v4("192.0.2.3"), &provider),
|
|
Err(Rejection::NextHopNotPeer)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_ipv6_next_hop_is_judged_in_each_of_its_forms() {
|
|
let policy = ImportPolicy::default();
|
|
let peer: Ipv6Addr = "2001:db8::2".parse().unwrap();
|
|
let peer_ll: Ipv6Addr = "fe80::2".parse().unwrap();
|
|
let over_v6 = RouteSource {
|
|
peer_address: peer.into(),
|
|
local_address: "2001:db8::1".parse().unwrap(),
|
|
..EXTERNAL
|
|
};
|
|
let v6 = |policy: &ImportPolicy, next_hop: NlriNextHop, from: &RouteSource| {
|
|
policy.check_next_hop(&next_hop, IpVersion::Ipv6, from)
|
|
};
|
|
assert_eq!(v6(&policy, NlriNextHop::Ipv6(peer), &over_v6), Ok(()));
|
|
let both =
|
|
|global: Ipv6Addr, link_local: Ipv6Addr| NlriNextHop::Ipv6WithLl { global, link_local };
|
|
assert_eq!(v6(&policy, both(peer, peer_ll), &over_v6), Ok(()));
|
|
assert_eq!(
|
|
v6(
|
|
&policy,
|
|
both("2001:db8::3".parse().unwrap(), peer_ll),
|
|
&over_v6
|
|
),
|
|
Err(Rejection::NextHopNotPeer)
|
|
);
|
|
// What goes with a global address has to be a link-local one.
|
|
assert_eq!(
|
|
v6(
|
|
&policy,
|
|
both(peer, "2001:db8::9".parse().unwrap()),
|
|
&over_v6
|
|
),
|
|
Err(Rejection::NextHopUnusable)
|
|
);
|
|
assert_eq!(
|
|
v6(&policy, both(peer, "::1".parse().unwrap()), &over_v6),
|
|
Err(Rejection::NextHopUnusable)
|
|
);
|
|
|
|
// A session between link-local addresses: the peer's own, alone,
|
|
// twice, or after nothing.
|
|
let link_local = RouteSource {
|
|
peer_address: peer_ll.into(),
|
|
local_address: "fe80::1".parse().unwrap(),
|
|
..EXTERNAL
|
|
};
|
|
for next_hop in [
|
|
NlriNextHop::Ipv6(peer_ll),
|
|
both(peer_ll, peer_ll),
|
|
both(Ipv6Addr::UNSPECIFIED, peer_ll),
|
|
] {
|
|
assert_eq!(v6(&policy, next_hop, &link_local), Ok(()), "{next_hop:?}");
|
|
}
|
|
assert_eq!(
|
|
v6(
|
|
&policy,
|
|
both(Ipv6Addr::UNSPECIFIED, "fe80::3".parse().unwrap()),
|
|
&link_local
|
|
),
|
|
Err(Rejection::NextHopNotPeer)
|
|
);
|
|
assert_eq!(
|
|
v6(
|
|
&policy,
|
|
NlriNextHop::Ipv6(Ipv6Addr::UNSPECIFIED),
|
|
&link_local
|
|
),
|
|
Err(Rejection::NextHopUnusable)
|
|
);
|
|
assert_eq!(
|
|
v6(
|
|
&policy,
|
|
NlriNextHop::Ipv6("fe80::1".parse().unwrap()),
|
|
&link_local
|
|
),
|
|
Err(Rejection::NextHopUnusable),
|
|
"our own address"
|
|
);
|
|
|
|
// An IPv4 route with an IPv6 next hop only where that was agreed
|
|
// on (RFC 8950).
|
|
let v4_route = |from: &RouteSource| {
|
|
policy.check_next_hop(&NlriNextHop::Ipv6(peer), IpVersion::Ipv4, from)
|
|
};
|
|
assert_eq!(v4_route(&over_v6), Err(Rejection::NextHopUnusable));
|
|
let agreed = RouteSource {
|
|
extended_next_hop: true,
|
|
..over_v6
|
|
};
|
|
assert_eq!(v4_route(&agreed), Ok(()));
|
|
// IPv6 routes over an IPv4 session: the session's addresses say
|
|
// nothing about the next hop.
|
|
assert_eq!(v6(&policy, NlriNextHop::Ipv6(peer), &EXTERNAL), Ok(()));
|
|
assert_eq!(
|
|
policy.check_next_hop(
|
|
&NlriNextHop::Ipv4("192.0.2.2".parse().unwrap()),
|
|
IpVersion::Ipv6,
|
|
&EXTERNAL
|
|
),
|
|
Err(Rejection::NextHopUnusable)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_absurdly_long_path_is_refused() {
|
|
let policy = ImportPolicy::default();
|
|
assert_eq!(
|
|
policy.check_path(&path(&[3333; 64]), &EXTERNAL),
|
|
Ok(Some(3333))
|
|
);
|
|
assert_eq!(
|
|
policy.check_path(&path(&[3333; 65]), &EXTERNAL),
|
|
Err(Rejection::PathTooLong)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_peer_may_announce_only_routes_of_the_origins_listed_for_it() {
|
|
let policy = ImportPolicy {
|
|
origin_asns: Some([3333, 2121].into()),
|
|
..ImportPolicy::default()
|
|
};
|
|
assert_eq!(
|
|
policy.check_path(&path(&[3333]), &EXTERNAL).map(drop),
|
|
Ok(())
|
|
);
|
|
assert_eq!(
|
|
policy.check_path(&path(&[3333, 2121]), &EXTERNAL).map(drop),
|
|
Ok(())
|
|
);
|
|
assert_eq!(
|
|
policy.check_path(&path(&[3333, 15169]), &EXTERNAL),
|
|
Err(Rejection::OriginNotAllowed)
|
|
);
|
|
// A path that ends in a set has no one origin.
|
|
let policy = ImportPolicy {
|
|
accept_as_sets: true,
|
|
..policy
|
|
};
|
|
let ends_in_set = vec![PathAttribute::AsPath(AsPathAttribute {
|
|
segments: vec![
|
|
AsPathSegment {
|
|
ordered: true,
|
|
path: vec![3333],
|
|
},
|
|
AsPathSegment {
|
|
ordered: false,
|
|
path: vec![2121],
|
|
},
|
|
],
|
|
})];
|
|
assert_eq!(
|
|
policy.check_path(&ends_in_set, &EXTERNAL),
|
|
Err(Rejection::OriginNotAllowed)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_peer_is_held_down_for_as_long_as_was_said() {
|
|
let held = HoldDowns::default();
|
|
let address = PeerAddress::from("192.0.2.1".parse::<std::net::IpAddr>().unwrap());
|
|
let other = PeerAddress::from("192.0.2.2".parse::<std::net::IpAddr>().unwrap());
|
|
|
|
// Two failures soon after coming up are let pass; from the third
|
|
// the peer is kept down, twice as long each time, up to a limit.
|
|
let flaps = HoldDowns::default();
|
|
let soon = Duration::from_secs(5);
|
|
assert_eq!(flaps.session_ended(address, soon, true), None);
|
|
assert_eq!(flaps.session_ended(address, soon, true), None);
|
|
assert_eq!(flaps.session_ended(address, soon, true), Some(FLAP_HOLD));
|
|
assert_eq!(
|
|
flaps.session_ended(address, soon, true),
|
|
Some(2 * FLAP_HOLD)
|
|
);
|
|
assert_eq!(
|
|
flaps.remaining(address).map(|(_, why)| why),
|
|
Some(HeldDown::Flapping)
|
|
);
|
|
assert_eq!(flaps.remaining(other), None, "each peer for itself");
|
|
for _ in 0..40 {
|
|
flaps.session_ended(address, soon, true);
|
|
}
|
|
assert_eq!(
|
|
flaps.session_ended(address, soon, true),
|
|
Some(FLAP_HOLD_MAX)
|
|
);
|
|
// A session that lasted, or that we ended ourselves, starts the
|
|
// count over.
|
|
assert_eq!(flaps.session_ended(address, FLAP_WITHIN, true), None);
|
|
assert_eq!(flaps.session_ended(address, soon, true), None);
|
|
assert_eq!(flaps.session_ended(address, soon, false), None);
|
|
assert_eq!(flaps.session_ended(address, soon, true), None);
|
|
assert_eq!(held.remaining(address), None);
|
|
|
|
held.hold(address, Duration::from_secs(600), HeldDown::OverLimit);
|
|
let (remaining, why) = held.remaining(address).expect("held");
|
|
assert!(remaining > Duration::from_secs(590), "{remaining:?}");
|
|
assert_eq!(why, HeldDown::OverLimit);
|
|
assert_eq!(held.remaining(other), None);
|
|
// Not made shorter by a lesser reason.
|
|
held.hold(address, Duration::from_secs(1), HeldDown::Flapping);
|
|
let (remaining, why) = held.remaining(address).expect("held");
|
|
assert!(remaining > Duration::from_secs(590), "{remaining:?}");
|
|
assert_eq!(why, HeldDown::OverLimit);
|
|
|
|
held.hold(other, Duration::ZERO, HeldDown::OverLimit);
|
|
assert_eq!(held.remaining(other), None);
|
|
assert_eq!(
|
|
held.held.locked().len(),
|
|
1,
|
|
"a hold that is over is forgotten"
|
|
);
|
|
}
|
|
|
|
/// The quick look at a prefix's first octets never lets through what
|
|
/// the list has.
|
|
fn assert_quick_check_agrees(prefix: IpPrefix) {
|
|
assert_eq!(
|
|
is_bogon_prefix(&prefix),
|
|
is_listed_bogon(&prefix),
|
|
"{prefix}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_quick_bogon_check_agrees_with_the_list_around_every_entry() {
|
|
for bogon in BOGONS.iter().chain([&*GLOBAL_UNICAST_V6]) {
|
|
let version = bogon.address_family();
|
|
let mut address = [0u8; 16];
|
|
address[..bogon.bytes().len()].copy_from_slice(bogon.bytes());
|
|
// The entry, what covers it, and what is inside it, at every
|
|
// length; and the same just past its end.
|
|
for flip in [None, Some(bogon.length().saturating_sub(1))] {
|
|
let mut address = address;
|
|
if let Some(bit) = flip {
|
|
address[usize::from(bit / 8)] ^= 0x80 >> (bit % 8);
|
|
}
|
|
for length in 0..=version.bits() {
|
|
assert_quick_check_agrees(IpPrefix::new(version, address, length).unwrap());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
proptest! {
|
|
#[test]
|
|
fn the_quick_bogon_check_agrees_with_the_list(
|
|
v6: bool, bytes: [u8; 16], length in 0u8..=128,
|
|
) {
|
|
let version = if v6 { IpVersion::Ipv6 } else { IpVersion::Ipv4 };
|
|
let prefix = IpPrefix::new(version, bytes, length.min(version.bits())).unwrap();
|
|
assert_quick_check_agrees(prefix);
|
|
}
|
|
|
|
/// The prefix set agrees with looking at every entry in turn.
|
|
#[test]
|
|
fn a_prefix_set_allows_what_some_entry_covers(
|
|
entries in proptest::collection::vec((any::<[u8; 4]>(), 0u8..=32, 0u8..=32), 0..12),
|
|
probes in proptest::collection::vec((any::<[u8; 4]>(), 0u8..=32), 1..40),
|
|
pick in any::<prop::sample::Index>(),
|
|
) {
|
|
let entries: Vec<(IpPrefix, u8)> = entries
|
|
.into_iter()
|
|
.map(|(bytes, length, extra)| {
|
|
let prefix = IpPrefix::new(IpVersion::Ipv4, bytes, length).unwrap();
|
|
(prefix, length.max(extra))
|
|
})
|
|
.collect();
|
|
let mut set = PrefixSet::default();
|
|
for (entry, max_length) in &entries {
|
|
set.allow(*entry, *max_length);
|
|
}
|
|
// Random prefixes rarely fall inside a random entry, so some
|
|
// probes are made from the entries themselves.
|
|
let mut probes: Vec<IpPrefix> = probes
|
|
.into_iter()
|
|
.map(|(bytes, length)| IpPrefix::new(IpVersion::Ipv4, bytes, length).unwrap())
|
|
.collect();
|
|
if !entries.is_empty() {
|
|
let (entry, _) = entries[pick.index(entries.len())];
|
|
for length in entry.length()..=32 {
|
|
let mut bytes = [0u8; 4];
|
|
bytes[..entry.bytes().len()].copy_from_slice(entry.bytes());
|
|
probes.push(IpPrefix::new(IpVersion::Ipv4, bytes, length).unwrap());
|
|
}
|
|
}
|
|
for probe in probes {
|
|
let expected = entries.iter().any(|(entry, max_length)| {
|
|
entry.contains(&probe) && probe.length() <= *max_length
|
|
});
|
|
prop_assert_eq!(set.allows(&probe), expected, "{}", probe);
|
|
}
|
|
}
|
|
}
|
|
}
|