From 3d946c32ce692e8f054147586fe4e528286e0832 Mon Sep 17 00:00:00 2001 From: Rayhaan Jaufeerally Date: Fri, 2 Oct 2026 19:31:19 +0200 Subject: [PATCH] Code health after review: docs, metrics, structure, one more fuzz target What the code-health review found, and what a fuzz target written for it found in its first seconds. - A set of attributes with the same attribute twice is refused by the routing table. Its readers (the selection key, the path, the next hop) could each take a different one of the two. The decoder never lets one through; the new fuzz target `attrs` found that the table would. - bgp_peer_up{peer}, to alert on a peer going away, and bgp_build_info. Every metric is there from the first scrape. Alerts for a peer down, hijack attempts and kernel errors are in the README. - `bgp --check config` validates a configuration without starting, and `--version` says which build it is. CI runs the first on the example configuration, which a test now also parses with its commented fields put in; the example had to learn about the next hop check. - Path attributes have a module of their own, packet::attributes, re-exported from packet::message as before. - policy::From, which shadowed the trait, is RouteSource. The server's connection, dialer and fsm modules are private. Logging arguments live in the binary; the server library no longer depends on clap. - One is_unicast_link_local (the standard library's) instead of two of our own; SelectionKey::origin(); one place where an Import is made. - READMEs say what the daemon does today rather than what it did not do a year ago. Documentation links are checked in CI. Unused dependencies and bgp_web::serve are gone; the Docker build is --locked and does not ship the fuzz corpus. Co-Authored-By: Claude Fable 5.1 --- .dockerignore | 5 + .github/workflows/ci.yml | 14 +- .gitignore | 1 - Cargo.lock | 6 +- Dockerfile | 2 +- README.md | 53 +- bin/bgp/Cargo.toml | 1 + bin/bgp/README.md | 26 +- bin/bgp/src/logging.rs | 29 + bin/bgp/src/main.rs | 35 +- crates/fib/src/kernel.rs | 6 + crates/fib/src/plan.rs | 8 +- crates/packet/README.md | 5 +- crates/packet/src/attributes.rs | 935 ++++++++++++++++++++++++++++++++ crates/packet/src/lib.rs | 1 + crates/packet/src/message.rs | 929 +------------------------------ crates/primitives/Cargo.toml | 2 - crates/rib/Cargo.toml | 1 - crates/rib/src/table/attrs.rs | 47 +- crates/rib/src/table/mod.rs | 2 +- crates/server/Cargo.toml | 2 - crates/server/src/address.rs | 12 +- crates/server/src/advertise.rs | 3 +- crates/server/src/config.rs | 42 +- crates/server/src/connection.rs | 59 +- crates/server/src/lib.rs | 7 +- crates/server/src/metrics.rs | 35 +- crates/server/src/policy.rs | 35 +- crates/server/src/routes.rs | 4 +- crates/server/src/rpki.rs | 10 +- crates/server/src/session.rs | 13 +- crates/server/src/tracing.rs | 29 - crates/web/src/api.rs | 2 +- crates/web/src/events.rs | 2 +- crates/web/src/lib.rs | 16 +- docs/RFCs.md | 2 +- fuzz/Cargo.toml | 8 + fuzz/README.md | 1 + fuzz/fuzz_targets/attrs.rs | 31 ++ fuzz/seeds/attrs/plain | Bin 0 -> 28 bytes testdata/server.textpb | 4 + 41 files changed, 1323 insertions(+), 1102 deletions(-) create mode 100644 bin/bgp/src/logging.rs create mode 100644 crates/packet/src/attributes.rs delete mode 100644 crates/server/src/tracing.rs create mode 100644 fuzz/fuzz_targets/attrs.rs create mode 100644 fuzz/seeds/attrs/plain diff --git a/.dockerignore b/.dockerignore index c7f83c2..6ae17f5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,7 @@ target .git +.claude +fuzz/target +fuzz/corpus +fuzz/artifacts +crates/bench/data diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa62695..6c6ea08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,17 @@ jobs: - name: Format run: cargo fmt --all -- --check - name: Lint - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --locked --workspace --all-targets -- -D warnings - name: Test - run: cargo test --workspace + run: cargo test --locked --workspace + # A link in the documentation to something that is not there. + - name: Documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --locked --workspace --no-deps --document-private-items + # The example operators start from, through the daemon itself. + - name: Example configuration + run: cargo run --locked -p bgp -- --check testdata/server.textpb # The tests that change a kernel's routes, in a network namespace of # their own; and two daemons that route for each other. - name: Kernel tests @@ -61,7 +69,7 @@ jobs: env: RUSTFLAGS: "" run: | - for target in message message_body prefix_text config rtr netlink; do + for target in message message_body attrs prefix_text config rtr netlink; do mkdir -p "corpus/$target" cargo fuzz run "$target" "corpus/$target" "seeds/$target" -- -max_total_time=60 done diff --git a/.gitignore b/.gitignore index 2c62971..4cd04a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ /target -__pycache__/ .DS_Store .claude/ diff --git a/Cargo.lock b/Cargo.lock index d4e49b4..b15ce89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,6 +166,7 @@ dependencies = [ "protobuf", "tokio", "tracing", + "tracing-subscriber", ] [[package]] @@ -242,13 +243,11 @@ dependencies = [ name = "bgp-primitives" version = "0.0.2" dependencies = [ - "bitfield", "bytes", "nom", "proptest", "serde", "serde_repr", - "strum", "thiserror 2.0.12", ] @@ -265,7 +264,6 @@ dependencies = [ "serde", "thiserror 2.0.12", "tokio", - "tracing", ] [[package]] @@ -291,7 +289,6 @@ dependencies = [ "bgp-rib", "bgp-rpki", "bytes", - "clap", "futures", "nix", "prometheus", @@ -302,7 +299,6 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tracing", - "tracing-subscriber", ] [[package]] diff --git a/Dockerfile b/Dockerfile index 73047d3..79d202a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM rust:1.92-bookworm AS build WORKDIR /src COPY . . -RUN cargo build --release -p bgp +RUN cargo build --release --locked -p bgp FROM debian:bookworm-slim COPY --from=build /src/target/release/bgp /usr/local/bin/bgp diff --git a/README.md b/README.md index 38d3d7a..3b1fff1 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,54 @@ -# BGP +# Ferrix BGP -The `bgp` crate is an implementation of the Border Gateway Protocol, which is used to exchange routing and control plane information between Autonomous Systems. +An implementation of the Border Gateway Protocol, which is used to exchange routing and control plane information between Autonomous Systems. The aim of this project is to provide a modern, high performance, secure, programmatic and customizable software package that can be useful to network operators, researchers, and people interested in Internet. -## Current status. +## Current status -The project is currently being rewritten from scratch to be more modular and maintainable, as well as to fix deeper issues with the previous implementation. +The daemon holds sessions with configured peers, takes their routes through an import policy, validates them against the RPKI (route origin validation and ASPA), picks the best route for each prefix and, on Linux, installs it in the kernel. It announces the routes it originates. It does not yet pass routes learned from one peer on to another. -⚠️ Warning: This is currently a work in progress and not ready for use. +- [`bin/bgp/README.md`](bin/bgp/README.md): what the daemon does, how to configure and run it, and how it is tested against FRR, BIRD and Routinator. +- [`docs/RFCs.md`](docs/RFCs.md): which parts of which RFCs are implemented. +- [`crates/server/proto/config.proto`](crates/server/proto/config.proto): the configuration, field by field. -### 🛣️ Roadmap to v1.0 +It has not yet run in a production network. -We aim for the 1.0 release to be functional and stable to operate peering sessions, and to install routes in the Linux Kernel. +### Not there yet -- ☐ Implement basic parsers for the fundamental types required - - ✅ Path Attributes from RFC4271 - - ✅ Open message - - ✅ Capabilities from RFC4271 - - ✅ Update message - - ☐ Notification message -- ✅ Implement scaffolding required to start and configure a server -- ✅ Implement connection logic to establish sessions with peers (passive and active) -- ✅ Routes originated from the configuration, with communities, announced to all peers -- ✅ Read-only HTTP API, status page and Prometheus metrics -- ☐ Implement an efficient data structure for storing prefixes (e.g. Poptrie) -- ☐ Filtering for peer RIB in / out -- ☐ Main / auxiliary RIBs for exchanging routes between peers +- Announcing learned routes to other peers, and an export policy for it +- TCP-AO / TCP MD5 and GTSM +- Graceful restart, BFD, changing the configuration without a restart +- Next hops that are not directly connected + +### Layout + +| Crate | What is in it | +|-------|---------------| +| `crates/primitives` | Prefixes, address families, parsing helpers | +| `crates/packet` | The wire format: messages, attributes, capabilities | +| `crates/rib` | The routing table, best route selection, the journal of changes | +| `crates/rpki` | The RTR client; origin validation and ASPA path verification | +| `crates/fib` | The best routes in the Linux kernel, over netlink | +| `crates/server` | Sessions, the state machine, policy, configuration | +| `crates/web` | The status page, HTTP API and metrics | +| `bin/bgp` | The daemon | ## Development -CI runs these three on every push, and fails on any warning: +CI runs these on every push, and fails on any warning: ``` cargo fmt --all -- --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items ``` +It also runs the tests that need a real Linux kernel +(`scripts/kernel-tests.sh`) and the lab with FRR, BIRD and Routinator +(`scripts/interop/lab.sh`); `scripts/linux-vm.sh` runs either from macOS. + Lints are configured once, under `[workspace.lints]` in the root `Cargo.toml`. `unsafe` code is forbidden throughout. diff --git a/bin/bgp/Cargo.toml b/bin/bgp/Cargo.toml index 9a3a02c..383a248 100644 --- a/bin/bgp/Cargo.toml +++ b/bin/bgp/Cargo.toml @@ -16,6 +16,7 @@ eyre.workspace = true protobuf.workspace = true tokio.workspace = true tracing.workspace = true +tracing-subscriber.workspace = true [lints] workspace = true diff --git a/bin/bgp/README.md b/bin/bgp/README.md index 8441e01..23f367d 100644 --- a/bin/bgp/README.md +++ b/bin/bgp/README.md @@ -7,6 +7,10 @@ daemon together with its read-only HTTP status interface. cargo run -- path/to/config.textpb ``` +`--check` reads the configuration, says whether it is in order, and stops +without listening on anything or touching a route: for trying a change +before the daemon is restarted with it. + The configuration format is defined in `crates/server/proto/config.proto`; `testdata/server.textpb` is a worked example. The daemon is configured entirely through that file: peers, and the routes it originates @@ -403,6 +407,8 @@ For alerting, `/metricz` has: | `bgp_peer_max_prefixes{peer}` | The peer's limit, where it has one | | `bgp_prefix_limit_exceeded_total{peer}` | Sessions ended for going over the limit | | `bgp_connections_refused_total{reason}` | Connections closed without a session | +| `bgp_peer_up{peer}` | 1 while the session with a configured peer is Established, else 0 | +| `bgp_build_info{version}` | Always 1; says which version is running | Rules to start from: @@ -429,6 +435,20 @@ groups: - alert: BgpPrefixLimitExceeded expr: increase(bgp_prefix_limit_exceeded_total[1h]) > 0 labels: { severity: page } + # A configured peer has no session. + - alert: BgpPeerDown + expr: bgp_peer_up == 0 + for: 5m + labels: { severity: page } + # Someone is trying to have our own prefixes, or our traffic, go + # where they should not. + - alert: BgpHijackAttempt + expr: increase(bgp_routes_rejected_total{reason=~"prefix is our own|next hop is not the peer"}[10m]) > 0 + labels: { severity: page } + # The kernel is refusing routes, or its answers are being lost. + - alert: BgpKernelErrors + expr: increase(bgp_kernel_errors_total[10m]) > 0 + labels: { severity: warning } ``` ## Trying it without a router @@ -445,9 +465,3 @@ cargo run -- testdata/server.textpb cargo run -p bgp-integration-tests --example fake_peer -- \ --server 127.0.0.1:179 --asn 65001 --announce 203.0.113.0/24 --echo ``` - -Please refer to the other implementation crates in this repository where most -of the actual implementation lives. - -Warning: This is still very much a work in progress and is not yet ready for -production use. diff --git a/bin/bgp/src/logging.rs b/bin/bgp/src/logging.rs new file mode 100644 index 0000000..1af1279 --- /dev/null +++ b/bin/bgp/src/logging.rs @@ -0,0 +1,29 @@ +//! What the daemon logs, and how much. + +use clap::Args; +use tracing::Level; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::filter::ParseError; + +/// What the level given on the command line is for: this program and the +/// crates it is made of, which all begin with `bgp`. Everything else (the +/// HTTP server, say) stays at what `RUST_LOG` says, or silent. +const OURS: &str = "bgp"; + +#[derive(Debug, Args)] +pub struct LoggingArgs { + /// Set the minimum log level. At `debug` and below there is a line for + /// every UPDATE and every route announced. + #[arg(short, long, global = true, default_value_t = Level::INFO, help_heading = "Logging")] + level: Level, +} + +impl LoggingArgs { + pub fn install(&self) -> Result<(), ParseError> { + let filter = EnvFilter::builder() + .from_env_lossy() + .add_directive(format!("{OURS}={}", self.level).parse()?); + tracing_subscriber::fmt().with_env_filter(filter).init(); + Ok(()) + } +} diff --git a/bin/bgp/src/main.rs b/bin/bgp/src/main.rs index 256011c..5c905b8 100644 --- a/bin/bgp/src/main.rs +++ b/bin/bgp/src/main.rs @@ -1,34 +1,55 @@ +use std::path::PathBuf; use std::sync::Arc; use bgp_server::config::Config; use bgp_server::proto::config::BgpConfig; use bgp_server::server::{BgpServer, shut_down}; -use bgp_server::tracing::TracingArgs; use bgp_web::{AppState, RouterInfo}; use clap::Parser; use eyre::{Context, Result}; use tracing::{error, info}; +use crate::logging::LoggingArgs; + +mod logging; + +/// A BGP speaker. #[derive(Parser, Debug)] +#[command(version)] struct ServerArgs { /// Path to the textproto configuration file. - config: String, + config: PathBuf, + + /// Read the configuration, say whether it is in order, and stop: + /// nothing is listened on and no route is touched. + #[arg(long)] + check: bool, #[command(flatten)] - tracing: TracingArgs, + logging: LoggingArgs, } #[tokio::main] async fn main() -> Result<()> { let args = ServerArgs::parse(); - args.tracing.install()?; + args.logging.install()?; log_panics(); - let config_string = - std::fs::read_to_string(&args.config).wrap_err("Failed to open config file")?; + let config_string = std::fs::read_to_string(&args.config) + .wrap_err_with(|| format!("Failed to read {}", args.config.display()))?; let proto_config = protobuf::text_format::parse_from_str::(&config_string) .wrap_err("Failed to parse config file")?; let config = Config::from_proto(proto_config).wrap_err("Invalid configuration")?; + if args.check { + println!( + "{} is in order: AS{}, peers: {}, routes originated: {}", + args.config.display(), + config.local_asn, + config.peers.len(), + config.originate.len() + ); + return Ok(()); + } let server = BgpServer::bind(config).await?; @@ -45,7 +66,7 @@ async fn main() -> Result<()> { server.config().local_asn, env!("CARGO_PKG_VERSION"), ) - .with_config(&args.config, &config_string), + .with_config(args.config.to_string_lossy(), &config_string), ), registry: server.registry.clone(), local_rib: server.local_rib.clone(), diff --git a/crates/fib/src/kernel.rs b/crates/fib/src/kernel.rs index cad6b41..73e9c55 100644 --- a/crates/fib/src/kernel.rs +++ b/crates/fib/src/kernel.rs @@ -1023,6 +1023,12 @@ pub(crate) async fn run( sockets: Sockets, mut stopping: watch::Receiver, ) -> Result<(), FibError> { + // There from the first scrape, not from the first time they count. + LazyLock::force(&ROUTES); + LazyLock::force(&UNRESOLVED); + LazyLock::force(&REFUSED); + LazyLock::force(&CHANGES); + LazyLock::force(&ERRORS); let Sockets { control, routes, diff --git a/crates/fib/src/plan.rs b/crates/fib/src/plan.rs index 4a2a88d..8d859d1 100644 --- a/crates/fib/src/plan.rs +++ b/crates/fib/src/plan.rs @@ -63,10 +63,6 @@ impl Unresolved { } } -fn is_link_local(address: &Ipv6Addr) -> bool { - address.segments()[0] & 0xffc0 == 0xfe80 -} - /// The peer a route was learned from, as far as its next hop is concerned. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Neighbor { @@ -152,14 +148,14 @@ pub fn resolve( }; match *next_hop { NlriNextHop::Ipv4(gateway) => direct(gateway.into()), - NlriNextHop::Ipv6(gateway) if is_link_local(&gateway) => on_peers_link(gateway), + NlriNextHop::Ipv6(gateway) if gateway.is_unicast_link_local() => on_peers_link(gateway), NlriNextHop::Ipv6(gateway) => direct(gateway.into()), // The global address if it is a neighbor; else the link-local one, // which is there for exactly that case (RFC 2545 §3). // Some put the link-local address, or nothing, where the global // one goes, having no global one to give. NlriNextHop::Ipv6WithLl { global, link_local } - if global.is_unspecified() || is_link_local(&global) => + if global.is_unspecified() || global.is_unicast_link_local() => { on_peers_link(link_local) } diff --git a/crates/packet/README.md b/crates/packet/README.md index a862408..badb0e1 100644 --- a/crates/packet/README.md +++ b/crates/packet/README.md @@ -2,6 +2,5 @@ This crate implements parsers and serialisers for the Border Gateway Protocol wire format. -## ⚠️ Current status - -This crate is still under development and is not yet ready for use. +It is used by the daemon in `bin/bgp`, and tested with property tests +(`tests/properties.rs`) and fuzz targets (`fuzz/`). diff --git a/crates/packet/src/attributes.rs b/crates/packet/src/attributes.rs new file mode 100644 index 0000000..cabf412 --- /dev/null +++ b/crates/packet/src/attributes.rs @@ -0,0 +1,935 @@ +//! Path attributes: what an UPDATE says about the routes it announces +//! (RFC 4271 §5 and those that added to it), each with how it is read +//! from the wire and written to it. +//! +//! Everything here is re-exported from [`crate::message`], which is where +//! the rest of the tree names it from. + +use std::net::{Ipv4Addr, Ipv6Addr}; + +use bitfield::bitfield; +use bytes::{BufMut, BytesMut}; +use nom::Err::Failure; +use nom::number::complete::{be_u8, be_u16, be_u32}; +use nom::{IResult, Parser}; +use serde::Serialize; +use serde_repr::Serialize_repr; + +use bgp_primitives::constants::{Afi, AfiSafi, IpVersion, Safi}; +use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; +use bgp_primitives::parser::{BgpParserError, EncodeError, ParserContext}; + +use crate::message::{AS_TRANS, write_nlri}; + +bitfield! { + #[derive(Clone, Debug, Serialize, PartialEq, Eq)] + pub struct PathAttributeFlags(u8); + impl new; + u8; + pub optional, set_optional: 7; + pub transitive, set_transitive: 6; + pub partial, set_partial: 5; + pub extended_length, set_extended_length: 4; +} + +/// Path attribute type codes. +pub mod attr_type { + pub const ORIGIN: u8 = 1; + pub const AS_PATH: u8 = 2; + pub const NEXT_HOP: u8 = 3; + pub const MULTI_EXIT_DISC: u8 = 4; + pub const LOCAL_PREF: u8 = 5; + pub const ATOMIC_AGGREGATE: u8 = 6; + pub const AGGREGATOR: u8 = 7; + pub const COMMUNITIES: u8 = 8; + pub const ORIGINATOR_ID: u8 = 9; + pub const CLUSTER_LIST: u8 = 10; + pub const MP_REACH_NLRI: u8 = 14; + pub const MP_UNREACH_NLRI: u8 = 15; + pub const EXTENDED_COMMUNITIES: u8 = 16; + pub const AS4_PATH: u8 = 17; + pub const AS4_AGGREGATOR: u8 = 18; + pub const LARGE_COMMUNITIES: u8 = 32; + pub const ONLY_TO_CUSTOMER: u8 = 35; +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub enum PathAttribute { + Origin(OriginPathAttribute), + AsPath(AsPathAttribute), + NextHop(NextHopPathAttribute), + MultiExitDisc(MultiExitDiscPathAttribute), + LocalPref(LocalPrefPathAttribute), + AtomicAggregate(AtomicAggregatePathAttribute), + Aggregator(AggregatorPathAttribute), + Communities(CommunitiesPathAttribute), + MpReachNlri(MpReachNlriPathAttribute), + MpUnreachNlri(MpUnreachNlriPathAttribute), + ExtendedCommunities(ExtendedCommunitiesPathAttribute), + /// The AS path with four-octet AS numbers, for a route that crosses + /// speakers that only handle two-octet ones (RFC 6793 §3). + As4Path(AsPathAttribute), + LargeCommunities(LargeCommunitiesPathAttribute), + /// Only to Customer (RFC 9234 §5): the route has been handed to a + /// customer, a peer or a route server client by the AS with this + /// number, and from there on may only go down to customers. + OnlyToCustomer(u32), + /// An attribute this implementation does not interpret, kept as it + /// came so that it can be passed on. + UnknownPathAttribute { + flags: PathAttributeFlags, + type_code: u8, + payload: Vec, + }, +} + +/// The Optional and Transitive flags that the attribute with this type code +/// is defined to have, for the attributes whose definition we know. A +/// received attribute with other values is in error (RFC 7606 §3.c). +pub(crate) fn defined_flags(type_code: u8) -> Option<(bool, bool)> { + use attr_type::*; + const WELL_KNOWN: (bool, bool) = (false, true); + const OPTIONAL_TRANSITIVE: (bool, bool) = (true, true); + const OPTIONAL_NON_TRANSITIVE: (bool, bool) = (true, false); + Some(match type_code { + ORIGIN | AS_PATH | NEXT_HOP | LOCAL_PREF | ATOMIC_AGGREGATE => WELL_KNOWN, + MULTI_EXIT_DISC | ORIGINATOR_ID | CLUSTER_LIST | MP_REACH_NLRI | MP_UNREACH_NLRI => { + OPTIONAL_NON_TRANSITIVE + } + AGGREGATOR | COMMUNITIES | EXTENDED_COMMUNITIES | AS4_PATH | AS4_AGGREGATOR + | LARGE_COMMUNITIES | ONLY_TO_CUSTOMER => OPTIONAL_TRANSITIVE, + _ => return None, + }) +} + +/// A parsed value, provided the parser used all of it. +fn whole((rest, parsed): (&[u8], T)) -> Result>> { + if rest.is_empty() { + Ok(parsed) + } else { + Err(Failure(BgpParserError::Malformed(format!( + "{} octets left over", + rest.len() + )))) + } +} + +/// One attribute as it lies on the wire, split out but not interpreted. +#[derive(Debug, Clone)] +pub struct RawAttribute<'a> { + pub flags: PathAttributeFlags, + pub type_code: u8, + pub value: &'a [u8], + /// The whole attribute, header and value. + pub wire: &'a [u8], +} + +/// Why the bytes at the front of a buffer are not an attribute. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotAnAttribute { + /// Too few octets for flags, type and length. + NoHeader, + /// The length says more than there is. + Truncated { + type_code: u8, + length: usize, + available: usize, + }, +} + +impl std::fmt::Display for NotAnAttribute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoHeader => f.write_str("too few octets for an attribute"), + Self::Truncated { + type_code, + length, + available, + } => write!( + f, + "attribute {type_code} has length {length} but only {available} octets remain" + ), + } + } +} + +/// Splits the attribute at the front of `buf` from what follows it. +pub fn split_attribute(buf: &[u8]) -> Result<(RawAttribute<'_>, &[u8]), NotAnAttribute> { + let [flags, type_code, rest @ ..] = buf else { + return Err(NotAnAttribute::NoHeader); + }; + let flags = PathAttributeFlags(*flags); + let (length, rest) = match (flags.extended_length(), rest) { + (true, [hi, lo, rest @ ..]) => (usize::from(u16::from_be_bytes([*hi, *lo])), rest), + (false, [length, rest @ ..]) => (usize::from(*length), rest), + _ => return Err(NotAnAttribute::NoHeader), + }; + let Some((value, rest)) = rest.split_at_checked(length) else { + return Err(NotAnAttribute::Truncated { + type_code: *type_code, + length, + available: rest.len(), + }); + }; + let raw = RawAttribute { + flags, + type_code: *type_code, + value, + wire: &buf[..buf.len() - rest.len()], + }; + Ok((raw, rest)) +} + +impl PathAttribute { + pub fn type_code(&self) -> u8 { + use attr_type::*; + match self { + Self::Origin(_) => ORIGIN, + Self::AsPath(_) => AS_PATH, + Self::NextHop(_) => NEXT_HOP, + Self::MultiExitDisc(_) => MULTI_EXIT_DISC, + Self::LocalPref(_) => LOCAL_PREF, + Self::AtomicAggregate(_) => ATOMIC_AGGREGATE, + Self::Aggregator(_) => AGGREGATOR, + Self::Communities(_) => COMMUNITIES, + Self::MpReachNlri(_) => MP_REACH_NLRI, + Self::MpUnreachNlri(_) => MP_UNREACH_NLRI, + Self::ExtendedCommunities(_) => EXTENDED_COMMUNITIES, + Self::As4Path(_) => AS4_PATH, + Self::LargeCommunities(_) => LARGE_COMMUNITIES, + Self::OnlyToCustomer(_) => ONLY_TO_CUSTOMER, + Self::UnknownPathAttribute { type_code, .. } => *type_code, + } + } + + /// The flags to send the attribute with, but for Extended Length, which + /// depends on how long the value turns out to be. + fn flags(&self) -> PathAttributeFlags { + match self { + // As they came: the Partial bit has to survive (RFC 4271 §5). + Self::UnknownPathAttribute { flags, .. } => flags.clone(), + known => { + let (optional, transitive) = defined_flags(known.type_code()) + .expect("every attribute with a variant has defined flags"); + PathAttributeFlags::new(optional, transitive, false, false) + } + } + } + + /// Parses one attribute, header and value, from the front of `buf`. + /// The flags are not checked against the attribute's definition; + /// [`UpdateMessage::decode`] does that for attributes off a session. + pub fn from_wire<'a>( + ctx: &ParserContext, + buf: &'a [u8], + ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { + let (raw, rest) = + split_attribute(buf).map_err(|e| Failure(BgpParserError::Malformed(e.to_string())))?; + let attribute = Self::parse_value(ctx, raw.flags, raw.type_code, raw.value)?; + Ok((rest, attribute)) + } + + /// Makes the attribute with this type code from its value. `value` is + /// exactly the value, and all of it has to be used. A type code without + /// a variant of its own is kept as bytes. + pub(crate) fn parse_value<'a>( + ctx: &ParserContext, + flags: PathAttributeFlags, + type_code: u8, + value: &'a [u8], + ) -> Result>> { + use attr_type::*; + Ok(match type_code { + ORIGIN => Self::Origin(whole(OriginPathAttribute::from_wire(value)?)?), + AS_PATH => Self::AsPath(whole(AsPathAttribute::from_wire(ctx, value)?)?), + NEXT_HOP => Self::NextHop(whole(NextHopPathAttribute::from_wire(value)?)?), + MULTI_EXIT_DISC => { + Self::MultiExitDisc(whole(MultiExitDiscPathAttribute::from_wire(value)?)?) + } + LOCAL_PREF => Self::LocalPref(whole(LocalPrefPathAttribute::from_wire(value)?)?), + ATOMIC_AGGREGATE => { + Self::AtomicAggregate(whole((value, AtomicAggregatePathAttribute {}))?) + } + AGGREGATOR => Self::Aggregator(whole(AggregatorPathAttribute::from_wire(ctx, value)?)?), + COMMUNITIES => Self::Communities(whole(CommunitiesPathAttribute::from_wire(value)?)?), + MP_REACH_NLRI => { + Self::MpReachNlri(whole(MpReachNlriPathAttribute::from_wire(ctx, value)?)?) + } + MP_UNREACH_NLRI => { + Self::MpUnreachNlri(whole(MpUnreachNlriPathAttribute::from_wire(ctx, value)?)?) + } + EXTENDED_COMMUNITIES => Self::ExtendedCommunities(whole( + ExtendedCommunitiesPathAttribute::from_wire(value)?, + )?), + // Always four octets wide, whatever the session's AS_PATH is. + AS4_PATH => Self::As4Path(whole(AsPathAttribute::from_wire_with_width(true, value)?)?), + LARGE_COMMUNITIES => { + Self::LargeCommunities(whole(LargeCommunitiesPathAttribute::from_wire(value)?)?) + } + ONLY_TO_CUSTOMER => { + let asn = <[u8; 4]>::try_from(value).map_err(|_| { + malformed(format!( + "Only to Customer is {} octets long, not four", + value.len() + )) + })?; + Self::OnlyToCustomer(u32::from_be_bytes(asn)) + } + _ => Self::UnknownPathAttribute { + flags, + type_code, + payload: value.to_vec(), + }, + }) + } + + /// Writes the attribute, header and value, to the end of `out`. + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { + // A header with a one-octet length first; whether the value needs + // the two-octet one is known once it is written. + let start = out.len(); + let mut flags = self.flags(); + out.put_u8(0); + out.put_u8(self.type_code()); + out.put_u8(0); + let value_start = out.len(); + self.value_to_wire(ctx, out)?; + let length = out.len() - value_start; + + if let Ok(short) = u8::try_from(length) + && !flags.extended_length() + { + out[start + 2] = short; + } else { + let long = u16::try_from(length).map_err(|_| EncodeError::TooLong { + what: "path attribute", + len: length, + max: u16::MAX.into(), + })?; + flags.set_extended_length(true); + // Move the value up by one octet to make room. + out.put_u8(0); + out.copy_within(value_start..value_start + length, value_start + 1); + out[start + 2..start + 4].copy_from_slice(&long.to_be_bytes()); + } + out[start] = flags.0; + Ok(()) + } + + fn value_to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { + match self { + Self::Origin(a) => a.to_wire(out), + Self::AsPath(a) => a.to_wire(ctx, out)?, + Self::NextHop(a) => a.to_wire(out), + Self::MultiExitDisc(a) => a.to_wire(out), + Self::LocalPref(a) => a.to_wire(out), + Self::AtomicAggregate(_) => {} + Self::Aggregator(a) => a.to_wire(ctx, out), + Self::Communities(a) => a.to_wire(out), + Self::MpReachNlri(a) => a.to_wire(ctx, out), + Self::MpUnreachNlri(a) => a.to_wire(ctx, out), + Self::ExtendedCommunities(a) => a.to_wire(out), + Self::As4Path(a) => a.to_wire_with_width(true, out)?, + Self::LargeCommunities(a) => a.to_wire(out), + Self::OnlyToCustomer(asn) => out.put_u32(*asn), + Self::UnknownPathAttribute { payload, .. } => out.put_slice(payload), + } + Ok(()) + } +} + +/// An Only to Customer attribute with this AS number, as it is on the +/// wire: for adding to attributes that are kept as bytes. +pub fn only_to_customer_wire(asn: u32) -> [u8; 7] { + const OPTIONAL_TRANSITIVE: u8 = 0xc0; + let [a, b, c, d] = asn.to_be_bytes(); + [ + OPTIONAL_TRANSITIVE, + attr_type::ONLY_TO_CUSTOMER, + 4, + a, + b, + c, + d, + ] +} + +type Parsed<'a, T> = IResult<&'a [u8], T, BgpParserError<&'a [u8]>>; + +fn malformed<'a>(detail: String) -> nom::Err> { + Failure(BgpParserError::Malformed(detail)) +} + +/// ORIGIN: how the route came into BGP (RFC 4271 §5.1.1). +#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize_repr)] +#[repr(u8)] +pub enum OriginPathAttribute { + IGP = 0, + EGP = 1, + INCOMPLETE = 2, +} + +impl OriginPathAttribute { + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, byte) = be_u8(buf)?; + let origin = match byte { + 0 => Self::IGP, + 1 => Self::EGP, + 2 => Self::INCOMPLETE, + other => return Err(malformed(format!("unknown ORIGIN {other}"))), + }; + Ok((buf, origin)) + } + + pub fn to_wire(&self, out: &mut BytesMut) { + out.put_u8(*self as u8); + } +} + +/// AS_PATH: the ASes a route has passed through, as a list of segments +/// (RFC 4271 §4.3). Each segment is a type (1 for AS_SET, 2 for +/// AS_SEQUENCE), a count in one octet, and that many AS numbers. +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct AsPathAttribute { + pub segments: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct AsPathSegment { + /// True for an AS_SEQUENCE, false for an AS_SET. + pub ordered: bool, + pub path: Vec, +} + +impl AsPathAttribute { + pub fn from_asns(asns: Vec) -> PathAttribute { + let segment = AsPathSegment { + ordered: true, + path: asns, + }; + PathAttribute::AsPath(AsPathAttribute { + segments: vec![segment], + }) + } + + /// How long the path is, as RFC 4271 §9.1.2.2 counts: one for each AS + /// of a sequence, one for a whole set. + pub fn hops(&self) -> usize { + self.segments + .iter() + .map(|segment| { + if segment.ordered { + segment.path.len() + } else { + 1 + } + }) + .sum() + } + + /// The path as it really is, given what a two-octet session carried: + /// this, with AS_TRANS wherever a number did not fit, and `wide`, the + /// AS4_PATH that has the numbers themselves for the path's tail + /// (RFC 6793 §4.2.3). What `wide` does not cover is kept from the + /// front of this. + /// + /// `None` if `wide` is longer than the path, which is not to be used: + /// some speaker on the way that knew nothing of AS4_PATH changed the + /// one and not the other. + pub fn with_as4_path(&self, wide: &AsPathAttribute) -> Option { + let mut keep = self.hops().checked_sub(wide.hops())?; + let mut segments = Vec::with_capacity(self.segments.len() + wide.segments.len()); + for segment in &self.segments { + if keep == 0 { + break; + } + let (hops, taken) = if segment.ordered { + let taken = segment.path.len().min(keep); + (taken, &segment.path[..taken]) + } else { + (1, &segment.path[..]) + }; + keep -= hops; + segments.push(AsPathSegment { + ordered: segment.ordered, + path: taken.to_vec(), + }); + } + segments.extend(wide.segments.iter().cloned()); + Some(AsPathAttribute { segments }) + } + + /// Parses an AS_PATH with AS numbers as wide as the session negotiated + /// (RFC 6793). + pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { + Self::from_wire_with_width(ctx.four_octet_asn, buf) + } + + fn from_wire_with_width(four_octet: bool, buf: &[u8]) -> Parsed<'_, Self> { + let parse_segment = |buf| -> Parsed<'_, AsPathSegment> { + let (buf, typ) = be_u8(buf)?; + let (buf, len) = be_u8(buf)?; + // AS_SET and AS_SEQUENCE. The confederation segment types of + // RFC 5065 are an error from a peer outside a confederation, + // and this speaker is never inside one. + if typ != 1 && typ != 2 { + return Err(malformed(format!( + "unrecognised AS path segment type {typ}" + ))); + } + if len == 0 { + return Err(malformed("AS path segment with no AS numbers".into())); + } + let count = usize::from(len); + let (buf, path): (_, Vec) = if four_octet { + nom::multi::many_m_n(count, count, be_u32).parse(buf)? + } else { + nom::multi::many_m_n(count, count, be_u16) + .parse(buf) + .map(|(buf, asns)| (buf, asns.into_iter().map(u32::from).collect()))? + }; + Ok(( + buf, + AsPathSegment { + ordered: typ == 2, + path, + }, + )) + }; + + // Not many0: that stops quietly at a segment it cannot read, and a + // truncated segment is an error. + let mut segments = Vec::new(); + let mut buf = buf; + while !buf.is_empty() { + let (rest, segment) = parse_segment(buf)?; + segments.push(segment); + buf = rest; + } + Ok((buf, Self { segments })) + } + + /// Writes the path with AS numbers as wide as the session negotiated. + /// On a two-octet session an AS number that does not fit is written as + /// AS_TRANS (RFC 6793 §4.2.2); [`AsPathAttribute::as4_path`] is what + /// carries the real numbers alongside. + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { + self.to_wire_with_width(ctx.four_octet_asn, out) + } + + fn to_wire_with_width(&self, four_octet: bool, out: &mut BytesMut) -> Result<(), EncodeError> { + for segment in &self.segments { + out.put_u8(if segment.ordered { 2 } else { 1 }); + let count = u8::try_from(segment.path.len()).map_err(|_| EncodeError::TooLong { + what: "AS path segment", + len: segment.path.len(), + max: u8::MAX.into(), + })?; + out.put_u8(count); + for asn in &segment.path { + if four_octet { + out.put_u32(*asn); + } else { + out.put_u16(u16::try_from(*asn).unwrap_or(AS_TRANS)); + } + } + } + Ok(()) + } + + /// The AS4_PATH to send with this path on a two-octet session, if one is + /// needed: it is when some AS number does not fit in two octets and so + /// goes out as AS_TRANS in the AS_PATH itself (RFC 6793 §4.2.2). + pub fn as4_path(&self) -> Option { + self.segments + .iter() + .flat_map(|segment| &segment.path) + .any(|asn| u16::try_from(*asn).is_err()) + .then(|| PathAttribute::As4Path(self.clone())) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct NextHopPathAttribute(pub Ipv4Addr); + +impl NextHopPathAttribute { + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, address) = be_u32(buf)?; + Ok((buf, Self(Ipv4Addr::from(address)))) + } + + pub fn to_wire(&self, out: &mut BytesMut) { + out.put_u32(self.0.into()); + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct MultiExitDiscPathAttribute(pub u32); + +impl MultiExitDiscPathAttribute { + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, value) = be_u32(buf)?; + Ok((buf, Self(value))) + } + + pub fn to_wire(&self, out: &mut BytesMut) { + out.put_u32(self.0); + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct LocalPrefPathAttribute(pub u32); + +impl LocalPrefPathAttribute { + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, value) = be_u32(buf)?; + Ok((buf, Self(value))) + } + + pub fn to_wire(&self, out: &mut BytesMut) { + out.put_u32(self.0); + } +} + +/// ATOMIC_AGGREGATE: a marker with no value (RFC 4271 §5.1.6). +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct AtomicAggregatePathAttribute {} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct AggregatorPathAttribute { + pub asn: u32, + pub ip: Ipv4Addr, +} + +impl AggregatorPathAttribute { + /// The AS number is as wide as the session's AS numbers (RFC 6793), so + /// the attribute is six or eight octets. + pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { + let (buf, asn) = if ctx.four_octet_asn { + be_u32(buf)? + } else { + be_u16(buf).map(|(buf, asn)| (buf, u32::from(asn)))? + }; + let (buf, ip) = be_u32(buf)?; + Ok(( + buf, + Self { + asn, + ip: Ipv4Addr::from(ip), + }, + )) + } + + /// On a two-octet session an AS number that does not fit is written as + /// AS_TRANS (RFC 6793 §4.2.2). + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) { + if ctx.four_octet_asn { + out.put_u32(self.asn); + } else { + out.put_u16(u16::try_from(self.asn).unwrap_or(AS_TRANS)); + } + out.put_u32(self.ip.into()); + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct CommunitiesPathAttribute(pub Vec<(u16, u16)>); + +impl CommunitiesPathAttribute { + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + // At least one: an empty COMMUNITIES is malformed (RFC 7606 §7.8). + let (buf, values) = nom::multi::many1(|i| (be_u16, be_u16).parse(i)).parse(buf)?; + Ok((buf, CommunitiesPathAttribute(values))) + } + + pub fn to_wire(&self, out: &mut BytesMut) { + for value in &self.0 { + out.put_u16(value.0); + out.put_u16(value.1); + } + } +} + +/// EXTENDED COMMUNITIES (RFC 4360). +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct ExtendedCommunitiesPathAttribute { + pub extended_communities: Vec, +} + +impl ExtendedCommunitiesPathAttribute { + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (communities, rest) = buf.as_chunks::<8>(); + if communities.is_empty() || !rest.is_empty() { + return Err(malformed(format!( + "{} octets are not a list of extended communities", + buf.len() + ))); + } + Ok(( + rest, + Self { + extended_communities: communities.iter().copied().map(ExtendedCommunity).collect(), + }, + )) + } + + pub fn to_wire(&self, out: &mut BytesMut) { + for community in &self.extended_communities { + out.put_slice(&community.0); + } + } +} + +/// One extended community: eight octets, of which the first is the type +/// and, for most types, the second a sub-type (RFC 4360 §2). How the rest +/// divides into an administrator and a value depends on the type, and is +/// left to whoever acts on a particular community. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize)] +pub struct ExtendedCommunity(pub [u8; 8]); + +impl ExtendedCommunity { + pub fn typ(&self) -> u8 { + self.0[0] + } + + pub fn sub_typ(&self) -> u8 { + self.0[1] + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct LargeCommunitiesPathAttribute { + pub communities: Vec, +} + +impl LargeCommunitiesPathAttribute { + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, communities) = nom::multi::many1(LargeCommunity::from_wire).parse(buf)?; + Ok((buf, Self { communities })) + } + + pub fn to_wire(&self, out: &mut BytesMut) { + for community in &self.communities { + community.to_wire(out); + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct LargeCommunity { + pub global_admin: u32, + pub data_1: u32, + pub data_2: u32, +} + +impl LargeCommunity { + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, global_admin) = be_u32(buf)?; + let (buf, data_1) = be_u32(buf)?; + let (buf, data_2) = be_u32(buf)?; + Ok(( + buf, + Self { + global_admin, + data_1, + data_2, + }, + )) + } + + pub fn to_wire(&self, out: &mut BytesMut) { + out.put_u32(self.global_admin); + out.put_u32(self.data_1); + out.put_u32(self.data_2); + } +} + +/// The next hop of routes in MP_REACH_NLRI. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Hash)] +pub enum NlriNextHop { + Ipv4(Ipv4Addr), + /// A global IPv6 address. + Ipv6(Ipv6Addr), + /// A global IPv6 address and a link-local one (RFC 2545 §3). + Ipv6WithLl { + global: Ipv6Addr, + link_local: Ipv6Addr, + }, +} + +impl std::fmt::Display for NlriNextHop { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NlriNextHop::Ipv4(addr) => write!(f, "{addr}"), + NlriNextHop::Ipv6(addr) => write!(f, "{addr}"), + NlriNextHop::Ipv6WithLl { global, link_local } => { + write!(f, "{global} (link-local {link_local})") + } + } + } +} + +impl NlriNextHop { + /// Reads a next hop that is all of `bytes`. `None` if they are not the + /// length of one. + pub fn from_bytes(bytes: &[u8]) -> Option { + let v6 = |bytes: &[u8]| <[u8; 16]>::try_from(bytes).ok().map(Ipv6Addr::from); + match bytes.len() { + 4 => <[u8; 4]>::try_from(bytes) + .ok() + .map(|a| Self::Ipv4(a.into())), + 16 => v6(bytes).map(Self::Ipv6), + 32 => Some(Self::Ipv6WithLl { + global: v6(&bytes[..16])?, + link_local: v6(&bytes[16..])?, + }), + _ => None, + } + } + + pub fn to_wire(&self, out: &mut BytesMut) { + match self { + NlriNextHop::Ipv4(address) => out.put_slice(&address.octets()), + NlriNextHop::Ipv6(address) => out.put_slice(&address.octets()), + NlriNextHop::Ipv6WithLl { global, link_local } => { + out.put_slice(&global.octets()); + out.put_slice(&link_local.octets()); + } + } + } + + /// How many octets the next hop is on the wire. + pub fn wire_len(&self) -> u8 { + match self { + NlriNextHop::Ipv4(_) => 4, + NlriNextHop::Ipv6(_) => 16, + NlriNextHop::Ipv6WithLl { .. } => 32, + } + } +} + +/// Parses one prefix as an UPDATE names it: with a path identifier in +/// front if the session uses them for the family (RFC 7911 §3). +fn parse_nlri(version: IpVersion, with_path_id: bool, buf: &[u8]) -> Parsed<'_, Nlri> { + let (buf, path_id) = if with_path_id { be_u32(buf)? } else { (buf, 0) }; + let (buf, prefix_len) = be_u8(buf)?; + let byte_len = usize::from(prefix_len.div_ceil(8)); + let (buf, prefix_bytes) = nom::bytes::take(byte_len).parse(buf)?; + let prefix = + IpPrefix::new(version, prefix_bytes, prefix_len).map_err(|e| malformed(e.to_string()))?; + Ok((buf, Nlri { prefix, path_id })) +} + +/// Parses a field that is nothing but prefixes, to its end. +pub(crate) fn parse_nlri_list( + version: IpVersion, + with_path_id: bool, + mut buf: &[u8], +) -> Parsed<'_, Vec> { + // Not many0: that stops quietly at a prefix it cannot read. + let mut nlri = Vec::new(); + while !buf.is_empty() { + let (rest, parsed) = parse_nlri(version, with_path_id, buf)?; + nlri.push(parsed); + buf = rest; + } + Ok((buf, nlri)) +} + +/// The IP version of the prefixes that make up the NLRI of `family`. +/// +/// Any family can be named in MP_REACH_NLRI and MP_UNREACH_NLRI, but only +/// unicast and multicast routes of IPv4 and IPv6 are plain prefixes, and +/// those are the only NLRI this parser reads. A peer does not send routes +/// of a family that was not negotiated, so the others do not come up. +fn ip_version_of_nlri<'a>( + family: AfiSafi, +) -> Result>> { + let unsupported = || malformed(format!("NLRI of {family} is not supported")); + if family.safi != Safi::UNICAST && family.safi != Safi::MULTICAST { + return Err(unsupported()); + } + IpVersion::try_from(family.afi).map_err(|_| unsupported()) +} + +fn parse_family(buf: &[u8]) -> Parsed<'_, AfiSafi> { + let (buf, afi) = be_u16(buf)?; + let (buf, safi) = be_u8(buf)?; + Ok((buf, AfiSafi::new(Afi(afi), Safi(safi)))) +} + +/// MP_REACH_NLRI: routes of a family other than the one in the message +/// body, with their next hop (RFC 4760 §3). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct MpReachNlriPathAttribute { + pub family: AfiSafi, + pub next_hop: NlriNextHop, + pub prefixes: Vec, +} + +impl MpReachNlriPathAttribute { + pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { + let (buf, family) = parse_family(buf)?; + let version = ip_version_of_nlri(family)?; + + let (buf, next_hop_len) = be_u8(buf)?; + let (buf, next_hop) = nom::bytes::take(usize::from(next_hop_len)).parse(buf)?; + // For IPv6 routes, a global address, optionally followed by a + // link-local one (RFC 2545). For IPv4 routes an IPv4 address, or + // the same as for IPv6: routes over a link that has only IPv6 + // addresses (RFC 8950). Which of the two it is shows in its length. + let next_hop = NlriNextHop::from_bytes(next_hop) + .filter(|next_hop| { + version == IpVersion::Ipv4 || !matches!(next_hop, NlriNextHop::Ipv4(_)) + }) + .ok_or_else(|| { + malformed(format!( + "a next hop of {next_hop_len} octets is not valid for {family}" + )) + })?; + + // Reserved octet (formerly the SNPA count), for every family. + let (buf, _) = be_u8(buf)?; + let (buf, prefixes) = parse_nlri_list(version, ctx.has_add_path(family), buf)?; + Ok(( + buf, + Self { + family, + next_hop, + prefixes, + }, + )) + } + + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) { + out.put_u16(self.family.afi.0); + out.put_u8(self.family.safi.0); + out.put_u8(self.next_hop.wire_len()); + self.next_hop.to_wire(out); + out.put_u8(0); + write_nlri(&self.prefixes, ctx.has_add_path(self.family), out); + } +} + +/// MP_UNREACH_NLRI: withdrawals of routes of a family other than the one +/// in the message body (RFC 4760 §4). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct MpUnreachNlriPathAttribute { + pub family: AfiSafi, + pub prefixes: Vec, +} + +impl MpUnreachNlriPathAttribute { + pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { + let (buf, family) = parse_family(buf)?; + let version = ip_version_of_nlri(family)?; + let (buf, prefixes) = parse_nlri_list(version, ctx.has_add_path(family), buf)?; + Ok((buf, Self { family, prefixes })) + } + + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) { + out.put_u16(self.family.afi.0); + out.put_u8(self.family.safi.0); + write_nlri(&self.prefixes, ctx.has_add_path(self.family), out); + } +} diff --git a/crates/packet/src/lib.rs b/crates/packet/src/lib.rs index 1646d90..fda6bfd 100644 --- a/crates/packet/src/lib.rs +++ b/crates/packet/src/lib.rs @@ -1,3 +1,4 @@ +pub mod attributes; pub mod community; pub mod errors; pub mod message; diff --git a/crates/packet/src/message.rs b/crates/packet/src/message.rs index 8047899..1a1304d 100644 --- a/crates/packet/src/message.rs +++ b/crates/packet/src/message.rs @@ -1,20 +1,10 @@ -use std::net::{Ipv4Addr, Ipv6Addr}; - -use bitfield::bitfield; use bytes::BufMut; use bytes::BytesMut; -use nom::Err::Failure; -use nom::IResult; -use nom::Parser; -use nom::number::complete::be_u8; -use nom::number::complete::be_u16; -use nom::number::complete::be_u32; use serde::Serialize; -use serde_repr::Serialize_repr; use crate::update_decode::UpdateError; -use bgp_primitives::constants::{Afi, AfiSafi, IpVersion, Safi}; -use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; +use bgp_primitives::constants::{Afi, AfiSafi, Safi}; +use bgp_primitives::ip_prefix::Nlri; use bgp_primitives::parser::BgpParserError; use bgp_primitives::parser::EncodeError; use bgp_primitives::parser::ParserContext; @@ -352,7 +342,7 @@ fn fill_length(out: &mut BytesMut, at: usize, what: &'static str) -> Result<(), /// Writes prefixes as an UPDATE names them: for each, its path identifier /// if the session uses them for the family (RFC 7911 §3), its length in /// bits, and the bytes that length covers. -fn write_nlri(nlri: &[Nlri], with_path_id: bool, out: &mut BytesMut) { +pub(crate) fn write_nlri(nlri: &[Nlri], with_path_id: bool, out: &mut BytesMut) { for n in nlri { if with_path_id { out.put_u32(n.path_id); @@ -376,918 +366,7 @@ pub(crate) fn describe(e: &nom::Err>) -> String { } } -bitfield! { - #[derive(Clone, Debug, Serialize, PartialEq, Eq)] - pub struct PathAttributeFlags(u8); - impl new; - u8; - pub optional, set_optional: 7; - pub transitive, set_transitive: 6; - pub partial, set_partial: 5; - pub extended_length, set_extended_length: 4; -} - -/// Path attribute type codes. -pub mod attr_type { - pub const ORIGIN: u8 = 1; - pub const AS_PATH: u8 = 2; - pub const NEXT_HOP: u8 = 3; - pub const MULTI_EXIT_DISC: u8 = 4; - pub const LOCAL_PREF: u8 = 5; - pub const ATOMIC_AGGREGATE: u8 = 6; - pub const AGGREGATOR: u8 = 7; - pub const COMMUNITIES: u8 = 8; - pub const ORIGINATOR_ID: u8 = 9; - pub const CLUSTER_LIST: u8 = 10; - pub const MP_REACH_NLRI: u8 = 14; - pub const MP_UNREACH_NLRI: u8 = 15; - pub const EXTENDED_COMMUNITIES: u8 = 16; - pub const AS4_PATH: u8 = 17; - pub const AS4_AGGREGATOR: u8 = 18; - pub const LARGE_COMMUNITIES: u8 = 32; - pub const ONLY_TO_CUSTOMER: u8 = 35; -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub enum PathAttribute { - Origin(OriginPathAttribute), - AsPath(AsPathAttribute), - NextHop(NextHopPathAttribute), - MultiExitDisc(MultiExitDiscPathAttribute), - LocalPref(LocalPrefPathAttribute), - AtomicAggregate(AtomicAggregatePathAttribute), - Aggregator(AggregatorPathAttribute), - Communities(CommunitiesPathAttribute), - MpReachNlri(MpReachNlriPathAttribute), - MpUnreachNlri(MpUnreachNlriPathAttribute), - ExtendedCommunities(ExtendedCommunitiesPathAttribute), - /// The AS path with four-octet AS numbers, for a route that crosses - /// speakers that only handle two-octet ones (RFC 6793 §3). - As4Path(AsPathAttribute), - LargeCommunities(LargeCommunitiesPathAttribute), - /// Only to Customer (RFC 9234 §5): the route has been handed to a - /// customer, a peer or a route server client by the AS with this - /// number, and from there on may only go down to customers. - OnlyToCustomer(u32), - /// An attribute this implementation does not interpret, kept as it - /// came so that it can be passed on. - UnknownPathAttribute { - flags: PathAttributeFlags, - type_code: u8, - payload: Vec, - }, -} - -/// The Optional and Transitive flags that the attribute with this type code -/// is defined to have, for the attributes whose definition we know. A -/// received attribute with other values is in error (RFC 7606 §3.c). -pub(crate) fn defined_flags(type_code: u8) -> Option<(bool, bool)> { - use attr_type::*; - const WELL_KNOWN: (bool, bool) = (false, true); - const OPTIONAL_TRANSITIVE: (bool, bool) = (true, true); - const OPTIONAL_NON_TRANSITIVE: (bool, bool) = (true, false); - Some(match type_code { - ORIGIN | AS_PATH | NEXT_HOP | LOCAL_PREF | ATOMIC_AGGREGATE => WELL_KNOWN, - MULTI_EXIT_DISC | ORIGINATOR_ID | CLUSTER_LIST | MP_REACH_NLRI | MP_UNREACH_NLRI => { - OPTIONAL_NON_TRANSITIVE - } - AGGREGATOR | COMMUNITIES | EXTENDED_COMMUNITIES | AS4_PATH | AS4_AGGREGATOR - | LARGE_COMMUNITIES | ONLY_TO_CUSTOMER => OPTIONAL_TRANSITIVE, - _ => return None, - }) -} - -/// A parsed value, provided the parser used all of it. -fn whole((rest, parsed): (&[u8], T)) -> Result>> { - if rest.is_empty() { - Ok(parsed) - } else { - Err(Failure(BgpParserError::Malformed(format!( - "{} octets left over", - rest.len() - )))) - } -} - -/// One attribute as it lies on the wire, split out but not interpreted. -#[derive(Debug, Clone)] -pub struct RawAttribute<'a> { - pub flags: PathAttributeFlags, - pub type_code: u8, - pub value: &'a [u8], - /// The whole attribute, header and value. - pub wire: &'a [u8], -} - -/// Why the bytes at the front of a buffer are not an attribute. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NotAnAttribute { - /// Too few octets for flags, type and length. - NoHeader, - /// The length says more than there is. - Truncated { - type_code: u8, - length: usize, - available: usize, - }, -} - -impl std::fmt::Display for NotAnAttribute { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::NoHeader => f.write_str("too few octets for an attribute"), - Self::Truncated { - type_code, - length, - available, - } => write!( - f, - "attribute {type_code} has length {length} but only {available} octets remain" - ), - } - } -} - -/// Splits the attribute at the front of `buf` from what follows it. -pub fn split_attribute(buf: &[u8]) -> Result<(RawAttribute<'_>, &[u8]), NotAnAttribute> { - let [flags, type_code, rest @ ..] = buf else { - return Err(NotAnAttribute::NoHeader); - }; - let flags = PathAttributeFlags(*flags); - let (length, rest) = match (flags.extended_length(), rest) { - (true, [hi, lo, rest @ ..]) => (usize::from(u16::from_be_bytes([*hi, *lo])), rest), - (false, [length, rest @ ..]) => (usize::from(*length), rest), - _ => return Err(NotAnAttribute::NoHeader), - }; - let Some((value, rest)) = rest.split_at_checked(length) else { - return Err(NotAnAttribute::Truncated { - type_code: *type_code, - length, - available: rest.len(), - }); - }; - let raw = RawAttribute { - flags, - type_code: *type_code, - value, - wire: &buf[..buf.len() - rest.len()], - }; - Ok((raw, rest)) -} - -impl PathAttribute { - pub fn type_code(&self) -> u8 { - use attr_type::*; - match self { - Self::Origin(_) => ORIGIN, - Self::AsPath(_) => AS_PATH, - Self::NextHop(_) => NEXT_HOP, - Self::MultiExitDisc(_) => MULTI_EXIT_DISC, - Self::LocalPref(_) => LOCAL_PREF, - Self::AtomicAggregate(_) => ATOMIC_AGGREGATE, - Self::Aggregator(_) => AGGREGATOR, - Self::Communities(_) => COMMUNITIES, - Self::MpReachNlri(_) => MP_REACH_NLRI, - Self::MpUnreachNlri(_) => MP_UNREACH_NLRI, - Self::ExtendedCommunities(_) => EXTENDED_COMMUNITIES, - Self::As4Path(_) => AS4_PATH, - Self::LargeCommunities(_) => LARGE_COMMUNITIES, - Self::OnlyToCustomer(_) => ONLY_TO_CUSTOMER, - Self::UnknownPathAttribute { type_code, .. } => *type_code, - } - } - - /// The flags to send the attribute with, but for Extended Length, which - /// depends on how long the value turns out to be. - fn flags(&self) -> PathAttributeFlags { - match self { - // As they came: the Partial bit has to survive (RFC 4271 §5). - Self::UnknownPathAttribute { flags, .. } => flags.clone(), - known => { - let (optional, transitive) = defined_flags(known.type_code()) - .expect("every attribute with a variant has defined flags"); - PathAttributeFlags::new(optional, transitive, false, false) - } - } - } - - /// Parses one attribute, header and value, from the front of `buf`. - /// The flags are not checked against the attribute's definition; - /// [`UpdateMessage::decode`] does that for attributes off a session. - pub fn from_wire<'a>( - ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (raw, rest) = - split_attribute(buf).map_err(|e| Failure(BgpParserError::Malformed(e.to_string())))?; - let attribute = Self::parse_value(ctx, raw.flags, raw.type_code, raw.value)?; - Ok((rest, attribute)) - } - - /// Makes the attribute with this type code from its value. `value` is - /// exactly the value, and all of it has to be used. A type code without - /// a variant of its own is kept as bytes. - pub(crate) fn parse_value<'a>( - ctx: &ParserContext, - flags: PathAttributeFlags, - type_code: u8, - value: &'a [u8], - ) -> Result>> { - use attr_type::*; - Ok(match type_code { - ORIGIN => Self::Origin(whole(OriginPathAttribute::from_wire(value)?)?), - AS_PATH => Self::AsPath(whole(AsPathAttribute::from_wire(ctx, value)?)?), - NEXT_HOP => Self::NextHop(whole(NextHopPathAttribute::from_wire(value)?)?), - MULTI_EXIT_DISC => { - Self::MultiExitDisc(whole(MultiExitDiscPathAttribute::from_wire(value)?)?) - } - LOCAL_PREF => Self::LocalPref(whole(LocalPrefPathAttribute::from_wire(value)?)?), - ATOMIC_AGGREGATE => { - Self::AtomicAggregate(whole((value, AtomicAggregatePathAttribute {}))?) - } - AGGREGATOR => Self::Aggregator(whole(AggregatorPathAttribute::from_wire(ctx, value)?)?), - COMMUNITIES => Self::Communities(whole(CommunitiesPathAttribute::from_wire(value)?)?), - MP_REACH_NLRI => { - Self::MpReachNlri(whole(MpReachNlriPathAttribute::from_wire(ctx, value)?)?) - } - MP_UNREACH_NLRI => { - Self::MpUnreachNlri(whole(MpUnreachNlriPathAttribute::from_wire(ctx, value)?)?) - } - EXTENDED_COMMUNITIES => Self::ExtendedCommunities(whole( - ExtendedCommunitiesPathAttribute::from_wire(value)?, - )?), - // Always four octets wide, whatever the session's AS_PATH is. - AS4_PATH => Self::As4Path(whole(AsPathAttribute::from_wire_with_width(true, value)?)?), - LARGE_COMMUNITIES => { - Self::LargeCommunities(whole(LargeCommunitiesPathAttribute::from_wire(value)?)?) - } - ONLY_TO_CUSTOMER => { - let asn = <[u8; 4]>::try_from(value).map_err(|_| { - malformed(format!( - "Only to Customer is {} octets long, not four", - value.len() - )) - })?; - Self::OnlyToCustomer(u32::from_be_bytes(asn)) - } - _ => Self::UnknownPathAttribute { - flags, - type_code, - payload: value.to_vec(), - }, - }) - } - - /// Writes the attribute, header and value, to the end of `out`. - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { - // A header with a one-octet length first; whether the value needs - // the two-octet one is known once it is written. - let start = out.len(); - let mut flags = self.flags(); - out.put_u8(0); - out.put_u8(self.type_code()); - out.put_u8(0); - let value_start = out.len(); - self.value_to_wire(ctx, out)?; - let length = out.len() - value_start; - - if let Ok(short) = u8::try_from(length) - && !flags.extended_length() - { - out[start + 2] = short; - } else { - let long = u16::try_from(length).map_err(|_| EncodeError::TooLong { - what: "path attribute", - len: length, - max: u16::MAX.into(), - })?; - flags.set_extended_length(true); - // Move the value up by one octet to make room. - out.put_u8(0); - out.copy_within(value_start..value_start + length, value_start + 1); - out[start + 2..start + 4].copy_from_slice(&long.to_be_bytes()); - } - out[start] = flags.0; - Ok(()) - } - - fn value_to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { - match self { - Self::Origin(a) => a.to_wire(out), - Self::AsPath(a) => a.to_wire(ctx, out)?, - Self::NextHop(a) => a.to_wire(out), - Self::MultiExitDisc(a) => a.to_wire(out), - Self::LocalPref(a) => a.to_wire(out), - Self::AtomicAggregate(_) => {} - Self::Aggregator(a) => a.to_wire(ctx, out), - Self::Communities(a) => a.to_wire(out), - Self::MpReachNlri(a) => a.to_wire(ctx, out), - Self::MpUnreachNlri(a) => a.to_wire(ctx, out), - Self::ExtendedCommunities(a) => a.to_wire(out), - Self::As4Path(a) => a.to_wire_with_width(true, out)?, - Self::LargeCommunities(a) => a.to_wire(out), - Self::OnlyToCustomer(asn) => out.put_u32(*asn), - Self::UnknownPathAttribute { payload, .. } => out.put_slice(payload), - } - Ok(()) - } -} - -/// An Only to Customer attribute with this AS number, as it is on the -/// wire: for adding to attributes that are kept as bytes. -pub fn only_to_customer_wire(asn: u32) -> [u8; 7] { - const OPTIONAL_TRANSITIVE: u8 = 0xc0; - let [a, b, c, d] = asn.to_be_bytes(); - [ - OPTIONAL_TRANSITIVE, - attr_type::ONLY_TO_CUSTOMER, - 4, - a, - b, - c, - d, - ] -} - -type Parsed<'a, T> = IResult<&'a [u8], T, BgpParserError<&'a [u8]>>; - -fn malformed<'a>(detail: String) -> nom::Err> { - Failure(BgpParserError::Malformed(detail)) -} - -/// ORIGIN: how the route came into BGP (RFC 4271 §5.1.1). -#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize_repr)] -#[repr(u8)] -pub enum OriginPathAttribute { - IGP = 0, - EGP = 1, - INCOMPLETE = 2, -} - -impl OriginPathAttribute { - pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { - let (buf, byte) = be_u8(buf)?; - let origin = match byte { - 0 => Self::IGP, - 1 => Self::EGP, - 2 => Self::INCOMPLETE, - other => return Err(malformed(format!("unknown ORIGIN {other}"))), - }; - Ok((buf, origin)) - } - - pub fn to_wire(&self, out: &mut BytesMut) { - out.put_u8(*self as u8); - } -} - -/// AS_PATH: the ASes a route has passed through, as a list of segments -/// (RFC 4271 §4.3). Each segment is a type (1 for AS_SET, 2 for -/// AS_SEQUENCE), a count in one octet, and that many AS numbers. -#[derive(Debug, PartialEq, Eq, Clone, Serialize)] -pub struct AsPathAttribute { - pub segments: Vec, -} - -#[derive(Debug, PartialEq, Eq, Clone, Serialize)] -pub struct AsPathSegment { - /// True for an AS_SEQUENCE, false for an AS_SET. - pub ordered: bool, - pub path: Vec, -} - -impl AsPathAttribute { - pub fn from_asns(asns: Vec) -> PathAttribute { - let segment = AsPathSegment { - ordered: true, - path: asns, - }; - PathAttribute::AsPath(AsPathAttribute { - segments: vec![segment], - }) - } - - /// How long the path is, as RFC 4271 §9.1.2.2 counts: one for each AS - /// of a sequence, one for a whole set. - pub fn hops(&self) -> usize { - self.segments - .iter() - .map(|segment| { - if segment.ordered { - segment.path.len() - } else { - 1 - } - }) - .sum() - } - - /// The path as it really is, given what a two-octet session carried: - /// this, with AS_TRANS wherever a number did not fit, and `wide`, the - /// AS4_PATH that has the numbers themselves for the path's tail - /// (RFC 6793 §4.2.3). What `wide` does not cover is kept from the - /// front of this. - /// - /// `None` if `wide` is longer than the path, which is not to be used: - /// some speaker on the way that knew nothing of AS4_PATH changed the - /// one and not the other. - pub fn with_as4_path(&self, wide: &AsPathAttribute) -> Option { - let mut keep = self.hops().checked_sub(wide.hops())?; - let mut segments = Vec::with_capacity(self.segments.len() + wide.segments.len()); - for segment in &self.segments { - if keep == 0 { - break; - } - let (hops, taken) = if segment.ordered { - let taken = segment.path.len().min(keep); - (taken, &segment.path[..taken]) - } else { - (1, &segment.path[..]) - }; - keep -= hops; - segments.push(AsPathSegment { - ordered: segment.ordered, - path: taken.to_vec(), - }); - } - segments.extend(wide.segments.iter().cloned()); - Some(AsPathAttribute { segments }) - } - - /// Parses an AS_PATH with AS numbers as wide as the session negotiated - /// (RFC 6793). - pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { - Self::from_wire_with_width(ctx.four_octet_asn, buf) - } - - fn from_wire_with_width(four_octet: bool, buf: &[u8]) -> Parsed<'_, Self> { - let parse_segment = |buf| -> Parsed<'_, AsPathSegment> { - let (buf, typ) = be_u8(buf)?; - let (buf, len) = be_u8(buf)?; - // AS_SET and AS_SEQUENCE. The confederation segment types of - // RFC 5065 are an error from a peer outside a confederation, - // and this speaker is never inside one. - if typ != 1 && typ != 2 { - return Err(malformed(format!( - "unrecognised AS path segment type {typ}" - ))); - } - if len == 0 { - return Err(malformed("AS path segment with no AS numbers".into())); - } - let count = usize::from(len); - let (buf, path): (_, Vec) = if four_octet { - nom::multi::many_m_n(count, count, be_u32).parse(buf)? - } else { - nom::multi::many_m_n(count, count, be_u16) - .parse(buf) - .map(|(buf, asns)| (buf, asns.into_iter().map(u32::from).collect()))? - }; - Ok(( - buf, - AsPathSegment { - ordered: typ == 2, - path, - }, - )) - }; - - // Not many0: that stops quietly at a segment it cannot read, and a - // truncated segment is an error. - let mut segments = Vec::new(); - let mut buf = buf; - while !buf.is_empty() { - let (rest, segment) = parse_segment(buf)?; - segments.push(segment); - buf = rest; - } - Ok((buf, Self { segments })) - } - - /// Writes the path with AS numbers as wide as the session negotiated. - /// On a two-octet session an AS number that does not fit is written as - /// AS_TRANS (RFC 6793 §4.2.2); [`AsPathAttribute::as4_path`] is what - /// carries the real numbers alongside. - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { - self.to_wire_with_width(ctx.four_octet_asn, out) - } - - fn to_wire_with_width(&self, four_octet: bool, out: &mut BytesMut) -> Result<(), EncodeError> { - for segment in &self.segments { - out.put_u8(if segment.ordered { 2 } else { 1 }); - let count = u8::try_from(segment.path.len()).map_err(|_| EncodeError::TooLong { - what: "AS path segment", - len: segment.path.len(), - max: u8::MAX.into(), - })?; - out.put_u8(count); - for asn in &segment.path { - if four_octet { - out.put_u32(*asn); - } else { - out.put_u16(u16::try_from(*asn).unwrap_or(AS_TRANS)); - } - } - } - Ok(()) - } - - /// The AS4_PATH to send with this path on a two-octet session, if one is - /// needed: it is when some AS number does not fit in two octets and so - /// goes out as AS_TRANS in the AS_PATH itself (RFC 6793 §4.2.2). - pub fn as4_path(&self) -> Option { - self.segments - .iter() - .flat_map(|segment| &segment.path) - .any(|asn| u16::try_from(*asn).is_err()) - .then(|| PathAttribute::As4Path(self.clone())) - } -} - -#[derive(Debug, PartialEq, Eq, Clone, Serialize)] -pub struct NextHopPathAttribute(pub Ipv4Addr); - -impl NextHopPathAttribute { - pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { - let (buf, address) = be_u32(buf)?; - Ok((buf, Self(Ipv4Addr::from(address)))) - } - - pub fn to_wire(&self, out: &mut BytesMut) { - out.put_u32(self.0.into()); - } -} - -#[derive(Debug, PartialEq, Eq, Clone, Serialize)] -pub struct MultiExitDiscPathAttribute(pub u32); - -impl MultiExitDiscPathAttribute { - pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { - let (buf, value) = be_u32(buf)?; - Ok((buf, Self(value))) - } - - pub fn to_wire(&self, out: &mut BytesMut) { - out.put_u32(self.0); - } -} - -#[derive(Debug, PartialEq, Eq, Clone, Serialize)] -pub struct LocalPrefPathAttribute(pub u32); - -impl LocalPrefPathAttribute { - pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { - let (buf, value) = be_u32(buf)?; - Ok((buf, Self(value))) - } - - pub fn to_wire(&self, out: &mut BytesMut) { - out.put_u32(self.0); - } -} - -/// ATOMIC_AGGREGATE: a marker with no value (RFC 4271 §5.1.6). -#[derive(Debug, PartialEq, Eq, Clone, Serialize)] -pub struct AtomicAggregatePathAttribute {} - -#[derive(Debug, PartialEq, Eq, Clone, Serialize)] -pub struct AggregatorPathAttribute { - pub asn: u32, - pub ip: Ipv4Addr, -} - -impl AggregatorPathAttribute { - /// The AS number is as wide as the session's AS numbers (RFC 6793), so - /// the attribute is six or eight octets. - pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { - let (buf, asn) = if ctx.four_octet_asn { - be_u32(buf)? - } else { - be_u16(buf).map(|(buf, asn)| (buf, u32::from(asn)))? - }; - let (buf, ip) = be_u32(buf)?; - Ok(( - buf, - Self { - asn, - ip: Ipv4Addr::from(ip), - }, - )) - } - - /// On a two-octet session an AS number that does not fit is written as - /// AS_TRANS (RFC 6793 §4.2.2). - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) { - if ctx.four_octet_asn { - out.put_u32(self.asn); - } else { - out.put_u16(u16::try_from(self.asn).unwrap_or(AS_TRANS)); - } - out.put_u32(self.ip.into()); - } -} - -#[derive(Debug, PartialEq, Eq, Clone, Serialize)] -pub struct CommunitiesPathAttribute(pub Vec<(u16, u16)>); - -impl CommunitiesPathAttribute { - pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { - // At least one: an empty COMMUNITIES is malformed (RFC 7606 §7.8). - let (buf, values) = nom::multi::many1(|i| (be_u16, be_u16).parse(i)).parse(buf)?; - Ok((buf, CommunitiesPathAttribute(values))) - } - - pub fn to_wire(&self, out: &mut BytesMut) { - for value in &self.0 { - out.put_u16(value.0); - out.put_u16(value.1); - } - } -} - -/// EXTENDED COMMUNITIES (RFC 4360). -#[derive(Debug, PartialEq, Eq, Clone, Serialize)] -pub struct ExtendedCommunitiesPathAttribute { - pub extended_communities: Vec, -} - -impl ExtendedCommunitiesPathAttribute { - pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { - let (communities, rest) = buf.as_chunks::<8>(); - if communities.is_empty() || !rest.is_empty() { - return Err(malformed(format!( - "{} octets are not a list of extended communities", - buf.len() - ))); - } - Ok(( - rest, - Self { - extended_communities: communities.iter().copied().map(ExtendedCommunity).collect(), - }, - )) - } - - pub fn to_wire(&self, out: &mut BytesMut) { - for community in &self.extended_communities { - out.put_slice(&community.0); - } - } -} - -/// One extended community: eight octets, of which the first is the type -/// and, for most types, the second a sub-type (RFC 4360 §2). How the rest -/// divides into an administrator and a value depends on the type, and is -/// left to whoever acts on a particular community. -#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize)] -pub struct ExtendedCommunity(pub [u8; 8]); - -impl ExtendedCommunity { - pub fn typ(&self) -> u8 { - self.0[0] - } - - pub fn sub_typ(&self) -> u8 { - self.0[1] - } -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct LargeCommunitiesPathAttribute { - pub communities: Vec, -} - -impl LargeCommunitiesPathAttribute { - pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { - let (buf, communities) = nom::multi::many1(LargeCommunity::from_wire).parse(buf)?; - Ok((buf, Self { communities })) - } - - pub fn to_wire(&self, out: &mut BytesMut) { - for community in &self.communities { - community.to_wire(out); - } - } -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct LargeCommunity { - pub global_admin: u32, - pub data_1: u32, - pub data_2: u32, -} - -impl LargeCommunity { - pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { - let (buf, global_admin) = be_u32(buf)?; - let (buf, data_1) = be_u32(buf)?; - let (buf, data_2) = be_u32(buf)?; - Ok(( - buf, - Self { - global_admin, - data_1, - data_2, - }, - )) - } - - pub fn to_wire(&self, out: &mut BytesMut) { - out.put_u32(self.global_admin); - out.put_u32(self.data_1); - out.put_u32(self.data_2); - } -} - -/// The next hop of routes in MP_REACH_NLRI. -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Hash)] -pub enum NlriNextHop { - Ipv4(Ipv4Addr), - /// A global IPv6 address. - Ipv6(Ipv6Addr), - /// A global IPv6 address and a link-local one (RFC 2545 §3). - Ipv6WithLl { - global: Ipv6Addr, - link_local: Ipv6Addr, - }, -} - -impl std::fmt::Display for NlriNextHop { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - NlriNextHop::Ipv4(addr) => write!(f, "{addr}"), - NlriNextHop::Ipv6(addr) => write!(f, "{addr}"), - NlriNextHop::Ipv6WithLl { global, link_local } => { - write!(f, "{global} (link-local {link_local})") - } - } - } -} - -impl NlriNextHop { - /// Reads a next hop that is all of `bytes`. `None` if they are not the - /// length of one. - pub fn from_bytes(bytes: &[u8]) -> Option { - let v6 = |bytes: &[u8]| <[u8; 16]>::try_from(bytes).ok().map(Ipv6Addr::from); - match bytes.len() { - 4 => <[u8; 4]>::try_from(bytes) - .ok() - .map(|a| Self::Ipv4(a.into())), - 16 => v6(bytes).map(Self::Ipv6), - 32 => Some(Self::Ipv6WithLl { - global: v6(&bytes[..16])?, - link_local: v6(&bytes[16..])?, - }), - _ => None, - } - } - - pub fn to_wire(&self, out: &mut BytesMut) { - match self { - NlriNextHop::Ipv4(address) => out.put_slice(&address.octets()), - NlriNextHop::Ipv6(address) => out.put_slice(&address.octets()), - NlriNextHop::Ipv6WithLl { global, link_local } => { - out.put_slice(&global.octets()); - out.put_slice(&link_local.octets()); - } - } - } - - /// How many octets the next hop is on the wire. - pub fn wire_len(&self) -> u8 { - match self { - NlriNextHop::Ipv4(_) => 4, - NlriNextHop::Ipv6(_) => 16, - NlriNextHop::Ipv6WithLl { .. } => 32, - } - } -} - -/// Parses one prefix as an UPDATE names it: with a path identifier in -/// front if the session uses them for the family (RFC 7911 §3). -fn parse_nlri(version: IpVersion, with_path_id: bool, buf: &[u8]) -> Parsed<'_, Nlri> { - let (buf, path_id) = if with_path_id { be_u32(buf)? } else { (buf, 0) }; - let (buf, prefix_len) = be_u8(buf)?; - let byte_len = usize::from(prefix_len.div_ceil(8)); - let (buf, prefix_bytes) = nom::bytes::take(byte_len).parse(buf)?; - let prefix = - IpPrefix::new(version, prefix_bytes, prefix_len).map_err(|e| malformed(e.to_string()))?; - Ok((buf, Nlri { prefix, path_id })) -} - -/// Parses a field that is nothing but prefixes, to its end. -pub(crate) fn parse_nlri_list( - version: IpVersion, - with_path_id: bool, - mut buf: &[u8], -) -> Parsed<'_, Vec> { - // Not many0: that stops quietly at a prefix it cannot read. - let mut nlri = Vec::new(); - while !buf.is_empty() { - let (rest, parsed) = parse_nlri(version, with_path_id, buf)?; - nlri.push(parsed); - buf = rest; - } - Ok((buf, nlri)) -} - -/// The IP version of the prefixes that make up the NLRI of `family`. -/// -/// Any family can be named in MP_REACH_NLRI and MP_UNREACH_NLRI, but only -/// unicast and multicast routes of IPv4 and IPv6 are plain prefixes, and -/// those are the only NLRI this parser reads. A peer does not send routes -/// of a family that was not negotiated, so the others do not come up. -fn ip_version_of_nlri<'a>( - family: AfiSafi, -) -> Result>> { - let unsupported = || malformed(format!("NLRI of {family} is not supported")); - if family.safi != Safi::UNICAST && family.safi != Safi::MULTICAST { - return Err(unsupported()); - } - IpVersion::try_from(family.afi).map_err(|_| unsupported()) -} - -fn parse_family(buf: &[u8]) -> Parsed<'_, AfiSafi> { - let (buf, afi) = be_u16(buf)?; - let (buf, safi) = be_u8(buf)?; - Ok((buf, AfiSafi::new(Afi(afi), Safi(safi)))) -} - -/// MP_REACH_NLRI: routes of a family other than the one in the message -/// body, with their next hop (RFC 4760 §3). -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct MpReachNlriPathAttribute { - pub family: AfiSafi, - pub next_hop: NlriNextHop, - pub prefixes: Vec, -} - -impl MpReachNlriPathAttribute { - pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { - let (buf, family) = parse_family(buf)?; - let version = ip_version_of_nlri(family)?; - - let (buf, next_hop_len) = be_u8(buf)?; - let (buf, next_hop) = nom::bytes::take(usize::from(next_hop_len)).parse(buf)?; - // For IPv6 routes, a global address, optionally followed by a - // link-local one (RFC 2545). For IPv4 routes an IPv4 address, or - // the same as for IPv6: routes over a link that has only IPv6 - // addresses (RFC 8950). Which of the two it is shows in its length. - let next_hop = NlriNextHop::from_bytes(next_hop) - .filter(|next_hop| { - version == IpVersion::Ipv4 || !matches!(next_hop, NlriNextHop::Ipv4(_)) - }) - .ok_or_else(|| { - malformed(format!( - "a next hop of {next_hop_len} octets is not valid for {family}" - )) - })?; - - // Reserved octet (formerly the SNPA count), for every family. - let (buf, _) = be_u8(buf)?; - let (buf, prefixes) = parse_nlri_list(version, ctx.has_add_path(family), buf)?; - Ok(( - buf, - Self { - family, - next_hop, - prefixes, - }, - )) - } - - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) { - out.put_u16(self.family.afi.0); - out.put_u8(self.family.safi.0); - out.put_u8(self.next_hop.wire_len()); - self.next_hop.to_wire(out); - out.put_u8(0); - write_nlri(&self.prefixes, ctx.has_add_path(self.family), out); - } -} - -/// MP_UNREACH_NLRI: withdrawals of routes of a family other than the one -/// in the message body (RFC 4760 §4). -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct MpUnreachNlriPathAttribute { - pub family: AfiSafi, - pub prefixes: Vec, -} - -impl MpUnreachNlriPathAttribute { - pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { - let (buf, family) = parse_family(buf)?; - let version = ip_version_of_nlri(family)?; - let (buf, prefixes) = parse_nlri_list(version, ctx.has_add_path(family), buf)?; - Ok((buf, Self { family, prefixes })) - } - - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) { - out.put_u16(self.family.afi.0); - out.put_u8(self.family.safi.0); - write_nlri(&self.prefixes, ctx.has_add_path(self.family), out); - } -} +pub use crate::attributes::*; /// Represents a BGP NOTIFICATION message per RFC 4271 §4.5. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 3894739..9e9f499 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -9,13 +9,11 @@ repository.workspace = true version.workspace = true [dependencies] -bitfield.workspace = true bytes.workspace = true nom.workspace = true serde.workspace = true serde_repr.workspace = true thiserror.workspace = true -strum.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/crates/rib/Cargo.toml b/crates/rib/Cargo.toml index ce39351..c654ab0 100644 --- a/crates/rib/Cargo.toml +++ b/crates/rib/Cargo.toml @@ -16,7 +16,6 @@ hashbrown.workspace = true serde.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["full"] } -tracing.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/crates/rib/src/table/attrs.rs b/crates/rib/src/table/attrs.rs index faceea0..f81134a 100644 --- a/crates/rib/src/table/attrs.rs +++ b/crates/rib/src/table/attrs.rs @@ -48,7 +48,15 @@ pub struct SelectionKey { /// Reads the selection key straight off attributes in the form an /// [`AttrSet`] keeps, without building anything. `None` if the bytes are /// not a well-formed list of attributes. +/// +/// A list with the same attribute twice is not well formed (RFC 4271 +/// §5). The decoder keeps only the first of such, so none comes from a +/// session; were one to get in some other way, the readers of a set, of +/// which this is one, could each take a different one of the two for the +/// route's path or next hop. fn scan(mut wire: &[u8]) -> Option { + // One bit for each type of attribute there can be. + let mut seen = [0u64; 4]; let mut key = SelectionKey { local_pref: None, med: None, @@ -64,6 +72,14 @@ fn scan(mut wire: &[u8]) -> Option { let (attribute, rest) = split_attribute(wire).ok()?; wire = rest; let value = attribute.value; + let (word, bit) = ( + usize::from(attribute.type_code / 64), + 1u64 << (attribute.type_code % 64), + ); + if seen[word] & bit != 0 { + return None; + } + seen[word] |= bit; match attribute.type_code { attr_type::ORIGIN => key.origin = *value.first()?, attr_type::AS_PATH => { @@ -90,7 +106,15 @@ struct PathScan { last: u32, } -/// Reads an AS_PATH or AS4_PATH value with four-octet AS numbers. +impl SelectionKey { + /// The AS the route originates from: the last of its path. `None` if + /// the path is empty or ends in a set, which has no one last AS. + pub fn origin(&self) -> Option { + Some(self.origin_as).filter(|origin| *origin != 0) + } +} + +/// Reads an AS_PATH value with four-octet AS numbers. fn scan_as_path(mut segments: &[u8]) -> Option { const AS_SEQUENCE: u8 = 2; let mut hops = 0usize; @@ -571,6 +595,27 @@ mod tests { assert!(path.is_empty()); } + /// Two of the same attribute are not a set: its readers could each + /// take a different one. + #[test] + fn the_same_attribute_twice_is_not_a_set() { + let store = AttrStore::new(); + let origin = [0x40, 1, 1, 0]; + let path = |asn: u8| [0x40, 2, 6, 2, 1, 0, 0, 0, asn]; + let once = [&origin[..], &path(1)].concat(); + assert!(store.intern_wire(&once).is_some()); + let twice = [&origin[..], &path(1), &path(2)].concat(); + assert!(store.intern_wire(&twice).is_none()); + // Whatever the type, known here or not. + let unknown = [0xc0, 200, 1, 7]; + assert!(store.intern_wire(&[&once[..], &unknown].concat()).is_some()); + assert!( + store + .intern_wire(&[&once[..], &unknown, &unknown].concat()) + .is_none() + ); + } + #[test] fn attributes_on_the_wire_are_interned_without_parsing_them() { let store = AttrStore::new(); diff --git a/crates/rib/src/table/mod.rs b/crates/rib/src/table/mod.rs index 68c1efc..e8d6e56 100644 --- a/crates/rib/src/table/mod.rs +++ b/crates/rib/src/table/mod.rs @@ -22,7 +22,7 @@ //! - **Readers do not stop the table.** Reading takes a shard's lock for //! reading, one shard at a time, and looks at the rows in place. //! - **Followers go at their own pace.** What acts on the best routes reads -//! the changes from a [`journal`], and a slow reader holds nothing up. +//! the changes from a `journal`, and a slow reader holds nothing up. pub mod attrs; mod journal; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 86b9690..4e81d34 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -14,12 +14,10 @@ bgp-rib.workspace = true bgp-fib.workspace = true bgp-rpki.workspace = true bytes.workspace = true -clap.workspace = true futures.workspace = true prometheus.workspace = true socket2.workspace = true tokio.workspace = true -tracing-subscriber.workspace = true tracing.workspace = true protobuf.workspace = true thiserror.workspace = true diff --git a/crates/server/src/address.rs b/crates/server/src/address.rs index e4605cf..0958cc3 100644 --- a/crates/server/src/address.rs +++ b/crates/server/src/address.rs @@ -30,7 +30,7 @@ pub struct PeerAddress { impl From for PeerAddress { fn from(address: SocketAddr) -> Self { let interface = match address { - SocketAddr::V6(v6) if is_link_local(v6.ip()) => v6.scope_id(), + SocketAddr::V6(v6) if v6.ip().is_unicast_link_local() => v6.scope_id(), _ => 0, }; Self { @@ -77,10 +77,6 @@ pub fn socket_text(address: SocketAddr) -> String { } } -pub fn is_link_local(address: &Ipv6Addr) -> bool { - address.segments()[0] & 0xffc0 == 0xfe80 -} - /// The number of the interface called `name`, if there is one now. pub fn interface_index(name: &str) -> Option { nix::net::if_::if_nametoindex(name).ok() @@ -131,7 +127,7 @@ impl Endpoint { .parse() .map_err(|_| EndpointProblem::NotAnAddress(text.to_string()))?; let link_local = match ip { - IpAddr::V6(v6) if is_link_local(&v6) => Some(v6), + IpAddr::V6(v6) if v6.is_unicast_link_local() => Some(v6), _ => None, }; let interface = match (link_local, interface) { @@ -199,7 +195,9 @@ impl Endpoint { ) })?; Ok(match ip { - IpAddr::V6(v6) if is_link_local(&v6) => SocketAddrV6::new(v6, port, 0, scope).into(), + IpAddr::V6(v6) if v6.is_unicast_link_local() => { + SocketAddrV6::new(v6, port, 0, scope).into() + } ip => SocketAddr::new(ip, port), }) } diff --git a/crates/server/src/advertise.rs b/crates/server/src/advertise.rs index 248424a..9a9a99d 100644 --- a/crates/server/src/advertise.rs +++ b/crates/server/src/advertise.rs @@ -34,7 +34,6 @@ use tokio::sync::broadcast::error::RecvError; use tokio::task::JoinHandle; use tracing::{debug, info, warn}; -use crate::address::is_link_local; use crate::policy::PeerRole; use crate::session::{PeerId, SessionClosed, SessionEvent, SessionHandle, SessionRegistry}; @@ -404,7 +403,7 @@ pub fn build_announce(route: &LocalRoute, towards: &Towards) -> Option NlriNextHop { - if is_link_local(&ours) { + if ours.is_unicast_link_local() { NlriNextHop::Ipv6WithLl { global: ours, link_local: ours, diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index bc734d4..551eefb 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -27,7 +27,8 @@ const MAX_AS_PATH_PREPEND: u32 = 16; pub struct Config { pub router_id: Ipv4Addr, pub local_asn: u32, - /// Default hold time advertised to peers that have no per-peer override. + /// 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, pub peers: Vec, @@ -461,6 +462,45 @@ mod tests { 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] diff --git a/crates/server/src/connection.rs b/crates/server/src/connection.rs index ca5f259..716608e 100644 --- a/crates/server/src/connection.rs +++ b/crates/server/src/connection.rs @@ -239,7 +239,7 @@ struct Established { /// The routes learned from the peer, which go when the session does. routes: PeerRoutes, /// Whose routes the import policy is judging. - from: policy::From, + from: policy::RouteSource, /// How many routes the peer has, and how many were refused. counters: Arc, /// The same number of routes, where metrics are read from. @@ -258,6 +258,26 @@ struct Established { } impl Established { + /// Whose routes are judged and by what rules, and where those that + /// are invalid for now are held. + fn import<'a>( + &'a mut self, + policy: &'a ImportPolicy, + refusals: &'a PeerRefusals, + ) -> (routes::Import<'a>, &'a mut Held) { + let import = routes::Import { + peer: self.rib_peer, + from: self.from, + policy, + checks: self + .validation + .as_mut() + .map_or(Checks::NONE, |validation| validation.validator.checks()), + refusals, + }; + (import, &mut self.held) + } + /// Keeps count after the peer's routes have changed, and says whether /// the peer now has more than it may. fn account( @@ -787,7 +807,7 @@ impl Session { rib_peer, outbound, routes: PeerRoutes::new(Arc::clone(&self.server.rib), rib_peer), - from: policy::From { + from: policy::RouteSource { peer_asn: negotiated.peer_asn, local_asn: self.server.local_asn, ibgp: negotiated.ibgp, @@ -813,23 +833,9 @@ impl Session { let Some(established) = &mut self.established else { return Ok(()); }; - let import = routes::Import { - peer: established.rib_peer, - from: established.from, - policy: &self.import, - checks: established - .validation - .as_mut() - .map_or(Checks::NONE, |validation| validation.validator.checks()), - refusals: &self.refusals, - }; - let routes::Applied { outcome, rejected } = routes::apply( - &self.server.rib, - &import, - &mut established.held, - update, - attributes_wire, - )?; + let (import, held) = established.import(&self.import, &self.refusals); + let routes::Applied { outcome, rejected } = + routes::apply(&self.server.rib, &import, held, update, attributes_wire)?; // One line for every UPDATE is too much to log by default: a full // table is a million of them. debug!( @@ -851,18 +857,9 @@ impl Session { let Some(established) = &mut self.established else { return vec![]; }; - let import = routes::Import { - peer: established.rib_peer, - from: established.from, - policy: &self.import, - checks: established - .validation - .as_mut() - .map_or(Checks::NONE, |validation| validation.validator.checks()), - refusals: &self.refusals, - }; + let (import, held) = established.import(&self.import, &self.refusals); let routes::Applied { outcome, rejected } = - routes::revalidate(&self.server.rib, &import, &mut established.held, suspects); + routes::revalidate(&self.server.rib, &import, held, suspects); if outcome != Outcome::default() { info!( peer = %established.peer_id, @@ -1274,7 +1271,7 @@ mod tests { }; let import = routes::Import { peer, - from: policy::From { + from: policy::RouteSource { peer_asn: 65001, local_asn: 65000, ibgp: false, diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 58703ad..675eb63 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -2,11 +2,11 @@ pub mod address; pub mod advertise; pub mod collision; pub mod config; -pub mod connection; -pub mod dialer; +mod connection; +mod dialer; pub mod error; pub mod family; -pub mod fsm; +mod fsm; pub mod metrics; mod panic; pub mod policy; @@ -17,7 +17,6 @@ pub mod server; pub mod session; mod sync; mod throttle; -pub mod tracing; pub mod proto { include!(concat!(env!("OUT_DIR"), "/protos/mod.rs")); diff --git a/crates/server/src/metrics.rs b/crates/server/src/metrics.rs index 00ddef3..b19c6ad 100644 --- a/crates/server/src/metrics.rs +++ b/crates/server/src/metrics.rs @@ -164,20 +164,53 @@ pub static RIB_JOURNAL_ENTRIES: LazyLock = LazyLock::new(|| { .expect("metric registration") }); +/// Whether each configured peer's session is up: what to alert on when a +/// peer goes away, which a count of sessions cannot say. +pub static PEER_UP: LazyLock = LazyLock::new(|| { + register_int_gauge_vec!( + "bgp_peer_up", + "Whether the session with a configured peer is Established (1) or not (0)", + &["peer"] + ) + .expect("metric registration") +}); + +pub static BUILD_INFO: LazyLock = LazyLock::new(|| { + register_int_gauge_vec!( + "bgp_build_info", + "Always 1; the version of the daemon is in the label", + &["version"] + ) + .expect("metric registration") +}); + /// Forces registration of every metric so they appear in scrapes before -/// their first observation. +/// their first observation: a counter that is not there until it first +/// counts hides that first count from `increase()`. pub fn init() { LazyLock::force(&MESSAGES_SENT); LazyLock::force(&MESSAGES_RECEIVED); LazyLock::force(&UPDATE_ERRORS); LazyLock::force(&SESSIONS_ESTABLISHED_TOTAL); LazyLock::force(&SESSIONS_ESTABLISHED); + LazyLock::force(&ROUTES_REJECTED); + LazyLock::force(&PEER_ROUTES); + LazyLock::force(&PEER_MAX_PREFIXES); + LazyLock::force(&PREFIX_LIMIT_EXCEEDED); + LazyLock::force(&RPKI_VRPS); + LazyLock::force(&RPKI_ASPAS); + LazyLock::force(&RPKI_UPDATES); + LazyLock::force(&CONNECTIONS_REFUSED); LazyLock::force(&COLLISIONS); LazyLock::force(&LOCAL_ROUTES); LazyLock::force(&RIB_ROUTES); LazyLock::force(&RIB_PREFIXES); LazyLock::force(&RIB_ATTRIBUTE_SETS); LazyLock::force(&RIB_JOURNAL_ENTRIES); + LazyLock::force(&PEER_UP); + BUILD_INFO + .with_label_values(&[env!("CARGO_PKG_VERSION")]) + .set(1); } pub fn message_type_name(msg: &Message) -> &'static str { diff --git a/crates/server/src/policy.rs b/crates/server/src/policy.rs index 6203944..1817958 100644 --- a/crates/server/src/policy.rs +++ b/crates/server/src/policy.rs @@ -318,7 +318,7 @@ impl PeerRole { /// Whose routes are being judged. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct From { +pub struct RouteSource { pub peer_asn: u32, pub local_asn: u32, /// Whether the peer is in our own AS. The checks on the path are about @@ -343,7 +343,7 @@ impl ImportPolicy { pub fn check_path( &self, attributes: &[PathAttribute], - from: &From, + from: &RouteSource, ) -> Result, Rejection> { let path = attributes.iter().find_map(|attribute| match attribute { PathAttribute::AsPath(path) => Some(path), @@ -451,7 +451,7 @@ impl ImportPolicy { &self, next_hop: &NlriNextHop, routes: IpVersion, - from: &From, + from: &RouteSource, ) -> Result<(), Rejection> { let (first, link_local): (IpAddr, Option) = match *next_hop { NlriNextHop::Ipv4(address) => (address.into(), None), @@ -515,7 +515,7 @@ impl PathFacts { /// Reads the path. It is the whole path: what a session with /// two-octet AS numbers brought in AS4_PATH the decoder has put in its /// place (RFC 6793 §4.2.3). - fn of(path: Option<&AsPathAttribute>, from: &From) -> Self { + fn of(path: Option<&AsPathAttribute>, from: &RouteSource) -> Self { let mut facts = Self { length: 0, has_local_asn: false, @@ -805,7 +805,7 @@ mod tests { IpPrefix::try_from(s).unwrap() } - const EXTERNAL: From = From { + const EXTERNAL: RouteSource = RouteSource { peer_asn: 3333, local_asn: 1299, ibgp: false, @@ -937,7 +937,7 @@ mod tests { ); } // From a peer in our own AS the path says nothing of the kind. - let internal = From { + let internal = RouteSource { peer_asn: 1299, ibgp: true, ..EXTERNAL @@ -1025,7 +1025,7 @@ mod tests { attributes.push(PathAttribute::OnlyToCustomer(asn)); attributes }; - let from = |role| From { + let from = |role| RouteSource { role: Some(role), ..EXTERNAL }; @@ -1213,7 +1213,7 @@ mod tests { fn the_next_hop_has_to_be_the_peer() { let policy = ImportPolicy::default(); let v4 = |s: &str| NlriNextHop::Ipv4(s.parse().unwrap()); - let check = |policy: &ImportPolicy, next_hop: &NlriNextHop, from: &From| { + let check = |policy: &ImportPolicy, next_hop: &NlriNextHop, from: &RouteSource| { policy.check_next_hop(next_hop, IpVersion::Ipv4, from) }; assert_eq!(check(&policy, &v4("192.0.2.2"), &EXTERNAL), Ok(())); @@ -1242,11 +1242,11 @@ mod tests { // Where it may be someone else: said so, a route server, our own AS. assert_eq!(check(&lenient, &v4("192.0.2.3"), &EXTERNAL), Ok(())); for from in [ - From { + RouteSource { role: Some(PeerRole::RouteServer), ..EXTERNAL }, - From { + RouteSource { ibgp: true, ..EXTERNAL }, @@ -1254,7 +1254,7 @@ mod tests { assert_eq!(check(&policy, &v4("192.0.2.3"), &from), Ok(())); } // A provider is not a route server. - let provider = From { + let provider = RouteSource { role: Some(PeerRole::Provider), ..EXTERNAL }; @@ -1269,12 +1269,12 @@ mod tests { let policy = ImportPolicy::default(); let peer: Ipv6Addr = "2001:db8::2".parse().unwrap(); let peer_ll: Ipv6Addr = "fe80::2".parse().unwrap(); - let over_v6 = From { + let over_v6 = RouteSource { peer_address: peer.into(), local_address: "2001:db8::1".parse().unwrap(), ..EXTERNAL }; - let v6 = |policy: &ImportPolicy, next_hop: NlriNextHop, from: &From| { + let v6 = |policy: &ImportPolicy, next_hop: NlriNextHop, from: &RouteSource| { policy.check_next_hop(&next_hop, IpVersion::Ipv6, from) }; assert_eq!(v6(&policy, NlriNextHop::Ipv6(peer), &over_v6), Ok(())); @@ -1305,7 +1305,7 @@ mod tests { // A session between link-local addresses: the peer's own, alone, // twice, or after nothing. - let link_local = From { + let link_local = RouteSource { peer_address: peer_ll.into(), local_address: "fe80::1".parse().unwrap(), ..EXTERNAL @@ -1345,10 +1345,11 @@ mod tests { // An IPv4 route with an IPv6 next hop only where that was agreed // on (RFC 8950). - let v4_route = - |from: &From| policy.check_next_hop(&NlriNextHop::Ipv6(peer), IpVersion::Ipv4, from); + let v4_route = |from: &RouteSource| { + policy.check_next_hop(&NlriNextHop::Ipv6(peer), IpVersion::Ipv4, from) + }; assert_eq!(v4_route(&over_v6), Err(Rejection::NextHopUnusable)); - let agreed = From { + let agreed = RouteSource { extended_next_hop: true, ..over_v6 }; diff --git a/crates/server/src/routes.rs b/crates/server/src/routes.rs index 618f10d..c83e04f 100644 --- a/crates/server/src/routes.rs +++ b/crates/server/src/routes.rs @@ -13,7 +13,7 @@ use bgp_primitives::parser::EncodeError; use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable}; use bgp_rpki::{PathValidity, Validity}; -use crate::policy::{self, From, ImportPolicy, Rejection}; +use crate::policy::{self, ImportPolicy, Rejection, RouteSource}; use crate::refusals::PeerRefusals; use crate::rpki::Checks; @@ -34,7 +34,7 @@ pub(crate) struct Applied { /// Whose UPDATE is being applied, and by what rules. pub(crate) struct Import<'a> { pub(crate) peer: PeerIndex, - pub(crate) from: From, + pub(crate) from: RouteSource, pub(crate) policy: &'a ImportPolicy, /// What the peer's routes are validated against, with the data of the /// RPKI. diff --git a/crates/server/src/rpki.rs b/crates/server/src/rpki.rs index 37cbdd6..33472f2 100644 --- a/crates/server/src/rpki.rs +++ b/crates/server/src/rpki.rs @@ -8,13 +8,13 @@ //! should (draft-ietf-sidrops-aspa-verification), which the ASPAs say. A //! route that fails either is not taken. //! -//! A route is validated when it arrives (see [`crate::routes`]). But +//! A route is validated when it arrives (see `crate::routes`). But //! whether a route is valid is not settled once: the data changes every //! few minutes, and each change can make routes that are in the table //! invalid, and routes that were refused as invalid acceptable. The -//! [revalidator](spawn_revalidator) deals with the first by looking +//! revalidator (`spawn_revalidator`) deals with the first by looking //! through the table; each session deals with the second, from the routes -//! it has [held](crate::routes::Held). +//! it has held (`crate::routes::Held`). use std::cell::RefCell; use std::collections::HashMap; @@ -142,7 +142,7 @@ impl<'a> Checks<'a> { path: &mut Vec, ) -> Option { if let Some(vrps) = self.vrps { - let origin = Some(attrs.key().origin_as).filter(|origin| *origin != 0); + let origin = attrs.key().origin(); if vrps.validate(prefix, origin) == Validity::Invalid { return Some(Rejection::RpkiInvalid); } @@ -295,7 +295,7 @@ fn newly_invalid( && vrps_may_matter && changed.is_none_or(|changed| changed.vrps.covers(&route.prefix)) { - let origin = Some(route.attrs.key().origin_as).filter(|origin| *origin != 0); + let origin = route.attrs.key().origin(); if data.vrps.validate(&route.prefix, origin) == Validity::Invalid { return true; } diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs index 03e89d5..d04b694 100644 --- a/crates/server/src/session.rs +++ b/crates/server/src/session.rs @@ -23,7 +23,7 @@ use crate::address::{Endpoint, PeerAddress, socket_text}; use crate::collision::Direction; use crate::family::FamilySet; use crate::fsm::Negotiated; -use crate::metrics::SESSIONS_ESTABLISHED; +use crate::metrics::{PEER_UP, SESSIONS_ESTABLISHED}; use crate::policy::PeerRole; use crate::rpki::Subject; use crate::sync::IgnorePoisonRw; @@ -262,6 +262,9 @@ impl SessionRegistry { .iter() .map(|p| (p.name.clone(), DialState::Idle)) .collect(); + for peer in &configured { + PEER_UP.with_label_values(&[&peer.name]).set(0); + } Self { inner: Arc::new(RwLock::new(HashMap::new())), configured: Arc::new(configured), @@ -306,6 +309,9 @@ impl SessionRegistry { let peer = handle.peer; self.inner.writing().insert(peer, handle.clone()); SESSIONS_ESTABLISHED.inc(); + if let Some(name) = &handle.name { + PEER_UP.with_label_values(&[name]).set(1); + } let _ = self.events.send(SessionEvent::Up(handle)); SessionGuard { registry: self.clone(), @@ -362,8 +368,11 @@ impl SessionRegistry { fn remove(&self, peer: &PeerId) { let removed = self.inner.writing().remove(peer); - if removed.is_some() { + if let Some(handle) = &removed { SESSIONS_ESTABLISHED.dec(); + if let Some(name) = &handle.name { + PEER_UP.with_label_values(&[name]).set(0); + } let _ = self.events.send(SessionEvent::Down(*peer)); } } diff --git a/crates/server/src/tracing.rs b/crates/server/src/tracing.rs deleted file mode 100644 index afaa7de..0000000 --- a/crates/server/src/tracing.rs +++ /dev/null @@ -1,29 +0,0 @@ -use clap::Args; -use tracing::Level; -use tracing_subscriber::EnvFilter; -use tracing_subscriber::filter::ParseError; - -/// Default targets that we enable logging for. -const DEFAULT_TARGETS: [&str; 3] = ["bgp", "bgp_server", "bgp_web"]; - -#[derive(Debug, Args)] -pub struct TracingArgs { - /// Set the minimum log level. At `debug` and below there is a line for - /// every UPDATE and every route announced. - #[arg(short, long, global = true, default_value_t = Level::INFO, help_heading = "Tracing")] - level: Level, -} - -impl TracingArgs { - pub fn install(&self) -> Result<(), ParseError> { - let filter = DEFAULT_TARGETS.into_iter().try_fold( - EnvFilter::builder().from_env_lossy(), - |filter, target| -> Result<_, ParseError> { - Ok(filter.add_directive(format!("{}={}", target, self.level).parse()?)) - }, - )?; - - tracing_subscriber::fmt().with_env_filter(filter).init(); - Ok(()) - } -} diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index afae5c4..8c61abf 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -346,7 +346,7 @@ impl PeerNames { /// the route is on its way out of the table. pub(crate) fn view(&self, route: &Route) -> Option { let (peer, name, check) = self.peers.get(&route.peer)?; - let origin = Some(route.attrs.key().origin_as).filter(|origin| *origin != 0); + let origin = route.attrs.key().origin(); // What a session stored was accepted by the decoder, and parses. let (attributes, next_hop) = route.attrs.attributes().unwrap_or_default(); Some(ReceivedRouteView { diff --git a/crates/web/src/events.rs b/crates/web/src/events.rs index 3835950..0eca57d 100644 --- a/crates/web/src/events.rs +++ b/crates/web/src/events.rs @@ -63,7 +63,7 @@ impl WireEvent { peer: PeerView::from(&h), }, Ok(SessionEvent::Down(p)) => Self::SessionDown { - peer_addr: p.addr.to_string(), + peer_addr: bgp_server::address::socket_text(p.addr), peer_asn: p.asn, }, Ok(SessionEvent::RefreshRequested(h)) => Self::RouteRefreshRequested { diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 0e1c9f2..258da61 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -9,7 +9,7 @@ mod api; mod events; mod metrics; -use std::net::{Ipv4Addr, SocketAddr}; +use std::net::Ipv4Addr; use std::sync::Arc; use std::time::{Instant, SystemTime}; @@ -123,18 +123,8 @@ pub fn router(state: AppState) -> Router { .with_state(state) } -/// Binds `listen` and serves the API until the task is dropped. -pub async fn serve(state: AppState, listen: SocketAddr) -> std::io::Result<()> { - let listener = TcpListener::bind(listen).await.map_err(|e| { - std::io::Error::new( - e.kind(), - format!("failed to bind HTTP listener on {listen}: {e}"), - ) - })?; - serve_on(state, listener).await -} - -/// Serves the API on an already-bound listener (lets tests use port 0). +/// Serves the API on `listener` until the task is dropped. The caller +/// binds it, and so finds out before anything else starts if it cannot. pub async fn serve_on(state: AppState, listener: TcpListener) -> std::io::Result<()> { info!(addr = %listener.local_addr()?, "HTTP API listening"); axum::serve(listener, router(state)).await?; diff --git a/docs/RFCs.md b/docs/RFCs.md index c770cf9..52a68c4 100644 --- a/docs/RFCs.md +++ b/docs/RFCs.md @@ -77,7 +77,7 @@ https://datatracker.ietf.org/doc/html/rfc2918 |---------|--------| | `RouteRefresh` capability advertisement | ✅ | | `ROUTE-REFRESH` message type (5) framing | ✅ | -| AFI/SAFI payload parsing | ❌ | +| AFI/SAFI payload parsing | ✅ | | Acting on received ROUTE-REFRESH (re-advertising all local routes, regardless of the AFI/SAFI asked for) | ✅ | ### RFC 7313 — Enhanced Route Refresh Capability for BGP-4 diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 3969755..ce35b0f 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -18,6 +18,7 @@ cargo-fuzz = true bgp-packet = { path = "../crates/packet" } bgp-primitives = { path = "../crates/primitives" } bgp-fib = { path = "../crates/fib" } +bgp-rib = { path = "../crates/rib" } bgp-rpki = { path = "../crates/rpki" } bgp-server = { path = "../crates/server" } bytes = "1.10.1" @@ -27,6 +28,13 @@ protobuf = "3.7.2" [lib] path = "src/lib.rs" +[[bin]] +name = "attrs" +path = "fuzz_targets/attrs.rs" +test = false +doc = false +bench = false + [[bin]] name = "message" path = "fuzz_targets/message.rs" diff --git a/fuzz/README.md b/fuzz/README.md index 5216941..76fd887 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -14,6 +14,7 @@ you leave them running. | `config` | The configuration file | | `rtr` | A stream of RTR PDUs, as an RPKI cache sends them | | `netlink` | Messages from the kernel's routing socket | +| `attrs` | The routing table's reading of stored attribute bytes | For the two message targets a finding is either a panic, or a message the decoder accepted that cannot be written and read back as itself. diff --git a/fuzz/fuzz_targets/attrs.rs b/fuzz/fuzz_targets/attrs.rs new file mode 100644 index 0000000..54e27e9 --- /dev/null +++ b/fuzz/fuzz_targets/attrs.rs @@ -0,0 +1,31 @@ +//! The routing table's own reading of attribute bytes. A set is kept as +//! the bytes it came in, and what selection and validation need (the +//! path, the origin, the next hop) is read straight from them, by code +//! other than the decoder's. Nothing a peer sends may make that panic, +//! and what it reads must be what the decoder would have read. +#![no_main] + +use bgp_rib::table::AttrStore; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let store = AttrStore::new(); + let Some(set) = store.intern_wire(data) else { + return; + }; + let key = set.key(); + let mut path = Vec::new(); + let has_set = set.as_path(&mut path); + let _ = set.next_hop(); + // The origin is the path's last AS, unless a set is in the way. + if let Some(origin) = key.origin() { + assert_eq!(path.last(), Some(&origin)); + } + if !has_set { + assert_eq!(usize::from(key.as_path_len), path.len()); + } + // The same bytes are the same set. + let again = store.intern_wire(data).expect("it was a set a moment ago"); + assert!(std::sync::Arc::ptr_eq(&set, &again)); + let _ = set.attributes(); +}); diff --git a/fuzz/seeds/attrs/plain b/fuzz/seeds/attrs/plain new file mode 100644 index 0000000000000000000000000000000000000000..0c4010f7d0ab6debebe610ee42204ea9af7260a7 GIT binary patch literal 28 jcmZ=@WMpt);$vcFVBlqCU|