From 655bd9bd00f592574e2d90a545e3f9579544a02c Mon Sep 17 00:00:00 2001 From: Leonardo Rodoni Date: Mon, 28 Sep 2026 16:49:24 +0200 Subject: [PATCH] feat(rib): add netcalyx-rib core data model crate Introduce crates/rib (netcalyx-rib): sync only, no tokio, no lookup or ingestion yet. - types: TableId/RibContext (per-VRF table addressing, RD-normalized the same way for every consumer), AfiSafiType (RFC 4760 AFI/SAFI identities), LabeledRouteExtra/Srv6RouteExtra (MPLS/SRv6 forwarding info carried per route). - attrs: RouteAttributes (flattened path attributes) and AttrStore, which interns both sub-components (AS-path, communities, cluster list, ...) and whole attribute sets, so a full table's worth of routes share Arc'd attribute sets instead of duplicating them per route. - model: RibStore -> RouterRib -> AfiSafiTable -> AfiSafiRib, the five RIB views per table (loc-rib plus the four adj-ribs) held per peer in PeerRibs, and Route/MultiRoute for per-prefix path storage (best-path selection, ECMP, path status). Arc-per-trie copy-on-write: a write after a snapshot publish clones only the one trie it touches, not the whole router. - peers: a flow's next-hop is an address, but BMP identifies a peer by a five-field tuple; PeerIndex resolves address -> peer within one RIB context in O(1) instead of scanning every peer in a table. peer_identity() normalizes RD the same way RibContext does, so inconsistent RD reporting can't split one peer's adj-rib in two. Lookup (LPM) and BMP ingestion are intentionally out of scope here, landing as separate follow-up PRs. Add docs/rib_structure.md: hierarchy sketch and copy-on-write lifecycle walkthrough for the new crate. --- Cargo.lock | 32 ++ Cargo.toml | 45 +-- crates/rib/Cargo.toml | 24 ++ crates/rib/LICENSE | 201 +++++++++++++ crates/rib/NOTICE | 1 + crates/rib/README.md | 12 + crates/rib/src/attrs.rs | 355 ++++++++++++++++++++++ crates/rib/src/lib.rs | 31 ++ crates/rib/src/model.rs | 534 ++++++++++++++++++++++++++++++++++ crates/rib/src/model/tests.rs | 507 ++++++++++++++++++++++++++++++++ crates/rib/src/peers.rs | 174 +++++++++++ crates/rib/src/types.rs | 384 ++++++++++++++++++++++++ docs/rib_structure.md | 78 +++++ 13 files changed, 2357 insertions(+), 21 deletions(-) create mode 100644 crates/rib/Cargo.toml create mode 100644 crates/rib/LICENSE create mode 120000 crates/rib/NOTICE create mode 100644 crates/rib/README.md create mode 100644 crates/rib/src/attrs.rs create mode 100644 crates/rib/src/lib.rs create mode 100644 crates/rib/src/model.rs create mode 100644 crates/rib/src/model/tests.rs create mode 100644 crates/rib/src/peers.rs create mode 100644 crates/rib/src/types.rs create mode 100644 docs/rib_structure.md diff --git a/Cargo.lock b/Cargo.lock index 6c253c7c..2d3beaa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,6 +222,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "array-const-fn-init" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bcb85e548c05d407fa6faff46b750ba287714ef32afc0f5e15b4641ffd6affb" + [[package]] name = "async-channel" version = "2.5.0" @@ -3041,6 +3047,19 @@ dependencies = [ "pdu", ] +[[package]] +name = "netcalyx-rib" +version = "0.3.0" +dependencies = [ + "ipnet", + "netcalyx-bgp-pkt", + "netcalyx-bmp-pkt", + "netcalyx-iana", + "prefix-trie", + "rustc-hash", + "smallvec", +] + [[package]] name = "netcalyx-serde-macros" version = "0.3.0" @@ -3862,6 +3881,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6369af434551b9bf8be05ab871ac1263687d6f0c9485e6d781f904f3e06d7d03" +dependencies = [ + "array-const-fn-init", + "either", + "ipnet", + "num-traits", + "smallvec", +] + [[package]] name = "prettyplease" version = "0.2.37" diff --git a/Cargo.toml b/Cargo.toml index 84fd101a..9a8fa531 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,27 @@ [workspace] members = [ - "crates/analytics", - "crates/pcap-reader", - "crates/bgp-pkt", - "crates/bgp-speaker", - "crates/bmp-service", - "crates/bmp-pkt", - "crates/iana", - "crates/ipfix-code-generator", - "crates/flow-pkt", - "crates/flow-service", - "crates/locate", - "crates/parse-utils", - "crates/serde-macros", - "crates/udp-notif-pkt", - "crates/udp-notif-service", - "crates/collector", - "crates/yang-push", - "crates/yang-consumer", - "crates/pcap-decoder", - "crates/netconf-proto", - "fuzz", + "crates/analytics", + "crates/pcap-reader", + "crates/bgp-pkt", + "crates/bgp-speaker", + "crates/bmp-service", + "crates/bmp-pkt", + "crates/iana", + "crates/ipfix-code-generator", + "crates/flow-pkt", + "crates/flow-service", + "crates/locate", + "crates/parse-utils", + "crates/serde-macros", + "crates/udp-notif-pkt", + "crates/udp-notif-service", + "crates/collector", + "crates/yang-push", + "crates/yang-consumer", + "crates/pcap-decoder", + "crates/netconf-proto", + "crates/rib", + "fuzz", ] resolver = "2" @@ -44,6 +45,7 @@ netcalyx-yang-push = { version = "0.3.0", path = "crates/yang-push" } netcalyx-yang-consumer = { version = "0.3.0", path = "crates/yang-consumer" } netcalyx-pcap-decoder = { version = "0.3.0", path = "crates/pcap-decoder" } netcalyx-netconf-proto = { version = "0.3.0", path = "crates/netconf-proto" } +netcalyx-rib = { version = "0.3.0", path = "crates/rib" } thiserror = "2.0" async-channel = "2.5" libc = { version = "0.2" } @@ -118,6 +120,7 @@ schema-registry-client = { version = "0.4" } bitflags = { version = "2.13" } axum = { version = "0.8"} schemars = { version = "1.2", features = ["derive"] } +prefix-trie = { version = "0.10" } [profile.release] codegen-units = 1 diff --git a/crates/rib/Cargo.toml b/crates/rib/Cargo.toml new file mode 100644 index 00000000..e2c8dc34 --- /dev/null +++ b/crates/rib/Cargo.toml @@ -0,0 +1,24 @@ +lints.workspace = true + +[package] +name = "netcalyx-rib" +version = "0.3.0" +edition = "2024" +authors = ["NetCalyx Authors"] +license = "Apache-2.0" +readme = "README.md" +repository = "https://github.com/network-analytics/NetCalyx" +description = """ +In-memory BGP RIB storage: core data model, attribute interning and LPM lookup for BMP-derived routing tables. +""" +keywords = ["bgp", "bmp", "rib", "routing"] +categories = ["network-programming", "data-structures"] + +[dependencies] +netcalyx-bgp-pkt = { workspace = true } +netcalyx-bmp-pkt = { workspace = true } +netcalyx-iana = { workspace = true } +rustc-hash = { workspace = true } +smallvec = { workspace = true } +ipnet = { workspace = true } +prefix-trie = { workspace = true } diff --git a/crates/rib/LICENSE b/crates/rib/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/crates/rib/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/rib/NOTICE b/crates/rib/NOTICE new file mode 120000 index 00000000..fb376cfa --- /dev/null +++ b/crates/rib/NOTICE @@ -0,0 +1 @@ +../../NOTICE \ No newline at end of file diff --git a/crates/rib/README.md b/crates/rib/README.md new file mode 100644 index 00000000..aeeabacb --- /dev/null +++ b/crates/rib/README.md @@ -0,0 +1,12 @@ +# RIB + +In-memory BGP RIB storage for a BMP collector: attribute interning and +per-router, per-AFI-SAFI containers (`loc-rib` plus the four adj-ribs) with +Arc-per-trie copy-on-write, so a write after a snapshot publish only clones +the one trie it touches. + +Supported so far: the data model (`types`, `attrs`, `model`, `peers`). LPM +lookup and BMP ingestion will be separate, later additions on top of this. + +- [`docs/rib_structure.md`](../../docs/rib_structure.md) — hierarchy sketch + and copy-on-write walkthrough. diff --git a/crates/rib/src/attrs.rs b/crates/rib/src/attrs.rs new file mode 100644 index 00000000..fd6edb81 --- /dev/null +++ b/crates/rib/src/attrs.rs @@ -0,0 +1,355 @@ +// Copyright (C) 2026-present The NetCalyx Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Path attributes and the interning store that deduplicates them. +//! +//! Usage: +//! ```ignore +//! let mut store = AttrStore::new(); +//! let shared = store.intern(attrs); // Arc +//! rib.insert(prefix, Route { attrs: Arc::clone(&shared), .. }); +//! rib.remove(&prefix); // refcount drops +//! store.gc(); // evicts Arcs only the store holds +//! ``` + +use std::hash::Hash; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::Arc; + +use rustc_hash::FxHashSet; + +use netcalyx_bgp_pkt::path_attribute::{As2Aggregator, As4Aggregator, AsPathSegmentType, Origin}; + +/// Flattened route attributes for fast enricher field access. +/// Arc-wrapped collections enable sharing across routes with identical sets. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RouteAttributes { + pub origin: Origin, + pub as_path: Arc<[AsPathSegment]>, + pub as4_path: Option>, + + pub next_hop: Option, + pub link_local_next_hop: Option, + + pub med: Option, + pub local_pref: Option, + + pub aggregator: Option, + pub aggregator4: Option, + pub atomic_aggregate: bool, + + pub originator_id: Option, + pub cluster_list: Arc<[Ipv4Addr]>, + pub aigp_metric: Option, + pub otc: Option, + pub psid_label_index: Option, + + // TODO: consider more compact storage (Arc<[u32]> / Arc<[[u8; 8]]>) + // (but would require more work when matching). + pub communities: Option]>>, + pub ext_communities: Option]>>, + pub ext_communities_ipv6: Option]>>, + pub large_communities: Option]>>, +} + +impl Default for RouteAttributes { + fn default() -> Self { + Self { + origin: Origin::Incomplete, + as_path: Arc::new([]), + as4_path: None, + next_hop: None, + link_local_next_hop: None, + med: None, + local_pref: None, + aggregator: None, + aggregator4: None, + atomic_aggregate: false, + originator_id: None, + cluster_list: Arc::new([]), + aigp_metric: None, + communities: None, + ext_communities: None, + ext_communities_ipv6: None, + large_communities: None, + otc: None, + psid_label_index: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AsPathSegment { + pub segment_type: AsPathSegmentType, + /// Normalized to u32 even for AS2 paths. + pub as_numbers: Box<[u32]>, +} + +/// Attribute store with interning for common sub-components (AS paths, +/// communities, etc.) and whole-attribute sets. +#[derive(Default)] +pub struct AttrStore { + // as_path and as4_path share one pool: same element type, segments are often identical + as_path: SliceInterner, + cluster_list: SliceInterner, + communities: SliceInterner>, + ext_communities: SliceInterner>, + ext_communities_ipv6: SliceInterner>, + large_communities: SliceInterner>, + + /// Whole-attribute pool. Sub-components are interned first so equal attrs + /// deduplicate here too. + attrs: FxHashSet>, +} + +impl AttrStore { + pub fn new() -> Self { + Self::default() + } + + /// Intern `attrs`, deduplicating sub-components and the whole struct. + pub fn intern(&mut self, mut attrs: RouteAttributes) -> Arc { + attrs.as_path = self.as_path.intern(&attrs.as_path); + attrs.as4_path = attrs.as4_path.as_deref().map(|p| self.as_path.intern(p)); + attrs.cluster_list = self.cluster_list.intern(&attrs.cluster_list); + attrs.communities = attrs + .communities + .as_deref() + .map(|c| self.communities.intern(c)); + attrs.ext_communities = attrs + .ext_communities + .as_deref() + .map(|e| self.ext_communities.intern(e)); + attrs.ext_communities_ipv6 = attrs + .ext_communities_ipv6 + .as_deref() + .map(|e| self.ext_communities_ipv6.intern(e)); + attrs.large_communities = attrs + .large_communities + .as_deref() + .map(|l| self.large_communities.intern(l)); + + if let Some(existing) = self.attrs.get(&attrs) { + return Arc::clone(existing); + } + let arc = Arc::new(attrs); + self.attrs.insert(Arc::clone(&arc)); + arc + } + + /// Evict unreachable entries. Call after a peer reset or bulk withdrawals. + /// Order matters: drop whole-attr Arcs first so sub-component refcounts + /// fall before the sub-pools are scanned. + pub fn gc(&mut self) { + self.attrs.retain(|a| Arc::strong_count(a) > 1); + self.as_path.gc(); + self.cluster_list.gc(); + self.communities.gc(); + self.ext_communities.gc(); + self.ext_communities_ipv6.gc(); + self.large_communities.gc(); + } + + /// Number of distinct interned attribute sets currently held. + pub fn unique_attrs(&self) -> usize { + self.attrs.len() + } + + #[cfg(test)] + fn as_path_pool_len(&self) -> usize { + self.as_path.pool_len() + } +} + +/// Interns `Arc<[T]>` slices, deduplicating equal slices into a single +/// allocation. Eviction is explicit via `gc()`. +pub struct SliceInterner { + pool: FxHashSet>, +} + +impl Default for SliceInterner { + fn default() -> Self { + Self { + pool: FxHashSet::default(), + } + } +} + +impl SliceInterner { + pub fn intern(&mut self, slice: &[T]) -> Arc<[T]> { + if let Some(existing) = self.pool.get(slice) { + Arc::clone(existing) + } else { + let arc: Arc<[T]> = Arc::from(slice); + self.pool.insert(Arc::clone(&arc)); + arc + } + } + + /// Evict entries whose only strong reference is this pool. + pub fn gc(&mut self) { + self.pool.retain(|a| Arc::strong_count(a) > 1); + } + + pub fn pool_len(&self) -> usize { + self.pool.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intern_deduplicates_equal_attrs() { + let mut store = AttrStore::new(); + let a = store.intern(RouteAttributes { + local_pref: Some(100), + ..Default::default() + }); + let b = store.intern(RouteAttributes { + local_pref: Some(100), + ..Default::default() + }); + assert!(Arc::ptr_eq(&a, &b)); + assert_eq!(store.unique_attrs(), 1); + } + + #[test] + fn intern_keeps_distinct_attrs_separate() { + let mut store = AttrStore::new(); + let a = store.intern(RouteAttributes { + local_pref: Some(100), + ..Default::default() + }); + let b = store.intern(RouteAttributes { + local_pref: Some(200), + ..Default::default() + }); + assert!(!Arc::ptr_eq(&a, &b)); + assert_eq!(store.unique_attrs(), 2); + } + + #[test] + fn gc_evicts_only_unreferenced_entries() { + let mut store = AttrStore::new(); + let kept = store.intern(RouteAttributes { + local_pref: Some(1), + ..Default::default() + }); + store.intern(RouteAttributes { + local_pref: Some(2), + ..Default::default() + }); + assert_eq!(store.unique_attrs(), 2); + + store.gc(); + assert_eq!(store.unique_attrs(), 1); + assert_eq!(kept.local_pref, Some(1)); + } + + #[test] + fn intern_dedupes_as_path_and_shares_pool_with_as4_path() { + let mut store = AttrStore::new(); + let as_path: Arc<[AsPathSegment]> = Arc::from(vec![AsPathSegment { + segment_type: AsPathSegmentType::AsSequence, + as_numbers: Box::new([65000, 65001]), + }]); + + let a = store.intern(RouteAttributes { + local_pref: Some(1), + as_path: Arc::clone(&as_path), + ..Default::default() + }); + let b = store.intern(RouteAttributes { + local_pref: Some(2), + as4_path: Some(Arc::clone(&as_path)), + ..Default::default() + }); + + // Distinct whole-attrs sets (different local_pref), but `as_path` and + // `as4_path` share one interning pool, so content-equal segments + // collapse to the same allocation regardless of which field they + // came in on. + assert!(!Arc::ptr_eq(&a, &b)); + assert!(Arc::ptr_eq(&a.as_path, b.as4_path.as_ref().unwrap())); + } + + #[test] + fn intern_dedupes_cluster_list_and_communities_across_attrs() { + let mut store = AttrStore::new(); + let cluster_list: Arc<[Ipv4Addr]> = Arc::from(vec![Ipv4Addr::new(1, 1, 1, 1)]); + let communities: Arc<[Box]> = Arc::from(vec![Box::from("65000:100")]); + + let a = store.intern(RouteAttributes { + local_pref: Some(1), + cluster_list: Arc::clone(&cluster_list), + communities: Some(Arc::clone(&communities)), + ..Default::default() + }); + let b = store.intern(RouteAttributes { + local_pref: Some(2), + cluster_list: Arc::clone(&cluster_list), + communities: Some(Arc::clone(&communities)), + ..Default::default() + }); + + assert!(!Arc::ptr_eq(&a, &b)); + assert!(Arc::ptr_eq(&a.cluster_list, &b.cluster_list)); + assert!(Arc::ptr_eq( + a.communities.as_ref().unwrap(), + b.communities.as_ref().unwrap() + )); + } + + #[test] + fn gc_evicts_sub_pools_only_after_the_whole_attrs_arc_is_dropped() { + let mut store = AttrStore::new(); + let as_path: Arc<[AsPathSegment]> = Arc::from(vec![AsPathSegment { + segment_type: AsPathSegmentType::AsSequence, + as_numbers: Box::new([65000]), + }]); + let attrs = store.intern(RouteAttributes { + as_path, + ..Default::default() + }); + assert_eq!(store.as_path_pool_len(), 1); + + // `attrs` still references the sub-pool entry (through the store's + // own whole-attrs copy), so neither gc pass may evict it yet. + store.gc(); + assert_eq!(store.unique_attrs(), 1); + assert_eq!(store.as_path_pool_len(), 1); + + drop(attrs); + store.gc(); + assert_eq!(store.unique_attrs(), 0); + assert_eq!(store.as_path_pool_len(), 0); + } + + #[test] + fn slice_interner_dedups_and_gcs() { + let mut interner = SliceInterner::::default(); + let a = interner.intern(&[1, 2, 3]); + let b = interner.intern(&[1, 2, 3]); + assert!(Arc::ptr_eq(&a, &b)); + assert_eq!(interner.pool_len(), 1); + + drop(a); + drop(b); + interner.gc(); + assert_eq!(interner.pool_len(), 0); + } +} diff --git a/crates/rib/src/lib.rs b/crates/rib/src/lib.rs new file mode 100644 index 00000000..3934381c --- /dev/null +++ b/crates/rib/src/lib.rs @@ -0,0 +1,31 @@ +// Copyright (C) 2026-present The NetCalyx Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! In-memory BGP RIB storage for a BMP collector. +//! +//! This crate holds the core data model, attribute interning, per-router +//! per-AFI-SAFI RIB containers with Arc-per-trie copy-on-write, peer address +//! resolution. + +pub mod attrs; +pub mod model; +pub mod peers; +pub mod types; + +pub use attrs::{AttrStore, RouteAttributes}; +pub use model::{AfiSafiTable, RibStore, RouterRib}; +pub use types::{ + AfiSafiType, LabeledRouteExtra, RibContext, RibView, Srv6RouteExtra, TableId, peer_identity, +}; diff --git a/crates/rib/src/model.rs b/crates/rib/src/model.rs new file mode 100644 index 00000000..0bf1fe57 --- /dev/null +++ b/crates/rib/src/model.rs @@ -0,0 +1,534 @@ +// Copyright (C) 2026-present The NetCalyx Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Prefix trie RIB () with +//! `Arc` + `make_mut` copy-on-write. +//! +//! `Arc` sits at four levels — `Arc` in the store, +//! `Arc` per table, `Arc` per RIB view, and `Arc` +//! per RD for VPN tables — so a write after a publish clones only the path down +//! to the one trie it touches. Every level above that trie is a shallow clone +//! of a map of `Arc`s; only the final `PrefixMap` is deep-copied. +//! +//! A "RIB view" is one of the five perspectives on a table's routes: `loc-rib` +//! (the local decision process's output: the best path, and optionally +//! backup/ECMP paths, tagged via path status) plus the four adj-ribs (`in`/ +//! `out` x `pre`/`post` policy: what was received from, or is being advertised +//! to, one peer) held per peer in [`PeerRibs`]. + +use ipnet::{Ipv4Net, Ipv6Net}; +use prefix_trie::{Prefix, PrefixMap}; +use rustc_hash::FxHashMap; +use smallvec::SmallVec; +use std::net::{IpAddr, Ipv6Addr}; +use std::sync::Arc; + +use netcalyx_bgp_pkt::nlri::{MplsLabel, RouteDistinguisher}; +use netcalyx_bmp_pkt::PeerKey; +use netcalyx_bmp_pkt::v4::PathMarking; + +use crate::attrs::RouteAttributes; +use crate::peers::PeerIndex; +use crate::types::{AfiSafiType, LabeledRouteExtra, RibContext, RibView, TableId}; + +/// Main RIB store: maps router IPs to their RIBs. +#[derive(Default)] +pub struct RibStore { + pub attr_store: crate::attrs::AttrStore, + pub ribs: FxHashMap>, +} + +/// Per-router RIB. +/// +/// `Arc` sits on each table and each RIB view, not just on `RouterRib`: a write +/// after a publish then clones only the one trie it touches, instead of every +/// trie the router owns. +#[derive(Default, Clone)] +pub struct RouterRib { + /// Resolves a neighbor address to its peer(s), scoped by RIB context (RD). + pub peer_index: PeerIndex<(RibContext, IpAddr), PeerKey>, + /// A single flat map of every table the router has + pub tables: FxHashMap>, +} + +impl RouterRib { + /// Inserts a table, deriving its `TableId` from `table` itself so the map + /// key can never disagree with the AFI-SAFI actually stored there. + pub fn insert_table(&mut self, context: RibContext, table: AfiSafiTable) -> TableId { + let table_id = TableId { + context, + afi_safi: table.afi_safi_type(), + }; + self.tables.insert(table_id, Arc::new(table)); + table_id + } +} + +/// A wrapper for any AFI-SAFI RIB +// TODO: extend for other afi-safi tables we want to support +#[derive(Clone)] +pub enum AfiSafiTable { + Ipv4Unicast(AfiSafiRib>), + Ipv6Unicast(AfiSafiRib>), + Ipv4LabeledUnicast(AfiSafiRib>), + Ipv6LabeledUnicast(AfiSafiRib>), + L3VpnIpv4Unicast(AfiSafiRib>), + L3VpnIpv6Unicast(AfiSafiRib>), +} + +/// Per-router, per-AFI-SAFI RIB state. Each view is behind its own `Arc` so +/// copy-on-write is per trie. +#[derive(Clone)] +pub struct AfiSafiRib { + pub loc_rib: Option>, + pub peers: FxHashMap>, +} + +impl Default for AfiSafiRib { + fn default() -> Self { + Self { + loc_rib: None, + peers: FxHashMap::default(), + } + } +} + +impl AfiSafiRib { + /// Read access to `loc_rib`; `None` until a route has been installed. + pub fn loc_rib_ref(&self) -> Option<&T> { + self.loc_rib.as_deref() + } +} + +impl AfiSafiRib { + /// Copy-on-write mutable access, allocating `loc_rib` on first use. + pub fn loc_rib_mut(&mut self) -> &mut T { + Arc::make_mut(self.loc_rib.get_or_insert_with(|| Arc::new(T::default()))) + } +} + +#[derive(Clone)] +pub struct PeerRibs { + pub adj_rib_in_pre: Option>, + pub adj_rib_in_post: Option>, + pub adj_rib_out_pre: Option>, + pub adj_rib_out_post: Option>, +} + +impl Default for PeerRibs { + fn default() -> Self { + Self { + adj_rib_in_pre: None, + adj_rib_in_post: None, + adj_rib_out_pre: None, + adj_rib_out_post: None, + } + } +} + +impl PeerRibs { + pub fn adj_rib_in_pre_ref(&self) -> Option<&T> { + self.adj_rib_in_pre.as_deref() + } + pub fn adj_rib_in_post_ref(&self) -> Option<&T> { + self.adj_rib_in_post.as_deref() + } + pub fn adj_rib_out_pre_ref(&self) -> Option<&T> { + self.adj_rib_out_pre.as_deref() + } + pub fn adj_rib_out_post_ref(&self) -> Option<&T> { + self.adj_rib_out_post.as_deref() + } + + /// Resolves a [`RibView`] to its field on this peer, the one accessor + /// every view-keyed reader (lookup, introspection) should go through. + /// `None` for `RibView::Loc` (not a `PeerRibs` field — see + /// [`AfiSafiRib::loc_rib_ref`]) or for a view never populated here. + pub fn view(&self, view: RibView) -> Option<&T> { + match view { + RibView::AdjInPre => self.adj_rib_in_pre_ref(), + RibView::AdjInPost => self.adj_rib_in_post_ref(), + RibView::AdjOutPre => self.adj_rib_out_pre_ref(), + RibView::AdjOutPost => self.adj_rib_out_post_ref(), + RibView::Loc => None, + } + } + + /// [`PeerRibs::view`] for callers holding the raw BMP `(post_policy, + /// adj_rib_out)` flags instead of a [`RibView`]. + pub fn adj_rib_view_ref(&self, post_policy: bool, adj_rib_out: bool) -> Option<&T> { + self.view(RibView::from_peer_flags(post_policy, adj_rib_out)) + } +} + +impl PeerRibs { + /// Copy-on-write mutable counterpart to [`PeerRibs::view`], allocating + /// the selected view on first use. `None` for `RibView::Loc`. + pub fn view_mut(&mut self, view: RibView) -> Option<&mut T> { + let slot = match view { + RibView::AdjInPre => &mut self.adj_rib_in_pre, + RibView::AdjInPost => &mut self.adj_rib_in_post, + RibView::AdjOutPre => &mut self.adj_rib_out_pre, + RibView::AdjOutPost => &mut self.adj_rib_out_post, + RibView::Loc => return None, + }; + Some(Arc::make_mut( + slot.get_or_insert_with(|| Arc::new(T::default())), + )) + } + + /// [`PeerRibs::view_mut`] for callers holding the raw BMP `(post_policy, + /// adj_rib_out)` flags instead of a [`RibView`]. Infallible: the flag + /// conversion never yields `RibView::Loc`. + pub fn adj_rib_view_mut(&mut self, post_policy: bool, adj_rib_out: bool) -> &mut T { + self.view_mut(RibView::from_peer_flags(post_policy, adj_rib_out)) + .expect("from_peer_flags never returns RibView::Loc") + } +} + +/// All known paths for one prefix within an AFI-SAFI table. +#[derive(Debug, Clone)] +pub struct MultiRoute { + // N=1 fits measured prod data where multipath is rare; if multipath grows, we could increase, + // or if the growth is constrained only on some views make N a const generic here and split + // loc_rib/peers in `AfiSafiRib` to size per-view. + pub paths: SmallVec<[Route; 1]>, +} + +impl Default for MultiRoute { + fn default() -> Self { + Self { + paths: SmallVec::default(), + } + } +} + +impl MultiRoute { + /// Returns the "active" path for correlation. + /// Priority: + /// 1. Path marked as BEST + /// 2. Any path marked as PRIMARY (useful for ECMP) + /// 3. Fallback to lowest Path-ID + pub fn active_path(&self) -> Option<&Route> { + use netcalyx_bmp_pkt::v4::PathStatus; + + self.find_by_status(PathStatus::BEST) + .or_else(|| self.find_by_status(PathStatus::PRIMARY)) + .or_else(|| self.lowest_id_path()) + } + + /// All traffic-carrying paths (ECMP). + pub fn forwarding_paths(&self) -> impl Iterator> { + self.filter_by_status(netcalyx_bmp_pkt::v4::PathStatus::PRIMARY) + } + + /// Access a specific path by its BGP Path-ID + pub fn find_by_path_id(&self, path_id: u32) -> Option<&Route> { + self.paths.iter().find(|r| r.path_id == path_id) + } + + /// Returns the first path matching a specific status flag (e.g., + /// `PathStatus::BACKUP`). Use carefully if multiple paths might share + /// the same status (like ECMP). + pub fn find_by_status(&self, status: netcalyx_bmp_pkt::v4::PathStatus) -> Option<&Route> { + self.paths + .iter() + .find(|r| r.path_marking.path_status().contains(status)) + } + + /// Returns an iterator over all paths matching a specific status bitmask + pub fn filter_by_status( + &self, + status: netcalyx_bmp_pkt::v4::PathStatus, + ) -> impl Iterator> { + self.paths + .iter() + .filter(move |r| r.path_marking.path_status().contains(status)) + } + + /// Returns the route with the lowest numerical path-id (RFC 7911). + /// Used as a deterministic fallback when best-path metadata is missing. + pub fn lowest_id_path(&self) -> Option<&Route> { + self.paths.iter().min_by_key(|r| r.path_id) + } + + /// Check if any path for this prefix is marked as Invalid + pub fn has_invalid_path(&self) -> bool { + self.paths.iter().any(|r| { + r.path_marking + .path_status() + .contains(netcalyx_bmp_pkt::v4::PathStatus::INVALID) + }) + } +} + +impl MultiRoute { + /// Find a path matching a specific label set (works for both MPLS and SRv6 + /// variants) + pub fn find_by_labels(&self, labels: &[MplsLabel]) -> Option<&Route> { + self.paths.iter().find(|r| match &r.extra { + LabeledRouteExtra::Mpls(s) => s.as_ref() == labels, + LabeledRouteExtra::Srv6(s) => s.label_stack.as_ref() == labels, + }) + } + + /// Find a path matching a specific SRv6 SID + pub fn find_by_srv6_sid(&self, sid: Ipv6Addr) -> Option<&Route> { + self.paths.iter().find(|r| match &r.extra { + LabeledRouteExtra::Srv6(s) => s.sid == sid, + _ => false, + }) + } +} + +/// Generic route node +#[derive(Debug, Clone)] +pub struct Route { + pub attrs: Arc, + pub path_id: u32, + /// BMP Path Marking Information + pub path_marking: PathMarking, + /// Extra attributes, only relevant for certain route types (e.g. labels) + pub extra: E, +} + +// Routing table types +#[derive(Debug, Clone)] +pub struct Routes { + pub routes: PrefixMap, +} + +impl Default for Routes

{ + fn default() -> Self { + Self { + routes: PrefixMap::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct LabeledRoutes { + pub routes: PrefixMap>, +} + +impl Default for LabeledRoutes

{ + fn default() -> Self { + Self { + routes: PrefixMap::new(), + } + } +} + +/// One trie per route distinguisher, each behind its own `Arc` so a write to +/// one VRF does not clone the rest. +#[derive(Debug, Clone)] +pub struct VpnRoutes { + pub tables: FxHashMap>>>, +} + +impl Default for VpnRoutes

{ + fn default() -> Self { + Self { + tables: FxHashMap::default(), + } + } +} + +impl VpnRoutes

{ + /// Read access to one RD's trie; `None` if never populated. + pub fn table_ref( + &self, + rd: RouteDistinguisher, + ) -> Option<&PrefixMap>> { + self.tables.get(&rd).map(Arc::as_ref) + } +} + +impl VpnRoutes

{ + /// Copy-on-write mutable access to one RD's trie, allocating it on + /// first use. + pub fn table_mut( + &mut self, + rd: RouteDistinguisher, + ) -> &mut PrefixMap> { + Arc::make_mut( + self.tables + .entry(rd) + .or_insert_with(|| Arc::new(PrefixMap::new())), + ) + } +} + +// AfiSafiTable helpers + +impl AfiSafiTable { + pub fn clear_loc_rib(&mut self) { + match self { + AfiSafiTable::Ipv4Unicast(f) => f.loc_rib = None, + AfiSafiTable::Ipv6Unicast(f) => f.loc_rib = None, + AfiSafiTable::Ipv4LabeledUnicast(f) => f.loc_rib = None, + AfiSafiTable::Ipv6LabeledUnicast(f) => f.loc_rib = None, + AfiSafiTable::L3VpnIpv4Unicast(f) => f.loc_rib = None, + AfiSafiTable::L3VpnIpv6Unicast(f) => f.loc_rib = None, + } + } + + pub fn remove_peer(&mut self, peer_key: &PeerKey) { + match self { + AfiSafiTable::Ipv4Unicast(f) => { + f.peers.remove(peer_key); + } + AfiSafiTable::Ipv6Unicast(f) => { + f.peers.remove(peer_key); + } + AfiSafiTable::Ipv4LabeledUnicast(f) => { + f.peers.remove(peer_key); + } + AfiSafiTable::Ipv6LabeledUnicast(f) => { + f.peers.remove(peer_key); + } + AfiSafiTable::L3VpnIpv4Unicast(f) => { + f.peers.remove(peer_key); + } + AfiSafiTable::L3VpnIpv6Unicast(f) => { + f.peers.remove(peer_key); + } + } + } + + /// Total path count across loc_rib and all peer adj-rib views. + pub fn route_count(&self) -> usize { + match self { + AfiSafiTable::Ipv4Unicast(f) => count_rib_routes(f, count_paths), + AfiSafiTable::Ipv6Unicast(f) => count_rib_routes(f, count_paths), + AfiSafiTable::Ipv4LabeledUnicast(f) => count_rib_routes(f, count_labeled_paths), + AfiSafiTable::Ipv6LabeledUnicast(f) => count_rib_routes(f, count_labeled_paths), + AfiSafiTable::L3VpnIpv4Unicast(f) => count_rib_routes(f, count_vpn_paths), + AfiSafiTable::L3VpnIpv6Unicast(f) => count_rib_routes(f, count_vpn_paths), + } + } + + /// Total prefix (trie node) count across loc_rib and all peer adj-rib + /// views. + pub fn prefix_count(&self) -> usize { + match self { + AfiSafiTable::Ipv4Unicast(f) => count_rib_routes(f, |t| t.routes.len()), + AfiSafiTable::Ipv6Unicast(f) => count_rib_routes(f, |t| t.routes.len()), + AfiSafiTable::Ipv4LabeledUnicast(f) => count_rib_routes(f, |t| t.routes.len()), + AfiSafiTable::Ipv6LabeledUnicast(f) => count_rib_routes(f, |t| t.routes.len()), + AfiSafiTable::L3VpnIpv4Unicast(f) => count_rib_routes(f, |t: &VpnRoutes<_>| { + t.tables.values().map(|m| m.len()).sum() + }), + AfiSafiTable::L3VpnIpv6Unicast(f) => count_rib_routes(f, |t: &VpnRoutes<_>| { + t.tables.values().map(|m| m.len()).sum() + }), + } + } + + pub fn afi_safi_type(&self) -> AfiSafiType { + match self { + AfiSafiTable::Ipv4Unicast(_) => AfiSafiType::Ipv4Unicast, + AfiSafiTable::Ipv6Unicast(_) => AfiSafiType::Ipv6Unicast, + AfiSafiTable::Ipv4LabeledUnicast(_) => AfiSafiType::Ipv4LabeledUnicast, + AfiSafiTable::Ipv6LabeledUnicast(_) => AfiSafiType::Ipv6LabeledUnicast, + AfiSafiTable::L3VpnIpv4Unicast(_) => AfiSafiType::L3VpnIpv4Unicast, + AfiSafiTable::L3VpnIpv6Unicast(_) => AfiSafiType::L3VpnIpv6Unicast, + } + } + + /// Entries per RIB view. + pub fn view_counts(&self) -> RibViewCounts { + match self { + AfiSafiTable::Ipv4Unicast(f) => view_counts_of(f, count_paths), + AfiSafiTable::Ipv6Unicast(f) => view_counts_of(f, count_paths), + AfiSafiTable::Ipv4LabeledUnicast(f) => view_counts_of(f, count_labeled_paths), + AfiSafiTable::Ipv6LabeledUnicast(f) => view_counts_of(f, count_labeled_paths), + AfiSafiTable::L3VpnIpv4Unicast(f) => view_counts_of(f, count_vpn_paths), + AfiSafiTable::L3VpnIpv6Unicast(f) => view_counts_of(f, count_vpn_paths), + } + } + + pub fn peer_keys(&self) -> Vec { + match self { + AfiSafiTable::Ipv4Unicast(f) => f.peers.keys().copied().collect(), + AfiSafiTable::Ipv6Unicast(f) => f.peers.keys().copied().collect(), + AfiSafiTable::Ipv4LabeledUnicast(f) => f.peers.keys().copied().collect(), + AfiSafiTable::Ipv6LabeledUnicast(f) => f.peers.keys().copied().collect(), + AfiSafiTable::L3VpnIpv4Unicast(f) => f.peers.keys().copied().collect(), + AfiSafiTable::L3VpnIpv6Unicast(f) => f.peers.keys().copied().collect(), + } + } +} + +fn count_paths(t: &Routes

) -> usize { + t.routes.iter().map(|(_, m)| m.paths.len()).sum() +} + +fn count_labeled_paths(t: &LabeledRoutes

) -> usize { + t.routes.iter().map(|(_, m)| m.paths.len()).sum() +} + +fn count_vpn_paths(t: &VpnRoutes

) -> usize { + t.tables + .values() + .map(|m| m.iter().map(|(_, r)| r.paths.len()).sum::()) + .sum() +} + +fn count_rib_routes(rib: &AfiSafiRib, count: impl Fn(&T) -> usize + Copy) -> usize { + let loc = rib.loc_rib_ref().map(count).unwrap_or(0); + let peers: usize = rib + .peers + .values() + .map(|pr| { + pr.adj_rib_in_pre_ref().map(count).unwrap_or(0) + + pr.adj_rib_in_post_ref().map(count).unwrap_or(0) + + pr.adj_rib_out_pre_ref().map(count).unwrap_or(0) + + pr.adj_rib_out_post_ref().map(count).unwrap_or(0) + }) + .sum(); + loc + peers +} + +/// Entries per RIB view: `loc-rib` plus the four adj-ribs, summed over every +/// peer for the adj-rib side. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct RibViewCounts { + pub loc: usize, + pub adj_out_pre: usize, + pub adj_out_post: usize, + pub adj_in_post: usize, + pub adj_in_pre: usize, +} + +fn view_counts_of( + rib: &AfiSafiRib, + count: impl Fn(&T) -> usize + Copy, +) -> RibViewCounts { + let mut out = RibViewCounts { + loc: rib.loc_rib_ref().map(count).unwrap_or(0), + ..Default::default() + }; + for pr in rib.peers.values() { + out.adj_out_pre += pr.adj_rib_out_pre_ref().map(count).unwrap_or(0); + out.adj_out_post += pr.adj_rib_out_post_ref().map(count).unwrap_or(0); + out.adj_in_post += pr.adj_rib_in_post_ref().map(count).unwrap_or(0); + out.adj_in_pre += pr.adj_rib_in_pre_ref().map(count).unwrap_or(0); + } + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/rib/src/model/tests.rs b/crates/rib/src/model/tests.rs new file mode 100644 index 00000000..0e2714cb --- /dev/null +++ b/crates/rib/src/model/tests.rs @@ -0,0 +1,507 @@ +// Copyright (C) 2026-present The NetCalyx Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::net::Ipv4Addr; + +use netcalyx_bmp_pkt::BmpPeerType; +use netcalyx_bmp_pkt::v4::PathStatus; + +use crate::model::*; +use crate::types::Srv6RouteExtra; + +fn route(local_pref: u32) -> Route { + Route { + attrs: Arc::new(RouteAttributes { + local_pref: Some(local_pref), + ..Default::default() + }), + path_id: 0, + path_marking: PathMarking::new(netcalyx_bmp_pkt::v4::PathStatus::BEST, None), + extra: (), + } +} + +fn labeled_route() -> Route { + Route { + attrs: Arc::new(RouteAttributes::default()), + path_id: 0, + path_marking: PathMarking::new(PathStatus::BEST, None), + extra: LabeledRouteExtra::default(), + } +} + +fn peer_key(asn: u32) -> PeerKey { + PeerKey::new( + None, + BmpPeerType::GlobalInstancePeer { + ipv6: false, + post_policy: false, + asn2: false, + adj_rib_out: false, + }, + None, + asn, + Ipv4Addr::new(1, 1, 1, 1), + ) +} + +#[test] +fn router_rib_default_is_empty() { + let rib = RouterRib::default(); + assert!(rib.tables.is_empty()); + assert!(rib.peer_index.is_empty()); +} + +#[test] +fn loc_rib_mut_inserts_into_fresh_table_and_counts_are_consistent() { + let mut table = AfiSafiTable::Ipv4Unicast(AfiSafiRib::>::default()); + let AfiSafiTable::Ipv4Unicast(rib) = &mut table else { + unreachable!() + }; + let prefix: Ipv4Net = "10.0.0.0/24".parse().unwrap(); + rib.loc_rib_mut().routes.insert( + prefix, + MultiRoute { + paths: [route(100)].into(), + }, + ); + + assert_eq!(table.route_count(), 1); + assert_eq!(table.prefix_count(), 1); + assert_eq!( + table.view_counts(), + RibViewCounts { + loc: 1, + ..Default::default() + } + ); +} + +#[test] +fn clear_loc_rib_removes_loc_rib_only() { + let mut table = AfiSafiTable::Ipv4Unicast(AfiSafiRib::>::default()); + let AfiSafiTable::Ipv4Unicast(rib) = &mut table else { + unreachable!() + }; + let prefix: Ipv4Net = "10.0.0.0/24".parse().unwrap(); + rib.loc_rib_mut().routes.insert( + prefix, + MultiRoute { + paths: [route(100)].into(), + }, + ); + + table.clear_loc_rib(); + assert_eq!(table.route_count(), 0); +} + +#[test] +fn multi_route_active_path_prefers_best_over_lowest_id() { + let best = Route { + path_id: 5, + path_marking: PathMarking::new(netcalyx_bmp_pkt::v4::PathStatus::BEST, None), + ..route(100) + }; + let other = Route { + path_id: 1, + path_marking: PathMarking::new(netcalyx_bmp_pkt::v4::PathStatus::empty(), None), + ..route(200) + }; + let multi = MultiRoute { + paths: SmallVec::from_vec(vec![other, best]), + }; + assert_eq!(multi.active_path().unwrap().path_id, 5); +} + +#[test] +fn active_path_falls_back_to_lowest_id_when_nothing_is_best_or_primary() { + let a = Route { + path_id: 5, + path_marking: PathMarking::new(PathStatus::empty(), None), + ..route(100) + }; + let b = Route { + path_id: 2, + path_marking: PathMarking::new(PathStatus::empty(), None), + ..route(100) + }; + let multi = MultiRoute { + paths: SmallVec::from_vec(vec![a, b]), + }; + assert_eq!(multi.active_path().unwrap().path_id, 2); +} + +#[test] +fn active_path_prefers_primary_over_lowest_id_when_nothing_is_best() { + let primary = Route { + path_id: 9, + path_marking: PathMarking::new(PathStatus::PRIMARY, None), + ..route(100) + }; + let lower_id_non_primary = Route { + path_id: 1, + path_marking: PathMarking::new(PathStatus::empty(), None), + ..route(100) + }; + let multi = MultiRoute { + paths: SmallVec::from_vec(vec![lower_id_non_primary, primary]), + }; + assert_eq!(multi.active_path().unwrap().path_id, 9); +} + +#[test] +fn find_by_path_id_returns_the_matching_route() { + let a = Route { + path_id: 1, + ..route(100) + }; + let b = Route { + path_id: 2, + ..route(100) + }; + let multi = MultiRoute { + paths: SmallVec::from_vec(vec![a, b]), + }; + assert_eq!(multi.find_by_path_id(2).unwrap().path_id, 2); + assert!(multi.find_by_path_id(3).is_none()); +} + +#[test] +fn filter_by_status_returns_every_primary_path_for_ecmp() { + let make = |path_id, status| Route { + path_id, + path_marking: PathMarking::new(status, None), + ..route(100) + }; + let multi = MultiRoute { + paths: SmallVec::from_vec(vec![ + make(1, PathStatus::PRIMARY), + make(2, PathStatus::PRIMARY), + make(3, PathStatus::BACKUP), + ]), + }; + let ids: Vec = multi.forwarding_paths().map(|r| r.path_id).collect(); + assert_eq!(ids, vec![1, 2]); +} + +#[test] +fn has_invalid_path_detects_any_path_marked_invalid() { + let clean = MultiRoute { + paths: SmallVec::from_vec(vec![Route { + path_marking: PathMarking::new(PathStatus::BEST, None), + ..route(100) + }]), + }; + assert!(!clean.has_invalid_path()); + + let with_invalid = MultiRoute { + paths: SmallVec::from_vec(vec![ + Route { + path_marking: PathMarking::new(PathStatus::BEST, None), + ..route(100) + }, + Route { + path_id: 2, + path_marking: PathMarking::new(PathStatus::INVALID, None), + ..route(100) + }, + ]), + }; + assert!(with_invalid.has_invalid_path()); +} + +#[test] +fn find_by_labels_and_srv6_sid_match_the_right_variant() { + let mpls_route = Route { + extra: LabeledRouteExtra::Mpls(Box::new([MplsLabel::new([0, 0, 1])])), + ..labeled_route() + }; + let srv6_route = Route { + path_id: 2, + extra: LabeledRouteExtra::Srv6(Box::new(Srv6RouteExtra { + sid: Ipv6Addr::LOCALHOST, + endpoint_behaviour: 0, + label_stack: Box::new([MplsLabel::new([0, 0, 2])]), + })), + ..labeled_route() + }; + let multi = MultiRoute { + paths: SmallVec::from_vec(vec![mpls_route, srv6_route]), + }; + + assert_eq!( + multi + .find_by_labels(&[MplsLabel::new([0, 0, 1])]) + .unwrap() + .path_id, + 0 + ); + assert_eq!( + multi.find_by_srv6_sid(Ipv6Addr::LOCALHOST).unwrap().path_id, + 2 + ); + assert!(multi.find_by_srv6_sid(Ipv6Addr::UNSPECIFIED).is_none()); +} + +#[test] +fn router_rib_clone_shares_table_and_view_arcs_until_write_touches_them() { + let peer = peer_key(1); + + // Seed two views before publishing: loc-rib and one peer's + // adj-rib-in-pre. + let mut table = AfiSafiTable::Ipv4Unicast(AfiSafiRib::>::default()); + { + let AfiSafiTable::Ipv4Unicast(rib) = &mut table else { + unreachable!() + }; + let loc_prefix: Ipv4Net = "10.0.0.0/24".parse().unwrap(); + rib.loc_rib_mut().routes.insert( + loc_prefix, + MultiRoute { + paths: [route(100)].into(), + }, + ); + let adj_prefix: Ipv4Net = "10.0.1.0/24".parse().unwrap(); + rib.peers + .entry(peer) + .or_default() + .adj_rib_view_mut(false, false) + .routes + .insert( + adj_prefix, + MultiRoute { + paths: [route(100)].into(), + }, + ); + } + + let mut original = RouterRib::default(); + let table_id = original.insert_table(RibContext::Global, table); + + let mut published = original.clone(); + assert!(Arc::ptr_eq( + original.tables.get(&table_id).unwrap(), + published.tables.get(&table_id).unwrap() + )); + assert_eq!( + Arc::strong_count(original.tables.get(&table_id).unwrap()), + 2 + ); + + // Table-level publish clones only the outer Arc; both views are + // still shared with `original` at this point. + let AfiSafiTable::Ipv4Unicast(pub_rib) = + Arc::make_mut(published.tables.get_mut(&table_id).unwrap()) + else { + unreachable!() + }; + let AfiSafiTable::Ipv4Unicast(orig_rib) = original.tables.get(&table_id).unwrap().as_ref() + else { + unreachable!() + }; + let loc_before = Arc::clone(pub_rib.loc_rib.as_ref().unwrap()); + let adj_before = Arc::clone(pub_rib.peers[&peer].adj_rib_in_pre.as_ref().unwrap()); + assert!(Arc::ptr_eq(&loc_before, orig_rib.loc_rib.as_ref().unwrap())); + assert!(Arc::ptr_eq( + &adj_before, + orig_rib.peers[&peer].adj_rib_in_pre.as_ref().unwrap() + )); + + // Write to loc-rib only, through `published`. + let new_prefix: Ipv4Net = "10.0.2.0/24".parse().unwrap(); + pub_rib.loc_rib_mut().routes.insert( + new_prefix, + MultiRoute { + paths: [route(100)].into(), + }, + ); + + // The touched view is now private, and `original` never sees the write. + assert!(!Arc::ptr_eq(pub_rib.loc_rib.as_ref().unwrap(), &loc_before)); + assert_eq!(orig_rib.loc_rib_ref().unwrap().routes.len(), 1); + assert_eq!(pub_rib.loc_rib_ref().unwrap().routes.len(), 2); + + // The untouched view is still the exact same Arc as before the write. + assert!(Arc::ptr_eq( + pub_rib.peers[&peer].adj_rib_in_pre.as_ref().unwrap(), + &adj_before + )); +} + +#[test] +fn adj_rib_view_mut_and_view_counts_attribute_each_view_correctly() { + let mut table = AfiSafiTable::Ipv4Unicast(AfiSafiRib::>::default()); + let AfiSafiTable::Ipv4Unicast(rib) = &mut table else { + unreachable!() + }; + let peer_ribs = rib.peers.entry(peer_key(1)).or_default(); + let insert = |view: &mut Routes, octet: u8| { + let prefix: Ipv4Net = format!("10.0.{octet}.0/24").parse().unwrap(); + view.routes.insert( + prefix, + MultiRoute { + paths: [route(100)].into(), + }, + ); + }; + insert(peer_ribs.adj_rib_view_mut(false, false), 1); // adj_rib_in_pre + insert(peer_ribs.adj_rib_view_mut(true, false), 2); // adj_rib_in_post + insert(peer_ribs.adj_rib_view_mut(false, true), 3); // adj_rib_out_pre + insert(peer_ribs.adj_rib_view_mut(true, true), 4); // adj_rib_out_post + + assert_eq!( + table.view_counts(), + RibViewCounts { + loc: 0, + adj_in_pre: 1, + adj_in_post: 1, + adj_out_pre: 1, + adj_out_post: 1, + } + ); + assert_eq!(table.route_count(), 4); +} + +#[test] +fn peer_ribs_view_accessors_return_none_for_loc() { + let mut peer_ribs = PeerRibs::>::default(); + assert!(peer_ribs.view(RibView::Loc).is_none()); + assert!(peer_ribs.view_mut(RibView::Loc).is_none()); +} + +#[test] +fn adj_rib_view_ref_matches_the_populated_view_only() { + let mut peer_ribs = PeerRibs::>::default(); + peer_ribs.adj_rib_view_mut(false, true).routes.insert( + "10.0.0.0/24".parse().unwrap(), + MultiRoute { + paths: [route(100)].into(), + }, + ); + assert!(peer_ribs.adj_rib_view_ref(false, true).is_some()); + assert!(peer_ribs.adj_rib_view_ref(true, true).is_none()); +} + +#[test] +fn vpn_route_and_prefix_counts_sum_across_route_distinguishers() { + let mut table = AfiSafiTable::L3VpnIpv4Unicast(AfiSafiRib::>::default()); + let AfiSafiTable::L3VpnIpv4Unicast(rib) = &mut table else { + unreachable!() + }; + let rd_a = RouteDistinguisher::As2Administrator { asn2: 1, number: 1 }; + let rd_b = RouteDistinguisher::As2Administrator { asn2: 2, number: 2 }; + let prefix: Ipv4Net = "10.0.0.0/24".parse().unwrap(); + + let vpn = rib.loc_rib_mut(); + vpn.table_mut(rd_a).insert( + prefix, + MultiRoute { + paths: [labeled_route()].into(), + }, + ); + vpn.table_mut(rd_b).insert( + prefix, + MultiRoute { + paths: SmallVec::from_vec(vec![labeled_route(), labeled_route()]), + }, + ); + + assert_eq!(table.prefix_count(), 2); // one trie node per RD + assert_eq!(table.route_count(), 3); // 1 + 2 paths +} + +#[test] +fn table_ref_reflects_table_mut_and_is_none_for_an_unpopulated_rd() { + let mut vpn = VpnRoutes::::default(); + let rd = RouteDistinguisher::As2Administrator { asn2: 1, number: 1 }; + let other_rd = RouteDistinguisher::As2Administrator { asn2: 2, number: 2 }; + assert!(vpn.table_ref(rd).is_none()); + + vpn.table_mut(rd).insert( + "10.0.0.0/24".parse().unwrap(), + MultiRoute { + paths: [labeled_route()].into(), + }, + ); + assert_eq!(vpn.table_ref(rd).unwrap().len(), 1); + assert!(vpn.table_ref(other_rd).is_none()); +} + +#[test] +fn labeled_unicast_table_counts_paths_and_prefixes() { + let mut table = + AfiSafiTable::Ipv4LabeledUnicast(AfiSafiRib::>::default()); + let AfiSafiTable::Ipv4LabeledUnicast(rib) = &mut table else { + unreachable!() + }; + rib.loc_rib_mut().routes.insert( + "10.0.0.0/24".parse().unwrap(), + MultiRoute { + paths: SmallVec::from_vec(vec![labeled_route(), labeled_route()]), + }, + ); + assert_eq!(table.prefix_count(), 1); // one trie node + assert_eq!(table.route_count(), 2); // two paths + assert_eq!( + table.view_counts(), + RibViewCounts { + loc: 2, + ..Default::default() + } + ); +} + +#[test] +fn remove_peer_and_peer_keys_reflect_current_membership() { + let mut table = AfiSafiTable::Ipv4Unicast(AfiSafiRib::>::default()); + let AfiSafiTable::Ipv4Unicast(rib) = &mut table else { + unreachable!() + }; + let peer_a = peer_key(1); + let peer_b = peer_key(2); + rib.peers.insert(peer_a, PeerRibs::default()); + rib.peers.insert(peer_b, PeerRibs::default()); + assert_eq!(table.peer_keys().len(), 2); + + table.remove_peer(&peer_a); + assert_eq!(table.peer_keys(), vec![peer_b]); +} + +#[test] +fn afi_safi_type_matches_the_table_variant() { + let table = AfiSafiTable::L3VpnIpv6Unicast(AfiSafiRib::default()); + assert_eq!(table.afi_safi_type(), AfiSafiType::L3VpnIpv6Unicast); +} + +#[test] +fn rib_store_default_is_empty() { + let store = RibStore::default(); + assert!(store.ribs.is_empty()); + assert_eq!(store.attr_store.unique_attrs(), 0); +} + +#[test] +fn insert_table_derives_table_id_from_the_table_value() { + let mut rib = RouterRib::default(); + let table_id = rib.insert_table( + RibContext::Global, + AfiSafiTable::Ipv6Unicast(AfiSafiRib::default()), + ); + assert_eq!(table_id.afi_safi, AfiSafiType::Ipv6Unicast); + assert!(matches!( + rib.tables.get(&table_id).unwrap().as_ref(), + AfiSafiTable::Ipv6Unicast(_) + )); +} diff --git a/crates/rib/src/peers.rs b/crates/rib/src/peers.rs new file mode 100644 index 00000000..07dccbea --- /dev/null +++ b/crates/rib/src/peers.rs @@ -0,0 +1,174 @@ +// Copyright (C) 2026-present The NetCalyx Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Peer-address resolution. +//! +//! A caller typically holds a neighbor or next-hop *address*, but BMP +//! identifies a peer by the tuple `(address, type, RD, ASN, BGP-ID)`. +//! Resolving address -> peer by scanning a router's peer map costs O(peers) +//! on the hot path. `PeerIndex` is a context-scoped `address -> peer(s)` map +//! built once as peers come and go, so resolution during a lookup is O(1) in +//! the expected case. +//! +//! An address normally resolves to exactly one peer once the RIB context (the +//! RD) is fixed. It can still map to several peers within one context while a +//! session flaps: the new peer-up carries a different BGP-ID or ASN and lands +//! before the old peer-down. The index keeps every candidate rather than +//! collapsing them, so a lookup narrows to those few peers instead of falling +//! back to scanning the whole peer map, and it returns to a single candidate +//! as soon as the stale peer goes down. + +use std::hash::Hash; + +use rustc_hash::FxHashMap; +use smallvec::SmallVec; + +pub struct PeerIndex { + map: FxHashMap>, +} + +impl Default for PeerIndex { + fn default() -> Self { + Self { + map: FxHashMap::default(), + } + } +} + +impl Clone for PeerIndex +where + K: Clone, + P: Clone, +{ + fn clone(&self) -> Self { + Self { + map: self.map.clone(), + } + } +} + +impl PeerIndex +where + K: Eq + Hash, + P: Copy + PartialEq, +{ + pub fn insert(&mut self, key: K, peer: P) { + let candidates = self.map.entry(key).or_default(); + if !candidates.contains(&peer) { + candidates.push(peer); + } + } + + /// Drop `peer` from `key`. If it was the last candidate, the key is + /// removed; if others remain (the ambiguous case), they stay resolvable. + pub fn remove(&mut self, key: &K, peer: P) { + if let Some(candidates) = self.map.get_mut(key) { + candidates.retain(|p| *p != peer); + if candidates.is_empty() { + self.map.remove(key); + } + } + } + + /// Every peer that `key` currently resolves to. Empty means no match; more + /// than one means the address is genuinely shared within this context + /// and the caller must disambiguate among the candidates itself. + pub fn get(&self, key: &K) -> &[P] { + self.map.get(key).map(SmallVec::as_slice).unwrap_or(&[]) + } + + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// Keys currently resolving to more than one peer. O(n); only exists to + /// assert the unambiguity in tests. Un-gate if needed by a consumer. + #[cfg(test)] + fn ambiguous_keys(&self) -> usize { + self.map.values().filter(|c| c.len() > 1).count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_and_get_resolves_single_peer() { + let mut index = PeerIndex::<&str, u32>::default(); + index.insert("10.0.0.1", 1); + assert_eq!(index.get(&"10.0.0.1"), &[1]); + assert_eq!(index.len(), 1); + } + + #[test] + fn insert_same_key_different_peer_keeps_both_candidates() { + let mut index = PeerIndex::<&str, u32>::default(); + index.insert("10.0.0.1", 1); + index.insert("10.0.0.1", 2); + assert_eq!(index.get(&"10.0.0.1"), &[1, 2]); + assert_eq!(index.ambiguous_keys(), 1); + } + + #[test] + fn insert_same_peer_twice_does_not_duplicate() { + let mut index = PeerIndex::<&str, u32>::default(); + index.insert("10.0.0.1", 1); + index.insert("10.0.0.1", 1); + assert_eq!(index.get(&"10.0.0.1"), &[1]); + } + + #[test] + fn remove_clears_the_only_candidate() { + let mut index = PeerIndex::<&str, u32>::default(); + index.insert("10.0.0.1", 1); + index.remove(&"10.0.0.1", 1); + assert_eq!(index.get(&"10.0.0.1"), &[] as &[u32]); + assert!(index.is_empty()); + } + + #[test] + fn remove_from_multi_candidate_key_resolves_it_back_to_one() { + let mut index = PeerIndex::<&str, u32>::default(); + index.insert("10.0.0.1", 1); + index.insert("10.0.0.1", 2); + index.remove(&"10.0.0.1", 1); + assert_eq!(index.get(&"10.0.0.1"), &[2]); + assert_eq!(index.ambiguous_keys(), 0); + } + + #[test] + fn remove_on_absent_key_is_a_no_op() { + let mut index = PeerIndex::<&str, u32>::default(); + index.remove(&"10.0.0.1", 1); + assert!(index.is_empty()); + } + + #[test] + fn clone_produces_an_independent_copy() { + let mut original = PeerIndex::<&str, u32>::default(); + original.insert("10.0.0.1", 1); + + let mut cloned = original.clone(); + cloned.insert("10.0.0.1", 2); + + assert_eq!(original.get(&"10.0.0.1"), &[1]); + assert_eq!(cloned.get(&"10.0.0.1"), &[1, 2]); + } +} diff --git a/crates/rib/src/types.rs b/crates/rib/src/types.rs new file mode 100644 index 00000000..b8d3b88a --- /dev/null +++ b/crates/rib/src/types.rs @@ -0,0 +1,384 @@ +// Copyright (C) 2026-present The NetCalyx Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! RIB vocabulary: table addressing, AFI-SAFI identity and the per-route +//! "extra" payloads for labeled families. + +use std::net::Ipv6Addr; + +use netcalyx_bgp_pkt::nlri::{MplsLabel, RouteDistinguisher}; +use netcalyx_bmp_pkt::{PeerHeader, PeerKey}; +use netcalyx_iana::address_family::{AddressFamily, AddressType, SubsequentAddressFamily}; + +/// A unique identifier for any RIB table in the system. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TableId { + pub context: RibContext, + pub afi_safi: AfiSafiType, +} + +/// Identifies a routing table instance within a router. +/// Derived from the BMP per-peer header's RD field. +/// - Global: the default/global routing table (RD absent, or the literal +/// all-zero 8-byte encoding BMP uses for Global Instance Peers, RFC 7854 +/// 4.2) +/// - Vrf(rd): a VRF-specific routing table +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RibContext { + Global, + Vrf(RouteDistinguisher), +} + +impl From> for RibContext { + fn from(rd: Option) -> Self { + match rd { + None | Some(RouteDistinguisher::As2Administrator { asn2: 0, number: 0 }) => { + RibContext::Global + } + Some(rd) => RibContext::Vrf(rd), + } + } +} + +/// Peer identity with the RD normalized the way [`RibContext`] normalizes it. +/// +/// `PeerKey` compares its RD verbatim while `RibContext` folds an absent RD and +/// the literal all-zero type-0 encoding into the global table. Without this +/// normalization, a router that reports no RD on peer-up but `0:0` on later +/// updates would produce two different `PeerKey`s for the same session, +/// splitting its adj-rib between them. +pub fn peer_identity(header: &PeerHeader) -> PeerKey { + let rd = match RibContext::from(header.rd()) { + RibContext::Global => None, + RibContext::Vrf(rd) => Some(rd), + }; + PeerKey::new( + header.address(), + header.peer_type(), + rd, + header.peer_as(), + header.bgp_id(), + ) +} + +/// BGP AFI-SAFI type identities (RFC 4760). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AfiSafiType { + Ipv4Unicast, + Ipv6Unicast, + Ipv4LabeledUnicast, + Ipv6LabeledUnicast, + L3VpnIpv4Unicast, + L3VpnIpv6Unicast, + Unsupported(AddressFamily, SubsequentAddressFamily), +} + +impl AfiSafiType { + pub fn from_afi_safi(afi: AddressFamily, safi: SubsequentAddressFamily) -> Self { + match (afi, safi) { + (AddressFamily::IPv4, SubsequentAddressFamily::Unicast) => AfiSafiType::Ipv4Unicast, + (AddressFamily::IPv6, SubsequentAddressFamily::Unicast) => AfiSafiType::Ipv6Unicast, + (AddressFamily::IPv4, SubsequentAddressFamily::NlriMplsLabels) => { + AfiSafiType::Ipv4LabeledUnicast + } + (AddressFamily::IPv6, SubsequentAddressFamily::NlriMplsLabels) => { + AfiSafiType::Ipv6LabeledUnicast + } + (AddressFamily::IPv4, SubsequentAddressFamily::MplsVpn) => { + AfiSafiType::L3VpnIpv4Unicast + } + (AddressFamily::IPv6, SubsequentAddressFamily::MplsVpn) => { + AfiSafiType::L3VpnIpv6Unicast + } + _ => AfiSafiType::Unsupported(afi, safi), + } + } +} + +impl From for AfiSafiType { + fn from(addr_type: AddressType) -> Self { + match addr_type { + AddressType::Ipv4Unicast => AfiSafiType::Ipv4Unicast, + AddressType::Ipv6Unicast => AfiSafiType::Ipv6Unicast, + AddressType::Ipv4NlriMplsLabels => AfiSafiType::Ipv4LabeledUnicast, + AddressType::Ipv6NlriMplsLabels => AfiSafiType::Ipv6LabeledUnicast, + AddressType::Ipv4MplsLabeledVpn => AfiSafiType::L3VpnIpv4Unicast, + AddressType::Ipv6MplsLabeledVpn => AfiSafiType::L3VpnIpv6Unicast, + addr_type => AfiSafiType::Unsupported( + addr_type.address_family(), + addr_type.subsequent_address_family(), + ), + } + } +} + +/// One of the five RIB views a router may expose per table: `loc-rib` (the +/// local decision process's output: the best path, and optionally +/// backup/ECMP paths, tagged via path status) plus the four adj-ribs +/// (`in`/`out` x `pre`/`post` policy: what was received from, or is being +/// advertised to, one peer). Shared vocabulary between ingest (which view a +/// BMP message writes to) and lookup (which views a read may consult, and in +/// what order). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RibView { + Loc, + AdjOutPre, + AdjOutPost, + AdjInPost, + AdjInPre, +} + +impl RibView { + /// `(post_policy, adj_rib_out)` as carried in the BMP per-peer header. + pub fn from_peer_flags(post_policy: bool, adj_rib_out: bool) -> RibView { + match (post_policy, adj_rib_out) { + (false, false) => RibView::AdjInPre, + (true, false) => RibView::AdjInPost, + (false, true) => RibView::AdjOutPre, + (true, true) => RibView::AdjOutPost, + } + } +} + +/// Forwarding information carried per route for labeled families. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LabeledRouteExtra { + /// Classic MPLS label stack + Mpls(Box<[MplsLabel]>), + /// SRv6 SID information + Srv6(Box), +} + +impl Default for LabeledRouteExtra { + fn default() -> Self { + LabeledRouteExtra::Mpls(Box::new([])) + } +} + +impl LabeledRouteExtra { + pub fn labels(&self) -> &[MplsLabel] { + match self { + LabeledRouteExtra::Mpls(l) => l, + LabeledRouteExtra::Srv6(s) => &s.label_stack, + } + } +} + +/// Relevant info from the BGP-SID extension. +/// The dummy label stack from the route is retained for completeness. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Srv6RouteExtra { + pub sid: Ipv6Addr, + pub endpoint_behaviour: u16, + pub label_stack: Box<[MplsLabel]>, +} + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + + use super::*; + + #[test] + fn rib_context_from_none_rd_is_global() { + assert_eq!(RibContext::from(None), RibContext::Global); + } + + #[test] + fn rib_context_from_type0_zero_rd_is_global() { + let zero_as2 = RouteDistinguisher::As2Administrator { asn2: 0, number: 0 }; + assert_eq!(RibContext::from(Some(zero_as2)), RibContext::Global); + } + + #[test] + fn rib_context_from_type1_and_type2_zero_admin_rds_are_distinct_vrfs() { + // Only the type-0 encoding is bit-for-bit all zero on the wire (RFC + // 4364 4.2); type-1/type-2 RDs with a zero administrator still carry + // a nonzero type field, so they are real, distinct RDs, not Global. + let zero_ipv4 = RouteDistinguisher::Ipv4Administrator { + ip: Ipv4Addr::UNSPECIFIED, + number: 0, + }; + let zero_as4 = RouteDistinguisher::As4Administrator { asn4: 0, number: 0 }; + + assert_eq!( + RibContext::from(Some(zero_ipv4)), + RibContext::Vrf(zero_ipv4) + ); + assert_eq!(RibContext::from(Some(zero_as4)), RibContext::Vrf(zero_as4)); + assert_ne!(RibContext::from(Some(zero_ipv4)), RibContext::Global); + assert_ne!(RibContext::from(Some(zero_as4)), RibContext::Global); + } + + #[test] + fn rib_context_from_nonzero_rd_is_vrf() { + let rd = RouteDistinguisher::As2Administrator { + asn2: 65000, + number: 1, + }; + assert_eq!(RibContext::from(Some(rd)), RibContext::Vrf(rd)); + } + + #[test] + fn rib_view_from_peer_flags_maps_all_four_combinations() { + assert_eq!(RibView::from_peer_flags(false, false), RibView::AdjInPre); + assert_eq!(RibView::from_peer_flags(true, false), RibView::AdjInPost); + assert_eq!(RibView::from_peer_flags(false, true), RibView::AdjOutPre); + assert_eq!(RibView::from_peer_flags(true, true), RibView::AdjOutPost); + } + + #[test] + fn afi_safi_type_from_afi_safi_maps_known_pairs() { + use netcalyx_iana::address_family::SubsequentAddressFamily; + + assert_eq!( + AfiSafiType::from_afi_safi(AddressFamily::IPv4, SubsequentAddressFamily::Unicast), + AfiSafiType::Ipv4Unicast + ); + assert_eq!( + AfiSafiType::from_afi_safi(AddressFamily::IPv6, SubsequentAddressFamily::Unicast), + AfiSafiType::Ipv6Unicast + ); + assert_eq!( + AfiSafiType::from_afi_safi( + AddressFamily::IPv4, + SubsequentAddressFamily::NlriMplsLabels + ), + AfiSafiType::Ipv4LabeledUnicast + ); + assert_eq!( + AfiSafiType::from_afi_safi( + AddressFamily::IPv6, + SubsequentAddressFamily::NlriMplsLabels + ), + AfiSafiType::Ipv6LabeledUnicast + ); + assert_eq!( + AfiSafiType::from_afi_safi(AddressFamily::IPv4, SubsequentAddressFamily::MplsVpn), + AfiSafiType::L3VpnIpv4Unicast + ); + assert_eq!( + AfiSafiType::from_afi_safi(AddressFamily::IPv6, SubsequentAddressFamily::MplsVpn), + AfiSafiType::L3VpnIpv6Unicast + ); + } + + #[test] + fn afi_safi_type_from_afi_safi_falls_back_to_unsupported() { + use netcalyx_iana::address_family::SubsequentAddressFamily; + + assert_eq!( + AfiSafiType::from_afi_safi(AddressFamily::IPv4, SubsequentAddressFamily::Multicast), + AfiSafiType::Unsupported(AddressFamily::IPv4, SubsequentAddressFamily::Multicast) + ); + } + + #[test] + fn afi_safi_type_from_address_type_matches_from_afi_safi() { + assert_eq!( + AfiSafiType::from(AddressType::Ipv4Unicast), + AfiSafiType::Ipv4Unicast + ); + assert_eq!( + AfiSafiType::from(AddressType::Ipv6Unicast), + AfiSafiType::Ipv6Unicast + ); + assert_eq!( + AfiSafiType::from(AddressType::Ipv4NlriMplsLabels), + AfiSafiType::Ipv4LabeledUnicast + ); + assert_eq!( + AfiSafiType::from(AddressType::Ipv6NlriMplsLabels), + AfiSafiType::Ipv6LabeledUnicast + ); + assert_eq!( + AfiSafiType::from(AddressType::Ipv4MplsLabeledVpn), + AfiSafiType::L3VpnIpv4Unicast + ); + assert_eq!( + AfiSafiType::from(AddressType::Ipv6MplsLabeledVpn), + AfiSafiType::L3VpnIpv6Unicast + ); + } + + #[test] + fn labeled_route_extra_labels_reads_both_variants() { + let mpls = LabeledRouteExtra::Mpls(Box::new([MplsLabel::new([0, 0, 1])])); + assert_eq!(mpls.labels(), &[MplsLabel::new([0, 0, 1])]); + + let srv6 = LabeledRouteExtra::Srv6(Box::new(Srv6RouteExtra { + sid: Ipv6Addr::UNSPECIFIED, + endpoint_behaviour: 0, + label_stack: Box::new([MplsLabel::new([0, 0, 2])]), + })); + assert_eq!(srv6.labels(), &[MplsLabel::new([0, 0, 2])]); + } + + #[test] + fn peer_identity_normalizes_absent_and_zero_rd_the_same() { + use netcalyx_bmp_pkt::BmpPeerType; + + fn header(rd: Option) -> PeerHeader { + PeerHeader::new( + BmpPeerType::GlobalInstancePeer { + ipv6: false, + post_policy: false, + asn2: false, + adj_rib_out: false, + }, + rd, + None, + 65000, + Ipv4Addr::new(1, 1, 1, 1), + None, + ) + } + + let zero_rd = RouteDistinguisher::As2Administrator { asn2: 0, number: 0 }; + let no_rd = peer_identity(&header(None)); + let with_zero_rd = peer_identity(&header(Some(zero_rd))); + assert_eq!(no_rd, with_zero_rd); + } + + #[test] + fn peer_identity_preserves_a_real_vrf_rd() { + use netcalyx_bmp_pkt::BmpPeerType; + + fn header(rd: Option) -> PeerHeader { + PeerHeader::new( + BmpPeerType::GlobalInstancePeer { + ipv6: false, + post_policy: false, + asn2: false, + adj_rib_out: false, + }, + rd, + None, + 65000, + Ipv4Addr::new(1, 1, 1, 1), + None, + ) + } + + let rd = RouteDistinguisher::As2Administrator { + asn2: 65000, + number: 1, + }; + let global = peer_identity(&header(None)); + let vrf = peer_identity(&header(Some(rd))); + assert_eq!(vrf.rd(), Some(rd)); + assert_ne!(global, vrf); + } +} diff --git a/docs/rib_structure.md b/docs/rib_structure.md new file mode 100644 index 00000000..69faff5d --- /dev/null +++ b/docs/rib_structure.md @@ -0,0 +1,78 @@ +# RIB structure + +Sketch of the `netcalyx-rib` (`crates/rib`) in-memory hierarchy: per-router +snapshots with per-trie copy-on-write. + +## Hierarchy + +```text +RibStore +├─ attr_store: AttrStore +└─ ribs: FxHashMap> [Arc 1] one entry per router + └─ RouterRib + ├─ peer_index: PeerIndex<(RibContext, IpAddr), PeerKey> + └─ tables: FxHashMap> [Arc 2] one per (context, afi-safi) + └─ AfiSafiTable::Ipv4Unicast(AfiSafiRib>) + ├─ loc_rib: Option>> [Arc 3] + │ └─ routes: PrefixMap [TRIE] + └─ peers: FxHashMap>> + └─ PeerRibs four independent views + ├─ adj_rib_in_pre: Option>> [Arc 3, own TRIE] + ├─ adj_rib_in_post: Option>> [Arc 3, own TRIE] + ├─ adj_rib_out_pre: Option>> [Arc 3, own TRIE] + └─ adj_rib_out_post: Option>> [Arc 3, own TRIE] +``` + +`Ipv6Unicast`, `Ipv4LabeledUnicast` and `Ipv6LabeledUnicast` repeat the same +shape with `Ipv6Net` and/or `LabeledRoutes` in place of `Routes`. + +The two L3VPN variants (`L3VpnIpv4Unicast`, `L3VpnIpv6Unicast`) replace every +leaf above — `loc_rib` and each `adj_rib_*` — with one extra layer, keyed by +route distinguisher: + +```text +AfiSafiTable::L3VpnIpv4Unicast(AfiSafiRib>) + └─ (loc_rib, or each adj_rib_* in PeerRibs): Option>> [Arc 3] + └─ VpnRoutes::tables: FxHashMap>> [Arc 4] + └─ [TRIE] one per VRF +``` + +`Arc` legend: + +| | wraps | shared until | +|---|---|---| +| Arc 1 | `RouterRib` | next publish of that router | +| Arc 2 | `AfiSafiTable` | next write to that table | +| Arc 3 | one view's trie (`Routes`/`LabeledRoutes`/`VpnRoutes`) | next write to that view | +| Arc 4 | one VRF's `PrefixMap` (VPN only) | next write to that VRF | + +Trie count for one router with K peers, P AFI-SAFIs, V VRFs: + +```text +tries = P × (1_loc + K × 4_adj) # the VPN families additionally multiply by V (one trie per RD) +``` + +## Copy-on-write + +`Arc` sits at four levels, not just on `RouterRib`. A write after a publish +walks down that chain calling `Arc::make_mut` at each level: every level above +the touched trie is a shallow clone of a map of `Arc`s, and only the single +trie being written is deep-copied. Superseded versions are reclaimed by plain +refcounting when the next publish drops the old snapshot — the cascade stops +wherever the new snapshot still shares the value. Full lifecycle walkthrough: +module doc on [`crates/rib/src/model.rs`](../crates/rib/src/model.rs). + +## RIB views + +Five perspectives on one table's routes: `loc-rib` (the local decision +process's output: the best path, and optionally additional backup or ECMP +paths) plus four adj-ribs (what was received from, or is being advertised +to, one peer), keyed by policy stage and direction: + +| | pre-policy | post-policy | +|---|---|---| +| adj-rib-in | `adj_rib_in_pre` | `adj_rib_in_post` | +| adj-rib-out | `adj_rib_out_pre` | `adj_rib_out_post` | + +The whole `RouterRib` is published as one unit — `loc-rib` and every peer's +four adj-rib views together.