Before this change, a route carrying the BLACKHOLE community (65535:666,
RFC 7999) from an EBGP neighbor was always rejected with the reason
"blackhole request", and nothing discarded traffic. A customer under a
denial-of-service attack had no way to ask this router to drop traffic
to an address.
This change adds `accept_blackholes` to the import policy. When it is
set, a route with the BLACKHOLE community is accepted if:
* its prefix is a single address, a /32 or a /128 (otherwise it is
rejected with the new reason "blackhole for more than one address");
the `max_ipv4_prefix_length` and `max_ipv6_prefix_length` limits do
not apply, as RFC 7999 section 3.2 recommends;
* the address is inside a prefix the neighbor may announce, if
`prefixes` is configured, regardless of the entry's `max_length`
(new `PrefixSet::covers`); without a prefix list, an address inside
the local AS's own prefixes is rejected as before ("prefix is our
own");
* the AS path, origin and RPKI checks that apply to every route pass.
The next hop of an accepted blackhole route is not checked for
third-party addresses and is not resolved, because traffic is never
forwarded to it. The route takes part in best path selection as usual;
a host route is normally the active route for its address.
The kernel task installs the active route as a kernel blackhole route
(`RTN_BLACKHOLE`, shown by `ip route` as `blackhole PREFIX proto bgp`).
Blackhole routes left by an earlier run are adopted at startup like
other routes of ours. The route is advertised to other neighbors as the
export policy and export limits permit, with the community intact, so a
provider that honors BLACKHOLE can discard the traffic upstream.
Implementation:
* `plan::Target` becomes an enum: `Via { gateway, interface }` or
`Discard`. `plan::resolve` takes a `blackhole` flag and returns
`Discard` for such routes without looking at the next hop (the
own-network check still applies). No nexthop object is made for a
discard target.
* `AttrSet::is_blackhole` reads the community off the stored attribute
bytes, like `next_hop` and `as_path`, so the kernel task, the
revalidator and the RPKI revalidation can ask it of every route.
* `ImportPolicy::is_blackhole_request` and `check_blackhole_prefix`
carry the import side; the import flow (`routes.rs`) computes once per
UPDATE whether its routes are an accepted blackhole request and skips
the next hop checks for them.
* `Status::blackholes` and the `blackholes` field of `/api/kernel` count
the installed blackhole routes; the status page shows the count next
to the installed routes.
Not covered: a prefix this router originates with the BLACKHOLE
community is advertised but not installed as a blackhole route. The
documentation says to add a kernel blackhole route by hand for that
case.
Documentation: the "Blackhole Routes" section of the import policy
guide replaces "Blackhole Requests", the kernel guide gains a "Blackhole
Routes" section, the configuration reference describes
`accept_blackholes`, the HTTP API reference describes `blackholes`, and
the RFC 7999 rows of the standards reference are updated.
Testing: unit tests for the attribute check, the prefix checks and the
plan; integration tests that a blackhole request is refused by default
and, with `accept_blackholes`, taken for one address inside the peer's
space and refused otherwise, with the recorded reasons; a kernel test
that a blackhole request becomes a kernel blackhole route whatever its
next hop, is removed on withdrawal and is adopted after a restart. The
existing kernel tests, the interoperability lab and the workspace tests
pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
142 lines
4.9 KiB
Rust
142 lines
4.9 KiB
Rust
//! Blackhole requests (RFC 7999): refused by default, and with
|
|
//! `accept_blackholes` taken for one address inside what the peer may
|
|
//! announce, whatever their next hop.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::net::Ipv4Addr;
|
|
use std::time::Duration;
|
|
|
|
use bgp_integration_tests::announcement::announcement;
|
|
use bgp_integration_tests::test_peer::TestPeer;
|
|
use bgp_integration_tests::test_server::{ServerWithPeer, TestServer};
|
|
use bgp_integration_tests::{default_policy, prefix, wait_until};
|
|
use bgp_server::policy::{ImportPolicy, PrefixSet};
|
|
use eyre::Result;
|
|
|
|
const LOCAL_ASN: u32 = 1299;
|
|
const PEER_ASN: u32 = 3333;
|
|
const PEER_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2);
|
|
const BLACKHOLE: (u16, u16) = (0xFFFF, 0x029A);
|
|
|
|
async fn server_with(import: ImportPolicy) -> Result<ServerWithPeer> {
|
|
TestServer::with_peer(LOCAL_ASN, PEER_ASN, |c, peer| {
|
|
c.connect_retry = Duration::from_millis(50);
|
|
peer.import = import;
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Waits until the server has taken `taken` routes of the session's and
|
|
/// refused `refused`, and returns the prefixes in its table.
|
|
async fn table_after(server: &TestServer, taken: u64, refused: u64) -> Result<BTreeSet<String>> {
|
|
let counters = server.registry.list()[0].counters.clone();
|
|
wait_until("the routes are dealt with", || {
|
|
(counters.routes(), counters.rejected()) == (taken, refused)
|
|
})
|
|
.await
|
|
.map_err(|_| {
|
|
eyre::eyre!(
|
|
"the server took {} routes and refused {}; expected {taken} and {refused}",
|
|
counters.routes(),
|
|
counters.rejected()
|
|
)
|
|
})?;
|
|
Ok(server
|
|
.received_routes()
|
|
.iter()
|
|
.map(|route| route.prefix.to_string())
|
|
.collect())
|
|
}
|
|
|
|
/// The policy that lets the peer announce 193.0.0.0/21 and nothing
|
|
/// longer than a /24 inside it, as a customer's would.
|
|
fn customer_policy(accept_blackholes: bool) -> ImportPolicy {
|
|
let mut prefixes = PrefixSet::default();
|
|
prefixes.allow(prefix("193.0.0.0/21"), 24);
|
|
ImportPolicy {
|
|
prefixes: Some(prefixes),
|
|
accept_blackholes,
|
|
..default_policy()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_blackhole_requests_are_refused_unless_accepted() -> Result<()> {
|
|
let with_peer = server_with(customer_policy(false)).await?;
|
|
let server = &with_peer.server;
|
|
let mut peer: TestPeer = server.established(PEER_ASN, PEER_ID).await?;
|
|
peer.announce(announcement(&["193.0.0.9/32"], &[PEER_ASN]).with_communities(&[BLACKHOLE]))
|
|
.await?;
|
|
peer.announce(announcement(&["193.0.0.0/24"], &[PEER_ASN]))
|
|
.await?;
|
|
assert_eq!(table_after(server, 1, 1).await?, set(&["193.0.0.0/24"]));
|
|
let refused = server.rejections.peers();
|
|
assert_eq!(refused.len(), 1);
|
|
assert_eq!(
|
|
refused[0]
|
|
.1
|
|
.by_reason()
|
|
.iter()
|
|
.map(|(reason, n)| (reason.as_str(), *n))
|
|
.collect::<Vec<_>>(),
|
|
[("blackhole request", 1)]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_a_blackhole_request_is_taken_for_one_address_in_the_peers_space() -> Result<()> {
|
|
let with_peer = server_with(customer_policy(true)).await?;
|
|
let server = &with_peer.server;
|
|
let mut peer: TestPeer = server.established(PEER_ASN, PEER_ID).await?;
|
|
// One address inside the peer's prefix, from a next hop that is no
|
|
// neighbor and would not pass as an ordinary route's.
|
|
peer.announce(
|
|
announcement(&["193.0.0.9/32"], &[PEER_ASN])
|
|
.with_next_hop(Ipv4Addr::new(172, 31, 0, 9))
|
|
.with_communities(&[BLACKHOLE]),
|
|
)
|
|
.await?;
|
|
// More than one address: not a blackhole request that is taken.
|
|
peer.announce(announcement(&["193.0.0.0/25"], &[PEER_ASN]).with_communities(&[BLACKHOLE]))
|
|
.await?;
|
|
// One address outside the peer's space.
|
|
peer.announce(announcement(&["193.0.8.9/32"], &[PEER_ASN]).with_communities(&[BLACKHOLE]))
|
|
.await?;
|
|
// And an ordinary route, taken as ever.
|
|
peer.announce(announcement(&["193.0.0.0/24"], &[PEER_ASN]))
|
|
.await?;
|
|
assert_eq!(
|
|
table_after(server, 2, 2).await?,
|
|
set(&["193.0.0.0/24", "193.0.0.9/32"])
|
|
);
|
|
let refused = server.rejections.peers();
|
|
let mut reasons: Vec<(&str, u64)> = refused[0]
|
|
.1
|
|
.by_reason()
|
|
.iter()
|
|
.map(|(reason, n)| (reason.as_str(), *n))
|
|
.collect();
|
|
reasons.sort();
|
|
assert_eq!(
|
|
reasons,
|
|
[
|
|
("blackhole for more than one address", 1),
|
|
("prefix not allowed", 1)
|
|
]
|
|
);
|
|
// The route is in the table with its request on it, for the kernel
|
|
// and for whoever it goes on to.
|
|
let routes = server.received_routes();
|
|
let blackhole = routes
|
|
.iter()
|
|
.find(|route| route.prefix == prefix("193.0.0.9/32"))
|
|
.expect("the blackhole route is in the table");
|
|
assert!(ImportPolicy::is_blackhole_request(&blackhole.attributes));
|
|
Ok(())
|
|
}
|
|
|
|
fn set(prefixes: &[&str]) -> BTreeSet<String> {
|
|
prefixes.iter().map(|p| p.to_string()).collect()
|
|
}
|