Bring back the built-in RISwhois tree for BGP analysis. (#1329)

This PR brings back the built-in tree of downloaded RISwhois data rather
than using the Roto API. It does so using a memory-optimized tree
implementation and has a much smaller memory footprint than the previous
iteration. At the time of writing, the a full RISwhois dataset requires 55
megabytes of memory.

This PR also reverts the changes to the configuration. It removes the
bgp_api_enabled, bgp_api_uri, and bgp_api_cache_duration fields and adds
bgp_riswhois_enabled, bgp_riswhois_v4_uri, bgp_riswhois_v6_uri, and
bgp_riswhois_refresh_duration fields, all of which are optional.

Because of these config changes, the PR is a breaking change.
This commit is contained in:
Martin Hoffmann
2025-11-17 17:54:27 +01:00
committed by GitHub
parent 38205078c8
commit dd5c7dcb1d
18 changed files with 1543503 additions and 501 deletions
Generated
+364 -391
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -21,6 +21,7 @@ exclude = [
]
[dependencies]
arc-swap = "1.7.1"
base64 = "0.22.1"
bytes = "1"
chrono = { version = "0.4.39", features = ["serde"] }
@@ -34,6 +35,7 @@ hyper = { version = "1.6.0", features = ["server"] }
hyper-util = { version = "0.1", features = [ "server" ] }
intervaltree = "0.2.7"
lazy_static = "1.5"
libflate = "2.1.0"
log = "0.4"
openssl = { version = "0.10", features = ["v110"] }
percent-encoding = "2.3.1"
+5 -4
View File
@@ -352,11 +352,12 @@
#
### ca_refresh_jitter_seconds = 43200
# Enable loading information from bgp-api.net for ROA vs BGP analysis.
# Enable downloading RISwhois data for ROA vs BGP analysis.
#
### bgp_api_enabled = true
### bgp_api_uri = "https://rest.bgp-api.net"
### bgp_api_cache_seconds = 1800
### bgp_riswhois_enabled = true
### bgp_riswhois_v4_uri = "https://www.ris.ripe.net/dumps/riswhoisdump.IPv4.gz"
### bgp_riswhois_v6_uri = "https://www.ris.ripe.net/dumps/riswhoisdump.IPv6.gz"
### bgp_riswhois_refresh_minutes = 60
# Restrict size of messages sent to the API.
#
+1 -2
View File
@@ -446,11 +446,10 @@ impl fmt::Display for BgpAnalysisReport {
writeln!(f)?;
writeln!(
f,
"\ttConfiguration: {}",
"\tConfiguration: {}",
roa.configured_roa()
)?;
writeln!(f)?;
writeln!(f)?;
writeln!(f, "\t\tDisallows:")?;
for ann in roa.disallows.iter() {
writeln!(f, "\t\t{ann}")?;
+181 -28
View File
@@ -2,7 +2,7 @@
use std::{error, fmt};
use std::cmp::Ordering;
use std::net::IpAddr;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::str::FromStr;
use rpki::uri;
use rpki::ca::publication::Base64;
@@ -661,16 +661,19 @@ impl TypedPrefix {
}
/// Returns the IP address part of the prefix.
pub fn ip_addr(&self) -> IpAddr {
pub fn ip_addr(self) -> IpAddr {
match self {
Self::V4(v4) => IpAddr::V4(v4.0.to_v4()),
Self::V6(v6) => IpAddr::V6(v6.0.to_v6()),
Self::V4(v4) => v4.addr().into(),
Self::V6(v6) => v6.addr().into(),
}
}
/// Returns the prefix length of the prefix.
pub fn addr_len(&self) -> u8 {
self.prefix().addr_len()
pub fn addr_len(self) -> u8 {
match self {
Self::V4(v4) => v4.addr_len(),
Self::V6(v6) => v6.addr_len(),
}
}
/// Returns whether `other` is of the same address family.
@@ -715,7 +718,7 @@ impl From<TypedPrefix> for ResourceSet {
match tp {
TypedPrefix::V4(v4) => {
let mut builder = IpBlocksBuilder::new();
builder.push(v4.0);
builder.push(Prefix::from(v4));
let blocks = builder.finalize();
ResourceSet::new(
@@ -726,7 +729,7 @@ impl From<TypedPrefix> for ResourceSet {
}
TypedPrefix::V6(v6) => {
let mut builder = IpBlocksBuilder::new();
builder.push(v6.0);
builder.push(Prefix::from(v6));
let blocks = builder.finalize();
ResourceSet::new(
@@ -744,12 +747,12 @@ impl FromStr for TypedPrefix {
fn from_str(prefix: &str) -> Result<Self, Self::Err> {
if prefix.contains('.') {
Ok(TypedPrefix::V4(Ipv4Prefix(
Ok(TypedPrefix::V4(Ipv4Prefix::from(
Prefix::from_v4_str(prefix.trim())
.map_err(|_| AuthorizationFmtError::pfx(prefix))?,
)))
} else {
Ok(TypedPrefix::V6(Ipv6Prefix(
Ok(TypedPrefix::V6(Ipv6Prefix::from(
Prefix::from_v6_str(prefix.trim())
.map_err(|_| AuthorizationFmtError::pfx(prefix))?,
)))
@@ -822,18 +825,82 @@ impl Serialize for TypedPrefix {
/// An IPv4 prefix.
//
// *Warning:* This type is used in stored state.
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
pub struct Ipv4Prefix(Prefix);
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Ipv4Prefix {
/// The address portion of the prefix.
///
/// This cannot be pub because we need to enforce that non-prefix bits are
/// zero.
addr: Ipv4Addr,
impl AsRef<Prefix> for Ipv4Prefix {
fn as_ref(&self) -> &Prefix {
&self.0
/// The address length.
///
/// This cannot be pub because it needs to be less than 33.
addr_len: u8,
}
impl Ipv4Prefix {
/// Returns the address portion of the prefix.
pub fn addr(self) -> Ipv4Addr {
self.addr
}
/// Returns the address length.
pub fn addr_len(self) -> u8 {
self.addr_len
}
/// Returns a prefix with the same address but given length.
pub fn resize(self, addr_len: u8) -> Self {
if addr_len >= 32 {
Self {
addr: self.addr,
addr_len: 32,
}
}
else {
Self {
addr: Ipv4Addr::from_bits(
self.addr.to_bits() & !(u32::MAX >> addr_len)
),
addr_len
}
}
}
}
impl Default for Ipv4Prefix {
fn default() -> Self {
Self { addr: Ipv4Addr::UNSPECIFIED, addr_len: 0 }
}
}
impl FromStr for Ipv4Prefix {
type Err = ParsePrefixError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let Some((addr, len)) = s.split_once('/') else {
return Err(ParsePrefixError(()))
};
let addr = Ipv4Addr::from_str(addr).map_err(|_| {
ParsePrefixError(())
})?;
let addr_len = u8::from_str(len).map_err(|_| {
ParsePrefixError(())
})?;
if addr_len > 32 {
return Err(ParsePrefixError(()));
}
if addr.to_bits().trailing_zeros() < (32 - addr_len).into() {
return Err(ParsePrefixError(()));
}
Ok(Self { addr, addr_len })
}
}
impl fmt::Display for Ipv4Prefix {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}/{}", self.0.to_v4(), self.0.addr_len())
write!(f, "{}/{}", self.addr, self.addr_len)
}
}
@@ -845,13 +912,16 @@ impl fmt::Debug for Ipv4Prefix {
impl From<Prefix> for Ipv4Prefix {
fn from(prefix: Prefix) -> Self {
Ipv4Prefix(prefix)
Self {
addr: prefix.to_v4(),
addr_len: prefix.addr_len(),
}
}
}
impl From<Ipv4Prefix> for Prefix {
fn from(prefix: Ipv4Prefix) -> Self {
prefix.0
fn from(src: Ipv4Prefix) -> Self {
Self::new(src.addr, src.addr_len)
}
}
@@ -860,18 +930,82 @@ impl From<Ipv4Prefix> for Prefix {
/// An IPv6 prefix.
//
// *Warning:* This type is used in stored state.
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
pub struct Ipv6Prefix(Prefix);
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Ipv6Prefix {
/// The address portion of the prefix.
///
/// This cannot be pub because we need to enforce that non-prefix bits
/// are zero.
addr: Ipv6Addr,
impl AsRef<Prefix> for Ipv6Prefix {
fn as_ref(&self) -> &Prefix {
&self.0
/// The address length.
///
/// This cannot be pub because it needs to be less than 129.
addr_len: u8,
}
impl Ipv6Prefix {
/// Returns the address portion of the prefix.
pub fn addr(self) -> Ipv6Addr {
self.addr
}
/// Returns the address length.
pub fn addr_len(self) -> u8 {
self.addr_len
}
/// Returns a prefix with the same address but given length.
pub fn resize(self, addr_len: u8) -> Self {
if addr_len >= 128 {
Self {
addr: self.addr,
addr_len: 128,
}
}
else {
Self {
addr: Ipv6Addr::from_bits(
self.addr.to_bits() & !(u128::MAX >> addr_len)
),
addr_len
}
}
}
}
impl Default for Ipv6Prefix {
fn default() -> Self {
Self { addr: Ipv6Addr::UNSPECIFIED, addr_len: 0 }
}
}
impl FromStr for Ipv6Prefix {
type Err = ParsePrefixError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let Some((addr, len)) = s.split_once('/') else {
return Err(ParsePrefixError(()))
};
let addr = Ipv6Addr::from_str(addr).map_err(|_| {
ParsePrefixError(())
})?;
let addr_len = u8::from_str(len).map_err(|_| {
ParsePrefixError(())
})?;
if addr_len > 128 {
return Err(ParsePrefixError(()));
}
if addr.to_bits().trailing_zeros() < (128 - addr_len).into() {
return Err(ParsePrefixError(()));
}
Ok(Self { addr, addr_len })
}
}
impl fmt::Display for Ipv6Prefix {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}/{}", self.0.to_v6(), self.0.addr_len())
write!(f, "{}/{}", self.addr, self.addr_len)
}
}
@@ -883,13 +1017,16 @@ impl fmt::Debug for Ipv6Prefix {
impl From<Prefix> for Ipv6Prefix {
fn from(prefix: Prefix) -> Self {
Ipv6Prefix(prefix)
Self {
addr: prefix.to_v6(),
addr_len: prefix.addr_len(),
}
}
}
impl From<Ipv6Prefix> for Prefix {
fn from(prefix: Ipv6Prefix) -> Self {
prefix.0
fn from(src: Ipv6Prefix) -> Self {
Self::new(src.addr, src.addr_len)
}
}
@@ -1009,6 +1146,22 @@ impl fmt::Display for AuthorizationFmtError {
impl error::Error for AuthorizationFmtError { }
//------------ ParsePrefixError ----------------------------------------------
/// An error happened while parsing a prefix.
#[derive(Debug)]
pub struct ParsePrefixError(());
impl fmt::Display for ParsePrefixError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("invalid prefix")
}
}
impl error::Error for ParsePrefixError { }
//============ Tests =========================================================
#[cfg(test)]
+34 -22
View File
@@ -177,16 +177,20 @@ impl ConfigDefaults {
240 // 4 minutes by default should be plenty in most cases
}
pub fn bgp_api_enabled() -> bool {
pub fn bgp_riswhois_enabled() -> bool {
true
}
pub fn bgp_api_uri() -> String {
"https://rest.bgp-api.net".to_string()
pub fn bgp_riswhois_v4_uri() -> String {
"https://www.ris.ripe.net/dumps/riswhoisdump.IPv4.gz".into()
}
pub fn bgp_api_cache_duration() -> Duration {
Duration::seconds(30 * 60)
pub fn bgp_riswhois_v6_uri() -> String {
"https://www.ris.ripe.net/dumps/riswhoisdump.IPv6.gz".into()
}
pub fn bgp_riswhois_refresh_interval() -> Duration {
Duration::seconds(60 * 60)
}
pub fn roa_aggregate_threshold() -> usize {
@@ -461,10 +465,12 @@ where
}
}
fn deserialize_seconds_duration<'de, D: Deserializer<'de>>(
fn deserialize_minutes_duration<'de, D: Deserializer<'de>>(
deserializer: D
) -> Result<Duration, D::Error> {
u32::deserialize(deserializer).map(|secs| Duration::seconds(secs.into()))
u32::deserialize(deserializer).map(|secs| {
Duration::seconds(i64::from(secs) * 60)
})
}
@@ -596,19 +602,22 @@ pub struct Config {
#[serde(default = "ConfigDefaults::rfc6492_log_dir")]
pub rfc6492_log_dir: Option<PathBuf>,
// RIS BGP
#[serde(default = "ConfigDefaults::bgp_api_enabled")]
pub bgp_api_enabled: bool,
// RISwhois data for the BGP analyser
#[serde(default = "ConfigDefaults::bgp_riswhois_enabled")]
pub bgp_riswhois_enabled: bool,
#[serde(default = "ConfigDefaults::bgp_api_uri")]
pub bgp_api_uri: String,
#[serde(default = "ConfigDefaults::bgp_riswhois_v4_uri")]
pub bgp_riswhois_v4_uri: String,
#[serde(default = "ConfigDefaults::bgp_riswhois_v6_uri")]
pub bgp_riswhois_v6_uri: String,
#[serde(
rename = "bgp_api_cache_seconds",
default = "ConfigDefaults::bgp_api_cache_duration",
deserialize_with = "deserialize_seconds_duration",
rename = "bgp_riswhois_refresh_minutes",
default = "ConfigDefaults::bgp_riswhois_refresh_interval",
deserialize_with = "deserialize_minutes_duration",
)]
pub bgp_api_cache_duration: Duration,
pub bgp_riswhois_refresh_interval: Duration,
// ROA Aggregation per ASN
#[serde(default = "ConfigDefaults::roa_aggregate_threshold")]
@@ -1140,9 +1149,11 @@ impl Config {
let post_protocol_msg_timeout_seconds =
ConfigDefaults::post_protocol_msg_timeout_seconds();
let bgp_api_enabled = false;
let bgp_api_uri = ConfigDefaults::bgp_api_uri();
let bgp_api_cache_duration = ConfigDefaults::bgp_api_cache_duration();
let bgp_riswhois_enabled = false;
let bgp_riswhois_v4_uri = ConfigDefaults::bgp_riswhois_v4_uri();
let bgp_riswhois_v6_uri = ConfigDefaults::bgp_riswhois_v6_uri();
let bgp_riswhois_refresh_interval
= ConfigDefaults::bgp_riswhois_refresh_interval();
let roa_aggregate_threshold = 3;
let roa_deaggregate_threshold = 2;
@@ -1252,9 +1263,10 @@ impl Config {
post_limit_rfc6492,
rfc6492_log_dir: None,
post_protocol_msg_timeout_seconds,
bgp_api_enabled,
bgp_api_uri,
bgp_api_cache_duration,
bgp_riswhois_enabled,
bgp_riswhois_v4_uri,
bgp_riswhois_v6_uri,
bgp_riswhois_refresh_interval,
roa_aggregate_threshold,
roa_deaggregate_threshold,
issuance_timing,
+8 -8
View File
@@ -902,13 +902,13 @@ async fn routes_try(
= request.read_json::<RoaConfigurationUpdates>().await?;
let effect = server.krill().ca_routes_bgp_dry_run(
&ca, updates.clone()
).await?;
)?;
if effect.contains_invalids() {
updates.set_explicit_max_length();
let resources = updates.affected_prefixes();
let suggestion = server.krill().ca_routes_bgp_suggest(
&ca, Some(resources)
).await?;
)?;
Ok(HttpResponse::json(
&BgpAnalysisAdvice {
effect, suggestion,
@@ -927,14 +927,14 @@ async fn routes_analysis(
ca: CaHandle,
) -> Result<HttpResponse, DispatchError> {
match path.next() {
Some("full") => routes_analysis_full(request, path, ca).await,
Some("full") => routes_analysis_full(request, path, ca),
Some("dryrun") => routes_analysis_dryrun(request, path, ca).await,
Some("suggest") => routes_analysis_suggest(request, path, ca).await,
_ => Ok(HttpResponse::not_found())
}
}
async fn routes_analysis_full(
fn routes_analysis_full(
request: Request<'_>,
path: PathIter<'_>,
ca: CaHandle,
@@ -946,7 +946,7 @@ async fn routes_analysis_full(
)?;
let server = request.empty()?;
Ok(HttpResponse::json(
&server.krill().ca_routes_bgp_analysis(&ca).await?
&server.krill().ca_routes_bgp_analysis(&ca)?
))
}
@@ -962,7 +962,7 @@ async fn routes_analysis_dryrun(
)?;
let (server, updates) = request.read_json().await?;
Ok(HttpResponse::json(
&server.krill().ca_routes_bgp_dry_run(&ca, updates).await?
&server.krill().ca_routes_bgp_dry_run(&ca, updates)?
))
}
@@ -979,7 +979,7 @@ async fn routes_analysis_suggest(
)?;
let server = request.empty()?;
Ok(HttpResponse::json(
&server.krill().ca_routes_bgp_suggest(&ca, None).await?
&server.krill().ca_routes_bgp_suggest(&ca, None)?
))
}
Method::POST => {
@@ -990,7 +990,7 @@ async fn routes_analysis_suggest(
Ok(HttpResponse::json(
&server.krill().ca_routes_bgp_suggest(
&ca, Some(resources)
).await?
)?
))
}
_ => Ok(HttpResponse::method_not_allowed())
+1 -1
View File
@@ -59,7 +59,7 @@ pub async fn dispatch(
server.authorizer().login_session_cache_size().await,
);
if let Ok(cas_stats) = server.krill().cas_stats().await {
if let Ok(cas_stats) = server.krill().cas_stats() {
target.single(
Metric::gauge("cas", "number of CAs in Krill"),
cas_stats.len()
+1 -1
View File
@@ -58,6 +58,6 @@ async fn cas(
request.check_get()?;
let (request, _) = request.proceed_unchecked();
let server = request.empty()?;
Ok(HttpResponse::json(&server.krill().cas_stats().await?))
Ok(HttpResponse::json(&server.krill().cas_stats()?))
}
+998
View File
@@ -0,0 +1,998 @@
//! The actual BGP analyser and its supporting, private data structures.
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use arc_swap::ArcSwapOption;
use chrono::{DateTime, Duration, Utc};
use log::trace;
use rpki::repository::resources::{IpBlock, ResourceSet};
use rpki::repository::x509::Time;
use crate::api::bgp::{
Announcement, BgpAnalysisEntry, BgpAnalysisReport, BgpAnalysisState,
BgpAnalysisSuggestion, ReplacementRoaSuggestion,
};
use crate::api::roa::{
AsNumber, ConfiguredRoa, Ipv4Prefix, Ipv6Prefix, RoaPayload, TypedPrefix,
};
use crate::config::Config;
use super::riswhois::{
RisWhois, RisWhoisError, RisWhoisLoader, RouteOrigin, RouteOriginSet,
RoutePrefix,
};
//------------ BgpAnalyser -------------------------------------------------
/// An analyser for the effects of ROAs against real-world BGP data.
///
/// The analyser is configured with the URLs of the RISwhois dumps and
/// whether to download them at all and if so, how often. It doesnt download
/// data immediately but only when the [`update`][Self::update] method is
/// called.
///
/// There are two methods that perform an analyis: [`analyse`][Self::analyse]
/// produces a report for an existing set of ROAs while
/// [`suggest`][Self::suggest] also adds suggestions what ROAs should be
/// created.
pub struct BgpAnalyser {
/// The loader for RISwhois data.
///
/// If this is `None`, loading data has been disabled.
loader: Option<RisWhoisLoader>,
/// How long should we wait before downloading the data again.
refresh_interval: Duration,
/// The last time we downloaded the data.
///
/// This is the Unix timestamp in full seconds of that time. If we never
/// downloaded the data, this will be set to `i64::MIN`.
last_checked: AtomicI64,
/// The current set of RISwhois data.
///
/// This may be `None` if we havent downloaded a set (yet).
riswhois: ArcSwapOption<RisWhois>,
}
impl BgpAnalyser {
/// Creates a new analyser using information in the config.
pub fn new(config: &Config) -> Self {
Self {
loader: config.bgp_riswhois_enabled.then(|| {
RisWhoisLoader::new(
config.bgp_riswhois_v4_uri.clone(),
config.bgp_riswhois_v6_uri.clone(),
)
}),
refresh_interval: config.bgp_riswhois_refresh_interval,
last_checked: i64::MIN.into(),
riswhois: ArcSwapOption::new(None),
}
}
/// Updates the RISwhois dataset.
///
/// This can be called at any time and will only actually download data
/// if downloading has been enabled and if the configured refresh
/// duration has passed since the last download.
///
/// Returns `Ok(true)` if it did do a download, `Ok(false)` if no download
/// was necessary, or an error if downloading was attempted but failed.
pub async fn update(&self) -> Result<bool, RisWhoisError> {
let Some(loader) = self.loader.as_ref() else {
return Ok(false)
};
let last_checked = Time::new(
DateTime::from_timestamp(
self.last_checked.load(Ordering::Relaxed), 0
).unwrap_or(DateTime::<Utc>::MIN_UTC)
);
if last_checked + self.refresh_interval >= Time::now() {
trace!(
"RISwhois update requested but refresh duration \
has not yet passed."
);
return Ok(false)
}
self.riswhois.store(Some(Arc::new(loader.load().await?)));
self.last_checked.store(Time::now().timestamp(), Ordering::Relaxed);
Ok(true)
}
/// Creates a BGP analysis report for a set of ROAs and resources.
///
/// The ROAs to be analysed are given via `roas` and the resources held
/// by the CA publishing the ROAs via `resources_held`. If required,
/// the ROAs to be analysed can be limited to those covered by the
/// resource set given through `limited_scope`.
///
/// The method returns a BGP analysis report providing information on
/// how the ROAs and resources based on the current RISwhois data. If no
/// data is currently available, the report will contain “no announcement
/// info” for each ROA.
pub fn analyse(
&self,
roas: &[ConfiguredRoa],
resources_held: &ResourceSet,
limited_scope: Option<ResourceSet>,
) -> BgpAnalysisReport {
let mut entries = Vec::new();
// Create a list of the ROAs that are contained in the held
// resources but not in the limited scope. Everything that is in
// neither goes directly into the `entries` as not held.
let mut roas_held = Vec::new();
for roa in roas {
if let Some(limit) = limited_scope.as_ref() {
if !limit.contains_roa_address(
&roa.roa_configuration.payload.as_roa_ip_address()
) {
continue
}
}
if resources_held.contains_roa_address(
&roa.roa_configuration.payload.as_roa_ip_address()
) {
roas_held.push(roa.clone());
}
else {
entries.push(BgpAnalysisEntry::roa_not_held(roa.clone()));
}
}
// If we dont have RISwhois data, we can add the held ROAs as well
// and return.
let seen = self.riswhois.load();
let Some(seen) = seen.as_ref() else {
for roa in roas_held {
entries.push(BgpAnalysisEntry::roa_no_announcement_info(roa));
}
return BgpAnalysisReport::new(entries)
};
// Determine the scope of our analysis. This is `limited_scope` if
// present or the held resources otherwise.
let scope = limited_scope.as_ref().unwrap_or(resources_held);
// Convert the scope to a list of prefixes for v4 and v6 each.
let (v4_scope, v6_scope) = Self::get_prefixes_from_scope(scope);
// The original code now collects all route origins seen under these
// prefixes into `scoped_announcements`. We dont really need that
// since we can just walk our trees if necessary.
// Extract the ROA prefixes and break them up into v4 and v6.
let (v4_roas, v6_roas) = Self::split_roas(&roas_held);
// Next, go over all route origins in scope and validate them using
// the held ROAs.
let mut v4_validated = Vec::new();
for v4 in v4_scope {
for route_origins in seen.v4().eq_or_more_specific(v4) {
ValidatedRouteOrigin::validate_set(
route_origins, &v4_roas, &mut v4_validated,
)
}
}
let mut v6_validated = Vec::new();
for v6 in v6_scope {
for route_origins in seen.v6().eq_or_more_specific(v6) {
ValidatedRouteOrigin::validate_set(
route_origins, &v6_roas, &mut v6_validated,
)
}
}
// Finally, go over all ROAs and determine their state based on the
// validated route_origins.
for roa in &v4_roas {
entries.push(
Self::categorise_roa(
*roa, &v4_validated, &v4_roas,
)
);
}
for roa in &v6_roas {
entries.push(
Self::categorise_roa(
*roa, &v6_validated, &v6_roas,
)
);
}
// Add the status of all route origins.
entries.extend(v4_validated.into_iter().map(|origin| {
origin.into_analysis_entry()
}));
entries.extend(v6_validated.into_iter().map(|origin| {
origin.into_analysis_entry()
}));
BgpAnalysisReport::new(entries)
}
/// Create a BGP suggestions report for a set of ROAs and resources.
///
/// This is very similar to the [`analyse`][Self::analyse] method but
/// the returned report also contains suggestions what ROAs the CA should
/// contain.
pub fn suggest(
&self,
roas: &[ConfiguredRoa],
resources_held: &ResourceSet,
limited_scope: Option<ResourceSet>,
) -> BgpAnalysisSuggestion {
let mut suggestion = BgpAnalysisSuggestion::default();
// perform analysis
let entries = self.analyse(
roas, resources_held, limited_scope
).into_entries();
for entry in &entries {
match entry.state() {
BgpAnalysisState::RoaUnseen => {
suggestion.stale.push(entry.configured_roa().clone())
}
BgpAnalysisState::RoaTooPermissive => {
let replace_with = entry
.authorizes()
.iter()
.filter(|ann| {
!entries.iter().any(|other| {
other != entry
&& other.authorizes().contains(*ann)
})
})
.map(|auth| RoaPayload::from(*auth))
.collect();
suggestion.too_permissive.push(
ReplacementRoaSuggestion {
current: entry.configured_roa().clone(),
new: replace_with,
}
);
}
BgpAnalysisState::RoaSeen | BgpAnalysisState::RoaAs0 => {
suggestion.keep.push(entry.configured_roa().clone())
}
BgpAnalysisState::RoaDisallowing => {
suggestion.disallowing.push(entry.configured_roa().clone())
}
BgpAnalysisState::RoaRedundant => {
suggestion.redundant.push(entry.configured_roa().clone())
}
BgpAnalysisState::RoaNotHeld => {
suggestion.not_held.push(entry.configured_roa().clone())
}
BgpAnalysisState::RoaAs0Redundant => {
suggestion.as0_redundant.push(
entry.configured_roa().clone()
)
}
BgpAnalysisState::AnnouncementValid => {}
BgpAnalysisState::AnnouncementNotFound => {
suggestion.not_found.push(entry.announcement())
}
BgpAnalysisState::AnnouncementInvalidAsn => {
suggestion.invalid_asn.push(entry.announcement())
}
BgpAnalysisState::AnnouncementInvalidLength => {
suggestion.invalid_length.push(entry.announcement())
}
BgpAnalysisState::AnnouncementDisallowed => {
suggestion.keep_disallowing.push(entry.announcement())
}
BgpAnalysisState::RoaNoAnnouncementInfo => {
suggestion.keep.push(entry.configured_roa().clone())
}
}
}
suggestion
}
/// Returns the address prefixes contained in a resource set.
///
/// The function will return two vecs, one for IPv4 and one for IPv6
/// prefixes.
fn get_prefixes_from_scope(
scope: &ResourceSet
) -> (Vec<Ipv4Prefix>, Vec<Ipv6Prefix>) {
let mut v4 = Vec::new();
for block in scope.ipv4().iter() {
match block {
IpBlock::Prefix(prefix) => v4.push(Ipv4Prefix::from(prefix)),
IpBlock::Range(range) => {
v4.extend(range.to_v4_prefixes().map(Ipv4Prefix::from))
}
}
}
let mut v6 = Vec::new();
for block in scope.ipv6().iter() {
match block {
IpBlock::Prefix(prefix) => v6.push(Ipv6Prefix::from(prefix)),
IpBlock::Range(range) => {
v6.extend(range.to_v6_prefixes().map(Ipv6Prefix::from))
}
}
}
// XXX This chould drop prefixes that are covered by other prefixes.
// Allthough, in practice, this shouldnt happen, since the
// relevant RFC doesnt allow resource sets to be like that.
(v4, v6)
}
/// Splits a set of ROAs into those for IPv4 and IPv6.
///
/// The function wraps each ROA into a type that also contains the
/// address prefix of the ROA and a reference to the [`ConfiguredRoa`].
fn split_roas(
roas: &[ConfiguredRoa]
) -> (Vec<Roa<'_, Ipv4Prefix>>, Vec<Roa<'_, Ipv6Prefix>>) {
let mut v4 = Vec::new();
let mut v6 = Vec::new();
for roa in roas {
match roa.roa_configuration.payload.prefix {
TypedPrefix::V4(prefix) => v4.push(Roa::new(prefix, roa)),
TypedPrefix::V6(prefix) => v6.push(Roa::new( prefix, roa)),
}
}
(v4, v6)
}
/// Categorises a ROA for analysis.
///
/// The function takes a roa, a set of validated route origins, and the
/// set of all ROAs and translates it into a [`BgpAnalysisEntry`] for the
/// report.
fn categorise_roa<P: RoutePrefix>(
roa: Roa<P>,
validated_origins: &[ValidatedRouteOrigin<P>],
all_roas: &[Roa<P>],
) -> BgpAnalysisEntry {
// Get all validated origins covered by the prefix.
let covered = validated_origins.iter().filter(|origin| {
roa.prefix.covers(origin.route_origin.prefix)
}).collect::<Vec<_>>();
// Find other ROAs that cover this ROA. Their max-len may be less,
// so they dont make this announcement superfluous.
let other_roas_covering_this_prefix = all_roas.iter().filter(|other| {
other.prefix.covers(roa.prefix) && roa.payload() != other.payload()
}).map(|roa| roa.payload()).collect::<Vec<_>>();
// Find other ROAs that include this ROAs definition and thus make
// it superfluous.
let other_roas_including_this_definition
= other_roas_covering_this_prefix.iter().filter(|other| {
other.asn == roa.origin()
&& other.prefix.addr_len() <= roa.prefix.addr_len()
&& other.effective_max_length() >= roa.effective_max_len()
}).copied().collect::<Vec<_>>();
// Find all route origins that are made valid by this ROA.
//
// (Using filter and then map here makes the code quite a bit
// easier ...)
let authorizes = covered.iter().filter(|origin| {
matches!(origin.validity, RouteOriginValidity::Valid(_))
&& origin.route_origin.prefix.addr_len()
<= roa.effective_max_len()
&& origin.route_origin.origin == roa.origin()
}).map(|origin| origin.announcement()).collect::<Vec<_>>();
// Find all route origins that are made invalid by this ROA.
let disallows = covered.iter().filter(|origin| {
matches!(
origin.validity,
RouteOriginValidity::InvalidLength
| RouteOriginValidity::InvalidAsn
)
}).map(|origin| origin.announcement()).collect::<Vec<_>>();
// Is the ROA too permissive?
//
// XXX: I dont understand why this does what it does.
let authorizes_excess = {
let max_len = roa.effective_max_len();
let nr_of_specific_origins = u128::try_from(
authorizes.iter().filter(|origin| {
origin.prefix.addr_len() == max_len
}).count()
).unwrap_or(u128::MAX);
nr_of_specific_origins > 0
&& nr_of_specific_origins
< roa.payload().nr_of_specific_prefixes()
};
// Now we have everything we need to categorize the ROA.
if roa.origin() == AsNumber::AS0 {
if other_roas_covering_this_prefix.is_empty() {
// Disallows all covered route origins.
BgpAnalysisEntry::roa_as0(
roa.roa.clone(),
covered.iter().map(|origin| {
origin.announcement()
}).collect()
)
}
else {
// This AS0 ROA is redundant.
BgpAnalysisEntry::roa_as0_redundant(
roa.roa.clone(),
other_roas_covering_this_prefix,
)
}
}
else if !other_roas_including_this_definition.is_empty() {
BgpAnalysisEntry::roa_redundant(
roa.roa.clone(),
authorizes,
disallows,
other_roas_including_this_definition
)
}
else if authorizes.is_empty() && disallows.is_empty() {
BgpAnalysisEntry::roa_unseen(roa.roa.clone())
}
else if authorizes_excess {
BgpAnalysisEntry::roa_too_permissive(
roa.roa.clone(), authorizes, disallows
)
}
else if authorizes.is_empty() {
BgpAnalysisEntry::roa_disallowing(roa.roa.clone(), disallows)
}
else {
BgpAnalysisEntry::roa_seen(roa.roa.clone(), authorizes, disallows)
}
}
}
//------------ Roa -----------------------------------------------------------
/// A configured ROA plus its address prefix.
///
/// This type only exists to be generic over the address family. Thus, `P`
/// can either be [`Ipv4Prefix`] or [`Ipv6Prefix`].
#[derive(Clone, Copy, Debug)]
pub struct Roa<'a, P> {
/// The address prefix of the ROA.
prefix: P,
/// A reference to the actual ROA.
roa: &'a ConfiguredRoa,
}
impl<'a, P: RoutePrefix> Roa<'a, P> {
/// Creates a new value from its parts.
fn new(prefix: P, roa: &'a ConfiguredRoa) -> Self {
Self { prefix, roa }
}
/// Returns the ROA payload definition of the ROA.
fn payload(self) -> RoaPayload {
self.roa.roa_configuration.payload
}
/// Returns the origin AS number of the ROA definition.
fn origin(self) -> AsNumber {
self.roa.roa_configuration.payload.asn
}
/// Retuns the maximum prefix length of the ROA definition.
fn max_len(self) -> Option<u8> {
self.roa.roa_configuration.payload.max_length
}
/// Returns the effective maximum prefix length of the ROA definition.
///
/// This is the maximum prefix length if provided or the address prefix
/// length otherwise.
fn effective_max_len(self) -> u8 {
self.max_len().unwrap_or(self.prefix.addr_len())
}
}
//------------ ValidatedRouteOrigin ------------------------------------------
/// A route origin with route origin validation applied to it.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ValidatedRouteOrigin<P> {
/// The route origin, i.e., address prefix and origin AS number.
route_origin: RouteOrigin<P>,
/// The validation status of the route origin.
validity: RouteOriginValidity,
/// The ROAs that contributed to invalidating the route origin.
disallowing: Vec<RoaPayload>,
}
impl<P: RoutePrefix> ValidatedRouteOrigin<P> {
/// Validates a set of route origin against a set of ROAs.
///
/// Appends the validation verdict for each route origin to the end of
/// `target`.
fn validate_set(
route_origins: RouteOriginSet<P>,
roas: &[Roa<P>],
target: &mut Vec<Self>,
) {
// Find all ROAs that cover the route origins prefix.
let covering = roas.iter().copied().filter(|roa| {
roa.prefix.covers(route_origins.prefix())
}).collect::<Vec<_>>();
// If there arent any, all route origins in the set are unknown.
if covering.is_empty() {
target.extend(route_origins.iter().map(|origin| {
Self {
route_origin: origin,
validity: RouteOriginValidity::NotFound,
disallowing: Vec::new(),
}
}));
return
}
for origin in route_origins.iter() {
target.push(Self::validate(origin, &covering));
}
}
/// Validates a single route origin against a set of ROAs.
///
/// Returns the verdict.
fn validate(
origin: RouteOrigin<P>,
covering: &[Roa<P>]
) -> Self {
let mut invalidating = Vec::new();
let mut same_asn_found = false;
let mut none_as0_found = false;
for roa in covering.iter().copied() {
if roa.origin() == origin.origin {
if roa.prefix.covers(origin.prefix)
&& roa.effective_max_len() >= origin.prefix.addr_len()
{
return Self {
route_origin: origin,
validity: RouteOriginValidity::Valid(roa.payload()),
disallowing: Vec::new(),
}
}
else {
same_asn_found = true;
}
}
if roa.origin() != AsNumber::AS0 {
none_as0_found = true;
}
invalidating.push(roa.payload());
}
Self {
route_origin: origin,
validity: if same_asn_found {
RouteOriginValidity::InvalidLength
}
else if none_as0_found {
RouteOriginValidity::InvalidAsn
}
else {
RouteOriginValidity::Disallowed
},
disallowing: invalidating,
}
}
/// Returns the announcement correlating with the route origin.
///
/// “Announcement” is the term used in the API for a route origin.
fn announcement(&self) -> Announcement {
self.route_origin.into()
}
/// Converts the value into a BGP analysis entry.
fn into_analysis_entry(self) -> BgpAnalysisEntry {
match self.validity {
RouteOriginValidity::Valid(roa) => {
BgpAnalysisEntry::announcement_valid(
self.route_origin.into(), roa
)
}
RouteOriginValidity::Disallowed => {
BgpAnalysisEntry::announcement_disallowed(
self.route_origin.into(),
self.disallowing,
)
}
RouteOriginValidity::InvalidLength => {
BgpAnalysisEntry::announcement_invalid_length(
self.route_origin.into(),
self.disallowing,
)
}
RouteOriginValidity::InvalidAsn => {
BgpAnalysisEntry::announcement_invalid_asn(
self.route_origin.into(),
self.disallowing,
)
}
RouteOriginValidity::NotFound => {
BgpAnalysisEntry::announcement_not_found(
self.route_origin.into(),
)
}
}
}
}
//------------ RouteOriginValidity -------------------------------------------
/// The status of a route origin after validation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RouteOriginValidity {
/// The route origin is valid.
///
/// The included ROA is the one that made the origin valid.
Valid(RoaPayload),
/// The route origin is invalid due to having an invalid prefix length.
InvalidLength,
/// The route origin is invalid due to having an invalid origin AS number.
InvalidAsn,
/// The route origin is invalid due to an AS0 ROA.
Disallowed,
/// No covering ROA exists and the route origin is “not found.”
NotFound,
}
//------------ Tests --------------------------------------------------------
#[cfg(test)]
mod tests {
use std::fmt;
use std::str::FromStr;
use crate::api::roa::RoaConfigurationUpdates;
use crate::commons::test::{configured_roa};
use super::super::riswhois::RouteOriginCollection;
use super::*;
fn ann(s: &str) -> Announcement {
Announcement::from_str(s).unwrap()
}
fn test_analyser() -> BgpAnalyser {
fn origin<P>(prefix: &str, origin: u32) -> RouteOrigin<P>
where
P: FromStr,
<P as FromStr>::Err: fmt::Debug
{
RouteOrigin {
prefix: P::from_str(prefix).unwrap(),
origin: AsNumber::from_u32(origin)
}
}
BgpAnalyser {
loader: None,
refresh_interval: Duration::seconds(12),
last_checked: i64::MIN.into(),
riswhois: ArcSwapOption::new(Some(Arc::new(RisWhois::new(
RouteOriginCollection::new(
vec![
origin("10.0.0.0/22", 64496),
origin("10.0.2.0/23", 64496),
origin("10.0.0.0/24", 64496),
origin("10.0.0.0/22", 64497),
origin("10.0.0.0/21", 64497),
origin("192.168.0.0/24", 64497),
origin("192.168.0.0/24", 64496),
origin("192.168.1.0/24", 64497),
]
).unwrap(),
RouteOriginCollection::new(
vec![
origin("2001:DB8::/32", 64498),
]
).unwrap(),
))))
}
}
fn test_analyser_full() -> BgpAnalyser {
let v4 = RisWhoisLoader::parse_data(include_bytes!(
"../../../test-resources/bgp/riswhoisdump.IPv4"
).as_ref()).unwrap();
let v6 = RisWhoisLoader::parse_data(include_bytes!(
"../../../test-resources/bgp/riswhoisdump.IPv6"
).as_ref()).unwrap();
let ris = RisWhois::new(v4, v6);
BgpAnalyser {
loader: None,
refresh_interval: Duration::seconds(12),
last_checked: i64::MIN.into(),
riswhois: ArcSwapOption::new(Some(Arc::new(ris))),
}
}
fn empty_analyser() -> BgpAnalyser {
BgpAnalyser {
loader: None,
refresh_interval: Duration::seconds(12),
last_checked: i64::MIN.into(),
riswhois: ArcSwapOption::new(None),
}
}
#[test]
fn analyse_bgp() {
let roa_too_permissive = configured_roa("10.0.0.0/22-23 => 64496");
let roa_as0 = configured_roa("10.0.4.0/24 => 0");
let roa_unseen_completely = configured_roa("10.0.3.0/24 => 64497");
let roa_not_held = configured_roa("10.1.0.0/24 => 64497");
let roa_authorizing_single =
configured_roa("192.168.1.0/24 => 64497");
let roa_unseen_redundant = configured_roa("192.168.1.0/24 => 64498");
let roa_as0_redundant = configured_roa("192.168.1.0/24 => 0");
let resources_held =
ResourceSet::from_strs("", "10.0.0.0/16, 192.168.0.0/16", "")
.unwrap();
let limit = None;
let analyser = test_analyser();
let report = analyser.analyse(
&[
roa_too_permissive,
roa_as0,
roa_unseen_completely,
roa_not_held,
roa_authorizing_single,
roa_unseen_redundant,
roa_as0_redundant,
],
&resources_held,
limit,
);
let expected: BgpAnalysisReport = serde_json::from_str(include_str!(
"../../../test-resources/bgp/expected_full_report.json"
))
.unwrap();
assert_eq!(report, expected);
}
#[test]
fn analyse_bgp_disallowed_announcements() {
let roa = configured_roa("10.0.0.0/22 => 0");
let roas = &[roa];
let analyser = test_analyser();
let resources_held =
ResourceSet::from_strs("", "10.0.0.0/8, 192.168.0.0/16", "")
.unwrap();
let report = analyser.analyse(roas, &resources_held, None);
assert!(!report.contains_invalids());
let mut disallowed = report
.matching_announcements(BgpAnalysisState::AnnouncementDisallowed);
disallowed.sort();
let disallowed_1 = ann("10.0.0.0/22 => 64496");
let disallowed_2 = ann("10.0.0.0/22 => 64497");
let disallowed_3 = ann("10.0.0.0/24 => 64496");
let disallowed_4 = ann("10.0.2.0/23 => 64496");
let mut expected =
vec![disallowed_1, disallowed_2, disallowed_3, disallowed_4];
expected.sort();
assert_eq!(disallowed, expected);
// The suggestion should not try to add the disallowed announcements
// because they were disallowed by an AS0 roa.
let suggestion = analyser.suggest(roas, &resources_held, None);
let updates = RoaConfigurationUpdates::from(suggestion);
let added = &updates.added;
for announcement in disallowed {
assert!(!added.iter().any(|added_roa| {
let added_payload = added_roa.payload;
let announcement_payload = RoaPayload::from(announcement);
added_payload.includes(announcement_payload)
}));
}
}
#[test]
fn analyse_bgp_no_announcements() {
let roa1 = configured_roa("10.0.0.0/23-24 => 64496");
let roa2 = configured_roa("10.0.3.0/24 => 64497");
let roa3 = configured_roa("10.0.4.0/24 => 0");
let roas = vec![roa1, roa2, roa3];
let resources_held =
ResourceSet::from_strs("", "10.0.0.0/16", "").unwrap();
let analyser = empty_analyser();
let table = analyser.analyse(&roas, &resources_held, None);
let table_entries = table.entries();
assert_eq!(3, table_entries.len());
let roas_no_info: Vec<ConfiguredRoa> = table_entries
.iter()
.filter(|e| e.state() == BgpAnalysisState::RoaNoAnnouncementInfo)
.map(|e| e.configured_roa().clone())
.collect();
assert_eq!(roas_no_info, roas);
}
#[test]
fn make_bgp_analysis_suggestion() {
let roa_too_permissive = configured_roa("10.0.0.0/22-23 => 64496");
let roa_redundant = configured_roa("10.0.0.0/23 => 64496");
let roa_as0 = configured_roa("10.0.4.0/24 => 0");
let roa_unseen_completely = configured_roa("10.0.3.0/24 => 64497");
let roa_authorizing_single =
configured_roa("192.168.1.0/24 => 64497");
let roa_unseen_redundant = configured_roa("192.168.1.0/24 => 64498");
let roa_as0_redundant = configured_roa("192.168.1.0/24 => 0");
let roas = &[
roa_too_permissive,
roa_redundant,
roa_as0,
roa_unseen_completely,
roa_authorizing_single,
roa_unseen_redundant,
roa_as0_redundant,
];
let analyser = test_analyser();
let resources_held =
ResourceSet::from_strs("", "10.0.0.0/8, 192.168.0.0/16", "")
.unwrap();
let limit =
Some(ResourceSet::from_strs("", "10.0.0.0/22", "").unwrap());
let suggestion_resource_subset =
analyser.suggest(roas, &resources_held, limit);
let expected: BgpAnalysisSuggestion =
serde_json::from_str(include_str!(
"../../../test-resources/bgp/expected_suggestion_some_roas.json"
))
.unwrap();
assert_eq!(suggestion_resource_subset, expected);
let suggestion_all_roas_in_scope =
analyser.suggest(roas, &resources_held, None);
let expected: BgpAnalysisSuggestion =
serde_json::from_str(include_str!(
"../../../test-resources/bgp/expected_suggestion_all_roas.json"
))
.unwrap();
assert_eq!(suggestion_all_roas_in_scope, expected);
}
#[test]
fn analyse_nlnet_labs_snapshot() {
let analyser = test_analyser_full();
let asns = "AS204325, AS211321";
let ipv4s = "185.49.140.0/22";
let ipv6s = "2a04:b900::/29";
let set = ResourceSet::from_strs(asns, ipv4s, ipv6s).unwrap();
let roas = &[
configured_roa("2a04:b906::/48-48 => 0"),
configured_roa("2a04:b907::/48-48 => 0"),
configured_roa("185.49.142.0/24-24 => 0"),
configured_roa("2a04:b900::/30-32 => 8587"),
configured_roa("185.49.140.0/23-23 => 8587"),
configured_roa("2a04:b900::/30-30 => 8587"),
configured_roa("2a04:b905::/48-48 => 16509"),
configured_roa("2a04:b904::/48-48 => 211321"),
configured_roa("2a04:b907::/47-47 => 211321"),
configured_roa("185.49.142.0/23-23 => 211321"),
configured_roa("2a04:b902::/48-48 => 211321"),
configured_roa("185.49.143.0/24-24 => 211321"),
];
let report = analyser.analyse(roas, &set, None);
let entry_expect_roa = |x: &str, y| {
let x = x.to_string();
dbg!(&x, &y);
assert!(report.entries().iter().any(|s|
s.state() == y &&
s.configured_roa().to_string() == x
));
};
let entry_expect_ann = |x: &str, y: u32, z: BgpAnalysisState| {
let x = x.to_string();
dbg!(&x, &y, &z);
assert!(report.entries().iter().any(|s|
s.state() == z &&
s.announcement().asn == AsNumber::from_u32(y) &&
s.announcement().prefix.to_string() == x
));
};
entry_expect_roa(
"2a04:b906::/48-48 => 0", BgpAnalysisState::RoaAs0
);
entry_expect_roa(
"2a04:b907::/48-48 => 0", BgpAnalysisState::RoaAs0Redundant
);
entry_expect_roa(
"185.49.142.0/24-24 => 0", BgpAnalysisState::RoaAs0Redundant
);
entry_expect_roa(
"2a04:b900::/30-32 => 8587", BgpAnalysisState::RoaSeen
);
entry_expect_roa(
"185.49.140.0/23-23 => 8587", BgpAnalysisState::RoaSeen
);
entry_expect_roa(
"2a04:b900::/30-30 => 8587", BgpAnalysisState::RoaRedundant
);
entry_expect_roa(
"2a04:b905::/48-48 => 16509", BgpAnalysisState::RoaSeen
);
entry_expect_roa(
"2a04:b904::/48-48 => 211321", BgpAnalysisState::RoaSeen
);
entry_expect_roa(
"2a04:b907::/47-47 => 211321", BgpAnalysisState::RoaSeen
);
entry_expect_roa(
"185.49.142.0/23-23 => 211321", BgpAnalysisState::RoaSeen
);
entry_expect_ann(
"2a04:b907::/48", 211321,
BgpAnalysisState::AnnouncementInvalidLength
);
entry_expect_ann(
"185.49.142.0/24", 211321,
BgpAnalysisState::AnnouncementInvalidLength
);
entry_expect_roa(
"2a04:b902::/48-48 => 211321", BgpAnalysisState::RoaUnseen
);
entry_expect_roa(
"185.49.143.0/24-24 => 211321", BgpAnalysisState::RoaUnseen
);
}
}
+14
View File
@@ -0,0 +1,14 @@
//! The analyser for checking and suggesting ROAs.
//!
//! The analyser, [`BgpAnalyser`], downloads RISwhois dumps which contain
//! prefixes and origins seen in real BGP data by RIS and stores them in
//! memory. Based on this data, it checks whether the ROAs for a given CA
//! reflect what is seen by RIS and can make suggestions which ROAs should
//! be created.
pub use self::analyser::BgpAnalyser;
pub use self::riswhois::RisWhoisError;
mod analyser;
mod riswhois;
+986
View File
@@ -0,0 +1,986 @@
//! The data from a RISwhois data set.
//!
//! These datasets provide the originating AS numbers for address prefixes
//! as encountered in BGP data collected by RIS. The [`RisWhois`] type in
//! this module collects all this data and makes it available for querying.
// This code only works with `usize` of at least 32 bits.
#[cfg(target_pointer_width = "16")]
compile_error!("cannot build on 16 bit systems");
use std::{cmp, error, fmt, io};
use std::io::BufReader;
use std::str::FromStr;
use libflate::gzip;
use crate::api::roa::{AsNumber, Ipv4Prefix, Ipv6Prefix, TypedPrefix};
use crate::api::bgp::Announcement;
//------------ Configuration -------------------------------------------------
/// How often to we need to see a route origin before accepting it.
///
/// For each pair of address prefix and origin AS number, RISwhois also lists
/// how many of the peers of RIS have seen this pair in their BGP streams.
/// Pairs that are only seen by very few peers are likely there by mistake
/// and should be filtered out. This constant sets the minimum number of
/// stream that have to have seen a pair for us to include it in our data.
///
/// This number was at some point recommended by RIS.
const MINIMUM_SEEN_BY: u32 = 13;
//------------ RisWhoisLoader ------------------------------------------------
/// A type that knows where RISwhois data lives and download it.
pub struct RisWhoisLoader {
/// The HTTP(S) URL of the location of IPv4 data set.
v4_url: String,
/// The HTTP(S) URL of the location of IPv6 data set.
v6_url: String,
}
impl RisWhoisLoader {
/// Creates a new loader from the URLS of the IPv4 and IPv6 data sets.
pub fn new(v4_url: String, v6_url: String) -> Self {
Self { v4_url, v6_url }
}
/// Downloads and processes a new data set.
pub async fn load(&self) -> Result<RisWhois, RisWhoisError> {
Ok(RisWhois::new(
Self::load_tree(&self.v4_url).await?,
Self::load_tree(&self.v6_url).await?,
))
}
/// Downloads and process the tree for one address family.
async fn load_tree<P: FromStr + RoutePrefix>(
uri: &str
) -> Result<RouteOriginCollection<P>, RisWhoisError>
where <P as FromStr>::Err: error::Error + Send + Sync + 'static {
Self::parse_gz_data(
&reqwest::get(uri).await.map_err(|err| {
RisWhoisError::new(uri, io::Error::other(err))
})?.bytes().await.map_err(|err| {
RisWhoisError::new(uri, io::Error::other(err))
})?
).map_err(|err| RisWhoisError::new(uri, err))
}
/// Parses the gzipped data.
fn parse_gz_data<P: FromStr + RoutePrefix>(
data: &[u8]
) -> Result<RouteOriginCollection<P>, io::Error>
where <P as FromStr>::Err: error::Error + Send + Sync + 'static {
let data = BufReader::new(
gzip::Decoder::new(data)?
);
Self::parse_data(data)
}
/// Parses the raw data.
pub(super) fn parse_data<P: FromStr + RoutePrefix>(
data: impl io::BufRead,
) -> Result<RouteOriginCollection<P>, io::Error>
where <P as FromStr>::Err: error::Error + Send + Sync + 'static {
let mut res = Vec::new();
for line in data.lines() {
// Each line is as follows:
//
// o empty lines and lines starting with % are ignored.
// o all other lines consist of three string separated by
// white space (technically: a single HTAB):
//
// o origin AS number as an integer,
// o prefix as IP address slash prefix length,
// o number of peers that have seen this pair.
//
// Instead of the origin AS number, there may be an AS set
// as a sequence of comma separated AS numbers surrounded by
// curly braces. We ignore those.
//
// If the number of peers that have seen a pair is smaller
// than `MINIMUM_SEEN_BY`, the line is also ignored.
let line = line?;
if line.is_empty() || line.starts_with('%') {
continue;
}
let mut values = line.split_whitespace();
let asn_str = values.next().ok_or(
io::Error::other("missing column")
)?;
let prefix_str = values.next().ok_or(
io::Error::other("missing column")
)?;
let peers = values.next().ok_or(
io::Error::other("missing column")
)?;
if u32::from_str(peers).map_err(io::Error::other)?
< MINIMUM_SEEN_BY
{
continue;
}
if asn_str.contains('{') {
continue; // assets not supported (not important here either)
}
let origin = AsNumber::from_str(asn_str).map_err(io::Error::other)?;
let prefix = P::from_str(prefix_str).map_err(|err| {
io::Error::other(err)
})?;
res.push(RouteOrigin { prefix, origin });
}
Ok(RouteOriginCollection::new(res).unwrap())
}
}
//------------ RisWhois ------------------------------------------------------
/// A set of RISwhois data.
///
/// This data consists of two route origin collections, one for IPv4 and one
/// for IPv6.
#[derive(Clone, Default)]
pub struct RisWhois {
/// The IPv4 route origin collection.
v4: RouteOriginCollection<Ipv4Prefix>,
/// The IPv6 route origin collection.
v6: RouteOriginCollection<Ipv6Prefix>,
}
impl RisWhois {
/// Creates a new data set from the IPv4 and IPv6 data.
pub fn new(
v4: RouteOriginCollection<Ipv4Prefix>,
v6: RouteOriginCollection<Ipv6Prefix>,
) -> Self {
Self { v4, v6 }
}
/// Returns the IPv4 route origin collection.
pub fn v4(&self) -> &RouteOriginCollection<Ipv4Prefix> {
&self.v4
}
/// Returns the IPv6 route origin collection.
pub fn v6(&self) -> &RouteOriginCollection<Ipv6Prefix> {
&self.v6
}
}
//------------ RouteOriginCollection -----------------------------------------
/// A collection of RISwhois route origins.
///
/// This type keeps the route origins for the prefix type `P`, which can be
/// [`Ipv4Prefix`] or [`Ipv6Prefix`]. It is read-only, allowing to iterate
/// over part of the data.
///
/// It currently only supports iterating over the more specifics of a given
/// prefix since that is all we need for the BGP analyser.
#[derive(Clone, Debug)]
pub struct RouteOriginCollection<P> {
/// The tree part of the collection.
///
/// The tree nodes are essentially three pointers (but we are using
/// 32 bits to save space): a pointer into data or no-data containing the
/// prefix for the node plus the origin if it points into actual data,
/// a pointer to the left child, and a pointer to the right child.
///
/// The left child is the the nearest longer prefix where the next bit
/// (i.e., the bit at the bit position equal to this prefix length) is
/// zero. The right child has that bit at one. They can, of course, be
/// “none.”
///
/// “No-data” nodes are added for nodes in the tree whose prefix doesnt
/// appear in the data but which are necessary to make the prefix tree
/// work. Unnecessary no-data node -- those that have only a left child
/// or only a right child -- are skipped. The result is that a child
/// node isnt necessarily for the prefix with an address length plus
/// one. This is why we need to keep the prefixes.
///
/// A possible optimization (for later) would be to not keep the prefixes
/// for direct children of a node since we can determine the prefix from
/// the parent plus whether it is a left or right child. However, that
/// makes the tree creation algorithm much more complicated, so this has
/// not yet been done.
tree: Box<[TreeNode]>,
/// The index in `tree` of the root node.
///
/// The root node will always be `P::default()`, i.e., the “0/0” prefix.
/// If it isnt part of the data, it will be an artifical no-data node.
tree_root_idx: TreeIndex,
/// The boxed slice of the data.
data: RouteOriginBox<P>,
/// The boxed slice of the “no-data.”
///
/// This contains prefixes of the empty nodes that we had to add to make
/// the tree work.
no_data: Box<[P]>,
}
impl<P: RoutePrefix> RouteOriginCollection<P> {
/// Creates a new collection from the given list of route origins.
pub fn new(data: Vec<RouteOrigin<P>>) -> Result<Self, LargeDataset> {
CollectionBuilder::new(data).process()
}
/// Returns an iterator over all equal or more sepcific route origins.
///
/// The iterator will start at the origin for the prefix itself, if
/// present, and walk the more specific in prefix order.
pub fn eq_or_more_specific(&self, prefix: P) -> TreeIter<'_, P> {
TreeIter::more_specific(self, prefix)
}
}
impl<P: RoutePrefix> RouteOriginCollection<P> {
/// Returns the node for the given tree index of available.
fn get_tree_node(&self, tree_idx: TreeIndex) -> Option<TreeNode> {
self.tree.get(tree_idx.into_usize()?).copied()
}
/// Returns the prefix for the given data index of available.
fn get_data_prefix(&self, data_idx: DataIndex) -> Option<P> {
match data_idx.into_data() {
Ok(data_idx) => Some(self.data.0.get(data_idx)?.prefix),
Err(no_data_idx) => self.no_data.get(no_data_idx).copied(),
}
}
}
impl<P> Default for RouteOriginCollection<P> {
fn default() -> Self {
Self {
tree: Box::new([]),
tree_root_idx: TreeIndex::none(),
data: RouteOriginBox::default(),
no_data: Box::new([]),
}
}
}
//------------ CollectionBuilder ---------------------------------------------
/// A builder for a route origin collection.
struct CollectionBuilder<P> {
/// The tree part of the collection.
///
/// See [`RouteOriginCollection`] for details.
tree: Vec<TreeNode>,
/// The no-data prefixes.
///
/// See [`RouteOriginCollection`] for details.
no_data: Vec<P>,
/// The data route origins.
///
/// See [`RouteOriginCollection`] for details.
data: RouteOriginBox<P>,
/// The index in `data` with the item next to process.
next_data_idx: usize,
}
impl<P: RoutePrefix> CollectionBuilder<P> {
/// Creates a new collection builder from the route origins.
fn new(data: Vec<RouteOrigin<P>>) -> Self {
let mut data = data.into_boxed_slice();
data.sort();
let data = RouteOriginBox(data);
Self {
tree: Vec::new(),
no_data: Vec::new(),
data,
next_data_idx: 0,
}
}
/// Creates and returns a collection from the builder.
fn process(mut self) -> Result<RouteOriginCollection<P>, LargeDataset> {
let Some(prefix) = self.next_prefix() else {
return Ok(RouteOriginCollection {
tree: Box::new([]),
tree_root_idx: TreeIndex::none(),
data: RouteOriginBox(Box::new([])),
no_data: Box::new([]),
})
};
let node = if prefix == P::default() {
self.advance_data();
self.process_node(
prefix, TreeNode::new(DataIndex::data(0)?)
)?
}
else {
let data = self.push_no_data(P::default())?;
self.process_node(
P::default(), TreeNode::new(data)
)?
};
let tree_root_idx = self.push_node(node)?;
Ok(RouteOriginCollection {
tree: self.tree.into_boxed_slice(),
tree_root_idx,
data: self.data,
no_data: self.no_data.into_boxed_slice()
})
}
/// Processes the given node.
///
/// Adds the necessary nodes and possibly creates no-data intermediary
/// nodes. Returns the node that the caller needs to add to the tree.
fn process_node(
&mut self,
prefix: P,
mut node: TreeNode
) -> Result<TreeNode, LargeDataset> {
loop {
// Get the next prefix or return the node as it is.
let Some(next_prefix) = self.next_prefix() else {
return Ok(node)
};
// If we dont cover the next prefix, return the node as it is.
if !prefix.covers(next_prefix) {
return Ok(node)
}
if !next_prefix.bit(prefix.addr_len()) {
// Next prefix doesnt have the next bit set, so it is a left
// child.
if let Some(left_idx) = node.left.into_usize() {
// If there already is a left child, we need to insert
// an empty node at the closest ancestor of the left
// childs prefix and whatever the next prefix turns into.
let ancestor_prefix = self.node_prefix(
left_idx
).closest_ancestor(next_prefix);
let data = self.push_no_data(ancestor_prefix)?;
let inter_node = self.process_node(
ancestor_prefix,
TreeNode::with_children(
data, node.left, TreeIndex::none()
)
)?;
node.left = self.push_node(inter_node)?;
}
else {
// If there isnt currently a left child, the next item
// will become the left child.
let left_node = TreeNode::new(
DataIndex::data(self.next_data_idx)?
);
self.advance_data();
let left_node = self.process_node(
next_prefix, left_node,
)?;
node.left = self.push_node(left_node)?;
}
}
else {
// Next prefix doesnt have the next bit set, so it is a right
// child.
if let Some(right_idx) = node.right.into_usize() {
// If there already is a right child, we need an empty
// node. The current right child will become the left
// child of that node.
let ancestor_prefix = self.node_prefix(
right_idx
).closest_ancestor(next_prefix);
let data = self.push_no_data(ancestor_prefix)?;
let inter_node = self.process_node(
ancestor_prefix,
TreeNode::with_children(
data, node.right, TreeIndex::none()
)
)?;
node.right = self.push_node(inter_node)?;
}
else {
// No current right child. Add it.
let right_node = TreeNode::new(
DataIndex::data(self.next_data_idx)?
);
self.advance_data();
let right_node = self.process_node(
next_prefix, right_node
)?;
node.right = self.push_node(right_node)?;
}
}
}
}
/// Returns the prefix of the next data item or `None` if we are done.
fn next_prefix(&self) -> Option<P> {
self.data.0.get(self.next_data_idx).map(|item| item.prefix)
}
/// Advances the next data item.
fn advance_data(&mut self) {
self.next_data_idx = self.data.next_prefix(self.next_data_idx);
}
/// Returns the node for the given tree node.
fn node_prefix(&self, node_idx: usize) -> P {
match self.tree[node_idx].data.into_data() {
Ok(idx) => self.data.0[idx].prefix,
Err(idx) => self.no_data[idx]
}
}
/// Appends the given node to the tree, returning its index.
fn push_node(&mut self, node: TreeNode) -> Result<TreeIndex, LargeDataset> {
let res = self.tree.len().try_into()?;
self.tree.push(node);
Ok(res)
}
/// Pushes the prefix to the no-data list and returns the data index.
fn push_no_data(&mut self, prefix: P) -> Result<DataIndex, LargeDataset> {
let res = DataIndex::no_data(self.no_data.len())?;
self.no_data.push(prefix);
Ok(res)
}
}
//------------ RoutePrefix ---------------------------------------------------
/// The implementatin of `Default` must return the slash zero prefix.
pub trait RoutePrefix: Clone + Copy + Default + fmt::Debug + Eq + Ord {
/// Returns whether this prefix covers the given prefix.
fn covers(self, other: Self) -> bool;
/// Returns the closest ancestor of the two prefixes.
fn closest_ancestor(self, other: Self) -> Self;
/// Returns the address length of the prefix.
fn addr_len(self) -> u8;
/// Returns the value of the `idx`th bit of the prefix.
///
/// Bit 0 is the leftmost bit.
fn bit(self, idx: u8) -> bool;
/// Converts the prefix into a typed prefix.
fn into_typed_prefix(self) -> TypedPrefix;
}
impl RoutePrefix for Ipv4Prefix {
fn covers(self, other: Self) -> bool {
if self.addr_len() > other.addr_len() {
return false
}
if self.addr_len() == 32 {
return self.addr() == other.addr()
}
self.addr().to_bits()
== other.addr().to_bits() & !(u32::MAX >> self.addr_len())
}
fn closest_ancestor(self, other: Self) -> Self {
self.resize(
cmp::min(
(self.addr().to_bits() ^ other.addr().to_bits())
.leading_zeros() as u8,
cmp::min(self.addr_len(), other.addr_len())
)
)
}
fn addr_len(self) -> u8 {
self.addr_len()
}
fn bit(self, idx: u8) -> bool {
let Some(mask) = 0x8000_0000u32.checked_shr(idx.into()) else {
return false
};
(self.addr().to_bits() & mask) != 0
}
fn into_typed_prefix(self) -> TypedPrefix {
TypedPrefix::V4(self)
}
}
impl RoutePrefix for Ipv6Prefix {
fn covers(self, other: Self) -> bool {
if self.addr_len() > other.addr_len() {
return false
}
if self.addr_len() == 128 {
return self.addr() == other.addr()
}
self.addr().to_bits()
== other.addr().to_bits() & !(u128::MAX >> self.addr_len())
}
fn closest_ancestor(self, other: Self) -> Self {
self.resize(
cmp::min(
(self.addr().to_bits() ^ other.addr().to_bits())
.leading_zeros() as u8,
cmp::min(self.addr_len(), other.addr_len())
)
)
}
fn addr_len(self) -> u8 {
self.addr_len()
}
fn bit(self, idx: u8) -> bool {
let Some(mask) = const { 1u128 << 127 }.checked_shr(idx.into()) else {
return false
};
(self.addr().to_bits() & mask) != 0
}
fn into_typed_prefix(self) -> TypedPrefix {
TypedPrefix::V6(self)
}
}
//------------ TreeIndex ------------------------------------------------------
/// The optional index of a tree node.
///
/// The index is kept as a u32 and uses `u32::MAX` as the sentinel for `None`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct TreeIndex(u32);
impl TreeIndex {
/// Returns the tree index for “none.”
const fn none() -> Self {
Self(u32::MAX)
}
/// Converts the tree index into an optional usize.
fn into_usize(self) -> Option<usize> {
self.into()
}
}
impl Default for TreeIndex {
fn default() -> Self {
Self::none()
}
}
impl TryFrom<Option<usize>> for TreeIndex {
type Error = LargeDataset;
fn try_from(src: Option<usize>) -> Result<Self, Self::Error> {
match src {
Some(src) => {
match u32::try_from(src) {
Ok(src) if src == u32::MAX => Err(LargeDataset(())),
Ok(src) => Ok(Self(src)),
Err(_) => Err(LargeDataset(())),
}
}
None => Ok(Self(u32::MAX))
}
}
}
impl TryFrom<usize> for TreeIndex {
type Error = LargeDataset;
fn try_from(src: usize) -> Result<Self, Self::Error> {
Some(src).try_into()
}
}
impl From<TreeIndex> for Option<usize> {
fn from(src: TreeIndex) -> Self {
if src.0 == u32::MAX {
None
}
else {
Some(src.0 as usize)
}
}
}
//------------ DataIndex -----------------------------------------------------
/// The index of a data item.
///
/// This may either be an index into the data vec or an index into the
/// non-data vec.
///
/// The index is kept as an u32. If the left-most bit is set, the
/// remaining bits are a data index. If it isnt, it is a no-data index.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct DataIndex(u32);
impl DataIndex {
/// Returns the 31 bit value for the given usize.
fn usize_to_u31(idx: usize) -> Result<u32, LargeDataset> {
match u32::try_from(idx) {
Ok(idx) if idx & 0x8000_0000 != 0 => Err(LargeDataset(())),
Ok(idx) => Ok(idx),
Err(_) => Err(LargeDataset(())),
}
}
/// Returns the index for the given index into the data set.
fn data(idx: usize) -> Result<Self, LargeDataset> {
Ok(Self(Self::usize_to_u31(idx)? | 0x8000_0000))
}
/// Returns the index for the given index into the no-data set.
fn no_data(idx: usize) -> Result<Self, LargeDataset> {
Ok(Self(Self::usize_to_u31(idx)?))
}
/// Converts the index into a usize index.
///
/// Returns `Ok(_)` if the index is into the data set and `Err(_)` if the
/// index is into the no-data set.
fn into_data(self) -> Result<usize, usize> {
if self.0 & 0x8000_0000 != 0 {
Ok((self.0 & 0x7FFF_FFFF) as usize)
}
else {
Err(self.0 as usize)
}
}
}
//------------ TreeNode ------------------------------------------------------
/// A node in the radix tree.
#[derive(Clone, Copy, Debug)]
struct TreeNode {
/// The index of the data item referred to by this node.
///
/// This is an optional index into the data slice.
data: DataIndex,
/// The index of the left child tree node.
///
/// This is an optional index into the same tree slice.
///
/// The left child is the prefix with at least one more bit where the
/// next bit is 0.
left: TreeIndex,
/// The right child tree node.
///
/// This is an optional index into the same tree slice.
///
/// The left child is the prefix with at least one more bit where the
/// next bit is 1.
right: TreeIndex,
}
impl TreeNode {
/// Creates a new node with the data index and no children.
fn new(data: DataIndex) -> Self {
Self {
data,
left: TreeIndex::none(),
right: TreeIndex::none(),
}
}
/// Creates a new node with the data index and the given children.
fn with_children(
data: DataIndex, left: TreeIndex, right: TreeIndex
) -> Self {
Self { data, left, right }
}
}
//------------ RouteOrigin ---------------------------------------------------
/// A prefix and an origin AS.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct RouteOrigin<P> {
/// The address prefix of this route origin.
pub prefix: P,
/// The origin AS of this route origin.
pub origin: AsNumber,
}
impl<P: RoutePrefix> From<RouteOrigin<P>> for Announcement {
fn from(src: RouteOrigin<P>) -> Self {
Self {
asn: src.origin,
prefix: src.prefix.into_typed_prefix()
}
}
}
//------------ RouteOriginSet ------------------------------------------------
/// A set of route origins.
///
/// This is a thin wrapper around a non-empty slice of [`RouteOrigin<P>']
/// with the same prefix, allowing access to the prefix with unwrapping and
/// such.
#[derive(Clone, Copy, Debug)]
pub struct RouteOriginSet<'a, P> {
/// The underlying slice.
slice: &'a [RouteOrigin<P>],
}
impl<'a, P: RoutePrefix> RouteOriginSet<'a, P> {
/// Creates a new value from a non-empty slice.
fn new(slice: &'a [RouteOrigin<P>]) -> Self {
debug_assert!(!slice.is_empty());
Self { slice }
}
/// Returns the prefix of the set.
pub fn prefix(self) -> P {
// Safety: self.slice is not empty.
self.slice[0].prefix
}
/// Returns an iterator over the individual route origins of the set.
pub fn iter(self) -> impl Iterator<Item = RouteOrigin<P>> + 'a {
self.slice.iter().copied()
}
}
//------------ RouteOriginBox ------------------------------------------------
/// A boxed slice of sorted `RouteOrgin`s.
#[derive(Clone, Debug)]
pub struct RouteOriginBox<P>(Box<[RouteOrigin<P>]>);
impl<P: RoutePrefix> RouteOriginBox<P> {
/// Returns the index of first following entry with a different prefix.
fn next_prefix(&self, idx: usize) -> usize {
let mut next_idx = idx;
loop {
next_idx = match next_idx.checked_add(1) {
Some(idx) => idx,
None => return usize::MAX,
};
if next_idx >= self.0.len() {
return next_idx;
}
if self.0[next_idx].prefix != self.0[idx].prefix {
return next_idx;
}
}
}
/// Returns the slice of all entries with the same prefix.
fn origin_set(&self, idx: usize) -> Option<RouteOriginSet<'_, P>> {
// XXX Check that this will always return non-empty slices or None.
let next = cmp::min(self.next_prefix(idx), self.0.len());
self.0.get(idx..next).map(RouteOriginSet::new)
}
}
impl<P> Default for RouteOriginBox<P> {
fn default() -> Self {
Self(Box::new([]))
}
}
//----------- TreeIter -------------------------------------------------------
/// An iterator over the items in a route origin collection.
///
/// The iterator goes over the elements in prefix order. That is, prefixes
/// with a smaller integer value go first and, if they are the same, those
/// with a shorter length go first.
///
/// The iterator returns non-empty slices of route origins with the same
/// prefix.
pub struct TreeIter<'a, P> {
/// A reference to the collection we iterate over.
collection: &'a RouteOriginCollection<P>,
/// The stack for recursion.
///
/// The last item is the node we need to process in this call to `next`.
tree_idx_stack: Vec<usize>,
}
impl<'a, P: RoutePrefix> TreeIter<'a, P> {
#[cfg(test)]
fn new(collection: &'a RouteOriginCollection<P>) -> Self {
Self {
collection,
tree_idx_stack: match collection.tree_root_idx.into_usize() {
Some(idx) => vec![idx],
None => Vec::new()
}
}
}
/// Creates a new iterator starting at the given prefix.
fn more_specific(
collection: &'a RouteOriginCollection<P>,
root_prefix: P
) -> Self {
let mut tree_idx = collection.tree_root_idx;
while let Some(node) = collection.get_tree_node(tree_idx) {
let Some(prefix) = collection.get_data_prefix(node.data) else {
break;
};
if prefix.addr_len() >= root_prefix.addr_len() {
let Some(tree_idx) = tree_idx.into_usize() else {
break
};
return Self {
collection,
tree_idx_stack: vec![tree_idx],
}
}
if !root_prefix.bit(prefix.addr_len()) {
tree_idx = node.left
}
else {
tree_idx = node.right
}
}
Self {
collection,
tree_idx_stack: Vec::new()
}
}
}
impl<'a, P: RoutePrefix> Iterator for TreeIter<'a, P> {
type Item = RouteOriginSet<'a, P>;
fn next(&mut self) -> Option<Self::Item> {
// We iterate node itself first, then left, then right.
loop {
let node_idx = *self.tree_idx_stack.last()?;
let node = self.collection.tree.get(node_idx)?;
self.tree_idx_stack.pop();
if let Some(idx) = node.right.into_usize() {
self.tree_idx_stack.push(idx);
}
if let Some(idx) = node.left.into_usize() {
self.tree_idx_stack.push(idx);
}
if let Ok(idx) = node.data.into_data() {
return self.collection.data.origin_set(idx)
}
}
}
}
//=========== Error Types ====================================================
//------------ RisWhoisError ------------------------------------------------
#[derive(Debug)]
pub struct RisWhoisError {
uri: String,
err: io::Error,
}
impl RisWhoisError {
fn new(uri: &str, err: io::Error) -> Self {
Self { uri: uri.into(), err }
}
}
impl fmt::Display for RisWhoisError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f,
"Failed to download RISwhois file `{}`: {}",
self.uri, self.err
)
}
}
//----------- LargeDataset -----------------------------------------------------
/// The dataset is too large to fit into the route origin collection.
#[derive(Debug)]
pub struct LargeDataset(());
impl fmt::Display for LargeDataset {
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
fmt.write_str("RISwhois dataset too large")
}
}
impl error::Error for LargeDataset { }
//------------ Tests --------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_bgp_ris_dumps() {
let v4 = RisWhoisLoader::parse_data(include_bytes!(
"../../../test-resources/bgp/riswhoisdump.IPv4"
).as_ref()).unwrap();
let v6 = RisWhoisLoader::parse_data(include_bytes!(
"../../../test-resources/bgp/riswhoisdump.IPv6"
).as_ref()).unwrap();
let ris = RisWhois { v4, v6 };
let v4 = TreeIter::new(&ris.v4).map(|item| {
for origin in item.iter() {
assert_eq!(origin.prefix, item.prefix());
}
item.prefix()
}).collect::<Vec<_>>();
for item in v4.windows(2) {
assert!(item[0] < item[1])
}
let v6 = TreeIter::new(&ris.v6).map(|item| {
for origin in item.iter() {
assert_eq!(origin.prefix, item.prefix());
}
item.prefix()
}).collect::<Vec<_>>();
for item in v6.windows(2) {
assert!(item[0] < item[1])
}
}
}
+21 -10
View File
@@ -6,7 +6,7 @@ use std::collections::HashMap;
use std::ops::Range;
use std::str::FromStr;
use intervaltree::IntervalTree;
use rpki::repository::resources::{Addr, AddressRange, ResourceSet};
use rpki::repository::resources::{Addr, AddressRange, Prefix, ResourceSet};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::Mutex;
@@ -100,6 +100,10 @@ impl BgpAnalyser {
// Now get all the necessary data from BGP API.
//
// Return early if this failed.
//
// If this succeeds, `scoped_announcements` will contain all
// announcements that overlap any of the IP address resources we
// are considering.
let scope = IpRange::from_resource_set(
match &limited_scope {
Some(limit) => limit,
@@ -124,27 +128,33 @@ impl BgpAnalyser {
}
}
// Now create a prefix tree for all the configured ROAs: `roa_tree`.
let roa_tree = IpRangeStore::create(
roas_held.iter().map(|configured| {
let payload = configured.roa_configuration.payload;
(payload.prefix.into(), payload)
})
);
// Now go over all announcements and determine their ROV status from
// our ROAs. Turn that into a prefix tree: `validated_tree`.
let validated: Vec<ValidatedAnnouncement> = scoped_announcements
.into_iter()
.map(|a| roa_tree.validate_announcement(a))
.collect();
// Check all ROAs.. and report ROA state in relation to validated
// announcements
let validated_tree = IpRangeStore::create(
validated.iter().map(|v| (v.announcement.prefix.into(), v.clone()))
);
// Now we go over each individual configured ROA and check how it
// influenced the validated tree.
for roa in roas_held {
// Get all announcements covered by the ROA.
let covered = validated_tree.matching_or_more_specific(
roa.roa_configuration.payload.prefix
);
// Get all other ROAs that cover the prefix of this ROA.
let other_roas_covering_this_prefix: Vec<_> = roa_tree
.matching_or_less_specific(
roa.roa_configuration.payload.prefix
@@ -154,6 +164,7 @@ impl BgpAnalyser {
.cloned()
.collect();
// Get all ROAs that include this ROA.
let other_roas_including_this_definition: Vec<_> =
other_roas_covering_this_prefix
.iter()
@@ -414,7 +425,7 @@ impl BgpAnalyser {
// When testing, the "test" URL is special. Also, unwrapping is
// fine.
let value = serde_json::from_str::<Value>(include_str!(
"../../test-resources/bgp/bgp-api.json")
"../../../test-resources/bgp/bgp-api.json")
).unwrap();
let Value::Object(mut value) = value else {
panic!("not an object")
@@ -623,13 +634,13 @@ impl From<TypedPrefix> for IpRange {
fn from(tp: TypedPrefix) -> Self {
match tp {
TypedPrefix::V4(pfx) => {
let (min, max) = pfx.as_ref().range();
let (min, max) = Prefix::from(pfx).range();
let start = min.to_v4().to_ipv6_mapped().into();
let end = max.to_v4().to_ipv6_mapped().into();
IpRange(Range { start, end })
}
TypedPrefix::V6(pfx) => {
let (min, max) = pfx.as_ref().range();
let (min, max) = Prefix::from(pfx).range();
let start = min.to_v6().into();
let end = max.to_v6().into();
IpRange(Range { start, end })
@@ -876,7 +887,7 @@ mod test {
.await;
let expected: BgpAnalysisReport = serde_json::from_str(include_str!(
"../../test-resources/bgp/expected_full_report.json"
"../../../test-resources/bgp/expected_full_report.json"
))
.unwrap();
@@ -990,7 +1001,7 @@ mod test {
let expected: BgpAnalysisSuggestion =
serde_json::from_str(include_str!(
"../../test-resources/bgp/expected_suggestion_some_roas.json"
"../../../test-resources/bgp/expected_suggestion_some_roas.json"
))
.unwrap();
assert_eq!(suggestion_resource_subset, expected);
@@ -1000,7 +1011,7 @@ mod test {
let expected: BgpAnalysisSuggestion =
serde_json::from_str(include_str!(
"../../test-resources/bgp/expected_suggestion_all_roas.json"
"../../../test-resources/bgp/expected_suggestion_all_roas.json"
))
.unwrap();
+20 -27
View File
@@ -151,13 +151,7 @@ impl KrillManager {
.await?,
);
let bgp_analyser = Arc::new(BgpAnalyser::new(
config.bgp_api_enabled,
config.bgp_api_uri.clone(),
config.bgp_api_cache_duration.to_std().unwrap_or(
std::time::Duration::from_secs(0)
)
));
let bgp_analyser = Arc::new(BgpAnalyser::new(&config));
// When multi-node set ups with a shared queue are
// supported then we can no longer safely reschedule
@@ -277,6 +271,7 @@ impl KrillManager {
self.mq.clone(),
self.ca_manager.clone(),
self.repo_manager.clone(),
self.bgp_analyser.clone(),
self.config.clone(),
self.system_actor.clone(),
)
@@ -623,7 +618,7 @@ impl KrillManager {
/// # Stats and status of CAS
impl KrillManager {
pub async fn cas_stats(
pub fn cas_stats(
&self,
) -> KrillResult<HashMap<CaHandle, CertAuthStats>> {
let mut res = HashMap::new();
@@ -639,10 +634,11 @@ impl KrillManager {
|| ca.handle().as_str() == "testbed"
{
BgpAnalysisReport::new(vec![])
} else {
self.bgp_analyser
.analyse(roas.as_slice(), &ca.all_resources(), None)
.await
}
else {
self.bgp_analyser.analyse(
roas.as_slice(), &ca.all_resources(), None
)
};
res.insert(
@@ -1146,20 +1142,19 @@ impl KrillManager {
Ok(ca.configured_roas())
}
pub async fn ca_routes_bgp_analysis(
pub fn ca_routes_bgp_analysis(
&self,
handle: &CaHandle,
) -> KrillResult<BgpAnalysisReport> {
let ca = self.ca_manager.get_ca(handle)?;
let definitions = ca.configured_roas();
let resources_held = ca.all_resources();
Ok(self
.bgp_analyser
.analyse(definitions.as_slice(), &resources_held, None)
.await)
Ok(self.bgp_analyser.analyse(
definitions.as_slice(), &resources_held, None
))
}
pub async fn ca_routes_bgp_dry_run(
pub fn ca_routes_bgp_dry_run(
&self,
handle: &CaHandle,
mut updates: RoaConfigurationUpdates,
@@ -1175,13 +1170,12 @@ impl KrillManager {
let configured_roas =
ca.configured_roas_for_configs(would_be_configurations);
Ok(self
.bgp_analyser
.analyse(&configured_roas, &resources_held, limit)
.await)
Ok(self.bgp_analyser.analyse(
&configured_roas, &resources_held, limit
))
}
pub async fn ca_routes_bgp_suggest(
pub fn ca_routes_bgp_suggest(
&self,
handle: &CaHandle,
limit: Option<ResourceSet>,
@@ -1190,10 +1184,9 @@ impl KrillManager {
let configured_roas = ca.configured_roas();
let resources_held = ca.all_resources();
Ok(self
.bgp_analyser
.suggest(configured_roas.as_slice(), &resources_held, limit)
.await)
Ok(self.bgp_analyser.suggest(
configured_roas.as_slice(), &resources_held, limit
))
}
/// Re-issue ROA objects so that they will use short subjects (see issue
+32 -1
View File
@@ -31,6 +31,7 @@ use crate::{
config::Config,
server::{
ca::{CaManager, CertAuth},
bgp::BgpAnalyser,
mq::{
in_hours, in_minutes, in_seconds, in_weeks, now, Task, TaskQueue,
},
@@ -45,6 +46,7 @@ pub struct Scheduler {
tasks: Arc<TaskQueue>,
ca_manager: Arc<CaManager>,
repo_manager: Arc<RepositoryManager>,
bgp_analyser: Arc<BgpAnalyser>,
config: Arc<Config>,
system_actor: Actor,
started: Timestamp,
@@ -55,6 +57,7 @@ impl Scheduler {
tasks: Arc<TaskQueue>,
ca_manager: Arc<CaManager>,
repo_manager: Arc<RepositoryManager>,
bgp_analyser: Arc<BgpAnalyser>,
config: Arc<Config>,
system_actor: Actor,
) -> Self {
@@ -62,6 +65,7 @@ impl Scheduler {
tasks,
ca_manager,
repo_manager,
bgp_analyser,
config,
system_actor,
started: Timestamp::now(),
@@ -190,7 +194,11 @@ impl Scheduler {
.await
}
Task::SweepLoginCache | Task::RefreshAnnouncementsInfo => {
Task::RefreshAnnouncementsInfo => {
self.announcements_refresh().await
}
Task::SweepLoginCache => {
// Dont do anything. These are deprecated.
Ok(TaskResult::Done)
}
@@ -298,6 +306,15 @@ impl Scheduler {
.schedule_missing(Task::RenewObjectsIfNeeded, now())
.map_err(FatalError)?;
// BGP announcement info is only kept in-memory, so it
// is lost after a restart, so schedule refreshing this
// immediately.
if self.config.bgp_riswhois_enabled {
self.tasks
.schedule(Task::RefreshAnnouncementsInfo, now())
.map_err(FatalError)?;
}
// Plan updating snapshots soon after a restart.
// This also ensures that this task gets triggered in long
// running tests, such as functional_parent_child.rs.
@@ -485,6 +502,20 @@ impl Scheduler {
))
}
/// Update announcement info
async fn announcements_refresh(&self) -> Result<TaskResult, FatalError> {
if let Err(e) = self.bgp_analyser.update().await {
error!("Failed to update BGP announcements: {}", e)
}
// check again in 10 minutes, note.. this is a no-op in case the
// actual update was less then 1 hour ago.
// See BGP_RIS_REFRESH_MINUTES constant.
Ok(TaskResult::FollowUp(
Task::RefreshAnnouncementsInfo, in_minutes(10)
))
}
/// Let CAs that need it re-issue signed objects
async fn renew_objects_if_needed(
&self,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -164,9 +164,11 @@ impl TestConfig {
let post_protocol_msg_timeout_seconds =
ConfigDefaults::post_protocol_msg_timeout_seconds();
let bgp_api_enabled = false;
let bgp_api_uri = ConfigDefaults::bgp_api_uri();
let bgp_api_cache_duration = ConfigDefaults::bgp_api_cache_duration();
let bgp_riswhois_enabled = false;
let bgp_riswhois_v4_uri = ConfigDefaults::bgp_riswhois_v4_uri();
let bgp_riswhois_v6_uri = ConfigDefaults::bgp_riswhois_v6_uri();
let bgp_riswhois_refresh_interval
= ConfigDefaults::bgp_riswhois_refresh_interval();
let roa_aggregate_threshold = 3;
let roa_deaggregate_threshold = 2;
@@ -282,9 +284,10 @@ impl TestConfig {
post_limit_rfc6492,
rfc6492_log_dir: None,
post_protocol_msg_timeout_seconds,
bgp_api_enabled,
bgp_api_uri,
bgp_api_cache_duration,
bgp_riswhois_enabled,
bgp_riswhois_v4_uri,
bgp_riswhois_v6_uri,
bgp_riswhois_refresh_interval,
roa_aggregate_threshold,
roa_deaggregate_threshold,
issuance_timing,