Rhai made a decision about a route cost one to three microseconds, some two hundred times what the same costs in Rust. That was bearable where a function is asked once for an UPDATE, and is not where every route is weighed against every peer, as it is when routes are passed on. What is wanted of a policy (mark routes by where they came from, mark what is announced, let communities say who is told what) needs no language; it is to be configuration, done natively. Kept from that work: route selection goes by a LOCAL_PREF that policy set on a route from an external peer, and the lab's configuration can be added to by a phase. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
948 lines
35 KiB
Rust
948 lines
35 KiB
Rust
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
|
use std::str::FromStr;
|
|
use std::sync::Arc;
|
|
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::address::{Endpoint, PeerAddress};
|
|
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,
|
|
/// The hold time offered to a peer that is not in the configuration
|
|
/// (see `accept_unconfigured_peers`). Configured peers have their own.
|
|
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,
|
|
/// Where the peer is. A link-local address has its interface with it.
|
|
pub endpoint: Endpoint,
|
|
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: PeerAddress) -> Option<&PeerConfig> {
|
|
self.peers.iter().find(|peer| peer.endpoint.is_at(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
|
|
&& earlier.endpoint.interface == peer.endpoint.interface
|
|
}) {
|
|
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);
|
|
}
|
|
// What we originate is not taken from other networks.
|
|
let mut own_prefixes = PrefixSet::default();
|
|
for route in &originate {
|
|
own_prefixes.allow(route.prefix, route.prefix.address_family().bits());
|
|
}
|
|
let own_prefixes = Arc::new(own_prefixes);
|
|
for peer in &mut peers {
|
|
if peer.remote_asn != proto.local_asn {
|
|
peer.import.own_prefixes = Arc::clone(&own_prefixes);
|
|
}
|
|
}
|
|
|
|
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(parse_kernel),
|
|
rpki,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn parse_kernel(kernel: &proto::Kernel) -> FibConfig {
|
|
let defaults = FibConfig::default();
|
|
FibConfig {
|
|
// The kernel has no table 0 to put routes in.
|
|
table: match kernel.table {
|
|
0 => defaults.table,
|
|
table => table,
|
|
},
|
|
metric: kernel.metric.unwrap_or(defaults.metric),
|
|
nexthop_objects: !kernel.without_nexthop_objects,
|
|
stale_time: kernel.stale_seconds.map_or(defaults.stale_time, |seconds| {
|
|
Duration::from_secs(seconds.into())
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
|
|
let ep = peer.endpoint.as_ref().ok_or(PeerProblem::MissingEndpoint)?;
|
|
let endpoint = parse_port(ep.port)
|
|
.and_then(|port| Ok(Endpoint::parse(&ep.address, 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 {
|
|
None => DEFAULT_HOLD_TIME_SECS,
|
|
// RFC 4271 §4.2: zero, or at least three seconds.
|
|
Some(1 | 2) => return Err(PeerProblem::HoldTime),
|
|
Some(secs) => u16::try_from(secs).map_err(|_| PeerProblem::HoldTime)?,
|
|
};
|
|
|
|
let local_address = if peer.local_address.is_empty() {
|
|
None
|
|
} else {
|
|
let ip = IpAddr::from_str(&peer.local_address)
|
|
.map_err(|_| PeerProblem::LocalAddress(peer.local_address.clone()))?;
|
|
if ip.is_ipv4() != endpoint.ip.is_ipv4() {
|
|
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.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,
|
|
accept_as_sets: import.accept_as_sets,
|
|
accept_third_party_next_hop: import.accept_third_party_next_hop,
|
|
own_prefixes: Arc::default(),
|
|
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_port(port: u32) -> Result<u16, ConfigError> {
|
|
match port {
|
|
0 => Ok(DEFAULT_BGP_PORT),
|
|
port => u16::try_from(port).map_err(|_| ConfigError::Port(port)),
|
|
}
|
|
}
|
|
|
|
fn parse_endpoint(address: &str, port: u32) -> Result<SocketAddr, ConfigError> {
|
|
let port = parse_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)
|
|
}
|
|
|
|
/// The example that the documentation points to, and that operators
|
|
/// start from: as it is, and with what it shows commented out put in.
|
|
#[test]
|
|
fn the_example_configuration_is_in_order() {
|
|
let example = include_str!("../../../testdata/server.textpb");
|
|
let config = parse(example).expect("the example as it is");
|
|
assert_eq!(config.peers.len(), 1);
|
|
assert!(config.peers[0].import.accept_third_party_next_hop);
|
|
assert!(config.rpki.is_none() && config.kernel.is_none());
|
|
|
|
// A commented line that is a field, or a block on one line, is
|
|
// configuration shown for the taking.
|
|
let shown: String = example
|
|
.lines()
|
|
.map(|line| {
|
|
let (indent, rest) = line.split_at(line.len() - line.trim_start().len());
|
|
let field = rest.strip_prefix("# ").filter(|field| {
|
|
let name_ends = field.find([':', ' ']).unwrap_or(0);
|
|
let is_name = name_ends > 0
|
|
&& field[..name_ends]
|
|
.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c == '_');
|
|
let after = &field[name_ends..];
|
|
is_name
|
|
&& (after.starts_with(": ") && !after[2..].contains(' ')
|
|
|| after.starts_with(" {") && after.ends_with('}'))
|
|
});
|
|
format!("{indent}{}\n", field.unwrap_or(rest))
|
|
})
|
|
.collect();
|
|
assert_ne!(shown, example, "nothing was taken for configuration");
|
|
let config = parse(&shown).unwrap_or_else(|e| panic!("{e}\n{shown}"));
|
|
assert!(config.rpki.is_some(), "{shown}");
|
|
assert!(config.kernel.is_some(), "{shown}");
|
|
assert_eq!(config.peers[0].role, Some(PeerRole::Peer));
|
|
assert!(config.peers[0].require_role);
|
|
assert!(config.peers[0].import.prefixes.is_some());
|
|
}
|
|
|
|
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 link-local address is one peer on each interface it is on.
|
|
assert_eq!(
|
|
problem(peer("r1", "fe80::1%eth0") + &peer("r2", "fe80::1%eth0")),
|
|
PeerProblem::DuplicateAddress
|
|
);
|
|
let config = parse(&format!(
|
|
"{BASE}{}{}",
|
|
peer("", "fe80::1%eth0"),
|
|
peer("", "fe80::1%eth1")
|
|
))
|
|
.unwrap();
|
|
assert_eq!(
|
|
config.peers.iter().map(|p| &p.name).collect::<Vec<_>>(),
|
|
["fe80::1%eth0", "fe80::1%eth1"]
|
|
);
|
|
// And nothing without one.
|
|
assert_eq!(
|
|
problem(peer("r1", "fe80::1")).to_string(),
|
|
"endpoint is invalid: fe80::1 is a link-local address, which needs the interface it \
|
|
is on: fe80::1%eth0, for example"
|
|
);
|
|
|
|
// 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::<IpAddr>().unwrap().into())
|
|
.map(|p| &*p.name),
|
|
Some("192.0.2.3")
|
|
);
|
|
assert!(
|
|
config
|
|
.peer_at("192.0.2.4".parse::<IpAddr>().unwrap().into())
|
|
.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,
|
|
nexthop_objects: true,
|
|
stale_time: Duration::from_secs(120),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
parse(&format!(
|
|
"{BASE}kernel {{ table: 1000 metric: 150 without_nexthop_objects: true \
|
|
stale_seconds: 30 }}"
|
|
))
|
|
.unwrap()
|
|
.kernel,
|
|
Some(FibConfig {
|
|
table: 1000,
|
|
metric: 150,
|
|
nexthop_objects: false,
|
|
stale_time: Duration::from_secs(30),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[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
|
|
);
|
|
}
|
|
}
|