Files
ferrix/crates/server/src/config.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 1fc44798b7 Install best routes in the Linux kernel over netlink
New crate bgp-fib. It follows the routing table's best routes and keeps
the kernel's routing table the same, and learns the machine's interfaces
and addresses from the kernel, which is what says whether a next hop can
be reached.

- netlink: the rtnetlink messages used (routes, addresses, links, errors),
  as bytes and back. No I/O; property tested and fuzzed.
- interfaces: what is directly reachable, and from which interface.
- plan: what to ask the kernel for, given the best routes and what the
  kernel has. No I/O; tested against a model kernel.
- kernel (Linux only): the sockets and the task. Requests go in batches;
  routes of ours found at startup are kept if still right and removed if
  not; a change of interfaces has every route worked out again; a clean
  stop removes our routes.

Only routes marked proto bgp with the configured table and metric are
ever touched. A best route is installed if its next hop is a neighbor;
next hops further away are not resolved through other routes yet, and
such prefixes are counted and shown instead.

Configured with `kernel { table metric }`; off by default. The daemon
needs CAP_NET_ADMIN and stops if the kernel refuses it. On shutdown it
closes sessions and then removes its routes.

/api/kernel and the status page show installed, unresolved and refused
counts, the last error and the interfaces; five bgp_kernel_* metrics.

Testing: scripts/kernel-tests.sh runs, in network namespaces of their
own and with only the capabilities the daemon needs, the installer
against `ip route`, the daemon with a peer, and two daemons that route
for each other with pings across over IPv4 and IPv6.
scripts/linux-vm.sh runs that, or anything else, in a Lima VM from
macOS. CI runs the kernel tests and fuzzes the netlink decoder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 15:15:43 +02:00

844 lines
30 KiB
Rust

use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::str::FromStr;
use std::time::Duration;
use bgp_fib::FibConfig;
use bgp_packet::community;
use bgp_packet::message::OriginPathAttribute;
use bgp_packet::open::AddPathSendRecv;
use bgp_primitives::constants::{AfiSafi, IpVersion};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRoute, LocalRouteAttrs};
use bgp_rpki::RpkiConfig;
use crate::error::{ConfigError, ImportProblem, OriginateProblem, PeerProblem};
use crate::family::FamilySet;
use crate::policy::{ImportPolicy, PeerRole, PrefixSet};
use crate::proto::config::{self as proto, BgpConfig, OriginatedRoute, Peer};
const DEFAULT_BGP_PORT: u16 = 179;
const DEFAULT_HOLD_TIME_SECS: u16 = 90;
const DEFAULT_CONNECT_RETRY: Duration = Duration::from_secs(5);
const MAX_AS_PATH_PREPEND: u32 = 16;
/// Validated, Rust-native server configuration derived from the proto config.
pub struct Config {
pub router_id: Ipv4Addr,
pub local_asn: u32,
/// Default hold time advertised to peers that have no per-peer override.
pub hold_time_secs: u16,
pub listen_endpoints: Vec<SocketAddr>,
pub peers: Vec<PeerConfig>,
/// Initial delay between attempts to connect to a configured peer;
/// doubles on each failure.
pub connect_retry: Duration,
/// Resolve collisions against Established sessions by BGP Identifier
/// instead of always closing the new connection (RFC 4271 §8.1.1
/// `CollisionDetectEstablishedState`).
pub collision_detect_established_state: bool,
/// Take connections from addresses that are not those of configured
/// peers. Off unless asked for: with it, any host that can reach us can
/// open a session and announce routes.
pub accept_unconfigured_peers: bool,
/// Routes this router originates.
pub originate: Vec<LocalRoute>,
/// Address for the HTTP API; `None` disables it.
pub http_endpoint: Option<SocketAddr>,
/// Where to install the best routes in the kernel, if at all.
pub kernel: Option<FibConfig>,
/// Where route origin validation gets its data; `None` for no
/// validation.
pub rpki: Option<RpkiConfig>,
}
#[derive(Debug, Clone)]
pub struct PeerConfig {
pub name: String,
pub endpoint: SocketAddr,
pub remote_asn: u32,
pub hold_time_secs: u16,
/// Source address for connections we open to this peer; `None` lets the
/// operating system choose.
pub local_address: Option<IpAddr>,
/// The address families offered to this peer.
pub families: FamilySet,
/// The directions ADD-PATH (RFC 7911) is offered to this peer for, on
/// every one of `families`. `None` offers it for neither.
pub add_path: Option<AddPathSendRecv>,
/// Which of the peer's routes are taken.
pub import: ImportPolicy,
/// What the peer is to us, if that was said.
pub role: Option<PeerRole>,
/// Refuse the peer if it does not say what it is itself (RFC 9234
/// §4.2, "strict mode").
pub require_role: bool,
}
impl Config {
/// The configured peer at `address`, if there is one.
pub fn peer_at(&self, address: IpAddr) -> Option<&PeerConfig> {
self.peers.iter().find(|peer| peer.endpoint.ip() == address)
}
pub fn from_proto(proto: BgpConfig) -> Result<Self, ConfigError> {
// Peers refuse an identifier of 0.0.0.0, as we do theirs.
let router_id = Ipv4Addr::from_str(&proto.router_id)
.ok()
.filter(|id| !id.is_unspecified())
.ok_or_else(|| ConfigError::RouterId(proto.router_id.clone()))?;
if proto.local_asn == 0 {
return Err(ConfigError::LocalAsn);
}
let listen_endpoints = if proto.listen_endpoints.is_empty() {
vec![
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), DEFAULT_BGP_PORT),
SocketAddr::new(
IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
DEFAULT_BGP_PORT,
),
]
} else {
proto
.listen_endpoints
.iter()
.map(|ep| parse_endpoint(&ep.address, ep.port))
.collect::<Result<_, _>>()?
};
let mut peers: Vec<PeerConfig> = Vec::with_capacity(proto.peers.len());
for peer in proto.peers {
let name = peer.name.clone();
let rejected = |problem| ConfigError::Peer {
peer: name.clone(),
problem,
};
let peer = parse_peer(peer).map_err(rejected)?;
// RFC 9234 is about what networks are to each other.
if peer.role.is_some() && peer.remote_asn == proto.local_asn {
return Err(rejected(PeerProblem::RoleInOwnAs));
}
// A peer is told from the others by its name in what the
// dialer reports, and by its address when it connects to us.
if peers.iter().any(|earlier| earlier.name == peer.name) {
return Err(rejected(PeerProblem::DuplicateName));
}
if peers
.iter()
.any(|earlier| earlier.endpoint.ip() == peer.endpoint.ip())
{
return Err(rejected(PeerProblem::DuplicateAddress));
}
peers.push(peer);
}
let mut originate: Vec<LocalRoute> = Vec::with_capacity(proto.originate.len());
for entry in &proto.originate {
let rejected = |problem| ConfigError::Originate {
prefix: entry.prefix.clone(),
problem,
};
let route = parse_originated_route(entry).map_err(rejected)?;
if originate
.iter()
.any(|earlier| earlier.prefix == route.prefix)
{
return Err(rejected(OriginateProblem::Duplicate));
}
originate.push(route);
}
let http_endpoint = match proto.http_endpoint.into_option() {
Some(ep) if ep.port == 0 => return Err(ConfigError::HttpPort),
Some(ep) => Some(parse_endpoint(&ep.address, ep.port)?),
None => None,
};
let rpki = match proto.rpki.as_ref() {
Some(rpki) => Some(parse_rpki(rpki)?),
None => None,
};
let connect_retry = if proto.connect_retry_seconds == 0 {
DEFAULT_CONNECT_RETRY
} else {
Duration::from_secs(proto.connect_retry_seconds as u64)
};
Ok(Self {
router_id,
local_asn: proto.local_asn,
hold_time_secs: DEFAULT_HOLD_TIME_SECS,
listen_endpoints,
peers,
connect_retry,
collision_detect_established_state: proto.collision_detect_established_state,
accept_unconfigured_peers: proto.accept_unconfigured_peers,
originate,
http_endpoint,
kernel: proto.kernel.as_ref().map(|kernel| {
let defaults = FibConfig::default();
let or = |value: u32, default: u32| if value == 0 { default } else { value };
FibConfig {
table: or(kernel.table, defaults.table),
metric: or(kernel.metric, defaults.metric),
}
}),
rpki,
})
}
}
fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
let ep = peer.endpoint.as_ref().ok_or(PeerProblem::MissingEndpoint)?;
let endpoint =
parse_endpoint(&ep.address, ep.port).map_err(|e| PeerProblem::Endpoint(Box::new(e)))?;
if peer.remote_asn == 0 {
return Err(PeerProblem::RemoteAsn);
}
let hold_time_secs = match peer.hold_time_seconds {
0 => DEFAULT_HOLD_TIME_SECS,
// RFC 4271 §4.2: zero, or at least three seconds.
1 | 2 => return Err(PeerProblem::HoldTime),
secs => u16::try_from(secs).map_err(|_| PeerProblem::HoldTime)?,
};
let local_address = if peer.local_address.is_empty() {
None
} else {
let ip = IpAddr::from_str(&peer.local_address)
.map_err(|_| PeerProblem::LocalAddress(peer.local_address.clone()))?;
if ip.is_ipv4() != endpoint.is_ipv4() {
return Err(PeerProblem::AddressFamilyMismatch);
}
Some(ip)
};
let families = if peer.address_families.is_empty() {
FamilySet::supported()
} else {
peer.address_families
.iter()
.map(|family| match family.enum_value() {
Ok(proto::AddressFamily::IPV4_UNICAST) => Ok(AfiSafi::IPV4_UNICAST),
Ok(proto::AddressFamily::IPV6_UNICAST) => Ok(AfiSafi::IPV6_UNICAST),
Ok(proto::AddressFamily::ADDRESS_FAMILY_UNSPECIFIED) | Err(_) => {
Err(PeerProblem::AddressFamily)
}
})
.collect::<Result<_, _>>()?
};
let add_path = match peer.add_path.enum_value() {
Ok(proto::AddPath::ADD_PATH_OFF) => None,
Ok(proto::AddPath::ADD_PATH_RECEIVE) => Some(AddPathSendRecv::Receive),
Ok(proto::AddPath::ADD_PATH_SEND) => Some(AddPathSendRecv::Send),
Ok(proto::AddPath::ADD_PATH_BOTH) => Some(AddPathSendRecv::Both),
Err(_) => return Err(PeerProblem::AddPath),
};
let import = match peer.import.as_ref() {
Some(import) => parse_import(import)?,
None => ImportPolicy::default(),
};
let role = match peer.role.enum_value() {
Ok(proto::Role::ROLE_UNSPECIFIED) => None,
Ok(proto::Role::PROVIDER) => Some(PeerRole::Provider),
Ok(proto::Role::CUSTOMER) => Some(PeerRole::Customer),
Ok(proto::Role::PEER) => Some(PeerRole::Peer),
Ok(proto::Role::ROUTE_SERVER) => Some(PeerRole::RouteServer),
Ok(proto::Role::ROUTE_SERVER_CLIENT) => Some(PeerRole::RouteServerClient),
Err(_) => return Err(PeerProblem::Role),
};
if peer.require_role && role.is_none() {
return Err(PeerProblem::RequireRoleWithoutRole);
}
Ok(PeerConfig {
// A peer without a name goes by its address.
name: if peer.name.is_empty() {
endpoint.ip().to_string()
} else {
peer.name
},
endpoint,
remote_asn: peer.remote_asn,
hold_time_secs,
local_address,
families,
add_path,
import,
role,
require_role: peer.require_role,
})
}
fn parse_rpki(rpki: &proto::Rpki) -> Result<RpkiConfig, ConfigError> {
if rpki.caches.is_empty() {
return Err(ConfigError::RpkiNoCaches);
}
let caches = rpki
.caches
.iter()
.map(|cache| match cache.port {
// There is no port a cache can be assumed to listen on.
0 => Err(ConfigError::RpkiCachePort(cache.address.clone())),
port => parse_endpoint(&cache.address, port),
})
.collect::<Result<_, _>>()?;
let seconds = |seconds: u32| (seconds > 0).then(|| Duration::from_secs(seconds.into()));
Ok(RpkiConfig {
caches,
refresh: seconds(rpki.refresh_seconds),
retry: seconds(rpki.retry_seconds),
expire: seconds(rpki.expire_seconds),
})
}
fn parse_import(import: &proto::ImportPolicy) -> Result<ImportPolicy, ImportProblem> {
let defaults = ImportPolicy::default();
// A length of zero is the field left out.
let longest = |field, value: u32, version: IpVersion, default: u8| match value {
0 => Ok(default),
value => u8::try_from(value)
.ok()
.filter(|length| *length <= version.bits())
.ok_or(ImportProblem::PrefixLength {
field,
value,
longest: version.bits(),
}),
};
let mut prefixes = PrefixSet::default();
for range in &import.prefixes {
let prefix =
IpPrefix::try_from(range.prefix.as_str()).map_err(|problem| ImportProblem::Prefix {
prefix: range.prefix.clone(),
problem,
})?;
let longest = prefix.address_family().bits();
let max_length = match range.max_length {
0 => prefix.length(),
max_length => u8::try_from(max_length)
.ok()
.filter(|max| (prefix.length()..=longest).contains(max))
.ok_or(ImportProblem::MaxLength {
prefix: range.prefix.clone(),
max_length,
longest,
})?,
};
prefixes.allow(prefix, max_length);
}
Ok(ImportPolicy {
max_prefixes: Some(import.max_prefixes).filter(|limit| *limit > 0),
hold_down: match import.hold_down_seconds {
0 => defaults.hold_down,
seconds => Duration::from_secs(seconds.into()),
},
accept_bogons: import.accept_bogons,
accept_default_route: import.accept_default_route,
max_ipv4_length: longest(
"max_ipv4_prefix_length",
import.max_ipv4_prefix_length,
IpVersion::Ipv4,
defaults.max_ipv4_length,
)?,
max_ipv6_length: longest(
"max_ipv6_prefix_length",
import.max_ipv6_prefix_length,
IpVersion::Ipv6,
defaults.max_ipv6_length,
)?,
accept_any_first_as: import.accept_any_first_as,
accept_rpki_invalid: import.accept_rpki_invalid,
accept_aspa_invalid: import.accept_aspa_invalid,
max_as_path_length: match import.max_as_path_length {
0 => defaults.max_as_path_length,
length => u16::try_from(length).map_err(|_| ImportProblem::PathLength(length))?,
},
prefixes: Some(prefixes).filter(|prefixes| !prefixes.is_empty()),
origin_asns: Some(import.origin_asns.iter().copied().collect())
.filter(|asns: &std::collections::HashSet<u32>| !asns.is_empty()),
})
}
fn parse_endpoint(address: &str, port: u32) -> Result<SocketAddr, ConfigError> {
let port = match port {
0 => DEFAULT_BGP_PORT,
port => u16::try_from(port).map_err(|_| ConfigError::Port(port))?,
};
let ip = IpAddr::from_str(address).map_err(|_| ConfigError::Address(address.to_string()))?;
Ok(SocketAddr::new(ip, port))
}
fn parse_originated_route(route: &OriginatedRoute) -> Result<LocalRoute, OriginateProblem> {
let prefix = IpPrefix::try_from(route.prefix.as_str())?;
let origin = match route.origin.to_ascii_uppercase().as_str() {
"" | "IGP" => OriginPathAttribute::IGP,
"EGP" => OriginPathAttribute::EGP,
"INCOMPLETE" => OriginPathAttribute::INCOMPLETE,
_ => return Err(OriginateProblem::Origin(route.origin.clone())),
};
let prepend = u8::try_from(route.as_path_prepend)
.ok()
.filter(|&n| u32::from(n) <= MAX_AS_PATH_PREPEND)
.ok_or(OriginateProblem::Prepend {
max: MAX_AS_PATH_PREPEND,
})?;
let communities = route
.communities
.iter()
.map(|c| community::parse_standard(c))
.collect::<Result<_, _>>()?;
let large_communities = route
.large_communities
.iter()
.map(|c| community::parse_large(c))
.collect::<Result<_, _>>()?;
Ok(LocalRoute::new(
prefix,
LocalRouteAttrs {
origin,
prepend,
med: route.med,
communities,
large_communities,
},
))
}
#[cfg(test)]
mod tests {
use bgp_primitives::ip_prefix::PrefixError;
use super::*;
fn parse(text: &str) -> Result<Config, ConfigError> {
let proto = protobuf::text_format::parse_from_str::<BgpConfig>(text)
.expect("the test's textproto is well formed");
Config::from_proto(proto)
}
fn rejected(text: &str) -> ConfigError {
parse(text).err().expect(text)
}
const BASE: &str = "router_id: \"192.0.2.1\" local_asn: 65000\n";
#[test]
fn originated_routes_are_parsed_with_their_attributes() {
let config = parse(&format!(
"{BASE}originate {{ prefix: \"192.0.2.0/24\" }}
originate {{
prefix: \"2001:db8::/32\" origin: \"incomplete\" as_path_prepend: 2 med: 0
communities: \"65000:100\" communities: \"NO_EXPORT\"
large_communities: \"65000:1:2\"
}}"
))
.unwrap();
assert_eq!(config.originate.len(), 2);
let plain = &config.originate[0];
assert_eq!(plain.prefix, IpPrefix::try_from("192.0.2.0/24").unwrap());
assert_eq!(plain.attrs, LocalRouteAttrs::default());
let full = &config.originate[1].attrs;
assert_eq!(full.origin, OriginPathAttribute::INCOMPLETE);
assert_eq!(full.prepend, 2);
assert_eq!(
full.med,
Some(0),
"an explicit MED of 0 is not the same as no MED"
);
assert_eq!(full.communities, vec![(65000, 100), (65535, 65281)]);
assert_eq!(full.large_communities.len(), 1);
}
#[test]
fn bad_originated_routes_are_rejected_with_the_entry_and_the_reason() {
let problem = |body: &str| match rejected(&format!("{BASE}originate {{ {body} }}")) {
ConfigError::Originate { problem, .. } => problem,
other => panic!("{body}: {other}"),
};
assert_eq!(
problem("prefix: \"not-a-prefix\""),
OriginateProblem::Prefix(PrefixError::Format("not-a-prefix".into()))
);
assert_eq!(
problem("prefix: \"192.0.2.0/24\" origin: \"static\""),
OriginateProblem::Origin("static".into())
);
assert_eq!(
problem("prefix: \"192.0.2.0/24\" as_path_prepend: 17"),
OriginateProblem::Prepend { max: 16 }
);
assert!(matches!(
problem("prefix: \"192.0.2.0/24\" communities: \"70000:1\""),
OriginateProblem::Community(_)
));
assert!(matches!(
problem("prefix: \"192.0.2.0/24\" large_communities: \"1:2\""),
OriginateProblem::Community(_)
));
let duplicate = rejected(&format!(
"{BASE}originate {{ prefix: \"192.0.2.0/24\" }} originate {{ prefix: \"192.0.2.0/24\" med: 5 }}"
));
assert_eq!(
duplicate,
ConfigError::Originate {
prefix: "192.0.2.0/24".into(),
problem: OriginateProblem::Duplicate
}
);
// The message names the entry, so the operator can find it.
assert_eq!(
duplicate.to_string(),
"originate \"192.0.2.0/24\": listed more than once"
);
}
#[test]
fn a_peer_is_offered_every_address_family_unless_some_are_listed() {
let families = |body: &str| {
let config = parse(&format!(
"{BASE}peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 {body} }}"
))
.unwrap();
config.peers[0].families.clone()
};
assert_eq!(families(""), FamilySet::supported());
assert_eq!(
families("address_families: IPV6_UNICAST"),
[AfiSafi::IPV6_UNICAST].into_iter().collect()
);
assert_eq!(
families("address_families: IPV4_UNICAST address_families: IPV6_UNICAST"),
FamilySet::supported()
);
}
#[test]
fn add_path_is_off_unless_a_peer_asks_for_it() {
let add_path = |body: &str| {
let config = parse(&format!(
"{BASE}peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 {body} }}"
))
.unwrap();
config.peers[0].add_path
};
assert_eq!(add_path(""), None);
assert_eq!(add_path("add_path: ADD_PATH_OFF"), None);
assert_eq!(
add_path("add_path: ADD_PATH_RECEIVE"),
Some(AddPathSendRecv::Receive)
);
assert_eq!(
add_path("add_path: ADD_PATH_BOTH"),
Some(AddPathSendRecv::Both)
);
}
#[test]
fn bad_peers_and_endpoints_are_rejected_with_the_peer_named() {
assert_eq!(
rejected("router_id: \"nope\" local_asn: 1"),
ConfigError::RouterId("nope".into())
);
assert_eq!(rejected("router_id: \"192.0.2.1\""), ConfigError::LocalAsn);
let problem = |body: &str| match rejected(&format!("{BASE}peers {{ name: \"r1\" {body} }}"))
{
ConfigError::Peer { peer, problem } => {
assert_eq!(peer, "r1");
problem
}
other => panic!("{body}: {other}"),
};
let endpoint = "endpoint { address: \"192.0.2.2\" }";
assert_eq!(problem("remote_asn: 1"), PeerProblem::MissingEndpoint);
assert_eq!(problem(endpoint), PeerProblem::RemoteAsn);
assert_eq!(
problem(&format!("{endpoint} remote_asn: 1 hold_time_seconds: 2")),
PeerProblem::HoldTime
);
assert_eq!(
problem(&format!(
"{endpoint} remote_asn: 1 hold_time_seconds: 70000"
)),
PeerProblem::HoldTime
);
assert_eq!(
problem(&format!("{endpoint} remote_asn: 1 local_address: \"::1\"")),
PeerProblem::AddressFamilyMismatch
);
assert_eq!(
problem(&format!(
"{endpoint} remote_asn: 1 address_families: ADDRESS_FAMILY_UNSPECIFIED"
)),
PeerProblem::AddressFamily
);
assert_eq!(
problem("endpoint { address: \"192.0.2.2\" port: 70000 } remote_asn: 1"),
PeerProblem::Endpoint(Box::new(ConfigError::Port(70000)))
);
assert_eq!(
rejected(&format!("{BASE}http_endpoint {{ address: \"127.0.0.1\" }}")),
ConfigError::HttpPort
);
}
/// Peers are told apart by name where the dialer reports on them and by
/// address when they connect to us; two that share either would be
/// taken for each other.
#[test]
fn peers_must_differ_in_name_and_address() {
let peer = |name: &str, address: &str| {
format!(
"peers {{ name: \"{name}\" endpoint {{ address: \"{address}\" }} remote_asn: 1 }}\n"
)
};
let problem = |peers: String| match rejected(&format!("{BASE}{peers}")) {
ConfigError::Peer { problem, .. } => problem,
other => panic!("{other}"),
};
assert_eq!(
problem(peer("r1", "192.0.2.2") + &peer("r1", "192.0.2.3")),
PeerProblem::DuplicateName
);
assert_eq!(
problem(peer("r1", "192.0.2.2") + &peer("r2", "192.0.2.2")),
PeerProblem::DuplicateAddress
);
// A peer without a name goes by its address, so two of them differ.
let config = parse(&format!(
"{BASE}{}{}",
peer("", "192.0.2.2"),
peer("", "192.0.2.3")
))
.unwrap();
assert_eq!(config.peers[0].name, "192.0.2.2");
assert_eq!(config.peers[1].name, "192.0.2.3");
assert_eq!(
config
.peer_at("192.0.2.3".parse().unwrap())
.map(|p| &*p.name),
Some("192.0.2.3")
);
assert!(config.peer_at("192.0.2.4".parse().unwrap()).is_none());
}
/// A peer would refuse our OPEN.
#[test]
fn a_router_id_of_zero_is_rejected() {
assert_eq!(
rejected("router_id: \"0.0.0.0\" local_asn: 1"),
ConfigError::RouterId("0.0.0.0".into())
);
}
fn import_of(body: &str) -> Result<ImportPolicy, ConfigError> {
parse(&format!(
"{BASE}peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 {body} }}"
))
.map(|config| config.peers[0].import.clone())
}
/// A peer with nothing said about its routes still gets the general
/// checks; what is said overrides them one by one.
#[test]
fn an_import_policy_starts_from_the_general_checks() {
assert_eq!(import_of("").unwrap(), ImportPolicy::default());
assert_eq!(import_of("import {}").unwrap(), ImportPolicy::default());
let policy = import_of(
"import {
max_prefixes: 1000 hold_down_seconds: 60
accept_default_route: true accept_any_first_as: true
max_ipv4_prefix_length: 32 max_as_path_length: 20
prefixes { prefix: \"193.0.0.0/21\" max_length: 24 }
prefixes { prefix: \"2001:67c:2e8::/48\" }
origin_asns: 3333 origin_asns: 2121
}",
)
.unwrap();
assert_eq!(policy.max_prefixes, Some(1000));
assert_eq!(policy.hold_down, Duration::from_secs(60));
assert!(policy.accept_default_route && policy.accept_any_first_as);
assert!(!policy.accept_bogons);
assert_eq!((policy.max_ipv4_length, policy.max_ipv6_length), (32, 48));
assert_eq!(policy.max_as_path_length, 20);
assert_eq!(policy.origin_asns, Some([3333, 2121].into()));
let prefixes = policy.prefixes.unwrap();
let p = |s: &str| IpPrefix::try_from(s).unwrap();
assert!(prefixes.allows(&p("193.0.4.0/24")));
assert!(!prefixes.allows(&p("193.0.4.0/25")));
assert!(prefixes.allows(&p("2001:67c:2e8::/48")));
assert!(!prefixes.allows(&p("2001:67c:2e8:1::/64")));
}
#[test]
fn a_bad_import_policy_is_rejected_with_the_reason() {
let problem = |body: &str| match import_of(&format!("import {{ {body} }}")) {
Err(ConfigError::Peer {
problem: PeerProblem::Import(problem),
..
}) => problem,
other => panic!("{body}: {:?}", other.map(|_| ())),
};
assert!(matches!(
problem("prefixes { prefix: \"193.0.0.0\" }"),
ImportProblem::Prefix { .. }
));
// Shorter than the prefix itself, and longer than an address.
assert_eq!(
problem("prefixes { prefix: \"193.0.0.0/21\" max_length: 20 }"),
ImportProblem::MaxLength {
prefix: "193.0.0.0/21".into(),
max_length: 20,
longest: 32
}
);
assert!(matches!(
problem("prefixes { prefix: \"193.0.0.0/21\" max_length: 33 }"),
ImportProblem::MaxLength { .. }
));
assert_eq!(
problem("max_ipv4_prefix_length: 33"),
ImportProblem::PrefixLength {
field: "max_ipv4_prefix_length",
value: 33,
longest: 32
}
);
assert_eq!(
problem("max_as_path_length: 70000"),
ImportProblem::PathLength(70000)
);
// The message says which peer and which entry.
assert_eq!(
import_of("import { prefixes { prefix: \"193.0.0.0/21\" max_length: 20 } }")
.err()
.unwrap()
.to_string(),
"peer \"r1\": import: prefix \"193.0.0.0/21\": max_length 20 is not between \
its own length and 32"
);
}
#[test]
fn the_kernel_is_left_alone_unless_asked_for() {
assert_eq!(parse(BASE).unwrap().kernel, None);
assert_eq!(
parse(&format!("{BASE}kernel {{}}")).unwrap().kernel,
Some(FibConfig {
table: 254,
metric: 20
})
);
assert_eq!(
parse(&format!("{BASE}kernel {{ table: 1000 metric: 150 }}"))
.unwrap()
.kernel,
Some(FibConfig {
table: 1000,
metric: 150
})
);
}
#[test]
fn route_origin_validation_is_off_unless_caches_are_given() {
assert!(parse(BASE).unwrap().rpki.is_none());
let config = parse(&format!(
"{BASE}rpki {{
caches {{ address: \"192.0.2.10\" port: 3323 }}
caches {{ address: \"2001:db8::10\" port: 8282 }}
expire_seconds: 3600
}}"
))
.unwrap();
let rpki = config.rpki.unwrap();
assert_eq!(
rpki.caches,
[
"192.0.2.10:3323".parse().unwrap(),
"[2001:db8::10]:8282".parse().unwrap()
]
);
// What is not set is left to the cache.
assert_eq!(rpki.refresh, None);
assert_eq!(rpki.expire, Some(Duration::from_secs(3600)));
assert_eq!(
rejected(&format!("{BASE}rpki {{}}")),
ConfigError::RpkiNoCaches
);
// No port is assumed for a cache.
assert_eq!(
rejected(&format!(
"{BASE}rpki {{ caches {{ address: \"192.0.2.10\" }} }}"
)),
ConfigError::RpkiCachePort("192.0.2.10".into())
);
// A peer can be exempt.
// A peer's paths are verified only if it is said what the peer is.
let role_of = |body: &str| {
parse(&format!(
"{BASE}peers {{ endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 {body} }}"
))
.map(|config| config.peers[0].role)
};
assert_eq!(role_of("").unwrap(), None);
assert_eq!(role_of("role: CUSTOMER").unwrap(), Some(PeerRole::Customer));
assert_eq!(
role_of("role: ROUTE_SERVER").unwrap(),
Some(PeerRole::RouteServer)
);
// Roles are between networks, and a peer can be required to say
// its own only where we know what to expect.
let problem = |body: &str| match role_of(body).err().unwrap() {
ConfigError::Peer { problem, .. } => problem,
other => panic!("{other:?}"),
};
assert_eq!(
problem("role: PEER remote_asn: 65000"),
PeerProblem::RoleInOwnAs
);
assert_eq!(
problem("require_role: true"),
PeerProblem::RequireRoleWithoutRole
);
let config = parse(&format!(
"{BASE}peers {{ endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 \
role: PEER require_role: true }}"
))
.unwrap();
assert!(config.peers[0].require_role);
assert!(!import_of("").unwrap().accept_aspa_invalid);
assert!(
import_of("import { accept_aspa_invalid: true }")
.unwrap()
.accept_aspa_invalid
);
assert!(!import_of("").unwrap().accept_rpki_invalid);
assert!(
import_of("import { accept_rpki_invalid: true }")
.unwrap()
.accept_rpki_invalid
);
}
}