diff --git a/Cargo.lock b/Cargo.lock index 4de88f22b..6a3662d14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -828,6 +828,10 @@ dependencies = [ [[package]] name = "client-common" version = "0.1.0" +dependencies = [ + "omicron-common", + "oxnet", +] [[package]] name = "cmake" @@ -3843,9 +3847,12 @@ version = "0.1.0" dependencies = [ "chrono", "clap", + "client-common", "nom 8.0.0", "num_enum 0.7.6", + "omicron-common", "oxnet", + "proptest", "schemars 0.8.22", "serde", "serde_json", @@ -5320,6 +5327,11 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "poptrie" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/poptrie?branch=main#5bf62f6b889c61e0608d8463ed11da28e130cb34" + [[package]] name = "port-file" version = "0.1.0" @@ -6022,6 +6034,7 @@ dependencies = [ "mg-common", "ndp", "oxnet", + "poptrie", "proptest", "schemars 0.8.22", "serde", diff --git a/Cargo.toml b/Cargo.toml index eec1d64ab..1029565d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,7 +96,7 @@ dropshot-api-manager = "0.7.2" dropshot-api-manager-types = "0.7.2" expectorate = "1.2.0" schemars = { version = "0.8.22", features = [ "uuid1", "chrono" ] } -tokio = { version = "1.49", features = ["full"] } +tokio = { version = "1.52.1", features = ["full"] } serde_repr = "0.1" anyhow = "1.0.103" port-file = "0.1.0" @@ -132,6 +132,7 @@ oximeter = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} oximeter-producer = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} oxnet = { version = "0.1.6", default-features = false, features = ["schemars", "serde"] } omicron-common = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} +poptrie = { git = "https://github.com/oxidecomputer/poptrie", branch = "main" } gateway-client = { git = "https://github.com/oxidecomputer/omicron", branch = "main" } uuid = { version = "1.21", features = ["serde", "v4"] } smf = { git = "https://github.com/illumos/smf-rs", branch = "main" } diff --git a/client-common/Cargo.toml b/client-common/Cargo.toml index a760e670f..545ef8cb1 100644 --- a/client-common/Cargo.toml +++ b/client-common/Cargo.toml @@ -2,3 +2,9 @@ name = "client-common" version = "0.1.0" edition = "2024" + +[dependencies] +oxnet.workspace = true + +[dev-dependencies] +omicron-common.workspace = true diff --git a/client-common/src/address.rs b/client-common/src/address.rs new file mode 100644 index 000000000..e91ad7c0a --- /dev/null +++ b/client-common/src/address.rs @@ -0,0 +1,143 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2026 Oxide Computer Company + +//! Multicast addressing constants shared across the routing suite. +//! +//! These mirror the canonical definitions in `omicron_common::address`. +//! They are duplicated here so the client and API-types crates consumed by +//! Omicron remain free of an Omicron dependency, which would otherwise form a +//! dependency cycle. These constants must be reachable at compile time from the +//! Omicron-free API-types crate because its newtypes validate addresses at +//! deserialization via `#[serde(try_from)]`. +//! +//! References: [RFC 4291] (IPv6 addressing), [RFC 4607] (SSM), +//! [RFC 5771] (IPv4 multicast), [RFC 7346] (IPv6 multicast scopes). +//! +//! [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291 +//! [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607 +//! [RFC 5771]: https://www.rfc-editor.org/rfc/rfc5771 +//! [RFC 7346]: https://www.rfc-editor.org/rfc/rfc7346 + +use oxnet::{Ipv4Net, Ipv6Net}; +use std::net::{Ipv4Addr, Ipv6Addr}; + +// TODO: Consolidate these constants and the `omicron_common::address` +// originals into `oxnet`, the cycle-free leaf crate that maghemite, dendrite, +// and omicron already share, so the duplication can be removed. + +/// IPv4 Source-Specific Multicast (SSM) subnet (232.0.0.0/8) per RFC 4607 §3. +pub const IPV4_SSM_SUBNET: Ipv4Net = + Ipv4Net::new_unchecked(Ipv4Addr::new(232, 0, 0, 0), 8); + +/// IPv6 Source-Specific Multicast (SSM) subnet. +/// +/// RFC 4607 §3 specifies ff3x::/32, where the `x` nibble is the multicast +/// scope. We use /12 as an implementation convenience matching all per-scope +/// blocks (ff30:: through ff3f:ffff:..:ffff) with a single subnet, since all +/// SSM addresses share the first 12 bits (0xff prefix plus flag field 3). +/// This superset is used only for contains-based classification, not as an +/// allocation boundary. +pub const IPV6_SSM_SUBNET: Ipv6Net = + Ipv6Net::new_unchecked(Ipv6Addr::new(0xff30, 0, 0, 0, 0, 0, 0, 0), 12); + +/// IPv4 multicast address range (224.0.0.0/4) per RFC 5771. +pub const IPV4_MULTICAST_RANGE: Ipv4Net = + Ipv4Net::new_unchecked(Ipv4Addr::new(224, 0, 0, 0), 4); + +/// IPv4 link-local multicast subnet (224.0.0.0/24) per RFC 5771 §4. +/// +/// Reserved for local network control protocols and not routed beyond the +/// local link. +pub const IPV4_LINK_LOCAL_MULTICAST_SUBNET: Ipv4Net = + Ipv4Net::new_unchecked(Ipv4Addr::new(224, 0, 0, 0), 24); + +/// IPv6 multicast address range (ff00::/8) per RFC 4291. +pub const IPV6_MULTICAST_RANGE: Ipv6Net = + Ipv6Net::new_unchecked(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8); + +/// IPv6 multicast prefix (ff00::/8) value for scope checking per RFC 4291 §2.7. +pub const IPV6_MULTICAST_PREFIX: u16 = 0xff00; + +/// Admin-local IPv6 multicast prefix (ff04::/16) as a u16 for address +/// construction and normalization of underlay multicast addresses. +/// +/// See RFC 4291 §2.7 and RFC 7346 for the multicast address format and scope +/// definitions. +pub const IPV6_ADMIN_SCOPED_MULTICAST_PREFIX: u16 = 0xff04; + +/// Fixed underlay admin-local IPv6 multicast subnet (ff04::/64). +/// +/// Admin-local scope (4) is the smallest scope that must be administratively +/// configured per RFC 7346. The Oxide rack maps overlay multicast groups 1:1 +/// into this /64. +pub const UNDERLAY_MULTICAST_SUBNET: Ipv6Net = Ipv6Net::new_unchecked( + Ipv6Addr::new(IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, 0, 0, 0, 0, 0, 0, 0), + 64, +); + +/// IPv6 interface-local multicast subnet (ff01::/16) per RFC 4291 §2.7. +/// +/// Not routable. +pub const IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET: Ipv6Net = + Ipv6Net::new_unchecked(Ipv6Addr::new(0xff01, 0, 0, 0, 0, 0, 0, 0), 16); + +/// IPv6 link-local multicast subnet (ff02::/16) per RFC 4291 §2.7. +/// +/// Not routable beyond the local link. +pub const IPV6_LINK_LOCAL_MULTICAST_SUBNET: Ipv6Net = + Ipv6Net::new_unchecked(Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 0), 16); + +/// IPv6 reserved-scope multicast subnet (ff00::/16) per RFC 4291 §2.7. +/// +/// Scope 0 is reserved. Packets with this scope must not be originated and +/// must be silently dropped if received. +pub const IPV6_RESERVED_SCOPE_MULTICAST_SUBNET: Ipv6Net = + Ipv6Net::new_unchecked(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 16); + +#[cfg(test)] +mod tests { + use omicron_common::address as canonical; + + use super::*; + + /// Assert each local constant equals its `omicron_common::address` + /// original so the copies cannot drift from the source of truth. + /// + /// `omicron_common` is a dev-dependency only, so it does not appear in the + /// normal dependency tree the no-omicron CI check inspects. + #[test] + fn constants_match_canonical_values() { + assert_eq!(IPV4_SSM_SUBNET, canonical::IPV4_SSM_SUBNET); + assert_eq!(IPV6_SSM_SUBNET, canonical::IPV6_SSM_SUBNET); + assert_eq!(IPV4_MULTICAST_RANGE, canonical::IPV4_MULTICAST_RANGE); + assert_eq!( + IPV4_LINK_LOCAL_MULTICAST_SUBNET, + canonical::IPV4_LINK_LOCAL_MULTICAST_SUBNET + ); + assert_eq!(IPV6_MULTICAST_RANGE, canonical::IPV6_MULTICAST_RANGE); + assert_eq!(IPV6_MULTICAST_PREFIX, canonical::IPV6_MULTICAST_PREFIX); + assert_eq!( + IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, + canonical::IPV6_ADMIN_SCOPED_MULTICAST_PREFIX + ); + assert_eq!( + UNDERLAY_MULTICAST_SUBNET, + canonical::UNDERLAY_MULTICAST_SUBNET + ); + assert_eq!( + IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, + canonical::IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET + ); + assert_eq!( + IPV6_LINK_LOCAL_MULTICAST_SUBNET, + canonical::IPV6_LINK_LOCAL_MULTICAST_SUBNET + ); + assert_eq!( + IPV6_RESERVED_SCOPE_MULTICAST_SUBNET, + canonical::IPV6_RESERVED_SCOPE_MULTICAST_SUBNET + ); + } +} diff --git a/client-common/src/lib.rs b/client-common/src/lib.rs index 0ee6444f1..f653328c9 100644 --- a/client-common/src/lib.rs +++ b/client-common/src/lib.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +pub mod address; + /// Like `println!`, but silently exits on broken pipe (EPIPE) instead of /// panicking. Other I/O errors still panic. #[macro_export] diff --git a/mg-api-types/Cargo.toml b/mg-api-types/Cargo.toml index bed637748..9727a81ff 100644 --- a/mg-api-types/Cargo.toml +++ b/mg-api-types/Cargo.toml @@ -8,3 +8,4 @@ mg-api-types-versions.workspace = true [features] clap = ["mg-api-types-versions/clap"] +proptest = ["mg-api-types-versions/proptest"] diff --git a/mg-api-types/src/lib.rs b/mg-api-types/src/lib.rs index 9212f3396..79d23b5b6 100644 --- a/mg-api-types/src/lib.rs +++ b/mg-api-types/src/lib.rs @@ -19,6 +19,7 @@ pub mod bfd; pub mod bgp; +pub mod mrib; pub mod ndp; pub mod rdb; pub mod rib; diff --git a/mg-api-types/src/mrib.rs b/mg-api-types/src/mrib.rs new file mode 100644 index 000000000..ae9c31e26 --- /dev/null +++ b/mg-api-types/src/mrib.rs @@ -0,0 +1,8 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +pub use mg_api_types_versions::latest::mrib::*; + +#[cfg(feature = "proptest")] +pub use mg_api_types_versions::proptest::mrib::*; diff --git a/mg-api-types/versions/Cargo.toml b/mg-api-types/versions/Cargo.toml index b2f66995c..cc2443acb 100644 --- a/mg-api-types/versions/Cargo.toml +++ b/mg-api-types/versions/Cargo.toml @@ -6,17 +6,22 @@ edition = "2024" [dependencies] chrono.workspace = true clap = { workspace = true, optional = true } +client-common.workspace = true nom.workspace = true num_enum.workspace = true oxnet.workspace = true +proptest = { workspace = true, optional = true } schemars.workspace = true serde.workspace = true +serde_json.workspace = true slog.workspace = true thiserror.workspace = true uuid.workspace = true [dev-dependencies] +omicron-common.workspace = true serde_json.workspace = true [features] clap = ["dep:clap"] +proptest = ["dep:proptest"] diff --git a/mg-api-types/versions/src/impls/mod.rs b/mg-api-types/versions/src/impls/mod.rs index dc9fc558f..fbd1e5361 100644 --- a/mg-api-types/versions/src/impls/mod.rs +++ b/mg-api-types/versions/src/impls/mod.rs @@ -5,4 +5,6 @@ //! Functional code for the latest versions of types. pub(crate) mod bgp; +#[cfg(feature = "proptest")] +pub mod mrib; pub(crate) mod rdb; diff --git a/mg-api-types/versions/src/impls/mrib.rs b/mg-api-types/versions/src/impls/mrib.rs new file mode 100644 index 000000000..ba80f0eaa --- /dev/null +++ b/mg-api-types/versions/src/impls/mrib.rs @@ -0,0 +1,411 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2026 Oxide Computer Company + +//! Proptest `Arbitrary` impls and strategy helpers for the latest MRIB types. + +use std::net::{Ipv4Addr, Ipv6Addr}; + +use client_common::address::{ + IPV4_MULTICAST_RANGE, IPV4_SSM_SUBNET, IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, + IPV6_MULTICAST_PREFIX, IPV6_SSM_SUBNET, +}; +use proptest::prelude::*; +use proptest::strategy::Just; + +use crate::latest::mrib::{ + MAX_VNI, MulticastAddr, MulticastAddrV4, MulticastAddrV6, + MulticastRouteKey, MulticastRouteKeyV4, MulticastRouteKeyV6, + UnderlayMulticastIpv6, UnicastAddrV4, UnicastAddrV6, Vni, +}; + +/// Minimum valid IPv6 multicast scope for proptest strategies. +/// +/// Scopes 0 (reserved), 1 (interface-local), and 2 (link-local) are +/// rejected by `MulticastAddrV6::new`, so generated addresses start +/// at scope 3 (realm-local). +const MIN_MULTICAST_SCOPE: u8 = 0x3; + +/// Maximum valid IPv6 multicast scope for proptest strategies. +/// +/// Scope F is reserved (RFC 7346) and rejected by `MulticastAddrV6::new`, +/// so generated addresses stop at scope E (global). +const MAX_MULTICAST_SCOPE: u8 = 0xe; + +/// Maximum IPv6 multicast flags value (4 bits). +const MAX_MULTICAST_FLAGS: u8 = 0xf; + +/// SSM flags nibble (RFC 4607). SSM addresses have flags = 3. +const SSM_FLAGS: u8 = 0x3; + +/// Generate IPv4 unicast addresses suitable as a multicast source. +/// +/// Avoids 0/8, 127/8, and the multicast/reserved ranges (224/3 onward). +pub fn ipv4_unicast_strategy() -> impl Strategy { + prop_oneof![ + // 1.x.x.x - 126.x.x.x (skip 0.x.x.x and 127.x.x.x loopback) + (1u8..=126, any::(), any::(), any::()) + .prop_map(|(a, b, c, d)| Ipv4Addr::new(a, b, c, d)), + // 128.x.x.x - 223.x.x.x (before multicast range) + (128u8..=223, any::(), any::(), any::()) + .prop_map(|(a, b, c, d)| Ipv4Addr::new(a, b, c, d)), + ] + .prop_filter_map("must be valid unicast", |addr| { + UnicastAddrV4::new(addr).ok() + }) +} + +/// Generate valid [`Vni`] values in `[0, MAX_VNI]`. +pub fn valid_vni_strategy() -> impl Strategy { + (0u32..=MAX_VNI).prop_map(|v| Vni::new(v).expect("VNI is in range")) +} + +/// Generate raw u32 values that exceed [`MAX_VNI`], rejected by [`Vni::new`]. +pub fn invalid_vni_strategy() -> impl Strategy { + (MAX_VNI + 1)..=u32::MAX +} + +/// Generate validated underlay multicast addresses within `ff04::/64`. +pub fn admin_local_multicast_strategy() +-> impl Strategy { + any::().prop_map(|bits| { + let addr = Ipv6Addr::new( + IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, + 0, + 0, + 0, + (bits >> 48) as u16, + (bits >> 32) as u16, + (bits >> 16) as u16, + bits as u16, + ); + UnderlayMulticastIpv6::new(addr).expect("valid underlay address") + }) +} + +/// Generate IPv6 multicast addresses outside the admin-local scope. +/// +/// Scope is restricted to values >= 3 (link-local/interface-local +/// rejected by `MulticastAddrV6::new`) and != admin-local. +pub fn non_admin_local_multicast_strategy() +-> impl Strategy { + // Extract admin-local scope from the constant (0xff04 -> 4) + let admin_local_scope = (IPV6_ADMIN_SCOPED_MULTICAST_PREFIX & 0xf) as u8; + // Scope must be valid (3+) and not admin-local + let scope = prop_oneof![ + Just(MIN_MULTICAST_SCOPE), + (admin_local_scope + 1)..=MAX_MULTICAST_SCOPE + ]; + (any::(), scope, any::<[u16; 7]>()).prop_map(|(flags, scope, segs)| { + let first = IPV6_MULTICAST_PREFIX + | ((flags as u16 & MAX_MULTICAST_FLAGS as u16) << 4) + | (scope as u16); + MulticastAddrV6::new(Ipv6Addr::new( + first, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], + segs[6], + )) + .expect("non-admin-local multicast is valid") + }) +} + +/// Generate routable IPv6 unicast addresses (excludes link-local, +/// loopback, unspecified, and multicast). +pub fn routable_ipv6_unicast_strategy() -> impl Strategy +{ + any::().prop_filter_map("must be valid unicast", |bits| { + UnicastAddrV6::new(Ipv6Addr::from(bits)).ok() + }) +} + +/// Generate ASM (non-SSM) IPv4 multicast addresses. +pub fn ipv4_asm_group_strategy() -> impl Strategy { + // Derive range boundaries from constants + let mcast_base = IPV4_MULTICAST_RANGE.addr().octets()[0]; + let mcast_end = mcast_base + 15; // /4 prefix = 16 values + let ssm_first = IPV4_SSM_SUBNET.addr().octets()[0]; + + // ASM ranges: mcast_base.0.1+ through (ssm_first-1), plus (ssm_first+1)-mcast_end + prop_oneof![ + // mcast_base.0.1.0 - mcast_base.0.255.255 (skip link-local) + (1u8..=u8::MAX, any::()).prop_map(move |(c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(mcast_base, 0, c, d)) + .expect("mcast_base.0.1+ is valid") + }), + // mcast_base.1.0.0 - mcast_base.255.255.255 + (1u8..=u8::MAX, any::(), any::()).prop_map(move |(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(mcast_base, b, c, d)) + .expect("mcast_base.1+ is valid") + }), + // (mcast_base+1).x.x.x - (ssm_first-1).x.x.x + ( + (mcast_base + 1)..=ssm_first - 1, + any::(), + any::(), + any::() + ) + .prop_map(|(a, b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)) + .expect("pre-SSM ASM is valid") + }), + // (ssm_first+1).x.x.x - mcast_end.x.x.x (skip SSM) + ( + (ssm_first + 1)..=mcast_end, + any::(), + any::(), + any::() + ) + .prop_map(|(a, b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)) + .expect("post-SSM ASM is valid") + }), + ] +} + +/// Generate SSM IPv4 multicast addresses (232.x.x.x). +pub fn ipv4_ssm_group_strategy() -> impl Strategy { + let ssm_first_octet = IPV4_SSM_SUBNET.addr().octets()[0]; + (any::(), any::(), any::()).prop_map(move |(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(ssm_first_octet, b, c, d)) + .expect("SSM range is valid multicast") + }) +} + +/// Generate ASM (non-SSM) IPv6 multicast addresses. +pub fn ipv6_asm_group_strategy() -> impl Strategy { + // ASM: ff:: where flags != SSM_FLAGS, scope in 3-f + let flags = prop_oneof![ + Just(0x0u8), + Just(0x1u8), + Just(0x2u8), + ((SSM_FLAGS + 1)..=MAX_MULTICAST_FLAGS), + ]; + ( + flags, + MIN_MULTICAST_SCOPE..=MAX_MULTICAST_SCOPE, + any::<[u16; 7]>(), + ) + .prop_map(|(f, s, segs)| { + let first = IPV6_MULTICAST_PREFIX | ((f as u16) << 4) | (s as u16); + MulticastAddrV6::new(Ipv6Addr::new( + first, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], + segs[6], + )) + .expect("ASM is valid") + }) +} + +/// Generate SSM IPv6 multicast addresses (FF3x::/32). +pub fn ipv6_ssm_group_strategy() -> impl Strategy { + // SSM: ff3:: where scope in 3-e (realm-local through global). The + // second segment stays zero per the RFC 4607 FF3x::/32 allocation, + // matching the SSM classification in `MulticastRouteKey::validate`. + let ssm_base = IPV6_SSM_SUBNET.addr().segments()[0]; + (MIN_MULTICAST_SCOPE..=MAX_MULTICAST_SCOPE, any::<[u16; 6]>()).prop_map( + move |(scope, segs)| { + let first = ssm_base | (scope as u16); + MulticastAddrV6::new(Ipv6Addr::new( + first, 0, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], + )) + .expect("SSM is valid") + }, + ) +} + +impl Arbitrary for MulticastAddrV4 { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + // Derive range boundaries from constants + let mcast_base = IPV4_MULTICAST_RANGE.addr().octets()[0]; + let mcast_end = mcast_base + 15; // /4 prefix = 16 values + let ssm_first = IPV4_SSM_SUBNET.addr().octets()[0]; + + // Generate directly in valid multicast ranges for efficiency + // Valid: 224.0.1.0 - 239.255.255.255 (excluding 224.0.0.x link-local) + prop_oneof![ + // mcast_base.0.1.0 - mcast_base.0.255.255 (skip link-local) + (1u8..=u8::MAX, any::()).prop_map(move |(c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(mcast_base, 0, c, d)) + .expect("mcast_base.0.1+ is valid multicast") + }), + // mcast_base.1.0.0 - mcast_base.255.255.255 + (1u8..=u8::MAX, any::(), any::()).prop_map( + move |(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(mcast_base, b, c, d)) + .expect("mcast_base.1+ is valid multicast") + } + ), + // (mcast_base+1).x.x.x - (ssm_first-1).x.x.x (globally routable) + ( + (mcast_base + 1)..=ssm_first - 1, + any::(), + any::(), + any::() + ) + .prop_map(|(a, b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)) + .expect("pre-SSM range is valid multicast") + }), + // ssm_first.x.x.x (SSM range) + (any::(), any::(), any::()).prop_map( + move |(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(ssm_first, b, c, d)) + .expect("SSM is valid") + } + ), + // (ssm_first+1).x.x.x - (mcast_end-1).x.x.x (GLOP, etc.) + ( + (ssm_first + 1)..=mcast_end - 1, + any::(), + any::(), + any::() + ) + .prop_map(|(a, b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)) + .expect("post-SSM range is valid multicast") + }), + // mcast_end.x.x.x (admin-scoped) + (any::(), any::(), any::()).prop_map( + move |(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(mcast_end, b, c, d)) + .expect("admin-scoped is valid") + } + ), + ] + .boxed() + } +} + +impl Arbitrary for MulticastAddrV6 { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + // Generate with all valid flag/scope combinations + // Format: ff:: + // Valid scopes: 3-e (excluding 0/f=reserved, 1=if-local, 2=link-local) + // Flags: 0-f (all combinations valid) + ( + 0x0u8..=MAX_MULTICAST_FLAGS, + MIN_MULTICAST_SCOPE..=MAX_MULTICAST_SCOPE, + any::<[u16; 7]>(), + ) + .prop_map(|(flags, scope, segs)| { + let first_segment = IPV6_MULTICAST_PREFIX + | ((flags as u16) << 4) + | (scope as u16); + let addr = Ipv6Addr::new( + first_segment, + segs[0], + segs[1], + segs[2], + segs[3], + segs[4], + segs[5], + segs[6], + ); + MulticastAddrV6::new(addr) + .expect("scope 3-e with any flags is valid") + }) + .boxed() + } +} + +impl Arbitrary for MulticastAddr { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + prop_oneof![ + any::().prop_map(MulticastAddr::V4), + any::().prop_map(MulticastAddr::V6), + ] + .boxed() + } +} + +impl Arbitrary for MulticastRouteKey { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + // Generate directly without filtering for efficiency with high case counts + let vni = (0u32..=MAX_VNI) + .prop_map(|v| Vni::new(v).expect("VNI is in range")); + + prop_oneof![ + // V4 ASM (*,G) + (ipv4_asm_group_strategy(), vni.clone()).prop_map(|(grp, vni)| { + MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: None, + group: grp, + vni, + }) + }), + // V4 ASM (S,G) + ( + ipv4_unicast_strategy(), + ipv4_asm_group_strategy(), + vni.clone() + ) + .prop_map(|(src, grp, vni)| { + MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: Some(src), + group: grp, + vni, + }) + }), + // V4 SSM (S,G) - SSM requires source + ( + ipv4_unicast_strategy(), + ipv4_ssm_group_strategy(), + vni.clone() + ) + .prop_map(|(src, grp, vni)| { + MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: Some(src), + group: grp, + vni, + }) + }), + // V6 ASM (*,G) + (ipv6_asm_group_strategy(), vni.clone()).prop_map(|(grp, vni)| { + MulticastRouteKey::V6(MulticastRouteKeyV6 { + source: None, + group: grp, + vni, + }) + }), + // V6 ASM (S,G) + ( + routable_ipv6_unicast_strategy(), + ipv6_asm_group_strategy(), + vni.clone() + ) + .prop_map(|(src, grp, vni)| { + MulticastRouteKey::V6(MulticastRouteKeyV6 { + source: Some(src), + group: grp, + vni, + }) + }), + // V6 SSM (S,G) - SSM requires source + ( + routable_ipv6_unicast_strategy(), + ipv6_ssm_group_strategy(), + vni + ) + .prop_map(|(src, grp, vni)| { + MulticastRouteKey::V6(MulticastRouteKeyV6 { + source: Some(src), + group: grp, + vni, + }) + }), + ] + .boxed() + } +} diff --git a/mg-api-types/versions/src/latest.rs b/mg-api-types/versions/src/latest.rs index 7255e654c..32b833319 100644 --- a/mg-api-types/versions/src/latest.rs +++ b/mg-api-types/versions/src/latest.rs @@ -180,6 +180,10 @@ pub mod ndp { pub use crate::v5::ndp::NdpThreadState; } +pub mod mrib { + pub use crate::v12::mrib::*; +} + pub mod rib { pub use crate::v1::rib::BestpathFanoutRequest; pub use crate::v1::rib::BestpathFanoutResponse; diff --git a/mg-api-types/versions/src/lib.rs b/mg-api-types/versions/src/lib.rs index 9bf14899f..0496e3ae3 100644 --- a/mg-api-types/versions/src/lib.rs +++ b/mg-api-types/versions/src/lib.rs @@ -31,12 +31,22 @@ mod impls; pub mod latest; + +/// Proptest `Arbitrary` impls and strategy helpers for the latest versions +/// of types. Re-exported through `mg_api_types::` so test code can +/// reach them without depending on this crate directly. +#[cfg(feature = "proptest")] +pub mod proptest { + pub use crate::impls::mrib; +} #[path = "initial/mod.rs"] pub mod v1; #[path = "v4_over_v6_static_routes/mod.rs"] pub mod v10; #[path = "prefix_to_oxnet/mod.rs"] pub mod v11; +#[path = "multicast_support/mod.rs"] +pub mod v12; #[path = "ipv6_basic/mod.rs"] pub mod v2; #[path = "switch_identifiers/mod.rs"] diff --git a/mg-api-types/versions/src/multicast_support/mod.rs b/mg-api-types/versions/src/multicast_support/mod.rs new file mode 100644 index 000000000..5f0b1552b --- /dev/null +++ b/mg-api-types/versions/src/multicast_support/mod.rs @@ -0,0 +1,15 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2026 Oxide Computer Company + +//! Version `MULTICAST_SUPPORT` of the Maghemite Admin API. +//! +//! Adds MRIB (Multicast Routing Information Base) endpoints for static +//! multicast route management and query of imported and selected multicast +//! routes. Introduces the supporting wire types (validated unicast and +//! multicast addresses, the underlay group within `ff04::/64`, multicast +//! route keys, and route entries). + +pub mod mrib; diff --git a/mg-api-types/versions/src/multicast_support/mrib.rs b/mg-api-types/versions/src/multicast_support/mrib.rs new file mode 100644 index 000000000..a0198b593 --- /dev/null +++ b/mg-api-types/versions/src/multicast_support/mrib.rs @@ -0,0 +1,1158 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2026 Oxide Computer Company + +//! MRIB (Multicast Routing Information Base) types for version +//! `MULTICAST_SUPPORT`. +//! +//! Covers the public API request and response shapes, validated wire +//! address types (unicast and multicast, IPv4/IPv6, plus the underlay +//! IPv6 group within `ff04::/64`), and the multicast route and route key +//! types stored in the MRIB. + +use std::collections::BTreeSet; +use std::fmt::{self, Formatter}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use client_common::address::{ + IPV4_LINK_LOCAL_MULTICAST_SUBNET, IPV4_MULTICAST_RANGE, IPV4_SSM_SUBNET, + IPV6_MULTICAST_RANGE, UNDERLAY_MULTICAST_SUBNET, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::v1::rdb::rib::AddressFamily; + +/// Maximum Geneve VNI value. +/// +/// Virtual Network Identifiers are constrained to 24-bit values per the +/// Geneve specification (RFC 8926 Section 3.3). +pub const MAX_VNI: u32 = 0xFF_FFFF; + +/// Default VNI for fleet-wide multicast routing. +/// +/// A low-numbered VNI chosen to avoid colliding with user VNIs, though it is +/// not yet within the Oxide-reserved range. +pub const DEFAULT_MULTICAST_VNI: u32 = 77; + +/// Errors raised while validating or serializing multicast route data. +#[derive(thiserror::Error, Debug)] +pub enum MulticastError { + /// A field failed semantic validation. + #[error("Validation error: {0}")] + Validation(String), + + /// Serialization of a value failed. + #[error("Parsing error {0}")] + Parsing(String), + + /// A database key could not be decoded. + #[error("db key error {0}")] + DbKey(String), +} + +/// A validated Geneve Virtual Network Identifier. +/// +/// Wraps a 24-bit VNI, rejecting any value above [`MAX_VNI`] at construction +/// and deserialization so an out-of-range identifier is unrepresentable. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(try_from = "u32", into = "u32")] +#[schemars(transparent)] +pub struct Vni(u32); + +impl Vni { + /// Default VNI for fleet-wide multicast routing. + pub const DEFAULT_MULTICAST: Self = Self(DEFAULT_MULTICAST_VNI); + + /// Create a validated VNI. + /// + /// # Errors + /// + /// Returns [`MulticastError::Validation`] if `value` exceeds [`MAX_VNI`], + /// the largest 24-bit Geneve VNI. + /// + /// # Examples + /// + /// ``` + /// use mg_api_types_versions::latest::mrib::{MAX_VNI, Vni}; + /// + /// assert!(Vni::new(77).is_ok()); + /// assert!(Vni::new(MAX_VNI + 1).is_err()); + /// ``` + pub fn new(value: u32) -> Result { + if value > MAX_VNI { + return Err(MulticastError::Validation(format!( + "VNI {value} exceeds the maximum 24-bit value {MAX_VNI}" + ))); + } + Ok(Self(value)) + } + + /// Return the underlying 24-bit value. + #[inline] + pub const fn as_u32(self) -> u32 { + self.0 + } +} + +impl fmt::Display for Vni { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for Vni { + type Error = MulticastError; + + fn try_from(value: u32) -> Result { + Self::new(value) + } +} + +impl From for u32 { + fn from(vni: Vni) -> Self { + vni.0 + } +} + +/// Input for adding static multicast routes. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct StaticMulticastRouteInput { + /// The multicast route key (S,G) or (*,G). + pub key: MulticastRouteKey, + /// Underlay multicast group address (ff04::/64). + pub underlay_group: UnderlayMulticastIpv6, +} + +/// Request body for adding static multicast routes to the MRIB. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribAddStaticRequest { + /// List of static multicast routes to add. + pub routes: Vec, +} + +/// Request body for deleting static multicast routes from the MRIB. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribDeleteStaticRequest { + /// List of route keys to delete. + pub keys: Vec, +} + +/// Filter for multicast route origin. +#[derive( + Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq, Eq, +)] +#[serde(rename_all = "snake_case")] +pub enum RouteOriginFilter { + /// Static routes only (operator configured). + Static, + /// Dynamic routes only (learned via IGMP, MLD, etc.). + Dynamic, +} + +/// Query parameters for MRIB routes. +/// +/// When `group` is provided, looks up a specific route. +/// When `group` is omitted, lists all routes (with optional filters). +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribQuery { + /// Multicast group address. If provided, returns a specific route. + /// If omitted, returns all routes matching the filters. + #[serde(default)] + pub group: Option, + /// Source address (`None` for (*,G) routes). Only used when `group` + /// is set. + #[serde(default)] + pub source: Option, + /// VNI (defaults to the fleet-wide multicast VNI). Only used when + /// `group` is set. + #[serde(default = "default_multicast_vni")] + pub vni: Vni, + /// Filter by address family. Only used when listing all routes. + #[serde(default)] + pub address_family: Option, + /// Filter by route origin ("static" or "dynamic"). + /// Only used when listing all routes. + #[serde(default)] + pub route_origin: Option, +} + +const fn default_multicast_vni() -> Vni { + Vni::DEFAULT_MULTICAST +} + +/// A validated IPv4 unicast address suitable for multicast source fields. +/// +/// This rejects addresses that cannot appear as a forwarded unicast source: +/// multicast, broadcast, loopback, unspecified, link-local, "this +/// network" (0/8), and Class E reserved (240/4). Private ranges +/// (RFC 1918) are allowed since overlay guests use them. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(try_from = "Ipv4Addr", into = "Ipv4Addr")] +#[schemars(transparent)] +pub struct UnicastAddrV4(Ipv4Addr); + +impl UnicastAddrV4 { + /// Create a new validated IPv4 unicast address. + pub fn new(value: Ipv4Addr) -> Result { + if value.is_multicast() { + return Err(MulticastError::Validation(format!( + "{value} is multicast, not unicast" + ))); + } + if value.is_broadcast() { + return Err(MulticastError::Validation(format!( + "{value} is broadcast, not unicast" + ))); + } + if value.is_loopback() { + return Err(MulticastError::Validation(format!( + "{value} is loopback, not a valid source" + ))); + } + // 0/8 "this network" per RFC 791 + if value.is_unspecified() || value.octets()[0] == 0 { + return Err(MulticastError::Validation(format!( + "{value} is in 0/8 (this-network), not a valid source" + ))); + } + // 169.254/16 per RFC 3927 Section 7: not forwarded by routers + if value.is_link_local() { + return Err(MulticastError::Validation(format!( + "{value} is link-local, not routable" + ))); + } + // Class E reserved (240/4) per RFC 1112 Section 4. + // Replace with Ipv4Addr::is_reserved() when stabilized. + if value.octets()[0] >= 240 { + return Err(MulticastError::Validation(format!( + "{value} is in the reserved Class E range (240/4)" + ))); + } + Ok(Self(value)) + } + + #[inline] + pub const fn ip(&self) -> Ipv4Addr { + self.0 + } +} + +impl fmt::Display for UnicastAddrV4 { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for UnicastAddrV4 { + type Error = MulticastError; + fn try_from(value: Ipv4Addr) -> Result { + Self::new(value) + } +} + +impl From for Ipv4Addr { + fn from(addr: UnicastAddrV4) -> Self { + addr.0 + } +} + +/// A validated IPv6 unicast address suitable for multicast source fields. +/// +/// Rejects multicast, loopback, unspecified, and link-local (fe80::/10). +/// ULA (fc00::/7) is allowed since overlay guests may use these ranges. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(try_from = "Ipv6Addr", into = "Ipv6Addr")] +#[schemars(transparent)] +pub struct UnicastAddrV6(Ipv6Addr); + +impl UnicastAddrV6 { + /// Create a new validated IPv6 unicast address. + pub fn new(value: Ipv6Addr) -> Result { + if value.is_multicast() { + return Err(MulticastError::Validation(format!( + "{value} is multicast, not unicast" + ))); + } + if value.is_loopback() { + return Err(MulticastError::Validation(format!( + "{value} is loopback, not a valid source" + ))); + } + if value.is_unspecified() { + return Err(MulticastError::Validation(format!( + "{value} is unspecified, not a valid source" + ))); + } + // fe80::/10 per RFC 4291 Section 2.5.6: not forwarded + if value.is_unicast_link_local() { + return Err(MulticastError::Validation(format!( + "{value} is link-local, not routable" + ))); + } + Ok(Self(value)) + } + + #[inline] + pub const fn ip(&self) -> Ipv6Addr { + self.0 + } +} + +impl fmt::Display for UnicastAddrV6 { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for UnicastAddrV6 { + type Error = MulticastError; + fn try_from(value: Ipv6Addr) -> Result { + Self::new(value) + } +} + +impl From for Ipv6Addr { + fn from(addr: UnicastAddrV6) -> Self { + addr.0 + } +} + +/// A validated IPv4 multicast address. +/// +/// This type guarantees that the inner address is a routable multicast address +/// (not link-local). +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(try_from = "Ipv4Addr", into = "Ipv4Addr")] +#[schemars(transparent)] +pub struct MulticastAddrV4(Ipv4Addr); + +impl MulticastAddrV4 { + /// Create a new validated IPv4 multicast address. + pub fn new(value: Ipv4Addr) -> Result { + // Must be in multicast range (224.0.0.0/4) + if !IPV4_MULTICAST_RANGE.contains(value) { + return Err(MulticastError::Validation(format!( + "IPv4 address {value} is not multicast \ + (must be in {IPV4_MULTICAST_RANGE})" + ))); + } + + // Reject link-local multicast (224.0.0.0/24) + if IPV4_LINK_LOCAL_MULTICAST_SUBNET.contains(value) { + return Err(MulticastError::Validation(format!( + "IPv4 address {value} is link-local multicast \ + ({IPV4_LINK_LOCAL_MULTICAST_SUBNET}) which is not routable" + ))); + } + + Ok(Self(value)) + } + + /// Returns the underlying IPv4 address. + #[inline] + pub const fn ip(&self) -> Ipv4Addr { + self.0 + } +} + +impl fmt::Display for MulticastAddrV4 { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for MulticastAddrV4 { + type Error = MulticastError; + + fn try_from(value: Ipv4Addr) -> Result { + Self::new(value) + } +} + +impl From for Ipv4Addr { + fn from(addr: MulticastAddrV4) -> Self { + addr.0 + } +} + +/// A validated IPv6 multicast address. +/// +/// This type guarantees that the inner address is a routable multicast address +/// (not interface-local, link-local, or reserved scope). +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(try_from = "Ipv6Addr", into = "Ipv6Addr")] +#[schemars(transparent)] +pub struct MulticastAddrV6(Ipv6Addr); + +impl MulticastAddrV6 { + /// Create a new validated IPv6 multicast address. + pub fn new(value: Ipv6Addr) -> Result { + // Must be in multicast range (ff00::/8) + if !IPV6_MULTICAST_RANGE.contains(value) { + return Err(MulticastError::Validation(format!( + "IPv6 address {value} is not multicast \ + (must be in {IPV6_MULTICAST_RANGE})" + ))); + } + + // RFC 4291 section 2.7 splits the second address byte into flags + // (high nibble) and scope (low nibble). Classify on the scope + // nibble alone: a /16 prefix comparison encodes flags=0 and would + // accept, e.g., ff11::1 despite its interface-local scope. + let scope = value.segments()[0] & 0x000f; + match scope { + 0x0 => Err(MulticastError::Validation(format!( + "IPv6 address {value} has reserved multicast scope 0, \ + which is not routable" + ))), + 0x1 => Err(MulticastError::Validation(format!( + "IPv6 address {value} has interface-local multicast scope, \ + which is not routable" + ))), + 0x2 => Err(MulticastError::Validation(format!( + "IPv6 address {value} has link-local multicast scope, \ + which is not routable" + ))), + // RFC 7346 section 2 reserves scope F alongside scope 0. + 0xf => Err(MulticastError::Validation(format!( + "IPv6 address {value} has reserved multicast scope F, \ + which is not routable" + ))), + _ => Ok(Self(value)), + } + } + + /// Returns the underlying IPv6 address. + #[inline] + pub const fn ip(&self) -> Ipv6Addr { + self.0 + } +} + +impl fmt::Display for MulticastAddrV6 { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for MulticastAddrV6 { + type Error = MulticastError; + + fn try_from(value: Ipv6Addr) -> Result { + Self::new(value) + } +} + +impl From for Ipv6Addr { + fn from(addr: MulticastAddrV6) -> Self { + addr.0 + } +} + +/// A validated underlay multicast IPv6 address within ff04::/64. +/// +/// The Oxide rack maps overlay multicast groups 1:1 to admin-local scoped +/// IPv6 multicast addresses in `UNDERLAY_MULTICAST_SUBNET` (ff04::/64). +/// This type enforces that invariant at construction time. +// TODO: Duplicates `dpd_types::mcast::UnderlayMulticastIpv6` in dendrite. +// Both should be consolidated into `oxnet`, the cycle-free leaf crate that +// maghemite, dendrite, and omicron already share. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(try_from = "Ipv6Addr", into = "Ipv6Addr")] +#[schemars(transparent)] +pub struct UnderlayMulticastIpv6(Ipv6Addr); + +impl UnderlayMulticastIpv6 { + /// Create a new validated underlay multicast address. + /// + /// # Errors + /// + /// Returns an error if the address is not within `UNDERLAY_MULTICAST_SUBNET` + /// (ff04::/64). + pub fn new(value: Ipv6Addr) -> Result { + if !UNDERLAY_MULTICAST_SUBNET.contains(value) { + return Err(MulticastError::Validation(format!( + "underlay address {value} is not within \ + {UNDERLAY_MULTICAST_SUBNET}" + ))); + } + Ok(Self(value)) + } + + /// Returns the underlying IPv6 address. + #[inline] + pub const fn ip(&self) -> Ipv6Addr { + self.0 + } +} + +impl fmt::Display for UnderlayMulticastIpv6 { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for UnderlayMulticastIpv6 { + type Error = MulticastError; + + fn try_from(value: Ipv6Addr) -> Result { + Self::new(value) + } +} + +impl From for Ipv6Addr { + fn from(addr: UnderlayMulticastIpv6) -> Self { + addr.0 + } +} + +impl From for IpAddr { + fn from(addr: UnderlayMulticastIpv6) -> Self { + IpAddr::V6(addr.0) + } +} + +impl FromStr for UnderlayMulticastIpv6 { + type Err = MulticastError; + + fn from_str(s: &str) -> Result { + let addr: Ipv6Addr = s.parse().map_err(|_| { + MulticastError::Validation(format!("invalid IPv6 address: {s}")) + })?; + Self::new(addr) + } +} + +/// A validated multicast group address (IPv4 or IPv6). +/// +/// This type guarantees that the contained address is a routable multicast +/// address. Construction is only possible through validated paths. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub enum MulticastAddr { + V4(MulticastAddrV4), + V6(MulticastAddrV6), +} + +impl MulticastAddr { + /// Create an IPv4 multicast address from octets. + pub fn new_v4(a: u8, b: u8, c: u8, d: u8) -> Result { + Ok(Self::V4(MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d))?)) + } + + /// Create an IPv6 multicast address from segments. + pub fn new_v6(segments: [u16; 8]) -> Result { + Ok(Self::V6(MulticastAddrV6::new(Ipv6Addr::new( + segments[0], + segments[1], + segments[2], + segments[3], + segments[4], + segments[5], + segments[6], + segments[7], + ))?)) + } + + /// Returns the underlying IP address. + pub const fn ip(&self) -> IpAddr { + match self { + Self::V4(v4) => IpAddr::V4(v4.ip()), + Self::V6(v6) => IpAddr::V6(v6.ip()), + } + } +} + +impl fmt::Display for MulticastAddr { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + MulticastAddr::V4(addr) => write!(f, "{}", addr), + MulticastAddr::V6(addr) => write!(f, "{}", addr), + } + } +} + +impl From for MulticastAddr { + fn from(addr: MulticastAddrV4) -> Self { + Self::V4(addr) + } +} + +impl From for MulticastAddr { + fn from(addr: MulticastAddrV6) -> Self { + Self::V6(addr) + } +} + +impl TryFrom for MulticastAddr { + type Error = MulticastError; + + fn try_from(value: Ipv4Addr) -> Result { + Ok(Self::V4(MulticastAddrV4::new(value)?)) + } +} + +impl TryFrom for MulticastAddr { + type Error = MulticastError; + + fn try_from(value: Ipv6Addr) -> Result { + Ok(Self::V6(MulticastAddrV6::new(value)?)) + } +} + +impl TryFrom for MulticastAddr { + type Error = MulticastError; + + fn try_from(value: IpAddr) -> Result { + match value { + IpAddr::V4(v4) => Self::try_from(v4), + IpAddr::V6(v6) => Self::try_from(v6), + } + } +} + +/// IPv4 multicast route key with type-enforced address family matching. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub struct MulticastRouteKeyV4 { + /// Source address (`None` for (*,G) routes). + pub(crate) source: Option, + /// Multicast group address. + pub(crate) group: MulticastAddrV4, + /// VNI (Virtual Network Identifier). + #[serde(default = "default_multicast_vni")] + pub(crate) vni: Vni, +} + +/// IPv6 multicast route key with type-enforced address family matching. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub struct MulticastRouteKeyV6 { + /// Source address (`None` for (*,G) routes). + pub(crate) source: Option, + /// Multicast group address. + pub(crate) group: MulticastAddrV6, + /// VNI (Virtual Network Identifier). + #[serde(default = "default_multicast_vni")] + pub(crate) vni: Vni, +} + +/// Multicast route key: (Source, Group) pair for source-specific multicast, +/// or (*, Group) for any-source multicast. +/// +/// Uses type-enforced address family matching: IPv4 sources can only be +/// paired with IPv4 groups, and IPv6 sources with IPv6 groups. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub enum MulticastRouteKey { + V4(MulticastRouteKeyV4), + V6(MulticastRouteKeyV6), +} + +impl fmt::Display for MulticastRouteKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::V4(key) => match key.source { + Some(src) => write!(f, "({src},{})", key.group), + None => write!(f, "(*,{})", key.group), + }, + Self::V6(key) => match key.source { + Some(src) => write!(f, "({src},{})", key.group), + None => write!(f, "(*,{})", key.group), + }, + } + } +} + +impl MulticastRouteKey { + /// Create a multicast route key, validating address family matching. + /// + /// Use this when the address family is not known at compile time (e.g., + /// from API requests). Returns an error if source and group address + /// families don't match. For compile-time type safety, prefer + /// [`Self::source_specific_v4`]/[`Self::source_specific_v6`] or + /// [`Self::any_source`]. + pub fn new( + source: Option, + group: MulticastAddr, + vni: Vni, + ) -> Result { + match group { + MulticastAddr::V4(g) => { + let src = match source { + None => None, + Some(IpAddr::V4(s)) => Some(UnicastAddrV4::new(s)?), + Some(IpAddr::V6(s)) => { + return Err(MulticastError::Validation(format!( + "source {s} is IPv6 but group {g} is IPv4" + ))); + } + }; + Ok(Self::V4(MulticastRouteKeyV4 { + source: src, + group: g, + vni, + })) + } + MulticastAddr::V6(g) => { + let src = match source { + None => None, + Some(IpAddr::V6(s)) => Some(UnicastAddrV6::new(s)?), + Some(IpAddr::V4(s)) => { + return Err(MulticastError::Validation(format!( + "source {s} is IPv4 but group {g} is IPv6" + ))); + } + }; + Ok(Self::V6(MulticastRouteKeyV6 { + source: src, + group: g, + vni, + })) + } + } + } + + /// Create an any-source multicast route (*,G) with default VNI. + pub fn any_source(group: MulticastAddr) -> Self { + match group { + MulticastAddr::V4(g) => Self::V4(MulticastRouteKeyV4 { + source: None, + group: g, + vni: Vni::DEFAULT_MULTICAST, + }), + MulticastAddr::V6(g) => Self::V6(MulticastRouteKeyV6 { + source: None, + group: g, + vni: Vni::DEFAULT_MULTICAST, + }), + } + } + + /// Create a source-specific IPv4 multicast route (S,G) with default VNI. + pub fn source_specific_v4( + source: UnicastAddrV4, + group: MulticastAddrV4, + ) -> Self { + Self::V4(MulticastRouteKeyV4 { + source: Some(source), + group, + vni: Vni::DEFAULT_MULTICAST, + }) + } + + /// Create a source-specific IPv6 multicast route (S,G) with default VNI. + pub fn source_specific_v6( + source: UnicastAddrV6, + group: MulticastAddrV6, + ) -> Self { + Self::V6(MulticastRouteKeyV6 { + source: Some(source), + group, + vni: Vni::DEFAULT_MULTICAST, + }) + } + + /// Create an any-source multicast route (*,G) with specified VNI. + pub fn any_source_with_vni(group: MulticastAddr, vni: Vni) -> Self { + match group { + MulticastAddr::V4(g) => Self::V4(MulticastRouteKeyV4 { + source: None, + group: g, + vni, + }), + MulticastAddr::V6(g) => Self::V6(MulticastRouteKeyV6 { + source: None, + group: g, + vni, + }), + } + } + + /// Create a source-specific IPv4 multicast route (S,G) with VNI. + pub fn source_specific_v4_with_vni( + source: UnicastAddrV4, + group: MulticastAddrV4, + vni: Vni, + ) -> Self { + Self::V4(MulticastRouteKeyV4 { + source: Some(source), + group, + vni, + }) + } + + /// Create a source-specific IPv6 multicast route (S,G) with VNI. + pub fn source_specific_v6_with_vni( + source: UnicastAddrV6, + group: MulticastAddrV6, + vni: Vni, + ) -> Self { + Self::V6(MulticastRouteKeyV6 { + source: Some(source), + group, + vni, + }) + } + + /// Get the source address as IpAddr. + pub fn source(&self) -> Option { + match self { + Self::V4(k) => k.source.map(|s| IpAddr::V4(s.ip())), + Self::V6(k) => k.source.map(|s| IpAddr::V6(s.ip())), + } + } + + /// Get the group address. + pub const fn group(&self) -> MulticastAddr { + match self { + Self::V4(k) => MulticastAddr::V4(k.group), + Self::V6(k) => MulticastAddr::V6(k.group), + } + } + + /// Get the VNI. + pub const fn vni(&self) -> Vni { + match self { + Self::V4(k) => k.vni, + Self::V6(k) => k.vni, + } + } + + /// Serialize this key to bytes for use as a sled database key. + pub fn db_key(&self) -> Result, MulticastError> { + let s = serde_json::to_string(self).map_err(|e| { + MulticastError::Parsing(format!( + "failed to serialize multicast route key: {e}" + )) + })?; + Ok(s.as_bytes().into()) + } + + /// Deserialize a key from sled database bytes. + pub fn from_db_key(v: &[u8]) -> Result { + let s = String::from_utf8_lossy(v); + serde_json::from_str(&s).map_err(|e| { + MulticastError::DbKey(format!( + "failed to parse multicast route key: {e}" + )) + }) + } + + /// Validate the multicast route key. + /// + /// Checks: + /// - SSM groups require a source address (RFC 4607) + /// - IPv4: 232.0.0.0/8 + /// - IPv6: FF3x::/32 (flags nibble 3, any scope) + /// - Source address (if present) must be unicast + /// - (S,G) joins on ASM ranges are permitted, giving source + /// filtering outside the SSM range (IGMPv3/MLDv2 semantics) + /// + /// The 24-bit VNI range is enforced by the [`Vni`] newtype at construction + /// and deserialization, so it is not re-checked here. + pub fn validate(&self) -> Result<(), MulticastError> { + // SSM addresses require a source (RFC 4607). This is consistent with + // DPD's validate_ipv4_multicast / validate_ipv6_multicast. + // + // ASM addresses can also have sources, allowing (S,G) joins on + // ASM ranges for source filtering outside the SSM range. + // + // If real-world deployments need (*,G) on SSM addresses, this + // check and the corresponding DPD validation can be relaxed + // together and we can update our policy handling. + let is_ssm = match self { + Self::V4(k) => IPV4_SSM_SUBNET.contains(k.group.ip()), + // RFC 4607 section 1 allocates IPv6 SSM as FF3x::/32 (flags + // nibble 3, any scope, remaining prefix bits zero). A broader + // ff30::/12 match would also classify RFC 3306 unicast-prefix + // based addresses with a nonzero network prefix as SSM. + Self::V6(k) => { + let segs = k.group.ip().segments(); + segs[0] & 0xfff0 == 0xff30 && segs[1] == 0 + } + }; + if is_ssm && self.source().is_none() { + return Err(MulticastError::Validation(format!( + "SSM group {} requires a source address", + self.group() + ))); + } + + Ok(()) + } +} + +/// Multicast route entry containing replication groups and metadata. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MulticastRoute { + /// The multicast route key (S,G) or (*,G). + pub key: MulticastRouteKey, + /// Upstream neighbor selected for RPF checks. + /// + /// This records one representative neighbor rather than the full ECMP + /// set. When the unicast route has multiple equal-cost paths, any active + /// member is valid. `None` means RPF does not apply or no active unicast + /// path is available. + /// + /// Derived from the unicast RIB, never persisted. Listings of static + /// route configuration always carry `None` here since they reflect + /// stored configuration only. + pub rpf_neighbor: Option, + /// Underlay multicast group address (ff04::/64). + /// + /// Overlay multicast addresses are mapped 1:1 to admin-local scope + /// underlay addresses. Switches replicate to this address via the + /// PRE (with tofino_asic). + /// + /// OPTE handles the overlay/underlay translation at sled boundaries, while + /// sled membership is managed by Omicron and programmed to DPD/OPTE + /// directly. + pub underlay_group: UnderlayMulticastIpv6, + /// Route source (static, IGMP, etc.). + pub source: MulticastSourceProtocol, + /// Creation timestamp. + pub created: DateTime, + /// Last updated timestamp. + /// + /// Only updated when route fields change semantically (rpf_neighbor, + /// underlay_group, source). An idempotent upsert with an identical + /// value does not update this timestamp. + pub updated: DateTime, +} + +impl MulticastRoute { + pub fn new( + key: MulticastRouteKey, + underlay_group: UnderlayMulticastIpv6, + source: MulticastSourceProtocol, + ) -> Self { + let now = Utc::now(); + Self { + key, + rpf_neighbor: None, + underlay_group, + source, + created: now, + updated: now, + } + } + + /// Validate the multicast route. + /// + /// Checks: + /// - Key validation (source unicast, VNI range) + /// - RPF neighbor (if present) must be unicast + /// + /// A cross-family neighbor is valid: derivation from the unicast RIB + /// may resolve v4 routes through v6 nexthops ([RFC 8950] style). + /// + /// [RFC 8950]: https://www.rfc-editor.org/rfc/rfc8950 + pub fn validate(&self) -> Result<(), MulticastError> { + self.key.validate()?; + + // underlay_group is validated by UnderlayMulticastIpv6 at + // construction time (must be within ff04::/64). + + // Validate RPF neighbor if present + if let Some(rpf) = &self.rpf_neighbor { + match rpf { + IpAddr::V4(addr) => { + if addr.is_multicast() { + return Err(MulticastError::Validation(format!( + "RPF neighbor {addr} must be unicast, not multicast" + ))); + } + if addr.is_broadcast() { + return Err(MulticastError::Validation(format!( + "RPF neighbor {addr} must be unicast, not broadcast" + ))); + } + } + IpAddr::V6(addr) => { + if addr.is_multicast() { + return Err(MulticastError::Validation(format!( + "RPF neighbor {addr} must be unicast, not multicast" + ))); + } + } + } + } + + Ok(()) + } +} + +/// Source of a multicast route entry. +#[derive( + Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, Eq, PartialEq, +)] +pub enum MulticastSourceProtocol { + /// Static route configured via API. + Static, + /// Learned via IGMP snooping (future). + Igmp, + /// Learned via MLD snooping (future). + Mld, +} + +/// Notification for MRIB changes, sent to watchers. +#[derive(Clone, Default, Debug)] +pub struct MribChangeNotification { + pub changed: BTreeSet, +} + +impl From for MribChangeNotification { + fn from(value: MulticastRouteKey) -> Self { + Self { + changed: BTreeSet::from([value]), + } + } +} + +#[cfg(test)] +mod tests { + use omicron_common::api::external::Vni as CanonicalVni; + + use super::*; + + /// Assert the locally copied VNI literals equal their + /// `omicron_common::api::external::Vni` originals so they cannot drift. + /// + /// `omicron_common` is a dev-dependency only, so it does not appear in the + /// normal dependency tree the no-omicron CI check inspects. + #[test] + fn vni_constants_match_canonical_values() { + assert_eq!(MAX_VNI, CanonicalVni::MAX_VNI); + assert_eq!( + DEFAULT_MULTICAST_VNI, + CanonicalVni::DEFAULT_MULTICAST_VNI.as_u32() + ); + } + + /// The [`Vni`] newtype accepts in-range values and rejects values above + /// [`MAX_VNI`], enforcing the 24-bit invariant at construction. + #[test] + fn vni_rejects_out_of_range() { + assert_eq!(Vni::new(0).unwrap().as_u32(), 0); + assert_eq!(Vni::new(MAX_VNI).unwrap().as_u32(), MAX_VNI); + assert_eq!(Vni::DEFAULT_MULTICAST.as_u32(), DEFAULT_MULTICAST_VNI); + assert!(Vni::new(MAX_VNI + 1).is_err()); + assert!(Vni::new(u32::MAX).is_err()); + } +} diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 9680ab903..4ede1c16d 100644 --- a/mg-api/src/lib.rs +++ b/mg-api/src/lib.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + use oxnet::IpNet; use std::collections::HashMap; use std::net::IpAddr; @@ -25,6 +27,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (12, MULTICAST_SUPPORT), (11, PREFIX_TO_OXNET), (10, V4_OVER_V6_STATIC_ROUTES), (9, ENDPOINT_RENAME), @@ -1529,4 +1532,40 @@ pub trait MgAdminApi { rqctx: RequestContext, request: Query, ) -> Result, HttpError>; + + // MRIB: Multicast ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + /// Get imported multicast routes from `mrib_in`. + #[endpoint { method = GET, path = "/mrib/status/imported", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn get_mrib_imported( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError>; + + /// Get selected multicast routes from `mrib_loc` (RPF-validated). + #[endpoint { method = GET, path = "/mrib/status/selected", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn get_mrib_selected( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError>; + + /// Add static multicast routes. + #[endpoint { method = PUT, path = "/static/mroute", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn static_add_mcast_route( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + /// Remove static multicast routes. + #[endpoint { method = DELETE, path = "/static/mroute", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn static_remove_mcast_route( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + /// List all static multicast routes from persistence. + #[endpoint { method = GET, path = "/static/mroute", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn static_list_mcast_routes( + rqctx: RequestContext, + ) -> Result>, HttpError>; } diff --git a/mgadm/src/main.rs b/mgadm/src/main.rs index 340179f57..570995037 100644 --- a/mgadm/src/main.rs +++ b/mgadm/src/main.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + #![allow(clippy::large_enum_variant)] use anyhow::Result; @@ -14,6 +16,7 @@ use std::net::{IpAddr, SocketAddr}; mod bfd; mod bgp; +mod mrib; mod ndp; mod rib; mod static_routing; @@ -60,6 +63,10 @@ enum Commands { /// Neighbor Discovery Protocol state for BGP unnumbered #[command(subcommand)] Ndp(ndp::Commands), + + /// Multicast RIB management commands. + #[command(subcommand)] + Mrib(mrib::Commands), } fn main() -> Result<()> { @@ -91,6 +98,7 @@ async fn run() -> Result<()> { Commands::Bfd(command) => bfd::commands(command, client).await?, Commands::Rib(command) => rib::commands(command, client).await?, Commands::Ndp(command) => ndp::commands(command, client).await?, + Commands::Mrib(command) => mrib::commands(command, client).await?, } Ok(()) } diff --git a/mgadm/src/mrib.rs b/mgadm/src/mrib.rs new file mode 100644 index 000000000..a8d911720 --- /dev/null +++ b/mgadm/src/mrib.rs @@ -0,0 +1,400 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2026 Oxide Computer Company + +//! MRIB (Multicast RIB) administration commands. +//! +//! This module provides read-only inspection of multicast routing state. +//! Omicron is the source of truth for multicast group membership and +//! programs the MRIB via the mg-api. Administrative writes are not +//! exposed here to avoid conflicts with Omicron-managed state. + +use std::net::IpAddr; + +use anyhow::Result; +use clap::{Args, Subcommand}; + +use client_common::println_nopipe; +use mg_admin_client::Client; +use mg_admin_client::types::{ + MulticastRoute, MulticastRouteKey, RouteOriginFilter, +}; +use mg_api_types::mrib::DEFAULT_MULTICAST_VNI; +use mg_api_types::rdb::rib::AddressFamily; + +const DEFAULT_VNI: u32 = DEFAULT_MULTICAST_VNI; + +fn parse_route_origin(s: &str) -> Result { + match s.to_lowercase().as_str() { + "static" => Ok(RouteOriginFilter::Static), + "dynamic" => Ok(RouteOriginFilter::Dynamic), + _ => Err(format!( + "invalid origin: {s} (expected 'static' or 'dynamic')" + )), + } +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// View MRIB state. + Status(StatusCommand), +} + +#[derive(Debug, Args)] +pub struct StatusCommand { + #[command(subcommand)] + command: StatusCmd, +} + +#[derive(Subcommand, Debug)] +pub enum StatusCmd { + /// Get imported multicast routes (`mrib_in`). + /// + /// Lists all routes, or gets a specific route with `-g`. + /// + /// Usage: `mrib status imported [ipv4|ipv6] [-g group] [-s source] [-v vni]` + Imported { + /// Address family to filter by. + #[arg(value_enum)] + address_family: Option, + + /// Multicast group address (if omitted, lists all routes). + #[arg(short, long)] + group: Option, + + /// Source address (omit for any-source (*,G)). + #[arg(short, long)] + source: Option, + + /// VNI (defaults to DEFAULT_MULTICAST_VNI for fleet-scoped multicast). + #[arg(short, long, default_value_t = DEFAULT_VNI, value_parser = clap::value_parser!(u32).range(0..=(mg_api_types::mrib::MAX_VNI as i64)))] + vni: u32, + + /// Filter by route origin ("static" or "dynamic"). + #[arg(long, value_parser = parse_route_origin)] + origin: Option, + }, + + /// Get selected multicast routes (`mrib_loc`, RPF-validated). + /// + /// Lists all routes, or gets a specific route with `-g`. + /// + /// Usage: `mrib status selected [ipv4|ipv6] [-g group] [-s source] [-v vni]` + Selected { + /// Address family to filter by. + #[arg(value_enum)] + address_family: Option, + + /// Multicast group address (if omitted, lists all routes). + #[arg(short, long)] + group: Option, + + /// Source address (omit for any-source (*,G)). + #[arg(short, long)] + source: Option, + + /// VNI (defaults to DEFAULT_MULTICAST_VNI for fleet-scoped multicast). + #[arg(short, long, default_value_t = DEFAULT_VNI, value_parser = clap::value_parser!(u32).range(0..=(mg_api_types::mrib::MAX_VNI as i64)))] + vni: u32, + + /// Filter by route origin ("static" or "dynamic"). + #[arg(long, value_parser = parse_route_origin)] + origin: Option, + }, +} + +pub async fn commands(command: Commands, c: Client) -> Result<()> { + match command { + Commands::Status(status_cmd) => match status_cmd.command { + StatusCmd::Imported { + group, + source, + vni, + address_family, + origin, + } => { + if let Some(g) = group { + get_route(c, g, source, vni).await? + } else { + get_imported(c, address_family, origin).await? + } + } + StatusCmd::Selected { + group, + source, + vni, + address_family, + origin, + } => { + if let Some(g) = group { + get_route_selected(c, g, source, vni).await? + } else { + get_selected(c, address_family, origin).await? + } + } + }, + } + Ok(()) +} + +async fn get_imported( + c: Client, + address_family: Option, + origin: Option, +) -> Result<()> { + let routes = c + .get_mrib_imported(address_family.as_ref(), None, origin, None, None) + .await? + .into_inner(); + print_routes(&routes); + Ok(()) +} + +async fn get_selected( + c: Client, + address_family: Option, + origin: Option, +) -> Result<()> { + let routes = c + .get_mrib_selected(address_family.as_ref(), None, origin, None, None) + .await? + .into_inner(); + print_routes(&routes); + Ok(()) +} + +async fn get_route( + c: Client, + group: IpAddr, + source: Option, + vni: u32, +) -> Result<()> { + let routes = c + .get_mrib_imported(None, Some(&group), None, source.as_ref(), Some(vni)) + .await? + .into_inner(); + if let Some(route) = routes.first() { + println_nopipe!("{route:#?}"); + } else { + anyhow::bail!("route not found"); + } + Ok(()) +} + +async fn get_route_selected( + c: Client, + group: IpAddr, + source: Option, + vni: u32, +) -> Result<()> { + let routes = c + .get_mrib_selected(None, Some(&group), None, source.as_ref(), Some(vni)) + .await? + .into_inner(); + if let Some(route) = routes.first() { + println_nopipe!("{route:#?}"); + } else { + anyhow::bail!("route not found in mrib_loc"); + } + Ok(()) +} + +fn print_routes(routes: &[MulticastRoute]) { + if routes.is_empty() { + println_nopipe!("No multicast routes"); + return; + } + for route in routes { + let (source_str, group_str, vni) = match &route.key { + MulticastRouteKey::V4(k) => { + let src = k.source.map_or("*".to_string(), |s| s.to_string()); + let grp = k.group.to_string(); + (src, grp, k.vni) + } + MulticastRouteKey::V6(k) => { + let src = k.source.map_or("*".to_string(), |s| s.to_string()); + let grp = k.group.to_string(); + (src, grp, k.vni) + } + }; + println_nopipe!( + "({source_str},{group_str}) vni={vni} underlay={} rpf={:?} source={:?}", + route.underlay_group, + route.rpf_neighbor, + route.source, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use std::net::{Ipv4Addr, Ipv6Addr}; + + // Wrapper to test subcommand parsing + #[derive(Parser, Debug)] + struct TestCli { + #[command(subcommand)] + command: Commands, + } + + #[test] + fn test_status_imported_specific_route() { + let cli = TestCli::try_parse_from([ + "test", + "status", + "imported", + "-g", + "225.1.2.3", + ]) + .unwrap(); + + let Commands::Status(cmd) = cli.command; + match cmd.command { + StatusCmd::Imported { + group, source, vni, .. + } => { + assert_eq!( + group, + Some(IpAddr::V4(Ipv4Addr::new(225, 1, 2, 3))) + ); + assert_eq!(source, None); + assert_eq!(vni, DEFAULT_VNI); + } + _ => panic!("expected Imported"), + } + } + + #[test] + fn test_status_imported_specific_route_all_flags() { + let cli = TestCli::try_parse_from([ + "test", + "status", + "imported", + "-g", + "225.1.2.3", + "-s", + "10.0.0.1", + "-v", + "100", + ]) + .unwrap(); + + let Commands::Status(cmd) = cli.command; + match cmd.command { + StatusCmd::Imported { + group, source, vni, .. + } => { + assert_eq!( + group, + Some(IpAddr::V4(Ipv4Addr::new(225, 1, 2, 3))) + ); + assert_eq!( + source, + Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))) + ); + assert_eq!(vni, 100); + } + _ => panic!("expected Imported"), + } + } + + #[test] + fn test_status_selected_specific_route_ipv6() { + let cli = TestCli::try_parse_from([ + "test", + "status", + "selected", + "--group", + "ff0e::1", + "--source", + "2001:db8::1", + "--vni", + "42", + ]) + .unwrap(); + + let Commands::Status(cmd) = cli.command; + match cmd.command { + StatusCmd::Selected { + group, source, vni, .. + } => { + assert_eq!( + group, + Some(IpAddr::V6(Ipv6Addr::new( + 0xff0e, 0, 0, 0, 0, 0, 0, 1 + ))) + ); + assert_eq!( + source, + Some(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1 + ))) + ); + assert_eq!(vni, 42); + } + _ => panic!("expected Selected"), + } + } + + #[test] + fn test_status_imported_list_with_af() { + let cli = + TestCli::try_parse_from(["test", "status", "imported", "ipv4"]) + .unwrap(); + + let Commands::Status(cmd) = cli.command; + match cmd.command { + StatusCmd::Imported { + group, + address_family, + .. + } => { + assert_eq!(group, None); + assert_eq!(address_family, Some(AddressFamily::Ipv4)); + } + _ => panic!("expected Imported"), + } + } + + #[test] + fn test_status_imported_list_with_origin() { + let cli = TestCli::try_parse_from([ + "test", "status", "imported", "--origin", "dynamic", + ]) + .unwrap(); + + let Commands::Status(cmd) = cli.command; + match cmd.command { + StatusCmd::Imported { group, origin, .. } => { + assert_eq!(group, None); + assert_eq!(origin, Some(RouteOriginFilter::Dynamic)); + } + _ => panic!("expected Imported"), + } + } + + #[test] + fn test_status_selected_list_all() { + let cli = + TestCli::try_parse_from(["test", "status", "selected"]).unwrap(); + + let Commands::Status(cmd) = cli.command; + match cmd.command { + StatusCmd::Selected { + group, + address_family, + origin, + .. + } => { + assert_eq!(group, None); + assert_eq!(address_family, None); + assert_eq!(origin, None); + } + _ => panic!("expected Selected"), + } + } +} diff --git a/mgadm/src/static_routing.rs b/mgadm/src/static_routing.rs index f384bf38c..61912a4fa 100644 --- a/mgadm/src/static_routing.rs +++ b/mgadm/src/static_routing.rs @@ -2,22 +2,28 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + use anyhow::Result; use clap::{Args, Subcommand}; use client_common::println_nopipe; -use mg_admin_client::Client; +use mg_admin_client::{Client, types}; use mg_api_types::rdb::DEFAULT_RIB_PRIORITY_STATIC; use oxnet::{Ipv4Net, Ipv6Net}; use std::net::{IpAddr, Ipv6Addr}; #[derive(Subcommand, Debug)] pub enum Commands { + // Unicast static routes GetV4Routes, AddV4Route(StaticRoute4), RemoveV4Routes(StaticRoute4), GetV6Routes, AddV6Route(StaticRoute6), RemoveV6Routes(StaticRoute6), + + // Multicast static routes (read-only -> Omicron is source of truth) + GetMroutes, } #[derive(Debug, Args)] @@ -102,10 +108,39 @@ pub async fn commands(command: Commands, client: Client) -> Result<()> { }; client.static_remove_v6_route(&arg).await?; } + Commands::GetMroutes => { + let routes = client.static_list_mcast_routes().await?.into_inner(); + if routes.is_empty() { + println_nopipe!("No static multicast routes"); + } else { + print_mroutes(&routes); + } + } } Ok(()) } +fn print_mroutes(routes: &[types::MulticastRoute]) { + for route in routes { + let (source_str, group_str, vni) = match &route.key { + types::MulticastRouteKey::V4(k) => { + let src = k.source.map_or("*".to_string(), |s| s.to_string()); + let grp = k.group.to_string(); + (src, grp, k.vni) + } + types::MulticastRouteKey::V6(k) => { + let src = k.source.map_or("*".to_string(), |s| s.to_string()); + let grp = k.group.to_string(); + (src, grp, k.vni) + } + }; + println_nopipe!( + "({source_str}, {group_str}) vni={vni} underlay={}", + route.underlay_group, + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/mgd/src/admin.rs b/mgd/src/admin.rs index 3f8be363d..3dd132015 100644 --- a/mgd/src/admin.rs +++ b/mgd/src/admin.rs @@ -2,7 +2,9 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use crate::{bfd_admin, bgp_admin, rib_admin, static_admin}; +// Copyright 2026 Oxide Computer Company + +use crate::{bfd_admin, bgp_admin, mrib_admin, rib_admin, static_admin}; use bfd_admin::BfdContext; use bgp_admin::BgpContext; use camino::Utf8PathBuf; @@ -24,6 +26,9 @@ use mg_api_types::bgp::session::{ ExportedSelector, FsmHistoryRequest, FsmHistoryResponse, MessageHistoryRequest, MessageHistoryResponse, }; +use mg_api_types::mrib::{ + MribAddStaticRequest, MribDeleteStaticRequest, MribQuery, +}; use mg_api_types::ndp::{NdpInterface, NdpInterfaceSelector, NdpManagerState}; use mg_api_types::rib::{ BestpathFanoutRequest, BestpathFanoutResponse, GetRibResult, Rib, RibQuery, @@ -676,6 +681,43 @@ impl MgAdminApi for MgAdminApiImpl { ) -> Result, HttpError> { bgp_admin::get_ndp_interface_detail(ctx, request).await } + + async fn get_mrib_imported( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError> + { + mrib_admin::get_mrib_imported(rqctx, query).await + } + + async fn get_mrib_selected( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError> + { + mrib_admin::get_mrib_selected(rqctx, query).await + } + + async fn static_add_mcast_route( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + mrib_admin::static_add_mcast_route(rqctx, request).await + } + + async fn static_remove_mcast_route( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + mrib_admin::static_remove_mcast_route(rqctx, request).await + } + + async fn static_list_mcast_routes( + rqctx: RequestContext, + ) -> Result>, HttpError> + { + mrib_admin::static_list_mcast_routes(rqctx).await + } } pub fn api_description() -> ApiDescription> { diff --git a/mgd/src/error.rs b/mgd/src/error.rs index bb50cb346..7f0de8599 100644 --- a/mgd/src/error.rs +++ b/mgd/src/error.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + use crate::unnumbered_manager::{AddNeighborError, ResolveNeighborError}; use dropshot::{ClientErrorStatusCode, HttpError}; @@ -35,13 +37,21 @@ pub enum Error { impl From for HttpError { fn from(value: Error) -> Self { match value { - Error::Db(rdb::error::Error::Conflict(_)) => { - Self::for_client_error_with_status( - Some(value.to_string()), - ClientErrorStatusCode::CONFLICT, - ) - } - Error::Db(_) => Self::for_internal_error(value.to_string()), + Error::Db(ref db_err) => match db_err { + rdb::error::Error::Conflict(_) => { + Self::for_client_error_with_status( + Some(value.to_string()), + ClientErrorStatusCode::CONFLICT, + ) + } + rdb::error::Error::Validation(msg) => { + Self::for_bad_request(None, msg.clone()) + } + rdb::error::Error::NotFound(msg) => { + Self::for_not_found(None, msg.clone()) + } + _ => Self::for_internal_error(value.to_string()), + }, Error::Conflict(_) => Self::for_client_error_with_status( Some(value.to_string()), ClientErrorStatusCode::CONFLICT, diff --git a/mgd/src/main.rs b/mgd/src/main.rs index fd03886e3..571291c9d 100644 --- a/mgd/src/main.rs +++ b/mgd/src/main.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + use crate::admin::HandlerContext; use crate::bfd_admin::BfdContext; use crate::bgp_admin::BgpContext; @@ -37,6 +39,7 @@ mod bfd_admin; mod bgp_admin; mod error; mod log; +mod mrib_admin; mod oxstats; mod rib_admin; mod signal; diff --git a/mgd/src/mrib_admin.rs b/mgd/src/mrib_admin.rs new file mode 100644 index 000000000..2a2b1da0f --- /dev/null +++ b/mgd/src/mrib_admin.rs @@ -0,0 +1,151 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2026 Oxide Computer Company + +use std::sync::Arc; + +use dropshot::{ + HttpError, HttpResponseDeleted, HttpResponseOk, + HttpResponseUpdatedNoContent, RequestContext, TypedBody, +}; + +use mg_api_types::mrib::{ + MribAddStaticRequest, MribDeleteStaticRequest, MribQuery, MulticastAddr, + MulticastRoute, MulticastRouteKey, MulticastSourceProtocol, + RouteOriginFilter, +}; + +use crate::admin::HandlerContext; +use crate::error::Error; + +/// Convert [`RouteOriginFilter`] to the `static_only` parameter +/// used by [`rdb::Db::mrib_list`]. +fn origin_to_static_only(origin: Option) -> Option { + match origin { + None => None, + Some(RouteOriginFilter::Static) => Some(true), + Some(RouteOriginFilter::Dynamic) => Some(false), + } +} + +pub async fn get_mrib_imported( + rqctx: RequestContext>, + query: dropshot::Query, +) -> Result>, HttpError> { + let ctx = rqctx.context(); + let q = query.into_inner(); + + // If group is provided, look up a specific route + if let Some(group_addr) = q.group { + let group = MulticastAddr::try_from(group_addr).map_err(|e| { + HttpError::for_bad_request( + None, + format!("invalid group address: {e}"), + ) + })?; + let key = MulticastRouteKey::new(q.source, group, q.vni) + .map_err(|e| HttpError::for_bad_request(None, format!("{e}")))?; + let route = ctx.db.get_mcast_route(&key).ok_or_else(|| { + HttpError::for_not_found(None, format!("route {key} not found")) + })?; + return Ok(HttpResponseOk(vec![route])); + } + + // Otherwise, list all routes with filters + let routes = ctx.db.mrib_list( + q.address_family, + origin_to_static_only(q.route_origin), + false, // `mrib_in` + ); + Ok(HttpResponseOk(routes)) +} + +pub async fn get_mrib_selected( + rqctx: RequestContext>, + query: dropshot::Query, +) -> Result>, HttpError> { + let ctx = rqctx.context(); + let q = query.into_inner(); + + // If group is provided, look up a specific route + if let Some(group_addr) = q.group { + let group = MulticastAddr::try_from(group_addr).map_err(|e| { + HttpError::for_bad_request( + None, + format!("invalid group address: {e}"), + ) + })?; + let key = MulticastRouteKey::new(q.source, group, q.vni) + .map_err(|e| HttpError::for_bad_request(None, format!("{e}")))?; + let route = ctx.db.get_selected_mcast_route(&key).ok_or_else(|| { + HttpError::for_not_found( + None, + format!("route {key} not found in mrib_loc"), + ) + })?; + return Ok(HttpResponseOk(vec![route])); + } + + // Otherwise, list all routes with filters + let routes = ctx.db.mrib_list( + q.address_family, + origin_to_static_only(q.route_origin), + true, // `mrib_loc` + ); + Ok(HttpResponseOk(routes)) +} + +pub async fn static_add_mcast_route( + rqctx: RequestContext>, + request: TypedBody, +) -> Result { + let ctx = rqctx.context(); + let body = request.into_inner(); + + // Convert input to full `MulticastRoute` with timestamps + let routes: Vec = body + .routes + .into_iter() + .map(|input| { + MulticastRoute::new( + input.key, + input.underlay_group, + MulticastSourceProtocol::Static, + ) + }) + .collect(); + + // Validate routes before adding + for route in &routes { + route.validate().map_err(|e| { + HttpError::for_bad_request(None, format!("validation error: {e}")) + })?; + } + + ctx.db + .add_static_mcast_routes(&routes) + .map_err(Error::from)?; + Ok(HttpResponseUpdatedNoContent()) +} + +pub async fn static_remove_mcast_route( + rqctx: RequestContext>, + request: TypedBody, +) -> Result { + let ctx = rqctx.context(); + let body = request.into_inner(); + ctx.db + .remove_static_mcast_routes(&body.keys) + .map_err(Error::from)?; + Ok(HttpResponseDeleted()) +} + +pub async fn static_list_mcast_routes( + rqctx: RequestContext>, +) -> Result>, HttpError> { + let ctx = rqctx.context(); + let routes = ctx.db.get_static_mcast_routes().map_err(Error::from)?; + Ok(HttpResponseOk(routes)) +} diff --git a/openapi/mg-admin/mg-admin-11.0.0-11c725.json.gitstub b/openapi/mg-admin/mg-admin-11.0.0-11c725.json.gitstub new file mode 100644 index 000000000..ebb0b70af --- /dev/null +++ b/openapi/mg-admin/mg-admin-11.0.0-11c725.json.gitstub @@ -0,0 +1 @@ +733779d92127d59cb580974ea67cb8e77085bd10:openapi/mg-admin/mg-admin-11.0.0-11c725.json diff --git a/openapi/mg-admin/mg-admin-11.0.0-11c725.json b/openapi/mg-admin/mg-admin-12.0.0-a8ad1e.json similarity index 92% rename from openapi/mg-admin/mg-admin-11.0.0-11c725.json rename to openapi/mg-admin/mg-admin-12.0.0-a8ad1e.json index 1d08b1b15..e06993970 100644 --- a/openapi/mg-admin/mg-admin-11.0.0-11c725.json +++ b/openapi/mg-admin/mg-admin-12.0.0-a8ad1e.json @@ -6,7 +6,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "11.0.0" + "version": "12.0.0" }, "paths": { "/bfd/peers": { @@ -1234,6 +1234,158 @@ } } }, + "/mrib/status/imported": { + "get": { + "summary": "Get imported multicast routes from `mrib_in`.", + "operationId": "get_mrib_imported", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family. Only used when listing all routes.", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "group", + "description": "Multicast group address. If provided, returns a specific route. If omitted, returns all routes matching the filters.", + "schema": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "route_origin", + "description": "Filter by route origin (\"static\" or \"dynamic\"). Only used when listing all routes.", + "schema": { + "$ref": "#/components/schemas/RouteOriginFilter" + } + }, + { + "in": "query", + "name": "source", + "description": "Source address (`None` for (*,G) routes). Only used when `group` is set.", + "schema": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "vni", + "description": "VNI (defaults to the fleet-wide multicast VNI). Only used when `group` is set.", + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_MulticastRoute", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRoute" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/mrib/status/selected": { + "get": { + "summary": "Get selected multicast routes from `mrib_loc` (RPF-validated).", + "operationId": "get_mrib_selected", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family. Only used when listing all routes.", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "group", + "description": "Multicast group address. If provided, returns a specific route. If omitted, returns all routes matching the filters.", + "schema": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "route_origin", + "description": "Filter by route origin (\"static\" or \"dynamic\"). Only used when listing all routes.", + "schema": { + "$ref": "#/components/schemas/RouteOriginFilter" + } + }, + { + "in": "query", + "name": "source", + "description": "Source address (`None` for (*,G) routes). Only used when `group` is set.", + "schema": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "vni", + "description": "VNI (defaults to the fleet-wide multicast VNI). Only used when `group` is set.", + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_MulticastRoute", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRoute" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/ndp/interface": { "get": { "operationId": "get_ndp_interface_detail", @@ -1484,6 +1636,84 @@ } } }, + "/static/mroute": { + "get": { + "summary": "List all static multicast routes from persistence.", + "operationId": "static_list_mcast_routes", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_MulticastRoute", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRoute" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Add static multicast routes.", + "operationId": "static_add_mcast_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MribAddStaticRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "summary": "Remove static multicast routes.", + "operationId": "static_remove_mcast_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MribDeleteStaticRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/static/route4": { "get": { "operationId": "static_list_v4_routes", @@ -3792,6 +4022,197 @@ } ] }, + "MribAddStaticRequest": { + "description": "Request body for adding static multicast routes to the MRIB.", + "type": "object", + "properties": { + "routes": { + "description": "List of static multicast routes to add.", + "type": "array", + "items": { + "$ref": "#/components/schemas/StaticMulticastRouteInput" + } + } + }, + "required": [ + "routes" + ] + }, + "MribDeleteStaticRequest": { + "description": "Request body for deleting static multicast routes from the MRIB.", + "type": "object", + "properties": { + "keys": { + "description": "List of route keys to delete.", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRouteKey" + } + } + }, + "required": [ + "keys" + ] + }, + "MulticastRoute": { + "description": "Multicast route entry containing replication groups and metadata.", + "type": "object", + "properties": { + "created": { + "description": "Creation timestamp.", + "type": "string", + "format": "date-time" + }, + "key": { + "description": "The multicast route key (S,G) or (*,G).", + "allOf": [ + { + "$ref": "#/components/schemas/MulticastRouteKey" + } + ] + }, + "rpf_neighbor": { + "nullable": true, + "description": "Upstream neighbor selected for RPF checks.\n\nThis records one representative neighbor rather than the full ECMP set. When the unicast route has multiple equal-cost paths, any active member is valid. `None` means RPF does not apply or no active unicast path is available.\n\nDerived from the unicast RIB, never persisted. Listings of static route configuration always carry `None` here since they reflect stored configuration only.", + "type": "string", + "format": "ip" + }, + "source": { + "description": "Route source (static, IGMP, etc.).", + "allOf": [ + { + "$ref": "#/components/schemas/MulticastSourceProtocol" + } + ] + }, + "underlay_group": { + "description": "Underlay multicast group address (ff04::/64).\n\nOverlay multicast addresses are mapped 1:1 to admin-local scope underlay addresses. Switches replicate to this address via the PRE (with tofino_asic).\n\nOPTE handles the overlay/underlay translation at sled boundaries, while sled membership is managed by Omicron and programmed to DPD/OPTE directly.", + "type": "string", + "format": "ipv6" + }, + "updated": { + "description": "Last updated timestamp.\n\nOnly updated when route fields change semantically (rpf_neighbor, underlay_group, source). An idempotent upsert with an identical value does not update this timestamp.", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "created", + "key", + "source", + "underlay_group", + "updated" + ] + }, + "MulticastRouteKey": { + "description": "Multicast route key: (Source, Group) pair for source-specific multicast, or (*, Group) for any-source multicast.\n\nUses type-enforced address family matching: IPv4 sources can only be paired with IPv4 groups, and IPv6 sources with IPv6 groups.", + "oneOf": [ + { + "type": "object", + "properties": { + "V4": { + "$ref": "#/components/schemas/MulticastRouteKeyV4" + } + }, + "required": [ + "V4" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "V6": { + "$ref": "#/components/schemas/MulticastRouteKeyV6" + } + }, + "required": [ + "V6" + ], + "additionalProperties": false + } + ] + }, + "MulticastRouteKeyV4": { + "description": "IPv4 multicast route key with type-enforced address family matching.", + "type": "object", + "properties": { + "group": { + "description": "Multicast group address.", + "type": "string", + "format": "ipv4" + }, + "source": { + "nullable": true, + "description": "Source address (`None` for (*,G) routes).", + "type": "string", + "format": "ipv4" + }, + "vni": { + "description": "VNI (Virtual Network Identifier).", + "default": 77, + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "group" + ] + }, + "MulticastRouteKeyV6": { + "description": "IPv6 multicast route key with type-enforced address family matching.", + "type": "object", + "properties": { + "group": { + "description": "Multicast group address.", + "type": "string", + "format": "ipv6" + }, + "source": { + "nullable": true, + "description": "Source address (`None` for (*,G) routes).", + "type": "string", + "format": "ipv6" + }, + "vni": { + "description": "VNI (Virtual Network Identifier).", + "default": 77, + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "group" + ] + }, + "MulticastSourceProtocol": { + "description": "Source of a multicast route entry.", + "oneOf": [ + { + "description": "Static route configured via API.", + "type": "string", + "enum": [ + "Static" + ] + }, + { + "description": "Learned via IGMP snooping (future).", + "type": "string", + "enum": [ + "Igmp" + ] + }, + { + "description": "Learned via MLD snooping (future).", + "type": "string", + "enum": [ + "Mld" + ] + } + ] + }, "NdpInterface": { "description": "NDP state for an interface", "type": "object", @@ -5265,6 +5686,29 @@ "code" ] }, + "StaticMulticastRouteInput": { + "description": "Input for adding static multicast routes.", + "type": "object", + "properties": { + "key": { + "description": "The multicast route key (S,G) or (*,G).", + "allOf": [ + { + "$ref": "#/components/schemas/MulticastRouteKey" + } + ] + }, + "underlay_group": { + "description": "Underlay multicast group address (ff04::/64).", + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "key", + "underlay_group" + ] + }, "StaticRoute4": { "type": "object", "properties": { @@ -5799,6 +6243,25 @@ } ] }, + "RouteOriginFilter": { + "description": "Filter for multicast route origin.", + "oneOf": [ + { + "description": "Static routes only (operator configured).", + "type": "string", + "enum": [ + "static" + ] + }, + { + "description": "Dynamic routes only (learned via IGMP, MLD, etc.).", + "type": "string", + "enum": [ + "dynamic" + ] + } + ] + }, "ProtocolFilter": { "oneOf": [ { diff --git a/openapi/mg-admin/mg-admin-latest.json b/openapi/mg-admin/mg-admin-latest.json index bd2cf73be..0fa590d7b 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-11.0.0-11c725.json \ No newline at end of file +mg-admin-12.0.0-a8ad1e.json \ No newline at end of file diff --git a/rdb/Cargo.toml b/rdb/Cargo.toml index 1ae492bf4..0782eeb56 100644 --- a/rdb/Cargo.toml +++ b/rdb/Cargo.toml @@ -19,9 +19,11 @@ chrono.workspace = true clap = { workspace = true, optional = true } oxnet.workspace = true mg-api-types = { workspace = true, features = ["clap"] } +poptrie.workspace = true ndp.workspace = true [dev-dependencies] +mg-api-types = { workspace = true, features = ["clap", "proptest"] } proptest.workspace = true mg-api-types-versions.workspace = true diff --git a/rdb/src/db.rs b/rdb/src/db.rs index 1370d032b..180059a1f 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + //! The routing database (rdb). //! //! This is the maghemite routing database. The routing database holds both @@ -9,9 +11,27 @@ //! in a sled key-value store that is persisted to disk via flush operations. //! Volatile information is stored in in-memory data structures such as hash //! sets. +//! +//! ## Lock Ordering +//! +//! The Db struct contains multiple locks, which we acquire +//! in this ordering: +//! +//! 1. Unicast RIB locks (`rib4_in`, `rib4_loc`, `rib6_in`, `rib6_loc`) +//! 2. MRIB locks (see [`mrib`] module: `mrib_in` → `mrib_loc` → `watchers`) +//! 3. RpfTable poptrie caches (`cache_v4`, `cache_v6`) +//! +//! **RPF lookup exception**: RPF lookups (`mrib::rpf::RpfTable::lookup`) hold +//! at most one lock at a time. They first try the poptrie cache (read lock), +//! release it, then fall back to linear scan (RIB lock) if needed. This avoids +//! deadlocks since no path holds cache + RIB locks simultaneously. + use crate::bestpath::bestpaths; use crate::error::Error; use crate::log::rdb_log; +use crate::mrib; +use crate::mrib::rpf::RpfTable; +use crate::mrib::{Mrib, spawn_rpf_revalidator}; use crate::types::*; use chrono::Utc; use mg_api_types::bfd::BfdPeerConfig; @@ -23,12 +43,12 @@ use mg_api_types::rdb::router::BgpRouterInfo; use mg_common::{lock, read_lock, write_lock}; use oxnet::{IpNet, Ipv4Net, Ipv6Net}; use sled::Tree; -use slog::{Logger, error}; +use slog::{Logger, debug, error}; use std::cmp::Ordering as CmpOrdering; use std::collections::{BTreeMap, BTreeSet}; use std::net::{IpAddr, Ipv6Addr}; use std::num::NonZeroU8; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::mpsc::Sender; use std::sync::{Arc, Mutex, RwLock}; use std::thread::{sleep, spawn}; @@ -66,6 +86,10 @@ const STATIC4_ROUTES: &str = "static4_routes"; /// The handle used to open a persistent key-value tree for IPv6 static routes. const STATIC6_ROUTES: &str = "static6_routes"; +/// The handle used to open a persistent key-value tree for multicast static +/// routes. +const STATIC_MCAST_ROUTES: &str = "static_mcast_routes"; + /// Key used in settings tree for tunnel endpoint setting const TEP_KEY: &str = "tep"; @@ -79,8 +103,28 @@ const BESTPATH_FANOUT: &str = "bestpath_fanout"; /// Default bestpath fanout value. Maximum number of ECMP paths in RIB. const DEFAULT_BESTPATH_FANOUT: u8 = 1; +/// Key used in settings tree for MRIB RPF revalidation interval. +const MRIB_RPF_REVALIDATION_INTERVAL: &str = "mrib_rpf_revalidation_interval"; + use crate::rib::{Rib, Rib4, Rib6}; +/// Cached configuration values for low-overhead reads. +/// +/// These atomic values are read frequently by hot paths (bestpath selection, +/// RPF revalidation) and cached here to avoid locking the persistent store. +#[derive(Debug, Clone)] +struct CachedConfig { + /// Bestpath fanout for ECMP. + /// + /// This controls how many equal-cost paths are selected for unicast routing + /// and considered for multicast RPF lookup. + bestpath_fanout: Arc, + + /// Periodic RPF revalidation sweep interval in milliseconds. + /// 0 means use [`mrib::DEFAULT_REVALIDATION_INTERVAL`]. + rpf_revalidation_interval_ms: Arc, +} + /// The central routing information base. Both persistent an volatile route /// information is managed through this structure. #[derive(Clone)] @@ -104,6 +148,17 @@ pub struct Db { /// added to the lower half forwarding plane. rib6_loc: Arc>, + /// Multicast routing information base (MRIB). + mrib: Mrib, + + /// [RPF] (Reverse Path Forwarding) table for multicast route verification. + /// + /// [RPF]: https://datatracker.ietf.org/doc/html/rfc5110 + rpf_table: RpfTable, + + /// Cached configuration for low-overhead reads on (possibly) hot paths. + config: CachedConfig, + /// A generation number for the overall data store. generation: Arc, @@ -119,6 +174,7 @@ pub struct Db { log: Logger, } + unsafe impl Sync for Db {} unsafe impl Send for Db {} @@ -155,23 +211,72 @@ struct Watcher { sender: Sender, } -//TODO we need bulk operations with atomic semantics here. +// TODO we need bulk operations with atomic semantics here. impl Db { /// Create a new routing database that stores persistent data at `path`. pub fn new(path: &str, log: Logger) -> Result { let rib_loc = Arc::new(Mutex::new(Rib::new())); - Ok(Self { - persistent: sled::open(path)?, + let persistent = sled::open(path)?; + + let config = CachedConfig { + bestpath_fanout: Arc::new(AtomicU8::new(DEFAULT_BESTPATH_FANOUT)), + rpf_revalidation_interval_ms: Arc::new(AtomicU64::new( + u64::try_from(mrib::DEFAULT_REVALIDATION_INTERVAL.as_millis()) + .unwrap(), + )), + }; + + let db = Self { + persistent, rib4_in: Arc::new(Mutex::new(BTreeMap::new())), rib4_loc: Arc::new(Mutex::new(BTreeMap::new())), rib6_in: Arc::new(Mutex::new(BTreeMap::new())), rib6_loc: Arc::new(Mutex::new(BTreeMap::new())), + mrib: Mrib::new(log.clone()), + rpf_table: RpfTable::new(log.clone()), + config, generation: Arc::new(AtomicU64::new(0)), watchers: Arc::new(RwLock::new(Vec::new())), reaper: Reaper::new(rib_loc), slot: Arc::new(RwLock::new(None)), log, - }) + }; + + // Load persisted static multicast routes into `mrib_in` + db.load_mcast_static_routes(); + + // Load bestpath fanout from settings. + let fanout = db.get_bestpath_fanout().unwrap_or_else(|e| { + error!(db.log, "failed to load bestpath_fanout from settings: {e}"); + NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() + }); + db.config + .bestpath_fanout + .store(fanout.get(), Ordering::Relaxed); + + // Load RPF revalidation interval from settings. + let revalidation_interval = + db.get_mrib_rpf_revalidation_interval().unwrap_or_else(|e| { + error!( + db.log, + "failed to load mrib_rpf_revalidation_interval \ + from settings: {e}" + ); + mrib::DEFAULT_REVALIDATION_INTERVAL + }); + + db.config.rpf_revalidation_interval_ms.store( + u64::try_from(revalidation_interval.as_millis()).unwrap(), + Ordering::Relaxed, + ); + + // Start RPF revalidator to handle unicast route changes. + // When the poptrie cache rebuilds, revalidate all (S,G) multicast routes. + if let Some(tx) = spawn_rpf_revalidator(db.clone()) { + db.rpf_table.set_rebuild_notifier(tx); + } + + Ok(db) } pub fn set_reaper_interval(&self, interval: std::time::Duration) { @@ -182,12 +287,112 @@ impl Db { *lock!(self.reaper.stale_max) = stale_max; } + pub fn slot(&self) -> Option { + match self.slot.read() { + Ok(v) => *v, + Err(e) => { + error!(self.log, "unable to read switch slot"; "error" => %e); + None + } + } + } + + pub fn set_slot(&mut self, slot: Option) { + let mut value = self.slot.write().unwrap(); + *value = slot; + } + + // ------------------------------------------------------------------------ + // MRIB / RPF revalidator gettings/setters + // ------------------------------------------------------------------------ + + /// Set the interval for periodic RPF revalidation sweeps. + /// + /// This controls how often the revalidator thread walks the MRIB to + /// re-check (S,G) routes even without explicit unicast RIB changes. + /// + /// This is persisted to the settings tree. + pub fn set_mrib_rpf_revalidation_interval( + &self, + interval: std::time::Duration, + ) -> Result<(), Error> { + let tree = self.persistent.open_tree(SETTINGS)?; + let interval_ms = u64::try_from(interval.as_millis()).unwrap(); + tree.insert( + MRIB_RPF_REVALIDATION_INTERVAL, + &interval_ms.to_be_bytes(), + )?; + tree.flush()?; + self.config + .rpf_revalidation_interval_ms + .store(interval_ms, Ordering::Relaxed); + Ok(()) + } + + /// Get the RPF revalidation sweep interval from the persistent store. + pub fn get_mrib_rpf_revalidation_interval( + &self, + ) -> Result { + let tree = self.persistent.open_tree(SETTINGS)?; + let interval_ms = match tree.get(MRIB_RPF_REVALIDATION_INTERVAL)? { + None => { + u64::try_from(mrib::DEFAULT_REVALIDATION_INTERVAL.as_millis()) + .unwrap() + } + Some(value) => { + let bytes: [u8; 8] = (*value).try_into().map_err(|_| { + Error::DbValue(format!( + "invalid mrib_rpf_revalidation_interval \ + value in db: expected 8 bytes, found {}", + value.len() + )) + })?; + u64::from_be_bytes(bytes) + } + }; + Ok(std::time::Duration::from_millis(interval_ms)) + } + + /// Get the RPF revalidation sweep interval (atomic, for the + /// revalidator thread). + pub fn get_mrib_rpf_revalidation_interval_ms(&self) -> Arc { + Arc::clone(&self.config.rpf_revalidation_interval_ms) + } + + /// Get the IPv4 loc-rib mutex (for revalidation). + pub fn rib4_loc(&self) -> Arc> { + Arc::clone(&self.rib4_loc) + } + + /// Get the IPv6 loc-rib mutex (for revalidation). + pub fn rib6_loc(&self) -> Arc> { + Arc::clone(&self.rib6_loc) + } + + /// Get the bestpath fanout atomic (for revalidation). + pub fn bestpath_fanout_atomic(&self) -> Arc { + Arc::clone(&self.config.bestpath_fanout) + } + + pub fn mrib(&self) -> &Mrib { + &self.mrib + } + + pub fn log(&self) -> &Logger { + &self.log + } + /// Register a routing databse watcher. pub fn watch(&self, tag: String, sender: Sender) { write_lock!(self.watchers).push(Watcher { tag, sender }); } fn notify(&self, n: PrefixChangeNotification) { + // The RPF caches are derived from the loc-RIB, so they follow the + // same change notifications as external watchers. Centralizing the + // trigger here means any batch that ends with a notification keeps + // the caches maintained without a separate caller obligation. + self.trigger_rpf_rebuild(n.changed.iter()); for Watcher { tag, sender } in read_lock!(self.watchers).iter() { if let Err(e) = sender.send(n.clone()) { rdb_log!( @@ -695,26 +900,23 @@ impl Db { } } - pub fn update_rib4_loc( + /// Re-run bestpath selection for a prefix, updating `rib_loc`. + /// + /// This helper does not trigger RPF cache maintenance. The enclosing + /// batch operation covers it by notifying watchers via [`Self::notify`] + /// after releasing the RIB locks. + fn update_rib4_loc( &self, rib_in: &Rib4, rib_loc: &mut Rib4, prefix: &Ipv4Net, ) { - let fanout = self.get_bestpath_fanout().unwrap_or_else(|e| { - rdb_log!( - self, - error, - "failed to get bestpath fanout: {e}"; - "unit" => UNIT_PERSISTENT - ); - NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() - }); + let fanout = self.config.bestpath_fanout.load(Ordering::Relaxed); match rib_in.get(prefix) { // rib-in has paths worth evaluating for loc-rib Some(paths) => { - match bestpaths(paths, fanout.get().into()) { + match bestpaths(paths, fanout as usize) { // bestpath found at least 1 path for loc-rib Some(bp) => { rib_loc.insert(*prefix, bp.clone()); @@ -732,26 +934,23 @@ impl Db { } } - pub fn update_rib6_loc( + /// Re-run bestpath selection for a prefix, updating `rib_loc`. + /// + /// This helper does not trigger RPF cache maintenance. The enclosing + /// batch operation covers it by notifying watchers via [`Self::notify`] + /// after releasing the RIB locks. + fn update_rib6_loc( &self, rib_in: &Rib6, rib_loc: &mut Rib6, prefix: &Ipv6Net, ) { - let fanout = self.get_bestpath_fanout().unwrap_or_else(|e| { - rdb_log!( - self, - error, - "failed to get bestpath fanout: {e}"; - "unit" => UNIT_PERSISTENT - ); - NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() - }); + let fanout = self.config.bestpath_fanout.load(Ordering::Relaxed); match rib_in.get(prefix) { // rib-in has paths worth evaluating for loc-rib Some(paths) => { - match bestpaths(paths, fanout.get().into()) { + match bestpaths(paths, fanout as usize) { // bestpath found at least 1 path for loc-rib Some(bp) => { rib_loc.insert(*prefix, bp.clone()); @@ -769,6 +968,47 @@ impl Db { } } + /// Trigger RPF cache rebuilds for a batch of changed prefixes. + /// + /// Folds the batch per address family: a single changed prefix keeps + /// targeted revalidation, multiple distinct prefixes widen to a full + /// sweep. Invoked from [`Self::notify`] for batches that produce a + /// change notification. Mutation paths that bypass notification must + /// call it directly, after the RIB locks are released. + fn trigger_rpf_rebuild<'a, I>(&self, changed: I) + where + I: IntoIterator, + { + let mut v4: Option> = None; + let mut v6: Option> = None; + for prefix in changed { + match prefix { + IpNet::V4(p) => { + v4 = match v4 { + None => Some(Some(*p)), + Some(Some(prev)) if prev == *p => Some(Some(prev)), + _ => Some(None), + } + } + IpNet::V6(p) => { + v6 = match v6 { + None => Some(Some(*p)), + Some(Some(prev)) if prev == *p => Some(Some(prev)), + _ => Some(None), + } + } + } + } + if let Some(prefix) = v4 { + self.rpf_table + .trigger_rebuild_v4(Arc::clone(&self.rib4_loc), prefix); + } + if let Some(prefix) = v6 { + self.rpf_table + .trigger_rebuild_v6(Arc::clone(&self.rib6_loc), prefix); + } + } + // generic helper function to kick off a bestpath run for some // subset of prefixes in rib_in. the caller chooses which prefixes // bestpath is run against via the bestpath_needed closure @@ -787,6 +1027,8 @@ impl Db { NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() }); + let mut changed: BTreeSet = BTreeSet::new(); + { // only grab the lock once, release it once the loop ends let rib4_in = lock!(self.rib4_in); @@ -799,6 +1041,7 @@ impl Db { &mut rib4_loc, fanout.get().into(), ); + changed.insert(IpNet::from(*prefix)); } } } @@ -815,9 +1058,14 @@ impl Db { &mut rib6_loc, fanout.get().into(), ); + changed.insert(IpNet::from(*prefix)); } } } + + // Bestpath re-runs (e.g., on fanout changes) mutate the loc-RIB, + // so the RPF caches must rebuild as well. + self.trigger_rpf_rebuild(changed.iter()); } fn update_rib_loc( @@ -872,7 +1120,10 @@ impl Db { self.update_rib6_loc(rib_in, rib_loc, p6); } - pub fn add_prefix_path(&self, prefix: &IpNet, path: &Path) { + /// Insert a path for a prefix into the RIB and refresh the loc-RIB + /// entry. RPF cache maintenance rides on the batch's change + /// notification via [`Self::notify`]. + fn add_prefix_path(&self, prefix: &IpNet, path: &Path) { match prefix { IpNet::V4(p4) => { let mut rib_in = lock!(self.rib4_in); @@ -1212,7 +1463,7 @@ impl Db { } } - pub fn remove_path_for_prefixes(&self, prefixes: &[IpNet], prefix_cmp: F) + fn remove_path_for_prefixes(&self, prefixes: &[IpNet], prefix_cmp: F) where F: Fn(&Path) -> bool, { @@ -1398,67 +1649,426 @@ impl Db { let tree = self.persistent.open_tree(SETTINGS)?; tree.insert(BESTPATH_FANOUT, &[fanout.get()])?; tree.flush()?; + + // Update cached atomic for RPF revalidator + self.config + .bestpath_fanout + .store(fanout.get(), Ordering::Relaxed); + self.trigger_bestpath_when(|_pfx, _paths| true); Ok(()) } pub fn mark_bgp_peer_stale4(&self, peer: PeerId) { - let mut rib = lock!(self.rib4_loc); - rib.iter_mut().for_each(|(_prefix, path)| { - let targets: Vec = path - .iter() - .filter_map(|p| { - if let Some(bgp) = p.bgp.as_ref() - && bgp.peer == peer - { - let mut marked = p.clone(); - marked.bgp = Some(bgp.as_stale()); - return Some(marked); - } - None - }) - .collect(); - for t in targets.into_iter() { - path.replace(t); - } - }); + { + let mut rib = lock!(self.rib4_loc); + rib.iter_mut().for_each(|(_prefix, path)| { + let targets: Vec = path + .iter() + .filter_map(|p| { + if let Some(bgp) = p.bgp.as_ref() + && bgp.peer == peer + { + let mut marked = p.clone(); + marked.bgp = Some(bgp.as_stale()); + return Some(marked); + } + None + }) + .collect(); + for t in targets.into_iter() { + path.replace(t); + } + }); + } + + // Staleness affects bestpath selection, so RPF caches derived from + // the loc-RIB must rebuild. Any prefix may have been touched, so + // request a full sweep. + self.rpf_table + .trigger_rebuild_v4(Arc::clone(&self.rib4_loc), None); } pub fn mark_bgp_peer_stale6(&self, peer: PeerId) { - let mut rib = lock!(self.rib6_loc); - rib.iter_mut().for_each(|(_prefix, path)| { - let targets: Vec = path - .iter() - .filter_map(|p| { - if let Some(bgp) = p.bgp.as_ref() - && bgp.peer == peer + { + let mut rib = lock!(self.rib6_loc); + rib.iter_mut().for_each(|(_prefix, path)| { + let targets: Vec = path + .iter() + .filter_map(|p| { + if let Some(bgp) = p.bgp.as_ref() + && bgp.peer == peer + { + let mut marked = p.clone(); + marked.bgp = Some(bgp.as_stale()); + return Some(marked); + } + None + }) + .collect(); + for t in targets.into_iter() { + path.replace(t); + } + }); + } + // Staleness affects bestpath selection, so RPF caches derived from + // the loc-RIB must rebuild. Any prefix may have been touched, so + // request a full sweep. + self.rpf_table + .trigger_rebuild_v6(Arc::clone(&self.rib6_loc), None); + } + + // ======================================================================== + // MRIB (Multicast RIB) functionality + // ======================================================================== + + /// Update `mrib_loc` by performing RPF verification for a multicast route. + /// + /// For a route to be promoted from `mrib_in` to `mrib_loc`, it must pass + /// Reverse Path Forwarding (RPF) checks: + /// - For (*,G) routes: always promoted (no source to verify) + /// - For (S,G) routes: derive the RPF neighbor from the unicast RIB. + /// If a route to the source exists, install with the derived neighbor. + /// Otherwise, remove from `mrib_loc`. + /// + /// Both cases use atomic operations to avoid races with concurrent route + /// updates (e.g., adding replication targets). + pub fn update_mrib_loc(&self, key: &MulticastRouteKey) { + // (*,G) always installs - no RPF check needed + let Some(source) = key.source() else { + self.mrib.promote_any_source(key); + return; + }; + + // (S,G): derive rpf_neighbor from the unicast RIB. + // + // Optimistic concurrency: if a cache swap lands between the lookup and + // the apply, the revalidator pass it triggers may have already applied + // a fresher derivation that ours would override. So, we detect the swap + // via the generation check and re-derive accordingly. + // + // The bound is a latency limit on inline retries, not a convergence + // guarantee. Convergence comes from the corrective revalidation pass + // queued on exhaustion. If no revalidator is running to take that + // pass, inline derivation continues instead. + const MAX_INLINE_DERIVE_ATTEMPTS: usize = 3; + let fanout = self.config.bestpath_fanout.load(Ordering::Relaxed); + loop { + let mut converged = false; + for _ in 0..MAX_INLINE_DERIVE_ATTEMPTS { + let generation = self.rpf_table.generation(); + let rpf_neighbor = self.rpf_table.lookup( + source, + &self.rib4_loc, + &self.rib6_loc, + fanout as usize, + ); + + if rpf_neighbor.is_none() { + debug!( + self.log, + "deselecting (S,G) route: no unicast path to source"; + "key" => %key + ); + } + + // Atomically update mrib_in and mrib_loc + self.mrib.apply_rpf_result(key, rpf_neighbor); + + if self.rpf_table.generation() == generation { + converged = true; + break; + } + } + + if converged { + break; + } + + // Inline retry budget exhausted under sustained cache churn. The + // last applied derivation may be stale, so hand the key's source + // to the revalidator for an explicit corrective pass rather than + // relying on an incidental sweep. + if self.rpf_table.request_revalidation(source) { + debug!( + self.log, + "rpf derivation did not converge, queued revalidation"; + "key" => %key + ); + break; + } + } + } + + /// Revalidate (S,G) routes against the unicast RIB. + /// + /// When the unicast RIB changes, re-derive `rpf_neighbor` for affected + /// routes. If `event` is provided with a specific prefix, only routes + /// whose source falls within that prefix are revalidated (targeted + /// revalidation). Otherwise, all (S,G) routes are revalidated (full + /// sweep). + /// + /// Uses atomic operations to avoid races with concurrent route updates. + pub(crate) fn revalidate_mrib( + &self, + event: Option, + ) { + let fanout = self.bestpath_fanout_atomic().load(Ordering::Relaxed); + let rib4_loc = self.rib4_loc(); + let rib6_loc = self.rib6_loc(); + + // Get all (S,G) route keys for revalidation + let keys: Vec<_> = self + .mrib + .get_source_specific_keys() + .into_iter() + .filter_map(|key| { + let source = key.source()?; + // Targeted revalidation (skip routes not affected) + if let Some(ref evt) = event + && !evt.matches_source(source) + { + return None; + } + Some((key, source)) + }) + .collect(); + + for (key, source) in keys { + // Re-derive rpf_neighbor from current unicast RIB + let rpf_neighbor = self.rpf_table.lookup( + source, + &rib4_loc, + &rib6_loc, + fanout as usize, + ); + + if rpf_neighbor.is_none() { + debug!( + self.log, + "revalidation: deselecting (S,G) route, no unicast path"; + "key" => %key + ); + } + + // Atomically update mrib_in and mrib_loc + self.mrib.apply_rpf_result(&key, rpf_neighbor); + } + } + + /// Load persisted static multicast routes into `mrib_in` at startup. + /// + /// After loading each route, we perform RPF verification to promote + /// eligible routes to `mrib_loc`. This ensures routes are installed + /// immediately at startup rather than waiting for the next periodic sweep. + fn load_mcast_static_routes(&self) { + let tree = match self.persistent.open_tree(STATIC_MCAST_ROUTES) { + Ok(t) => t, + Err(e) => { + error!( + self.log, + "failed to open static mcast routes tree: {e}" + ); + return; + } + }; + + for result in tree.iter() { + let (_, value) = match result { + Ok(kv) => kv, + Err(e) => { + error!(self.log, "failed to read mcast route: {e}"); + continue; + } + }; + + let value = String::from_utf8_lossy(&value); + let route = match serde_json::from_str::(&value) { + Ok(r) => r, + Err(e) => { + error!(self.log, "failed to deserialize mcast route: {e}"); + continue; + } + }; + + let key = route.key; + if let Err(e) = self.mrib.add_route(route) { + error!( + self.log, + "failed to load mcast route: {e}"; + "key" => %key + ); + continue; + } + + // Perform RPF verification and promote to mrib_loc if eligible + self.update_mrib_loc(&key); + } + } + + /// Add static multicast routes to the MRIB. + /// + /// Routes are persisted to disk and added to `mrib_in`. Then + /// `update_mrib_loc` derives `rpf_neighbor` from the unicast RIB and + /// promotes routes to `mrib_loc` if a valid path exists. + /// + /// Uses upsert semantics: existing routes with the same key are updated. + /// This enables idempotent calls from Nexus RPWs. + pub fn add_static_mcast_routes( + &self, + routes: &[MulticastRoute], + ) -> Result<(), Error> { + let tree = self.persistent.open_tree(STATIC_MCAST_ROUTES)?; + + // Persist configuration only: `rpf_neighbor` is derived from the + // unicast RIB at load and revalidation time, so writing it to disk + // would only capture a stale derivation. Timestamps merge against + // the previously persisted entry so idempotent upserts do not + // rewrite them. Reads happen outside the transaction to keep the + // closure infallible. + let entries: Vec<(Vec, String)> = routes + .iter() + .map(|route| { + let key = route.key.db_key()?; + let mut normalized = route.clone(); + normalized.rpf_neighbor = None; + if let Some(prev) = tree.get(&key)?.and_then(|v| { + serde_json::from_slice::(&v).ok() + }) { + normalized.created = prev.created; + if prev.underlay_group == normalized.underlay_group + && prev.source == normalized.source { - let mut marked = p.clone(); - marked.bgp = Some(bgp.as_stale()); - return Some(marked); + normalized.updated = prev.updated; } - None - }) - .collect(); - for t in targets.into_iter() { - path.replace(t); + } + Ok((key, serde_json::to_string(&normalized)?)) + }) + .collect::>()?; + + // Durability precedes visibility: the transaction and flush complete + // before add_route publishes the change to MRIB watchers, so a + // notified consumer can never observe state that a crash would lose. + tree.transaction(|tx_db| { + for (key, value) in &entries { + tx_db.insert(key.as_slice(), value.as_str())?; } - }); + Ok(()) + })?; + + tree.flush()?; + + for route in routes { + self.mrib.add_route(route.clone())?; + } + + // Derive rpf_neighbor and promote to `mrib_loc` + for route in routes { + self.update_mrib_loc(&route.key); + } + + Ok(()) } - pub fn slot(&self) -> Option { - match self.slot.read() { - Ok(v) => *v, - Err(e) => { - error!(self.log, "unable to read switch slot"; "error" => %e); - None + /// Remove static multicast routes from the MRIB. + /// + /// Routes are removed from persistence and both `mrib_in` and `mrib_loc`. + pub fn remove_static_mcast_routes( + &self, + keys: &[MulticastRouteKey], + ) -> Result<(), Error> { + let tree = self.persistent.open_tree(STATIC_MCAST_ROUTES)?; + + let key_bytes: Vec> = keys + .iter() + .map(|key| key.db_key().map_err(Error::from)) + .collect::>()?; + + // Remove from persistence atomically first. + tree.transaction(|tx_db| { + for kb in &key_bytes { + tx_db.remove(kb.as_slice())?; } + Ok(()) + })?; + + tree.flush()?; + + for key in keys { + self.mrib.remove_route(key)?; } + Ok(()) } - pub fn set_slot(&mut self, slot: Option) { - let mut value = self.slot.write().unwrap(); - *value = slot; + /// Get all static multicast routes from persistence. + pub fn get_static_mcast_routes( + &self, + ) -> Result, Error> { + let tree = self.persistent.open_tree(STATIC_MCAST_ROUTES)?; + let mut routes = Vec::new(); + + for result in tree.iter() { + let (_, value) = result?; + let value = String::from_utf8_lossy(&value); + let route: MulticastRoute = serde_json::from_str(&value)?; + routes.push(route); + } + + Ok(routes) + } + + /// Get a specific multicast route. + pub fn get_mcast_route( + &self, + key: &MulticastRouteKey, + ) -> Option { + self.mrib.get_route(key) + } + + /// Get the full MRIB input table (all routes from all sources). + pub fn full_mrib(&self) -> crate::mrib::MribTable { + self.mrib.full_mrib() + } + + /// Get the local MRIB table (selected/installed routes). + pub fn loc_mrib(&self) -> crate::mrib::MribTable { + self.mrib.loc_mrib() + } + + /// List MRIB routes with filtering, cloning only matching entries. + /// + /// This is more efficient than `full_mrib()`/`loc_mrib()` when filtering + /// is needed, as it clones only the routes that match the filter. + /// + /// Parameters: + /// - `af`: Filter by address family (`None = all`) + /// - `static_only`: Filter by origin (`None = all`, `Some(true) = static`, + /// `Some(false) = dynamic`) + /// - `installed`: If true, query `mrib_loc`; otherwise `mrib_in` + pub fn mrib_list( + &self, + af: Option, + static_only: Option, + installed: bool, + ) -> Vec { + self.mrib.list_routes(af, static_only, installed) + } + + /// Get a specific multicast route from `mrib_loc` (selected/installed). + pub fn get_selected_mcast_route( + &self, + key: &MulticastRouteKey, + ) -> Option { + self.mrib.get_selected_route(key) + } + + /// Register a watcher for MRIB changes. + pub fn watch_mrib( + &self, + tag: String, + sender: Sender, + ) { + self.mrib.watch(tag, sender); } pub fn mark_bgp_peer_stale(&self, peer: PeerId, af: AddressFamily) { @@ -1497,43 +2107,53 @@ impl Reaper { } fn reap(self: &Arc) { - self.rib - .lock() - .unwrap() - .iter_mut() - .for_each(|(_prefix, paths)| { - paths.retain(|p| { - p.bgp - .as_ref() - .map(|b| { - b.stale - .map(|s| { - Utc::now().signed_duration_since(s) - < *lock!(self.stale_max) - }) - .unwrap_or(true) - }) - .unwrap_or(true) - }) - }); + lock!(self.rib).iter_mut().for_each(|(_prefix, paths)| { + paths.retain(|p| { + p.bgp + .as_ref() + .map(|b| { + b.stale + .map(|s| { + Utc::now().signed_duration_since(s) + < *lock!(self.stale_max) + }) + .unwrap_or(true) + }) + .unwrap_or(true) + }) + }); } } #[cfg(test)] mod test { use crate::{ - StaticRouteKey, db::Db, test::TestDb, types::Asn, types::PrefixDbKey, - types::test_helpers::path_vecs_equal, + StaticRouteKey, + db::Db, + test::{TEST_WAIT_ITERATIONS, TestDb}, + types::{ + Asn, MulticastAddr, MulticastAddrV4, MulticastAddrV6, + MulticastRoute, MulticastRouteKey, MulticastSourceProtocol, + PrefixDbKey, UnderlayMulticastIpv6, UnicastAddrV4, UnicastAddrV6, + Vni, test_helpers::path_vecs_equal, + }, }; use client_common::eprintln_nopipe; use mg_api_types::rdb::DEFAULT_RIB_PRIORITY_STATIC; use mg_api_types::rdb::path::Path; use mg_api_types::rdb::rib::AddressFamily; use mg_common::log::*; + use mg_common::test::DEFAULT_INTERVAL; + use mg_common::wait_for; use oxnet::{IpNet, Ipv4Net, Ipv6Net}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::str::FromStr; + fn test_underlay() -> UnderlayMulticastIpv6 { + UnderlayMulticastIpv6::new(Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1)) + .expect("valid test underlay address") + } + fn get_test_db() -> TestDb { let log = init_file_logger("rib.log"); crate::test::get_test_db("rib_test", log).expect("create db") @@ -1855,6 +2475,385 @@ mod test { assert!(db.loc_rib(None).is_empty()); } + #[test] + fn test_mrib_revalidation_on_rib_change() { + // Inlined helper to test revalidation for a given address family. + // `rpf_neighbor` is derived from the unicast RIB. A route is + // selected when the unicast path exists and deselected when + // a unicast path is removed + fn test_af + Copy>( + db: &Db, + s_ip: IpAddr, + prefix: P, + nexthop: IpAddr, + group: MulticastAddr, + ) { + let srk = StaticRouteKey { + prefix: prefix.into(), + nexthop, + vlan_id: None, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + }; + db.add_static_routes(&[srk]).unwrap(); + + let key = MulticastRouteKey::new( + Some(s_ip), + group, + Vni::DEFAULT_MULTICAST, + ) + .expect("AF match"); + let route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + db.add_static_mcast_routes(&[route]).unwrap(); + + // Initially should be selected + wait_for!( + db.get_selected_mcast_route(&key).is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "(S,G) was not selected initially" + ); + + // Verify `rpf_neighbor` was derived + let selected = db.get_selected_mcast_route(&key).unwrap(); + assert_eq!( + selected.rpf_neighbor, + Some(nexthop), + "rpf_neighbor should be derived from unicast RIB" + ); + + // Remove unicast route; MRIB should be de-selected + db.remove_static_routes(&[srk]).unwrap(); + + wait_for!( + db.get_selected_mcast_route(&key).is_none(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "(S,G) remained selected after unicast route removed" + ); + + // Re-add unicast route + db.add_static_routes(&[srk]).unwrap(); + + // MRIB should be selected again + wait_for!( + db.get_selected_mcast_route(&key).is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "(S,G) not re-selected after unicast route restored" + ); + + // Cleanup + db.remove_static_routes(&[srk]).unwrap(); + db.remove_static_mcast_routes(&[key]).unwrap(); + } + + let log = init_file_logger("mrib_reval.log"); + let db = + crate::test::get_test_db("mrib_reval", log).expect("create db"); + + // IPv4 + test_af( + &db, + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), + "192.0.2.0/24".parse::().unwrap(), + IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + MulticastAddr::new_v4(225, 1, 1, 1).expect("valid mcast"), + ); + + // IPv6 + test_af( + &db, + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 10)), + "2001:db8::/32".parse::().unwrap(), + IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), + MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) + .expect("valid mcast"), + ); + } + + /// Static (S,G) re-upserts arrive as fresh constructions, with no derived + /// neighbor and new timestamps. The MRIB must treat them as no-ops. The + /// persisted entry stores configuration only, so its derived neighbor stays + /// `None` and its timestamps survive the replay rather than taking the + /// request's fresh ones. + #[test] + fn test_mrib_static_upsert_preserves_canonical() { + let log = init_file_logger("mrib_upsert_canonical.log"); + let db = crate::test::get_test_db("mrib_upsert_canonical", log) + .expect("create db"); + + let nexthop = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + let srk = StaticRouteKey { + prefix: "192.0.2.0/24".parse::().unwrap().into(), + nexthop, + vlan_id: None, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + }; + db.add_static_routes(&[srk]).unwrap(); + + let key = MulticastRouteKey::new( + Some(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10))), + MulticastAddr::new_v4(225, 1, 1, 2).expect("valid mcast"), + Vni::DEFAULT_MULTICAST, + ) + .expect("AF match"); + + let route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + db.add_static_mcast_routes(&[route]).unwrap(); + + wait_for!( + db.get_selected_mcast_route(&key).is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "(S,G) was not selected" + ); + + let before = db.get_mcast_route(&key).expect("route in mrib_in"); + assert_eq!(before.rpf_neighbor, Some(nexthop)); + + let persisted_before = db + .get_static_mcast_routes() + .unwrap() + .into_iter() + .find(|r| r.key == key) + .expect("route persisted"); + + let replay = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + db.add_static_mcast_routes(&[replay]).unwrap(); + + let after = db.get_mcast_route(&key).expect("route in mrib_in"); + assert_eq!( + after.rpf_neighbor, + Some(nexthop), + "idempotent upsert must not clobber the derived neighbor" + ); + assert_eq!(after.created, before.created); + assert_eq!( + after.updated, before.updated, + "idempotent upsert must not bump updated" + ); + + let persisted = db + .get_static_mcast_routes() + .unwrap() + .into_iter() + .find(|r| r.key == key) + .expect("route persisted"); + + assert_eq!( + persisted.rpf_neighbor, None, + "persistence stores configuration only, not derived state" + ); + assert_eq!(persisted.created, persisted_before.created); + assert_eq!( + persisted.updated, persisted_before.updated, + "idempotent replay must not rewrite persisted timestamps" + ); + + db.remove_static_routes(&[srk]).unwrap(); + db.remove_static_mcast_routes(&[key]).unwrap(); + } + + /// Test (*,G) vs (S,G) selection behavior. + /// + /// - (*,G) routes are always selected (no RPF check needed) + /// - (S,G) routes require a unicast route to the source for RPF + #[test] + fn test_mrib_any_source_vs_source_specific() { + let log = init_file_logger("mrib_asm_ssm.log"); + let db = + crate::test::get_test_db("mrib_asm_ssm", log).expect("create db"); + + // Case: (*,G) with ASM address goes to `mrib_loc` immediately + // (no unicast route needed) + let asm_group = + MulticastAddr::new_v4(225, 5, 5, 5).expect("valid mcast"); + let star_g_key = MulticastRouteKey::any_source(asm_group); + let star_g_route = MulticastRoute::new( + star_g_key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + db.add_static_mcast_routes(&[star_g_route]).unwrap(); + + // (*,G) should be in both `mrib_in` AND `mrib_loc` immediately + wait_for!( + db.get_selected_mcast_route(&star_g_key).is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "(*,G) should be in mrib_loc immediately" + ); + assert!( + db.get_mcast_route(&star_g_key).is_some(), + "(*,G) should also be in mrib_in" + ); + + // Case: (S,G) with SSM address (232.x) - requires unicast route + let ssm_group = MulticastAddrV4::new(Ipv4Addr::new(232, 1, 1, 1)) + .expect("valid mcast"); // SSM range + let source = UnicastAddrV4::new(Ipv4Addr::new(10, 0, 0, 100)) + .expect("valid unicast"); + let sg_key = MulticastRouteKey::source_specific_v4(source, ssm_group); + let sg_route = MulticastRoute::new( + sg_key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + db.add_static_mcast_routes(&[sg_route]).unwrap(); + + // (S,G) should be in `mrib_in` but NOT in `mrib_loc` yet + assert!( + db.get_mcast_route(&sg_key).is_some(), + "(S,G) should be in mrib_in" + ); + assert!( + db.get_selected_mcast_route(&sg_key).is_none(), + "(S,G) should NOT be in mrib_loc without unicast route" + ); + + // Add unicast route to source, now (S,G) should be selected + let srk = StaticRouteKey { + prefix: "10.0.0.0/24".parse::().unwrap().into(), + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + vlan_id: None, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + }; + db.add_static_routes(&[srk]).unwrap(); + + wait_for!( + db.get_selected_mcast_route(&sg_key).is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "(S,G) should be selected after adding unicast route" + ); + + // Case: IPv6 (*,G) with global scope - goes to `mrib_loc` immediately + let v6_group = + MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 0x5555]) + .expect("valid mcast"); + let v6_star_g_key = MulticastRouteKey::any_source(v6_group); + let v6_star_g_route = MulticastRoute::new( + v6_star_g_key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + db.add_static_mcast_routes(&[v6_star_g_route]).unwrap(); + + wait_for!( + db.get_selected_mcast_route(&v6_star_g_key).is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "IPv6 (*,G) should be selected immediately" + ); + + // Case: IPv6 (S,G) with SSM address (ff3e::) + let v6_ssm_group = MulticastAddrV6::new(Ipv6Addr::new( + 0xff3e, 0, 0, 0, 0, 0, 0, 0x1234, + )) + .expect("valid mcast"); + let v6_source = UnicastAddrV6::new(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x100, + )) + .expect("valid unicast"); + let v6_sg_key = + MulticastRouteKey::source_specific_v6(v6_source, v6_ssm_group); + let v6_sg_route = MulticastRoute::new( + v6_sg_key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + db.add_static_mcast_routes(&[v6_sg_route]).unwrap(); + + // IPv6 (S,G) should be in `mrib_in` but NOT in `mrib_loc` yet + // (operations are synchronous, no sleep needed) + assert!( + db.get_mcast_route(&v6_sg_key).is_some(), + "IPv6 (S,G) should be in mrib_in" + ); + assert!( + db.get_selected_mcast_route(&v6_sg_key).is_none(), + "IPv6 (S,G) should NOT be in mrib_loc without unicast route" + ); + + // Add unicast route + let v6_srk = StaticRouteKey { + prefix: "2001:db8::/32".parse::().unwrap().into(), + nexthop: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), + vlan_id: None, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + }; + db.add_static_routes(&[v6_srk]).unwrap(); + + wait_for!( + db.get_selected_mcast_route(&v6_sg_key).is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "IPv6 (S,G) should be selected after adding unicast route" + ); + + // Cleanup + db.remove_static_routes(&[srk, v6_srk]).unwrap(); + db.remove_static_mcast_routes(&[ + star_g_key, + sg_key, + v6_star_g_key, + v6_sg_key, + ]) + .unwrap(); + } + + #[test] + fn test_mrib_static_persistence() { + let db_path = "/tmp/mrib_persist_test.db"; + let _ = std::fs::remove_dir_all(db_path); + + let group = MulticastAddr::new_v4(225, 2, 2, 2).expect("valid mcast"); + let key = MulticastRouteKey::any_source(group); + let route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + // Create Db and add static multicast route + { + let log = init_file_logger("mrib_persist1.log"); + let db = Db::new(db_path, log).expect("create db"); + db.add_static_mcast_routes(std::slice::from_ref(&route)) + .expect("add static mcast route"); + assert_eq!(db.get_static_mcast_routes().unwrap().len(), 1); + assert!(db.get_mcast_route(&key).is_some()); + } + + // Reopen Db and verify route was loaded from persistence + { + let log = init_file_logger("mrib_persist2.log"); + let db = Db::new(db_path, log).expect("reopen db"); + assert_eq!(db.full_mrib().len(), 1); + assert!(db.get_mcast_route(&key).is_some()); + assert_eq!(db.get_static_mcast_routes().unwrap().len(), 1); + } + + // Cleanup + let _ = std::fs::remove_dir_all(db_path); + } + #[test] fn test_static_routing_ipv4_basic() { let db = get_test_db(); diff --git a/rdb/src/error.rs b/rdb/src/error.rs index e562a36ea..7fbadf45e 100644 --- a/rdb/src/error.rs +++ b/rdb/src/error.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("datastore error {0}")] @@ -24,4 +26,22 @@ pub enum Error { #[error("Parsing error {0}")] Parsing(String), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Validation error: {0}")] + Validation(String), +} + +impl From for Error { + fn from(value: mg_api_types::mrib::MulticastError) -> Self { + match value { + mg_api_types::mrib::MulticastError::Validation(s) => { + Self::Validation(s) + } + mg_api_types::mrib::MulticastError::Parsing(s) => Self::Parsing(s), + mg_api_types::mrib::MulticastError::DbKey(s) => Self::DbKey(s), + } + } } diff --git a/rdb/src/lib.rs b/rdb/src/lib.rs index eec67b81d..55926e5a2 100644 --- a/rdb/src/lib.rs +++ b/rdb/src/lib.rs @@ -2,11 +2,15 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + pub mod db; +pub mod mrib; pub mod rib; pub mod types; pub use db::Db; +pub use mrib::Mrib; pub use rib::{Rib, Rib4, Rib6, RibExt}; pub use types::*; pub mod bestpath; diff --git a/rdb/src/mrib/mod.rs b/rdb/src/mrib/mod.rs new file mode 100644 index 000000000..1a67a87d8 --- /dev/null +++ b/rdb/src/mrib/mod.rs @@ -0,0 +1,658 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2026 Oxide Computer Company + +//! Multicast Routing Information Base (MRIB). +//! +//! The MRIB manages in-memory multicast routing state, including: +//! - (*,G) entries (any-source multicast) +//! - (S,G) entries (source-specific multicast) +//! - Replication targets (local interfaces and remote nexthops) +//! - TODO: IGMP/MLD-learned routes (dynamic) +//! +//! ## Lock Ordering +//! +//! When acquiring multiple locks, we acquire them in this ordering: +//! 1. `mrib_in` +//! 2. `mrib_loc` +//! 3. `watchers` + +use std::collections::BTreeMap; +use std::collections::btree_map::Entry; +use std::net::IpAddr; +use std::sync::atomic::Ordering; +use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, RwLock}; +use std::thread; +use std::time::Duration; + +use chrono::Utc; +use slog::{Logger, error, info}; + +use mg_common::{lock, read_lock, write_lock}; + +use crate::error::Error; +use crate::types::{ + MribChangeNotification, MulticastAddr, MulticastRoute, MulticastRouteKey, + MulticastSourceProtocol, +}; +use mg_api_types::rdb::rib::AddressFamily; + +pub mod rpf; + +// Re-export from rpf module +pub use rpf::DEFAULT_REVALIDATION_INTERVAL; + +/// The MRIB table type: maps multicast route keys to route entries. +/// Each entry maps a [MulticastRouteKey] to a [MulticastRoute]. +pub type MribTable = BTreeMap; + +/// The Multicast Routing Information Base. +/// +/// Pure in-memory multicast routing tables, matching the unicast RIB pattern. +/// Persistence is handled by [`crate::Db`]. +/// +/// The MRIB maintains two tables: +/// - `mrib_in`: All multicast routes from all sources (static, IGMP) +/// - `mrib_loc`: Selected routes that pass Reverse Path Forwarding (RPF) +/// checks and are installed in the data plane. +/// +/// Note: `(*,G)` routes have no source address, so they always pass +/// to `mrib_loc` immediately (RPF only applies to `(S,G)` routes). +#[derive(Clone)] +pub struct Mrib { + /// All multicast routes from all sources (static, IGMP). + mrib_in: Arc>, + + /// Selected multicast routes that have passed RPF verification. + mrib_loc: Arc>, + + /// Watchers notified of MRIB changes. + watchers: Arc>>, + + log: Logger, +} + +#[derive(Clone)] +struct MribWatcher { + tag: String, + sender: Sender, +} + +impl Mrib { + pub fn new(log: Logger) -> Self { + Self { + mrib_in: Arc::new(Mutex::new(MribTable::new())), + mrib_loc: Arc::new(Mutex::new(MribTable::new())), + watchers: Arc::new(RwLock::new(Vec::new())), + log, + } + } + + /// Register a watcher for MRIB changes. + pub fn watch(&self, tag: String, sender: Sender) { + write_lock!(self.watchers).push(MribWatcher { tag, sender }); + } + + /// Remove a watcher by tag. + pub fn unwatch(&self, tag: &str) { + write_lock!(self.watchers).retain(|w| w.tag != tag); + } + + /// Notify all watchers of MRIB changes. + /// + /// Automatically removes watchers whose channels have been closed. + /// + /// This function releases the lock before sending to avoid potential + /// deadlocks if a watcher's receiver calls back into the MRIB. + fn notify(&self, n: MribChangeNotification) { + // Snapshot watchers under lock, then release before sending + let snapshot: Vec<_> = + read_lock!(self.watchers).iter().cloned().collect(); + + // Send to all watchers (lock released, no deadlock risk) + let mut dead_tags = Vec::new(); + for MribWatcher { tag, sender } in &snapshot { + if let Err(e) = sender.send(n.clone()) { + error!(self.log, "watcher '{tag}' disconnected, removing: {e}"); + dead_tags.push(tag.clone()); + } + } + + // Remove dead watchers + if !dead_tags.is_empty() { + write_lock!(self.watchers).retain(|w| !dead_tags.contains(&w.tag)); + } + } + + /// Get a copy of the full MRIB input table (all routes from all sources). + pub fn full_mrib(&self) -> MribTable { + lock!(self.mrib_in).clone() + } + + /// Get a copy of the local MRIB table (selected/installed routes). + pub fn loc_mrib(&self) -> MribTable { + lock!(self.mrib_loc).clone() + } + + /// List routes with filtering, cloning only matching entries. + /// + /// Arguments: + /// - `af`: Filter by address family (`None = all`) + /// - `static_only`: Filter by origin (`None = all`, `Some(true) = static`, + /// Some(false) = dynamic) + /// - `installed`: If true, query `mrib_loc`; otherwise `mrib_in` + pub fn list_routes( + &self, + af: Option, + static_only: Option, + installed: bool, + ) -> Vec { + let filter = |route: &&MulticastRoute| -> bool { + // Address family filter + let af_match = match af { + None => true, + Some(AddressFamily::Ipv4) => { + matches!(route.key.group(), MulticastAddr::V4(_)) + } + Some(AddressFamily::Ipv6) => { + matches!(route.key.group(), MulticastAddr::V6(_)) + } + }; + // Origin filter + let origin_match = match static_only { + None => true, + Some(true) => { + matches!(route.source, MulticastSourceProtocol::Static) + } + Some(false) => { + !matches!(route.source, MulticastSourceProtocol::Static) + } + }; + af_match && origin_match + }; + + if installed { + lock!(self.mrib_loc) + .values() + .filter(filter) + .cloned() + .collect() + } else { + lock!(self.mrib_in) + .values() + .filter(filter) + .cloned() + .collect() + } + } + + /// Get a specific multicast route from `mrib_in`. + /// + /// Returns a cloned [MulticastRoute], if present. + pub fn get_route(&self, key: &MulticastRouteKey) -> Option { + lock!(self.mrib_in).get(key).cloned() + } + + /// Get a specific multicast route from `mrib_loc` (selected/installed). + /// + /// Returns a cloned [MulticastRoute], if present. + pub fn get_selected_route( + &self, + key: &MulticastRouteKey, + ) -> Option { + lock!(self.mrib_loc).get(key).cloned() + } + + /// Atomically promote a (*,G) route from `mrib_in` to `mrib_loc`. + /// + /// (*,G) routes have no source address, so they always pass RPF checks. + /// This method atomically copies the fresh route data to `mrib_loc`, + /// avoiding races with concurrent route updates. + /// + /// Returns `true` if the route was found and promoted. + pub(crate) fn promote_any_source(&self, key: &MulticastRouteKey) -> bool { + let changed = { + let mrib_in = lock!(self.mrib_in); + let mut mrib_loc = lock!(self.mrib_loc); + + let Some(route) = mrib_in.get(key) else { + return false; + }; + + match mrib_loc.entry(*key) { + Entry::Occupied(mut e) => { + let unchanged = e.get().rpf_neighbor == route.rpf_neighbor + && e.get().underlay_group == route.underlay_group + && e.get().source == route.source; + if !unchanged { + e.insert(route.clone()); + } + !unchanged + } + Entry::Vacant(e) => { + e.insert(route.clone()); + true + } + } + }; + + if changed { + self.notify(MribChangeNotification::from(*key)); + } + true + } + + /// Apply an RPF verification result atomically for (S,G) routes. + /// + /// Updates `rpf_neighbor` in `mrib_in` (so API queries show the derived + /// neighbor) and then promotes/removes the fresh route to/from `mrib_loc`. + /// + /// By holding both locks and re-fetching from `mrib_in`, we avoid a race + /// where concurrent route updates (e.g., adding underlay nexthops) could + /// be lost if we used a stale snapshot. + /// + /// Only notifies watchers if `mrib_loc` actually changed. + pub(crate) fn apply_rpf_result( + &self, + key: &MulticastRouteKey, + neighbor: Option, + ) { + let changed = { + let mut mrib_in = lock!(self.mrib_in); + let mut mrib_loc = lock!(self.mrib_loc); + + match mrib_in.get_mut(key) { + None => { + // Route removed from mrib_in, ensure gone from mrib_loc + mrib_loc.remove(key).is_some() + } + Some(route) => { + // Update rpf_neighbor in mrib_in, bumping `updated` only + // on a semantic change per its documented contract. + if route.rpf_neighbor != neighbor { + route.rpf_neighbor = neighbor; + route.updated = Utc::now(); + } + + // Promote or remove from mrib_loc based on RPF result + if neighbor.is_some() { + match mrib_loc.entry(*key) { + Entry::Occupied(mut e) => { + let unchanged = e.get().rpf_neighbor + == route.rpf_neighbor + && e.get().underlay_group + == route.underlay_group + && e.get().source == route.source; + if !unchanged { + e.insert(route.clone()); + } + !unchanged + } + Entry::Vacant(e) => { + e.insert(route.clone()); + true + } + } + } else { + // No unicast route to source -> remove from mrib_loc + mrib_loc.remove(key).is_some() + } + } + } + }; + + if changed { + self.notify(MribChangeNotification::from(*key)); + } + } + + /// Add or update a multicast route in `mrib_in`. + /// + /// [`MulticastRoute::rpf_neighbor`] is derived from the unicast RIB + /// rather than configured, so upserts compare only the configured fields + /// ([`MulticastRoute::underlay_group`] and [`MulticastRoute::source`]) + /// and preserve the stored neighbor until revalidation recomputes it. + /// An idempotent upsert leaves the entry untouched. A change preserves + /// [`MulticastRoute::created`] and bumps [`MulticastRoute::updated`]. + /// + /// The route is added to `mrib_in` only. The caller (`Db`) is responsible + /// for calling [`crate::Db::update_mrib_loc()`] to perform RPF + /// verification and potentially promote the route to `mrib_loc`. + pub(crate) fn add_route(&self, route: MulticastRoute) -> Result<(), Error> { + let key = route.key; + let changed = { + let mut mrib_in = lock!(self.mrib_in); + match mrib_in.get(&key) { + Some(existing) => { + let changed = existing.source != route.source + || existing.underlay_group != route.underlay_group; + if changed { + let mut route = route; + route.rpf_neighbor = existing.rpf_neighbor; + route.created = existing.created; + route.updated = Utc::now(); + mrib_in.insert(key, route); + } + changed + } + None => { + mrib_in.insert(key, route); + true + } + } + }; + + if changed { + self.notify(MribChangeNotification::from(key)); + } + Ok(()) + } + + /// Remove a multicast route from both `mrib_in` and `mrib_loc`. + pub(crate) fn remove_route( + &self, + key: &MulticastRouteKey, + ) -> Result { + // Acquire both locks following documented order to ensure atomicity + let removed = { + let mut mrib_in = lock!(self.mrib_in); + let mut mrib_loc = lock!(self.mrib_loc); + let removed_in = mrib_in.remove(key).is_some(); + let removed_loc = mrib_loc.remove(key).is_some(); + removed_in || removed_loc + }; + + if removed { + self.notify(MribChangeNotification::from(*key)); + } + Ok(removed) + } + + /// Get all routes for a specific multicast group from `mrib_in`. + pub fn get_routes_for_group( + &self, + group: &MulticastAddr, + ) -> Vec { + lock!(self.mrib_in) + .values() + .filter(|route| &route.key.group() == group) + .cloned() + .collect() + } + + /// Get all routes with a specific source from `mrib_in`. + pub fn get_routes_for_source( + &self, + source: &IpAddr, + ) -> Vec { + lock!(self.mrib_in) + .values() + .filter(|route| route.key.source().as_ref() == Some(source)) + .cloned() + .collect() + } + + /// Get all any-source (*,G) routes from `mrib_in`. + pub fn get_any_source_routes(&self) -> Vec { + lock!(self.mrib_in) + .values() + .filter(|route| route.key.source().is_none()) + .cloned() + .collect() + } + + /// Get keys for all source-specific (S,G) routes. + pub fn get_source_specific_keys(&self) -> Vec { + lock!(self.mrib_in) + .keys() + .filter(|key| key.source().is_some()) + .copied() + .collect() + } +} + +/// Spawn the RPF revalidator background thread. +/// +/// Listens for RPF cache rebuild events and re-checks RPF validity for all +/// source-specific (S,G) multicast routes. Routes that pass RPF validation +/// are installed in `mrib_loc`, while routes that fail are removed. +/// +/// Returns the sender for rebuild events if spawn succeeded, `None` if failed. +/// The caller should only install the notifier in `RpfTable` if this returns +/// `Some`, ensuring the channel receiver is actually running. +pub(crate) fn spawn_rpf_revalidator( + db: crate::Db, +) -> Option> { + let err_log = db.log().clone(); + let sweep_interval_ms = db.get_mrib_rpf_revalidation_interval_ms(); + let (tx, rx) = mpsc::channel::(); + + match thread::Builder::new() + .name("rpf-revalidator".to_string()) + .spawn(move || { + loop { + let ms = sweep_interval_ms.load(Ordering::Relaxed); + let timeout = if ms == 0 { + DEFAULT_REVALIDATION_INTERVAL + } else { + Duration::from_millis(ms) + }; + + // Wait for an event or timeout + let first_event = match rx.recv_timeout(timeout) { + Ok(evt) => Some(evt), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => break, + }; + + // Drain any queued events to avoid redundant full MRIB scans + // when many events arrive in quick succession. + let mut extra_events = 0usize; + while rx.try_recv().is_ok() { + extra_events += 1; + } + + // If we coalesced multiple events, do a full sweep. + // Otherwise use the specific event for targeted revalidation. + let event = if extra_events > 0 { None } else { first_event }; + db.revalidate_mrib(event); + } + info!(db.log(), "rpf revalidator shutting down"); + }) { + Ok(_) => Some(tx), + Err(e) => { + error!(err_log, "failed to spawn rpf-revalidator: {e}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::net::{Ipv4Addr, Ipv6Addr}; + + use mg_common::log::*; + + use crate::types::{ + MulticastAddrV4, MulticastAddrV6, UnderlayMulticastIpv6, UnicastAddrV4, + UnicastAddrV6, + }; + + fn test_underlay() -> UnderlayMulticastIpv6 { + UnderlayMulticastIpv6::new(Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1)) + .expect("valid test underlay address") + } + + #[test] + fn test_mrib_basic() { + let log = init_file_logger("mrib_test.log"); + let mrib = Mrib::new(log); + + // Test ASM route (*,G) + let group = MulticastAddr::new_v4(225, 1, 1, 1).expect("valid mcast"); + let key = MulticastRouteKey::any_source(group); + let route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + mrib.add_route(route.clone()).expect("add route"); + assert!(mrib.get_route(&key).is_some()); + + // Test source-specific multicast route (S,G) + let source = UnicastAddrV4::new(Ipv4Addr::new(10, 0, 0, 1)) + .expect("valid unicast"); + let group_v4 = MulticastAddrV4::new(Ipv4Addr::new(225, 1, 1, 1)) + .expect("valid mcast"); + let key_sg = MulticastRouteKey::source_specific_v4(source, group_v4); + let route_sg = MulticastRoute::new( + key_sg, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + mrib.add_route(route_sg.clone()).expect("add S,G route"); + assert_eq!(mrib.full_mrib().len(), 2); + + // Test queries + let group_routes = mrib.get_routes_for_group(&group); + assert_eq!(group_routes.len(), 2); + + let any_source = mrib.get_any_source_routes(); + assert_eq!(any_source.len(), 1); + + let source_specific = mrib.get_source_specific_keys(); + assert_eq!(source_specific.len(), 1); + + // Test removal + mrib.remove_route(&key).expect("remove *,G route"); + assert_eq!(mrib.full_mrib().len(), 1); + assert!(mrib.get_route(&key).is_none()); + } + + #[test] + fn test_mrib_watchers() { + use std::sync::mpsc::channel; + + let log = init_file_logger("mrib_watcher_test.log"); + let mrib = Mrib::new(log); + + // Register watcher + let (tx, rx) = channel(); + mrib.watch("test-watcher".to_string(), tx); + + // Add a route and verify notification + let group = MulticastAddr::new_v4(225, 3, 3, 3).expect("valid mcast"); + let key = MulticastRouteKey::any_source(group); + let route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + mrib.add_route(route.clone()).expect("add route"); + + // Should receive notification + let notification = rx.recv().expect("receive notification"); + assert_eq!(notification.changed.len(), 1); + assert!(notification.changed.contains(&key)); + + // Remove route and verify notification + mrib.remove_route(&key).expect("remove route"); + + let notification = rx.recv().expect("receive notification"); + assert_eq!(notification.changed.len(), 1); + assert!(notification.changed.contains(&key)); + } + + #[test] + fn test_mrib_in_vs_loc() { + let log = init_file_logger("mrib_in_loc_test.log"); + let mrib = Mrib::new(log); + + // Add a (*,G) route to mrib_in only + let group = MulticastAddr::new_v4(225, 4, 4, 4).expect("valid mcast"); + let key = MulticastRouteKey::any_source(group); + let route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + mrib.add_route(route.clone()).expect("add route"); + + // Verify route exists in `mrib_in` but not in `mrib_loc` + assert_eq!(mrib.full_mrib().len(), 1); + assert_eq!(mrib.loc_mrib().len(), 0); + assert!(mrib.get_route(&key).is_some()); + assert!(mrib.get_selected_route(&key).is_none()); + + // Promote (*,G) route to `mrib_loc` + assert!(mrib.promote_any_source(&key)); + + // Now verify route exists in both tables + assert_eq!(mrib.full_mrib().len(), 1); + assert_eq!(mrib.loc_mrib().len(), 1); + assert!(mrib.get_route(&key).is_some()); + assert!(mrib.get_selected_route(&key).is_some()); + + // Remove route completely (from both tables) + mrib.remove_route(&key).expect("remove route"); + assert_eq!(mrib.full_mrib().len(), 0); + assert_eq!(mrib.loc_mrib().len(), 0); + assert!(mrib.get_route(&key).is_none()); + assert!(mrib.get_selected_route(&key).is_none()); + } + + #[test] + fn test_mrib_ipv6_groups() { + let log = init_file_logger("mrib_v6_test.log"); + let mrib = Mrib::new(log); + + // IPv6 ASM route (*,G) + let group = MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) + .expect("valid mcast"); + let key = MulticastRouteKey::any_source(group); + let route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + mrib.add_route(route.clone()).expect("add v6 route"); + assert!(mrib.get_route(&key).is_some()); + + // IPv6 source-specific multicast route (S,G) + let source = + UnicastAddrV6::new(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)) + .expect("valid unicast"); + let group_v6 = + MulticastAddrV6::new(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)) + .expect("valid mcast"); + let key_sg = MulticastRouteKey::source_specific_v6(source, group_v6); + let route_sg = MulticastRoute::new( + key_sg, + test_underlay(), + MulticastSourceProtocol::Static, + ); + + mrib.add_route(route_sg).expect("add v6 S,G route"); + assert_eq!(mrib.full_mrib().len(), 2); + + // Verify address family filtering + let v6_routes: Vec<_> = + mrib.get_routes_for_group(&group).into_iter().collect(); + assert_eq!(v6_routes.len(), 2); + + // Cleanup + mrib.remove_route(&key).expect("remove *,G"); + mrib.remove_route(&key_sg).expect("remove S,G"); + assert_eq!(mrib.full_mrib().len(), 0); + } +} diff --git a/rdb/src/mrib/rpf.rs b/rdb/src/mrib/rpf.rs new file mode 100644 index 000000000..9d0c0e94a --- /dev/null +++ b/rdb/src/mrib/rpf.rs @@ -0,0 +1,1477 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2026 Oxide Computer Company + +//! [Reverse Path Forwarding][RPF] (RPF) verification for multicast routing. +//! +//! RPF verification ensures that multicast packets arrive from the expected +//! upstream direction, preventing loops in multicast distribution trees. +//! See [RFD 488] for the overall multicast routing design. +//! +//! This module provides an optimized RPF implementation using Oxide's +//! [poptrie] implementation for O(1) longest-prefix matching (LPM), with +//! asynchronous cache rebuilds and fallback to linear scan while the cache +//! is absent. RPF lookups happen frequently during multicast route +//! installation and unicast RIB changes, requiring LPM against the unicast +//! RIB. +//! +//! ## Why a Cache +//! +//! The two sides of the cache run at different frequencies. Lookups run at +//! group-membership frequency: (S,G) installation and removal follow dynamic +//! join/leave activity, both explicit and implicit (instance lifecycle and +//! placement), so there is no quiescent period to absorb slower lookups. +//! Rebuilds run at unicast-event frequency, which is episodic and settles +//! after convergence. With the cache, lookups stay O(1) and never contend with +//! unicast RIB writers, while LPM directly against the `BTreeMap` RIB would pay +//! up to address-width ordered probes under the RIB mutex on every group event +//! and revalidation sweep. +//! +//! ## (S,G) vs (*,G) Routes +//! +//! RPF verification only applies to (S,G) routes where a specific source +//! address is known. The source address is looked up in the unicast RIB to +//! find the expected upstream neighbor(s). +//! +//! (*,G) routes have no source address to verify, so RPF is skipped entirely +//! and routes are directly "installed." +//! +//! ## Revalidator Integration +//! +//! The RPF revalidator (spawned in `db.rs`) listens for rebuild events and +//! re-checks (S,G) routes when unicast RIB changes. Lock ordering: +//! +//! 1. Revalidator reads unicast RIB (`rib4_loc`/`rib6_loc`) +//! 2. Revalidator writes MRIB (`mrib_in`/`mrib_loc`) +//! +//! This matches the lock order in `mrib/mod.rs`. RPF lookups hold at most one +//! lock at a time: they try poptrie first (read lock), release it, then fall +//! back to linear scan (RIB lock) if needed. No path holds both locks. +//! +//! ## Rebuild Worker +//! +//! A single long-lived worker thread ("rpf-rebuild") owns all poptrie +//! rebuilds. Triggers merge their request into a shared pending slot (one +//! per address family, so memory use is bounded regardless of trigger +//! volume) and wake the worker through a bounded channel. The worker waits +//! one coalescing window, takes the pending work, snapshots the RIB while +//! holding its lock (deriving the compact `RpfNexthops` payload in the same +//! pass), builds the poptrie outside the lock, installs that snapshot, and +//! notifies the revalidator. +//! +//! Because this is the only rebuild worker, snapshots are installed in +//! capture order (i.e. an older build cannot overwrite a newer one). A trigger +//! that arrives during a build remains in the pending slot, so the worker +//! takes a newer snapshot on its next pass. Installing each completed +//! snapshot lets the cache advance during sustained route churn. After +//! updates quiesce, the final pending rebuild converges to the current RIB. +//! The cache therefore provides eventual consistency rather than being +//! guaranteed fresh at every instant. +//! +//! The cached payload is `RpfNexthops`, nexthops derived at build time, +//! rather than full path sets. Poptrie clones the payload on every match, +//! so caching derived nexthops keeps lookups allocation-free and makes +//! rebuilds cheaper. +//! +//! ## Lock Poisoning +//! +//! Cache writes never panic. If the cache `RwLock` is poisoned, the writer +//! heals it (`clear_poison`) and installs the new value, since the cache is +//! replaced wholesale and cannot expose a broken invariant. Readers use +//! `.ok()` and fall back to linear scan. +//! +//! [RPF]: https://datatracker.ietf.org/doc/html/rfc5110 +//! [RFD 488]: https://rfd.shared.oxide.computer/rfd/0488 +//! [poptrie]: https://conferences.sigcomm.org/sigcomm/2015/pdf/papers/p57.pdf + +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, RwLock, mpsc}; +use std::thread; +use std::time::Duration; + +use poptrie::Poptrie; +use slog::{Logger, debug, error}; + +use mg_common::lock; + +use crate::bestpath::bestpaths; +use crate::rib::{Rib4, Rib6}; +use mg_api_types::rdb::path::Path; +use oxnet::{Ipv4Net, Ipv6Net}; + +/// Default interval for periodic RPF revalidation sweeps. +pub const DEFAULT_REVALIDATION_INTERVAL: Duration = Duration::from_secs(60); + +/// Monotonic generation number for the RPF caches. +/// +/// Modeled on `omicron_common::api::external::Generation`, used here for +/// optimistic concurrency control over RPF derivations. Purely in-process, +/// so the database-driven i64 range restriction and serialization of the +/// original do not apply. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(crate) struct Generation(u64); + +/// Shared counter producing [`Generation`] values. +/// +/// Advanced by the rebuild worker each time a cache swap makes new lookup +/// results observable. A caller that snapshots the generation before a +/// lookup and observes the same generation after applying the result knows +/// no swap (and thus no revalidator pass it could clobber) intervened. +#[derive(Default)] +pub(crate) struct GenerationCounter(AtomicU64); + +impl GenerationCounter { + pub(crate) fn current(&self) -> Generation { + Generation(self.0.load(Ordering::Acquire)) + } + + fn advance(&self) { + self.0.fetch_add(1, Ordering::Release); + } +} + +/// Event emitted when RPF revalidation is needed. +/// +/// This is emitted when a poptrie rebuild completes, or when the rebuild +/// worker is unavailable and multicast RPF revalidation should proceed +/// using the linear-scan fallback. +/// +/// The optional prefix ([`Ipv4Net`]/[`Ipv6Net`]) indicates which unicast route +/// changed, enabling targeted (S,G) revalidation. If `None`, a full sweep is +/// performed. +#[derive(Clone, Copy, Debug)] +pub(crate) enum RebuildEvent { + /// IPv4 unicast routing changed. If a prefix is provided, only (S,G) + /// routes with sources matching that prefix need revalidation. + V4(Option), + /// IPv6 unicast routing changed. If a prefix is provided, only (S,G) + /// routes with sources matching that prefix need revalidation. + V6(Option), +} + +impl RebuildEvent { + /// Check if a source address is potentially affected by this event. + /// + /// Returns true if the source falls within the changed prefix (targeted), + /// or if no specific prefix is provided (full sweep). + pub(crate) fn matches_source(&self, source: IpAddr) -> bool { + match (source, self) { + (IpAddr::V4(src), RebuildEvent::V4(Some(prefix))) => { + prefix.contains(src) + } + (IpAddr::V6(src), RebuildEvent::V6(Some(prefix))) => { + prefix.contains(src) + } + // No specific prefix = full sweep for this AF + (IpAddr::V4(_), RebuildEvent::V4(None)) => true, + (IpAddr::V6(_), RebuildEvent::V6(None)) => true, + // Wrong AF = skip + (IpAddr::V4(_), RebuildEvent::V6(_)) => false, + (IpAddr::V6(_), RebuildEvent::V4(_)) => false, + } + } +} + +/// Nexthops derived from a prefix's path set at cache-build time. +/// +/// Both fanout interpretations are precomputed because the RPF neighbor +/// depends on the fanout configured at lookup time. Caching derived +/// nexthops instead of full [`Path`] sets keeps poptrie lookups +/// allocation-free (poptrie clones the payload on every match) and makes +/// rebuilds cheaper. +#[derive(Clone, Copy, Debug)] +pub(crate) struct RpfNexthops { + /// Bestpath-selected nexthop (fanout == 1 semantics). + best: Option, + /// One representative active nexthop for fanout > 1. + /// + /// RPF records a single upstream neighbor even when the loc-RIB contains + /// multiple ECMP paths. + first_active: Option, +} + +impl RpfNexthops { + /// Derive cached nexthops from a prefix's path set. + /// + /// Delegates to [`RpfTable::get_rpf_neighbor`] for both fanout + /// interpretations so the cached results match the linear-scan + /// fallback by construction. + fn from_paths(paths: &BTreeSet) -> Self { + Self { + best: RpfTable::get_rpf_neighbor(paths, 1), + first_active: RpfTable::get_rpf_neighbor(paths, 2), + } + } + + /// Select the nexthop for the given fanout. + fn for_fanout(&self, fanout: usize) -> Option { + if fanout == 1 { + self.best + } else { + self.first_active + } + } +} + +/// Coalesced pending rebuild work, shared between trigger sites and the +/// rebuild worker. +/// +/// One slot per address family bounds memory regardless of trigger +/// volume. Each slot carries the RIB handle so the worker can snapshot at +/// build time, and the changed prefix (if any) for targeted revalidation. +#[derive(Default)] +struct Pending { + v4: Option<(Arc>, Option)>, + v6: Option<(Arc>, Option)>, +} + +impl Pending { + /// Fold an IPv4 request into the pending slot. + /// + /// Requests with distinct changed prefixes coalesce into a full sweep + /// (`None`), since a single targeted revalidation can no longer cover + /// them. + fn merge_v4(&mut self, rib: Arc>, prefix: Option) { + let prefix = match self.v4.take() { + Some((_, existing)) if existing != prefix => None, + _ => prefix, + }; + self.v4 = Some((rib, prefix)); + } + + /// Fold an IPv6 request into the pending slot. + /// + /// Requests with distinct changed prefixes coalesce into a full sweep + /// (`None`), since a single targeted revalidation can no longer cover + /// them. + fn merge_v6(&mut self, rib: Arc>, prefix: Option) { + let prefix = match self.v6.take() { + Some((_, existing)) if existing != prefix => None, + _ => prefix, + }; + self.v6 = Some((rib, prefix)); + } + + fn is_empty(&self) -> bool { + self.v4.is_none() && self.v6.is_none() + } +} + +/// Lock the shared pending slot, healing a poisoned lock. +/// +/// [`Pending`] carries no invariants across its fields, so recovering the +/// data from a poisoned lock is safe. This must not panic: it runs on +/// trigger paths. +fn lock_pending(pending: &Mutex) -> MutexGuard<'_, Pending> { + match pending.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +/// Store a cache value, healing a poisoned lock if needed. +/// +/// The cache is replaced wholesale, so poisoning cannot expose a broken +/// invariant. This must not panic: it also runs on trigger fallback paths. +fn store_cache( + cache: &RwLock>>, + value: Option>, +) { + match cache.write() { + Ok(mut guard) => *guard = value, + Err(poisoned) => { + cache.clear_poison(); + *poisoned.into_inner() = value; + } + } +} + +/// The long-lived worker that owns all poptrie rebuilds. +/// +/// A single worker serves both address families, serializing rebuilds and +/// spreading CPU load when both families change at once. The worker exits +/// when every [`RpfTable`] clone (and thus the send side of its wakeup +/// channel) has been dropped. +struct RebuildWorker { + cache_v4: Arc>>>, + cache_v6: Arc>>>, + pending: Arc>, + notifier: Arc>>>, + generation: Arc, +} + +impl RebuildWorker { + /// Fixed window for coalescing RPF rebuild requests. + /// + /// Before taking pending work, the worker waits one fixed window. + /// Requests arriving during that window merge into the pending rebuild + /// without restarting the wait, so bursty route changes pack into one + /// rebuild while sustained triggers cannot postpone rebuilding + /// indefinitely. + const COALESCE_WINDOW: Duration = Duration::from_millis(10); + + fn run(self, rx: mpsc::Receiver<()>) { + // A recv error means every sender (each `RpfTable` clone) is gone. + while rx.recv().is_ok() { + loop { + thread::sleep(Self::COALESCE_WINDOW); + // Extra wakeups drained here (or observed as an immediate + // outer recv) only cost a noop pass. + while rx.try_recv().is_ok() {} + let work = std::mem::take(&mut *lock_pending(&self.pending)); + if work.is_empty() { + break; + } + + if let Some((rib, prefix)) = work.v4 { + self.rebuild_v4(&rib, prefix); + } + if let Some((rib, prefix)) = work.v6 { + self.rebuild_v6(&rib, prefix); + } + } + } + } + + /// Rebuild the IPv4 cache once. + fn rebuild_v4( + &self, + rib: &Arc>, + changed_prefix: Option, + ) { + let snapshot = { + let r = lock!(rib); + RpfTable::snapshot_rib(&r, |p| (p.addr().octets(), p.width())) + }; + // Build the poptrie outside the RIB lock. + let mut table = poptrie::Ipv4RoutingTable::default(); + for (addr, len, nexthops) in snapshot { + table.insert((addr, len), nexthops); + } + store_cache(&self.cache_v4, Some(Poptrie::from(table))); + // Advance before notifying so any revalidator pass triggered by + // this swap is observable through the generation check. + self.generation.advance(); + self.notify(RebuildEvent::V4(changed_prefix)); + } + + /// Rebuild the IPv6 cache once. + fn rebuild_v6( + &self, + rib: &Arc>, + changed_prefix: Option, + ) { + let snapshot = { + let r = lock!(rib); + RpfTable::snapshot_rib(&r, |p| (p.addr().octets(), p.width())) + }; + // Build the poptrie outside the RIB lock. + let mut table = poptrie::Ipv6RoutingTable::default(); + for (addr, len, nexthops) in snapshot { + table.insert((addr, len), nexthops); + } + store_cache(&self.cache_v6, Some(Poptrie::from(table))); + // Advance before notifying so any revalidator pass triggered by + // this swap is observable through the generation check. + self.generation.advance(); + self.notify(RebuildEvent::V6(changed_prefix)); + } + + /// Send a rebuild event notification if a revalidator is installed. + fn notify(&self, event: RebuildEvent) { + if let Ok(guard) = self.notifier.lock() + && let Some(tx) = &*guard + { + let _ = tx.send(event); + } + } +} + +/// RPF verification table using poptrie for O(1) LPM (longest-prefix matching). +/// +/// This table maintains a poptrie-based cache of the RIB for fast RPF +/// lookups, rebuilt asynchronously by a dedicated worker thread (see the +/// module docs). Lookups fall back to a linear scan of the live RIB while +/// the cache is absent. +/// +/// The poptrie stores [`RpfNexthops`] derived at build time via the same +/// selection logic as the linear-scan fallback, so RPF verification +/// behavior is consistent across both paths. +#[derive(Clone)] +pub(crate) struct RpfTable { + /// IPv4 poptrie cache. + cache_v4: Arc>>>, + /// IPv6 poptrie cache. + cache_v6: Arc>>>, + /// Pending rebuild work shared with the worker. + pending: Arc>, + /// Send side of the worker's wakeup channel. + wake_tx: mpsc::SyncSender<()>, + /// Optional notifier for rebuild-complete events. + rebuild_notifier: Arc>>>, + /// Cache generation, advanced on every swap that changes lookup results. + generation: Arc, + /// Logger for error reporting. + log: Logger, +} + +impl RpfTable { + /// Create a new empty RPF table and spawn its rebuild worker. + pub fn new(log: Logger) -> Self { + let cache_v4 = Arc::new(RwLock::new(None)); + let cache_v6 = Arc::new(RwLock::new(None)); + let pending = Arc::new(Mutex::new(Pending::default())); + let rebuild_notifier = Arc::new(Mutex::new(None)); + let generation = Arc::new(GenerationCounter::default()); + + let (wake_tx, rx) = mpsc::sync_channel(1); + let worker = RebuildWorker { + cache_v4: Arc::clone(&cache_v4), + cache_v6: Arc::clone(&cache_v6), + pending: Arc::clone(&pending), + notifier: Arc::clone(&rebuild_notifier), + generation: Arc::clone(&generation), + }; + // If the spawn fails, the receiver is dropped and triggers fall + // back to clearing the cache, so lookups linear-scan the live RIB. + if let Err(e) = thread::Builder::new() + .name("rpf-rebuild".to_string()) + .spawn(move || worker.run(rx)) + { + error!(log, "failed to spawn rpf-rebuild worker: {e}"); + } + + Self { + cache_v4, + cache_v6, + pending, + wake_tx, + rebuild_notifier, + generation, + log, + } + } + + /// Current cache generation for optimistic concurrency over derivations. + pub(crate) fn generation(&self) -> Generation { + self.generation.current() + } + + /// Request a targeted revalidation pass for routes sourced at `source`. + /// + /// Used when an optimistic derivation exhausts its inline retry budget + /// under sustained cache churn. The revalidator re-derives from the + /// then-current cache, correcting any stale result the caller may have + /// applied. + /// + /// This returns whether the request reached a running revalidator. On + /// `false`, no corrective pass is coming and the caller must converge on + /// its own. + pub(crate) fn request_revalidation(&self, source: IpAddr) -> bool { + let event = match source { + IpAddr::V4(addr) => { + RebuildEvent::V4(Some(Ipv4Net::new_unchecked(addr, 32))) + } + IpAddr::V6(addr) => { + RebuildEvent::V6(Some(Ipv6Net::new_unchecked(addr, 128))) + } + }; + self.notify(event) + } + + /// Wake the rebuild worker through its bounded channel. + /// + /// Returns `false` if the worker is unavailable because it failed to + /// spawn or exited, in which case the caller must handle the fallback. + fn wake_worker(&self) -> bool { + match self.wake_tx.try_send(()) { + Ok(()) | Err(mpsc::TrySendError::Full(())) => true, + Err(mpsc::TrySendError::Disconnected(())) => false, + } + } + + /// Send a rebuild event notification if configured. + /// + /// Returns whether the event reached a revalidator. + fn notify(&self, event: RebuildEvent) -> bool { + if let Ok(guard) = self.rebuild_notifier.lock() + && let Some(tx) = &*guard + { + if tx.send(event).is_ok() { + return true; + } + debug!(self.log, "rpf revalidator not running"); + } + false + } + + /// Snapshot a RIB for poptrie rebuild. + /// + /// Extracts (addr_bits, prefix_len, [`RpfNexthops`]) tuples from the + /// RIB, deriving the compact nexthop payload in the same pass so full + /// path sets are never cloned. The `to_bits` closure converts the + /// prefix to address bits. + fn snapshot_rib( + rib: &BTreeMap>, + to_bits: F, + ) -> Vec<(A, u8, RpfNexthops)> + where + F: Fn(&P) -> (A, u8), + { + rib.iter() + .filter(|(_, paths)| !paths.is_empty()) + .map(|(prefix, paths)| { + let (bits, len) = to_bits(prefix); + (bits, len, RpfNexthops::from_paths(paths)) + }) + .collect() + } + + /// Install a notifier to be called on rebuild completion. + pub fn set_rebuild_notifier(&self, tx: mpsc::Sender) { + if let Ok(mut guard) = self.rebuild_notifier.lock() { + *guard = Some(tx); + } + } + + /// Trigger a background rebuild of the IPv4 RPF cache. + /// + /// Pending work is merged before the worker is woken. A request arriving + /// during a build remains pending for the worker's next pass. + /// + /// The `changed_prefix` ([`Ipv4Net`]) parameter enables targeted + /// revalidation: only (S,G) routes whose source falls within this prefix + /// need RPF re-checking. + pub fn trigger_rebuild_v4( + &self, + rib4_loc: Arc>, + changed_prefix: Option, + ) { + lock_pending(&self.pending).merge_v4(rib4_loc, changed_prefix); + if !self.wake_worker() { + // The worker is unavailable. + // Clear the cache so lookups fall back to a linear scan of + // the live RIB, and notify the revalidator directly. + debug!(self.log, "rpf rebuild worker not running"); + store_cache(&self.cache_v4, None); + self.generation.advance(); + self.notify(RebuildEvent::V4(changed_prefix)); + } + } + + /// Trigger a background rebuild of the IPv6 RPF cache. + /// + /// Pending work is merged before the worker is woken. A request arriving + /// during a build remains pending for the worker's next pass. + /// + /// The `changed_prefix` ([`Ipv6Net`]) parameter enables targeted + /// revalidation: only (S,G) routes whose source falls within this prefix + /// need RPF re-checking. + pub fn trigger_rebuild_v6( + &self, + rib6_loc: Arc>, + changed_prefix: Option, + ) { + lock_pending(&self.pending).merge_v6(rib6_loc, changed_prefix); + if !self.wake_worker() { + // The worker is unavailable. + // Clear the cache so lookups fall back to a linear scan of + // the live RIB, and notify the revalidator directly. + debug!(self.log, "rpf rebuild worker not running"); + store_cache(&self.cache_v6, None); + self.generation.advance(); + self.notify(RebuildEvent::V6(changed_prefix)); + } + } + + /// Look up the RPF neighbor for a multicast source address. + /// + /// Returns the best nexthop from the unicast RIB for reaching the source, + /// which is the valid RPF neighbor for (S,G) routes. Returns `None` if + /// no route with an active path exists for the source. + /// + /// Uses poptrie for O(1) lookup with linear scan fallback. + pub fn lookup( + &self, + source: IpAddr, + rib4_loc: &Arc>, + rib6_loc: &Arc>, + fanout: usize, + ) -> Option { + // Try poptrie lookup first + let cached = match source { + IpAddr::V4(addr) => self.cache_v4.read().ok().and_then(|cache| { + cache.as_ref().and_then(|pt| pt.match_v4(u32::from(addr))) + }), + IpAddr::V6(addr) => self.cache_v6.read().ok().and_then(|cache| { + cache.as_ref().and_then(|pt| pt.match_v6(u128::from(addr))) + }), + }; + + if let Some(nexthops) = cached { + return nexthops.for_fanout(fanout); + } + + // Fallback to linear scan + match source { + IpAddr::V4(addr) => Self::lookup_v4(addr, rib4_loc, fanout), + IpAddr::V6(addr) => Self::lookup_v6(addr, rib6_loc, fanout), + } + } + + /// IPv4 RPF lookup (linear scan fallback when poptrie unavailable). + /// + /// This O(n) scan is acceptable for deployments where the + /// unicast RIB is small. + fn lookup_v4( + source: Ipv4Addr, + rib4_loc: &Arc>, + fanout: usize, + ) -> Option { + let rib = rib4_loc.lock().ok()?; + + // Find best matching prefix (longest-prefix match). The first match + // is accepted regardless of width so a default route (/0) can serve + // as the RPF match of last resort. + let mut best_paths: Option<&BTreeSet> = None; + let mut best_len = 0u8; + + for (prefix, paths) in rib.iter() { + if prefix.contains(source) + && (best_paths.is_none() || prefix.width() > best_len) + { + best_len = prefix.width(); + best_paths = Some(paths); + } + } + + best_paths.and_then(|paths| Self::get_rpf_neighbor(paths, fanout)) + } + + /// IPv6 RPF lookup (linear scan fallback when poptrie unavailable). + /// + /// This O(n) scan is acceptable for deployments where the + /// unicast RIB is small. + fn lookup_v6( + source: Ipv6Addr, + rib6_loc: &Arc>, + fanout: usize, + ) -> Option { + let rib = rib6_loc.lock().ok()?; + + // Find best matching prefix (longest-prefix match). The first match + // is accepted regardless of width so a default route (/0) can serve + // as the RPF match of last resort. + let mut best_paths: Option<&BTreeSet> = None; + let mut best_len = 0u8; + + for (prefix, paths) in rib.iter() { + if prefix.contains(source) + && (best_paths.is_none() || prefix.width() > best_len) + { + best_len = prefix.width(); + best_paths = Some(paths); + } + } + + best_paths.and_then(|paths| Self::get_rpf_neighbor(paths, fanout)) + } + + /// Extract the RPF neighbor from a set of paths. + /// + /// For fanout == 1, returns the single bestpath nexthop. + /// For fanout > 1, returns one representative active nexthop. RPF records + /// a single upstream neighbor rather than the full ECMP set. The loc-RIB + /// paths are already bestpath-selected as equal-cost, so any active + /// member is valid. + /// + /// Returns `None` when no active path exists. This deviates from + /// [`bestpaths`], which selects among shutdown paths when no active + /// path exists: a shutdown path is never a valid RPF neighbor. + fn get_rpf_neighbor( + paths: &BTreeSet, + fanout: usize, + ) -> Option { + let first_active = paths.iter().find(|p| !p.shutdown)?; + + if fanout == 1 { + // With an active path present, bestpaths selects among active + // paths only, so passing the full set matches selection over + // the active subset without cloning it. + bestpaths(paths, 1) + .and_then(|selected| selected.iter().next().map(|p| p.nexthop)) + } else { + Some(first_active.nexthop) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::BTreeMap; + + use mg_common::log::*; + use mg_common::test::DEFAULT_INTERVAL; + use mg_common::wait_for; + + use crate::test::TEST_WAIT_ITERATIONS; + use mg_api_types::rdb::{ + DEFAULT_RIB_PRIORITY_BGP, DEFAULT_RIB_PRIORITY_STATIC, + }; + + /// Helper to create empty Rib4 for tests + fn empty_rib4() -> Arc> { + Arc::new(Mutex::new(BTreeMap::new())) + } + + /// Helper to create empty Rib6 for tests + fn empty_rib6() -> Arc> { + Arc::new(Mutex::new(BTreeMap::new())) + } + + #[test] + fn pending_widens_distinct_prefixes() { + let rib4 = empty_rib4(); + let first: Ipv4Net = "192.0.2.0/24".parse().unwrap(); + let second: Ipv4Net = "198.51.100.0/24".parse().unwrap(); + let mut pending = Pending::default(); + + pending.merge_v4(Arc::clone(&rib4), Some(first)); + pending.merge_v4(Arc::clone(&rib4), Some(first)); + assert_eq!(pending.v4.as_ref().unwrap().1, Some(first)); + + pending.merge_v4(Arc::clone(&rib4), Some(second)); + assert_eq!(pending.v4.as_ref().unwrap().1, None); + + // A full sweep remains sticky when later targeted work arrives. + pending.merge_v4(rib4, Some(first)); + assert_eq!(pending.v4.as_ref().unwrap().1, None); + } + + /// Extract nexthops from paths (filters out shutdown paths). + fn nexthops_from_paths(paths: &BTreeSet) -> BTreeSet { + paths + .iter() + .filter(|p| !p.shutdown) + .map(|p| p.nexthop) + .collect() + } + + #[test] + fn test_nexthops_from_paths() { + let mut paths = BTreeSet::new(); + let path1 = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + let path2 = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + paths.insert(path1); + paths.insert(path2); + + let next_hops = nexthops_from_paths(&paths); + assert_eq!(next_hops.len(), 2); + assert!(next_hops.contains(&IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)))); + assert!(next_hops.contains(&IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)))); + assert!(!next_hops.contains(&IpAddr::V4(Ipv4Addr::new(192, 0, 2, 3)))); + } + + #[test] + fn test_rpf_table_linear_scan() { + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Ipv4Net = "192.0.2.0/24".parse().unwrap(); + + let mut paths = BTreeSet::new(); + let path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + paths.insert(path); + rib4_inner.insert(prefix, paths); + + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = empty_rib6(); + let log = init_file_logger("rpf_linear_scan.log"); + let rpf_table = RpfTable::new(log); + + // Without poptrie cache, should use linear scan + let source = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + let expected = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(expected) + ); + } + + #[test] + fn test_rpf_default_route_linear_scan() { + // A source reachable only through a default route must still resolve + // an RPF neighbor. The /0 match is the match of last resort. This + // pins the linear-scan fallback only, which runs whenever the poptrie + // cache is absent (e.g., at startup before the first rebuild + // completes). + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Ipv4Net = "0.0.0.0/0".parse().unwrap(); + let mut paths = BTreeSet::new(); + paths.insert(Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }); + rib4_inner.insert(prefix, paths); + + let mut rib6_inner: Rib6 = BTreeMap::new(); + let prefix6: Ipv6Net = "::/0".parse().unwrap(); + let mut paths6 = BTreeSet::new(); + paths6.insert(Path { + nexthop: IpAddr::V6("fd00::1".parse().unwrap()), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }); + rib6_inner.insert(prefix6, paths6); + + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = Arc::new(Mutex::new(rib6_inner)); + let log = init_file_logger("rpf_default_route.log"); + let rpf_table = RpfTable::new(log); + + let source = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)); + let expected = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(expected) + ); + + let source6 = IpAddr::V6("2001:db8::7".parse().unwrap()); + let expected6 = IpAddr::V6("fd00::1".parse().unwrap()); + assert_eq!( + rpf_table.lookup(source6, &rib4_loc, &rib6_loc, 1), + Some(expected6) + ); + } + + #[test] + fn test_rpf_table_with_poptrie() { + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Ipv4Net = "192.0.2.0/24".parse().unwrap(); + + let mut paths = BTreeSet::new(); + let path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + paths.insert(path); + rib4_inner.insert(prefix, paths.clone()); + + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = empty_rib6(); + + let log = init_file_logger("rpf_poptrie.log"); + let rpf_table = RpfTable::new(log); + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + + // Wait for rebuild to complete + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" + ); + + // Should now use poptrie cache + let source = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + let expected = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(expected) + ); + } + + /// Every cache swap advances the generation. `Db::update_mrib_loc`'s + /// post-apply generation check relies on this to detect a racing + /// revalidator pass. + #[test] + fn generation_advances_on_each_rebuild() { + let prefix: Ipv4Net = "192.0.2.0/24".parse().unwrap(); + let path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + let rib4_loc = Arc::new(Mutex::new(BTreeMap::from([( + prefix, + BTreeSet::from([path]), + )]))); + + let log = init_file_logger("rpf_generation.log"); + let rpf_table = RpfTable::new(log); + let (tx, rx) = mpsc::channel(); + rpf_table.set_rebuild_notifier(tx); + + let before = rpf_table.generation(); + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), Some(prefix)); + rx.recv_timeout(Duration::from_secs(10)) + .expect("rebuild notification"); + let after = rpf_table.generation(); + assert_ne!(after, before, "cache swap must advance the generation"); + + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + rx.recv_timeout(Duration::from_secs(10)) + .expect("second rebuild notification"); + assert_ne!( + rpf_table.generation(), + after, + "each swap advances the generation" + ); + } + + #[test] + fn rapid_rebuilds_converge() { + let prefix: Ipv4Net = "192.0.2.0/24".parse().unwrap(); + let path = |last| Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, last)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + let rib4_loc = Arc::new(Mutex::new(BTreeMap::from([( + prefix, + BTreeSet::from([path(1)]), + )]))); + let source = Ipv4Addr::new(192, 0, 2, 50); + let log = init_file_logger("rpf_rapid_rebuilds.log"); + let rpf_table = RpfTable::new(log); + + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), Some(prefix)); + wait_for!( + rpf_table + .cache_v4 + .read() + .unwrap() + .as_ref() + .and_then(|cache| cache.match_v4(u32::from(source))) + .and_then(|nexthops| nexthops.for_fanout(1)) + == Some(path(1).nexthop), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "initial poptrie rebuild timed out" + ); + + for last in 2..=5 { + rib4_loc + .lock() + .unwrap() + .insert(prefix, BTreeSet::from([path(last)])); + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), Some(prefix)); + } + + wait_for!( + rpf_table + .cache_v4 + .read() + .unwrap() + .as_ref() + .and_then(|cache| cache.match_v4(u32::from(source))) + .and_then(|nexthops| nexthops.for_fanout(1)) + == Some(path(5).nexthop), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "poptrie did not converge to the latest RIB snapshot" + ); + } + + #[test] + fn test_rpf_table_shutdown_paths() { + // Test that shutdown paths are filtered out + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Ipv4Net = "192.0.2.0/24".parse().unwrap(); + + let mut paths = BTreeSet::new(); + // Active path + let active_path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + rib_priority: 10, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + // Shutdown path + let shutdown_path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 2)), + rib_priority: 20, + shutdown: true, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + paths.insert(active_path); + paths.insert(shutdown_path); + rib4_inner.insert(prefix, paths); + + let log = init_file_logger("rpf_shutdown.log"); + let rpf_table = RpfTable::new(log); + let source = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = empty_rib6(); + let active_neighbor = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + + // Linear scan should return active path, not shutdown + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(active_neighbor) + ); + + // Rebuild poptrie and test again + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" + ); + + // Poptrie should also return active path + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(active_neighbor) + ); + } + + #[test] + fn test_rpf_table_all_shutdown() { + // Test that a prefix with ALL paths shutdown returns None + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Ipv4Net = "192.0.2.0/24".parse().unwrap(); + + let mut paths = BTreeSet::new(); + let shutdown_path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + rib_priority: 1, + shutdown: true, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + paths.insert(shutdown_path); + rib4_inner.insert(prefix, paths); + + let log = init_file_logger("rpf_all_shutdown.log"); + let rpf_table = RpfTable::new(log); + let source = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = empty_rib6(); + + // Linear scan - should return `None` (all paths shutdown) + assert_eq!(rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), None); + + // Rebuild poptrie + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" + ); + + // Poptrie finds the route but all paths shutdown, still `None` + assert_eq!(rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), None); + } + + #[test] + fn test_rpf_ecmp_different_priorities() { + // Test that bestpath selection prefers lower rib_priority + + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Ipv4Net = "192.0.2.0/24".parse().unwrap(); + + // Static route (priority 1) + let static_nexthop = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + let static_path = Path { + nexthop: static_nexthop, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + + // BGP route (priority 20) + let bgp_nexthop = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 2)); + let bgp_path = Path { + nexthop: bgp_nexthop, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + + let mut paths = BTreeSet::new(); + paths.insert(static_path); + paths.insert(bgp_path); + rib4_inner.insert(prefix, paths); + + let log = init_file_logger("rpf_ecmp_priority.log"); + let rpf_table = RpfTable::new(log); + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = empty_rib6(); + let source = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + + // fanout=1: returns static (best priority) + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(static_nexthop) + ); + + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" + ); + + // Same with poptrie + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(static_nexthop) + ); + } + + #[test] + fn test_rpf_table_linear_scan_v6() { + let mut rib6_inner: Rib6 = BTreeMap::new(); + let prefix: Ipv6Net = "2001:db8::/32".parse().unwrap(); + + let mut paths = BTreeSet::new(); + let path = Path { + nexthop: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + paths.insert(path); + rib6_inner.insert(prefix, paths); + + let rib4_loc = empty_rib4(); + let rib6_loc = Arc::new(Mutex::new(rib6_inner)); + let log = init_file_logger("rpf_linear_scan_v6.log"); + let rpf_table = RpfTable::new(log); + + // Without poptrie cache, should use linear scan + let source = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 50)); + let expected = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)); + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(expected) + ); + } + + #[test] + fn test_rpf_table_with_poptrie_v6() { + let mut rib6_inner: Rib6 = BTreeMap::new(); + let prefix: Ipv6Net = "2001:db8::/32".parse().unwrap(); + + let mut paths = BTreeSet::new(); + let path = Path { + nexthop: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + paths.insert(path); + rib6_inner.insert(prefix, paths.clone()); + + let rib4_loc = empty_rib4(); + let rib6_loc = Arc::new(Mutex::new(rib6_inner)); + + let log = init_file_logger("rpf_poptrie_v6.log"); + let rpf_table = RpfTable::new(log); + rpf_table.trigger_rebuild_v6(Arc::clone(&rib6_loc), None); + + // Wait for rebuild to complete + wait_for!( + rpf_table.cache_v6.read().unwrap().is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "poptrie v6 rebuild timed out" + ); + + // Should now use poptrie cache + let source = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 50)); + let expected = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)); + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(expected) + ); + } + + #[test] + fn test_rpf_lpm() { + // Test longest-prefix match -> the more specific route wins + let mut rib4_inner: Rib4 = BTreeMap::new(); + + // Less specific: 192.0.2.0/24 -> nexthop1 + let prefix_24: Ipv4Net = "192.0.2.0/24".parse().unwrap(); + let nexthop1 = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + let mut paths1 = BTreeSet::new(); + paths1.insert(Path { + nexthop: nexthop1, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }); + rib4_inner.insert(prefix_24, paths1); + + // More specific: 192.0.2.128/25 -> nexthop2 + let prefix_25: Ipv4Net = "192.0.2.128/25".parse().unwrap(); + let nexthop2 = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 2)); + let mut paths2 = BTreeSet::new(); + paths2.insert(Path { + nexthop: nexthop2, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }); + rib4_inner.insert(prefix_25, paths2); + + let log = init_file_logger("rpf_lpm.log"); + let rpf_table = RpfTable::new(log); + let rib4_loc = Arc::new(Mutex::new(rib4_inner.clone())); + let rib6_loc = empty_rib6(); + + // Source in /25 should match more specific route + let source_in_25 = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 200)); + assert_eq!( + rpf_table.lookup(source_in_25, &rib4_loc, &rib6_loc, 1), + Some(nexthop2) + ); + + // Source in /24 but not /25 should match less specific route + let source_in_24 = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + assert_eq!( + rpf_table.lookup(source_in_24, &rib4_loc, &rib6_loc, 1), + Some(nexthop1) + ); + + // Test with poptrie too + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" + ); + + assert_eq!( + rpf_table.lookup(source_in_25, &rib4_loc, &rib6_loc, 1), + Some(nexthop2) + ); + assert_eq!( + rpf_table.lookup(source_in_24, &rib4_loc, &rib6_loc, 1), + Some(nexthop1) + ); + } + + #[test] + fn test_rpf_ecmp_v6() { + // Test IPv6 ECMP: lookup returns one of the equal-priority paths + let mut rib6_inner: Rib6 = BTreeMap::new(); + let prefix: Ipv6Net = "2001:db8::/32".parse().unwrap(); + + let nexthop1 = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)); + let nexthop2 = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 2)); + + let path1 = Path { + nexthop: nexthop1, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + let path2 = Path { + nexthop: nexthop2, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }; + + let mut paths = BTreeSet::new(); + paths.insert(path1); + paths.insert(path2); + rib6_inner.insert(prefix, paths); + + let log = init_file_logger("rpf_ecmp_v6.log"); + let rpf_table = RpfTable::new(log); + let rib4_loc = empty_rib4(); + let rib6_loc = Arc::new(Mutex::new(rib6_inner)); + let source = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 50)); + + // fanout=1: returns one of the equal-priority paths + let result = rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1); + assert!( + result == Some(nexthop1) || result == Some(nexthop2), + "expected one of the ECMP nexthops" + ); + } + + #[test] + fn test_rpf_v6_with_nexthop_interface() { + // RPF with link-local nexthops and interface binding + // (BGP unnumbered underlay for multicast). + // + // This verifies both linear scan and poptrie paths return the correct + // nexthop. + let mut rib6_inner: Rib6 = BTreeMap::new(); + let prefix: Ipv6Net = "2001:db8:1::/48".parse().unwrap(); + + let nexthop = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)); + let path = Path { + nexthop, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: Some("qsfp0".to_string()), + }; + + let mut paths = BTreeSet::new(); + paths.insert(path); + rib6_inner.insert(prefix, paths); + + let log = init_file_logger("rpf_v6_interface.log"); + let rpf_table = RpfTable::new(log); + let rib4_loc = empty_rib4(); + let rib6_loc = Arc::new(Mutex::new(rib6_inner)); + + let source = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 1, 0, 0, 0, 0, 100)); + + // Linear scan + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(nexthop), + ); + + // Poptrie + rpf_table.trigger_rebuild_v6(Arc::clone(&rib6_loc), None); + wait_for!( + rpf_table.cache_v6.read().unwrap().is_some(), + DEFAULT_INTERVAL, + TEST_WAIT_ITERATIONS, + "poptrie v6 rebuild timed out" + ); + + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(nexthop), + ); + } + + #[test] + fn test_rpf_v6_lpm() { + const NEXTHOP1: IpAddr = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0xff, 0, 0, 0, 0, 1)); + const NEXTHOP2: IpAddr = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0xff, 0, 0, 0, 0, 2)); + + let mut rib6_inner: Rib6 = BTreeMap::new(); + + // Less specific: 2001:db8::/32 -> NEXTHOP1 + let prefix_32: Ipv6Net = "2001:db8::/32".parse().unwrap(); + let mut paths1 = BTreeSet::new(); + paths1.insert(Path { + nexthop: NEXTHOP1, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }); + rib6_inner.insert(prefix_32, paths1); + + // More specific: 2001:db8:1::/48 -> NEXTHOP2 + let prefix_48: Ipv6Net = "2001:db8:1::/48".parse().unwrap(); + let mut paths2 = BTreeSet::new(); + paths2.insert(Path { + nexthop: NEXTHOP2, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + nexthop_interface: None, + }); + rib6_inner.insert(prefix_48, paths2); + + let log = init_file_logger("rpf_v6_lpm.log"); + let rpf_table = RpfTable::new(log); + let rib4_loc = empty_rib4(); + let rib6_loc = Arc::new(Mutex::new(rib6_inner)); + + // Source in /48 should match more specific route + let source_in_48 = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 1, 0, 0, 0, 0, 50)); + assert_eq!( + rpf_table.lookup(source_in_48, &rib4_loc, &rib6_loc, 1), + Some(NEXTHOP2) + ); + + // Source in /32 but not /48 should match less specific route + let source_in_32 = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 2, 0, 0, 0, 0, 50)); + assert_eq!( + rpf_table.lookup(source_in_32, &rib4_loc, &rib6_loc, 1), + Some(NEXTHOP1) + ); + } +} diff --git a/rdb/src/proptest.rs b/rdb/src/proptest.rs index a9280c4ab..ec6b689ba 100644 --- a/rdb/src/proptest.rs +++ b/rdb/src/proptest.rs @@ -2,13 +2,30 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + //! Property-based tests for Prefix types using proptest //! //! These tests verify key invariants of the Prefix types to ensure //! correctness and consistency of prefix operations (excluding wire format //! tests, which are in bgp/src/proptest.rs since they test BgpWireFormat). -use crate::types::StaticRouteKey; +use crate::types::{ + MulticastAddrV4, MulticastAddrV6, MulticastRoute, MulticastRouteKey, + MulticastSourceProtocol, StaticRouteKey, UnderlayMulticastIpv6, + UnicastAddrV4, Vni, +}; +use client_common::address::{ + IPV4_MULTICAST_RANGE, IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, + IPV6_LINK_LOCAL_MULTICAST_SUBNET, +}; +use mg_api_types::mrib::{ + admin_local_multicast_strategy, invalid_vni_strategy, + ipv4_asm_group_strategy, ipv4_ssm_group_strategy, ipv4_unicast_strategy, + ipv6_asm_group_strategy, ipv6_ssm_group_strategy, + non_admin_local_multicast_strategy, routable_ipv6_unicast_strategy, + valid_vni_strategy, +}; use mg_api_types::rdb::neighbor::{BgpNeighborInfo, BgpNeighborParameters}; use mg_api_types_versions::v1::rdb::prefix::{Prefix4, Prefix6}; use mg_api_types_versions::v4::bgp::policy::{ @@ -494,3 +511,315 @@ proptest! { ); } } + +// Generate IPv6 addresses that are not multicast or loopback. +// +// Returns raw Ipv6Addr, not UnicastAddrV6. Use this for tests that need +// a non-multicast address but don't require a routable unicast source +// (e.g., AF mismatch tests, RPF neighbor fields, multicast addr rejection). +// +// For multicast route key sources, use routable_ipv6_unicast_strategy() +// from mg_api_types::mrib. +fn ipv6_unicast_strategy() -> impl Strategy { + // Generate any address except ff00::/8 (multicast) and ::1 (loopback). + // Multicast is only 1/256 of address space, so filter rejection is fine. + any::().prop_filter_map("skip multicast/loopback", |bits| { + let addr = Ipv6Addr::from(bits); + if addr.is_multicast() || addr.is_loopback() { + None + } else { + Some(addr) + } + }) +} + +proptest! { + /// Property: Arbitrary `MulticastAddrV4` always validates + #[test] + fn prop_multicast_addr_v4_arbitrary_valid(addr in any::()) { + // Arbitrary impl only generates valid addresses + prop_assert!(addr.ip().is_multicast()); + } + + /// Property: Arbitrary `MulticastAddrV6` always validates + #[test] + fn prop_multicast_addr_v6_arbitrary_valid(addr in any::()) { + // Arbitrary impl only generates valid addresses + prop_assert!(addr.ip().is_multicast()); + } + + /// Property: IPv4 unicast addresses are rejected as multicast + #[test] + fn prop_multicast_addr_v4_rejects_unicast(addr in ipv4_unicast_strategy()) { + let result = MulticastAddrV4::new(addr.ip()); + prop_assert!( + result.is_err(), + "unicast {addr} should be rejected as multicast" + ); + } + + /// Property: IPv6 unicast addresses are rejected as multicast + #[test] + fn prop_multicast_addr_v6_rejects_unicast(addr in ipv6_unicast_strategy()) { + let result = MulticastAddrV6::new(addr); + prop_assert!( + result.is_err(), + "unicast {addr} should be rejected as multicast" + ); + } + + /// Property: IPv4 link-local multicast (224.0.0.x) is rejected + #[test] + fn prop_multicast_addr_v4_rejects_link_local(last_octet in 0u8..=u8::MAX) { + let mcast_base = IPV4_MULTICAST_RANGE.addr().octets()[0]; + let addr = Ipv4Addr::new(mcast_base, 0, 0, last_octet); + let result = MulticastAddrV4::new(addr); + prop_assert!( + result.is_err(), + "link-local {addr} should be rejected" + ); + } + + /// Property: IPv6 link-local multicast (ff02::/16) is rejected + #[test] + fn prop_multicast_addr_v6_rejects_link_local(segs in any::<[u16; 7]>()) { + let prefix = IPV6_LINK_LOCAL_MULTICAST_SUBNET.addr().segments()[0]; + let link_local = Ipv6Addr::new( + prefix, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], + ); + let result = MulticastAddrV6::new(link_local); + prop_assert!( + result.is_err(), + "link-local {link_local} should be rejected" + ); + } + + /// Property: IPv6 interface-local multicast (ff01::/16) is rejected + #[test] + fn prop_multicast_addr_v6_rejects_interface_local(segs in any::<[u16; 7]>()) { + let prefix = IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET.addr().segments()[0]; + let if_local = Ipv6Addr::new( + prefix, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], + ); + let result = MulticastAddrV6::new(if_local); + prop_assert!( + result.is_err(), + "interface-local {if_local} should be rejected" + ); + } + + /// Property: `MulticastAddrV4` roundtrip through ip() preserves address + #[test] + fn prop_multicast_addr_ip_roundtrip_v4(mcast in any::()) { + let ip = mcast.ip(); + let roundtrip = MulticastAddrV4::new(ip).expect("valid"); + prop_assert_eq!(mcast, roundtrip); + } + + /// Property: `MulticastAddrV6` roundtrip through ip() preserves address + #[test] + fn prop_multicast_addr_ip_roundtrip_v6(mcast in any::()) { + let ip = mcast.ip(); + let roundtrip = MulticastAddrV6::new(ip).expect("valid"); + prop_assert_eq!(mcast, roundtrip); + } + + /// Property: Arbitrary `MulticastRouteKey` always validates + #[test] + fn prop_route_key_arbitrary_valid(key in any::()) { + prop_assert!( + key.validate().is_ok(), + "arbitrary key should validate: {key:?}" + ); + } + + /// Property: (*,G) with ASM group validates (source optional for ASM) + #[test] + fn prop_route_key_asm_star_g_valid_v4(group in ipv4_asm_group_strategy()) { + let key = MulticastRouteKey::any_source(group.into()); + prop_assert!( + key.validate().is_ok(), + "(*,G) with ASM {group} should be valid"); + } + + /// Property: (*,G) with ASM group validates (source optional for ASM) + #[test] + fn prop_route_key_asm_star_g_valid_v6(group in ipv6_asm_group_strategy()) { + let key = MulticastRouteKey::any_source(group.into()); + prop_assert!( + key.validate().is_ok(), + "(*,G) with ASM {group} should be valid" + ); + } + + /// Property: (S,G) with unicast source validates (covers ASM and SSM) + #[test] + fn prop_route_key_sg_valid_v4( + src in ipv4_unicast_strategy(), + group in any::(), + ) { + let key = MulticastRouteKey::source_specific_v4(src, group); + prop_assert!( + key.validate().is_ok(), + "(S,G) with unicast source {src} should be valid" + ); + } + + /// Property: (S,G) with unicast source validates (covers ASM and SSM) + #[test] + fn prop_route_key_sg_valid_v6( + src in routable_ipv6_unicast_strategy(), + group in any::(), + ) { + let key = MulticastRouteKey::source_specific_v6(src, group); + prop_assert!( + key.validate().is_ok(), + "(S,G) with unicast source {src} should be valid" + ); + } + + /// Property: SSM without source fails validation (IPv4) + #[test] + fn prop_route_key_ssm_requires_source_v4(group in ipv4_ssm_group_strategy()) { + let key = MulticastRouteKey::any_source(group.into()); + prop_assert!( + key.validate().is_err(), + "SSM (*,G) with {group} should require source" + ); + } + + /// Property: SSM without source fails validation (IPv6) + #[test] + fn prop_route_key_ssm_requires_source_v6(group in ipv6_ssm_group_strategy()) { + let key = MulticastRouteKey::any_source(group.into()); + prop_assert!( + key.validate().is_err(), + "SSM (*,G) with {group} should require source" + ); + } + + /// Property: SSM with source passes validation (IPv4) + #[test] + fn prop_route_key_ssm_with_source_valid_v4( + src in ipv4_unicast_strategy(), + group in ipv4_ssm_group_strategy(), + ) { + let key = MulticastRouteKey::source_specific_v4(src, group); + prop_assert!( + key.validate().is_ok(), + "SSM (S,G) with {src},{group} should be valid" + ); + } + + /// Property: SSM with source passes validation (IPv6) + #[test] + fn prop_route_key_ssm_with_source_valid_v6( + src in routable_ipv6_unicast_strategy(), + group in ipv6_ssm_group_strategy(), + ) { + let key = MulticastRouteKey::source_specific_v6(src, group); + prop_assert!( + key.validate().is_ok(), + "SSM (S,G) with {src},{group} should be valid" + ); + } + + /// Property: VNI in valid range passes validation + #[test] + fn prop_route_key_valid_vni( + src in ipv4_unicast_strategy(), + group in any::(), + vni in valid_vni_strategy(), + ) { + // Use (S,G) so both ASM and SSM groups work + let key = MulticastRouteKey::new( + Some(IpAddr::V4(src.ip())), + group.into(), + vni, + ) + .expect("valid key construction"); + let result = key.validate(); + prop_assert!( + result.is_ok(), + "VNI {vni:?} should be valid: {result:?}" + ); + } + + /// Property: a VNI outside the 24-bit range is rejected at construction. + #[test] + fn prop_vni_rejects_out_of_range(vni in invalid_vni_strategy()) { + prop_assert!( + Vni::new(vni).is_err(), + "VNI {vni} above the 24-bit max should be rejected" + ); + } + + /// Property: VNI in valid range passes validation (IPv6) + #[test] + fn prop_route_key_valid_vni_v6( + src in routable_ipv6_unicast_strategy(), + group in any::(), + vni in valid_vni_strategy(), + ) { + let key = MulticastRouteKey::new( + Some(IpAddr::V6(src.ip())), + group.into(), + vni, + ) + .expect("valid key construction"); + let result = key.validate(); + prop_assert!( + result.is_ok(), + "VNI {vni:?} should be valid for v6: {result:?}" + ); + } + + /// Property: Class E reserved (240/4) addresses rejected as unicast + /// source. Per RFC 1112 Section 4, this range is reserved. + #[test] + fn prop_unicast_addr_v4_rejects_class_e( + a in 240u8..=254, + b in any::(), + c in any::(), + d in any::(), + ) { + let addr = Ipv4Addr::new(a, b, c, d); + prop_assert!( + UnicastAddrV4::new(addr).is_err(), + "Class E address {addr} should be rejected" + ); + } + + + /// Property: Route with admin-local underlay group passes validation + #[test] + fn prop_route_admin_local_underlay_valid( + group in ipv4_asm_group_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let route = MulticastRoute::new( + key, + underlay, + MulticastSourceProtocol::Static, + ); + prop_assert!( + route.validate().is_ok(), + "route with admin-local underlay should be valid" + ); + } + + /// Property: Non-admin-local address is rejected by UnderlayMulticastIpv6 + #[test] + fn prop_non_admin_local_underlay_rejected( + underlay in non_admin_local_multicast_strategy(), + ) { + prop_assert!( + UnderlayMulticastIpv6::new(underlay.ip()).is_err(), + "non-admin-local address {underlay} should be rejected" + ); + } + + + +} diff --git a/rdb/src/test.rs b/rdb/src/test.rs index b43ac08d0..471fc0acc 100644 --- a/rdb/src/test.rs +++ b/rdb/src/test.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + //! Test utilities for rdb tests. use crate::{Db, error::Error}; @@ -10,6 +12,9 @@ use slog::Logger; use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicU64, Ordering}; +/// Default iteration count for rdb wait_for! calls (5 seconds at 1s polling). +pub const TEST_WAIT_ITERATIONS: u64 = 5; + /// A test database wrapper that automatically cleans up the database directory /// when dropped, but only if the test succeeded. /// diff --git a/rdb/src/types.rs b/rdb/src/types.rs index b5d829919..961083f97 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2026 Oxide Computer Company + use crate::error::Error; use anyhow::Result; use schemars::JsonSchema; @@ -9,7 +11,6 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::fmt::Display; use std::fmt::{self, Formatter}; -use std::hash::Hash; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::str::FromStr; @@ -249,9 +250,9 @@ impl From for Asn { } impl Asn { - pub fn as_u32(&self) -> u32 { + pub const fn as_u32(&self) -> u32 { match self { - Self::TwoOctet(value) => u32::from(*value), + Self::TwoOctet(value) => *value as u32, Self::FourOctet(value) => *value, } } @@ -326,6 +327,9 @@ impl Display for PrefixChangeNotification { } } +// Multicast types live in mg-api-types under the `mrib` module. +pub use mg_api_types::mrib::*; + #[cfg(test)] pub mod test_helpers { use super::Path; @@ -368,6 +372,18 @@ mod test { cmp::Ordering, collections::BTreeSet, net::IpAddr, str::FromStr, }; + /// ASM IPv4 group suitable for (*,G) tests. + const TEST_GROUP_V4: Ipv4Addr = Ipv4Addr::new(239, 1, 1, 1); + + /// ASM IPv6 group suitable for (*,G) tests. + const TEST_GROUP_V6: Ipv6Addr = Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1); + + /// Test underlay address within ff04::/64. + fn test_underlay() -> UnderlayMulticastIpv6 { + UnderlayMulticastIpv6::new(Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1)) + .expect("valid test underlay address") + } + fn bgp_path( nexthop: IpAddr, peer: PeerId, @@ -578,6 +594,175 @@ mod test { assert_eq!(set.iter().next().unwrap().bgp.as_ref().unwrap().med, None,); } + #[test] + fn broadcast_source_rejected() { + assert!( + UnicastAddrV4::new(Ipv4Addr::BROADCAST).is_err(), + "broadcast should be rejected as unicast source" + ); + } + + #[test] + fn loopback_source_rejected_v4() { + assert!( + UnicastAddrV4::new(Ipv4Addr::LOCALHOST).is_err(), + "loopback should be rejected as unicast source" + ); + } + + #[test] + fn loopback_source_rejected_v6() { + assert!( + UnicastAddrV6::new(Ipv6Addr::LOCALHOST).is_err(), + "loopback should be rejected as unicast source" + ); + } + + #[test] + fn unspecified_source_rejected_v4() { + assert!( + UnicastAddrV4::new(Ipv4Addr::UNSPECIFIED).is_err(), + "unspecified should be rejected as unicast source" + ); + } + + #[test] + fn unspecified_source_rejected_v6() { + assert!( + UnicastAddrV6::new(Ipv6Addr::UNSPECIFIED).is_err(), + "unspecified should be rejected as unicast source" + ); + } + + #[test] + fn route_key_af_mismatch_v4_source_v6_group() { + let src = UnicastAddrV4::new(Ipv4Addr::new(10, 0, 0, 1)).unwrap(); + let group = + MulticastAddrV6::new(Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 1)) + .unwrap(); + let result = MulticastRouteKey::new( + Some(IpAddr::V4(src.ip())), + group.into(), + Vni::DEFAULT_MULTICAST, + ); + assert!( + result.is_err(), + "v4 source with v6 group should be rejected" + ); + } + + #[test] + fn route_key_af_mismatch_v6_source_v4_group() { + let src = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1); + let group = MulticastAddrV4::new(TEST_GROUP_V4).unwrap(); + let result = MulticastRouteKey::new( + Some(IpAddr::V6(src)), + group.into(), + Vni::DEFAULT_MULTICAST, + ); + assert!( + result.is_err(), + "v6 source with v4 group should be rejected" + ); + } + + #[test] + fn multicast_source_rejected_v4() { + assert!( + UnicastAddrV4::new(Ipv4Addr::new(224, 0, 0, 1)).is_err(), + "multicast address should be rejected as source" + ); + } + + #[test] + fn multicast_source_rejected_v6() { + assert!( + UnicastAddrV6::new(Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1)) + .is_err(), + "multicast address should be rejected as source" + ); + } + + #[test] + fn unicast_rpf_valid_v4() { + let group = MulticastAddrV4::new(TEST_GROUP_V4).unwrap(); + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); + assert!(route.validate().is_ok(), "unicast v4 RPF should be valid"); + } + + #[test] + fn unicast_rpf_valid_v6() { + let group = MulticastAddrV6::new(TEST_GROUP_V6).unwrap(); + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + route.rpf_neighbor = + Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))); + assert!(route.validate().is_ok(), "unicast v6 RPF should be valid"); + } + + #[test] + fn multicast_rpf_invalid_v4() { + let group = MulticastAddrV4::new(TEST_GROUP_V4).unwrap(); + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 1))); + assert!( + route.validate().is_err(), + "multicast RPF should be rejected" + ); + } + + #[test] + fn multicast_rpf_invalid_v6() { + let group = MulticastAddrV6::new(TEST_GROUP_V6).unwrap(); + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + route.rpf_neighbor = + Some(IpAddr::V6(Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1))); + assert!( + route.validate().is_err(), + "multicast RPF should be rejected" + ); + } + + /// A cross-family RPF neighbor is valid: the neighbor comes from the + /// unicast RIB, where v4 routes may resolve through v6 nexthops + /// (RFC 8950 style). + #[test] + fn rpf_cross_af_v6_neighbor_v4_group_accepted() { + let group = MulticastAddrV4::new(TEST_GROUP_V4).unwrap(); + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + test_underlay(), + MulticastSourceProtocol::Static, + ); + route.rpf_neighbor = + Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))); + assert!( + route.validate().is_ok(), + "v6 RPF neighbor with v4 group should be accepted" + ); + } + /// remove() targets the correct path by identity, not by /// attribute values. #[test]