From c0be29bd09cb1a65f302ad89e5ff137811e9fe70 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Mon, 23 Mar 2026 13:05:18 +0000 Subject: [PATCH 1/9] [review] address PR review comments: UnderlayMulticastIpv6 newtype, Vni type, sled transactions, merge main (665) This is setup to come after https://github.com/oxidecomputer/maghemite/pull/665's API modifications, but uses the up-to-date setup and dropshot API manager. --- Cargo.lock | 7 + Cargo.toml | 1 + mg-api/src/lib.rs | 56 + mg-types/src/lib.rs | 3 + mg-types/src/mrib.rs | 7 + mg-types/versions/src/impls/mod.rs | 2 + mg-types/versions/src/latest.rs | 6 + mg-types/versions/src/lib.rs | 6 + .../versions/src/multicast_support/mod.rs | 12 + .../versions/src/multicast_support/mrib.rs | 105 ++ mgadm/src/main.rs | 8 + mgadm/src/mrib.rs | 483 +++++++ mgadm/src/static_routing.rs | 36 + mgd/src/admin.rs | 58 +- mgd/src/error.rs | 12 +- mgd/src/main.rs | 3 + mgd/src/mrib_admin.rs | 184 +++ .../mg-admin-7.0.0-fba416.json.gitstub | 1 + ...fba416.json => mg-admin-8.0.0-5dd4ca.json} | 565 +++++++- openapi/mg-admin/mg-admin-latest.json | 2 +- rdb-types/src/lib.rs | 38 +- rdb/Cargo.toml | 2 + rdb/src/db.rs | 958 ++++++++++++- rdb/src/error.rs | 11 + rdb/src/lib.rs | 4 + rdb/src/mrib/mod.rs | 714 +++++++++ rdb/src/mrib/rpf.rs | 1274 +++++++++++++++++ rdb/src/proptest.rs | 1030 ++++++++++++- rdb/src/test.rs | 5 + rdb/src/types.rs | 904 ++++++++++++ 30 files changed, 6415 insertions(+), 82 deletions(-) create mode 100644 mg-types/src/mrib.rs create mode 100644 mg-types/versions/src/multicast_support/mod.rs create mode 100644 mg-types/versions/src/multicast_support/mrib.rs create mode 100644 mgadm/src/mrib.rs create mode 100644 mgd/src/mrib_admin.rs create mode 100644 openapi/mg-admin/mg-admin-7.0.0-fba416.json.gitstub rename openapi/mg-admin/{mg-admin-7.0.0-fba416.json => mg-admin-8.0.0-5dd4ca.json} (90%) create mode 100644 rdb/src/mrib/mod.rs create mode 100644 rdb/src/mrib/rpf.rs diff --git a/Cargo.lock b/Cargo.lock index 9a4dee8cb..d83183ebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5256,6 +5256,11 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "poptrie" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/poptrie?branch=main#5bf62f6b889c61e0608d8463ed11da28e130cb34" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -5944,7 +5949,9 @@ dependencies = [ "itertools 0.14.0", "mg-common", "ndp", + "omicron-common", "oxnet", + "poptrie", "proptest", "rdb-types 0.1.0", "schemars 0.8.22", diff --git a/Cargo.toml b/Cargo.toml index efb5a6d2d..09e63b847 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,6 +126,7 @@ oximeter = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} oximeter-producer = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} oxnet = { version = "0.1.4", 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/mg-api/src/lib.rs b/mg-api/src/lib.rs index 1da4cd83e..fa28fae4a 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 std::collections::HashMap; use std::net::IpAddr; @@ -17,6 +19,7 @@ use dropshot::{ }; use dropshot_api_manager_types::api_versions; use mg_types_versions::{latest, v1, v2, v5}; +use rdb::types::MulticastRoute; use rdb::{BfdPeerConfig, Prefix}; api_versions!([ @@ -31,6 +34,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (8, MULTICAST_SUPPORT), (7, OPERATION_ID_CLEANUP), (6, RIB_EXPORTED_STRING_KEY), (5, UNNUMBERED), @@ -622,4 +626,56 @@ 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>; + + /// Get the RPF rebuild rate-limit interval. + #[endpoint { method = GET, path = "/mrib/config/rpf/rebuild-interval", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn read_mrib_rpf_rebuild_interval( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk, + HttpError, + >; + + /// Set the RPF rebuild rate-limit interval. + #[endpoint { method = POST, path = "/mrib/config/rpf/rebuild-interval", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn update_mrib_rpf_rebuild_interval( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; } diff --git a/mg-types/src/lib.rs b/mg-types/src/lib.rs index 9e307a20a..1d5b1883a 100644 --- a/mg-types/src/lib.rs +++ b/mg-types/src/lib.rs @@ -2,8 +2,11 @@ // 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 bfd; pub mod bgp; +pub mod mrib; pub mod ndp; pub mod rib; pub mod static_routes; diff --git a/mg-types/src/mrib.rs b/mg-types/src/mrib.rs new file mode 100644 index 000000000..adfc51ae6 --- /dev/null +++ b/mg-types/src/mrib.rs @@ -0,0 +1,7 @@ +// 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 + +pub use mg_types_versions::latest::mrib::*; diff --git a/mg-types/versions/src/impls/mod.rs b/mg-types/versions/src/impls/mod.rs index 9bf28bda4..250ed83f5 100644 --- a/mg-types/versions/src/impls/mod.rs +++ b/mg-types/versions/src/impls/mod.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 + //! Functional code for the latest versions of types. mod bgp; diff --git a/mg-types/versions/src/latest.rs b/mg-types/versions/src/latest.rs index 4587af2f0..3dd8487de 100644 --- a/mg-types/versions/src/latest.rs +++ b/mg-types/versions/src/latest.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 + //! Re-exports of the latest versions of types. pub mod bfd { @@ -27,6 +29,10 @@ pub mod bgp { pub use crate::v5::bgp::UnnumberedNeighborSelector; } +pub mod mrib { + pub use crate::v8::mrib::*; +} + pub mod ndp { pub use crate::v5::ndp::NdpInterface; pub use crate::v5::ndp::NdpInterfaceSelector; diff --git a/mg-types/versions/src/lib.rs b/mg-types/versions/src/lib.rs index 15fa5c873..7825c31a0 100644 --- a/mg-types/versions/src/lib.rs +++ b/mg-types/versions/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 + //! Versioned types for the Maghemite Admin API. //! //! # Adding a new API version @@ -41,3 +43,7 @@ pub mod v3; pub mod v4; #[path = "unnumbered/mod.rs"] pub mod v5; +// v6 (RIB_EXPORTED_STRING_KEY) and v7 (OPERATION_ID_CLEANUP) introduced +// no new types. +#[path = "multicast_support/mod.rs"] +pub mod v8; diff --git a/mg-types/versions/src/multicast_support/mod.rs b/mg-types/versions/src/multicast_support/mod.rs new file mode 100644 index 000000000..19c9eaf58 --- /dev/null +++ b/mg-types/versions/src/multicast_support/mod.rs @@ -0,0 +1,12 @@ +// 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. +//! +//! Added MRIB (Multicast Routing Information Base) support with static +//! multicast route management, RPF verification, and query endpoints. + +pub mod mrib; diff --git a/mg-types/versions/src/multicast_support/mrib.rs b/mg-types/versions/src/multicast_support/mrib.rs new file mode 100644 index 000000000..53327b0bd --- /dev/null +++ b/mg-types/versions/src/multicast_support/mrib.rs @@ -0,0 +1,105 @@ +// 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) API types. + +use std::net::IpAddr; + +use rdb::types::{ + AddressFamily, MulticastRouteKey, UnderlayMulticastIpv6, Vni, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::net::Ipv6Addr; + +/// 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 unicast nexthops for multicast replication. + /// + /// Unicast IPv6 addresses where encapsulated overlay multicast traffic + /// is forwarded. These are sled underlay addresses hosting VMs subscribed + /// to the multicast group. Forms the outgoing interface list (OIL). + pub underlay_nexthops: Vec, + /// 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, +} + +/// Response containing the current RPF rebuild interval. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribRpfRebuildIntervalResponse { + /// Minimum interval between RPF cache rebuilds in milliseconds. + /// A value of 0 means rate-limiting is disabled. + pub interval_ms: u64, +} + +/// Request body for setting the RPF rebuild interval. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribRpfRebuildIntervalRequest { + /// Minimum interval between RPF cache rebuilds in milliseconds. + /// A value of 0 disables rate-limiting. + /// Every unicast RIB change triggers an immediate poptrie rebuild. + pub interval_ms: u64, +} + +/// 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 77 for fleet-scoped multicast). 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, +} + +fn default_multicast_vni() -> Vni { + Vni::DEFAULT_MULTICAST_VNI +} diff --git a/mgadm/src/main.rs b/mgadm/src/main.rs index 3ceec95c1..8e72ca1e3 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<()> { @@ -83,6 +90,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..84e96a113 --- /dev/null +++ b/mgadm/src/mrib.rs @@ -0,0 +1,483 @@ +// 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 mg_admin_client::Client; +use mg_admin_client::types::{ + MribRpfRebuildIntervalRequest, MulticastRoute, MulticastRouteKey, + RouteOriginFilter, Vni, +}; +use rdb::types::{AddressFamily, 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), + + /// RPF (Reverse Path Forwarding) table configuration and lookup. + Rpf(RpfCommand), +} + +#[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_MULTICAST_VNI)] + 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_MULTICAST_VNI)] + vni: u32, + + /// Filter by route origin ("static" or "dynamic"). + #[arg(long, value_parser = parse_route_origin)] + origin: Option, + }, +} + +#[derive(Debug, Args)] +pub struct RpfCommand { + #[command(subcommand)] + command: RpfCmd, +} + +#[derive(Subcommand, Debug)] +pub enum RpfCmd { + /// Get RPF rebuild interval. + GetInterval, + + /// Set RPF rebuild interval. + SetInterval { + /// Rebuild interval in milliseconds + interval_ms: u64, + }, +} + +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? + } + } + }, + Commands::Rpf(rpf_cmd) => match rpf_cmd.command { + RpfCmd::GetInterval => get_rpf_interval(c).await?, + RpfCmd::SetInterval { interval_ms } => { + set_rpf_interval(c, interval_ms).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 vni = Vni::from(vni); + 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!("{route:#?}"); + } else { + anyhow::bail!("route not found"); + } + Ok(()) +} + +async fn get_route_selected( + c: Client, + group: IpAddr, + source: Option, + vni: u32, +) -> Result<()> { + let vni = Vni::from(vni); + 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!("{route:#?}"); + } else { + anyhow::bail!("route not found in mrib_loc"); + } + Ok(()) +} + +async fn get_rpf_interval(c: Client) -> Result<()> { + let result = c.read_mrib_rpf_rebuild_interval().await?.into_inner(); + println!("RPF rebuild interval: {}ms", result.interval_ms); + Ok(()) +} + +async fn set_rpf_interval(c: Client, interval_ms: u64) -> Result<()> { + c.update_mrib_rpf_rebuild_interval(&MribRpfRebuildIntervalRequest { + interval_ms, + }) + .await?; + println!("Updated RPF rebuild interval to: {interval_ms}ms"); + Ok(()) +} + +fn print_routes(routes: &[MulticastRoute]) { + if routes.is_empty() { + println!("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!( + "({source_str},{group_str}) vni={vni} underlay={} rpf={:?} nexthops={} source={:?}", + route.underlay_group, + route.rpf_neighbor, + route.underlay_nexthops.len(), + 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(); + + match cli.command { + Commands::Status(cmd) => 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_MULTICAST_VNI); + } + _ => panic!("expected Imported"), + }, + _ => panic!("expected Status command"), + } + } + + #[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(); + + match cli.command { + Commands::Status(cmd) => 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"), + }, + _ => panic!("expected Status command"), + } + } + + #[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(); + + match cli.command { + Commands::Status(cmd) => 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"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_status_imported_list_with_af() { + let cli = + TestCli::try_parse_from(["test", "status", "imported", "ipv4"]) + .unwrap(); + + match cli.command { + Commands::Status(cmd) => match cmd.command { + StatusCmd::Imported { + group, + address_family, + .. + } => { + assert_eq!(group, None); + assert_eq!(address_family, Some(AddressFamily::Ipv4)); + } + _ => panic!("expected Imported"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_status_imported_list_with_origin() { + let cli = TestCli::try_parse_from([ + "test", "status", "imported", "--origin", "dynamic", + ]) + .unwrap(); + + match cli.command { + Commands::Status(cmd) => match cmd.command { + StatusCmd::Imported { group, origin, .. } => { + assert_eq!(group, None); + assert_eq!(origin, Some(RouteOriginFilter::Dynamic)); + } + _ => panic!("expected Imported"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_status_selected_list_all() { + let cli = + TestCli::try_parse_from(["test", "status", "selected"]).unwrap(); + + match cli.command { + Commands::Status(cmd) => 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"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_rpf_set_interval() { + let cli = + TestCli::try_parse_from(["test", "rpf", "set-interval", "500"]) + .unwrap(); + + match cli.command { + Commands::Rpf(cmd) => match cmd.command { + RpfCmd::SetInterval { interval_ms } => { + assert_eq!(interval_ms, 500); + } + _ => panic!("expected SetInterval"), + }, + _ => panic!("expected Rpf command"), + } + } +} diff --git a/mgadm/src/static_routing.rs b/mgadm/src/static_routing.rs index 761f187ef..3099caada 100644 --- a/mgadm/src/static_routing.rs +++ b/mgadm/src/static_routing.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 anyhow::Result; use clap::{Args, Subcommand}; use mg_admin_client::{Client, types}; @@ -11,12 +13,16 @@ use std::net::{Ipv4Addr, 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)] @@ -113,10 +119,40 @@ 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!("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!( + "({source_str}, {group_str}) vni={vni} underlay={} nexthops={}", + route.underlay_group, + route.underlay_nexthops.len(), + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/mgd/src/admin.rs b/mgd/src/admin.rs index bae5a04a9..668e83685 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::params::*; use bgp_admin::BgpContext; @@ -20,6 +22,10 @@ use mg_types::bgp::{ NeighborSelector, UnnumberedNeighborResetRequest, UnnumberedNeighborSelector, }; +use mg_types::mrib::{ + MribAddStaticRequest, MribDeleteStaticRequest, MribQuery, + MribRpfRebuildIntervalRequest, MribRpfRebuildIntervalResponse, +}; use mg_types::ndp::{NdpInterface, NdpInterfaceSelector, NdpManagerState}; use mg_types::rib::{ BestpathFanoutRequest, BestpathFanoutResponse, GetRibResult, Rib, RibQuery, @@ -621,6 +627,56 @@ 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 + } + + async fn read_mrib_rpf_rebuild_interval( + rqctx: RequestContext, + ) -> Result, HttpError> { + mrib_admin::read_mrib_rpf_rebuild_interval(rqctx).await + } + + async fn update_mrib_rpf_rebuild_interval( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + mrib_admin::update_mrib_rpf_rebuild_interval(rqctx, request).await + } } pub fn api_description() -> ApiDescription> { diff --git a/mgd/src/error.rs b/mgd/src/error.rs index e184c9809..7a9fb97b3 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,7 +37,15 @@ pub enum Error { impl From for HttpError { fn from(value: Error) -> Self { match value { - Error::Db(_) => Self::for_internal_error(value.to_string()), + Error::Db(ref db_err) => match db_err { + 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 02a77ad09..549015c29 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; @@ -33,6 +35,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..97dc9c42c --- /dev/null +++ b/mgd/src/mrib_admin.rs @@ -0,0 +1,184 @@ +// 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 std::time::Duration; + +use dropshot::{ + HttpError, HttpResponseDeleted, HttpResponseOk, + HttpResponseUpdatedNoContent, RequestContext, TypedBody, +}; + +use mg_types::mrib::{ + MribAddStaticRequest, MribDeleteStaticRequest, MribQuery, + MribRpfRebuildIntervalRequest, MribRpfRebuildIntervalResponse, + RouteOriginFilter, +}; +use rdb::types::{ + MulticastAddr, MulticastRoute, MulticastRouteKey, MulticastRouteSource, +}; + +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.as_u32()) + .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.as_u32()) + .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| { + let mut route = MulticastRoute::new( + input.key, + input.underlay_group, + MulticastRouteSource::Static, + ); + route.underlay_nexthops = + input.underlay_nexthops.into_iter().collect(); + route + }) + .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)) +} + +pub async fn read_mrib_rpf_rebuild_interval( + rqctx: RequestContext>, +) -> Result, HttpError> { + let ctx = rqctx.context(); + let interval = ctx + .db + .get_mrib_rpf_rebuild_interval() + .map_err(|e| HttpError::for_internal_error(format!("{e}")))?; + Ok(HttpResponseOk(MribRpfRebuildIntervalResponse { + interval_ms: u64::try_from(interval.as_millis()).unwrap_or(u64::MAX), + })) +} + +pub async fn update_mrib_rpf_rebuild_interval( + rqctx: RequestContext>, + request: TypedBody, +) -> Result { + let ctx = rqctx.context(); + let body = request.into_inner(); + let interval = Duration::from_millis(body.interval_ms); + ctx.db + .set_mrib_rpf_rebuild_interval(interval) + .map_err(|e| HttpError::for_internal_error(format!("{e}")))?; + Ok(HttpResponseUpdatedNoContent()) +} diff --git a/openapi/mg-admin/mg-admin-7.0.0-fba416.json.gitstub b/openapi/mg-admin/mg-admin-7.0.0-fba416.json.gitstub new file mode 100644 index 000000000..fa8c6377c --- /dev/null +++ b/openapi/mg-admin/mg-admin-7.0.0-fba416.json.gitstub @@ -0,0 +1 @@ +d20fd61909d7a4c06ec322962321cb61f2f4e252:openapi/mg-admin/mg-admin-7.0.0-fba416.json diff --git a/openapi/mg-admin/mg-admin-7.0.0-fba416.json b/openapi/mg-admin/mg-admin-8.0.0-5dd4ca.json similarity index 90% rename from openapi/mg-admin/mg-admin-7.0.0-fba416.json rename to openapi/mg-admin/mg-admin-8.0.0-5dd4ca.json index 5231dda4e..f8fa5eb82 100644 --- a/openapi/mg-admin/mg-admin-7.0.0-fba416.json +++ b/openapi/mg-admin/mg-admin-8.0.0-5dd4ca.json @@ -6,7 +6,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "7.0.0" + "version": "8.0.0" }, "paths": { "/bfd/peers": { @@ -1234,6 +1234,203 @@ } } }, + "/mrib/config/rpf/rebuild-interval": { + "get": { + "summary": "Get the RPF rebuild rate-limit interval.", + "operationId": "read_mrib_rpf_rebuild_interval", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MribRpfRebuildIntervalResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "summary": "Set the RPF rebuild rate-limit interval.", + "operationId": "update_mrib_rpf_rebuild_interval", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MribRpfRebuildIntervalRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/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 77 for fleet-scoped multicast). Only used when `group` is set.", + "schema": { + "$ref": "#/components/schemas/Vni" + } + } + ], + "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 77 for fleet-scoped multicast). Only used when `group` is set.", + "schema": { + "$ref": "#/components/schemas/Vni" + } + } + ], + "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 +1681,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", @@ -3725,6 +4000,237 @@ } ] }, + "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" + ] + }, + "MribRpfRebuildIntervalRequest": { + "description": "Request body for setting the RPF rebuild interval.", + "type": "object", + "properties": { + "interval_ms": { + "description": "Minimum interval between RPF cache rebuilds in milliseconds. A value of 0 disables rate-limiting. Every unicast RIB change triggers an immediate poptrie rebuild.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "interval_ms" + ] + }, + "MribRpfRebuildIntervalResponse": { + "description": "Response containing the current RPF rebuild interval.", + "type": "object", + "properties": { + "interval_ms": { + "description": "Minimum interval between RPF cache rebuilds in milliseconds. A value of 0 means rate-limiting is disabled.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "interval_ms" + ] + }, + "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": "Expected RPF neighbor for the source (for RPF checks).", + "type": "string", + "format": "ip" + }, + "source": { + "description": "Route source (static, IGMP, etc.).", + "allOf": [ + { + "$ref": "#/components/schemas/MulticastRouteSource" + } + ] + }, + "underlay_group": { + "description": "Underlay multicast group address (ff04::/64).", + "type": "string", + "format": "ipv6" + }, + "underlay_nexthops": { + "description": "Underlay unicast nexthops for multicast replication.\n\nUnicast IPv6 addresses where encapsulated overlay multicast traffic is forwarded. These are sled underlay addresses hosting VMs subscribed to the multicast group. Forms the outgoing interface list (OIL).", + "type": "array", + "items": { + "type": "string", + "format": "ipv6" + }, + "uniqueItems": true + }, + "updated": { + "description": "Last updated timestamp.\n\nOnly updated when route fields change semantically (rpf_neighbor, underlay_group, underlay_nexthops, source). An idempotent upsert with an identical value does not update this timestamp.", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "created", + "key", + "source", + "underlay_group", + "underlay_nexthops", + "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" + ] + }, + "MulticastRouteSource": { + "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", @@ -5244,6 +5750,38 @@ "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" + }, + "underlay_nexthops": { + "description": "Underlay unicast nexthops for multicast replication.\n\nUnicast IPv6 addresses where encapsulated overlay multicast traffic is forwarded. These are sled underlay addresses hosting VMs subscribed to the multicast group. Forms the outgoing interface list (OIL).", + "type": "array", + "items": { + "type": "string", + "format": "ipv6" + } + } + }, + "required": [ + "key", + "underlay_group", + "underlay_nexthops" + ] + }, "StaticRoute4": { "type": "object", "properties": { @@ -5752,6 +6290,31 @@ } ] }, + "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" + ] + } + ] + }, + "Vni": { + "description": "A Geneve Virtual Network Identifier", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, "ProtocolFilter": { "oneOf": [ { diff --git a/openapi/mg-admin/mg-admin-latest.json b/openapi/mg-admin/mg-admin-latest.json index 0858fdf0b..90e4e89b5 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-7.0.0-fba416.json \ No newline at end of file +mg-admin-8.0.0-5dd4ca.json \ No newline at end of file diff --git a/rdb-types/src/lib.rs b/rdb-types/src/lib.rs index 4705985ce..af365e515 100644 --- a/rdb-types/src/lib.rs +++ b/rdb-types/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 + //! Core types for routing database operations, shared across maghemite components. //! //! This crate provides the fundamental types used for representing network prefixes @@ -59,22 +61,20 @@ impl Prefix4 { new } - pub fn host_bits_are_unset(&self) -> bool { - let mask = match self.length { + /// Return the network mask as a `u32` with the top `length` bits set. + pub fn mask(&self) -> u32 { + match self.length { 0 => 0, _ => u32::MAX << (Self::HOST_MASK - self.length), - }; + } + } - self.value.to_bits() & mask == self.value.to_bits() + pub fn host_bits_are_unset(&self) -> bool { + self.value.to_bits() & self.mask() == self.value.to_bits() } pub fn unset_host_bits(&mut self) { - let mask = match self.length { - 0 => 0, - _ => u32::MAX << (Self::HOST_MASK - self.length), - }; - - self.value = Ipv4Addr::from_bits(self.value.to_bits() & mask) + self.value = Ipv4Addr::from_bits(self.value.to_bits() & self.mask()) } /// Check if this prefix is contained within another prefix. @@ -187,22 +187,20 @@ impl Prefix6 { new } - pub fn host_bits_are_unset(&self) -> bool { - let mask = match self.length { + /// Return the network mask as a `u128` with the top `length` bits set. + pub fn mask(&self) -> u128 { + match self.length { 0 => 0, _ => u128::MAX << (Self::HOST_MASK - self.length), - }; + } + } - self.value.to_bits() & mask == self.value.to_bits() + pub fn host_bits_are_unset(&self) -> bool { + self.value.to_bits() & self.mask() == self.value.to_bits() } pub fn unset_host_bits(&mut self) { - let mask = match self.length { - 0 => 0, - _ => u128::MAX << (Self::HOST_MASK - self.length), - }; - - self.value = Ipv6Addr::from_bits(self.value.to_bits() & mask) + self.value = Ipv6Addr::from_bits(self.value.to_bits() & self.mask()) } /// Check if this prefix is contained within another prefix. diff --git a/rdb/Cargo.toml b/rdb/Cargo.toml index 7cce97825..2b7c5e7a4 100644 --- a/rdb/Cargo.toml +++ b/rdb/Cargo.toml @@ -18,6 +18,8 @@ chrono.workspace = true clap = { workspace = true, optional = true } oxnet.workspace = true rdb-types = { workspace = true, features = ["clap"] } +poptrie.workspace = true +omicron-common.workspace = true ndp.workspace = true [dev-dependencies] diff --git a/rdb/src/db.rs b/rdb/src/db.rs index a0ad67082..d6e9671c1 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 maghmite routing database. The routing database holds both @@ -9,19 +11,37 @@ //! 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_common::{lock, read_lock, write_lock}; 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}; @@ -59,6 +79,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"; @@ -72,10 +96,39 @@ 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 rebuild interval. +const MRIB_RPF_REBUILD_INTERVAL: &str = "mrib_rpf_rebuild_interval"; + +/// Key used in settings tree for MRIB RPF revalidation interval. +const MRIB_RPF_REVALIDATION_INTERVAL: &str = "mrib_rpf_revalidation_interval"; + +/// Default MRIB RPF rebuild interval in milliseconds. +/// +/// Multicast route additions can be bursty, and poptrie rebuilds can be +/// expensive. During rebuilds, RPF verification falls back to linear scan. +const DEFAULT_MRIB_RPF_REBUILD_INTERVAL_MS: u64 = 1000; + pub type Rib = BTreeMap>; pub type Rib4 = BTreeMap>; pub type Rib6 = BTreeMap>; +/// 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)] @@ -99,6 +152,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, @@ -114,6 +178,7 @@ pub struct Db { log: Logger, } + unsafe impl Sync for Db {} unsafe impl Send for Db {} @@ -123,23 +188,85 @@ 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 rebuild interval from settings and apply to `RpfTable` + let rebuild_interval = db.get_mrib_rpf_rebuild_interval().unwrap_or_else(|e| { + error!( + db.log, + "failed to load mrib_rpf_rebuild_interval from settings: {e}" + ); + std::time::Duration::from_millis( + DEFAULT_MRIB_RPF_REBUILD_INTERVAL_MS, + ) + }); + + db.rpf_table.set_rebuild_interval(rebuild_interval); + + // 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) { @@ -150,6 +277,103 @@ 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 separate from the poptrie rebuild interval (see + /// [`Self::set_mrib_rpf_rebuild_interval`]). + /// + /// 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 }); @@ -651,20 +875,12 @@ impl Db { rib_loc: &mut Rib4, prefix: &Prefix4, ) { - 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()); @@ -680,6 +896,11 @@ impl Db { rib_loc.remove(prefix); } } + + // Request RPF table rebuild (may be rate-limited). + // Pass the specific prefix for targeted (S,G) revalidation. + self.rpf_table + .trigger_rebuild_v4(Arc::clone(&self.rib4_loc), Some(*prefix)); } pub fn update_rib6_loc( @@ -688,20 +909,12 @@ impl Db { rib_loc: &mut Rib6, prefix: &Prefix6, ) { - 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()); @@ -717,6 +930,11 @@ impl Db { rib_loc.remove(prefix); } } + + // Request RPF table rebuild (may be rate-limited). + // Pass the specific prefix for targeted (S,G) revalidation. + self.rpf_table + .trigger_rebuild_v6(Arc::clone(&self.rib6_loc), Some(*prefix)); } // generic helper function to kick off a bestpath run for some @@ -1351,10 +1569,60 @@ 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(()) } + /// Get the minimum interval between poptrie cache rebuilds. + /// + /// This rate-limits how often the poptrie is rebuilt in response to + /// unicast RIB changes. When rate-limited, lookups fall back to linear + /// scan. This is separate from the revalidation sweep interval (see + /// [`Self::set_mrib_rpf_revalidation_interval`]). + pub fn get_mrib_rpf_rebuild_interval( + &self, + ) -> Result { + let tree = self.persistent.open_tree(SETTINGS)?; + let interval_ms = match tree.get(MRIB_RPF_REBUILD_INTERVAL)? { + None => DEFAULT_MRIB_RPF_REBUILD_INTERVAL_MS, + Some(value) => { + let bytes: [u8; 8] = (*value).try_into().map_err(|_| { + Error::DbValue(format!( + "invalid mrib_rpf_rebuild_interval value in db: expected 8 bytes, found {}", + value.len() + )) + })?; + u64::from_be_bytes(bytes) + } + }; + Ok(std::time::Duration::from_millis(interval_ms)) + } + + /// Set the minimum interval between poptrie cache rebuilds. + /// + /// This rate-limits how often the poptrie is rebuilt in response to + /// unicast RIB changes. When rate-limited, lookups fall back to linear + /// scan. + /// + /// This is persisted to the settings tree. + pub fn set_mrib_rpf_rebuild_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_REBUILD_INTERVAL, &interval_ms.to_be_bytes())?; + tree.flush()?; + self.rpf_table.set_rebuild_interval(interval); + Ok(()) + } + pub fn mark_bgp_peer_stale4(&self, peer: PeerId) { let mut rib = lock!(self.rib4_loc); rib.iter_mut().for_each(|(_prefix, path)| { @@ -1399,19 +1667,300 @@ impl Db { }); } - pub fn slot(&self) -> Option { - match self.slot.read() { - Ok(v) => *v, + // ======================================================================== + // 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 unicast RIB + let fanout = self.config.bestpath_fanout.load(Ordering::Relaxed); + 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); + } + + /// 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, "unable to read switch slot"; "error" => %e); - None + 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); } } - pub fn set_slot(&mut self, slot: Option) { - let mut value = self.slot.write().unwrap(); - *value = slot; + /// 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)?; + + // Pre-serialize keys and values outside the transaction to + // keep fallible serde operations out of the closure. + let entries: Vec<(Vec, String)> = routes + .iter() + .map(|route| { + Ok((route.key.db_key()?, serde_json::to_string(route)?)) + }) + .collect::>()?; + + // Persist atomically before updating the in-memory MRIB, so a + // partial write never leaves config and state out of sync. + 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(()) + } + + /// 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()) + .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(()) + } + + /// 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) { @@ -1450,38 +1999,48 @@ 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::{ - AddressFamily, DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, - Prefix6, StaticRouteKey, db::Db, test::TestDb, types::PrefixDbKey, - types::test_helpers::path_vecs_equal, + AddressFamily, DEFAULT_MULTICAST_VNI, DEFAULT_RIB_PRIORITY_STATIC, + Path, Prefix, Prefix4, Prefix6, StaticRouteKey, + db::Db, + test::{TEST_WAIT_ITERATIONS, TestDb}, + types::{ + MulticastAddr, MulticastAddrV4, MulticastAddrV6, MulticastRoute, + MulticastRouteKey, MulticastRouteSource, PrefixDbKey, + UnderlayMulticastIpv6, test_helpers::path_vecs_equal, + }, }; use mg_common::log::*; + use mg_common::test::DEFAULT_INTERVAL; + use mg_common::wait_for; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::str::FromStr; + use std::time::Duration; + + 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"); @@ -1801,6 +2360,293 @@ 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, + DEFAULT_MULTICAST_VNI, + ) + .expect("AF match"); + let route = MulticastRoute::new( + key, + test_underlay(), + MulticastRouteSource::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"); + db.set_mrib_rpf_rebuild_interval(std::time::Duration::ZERO) + .unwrap(); + + // 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"), + ); + } + + /// 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"); + db.set_mrib_rpf_rebuild_interval(Duration::ZERO).unwrap(); + + // 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(), + MulticastRouteSource::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 = Ipv4Addr::new(10, 0, 0, 100); + let sg_key = MulticastRouteKey::source_specific_v4(source, ssm_group); + let sg_route = MulticastRoute::new( + sg_key, + test_underlay(), + MulticastRouteSource::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(), + MulticastRouteSource::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 = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x100); + let v6_sg_key = + MulticastRouteKey::source_specific_v6(v6_source, v6_ssm_group); + let v6_sg_route = MulticastRoute::new( + v6_sg_key, + test_underlay(), + MulticastRouteSource::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(), + MulticastRouteSource::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..fab696e29 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}")] @@ -13,6 +15,9 @@ pub enum Error { #[error("serialization error {0}")] Serialization(#[from] serde_json::Error), + #[error("io error {0}")] + Io(#[from] std::io::Error), + #[error("db key error {0}")] DbKey(String), @@ -24,4 +29,10 @@ pub enum Error { #[error("Parsing error {0}")] Parsing(String), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Validation error: {0}")] + Validation(String), } diff --git a/rdb/src/lib.rs b/rdb/src/lib.rs index 58a494f0f..bd606767d 100644 --- a/rdb/src/lib.rs +++ b/rdb/src/lib.rs @@ -2,10 +2,14 @@ // 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 types; pub use db::Db; +pub use mrib::Mrib; pub use types::*; pub mod bestpath; pub mod error; diff --git a/rdb/src/mrib/mod.rs b/rdb/src/mrib/mod.rs new file mode 100644 index 000000000..f942a6b3e --- /dev/null +++ b/rdb/src/mrib/mod.rs @@ -0,0 +1,714 @@ +// 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, Ipv6Addr}; +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 slog::{Logger, error, info}; + +use mg_common::{lock, read_lock, write_lock}; + +use crate::error::Error; +use crate::types::{ + AddressFamily, MribChangeNotification, MulticastAddr, MulticastRoute, + MulticastRouteKey, MulticastRouteSource, +}; + +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, MulticastRouteSource::Static) + } + Some(false) => { + !matches!(route.source, MulticastRouteSource::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().underlay_nexthops == route.underlay_nexthops + && 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 + route.rpf_neighbor = neighbor; + + // 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().underlay_nexthops + == route.underlay_nexthops + && 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`. + /// + /// 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`. + /// + /// Accepts a full [MulticastRoute]. + pub fn add_route(&self, route: MulticastRoute) -> Result<(), Error> { + let key = route.key; + let changed = { + let mut mrib_in = lock!(self.mrib_in); + let changed = match mrib_in.get(&key) { + Some(existing) => { + // Check if route actually changed + existing.underlay_nexthops != route.underlay_nexthops + || existing.rpf_neighbor != route.rpf_neighbor + || existing.source != route.source + || existing.underlay_group != route.underlay_group + } + None => true, // New route + }; + mrib_in.insert(key, route); + changed + }; + + if changed { + self.notify(MribChangeNotification::from(key)); + } + Ok(()) + } + + /// Remove a multicast route from both `mrib_in` and `mrib_loc`. + pub 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) + } + + /// Add a replication target to an existing route in both `mrib_in` and + /// `mrib_loc`. + pub fn add_target( + &self, + key: &MulticastRouteKey, + target: Ipv6Addr, + ) -> Result<(), Error> { + // Acquire both locks following documented order to ensure atomicity + let changed = { + let mut mrib_in = lock!(self.mrib_in); + let mut mrib_loc = lock!(self.mrib_loc); + + let changed_in = if let Some(route) = mrib_in.get_mut(key) { + route.add_target(target) + } else { + return Err(Error::NotFound(format!( + "multicast route {key} not found", + ))); + }; + + let changed_loc = if let Some(route) = mrib_loc.get_mut(key) { + route.add_target(target) + } else { + false + }; + + changed_in || changed_loc + }; + + if changed { + self.notify(MribChangeNotification::from(*key)); + } + Ok(()) + } + + /// Remove a replication target from an existing route in both `mrib_in` and + /// `mrib_loc`. + pub fn remove_target( + &self, + key: &MulticastRouteKey, + target: &Ipv6Addr, + ) -> 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 = if let Some(route) = mrib_in.get_mut(key) { + route.remove_target(target) + } else { + return Err(Error::NotFound(format!( + "multicast route {key} not found", + ))); + }; + + let removed_loc = if let Some(route) = mrib_loc.get_mut(key) { + route.remove_target(target) + } else { + false + }; + + 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; + + use mg_common::log::*; + + use crate::types::{ + MulticastAddrV4, MulticastAddrV6, UnderlayMulticastIpv6, + }; + + 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(), + MulticastRouteSource::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 = Ipv4Addr::new(10, 0, 0, 1); + 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 mut route_sg = MulticastRoute::new( + key_sg, + test_underlay(), + MulticastRouteSource::Static, + ); + + // Add replication targets + let target1 = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1); + let target2 = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 2); + + route_sg.add_target(target1); + route_sg.add_target(target2); + + 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(), + MulticastRouteSource::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(), + MulticastRouteSource::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(), + MulticastRouteSource::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 = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1); + 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(), + MulticastRouteSource::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..76b8870d0 --- /dev/null +++ b/rdb/src/mrib/rpf.rs @@ -0,0 +1,1274 @@ +// 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 a lazy +//! rebuild strategy and fallback to linear scan during rebuilds. RPF lookups +//! happen frequently during multicast route installation and unicast RIB +//! changes, requiring LPM against the unicast RIB. +//! +//! ## (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. +//! +//! ## Lock Poisoning +//! +//! The poptrie cache uses asymmetric poison handling: +//! +//! - **Write side** (background rebuild thread): Panics on poison via +//! `write_lock!`. +//! +//! - **Read side**: Uses `.ok()` for graceful fallback to linear +//! scan if the cache is poisoned. This avoids crashing during RPF checks +//! while the linear scan fallback remains intact. +//! +//! Once an `RwLock` is poisoned, it **cannot be unpoisoned**. +//! Subsequent rebuild attempts will also panic on `write_lock!`, so the cache +//! remains permanently disabled. Reads continue to work via the +//! linear-scan fallback, keeping the system functional until SMF restarts the +//! daemon (which is the normal recovery path here). +//! +//! ## Threading Model +//! +//! Poptrie cache rebuilds run in short-lived, named background threads +//! ("rpf-poptrie-v4"/"rpf-poptrie-v6"). These threads are fire-and-forget: +//! we deliberately drop their `JoinHandle`s. If a rebuild thread panics, the +//! cache is simply not updated and RPF verification transparently falls back +//! to the linear-scan path until the next successful rebuild. +//! +//! [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::{AtomicU8, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use poptrie::Poptrie; +use slog::{Logger, debug, error}; + +use mg_common::{lock, write_lock}; + +use crate::bestpath::bestpaths; +use crate::db::{Rib4, Rib6}; +use crate::types::{Path, Prefix, PrefixContains}; +use crate::{Prefix4, Prefix6}; + +/// Default interval for periodic RPF revalidation sweeps. +pub const DEFAULT_REVALIDATION_INTERVAL: Duration = Duration::from_secs(60); + +/// Event emitted when RPF revalidation is needed. +/// +/// This is emitted when a poptrie rebuild completes, or when a rebuild +/// request was rate-limited but multicast RPF revalidation should still +/// proceed using the linear-scan fallback. +/// +/// The optional prefix ([`Prefix4`]/[`Prefix6`]) 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 { + /// Convert to a full-sweep event (no specific prefix). + /// + /// Used when multiple prefixes may have changed during pending rebuilds. + fn to_full_sweep(self) -> Self { + match self { + Self::V4(_) => Self::V4(None), + Self::V6(_) => Self::V6(None), + } + } + + /// 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) { + (src, RebuildEvent::V4(Some(prefix))) => { + Prefix::V4(*prefix).contains(src).is_some() + } + (src, RebuildEvent::V6(Some(prefix))) => { + Prefix::V6(*prefix).contains(src).is_some() + } + // 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, + } + } +} + +/// Set of paths for a prefix, stored in the poptrie cache. +/// +/// We store full [`Path`] objects (not just nexthops) so that we can apply +/// bestpath selection at lookup time. This ensures consistent behavior +/// between the poptrie fast path and linear scan fallback, regardless of +/// the configured fanout value. +pub(crate) type CachedPaths = BTreeSet; + +/// State machine for coordinating poptrie rebuilds. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum RebuildState { + /// No rebuild in progress. + Idle = 0, + /// Rebuild thread is running. + Running = 1, + /// Rebuild thread is running and more changes arrived. + RunningPending = 2, +} + +/// Coordinator for poptrie rebuild threads. +/// +/// Provides atomic state transitions to prevent race conditions where +/// pending work could be missed between checking the pending flag and +/// releasing the in-progress lock. +#[derive(Debug)] +struct RebuildCoordinator(AtomicU8); + +impl RebuildCoordinator { + /// Create a new coordinator in the idle state. + fn new() -> Self { + Self(AtomicU8::new(RebuildState::Idle as u8)) + } + + /// Try to start a rebuild. Returns `true` if this thread should do work. + /// + /// Atomically transitions `Idle → Running`. + fn try_start(&self) -> bool { + self.0 + .compare_exchange( + RebuildState::Idle as u8, + RebuildState::Running as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + /// Signal that more work arrived while a rebuild is in progress. + /// + /// Atomically transitions `Running → RunningPending`. If already + /// `RunningPending` or `Idle`, this is a no-op. + fn signal_pending(&self) { + // Only transition Running → RunningPending + let _ = self.0.compare_exchange( + RebuildState::Running as u8, + RebuildState::RunningPending as u8, + Ordering::AcqRel, + Ordering::Relaxed, + ); + } + + /// Check if more work is pending and atomically clear the pending flag. + /// + /// Returns `true` if we should continue working (previously `RunningPending`). + /// Atomically transitions `RunningPending → Running`. + fn check_pending(&self) -> bool { + self.0 + .compare_exchange( + RebuildState::RunningPending as u8, + RebuildState::Running as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + /// Mark the rebuild as complete. + /// + /// Transitions to `Idle`. Should only be called by the rebuild thread. + fn finish(&self) { + self.0.store(RebuildState::Idle as u8, Ordering::Release); + } +} + +/// Scope guard to mark rebuild as finished on drop. +/// +/// This ensures the coordinator transitions to `Idle` even if the rebuild +/// thread panics, preventing deadlock. +struct RebuildGuard(Arc); + +impl Drop for RebuildGuard { + fn drop(&mut self) { + self.0.finish(); + } +} + +/// Address-family related rebuild job. +/// +/// Encapsulates the RIB source and cache destination for a poptrie rebuild, +/// allowing the rebuild logic to be shared between IPv4 and IPv6. +enum RebuildJob { + V4 { + rib: Arc>, + cache: Arc>>>, + }, + V6 { + rib: Arc>, + cache: Arc>>>, + }, +} + +impl RebuildJob { + /// Take a snapshot of the RIB and build a fresh poptrie cache. + fn rebuild(&self) { + match self { + Self::V4 { rib, cache } => { + let snapshot = { + let r = lock!(rib); + RpfTable::snapshot_rib(&r, |p| (p.value.octets(), p.length)) + }; + let mut table = poptrie::Ipv4RoutingTable::default(); + for (addr, len, paths) in snapshot { + table.insert((addr, len), paths); + } + *write_lock!(cache) = Some(Poptrie::from(table)); + } + Self::V6 { rib, cache } => { + let snapshot = { + let r = lock!(rib); + RpfTable::snapshot_rib(&r, |p| (p.value.octets(), p.length)) + }; + let mut table = poptrie::Ipv6RoutingTable::default(); + for (addr, len, paths) in snapshot { + table.insert((addr, len), paths); + } + *write_lock!(cache) = Some(Poptrie::from(table)); + } + } + } + + /// Thread name for debugging. + fn thread_name(&self) -> &'static str { + match self { + Self::V4 { .. } => "rpf-poptrie-v4", + Self::V6 { .. } => "rpf-poptrie-v6", + } + } +} + +/// 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. +/// The cache is rebuilt asynchronously in the background when triggered, with +/// rate limiting to avoid excessive rebuilds. Falls back to linear scan during +/// rebuilds or if poptrie is unavailable. +/// +/// The poptrie stores full [`Path`] objects (not just nexthops) so that +/// bestpath selection can be applied at lookup time. This ensures consistent +/// RPF verification behavior regardless of whether poptrie or linear scan is +/// used. +#[derive(Clone)] +pub(crate) struct RpfTable { + /// IPv4 poptrie cache. + cache_v4: Arc>>>, + /// IPv6 poptrie cache. + cache_v6: Arc>>>, + /// Last rebuild completion time for rate limiting. + /// + /// Shared between v4 and v6 rebuilds. During route updates + /// affecting both address families, this prevents simultaneous + /// rebuilds and spreads the CPU load. The fallback is a linear scan. + last_rebuild: Arc>>, + /// Configurable minimum interval between rebuilds (milliseconds). + /// A value of 0 disables rate-limiting. + rebuild_interval_ms: Arc, + /// Optional notifier for rebuild-complete events. + rebuild_notifier: Arc>>>, + /// Coordinator for IPv4 poptrie rebuilds. + rebuild_v4: Arc, + /// Coordinator for IPv6 poptrie rebuilds. + rebuild_v6: Arc, + /// Logger for error reporting. + log: Logger, +} + +impl RpfTable { + /// Default minimum time between rebuilds (milliseconds). + const DEFAULT_REBUILD_INTERVAL_MS: u64 = 1000; + + /// Create a new empty RPF table with default rebuild interval. + pub fn new(log: Logger) -> Self { + Self { + cache_v4: Arc::new(RwLock::new(None)), + cache_v6: Arc::new(RwLock::new(None)), + last_rebuild: Arc::new(Mutex::new(None)), + rebuild_interval_ms: Arc::new(AtomicU64::new( + Self::DEFAULT_REBUILD_INTERVAL_MS, + )), + rebuild_notifier: Arc::new(Mutex::new(None)), + rebuild_v4: Arc::new(RebuildCoordinator::new()), + rebuild_v6: Arc::new(RebuildCoordinator::new()), + log, + } + } + + /// Set the minimum interval between rebuilds. A zeroed duration + /// disables rate-limiting, causing every trigger to rebuild + /// immediately. + pub fn set_rebuild_interval(&self, interval: Duration) { + self.rebuild_interval_ms.store( + u64::try_from(interval.as_millis()).unwrap(), + Ordering::Relaxed, + ); + } + + /// Check if enough time has passed since the last rebuild. + /// Returns `true` if rebuild should proceed, `false` if rate limited. + fn check_rate_limit(&self) -> bool { + let min_interval_ms = self.rebuild_interval_ms.load(Ordering::Relaxed); + let min_interval = Duration::from_millis(min_interval_ms); + + if let Ok(last) = self.last_rebuild.lock() + && let Some(last_instant) = *last + && last_instant.elapsed() < min_interval + { + return false; // Skip rebuild, too soon + } + true + } + + /// Send a rebuild event notification if configured. + fn notify(&self, event: RebuildEvent) { + if let Ok(guard) = self.rebuild_notifier.lock() + && let Some(tx) = &*guard + && tx.send(event).is_err() + { + debug!(self.log, "rpf revalidator not running"); + } + } + + /// Snapshot a RIB for poptrie rebuild. + /// + /// Extracts (addr_bits, prefix_len, paths) tuples from the RIB. + /// The `to_bits` closure converts the prefix to address bits. + fn snapshot_rib( + rib: &BTreeMap>, + to_bits: F, + ) -> Vec<(A, u8, BTreeSet)> + where + F: Fn(&P) -> (A, u8), + { + rib.iter() + .filter(|(_, paths)| !paths.is_empty()) + .map(|(prefix, paths)| { + let (bits, len) = to_bits(prefix); + (bits, len, paths.clone()) + }) + .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); + } + } + + /// Spawn a background thread to execute a rebuild job. + /// + /// This is the shared implementation for both IPv4 and IPv6 rebuilds. + /// The job encapsulates the address-family-specific parts (RIB, cache), + /// while this method handles the shared logic (coordinator, timing, notify). + /// + /// The `event` parameter is used for targeted revalidation: if we complete + /// without looping, we send the original prefix so only affected (S,G) + /// routes are re-checked. If pending changes caused us to loop, we send + /// a full-sweep event (`None` prefix) since multiple prefixes may have + /// changed. + fn spawn_rebuild( + &self, + job: RebuildJob, + coordinator: Arc, + event: RebuildEvent, + ) { + let last_rebuild = self.last_rebuild.clone(); + let notifier = self.rebuild_notifier.clone(); + let log = self.log.clone(); + let thread_name = job.thread_name().to_string(); + let thread_coord = Arc::clone(&coordinator); + + if let Err(e) = + thread::Builder::new() + .name(thread_name.clone()) + .spawn(move || { + let _guard = RebuildGuard(Arc::clone(&thread_coord)); + + // Track whether we looped due to pending changes. + let mut looped = false; + + // Loop while there are pending rebuilds. This ensures we + // capture all RIB changes that occurred during the rebuild. + loop { + job.rebuild(); + + if let Ok(mut last) = last_rebuild.lock() { + *last = Some(Instant::now()); + } + + // Atomically check if more changes arrived during rebuild. + // If so, loop again to capture them. + if !thread_coord.check_pending() { + break; + } + looped = true; + } + + // Notify revalidator. If we looped, multiple prefixes may + // have changed so we send a full-sweep event. + if let Ok(guard) = notifier.lock() + && let Some(tx) = &*guard + { + let final_event = + if looped { event.to_full_sweep() } else { event }; + let _ = tx.send(final_event); + } + }) + { + error!(log, "failed to spawn {thread_name}: {e}"); + coordinator.finish(); + self.notify(event); + } + } + + /// Trigger a background rebuild of the IPv4 RPF cache. + /// + /// The RIB snapshot is taken lazily in the background thread, reducing + /// lock contention during RIB updates. + /// + /// The `changed_prefix` ([`Prefix4`]) parameter enables targeted + /// revalidation: only (S,G) routes whose source falls within this prefix + /// need RPF re-checking. + /// + /// This trigger is rate limited based on configured interval. Only one + /// rebuild can be in progress at a time per address family. + pub fn trigger_rebuild_v4( + &self, + rib4_loc: Arc>, + changed_prefix: Option, + ) { + if !self.check_rate_limit() { + // Clear cache to force linear-scan fallback until next rebuild. + // This ensures lookups use fresh RIB data rather than stale cache. + if let Ok(mut guard) = self.cache_v4.write() { + *guard = None; + } + self.notify(RebuildEvent::V4(changed_prefix)); + return; + } + + if !self.rebuild_v4.try_start() { + self.rebuild_v4.signal_pending(); + return; + } + + let job = RebuildJob::V4 { + rib: rib4_loc, + cache: self.cache_v4.clone(), + }; + + self.spawn_rebuild( + job, + Arc::clone(&self.rebuild_v4), + RebuildEvent::V4(changed_prefix), + ); + } + + /// Trigger a background rebuild of the IPv6 RPF cache. + /// + /// The RIB snapshot is taken lazily in the background thread, reducing + /// lock contention during RIB updates. + /// + /// The `changed_prefix` ([`Prefix6`]) parameter enables targeted + /// revalidation: only (S,G) routes whose source falls within this prefix + /// need RPF re-checking. + /// + /// This trigger is rate limited based on configured interval. Only one + /// rebuild can be in progress at a time per address family. + pub fn trigger_rebuild_v6( + &self, + rib6_loc: Arc>, + changed_prefix: Option, + ) { + if !self.check_rate_limit() { + // Clear cache to force linear-scan fallback until next rebuild. + // This ensures lookups use fresh RIB data rather than stale cache. + if let Ok(mut guard) = self.cache_v6.write() { + *guard = None; + } + self.notify(RebuildEvent::V6(changed_prefix)); + return; + } + + if !self.rebuild_v6.try_start() { + self.rebuild_v6.signal_pending(); + return; + } + + let job = RebuildJob::V6 { + rib: rib6_loc, + cache: self.cache_v6.clone(), + }; + + self.spawn_rebuild( + job, + Arc::clone(&self.rebuild_v6), + 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 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_paths = 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(paths) = cached_paths { + return Self::get_rpf_neighbor(&paths, 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) + let mut best_paths: Option<&BTreeSet> = None; + let mut best_len = 0u8; + + let source_bits = u32::from(source); + for (prefix, paths) in rib.iter() { + let prefix_bits = u32::from(prefix.value); + let mask = prefix.mask(); + if (prefix_bits & mask) == (source_bits & mask) + && prefix.length > best_len + { + best_len = prefix.length; + 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) + let mut best_paths: Option<&BTreeSet> = None; + let mut best_len = 0u8; + + let source_bits = u128::from(source); + for (prefix, paths) in rib.iter() { + let prefix_bits = u128::from(prefix.value); + let mask = prefix.mask(); + if (prefix_bits & mask) == (source_bits & mask) + && prefix.length > best_len + { + best_len = prefix.length; + 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 the first active nexthop. All paths in loc-rib + /// are valid ECMP paths (already bestpath-selected), so any one suffices + /// for RPF verification. + fn get_rpf_neighbor( + paths: &BTreeSet, + fanout: usize, + ) -> Option { + let active_paths: BTreeSet = + paths.iter().filter(|p| !p.shutdown).cloned().collect(); + + if active_paths.is_empty() { + return None; + } + + if fanout == 1 { + bestpaths(&active_paths, 1) + .and_then(|selected| selected.iter().next().map(|p| p.nexthop)) + } else { + active_paths.iter().next().map(|p| p.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 crate::{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())) + } + + /// 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: Prefix4 = "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_table_with_poptrie() { + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Prefix4 = "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) + ); + } + + #[test] + fn test_rpf_table_shutdown_paths() { + // Test that shutdown paths are filtered out + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Prefix4 = "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: Prefix4 = "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: Prefix4 = "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: Prefix6 = "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: Prefix6 = "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: Prefix4 = "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: Prefix4 = "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: Prefix6 = "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: Prefix6 = "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: Prefix6 = "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: Prefix6 = "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 a4bba03fb..3ab13a686 100644 --- a/rdb/src/proptest.rs +++ b/rdb/src/proptest.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 + //! Property-based tests for Prefix types using proptest //! //! These tests verify key invariants of the Prefix types to ensure @@ -11,13 +13,38 @@ use crate::{ BgpNeighborParameters, types::{ - BgpNeighborInfo, ImportExportPolicy4, ImportExportPolicy6, Prefix, - Prefix4, Prefix6, StaticRouteKey, + BgpNeighborInfo, DEFAULT_MULTICAST_VNI, ImportExportPolicy4, + ImportExportPolicy6, MulticastAddr, MulticastAddrV4, MulticastAddrV6, + MulticastRoute, MulticastRouteKey, MulticastRouteKeyV4, + MulticastRouteKeyV6, MulticastRouteSource, Prefix, Prefix4, Prefix6, + StaticRouteKey, UnderlayMulticastIpv6, }, }; +use omicron_common::address::{ + IPV4_MULTICAST_RANGE, IPV4_SSM_SUBNET, IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, + IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, IPV6_LINK_LOCAL_MULTICAST_SUBNET, + IPV6_MULTICAST_PREFIX, IPV6_SSM_SUBNET, +}; +use omicron_common::api::external::Vni; use proptest::{prelude::*, strategy::Just}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +/// 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 IPv6 multicast scope value (4 bits). +const MAX_MULTICAST_SCOPE: u8 = 0xf; + +/// 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; + // Strategy for generating valid IPv4 prefixes fn ipv4_prefix_strategy() -> impl Strategy { (any::(), 0u8..=32u8).prop_map(|(addr_bits, length)| { @@ -520,3 +547,1002 @@ proptest! { ); } } + +// ============================================================================ +// Multicast address and route-key property tests and setup +// ============================================================================ + +// Strategy for generating IPv4 unicast addresses (non-multicast, non-loopback) +// Generates directly in valid ranges to avoid filter rejection limits +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)), + ] +} + +// Strategy for generating IPv6 unicast addresses (non-multicast, non-loopback) +// Generates directly in valid ranges to avoid filter rejection limits +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) + } + }) +} + +// Strategy for generating valid VNIs (0 to Vni::MAX_VNI) +fn valid_vni_strategy() -> impl Strategy { + 0u32..=Vni::MAX_VNI +} + +// Strategy for generating invalid VNIs (> Vni::MAX_VNI) +fn invalid_vni_strategy() -> impl Strategy { + (Vni::MAX_VNI + 1)..=u32::MAX +} + +// Strategy for underlay multicast addresses within ff04::/64. +fn admin_local_multicast_strategy() +-> impl Strategy { + any::().prop_map(|bits| { + let lower = bits | 1; // ensure non-zero group ID + let addr = Ipv6Addr::new( + IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, + 0, + 0, + 0, + (lower >> 48) as u16, + (lower >> 32) as u16, + (lower >> 16) as u16, + lower as u16, + ); + UnderlayMulticastIpv6::new(addr).expect("valid underlay address") + }) +} + +// Strategy for generating IPv6 multicast addresses that are not admin-local +// Admin-local scope is derived from IPV6_ADMIN_SCOPED_MULTICAST_PREFIX +// Scopes 0-2 are rejected by MulticastAddrV6::new (reserved, interface-local, +// link-local), so we use scope 3 or 5-15. +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") + }) +} + +// Strategy for routable IPv6 unicast (not link-local, loopback, unspecified) +fn routable_ipv6_unicast_strategy() -> impl Strategy { + any::().prop_filter_map("must be routable unicast", |bits| { + let addr = Ipv6Addr::from(bits); + if !addr.is_multicast() + && !addr.is_loopback() + && !addr.is_unspecified() + && !addr.is_unicast_link_local() + { + Some(addr) + } else { + None + } + }) +} + +// ============================================================================ +// Arbitrary implementations for multicast types +// ============================================================================ +// +// These allow using `any::()` etc. in property tests, +// generating only valid instances of each type + +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-f (excluding 0=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-f 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(crate::types::MulticastAddr::V4), + any::().prop_map(crate::types::MulticastAddr::V6), + ] + .boxed() + } +} + +// Strategy for generating ASM (non-SSM) IPv4 multicast addresses directly +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") + }), + ] +} + +// Strategy for generating SSM IPv4 multicast addresses directly (232.x.x.x) +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") + }) +} + +// Strategy for generating ASM (non-SSM) IPv6 multicast addresses directly +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") + }) +} + +// Strategy for generating SSM IPv6 multicast addresses directly (ff3x::) +fn ipv6_ssm_group_strategy() -> impl Strategy { + // SSM: ff3:: where scope in 3-f (link-local and above) + // IPV6_SSM_SUBNET is ff30::/12, so base segment is 0xff30 + let ssm_base = IPV6_SSM_SUBNET.addr().segments()[0]; + (MIN_MULTICAST_SCOPE..=MAX_MULTICAST_SCOPE, any::<[u16; 7]>()).prop_map( + move |(scope, segs)| { + let first = ssm_base | (scope as u16); + MulticastAddrV6::new(Ipv6Addr::new( + first, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], + segs[6], + )) + .expect("SSM is valid") + }, + ) +} + +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..=Vni::MAX_VNI; + + 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) + ( + 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 + (ipv6_unicast_strategy(), ipv6_ssm_group_strategy(), vni).prop_map( + |(src, grp, vni)| { + MulticastRouteKey::V6(MulticastRouteKeyV6 { + source: Some(src), + group: grp, + vni, + }) + } + ), + ] + .boxed() + } +} + +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); + 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 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 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)), + group.into(), + vni, + ) + .expect("valid key construction"); + let result = key.validate(); + prop_assert!( + result.is_ok(), + "VNI {vni} should be valid: {result:?}" + ); + } + + /// Property: VNI exceeding 24 bits fails validation + #[test] + fn prop_route_key_invalid_vni( + src in ipv4_unicast_strategy(), + group in any::(), + vni in invalid_vni_strategy(), + ) { + // Use (S,G) so both ASM and SSM groups work + let key = MulticastRouteKey::new( + Some(IpAddr::V4(src)), + group.into(), + vni, + ) + .expect("valid key construction"); + prop_assert!( + key.validate().is_err(), + "VNI {vni} should be invalid (> 2^24-1)" + ); + } + + /// Property: VNI in valid range passes validation (IPv6) + #[test] + fn prop_route_key_valid_vni_v6( + src in ipv6_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::V6(src)), + 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: VNI exceeding 24 bits fails validation (IPv6) + #[test] + fn prop_route_key_invalid_vni_v6( + src in ipv6_unicast_strategy(), + group in any::(), + vni in invalid_vni_strategy(), + ) { + // Use (S,G) so both ASM and SSM groups work + let key = MulticastRouteKey::new( + Some(IpAddr::V6(src)), + group.into(), + vni, + ) + .expect("valid key construction"); + prop_assert!( + key.validate().is_err(), + "VNI {vni} should be invalid (> 2^24-1)" + ); + } + + /// Property: Broadcast source address is rejected (IPv4) + #[test] + fn prop_route_key_broadcast_source_rejected( + group in ipv4_asm_group_strategy(), + ) { + let key = MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: Some(Ipv4Addr::BROADCAST), + group, + vni: DEFAULT_MULTICAST_VNI, + }); + prop_assert!( + key.validate().is_err(), + "broadcast source should be rejected" + ); + } + + /// Property: AF mismatch (v4 source, v6 group) rejected at construction + #[test] + fn prop_route_key_af_mismatch_v4_v6( + src in ipv4_unicast_strategy(), + group in any::(), + ) { + let result = MulticastRouteKey::new( + Some(IpAddr::V4(src)), + group.into(), + DEFAULT_MULTICAST_VNI, + ); + prop_assert!( + result.is_err(), + "v4 source with v6 group should be rejected" + ); + } + + /// Property: AF mismatch (v6 source, v4 group) rejected at construction + #[test] + fn prop_route_key_af_mismatch_v6_v4( + src in ipv6_unicast_strategy(), + group in any::(), + ) { + let result = MulticastRouteKey::new( + Some(IpAddr::V6(src)), + group.into(), + DEFAULT_MULTICAST_VNI, + ); + prop_assert!( + result.is_err(), + "v6 source with v4 group should be rejected" + ); + } + + /// Property: Multicast address as source rejected + #[test] + fn prop_route_key_multicast_source_rejected_v4( + src in any::(), + group in ipv4_asm_group_strategy(), + ) { + let key = MulticastRouteKey::source_specific_v4(src.ip(), group); + prop_assert!( + key.validate().is_err(), + "multicast source {src} should be rejected" + ); + } + + /// Property: Multicast address as source rejected + #[test] + fn prop_route_key_multicast_source_rejected_v6( + src in any::(), + group in ipv6_asm_group_strategy(), + ) { + let key = MulticastRouteKey::source_specific_v6(src.ip(), group); + prop_assert!( + key.validate().is_err(), + "multicast source {src} 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, + MulticastRouteSource::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" + ); + } + + /// Property: Unicast RPF neighbor passes validation (v4 group, v4 rpf) + #[test] + fn prop_route_unicast_rpf_valid_v4( + group in ipv4_asm_group_strategy(), + rpf in ipv4_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(rpf)); + prop_assert!( + route.validate().is_ok(), + "unicast v4 RPF {rpf} should be valid" + ); + } + + /// Property: Unicast RPF neighbor passes validation (v6 group, v6 rpf) + #[test] + fn prop_route_unicast_rpf_valid_v6( + group in ipv6_asm_group_strategy(), + rpf in ipv6_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V6(rpf)); + prop_assert!( + route.validate().is_ok(), + "unicast v6 RPF {rpf} should be valid" + ); + } + + /// Property: Multicast RPF neighbor fails validation (IPv4) + #[test] + fn prop_route_multicast_rpf_invalid_v4( + group in ipv4_asm_group_strategy(), + rpf in any::(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(rpf.ip())); + prop_assert!( + route.validate().is_err(), + "multicast RPF {rpf} should be rejected" + ); + } + + /// Property: Multicast RPF neighbor fails validation (IPv6) + #[test] + fn prop_route_multicast_rpf_invalid_v6( + group in ipv6_asm_group_strategy(), + rpf in any::(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V6(rpf.ip())); + prop_assert!( + route.validate().is_err(), + "multicast RPF {rpf} should be rejected" + ); + } + + /// Property: RPF AF mismatch fails validation (v4 rpf, v6 group) + #[test] + fn prop_route_rpf_af_mismatch_v4_v6( + group in ipv6_asm_group_strategy(), + rpf in ipv4_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(rpf)); + prop_assert!( + route.validate().is_err(), + "v4 RPF with v6 group should be rejected" + ); + } + + /// Property: RPF AF mismatch fails validation (v6 rpf, v4 group) + #[test] + fn prop_route_rpf_af_mismatch_v6_v4( + group in ipv4_asm_group_strategy(), + rpf in ipv6_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V6(rpf)); + prop_assert!( + route.validate().is_err(), + "v6 RPF with v4 group should be rejected" + ); + } + + /// Property: Routable unicast underlay nexthops pass validation + #[test] + fn prop_route_routable_nexthop_valid( + group in ipv4_asm_group_strategy(), + nexthop in routable_ipv6_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.underlay_nexthops.insert(nexthop); + prop_assert!( + route.validate().is_ok(), + "routable nexthop {nexthop} should be valid" + ); + } + + /// Property: Multicast underlay nexthop fails validation + #[test] + fn prop_route_multicast_nexthop_invalid( + group in ipv4_asm_group_strategy(), + nexthop in any::(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.underlay_nexthops.insert(nexthop.ip()); + prop_assert!( + route.validate().is_err(), + "multicast nexthop {nexthop} should be rejected" + ); + } + + /// Property: Link-local underlay nexthop fails validation + #[test] + fn prop_route_link_local_nexthop_invalid( + group in ipv4_asm_group_strategy(), + segs in any::<[u16; 7]>(), + underlay in admin_local_multicast_strategy(), + ) { + // Create a link-local address (fe80::/10) + let link_local = Ipv6Addr::new( + 0xfe80, + segs[0] & 0x03ff, // Keep only bottom 10 bits for /10 + segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], + ); + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.underlay_nexthops.insert(link_local); + prop_assert!( + route.validate().is_err(), + "link-local nexthop {link_local} should be rejected" + ); + } + + /// Property: Loopback underlay nexthop fails validation + #[test] + fn prop_route_loopback_nexthop_invalid( + group in ipv4_asm_group_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.underlay_nexthops.insert(Ipv6Addr::LOCALHOST); + prop_assert!( + route.validate().is_err(), + "loopback nexthop should be rejected" + ); + } + + /// Property: Unspecified underlay nexthop fails validation + #[test] + fn prop_route_unspecified_nexthop_invalid( + group in ipv4_asm_group_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.underlay_nexthops.insert(Ipv6Addr::UNSPECIFIED); + prop_assert!( + route.validate().is_err(), + "unspecified nexthop should be rejected" + ); + } +} diff --git a/rdb/src/test.rs b/rdb/src/test.rs index 8e4b27b35..4dba12524 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}; @@ -9,6 +11,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 637428bf4..858eace0e 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -2,9 +2,17 @@ // 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 chrono::{DateTime, Utc}; +use omicron_common::address::{ + IPV4_LINK_LOCAL_MULTICAST_SUBNET, IPV4_MULTICAST_RANGE, IPV4_SSM_SUBNET, + IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, IPV6_LINK_LOCAL_MULTICAST_SUBNET, + IPV6_MULTICAST_RANGE, IPV6_RESERVED_SCOPE_MULTICAST_SUBNET, + IPV6_SSM_SUBNET, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; @@ -20,6 +28,14 @@ pub use rdb_types::{ AddressFamily, PeerId, Prefix, Prefix4, Prefix6, ProtocolFilter, }; +// Re-export multicast address constants and types from omicron_common so +// consumers don't need a direct omicron_common dependency. +pub use omicron_common::address::{ + IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, UNDERLAY_MULTICAST_SUBNET, + UNDERLAY_MULTICAST_SUBNET_LAST, +}; +pub use omicron_common::api::external::Vni; + // Marker types for compile-time address family discrimination. // // These zero-sized types enable type-level enforcement of IPv4/IPv6 @@ -380,6 +396,47 @@ impl PrefixDbKey for Prefix6 { } } +/// Extension trait to add `contains` method for checking if a prefix contains +/// an IP address. The base [`Prefix`] type is defined in rdb-types, but this +/// method is specific to RDB's RPF (Reverse Path Forwarding) needs for +/// multicast routing. +pub trait PrefixContains { + /// Check if this prefix contains the given IP address. + /// + /// Performs LPM matching to determine if the address falls + /// within this prefix. Returns `Some(prefix_length)` if the address is + /// contained, `None` otherwise. + fn contains(&self, addr: IpAddr) -> Option; +} + +impl PrefixContains for Prefix { + fn contains(&self, addr: IpAddr) -> Option { + match (self, addr) { + (Prefix::V4(p), IpAddr::V4(a)) => { + let prefix_bits = u32::from(p.value); + let addr_bits = u32::from(a); + let mask = p.mask(); + if (prefix_bits & mask) == (addr_bits & mask) { + Some(p.length) + } else { + None + } + } + (Prefix::V6(p), IpAddr::V6(a)) => { + let prefix_bits = u128::from(p.value); + let addr_bits = u128::from(a); + let mask = p.mask(); + if (prefix_bits & mask) == (addr_bits & mask) { + Some(p.length) + } else { + None + } + } + _ => None, // IPv4 prefix with IPv6 address or vice versa + } + } +} + #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] pub enum Asn { TwoOctet(u16), @@ -733,6 +790,853 @@ impl Display for PrefixChangeNotification { } } +// ============================================================================ +// MRIB (Multicast RIB) Types +// ============================================================================ + +/// Default VNI for fleet-wide multicast routing. +pub const DEFAULT_MULTICAST_VNI: u32 = Vni::DEFAULT_MULTICAST_VNI.as_u32(); + +/// 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(Error::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(Error::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 = Error; + + 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(Error::Validation(format!( + "IPv6 address {value} is not multicast \ + (must be in {IPV6_MULTICAST_RANGE})" + ))); + } + + // Reject reserved scope (ff00::/16) (reserved, not usable) + if IPV6_RESERVED_SCOPE_MULTICAST_SUBNET.contains(value) { + return Err(Error::Validation(format!( + "IPv6 address {value} is in reserved scope \ + ({IPV6_RESERVED_SCOPE_MULTICAST_SUBNET}) which is not routable" + ))); + } + + // Reject interface-local multicast (ff01::/16) + if IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET.contains(value) { + return Err(Error::Validation(format!( + "IPv6 address {value} is interface-local multicast \ + ({IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET}) which is not routable" + ))); + } + + // Reject link-local multicast (ff02::/16) + if IPV6_LINK_LOCAL_MULTICAST_SUBNET.contains(value) { + return Err(Error::Validation(format!( + "IPv6 address {value} is link-local multicast \ + ({IPV6_LINK_LOCAL_MULTICAST_SUBNET}) 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 = Error; + + 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: This duplicates `dpd_types::mcast::UnderlayMulticastIpv6` in dendrite. +// Both should be consolidated into `omicron_common` so maghemite, dendrite, +// and omicron share a single definition. +#[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(Error::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 = Error; + + 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 = Error; + + fn from_str(s: &str) -> Result { + let addr: Ipv6Addr = s.parse().map_err(|_| { + Error::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 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 = Error; + + fn try_from(value: Ipv4Addr) -> Result { + Ok(Self::V4(MulticastAddrV4::new(value)?)) + } +} + +impl TryFrom for MulticastAddr { + type Error = Error; + + fn try_from(value: Ipv6Addr) -> Result { + Ok(Self::V6(MulticastAddrV6::new(value)?)) + } +} + +impl TryFrom for MulticastAddr { + type Error = Error; + + 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: u32, +} + +/// 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: u32, +} + +/// 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), +} + +const fn default_multicast_vni() -> u32 { + DEFAULT_MULTICAST_VNI +} + +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: u32, + ) -> Result { + match group { + MulticastAddr::V4(g) => { + let src = match source { + None => None, + Some(IpAddr::V4(s)) => Some(s), + Some(IpAddr::V6(s)) => { + return Err(Error::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(s), + Some(IpAddr::V4(s)) => { + return Err(Error::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: DEFAULT_MULTICAST_VNI, + }), + MulticastAddr::V6(g) => Self::V6(MulticastRouteKeyV6 { + source: None, + group: g, + vni: DEFAULT_MULTICAST_VNI, + }), + } + } + + /// Create a source-specific IPv4 multicast route (S,G) with default VNI. + pub fn source_specific_v4( + source: Ipv4Addr, + group: MulticastAddrV4, + ) -> Self { + Self::V4(MulticastRouteKeyV4 { + source: Some(source), + group, + vni: DEFAULT_MULTICAST_VNI, + }) + } + + /// Create a source-specific IPv6 multicast route (S,G) with default VNI. + pub fn source_specific_v6( + source: Ipv6Addr, + group: MulticastAddrV6, + ) -> Self { + Self::V6(MulticastRouteKeyV6 { + source: Some(source), + group, + vni: DEFAULT_MULTICAST_VNI, + }) + } + + /// Create an any-source multicast route (*,G) with specified VNI. + pub fn any_source_with_vni(group: MulticastAddr, vni: u32) -> 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: Ipv4Addr, + group: MulticastAddrV4, + vni: u32, + ) -> 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: Ipv6Addr, + group: MulticastAddrV6, + vni: u32, + ) -> 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(IpAddr::V4), + Self::V6(k) => k.source.map(IpAddr::V6), + } + } + + /// Get the group address. + pub fn group(&self) -> MulticastAddr { + match self { + Self::V4(k) => MulticastAddr::V4(k.group), + Self::V6(k) => MulticastAddr::V6(k.group), + } + } + + /// Get the VNI. + pub fn vni(&self) -> u32 { + 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, Error> { + let s = serde_json::to_string(self).map_err(|e| { + Error::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| { + Error::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: ff30::/12 (superset covering all ff3x:: scopes for validation) + /// - Source address (if present) must be unicast + /// - VNI must be in valid range (0 to 16777215) + pub fn validate(&self) -> Result<(), Error> { + // VNI must fit in 24 bits + const MAX_VNI: u32 = (1 << 24) - 1; + if self.vni() > MAX_VNI { + return Err(Error::Validation(format!( + "VNI {} exceeds maximum value {MAX_VNI}", + self.vni() + ))); + } + + // SSM addresses require a source (RFC 4607). Note: ASM addresses + // can also have sources, allowing (S,G) joins on ASM ranges gives + // customers source filtering outside the SSM range. + let is_ssm = match self { + Self::V4(k) => IPV4_SSM_SUBNET.contains(k.group.ip()), + Self::V6(k) => IPV6_SSM_SUBNET.contains(k.group.ip()), + }; + if is_ssm && self.source().is_none() { + return Err(Error::Validation(format!( + "SSM group {} requires a source address", + self.group() + ))); + } + + // Validate source address if present + match self { + Self::V4(k) => { + if let Some(addr) = k.source { + if addr.is_multicast() { + return Err(Error::Validation(format!( + "source address {addr} must be unicast, not multicast" + ))); + } + if addr.is_broadcast() { + return Err(Error::Validation(format!( + "source address {addr} must be unicast, not broadcast" + ))); + } + if addr.is_loopback() { + return Err(Error::Validation(format!( + "source address {addr} must not be loopback" + ))); + } + } + } + Self::V6(k) => { + if let Some(addr) = k.source { + if addr.is_multicast() { + return Err(Error::Validation(format!( + "source address {addr} must be unicast, not multicast" + ))); + } + if addr.is_loopback() { + return Err(Error::Validation(format!( + "source address {addr} must not be loopback" + ))); + } + } + } + } + + 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, + /// Expected RPF neighbor for the source (for RPF checks). + pub rpf_neighbor: Option, + /// Underlay unicast nexthops for multicast replication. + /// + /// Unicast IPv6 addresses where encapsulated overlay multicast traffic + /// is forwarded. These are sled underlay addresses hosting VMs subscribed + /// to the multicast group. Forms the outgoing interface list (OIL). + pub underlay_nexthops: BTreeSet, + /// Underlay multicast group address (ff04::/64). + pub underlay_group: UnderlayMulticastIpv6, + /// Route source (static, IGMP, etc.). + pub source: MulticastRouteSource, + /// Creation timestamp. + pub created: DateTime, + /// Last updated timestamp. + /// + /// Only updated when route fields change semantically (rpf_neighbor, + /// underlay_group, underlay_nexthops, 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: MulticastRouteSource, + ) -> Self { + let now = Utc::now(); + Self { + key, + rpf_neighbor: None, + underlay_nexthops: BTreeSet::new(), + underlay_group, + source, + created: now, + updated: now, + } + } + + pub fn add_target(&mut self, target: Ipv6Addr) -> bool { + let inserted = self.underlay_nexthops.insert(target); + if inserted { + self.updated = Utc::now(); + } + inserted + } + + pub fn remove_target(&mut self, target: &Ipv6Addr) -> bool { + let removed = self.underlay_nexthops.remove(target); + if removed { + self.updated = Utc::now(); + } + removed + } + + /// Validate the multicast route. + /// + /// Checks: + /// - Key validation (source unicast, AF match, VNI range) + /// - RPF neighbor (if present) must be unicast + /// - RPF neighbor address family must match group address family + /// - Underlay nexthops must be routable unicast IPv6 (not link-local) + pub fn validate(&self) -> Result<(), Error> { + 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(Error::Validation(format!( + "RPF neighbor {addr} must be unicast, not multicast" + ))); + } + if addr.is_broadcast() { + return Err(Error::Validation(format!( + "RPF neighbor {addr} must be unicast, not broadcast" + ))); + } + // Address family must match group + if !matches!(self.key.group(), MulticastAddr::V4(_)) { + return Err(Error::Validation(format!( + "RPF neighbor {addr} is IPv4 but group {} is IPv6", + self.key.group() + ))); + } + } + IpAddr::V6(addr) => { + if addr.is_multicast() { + return Err(Error::Validation(format!( + "RPF neighbor {addr} must be unicast, not multicast" + ))); + } + // AF must match group + if !matches!(self.key.group(), MulticastAddr::V6(_)) { + return Err(Error::Validation(format!( + "RPF neighbor {addr} is IPv6 but group {} is IPv4", + self.key.group() + ))); + } + } + } + } + + // Validate underlay nexthops are routable unicast IPv6 + for target in &self.underlay_nexthops { + if target.is_multicast() { + return Err(Error::Validation(format!( + "underlay nexthop {target} must be unicast, not multicast" + ))); + } + if target.is_unspecified() { + return Err(Error::Validation(format!( + "underlay nexthop {target} must not be unspecified (::)" + ))); + } + if target.is_loopback() { + return Err(Error::Validation(format!( + "underlay nexthop {target} must not be loopback (::1)" + ))); + } + if target.is_unicast_link_local() { + return Err(Error::Validation(format!( + "underlay nexthop {target} must not be link-local (fe80::/10)" + ))); + } + } + + Ok(()) + } +} + +/// Source of a multicast route entry. +#[derive( + Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, Eq, PartialEq, +)] +pub enum MulticastRouteSource { + /// 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)] pub mod test_helpers { use super::Path; From 2a7568c1e58dff5b21c643161b0bc151d82ade61 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Tue, 31 Mar 2026 00:30:58 +0000 Subject: [PATCH 2/9] [review] UnicastAddr types, Vni hook-in, remove underlay_nexthops + merge main --- Cargo.lock | 32 +- Cargo.toml | 4 +- .../versions/src/multicast_support/mrib.rs | 7 - mgadm/src/mrib.rs | 19 +- mgadm/src/static_routing.rs | 7 +- mgd/src/mrib_admin.rs | 15 +- ...5dd4ca.json => mg-admin-8.0.0-fc8d9c.json} | 57 +- openapi/mg-admin/mg-admin-latest.json | 2 +- rdb/src/db.rs | 25 +- rdb/src/mrib/mod.rs | 117 +--- rdb/src/proptest.rs | 421 ++------------ rdb/src/types.rs | 542 +++++++++++++----- 12 files changed, 559 insertions(+), 689 deletions(-) rename openapi/mg-admin/{mg-admin-8.0.0-5dd4ca.json => mg-admin-8.0.0-fc8d9c.json} (99%) diff --git a/Cargo.lock b/Cargo.lock index d83183ebb..e3f50d301 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1328,8 +1328,8 @@ dependencies = [ "libnet", "mg-common", "omicron-common", - "opte-ioctl 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8)", - "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8)", + "opte-ioctl 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e)", + "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e)", "oximeter", "oximeter-producer", "oxnet", @@ -3001,7 +3001,7 @@ dependencies = [ [[package]] name = "illumos-sys-hdrs" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8#04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8" +source = "git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e#c570ac2126dbbebbd8e98e73b580c5be6b7e460e" dependencies = [ "bitflags 2.11.0", ] @@ -3437,7 +3437,7 @@ dependencies = [ [[package]] name = "kstat-macro" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8#04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8" +source = "git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e#c570ac2126dbbebbd8e98e73b580c5be6b7e460e" dependencies = [ "quote", "syn 2.0.117", @@ -4632,14 +4632,14 @@ dependencies = [ [[package]] name = "opte" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8#04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8" +source = "git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e#c570ac2126dbbebbd8e98e73b580c5be6b7e460e" dependencies = [ "bitflags 2.11.0", "dyn-clone", - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8)", + "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e)", "ingot", - "kstat-macro 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8)", - "opte-api 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8)", + "kstat-macro 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e)", + "opte-api 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e)", "postcard", "ref-cast", "serde", @@ -4670,9 +4670,9 @@ dependencies = [ [[package]] name = "opte-api" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8#04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8" +source = "git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e#c570ac2126dbbebbd8e98e73b580c5be6b7e460e" dependencies = [ - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8)", + "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e)", "ingot", "ipnetwork", "postcard", @@ -4696,12 +4696,12 @@ dependencies = [ [[package]] name = "opte-ioctl" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8#04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8" +source = "git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e#c570ac2126dbbebbd8e98e73b580c5be6b7e460e" dependencies = [ "libc", "libnet", - "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8)", - "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8)", + "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e)", + "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e)", "postcard", "serde", "thiserror 2.0.18", @@ -4742,11 +4742,11 @@ dependencies = [ [[package]] name = "oxide-vpc" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8#04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8" +source = "git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e#c570ac2126dbbebbd8e98e73b580c5be6b7e460e" dependencies = [ "cfg-if", - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8)", - "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8)", + "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e)", + "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=c570ac2126dbbebbd8e98e73b580c5be6b7e460e)", "serde", "tabwriter", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 09e63b847..ba358134c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -143,11 +143,11 @@ natord = "1.0" [workspace.dependencies.opte-ioctl] git = "https://github.com/oxidecomputer/opte" -rev = "04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8" +rev = "c570ac2126dbbebbd8e98e73b580c5be6b7e460e" [workspace.dependencies.oxide-vpc] git = "https://github.com/oxidecomputer/opte" -rev = "04c3d5d37d7b919cbf01019d2a17b93ff2df2eb8" +rev = "c570ac2126dbbebbd8e98e73b580c5be6b7e460e" [workspace.dependencies.dpd-client] git = "https://github.com/oxidecomputer/dendrite" diff --git a/mg-types/versions/src/multicast_support/mrib.rs b/mg-types/versions/src/multicast_support/mrib.rs index 53327b0bd..e6779b3ad 100644 --- a/mg-types/versions/src/multicast_support/mrib.rs +++ b/mg-types/versions/src/multicast_support/mrib.rs @@ -13,19 +13,12 @@ use rdb::types::{ }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::net::Ipv6Addr; /// 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 unicast nexthops for multicast replication. - /// - /// Unicast IPv6 addresses where encapsulated overlay multicast traffic - /// is forwarded. These are sled underlay addresses hosting VMs subscribed - /// to the multicast group. Forms the outgoing interface list (OIL). - pub underlay_nexthops: Vec, /// Underlay multicast group address (ff04::/64). pub underlay_group: UnderlayMulticastIpv6, } diff --git a/mgadm/src/mrib.rs b/mgadm/src/mrib.rs index 84e96a113..402421d27 100644 --- a/mgadm/src/mrib.rs +++ b/mgadm/src/mrib.rs @@ -23,6 +23,8 @@ use mg_admin_client::types::{ }; use rdb::types::{AddressFamily, DEFAULT_MULTICAST_VNI}; +const DEFAULT_VNI: u32 = DEFAULT_MULTICAST_VNI.as_u32(); + fn parse_route_origin(s: &str) -> Result { match s.to_lowercase().as_str() { "static" => Ok(RouteOriginFilter::Static), @@ -69,7 +71,7 @@ pub enum StatusCmd { source: Option, /// VNI (defaults to DEFAULT_MULTICAST_VNI for fleet-scoped multicast). - #[arg(short, long, default_value_t = DEFAULT_MULTICAST_VNI)] + #[arg(short, long, default_value_t = DEFAULT_VNI, value_parser = clap::value_parser!(u32).range(0..=(rdb::Vni::MAX_VNI as i64)))] vni: u32, /// Filter by route origin ("static" or "dynamic"). @@ -96,7 +98,7 @@ pub enum StatusCmd { source: Option, /// VNI (defaults to DEFAULT_MULTICAST_VNI for fleet-scoped multicast). - #[arg(short, long, default_value_t = DEFAULT_MULTICAST_VNI)] + #[arg(short, long, default_value_t = DEFAULT_VNI, value_parser = clap::value_parser!(u32).range(0..=(rdb::Vni::MAX_VNI as i64)))] vni: u32, /// Filter by route origin ("static" or "dynamic"). @@ -264,20 +266,17 @@ fn print_routes(routes: &[MulticastRoute]) { 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) + (src, grp, k.vni.clone()) } 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) + (src, grp, k.vni.clone()) } }; println!( - "({source_str},{group_str}) vni={vni} underlay={} rpf={:?} nexthops={} source={:?}", - route.underlay_group, - route.rpf_neighbor, - route.underlay_nexthops.len(), - route.source, + "({source_str},{group_str}) vni={vni} underlay={} rpf={:?} source={:?}", + route.underlay_group, route.rpf_neighbor, route.source, ); } } @@ -316,7 +315,7 @@ mod tests { Some(IpAddr::V4(Ipv4Addr::new(225, 1, 2, 3))) ); assert_eq!(source, None); - assert_eq!(vni, DEFAULT_MULTICAST_VNI); + assert_eq!(vni, DEFAULT_VNI); } _ => panic!("expected Imported"), }, diff --git a/mgadm/src/static_routing.rs b/mgadm/src/static_routing.rs index 3099caada..01c4dc4a1 100644 --- a/mgadm/src/static_routing.rs +++ b/mgadm/src/static_routing.rs @@ -137,18 +137,17 @@ fn print_mroutes(routes: &[types::MulticastRoute]) { 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) + (src, grp, k.vni.clone()) } 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) + (src, grp, k.vni.clone()) } }; println!( - "({source_str}, {group_str}) vni={vni} underlay={} nexthops={}", + "({source_str}, {group_str}) vni={vni} underlay={}", route.underlay_group, - route.underlay_nexthops.len(), ); } } diff --git a/mgd/src/mrib_admin.rs b/mgd/src/mrib_admin.rs index 97dc9c42c..81bdaae29 100644 --- a/mgd/src/mrib_admin.rs +++ b/mgd/src/mrib_admin.rs @@ -18,7 +18,7 @@ use mg_types::mrib::{ RouteOriginFilter, }; use rdb::types::{ - MulticastAddr, MulticastRoute, MulticastRouteKey, MulticastRouteSource, + MulticastAddr, MulticastRoute, MulticastRouteKey, MulticastSourceProtocol, }; use crate::admin::HandlerContext; @@ -49,7 +49,7 @@ pub async fn get_mrib_imported( format!("invalid group address: {e}"), ) })?; - let key = MulticastRouteKey::new(q.source, group, q.vni.as_u32()) + 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")) @@ -81,7 +81,7 @@ pub async fn get_mrib_selected( format!("invalid group address: {e}"), ) })?; - let key = MulticastRouteKey::new(q.source, group, q.vni.as_u32()) + 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( @@ -113,14 +113,11 @@ pub async fn static_add_mcast_route( .routes .into_iter() .map(|input| { - let mut route = MulticastRoute::new( + MulticastRoute::new( input.key, input.underlay_group, - MulticastRouteSource::Static, - ); - route.underlay_nexthops = - input.underlay_nexthops.into_iter().collect(); - route + MulticastSourceProtocol::Static, + ) }) .collect(); diff --git a/openapi/mg-admin/mg-admin-8.0.0-5dd4ca.json b/openapi/mg-admin/mg-admin-8.0.0-fc8d9c.json similarity index 99% rename from openapi/mg-admin/mg-admin-8.0.0-5dd4ca.json rename to openapi/mg-admin/mg-admin-8.0.0-fc8d9c.json index f8fa5eb82..b264b1094 100644 --- a/openapi/mg-admin/mg-admin-8.0.0-5dd4ca.json +++ b/openapi/mg-admin/mg-admin-8.0.0-fc8d9c.json @@ -4089,26 +4089,17 @@ "description": "Route source (static, IGMP, etc.).", "allOf": [ { - "$ref": "#/components/schemas/MulticastRouteSource" + "$ref": "#/components/schemas/MulticastSourceProtocol" } ] }, "underlay_group": { - "description": "Underlay multicast group address (ff04::/64).", + "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" }, - "underlay_nexthops": { - "description": "Underlay unicast nexthops for multicast replication.\n\nUnicast IPv6 addresses where encapsulated overlay multicast traffic is forwarded. These are sled underlay addresses hosting VMs subscribed to the multicast group. Forms the outgoing interface list (OIL).", - "type": "array", - "items": { - "type": "string", - "format": "ipv6" - }, - "uniqueItems": true - }, "updated": { - "description": "Last updated timestamp.\n\nOnly updated when route fields change semantically (rpf_neighbor, underlay_group, underlay_nexthops, source). An idempotent upsert with an identical value does not update this timestamp.", + "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" } @@ -4118,7 +4109,6 @@ "key", "source", "underlay_group", - "underlay_nexthops", "updated" ] }, @@ -4169,9 +4159,11 @@ "vni": { "description": "VNI (Virtual Network Identifier).", "default": 77, - "type": "integer", - "format": "uint32", - "minimum": 0 + "allOf": [ + { + "$ref": "#/components/schemas/Vni" + } + ] } }, "required": [ @@ -4196,16 +4188,18 @@ "vni": { "description": "VNI (Virtual Network Identifier).", "default": 77, - "type": "integer", - "format": "uint32", - "minimum": 0 + "allOf": [ + { + "$ref": "#/components/schemas/Vni" + } + ] } }, "required": [ "group" ] }, - "MulticastRouteSource": { + "MulticastSourceProtocol": { "description": "Source of a multicast route entry.", "oneOf": [ { @@ -5766,20 +5760,11 @@ "description": "Underlay multicast group address (ff04::/64).", "type": "string", "format": "ipv6" - }, - "underlay_nexthops": { - "description": "Underlay unicast nexthops for multicast replication.\n\nUnicast IPv6 addresses where encapsulated overlay multicast traffic is forwarded. These are sled underlay addresses hosting VMs subscribed to the multicast group. Forms the outgoing interface list (OIL).", - "type": "array", - "items": { - "type": "string", - "format": "ipv6" - } } }, "required": [ "key", - "underlay_group", - "underlay_nexthops" + "underlay_group" ] }, "StaticRoute4": { @@ -6271,6 +6256,12 @@ "withdrawn" ] }, + "Vni": { + "description": "A Geneve Virtual Network Identifier", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, "AddressFamily": { "description": "Represents the address family (protocol version) for network routes.\n\nThis is the canonical source of truth for address family definitions across the entire codebase. All routing-related components (RIB operations, BGP messages, API filtering, CLI tools) use this single enum rather than defining their own.\n\n# Semantics\n\nWhen used in filtering contexts (e.g., database queries or API parameters), `Option` is preferred: - `None` = no filter (match all address families) - `Some(Ipv4)` = IPv4 routes only - `Some(Ipv6)` = IPv6 routes only\n\n# Examples\n\n``` use rdb_types::AddressFamily;\n\nlet ipv4 = AddressFamily::Ipv4; let ipv6 = AddressFamily::Ipv6;\n\n// For filtering, use Option let filter: Option = Some(AddressFamily::Ipv4); let no_filter: Option = None; // matches all families ```", "oneOf": [ @@ -6309,12 +6300,6 @@ } ] }, - "Vni": { - "description": "A Geneve Virtual Network Identifier", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, "ProtocolFilter": { "oneOf": [ { diff --git a/openapi/mg-admin/mg-admin-latest.json b/openapi/mg-admin/mg-admin-latest.json index 90e4e89b5..329966e4a 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-8.0.0-5dd4ca.json \ No newline at end of file +mg-admin-8.0.0-fc8d9c.json \ No newline at end of file diff --git a/rdb/src/db.rs b/rdb/src/db.rs index d6e9671c1..e4e52f036 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -2026,8 +2026,9 @@ mod test { test::{TEST_WAIT_ITERATIONS, TestDb}, types::{ MulticastAddr, MulticastAddrV4, MulticastAddrV6, MulticastRoute, - MulticastRouteKey, MulticastRouteSource, PrefixDbKey, - UnderlayMulticastIpv6, test_helpers::path_vecs_equal, + MulticastRouteKey, MulticastSourceProtocol, PrefixDbKey, + UnderlayMulticastIpv6, UnicastAddrV4, UnicastAddrV6, + test_helpers::path_vecs_equal, }, }; use mg_common::log::*; @@ -2390,7 +2391,7 @@ mod test { let route = MulticastRoute::new( key, test_underlay(), - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); db.add_static_mcast_routes(&[route]).unwrap(); @@ -2481,7 +2482,7 @@ mod test { let star_g_route = MulticastRoute::new( star_g_key, test_underlay(), - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); db.add_static_mcast_routes(&[star_g_route]).unwrap(); @@ -2501,12 +2502,13 @@ mod test { // 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 = Ipv4Addr::new(10, 0, 0, 100); + 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(), - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); db.add_static_mcast_routes(&[sg_route]).unwrap(); @@ -2545,7 +2547,7 @@ mod test { let v6_star_g_route = MulticastRoute::new( v6_star_g_key, test_underlay(), - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); db.add_static_mcast_routes(&[v6_star_g_route]).unwrap(); @@ -2562,13 +2564,16 @@ mod test { 0xff3e, 0, 0, 0, 0, 0, 0, 0x1234, )) .expect("valid mcast"); - let v6_source = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x100); + 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(), - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); db.add_static_mcast_routes(&[v6_sg_route]).unwrap(); @@ -2621,7 +2626,7 @@ mod test { let route = MulticastRoute::new( key, test_underlay(), - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); // Create Db and add static multicast route diff --git a/rdb/src/mrib/mod.rs b/rdb/src/mrib/mod.rs index f942a6b3e..599297506 100644 --- a/rdb/src/mrib/mod.rs +++ b/rdb/src/mrib/mod.rs @@ -21,7 +21,7 @@ use std::collections::BTreeMap; use std::collections::btree_map::Entry; -use std::net::{IpAddr, Ipv6Addr}; +use std::net::IpAddr; use std::sync::atomic::Ordering; use std::sync::mpsc::{self, RecvTimeoutError, Sender}; use std::sync::{Arc, Mutex, RwLock}; @@ -35,7 +35,7 @@ use mg_common::{lock, read_lock, write_lock}; use crate::error::Error; use crate::types::{ AddressFamily, MribChangeNotification, MulticastAddr, MulticastRoute, - MulticastRouteKey, MulticastRouteSource, + MulticastRouteKey, MulticastSourceProtocol, }; pub mod rpf; @@ -163,10 +163,10 @@ impl Mrib { let origin_match = match static_only { None => true, Some(true) => { - matches!(route.source, MulticastRouteSource::Static) + matches!(route.source, MulticastSourceProtocol::Static) } Some(false) => { - !matches!(route.source, MulticastRouteSource::Static) + !matches!(route.source, MulticastSourceProtocol::Static) } }; af_match && origin_match @@ -224,7 +224,6 @@ impl Mrib { Entry::Occupied(mut e) => { let unchanged = e.get().rpf_neighbor == route.rpf_neighbor && e.get().underlay_group == route.underlay_group - && e.get().underlay_nexthops == route.underlay_nexthops && e.get().source == route.source; if !unchanged { e.insert(route.clone()); @@ -280,8 +279,6 @@ impl Mrib { == route.rpf_neighbor && e.get().underlay_group == route.underlay_group - && e.get().underlay_nexthops - == route.underlay_nexthops && e.get().source == route.source; if !unchanged { e.insert(route.clone()); @@ -320,8 +317,7 @@ impl Mrib { let changed = match mrib_in.get(&key) { Some(existing) => { // Check if route actually changed - existing.underlay_nexthops != route.underlay_nexthops - || existing.rpf_neighbor != route.rpf_neighbor + existing.rpf_neighbor != route.rpf_neighbor || existing.source != route.source || existing.underlay_group != route.underlay_group } @@ -354,76 +350,6 @@ impl Mrib { Ok(removed) } - /// Add a replication target to an existing route in both `mrib_in` and - /// `mrib_loc`. - pub fn add_target( - &self, - key: &MulticastRouteKey, - target: Ipv6Addr, - ) -> Result<(), Error> { - // Acquire both locks following documented order to ensure atomicity - let changed = { - let mut mrib_in = lock!(self.mrib_in); - let mut mrib_loc = lock!(self.mrib_loc); - - let changed_in = if let Some(route) = mrib_in.get_mut(key) { - route.add_target(target) - } else { - return Err(Error::NotFound(format!( - "multicast route {key} not found", - ))); - }; - - let changed_loc = if let Some(route) = mrib_loc.get_mut(key) { - route.add_target(target) - } else { - false - }; - - changed_in || changed_loc - }; - - if changed { - self.notify(MribChangeNotification::from(*key)); - } - Ok(()) - } - - /// Remove a replication target from an existing route in both `mrib_in` and - /// `mrib_loc`. - pub fn remove_target( - &self, - key: &MulticastRouteKey, - target: &Ipv6Addr, - ) -> 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 = if let Some(route) = mrib_in.get_mut(key) { - route.remove_target(target) - } else { - return Err(Error::NotFound(format!( - "multicast route {key} not found", - ))); - }; - - let removed_loc = if let Some(route) = mrib_loc.get_mut(key) { - route.remove_target(target) - } else { - false - }; - - 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, @@ -527,12 +453,13 @@ pub(crate) fn spawn_rpf_revalidator( mod tests { use super::*; - use std::net::Ipv4Addr; + use std::net::{Ipv4Addr, Ipv6Addr}; use mg_common::log::*; use crate::types::{ - MulticastAddrV4, MulticastAddrV6, UnderlayMulticastIpv6, + MulticastAddrV4, MulticastAddrV6, UnderlayMulticastIpv6, UnicastAddrV4, + UnicastAddrV6, }; fn test_underlay() -> UnderlayMulticastIpv6 { @@ -551,30 +478,24 @@ mod tests { let route = MulticastRoute::new( key, test_underlay(), - MulticastRouteSource::Static, + 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 = Ipv4Addr::new(10, 0, 0, 1); + 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 mut route_sg = MulticastRoute::new( + let route_sg = MulticastRoute::new( key_sg, test_underlay(), - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); - // Add replication targets - let target1 = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1); - let target2 = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 2); - - route_sg.add_target(target1); - route_sg.add_target(target2); - mrib.add_route(route_sg.clone()).expect("add S,G route"); assert_eq!(mrib.full_mrib().len(), 2); @@ -611,7 +532,7 @@ mod tests { let route = MulticastRoute::new( key, test_underlay(), - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); mrib.add_route(route.clone()).expect("add route"); @@ -640,7 +561,7 @@ mod tests { let route = MulticastRoute::new( key, test_underlay(), - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); mrib.add_route(route.clone()).expect("add route"); @@ -680,14 +601,16 @@ mod tests { let route = MulticastRoute::new( key, test_underlay(), - MulticastRouteSource::Static, + 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 = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1); + 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"); @@ -695,7 +618,7 @@ mod tests { let route_sg = MulticastRoute::new( key_sg, test_underlay(), - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); mrib.add_route(route_sg).expect("add v6 S,G route"); diff --git a/rdb/src/proptest.rs b/rdb/src/proptest.rs index 3ab13a686..aef6f0235 100644 --- a/rdb/src/proptest.rs +++ b/rdb/src/proptest.rs @@ -13,11 +13,11 @@ use crate::{ BgpNeighborParameters, types::{ - BgpNeighborInfo, DEFAULT_MULTICAST_VNI, ImportExportPolicy4, - ImportExportPolicy6, MulticastAddr, MulticastAddrV4, MulticastAddrV6, - MulticastRoute, MulticastRouteKey, MulticastRouteKeyV4, - MulticastRouteKeyV6, MulticastRouteSource, Prefix, Prefix4, Prefix6, - StaticRouteKey, UnderlayMulticastIpv6, + BgpNeighborInfo, ImportExportPolicy4, ImportExportPolicy6, + MulticastAddr, MulticastAddrV4, MulticastAddrV6, MulticastRoute, + MulticastRouteKey, MulticastRouteKeyV4, MulticastRouteKeyV6, + MulticastSourceProtocol, Prefix, Prefix4, Prefix6, StaticRouteKey, + UnderlayMulticastIpv6, UnicastAddrV4, UnicastAddrV6, }, }; use omicron_common::address::{ @@ -554,7 +554,7 @@ proptest! { // Strategy for generating IPv4 unicast addresses (non-multicast, non-loopback) // Generates directly in valid ranges to avoid filter rejection limits -fn ipv4_unicast_strategy() -> impl Strategy { +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::()) @@ -563,10 +563,18 @@ fn ipv4_unicast_strategy() -> impl Strategy { (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() + }) } -// Strategy for generating IPv6 unicast addresses (non-multicast, non-loopback) -// Generates directly in valid ranges to avoid filter rejection limits +// Strategy for generating 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(). 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 @@ -581,8 +589,8 @@ fn ipv6_unicast_strategy() -> impl Strategy { } // Strategy for generating valid VNIs (0 to Vni::MAX_VNI) -fn valid_vni_strategy() -> impl Strategy { - 0u32..=Vni::MAX_VNI +fn valid_vni_strategy() -> impl Strategy { + (0u32..=Vni::MAX_VNI).prop_map(|v| Vni::try_from(v).unwrap()) } // Strategy for generating invalid VNIs (> Vni::MAX_VNI) @@ -594,16 +602,15 @@ fn invalid_vni_strategy() -> impl Strategy { fn admin_local_multicast_strategy() -> impl Strategy { any::().prop_map(|bits| { - let lower = bits | 1; // ensure non-zero group ID let addr = Ipv6Addr::new( IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, 0, 0, 0, - (lower >> 48) as u16, - (lower >> 32) as u16, - (lower >> 16) as u16, - lower as u16, + (bits >> 48) as u16, + (bits >> 32) as u16, + (bits >> 16) as u16, + bits as u16, ); UnderlayMulticastIpv6::new(addr).expect("valid underlay address") }) @@ -635,18 +642,9 @@ fn non_admin_local_multicast_strategy() -> impl Strategy impl Strategy { - any::().prop_filter_map("must be routable unicast", |bits| { - let addr = Ipv6Addr::from(bits); - if !addr.is_multicast() - && !addr.is_loopback() - && !addr.is_unspecified() - && !addr.is_unicast_link_local() - { - Some(addr) - } else { - None - } +fn routable_ipv6_unicast_strategy() -> impl Strategy { + any::().prop_filter_map("must be valid unicast", |bits| { + UnicastAddrV6::new(Ipv6Addr::from(bits)).ok() }) } @@ -871,7 +869,7 @@ impl Arbitrary for MulticastRouteKey { fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { // Generate directly without filtering for efficiency with high case counts - let vni = 0u32..=Vni::MAX_VNI; + let vni = (0u32..=Vni::MAX_VNI).prop_map(|v| Vni::try_from(v).unwrap()); prop_oneof![ // V4 ASM (*,G) @@ -918,7 +916,7 @@ impl Arbitrary for MulticastRouteKey { }), // V6 ASM (S,G) ( - ipv6_unicast_strategy(), + routable_ipv6_unicast_strategy(), ipv6_asm_group_strategy(), vni.clone() ) @@ -930,15 +928,18 @@ impl Arbitrary for MulticastRouteKey { }) }), // V6 SSM (S,G) - SSM requires source - (ipv6_unicast_strategy(), ipv6_ssm_group_strategy(), vni).prop_map( - |(src, grp, vni)| { + ( + routable_ipv6_unicast_strategy(), + ipv6_ssm_group_strategy(), + vni + ) + .prop_map(|(src, grp, vni)| { MulticastRouteKey::V6(MulticastRouteKeyV6 { source: Some(src), group: grp, vni, }) - } - ), + }), ] .boxed() } @@ -962,7 +963,7 @@ proptest! { /// 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); + let result = MulticastAddrV4::new(addr.ip()); prop_assert!( result.is_err(), "unicast {addr} should be rejected as multicast" @@ -1079,7 +1080,7 @@ proptest! { /// Property: (S,G) with unicast source validates (covers ASM and SSM) #[test] fn prop_route_key_sg_valid_v6( - src in ipv6_unicast_strategy(), + src in routable_ipv6_unicast_strategy(), group in any::(), ) { let key = MulticastRouteKey::source_specific_v6(src, group); @@ -1125,7 +1126,7 @@ proptest! { /// Property: SSM with source passes validation (IPv6) #[test] fn prop_route_key_ssm_with_source_valid_v6( - src in ipv6_unicast_strategy(), + src in routable_ipv6_unicast_strategy(), group in ipv6_ssm_group_strategy(), ) { let key = MulticastRouteKey::source_specific_v6(src, group); @@ -1144,7 +1145,7 @@ proptest! { ) { // Use (S,G) so both ASM and SSM groups work let key = MulticastRouteKey::new( - Some(IpAddr::V4(src)), + Some(IpAddr::V4(src.ip())), group.into(), vni, ) @@ -1152,40 +1153,30 @@ proptest! { let result = key.validate(); prop_assert!( result.is_ok(), - "VNI {vni} should be valid: {result:?}" + "VNI {vni:?} should be valid: {result:?}" ); } - /// Property: VNI exceeding 24 bits fails validation + /// Property: invalid VNI is rejected at construction by Vni::try_from. #[test] fn prop_route_key_invalid_vni( - src in ipv4_unicast_strategy(), - group in any::(), vni in invalid_vni_strategy(), ) { - // Use (S,G) so both ASM and SSM groups work - let key = MulticastRouteKey::new( - Some(IpAddr::V4(src)), - group.into(), - vni, - ) - .expect("valid key construction"); prop_assert!( - key.validate().is_err(), - "VNI {vni} should be invalid (> 2^24-1)" + Vni::try_from(vni).is_err(), + "VNI {vni} should be rejected by Vni::try_from" ); } /// Property: VNI in valid range passes validation (IPv6) #[test] fn prop_route_key_valid_vni_v6( - src in ipv6_unicast_strategy(), + src in routable_ipv6_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::V6(src)), + Some(IpAddr::V6(src.ip())), group.into(), vni, ) @@ -1193,105 +1184,26 @@ proptest! { let result = key.validate(); prop_assert!( result.is_ok(), - "VNI {vni} should be valid for v6: {result:?}" + "VNI {vni:?} should be valid for v6: {result:?}" ); } - /// Property: VNI exceeding 24 bits fails validation (IPv6) + /// Property: Class E reserved (240/4) addresses rejected as unicast + /// source. Per RFC 1112 Section 4, this range is reserved. #[test] - fn prop_route_key_invalid_vni_v6( - src in ipv6_unicast_strategy(), - group in any::(), - vni in invalid_vni_strategy(), - ) { - // Use (S,G) so both ASM and SSM groups work - let key = MulticastRouteKey::new( - Some(IpAddr::V6(src)), - group.into(), - vni, - ) - .expect("valid key construction"); - prop_assert!( - key.validate().is_err(), - "VNI {vni} should be invalid (> 2^24-1)" - ); - } - - /// Property: Broadcast source address is rejected (IPv4) - #[test] - fn prop_route_key_broadcast_source_rejected( - group in ipv4_asm_group_strategy(), + fn prop_unicast_addr_v4_rejects_class_e( + a in 240u8..=254, + b in any::(), + c in any::(), + d in any::(), ) { - let key = MulticastRouteKey::V4(MulticastRouteKeyV4 { - source: Some(Ipv4Addr::BROADCAST), - group, - vni: DEFAULT_MULTICAST_VNI, - }); + let addr = Ipv4Addr::new(a, b, c, d); prop_assert!( - key.validate().is_err(), - "broadcast source should be rejected" + UnicastAddrV4::new(addr).is_err(), + "Class E address {addr} should be rejected" ); } - /// Property: AF mismatch (v4 source, v6 group) rejected at construction - #[test] - fn prop_route_key_af_mismatch_v4_v6( - src in ipv4_unicast_strategy(), - group in any::(), - ) { - let result = MulticastRouteKey::new( - Some(IpAddr::V4(src)), - group.into(), - DEFAULT_MULTICAST_VNI, - ); - prop_assert!( - result.is_err(), - "v4 source with v6 group should be rejected" - ); - } - - /// Property: AF mismatch (v6 source, v4 group) rejected at construction - #[test] - fn prop_route_key_af_mismatch_v6_v4( - src in ipv6_unicast_strategy(), - group in any::(), - ) { - let result = MulticastRouteKey::new( - Some(IpAddr::V6(src)), - group.into(), - DEFAULT_MULTICAST_VNI, - ); - prop_assert!( - result.is_err(), - "v6 source with v4 group should be rejected" - ); - } - - /// Property: Multicast address as source rejected - #[test] - fn prop_route_key_multicast_source_rejected_v4( - src in any::(), - group in ipv4_asm_group_strategy(), - ) { - let key = MulticastRouteKey::source_specific_v4(src.ip(), group); - prop_assert!( - key.validate().is_err(), - "multicast source {src} should be rejected" - ); - } - - /// Property: Multicast address as source rejected - #[test] - fn prop_route_key_multicast_source_rejected_v6( - src in any::(), - group in ipv6_asm_group_strategy(), - ) { - let key = MulticastRouteKey::source_specific_v6(src.ip(), group); - prop_assert!( - key.validate().is_err(), - "multicast source {src} should be rejected" - ); - } /// Property: Route with admin-local underlay group passes validation #[test] @@ -1303,7 +1215,7 @@ proptest! { let route = MulticastRoute::new( key, underlay, - MulticastRouteSource::Static, + MulticastSourceProtocol::Static, ); prop_assert!( route.validate().is_ok(), @@ -1322,227 +1234,6 @@ proptest! { ); } - /// Property: Unicast RPF neighbor passes validation (v4 group, v4 rpf) - #[test] - fn prop_route_unicast_rpf_valid_v4( - group in ipv4_asm_group_strategy(), - rpf in ipv4_unicast_strategy(), - underlay in admin_local_multicast_strategy(), - ) { - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = Some(IpAddr::V4(rpf)); - prop_assert!( - route.validate().is_ok(), - "unicast v4 RPF {rpf} should be valid" - ); - } - /// Property: Unicast RPF neighbor passes validation (v6 group, v6 rpf) - #[test] - fn prop_route_unicast_rpf_valid_v6( - group in ipv6_asm_group_strategy(), - rpf in ipv6_unicast_strategy(), - underlay in admin_local_multicast_strategy(), - ) { - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = Some(IpAddr::V6(rpf)); - prop_assert!( - route.validate().is_ok(), - "unicast v6 RPF {rpf} should be valid" - ); - } - /// Property: Multicast RPF neighbor fails validation (IPv4) - #[test] - fn prop_route_multicast_rpf_invalid_v4( - group in ipv4_asm_group_strategy(), - rpf in any::(), - underlay in admin_local_multicast_strategy(), - ) { - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = Some(IpAddr::V4(rpf.ip())); - prop_assert!( - route.validate().is_err(), - "multicast RPF {rpf} should be rejected" - ); - } - - /// Property: Multicast RPF neighbor fails validation (IPv6) - #[test] - fn prop_route_multicast_rpf_invalid_v6( - group in ipv6_asm_group_strategy(), - rpf in any::(), - underlay in admin_local_multicast_strategy(), - ) { - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = Some(IpAddr::V6(rpf.ip())); - prop_assert!( - route.validate().is_err(), - "multicast RPF {rpf} should be rejected" - ); - } - - /// Property: RPF AF mismatch fails validation (v4 rpf, v6 group) - #[test] - fn prop_route_rpf_af_mismatch_v4_v6( - group in ipv6_asm_group_strategy(), - rpf in ipv4_unicast_strategy(), - underlay in admin_local_multicast_strategy(), - ) { - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = Some(IpAddr::V4(rpf)); - prop_assert!( - route.validate().is_err(), - "v4 RPF with v6 group should be rejected" - ); - } - - /// Property: RPF AF mismatch fails validation (v6 rpf, v4 group) - #[test] - fn prop_route_rpf_af_mismatch_v6_v4( - group in ipv4_asm_group_strategy(), - rpf in ipv6_unicast_strategy(), - underlay in admin_local_multicast_strategy(), - ) { - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = Some(IpAddr::V6(rpf)); - prop_assert!( - route.validate().is_err(), - "v6 RPF with v4 group should be rejected" - ); - } - - /// Property: Routable unicast underlay nexthops pass validation - #[test] - fn prop_route_routable_nexthop_valid( - group in ipv4_asm_group_strategy(), - nexthop in routable_ipv6_unicast_strategy(), - underlay in admin_local_multicast_strategy(), - ) { - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.underlay_nexthops.insert(nexthop); - prop_assert!( - route.validate().is_ok(), - "routable nexthop {nexthop} should be valid" - ); - } - - /// Property: Multicast underlay nexthop fails validation - #[test] - fn prop_route_multicast_nexthop_invalid( - group in ipv4_asm_group_strategy(), - nexthop in any::(), - underlay in admin_local_multicast_strategy(), - ) { - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.underlay_nexthops.insert(nexthop.ip()); - prop_assert!( - route.validate().is_err(), - "multicast nexthop {nexthop} should be rejected" - ); - } - - /// Property: Link-local underlay nexthop fails validation - #[test] - fn prop_route_link_local_nexthop_invalid( - group in ipv4_asm_group_strategy(), - segs in any::<[u16; 7]>(), - underlay in admin_local_multicast_strategy(), - ) { - // Create a link-local address (fe80::/10) - let link_local = Ipv6Addr::new( - 0xfe80, - segs[0] & 0x03ff, // Keep only bottom 10 bits for /10 - segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], - ); - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.underlay_nexthops.insert(link_local); - prop_assert!( - route.validate().is_err(), - "link-local nexthop {link_local} should be rejected" - ); - } - - /// Property: Loopback underlay nexthop fails validation - #[test] - fn prop_route_loopback_nexthop_invalid( - group in ipv4_asm_group_strategy(), - underlay in admin_local_multicast_strategy(), - ) { - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.underlay_nexthops.insert(Ipv6Addr::LOCALHOST); - prop_assert!( - route.validate().is_err(), - "loopback nexthop should be rejected" - ); - } - - /// Property: Unspecified underlay nexthop fails validation - #[test] - fn prop_route_unspecified_nexthop_invalid( - group in ipv4_asm_group_strategy(), - underlay in admin_local_multicast_strategy(), - ) { - let key = MulticastRouteKey::any_source(group.into()); - let mut route = MulticastRoute::new( - key, - underlay, - MulticastRouteSource::Static, - ); - route.underlay_nexthops.insert(Ipv6Addr::UNSPECIFIED); - prop_assert!( - route.validate().is_err(), - "unspecified nexthop should be rejected" - ); - } } diff --git a/rdb/src/types.rs b/rdb/src/types.rs index 858eace0e..3a68b5624 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -465,9 +465,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, } } @@ -795,7 +795,168 @@ impl Display for PrefixChangeNotification { // ============================================================================ /// Default VNI for fleet-wide multicast routing. -pub const DEFAULT_MULTICAST_VNI: u32 = Vni::DEFAULT_MULTICAST_VNI.as_u32(); +pub const DEFAULT_MULTICAST_VNI: Vni = Vni::DEFAULT_MULTICAST_VNI; + +/// 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(Error::Validation(format!( + "{value} is multicast, not unicast" + ))); + } + if value.is_broadcast() { + return Err(Error::Validation(format!( + "{value} is broadcast, not unicast" + ))); + } + if value.is_loopback() { + return Err(Error::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(Error::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(Error::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(Error::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 = Error; + 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(Error::Validation(format!( + "{value} is multicast, not unicast" + ))); + } + if value.is_loopback() { + return Err(Error::Validation(format!( + "{value} is loopback, not a valid source" + ))); + } + if value.is_unspecified() { + return Err(Error::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(Error::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 = Error; + 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. /// @@ -1080,7 +1241,7 @@ impl MulticastAddr { } /// Returns the underlying IP address. - pub fn ip(&self) -> IpAddr { + pub const fn ip(&self) -> IpAddr { match self { Self::V4(v4) => IpAddr::V4(v4.ip()), Self::V6(v6) => IpAddr::V6(v6.ip()), @@ -1151,12 +1312,12 @@ impl TryFrom for MulticastAddr { )] pub struct MulticastRouteKeyV4 { /// Source address (`None` for (*,G) routes). - pub(crate) source: Option, + pub(crate) source: Option, /// Multicast group address. pub(crate) group: MulticastAddrV4, /// VNI (Virtual Network Identifier). #[serde(default = "default_multicast_vni")] - pub(crate) vni: u32, + pub(crate) vni: Vni, } /// IPv6 multicast route key with type-enforced address family matching. @@ -1174,12 +1335,12 @@ pub struct MulticastRouteKeyV4 { )] pub struct MulticastRouteKeyV6 { /// Source address (`None` for (*,G) routes). - pub(crate) source: Option, + pub(crate) source: Option, /// Multicast group address. pub(crate) group: MulticastAddrV6, /// VNI (Virtual Network Identifier). #[serde(default = "default_multicast_vni")] - pub(crate) vni: u32, + pub(crate) vni: Vni, } /// Multicast route key: (Source, Group) pair for source-specific multicast, @@ -1204,8 +1365,8 @@ pub enum MulticastRouteKey { V6(MulticastRouteKeyV6), } -const fn default_multicast_vni() -> u32 { - DEFAULT_MULTICAST_VNI +const fn default_multicast_vni() -> Vni { + Vni::DEFAULT_MULTICAST_VNI } impl fmt::Display for MulticastRouteKey { @@ -1234,13 +1395,13 @@ impl MulticastRouteKey { pub fn new( source: Option, group: MulticastAddr, - vni: u32, + vni: Vni, ) -> Result { match group { MulticastAddr::V4(g) => { let src = match source { None => None, - Some(IpAddr::V4(s)) => Some(s), + Some(IpAddr::V4(s)) => Some(UnicastAddrV4::new(s)?), Some(IpAddr::V6(s)) => { return Err(Error::Validation(format!( "source {s} is IPv6 but group {g} is IPv4" @@ -1256,7 +1417,7 @@ impl MulticastRouteKey { MulticastAddr::V6(g) => { let src = match source { None => None, - Some(IpAddr::V6(s)) => Some(s), + Some(IpAddr::V6(s)) => Some(UnicastAddrV6::new(s)?), Some(IpAddr::V4(s)) => { return Err(Error::Validation(format!( "source {s} is IPv4 but group {g} is IPv6" @@ -1278,42 +1439,42 @@ impl MulticastRouteKey { MulticastAddr::V4(g) => Self::V4(MulticastRouteKeyV4 { source: None, group: g, - vni: DEFAULT_MULTICAST_VNI, + vni: Vni::DEFAULT_MULTICAST_VNI, }), MulticastAddr::V6(g) => Self::V6(MulticastRouteKeyV6 { source: None, group: g, - vni: DEFAULT_MULTICAST_VNI, + vni: Vni::DEFAULT_MULTICAST_VNI, }), } } /// Create a source-specific IPv4 multicast route (S,G) with default VNI. pub fn source_specific_v4( - source: Ipv4Addr, + source: UnicastAddrV4, group: MulticastAddrV4, ) -> Self { Self::V4(MulticastRouteKeyV4 { source: Some(source), group, - vni: DEFAULT_MULTICAST_VNI, + vni: Vni::DEFAULT_MULTICAST_VNI, }) } /// Create a source-specific IPv6 multicast route (S,G) with default VNI. pub fn source_specific_v6( - source: Ipv6Addr, + source: UnicastAddrV6, group: MulticastAddrV6, ) -> Self { Self::V6(MulticastRouteKeyV6 { source: Some(source), group, - vni: DEFAULT_MULTICAST_VNI, + vni: Vni::DEFAULT_MULTICAST_VNI, }) } /// Create an any-source multicast route (*,G) with specified VNI. - pub fn any_source_with_vni(group: MulticastAddr, vni: u32) -> Self { + pub fn any_source_with_vni(group: MulticastAddr, vni: Vni) -> Self { match group { MulticastAddr::V4(g) => Self::V4(MulticastRouteKeyV4 { source: None, @@ -1330,9 +1491,9 @@ impl MulticastRouteKey { /// Create a source-specific IPv4 multicast route (S,G) with VNI. pub fn source_specific_v4_with_vni( - source: Ipv4Addr, + source: UnicastAddrV4, group: MulticastAddrV4, - vni: u32, + vni: Vni, ) -> Self { Self::V4(MulticastRouteKeyV4 { source: Some(source), @@ -1343,9 +1504,9 @@ impl MulticastRouteKey { /// Create a source-specific IPv6 multicast route (S,G) with VNI. pub fn source_specific_v6_with_vni( - source: Ipv6Addr, + source: UnicastAddrV6, group: MulticastAddrV6, - vni: u32, + vni: Vni, ) -> Self { Self::V6(MulticastRouteKeyV6 { source: Some(source), @@ -1357,13 +1518,13 @@ impl MulticastRouteKey { /// Get the source address as IpAddr. pub fn source(&self) -> Option { match self { - Self::V4(k) => k.source.map(IpAddr::V4), - Self::V6(k) => k.source.map(IpAddr::V6), + 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 fn group(&self) -> MulticastAddr { + pub const fn group(&self) -> MulticastAddr { match self { Self::V4(k) => MulticastAddr::V4(k.group), Self::V6(k) => MulticastAddr::V6(k.group), @@ -1371,7 +1532,7 @@ impl MulticastRouteKey { } /// Get the VNI. - pub fn vni(&self) -> u32 { + pub const fn vni(&self) -> Vni { match self { Self::V4(k) => k.vni, Self::V6(k) => k.vni, @@ -1401,22 +1562,22 @@ impl MulticastRouteKey { /// Checks: /// - SSM groups require a source address (RFC 4607) /// - IPv4: 232.0.0.0/8 - /// - IPv6: ff30::/12 (superset covering all ff3x:: scopes for validation) + /// - IPv6: ff30::/12 (superset covering all ff3x:: scopes) /// - Source address (if present) must be unicast - /// - VNI must be in valid range (0 to 16777215) + /// - (S,G) joins on ASM ranges are permitted, giving source + /// filtering outside the SSM range (IGMPv3/MLDv2 semantics) + /// + /// VNI validity is enforced by the [`Vni`] type at construction. pub fn validate(&self) -> Result<(), Error> { - // VNI must fit in 24 bits - const MAX_VNI: u32 = (1 << 24) - 1; - if self.vni() > MAX_VNI { - return Err(Error::Validation(format!( - "VNI {} exceeds maximum value {MAX_VNI}", - self.vni() - ))); - } - - // SSM addresses require a source (RFC 4607). Note: ASM addresses - // can also have sources, allowing (S,G) joins on ASM ranges gives - // customers source filtering outside the SSM range. + // 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()), Self::V6(k) => IPV6_SSM_SUBNET.contains(k.group.ip()), @@ -1428,43 +1589,6 @@ impl MulticastRouteKey { ))); } - // Validate source address if present - match self { - Self::V4(k) => { - if let Some(addr) = k.source { - if addr.is_multicast() { - return Err(Error::Validation(format!( - "source address {addr} must be unicast, not multicast" - ))); - } - if addr.is_broadcast() { - return Err(Error::Validation(format!( - "source address {addr} must be unicast, not broadcast" - ))); - } - if addr.is_loopback() { - return Err(Error::Validation(format!( - "source address {addr} must not be loopback" - ))); - } - } - } - Self::V6(k) => { - if let Some(addr) = k.source { - if addr.is_multicast() { - return Err(Error::Validation(format!( - "source address {addr} must be unicast, not multicast" - ))); - } - if addr.is_loopback() { - return Err(Error::Validation(format!( - "source address {addr} must not be loopback" - ))); - } - } - } - } - Ok(()) } } @@ -1476,23 +1600,25 @@ pub struct MulticastRoute { pub key: MulticastRouteKey, /// Expected RPF neighbor for the source (for RPF checks). pub rpf_neighbor: Option, - /// Underlay unicast nexthops for multicast replication. - /// - /// Unicast IPv6 addresses where encapsulated overlay multicast traffic - /// is forwarded. These are sled underlay addresses hosting VMs subscribed - /// to the multicast group. Forms the outgoing interface list (OIL). - pub underlay_nexthops: BTreeSet, /// 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: MulticastRouteSource, + pub source: MulticastSourceProtocol, /// Creation timestamp. pub created: DateTime, /// Last updated timestamp. /// /// Only updated when route fields change semantically (rpf_neighbor, - /// underlay_group, underlay_nexthops, source). An idempotent upsert with - /// an identical value does not update this timestamp. + /// underlay_group, source). An idempotent upsert with an identical + /// value does not update this timestamp. pub updated: DateTime, } @@ -1500,13 +1626,12 @@ impl MulticastRoute { pub fn new( key: MulticastRouteKey, underlay_group: UnderlayMulticastIpv6, - source: MulticastRouteSource, + source: MulticastSourceProtocol, ) -> Self { let now = Utc::now(); Self { key, rpf_neighbor: None, - underlay_nexthops: BTreeSet::new(), underlay_group, source, created: now, @@ -1514,29 +1639,12 @@ impl MulticastRoute { } } - pub fn add_target(&mut self, target: Ipv6Addr) -> bool { - let inserted = self.underlay_nexthops.insert(target); - if inserted { - self.updated = Utc::now(); - } - inserted - } - - pub fn remove_target(&mut self, target: &Ipv6Addr) -> bool { - let removed = self.underlay_nexthops.remove(target); - if removed { - self.updated = Utc::now(); - } - removed - } - /// Validate the multicast route. /// /// Checks: /// - Key validation (source unicast, AF match, VNI range) /// - RPF neighbor (if present) must be unicast /// - RPF neighbor address family must match group address family - /// - Underlay nexthops must be routable unicast IPv6 (not link-local) pub fn validate(&self) -> Result<(), Error> { self.key.validate()?; @@ -1582,30 +1690,6 @@ impl MulticastRoute { } } - // Validate underlay nexthops are routable unicast IPv6 - for target in &self.underlay_nexthops { - if target.is_multicast() { - return Err(Error::Validation(format!( - "underlay nexthop {target} must be unicast, not multicast" - ))); - } - if target.is_unspecified() { - return Err(Error::Validation(format!( - "underlay nexthop {target} must not be unspecified (::)" - ))); - } - if target.is_loopback() { - return Err(Error::Validation(format!( - "underlay nexthop {target} must not be loopback (::1)" - ))); - } - if target.is_unicast_link_local() { - return Err(Error::Validation(format!( - "underlay nexthop {target} must not be link-local (fe80::/10)" - ))); - } - } - Ok(()) } } @@ -1614,7 +1698,7 @@ impl MulticastRoute { #[derive( Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, Eq, PartialEq, )] -pub enum MulticastRouteSource { +pub enum MulticastSourceProtocol { /// Static route configured via API. Static, /// Learned via IGMP snooping (future). @@ -1679,6 +1763,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, @@ -1889,6 +1985,188 @@ 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(), + DEFAULT_MULTICAST_VNI, + ); + 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(), + DEFAULT_MULTICAST_VNI, + ); + 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" + ); + } + + #[test] + fn rpf_af_mismatch_v4_rpf_v6_group() { + 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::V4(Ipv4Addr::new(10, 0, 0, 1))); + assert!( + route.validate().is_err(), + "v4 RPF with v6 group should be rejected" + ); + } + + #[test] + fn rpf_af_mismatch_v6_rpf_v4_group() { + 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_err(), + "v6 RPF with v4 group should be rejected" + ); + } + /// remove() targets the correct path by identity, not by /// attribute values. #[test] From 65c46199b34770be7886d1ce1890f069e135d65b Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Thu, 2 Apr 2026 09:12:52 +0000 Subject: [PATCH 3/9] [review] error type cleanup variant --- rdb/src/error.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/rdb/src/error.rs b/rdb/src/error.rs index fab696e29..0e9fbdcb3 100644 --- a/rdb/src/error.rs +++ b/rdb/src/error.rs @@ -15,9 +15,6 @@ pub enum Error { #[error("serialization error {0}")] Serialization(#[from] serde_json::Error), - #[error("io error {0}")] - Io(#[from] std::io::Error), - #[error("db key error {0}")] DbKey(String), From 6234a9da1738d7dc3f99b8792ddc6af735bbbfa9 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Tue, 7 Apr 2026 05:02:12 +0000 Subject: [PATCH 4/9] [doc] fix --- rdb/src/db.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rdb/src/db.rs b/rdb/src/db.rs index e4e52f036..3144bcde6 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -21,7 +21,7 @@ //! 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 +//! **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. From 526b5ca52bb458148ec75f006817ebf71dea742b Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Sat, 18 Apr 2026 08:39:03 +0000 Subject: [PATCH 5/9] bump oxide-tokio-rt to 0.1.4, tokio to 1.52.1 --- Cargo.lock | 28 ++++++++++++++-------------- Cargo.toml | 4 ++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7be3ca0f..297e6b678 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -842,7 +842,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1941,7 +1941,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3261,7 +3261,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3320,7 +3320,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4728,9 +4728,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "oxide-tokio-rt" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb926ddb4c76e47e312fb4cf0491573760042037ef6b3b09756ebc1a06f68845" +checksum = "f7d3e6073f692ff812f2d99b61e0ea7f503e54fc9ba44481f619c50a16f0565d" dependencies = [ "anyhow", "nix", @@ -6255,7 +6255,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6337,7 +6337,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7519,7 +7519,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7528,7 +7528,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7538,7 +7538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7758,9 +7758,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -8972,7 +8972,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 79c55231f..b7374d1d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ dropshot = { version = "0.17.0", features = [ "usdt-probes" ] } dropshot-api-manager = "0.7.0" dropshot-api-manager-types = "0.7.0" schemars = { version = "0.8", features = [ "uuid1", "chrono" ] } -tokio = { version = "1.49", features = ["full"] } +tokio = { version = "1.52.1", features = ["full"] } serde_repr = "0.1" anyhow = "1.0.102" hyper = "1.9.0" @@ -121,7 +121,7 @@ http-body-util = "0.1" rand = "0.10.1" backoff = "0.4" chrono = { version = "0.4.44", features = ["serde"] } -oxide-tokio-rt = "0.1.3" +oxide-tokio-rt = "0.1.4" oximeter = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} oximeter-producer = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} oxnet = { version = "0.1.4", default-features = false, features = ["schemars", "serde"] } From e49df461324b336b428e136ef986e6243b202ac7 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Tue, 21 Apr 2026 02:47:35 +0000 Subject: [PATCH 6/9] [deps] bump opte to bae0440c (API 40) --- Cargo.lock | 101 +++++++---------------------------------------------- Cargo.toml | 4 +-- 2 files changed, 14 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 297e6b678..2ca19b76e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1328,8 +1328,8 @@ dependencies = [ "libnet", "mg-common", "omicron-common", - "opte-ioctl 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3)", - "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3)", + "opte-ioctl", + "oxide-vpc", "oximeter", "oximeter-producer", "oxnet", @@ -2997,14 +2997,6 @@ dependencies = [ "num_enum 0.5.11", ] -[[package]] -name = "illumos-sys-hdrs" -version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3#87ab6e17133d057f762cdcb27940faaa705737a3" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "illumos-sys-hdrs" version = "0.1.0" @@ -3041,8 +3033,8 @@ dependencies = [ "omicron-common", "omicron-uuid-kinds", "omicron-workspace-hack", - "opte-ioctl 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56)", - "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56)", + "opte-ioctl", + "oxide-vpc", "oxlog", "oxnet", "rustix 1.1.4", @@ -3433,15 +3425,6 @@ dependencies = [ "secrecy", ] -[[package]] -name = "kstat-macro" -version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3#87ab6e17133d057f762cdcb27940faaa705737a3" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "kstat-macro" version = "0.1.0" @@ -4628,25 +4611,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "opte" -version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3#87ab6e17133d057f762cdcb27940faaa705737a3" -dependencies = [ - "bitflags 2.11.0", - "dyn-clone", - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3)", - "ingot", - "kstat-macro 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3)", - "opte-api 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3)", - "postcard", - "ref-cast", - "serde", - "tabwriter", - "version_check", - "zerocopy 0.8.48", -] - [[package]] name = "opte" version = "0.1.0" @@ -4654,10 +4618,10 @@ source = "git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb dependencies = [ "bitflags 2.11.0", "dyn-clone", - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56)", + "illumos-sys-hdrs", "ingot", - "kstat-macro 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56)", - "opte-api 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56)", + "kstat-macro", + "opte-api", "postcard", "ref-cast", "serde", @@ -4666,25 +4630,12 @@ dependencies = [ "zerocopy 0.8.48", ] -[[package]] -name = "opte-api" -version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3#87ab6e17133d057f762cdcb27940faaa705737a3" -dependencies = [ - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3)", - "ingot", - "ipnetwork", - "postcard", - "serde", - "smoltcp", -] - [[package]] name = "opte-api" version = "0.1.0" source = "git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56#e547d07b08c3f3d6c821c9eb7a958adcffce6e56" dependencies = [ - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56)", + "illumos-sys-hdrs", "ingot", "ipnetwork", "postcard", @@ -4692,20 +4643,6 @@ dependencies = [ "smoltcp", ] -[[package]] -name = "opte-ioctl" -version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3#87ab6e17133d057f762cdcb27940faaa705737a3" -dependencies = [ - "libc", - "libnet", - "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3)", - "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3)", - "postcard", - "serde", - "thiserror 2.0.18", -] - [[package]] name = "opte-ioctl" version = "0.1.0" @@ -4713,8 +4650,8 @@ source = "git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb dependencies = [ "libc", "libnet", - "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56)", - "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56)", + "opte", + "oxide-vpc", "postcard", "serde", "thiserror 2.0.18", @@ -4738,28 +4675,14 @@ dependencies = [ "tokio-dtrace", ] -[[package]] -name = "oxide-vpc" -version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3#87ab6e17133d057f762cdcb27940faaa705737a3" -dependencies = [ - "cfg-if", - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3)", - "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=87ab6e17133d057f762cdcb27940faaa705737a3)", - "serde", - "tabwriter", - "uuid", - "zerocopy 0.8.48", -] - [[package]] name = "oxide-vpc" version = "0.1.0" source = "git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56#e547d07b08c3f3d6c821c9eb7a958adcffce6e56" dependencies = [ "cfg-if", - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56)", - "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=e547d07b08c3f3d6c821c9eb7a958adcffce6e56)", + "illumos-sys-hdrs", + "opte", "serde", "tabwriter", "uuid", diff --git a/Cargo.toml b/Cargo.toml index b7374d1d8..eda6f275d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -143,11 +143,11 @@ natord = "1.0" [workspace.dependencies.opte-ioctl] git = "https://github.com/oxidecomputer/opte" -rev = "87ab6e17133d057f762cdcb27940faaa705737a3" +rev = "bae0440c199b3908c12903a9532854936353433b" [workspace.dependencies.oxide-vpc] git = "https://github.com/oxidecomputer/opte" -rev = "87ab6e17133d057f762cdcb27940faaa705737a3" +rev = "bae0440c199b3908c12903a9532854936353433b" [workspace.dependencies.dpd-client] git = "https://github.com/oxidecomputer/dendrite" From 93fc895f0715ed3c60c0d74c48aa92070a84efe5 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Wed, 27 May 2026 20:00:48 +0000 Subject: [PATCH 7/9] [deps] revert opte to 3d1fe6ad to match main --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 4 ++-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9752e3a0..368a39ced 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1355,8 +1355,8 @@ dependencies = [ "libnet", "mg-common", "omicron-common", - "opte-ioctl 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350)", - "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350)", + "opte-ioctl 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8)", + "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8)", "oximeter", "oximeter-producer", "oxnet", @@ -2971,7 +2971,7 @@ dependencies = [ [[package]] name = "illumos-sys-hdrs" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350#5470aa15eaa06e5183c374c1f61e5e5ddd5e0350" +source = "git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8#3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8" dependencies = [ "bitflags 2.11.1", ] @@ -3418,7 +3418,7 @@ dependencies = [ [[package]] name = "kstat-macro" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350#5470aa15eaa06e5183c374c1f61e5e5ddd5e0350" +source = "git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8#3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8" dependencies = [ "quote", "syn 2.0.117", @@ -4626,14 +4626,14 @@ dependencies = [ [[package]] name = "opte" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350#5470aa15eaa06e5183c374c1f61e5e5ddd5e0350" +source = "git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8#3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8" dependencies = [ "bitflags 2.11.1", "dyn-clone", - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350)", + "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8)", "ingot", - "kstat-macro 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350)", - "opte-api 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350)", + "kstat-macro 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8)", + "opte-api 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8)", "postcard", "ref-cast", "serde", @@ -4658,9 +4658,9 @@ dependencies = [ [[package]] name = "opte-api" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350#5470aa15eaa06e5183c374c1f61e5e5ddd5e0350" +source = "git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8#3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8" dependencies = [ - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350)", + "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8)", "ingot", "ipnetwork", "postcard", @@ -4685,12 +4685,12 @@ dependencies = [ [[package]] name = "opte-ioctl" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350#5470aa15eaa06e5183c374c1f61e5e5ddd5e0350" +source = "git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8#3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8" dependencies = [ "libc", "libnet", - "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350)", - "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350)", + "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8)", + "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8)", "postcard", "serde", "thiserror 2.0.18", @@ -4731,11 +4731,11 @@ dependencies = [ [[package]] name = "oxide-vpc" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350#5470aa15eaa06e5183c374c1f61e5e5ddd5e0350" +source = "git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8#3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8" dependencies = [ "cfg-if", - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350)", - "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=5470aa15eaa06e5183c374c1f61e5e5ddd5e0350)", + "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8)", + "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8)", "serde", "tabwriter", "uuid", diff --git a/Cargo.toml b/Cargo.toml index b16648d34..5e41152bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,11 +141,11 @@ iddqd = "0.3" [workspace.dependencies.opte-ioctl] git = "https://github.com/oxidecomputer/opte" -rev = "5470aa15eaa06e5183c374c1f61e5e5ddd5e0350" +rev = "3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8" [workspace.dependencies.oxide-vpc] git = "https://github.com/oxidecomputer/opte" -rev = "5470aa15eaa06e5183c374c1f61e5e5ddd5e0350" +rev = "3d1fe6ad2df3752dd189ad462c4fdec74ebe25f8" [workspace.dependencies.dpd-client] git = "https://github.com/oxidecomputer/dendrite" From 6180e7e54dd39411c716ee2e6dd6b0741f58b98c Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Tue, 9 Jun 2026 05:16:25 +0000 Subject: [PATCH 8/9] [mg-api-types] keep v11 multicast types omicron-free The version 11 (MULTICAST_SUPPORT) MRIB types pulled omicron_common into mg-api-types/versions for two things: the multicast address constants and the Vni newtype. Because omicron consumes the client and api-types crates, a normal dependency on omicron from any of them closes a build cycle, which the `no-omicron-deps-in-client-crates` CI job rejects. This PR makes two changes to keep that boundary clean: The multicast address constants are duplicated into client-common::address, the cycle-free leaf crate. It's opportune for them to be reachable at compile time because the validated address newtypes check them at deserialization via serde. We include test module asserts where each "copy" is expected to match omicron_common. omicron_common remains a dev-dependency, keeping it out of the dependency tree the CI job inspects. On `omicron_common::Vni`, it is replaced by a local Vni(u32) newtype in mrib.rs, alongside the other validated wire types. It enforces the 24-bit Geneve range (RFC 8926 Section 3.3) at construction, and, via serde, at deserialization, so an out-of-range identifier arriving over the API is rejected at the boundary. `#[schemars(transparent)]` keeps the OpenAPI schema, and the generated client, a bare uint32, for downstream callers. Note: The MAX_VNI and DEFAULT_MULTICAST_VNI constants are pinned to omicron's by the same dev-dependency drift guard. The long-term home for both copies is probably oxnet, which is the leaf crate omicron, dendrite, and maghemite already share. --- Cargo.lock | 6 +- client-common/Cargo.toml | 6 + client-common/src/address.rs | 143 ++++++++++++++++++ client-common/src/lib.rs | 2 + mg-api-types/versions/Cargo.toml | 3 +- mg-api-types/versions/src/impls/mrib.rs | 20 +-- .../versions/src/multicast_support/mrib.rs | 139 +++++++++++++++-- mgadm/src/mrib.rs | 30 +--- mgadm/src/static_routing.rs | 4 +- ...a2cd0.json => mg-admin-11.0.0-6baeab.json} | 30 ++-- openapi/mg-admin/mg-admin-latest.json | 2 +- rdb/Cargo.toml | 1 - rdb/src/db.rs | 6 +- rdb/src/proptest.rs | 21 ++- rdb/src/types.rs | 4 +- 15 files changed, 333 insertions(+), 84 deletions(-) create mode 100644 client-common/src/address.rs rename openapi/mg-admin/{mg-admin-11.0.0-ca2cd0.json => mg-admin-11.0.0-6baeab.json} (99%) diff --git a/Cargo.lock b/Cargo.lock index c6d367dfb..a216032de 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" @@ -3826,6 +3830,7 @@ version = "0.1.0" dependencies = [ "chrono", "clap", + "client-common", "nom 8.0.0", "num_enum 0.7.6", "omicron-common", @@ -5995,7 +6000,6 @@ dependencies = [ "mg-api-types", "mg-common", "ndp", - "omicron-common", "oxnet", "poptrie", "proptest", 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/versions/Cargo.toml b/mg-api-types/versions/Cargo.toml index 0e143892a..cc2443acb 100644 --- a/mg-api-types/versions/Cargo.toml +++ b/mg-api-types/versions/Cargo.toml @@ -6,9 +6,9 @@ edition = "2024" [dependencies] chrono.workspace = true clap = { workspace = true, optional = true } +client-common.workspace = true nom.workspace = true num_enum.workspace = true -omicron-common.workspace = true oxnet.workspace = true proptest = { workspace = true, optional = true } schemars.workspace = true @@ -19,6 +19,7 @@ thiserror.workspace = true uuid.workspace = true [dev-dependencies] +omicron-common.workspace = true serde_json.workspace = true [features] diff --git a/mg-api-types/versions/src/impls/mrib.rs b/mg-api-types/versions/src/impls/mrib.rs index 7d4742622..d0942d1d4 100644 --- a/mg-api-types/versions/src/impls/mrib.rs +++ b/mg-api-types/versions/src/impls/mrib.rs @@ -8,18 +8,17 @@ use std::net::{Ipv4Addr, Ipv6Addr}; -use omicron_common::address::{ +use client_common::address::{ IPV4_MULTICAST_RANGE, IPV4_SSM_SUBNET, IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, IPV6_MULTICAST_PREFIX, IPV6_SSM_SUBNET, }; -use omicron_common::api::external::Vni; use proptest::prelude::*; use proptest::strategy::Just; use crate::latest::mrib::{ - MulticastAddr, MulticastAddrV4, MulticastAddrV6, MulticastRouteKey, - MulticastRouteKeyV4, MulticastRouteKeyV6, UnderlayMulticastIpv6, - UnicastAddrV4, UnicastAddrV6, + MAX_VNI, MulticastAddr, MulticastAddrV4, MulticastAddrV6, + MulticastRouteKey, MulticastRouteKeyV4, MulticastRouteKeyV6, + UnderlayMulticastIpv6, UnicastAddrV4, UnicastAddrV6, Vni, }; /// Minimum valid IPv6 multicast scope for proptest strategies. @@ -55,14 +54,14 @@ pub fn ipv4_unicast_strategy() -> impl Strategy { }) } -/// Generate valid VNIs in `[0, Vni::MAX_VNI]`. +/// Generate valid [`Vni`] values in `[0, MAX_VNI]`. pub fn valid_vni_strategy() -> impl Strategy { - (0u32..=Vni::MAX_VNI).prop_map(|v| Vni::try_from(v).unwrap()) + (0u32..=MAX_VNI).prop_map(|v| Vni::new(v).expect("VNI is in range")) } -/// Generate raw u32 values that exceed `Vni::MAX_VNI`. +/// Generate raw u32 values that exceed [`MAX_VNI`], rejected by [`Vni::new`]. pub fn invalid_vni_strategy() -> impl Strategy { - (Vni::MAX_VNI + 1)..=u32::MAX + (MAX_VNI + 1)..=u32::MAX } /// Generate validated underlay multicast addresses within `ff04::/64`. @@ -331,7 +330,8 @@ impl Arbitrary for MulticastRouteKey { fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { // Generate directly without filtering for efficiency with high case counts - let vni = (0u32..=Vni::MAX_VNI).prop_map(|v| Vni::try_from(v).unwrap()); + let vni = (0u32..=MAX_VNI) + .prop_map(|v| Vni::new(v).expect("VNI is in range")); prop_oneof![ // V4 ASM (*,G) diff --git a/mg-api-types/versions/src/multicast_support/mrib.rs b/mg-api-types/versions/src/multicast_support/mrib.rs index 34992dee1..4cff26616 100644 --- a/mg-api-types/versions/src/multicast_support/mrib.rs +++ b/mg-api-types/versions/src/multicast_support/mrib.rs @@ -18,20 +18,28 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::str::FromStr; use chrono::{DateTime, Utc}; -use omicron_common::address::{ +use client_common::address::{ IPV4_LINK_LOCAL_MULTICAST_SUBNET, IPV4_MULTICAST_RANGE, IPV4_SSM_SUBNET, IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, IPV6_LINK_LOCAL_MULTICAST_SUBNET, IPV6_MULTICAST_RANGE, IPV6_RESERVED_SCOPE_MULTICAST_SUBNET, IPV6_SSM_SUBNET, UNDERLAY_MULTICAST_SUBNET, }; -pub use omicron_common::api::external::Vni; 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. -pub const DEFAULT_MULTICAST_VNI: Vni = Vni::DEFAULT_MULTICAST_VNI; +/// +/// 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)] @@ -49,6 +57,82 @@ pub enum MulticastError { 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 { @@ -129,7 +213,7 @@ pub struct MribQuery { } const fn default_multicast_vni() -> Vni { - Vni::DEFAULT_MULTICAST_VNI + Vni::DEFAULT_MULTICAST } /// A validated IPv4 unicast address suitable for multicast source fields. @@ -453,8 +537,8 @@ impl From for Ipv6Addr { /// 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 `omicron_common` so maghemite, dendrite, -// and omicron share a single definition. +// Both should be consolidated into `oxnet`, the cycle-free leaf crate that +// maghemite, dendrite, and omicron already share. #[derive( Debug, Copy, @@ -769,12 +853,12 @@ impl MulticastRouteKey { MulticastAddr::V4(g) => Self::V4(MulticastRouteKeyV4 { source: None, group: g, - vni: Vni::DEFAULT_MULTICAST_VNI, + vni: Vni::DEFAULT_MULTICAST, }), MulticastAddr::V6(g) => Self::V6(MulticastRouteKeyV6 { source: None, group: g, - vni: Vni::DEFAULT_MULTICAST_VNI, + vni: Vni::DEFAULT_MULTICAST, }), } } @@ -787,7 +871,7 @@ impl MulticastRouteKey { Self::V4(MulticastRouteKeyV4 { source: Some(source), group, - vni: Vni::DEFAULT_MULTICAST_VNI, + vni: Vni::DEFAULT_MULTICAST, }) } @@ -799,7 +883,7 @@ impl MulticastRouteKey { Self::V6(MulticastRouteKeyV6 { source: Some(source), group, - vni: Vni::DEFAULT_MULTICAST_VNI, + vni: Vni::DEFAULT_MULTICAST, }) } @@ -899,7 +983,8 @@ impl MulticastRouteKey { /// - (S,G) joins on ASM ranges are permitted, giving source /// filtering outside the SSM range (IGMPv3/MLDv2 semantics) /// - /// VNI validity is enforced by the [`Vni`] type at construction. + /// 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. @@ -1052,3 +1137,35 @@ impl From for MribChangeNotification { } } } + +#[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/mgadm/src/mrib.rs b/mgadm/src/mrib.rs index 84fb9dc35..57d701ac9 100644 --- a/mgadm/src/mrib.rs +++ b/mgadm/src/mrib.rs @@ -20,12 +20,12 @@ use client_common::println_nopipe; use mg_admin_client::Client; use mg_admin_client::types::{ MribRpfRebuildIntervalRequest, MulticastRoute, MulticastRouteKey, - RouteOriginFilter, Vni, + RouteOriginFilter, }; use mg_api_types::mrib::DEFAULT_MULTICAST_VNI; use mg_api_types::rdb::rib::AddressFamily; -const DEFAULT_VNI: u32 = DEFAULT_MULTICAST_VNI.as_u32(); +const DEFAULT_VNI: u32 = DEFAULT_MULTICAST_VNI; fn parse_route_origin(s: &str) -> Result { match s.to_lowercase().as_str() { @@ -73,7 +73,7 @@ pub enum StatusCmd { 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::Vni::MAX_VNI as i64)))] + #[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"). @@ -100,7 +100,7 @@ pub enum StatusCmd { 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::Vni::MAX_VNI as i64)))] + #[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"). @@ -199,15 +199,8 @@ async fn get_route( source: Option, vni: u32, ) -> Result<()> { - let vni = Vni::from(vni); let routes = c - .get_mrib_imported( - None, - Some(&group), - None, - source.as_ref(), - Some(&vni), - ) + .get_mrib_imported(None, Some(&group), None, source.as_ref(), Some(vni)) .await? .into_inner(); if let Some(route) = routes.first() { @@ -224,15 +217,8 @@ async fn get_route_selected( source: Option, vni: u32, ) -> Result<()> { - let vni = Vni::from(vni); let routes = c - .get_mrib_selected( - None, - Some(&group), - None, - source.as_ref(), - Some(&vni), - ) + .get_mrib_selected(None, Some(&group), None, source.as_ref(), Some(vni)) .await? .into_inner(); if let Some(route) = routes.first() { @@ -268,12 +254,12 @@ fn print_routes(routes: &[MulticastRoute]) { 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.clone()) + (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.clone()) + (src, grp, k.vni) } }; println_nopipe!( diff --git a/mgadm/src/static_routing.rs b/mgadm/src/static_routing.rs index 5357c7ad8..1b7ecfcea 100644 --- a/mgadm/src/static_routing.rs +++ b/mgadm/src/static_routing.rs @@ -139,12 +139,12 @@ fn print_mroutes(routes: &[types::MulticastRoute]) { 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.clone()) + (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.clone()) + (src, grp, k.vni) } }; println_nopipe!( diff --git a/openapi/mg-admin/mg-admin-11.0.0-ca2cd0.json b/openapi/mg-admin/mg-admin-11.0.0-6baeab.json similarity index 99% rename from openapi/mg-admin/mg-admin-11.0.0-ca2cd0.json rename to openapi/mg-admin/mg-admin-11.0.0-6baeab.json index 4fd6afc82..17749659a 100644 --- a/openapi/mg-admin/mg-admin-11.0.0-ca2cd0.json +++ b/openapi/mg-admin/mg-admin-11.0.0-6baeab.json @@ -1329,7 +1329,9 @@ "name": "vni", "description": "VNI (defaults to the fleet-wide multicast VNI). Only used when `group` is set.", "schema": { - "$ref": "#/components/schemas/Vni" + "type": "integer", + "format": "uint32", + "minimum": 0 } } ], @@ -1403,7 +1405,9 @@ "name": "vni", "description": "VNI (defaults to the fleet-wide multicast VNI). Only used when `group` is set.", "schema": { - "$ref": "#/components/schemas/Vni" + "type": "integer", + "format": "uint32", + "minimum": 0 } } ], @@ -4172,11 +4176,9 @@ "vni": { "description": "VNI (Virtual Network Identifier).", "default": 77, - "allOf": [ - { - "$ref": "#/components/schemas/Vni" - } - ] + "type": "integer", + "format": "uint32", + "minimum": 0 } }, "required": [ @@ -4201,11 +4203,9 @@ "vni": { "description": "VNI (Virtual Network Identifier).", "default": 77, - "allOf": [ - { - "$ref": "#/components/schemas/Vni" - } - ] + "type": "integer", + "format": "uint32", + "minimum": 0 } }, "required": [ @@ -6308,12 +6308,6 @@ "withdrawn" ] }, - "Vni": { - "description": "A Geneve Virtual Network Identifier", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, "AddressFamily": { "description": "Represents the address family (protocol version) for network routes.\n\nThis is the canonical source of truth for address family definitions across the entire codebase. All routing-related components (RIB operations, BGP messages, API filtering, CLI tools) use this single enum rather than defining their own.\n\n# Semantics\n\nWhen used in filtering contexts (e.g., database queries or API parameters), `Option` is preferred: - `None` = no filter (match all address families) - `Some(Ipv4)` = IPv4 routes only - `Some(Ipv6)` = IPv6 routes only\n\n# Examples\n\n```ignore use mg_api_types::rdb::AddressFamily;\n\nlet ipv4 = AddressFamily::Ipv4; let ipv6 = AddressFamily::Ipv6;\n\n// For filtering, use Option let filter: Option = Some(AddressFamily::Ipv4); let no_filter: Option = None; // matches all families ```", "oneOf": [ diff --git a/openapi/mg-admin/mg-admin-latest.json b/openapi/mg-admin/mg-admin-latest.json index b95c4a008..bce0adb7a 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-11.0.0-ca2cd0.json \ No newline at end of file +mg-admin-11.0.0-6baeab.json \ No newline at end of file diff --git a/rdb/Cargo.toml b/rdb/Cargo.toml index 95f422066..dee89cd0a 100644 --- a/rdb/Cargo.toml +++ b/rdb/Cargo.toml @@ -20,7 +20,6 @@ clap = { workspace = true, optional = true } oxnet.workspace = true mg-api-types = { workspace = true, features = ["clap"] } poptrie.workspace = true -omicron-common.workspace = true ndp.workspace = true [dev-dependencies] diff --git a/rdb/src/db.rs b/rdb/src/db.rs index 97f9c7ecb..54ea04a81 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -2025,13 +2025,13 @@ impl Reaper { #[cfg(test)] mod test { use crate::{ - DEFAULT_MULTICAST_VNI, StaticRouteKey, + StaticRouteKey, db::Db, test::{TEST_WAIT_ITERATIONS, TestDb}, types::{ MulticastAddr, MulticastAddrV4, MulticastAddrV6, MulticastRoute, MulticastRouteKey, MulticastSourceProtocol, PrefixDbKey, - UnderlayMulticastIpv6, UnicastAddrV4, UnicastAddrV6, + UnderlayMulticastIpv6, UnicastAddrV4, UnicastAddrV6, Vni, test_helpers::path_vecs_equal, }, }; @@ -2397,7 +2397,7 @@ mod test { let key = MulticastRouteKey::new( Some(s_ip), group, - DEFAULT_MULTICAST_VNI, + Vni::DEFAULT_MULTICAST, ) .expect("AF match"); let route = MulticastRoute::new( diff --git a/rdb/src/proptest.rs b/rdb/src/proptest.rs index 4297e2710..8463836d0 100644 --- a/rdb/src/proptest.rs +++ b/rdb/src/proptest.rs @@ -13,7 +13,11 @@ use crate::types::{ MulticastAddrV4, MulticastAddrV6, MulticastRoute, MulticastRouteKey, MulticastSourceProtocol, StaticRouteKey, UnderlayMulticastIpv6, - UnicastAddrV4, + UnicastAddrV4, Vni, +}; +use client_common::address::{ + IPV4_MULTICAST_RANGE, IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, + IPV6_LINK_LOCAL_MULTICAST_SUBNET, }; use mg_api_types::bgp::policy::{ImportExportPolicy4, ImportExportPolicy6}; use mg_api_types::mrib::{ @@ -25,11 +29,6 @@ use mg_api_types::mrib::{ }; use mg_api_types::rdb::neighbor::{BgpNeighborInfo, BgpNeighborParameters}; use mg_api_types::rdb::prefix::{Prefix, Prefix4, Prefix6}; -use omicron_common::address::{ - IPV4_MULTICAST_RANGE, IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, - IPV6_LINK_LOCAL_MULTICAST_SUBNET, -}; -use omicron_common::api::external::Vni; use proptest::prelude::*; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; @@ -749,14 +748,12 @@ proptest! { ); } - /// Property: invalid VNI is rejected at construction by Vni::try_from. + /// Property: a VNI outside the 24-bit range is rejected at construction. #[test] - fn prop_route_key_invalid_vni( - vni in invalid_vni_strategy(), - ) { + fn prop_vni_rejects_out_of_range(vni in invalid_vni_strategy()) { prop_assert!( - Vni::try_from(vni).is_err(), - "VNI {vni} should be rejected by Vni::try_from" + Vni::new(vni).is_err(), + "VNI {vni} above the 24-bit max should be rejected" ); } diff --git a/rdb/src/types.rs b/rdb/src/types.rs index cb57876a1..5c0b1f532 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -686,7 +686,7 @@ mod test { let result = MulticastRouteKey::new( Some(IpAddr::V4(src.ip())), group.into(), - DEFAULT_MULTICAST_VNI, + Vni::DEFAULT_MULTICAST, ); assert!( result.is_err(), @@ -701,7 +701,7 @@ mod test { let result = MulticastRouteKey::new( Some(IpAddr::V6(src)), group.into(), - DEFAULT_MULTICAST_VNI, + Vni::DEFAULT_MULTICAST, ); assert!( result.is_err(), From 22503d6c7389cd6282a12efd7da01f02b4d21e54 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Tue, 14 Jul 2026 00:50:19 +0000 Subject: [PATCH 9/9] [rdb] rework RPF cache rebuilds around a bounded worker This replaces the interval-driven poptrie rebuild with a single long-lived worker that rebuilds on loc-RIB change. Mutation batches already end with a change notification, so `notify()` now triggers the rebuild as well. Paths that bypass notification (bestpath re-runs, peer staleness marking, direct path removal) still trigger explicitly. This long-lived worker keeps memory bounded with a one-slot pending queue per address family, where requests arriving within a fixed coalescing window merge, and distinct changed prefixes widen to a full sweep. Completed snapshots install unconditionally. A trigger that lands during a build leaves its request in the pending slot, so a possibly stale install is followed by a rebuild from a newer snapshot 1 coalescing window later. Installs are serialized in the single worker, so each is newer than the last. After updates quiesce, the final rebuild converges to the current RIB. Optimizations: - Snapshots derive the compact `RpfNexthops` payload in the same pass under the RIB lock, so full path sets are never cloned, and `get_rpf_neighbor` selects a neighbor without cloning the path set. - The linear-scan fallback now also matches /0 default routes. Concurrency between inline derivations and cache swaps uses a `Generation` counter, modeled on `omicron_common::api::external::Generation`. Every cache swap advances the generation, and `update_mrib_loc` snapshots it before a lookup and re-derives if a swap intervened. Retries are bounded, and on exhaustion a targeted revalidation pass is queued. Static multicast persistence is configuration-only. `rpf_neighbor` is derived from the unicast RIB at load and revalidation time, so it is normalized to `None` on disk, and timestamps merge against the previously persisted entry so idempotent upserts do not rewrite them. Persistence completes before the MRIB publishes the change to watchers. We also added a why section for having this cache and async machinery. Validation fixes: - IPv6 multicast scope classifies on the RFC 4291 scope nibble, rejecting reserved scopes 0 and F (RFC 7346) alongside interface- and link-local. - IPv6 SSM classification narrows to FF3x::/32 per RFC 4607, so RFC 3306 unicast-prefix-based addresses with a nonzero network prefix are no longer treated as SSM. - Cross-family RPF neighbors are accepted, since derivation from the unicast RIB may resolve v4 routes through v6 nexthops (RFC 8950 style). With these changes, the rebuild-interval setting and its plumbing are removed, including the admin API endpoints and a regeneration of the unblessed v12 OpenAPI spec. --- mg-api-types/versions/src/impls/mrib.rs | 23 +- .../versions/src/multicast_support/mod.rs | 8 +- .../versions/src/multicast_support/mrib.rs | 113 +-- mg-api/src/lib.rs | 16 - mgadm/src/mrib.rs | 234 ++--- mgd/src/admin.rs | 14 - mgd/src/mrib_admin.rs | 32 +- ...cf43c.json => mg-admin-12.0.0-a8ad1e.json} | 81 +- openapi/mg-admin/mg-admin-latest.json | 2 +- rdb/src/db.rs | 436 +++++--- rdb/src/mrib/mod.rs | 54 +- rdb/src/mrib/rpf.rs | 931 +++++++++++------- rdb/src/types.rs | 25 +- 13 files changed, 1056 insertions(+), 913 deletions(-) rename openapi/mg-admin/{mg-admin-12.0.0-9cf43c.json => mg-admin-12.0.0-a8ad1e.json} (98%) diff --git a/mg-api-types/versions/src/impls/mrib.rs b/mg-api-types/versions/src/impls/mrib.rs index d0942d1d4..ba80f0eaa 100644 --- a/mg-api-types/versions/src/impls/mrib.rs +++ b/mg-api-types/versions/src/impls/mrib.rs @@ -28,8 +28,11 @@ use crate::latest::mrib::{ /// at scope 3 (realm-local). const MIN_MULTICAST_SCOPE: u8 = 0x3; -/// Maximum IPv6 multicast scope value (4 bits). -const MAX_MULTICAST_SCOPE: u8 = 0xf; +/// 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; @@ -193,17 +196,17 @@ pub fn ipv6_asm_group_strategy() -> impl Strategy { }) } -/// Generate SSM IPv6 multicast addresses (ff3x::). +/// Generate SSM IPv6 multicast addresses (FF3x::/32). pub fn ipv6_ssm_group_strategy() -> impl Strategy { - // SSM: ff3:: where scope in 3-f (link-local and above) - // IPV6_SSM_SUBNET is ff30::/12, so base segment is 0xff30 + // 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; 7]>()).prop_map( + (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, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], - segs[6], + first, 0, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], )) .expect("SSM is valid") }, @@ -283,7 +286,7 @@ impl Arbitrary for MulticastAddrV6 { fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { // Generate with all valid flag/scope combinations // Format: ff:: - // Valid scopes: 3-f (excluding 0=reserved, 1=if-local, 2=link-local) + // Valid scopes: 3-e (excluding 0/f=reserved, 1=if-local, 2=link-local) // Flags: 0-f (all combinations valid) ( 0x0u8..=MAX_MULTICAST_FLAGS, @@ -305,7 +308,7 @@ impl Arbitrary for MulticastAddrV6 { segs[6], ); MulticastAddrV6::new(addr) - .expect("scope 3-f with any flags is valid") + .expect("scope 3-e with any flags is valid") }) .boxed() } diff --git a/mg-api-types/versions/src/multicast_support/mod.rs b/mg-api-types/versions/src/multicast_support/mod.rs index 3f60eeebd..5f0b1552b 100644 --- a/mg-api-types/versions/src/multicast_support/mod.rs +++ b/mg-api-types/versions/src/multicast_support/mod.rs @@ -7,9 +7,9 @@ //! Version `MULTICAST_SUPPORT` of the Maghemite Admin API. //! //! Adds MRIB (Multicast Routing Information Base) endpoints for static -//! multicast route management, RPF rebuild rate control, 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). +//! 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 index 4cff26616..a0198b593 100644 --- a/mg-api-types/versions/src/multicast_support/mrib.rs +++ b/mg-api-types/versions/src/multicast_support/mrib.rs @@ -20,9 +20,7 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use client_common::address::{ IPV4_LINK_LOCAL_MULTICAST_SUBNET, IPV4_MULTICAST_RANGE, IPV4_SSM_SUBNET, - IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, IPV6_LINK_LOCAL_MULTICAST_SUBNET, - IPV6_MULTICAST_RANGE, IPV6_RESERVED_SCOPE_MULTICAST_SUBNET, - IPV6_SSM_SUBNET, UNDERLAY_MULTICAST_SUBNET, + IPV6_MULTICAST_RANGE, UNDERLAY_MULTICAST_SUBNET, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -156,23 +154,6 @@ pub struct MribDeleteStaticRequest { pub keys: Vec, } -/// Response containing the current RPF rebuild interval. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct MribRpfRebuildIntervalResponse { - /// Minimum interval between RPF cache rebuilds in milliseconds. - /// A value of 0 means rate-limiting is disabled. - pub interval_ms: u64, -} - -/// Request body for setting the RPF rebuild interval. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct MribRpfRebuildIntervalRequest { - /// Minimum interval between RPF cache rebuilds in milliseconds. - /// A value of 0 disables rate-limiting. - /// Every unicast RIB change triggers an immediate poptrie rebuild. - pub interval_ms: u64, -} - /// Filter for multicast route origin. #[derive( Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq, Eq, @@ -477,31 +458,31 @@ impl MulticastAddrV6 { ))); } - // Reject reserved scope (ff00::/16) (reserved, not usable) - if IPV6_RESERVED_SCOPE_MULTICAST_SUBNET.contains(value) { - return Err(MulticastError::Validation(format!( - "IPv6 address {value} is in reserved scope \ - ({IPV6_RESERVED_SCOPE_MULTICAST_SUBNET}) which is not routable" - ))); + // 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)), } - - // Reject interface-local multicast (ff01::/16) - if IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET.contains(value) { - return Err(MulticastError::Validation(format!( - "IPv6 address {value} is interface-local multicast \ - ({IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET}) which is not routable" - ))); - } - - // Reject link-local multicast (ff02::/16) - if IPV6_LINK_LOCAL_MULTICAST_SUBNET.contains(value) { - return Err(MulticastError::Validation(format!( - "IPv6 address {value} is link-local multicast \ - ({IPV6_LINK_LOCAL_MULTICAST_SUBNET}) which is not routable" - ))); - } - - Ok(Self(value)) } /// Returns the underlying IPv6 address. @@ -978,7 +959,7 @@ impl MulticastRouteKey { /// Checks: /// - SSM groups require a source address (RFC 4607) /// - IPv4: 232.0.0.0/8 - /// - IPv6: ff30::/12 (superset covering all ff3x:: scopes) + /// - 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) @@ -997,7 +978,14 @@ impl MulticastRouteKey { // together and we can update our policy handling. let is_ssm = match self { Self::V4(k) => IPV4_SSM_SUBNET.contains(k.group.ip()), - Self::V6(k) => IPV6_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!( @@ -1015,7 +1003,16 @@ impl MulticastRouteKey { pub struct MulticastRoute { /// The multicast route key (S,G) or (*,G). pub key: MulticastRouteKey, - /// Expected RPF neighbor for the source (for RPF checks). + /// 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). /// @@ -1059,9 +1056,13 @@ impl MulticastRoute { /// Validate the multicast route. /// /// Checks: - /// - Key validation (source unicast, AF match, VNI range) + /// - Key validation (source unicast, VNI range) /// - RPF neighbor (if present) must be unicast - /// - RPF neighbor address family must match group address family + /// + /// 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()?; @@ -1082,13 +1083,6 @@ impl MulticastRoute { "RPF neighbor {addr} must be unicast, not broadcast" ))); } - // Address family must match group - if !matches!(self.key.group(), MulticastAddr::V4(_)) { - return Err(MulticastError::Validation(format!( - "RPF neighbor {addr} is IPv4 but group {} is IPv6", - self.key.group() - ))); - } } IpAddr::V6(addr) => { if addr.is_multicast() { @@ -1096,13 +1090,6 @@ impl MulticastRoute { "RPF neighbor {addr} must be unicast, not multicast" ))); } - // AF must match group - if !matches!(self.key.group(), MulticastAddr::V6(_)) { - return Err(MulticastError::Validation(format!( - "RPF neighbor {addr} is IPv6 but group {} is IPv4", - self.key.group() - ))); - } } } } diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 5475d413b..4ede1c16d 100644 --- a/mg-api/src/lib.rs +++ b/mg-api/src/lib.rs @@ -1568,20 +1568,4 @@ pub trait MgAdminApi { async fn static_list_mcast_routes( rqctx: RequestContext, ) -> Result>, HttpError>; - - /// Get the RPF rebuild rate-limit interval. - #[endpoint { method = GET, path = "/mrib/config/rpf/rebuild-interval", versions = VERSION_MULTICAST_SUPPORT.. }] - async fn read_mrib_rpf_rebuild_interval( - rqctx: RequestContext, - ) -> Result< - HttpResponseOk, - HttpError, - >; - - /// Set the RPF rebuild rate-limit interval. - #[endpoint { method = POST, path = "/mrib/config/rpf/rebuild-interval", versions = VERSION_MULTICAST_SUPPORT.. }] - async fn update_mrib_rpf_rebuild_interval( - rqctx: RequestContext, - request: TypedBody, - ) -> Result; } diff --git a/mgadm/src/mrib.rs b/mgadm/src/mrib.rs index 57d701ac9..a8d911720 100644 --- a/mgadm/src/mrib.rs +++ b/mgadm/src/mrib.rs @@ -19,8 +19,7 @@ use clap::{Args, Subcommand}; use client_common::println_nopipe; use mg_admin_client::Client; use mg_admin_client::types::{ - MribRpfRebuildIntervalRequest, MulticastRoute, MulticastRouteKey, - RouteOriginFilter, + MulticastRoute, MulticastRouteKey, RouteOriginFilter, }; use mg_api_types::mrib::DEFAULT_MULTICAST_VNI; use mg_api_types::rdb::rib::AddressFamily; @@ -41,9 +40,6 @@ fn parse_route_origin(s: &str) -> Result { pub enum Commands { /// View MRIB state. Status(StatusCommand), - - /// RPF (Reverse Path Forwarding) table configuration and lookup. - Rpf(RpfCommand), } #[derive(Debug, Args)] @@ -109,24 +105,6 @@ pub enum StatusCmd { }, } -#[derive(Debug, Args)] -pub struct RpfCommand { - #[command(subcommand)] - command: RpfCmd, -} - -#[derive(Subcommand, Debug)] -pub enum RpfCmd { - /// Get RPF rebuild interval. - GetInterval, - - /// Set RPF rebuild interval. - SetInterval { - /// Rebuild interval in milliseconds - interval_ms: u64, - }, -} - pub async fn commands(command: Commands, c: Client) -> Result<()> { match command { Commands::Status(status_cmd) => match status_cmd.command { @@ -157,12 +135,6 @@ pub async fn commands(command: Commands, c: Client) -> Result<()> { } } }, - Commands::Rpf(rpf_cmd) => match rpf_cmd.command { - RpfCmd::GetInterval => get_rpf_interval(c).await?, - RpfCmd::SetInterval { interval_ms } => { - set_rpf_interval(c, interval_ms).await? - } - }, } Ok(()) } @@ -229,21 +201,6 @@ async fn get_route_selected( Ok(()) } -async fn get_rpf_interval(c: Client) -> Result<()> { - let result = c.read_mrib_rpf_rebuild_interval().await?.into_inner(); - println_nopipe!("RPF rebuild interval: {}ms", result.interval_ms); - Ok(()) -} - -async fn set_rpf_interval(c: Client, interval_ms: u64) -> Result<()> { - c.update_mrib_rpf_rebuild_interval(&MribRpfRebuildIntervalRequest { - interval_ms, - }) - .await?; - println_nopipe!("Updated RPF rebuild interval to: {interval_ms}ms"); - Ok(()) -} - fn print_routes(routes: &[MulticastRoute]) { if routes.is_empty() { println_nopipe!("No multicast routes"); @@ -295,21 +252,19 @@ mod tests { ]) .unwrap(); - match cli.command { - Commands::Status(cmd) => 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"), - }, - _ => panic!("expected Status command"), + 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"), } } @@ -328,24 +283,22 @@ mod tests { ]) .unwrap(); - match cli.command { - Commands::Status(cmd) => 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"), - }, - _ => panic!("expected Status command"), + 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"), } } @@ -364,28 +317,26 @@ mod tests { ]) .unwrap(); - match cli.command { - Commands::Status(cmd) => 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"), - }, - _ => panic!("expected Status command"), + 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"), } } @@ -395,19 +346,17 @@ mod tests { TestCli::try_parse_from(["test", "status", "imported", "ipv4"]) .unwrap(); - match cli.command { - Commands::Status(cmd) => match cmd.command { - StatusCmd::Imported { - group, - address_family, - .. - } => { - assert_eq!(group, None); - assert_eq!(address_family, Some(AddressFamily::Ipv4)); - } - _ => panic!("expected Imported"), - }, - _ => panic!("expected Status command"), + 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"), } } @@ -418,15 +367,13 @@ mod tests { ]) .unwrap(); - match cli.command { - Commands::Status(cmd) => match cmd.command { - StatusCmd::Imported { group, origin, .. } => { - assert_eq!(group, None); - assert_eq!(origin, Some(RouteOriginFilter::Dynamic)); - } - _ => panic!("expected Imported"), - }, - _ => panic!("expected Status command"), + 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"), } } @@ -435,38 +382,19 @@ mod tests { let cli = TestCli::try_parse_from(["test", "status", "selected"]).unwrap(); - match cli.command { - Commands::Status(cmd) => 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"), - }, - _ => panic!("expected Status command"), - } - } - - #[test] - fn test_rpf_set_interval() { - let cli = - TestCli::try_parse_from(["test", "rpf", "set-interval", "500"]) - .unwrap(); - - match cli.command { - Commands::Rpf(cmd) => match cmd.command { - RpfCmd::SetInterval { interval_ms } => { - assert_eq!(interval_ms, 500); - } - _ => panic!("expected SetInterval"), - }, - _ => panic!("expected Rpf command"), + 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/mgd/src/admin.rs b/mgd/src/admin.rs index 44497b18e..3dd132015 100644 --- a/mgd/src/admin.rs +++ b/mgd/src/admin.rs @@ -28,7 +28,6 @@ use mg_api_types::bgp::session::{ }; use mg_api_types::mrib::{ MribAddStaticRequest, MribDeleteStaticRequest, MribQuery, - MribRpfRebuildIntervalRequest, MribRpfRebuildIntervalResponse, }; use mg_api_types::ndp::{NdpInterface, NdpInterfaceSelector, NdpManagerState}; use mg_api_types::rib::{ @@ -719,19 +718,6 @@ impl MgAdminApi for MgAdminApiImpl { { mrib_admin::static_list_mcast_routes(rqctx).await } - - async fn read_mrib_rpf_rebuild_interval( - rqctx: RequestContext, - ) -> Result, HttpError> { - mrib_admin::read_mrib_rpf_rebuild_interval(rqctx).await - } - - async fn update_mrib_rpf_rebuild_interval( - rqctx: RequestContext, - request: TypedBody, - ) -> Result { - mrib_admin::update_mrib_rpf_rebuild_interval(rqctx, request).await - } } pub fn api_description() -> ApiDescription> { diff --git a/mgd/src/mrib_admin.rs b/mgd/src/mrib_admin.rs index 93934e3a7..2a2b1da0f 100644 --- a/mgd/src/mrib_admin.rs +++ b/mgd/src/mrib_admin.rs @@ -5,7 +5,6 @@ // Copyright 2026 Oxide Computer Company use std::sync::Arc; -use std::time::Duration; use dropshot::{ HttpError, HttpResponseDeleted, HttpResponseOk, @@ -13,9 +12,8 @@ use dropshot::{ }; use mg_api_types::mrib::{ - MribAddStaticRequest, MribDeleteStaticRequest, MribQuery, - MribRpfRebuildIntervalRequest, MribRpfRebuildIntervalResponse, - MulticastAddr, MulticastRoute, MulticastRouteKey, MulticastSourceProtocol, + MribAddStaticRequest, MribDeleteStaticRequest, MribQuery, MulticastAddr, + MulticastRoute, MulticastRouteKey, MulticastSourceProtocol, RouteOriginFilter, }; @@ -151,29 +149,3 @@ pub async fn static_list_mcast_routes( let routes = ctx.db.get_static_mcast_routes().map_err(Error::from)?; Ok(HttpResponseOk(routes)) } - -pub async fn read_mrib_rpf_rebuild_interval( - rqctx: RequestContext>, -) -> Result, HttpError> { - let ctx = rqctx.context(); - let interval = ctx - .db - .get_mrib_rpf_rebuild_interval() - .map_err(|e| HttpError::for_internal_error(format!("{e}")))?; - Ok(HttpResponseOk(MribRpfRebuildIntervalResponse { - interval_ms: u64::try_from(interval.as_millis()).unwrap_or(u64::MAX), - })) -} - -pub async fn update_mrib_rpf_rebuild_interval( - rqctx: RequestContext>, - request: TypedBody, -) -> Result { - let ctx = rqctx.context(); - let body = request.into_inner(); - let interval = Duration::from_millis(body.interval_ms); - ctx.db - .set_mrib_rpf_rebuild_interval(interval) - .map_err(|e| HttpError::for_internal_error(format!("{e}")))?; - Ok(HttpResponseUpdatedNoContent()) -} diff --git a/openapi/mg-admin/mg-admin-12.0.0-9cf43c.json b/openapi/mg-admin/mg-admin-12.0.0-a8ad1e.json similarity index 98% rename from openapi/mg-admin/mg-admin-12.0.0-9cf43c.json rename to openapi/mg-admin/mg-admin-12.0.0-a8ad1e.json index f6aa469d3..e06993970 100644 --- a/openapi/mg-admin/mg-admin-12.0.0-9cf43c.json +++ b/openapi/mg-admin/mg-admin-12.0.0-a8ad1e.json @@ -1234,55 +1234,6 @@ } } }, - "/mrib/config/rpf/rebuild-interval": { - "get": { - "summary": "Get the RPF rebuild rate-limit interval.", - "operationId": "read_mrib_rpf_rebuild_interval", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MribRpfRebuildIntervalResponse" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "post": { - "summary": "Set the RPF rebuild rate-limit interval.", - "operationId": "update_mrib_rpf_rebuild_interval", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MribRpfRebuildIntervalRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, "/mrib/status/imported": { "get": { "summary": "Get imported multicast routes from `mrib_in`.", @@ -4103,36 +4054,6 @@ "keys" ] }, - "MribRpfRebuildIntervalRequest": { - "description": "Request body for setting the RPF rebuild interval.", - "type": "object", - "properties": { - "interval_ms": { - "description": "Minimum interval between RPF cache rebuilds in milliseconds. A value of 0 disables rate-limiting. Every unicast RIB change triggers an immediate poptrie rebuild.", - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "interval_ms" - ] - }, - "MribRpfRebuildIntervalResponse": { - "description": "Response containing the current RPF rebuild interval.", - "type": "object", - "properties": { - "interval_ms": { - "description": "Minimum interval between RPF cache rebuilds in milliseconds. A value of 0 means rate-limiting is disabled.", - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "interval_ms" - ] - }, "MulticastRoute": { "description": "Multicast route entry containing replication groups and metadata.", "type": "object", @@ -4152,7 +4073,7 @@ }, "rpf_neighbor": { "nullable": true, - "description": "Expected RPF neighbor for the source (for RPF checks).", + "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" }, diff --git a/openapi/mg-admin/mg-admin-latest.json b/openapi/mg-admin/mg-admin-latest.json index aa4a8e356..0fa590d7b 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-12.0.0-9cf43c.json \ No newline at end of file +mg-admin-12.0.0-a8ad1e.json \ No newline at end of file diff --git a/rdb/src/db.rs b/rdb/src/db.rs index 1190107a3..180059a1f 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -103,18 +103,9 @@ 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 rebuild interval. -const MRIB_RPF_REBUILD_INTERVAL: &str = "mrib_rpf_rebuild_interval"; - /// Key used in settings tree for MRIB RPF revalidation interval. const MRIB_RPF_REVALIDATION_INTERVAL: &str = "mrib_rpf_revalidation_interval"; -/// Default MRIB RPF rebuild interval in milliseconds. -/// -/// Multicast route additions can be bursty, and poptrie rebuilds can be -/// expensive. During rebuilds, RPF verification falls back to linear scan. -const DEFAULT_MRIB_RPF_REBUILD_INTERVAL_MS: u64 = 1000; - use crate::rib::{Rib, Rib4, Rib6}; /// Cached configuration values for low-overhead reads. @@ -263,19 +254,6 @@ impl Db { .bestpath_fanout .store(fanout.get(), Ordering::Relaxed); - // Load RPF rebuild interval from settings and apply to `RpfTable` - let rebuild_interval = db.get_mrib_rpf_rebuild_interval().unwrap_or_else(|e| { - error!( - db.log, - "failed to load mrib_rpf_rebuild_interval from settings: {e}" - ); - std::time::Duration::from_millis( - DEFAULT_MRIB_RPF_REBUILD_INTERVAL_MS, - ) - }); - - db.rpf_table.set_rebuild_interval(rebuild_interval); - // Load RPF revalidation interval from settings. let revalidation_interval = db.get_mrib_rpf_revalidation_interval().unwrap_or_else(|e| { @@ -332,8 +310,6 @@ impl Db { /// /// This controls how often the revalidator thread walks the MRIB to /// re-check (S,G) routes even without explicit unicast RIB changes. - /// This is separate from the poptrie rebuild interval (see - /// [`Self::set_mrib_rpf_rebuild_interval`]). /// /// This is persisted to the settings tree. pub fn set_mrib_rpf_revalidation_interval( @@ -412,6 +388,11 @@ impl Db { } 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!( @@ -919,7 +900,12 @@ 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, @@ -946,14 +932,14 @@ impl Db { rib_loc.remove(prefix); } } - - // Request RPF table rebuild (may be rate-limited). - // Pass the specific prefix for targeted (S,G) revalidation. - self.rpf_table - .trigger_rebuild_v4(Arc::clone(&self.rib4_loc), Some(*prefix)); } - 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, @@ -980,11 +966,47 @@ impl Db { rib_loc.remove(prefix); } } + } - // Request RPF table rebuild (may be rate-limited). - // Pass the specific prefix for targeted (S,G) revalidation. - self.rpf_table - .trigger_rebuild_v6(Arc::clone(&self.rib6_loc), Some(*prefix)); + /// 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 @@ -1005,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); @@ -1017,6 +1041,7 @@ impl Db { &mut rib4_loc, fanout.get().into(), ); + changed.insert(IpNet::from(*prefix)); } } } @@ -1033,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( @@ -1090,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); @@ -1430,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, { @@ -1626,92 +1659,63 @@ impl Db { Ok(()) } - /// Get the minimum interval between poptrie cache rebuilds. - /// - /// This rate-limits how often the poptrie is rebuilt in response to - /// unicast RIB changes. When rate-limited, lookups fall back to linear - /// scan. This is separate from the revalidation sweep interval (see - /// [`Self::set_mrib_rpf_revalidation_interval`]). - pub fn get_mrib_rpf_rebuild_interval( - &self, - ) -> Result { - let tree = self.persistent.open_tree(SETTINGS)?; - let interval_ms = match tree.get(MRIB_RPF_REBUILD_INTERVAL)? { - None => DEFAULT_MRIB_RPF_REBUILD_INTERVAL_MS, - Some(value) => { - let bytes: [u8; 8] = (*value).try_into().map_err(|_| { - Error::DbValue(format!( - "invalid mrib_rpf_rebuild_interval value in db: expected 8 bytes, found {}", - value.len() - )) - })?; - u64::from_be_bytes(bytes) - } - }; - Ok(std::time::Duration::from_millis(interval_ms)) - } - - /// Set the minimum interval between poptrie cache rebuilds. - /// - /// This rate-limits how often the poptrie is rebuilt in response to - /// unicast RIB changes. When rate-limited, lookups fall back to linear - /// scan. - /// - /// This is persisted to the settings tree. - pub fn set_mrib_rpf_rebuild_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_REBUILD_INTERVAL, &interval_ms.to_be_bytes())?; - tree.flush()?; - self.rpf_table.set_rebuild_interval(interval); - 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 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.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); } // ======================================================================== @@ -1736,25 +1740,64 @@ impl Db { return; }; - // (S,G): derive rpf_neighbor from unicast RIB + // (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); - let rpf_neighbor = self.rpf_table.lookup( - source, - &self.rib4_loc, - &self.rib6_loc, - fanout as usize, - ); + 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 - ); - } + 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; + } - // Atomically update mrib_in and mrib_loc - self.mrib.apply_rpf_result(key, rpf_neighbor); + // 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. @@ -1877,17 +1920,35 @@ impl Db { ) -> Result<(), Error> { let tree = self.persistent.open_tree(STATIC_MCAST_ROUTES)?; - // Pre-serialize keys and values outside the transaction to - // keep fallible serde operations out of the closure. + // 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| { - Ok((route.key.db_key()?, serde_json::to_string(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 + { + normalized.updated = prev.updated; + } + } + Ok((key, serde_json::to_string(&normalized)?)) }) .collect::>()?; - // Persist atomically before updating the in-memory MRIB, so a - // partial write never leaves config and state out of sync. + // 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())?; @@ -2087,7 +2148,6 @@ mod test { use oxnet::{IpNet, Ipv4Net, Ipv6Net}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::str::FromStr; - use std::time::Duration; fn test_underlay() -> UnderlayMulticastIpv6 { UnderlayMulticastIpv6::new(Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1)) @@ -2494,8 +2554,6 @@ mod test { let log = init_file_logger("mrib_reval.log"); let db = crate::test::get_test_db("mrib_reval", log).expect("create db"); - db.set_mrib_rpf_rebuild_interval(std::time::Duration::ZERO) - .unwrap(); // IPv4 test_af( @@ -2517,6 +2575,97 @@ mod test { ); } + /// 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) @@ -2526,7 +2675,6 @@ mod test { let log = init_file_logger("mrib_asm_ssm.log"); let db = crate::test::get_test_db("mrib_asm_ssm", log).expect("create db"); - db.set_mrib_rpf_rebuild_interval(Duration::ZERO).unwrap(); // Case: (*,G) with ASM address goes to `mrib_loc` immediately // (no unicast route needed) diff --git a/rdb/src/mrib/mod.rs b/rdb/src/mrib/mod.rs index 96a328e7d..1a67a87d8 100644 --- a/rdb/src/mrib/mod.rs +++ b/rdb/src/mrib/mod.rs @@ -28,6 +28,7 @@ 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}; @@ -269,8 +270,12 @@ impl Mrib { mrib_loc.remove(key).is_some() } Some(route) => { - // Update rpf_neighbor in mrib_in - route.rpf_neighbor = neighbor; + // 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() { @@ -306,26 +311,38 @@ impl Mrib { /// Add or update a multicast route in `mrib_in`. /// - /// 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`. + /// [`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`]. /// - /// Accepts a full [MulticastRoute]. - pub fn add_route(&self, route: MulticastRoute) -> Result<(), Error> { + /// 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); - let changed = match mrib_in.get(&key) { + match mrib_in.get(&key) { Some(existing) => { - // Check if route actually changed - existing.rpf_neighbor != route.rpf_neighbor - || existing.source != route.source - || existing.underlay_group != route.underlay_group + 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 => true, // New route - }; - mrib_in.insert(key, route); - changed + None => { + mrib_in.insert(key, route); + true + } + } }; if changed { @@ -335,7 +352,10 @@ impl Mrib { } /// Remove a multicast route from both `mrib_in` and `mrib_loc`. - pub fn remove_route(&self, key: &MulticastRouteKey) -> Result { + 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); diff --git a/rdb/src/mrib/rpf.rs b/rdb/src/mrib/rpf.rs index a84a52f70..9d0c0e94a 100644 --- a/rdb/src/mrib/rpf.rs +++ b/rdb/src/mrib/rpf.rs @@ -11,10 +11,23 @@ //! 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 a lazy -//! rebuild strategy and fallback to linear scan during rebuilds. RPF lookups -//! happen frequently during multicast route installation and unicast RIB -//! changes, requiring LPM against the unicast RIB. +//! [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 //! @@ -37,30 +50,37 @@ //! 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. //! -//! ## Lock Poisoning -//! -//! The poptrie cache uses asymmetric poison handling: +//! ## Rebuild Worker //! -//! - **Write side** (background rebuild thread): Panics on poison via -//! `write_lock!`. +//! 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. //! -//! - **Read side**: Uses `.ok()` for graceful fallback to linear -//! scan if the cache is poisoned. This avoids crashing during RPF checks -//! while the linear scan fallback remains intact. +//! 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. //! -//! Once an `RwLock` is poisoned, it **cannot be unpoisoned**. -//! Subsequent rebuild attempts will also panic on `write_lock!`, so the cache -//! remains permanently disabled. Reads continue to work via the -//! linear-scan fallback, keeping the system functional until SMF restarts the -//! daemon (which is the normal recovery path here). +//! 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. //! -//! ## Threading Model +//! ## Lock Poisoning //! -//! Poptrie cache rebuilds run in short-lived, named background threads -//! ("rpf-poptrie-v4"/"rpf-poptrie-v6"). These threads are fire-and-forget: -//! we deliberately drop their `JoinHandle`s. If a rebuild thread panics, the -//! cache is simply not updated and RPF verification transparently falls back -//! to the linear-scan path until the next successful rebuild. +//! 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 @@ -68,15 +88,15 @@ use std::collections::{BTreeMap, BTreeSet}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, RwLock, mpsc}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, RwLock, mpsc}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; use poptrie::Poptrie; use slog::{Logger, debug, error}; -use mg_common::{lock, write_lock}; +use mg_common::lock; use crate::bestpath::bestpaths; use crate::rib::{Rib4, Rib6}; @@ -86,11 +106,39 @@ 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 a rebuild -/// request was rate-limited but multicast RPF revalidation should still -/// proceed using the linear-scan fallback. +/// 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 @@ -106,16 +154,6 @@ pub(crate) enum RebuildEvent { } impl RebuildEvent { - /// Convert to a full-sweep event (no specific prefix). - /// - /// Used when multiple prefixes may have changed during pending rebuilds. - fn to_full_sweep(self) -> Self { - match self { - Self::V4(_) => Self::V4(None), - Self::V6(_) => Self::V6(None), - } - } - /// Check if a source address is potentially affected by this event. /// /// Returns true if the source falls within the changed prefix (targeted), @@ -138,259 +176,349 @@ impl RebuildEvent { } } -/// Set of paths for a prefix, stored in the poptrie cache. +/// Nexthops derived from a prefix's path set at cache-build time. /// -/// We store full [`Path`] objects (not just nexthops) so that we can apply -/// bestpath selection at lookup time. This ensures consistent behavior -/// between the poptrie fast path and linear scan fallback, regardless of -/// the configured fanout value. -pub(crate) type CachedPaths = BTreeSet; - -/// State machine for coordinating poptrie rebuilds. -#[repr(u8)] -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum RebuildState { - /// No rebuild in progress. - Idle = 0, - /// Rebuild thread is running. - Running = 1, - /// Rebuild thread is running and more changes arrived. - RunningPending = 2, +/// 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, } -/// Coordinator for poptrie rebuild threads. -/// -/// Provides atomic state transitions to prevent race conditions where -/// pending work could be missed between checking the pending flag and -/// releasing the in-progress lock. -#[derive(Debug)] -struct RebuildCoordinator(AtomicU8); - -impl RebuildCoordinator { - /// Create a new coordinator in the idle state. - fn new() -> Self { - Self(AtomicU8::new(RebuildState::Idle as u8)) +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), + } } - /// Try to start a rebuild. Returns `true` if this thread should do work. - /// - /// Atomically transitions `Idle → Running`. - fn try_start(&self) -> bool { - self.0 - .compare_exchange( - RebuildState::Idle as u8, - RebuildState::Running as u8, - Ordering::AcqRel, - Ordering::Acquire, - ) - .is_ok() + /// 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)>, +} - /// Signal that more work arrived while a rebuild is in progress. +impl Pending { + /// Fold an IPv4 request into the pending slot. /// - /// Atomically transitions `Running → RunningPending`. If already - /// `RunningPending` or `Idle`, this is a no-op. - fn signal_pending(&self) { - // Only transition Running → RunningPending - let _ = self.0.compare_exchange( - RebuildState::Running as u8, - RebuildState::RunningPending as u8, - Ordering::AcqRel, - Ordering::Relaxed, - ); + /// 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)); } - /// Check if more work is pending and atomically clear the pending flag. + /// Fold an IPv6 request into the pending slot. /// - /// Returns `true` if we should continue working (previously `RunningPending`). - /// Atomically transitions `RunningPending → Running`. - fn check_pending(&self) -> bool { - self.0 - .compare_exchange( - RebuildState::RunningPending as u8, - RebuildState::Running as u8, - Ordering::AcqRel, - Ordering::Acquire, - ) - .is_ok() + /// 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)); } - /// Mark the rebuild as complete. - /// - /// Transitions to `Idle`. Should only be called by the rebuild thread. - fn finish(&self) { - self.0.store(RebuildState::Idle as u8, Ordering::Release); + fn is_empty(&self) -> bool { + self.v4.is_none() && self.v6.is_none() } } -/// Scope guard to mark rebuild as finished on drop. +/// Lock the shared pending slot, healing a poisoned lock. /// -/// This ensures the coordinator transitions to `Idle` even if the rebuild -/// thread panics, preventing deadlock. -struct RebuildGuard(Arc); +/// [`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(), + } +} -impl Drop for RebuildGuard { - fn drop(&mut self) { - self.0.finish(); +/// 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; + } } } -/// Address-family related rebuild job. +/// The long-lived worker that owns all poptrie rebuilds. /// -/// Encapsulates the RIB source and cache destination for a poptrie rebuild, -/// allowing the rebuild logic to be shared between IPv4 and IPv6. -enum RebuildJob { - V4 { - rib: Arc>, - cache: Arc>>>, - }, - V6 { - rib: Arc>, - cache: Arc>>>, - }, +/// 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 RebuildJob { - /// Take a snapshot of the RIB and build a fresh poptrie cache. - fn rebuild(&self) { - match self { - Self::V4 { rib, cache } => { - let snapshot = { - let r = lock!(rib); - RpfTable::snapshot_rib(&r, |p| { - (p.addr().octets(), p.width()) - }) - }; - let mut table = poptrie::Ipv4RoutingTable::default(); - for (addr, len, paths) in snapshot { - table.insert((addr, len), paths); +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; } - *write_lock!(cache) = Some(Poptrie::from(table)); - } - Self::V6 { rib, cache } => { - let snapshot = { - let r = lock!(rib); - RpfTable::snapshot_rib(&r, |p| { - (p.addr().octets(), p.width()) - }) - }; - let mut table = poptrie::Ipv6RoutingTable::default(); - for (addr, len, paths) in snapshot { - table.insert((addr, len), paths); + + if let Some((rib, prefix)) = work.v4 { + self.rebuild_v4(&rib, prefix); + } + if let Some((rib, prefix)) = work.v6 { + self.rebuild_v6(&rib, prefix); } - *write_lock!(cache) = Some(Poptrie::from(table)); } } } - /// Thread name for debugging. - fn thread_name(&self) -> &'static str { - match self { - Self::V4 { .. } => "rpf-poptrie-v4", - Self::V6 { .. } => "rpf-poptrie-v6", + /// 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. -/// The cache is rebuilt asynchronously in the background when triggered, with -/// rate limiting to avoid excessive rebuilds. Falls back to linear scan during -/// rebuilds or if poptrie is unavailable. +/// 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 full [`Path`] objects (not just nexthops) so that -/// bestpath selection can be applied at lookup time. This ensures consistent -/// RPF verification behavior regardless of whether poptrie or linear scan is -/// used. +/// 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>>>, + cache_v4: Arc>>>, /// IPv6 poptrie cache. - cache_v6: Arc>>>, - /// Last rebuild completion time for rate limiting. - /// - /// Shared between v4 and v6 rebuilds. During route updates - /// affecting both address families, this prevents simultaneous - /// rebuilds and spreads the CPU load. The fallback is a linear scan. - last_rebuild: Arc>>, - /// Configurable minimum interval between rebuilds (milliseconds). - /// A value of 0 disables rate-limiting. - rebuild_interval_ms: Arc, + 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>>>, - /// Coordinator for IPv4 poptrie rebuilds. - rebuild_v4: Arc, - /// Coordinator for IPv6 poptrie rebuilds. - rebuild_v6: Arc, + /// Cache generation, advanced on every swap that changes lookup results. + generation: Arc, /// Logger for error reporting. log: Logger, } impl RpfTable { - /// Default minimum time between rebuilds (milliseconds). - const DEFAULT_REBUILD_INTERVAL_MS: u64 = 1000; - - /// Create a new empty RPF table with default rebuild interval. + /// 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: Arc::new(RwLock::new(None)), - cache_v6: Arc::new(RwLock::new(None)), - last_rebuild: Arc::new(Mutex::new(None)), - rebuild_interval_ms: Arc::new(AtomicU64::new( - Self::DEFAULT_REBUILD_INTERVAL_MS, - )), - rebuild_notifier: Arc::new(Mutex::new(None)), - rebuild_v4: Arc::new(RebuildCoordinator::new()), - rebuild_v6: Arc::new(RebuildCoordinator::new()), + cache_v4, + cache_v6, + pending, + wake_tx, + rebuild_notifier, + generation, log, } } - /// Set the minimum interval between rebuilds. A zeroed duration - /// disables rate-limiting, causing every trigger to rebuild - /// immediately. - pub fn set_rebuild_interval(&self, interval: Duration) { - self.rebuild_interval_ms.store( - u64::try_from(interval.as_millis()).unwrap(), - Ordering::Relaxed, - ); + /// Current cache generation for optimistic concurrency over derivations. + pub(crate) fn generation(&self) -> Generation { + self.generation.current() } - /// Check if enough time has passed since the last rebuild. - /// Returns `true` if rebuild should proceed, `false` if rate limited. - fn check_rate_limit(&self) -> bool { - let min_interval_ms = self.rebuild_interval_ms.load(Ordering::Relaxed); - let min_interval = Duration::from_millis(min_interval_ms); + /// 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) + } - if let Ok(last) = self.last_rebuild.lock() - && let Some(last_instant) = *last - && last_instant.elapsed() < min_interval - { - return false; // Skip rebuild, too soon + /// 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, } - true } /// Send a rebuild event notification if configured. - fn notify(&self, event: RebuildEvent) { + /// + /// 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 - && tx.send(event).is_err() { + 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, paths) tuples from the RIB. - /// The `to_bits` closure converts the prefix to address bits. + /// 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, BTreeSet)> + ) -> Vec<(A, u8, RpfNexthops)> where F: Fn(&P) -> (A, u8), { @@ -398,7 +526,7 @@ impl RpfTable { .filter(|(_, paths)| !paths.is_empty()) .map(|(prefix, paths)| { let (bits, len) = to_bits(prefix); - (bits, len, paths.clone()) + (bits, len, RpfNexthops::from_paths(paths)) }) .collect() } @@ -410,163 +538,61 @@ impl RpfTable { } } - /// Spawn a background thread to execute a rebuild job. - /// - /// This is the shared implementation for both IPv4 and IPv6 rebuilds. - /// The job encapsulates the address-family-specific parts (RIB, cache), - /// while this method handles the shared logic (coordinator, timing, notify). - /// - /// The `event` parameter is used for targeted revalidation: if we complete - /// without looping, we send the original prefix so only affected (S,G) - /// routes are re-checked. If pending changes caused us to loop, we send - /// a full-sweep event (`None` prefix) since multiple prefixes may have - /// changed. - fn spawn_rebuild( - &self, - job: RebuildJob, - coordinator: Arc, - event: RebuildEvent, - ) { - let last_rebuild = self.last_rebuild.clone(); - let notifier = self.rebuild_notifier.clone(); - let log = self.log.clone(); - let thread_name = job.thread_name().to_string(); - let thread_coord = Arc::clone(&coordinator); - - if let Err(e) = - thread::Builder::new() - .name(thread_name.clone()) - .spawn(move || { - let _guard = RebuildGuard(Arc::clone(&thread_coord)); - - // Track whether we looped due to pending changes. - let mut looped = false; - - // Loop while there are pending rebuilds. This ensures we - // capture all RIB changes that occurred during the rebuild. - loop { - job.rebuild(); - - if let Ok(mut last) = last_rebuild.lock() { - *last = Some(Instant::now()); - } - - // Atomically check if more changes arrived during rebuild. - // If so, loop again to capture them. - if !thread_coord.check_pending() { - break; - } - looped = true; - } - - // Notify revalidator. If we looped, multiple prefixes may - // have changed so we send a full-sweep event. - if let Ok(guard) = notifier.lock() - && let Some(tx) = &*guard - { - let final_event = - if looped { event.to_full_sweep() } else { event }; - let _ = tx.send(final_event); - } - }) - { - error!(log, "failed to spawn {thread_name}: {e}"); - coordinator.finish(); - self.notify(event); - } - } - /// Trigger a background rebuild of the IPv4 RPF cache. /// - /// The RIB snapshot is taken lazily in the background thread, reducing - /// lock contention during RIB updates. + /// 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. - /// - /// This trigger is rate limited based on configured interval. Only one - /// rebuild can be in progress at a time per address family. pub fn trigger_rebuild_v4( &self, rib4_loc: Arc>, changed_prefix: Option, ) { - if !self.check_rate_limit() { - // Clear cache to force linear-scan fallback until next rebuild. - // This ensures lookups use fresh RIB data rather than stale cache. - if let Ok(mut guard) = self.cache_v4.write() { - *guard = None; - } + 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)); - return; - } - - if !self.rebuild_v4.try_start() { - self.rebuild_v4.signal_pending(); - return; } - - let job = RebuildJob::V4 { - rib: rib4_loc, - cache: self.cache_v4.clone(), - }; - - self.spawn_rebuild( - job, - Arc::clone(&self.rebuild_v4), - RebuildEvent::V4(changed_prefix), - ); } /// Trigger a background rebuild of the IPv6 RPF cache. /// - /// The RIB snapshot is taken lazily in the background thread, reducing - /// lock contention during RIB updates. + /// 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. - /// - /// This trigger is rate limited based on configured interval. Only one - /// rebuild can be in progress at a time per address family. pub fn trigger_rebuild_v6( &self, rib6_loc: Arc>, changed_prefix: Option, ) { - if !self.check_rate_limit() { - // Clear cache to force linear-scan fallback until next rebuild. - // This ensures lookups use fresh RIB data rather than stale cache. - if let Ok(mut guard) = self.cache_v6.write() { - *guard = None; - } + 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)); - return; - } - - if !self.rebuild_v6.try_start() { - self.rebuild_v6.signal_pending(); - return; } - - let job = RebuildJob::V6 { - rib: rib6_loc, - cache: self.cache_v6.clone(), - }; - - self.spawn_rebuild( - job, - Arc::clone(&self.rebuild_v6), - 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 exists for the source. + /// no route with an active path exists for the source. /// /// Uses poptrie for O(1) lookup with linear scan fallback. pub fn lookup( @@ -577,7 +603,7 @@ impl RpfTable { fanout: usize, ) -> Option { // Try poptrie lookup first - let cached_paths = match source { + 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))) }), @@ -586,8 +612,8 @@ impl RpfTable { }), }; - if let Some(paths) = cached_paths { - return Self::get_rpf_neighbor(&paths, fanout); + if let Some(nexthops) = cached { + return nexthops.for_fanout(fanout); } // Fallback to linear scan @@ -608,12 +634,16 @@ impl RpfTable { ) -> Option { let rib = rib4_loc.lock().ok()?; - // Find best matching prefix (longest-prefix match) + // 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) && prefix.width() > best_len { + if prefix.contains(source) + && (best_paths.is_none() || prefix.width() > best_len) + { best_len = prefix.width(); best_paths = Some(paths); } @@ -633,12 +663,16 @@ impl RpfTable { ) -> Option { let rib = rib6_loc.lock().ok()?; - // Find best matching prefix (longest-prefix match) + // 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) && prefix.width() > best_len { + if prefix.contains(source) + && (best_paths.is_none() || prefix.width() > best_len) + { best_len = prefix.width(); best_paths = Some(paths); } @@ -650,25 +684,28 @@ impl RpfTable { /// Extract the RPF neighbor from a set of paths. /// /// For fanout == 1, returns the single bestpath nexthop. - /// For fanout > 1, returns the first active nexthop. All paths in loc-rib - /// are valid ECMP paths (already bestpath-selected), so any one suffices - /// for RPF verification. + /// 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 active_paths: BTreeSet = - paths.iter().filter(|p| !p.shutdown).cloned().collect(); - - if active_paths.is_empty() { - return None; - } + let first_active = paths.iter().find(|p| !p.shutdown)?; if fanout == 1 { - bestpaths(&active_paths, 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 { - active_paths.iter().next().map(|p| p.nexthop) + Some(first_active.nexthop) } } } @@ -698,6 +735,25 @@ mod tests { 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 @@ -767,6 +823,59 @@ mod tests { ); } + #[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(); @@ -808,6 +917,104 @@ mod tests { ); } + /// 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 diff --git a/rdb/src/types.rs b/rdb/src/types.rs index aa1bffaa3..961083f97 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -743,24 +743,11 @@ mod test { ); } + /// 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_af_mismatch_v4_rpf_v6_group() { - 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::V4(Ipv4Addr::new(10, 0, 0, 1))); - assert!( - route.validate().is_err(), - "v4 RPF with v6 group should be rejected" - ); - } - - #[test] - fn rpf_af_mismatch_v6_rpf_v4_group() { + 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( @@ -771,8 +758,8 @@ mod test { route.rpf_neighbor = Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))); assert!( - route.validate().is_err(), - "v6 RPF with v4 group should be rejected" + route.validate().is_ok(), + "v6 RPF neighbor with v4 group should be accepted" ); }