From 7c765d74e57a745e1f20b5420ed53826c3237302 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Sat, 18 Jul 2026 04:33:45 +0000 Subject: [PATCH 1/5] [dpd, mcast] Tighten multicast group and source validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This work constrains group and SSM source validation based on a variety of specs, linked throughout the PR. Previously, DPD was accepting groups and (S, G) sources that no packet could actually exercise, including ones that can never leave a host/link or, worse, sources a switch can't forward. Here's the list (the first classifier one was the most obvious): | address | verdict | spec | |--------------------------------|---------|-------------------------------| | ff3x::/32 in ff30::/12 only | is SSM | RFC 4607 §1; RFC 3306 is ASM | | ff3x:: group ID > 0x40000000 | accept | RFC 4607 §4.3 | | v6 scopes other than 4/5/8/e | reject | RFC 7346; no Ethernet realm | | class E (240.0.0.0/4) source | reject | RFC 6890 "Source: False" | | 0.0.0.0/8 source | reject | RFC 6890 "Forwardable: False" | | 100.64.0.0/10 source | accept | RFC 6598, operator-internal | | source/group family mismatch | reject | (local choice) | | 232.0.0.0 (SSM null) | reject | RFC 4607 §4.3 | | v4-mapped/compat v6 source | reject | RFC 4291 §2.5.5, aliases IPv4 | --- dpd-client/tests/integration_tests/mcast.rs | 2 +- dpd/src/mcast/validate.rs | 312 ++++++++++++++++++-- 2 files changed, 288 insertions(+), 26 deletions(-) diff --git a/dpd-client/tests/integration_tests/mcast.rs b/dpd-client/tests/integration_tests/mcast.rs index 1d8a003b..e02dc0ef 100644 --- a/dpd-client/tests/integration_tests/mcast.rs +++ b/dpd-client/tests/integration_tests/mcast.rs @@ -24,7 +24,7 @@ const MULTICAST_TEST_IPV6: Ipv6Addr = Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 1, 0x1010); const MULTICAST_TEST_IPV4_SSM: Ipv4Addr = Ipv4Addr::new(232, 123, 45, 67); const MULTICAST_TEST_IPV6_SSM: Ipv6Addr = - Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 0x1111); + Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0xf000, 0x1111); const MULTICAST_NAT_IP: Ipv6Addr = Ipv6Addr::new(ADMIN_LOCAL_MULTICAST_PREFIX, 0, 0, 0, 0, 0, 0, 1); const GIMLET_MAC: &str = "11:22:33:44:55:66"; diff --git a/dpd/src/mcast/validate.rs b/dpd/src/mcast/validate.rs index 428cafb9..c5c2fcb2 100644 --- a/dpd/src/mcast/validate.rs +++ b/dpd/src/mcast/validate.rs @@ -17,11 +17,12 @@ use common::network::NatTarget; use dpd_types::mcast::MulticastTag; use omicron_common::address::{ IPV4_LINK_LOCAL_MULTICAST_SUBNET, IPV4_SSM_SUBNET, - IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, IPV6_LINK_LOCAL_MULTICAST_SUBNET, - IPV6_RESERVED_SCOPE_MULTICAST_SUBNET, IPV6_SSM_SUBNET, UNDERLAY_MULTICAST_SUBNET, }; +const IPV4_SSM_RESERVED_NULL: Ipv4Addr = Ipv4Addr::new(232, 0, 0, 0); +const IPV6_SSM_MIN_VALID_GROUP_ID: u32 = 0x4000_0001; + /// Check if an IP address is unicast (emulating the unstable std::net API). /// For IP addresses, unicast means simply "not multicast". const fn is_unicast(addr: IpAddr) -> bool { @@ -35,6 +36,8 @@ pub(crate) fn validate_multicast_address( addr: IpAddr, sources: Option<&[IpSrc]>, ) -> DpdResult<()> { + validate_source_address_family(addr, sources)?; + // First validate that source addresses are unicast validate_source_addresses(sources)?; @@ -45,6 +48,28 @@ pub(crate) fn validate_multicast_address( } } +/// Validates that exact sources use the multicast group's address family. +fn validate_source_address_family( + group: IpAddr, + sources: Option<&[IpSrc]>, +) -> DpdResult<()> { + let Some(sources) = sources else { + return Ok(()); + }; + + for source in sources { + let IpSrc::Exact(source) = source else { + continue; + }; + if source.is_ipv4() != group.is_ipv4() { + return Err(DpdError::Invalid(format!( + "Source IP {source} does not match multicast group address family ({group})", + ))); + } + } + Ok(()) +} + /// Validates the NAT target inner MAC and internal IP address. /// /// NAT targets must use addresses from the reserved underlay multicast subnet @@ -69,10 +94,21 @@ pub(crate) fn validate_nat_target(nat_target: NatTarget) -> DpdResult<()> { } /// Check if an IP address is a Source-Specific Multicast (SSM) address. +/// +/// IPv6 SSM uses `ff3x::/32`: flags `3`, any scope, and a zero second +/// segment. This excludes the broader `ff30::/12` range and RFC 3306 +/// unicast-prefix-based multicast addresses, whose second segment carries +/// `plen`. +/// +/// [RFC 4607 §1]: https://www.rfc-editor.org/rfc/rfc4607#section-1 +/// [RFC 3306]: https://www.rfc-editor.org/rfc/rfc3306 pub(crate) fn is_ssm(addr: IpAddr) -> bool { match addr { IpAddr::V4(ipv4) => IPV4_SSM_SUBNET.contains(ipv4), - IpAddr::V6(ipv6) => IPV6_SSM_SUBNET.contains(ipv6), + IpAddr::V6(ipv6) => { + let segs = ipv6.segments(); + segs[0] & 0xfff0 == 0xff30 && segs[1] == 0 + } } } @@ -95,6 +131,12 @@ fn validate_ipv4_multicast( // If this is SSM, require specific sources (RFC 4607) if is_ssm(addr.into()) { + if addr == IPV4_SSM_RESERVED_NULL { + return Err(DpdError::Invalid(format!( + "{addr} is reserved and must not be used as a destination \ + (RFC 4607)", + ))); + } if sources.is_none() || sources.unwrap().is_empty() { return Err(DpdError::Invalid(format!( "{addr} is a Source-Specific Multicast address and \ @@ -131,8 +173,40 @@ fn validate_ipv6_multicast( ))); } + // Allow admin-local (4), site-local (5), organization-local (8), and + // global (e) scopes. Reject reserved, interface-local, link-local, + // realm-local, and unassigned scopes. See RFC 7346 §2. + let scope = addr.segments()[0] & 0x000f; + let scope_name = match scope { + 0x0 | 0xf => Some("reserved"), + 0x1 => Some("interface-local"), + 0x2 => Some("link-local"), + 0x3 => Some("realm-local"), + 0x4 | 0x5 | 0x8 | 0xe => None, + _ => Some("unassigned"), + }; + + if let Some(scope_name) = scope_name { + return Err(DpdError::Invalid(format!( + "{addr} has {scope_name} multicast scope ({scope:#x}), which \ + cannot be used for multicast groups. Allowed scopes are 0x4 \ + (admin-local), 0x5 (site-local), 0x8 (organization-local), and \ + 0xe (global)", + ))); + } + // If this is SSM, require specific sources (RFC 4607) if is_ssm(addr.into()) { + let segs = addr.segments(); + let within_prefix = + segs[2] == 0 && segs[3] == 0 && segs[4] == 0 && segs[5] == 0; + let group_id = (u32::from(segs[6]) << 16) | u32::from(segs[7]); + if !within_prefix || group_id < IPV6_SSM_MIN_VALID_GROUP_ID { + return Err(DpdError::Invalid(format!( + "{addr} is not a valid IPv6 SSM address (group IDs \ + ff3x::4000:1 through ff3x::ffff:ffff, RFC 4607)", + ))); + } if sources.is_none() || sources.unwrap().is_empty() { return Err(DpdError::Invalid(format!( "{addr} is an IPv6 Source-Specific Multicast address (ff3x::/32) \ @@ -148,21 +222,6 @@ fn validate_ipv6_multicast( return Ok(()); } - // Check reserved subnets - let reserved_subnets = [ - IPV6_LINK_LOCAL_MULTICAST_SUBNET, - IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, - IPV6_RESERVED_SCOPE_MULTICAST_SUBNET, - ]; - - for subnet in &reserved_subnets { - if subnet.contains(addr) { - return Err(DpdError::Invalid(format!( - "{addr} is in the reserved multicast subnet {subnet}", - ))); - } - } - Ok(()) } @@ -194,9 +253,11 @@ pub(crate) fn validate_source_addresses( }; for source in sources { + // Any-source has no IP address to validate here; SSM policy is checked + // by the multicast-address validator. match source { IpSrc::Exact(ip) => validate_exact_source_address(*ip)?, - IpSrc::Any => {} // Any-source is always valid + IpSrc::Any => {} } } Ok(()) @@ -204,7 +265,7 @@ pub(crate) fn validate_source_addresses( /// Validates a single exact source IP address. fn validate_exact_source_address(ip: IpAddr) -> DpdResult<()> { - // First check if it's unicast (excludes multicast) + // Reject multicast addresses before checking other invalid source ranges. if !is_unicast(ip) { return Err(DpdError::Invalid(format!( "Source IP {ip} must be a unicast address (multicast addresses are not allowed)", @@ -220,14 +281,18 @@ fn validate_exact_source_address(ip: IpAddr) -> DpdResult<()> { /// Validates IPv4 source addresses for problematic types. fn validate_ipv4_source_address(ipv4: Ipv4Addr) -> DpdResult<()> { + let first_octet = ipv4.octets()[0]; if ipv4.is_loopback() || ipv4.is_broadcast() || ipv4.is_unspecified() || ipv4.is_link_local() + || first_octet == 0 + || first_octet >= 240 { return Err(DpdError::Invalid(format!( "Source IP {ipv4} is not a valid source address \ - (loopback, broadcast, unspecified, and link-local addresses are not allowed)", + (loopback, broadcast, unspecified, link-local, 0.0.0.0/8, and \ + 240.0.0.0/4 addresses are not allowed)", ))); } Ok(()) @@ -237,13 +302,30 @@ fn validate_ipv4_source_address(ipv4: Ipv4Addr) -> DpdResult<()> { fn validate_ipv6_source_address(ipv6: Ipv6Addr) -> DpdResult<()> { if ipv6.is_loopback() || ipv6.is_unspecified() - || ((ipv6.segments()[0] & 0xffc0) == 0xfe80) + || ipv6.is_unicast_link_local() { return Err(DpdError::Invalid(format!( "Source IP {ipv6} is not a valid source address \ (loopback, unspecified, and link-local addresses are not allowed)", ))); } + + let embedded = match ipv6.segments() { + [0, 0, 0, 0, 0, 0xffff, ..] => { + Some("IPv4-mapped (::ffff:0:0/96, RFC 4291 §2.5.5.2)") + } + [0, 0, 0, 0, 0, 0, ..] => { + Some("IPv4-compatible (::/96, RFC 4291 §2.5.5.1)") + } + _ => None, + }; + if let Some(form) = embedded { + return Err(DpdError::Invalid(format!( + "Source IP {ipv6} embeds an IPv4 address, {form}, and is not a \ + valid IPv6 source address", + ))); + } + Ok(()) } @@ -395,7 +477,7 @@ mod tests { #[test] fn test_ipv6_ssm_with_sources() { - let ssm_addr = Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 0x1234); + let ssm_addr = Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0xf000, 0x1234); let asm_addr = Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 0x1234); let exact_sources = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( @@ -426,6 +508,115 @@ mod tests { assert!(validate_ipv6_multicast(asm_addr, Some(&any_source)).is_ok()); } + #[test] + fn test_multicast_validation_boundaries() { + let v4_sources = + vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))]; + let v6_sources = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1, + )))]; + + // 232.0.0.0 is the reserved null SSM destination; everything above + // 232.0.0.0 through to 232.255.255.255 is usable. + assert!( + validate_ipv4_multicast( + Ipv4Addr::new(232, 0, 0, 0), + Some(&v4_sources) + ) + .is_err() + ); + assert!( + validate_ipv4_multicast(Ipv4Addr::new(232, 0, 0, 0), None).is_err() + ); + assert!( + validate_ipv4_multicast( + Ipv4Addr::new(232, 0, 0, 1), + Some(&v4_sources) + ) + .is_ok() + ); + assert!( + validate_ipv4_multicast( + Ipv4Addr::new(232, 0, 1, 0), + Some(&v4_sources) + ) + .is_ok() + ); + assert!( + validate_ipv4_multicast( + Ipv4Addr::new(232, 255, 255, 255), + Some(&v4_sources) + ) + .is_ok() + ); + assert!( + validate_ipv4_multicast(Ipv4Addr::new(231, 255, 255, 255), None) + .is_ok() + ); + assert!( + validate_ipv4_multicast(Ipv4Addr::new(233, 0, 0, 0), None).is_ok() + ); + + let invalid_group_ids = [(0, 0x1234), (0x3fff, 0xffff), (0x4000, 0)]; + for (hi, lo) in invalid_group_ids { + assert!( + validate_ipv6_multicast( + Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, hi, lo), + Some(&v6_sources) + ) + .is_err(), + "group ID {hi:#06x}{lo:04x} should be rejected", + ); + } + + let valid_group_ids = [ + (0x4000, 1), + (0x7fff, 0xffff), + (0x8000, 0), + (0x9000, 0), + (0xefff, 0xffff), + (0xf000, 0), + (0xfeff, 0xffff), + (0xffff, 0xffff), + ]; + for (hi, lo) in valid_group_ids { + assert!( + validate_ipv6_multicast( + Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, hi, lo), + Some(&v6_sources) + ) + .is_ok(), + "group ID {hi:#06x}{lo:04x} should be accepted", + ); + } + + // Inside the ff3e::/32 SSM block but outside ff3e::/96, so not a + // valid 32-bit group ID. + assert!( + validate_ipv6_multicast( + Ipv6Addr::new(0xff3e, 0, 0x1234, 0, 0, 0, 0xf000, 0x1), + Some(&v6_sources) + ) + .is_err() + ); + + // Unusable and unassigned scope nibbles are rejected across flag + // variants, SSM included. + for seg0 in [ + 0xff30, 0xff31, 0xff32, 0xff33, 0xff36, 0xff39, 0xff3d, 0xff3f, + 0xff03, 0xff11, 0xff12, 0xff13, 0xff07, 0xff1a, + ] { + assert!( + validate_ipv6_multicast( + Ipv6Addr::new(seg0, 0, 0, 0, 0, 0, 0xf000, 0x1), + Some(&v6_sources) + ) + .is_err(), + "{seg0:x}::/16 should be rejected for its scope" + ); + } + } + #[test] fn test_is_ssm_function() { // Test IPv4 SSM detection @@ -444,6 +635,11 @@ mod tests { assert!(is_ssm(IpAddr::V6(Ipv6Addr::new( 0xff35, 0, 0, 0, 0, 0, 0, 0x1 )))); // Site-local scope (5) + // The zero second segment keeps this address inside the + // ff3e::/32 SSM range. + assert!(is_ssm(IpAddr::V6(Ipv6Addr::new( + 0xff3e, 0, 0x1234, 0, 0, 0, 0, 0x1 + )))); // Not SSM assert!(!is_ssm(IpAddr::V6(Ipv6Addr::new( @@ -452,6 +648,11 @@ mod tests { assert!(!is_ssm(IpAddr::V6(Ipv6Addr::new( 0xff1e, 0, 0, 0, 0, 0, 0, 0x1 )))); // Flag bit not 3 + // RFC 3306 unicast-prefix-based ASM address. This shares ff30::/12 but + // has a plen of 32 in the second segment, outside of the ff3e::/32 range. + assert!(!is_ssm(IpAddr::V6(Ipv6Addr::new( + 0xff3e, 0x0020, 0, 0x1234, 0, 0, 0, 0x1 + )))); } #[test] @@ -506,7 +707,9 @@ mod tests { )))]; assert!( validate_multicast_address( - IpAddr::V6(Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 0x1234)), + IpAddr::V6(Ipv6Addr::new( + 0xff3e, 0, 0, 0, 0, 0, 0xf000, 0x1234 + )), Some(&ip6_sources) ) .is_ok() @@ -548,7 +751,9 @@ mod tests { // IPv6 SSM without sources assert!( validate_multicast_address( - IpAddr::V6(Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 0x1234)), + IpAddr::V6(Ipv6Addr::new( + 0xff3e, 0, 0, 0, 0, 0, 0xf000, 0x1234 + )), None ) .is_err() @@ -562,6 +767,30 @@ mod tests { ) .is_ok() ); + + // Source and group address families must match. + let invalid_v6_source_for_v4_group = vec![IpSrc::Exact(IpAddr::V6( + Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), + ))]; + assert!( + validate_multicast_address( + IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), + Some(&invalid_v6_source_for_v4_group), + ) + .is_err() + ); + + let invalid_v4_source_for_v6_group = + vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))]; + assert!( + validate_multicast_address( + IpAddr::V6(Ipv6Addr::new( + 0xff3e, 0, 0, 0, 0, 0, 0xf000, 0x1234, + )), + Some(&invalid_v4_source_for_v6_group), + ) + .is_err() + ); } #[test] @@ -660,6 +889,39 @@ mod tests { validate_source_addresses(Some(&invalid_loopback_ipv6)).is_err() ); + // ::ffff:192.0.2.1, RFC 4291 §2.5.5.2 + let invalid_v4_mapped = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( + 0, 0, 0, 0, 0, 0xffff, 0xc000, 0x0201, + )))]; + assert!(validate_source_addresses(Some(&invalid_v4_mapped)).is_err()); + + // ::192.0.2.1, RFC 4291 §2.5.5.1 + let invalid_v4_compat = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( + 0, 0, 0, 0, 0, 0, 0xc000, 0x0201, + )))]; + assert!(validate_source_addresses(Some(&invalid_v4_compat)).is_err()); + + // 0.0.0.0/8, this host on this network, RFC 1122 §3.2.1.3 + let invalid_this_network = + vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(0, 1, 2, 3)))]; + assert!( + validate_source_addresses(Some(&invalid_this_network)).is_err() + ); + + // 240.0.0.0/4, class E, RFC 1112 §4 + for octets in [[240, 0, 0, 1], [255, 255, 255, 254]] { + let invalid_class_e = + vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::from(octets)))]; + assert!( + validate_source_addresses(Some(&invalid_class_e)).is_err(), + "{octets:?} should be rejected as a class E source" + ); + } + + let valid_below_class_e = + vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(223, 255, 255, 255)))]; + assert!(validate_source_addresses(Some(&valid_below_class_e)).is_ok()); + // No sources should be valid assert!(validate_source_addresses(None).is_ok()); From aeb2d6c66ea1cb11b3079b71c946d41b16db7c45 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Wed, 2 Sep 2026 08:37:45 +0000 Subject: [PATCH 2/5] [dpd-api] correct external multicast group scope docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external-group docs say “IPv4 and non-admin-local IPv6 multicast traffic,” but external groups can use admin-local IPv6 too. The exception, however, is the `ff04::/64` underlay range, which is reserved for the underlay mcast group mapped from the external mcast group. This makes the information explicit. The old wording is in a blessed spec, so this needs a new API version: `MCAST_EXTERNAL_SCOPE_DOCS`. --- dpd-api/src/lib.rs | 73 ++++++++++++++++--- dpd/src/mcast/mod.rs | 15 ++-- openapi/dpd/dpd-13.0.0-5db8bd.json.gitstub | 1 + ...0.0-5db8bd.json => dpd-14.0.0-0e5ee0.json} | 6 +- openapi/dpd/dpd-latest.json | 2 +- 5 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 openapi/dpd/dpd-13.0.0-5db8bd.json.gitstub rename openapi/dpd/{dpd-13.0.0-5db8bd.json => dpd-14.0.0-0e5ee0.json} (99%) diff --git a/dpd-api/src/lib.rs b/dpd-api/src/lib.rs index 7441178e..8d1c3e8b 100644 --- a/dpd-api/src/lib.rs +++ b/dpd-api/src/lib.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use std::net::{Ipv4Addr, Ipv6Addr}; -use dpd_types_versions::{latest, v1, v4, v7}; +use dpd_types_versions::{latest, v1, v4, v7, v8}; use dropshot::{ EmptyScanParams, HttpError, HttpResponseCreated, HttpResponseDeleted, HttpResponseOk, HttpResponseUpdatedNoContent, PaginationParams, Path, @@ -29,6 +29,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (14, MCAST_EXTERNAL_SCOPE_DOCS), (13, ALLOW_DDM_TRAFFIC), (12, PRBS_ERROR_TRACKING), (11, WALLCLOCK_HISTORY), @@ -1971,14 +1972,17 @@ pub trait DpdApi { /** * Create an external-only multicast group configuration. * - * External-only groups are used for IPv4 and non-admin-local IPv6 multicast - * traffic that doesn't require replication infrastructure. These groups use - * simple forwarding tables and require a NAT target. + * External-only groups are used for IPv4 and IPv6 multicast traffic that + * does not require replication infrastructure. Any admitted IPv6 scope may + * be used, admin-local included, except within the reserved underlay + * subnet ff04::/64, which belongs to the internal multicast API. + * + * These groups carry no direct members and require a NAT target. */ #[endpoint { method = POST, path = "/multicast/external-groups", - versions = VERSION_MCAST_STRICT_UNDERLAY.., + versions = VERSION_MCAST_EXTERNAL_SCOPE_DOCS.., }] async fn multicast_group_create_external( rqctx: RequestContext, @@ -1988,6 +1992,29 @@ pub trait DpdApi { HttpError, >; + /** + * Create an external-only multicast group configuration. + * + * External-only groups are used for IPv4 and non-admin-local IPv6 multicast + * traffic that doesn't require replication infrastructure. These groups use + * simple forwarding tables and require a NAT target. + */ + #[endpoint { + method = POST, + path = "/multicast/external-groups", + versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE_DOCS, + operation_id = "multicast_group_create_external", + }] + async fn multicast_group_create_external_v8( + rqctx: RequestContext, + group: TypedBody, + ) -> Result< + HttpResponseCreated, + HttpError, + > { + Self::multicast_group_create_external(rqctx, group).await + } + /// Create an external-only multicast group configuration. #[endpoint { method = POST, @@ -2002,7 +2029,7 @@ pub trait DpdApi { HttpResponseCreated, HttpError, > { - Self::multicast_group_create_external(rqctx, group) + Self::multicast_group_create_external_v8(rqctx, group) .await .map(|resp| resp.map(Into::into)) } @@ -2278,15 +2305,17 @@ pub trait DpdApi { /** * Update an external-only multicast group configuration for a given group IP address. * - * External-only groups are used for IPv4 and non-admin-local IPv6 multicast - * traffic that doesn't require replication infrastructure. + * External-only groups are used for IPv4 and IPv6 multicast traffic that + * does not require replication infrastructure. Any admitted IPv6 scope may + * be used, admin-local included, except within the reserved underlay + * subnet ff04::/64, which belongs to the internal multicast API. * * The `tag` query parameter must match the group's existing tag. */ #[endpoint { method = PUT, path = "/multicast/external-groups/{group_ip}", - versions = VERSION_MCAST_STRICT_UNDERLAY.., + versions = VERSION_MCAST_EXTERNAL_SCOPE_DOCS.., }] async fn multicast_group_update_external( rqctx: RequestContext, @@ -2298,6 +2327,32 @@ pub trait DpdApi { HttpError, >; + /** + * Update an external-only multicast group configuration for a given group IP address. + * + * External-only groups are used for IPv4 and non-admin-local IPv6 multicast + * traffic that doesn't require replication infrastructure. + * + * The `tag` query parameter must match the group's existing tag. + */ + #[endpoint { + method = PUT, + path = "/multicast/external-groups/{group_ip}", + versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE_DOCS, + operation_id = "multicast_group_update_external", + }] + async fn multicast_group_update_external_v8( + rqctx: RequestContext, + path: Path, + query: Query, + group: TypedBody, + ) -> Result< + HttpResponseOk, + HttpError, + > { + Self::multicast_group_update_external(rqctx, path, query, group).await + } + /** * Update an external-only multicast group configuration. * diff --git a/dpd/src/mcast/mod.rs b/dpd/src/mcast/mod.rs index 3ac32330..7b961945 100644 --- a/dpd/src/mcast/mod.rs +++ b/dpd/src/mcast/mod.rs @@ -37,16 +37,18 @@ //! The multicast implementation uses a bifurcated design that separates //! external (customer) and (internal) underlay traffic: //! -//! 1. External-only groups (IPv4 and non-admin-local IPv6): +//! 1. External-only groups (IPv4 and IPv6 outside the reserved underlay +//! subnet ff04::/64): //! - Created from API control plane IPs for customer traffic //! - Handle customer traffic to/from outside the rack //! - Use the external multicast API (/multicast/external-groups) //! - Must have NAT targets pointing to internal groups for proper forwarding //! -//! 2. Internal groups (admin-local IPv6 multicast): -//! - Admin-local = scope 4 (ff04::/16) as defined in -//! [RFC 7346] and [RFC 4291] -//! - Geneve encapsulated multicast traffic (NAT targets of external-only groups) +//! 2. Internal groups (underlay IPv6 multicast): +//! - Reserved underlay subnet ff04::/64, within admin-local scope-4 +//! (ff04::/16) as defined in [RFC 7346] and [RFC 4291] +//! - Geneve encapsulated multicast traffic (NAT targets of +//! external-only groups) //! - Use the internal multicast API (/multicast/underlay-groups) //! - Can replicate to: //! a) External group members (customer traffic) @@ -559,7 +561,8 @@ pub(crate) fn del_group( let mut mcast = s.mcast.lock().unwrap(); // Check if this is an internal group referenced by an external group. - // Internal groups are identified by admin-scoped IPv6 addresses (ff04::/16). + // Internal groups are identified by addresses in the reserved underlay + // subnet (i.e., ff04::/64). if let IpAddr::V6(ipv6) = group_ip && let Ok(admin_scoped) = UnderlayMulticastIpv6::new(ipv6) && let Some(external_ip) = mcast.nat_target_refs.get(&admin_scoped) diff --git a/openapi/dpd/dpd-13.0.0-5db8bd.json.gitstub b/openapi/dpd/dpd-13.0.0-5db8bd.json.gitstub new file mode 100644 index 00000000..c886f19e --- /dev/null +++ b/openapi/dpd/dpd-13.0.0-5db8bd.json.gitstub @@ -0,0 +1 @@ +2df9101e95757037f2cc4e31e8bf00fd9e79efc7:openapi/dpd/dpd-13.0.0-5db8bd.json diff --git a/openapi/dpd/dpd-13.0.0-5db8bd.json b/openapi/dpd/dpd-14.0.0-0e5ee0.json similarity index 99% rename from openapi/dpd/dpd-13.0.0-5db8bd.json rename to openapi/dpd/dpd-14.0.0-0e5ee0.json index 22694237..1be26aea 100644 --- a/openapi/dpd/dpd-13.0.0-5db8bd.json +++ b/openapi/dpd/dpd-14.0.0-0e5ee0.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "13.0.0" + "version": "14.0.0" }, "paths": { "/all-settings": { @@ -1249,7 +1249,7 @@ "/multicast/external-groups": { "post": { "summary": "Create an external-only multicast group configuration.", - "description": "External-only groups are used for IPv4 and non-admin-local IPv6 multicast traffic that doesn't require replication infrastructure. These groups use simple forwarding tables and require a NAT target.", + "description": "External-only groups are used for IPv4 and IPv6 multicast traffic that does not require replication infrastructure. Any admitted IPv6 scope may be used, admin-local included, except within the reserved underlay subnet ff04::/64, which belongs to the internal multicast API.\n\nThese groups carry no direct members and require a NAT target.", "operationId": "multicast_group_create_external", "requestBody": { "content": { @@ -1284,7 +1284,7 @@ "/multicast/external-groups/{group_ip}": { "put": { "summary": "Update an external-only multicast group configuration for a given group IP address.", - "description": "External-only groups are used for IPv4 and non-admin-local IPv6 multicast traffic that doesn't require replication infrastructure.\n\nThe `tag` query parameter must match the group's existing tag.", + "description": "External-only groups are used for IPv4 and IPv6 multicast traffic that does not require replication infrastructure. Any admitted IPv6 scope may be used, admin-local included, except within the reserved underlay subnet ff04::/64, which belongs to the internal multicast API.\n\nThe `tag` query parameter must match the group's existing tag.", "operationId": "multicast_group_update_external", "parameters": [ { diff --git a/openapi/dpd/dpd-latest.json b/openapi/dpd/dpd-latest.json index d1a5660e..ebaa5053 120000 --- a/openapi/dpd/dpd-latest.json +++ b/openapi/dpd/dpd-latest.json @@ -1 +1 @@ -dpd-13.0.0-5db8bd.json \ No newline at end of file +dpd-14.0.0-0e5ee0.json \ No newline at end of file From aa4087d472ea26d1b1ef3ff55a06153a1eb31f30 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Wed, 23 Sep 2026 18:47:54 +0000 Subject: [PATCH 3/5] [dpd,dpd-api,dpd-types] make external multicast constraints self-enforcing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This began as a normal, ol' doc fix. The external multicast group API endpoints had documented scope that didn't match the handlers. Group and source validation already ran in the handlers, but this moves the checks into the request types themselves, at the deserialization boundary, and revisits the address rules for consistency. This includes relaxing the IPv6 scope rules from the earlier validation branch, preserving flexibility vs forcing some kind of extra Oxide-driven policy. Then, this review question came along: > Could these documented constraints also be made self-enforcing via > newtypes? Your decision whether doing so is worth the API churn and > code complexity, but imo it's worth considering. My response to this grew into newtypes for group addresses, sources, and NAT targets; plus, ASM/SSM request variants. The rollback work and cleanup account for much of this diff. Creation requests now check group/source constraints during deserialization. Updates validate addresses at parse time; source-family and SSM validations still run against the group address within the handler(s). Group addresses (typed as `ExternalMulticastIp`): | Group addr / condition | Outcome | Spec / qualification | | --- | --- | --- | | Not multicast | Reject | Group addresses must be multicast. | | `224.0.0.0` | Reject | RFC 1112 §4: "guaranteed not to be assigned to any group", not a MUST NOT; rejected because it can never name a group. | | `232.0.0.0` | Reject | RFC 4607 §4.3: SSM null address. | | `232.0.0.1`–`232.0.0.255` | Accept | RFC 4607 §4.3: reserved for IANA allocation. | | `232/8` | SSM | RFC 4607 §1. | | `ff0x::`, with all-zero group ID | Reject | RFC 4291 §2.7.1: "reserved and shall never be assigned to any multicast group"; rejected on the same reasoning as `224.0.0.0`. | | IPv6 scope `0` | Reject | RFC 4291 §2.7: reserved; packets must be discarded. | | IPv6 scopes `1`, `2` | Reject | RFC 4291 §2.7: interface-local and link-local traffic cannot be forwarded past those scopes. | | IPv6 scopes `3`–`f` | Accept, subject to other checks | RFC 4291 §2.7; RFC 7346. Scope `f` is reserved; received packets are treated as global scope. | | `ff04::/64` | Reject | Oxide underlay reservation. | | Rest of `ff04::/16` | Accept, subject to other checks | Outside the reserved `/64`. | | Flags ~ `R=0`, `P=T=1`, `plen=0`, either `X` value | SSM | RFC 4607 §1; RFC 7371 §4.1.2: `ff3x` and `ffbx` flag patterns; reserved fields ignored. | | IPv6 SSM with nonzero network prefix | Accept | RFC 4607 §1: a system should treat all of `ff3x::/32` as SSM. RFC 3306 §6 requires a zero prefix only of the node that's forming the address. | | `ff3x::4000:0` | Reject | RFC 4607 §4.3: reserved SSM null address. | | IPv6 SSM group IDs `0`–`3fff:ffff` | Accept | RFC 4607 §1: invalid as SSM addresses (assigned with `P=T=0`, RFC 3307 §4.1), but a router *may* drop them, so we don't reject them at the API. | | IPv6 SSM group IDs `4000:1`–`7fff:ffff` | Accept | RFC 3307 §4.2; RFC 4607 §4.3: IANA allocation range. | | IPv6 SSM group IDs `8000:0`–`ffff:ffff` | Accept | RFC 3307 §4.3.1, §4.3.2 and RFC 10028 §3: dynamic range where host SSM allocation is `f000:0`–`fcff:ffff`. | | Flags ~ `P=1`, `T=0` | Reject | RFC 7371 §4.1.1: `P` requires `T`. | | Flags ~ `R=1` without both `P` and `T` | Reject | RFC 7371 §4.2.2: `R` requires `P` and `T`. | | Flags ~ `R=1` with `plen` outside range `1`–`64` | Reject | RFC 3956 §4: embedded-Rendezvous Point (RP) prefix-length bounds. | | Flags ~ `R=1` with `RIID=0` (RP Interface ID) | Reject | RFC 3956 §6.3: zero RIID is excluded. | | Flags ~ `R=1` with derived RP in `::/16`, `fe80::/10`, or `ff00::/8` | Reject | RFC 3956 §4: unusable embedded RP address. | | Prefix-based `ff2` bits | Ignored | RFC 7371 §4.1.1, §4.2.1: reserved bits ignored on receipt. | | Prefix-based, `R=0`: `rsvd` bits | Ignored | RFC 3956 §3: reserved bits ignored on receipt. | | Flags ~ `R=1`: low `rsvd` nibble becomes `RIID` | Checked | RFC 7371 §4.2.2; RFC 3956 §6.3: reject `RIID=0`. | | IANA assignment or an embedded-prefix scope comparison | Not checked | No registry lookup or scope comparison with the embedded prefix. | Exact sources (typed as `ExactSource`) and request constraints: | Exact source / condition | Outcome | Spec / qualification | | --- | --- | --- | | Multicast source | Reject | RFC 1112 §6.2; RFC 4291 §2.7: multicast addresses cannot be packet sources. | | `0.0.0.0`, `::` | Reject | RFC 1812 §5.3.7 and RFC 6890 for `0.0.0.0`, as with `0.0.0.0/8` specified below; RFC 4291 §2.5.2: an unspecified IPv6 source is never forwarded. | | `127/8`, `::1` | Reject | RFC 1122 §3.2.1.3; RFC 4291 §2.5.3: loopback sources cannot be forwarded outside the host. | | `255.255.255.255` | Reject | RFC 1122 §3.2.1.3: broadcast addresses cannot be sources. | | `169.254/16`, `fe80::/10` | Reject | RFC 3927 §2.7; RFC 4291 §2.5.6: link-local traffic cannot be forwarded off-link. | | `0.0.0.0/8` | Reject | RFC 1122 §3.2.1.3 permits initialization use; RFC 1812 §5.3.7 recommends against forwarding these sources. | | `240/4` | Reject | RFC 1112 §4: reserved Class E space; the special-purpose registry marks `Source=False` (RFC 6890 §2.2.2). | | `::ffff:0:0/96`, `::/96` | Reject | RFC 6890 §2.2.3 marks the mapped form `Source=False`; RFC 4291 §2.5.5.1 deprecates the compatible form. This matches OPTE. | | `100.64/10` | Accept | RFC 6598 §4: shared address space is usable within an operator's network. | | Source family differs from group family | Reject | Same-family `(S,G)` check; matches OPTE. | | SSM create or supplied update source list: no exact source, or `Any` | Reject | RFC 4607 §2: SSM requires a source-specific channel. | Includes: - Exact-source rejection set and same-family checks match OPTE `Include` subscriptions. - In the new API version, omitted sources on the update/modification preserve the source filter, while the older endpoints convert this omission to `Any` (wildcard), which SSM will reject. - External groups use caller-supplied addresses. No IANA-assignment lookup or embedded-prefix scope check occurs there. - Regarding rollback, `DpdError::Unwind` now collects multiple rollback failures. HTTP errors preserve the initial status and use a special `ROLLBACK_FAILURE_ERROR_CODE` in propagation. Restoration can re-add missing bitmap, replication, and route entries. - `chaos`-feature tests inject ASIC failures and check usage counts after rollback, including failures during the rollback op itself. - `dpd-client` now maps `ExternalMulticastIp` and `UnderlayMulticastIpv6` to the `dpd-types` newtypes (via progenitor `replace`), allowing clients to reject bad addresses when parsing, before a request goes out. --- Cargo.lock | 9 + Cargo.toml | 1 + aal/src/lib.rs | 6 +- asic/src/chaos/mod.rs | 10 +- asic/src/chaos/table.rs | 42 +- asic/src/softnpu/mod.rs | 6 +- asic/src/tofino_asic/imported_bf_functions | 1 + asic/src/tofino_asic/mcast.rs | 214 +- asic/src/tofino_asic/mod.rs | 2 +- asic/src/tofino_common/ports.rs | 6 +- asic/src/tofino_stub/mcast.rs | 12 +- asic/src/tofino_stub/table.rs | 2 +- common/src/lib.rs | 7 +- dpd-api/src/lib.rs | 179 +- dpd-client/Cargo.toml | 1 + dpd-client/src/lib.rs | 9 +- dpd-client/tests/chaos_tests/port_settings.rs | 23 +- dpd-client/tests/integration_tests/mcast.rs | 1625 ++++--- .../tests/integration_tests/table_tests.rs | 3 +- dpd-types/versions/Cargo.toml | 3 + dpd-types/versions/src/impls/mcast.rs | 1427 +++++- dpd-types/versions/src/initial/route.rs | 4 + dpd-types/versions/src/latest.rs | 26 +- dpd-types/versions/src/lib.rs | 2 + .../src/mcast_external_scope/mcast.rs | 1193 +++++ .../versions/src/mcast_external_scope/mod.rs | 15 + dpd/Cargo.toml | 1 + dpd/src/api_server.rs | 62 +- dpd/src/link.rs | 9 +- dpd/src/loopback.rs | 4 +- dpd/src/main.rs | 27 +- dpd/src/mcast/mod.rs | 2995 ++++++------ dpd/src/mcast/rollback.rs | 3997 ++++++++++++++--- dpd/src/mcast/validate.rs | 1059 ----- dpd/src/port_settings.rs | 7 +- dpd/src/table/mac.rs | 4 +- dpd/src/table/mcast/mcast_egress.rs | 15 +- dpd/src/table/mcast/mcast_nat.rs | 124 +- dpd/src/table/mcast/mcast_route.rs | 50 +- dpd/src/table/mcast/mcast_src_filter.rs | 32 +- dpd/src/table/mcast/mod.rs | 33 +- dpd/src/table/mod.rs | 4 +- dpd/src/table/uplink.rs | 23 +- dpd/src/types.rs | 124 +- ...0.0-0e5ee0.json => dpd-14.0.0-14424d.json} | 160 +- openapi/dpd/dpd-latest.json | 2 +- 46 files changed, 9361 insertions(+), 4199 deletions(-) create mode 100644 dpd-types/versions/src/mcast_external_scope/mcast.rs create mode 100644 dpd-types/versions/src/mcast_external_scope/mod.rs delete mode 100644 dpd/src/mcast/validate.rs rename openapi/dpd/{dpd-14.0.0-0e5ee0.json => dpd-14.0.0-14424d.json} (98%) diff --git a/Cargo.lock b/Cargo.lock index 1ef80514..1b3999a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1680,6 +1680,7 @@ dependencies = [ "libc", "mockall", "nexus-client", + "nonempty", "omicron-common", "openssl", "oxide-tokio-rt", @@ -1733,6 +1734,7 @@ dependencies = [ "common 0.1.0", "crc8", "dpd-types", + "dpd-types-versions", "futures", "http", "lazy_static", @@ -1799,6 +1801,7 @@ dependencies = [ "rand 0.9.3", "schemars 0.8.22", "serde", + "serde_json", "thiserror 2.0.18", "uuid", ] @@ -4287,6 +4290,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "normalize-line-endings" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index a8f11cfc..49d0b634 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ kstat-rs = "0.2.4" lazy_static = "1.5" libc = "0.2" mockall = "0.14.0" +nonempty = "0.12.0" omicron-zone-package = "0.12" openssl = "0.10" oxide-tokio-rt = "0.1.2" diff --git a/aal/src/lib.rs b/aal/src/lib.rs index 36b0bf75..753253a0 100644 --- a/aal/src/lib.rs +++ b/aal/src/lib.rs @@ -109,11 +109,11 @@ pub enum AsicError { Synthetic(String), /// A general indication that a caller is trying to create something that /// already exists. - #[error("Already exists")] - Exists, + #[error("Already exists: {}", .0)] + Exists(String), /// A general indication that a caller is trying to modify something that /// is not present. - #[error("Missing")] + #[error("Missing: {}", .0)] Missing(String), } diff --git a/asic/src/chaos/mod.rs b/asic/src/chaos/mod.rs index ccf2deed..9215a381 100644 --- a/asic/src/chaos/mod.rs +++ b/asic/src/chaos/mod.rs @@ -57,7 +57,7 @@ impl Chaos { /// Return a chaos error according to the underlying probability value. pub fn unfurled(&self, log: &Logger, message: &str) -> AsicResult<()> { - if self.value >= random() { + if self.value > random() { slog::error!(log, "chaos error: {}", message); return Err(AsicError::Synthetic(message.into())); } @@ -143,7 +143,7 @@ impl TableChaos { message: &str, ) -> AsicResult<()> { if let Some(value) = self.values.get(&id) - && *value >= random() + && *value > random() { slog::error!(log, "chaos table error: {}", message); return Err(AsicError::Synthetic(message.into())); @@ -407,7 +407,7 @@ impl AsicMulticastOps for Handle { _level1_excl_id: u16, ) -> AsicResult<()> { unfurl_mc!(self, mc_port_add); - Err(AsicError::OperationUnsupported) + Ok(()) } fn mc_port_remove(&self, _group_id: u16, _port: u16) -> AsicResult<()> { @@ -417,7 +417,7 @@ impl AsicMulticastOps for Handle { fn mc_group_create(&self, _group_id: u16) -> AsicResult<()> { unfurl_mc!(self, mc_group_create); - Err(AsicError::OperationUnsupported) + Ok(()) } fn mc_group_destroy(&self, _group_id: u16) -> AsicResult<()> { @@ -552,7 +552,7 @@ impl AsicOps for Handle { // and use it as before. let port_hdl = PortHdl::new(connector, 0); if ports.contains_key(&port_hdl) { - return Err(AsicError::Exists); + return Err(AsicError::Exists(format!("port {port_hdl} exists"))); } ports.insert(port_hdl, Port { enabled: true }); self.port_to_asic_id(port_hdl).map(|id| (port_hdl, id)) diff --git a/asic/src/chaos/table.rs b/asic/src/chaos/table.rs index 36515270..6febec96 100644 --- a/asic/src/chaos/table.rs +++ b/asic/src/chaos/table.rs @@ -4,7 +4,7 @@ // // Copyright 2026 Oxide Computer Company -use std::collections::HashSet; +use std::collections::HashMap; use std::collections::hash_map::DefaultHasher; use std::hash::Hash; use std::hash::Hasher; @@ -12,19 +12,20 @@ use std::sync::Mutex; use crate::chaos::{Handle, table_unfurl}; use aal::{ - ActionParse, AsicError, AsicResult, CounterData, MatchParse, TableOps, + ActionData, ActionParse, AsicError, AsicResult, CounterData, MatchData, + MatchParse, TableOps, }; use common::table::TableType; pub struct Table { type_: TableType, - keys: Mutex>, + keys: Mutex>, } impl TableOps for Table { fn new(hdl: &Handle, type_: TableType) -> AsicResult { table_unfurl!(hdl, type_, table_new); - Ok(Table { type_, keys: Mutex::new(HashSet::new()) }) + Ok(Table { type_, keys: Mutex::new(HashMap::new()) }) } fn size(&self) -> usize { @@ -39,7 +40,7 @@ impl TableOps for Table { fn clear(&self, hdl: &Handle) -> AsicResult<()> { table_unfurl!(hdl, self.type_, table_clear); let mut keys = self.keys.lock().unwrap(); - *keys = HashSet::new(); + *keys = HashMap::new(); Ok(()) } @@ -47,18 +48,21 @@ impl TableOps for Table { &self, hdl: &Handle, key: &M, - _data: &A, + data: &A, ) -> AsicResult<()> { let mut hasher = DefaultHasher::new(); key.hash(&mut hasher); let x: u64 = hasher.finish(); let mut keys = self.keys.lock().unwrap(); - if keys.contains(&x) { - return Err(AsicError::Exists); + if keys.contains_key(&x) { + return Err(AsicError::Exists(format!( + "entry already in table {}", + self.type_ + ))); } table_unfurl!(hdl, self.type_, table_entry_add); - keys.insert(x); + keys.insert(x, (key.key_to_ir()?, data.action_to_ir()?)); Ok(()) } @@ -66,19 +70,20 @@ impl TableOps for Table { &self, hdl: &Handle, key: &M, - _data: &A, + data: &A, ) -> AsicResult<()> { let mut hasher = DefaultHasher::new(); key.hash(&mut hasher); let x: u64 = hasher.finish(); - let keys = self.keys.lock().unwrap(); - if !keys.contains(&x) { + let mut keys = self.keys.lock().unwrap(); + let Some((_, action)) = keys.get_mut(&x) else { return Err(AsicError::Missing( "table entry not found".to_string(), )); - } + }; table_unfurl!(hdl, self.type_, table_entry_update); + *action = data.action_to_ir()?; Ok(()) } @@ -92,7 +97,7 @@ impl TableOps for Table { let x: u64 = hasher.finish(); let mut keys = self.keys.lock().unwrap(); - if !keys.contains(&x) { + if !keys.contains_key(&x) { return Err(AsicError::Missing( "table entry not found".to_string(), )); @@ -107,7 +112,14 @@ impl TableOps for Table { _hdl: &Handle, _from_hardware: bool, ) -> AsicResult> { - Err(AsicError::OperationUnsupported) + self.keys + .lock() + .unwrap() + .values() + .map(|(key, action)| { + Ok((M::ir_to_key(key)?, A::ir_to_action(action)?)) + }) + .collect() } fn get_counters( diff --git a/asic/src/softnpu/mod.rs b/asic/src/softnpu/mod.rs index 94246790..5d539e9b 100644 --- a/asic/src/softnpu/mod.rs +++ b/asic/src/softnpu/mod.rs @@ -338,9 +338,7 @@ impl AsicOps for Handle { // and use it as before. let port_hdl = PortHdl::new(connector, 0); if ports.contains_key(&port_hdl) { - return Err(AsicError::InvalidArg(format!( - "Port {port_hdl:?} exists" - ))); + return Err(AsicError::Exists(format!("Port {port_hdl:?} exists"))); } ports.insert(port_hdl, Port { enabled: true, tx_eq: 0 }); self.port_to_asic_id(port_hdl).map(|id| (port_hdl, id)) @@ -349,7 +347,7 @@ impl AsicOps for Handle { fn port_delete(&self, port_hdl: PortHdl) -> AsicResult<()> { let mut ports = self.ports.lock().unwrap(); if !ports.contains_key(&port_hdl) { - return Err(AsicError::InvalidArg(format!( + return Err(AsicError::Missing(format!( "Port {port_hdl:?} does not exist" ))); } diff --git a/asic/src/tofino_asic/imported_bf_functions b/asic/src/tofino_asic/imported_bf_functions index 547abe30..37ceca49 100644 --- a/asic/src/tofino_asic/imported_bf_functions +++ b/asic/src/tofino_asic/imported_bf_functions @@ -25,6 +25,7 @@ bf_mc_mgrp_destroy bf_mc_mgrp_get_count bf_mc_node_create bf_mc_node_destroy +bf_mc_node_get_association bf_mc_node_update bf_mc_associate_node bf_mc_dissociate_node diff --git a/asic/src/tofino_asic/mcast.rs b/asic/src/tofino_asic/mcast.rs index c6b2d4b3..abb0c344 100644 --- a/asic/src/tofino_asic/mcast.rs +++ b/asic/src/tofino_asic/mcast.rs @@ -18,7 +18,12 @@ use aal::{AsicError, AsicResult}; pub struct DomainState { id: u16, mgrp_hdl: bf_mc_mgrp_hdl_t, + /// Ports whose node is associated with the `mgrp_hdl`, i.e. a port still + /// available for replication. ports: HashMap, + /// Nodes that are dissociated (or were never associated) but failed to + /// destroy. `destroy_detached_nodes` retries them. + detached_nodes: Vec, } /* @@ -120,11 +125,38 @@ fn dissociate_node( mgrp_hdl: bf_mc_mgrp_hdl_t, node_hdl: bf_mc_node_hdl_t, ) -> AsicResult<()> { - unsafe { + let res = unsafe { bf_mc_dissociate_node(mcast_hdl, dev_id, mgrp_hdl, node_hdl) - .check_error("dissociating multicast node from group")?; + .check_error("dissociating multicast node from group") + }; + + if let Err(AsicError::Exists(_)) = &res + && !node_is_associated(mcast_hdl, dev_id, node_hdl)? + { + return Ok(()); } - Ok(()) + res +} + +fn node_is_associated( + mcast_hdl: bf_mc_session_hdl_t, + dev_id: bf_dev_id_t, + node_hdl: bf_mc_node_hdl_t, +) -> AsicResult { + let mut is_associated = false; + unsafe { + bf_mc_node_get_association( + mcast_hdl, + dev_id, + node_hdl, + &mut is_associated, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + .check_error("getting multicast node association")?; + } + Ok(is_associated) } fn node_create( @@ -162,21 +194,60 @@ fn node_destroy( Ok(()) } -fn cleanup_node( +/// Dissociate the `node_hdl` from `mgrp_hdl` and destroy it. +/// +/// A dissociation error can leave the node attached or detached. The caller +/// retains its handle for retry or re-association. +/// +/// Once the node is detached, a failed destroy on the underlying ASIC retains +/// the handle in `detached_nodes` and still returns a non-error result because +/// the port no longer replicates to subscribers. +fn detach_node( + log: &slog::Logger, bf: &BfCommon, - mgrp_hdl: Option, - port_state: &MulticastState, + mgrp_hdl: bf_mc_mgrp_hdl_t, + node_hdl: bf_mc_node_hdl_t, + detached_nodes: &mut Vec, ) -> AsicResult<()> { - if let Some(mgrp_hdl) = mgrp_hdl { - dissociate_node( - bf.mcast_hdl, - bf.dev_id, - mgrp_hdl, - port_state.node_hdl, - )?; + match dissociate_node(bf.mcast_hdl, bf.dev_id, mgrp_hdl, node_hdl) { + Ok(()) | Err(AsicError::Missing(_)) => {} + Err(e) => return Err(e), } - node_destroy(bf.mcast_hdl, bf.dev_id, port_state.node_hdl) + if let Err(e) = node_destroy(bf.mcast_hdl, bf.dev_id, node_hdl) { + error!( + log, + "multicast node {:#x} detached but not destroyed: {:?}", + node_hdl, + e + ); + detached_nodes.push(node_hdl); + } + Ok(()) +} + +/// Retry destroying the handles in `detached_nodes` within the domain state, +/// keeping any that remain in a failing state, and returning the first failure. +fn destroy_detached_nodes( + log: &slog::Logger, + bf: &BfCommon, + domain: &mut DomainState, +) -> Option { + let mut first_error = None; + for node_hdl in std::mem::take(&mut domain.detached_nodes) { + if let Err(e) = node_destroy(bf.mcast_hdl, bf.dev_id, node_hdl) { + error!( + log, + "destroying detached multicast node {:#x} in domain {}: {:?}", + node_hdl, + domain.id, + e + ); + first_error.get_or_insert(e); + domain.detached_nodes.push(node_hdl); + } + } + first_error } fn set_max_node_threshold( @@ -225,10 +296,10 @@ fn domain_ports(domain: &DomainState) -> Vec { /// Get the number of ports in a multicast domain. pub fn domain_port_count(hdl: &Handle, group_id: u16) -> AsicResult { - let mut domains = hdl.domains.lock().unwrap(); - match domains.get_mut(&group_id) { + let domains = hdl.domains.lock().unwrap(); + match domains.get(&group_id) { Some(d) => Ok(d.ports.len()), - None => Err(AsicError::InvalidArg("no such domain domain".to_string())), + None => Err(AsicError::Missing("no such domain".to_string())), } } @@ -245,16 +316,27 @@ pub fn domain_add_port( let mut domains = hdl.domains.lock().unwrap(); let domain = match domains.get_mut(&group_id) { Some(d) => Ok(d), - None => Err(AsicError::InvalidArg("no such domain domain".to_string())), + None => Err(AsicError::Missing("no such domain".to_string())), }?; - if domain.ports.contains_key(&port) { - return Err(AsicError::InvalidArg( - "port already in domain".to_string(), - )); + let bf = hdl.bf_get(); + if let Some(mc) = domain.ports.get(&port) { + if node_is_associated(bf.mcast_hdl, bf.dev_id, mc.node_hdl)? { + return Err(AsicError::Exists(format!( + "port {port} already in multicast domain {group_id}" + ))); + } + + return associate_node( + bf.mcast_hdl, + bf.dev_id, + domain.mgrp_hdl, + mc.node_hdl, + level_1_excl_id, + ); } - let bf = hdl.bf_get(); + destroy_detached_nodes(&hdl.log, &bf, domain); let mut mc = MulticastState { node_hdl: 0, @@ -283,11 +365,12 @@ pub fn domain_add_port( Ok(()) } Err(e) => { - if let Err(x) = cleanup_node(&bf, None, &mc) { + if let Err(x) = node_destroy(bf.mcast_hdl, bf.dev_id, mc.node_hdl) { error!( hdl.log, "post-failure multicast cleanup failed: {:?}", x ); + domain.detached_nodes.push(mc.node_hdl); } Err(e) } @@ -304,21 +387,27 @@ pub fn domain_remove_port( let mut domains = hdl.domains.lock().unwrap(); let domain = match domains.get_mut(&group_id) { Some(d) => Ok(d), - None => Err(AsicError::InvalidArg("no such domain domain".to_string())), + None => Err(AsicError::Missing("no such domain".to_string())), }?; let bf = hdl.bf_get(); + destroy_detached_nodes(&hdl.log, &bf, domain); - let mc = match domain.ports.remove(&port) { - Some(n) => n, - None => { - return Err(AsicError::InvalidArg( - "port not in domain".to_string(), - )); - } - }; + let node_hdl = domain + .ports + .get(&port) + .ok_or_else(|| AsicError::Missing("port not in domain".to_string()))? + .node_hdl; + + detach_node( + &hdl.log, + &bf, + domain.mgrp_hdl, + node_hdl, + &mut domain.detached_nodes, + )?; - cleanup_node(&bf, Some(domain.mgrp_hdl), &mc)?; + domain.ports.remove(&port); Ok(()) } @@ -327,7 +416,9 @@ pub fn domain_create(hdl: &Handle, group_id: u16) -> AsicResult<()> { info!(hdl.log, "creating multicast domain {}", group_id); let mut domains = hdl.domains.lock().unwrap(); if domains.get(&group_id).is_some() { - return Err(AsicError::InvalidArg("domain already exists".to_string())); + return Err(AsicError::Exists(format!( + "multicast domain {group_id} already exists" + ))); }; let bf = hdl.bf_get(); @@ -335,7 +426,12 @@ pub fn domain_create(hdl: &Handle, group_id: u16) -> AsicResult<()> { let mgrp_hdl = mgrp_create(bf.mcast_hdl, bf.dev_id, group_id)?; domains.insert( group_id, - DomainState { id: group_id, mgrp_hdl, ports: HashMap::new() }, + DomainState { + id: group_id, + mgrp_hdl, + ports: HashMap::new(), + detached_nodes: Vec::new(), + }, ); Ok(()) } @@ -344,27 +440,61 @@ pub fn domain_create(hdl: &Handle, group_id: u16) -> AsicResult<()> { pub fn domain_destroy(hdl: &Handle, group_id: u16) -> AsicResult<()> { info!(hdl.log, "destroying multicast domain {}", group_id); let mut domains = hdl.domains.lock().unwrap(); - let mut domain = match domains.remove(&group_id) { + let domain = match domains.get_mut(&group_id) { Some(d) => Ok(d), - None => Err(AsicError::InvalidArg("no such domain".to_string())), + None => Err(AsicError::Missing("no such domain".to_string())), }?; let bf = hdl.bf_get(); let mgrp_hdl = domain.mgrp_hdl; - for (port, mc) in domain.ports.drain() { - if let Err(e) = cleanup_node(&bf, Some(mgrp_hdl), &mc) { + let domain_id = domain.id; + let mut first_err = None; + domain.ports.retain(|port, mc| { + if let Err(e) = detach_node( + &hdl.log, + &bf, + mgrp_hdl, + mc.node_hdl, + &mut domain.detached_nodes, + ) { error!( hdl.log, "cleaning up port {} for multicast domain {}: {:?}", port, - domain.id, + domain_id, e ); + first_err.get_or_insert(e); + true + } else { + false } + }); + + if let Some(e) = destroy_detached_nodes(&hdl.log, &bf, domain) { + first_err.get_or_insert(e); + } + + if let Some(e) = first_err { + return Err(e); } - mgrp_destroy(bf.mcast_hdl, bf.dev_id, domain.mgrp_hdl) + match mgrp_destroy(bf.mcast_hdl, bf.dev_id, mgrp_hdl) { + Ok(()) | Err(AsicError::Missing(_)) => { + domains.remove(&group_id); + Ok(()) + } + Err(e) => { + if matches!( + mgrp_destroy(bf.mcast_hdl, bf.dev_id, mgrp_hdl), + Ok(()) | Err(AsicError::Missing(_)) + ) { + domains.remove(&group_id); + } + Err(e) + } + } } /// Domain exists. diff --git a/asic/src/tofino_asic/mod.rs b/asic/src/tofino_asic/mod.rs index c26a0fd2..a5f7d2af 100644 --- a/asic/src/tofino_asic/mod.rs +++ b/asic/src/tofino_asic/mod.rs @@ -459,7 +459,7 @@ impl Handle { pub fn sde_error(ctx: impl ToString, err: bf_status_t) -> AsicError { match err { - bf_wrapper::BF_ALREADY_EXISTS => AsicError::Exists, + bf_wrapper::BF_ALREADY_EXISTS => AsicError::Exists(ctx.to_string()), bf_wrapper::BF_OBJECT_NOT_FOUND => AsicError::Missing(ctx.to_string()), _ => AsicError::SdeError { ctx: ctx.to_string(), diff --git a/asic/src/tofino_common/ports.rs b/asic/src/tofino_common/ports.rs index 82b4a207..0752452b 100644 --- a/asic/src/tofino_common/ports.rs +++ b/asic/src/tofino_common/ports.rs @@ -2,7 +2,7 @@ // 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 2025 Oxide Computer Company +// Copyright 2026 Oxide Computer Company use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -181,7 +181,9 @@ impl PortData { ) -> AsicResult<()> { match self.config_ports.insert(port_hdl, cfg) { None => Ok(()), - _ => Err(AsicError::Exists), + _ => { + Err(AsicError::Exists(format!("port {port_hdl} already added"))) + } } } diff --git a/asic/src/tofino_stub/mcast.rs b/asic/src/tofino_stub/mcast.rs index 22ad588f..b7193e7d 100644 --- a/asic/src/tofino_stub/mcast.rs +++ b/asic/src/tofino_stub/mcast.rs @@ -13,7 +13,7 @@ pub struct McGroupData { } fn no_group(group_id: u16) -> AsicError { - AsicError::InvalidArg(format!("no such multicast group: {group_id}")) + AsicError::Missing(format!("no such multicast group: {group_id}")) } impl McGroupData { @@ -26,7 +26,9 @@ impl McGroupData { pub fn domain_port_count(&self, group_id: u16) -> AsicResult { match self.groups.get(&group_id) { Some(g) => Ok(g.len()), - None => Err(no_group(group_id)), + None => Err(AsicError::Missing(format!( + "no such multicast group: {group_id}" + ))), } } @@ -45,7 +47,7 @@ impl McGroupData { match group.insert(port) { true => Ok(()), - false => Err(AsicError::InvalidArg(format!( + false => Err(AsicError::Exists(format!( "multicast group {group_id} already contains port {port}" ))), } @@ -64,7 +66,7 @@ impl McGroupData { match group.remove(&port) { true => Ok(()), - false => Err(AsicError::InvalidArg(format!( + false => Err(AsicError::Missing(format!( "multicast group {group_id} doesn't contain port {port}" ))), } @@ -74,7 +76,7 @@ impl McGroupData { #[allow(clippy::map_entry)] pub fn domain_create(&mut self, group_id: u16) -> AsicResult<()> { if self.groups.contains_key(&group_id) { - Err(AsicError::InvalidArg(format!( + Err(AsicError::Exists(format!( "multicast group {group_id} already exists" ))) } else { diff --git a/asic/src/tofino_stub/table.rs b/asic/src/tofino_stub/table.rs index 1ab31f60..a82dea23 100644 --- a/asic/src/tofino_stub/table.rs +++ b/asic/src/tofino_stub/table.rs @@ -104,7 +104,7 @@ impl TableOps for Table { _s: &StubHandle, _from_hardware: bool, ) -> AsicResult> { - Err(AsicError::OperationUnsupported) + Ok(Vec::new()) } fn get_counters( diff --git a/common/src/lib.rs b/common/src/lib.rs index 25ed16b8..8b939fc5 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -28,9 +28,14 @@ pub use smf::{SmfError, SmfResult, is_smf_active}; /// The default port on which the Dendrite API server listens. pub const DEFAULT_DPD_PORT: u16 = 12224; -/// The http error code used when a rollback failure occurs. +/// The `error_code` sent in an HTTP error response when a rollback failure +/// occurs. pub const ROLLBACK_FAILURE_ERROR_CODE: &str = "rollback failure"; +/// The `error_code` sent in an HTTP error response when an external multicast +/// group's NAT target (its underlay group) is absent from the switch. +pub const MISSING_NAT_TARGET_ERROR_CODE: &str = "missing nat target"; + /// Given two arrays, return two vectors containing only the unique items from each array. pub fn purge_common(a: &[T], b: &[T]) -> (Vec, Vec) where diff --git a/dpd-api/src/lib.rs b/dpd-api/src/lib.rs index 8d1c3e8b..3c8e349b 100644 --- a/dpd-api/src/lib.rs +++ b/dpd-api/src/lib.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use std::net::{Ipv4Addr, Ipv6Addr}; -use dpd_types_versions::{latest, v1, v4, v7, v8}; +use dpd_types_versions::{latest, v1, v4, v7, v8, v9, v14}; use dropshot::{ EmptyScanParams, HttpError, HttpResponseCreated, HttpResponseDeleted, HttpResponseOk, HttpResponseUpdatedNoContent, PaginationParams, Path, @@ -29,7 +29,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), - (14, MCAST_EXTERNAL_SCOPE_DOCS), + (14, MCAST_EXTERNAL_SCOPE), (13, ALLOW_DDM_TRAFFIC), (12, PRBS_ERROR_TRACKING), (11, WALLCLOCK_HISTORY), @@ -1906,7 +1906,7 @@ pub trait DpdApi { ) -> Result, HttpError> { Self::table_dump( rqctx, - Query::from(latest::snapshot::TableDumpOptions { + Query::from(v9::snapshot::TableDumpOptions { from_hardware: false, }), path, @@ -1972,17 +1972,24 @@ pub trait DpdApi { /** * Create an external-only multicast group configuration. * - * External-only groups are used for IPv4 and IPv6 multicast traffic that - * does not require replication infrastructure. Any admitted IPv6 scope may - * be used, admin-local included, except within the reserved underlay - * subnet ff04::/64, which belongs to the internal multicast API. + * External-only groups carry either IPv4 or IPv6 traffic and share their + * NAT target's replication groups. Any admitted IPv6 scope can be used, + * including admin-local, except for the reserved underlay subnet of + * ff04::/64, which belongs specifically to the internal multicast API. * - * These groups carry no direct members and require a NAT target. + * The request body is decoded into either an ASM or SSM-classified + * request with validation running on the group address and source list. + * + * These groups do not carry member subscribers, as membership lives + * only on the underlay group mapped to by the required NAT target. + * + * The NAT target must reference an existing underlay group. The underlay + * group cannot already be referenced by another external group. */ #[endpoint { method = POST, path = "/multicast/external-groups", - versions = VERSION_MCAST_EXTERNAL_SCOPE_DOCS.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn multicast_group_create_external( rqctx: RequestContext, @@ -2002,7 +2009,7 @@ pub trait DpdApi { #[endpoint { method = POST, path = "/multicast/external-groups", - versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE_DOCS, + versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE, operation_id = "multicast_group_create_external", }] async fn multicast_group_create_external_v8( @@ -2012,7 +2019,17 @@ pub trait DpdApi { HttpResponseCreated, HttpError, > { - Self::multicast_group_create_external(rqctx, group).await + let body = group + .try_map(|entry| { + v14::mcast::MulticastGroupCreateExternalEntry::try_from(entry) + }) + .map_err(|e: v14::mcast::MulticastGroupCreateExternalError| { + HttpError::for_bad_request(None, e.to_string()) + })?; + + Self::multicast_group_create_external(rqctx, body) + .await + .map(|resp| resp.map(Into::into)) } /// Create an external-only multicast group configuration. @@ -2095,10 +2112,9 @@ pub trait DpdApi { > { let v4_body = group .try_map(|entry| { - let group_ip = latest::mcast::UnderlayMulticastIpv6::try_from( - entry.group_ip, - )?; - Ok(latest::mcast::MulticastGroupCreateUnderlayEntry { + let group_ip = + v8::mcast::UnderlayMulticastIpv6::try_from(entry.group_ip)?; + Ok(v8::mcast::MulticastGroupCreateUnderlayEntry { group_ip, tag: entry.tag, members: entry.members, @@ -2162,13 +2178,32 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/multicast/groups/{group_ip}", - versions = VERSION_MCAST_STRICT_UNDERLAY.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn multicast_group_get( rqctx: RequestContext, path: Path, ) -> Result, HttpError>; + /** + * Get the multicast group configuration for a given group IP address. + */ + #[endpoint { + method = GET, + path = "/multicast/groups/{group_ip}", + versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "multicast_group_get", + }] + async fn multicast_group_get_v8( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError> + { + Self::multicast_group_get(rqctx, path) + .await + .map(|resp| resp.map(Into::into)) + } + /// Get the multicast group configuration for a given group IP address. #[endpoint { method = GET, @@ -2181,7 +2216,7 @@ pub trait DpdApi { path: Path, ) -> Result, HttpError> { - Self::multicast_group_get(rqctx, path) + Self::multicast_group_get_v8(rqctx, path) .await .map(|resp| resp.map(Into::into)) } @@ -2240,8 +2275,8 @@ pub trait DpdApi { HttpError, > { let v4_path = path.try_map(|p| { - latest::mcast::UnderlayMulticastIpv6::try_from(p.group_ip) - .map(|group_ip| latest::mcast::MulticastUnderlayGroupIpParam { + v8::mcast::UnderlayMulticastIpv6::try_from(p.group_ip) + .map(|group_ip| v8::mcast::MulticastUnderlayGroupIpParam { group_ip, }) .map_err(|e| { @@ -2305,21 +2340,28 @@ pub trait DpdApi { /** * Update an external-only multicast group configuration for a given group IP address. * - * External-only groups are used for IPv4 and IPv6 multicast traffic that - * does not require replication infrastructure. Any admitted IPv6 scope may - * be used, admin-local included, except within the reserved underlay - * subnet ff04::/64, which belongs to the internal multicast API. + * External-only groups carry IPv4 or IPv6 traffic and share their NAT + * target's replication groups. Any admitted IPv6 scope can be used, + * including admin-local, except for the reserved underlay subnet of + * ff04::/64, which belongs to the internal multicast API. + * + * The path address is checked as an external multicast address before the + * handler runs. + * + * Omitting `sources` preserves the group's existing source filter. * * The `tag` query parameter must match the group's existing tag. + * The NAT target must reference an existing underlay group. The underlay + * group cannot be referenced by another external group. */ #[endpoint { method = PUT, path = "/multicast/external-groups/{group_ip}", - versions = VERSION_MCAST_EXTERNAL_SCOPE_DOCS.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn multicast_group_update_external( rqctx: RequestContext, - path: Path, + path: Path, query: Query, group: TypedBody, ) -> Result< @@ -2338,7 +2380,7 @@ pub trait DpdApi { #[endpoint { method = PUT, path = "/multicast/external-groups/{group_ip}", - versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE_DOCS, + versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE, operation_id = "multicast_group_update_external", }] async fn multicast_group_update_external_v8( @@ -2350,7 +2392,22 @@ pub trait DpdApi { HttpResponseOk, HttpError, > { - Self::multicast_group_update_external(rqctx, path, query, group).await + let path = path.try_map(|param| { + v14::mcast::MulticastExternalGroupIpParam::try_from(param) + .map_err(|e| HttpError::for_bad_request(None, e.to_string())) + })?; + + let group = group + .try_map(|entry| { + v14::mcast::MulticastGroupUpdateExternalEntry::try_from(entry) + }) + .map_err(|e: v14::mcast::MulticastGroupCreateExternalError| { + HttpError::for_bad_request(None, e.to_string()) + })?; + + Self::multicast_group_update_external(rqctx, path, query, group) + .await + .map(|resp| resp.map(Into::into)) } /** @@ -2406,7 +2463,7 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/multicast/groups", - versions = VERSION_MCAST_STRICT_UNDERLAY.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn multicast_groups_list( rqctx: RequestContext, @@ -2421,6 +2478,32 @@ pub trait DpdApi { HttpError, >; + /** + * List all multicast groups. + */ + #[endpoint { + method = GET, + path = "/multicast/groups", + versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "multicast_groups_list", + }] + async fn multicast_groups_list_v8( + rqctx: RequestContext, + query_params: Query< + PaginationParams, + >, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + let HttpResponseOk(page) = + Self::multicast_groups_list(rqctx, query_params).await?; + Ok(HttpResponseOk(ResultsPage { + items: page.items.into_iter().map(Into::into).collect(), + next_page: page.next_page, + })) + } + /// List all multicast groups. #[endpoint { method = GET, @@ -2438,7 +2521,7 @@ pub trait DpdApi { HttpError, > { let HttpResponseOk(page) = - Self::multicast_groups_list(rqctx, query_params).await?; + Self::multicast_groups_list_v8(rqctx, query_params).await?; Ok(HttpResponseOk(ResultsPage { items: page.items.into_iter().map(Into::into).collect(), next_page: page.next_page, @@ -2481,7 +2564,7 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/multicast/tags/{tag}", - versions = VERSION_MCAST_STRICT_UNDERLAY.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn multicast_groups_list_by_tag( rqctx: RequestContext, @@ -2497,6 +2580,38 @@ pub trait DpdApi { HttpError, >; + /** + * List all multicast groups with a given tag. + * + * Returns paginated multicast groups matching the specified tag. Tags are + * assigned at group creation and are immutable. Use this endpoint to find + * all groups associated with a specific client or component. + */ + #[endpoint { + method = GET, + path = "/multicast/tags/{tag}", + versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "multicast_groups_list_by_tag", + }] + async fn multicast_groups_list_by_tag_v8( + rqctx: RequestContext, + path: Path, + query_params: Query< + PaginationParams, + >, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + let HttpResponseOk(page) = + Self::multicast_groups_list_by_tag(rqctx, path, query_params) + .await?; + Ok(HttpResponseOk(ResultsPage { + items: page.items.into_iter().map(Into::into).collect(), + next_page: page.next_page, + })) + } + /// List all multicast groups with a given tag. #[endpoint { method = GET, @@ -2514,7 +2629,7 @@ pub trait DpdApi { HttpResponseOk>, HttpError, > { - let HttpResponseOk(page) = Self::multicast_groups_list_by_tag( + let HttpResponseOk(page) = Self::multicast_groups_list_by_tag_v8( rqctx, path.map(Into::into), query_params, @@ -2608,7 +2723,7 @@ pub trait DpdApi { * Delete all multicast groups (and associated routes) without a tag. */ // Required method: the latest version always returns 410 Gone, while v1 - // actually deletes untagged groups. The semantic behavior is different. + // returns 204 with no effect now that every group has a tag. #[endpoint { method = DELETE, path = "/multicast/untagged", diff --git a/dpd-client/Cargo.toml b/dpd-client/Cargo.toml index 1cba04cf..2c8d0540 100644 --- a/dpd-client/Cargo.toml +++ b/dpd-client/Cargo.toml @@ -26,6 +26,7 @@ transceiver-controller = { workspace = true, features = [ "api-traits" ] } uuid.workspace = true common.workspace = true +dpd-types-versions.workspace = true progenitor.workspace = true [dev-dependencies] diff --git a/dpd-client/src/lib.rs b/dpd-client/src/lib.rs index 2dab3bb5..b5995cd0 100644 --- a/dpd-client/src/lib.rs +++ b/dpd-client/src/lib.rs @@ -6,10 +6,10 @@ //! Client library for the Dendrite data plane daemon. -pub use common::ROLLBACK_FAILURE_ERROR_CODE; use common::counters; use common::network; use common::ports; +pub use common::{MISSING_NAT_TARGET_ERROR_CODE, ROLLBACK_FAILURE_ERROR_CODE}; use slog::Logger; use std::cmp::Ordering; use std::fmt; @@ -50,6 +50,13 @@ progenitor::generate_api!( crates = { "oxnet" = "0.1.0", }, + // The generated `format: ip` newtype accepts any IP address. Use + // `ExternalMulticastIp` to apply the server's scope validation in the + // client's `TryFrom` conversion. + replace = { + ExternalMulticastIp = dpd_types_versions::latest::mcast::ExternalMulticastIp, + UnderlayMulticastIpv6 = dpd_types_versions::latest::mcast::UnderlayMulticastIpv6, + }, ); impl Client { diff --git a/dpd-client/tests/chaos_tests/port_settings.rs b/dpd-client/tests/chaos_tests/port_settings.rs index bd781779..669ecc6b 100644 --- a/dpd-client/tests/chaos_tests/port_settings.rs +++ b/dpd-client/tests/chaos_tests/port_settings.rs @@ -9,6 +9,7 @@ use super::harness::{ new_dpd_client, run_dpd, }; use super::util::{link_list_ipv4, link_list_ipv6}; +use anyhow::Context; use asic::chaos::{AsicConfig, Chaos, TableChaos}; use asic::table_chaos; use common::table::TableType; @@ -395,7 +396,13 @@ async fn test_port_settings_txn_sweep() -> anyhow::Result<()> { } } }) - .await?; + .await + .with_context(|| { + format!( + "after {success} applied, {fail} failed, \ + {rollback_fail} rollback-failed iterations" + ) + })?; print!("operation succeeded, settings changed as expected"); success += 1; } @@ -483,15 +490,11 @@ async fn test_port_settings_txn_par_sweep() -> anyhow::Result<()> { } fn is_rollback_error(e: &dpd_client::Error) -> bool { - if e.status() != Some(StatusCode::INTERNAL_SERVER_ERROR) { - return false; - } - if let dpd_client::Error::ErrorResponse(err) = e - && err.error_code == Some(ROLLBACK_FAILURE_ERROR_CODE.into()) - { - return true; - } - false + matches!( + e, + dpd_client::Error::ErrorResponse(err) + if err.error_code.as_deref() == Some(ROLLBACK_FAILURE_ERROR_CODE) + ) } async fn current_port_settings( diff --git a/dpd-client/tests/integration_tests/mcast.rs b/dpd-client/tests/integration_tests/mcast.rs index e02dc0ef..09886593 100644 --- a/dpd-client/tests/integration_tests/mcast.rs +++ b/dpd-client/tests/integration_tests/mcast.rs @@ -10,15 +10,20 @@ use std::{ sync::Arc, }; -use crate::integration_tests::common; -use crate::integration_tests::common::prelude::*; -use ::common::network::MacAddr; use anyhow::anyhow; -use dpd_client::{Error, types}; use futures::TryStreamExt; use oxnet::MulticastMac; + +use ::common::network::MacAddr; +use dpd_client::Error; +use dpd_client::MISSING_NAT_TARGET_ERROR_CODE; +use dpd_client::types; +use dpd_types::mcast::{ExternalMulticastIp, UnderlayMulticastIpv6}; use packet::{Endpoint, eth, geneve, ipv4, ipv6, sidecar, udp}; +use crate::integration_tests::common; +use crate::integration_tests::common::prelude::*; + const MULTICAST_TEST_IPV4: Ipv4Addr = Ipv4Addr::new(224, 0, 1, 0); const MULTICAST_TEST_IPV6: Ipv6Addr = Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 1, 0x1010); @@ -40,16 +45,6 @@ const TAG_B: &str = "tag_b"; const TAG_WRONG: &str = "wrong_tag"; const TAG_DIFFERENT: &str = "different_tag"; -trait ToIpAddr { - fn to_ip_addr(&self) -> IpAddr; -} - -impl ToIpAddr for types::UnderlayMulticastIpv6 { - fn to_ip_addr(&self) -> IpAddr { - IpAddr::V6(self.0) - } -} - /// Count table entries matching a specific IP address in any key field. fn count_entries_for_ip(entries: &[types::TableEntry], ip: &str) -> usize { entries.iter().filter(|e| e.keys.values().any(|v| v.contains(ip))).count() @@ -76,7 +71,7 @@ async fn create_test_multicast_group( group_ip: IpAddr, tag: Option<&str>, ports: &[(PhysPort, types::Direction)], - internal_forwarding: types::InternalForwarding, + nat_target: Option, external_forwarding: types::ExternalForwarding, sources: Option>, ) -> types::MulticastGroupResponse { @@ -96,9 +91,13 @@ async fn create_test_multicast_group( IpAddr::V4(_) => { // IPv4 groups are always external and require NAT targets let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: tag.map(String::from), - internal_forwarding, + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.expect( + "external multicast group requires a NAT target", + ), + }, external_forwarding, sources, }; @@ -124,7 +123,8 @@ async fn create_test_multicast_group( .is_admin_local_multicast() { // Admin-local IPv6 groups are internal - let admin_local_ip = types::UnderlayMulticastIpv6(ipv6); + let admin_local_ip = + UnderlayMulticastIpv6::try_from(ipv6).unwrap(); let internal_entry = types::MulticastGroupCreateUnderlayEntry { group_ip: admin_local_ip, tag: tag.map(String::from), @@ -148,9 +148,13 @@ async fn create_test_multicast_group( } else { // Non-admin-local IPv6 groups are external-only and require NAT targets let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: tag.map(String::from), - internal_forwarding, + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.expect( + "external multicast group requires a NAT target", + ), + }, external_forwarding, sources, }; @@ -199,9 +203,11 @@ async fn cleanup_test_group( fn get_group_ip(response: &types::MulticastGroupResponse) -> IpAddr { match response { types::MulticastGroupResponse::Underlay { group_ip, .. } => { - group_ip.to_ip_addr() + (*group_ip).into() + } + types::MulticastGroupResponse::External { group_ip, .. } => { + (*group_ip).into() } - types::MulticastGroupResponse::External { group_ip, .. } => *group_ip, } } @@ -251,12 +257,12 @@ fn get_sources( fn get_nat_target( response: &types::MulticastGroupResponse, -) -> Option<&types::NatTarget> { +) -> Option<&types::ExternalNatTarget> { match response { types::MulticastGroupResponse::Underlay { .. } => None, types::MulticastGroupResponse::External { internal_forwarding, .. - } => internal_forwarding.nat_target.as_ref(), + } => Some(&internal_forwarding.nat_target), } } @@ -267,17 +273,17 @@ fn get_tag(response: &types::MulticastGroupResponse) -> &String { } } -fn create_nat_target_ipv4() -> types::NatTarget { - types::NatTarget { - internal_ip: MULTICAST_NAT_IP, +fn create_nat_target_ipv4() -> types::ExternalNatTarget { + types::ExternalNatTarget { + internal_ip: MULTICAST_NAT_IP.try_into().unwrap(), inner_mac: MacAddr::new(0x11, 0x22, 0x33, 0x44, 0x55, 0x66).into(), vni: 100.into(), } } -fn create_nat_target_ipv6() -> types::NatTarget { - types::NatTarget { - internal_ip: MULTICAST_NAT_IP, +fn create_nat_target_ipv6() -> types::ExternalNatTarget { + types::ExternalNatTarget { + internal_ip: MULTICAST_NAT_IP.try_into().unwrap(), inner_mac: MacAddr::new(0x11, 0x22, 0x33, 0x44, 0x55, 0x66).into(), vni: 101.into(), } @@ -379,8 +385,8 @@ fn prepare_expected_pkt( switch: &Switch, send_pkt: &packet::Packet, vlan: Option, - nat_target: Option<&types::NatTarget>, - switch_port: Option, + nat_target: Option<&types::ExternalNatTarget>, + switch_port: PhysPort, ) -> packet::Packet { match nat_target { Some(nat) => { @@ -397,7 +403,7 @@ fn prepare_expected_pkt( }; let switch_port_mac = - switch.get_port_mac(switch_port.unwrap()).unwrap().to_string(); + switch.get_port_mac(switch_port).unwrap().to_string(); let mut forward_pkt = common::gen_external_geneve_packet( Endpoint::parse( @@ -407,8 +413,10 @@ fn prepare_expected_pkt( ) .unwrap(), Endpoint::parse( - &MacAddr::from(nat.internal_ip.derive_multicast_mac()) - .to_string(), + &MacAddr::from( + Ipv6Addr::from(nat.internal_ip).derive_multicast_mac(), + ) + .to_string(), &nat.internal_ip.to_string(), geneve::GENEVE_UDP_PORT, ) @@ -445,11 +453,8 @@ fn prepare_expected_pkt( } // Rewrite src mac - if let Some(port) = switch_port { - let port_mac = switch.get_port_mac(port).unwrap(); - recv_pkt.hdrs.eth_hdr.as_mut().unwrap().eth_smac = - port_mac.clone(); - } + let port_mac = switch.get_port_mac(switch_port).unwrap(); + recv_pkt.hdrs.eth_hdr.as_mut().unwrap().eth_smac = port_mac.clone(); recv_pkt } @@ -500,7 +505,7 @@ async fn test_group_creation_with_validation() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &[(egress1, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -515,10 +520,10 @@ async fn test_group_creation_with_validation() -> TestResult { // Test creating a group with invalid parameters (e.g., invalid VLAN ID) // IPv4 groups are always external let external_invalid = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4(MULTICAST_TEST_IPV4), + group_ip: IpAddr::V4(MULTICAST_TEST_IPV4).try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(4096), // Invalid: VLAN ID must be 1-4094 @@ -545,10 +550,10 @@ async fn test_group_creation_with_validation() -> TestResult { // Test with the null VLAN ID 0 (also invalid) let external_vlan0 = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4(MULTICAST_TEST_IPV4), + group_ip: IpAddr::V4(MULTICAST_TEST_IPV4).try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(0), // Invalid: VLAN 0 is the null VID @@ -575,10 +580,10 @@ async fn test_group_creation_with_validation() -> TestResult { // Test with reserved VLAN ID 4095 (also invalid) let external_vlan4095 = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4(MULTICAST_TEST_IPV4), + group_ip: IpAddr::V4(MULTICAST_TEST_IPV4).try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(4095), // Invalid: VLAN 4095 is reserved @@ -606,10 +611,10 @@ async fn test_group_creation_with_validation() -> TestResult { // Test with valid parameters // IPv4 groups are always external let external_valid = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4(MULTICAST_TEST_IPV4_SSM), + group_ip: IpAddr::V4(MULTICAST_TEST_IPV4_SSM).try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![types::IpSrc::Exact( @@ -630,12 +635,12 @@ async fn test_group_creation_with_validation() -> TestResult { "External group should reference the same external group ID as the internal group" ); - assert_eq!(created.group_ip, MULTICAST_TEST_IPV4_SSM); - assert_eq!(created.tag, TEST_TAG); assert_eq!( - created.internal_forwarding.nat_target, - Some(nat_target.clone()) + IpAddr::from(created.group_ip), + IpAddr::V4(MULTICAST_TEST_IPV4_SSM) ); + assert_eq!(created.tag, TEST_TAG); + assert_eq!(created.internal_forwarding.nat_target, nat_target.clone()); assert_eq!(created.external_forwarding.vlan_id, Some(10)); assert_eq!( created.sources, @@ -645,13 +650,15 @@ async fn test_group_creation_with_validation() -> TestResult { ); // Clean up external first (references internal via NAT target), then internal - cleanup_test_group(switch, created.group_ip, TEST_TAG).await.unwrap(); + cleanup_test_group(switch, created.group_ip.into(), TEST_TAG) + .await + .unwrap(); cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await } #[tokio::test] #[ignore] -async fn test_internal_ipv6_validation() -> TestResult { +async fn test_underlay_ipv6_validation() -> TestResult { let switch = &*get_switch().await; let (port_id, link_id) = switch.link_id(PhysPort(15)).unwrap(); @@ -701,7 +708,7 @@ async fn test_internal_ipv6_validation() -> TestResult { assert_eq!(updated.tag, TEST_TAG); - cleanup_test_group(switch, created.group_ip.to_ip_addr(), TEST_TAG).await + cleanup_test_group(switch, IpAddr::from(created.group_ip), TEST_TAG).await } #[tokio::test] @@ -737,17 +744,17 @@ async fn test_vlan_propagation_to_internal() -> TestResult { .into_inner(); // Create external group that references the admin-scoped group - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: "ff04::200".parse().unwrap(), // References admin-scoped group inner_mac: MacAddr::new(0x03, 0x00, 0x00, 0x00, 0x00, 0x03).into(), vni: 200.into(), }; let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4("224.1.2.3".parse().unwrap()), + group_ip: "224.1.2.3".parse().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(42) }, // This VLAN should be used by admin-scoped group sources: None, @@ -762,13 +769,8 @@ async fn test_vlan_propagation_to_internal() -> TestResult { assert_eq!(created_external.external_forwarding.vlan_id, Some(42)); assert_eq!( - created_external - .internal_forwarding - .nat_target - .as_ref() - .unwrap() - .internal_ip, - "ff04::200".parse::().unwrap() + created_external.internal_forwarding.nat_target.internal_ip, + "ff04::200".parse::().unwrap() ); // Verify IPv4 route table has one entry for the VLAN group. @@ -827,20 +829,133 @@ async fn test_vlan_propagation_to_internal() -> TestResult { // Verify the admin-scoped group's bitmap entry has VLAN 42 from external // group propagation. - assert!( - bitmap_table - .entries - .iter() - .any(|entry| entry.action_args.values().any(|v| v.contains("42"))), - "Admin-scoped group bitmap should have VLAN 42 from external group" + let bitmap_entry = bitmap_table + .entries + .iter() + .find(|entry| { + entry.keys.get("mcast_external_grp") + == Some(&created_admin.external_group_id.to_string()) + }) + .expect("Internal group bitmap entry should exist"); + assert_eq!(bitmap_entry.action, "set_decap_ports_and_vlan"); + assert_eq!( + bitmap_entry.action_args.get("vlan_id").map(String::as_str), + Some("42"), ); + let second_admin = switch + .client + .multicast_group_create_underlay( + &types::MulticastGroupCreateUnderlayEntry { + group_ip: "ff04::201".parse().unwrap(), + ..internal_group_entry.clone() + }, + ) + .await? + .into_inner(); + + let mut port_args = bitmap_entry.action_args.clone(); + port_args.remove("vlan_id"); + let mut update = types::MulticastGroupUpdateExternalEntry { + internal_forwarding: external_group.internal_forwarding.clone(), + external_forwarding: external_group.external_forwarding.clone(), + sources: None, + }; + + for (target, vlan_id) in [ + (&second_admin, Some(42)), + (&created_admin, None), + (&created_admin, Some(42)), + ] { + update.internal_forwarding.nat_target.internal_ip = target.group_ip; + update.external_forwarding.vlan_id = vlan_id; + let updated = switch + .client + .multicast_group_update_external( + &created_external.group_ip, + &make_tag(TEST_TAG), + &update, + ) + .await? + .into_inner(); + assert_eq!(updated.external_group_id, target.external_group_id); + assert_eq!(updated.internal_forwarding, update.internal_forwarding); + assert_eq!(updated.external_forwarding.vlan_id, vlan_id); + + let bitmaps = switch + .client + .table_dump("Egress.mcast_egress.tbl_decap_ports", false) + .await? + .into_inner(); + + for group in [&created_admin, &second_admin] { + let entry = bitmaps + .entries + .iter() + .find(|entry| { + entry.keys.get("mcast_external_grp") + == Some(&group.external_group_id.to_string()) + }) + .unwrap(); + let group_vlan = + if group.external_group_id == target.external_group_id { + vlan_id + } else { + None + }; + let mut expected_args = port_args.clone(); + let expected_action = match group_vlan { + Some(vlan_id) => { + expected_args.insert("vlan_id".into(), vlan_id.to_string()); + "set_decap_ports_and_vlan" + } + None => "set_decap_ports", + }; + assert_eq!(entry.action, expected_action); + assert_eq!(entry.action_args, expected_args); + } + } + // Delete external group first since it references the internal group via // NAT target - cleanup_test_group(switch, created_external.group_ip, TEST_TAG) + cleanup_test_group(switch, created_external.group_ip.into(), TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, created_admin.group_ip.to_ip_addr(), TEST_TAG) + + let recreated = switch + .client + .multicast_group_create_external( + &types::MulticastGroupCreateExternalEntry { + external_forwarding: types::ExternalForwarding { + vlan_id: None, + }, + ..external_group + }, + ) + .await? + .into_inner(); + assert_eq!(recreated.external_group_id, created_admin.external_group_id); + assert_eq!(recreated.external_forwarding.vlan_id, None); + + let bitmaps = switch + .client + .table_dump("Egress.mcast_egress.tbl_decap_ports", false) + .await? + .into_inner(); + let entry = bitmaps + .entries + .iter() + .find(|entry| { + entry.keys.get("mcast_external_grp") + == Some(&created_admin.external_group_id.to_string()) + }) + .unwrap(); + assert_eq!(entry.action, "set_decap_ports"); + assert_eq!(entry.action_args, port_args); + cleanup_test_group(switch, recreated.group_ip.into(), TEST_TAG).await?; + cleanup_test_group(switch, IpAddr::from(second_admin.group_ip), TEST_TAG) + .await?; + cleanup_test_group(switch, IpAddr::from(created_admin.group_ip), TEST_TAG) .await } @@ -856,7 +971,7 @@ async fn test_group_api_lifecycle() { internal_multicast_ip, Some(TEST_TAG), &[(egress1, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -867,10 +982,10 @@ async fn test_group_api_lifecycle() { let vlan_id = 10; let nat_target = create_nat_target_ipv4(); let external_create = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(vlan_id), @@ -887,12 +1002,9 @@ async fn test_group_api_lifecycle() { let external_group_id = created.external_group_id; - assert_eq!(created.group_ip, MULTICAST_TEST_IPV4); + assert_eq!(IpAddr::from(created.group_ip), IpAddr::V4(MULTICAST_TEST_IPV4)); assert_eq!(created.tag, TEST_TAG); - assert_eq!( - created.internal_forwarding.nat_target, - Some(nat_target.clone()) - ); + assert_eq!(created.internal_forwarding.nat_target, nat_target.clone()); assert_eq!(created.external_forwarding.vlan_id, Some(vlan_id)); // Route table: 1 entry (dst_addr only, VLAN via action) @@ -981,16 +1093,16 @@ async fn test_group_api_lifecycle() { ); // Update the group - let updated_nat_target = types::NatTarget { - internal_ip: MULTICAST_NAT_IP.into(), + let updated_nat_target = types::ExternalNatTarget { + internal_ip: MULTICAST_NAT_IP.try_into().unwrap(), inner_mac: MacAddr::from(MULTICAST_NAT_IP.derive_multicast_mac()) .into(), vni: 200.into(), }; let external_update = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(updated_nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: updated_nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(20) }, sources: None, @@ -999,7 +1111,7 @@ async fn test_group_api_lifecycle() { let updated = switch .client .multicast_group_update_external( - &group_ip, + &group_ip.try_into().unwrap(), &make_tag(TEST_TAG), &external_update, ) @@ -1009,10 +1121,7 @@ async fn test_group_api_lifecycle() { assert_eq!(updated.external_group_id, external_group_id); assert_eq!(updated.tag, TEST_TAG); - assert_eq!( - updated.internal_forwarding.nat_target, - Some(updated_nat_target) - ); + assert_eq!(updated.internal_forwarding.nat_target, updated_nat_target); assert_eq!(updated.external_forwarding.vlan_id, Some(20)); assert_eq!(updated.sources, None); @@ -1080,7 +1189,7 @@ async fn test_multicast_del_tag_validation() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &[(PhysPort(11), types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1089,10 +1198,10 @@ async fn test_multicast_del_tag_validation() -> TestResult { // Test Case 1: Delete with mismatched tag should fail let tagged_group_ip = IpAddr::V4(Ipv4Addr::new(224, 0, 10, 1)); let external_tagged = types::MulticastGroupCreateExternalEntry { - group_ip: tagged_group_ip, + group_ip: tagged_group_ip.try_into().unwrap(), tag: Some(TAG_A.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1159,10 +1268,10 @@ async fn test_multicast_del_tag_validation() -> TestResult { // then delete with the generated tag let auto_tagged_group_ip = IpAddr::V4(Ipv4Addr::new(224, 0, 10, 2)); let external_auto_tagged = types::MulticastGroupCreateExternalEntry { - group_ip: auto_tagged_group_ip, + group_ip: auto_tagged_group_ip.try_into().unwrap(), tag: None, // Will get auto-generated tag - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1206,21 +1315,37 @@ async fn test_multicast_tagged_groups_management() { internal_multicast_ip, Some(&format!("{}_internal", tag)), &[(PhysPort(11), types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; + let additional_internal_ips: [Ipv6Addr; 2] = + ["ff04::2", "ff04::3"].map(|ip| ip.parse().unwrap()); + + for internal_ip in additional_internal_ips { + create_test_multicast_group( + switch, + IpAddr::V6(internal_ip), + Some(&format!("{}_internal", tag)), + &[(PhysPort(11), types::Direction::Underlay)], + None, + types::ExternalForwarding { vlan_id: None }, + None, + ) + .await; + } + let nat_target = create_nat_target_ipv4(); let group_ip = IpAddr::V4(MULTICAST_TEST_IPV4); // Create first IPv4 external group (entry point only, no members) let external_group1 = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: Some(tag.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1237,8 +1362,11 @@ async fn test_multicast_tagged_groups_management() { let external_group2 = types::MulticastGroupCreateExternalEntry { group_ip: "224.0.1.2".parse().unwrap(), // Different IP tag: Some(tag.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: additional_internal_ips[0].try_into().unwrap(), + ..nat_target.clone() + }, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1255,8 +1383,11 @@ async fn test_multicast_tagged_groups_management() { let external_group3 = types::MulticastGroupCreateExternalEntry { group_ip: "224.0.1.3".parse().unwrap(), // Different IP tag: Some(TAG_DIFFERENT.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: additional_internal_ips[1].try_into().unwrap(), + ..nat_target.clone() + }, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1281,9 +1412,9 @@ async fn test_multicast_tagged_groups_management() { let group_ips: HashSet<_> = tagged_groups.iter().map(|g| get_group_ip(g)).collect(); - assert!(group_ips.contains(&created1.group_ip)); - assert!(group_ips.contains(&created2.group_ip)); - assert!(!group_ips.contains(&created3.group_ip)); + assert!(group_ips.contains(&IpAddr::from(created1.group_ip))); + assert!(group_ips.contains(&IpAddr::from(created2.group_ip))); + assert!(!group_ips.contains(&IpAddr::from(created3.group_ip))); // Delete all groups with the tag switch @@ -1302,9 +1433,26 @@ async fn test_multicast_tagged_groups_management() { let remaining_ips: HashSet<_> = remaining_groups.iter().map(|g| get_group_ip(g)).collect(); - assert!(!remaining_ips.contains(&created1.group_ip)); - assert!(!remaining_ips.contains(&created2.group_ip)); - assert!(remaining_ips.contains(&created3.group_ip)); + assert!(!remaining_ips.contains(&IpAddr::from(created1.group_ip))); + assert!(!remaining_ips.contains(&IpAddr::from(created2.group_ip))); + assert!(remaining_ips.contains(&IpAddr::from(created3.group_ip))); + + cleanup_test_group(switch, IpAddr::from(created3.group_ip), TAG_DIFFERENT) + .await + .unwrap(); + for internal_ip in [ + MULTICAST_NAT_IP, + additional_internal_ips[0], + additional_internal_ips[1], + ] { + cleanup_test_group( + switch, + IpAddr::V6(internal_ip), + &format!("{}_internal", tag), + ) + .await + .unwrap(); + } } #[tokio::test] @@ -1354,22 +1502,9 @@ async fn test_api_internal_ipv6_bifurcated_replication() -> TestResult { ); // Verify members are preserved - assert_eq!(created.members.len(), 2); - let external_members: Vec<_> = created - .members - .iter() - .filter(|m| m.direction == types::Direction::External) - .collect(); - let underlay_members: Vec<_> = created - .members - .iter() - .filter(|m| m.direction == types::Direction::Underlay) - .collect(); - - assert_eq!(external_members.len(), 1); - assert_eq!(underlay_members.len(), 1); + assert_eq!(created.members, admin_scoped_group.members); - cleanup_test_group(switch, created.group_ip.to_ip_addr(), TEST_TAG).await + cleanup_test_group(switch, IpAddr::from(created.group_ip), TEST_TAG).await } #[tokio::test] @@ -1401,7 +1536,7 @@ async fn test_api_internal_ipv6_underlay_only() -> TestResult { assert_eq!(created.members.len(), 1); assert_eq!(created.members[0].direction, types::Direction::Underlay); - cleanup_test_group(switch, created.group_ip.to_ip_addr(), TEST_TAG).await + cleanup_test_group(switch, IpAddr::from(created.group_ip), TEST_TAG).await } #[tokio::test] @@ -1434,7 +1569,7 @@ async fn test_api_internal_ipv6_external_only() -> TestResult { assert_eq!(created.members.len(), 1); assert_eq!(created.members[0].direction, types::Direction::External); - cleanup_test_group(switch, created.group_ip.to_ip_addr(), TEST_TAG).await + cleanup_test_group(switch, IpAddr::from(created.group_ip), TEST_TAG).await } #[tokio::test] @@ -1449,7 +1584,7 @@ async fn test_api_invalid_combinations() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &[(PhysPort(26), types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1457,10 +1592,10 @@ async fn test_api_invalid_combinations() -> TestResult { // IPv4 with underlay members should fail let ipv4_with_underlay = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4("224.1.0.200".parse().unwrap()), // Avoid 224.0.0.0/24 reserved range + group_ip: "224.1.0.200".parse().unwrap(), // Avoid 224.0.0.0/24 reserved range tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1474,12 +1609,27 @@ async fn test_api_invalid_combinations() -> TestResult { .expect("IPv4 external group should be created") .into_inner(); + let internal_multicast_ip2 = "ff04::2".parse().unwrap(); + create_test_multicast_group( + switch, + IpAddr::V6(internal_multicast_ip2), + Some(TEST_TAG), + &[(PhysPort(26), types::Direction::Underlay)], + None, + types::ExternalForwarding { vlan_id: None }, + None, + ) + .await; + // Non-admin-scoped IPv6 should use external API let non_admin_ipv6 = types::MulticastGroupCreateExternalEntry { group_ip: "ff0e::400".parse().unwrap(), // Global scope, not admin-scoped tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv6()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: internal_multicast_ip2.try_into().unwrap(), + ..create_nat_target_ipv6() + }, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(20) }, sources: None, @@ -1492,43 +1642,17 @@ async fn test_api_invalid_combinations() -> TestResult { .expect("Non-admin-scoped IPv6 should use external API") .into_inner(); - // Admin-scoped IPv6 with underlay members should fail via external API - let admin_scoped_external_entry = - types::MulticastGroupCreateExternalEntry { - group_ip: "ff04::500".parse().unwrap(), // Admin-scoped - tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv6()), - }, - external_forwarding: types::ExternalForwarding { - vlan_id: Some(30), - }, - sources: None, - }; + assert!("ff04::500".parse::().is_err()); - // This should fail because admin-scoped groups must use internal API - let result = switch - .client - .multicast_group_create_external(&admin_scoped_external_entry) + cleanup_test_group(switch, created_ipv4.group_ip.into(), TEST_TAG) .await - .expect_err("Admin-scoped IPv6 should fail via external API"); - - // Verify it's the expected validation error - match result { - Error::ErrorResponse(inner) => { - assert_eq!(inner.status(), 400); - assert!(inner.message.contains("admin-local scope")); - } - _ => panic!( - "Expected ErrorResponse for admin-local external group creation" - ), - } - - cleanup_test_group(switch, created_ipv4.group_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(switch, created_non_admin.group_ip, TEST_TAG) + .unwrap(); + cleanup_test_group(switch, created_non_admin.group_ip.into(), TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await.unwrap(); + cleanup_test_group(switch, IpAddr::V6(internal_multicast_ip2), TEST_TAG) + .await } #[tokio::test] @@ -1549,7 +1673,7 @@ async fn test_ipv4_multicast_invalid_destination_mac() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &[(egress1, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1564,9 +1688,7 @@ async fn test_ipv4_multicast_invalid_destination_mac() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -1644,7 +1766,7 @@ async fn test_ipv6_multicast_invalid_destination_mac() -> TestResult { multicast_ip, Some(TEST_TAG), &[(egress1, types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1729,7 +1851,7 @@ async fn test_multicast_ttl_zero() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &[(egress1, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1743,9 +1865,7 @@ async fn test_multicast_ttl_zero() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -1811,7 +1931,7 @@ async fn test_multicast_ttl_one() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &[(egress1, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1825,9 +1945,7 @@ async fn test_multicast_ttl_one() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -1901,7 +2019,7 @@ async fn test_ipv4_multicast_basic_replication_nat_ingress() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &underlay_members, - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1921,9 +2039,7 @@ async fn test_ipv4_multicast_basic_replication_nat_ingress() -> TestResult { multicast_ip, Some(TEST_TAG), &external_members, - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan }, None, ) @@ -1965,14 +2081,14 @@ async fn test_ipv4_multicast_basic_replication_nat_ingress() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv2 = prepare_expected_pkt( switch, &to_send, vlan, get_nat_target(&created_group), - Some(egress3), + egress3, ); let test_pkt = TestPacket { packet: Arc::new(to_send), port: ingress }; @@ -2029,7 +2145,7 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() internal_multicast_ip, Some(TEST_TAG), &replication_members, - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -2044,9 +2160,7 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan }, None, ) @@ -2071,10 +2185,10 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() // decrement the ttl/hlim, and update the desination MAC to the // Egress port MAC let expected_pkt1 = - prepare_expected_pkt(switch, &og_pkt, vlan, None, Some(egress1)); + prepare_expected_pkt(switch, &og_pkt, vlan, None, egress1); let expected_pkt2 = - prepare_expected_pkt(switch, &og_pkt, vlan, None, Some(egress2)); + prepare_expected_pkt(switch, &og_pkt, vlan, None, egress2); // Use same NAT target as the one used in the original packet let nat_target = create_nat_target_ipv4(); @@ -2094,8 +2208,10 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() ) .unwrap(), Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), + &MacAddr::from( + Ipv6Addr::from(nat_target.internal_ip).derive_multicast_mac(), + ) + .to_string(), &nat_target.internal_ip.to_string(), geneve::GENEVE_UDP_PORT, ) @@ -2215,7 +2331,7 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_members() (egress3, types::Direction::Underlay), (egress4, types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -2229,9 +2345,7 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_members() multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -2266,8 +2380,10 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_members() ) .unwrap(); let geneve_dst = Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), + &MacAddr::from( + Ipv6Addr::from(nat_target.internal_ip).derive_multicast_mac(), + ) + .to_string(), &nat_target.internal_ip.to_string(), geneve::GENEVE_UDP_PORT, ) @@ -2290,9 +2406,9 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_members() // Vlan should be stripped and we only replicate to underlay ports let recv_pkt1 = - prepare_expected_pkt(switch, &geneve_pkt, None, None, Some(egress3)); + prepare_expected_pkt(switch, &geneve_pkt, None, None, egress3); let recv_pkt2 = - prepare_expected_pkt(switch, &geneve_pkt, None, None, Some(egress4)); + prepare_expected_pkt(switch, &geneve_pkt, None, None, egress4); // We expect the packet not be decapped and forwarded to both egress // ports @@ -2405,7 +2521,7 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe (egress3, types::Direction::Underlay), (egress4, types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -2420,9 +2536,7 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan }, None, ) @@ -2462,8 +2576,10 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe ) .unwrap(), Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), + &MacAddr::from( + Ipv6Addr::from(nat_target.internal_ip).derive_multicast_mac(), + ) + .to_string(), &nat_target.internal_ip.to_string(), geneve::GENEVE_UDP_PORT, ) @@ -2478,17 +2594,15 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe TestPacket { packet: Arc::new(geneve_pkt.clone()), port: ingress }; // External ports should be replicated with Vlan information - let recv_pkt1 = - prepare_expected_pkt(switch, &og_pkt, vlan, None, Some(egress1)); + let recv_pkt1 = prepare_expected_pkt(switch, &og_pkt, vlan, None, egress1); - let recv_pkt2 = - prepare_expected_pkt(switch, &og_pkt, vlan, None, Some(egress2)); + let recv_pkt2 = prepare_expected_pkt(switch, &og_pkt, vlan, None, egress2); // Vlan should be stripped when we replicate to underlay ports let recv_pkt3 = - prepare_expected_pkt(switch, &geneve_pkt, None, None, Some(egress3)); + prepare_expected_pkt(switch, &geneve_pkt, None, None, egress3); let recv_pkt4 = - prepare_expected_pkt(switch, &geneve_pkt, None, None, Some(egress4)); + prepare_expected_pkt(switch, &geneve_pkt, None, None, egress4); // We expect 2 packets to be decapped and forwarded to external ports // and 2 packets to be forwarded to underlay ports (still encapped) @@ -2728,7 +2842,7 @@ async fn test_ipv4_multicast_drops_ingress_is_egress_port() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &[(ingress, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // No NAT target for admin-scoped group None, ) @@ -2742,9 +2856,7 @@ async fn test_ipv4_multicast_drops_ingress_is_egress_port() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: None }, None, ) @@ -2811,7 +2923,7 @@ async fn test_ipv6_multicast_hop_limit_zero() -> TestResult { (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -2825,9 +2937,7 @@ async fn test_ipv6_multicast_hop_limit_zero() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -2893,7 +3003,7 @@ async fn test_ipv6_multicast_hop_limit_one() -> TestResult { (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -2907,9 +3017,7 @@ async fn test_ipv6_multicast_hop_limit_one() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -2977,7 +3085,7 @@ async fn test_ipv6_multicast_basic_replication_nat_ingress() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &underlay_members, - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -2992,9 +3100,7 @@ async fn test_ipv6_multicast_basic_replication_nat_ingress() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan }, None, ) @@ -3026,7 +3132,7 @@ async fn test_ipv6_multicast_basic_replication_nat_ingress() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let test_pkt = TestPacket { packet: Arc::new(to_send), port: ingress }; @@ -3079,7 +3185,7 @@ async fn test_ipv4_multicast_source_filtering_exact_match() -> TestResult { (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -3096,9 +3202,7 @@ async fn test_ipv4_multicast_source_filtering_exact_match() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, Some(vec![allowed_src]), ) @@ -3131,7 +3235,7 @@ async fn test_ipv4_multicast_source_filtering_exact_match() -> TestResult { &allowed_pkt, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv12 = prepare_expected_pkt( @@ -3139,7 +3243,7 @@ async fn test_ipv4_multicast_source_filtering_exact_match() -> TestResult { &allowed_pkt, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let test_pkts = vec![ @@ -3196,7 +3300,7 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -3220,9 +3324,7 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, Some(allowed_sources), ) @@ -3264,7 +3366,7 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { &allowed_pkt1, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv22 = prepare_expected_pkt( @@ -3272,7 +3374,7 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { &allowed_pkt2, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv12 = prepare_expected_pkt( @@ -3280,7 +3382,7 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { &allowed_pkt1, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv21 = prepare_expected_pkt( @@ -3288,7 +3390,7 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { &allowed_pkt2, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let test_pkts = vec![ @@ -3348,7 +3450,7 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -3371,9 +3473,7 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan }, Some(sources), ) @@ -3416,7 +3516,7 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { &allowed_pkt1, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv22 = prepare_expected_pkt( @@ -3424,7 +3524,7 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { &allowed_pkt2, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv12 = prepare_expected_pkt( @@ -3432,7 +3532,7 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { &allowed_pkt1, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv21 = prepare_expected_pkt( @@ -3440,7 +3540,7 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { &allowed_pkt2, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let test_pkts = vec![ @@ -3501,7 +3601,7 @@ async fn test_multicast_dynamic_membership() -> TestResult { (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -3516,9 +3616,7 @@ async fn test_multicast_dynamic_membership() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -3540,7 +3638,7 @@ async fn test_multicast_dynamic_membership() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv2 = prepare_expected_pkt( @@ -3548,7 +3646,7 @@ async fn test_multicast_dynamic_membership() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let test_pkt = @@ -3566,8 +3664,8 @@ async fn test_multicast_dynamic_membership() -> TestResult { // but we can update their NAT target, vlan, and sources. // Must pass same tag for validation. let external_update_entry = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, // Keep the same NAT target external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, // Test with VLAN like reference test sources: None, @@ -3576,7 +3674,7 @@ async fn test_multicast_dynamic_membership() -> TestResult { switch .client .multicast_group_update_external( - &get_group_ip(&created_group), + &get_group_ip(&created_group).try_into().unwrap(), &make_tag(TEST_TAG), &external_update_entry, ) @@ -3611,7 +3709,7 @@ async fn test_multicast_dynamic_membership() -> TestResult { switch .client .multicast_group_update_underlay( - &types::UnderlayMulticastIpv6(ipv6), + &UnderlayMulticastIpv6::try_from(ipv6).unwrap(), &make_tag(TEST_TAG), &internal_update_entry, ) @@ -3624,14 +3722,14 @@ async fn test_multicast_dynamic_membership() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv2_new = prepare_expected_pkt( switch, &to_send, vlan, get_nat_target(&created_group), - Some(egress3), + egress3, ); let test_pkt_new = TestPacket { packet: Arc::new(to_send), port: ingress }; @@ -3673,7 +3771,7 @@ async fn test_multicast_multiple_groups() -> TestResult { (egress3, types::Direction::External), (egress4, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -3688,14 +3786,29 @@ async fn test_multicast_multiple_groups() -> TestResult { multicast_ip1, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) .await; + let internal_multicast_ip2 = "ff04::2".parse().unwrap(); + create_test_multicast_group( + switch, + IpAddr::V6(internal_multicast_ip2), + Some(TEST_TAG), + &[ + (egress1, types::Direction::External), + (egress2, types::Direction::External), + (egress3, types::Direction::External), + (egress4, types::Direction::External), + ], + None, + types::ExternalForwarding { vlan_id: None }, + None, + ) + .await; + // Create second IPv4 external group with NAT target (no members) let multicast_ip2 = IpAddr::V4(Ipv4Addr::new(224, 1, 2, 0)); // Changed to valid range let vlan2 = Some(20); @@ -3705,9 +3818,10 @@ async fn test_multicast_multiple_groups() -> TestResult { multicast_ip2, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(types::ExternalNatTarget { + internal_ip: internal_multicast_ip2.try_into().unwrap(), + ..create_nat_target_ipv4() + }), types::ExternalForwarding { vlan_id: Some(20) }, None, ) @@ -3738,7 +3852,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send1, vlan1, get_nat_target(&created_group1), - Some(egress1), + egress1, ); let to_recv1_2 = prepare_expected_pkt( @@ -3746,7 +3860,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send1, vlan1, get_nat_target(&created_group1), - Some(egress2), + egress2, ); let to_recv2_1 = prepare_expected_pkt( @@ -3754,7 +3868,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send2, vlan2, get_nat_target(&created_group2), - Some(egress3), + egress3, ); let to_recv2_2 = prepare_expected_pkt( @@ -3762,16 +3876,15 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send2, vlan2, get_nat_target(&created_group2), - Some(egress4), + egress4, ); - // Since both groups NAT to the same admin-scoped group, they both replicate to all ports let to_recv1_3 = prepare_expected_pkt( switch, &to_send1, vlan1, get_nat_target(&created_group1), - Some(egress3), + egress3, ); let to_recv1_4 = prepare_expected_pkt( @@ -3779,7 +3892,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send1, vlan1, get_nat_target(&created_group1), - Some(egress4), + egress4, ); let to_recv2_3 = prepare_expected_pkt( @@ -3787,7 +3900,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send2, vlan2, get_nat_target(&created_group2), - Some(egress1), + egress1, ); let to_recv2_4 = prepare_expected_pkt( @@ -3795,7 +3908,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send2, vlan2, get_nat_target(&created_group2), - Some(egress2), + egress2, ); let test_pkts = vec![ @@ -3804,7 +3917,6 @@ async fn test_multicast_multiple_groups() -> TestResult { ]; let expected_pkts = vec![ - // First multicast group - replicates to all ports since both groups share same NAT target TestPacket { packet: Arc::new(to_recv1_1), port: egress1 }, TestPacket { packet: Arc::new(to_recv1_2), port: egress2 }, TestPacket { packet: Arc::new(to_recv1_3), port: egress3 }, @@ -3824,7 +3936,9 @@ async fn test_multicast_multiple_groups() -> TestResult { cleanup_test_group(switch, get_group_ip(&created_group2), TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await.unwrap(); + cleanup_test_group(switch, IpAddr::V6(internal_multicast_ip2), TEST_TAG) + .await } #[tokio::test] @@ -3848,12 +3962,31 @@ async fn test_multicast_reset_all_tables() -> TestResult { (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) .await; + let additional_internal_ips: [Ipv6Addr; 3] = + ["ff04::3", "ff04::4", "ff04::5"].map(|ip| ip.parse().unwrap()); + + for internal_ip in additional_internal_ips { + create_test_multicast_group( + switch, + IpAddr::V6(internal_ip), + Some(TEST_TAG), + &[ + (egress1, types::Direction::External), + (egress2, types::Direction::External), + ], + None, + types::ExternalForwarding { vlan_id: None }, + None, + ) + .await; + } + // IPv4 external group with NAT and VLAN let multicast_ip1 = IpAddr::V4(MULTICAST_TEST_IPV4); let vlan1 = Some(10); @@ -3863,9 +3996,7 @@ async fn test_multicast_reset_all_tables() -> TestResult { multicast_ip1, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan1 }, None, ) @@ -3880,9 +4011,10 @@ async fn test_multicast_reset_all_tables() -> TestResult { multicast_ip2, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv6()), - }, + Some(types::ExternalNatTarget { + internal_ip: additional_internal_ips[0].try_into().unwrap(), + ..create_nat_target_ipv6() + }), types::ExternalForwarding { vlan_id: vlan2 }, None, // No sources for this group ) @@ -3892,7 +4024,7 @@ async fn test_multicast_reset_all_tables() -> TestResult { let ipv6 = Ipv6Addr::new(ADMIN_LOCAL_MULTICAST_PREFIX, 0, 0, 0, 0, 0, 0, 2); let group_entry2b = types::MulticastGroupCreateUnderlayEntry { - group_ip: types::UnderlayMulticastIpv6(ipv6), + group_ip: UnderlayMulticastIpv6::try_from(ipv6).unwrap(), tag: Some(TEST_TAG.to_string()), members: vec![types::MulticastGroupMember { port_id: switch.link_id(egress1).unwrap().0, @@ -3921,9 +4053,10 @@ async fn test_multicast_reset_all_tables() -> TestResult { multicast_ip3, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(types::ExternalNatTarget { + internal_ip: additional_internal_ips[1].try_into().unwrap(), + ..create_nat_target_ipv4() + }), types::ExternalForwarding { vlan_id: vlan3 }, sources.clone(), ) @@ -3940,9 +4073,10 @@ async fn test_multicast_reset_all_tables() -> TestResult { multicast_ip4, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv6()), - }, + Some(types::ExternalNatTarget { + internal_ip: additional_internal_ips[2].try_into().unwrap(), + ..create_nat_target_ipv6() + }), types::ExternalForwarding { vlan_id: vlan4 }, ipv6_sources.clone(), ) @@ -4131,10 +4265,13 @@ async fn test_multicast_reset_all_tables() -> TestResult { for group_ip in [ get_group_ip(&created_group1), get_group_ip(&created_group2), - created_group2b.group_ip.to_ip_addr(), + IpAddr::from(created_group2b.group_ip), get_group_ip(&created_group3), get_group_ip(&created_group4), internal_multicast_ip, + IpAddr::V6(additional_internal_ips[0]), + IpAddr::V6(additional_internal_ips[1]), + IpAddr::V6(additional_internal_ips[2]), ] { let result = switch.client.multicast_group_get(&group_ip).await; @@ -4163,7 +4300,7 @@ async fn test_multicast_vlan_translation_not_possible() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &[(egress1, types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -4177,10 +4314,8 @@ async fn test_multicast_vlan_translation_not_possible() -> TestResult { switch, multicast_ip, Some(TEST_TAG), - &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, // Create NAT target + &[], // External groups have no members + Some(create_nat_target_ipv4()), // Create NAT target types::ExternalForwarding { vlan_id: output_vlan }, None, ) @@ -4236,7 +4371,7 @@ async fn test_multicast_multiple_packets() -> TestResult { (egress2, types::Direction::Underlay), (egress3, types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -4251,9 +4386,7 @@ async fn test_multicast_multiple_packets() -> TestResult { multicast_ip, Some(TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -4286,7 +4419,7 @@ async fn test_multicast_multiple_packets() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv2 = prepare_expected_pkt( @@ -4294,7 +4427,7 @@ async fn test_multicast_multiple_packets() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv3 = prepare_expected_pkt( @@ -4302,7 +4435,7 @@ async fn test_multicast_multiple_packets() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress3), + egress3, ); test_pkts.push(TestPacket { packet: Arc::new(to_send), port: ingress }); @@ -4431,17 +4564,17 @@ async fn test_external_group_nat_target_validation() -> TestResult { let (port_id, link_id) = switch.link_id(PhysPort(11)).unwrap(); // Creating external group with NAT target referencing non-existent group should fail - let nonexistent_nat_target = types::NatTarget { + let nonexistent_nat_target = types::ExternalNatTarget { internal_ip: "ff04::1".parse().unwrap(), // Admin-scoped IPv6 that does not exist inner_mac: MacAddr::new(0x03, 0x00, 0x00, 0x00, 0x00, 0x01).into(), vni: 100.into(), }; let group_with_invalid_nat = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4("224.1.0.101".parse().unwrap()), + group_ip: "224.1.0.101".parse().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nonexistent_nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nonexistent_nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -4455,7 +4588,11 @@ async fn test_external_group_nat_target_validation() -> TestResult { match res { Error::ErrorResponse(inner) => { - assert_eq!(inner.status(), 400, "Expected 400 Bad Request"); + assert_eq!(inner.status(), 409, "Expected 409 Conflict"); + assert_eq!( + inner.error_code.as_deref(), + Some(MISSING_NAT_TARGET_ERROR_CODE) + ); } _ => panic!("Expected ErrorResponse for invalid NAT target"), } @@ -4493,17 +4630,17 @@ async fn test_external_group_nat_target_validation() -> TestResult { ); // Test 3: Now create external group with valid NAT target - let valid_nat_target = types::NatTarget { + let valid_nat_target = types::ExternalNatTarget { internal_ip: "ff04::1".parse().unwrap(), // References the admin-scoped group we just created inner_mac: MacAddr::new(0x03, 0x00, 0x00, 0x00, 0x00, 0x02).into(), vni: 100.into(), }; let group_with_valid_nat = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4("224.1.0.102".parse().unwrap()), + group_ip: "224.1.0.102".parse().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(valid_nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: valid_nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -4524,21 +4661,121 @@ async fn test_external_group_nat_target_validation() -> TestResult { // Verify NAT target configuration assert_eq!( - created_external - .internal_forwarding - .nat_target - .as_ref() - .unwrap() - .internal_ip, + created_external.internal_forwarding.nat_target.internal_ip, valid_nat_target.internal_ip, "External group's NAT target should point to the correct internal IP" ); + let update_with_missing_nat_target = + types::MulticastGroupUpdateExternalEntry { + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: "ff04::2".parse().unwrap(), + inner_mac: MacAddr::new(0x03, 0x00, 0x00, 0x00, 0x00, 0x03) + .into(), + vni: 101.into(), + }, + }, + external_forwarding: types::ExternalForwarding { + vlan_id: Some(10), + }, + sources: None, + }; + + let res = switch + .client + .multicast_group_update_external( + &created_external.group_ip, + &make_tag(TEST_TAG), + &update_with_missing_nat_target, + ) + .await + .expect_err("Should reject an update with a missing NAT target"); + + match res { + Error::ErrorResponse(inner) => { + assert_eq!(inner.status(), 409, "Expected 409 Conflict"); + assert_eq!( + inner.error_code.as_deref(), + Some(MISSING_NAT_TARGET_ERROR_CODE) + ); + } + _ => panic!("Expected ErrorResponse for missing NAT target"), + } + + let second_admin = switch + .client + .multicast_group_create_underlay( + &types::MulticastGroupCreateUnderlayEntry { + group_ip: "ff04::2".parse().unwrap(), + ..admin_scoped_group.clone() + }, + ) + .await? + .into_inner(); + + let second_external = switch + .client + .multicast_group_create_external( + &types::MulticastGroupCreateExternalEntry { + group_ip: "224.1.0.103".parse().unwrap(), + internal_forwarding: update_with_missing_nat_target + .internal_forwarding + .clone(), + ..group_with_valid_nat.clone() + }, + ) + .await? + .into_inner(); + + let err = switch + .client + .multicast_group_create_external(&group_with_invalid_nat) + .await + .expect_err("Should reject an already owned NAT target"); + + let Error::ErrorResponse(response) = err else { + panic!("Unexpected NAT target error: {err:?}"); + }; + assert_eq!(response.status(), 400); + assert!(response.message.contains("already referenced")); + + let err = switch + .client + .multicast_group_update_external( + &created_external.group_ip, + &make_tag(TEST_TAG), + &update_with_missing_nat_target, + ) + .await + .expect_err("Should reject an already owned NAT target"); + + let Error::ErrorResponse(response) = err else { + panic!("Unexpected NAT target error: {err:?}"); + }; + assert_eq!(response.status(), 400); + assert!(response.message.contains("already referenced")); + + let group_after_rejected_update = switch + .client + .multicast_group_get(&created_external.group_ip.into()) + .await + .expect("Should retain the external group after the rejected update") + .into_inner(); + assert_eq!( + get_nat_target(&group_after_rejected_update), + Some(&valid_nat_target), + ); + // Delete external group first since it references the internal group via NAT target - cleanup_test_group(switch, created_external.group_ip, TEST_TAG) + cleanup_test_group(switch, created_external.group_ip.into(), TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, created_admin.group_ip.to_ip_addr(), TEST_TAG) + cleanup_test_group(switch, second_external.group_ip.into(), TEST_TAG) + .await?; + cleanup_test_group(switch, IpAddr::from(second_admin.group_ip), TEST_TAG) + .await?; + cleanup_test_group(switch, IpAddr::from(created_admin.group_ip), TEST_TAG) .await } @@ -4568,66 +4805,18 @@ async fn test_ipv6_multicast_scope_validation() { "Admin-local scope (ff04::/16) should work with internal API" ); - // Site-local scope (ff05::/16) - should be rejected (only admin-local ff04 allowed) - let site_local_group = types::MulticastGroupCreateUnderlayEntry { - group_ip: "ff05::200".parse().unwrap(), - tag: Some(TEST_TAG.to_string()), - members: vec![types::MulticastGroupMember { - port_id: egress_port.clone(), - link_id: egress_link, - direction: types::Direction::External, - }], - }; - - let site_local_result = - switch.client.multicast_group_create_underlay(&site_local_group).await; - assert!( - site_local_result.is_err(), - "Site-local scope (ff05::/16) should be rejected - only admin-local (ff04) allowed" - ); - - // Organization-local scope (ff08::/16) - should be rejected (only admin-local ff04 allowed) - let org_local_group = types::MulticastGroupCreateUnderlayEntry { - group_ip: "ff08::300".parse().unwrap(), - tag: Some(TEST_TAG.to_string()), - members: vec![types::MulticastGroupMember { - port_id: egress_port.clone(), - link_id: egress_link, - direction: types::Direction::External, - }], - }; - - let org_local_result = - switch.client.multicast_group_create_underlay(&org_local_group).await; - assert!( - org_local_result.is_err(), - "Organization-local scope (ff08::/16) should be rejected - only admin-local (ff04) allowed" - ); - - // Global scope (ff0e::/16) - should be rejected by server-side validation - let global_scope_group = types::MulticastGroupCreateUnderlayEntry { - group_ip: "ff0e::400".parse().unwrap(), - tag: Some(TEST_TAG.to_string()), - members: vec![types::MulticastGroupMember { - port_id: egress_port.clone(), - link_id: egress_link, - direction: types::Direction::External, - }], - }; - - let global_scope_result = switch - .client - .multicast_group_create_underlay(&global_scope_group) - .await; - assert!( - global_scope_result.is_err(), - "Global scope (ff0e::/16) should be rejected by server-side validation" - ); - - // Test the reverse: admin-scoped should be rejected by external API - // First create an admin-scoped group to reference - let admin_target_group = types::MulticastGroupCreateUnderlayEntry { - group_ip: "ff04::1000".parse().unwrap(), + // Underlay groups must fall within the internal ff04::/64 block, so + // site-local (ff05), organization-local (ff08), and global (ff0e) scopes + // fail to parse as `UnderlayMulticastIpv6` before any request reaches + // dpd. + assert!("ff05::200".parse::().is_err()); + assert!("ff08::300".parse::().is_err()); + assert!("ff0e::400".parse::().is_err()); + + // Test the reverse: admin-scoped should be rejected by external API + // First create an admin-scoped group to reference + let admin_target_group = types::MulticastGroupCreateUnderlayEntry { + group_ip: "ff04::1000".parse().unwrap(), tag: Some(TEST_TAG.to_string()), members: vec![types::MulticastGroupMember { port_id: egress_port.clone(), @@ -4642,35 +4831,7 @@ async fn test_ipv6_multicast_scope_validation() { .await .expect("Should create target group"); - let admin_scoped_external = types::MulticastGroupCreateExternalEntry { - group_ip: "ff04::500".parse().unwrap(), - tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(types::NatTarget { - internal_ip: "ff04::1000".parse().unwrap(), - inner_mac: MacAddr::new(0x02, 0x00, 0x00, 0x00, 0x00, 0x01) - .into(), - vni: 100.into(), - }), - }, - external_forwarding: types::ExternalForwarding { vlan_id: Some(42) }, - sources: None, - }; - - let admin_external_result = switch - .client - .multicast_group_create_external(&admin_scoped_external) - .await; - assert!( - admin_external_result.is_err(), - "Admin-scoped addresses should be rejected by external API" - ); - let external_error_msg = - format!("{:?}", admin_external_result.unwrap_err()); - assert!( - external_error_msg.contains("admin-local scope"), - "Error should indicate admin-local addresses require internal API" - ); + assert!("ff04::500".parse::().is_err()); // Cleanup all created groups let admin_local_group = admin_local_result.unwrap().into_inner(); @@ -4680,7 +4841,7 @@ async fn test_ipv6_multicast_scope_validation() { switch .client .multicast_group_delete( - &admin_local_group.group_ip.to_ip_addr(), + &IpAddr::from(admin_local_group.group_ip), &del_tag, ) .await @@ -4689,7 +4850,7 @@ async fn test_ipv6_multicast_scope_validation() { let del_tag = make_tag(TEST_TAG); switch .client - .multicast_group_delete(&target_group.group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(target_group.group_ip), &del_tag) .await .ok(); } @@ -4737,7 +4898,7 @@ async fn test_multicast_group_id_recycling() -> TestResult { group1_ip, Some(TEST_TAG), &[(PhysPort(11), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -4749,7 +4910,7 @@ async fn test_multicast_group_id_recycling() -> TestResult { group2_ip, Some(TEST_TAG), &[(PhysPort(12), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -4783,7 +4944,7 @@ async fn test_multicast_group_id_recycling() -> TestResult { group3_ip, Some(TEST_TAG), &[(PhysPort(13), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -4832,7 +4993,7 @@ async fn test_multicast_group_id_recycling() -> TestResult { group4_ip, Some(TEST_TAG), &[(PhysPort(14), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -4873,7 +5034,7 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { internal_group_ip, Some(TEST_TAG), &[], // No members (Omicron setup) - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -4885,17 +5046,17 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { _ => panic!("Expected IPv6 address"), }; - let nat_target = types::NatTarget { - internal_ip: ipv6, + let nat_target = types::ExternalNatTarget { + internal_ip: ipv6.try_into().unwrap(), inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x00, 0x00, 0x01).into(), vni: 100.into(), }; let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, // Test with VLAN like reference test sources: None, @@ -4962,8 +5123,10 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { ) .unwrap(), Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), + &MacAddr::from( + Ipv6Addr::from(nat_target.internal_ip).derive_multicast_mac(), + ) + .to_string(), &nat_target.internal_ip.to_string(), geneve::GENEVE_UDP_PORT, ) @@ -5028,10 +5191,12 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { ], }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); switch .client @@ -5086,8 +5251,10 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { ) .unwrap(), Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), + &MacAddr::from( + Ipv6Addr::from(nat_target.internal_ip).derive_multicast_mac(), + ) + .to_string(), &nat_target.internal_ip.to_string(), geneve::GENEVE_UDP_PORT, ) @@ -5104,7 +5271,7 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { &og_pkt2, Some(10), // VLAN should come from external group None, // No NAT target for external members - Some(egress1), + egress1, ); let expected2 = prepare_expected_pkt( @@ -5112,7 +5279,7 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { &og_pkt2, Some(10), // VLAN should come from external group None, // No NAT target for external members (like reference test) - Some(egress2), + egress2, ); // Underlay member gets the Geneve packet unchanged @@ -5121,7 +5288,7 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { &to_send_again, None, // No VLAN None, // No NAT target for underlay member - Some(egress3), + egress3, ); let send_again = @@ -5154,10 +5321,12 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { members: vec![], // Remove all members }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); switch .client @@ -5272,16 +5441,16 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { internal_group_ip, Some(TEST_TAG), &[], // No members - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; // Create external group that references the internal group (empty, no members) - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6 address"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x02, 0x64).into(), @@ -5289,10 +5458,10 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { }; let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, // Test with VLAN like reference test sources: None, @@ -5348,7 +5517,7 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { Some(10), ); - // Create Geneve packet targeting the internal group (like test_encapped_multicast_geneve_mcast_tag_to_underlay_members) + // Create Geneve packet targeting the internal group let eth_hdr_len = og_pkt.hdrs.eth_hdr.as_ref().unwrap().hdr_len(); let payload = og_pkt.deparse().unwrap()[eth_hdr_len..].to_vec(); let geneve_pkt = common::gen_geneve_packet_with_mcast_tag( @@ -5359,8 +5528,10 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { ) .unwrap(), Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), + &MacAddr::from( + Ipv6Addr::from(nat_target.internal_ip).derive_multicast_mac(), + ) + .to_string(), &nat_target.internal_ip.to_string(), geneve::GENEVE_UDP_PORT, ) @@ -5420,10 +5591,12 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { members: vec![external_member1, external_member2, underlay_member], }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); switch .client @@ -5478,8 +5651,10 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { ) .unwrap(), Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), + &MacAddr::from( + Ipv6Addr::from(nat_target.internal_ip).derive_multicast_mac(), + ) + .to_string(), &nat_target.internal_ip.to_string(), geneve::GENEVE_UDP_PORT, ) @@ -5496,7 +5671,7 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { &og_pkt2, Some(10), // VLAN should come from external group None, // No NAT target for external members (like reference test) - Some(egress1), + egress1, ); let expected2 = prepare_expected_pkt( @@ -5504,7 +5679,7 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { &og_pkt2, Some(10), // VLAN should come from external group None, // No NAT target for external members (like reference test) - Some(egress2), + egress2, ); // Underlay member gets the Geneve packet unchanged (like test_encapped_multicast_geneve_mcast_tag_to_underlay_members) @@ -5513,7 +5688,7 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { &test_packet2, None, // No VLAN None, // No NAT target for underlay member - Some(egress3), + egress3, ); let send_again = @@ -5546,10 +5721,12 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { members: vec![], // Remove all members }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); switch .client @@ -5607,7 +5784,7 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { #[tokio::test] #[ignore] -async fn test_multicast_rollback_external_group_creation_failure() -> TestResult +async fn test_multicast_create_missing_nat_target_preserves_state() -> TestResult { let switch = &*get_switch().await; @@ -5632,7 +5809,7 @@ async fn test_multicast_rollback_external_group_creation_failure() -> TestResult (PhysPort(15), types::Direction::External), (PhysPort(17), types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -5679,17 +5856,17 @@ async fn test_multicast_rollback_external_group_creation_failure() -> TestResult } _ => panic!("Expected IPv6 address"), }; - let nat_target = types::NatTarget { - internal_ip: invalid_internal_ip, + let nat_target = types::ExternalNatTarget { + internal_ip: invalid_internal_ip.try_into().unwrap(), inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x02, 0x66).into(), vni: 200.into(), }; let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: None, - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -5805,7 +5982,7 @@ async fn test_multicast_rollback_member_update_failure() -> TestResult { (PhysPort(15), types::Direction::External), (PhysPort(17), types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -5832,10 +6009,12 @@ async fn test_multicast_rollback_member_update_failure() -> TestResult { let update_request = types::MulticastGroupUpdateUnderlayEntry { members: invalid_members }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); // This should fail and trigger rollback let result = switch @@ -5869,7 +6048,7 @@ async fn test_multicast_rollback_member_update_failure() -> TestResult { #[tokio::test] #[ignore] -async fn test_multicast_rollback_nat_transition_failure() -> TestResult { +async fn test_multicast_update_invalid_nat_mac_preserves_state() -> TestResult { let switch = &*get_switch().await; let internal_group_ip = IpAddr::V6(Ipv6Addr::new( @@ -5890,16 +6069,16 @@ async fn test_multicast_rollback_nat_transition_failure() -> TestResult { internal_group_ip, Some(TEST_TAG), &[(PhysPort(15), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; // Create external group with NAT - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6 address"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x02, 0x68).into(), @@ -5907,10 +6086,10 @@ async fn test_multicast_rollback_nat_transition_failure() -> TestResult { }; let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -5937,9 +6116,9 @@ async fn test_multicast_rollback_nat_transition_failure() -> TestResult { .expect("Should be able to dump NAT table"); // Attempt to update NAT target to invalid configuration that should fail - let invalid_nat_target = types::NatTarget { + let invalid_nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6 address"), }, // Invalid MAC (not multicast) @@ -5948,8 +6127,8 @@ async fn test_multicast_rollback_nat_transition_failure() -> TestResult { }; let invalid_update = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(invalid_nat_target), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: invalid_nat_target, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -5959,7 +6138,7 @@ async fn test_multicast_rollback_nat_transition_failure() -> TestResult { let result = switch .client .multicast_group_update_external( - &external_group_ip, + &external_group_ip.try_into().unwrap(), &make_tag(TEST_TAG), &invalid_update, ) @@ -6014,134 +6193,7 @@ async fn test_multicast_rollback_nat_transition_failure() -> TestResult { #[tokio::test] #[ignore] -async fn test_multicast_rollback_vlan_propagation_consistency() { - let switch = &*get_switch().await; - - let internal_group_ip = IpAddr::V6(Ipv6Addr::new( - ADMIN_LOCAL_MULTICAST_PREFIX, - 0, - 0, - 0, - 0, - 0, - 0, - 105, - )); - let external_group_ip = IpAddr::V4(Ipv4Addr::new(224, 1, 2, 105)); - - // Create internal group with members (so bitmap entry get created) - create_test_multicast_group( - &switch, - internal_group_ip, - Some(TEST_TAG), - &[ - (PhysPort(15), types::Direction::External), - (PhysPort(17), types::Direction::Underlay), - ], - types::InternalForwarding { nat_target: None }, - types::ExternalForwarding { vlan_id: None }, - None, - ) - .await; - - // Get initial bitmap table state - let _initial_bitmap_table = switch - .client - .table_dump("Egress.mcast_egress.tbl_decap_ports", false) - .await - .expect("Should be able to dump bitmap table"); - - // First, delete the internal group to break the NAT target reference - cleanup_test_group(&switch, internal_group_ip, TEST_TAG) - .await - .expect("Should cleanup internal group"); - - let nat_target = types::NatTarget { - internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, - _ => panic!("Expected IPv6 address"), - }, - inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x02, 0x69).into(), - vni: 105.into(), - }; - - // Get initial table states before attempting creation - let initial_route_table = switch - .client - .table_dump("Ingress.l3_router.MulticastRouter4.tbl", false) - .await - .expect("Should be able to dump route table"); - let initial_bitmap_table = switch - .client - .table_dump("Egress.mcast_egress.tbl_decap_ports", false) - .await - .expect("Should be able to dump bitmap table"); - - // Attempt to create external group that references the deleted internal group - let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, - tag: None, - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target), - }, - external_forwarding: types::ExternalForwarding { vlan_id: Some(999) }, - sources: None, - }; - - // This should fail because the NAT target (internal group) no longer exists - let result = - switch.client.multicast_group_create_external(&external_entry).await; - - assert!( - result.is_err(), - "External group creation should fail when NAT target doesn't exist" - ); - - // Verify rollback worked - tables should remain unchanged - let post_route_table = switch - .client - .table_dump("Ingress.l3_router.MulticastRouter4.tbl", false) - .await - .expect("Should be able to dump route table after rollback"); - let post_bitmap_table = switch - .client - .table_dump("Egress.mcast_egress.tbl_decap_ports", false) - .await - .expect("Should be able to dump bitmap table after rollback"); - - assert_eq!( - initial_route_table.entries.len(), - post_route_table.entries.len(), - "Route table should be unchanged after rollback" - ); - assert_eq!( - initial_bitmap_table.entries.len(), - post_bitmap_table.entries.len(), - "Bitmap table should be unchanged after rollback" - ); - - // No external group should exist since creation failed - let groups = switch - .client - .multicast_groups_list(None, None) - .await - .expect("Should be able to list groups after rollback"); - - let external_groups: Vec<_> = groups - .items - .iter() - .filter(|g| get_group_ip(g) == external_group_ip) - .collect(); - - assert!( - external_groups.is_empty(), - "No external group should exist after failed creation" - ); -} - -#[tokio::test] -#[ignore] -async fn test_multicast_rollback_source_filter_update() -> TestResult { +async fn test_multicast_update_invalid_source_preserves_state() -> TestResult { let switch = &*get_switch().await; // First create the internal admin-scoped group that will be the NAT target @@ -6152,7 +6204,7 @@ async fn test_multicast_rollback_source_filter_update() -> TestResult { internal_multicast_ip, Some(TEST_TAG), &[(egress1, types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // No NAT needed for internal groups None, ) @@ -6160,8 +6212,8 @@ async fn test_multicast_rollback_source_filter_update() -> TestResult { // Create IPv4 SSM group that supports source filters let group_ip = IpAddr::V4(Ipv4Addr::new(232, 1, 1, 100)); // SSM range - let nat_target = types::NatTarget { - internal_ip: MULTICAST_NAT_IP.into(), + let nat_target = types::ExternalNatTarget { + internal_ip: MULTICAST_NAT_IP.try_into().unwrap(), inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x01, 0x64).into(), vni: 100.into(), }; @@ -6173,10 +6225,10 @@ async fn test_multicast_rollback_source_filter_update() -> TestResult { ]; let external_group = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(initial_sources.clone()), @@ -6211,7 +6263,7 @@ async fn test_multicast_rollback_source_filter_update() -> TestResult { let result = switch .client .multicast_group_update_external( - &group_ip, + &group_ip.try_into().unwrap(), &make_tag(TEST_TAG), &failing_update_entry, ) @@ -6283,7 +6335,7 @@ async fn test_multicast_rollback_partial_member_addition() -> TestResult { (PhysPort(15), types::Direction::External), (PhysPort(16), types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -6326,10 +6378,12 @@ async fn test_multicast_rollback_partial_member_addition() -> TestResult { let update_request = types::MulticastGroupUpdateUnderlayEntry { members: mixed_members }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); // This should fail after partially adding some members, triggering incremental rollback let result = switch @@ -6364,146 +6418,6 @@ async fn test_multicast_rollback_partial_member_addition() -> TestResult { cleanup_test_group(&switch, internal_group_ip, TEST_TAG).await } -#[tokio::test] -#[ignore] -async fn test_multicast_rollback_table_operation_failure() { - let switch = &*get_switch().await; - - let internal_group_ip = IpAddr::V6(Ipv6Addr::new( - ADMIN_LOCAL_MULTICAST_PREFIX, - 0, - 0, - 0, - 0, - 0, - 0, - 107, - )); - let external_group_ip = IpAddr::V4(Ipv4Addr::new(224, 1, 2, 107)); - - // Create internal group first - create_test_multicast_group( - &switch, - internal_group_ip, - Some(TEST_TAG), - &[ - (PhysPort(15), types::Direction::External), - (PhysPort(17), types::Direction::Underlay), - ], - types::InternalForwarding { nat_target: None }, - types::ExternalForwarding { vlan_id: None }, - None, - ) - .await; - - // Delete the internal group to break the NAT target reference - cleanup_test_group(&switch, internal_group_ip, TEST_TAG) - .await - .expect("Should cleanup internal group"); - - // Get table states after internal group deletion but before external group attempt - let initial_route_table = switch - .client - .table_dump("Ingress.l3_router.MulticastRouter4.tbl", false) - .await - .expect("Should be able to dump route table"); - let initial_nat_table = switch - .client - .table_dump("Ingress.nat_ingress.ingress_ipv4_mcast", false) - .await - .expect("Should be able to dump NAT table"); - let initial_bitmap_table = switch - .client - .table_dump("Egress.mcast_egress.tbl_decap_ports", false) - .await - .expect("Should be able to dump bitmap table"); - - // Attempt to create external group that references the non-existent internal group - let broken_nat_target = types::NatTarget { - internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, - _ => panic!("Expected IPv6 address"), - }, - inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x02, 0x6b).into(), - vni: 107.into(), - }; - - let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, - tag: None, - internal_forwarding: types::InternalForwarding { - nat_target: Some(broken_nat_target), - }, - external_forwarding: types::ExternalForwarding { vlan_id: Some(200) }, - sources: None, - }; - - // This should fail because the NAT target (internal group) doesn't exist - let result = - switch.client.multicast_group_create_external(&external_entry).await; - - // Verify the creation failed - assert!( - result.is_err(), - "External group creation should fail when NAT target doesn't exist" - ); - - // Verify table rollback worked - all tables should be unchanged - let post_route_table = switch - .client - .table_dump("Ingress.l3_router.MulticastRouter4.tbl", false) - .await - .expect("Should be able to dump route table after rollback"); - - let post_nat_table = switch - .client - .table_dump("Ingress.nat_ingress.ingress_ipv4_mcast", false) - .await - .expect("Should be able to dump NAT table after rollback"); - - let post_bitmap_table = switch - .client - .table_dump("Egress.mcast_egress.tbl_decap_ports", false) - .await - .expect("Should be able to dump bitmap table after rollback"); - - assert_eq!( - post_route_table.entries.len(), - initial_route_table.entries.len(), - "Route table should be unchanged after table operation rollback" - ); - - assert_eq!( - post_nat_table.entries.len(), - initial_nat_table.entries.len(), - "NAT table should be unchanged after table operation rollback" - ); - - assert_eq!( - post_bitmap_table.entries.len(), - initial_bitmap_table.entries.len(), - "Bitmap table should be unchanged after table operation rollback" - ); - - // Verify no external group was created - let groups = switch - .client - .multicast_groups_list(None, None) - .await - .expect("Should be able to list groups after rollback"); - - let external_groups: Vec<_> = groups - .items - .iter() - .filter(|g| get_group_ip(g) == external_group_ip) - .collect(); - - assert!( - external_groups.is_empty(), - "No external group should exist after table operation rollback" - ); -} - #[tokio::test] #[ignore] #[allow(dead_code)] @@ -6530,7 +6444,7 @@ async fn test_multicast_group_get_underlay() -> TestResult { (PhysPort(10), types::Direction::External), (PhysPort(12), types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -6538,12 +6452,14 @@ async fn test_multicast_group_get_underlay() -> TestResult { let retrieved_underlay = switch .client - .multicast_group_get_underlay(&types::UnderlayMulticastIpv6( - match internal_group_ip { + .multicast_group_get_underlay( + &match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }, - )) + } + .try_into() + .unwrap(), + ) .await .expect( "Should be able to get underlay group via admin-scoped endpoint", @@ -6551,7 +6467,7 @@ async fn test_multicast_group_get_underlay() -> TestResult { .into_inner(); // Verify the response matches what we created - assert_eq!(retrieved_underlay.group_ip.to_ip_addr(), internal_group_ip); + assert_eq!(IpAddr::from(retrieved_underlay.group_ip), internal_group_ip); assert_eq!(retrieved_underlay.tag, TEST_TAG); assert_eq!(retrieved_underlay.members.len(), 2); @@ -6621,15 +6537,15 @@ async fn test_source_filter_ipv4_collapses_to_any() -> TestResult { internal_group_ip, Some(TEST_TAG), &[(PhysPort(10), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x01, 0x64).into(), @@ -6647,10 +6563,10 @@ async fn test_source_filter_ipv4_collapses_to_any() -> TestResult { // Create external group with mixed sources: specific + `Any` // The optimization should collapse this to just one /0 entry let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![ @@ -6696,11 +6612,6 @@ async fn test_source_filter_ipv4_collapses_to_any() -> TestResult { .multicast_group_delete(&internal_group_ip, &del_tag) .await; - assert!( - delete_internal_first_result.is_err(), - "Deleting internal group while still referenced by external group should fail" - ); - if let Err(Error::ErrorResponse(resp)) = &delete_internal_first_result { let error_msg = format!("{resp:?}"); assert!( @@ -6742,15 +6653,15 @@ async fn test_source_filter_ipv6_collapses_to_any() -> TestResult { internal_group_ip, Some(TEST_TAG), &[(PhysPort(10), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6"), }, inner_mac: MacAddr::new(0x33, 0x33, 0x00, 0x00, 0x01, 0x00).into(), @@ -6766,10 +6677,10 @@ async fn test_source_filter_ipv6_collapses_to_any() -> TestResult { // Create external group with mixed sources: specific + `Any` let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![ @@ -6815,11 +6726,6 @@ async fn test_source_filter_ipv6_collapses_to_any() -> TestResult { .multicast_group_delete(&internal_group_ip, &del_tag) .await; - assert!( - delete_internal_first_result.is_err(), - "Deleting internal group while still referenced by external group should fail" - ); - if let Err(Error::ErrorResponse(resp)) = &delete_internal_first_result { let error_msg = format!("{resp:?}"); assert!( @@ -6860,15 +6766,15 @@ async fn test_source_filter_update_to_any() -> TestResult { internal_group_ip, Some(TEST_TAG), &[(PhysPort(10), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x01, 0x65).into(), @@ -6884,10 +6790,10 @@ async fn test_source_filter_update_to_any() -> TestResult { // Create external group with only specific sources (no `Any`) let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![ @@ -6917,8 +6823,8 @@ async fn test_source_filter_update_to_any() -> TestResult { // Update to include `Any`, simulating an "any source" member joining let update_entry = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![ @@ -6931,7 +6837,7 @@ async fn test_source_filter_update_to_any() -> TestResult { let updated = switch .client .multicast_group_update_external( - &external_group_ip, + &external_group_ip.try_into().unwrap(), &make_tag(TEST_TAG), &update_entry, ) @@ -6987,15 +6893,15 @@ async fn test_source_filter_cleanup_on_delete() -> TestResult { internal_group_ip, Some(TEST_TAG), &[(PhysPort(10), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x01, 0x66).into(), @@ -7011,10 +6917,10 @@ async fn test_source_filter_cleanup_on_delete() -> TestResult { // Create external group with sources let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![ @@ -7087,15 +6993,15 @@ async fn test_source_filter_empty_vec_normalizes_to_any() -> TestResult { internal_group_ip, Some(TEST_TAG), &[(PhysPort(10), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x01, 0x67).into(), @@ -7112,10 +7018,10 @@ async fn test_source_filter_empty_vec_normalizes_to_any() -> TestResult { // Create external group with empty sources vec - should normalize to None // and add a single /0 entry (allow any source) let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![]), // Empty vec should normalize to None @@ -7173,7 +7079,7 @@ async fn test_update_nonexistent_group_returns_404() -> TestResult { let switch = &*get_switch().await; // Case: Update non-existent underlay group - let nonexistent_underlay: types::UnderlayMulticastIpv6 = + let nonexistent_underlay: UnderlayMulticastIpv6 = Ipv6Addr::new(ADMIN_LOCAL_MULTICAST_PREFIX, 0, 0, 0, 0, 0, 0, 0xdead) .try_into() .unwrap(); @@ -7209,14 +7115,21 @@ async fn test_update_nonexistent_group_returns_404() -> TestResult { let external_update = types::MulticastGroupUpdateExternalEntry { external_forwarding: types::ExternalForwarding { vlan_id: Some(100) }, - internal_forwarding: types::InternalForwarding { nat_target: None }, + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: "ff04::1".parse().unwrap(), + inner_mac: MacAddr::new(1, 0, 94, 0, 0, 1).into(), + vni: 100.into(), + }, + }, + sources: None, }; let result = switch .client .multicast_group_update_external( - &nonexistent_external, + &nonexistent_external.try_into().unwrap(), &make_tag("nonexistent_test"), &external_update, ) @@ -7272,7 +7185,7 @@ async fn test_delete_nonexistent_group_returns_404() -> TestResult { async fn test_underlay_delete_recreate_recovery_flow() -> TestResult { let switch = &*get_switch().await; - let group_ip: types::UnderlayMulticastIpv6 = + let group_ip: UnderlayMulticastIpv6 = Ipv6Addr::new(ADMIN_LOCAL_MULTICAST_PREFIX, 0, 0, 0, 0, 0, 0, 0x501) .try_into() .unwrap(); @@ -7302,7 +7215,7 @@ async fn test_underlay_delete_recreate_recovery_flow() -> TestResult { let del_tag = make_tag(tag); switch .client - .multicast_group_delete(&group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(group_ip), &del_tag) .await .expect("Should delete group during recovery"); @@ -7384,7 +7297,7 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { internal_ip, Some(tag), &[(egress_port, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -7396,10 +7309,10 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { // Create without VLAN let create_no_vlan = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: Some(tag.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: None }, sources: None, @@ -7413,8 +7326,8 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { // Update to add VLAN 10 let update_add_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -7423,7 +7336,7 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, + &group_ip.try_into().unwrap(), &make_tag(tag), &update_add_vlan, ) @@ -7443,8 +7356,8 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { // Update to change VLAN 10 -> 20, verify no stale entries let update_change_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(20) }, sources: None, @@ -7453,7 +7366,7 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, + &group_ip.try_into().unwrap(), &make_tag(tag), &update_change_vlan, ) @@ -7477,8 +7390,8 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { // Update to remove VLAN let update_remove_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: None }, sources: None, @@ -7487,7 +7400,7 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, + &group_ip.try_into().unwrap(), &make_tag(tag), &update_remove_vlan, ) @@ -7532,7 +7445,7 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { internal_ip, Some(tag), &[(egress_port, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -7545,10 +7458,10 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { // Create without VLAN let create_no_vlan = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: Some(tag.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: None }, sources: None, @@ -7562,8 +7475,8 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { // Update to add VLAN 10 let update_add_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -7572,7 +7485,7 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, + &group_ip.try_into().unwrap(), &make_tag(tag), &update_add_vlan, ) @@ -7592,8 +7505,8 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { // Update to change VLAN 10 -> 20, verify no stale entries let update_change_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(20) }, sources: None, @@ -7602,7 +7515,7 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, + &group_ip.try_into().unwrap(), &make_tag(tag), &update_change_vlan, ) @@ -7626,8 +7539,8 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { // Update to remove VLAN let update_remove_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: None }, sources: None, @@ -7636,7 +7549,7 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, + &group_ip.try_into().unwrap(), &make_tag(tag), &update_remove_vlan, ) @@ -7736,7 +7649,7 @@ async fn test_tag_immutability_on_update() -> TestResult { let del_tag = make_tag(TAG_A); switch .client - .multicast_group_delete(&group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(group_ip), &del_tag) .await .expect("Should delete with correct tag"); @@ -7779,7 +7692,7 @@ async fn test_tag_validation_on_delete() -> TestResult { let del_tag = make_tag(TAG_WRONG); let result = switch .client - .multicast_group_delete(&group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(group_ip), &del_tag) .await; match &result { @@ -7808,7 +7721,7 @@ async fn test_tag_validation_on_delete() -> TestResult { let del_tag = make_tag(TAG_A); switch .client - .multicast_group_delete(&group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(group_ip), &del_tag) .await .expect("Should delete with correct tag"); @@ -7833,7 +7746,7 @@ async fn test_tag_validation() -> TestResult { internal_ip, Some(TEST_TAG), &[(PhysPort(11), types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -7841,10 +7754,10 @@ async fn test_tag_validation() -> TestResult { let external_ip = IpAddr::V4(Ipv4Addr::new(224, 0, 12, 1)); let create_entry = types::MulticastGroupCreateExternalEntry { - group_ip: external_ip, + group_ip: external_ip.try_into().unwrap(), tag: Some(TAG_A.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -7857,8 +7770,8 @@ async fn test_tag_validation() -> TestResult { .expect("Should create external group"); let update_entry = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(20) }, sources: None, @@ -7867,7 +7780,7 @@ async fn test_tag_validation() -> TestResult { let result = switch .client .multicast_group_update_external( - &external_ip, + &external_ip.try_into().unwrap(), &make_tag(TAG_WRONG), &update_entry, ) @@ -7917,7 +7830,7 @@ async fn test_tag_validation() -> TestResult { let del_tag = make_tag("casesensitivetag"); let result = switch .client - .multicast_group_delete(&case_group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(case_group_ip), &del_tag) .await; assert!( @@ -7928,7 +7841,7 @@ async fn test_tag_validation() -> TestResult { let del_tag = make_tag("CaseSensitiveTag"); switch .client - .multicast_group_delete(&case_group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(case_group_ip), &del_tag) .await .expect("Should delete with correct case"); diff --git a/dpd-client/tests/integration_tests/table_tests.rs b/dpd-client/tests/integration_tests/table_tests.rs index 1407db41..4d51c7e1 100644 --- a/dpd-client/tests/integration_tests/table_tests.rs +++ b/dpd-client/tests/integration_tests/table_tests.rs @@ -17,6 +17,7 @@ use reqwest::StatusCode; use dpd_client::ClientInfo; use dpd_client::ResponseValue; use dpd_client::types; +use dpd_types::mcast::UnderlayMulticastIpv6; use crate::integration_tests::common::prelude::*; @@ -483,7 +484,7 @@ impl TableTest // Admin-local IPv6 groups are internal with replication info and members let internal_entry = types::MulticastGroupCreateUnderlayEntry { - group_ip: types::UnderlayMulticastIpv6(group_ip), + group_ip: UnderlayMulticastIpv6::try_from(group_ip).unwrap(), tag: Some(MCAST_TAG.to_string()), members: vec![ types::MulticastGroupMember { diff --git a/dpd-types/versions/Cargo.toml b/dpd-types/versions/Cargo.toml index 70dcaeec..e432012b 100644 --- a/dpd-types/versions/Cargo.toml +++ b/dpd-types/versions/Cargo.toml @@ -13,3 +13,6 @@ schemars.workspace = true serde.workspace = true thiserror.workspace = true uuid.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/dpd-types/versions/src/impls/mcast.rs b/dpd-types/versions/src/impls/mcast.rs index a067c211..69e333dc 100644 --- a/dpd-types/versions/src/impls/mcast.rs +++ b/dpd-types/versions/src/impls/mcast.rs @@ -4,16 +4,45 @@ // // Copyright 2026 Oxide Computer Company +//! Validation and conversions for the multicast types re-exported by +//! [`crate::latest::mcast`]. +//! +//! Most of this module is centered on address validation. IPv4 excludes the +//! reserved base address of `224.0.0.0` ([RFC 1112] §4) and the SSM null +//! address. IPv6 rejects reserved scope `0x0`, the interface-local and +//! link-local scopes that routers must not forward past ([RFC 4291] §2.7), +//! the reserved base addresses `ff0x::` ([RFC 4291] §2.7.1), and the +//! internally reserved underlay subnet `ff04::/64`. IPv6 then checks the +//! prefix-based and embedded rendezvous point (RP) flag rules of [RFC 3306] +//! and [RFC 3956] (as updated by [RFC 7371]), and the SSM null channel of +//! [RFC 4607] §4.3. +//! +//! Exact source addresses follow the rule set of OPTE's source validator, as +//! described on [`crate::latest::mcast::ExactSource`]. +//! +//! [RFC 1112]: https://www.rfc-editor.org/rfc/rfc1112.html +//! [RFC 3306]: https://www.rfc-editor.org/rfc/rfc3306.html +//! [RFC 3956]: https://www.rfc-editor.org/rfc/rfc3956.html +//! [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html +//! [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html +//! [RFC 7371]: https://www.rfc-editor.org/rfc/rfc7371.html + use std::{ fmt, - net::{IpAddr, Ipv6Addr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, str::FromStr, }; -use omicron_common::address::UNDERLAY_MULTICAST_SUBNET; +use omicron_common::address::{IPV4_SSM_SUBNET, UNDERLAY_MULTICAST_SUBNET}; use crate::latest::mcast::{ - Error, IpSrc, MulticastGroupResponse, MulticastTag, UnderlayMulticastIpv6, + AsmMulticastGroupCreate, EmbeddedIpv4, Error, ExactSource, + ExternalForwarding, ExternalInternalForwarding, ExternalMulticastIp, + ExternalMulticastIpError, InvalidMulticastSource, IpSrc, + MulticastGroupCreateExternalEntry, MulticastGroupCreateExternalError, + MulticastGroupResponse, MulticastTag, NonEmptyExactSources, SourceEntry, + SourceFilter, SsmMulticastGroupCreate, SsmMulticastIp, + UnderlayMulticastIpv6, }; /// Maximum length for multicast tags. @@ -57,6 +86,556 @@ impl FromStr for UnderlayMulticastIpv6 { } } +impl FromStr for ExternalMulticastIp { + type Err = ExternalMulticastIpError; + + fn from_str(s: &str) -> Result { + let addr: IpAddr = s.parse().map_err(|e| { + ExternalMulticastIpError::InvalidIpAddress(s.to_string(), e) + })?; + Self::new(addr) + } +} + +impl FromStr for ExactSource { + type Err = MulticastGroupCreateExternalError; + + fn from_str(s: &str) -> Result { + let addr: IpAddr = s.parse().map_err(|e| { + MulticastGroupCreateExternalError::InvalidSourceIpAddress( + s.to_string(), + e, + ) + })?; + Self::new(addr) + } +} + +const IPV4_RESERVED_BASE: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 0); +const IPV4_SSM_RESERVED_NULL: Ipv4Addr = Ipv4Addr::new(232, 0, 0, 0); +const IPV6_SSM_NULL_GROUP_ID: u32 = 0x4000_0000; +const IPV6_SCOPE_MASK: u16 = 0x000f; +const IPV6_SCOPE_RESERVED_ZERO: u16 = 0x0; +const IPV6_SCOPE_INTERFACE_LOCAL: u16 = 0x1; +const IPV6_SCOPE_LINK_LOCAL: u16 = 0x2; + +// Flags field: the nibble above scope (RFC 4291 §2.7). + +const IPV6_FLAGS_SHIFT: u16 = 4; +const IPV6_FLAGS_MASK: u16 = 0xf; +const IPV6_FLAG_TRANSIENT: u16 = 0x1; +const IPV6_FLAG_PREFIX_BASED: u16 = 0x2; +const IPV6_FLAG_EMBEDDED_RP: u16 = 0x4; +const IPV6_RIID_SHIFT: u16 = 8; +const IPV6_RIID_MASK: u16 = 0xf; +const IPV6_PREFIX_BASED_PLEN_MASK: u16 = 0xff; +const IPV6_PREFIX_BASED_MAX_PLEN: u16 = 64; + +// Reasons an embedded-Rendezvous Point (RP) address gets rejected +// (RFC 7371 §4.2.2, §4.2.3; RFC 3956 §4, §6.3, §10). + +const RP_REQUIRES_PREFIX_BASED: &str = "the P flag is required"; +const RP_REQUIRES_TRANSIENT: &str = "the T flag is required"; +const RP_PLEN_OUT_OF_RANGE: &str = + "the prefix length must be from 1 through 64"; +const RP_RIID_ZERO: &str = "the RP interface ID must be nonzero"; +const RP_UNUSABLE_ADDRESS: &str = + "the derived RP address is in ::/16, link-local, or multicast space"; + +// SSM flag pattern: R=0, P=T=1 (RFC 7371 §4.1.2). +const IPV6_SSM_PREFIX: u16 = 0xff30; +const IPV6_SSM_PREFIX_MASK: u16 = 0xff70; + +fn embedded_rendezvous_point_address( + segs: [u16; 8], + plen: u16, + riid: u16, +) -> Ipv6Addr { + let mut rp = [0u16; 8]; + for (index, segment) in segs[2..6].iter().enumerate() { + let offset = (index as u16) * 16; + rp[index] = if offset >= plen { + 0 + } else if offset + 16 > plen { + segment & (!0u16 << (16 - (plen - offset))) + } else { + *segment + }; + } + rp[7] = riid; + Ipv6Addr::from(rp) +} + +fn is_usable_rendezvous_point_address(rp: Ipv6Addr) -> bool { + let first = rp.segments()[0]; + first != 0 && first & 0xffc0 != 0xfe80 && first >> 8 != 0xff +} + +const fn ipv6_segments_are_ssm(segs: [u16; 8]) -> bool { + segs[0] & IPV6_SSM_PREFIX_MASK == IPV6_SSM_PREFIX + && segs[1] & IPV6_PREFIX_BASED_PLEN_MASK == 0 +} + +/// Reject an address that cannot identify an external multicast group. +/// +/// This enforces the RFC constraints on the address, plus one Oxide allocation +/// rule: the underlay subnet `ff04::/64` belongs to the internal multicast API. +/// +/// See [`crate::v14::mcast::ExternalMulticastIp`] for the constraints and +/// their citations. +pub(crate) fn validate_external_multicast_ip( + addr: IpAddr, +) -> Result<(), ExternalMulticastIpError> { + if !addr.is_multicast() { + return Err(ExternalMulticastIpError::NotMulticast(addr)); + } + + if let IpAddr::V6(ipv6) = addr { + let segs = ipv6.segments(); + match segs[0] & IPV6_SCOPE_MASK { + IPV6_SCOPE_RESERVED_ZERO => { + return Err(ExternalMulticastIpError::ReservedIpv6Scope(addr)); + } + IPV6_SCOPE_INTERFACE_LOCAL | IPV6_SCOPE_LINK_LOCAL => { + return Err(ExternalMulticastIpError::LocalIpv6Scope(addr)); + } + _ => {} + } + + if UNDERLAY_MULTICAST_SUBNET.contains(ipv6) { + return Err(ExternalMulticastIpError::ReservedUnderlaySubnet(addr)); + } + + // With P set, the low octet of the 2nd segment is the prefix length. + // + // See RFC 3306 §4 and RFC 7371 §4.1.1. + let flags = (segs[0] >> IPV6_FLAGS_SHIFT) & IPV6_FLAGS_MASK; + let plen = segs[1] & IPV6_PREFIX_BASED_PLEN_MASK; + + if flags == 0 && segs[1..].iter().all(|seg| *seg == 0) { + return Err(ExternalMulticastIpError::ReservedBaseAddress(addr)); + } + + if flags & IPV6_FLAG_EMBEDDED_RP != 0 { + let riid = (segs[1] >> IPV6_RIID_SHIFT) & IPV6_RIID_MASK; + let malformed = |reason| { + Err(ExternalMulticastIpError::MalformedEmbeddedRp { + addr, + reason, + }) + }; + + if flags & IPV6_FLAG_PREFIX_BASED == 0 { + return malformed(RP_REQUIRES_PREFIX_BASED); + } + if flags & IPV6_FLAG_TRANSIENT == 0 { + return malformed(RP_REQUIRES_TRANSIENT); + } + if plen == 0 || plen > IPV6_PREFIX_BASED_MAX_PLEN { + return malformed(RP_PLEN_OUT_OF_RANGE); + } + if riid == 0 { + return malformed(RP_RIID_ZERO); + } + if !is_usable_rendezvous_point_address( + embedded_rendezvous_point_address(segs, plen, riid), + ) { + return malformed(RP_UNUSABLE_ADDRESS); + } + } else if flags & IPV6_FLAG_PREFIX_BASED != 0 + && flags & IPV6_FLAG_TRANSIENT == 0 + { + return Err(ExternalMulticastIpError::MalformedPrefixBased { + addr, + }); + } + + // RFC 3306 §6 places the zero network prefix requirement on the node + // that forms an address, and RFC 4607 §1 has the system treat all of + // the ff3x::/32 block as SSM. The prefix matters only for the reserved + // null address ff3x::4000:0 (RFC 4607 §4.3), the one SSM destination + // rejected here. Group IDs below 0x4000_0000 are assigned with P=T=0 + // (RFC 3307 §4.1), making them invalid SSM addresses that a router may + // drop (RFC 4607 §1). 0x4000_0001 through 0x7fff_ffff are reserved for + // IANA allocation (RFC 3307 §4.2), and 0x8000_0000 and above are the + // dynamic range (RFC 3307 §4.3), which RFC 10028 §3 partitions, with + // host SSM allocation at 0xf000_0000 through 0xfcff_ffff. + if ipv6_segments_are_ssm(segs) { + let within_prefix = + segs[2] == 0 && segs[3] == 0 && segs[4] == 0 && segs[5] == 0; + let group_id = (u32::from(segs[6]) << 16) | u32::from(segs[7]); + if within_prefix && group_id == IPV6_SSM_NULL_GROUP_ID { + return Err(ExternalMulticastIpError::InvalidIpv6Ssm(addr)); + } + } + } + + if let IpAddr::V4(ipv4) = addr { + if ipv4 == IPV4_RESERVED_BASE { + return Err(ExternalMulticastIpError::ReservedBaseAddress(addr)); + } + if ipv4 == IPV4_SSM_RESERVED_NULL { + return Err(ExternalMulticastIpError::ReservedSsmNull(addr)); + } + } + + Ok(()) +} + +/// Check whether an address is a source-specific multicast one. +/// +/// IPv4 is all of `232.0.0.0/8` ([RFC 4607] §1). IPv6 is `ff3x::/32` or +/// `ffbx::/32` with a zero prefix length; the `ff2` and `rsvd` nibbles are +/// ignored ([RFC 7371] §4.1.1, §4.1.2; [RFC 3956] §3). +/// +/// [RFC 3956]: https://www.rfc-editor.org/rfc/rfc3956.html +/// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html +/// [RFC 7371]: https://www.rfc-editor.org/rfc/rfc7371.html +pub(crate) fn is_ssm_address(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(ipv4) => IPV4_SSM_SUBNET.contains(ipv4), + IpAddr::V6(ipv6) => ipv6_segments_are_ssm(ipv6.segments()), + } +} + +const IPV4_SOURCE_RESERVED_FIRST_OCTET: u8 = 240; + +/// Reject a source address that cannot appear in a source filter. +/// +/// A source must be unicast and routable. IPv4 excludes loopback, broadcast +/// ([RFC 919] §7), the unspecified address, link-local ([RFC 3927]), +/// `0.0.0.0/8` ([RFC 1122] §3.2.1.3), and `240.0.0.0/4` ([RFC 1112] §4). +/// IPv6 excludes loopback, the unspecified address, link-local +/// ([RFC 4291] §2.5.6), and the IPv4-mapped ([RFC 4291] §2.5.5.2) and +/// IPv4-compatible ([RFC 4291] §2.5.5.1) forms. +/// +/// [RFC 919]: https://www.rfc-editor.org/rfc/rfc919.html +/// [RFC 1112]: https://www.rfc-editor.org/rfc/rfc1112.html +/// [RFC 1122]: https://www.rfc-editor.org/rfc/rfc1122.html +/// [RFC 3927]: https://www.rfc-editor.org/rfc/rfc3927.html +/// [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html +pub(crate) fn validate_exact_source( + ip: IpAddr, +) -> Result<(), MulticastGroupCreateExternalError> { + if ip.is_multicast() { + return Err(MulticastGroupCreateExternalError::SourceNotUnicast(ip)); + } + + match ip { + IpAddr::V4(addr) => { + let reason = match addr.octets() { + [0, 0, 0, 0] => InvalidMulticastSource::Unspecified, + [127, ..] => InvalidMulticastSource::Loopback, + [255, 255, 255, 255] => InvalidMulticastSource::Broadcast, + [169, 254, ..] => InvalidMulticastSource::LinkLocal, + [0, ..] => InvalidMulticastSource::ThisNetwork, + [IPV4_SOURCE_RESERVED_FIRST_OCTET..=u8::MAX, ..] => { + InvalidMulticastSource::Reserved + } + _ => return Ok(()), + }; + Err(MulticastGroupCreateExternalError::InvalidIpv4Source { + addr, + reason, + }) + } + IpAddr::V6(addr) => { + let embedded = |form| { + Err(MulticastGroupCreateExternalError::Ipv4EmbeddedSource { + addr, + form, + }) + }; + let reason = match addr.segments() { + [0, 0, 0, 0, 0, 0, 0, 0] => InvalidMulticastSource::Unspecified, + [0, 0, 0, 0, 0, 0, 0, 1] => InvalidMulticastSource::Loopback, + [0xfe80..=0xfebf, ..] => InvalidMulticastSource::LinkLocal, + [0, 0, 0, 0, 0, 0xffff, ..] => { + return embedded(EmbeddedIpv4::Mapped); + } + [0, 0, 0, 0, 0, 0, ..] => { + return embedded(EmbeddedIpv4::Compatible); + } + _ => return Ok(()), + }; + Err(MulticastGroupCreateExternalError::InvalidIpv6Source { + addr, + reason, + }) + } + } +} + +/// A request's sources whether in ASM or SSM structure, driven by its +/// group address. +pub(crate) enum RequestSources { + /// Any-source group, with the filter already canonicalized. + Asm(SourceFilter), + /// Source-specific group, carrying the narrowed address alongside the + /// sources that [RFC 4607] requires it to have. + /// + /// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html + Ssm { group_ip: SsmMulticastIp, sources: NonEmptyExactSources }, +} + +impl RequestSources { + /// Disambiguate a create request into its any-source (ASM) or + /// source-specific (SSM) structure, validating each source against the + /// group address. + /// + /// This rejects any source whose address family differs from the group's. + /// An SSM group rejects an `Any` entry and requires at least one exact + /// source, since routers must ignore a request for an SSM destination + /// that names no source ([RFC 4607] §2); an ASM group accepts either form. + /// + /// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html + pub(crate) fn new( + group_ip: ExternalMulticastIp, + sources: Option<&[SourceEntry]>, + ) -> Result { + let group: IpAddr = group_ip.into(); + let sources = sources.unwrap_or_default(); + let has_any = sources.iter().any(|s| matches!(s, SourceEntry::Any)); + let exact = sources + .iter() + .filter_map(|source| match source { + SourceEntry::Any => None, + SourceEntry::Exact(source) => Some(*source), + }) + .collect::>(); + + if let Some(source) = + exact.iter().find(|source| source.ip().is_ipv4() != group.is_ipv4()) + { + return Err( + MulticastGroupCreateExternalError::SourceFamilyMismatch { + source_ip: source.ip(), + group, + }, + ); + } + + match (SsmMulticastIp::new(group_ip), has_any) { + (None, true) => Ok(Self::Asm(SourceFilter::Any)), + (None, false) => Ok(Self::Asm(SourceFilter::from_exact(exact))), + (Some(_), true) => Err( + MulticastGroupCreateExternalError::SsmRejectsAnySource(group), + ), + (Some(group_ip), false) => NonEmptyExactSources::new(exact) + .map(|sources| Self::Ssm { group_ip, sources }) + .ok_or(MulticastGroupCreateExternalError::SsmRequiresSources( + group, + )), + } + } +} + +impl From<&MulticastGroupCreateExternalEntry> for SourceFilter { + /// Build the stored filter from a validated create request. + fn from(entry: &MulticastGroupCreateExternalEntry) -> Self { + match entry { + MulticastGroupCreateExternalEntry::Asm(group) => { + group.sources.clone() + } + MulticastGroupCreateExternalEntry::Ssm(group) => { + Self::Exact(group.sources.clone()) + } + } + } +} + +impl SourceFilter { + /// Borrow the exact sources or `None` if the filter is unrestricted. + pub fn exact_sources(&self) -> Option<&[ExactSource]> { + match self { + Self::Any => None, + Self::Exact(sources) => Some(sources.as_slice()), + } + } + + /// Validate and canonicalize source entries against the given + /// `group_ip`. + /// + /// Each entry has previously been validated on its own address. + /// This checks only the address-family (mis-)match and the ASM/SSM + /// disambiguation. + /// + /// # Errors + /// + /// Returns an [`MulticastGroupCreateExternalError`] if a source and the + /// group IP use different address families, or if an SSM group requests + /// an `Any`, or if it does not supply an exact source at all. + pub fn from_entries( + group_ip: ExternalMulticastIp, + sources: Option<&[SourceEntry]>, + ) -> Result { + Ok(match RequestSources::new(group_ip, sources)? { + RequestSources::Asm(sources) => sources, + RequestSources::Ssm { sources, .. } => Self::Exact(sources), + }) + } + + /// Build a filter from the exact source list, collapsing an empty list + /// into `Any`. + pub fn from_exact(sources: impl IntoIterator) -> Self { + match NonEmptyExactSources::new(sources.into_iter().collect()) { + Some(sources) => Self::Exact(sources), + None => Self::Any, + } + } + + /// Iterate over source entries. + pub fn iter(&self) -> impl Iterator + '_ { + let (any, exact) = match self { + Self::Any => (Some(IpSrc::Any), [].as_slice()), + Self::Exact(sources) => (None, sources.as_slice()), + }; + any.into_iter().chain(exact.iter().copied().map(IpSrc::from)) + } + + /// Project the filter back into its serialized form. + /// + /// [`SourceFilter::Any`] emits an absent list, not an explicit + /// `[Any]` one. + /// + /// A request that mixes `Any` with exact sources does not round-trip. + pub fn to_ip_sources(&self) -> Option> { + match self { + Self::Any => None, + Self::Exact(sources) => Some( + sources.as_slice().iter().copied().map(IpSrc::from).collect(), + ), + } + } + + /// Project the source filter into the request's entry shape. + /// + /// [`SourceFilter::Any`] emits an absent list and not an explicit + /// [`SourceEntry::Any`] one, as with + /// [`SourceFilter::to_ip_sources`]. + pub fn to_entries(&self) -> Option> { + match self { + Self::Any => None, + Self::Exact(sources) => Some( + sources + .as_slice() + .iter() + .copied() + .map(SourceEntry::Exact) + .collect(), + ), + } + } +} + +impl AsmMulticastGroupCreate { + /// Return the multicast group address. + pub fn group_ip(&self) -> ExternalMulticastIp { + self.group_ip + } + + /// Return the requested tag, if present. + pub fn tag(&self) -> Option<&str> { + self.tag.as_deref() + } + + /// Return the internal forwarding configuration. + pub fn internal_forwarding(&self) -> &ExternalInternalForwarding { + &self.internal_forwarding + } + + /// Return the external forwarding configuration. + pub fn external_forwarding(&self) -> &ExternalForwarding { + &self.external_forwarding + } + + /// Return the source filter in its serialized form. + pub fn sources(&self) -> Option> { + self.sources.to_ip_sources() + } + + /// Return exact sources, or `None` for an unrestricted filter. + pub fn exact_sources(&self) -> Option<&[ExactSource]> { + self.sources.exact_sources() + } + + /// Return whether the group accepts every source address. + pub fn allows_any_source(&self) -> bool { + matches!(self.sources, SourceFilter::Any) + } +} + +impl SsmMulticastGroupCreate { + /// Return the SSM group address. + pub fn group_ip(&self) -> SsmMulticastIp { + self.group_ip + } + + /// Return the requested tag, if present. + pub fn tag(&self) -> Option<&str> { + self.tag.as_deref() + } + + /// Return the internal forwarding configuration. + pub fn internal_forwarding(&self) -> &ExternalInternalForwarding { + &self.internal_forwarding + } + + /// Return the external forwarding configuration. + pub fn external_forwarding(&self) -> &ExternalForwarding { + &self.external_forwarding + } + + /// Return the exact source addresses. + pub fn sources(&self) -> &[ExactSource] { + self.sources.as_slice() + } +} + +impl MulticastGroupCreateExternalEntry { + /// Return the multicast group address. + pub fn group_ip(&self) -> ExternalMulticastIp { + match self { + Self::Asm(group) => group.group_ip, + Self::Ssm(group) => group.group_ip.into(), + } + } + + /// Return the requested tag, if present. + pub fn tag(&self) -> Option<&str> { + match self { + Self::Asm(group) => group.tag(), + Self::Ssm(group) => group.tag(), + } + } + + /// Return the internal forwarding configuration. + pub fn internal_forwarding(&self) -> &ExternalInternalForwarding { + match self { + Self::Asm(group) => group.internal_forwarding(), + Self::Ssm(group) => group.internal_forwarding(), + } + } + + /// Return the external forwarding configuration. + pub fn external_forwarding(&self) -> &ExternalForwarding { + match self { + Self::Asm(group) => group.external_forwarding(), + Self::Ssm(group) => group.external_forwarding(), + } + } + + /// Return the source filter in its serialized form. + pub fn sources(&self) -> Option> { + match self { + Self::Asm(group) => group.sources.to_ip_sources(), + Self::Ssm(group) => { + Some(group.sources().iter().copied().map(IpSrc::from).collect()) + } + } + } +} + impl AsRef for MulticastTag { fn as_ref(&self) -> &str { &self.0 @@ -111,15 +690,15 @@ impl FromStr for MulticastTag { } impl MulticastGroupResponse { - /// Get the multicast group IP address. + /// Return the multicast group IP address. pub fn ip(&self) -> IpAddr { match self { Self::Underlay(resp) => resp.group_ip.into(), - Self::External(resp) => resp.group_ip, + Self::External(resp) => resp.group_ip.into(), } } - /// Get the tag. + /// Return the tag. pub fn tag(&self) -> &str { match self { Self::Underlay(resp) => &resp.tag, @@ -136,3 +715,839 @@ impl fmt::Display for IpSrc { } } } + +#[cfg(test)] +mod tests { + use super::*; + + use crate::v14::mcast::MulticastGroupCreateExternalEntryShadow; + + #[test] + fn tag_format() { + let parse = |tag: &str| tag.parse::(); + + // Valid tags + assert!(parse("my-tag").is_ok()); + assert!(parse("nexus").is_ok()); + assert!(parse("a1b2c3").is_ok()); + assert!(parse("tag_with_underscore").is_ok()); + assert!(parse("tag.with.periods").is_ok()); + assert!(parse("tag:with:colons").is_ok()); + assert!(parse("mixed-tag_v1.0:test").is_ok()); + + // Auto-generated tag format (uuid:ip) + assert!( + parse("550e8400-e29b-41d4-a716-446655440000:224.1.2.3").is_ok() + ); + + // Tag at exactly MAX_TAG_LENGTH characters is valid + assert!(parse(&"a".repeat(MAX_TAG_LENGTH)).is_ok()); + + // Empty tag rejected + assert!(parse("").is_err()); + + // Tag exceeding MAX_TAG_LENGTH characters rejected + assert!(parse(&"a".repeat(MAX_TAG_LENGTH + 1)).is_err()); + + // Invalid characters rejected + assert!(parse("tag with spaces").is_err()); + assert!(parse("tag/with/slashes").is_err()); + assert!(parse("tag@with@at").is_err()); + assert!(parse("tag#with#hash").is_err()); + } + + #[test] + fn accepts_ipv4_asm() { + for addr in + ["224.1.2.3", "239.255.255.250", "225.0.0.1", "231.255.255.255"] + { + let parsed = addr.parse::().unwrap(); + assert_eq!(IpAddr::from(parsed), addr.parse::().unwrap()); + } + } + + #[test] + fn accepts_ipv4_ssm() { + for addr in ["232.0.0.1", "232.1.2.3", "232.255.255.255"] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + } + + #[test] + fn accepts_admin_local_ipv6_outside_underlay_subnet() { + for addr in ["ff04:1::1", "ff04::1:0:0:0:1", "ff14::1"] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + } + + #[test] + fn rejects_non_multicast() { + for addr in ["10.0.0.1", "192.168.1.1", "2001:db8::1", "::1"] { + assert!( + matches!( + addr.parse::(), + Err(ExternalMulticastIpError::NotMulticast(_)) + ), + "{addr} should be rejected as non-multicast" + ); + } + } + + #[test] + fn accepts_forwardable_ipv6_scopes() { + for addr in [ + "ff03::1", + "ff05::1", + "ff06::1", + "ff07::1", + "ff08::1", + "ff09::1", + "ff0d::1", + "ff0e::1", + "ff0f::1", + "ff3e::4000:1", + ] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + + for addr in ["ff01::1", "ff02::1", "ff11::1", "ff32::4000:1"] { + assert!( + matches!( + addr.parse::(), + Err(ExternalMulticastIpError::LocalIpv6Scope(_)) + ), + "{addr} should be rejected as an unforwardable scope" + ); + } + + assert!( + matches!( + "ff00::1".parse::(), + Err(ExternalMulticastIpError::ReservedIpv6Scope(_)) + ), + "ff00::1 should be rejected as reserved scope 0" + ); + } + + #[test] + fn rejects_underlay_subnet() { + for addr in ["ff04::1", "ff04::ffff:ffff:ffff:ffff"] { + assert!( + matches!( + addr.parse::(), + Err(ExternalMulticastIpError::ReservedUnderlaySubnet(_)) + ), + "{addr} should be rejected as underlay" + ); + } + } + + #[test] + fn rejects_reserved_ssm_null() { + assert!( + matches!( + "232.0.0.0".parse::(), + Err(ExternalMulticastIpError::ReservedSsmNull(_)) + ), + "232.0.0.0 should be rejected as reserved" + ); + for addr in ["232.0.0.1", "232.0.0.255", "232.0.1.0"] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + } + + #[test] + fn rejects_malformed_embedded_rendezvous_point() { + for (addr, expected) in [ + ("ff5e:140:2001:db8::1", RP_REQUIRES_PREFIX_BASED), + ("ff6e:140:2001:db8::1", RP_REQUIRES_TRANSIENT), + ("ff7e:100:2001:db8::1", RP_PLEN_OUT_OF_RANGE), + ("ff7e:180:2001:db8::1", RP_PLEN_OUT_OF_RANGE), + ("ff7e:40:2001:db8::1", RP_RIID_ZERO), + ("ff7e:140::1", RP_UNUSABLE_ADDRESS), + ("ff7e:140:fe80::1", RP_UNUSABLE_ADDRESS), + ("ff7e:140:ff00::1", RP_UNUSABLE_ADDRESS), + ] { + match addr.parse::() { + Err(ExternalMulticastIpError::MalformedEmbeddedRp { + reason, + .. + }) => assert_eq!( + reason, expected, + "{addr} rejected for the wrong reason" + ), + other => panic!( + "{addr} should be rejected as malformed embedded-RP, \ + got {other:?}" + ), + } + } + } + + #[test] + fn accepts_embedded_rendezvous_point_as_asm() { + for addr in [ + "ff7e:140:2001:db8::1", + "ff7e:120:2001:db8::1", + "ff7e:f140:2001:db8:beef:feed:0:1234", + "ff7e:140:2001:db8:beef:feed:0:1234", + ] { + let parsed = addr + .parse::() + .unwrap_or_else(|e| panic!("{addr} should be accepted: {e}")); + assert!(!parsed.is_ssm(), "{addr} should not be SSM"); + } + } + + #[test] + fn derives_embedded_rendezvous_point_address() { + for (group, expected) in [ + ("ff7e:140:2001:db8:beef:feed::", "2001:db8:beef:feed::1"), + ("ff7e:120:2001:db8::", "2001:db8::1"), + ("ff7e:120:2001:db8:dead::", "2001:db8::1"), + ("ff7e:130:2001:db8:beef::", "2001:db8:beef::1"), + ("ff7e:128:2001:db8:beef::", "2001:db8:be00::1"), + ] { + let segs = group.parse::().unwrap().segments(); + let plen = segs[1] & IPV6_PREFIX_BASED_PLEN_MASK; + let riid = (segs[1] >> IPV6_RIID_SHIFT) & IPV6_RIID_MASK; + + assert_eq!( + embedded_rendezvous_point_address(segs, plen, riid), + expected.parse::().unwrap(), + "{group} should derive RP {expected}" + ); + } + } + + #[test] + fn detects_ssm_addresses() { + for addr in [ + "232.0.0.1", + "232.255.255.255", + "ff3e::4000:1", + "ff35::4000:1", + "ffbe::4000:1", + "ff3e:f000::4000:1", + "ff3e:100::4000:1", + ] { + assert!( + addr.parse::().unwrap().is_ssm(), + "{addr} should be SSM" + ); + } + for addr in [ + "224.1.2.3", + "231.0.0.1", + "233.0.0.0", + "ff0e::1", + "ff05::1", + "ffbe:20::4000:1", + "ff3e:20::4000:1", + ] { + assert!( + !addr.parse::().unwrap().is_ssm(), + "{addr} should not be SSM" + ); + } + } + + #[test] + fn rejects_invalid_ipv6_ssm_group_ids() { + for addr in ["ff3e::4000:0", "ff3e:f000::4000:0"] { + assert!( + matches!( + addr.parse::(), + Err(ExternalMulticastIpError::InvalidIpv6Ssm(_)) + ), + "{addr} should be rejected as invalid SSM" + ); + } + } + + #[test] + fn accepts_valid_ipv6_ssm_group_ids() { + for addr in [ + "ff3e::1", + "ff3e::1234", + "ff3e::3fff:ffff", + "ff3e::4000:1", + "ff3e::7fff:ffff", + "ff3e::8000:0", + "ff3e::ffff:ffff", + "ff35::4000:1", + "ff3e:0:1234::f000:1", + "ff3e:f000:1234::1", + "ff3e:0:1234::4000:0", + ] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + } + + #[test] + fn accepts_rfc3306_unicast_prefix_ipv6_as_asm() { + let parsed: ExternalMulticastIp = "ff3e:20:1234::1".parse().unwrap(); + assert!(!parsed.is_ssm()); + } + + fn internal_forwarding() -> ExternalInternalForwarding { + serde_json::from_value( + serde_json::json!({ "nat_target": nat_target_json() }), + ) + .unwrap() + } + + fn nat_target_json() -> serde_json::Value { + serde_json::json!({ + "internal_ip": "ff04::1", + "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, + "vni": 100, + }) + } + + #[test] + fn nat_target_validates_on_deserialize() { + let forwarding = + |nat_target| serde_json::json!({ "nat_target": nat_target }); + + assert!( + serde_json::from_value::(forwarding( + nat_target_json() + ),) + .is_ok() + ); + assert!( + serde_json::from_value::(forwarding( + serde_json::Value::Null + ),) + .is_err() + ); + + let mut unicast_mac = nat_target_json(); + unicast_mac["inner_mac"] = + serde_json::json!({ "a": [0, 0, 94, 0, 0, 1] }); + assert!( + serde_json::from_value::(forwarding( + unicast_mac + ),) + .is_err() + ); + + let mut outside_underlay = nat_target_json(); + outside_underlay["internal_ip"] = serde_json::json!("ff04:0:0:1::1"); + assert!( + serde_json::from_value::(forwarding( + outside_underlay + ),) + .is_err() + ); + } + + fn entry( + group_ip: &str, + sources: Option>, + ) -> Result< + MulticastGroupCreateExternalEntry, + MulticastGroupCreateExternalError, + > { + let sources = sources + .map(|sources| { + sources.into_iter().map(SourceEntry::try_from).collect() + }) + .transpose()?; + + MulticastGroupCreateExternalEntry::try_from( + MulticastGroupCreateExternalEntryShadow { + group_ip: group_ip.parse().unwrap(), + tag: None, + internal_forwarding: internal_forwarding(), + external_forwarding: ExternalForwarding { vlan_id: None }, + sources, + }, + ) + } + + fn exact(addr: &str) -> IpSrc { + IpSrc::Exact(addr.parse().unwrap()) + } + + #[test] + fn ssm_requires_sources() { + for sources in [None, Some(vec![])] { + for group in ["232.1.2.3", "ff3e::4000:1", "ff35::4000:1"] { + assert!(matches!( + entry(group, sources.clone()), + Err(MulticastGroupCreateExternalError::SsmRequiresSources( + _ + )) + )); + } + } + } + + #[test] + fn ssm_rejects_any_source() { + for (group, source) in + [("232.1.2.3", "10.1.1.1"), ("ff35::4000:1", "2001:db8::1")] + { + for sources in [vec![IpSrc::Any], vec![exact(source), IpSrc::Any]] { + assert!(matches!( + entry(group, Some(sources)), + Err( + MulticastGroupCreateExternalError::SsmRejectsAnySource( + _ + ) + ) + )); + } + } + } + + #[test] + fn ssm_accepts_exact_sources() { + let group = entry("232.1.2.3", Some(vec![exact("10.1.1.1")])).unwrap(); + let MulticastGroupCreateExternalEntry::Ssm(ssm) = group else { + panic!("232.1.2.3 should classify as SSM"); + }; + assert_eq!( + ssm.sources().iter().map(ExactSource::ip).collect::>(), + vec!["10.1.1.1".parse::().unwrap()] + ); + assert_eq!( + IpAddr::from(ssm.group_ip()), + "232.1.2.3".parse::().unwrap() + ); + + for group in ["ff3e::4000:1", "ff35::4000:1"] { + assert!(matches!( + entry(group, Some(vec![exact("2001:db8::1")])), + Ok(MulticastGroupCreateExternalEntry::Ssm(_)) + )); + } + } + + #[test] + fn asm_accepts_any_source_and_no_sources() { + for sources in [ + None, + Some(vec![]), + Some(vec![IpSrc::Any]), + Some(vec![exact("10.1.1.1"), IpSrc::Any]), + ] { + let group = entry("224.1.2.3", sources.clone()).unwrap(); + assert!( + matches!(group, MulticastGroupCreateExternalEntry::Asm(_)), + "224.1.2.3 with {sources:?} should classify as ASM" + ); + assert_eq!(group.sources(), None); + } + + assert!(matches!( + entry("ff0e::1", Some(vec![IpSrc::Any])), + Ok(MulticastGroupCreateExternalEntry::Asm(_)) + )); + } + + #[test] + fn rejects_source_family_mismatch() { + for (group_ip, source) in [ + ("232.1.2.3", "2001:db8::1"), + ("ff3e::4000:1", "10.1.1.1"), + ("224.1.2.3", "2001:db8::1"), + ("ff0e::1", "10.1.1.1"), + ] { + assert!( + matches!( + entry(group_ip, Some(vec![exact(source)])), + Err( + MulticastGroupCreateExternalError::SourceFamilyMismatch { .. } + ) + ), + "{source} for {group_ip} should be rejected" + ); + } + } + + #[test] + fn rejects_invalid_exact_sources() { + assert!(matches!( + entry("224.1.2.3", Some(vec![exact("224.9.9.9")])), + Err(MulticastGroupCreateExternalError::SourceNotUnicast(_)) + )); + assert!(matches!( + entry("ff0e::1", Some(vec![exact("ff0e::2")])), + Err(MulticastGroupCreateExternalError::SourceNotUnicast(_)) + )); + + for (source, expected) in [ + ("127.0.0.1", InvalidMulticastSource::Loopback), + ("255.255.255.255", InvalidMulticastSource::Broadcast), + ("0.0.0.0", InvalidMulticastSource::Unspecified), + ("0.1.2.3", InvalidMulticastSource::ThisNetwork), + ("169.254.10.1", InvalidMulticastSource::LinkLocal), + ("240.0.0.1", InvalidMulticastSource::Reserved), + ("255.255.255.254", InvalidMulticastSource::Reserved), + ] { + assert!( + matches!( + entry("232.1.2.3", Some(vec![exact(source)])), + Err(MulticastGroupCreateExternalError::InvalidIpv4Source { + reason, + .. + }) if reason == expected + ), + "{source} should be rejected as {expected:?}" + ); + } + + for (source, expected) in [ + ("::", InvalidMulticastSource::Unspecified), + ("::1", InvalidMulticastSource::Loopback), + ("fe80::1", InvalidMulticastSource::LinkLocal), + ("febf:ffff::1", InvalidMulticastSource::LinkLocal), + ] { + assert!( + matches!( + entry("ff0e::1", Some(vec![exact(source)])), + Err(MulticastGroupCreateExternalError::InvalidIpv6Source { + reason, + .. + }) if reason == expected + ), + "{source} should be rejected as {expected:?}" + ); + } + + for source in ["100.64.0.1", "223.255.255.255"] { + assert!( + entry("232.1.2.3", Some(vec![exact(source)])).is_ok(), + "{source} should be accepted as a source" + ); + } + + for (source, expected) in [ + ("::ffff:192.0.2.1", EmbeddedIpv4::Mapped), + ("::192.0.2.1", EmbeddedIpv4::Compatible), + ] { + assert!( + matches!( + entry("ff0e::1", Some(vec![exact(source)])), + Err(MulticastGroupCreateExternalError::Ipv4EmbeddedSource { + form, + .. + }) if form == expected + ), + "{source} should be rejected as {expected:?}" + ); + } + } + + #[test] + fn deserialize_enforces_joint_validation() { + let body = |group_ip: &str, sources: serde_json::Value| { + serde_json::json!({ + "group_ip": group_ip, + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": sources, + }) + }; + + assert!( + serde_json::from_value::(body( + "232.1.2.3", + serde_json::Value::Null, + )) + .is_err() + ); + + assert!(matches!( + serde_json::from_value::(body( + "232.1.2.3", + serde_json::json!([{ "Exact": "10.1.1.1" }]), + )) + .unwrap(), + MulticastGroupCreateExternalEntry::Ssm(_) + )); + + assert!(matches!( + serde_json::from_value::(body( + "224.1.2.3", + serde_json::Value::Null, + )) + .unwrap(), + MulticastGroupCreateExternalEntry::Asm(_) + )); + } + + #[test] + fn serde_round_trips_flat_request_form() { + for (body, expected) in [ + ( + serde_json::json!({ + "group_ip": "224.1.2.3", + "tag": "asm-tag", + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": 10 }, + "sources": ["Any"], + }), + serde_json::json!({ + "group_ip": "224.1.2.3", + "tag": "asm-tag", + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + }), + ), + ( + serde_json::json!({ + "group_ip": "ff0e::1", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + }), + serde_json::json!({ + "group_ip": "ff0e::1", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + }), + ), + ( + serde_json::json!({ + "group_ip": "232.1.2.3", + "tag": "ssm-tag", + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": [ + { "Exact": "10.1.1.1" }, + { "Exact": "10.1.1.2" }, + ], + }), + serde_json::json!({ + "group_ip": "232.1.2.3", + "tag": "ssm-tag", + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": [ + { "Exact": "10.1.1.1" }, + { "Exact": "10.1.1.2" }, + ], + }), + ), + ( + serde_json::json!({ + "group_ip": "ff3e::4000:1", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": 4094 }, + "sources": [{ "Exact": "2001:db8::1" }], + }), + serde_json::json!({ + "group_ip": "ff3e::4000:1", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": 4094 }, + "sources": [{ "Exact": "2001:db8::1" }], + }), + ), + ] { + let parsed = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(body) + .unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), expected); + } + } + + #[test] + fn rejects_malformed_rfc3306_prefix_based() { + for addr in ["ff2e:20:2001:db8::1", "ff2e::1"] { + assert!( + matches!( + addr.parse::(), + Err(ExternalMulticastIpError::MalformedPrefixBased { .. }) + ), + "{addr} should be rejected as malformed prefix-based" + ); + } + } + + #[test] + fn accepts_prefix_based_ipv6_as_asm() { + for addr in [ + "ff3e:40::1", + "ff3e:80::1", + "ff3e:f020::1", + "ff3e:ff20::1", + "ff3e:140:2001:db8::1", + "ff3e:40:2001:db8::1", + "ff7e:140:2001:db8:beef:feed:0:1234", + "ff7e:f140:2001:db8:beef:feed:0:1234", + "fffe:f40:2001:db8:beef:feed:0:1234", + ] { + assert!( + matches!( + entry(addr, None), + Ok(MulticastGroupCreateExternalEntry::Asm(_)) + ), + "{addr} should be accepted as ASM" + ); + } + } + + #[test] + fn accepts_transient_flagged_ipv6_as_asm() { + assert!(matches!( + entry("ff1e::1", None), + Ok(MulticastGroupCreateExternalEntry::Asm(_)) + )); + } + + #[test] + fn accepts_ipv4_local_network_control_block() { + for addr in [ + "224.0.0.1", + "224.0.0.22", + "224.0.0.251", + "224.0.0.255", + "224.0.1.0", + ] { + let parsed = addr.parse::().unwrap(); + assert_eq!(IpAddr::from(parsed), addr.parse::().unwrap()); + } + } + + #[test] + fn rejects_only_reserved_base_addresses() { + for addr in ["224.0.0.0", "ff03::", "ff05::", "ff0e::", "ff0f::"] { + assert!( + matches!( + addr.parse::(), + Err(ExternalMulticastIpError::ReservedBaseAddress(_)) + ), + "{addr} should be rejected as a reserved base address" + ); + } + for addr in ["224.0.0.1", "ff05::1", "ff0e::1", "ff1e::"] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + } + + #[test] + fn source_validation_precedes_family_mismatch() { + assert!(matches!( + entry("232.1.2.3", Some(vec![exact("::1")])), + Err(MulticastGroupCreateExternalError::InvalidIpv6Source { + reason: InvalidMulticastSource::Loopback, + .. + }) + )); + } + + #[test] + fn deduplicates_exact_sources() { + let asm_group = entry( + "224.1.2.3", + Some(vec![exact("10.1.1.1"), exact("10.1.1.1")]), + ) + .unwrap(); + let MulticastGroupCreateExternalEntry::Asm(asm) = asm_group else { + panic!("224.1.2.3 should classify as ASM"); + }; + assert_eq!(asm.exact_sources().unwrap().len(), 1); + + let ssm_group = entry( + "232.1.2.3", + Some(vec![exact("10.1.1.1"), exact("10.1.1.1")]), + ) + .unwrap(); + let MulticastGroupCreateExternalEntry::Ssm(ssm) = ssm_group else { + panic!("232.1.2.3 should classify as SSM"); + }; + assert_eq!(ssm.sources().len(), 1); + } + + #[test] + fn mixed_any_and_exact_sources_serialize_to_null() { + let parsed = + serde_json::from_value::( + serde_json::json!({ + "group_ip": "224.1.2.3", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": ["Any", { "Exact": "1.2.3.4" }], + }), + ) + .unwrap(); + + assert_eq!( + serde_json::to_value(parsed).unwrap(), + serde_json::json!({ + "group_ip": "224.1.2.3", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + }) + ); + } + + #[test] + fn external_multicast_ip_serde_uses_plain_string() { + let parsed = + serde_json::from_str::("\"239.1.1.1\"") + .unwrap(); + assert_eq!( + IpAddr::from(parsed), + "239.1.1.1".parse::().unwrap() + ); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"239.1.1.1\""); + + assert!( + serde_json::from_str::("\"10.0.0.1\"") + .is_err() + ); + assert!( + serde_json::from_str::("\"ff04::1\"").is_err() + ); + } + + #[test] + fn underlay_multicast_ipv6_validates_subnet() { + let parsed: UnderlayMulticastIpv6 = "ff04::1".parse().unwrap(); + assert_eq!(IpAddr::from(parsed), "ff04::1".parse::().unwrap()); + + for addr in ["ff04:0:0:1::1", "ff05::1", "2001:db8::1"] { + assert!( + matches!( + UnderlayMulticastIpv6::new(addr.parse().unwrap()), + Err(Error::InvalidUnderlayMulticastIp(_)) + ), + "{addr} should be rejected as outside ff04::/64" + ); + } + } +} diff --git a/dpd-types/versions/src/initial/route.rs b/dpd-types/versions/src/initial/route.rs index 472ddb02..d7a3d51f 100644 --- a/dpd-types/versions/src/initial/route.rs +++ b/dpd-types/versions/src/initial/route.rs @@ -108,6 +108,8 @@ pub struct Ipv6RouteToken { } /// An object with IPv4 route settings used in concert with [`PortSettings`]. +/// +/// [`PortSettings`]: super::port::PortSettings #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct RouteSettingsV4 { pub link_id: u8, @@ -115,6 +117,8 @@ pub struct RouteSettingsV4 { } /// An object with IPV6 route settings used in concert with [`PortSettings`]. +/// +/// [`PortSettings`]: super::port::PortSettings #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct RouteSettingsV6 { pub link_id: u8, diff --git a/dpd-types/versions/src/latest.rs b/dpd-types/versions/src/latest.rs index 3b56eb39..786e916a 100644 --- a/dpd-types/versions/src/latest.rs +++ b/dpd-types/versions/src/latest.rs @@ -74,21 +74,39 @@ pub mod mcast { pub use crate::v1::mcast::MulticastGroupMember; pub use crate::v7::mcast::IpSrc; - pub use crate::v7::mcast::MulticastGroupCreateExternalEntry; pub use crate::v8::mcast::Error; pub use crate::v8::mcast::MulticastGroupCreateUnderlayEntry; - pub use crate::v8::mcast::MulticastGroupExternalResponse; - pub use crate::v8::mcast::MulticastGroupResponse; pub use crate::v8::mcast::MulticastGroupTagQuery; pub use crate::v8::mcast::MulticastGroupUnderlayResponse; - pub use crate::v8::mcast::MulticastGroupUpdateExternalEntry; pub use crate::v8::mcast::MulticastGroupUpdateUnderlayEntry; pub use crate::v8::mcast::MulticastTag; pub use crate::v8::mcast::MulticastTagPath; pub use crate::v8::mcast::MulticastUnderlayGroupIpParam; pub use crate::v8::mcast::UnderlayMulticastIpv6; + pub use crate::v14::mcast::AsmMulticastGroupCreate; + pub use crate::v14::mcast::EmbeddedIpv4; + pub use crate::v14::mcast::ExactSource; + pub use crate::v14::mcast::ExternalInternalForwarding; + pub use crate::v14::mcast::ExternalInternalForwardingError; + pub use crate::v14::mcast::ExternalMulticastIp; + pub use crate::v14::mcast::ExternalMulticastIpError; + pub use crate::v14::mcast::ExternalNatTarget; + pub use crate::v14::mcast::ExternalNatTargetError; + pub use crate::v14::mcast::InvalidMulticastSource; + pub use crate::v14::mcast::MulticastExternalGroupIpParam; + pub use crate::v14::mcast::MulticastGroupCreateExternalEntry; + pub use crate::v14::mcast::MulticastGroupCreateExternalError; + pub use crate::v14::mcast::MulticastGroupExternalResponse; + pub use crate::v14::mcast::MulticastGroupResponse; + pub use crate::v14::mcast::MulticastGroupUpdateExternalEntry; + pub use crate::v14::mcast::NonEmptyExactSources; + pub use crate::v14::mcast::SourceEntry; + pub use crate::v14::mcast::SourceFilter; + pub use crate::v14::mcast::SsmMulticastGroupCreate; + pub use crate::v14::mcast::SsmMulticastIp; + pub use crate::impls::mcast::MAX_TAG_LENGTH; pub use crate::impls::mcast::MulticastTagParseError; } diff --git a/dpd-types/versions/src/lib.rs b/dpd-types/versions/src/lib.rs index 617a58c7..886dd966 100644 --- a/dpd-types/versions/src/lib.rs +++ b/dpd-types/versions/src/lib.rs @@ -43,6 +43,8 @@ pub mod v11; pub mod v12; #[path = "allow_ddm_traffic/mod.rs"] pub mod v13; +#[path = "mcast_external_scope/mod.rs"] +pub mod v14; #[path = "attached_subnets/mod.rs"] pub mod v3; #[path = "v4_over_v6_routes/mod.rs"] diff --git a/dpd-types/versions/src/mcast_external_scope/mcast.rs b/dpd-types/versions/src/mcast_external_scope/mcast.rs new file mode 100644 index 00000000..caaeaaa8 --- /dev/null +++ b/dpd-types/versions/src/mcast_external_scope/mcast.rs @@ -0,0 +1,1193 @@ +// 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 types added in API version `MCAST_EXTERNAL_SCOPE`. +//! +//! The incoming request format remains flat. The deserialization boundary +//! is where we can classify the request as any-source multicast (ASM) or +//! source-specific multicast (SSM). +//! +//! Validation of an external group address is limited to what the RFCs +//! require, with one Oxide exception: `ff04::/64` is our underlay subnet +//! and belongs to the internal multicast API. We reject addresses on this +//! subnet, even though the rest of admin-local `ff04::/16` is permitted. +//! +//! All IPv6 scopes are admitted except for the reserved scope of `0x0` and +//! the interface-local `0x1` and link-local `0x2` ones, which a router must +//! not forward past ([RFC 4291] §2.7). Scope `0xf` is retained because +//! [RFC 4291] §2.7 treats it as global when it is sent or received. IPv4 admits +//! the whole multicast range apart from the base address of `224.0.0.0`, +//! which [RFC 1112] §4 guarantees is never assigned to a group, and the +//! SSM null address of `232.0.0.0` ([RFC 4607] §4.3). +//! +//! The four flag bits above the scope nibble are `|X|R|P|T|` ([RFC 7371] +//! §4.2.1): T marks a transient address, P a prefix-based one, R an embedded +//! rendezvous point, and X may be either zero or one. [RFC 7371] §4.1.1 +//! names the same field `|X|Y|P|T|`, where Y is the bit that [RFC 3956] +//! defines as the R flag. +//! +//! Prefix-based addresses require only that the P flag implies the T flag +//! ([RFC 7371] §4.1.1, which replaces that passage of [RFC 3306] §4). The +//! replacement drops the "reserved field MUST be zero" rule: the `ff2` and +//! `rsvd` nibbles are sent as zero and ignored on receipt instead, per +//! [RFC 7371] §4.1.1 and [RFC 3956] §3, so neither is validated here. The +//! prefix length is otherwise unbounded, since the `plen` ceiling of 64 +//! comes from [RFC 3956] §4 and applies to embedded rendezvous point (RP) +//! addresses only. [RFC 3306] §4 notes the same 64-bit ceiling on the +//! network prefix without requiring it. +//! +//! Embedded RP addresses require the R, P, and T flags ([RFC 7371] §4.2.2 +//! and §4.2.3), a prefix length from 1 through 64, a nonzero RP interface ID +//! ([RFC 3956] §6.3), and a derived RP address outside `::/16`, link-local +//! space, and the multicast range. [RFC 3956] §4 states those exclusions +//! as requirements; §10 repeats them as a security consideration. +//! +//! Classifying SSM addresses follows [RFC 4607] and [RFC 7371]. IPv4 SSM uses +//! `232.0.0.0/8`. IPv6 SSM covers `ff3x::/32` and `ffbx::/32`, the latter +//! with the X flag set. Both IPv6 ranges require R=0, P=T=1, and a zero +//! prefix length. The `ff2` and `rsvd` nibbles lie between `scop` and +//! `plen` and are ignored on receipt ([RFC 7371] §4.1.1, [RFC 3956] §3). +//! This implementation masks both nibbles off before testing for SSM-range +//! membership, and an address that sets either of them is still considered +//! an SSM one. +//! +//! The only SSM address rejected is the reserved null address of +//! `ff3x::4000:0` from [RFC 4607] §4.3. The network prefix is unchecked +//! otherwise: [RFC 3306] §6 applies to the node that's forming the address, +//! and [RFC 4607] §1 has a system treat all of the `ff3x::/32` block as SSM. +//! Group IDs below `4000:0` are assigned with flags P=T=0 ([RFC 3307] §4.1), +//! making them invalid SSM addresses that routers may drop ([RFC 4607] §1). +//! The band just above it, `4000:0001` through `7fff:ffff`, is reserved for +//! IANA allocation ([RFC 4607] §4.3, [RFC 3307] §4.2) rather than forbidden +//! as a destination. From `8000:0` up is the dynamic range of [RFC 3307] +//! §4.3, which [RFC 10028] §3 partitions; host SSM allocation sits at +//! `f000:0` through `fcff:ffff`. All of these are accepted. +//! +//! Permanently assigned addresses (T=0) with the P and R flags cleared are +//! permitted. They face the same `ff04::/64` underlay reservation as any +//! other address, and the all-zero group ID `ff0x::` is refused outright, +//! since [RFC 4291] §2.7.1 reserves it and never assigns it. A registered +//! meaning is independent of scope ([RFC 4291] §2.7), so `ff0e::101` and +//! `ff05::fb` keep those meanings in the overlay. +//! +//! [RFC 1112]: https://www.rfc-editor.org/rfc/rfc1112.html +//! [RFC 3306]: https://www.rfc-editor.org/rfc/rfc3306.html +//! [RFC 3307]: https://www.rfc-editor.org/rfc/rfc3307.html +//! [RFC 3956]: https://www.rfc-editor.org/rfc/rfc3956.html +//! [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html +//! [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html +//! [RFC 7371]: https://www.rfc-editor.org/rfc/rfc7371.html +//! [RFC 10028]: https://www.rfc-editor.org/rfc/rfc10028.html + +use std::collections::BTreeSet; +use std::fmt; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::impls::mcast::{ + RequestSources, is_ssm_address, validate_exact_source, + validate_external_multicast_ip, +}; +use crate::v1; +use crate::v1::mcast::{ + ExternalForwarding, InternalForwarding, MulticastGroupId, +}; +use crate::v1::network::{MacAddr, NatTarget, Vni}; +use crate::v7; +use crate::v7::mcast::IpSrc; +use crate::v8; +use crate::v8::mcast::{MulticastGroupUnderlayResponse, UnderlayMulticastIpv6}; + +/// A validated IP address for a customer-visible overlay multicast group. +/// +/// JSON uses the ordinary IP-address string form. Deserialization applies +/// the module's validation rules before the request reaches an endpoint +/// handler. +#[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Deserialize, + Serialize, + JsonSchema, +)] +#[serde(try_from = "IpAddr", into = "IpAddr")] +pub struct ExternalMulticastIp(IpAddr); + +impl TryFrom for ExternalMulticastIp { + type Error = ExternalMulticastIpError; + + fn try_from(addr: IpAddr) -> Result { + Self::new(addr) + } +} + +impl From for IpAddr { + fn from(addr: ExternalMulticastIp) -> Self { + addr.0 + } +} + +impl ExternalMulticastIp { + /// Create a new `ExternalMulticastIp` if the address is valid for an + /// external multicast group. + /// + /// # Errors + /// + /// Returns [`ExternalMulticastIpError`] if `addr` cannot identify an + /// external multicast group. + /// + /// # Examples + /// + /// ``` + /// use std::net::IpAddr; + /// use dpd_types_versions::latest::mcast::ExternalMulticastIp; + /// + /// let global: IpAddr = "ff0e::1".parse().unwrap(); + /// assert!(ExternalMulticastIp::new(global).is_ok()); + /// + /// let underlay: IpAddr = "ff04::1".parse().unwrap(); + /// assert!(ExternalMulticastIp::new(underlay).is_err()); + /// ``` + pub fn new(addr: IpAddr) -> Result { + validate_external_multicast_ip(addr)?; + Ok(Self(addr)) + } + + /// Check whether this address is a Source-Specific Multicast address. + pub fn is_ssm(&self) -> bool { + is_ssm_address(self.0) + } +} + +impl fmt::Display for ExternalMulticastIp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// Errors returned when parsing or validating an [`ExternalMulticastIp`]. +#[derive(Clone, Debug, thiserror::Error)] +pub enum ExternalMulticastIpError { + /// The address is not a multicast address. + #[error("Address {0} is not a multicast address")] + NotMulticast(IpAddr), + + /// The IPv6 multicast scope value zero is reserved. + #[error("Address {0} uses reserved IPv6 multicast scope 0")] + ReservedIpv6Scope(IpAddr), + + /// The IPv6 multicast scope is interface-local or link-local, which a + /// router must not forward beyond (RFC 4291 §2.7). + #[error( + "Address {0} has interface-local or link-local IPv6 multicast scope, \ + which routers must not forward beyond (RFC 4291 §2.7)" + )] + LocalIpv6Scope(IpAddr), + + /// The address is in the reserved `ff04::/64` underlay subnet for internal + /// routing. + #[error( + "Address {0} is in the reserved underlay multicast subnet (ff04::/64, \ + within admin-local scope ff04::/16) and must be created via the \ + internal multicast API" + )] + ReservedUnderlaySubnet(IpAddr), + + /// The address is reserved and never assigned to any multicast group: + /// `224.0.0.0` (RFC 1112 §4) or `ff0x::` (RFC 4291 §2.7.1). + #[error( + "Address {0} is reserved and never assigned to a multicast group \ + (RFC 1112 §4, RFC 4291 §2.7.1)" + )] + ReservedBaseAddress(IpAddr), + + /// The IPv4 SSM null address `232.0.0.0` must not be used as a + /// destination. + #[error( + "Address {0} is the reserved IPv4 SSM null address and must not be \ + used as a destination (RFC 4607 §4.3)" + )] + ReservedSsmNull(IpAddr), + + /// The IPv6 SSM address is the reserved null address of `ff3x::4000:0`. + #[error( + "Address {0} is the reserved IPv6 SSM null address ff3x::4000:0 and \ + must not be used as a destination (RFC 4607 §4.3)" + )] + InvalidIpv6Ssm(IpAddr), + + /// The prefix-based P flag is set without the T flag (RFC 7371 §4.1.1, + /// amending RFC 3306 §4). + #[error( + "Address {addr} sets the P flag, but RFC 7371 §4.1.1 requires the T \ + flag to be set as well" + )] + MalformedPrefixBased { + /// The rejected group address. + addr: IpAddr, + }, + + /// The embedded-Rendezvous (RP) R flag is set with an invalid flag + /// combination, prefix length, RP interface ID, or a derived RP address + /// (RFC 7371 §4.2.2, §4.2.3; RFC 3956 §4, §6.3, §10). + #[error( + "Address {addr} sets the R flag, but {reason} (RFC 3956, RFC 7371)" + )] + MalformedEmbeddedRp { + /// The rejected group address. + addr: IpAddr, + /// The embedded-RP rule that failed. + reason: &'static str, + }, + + /// The given string does not parse as an IP address. + #[error("Invalid IP address '{0}': {1}")] + InvalidIpAddress(String, std::net::AddrParseError), +} + +/// Errors returned when validating an external multicast group creation +/// request. +#[derive(Clone, Debug, thiserror::Error)] +pub enum MulticastGroupCreateExternalError { + /// The group IP failed external multicast address validation, returning an + /// [`ExternalMulticastIpError`]. + #[error(transparent)] + InvalidGroupIp(#[from] ExternalMulticastIpError), + + /// An exact source address supplied in the request uses a different + /// address family than the given group IP address. + #[error( + "Source IP {source_ip} does not match multicast group address family \ + ({group})" + )] + SourceFamilyMismatch { + /// The rejected source address. + source_ip: IpAddr, + /// The group address whose family it must match. + group: IpAddr, + }, + + /// An exact source address supplied in the request is a multicast address + /// (not a unicast one). + #[error( + "Source IP {0} must be a unicast address (multicast addresses are not \ + allowed)" + )] + SourceNotUnicast(IpAddr), + + /// An exact IPv4 source address supplied in the request is invalid if it + /// is loopback, broadcast, unspecified, link-local, in the "this host on + /// this network" block `0.0.0.0/8`, or in the class E block `240.0.0.0/4`. + /// [`InvalidMulticastSource`] names the rule that failed. + #[error("Source IP {addr} {reason}, which is not a valid source address")] + InvalidIpv4Source { + /// The rejected source address. + addr: Ipv4Addr, + /// The source rule that failed. + reason: InvalidMulticastSource, + }, + + /// An exact IPv6 source address supplied in the request is invalid if it + /// is loopback, unspecified, or link-local. [`InvalidMulticastSource`] names + /// the rule that failed. + #[error("Source IP {addr} {reason}, which is not a valid source address")] + InvalidIpv6Source { + /// The rejected source address. + addr: Ipv6Addr, + /// The source rule that failed. + reason: InvalidMulticastSource, + }, + + /// An exact IPv6 source address supplied in the request is invalid if it + /// uses an IPv4-mapped (RFC 4291 §2.5.5.2) or IPv4-compatible (RFC 4291 + /// §2.5.5.1) representation. [`EmbeddedIpv4`] names the form. + #[error( + "Source IP {addr} embeds an IPv4 address, {form}, which is not a \ + valid IPv6 source address" + )] + Ipv4EmbeddedSource { + /// The rejected source address. + addr: Ipv6Addr, + /// The embedded IPv4 form it uses. + form: EmbeddedIpv4, + }, + + /// An SSM group requires at least one exact source. + #[error( + "Group IP {0} is a Source-Specific Multicast address and requires at \ + least one source to be defined" + )] + SsmRequiresSources(IpAddr), + + /// An SSM group cannot use `IpSrc::Any` (only ASM groups can). + #[error( + "Group IP {0} is a Source-Specific Multicast address and requires \ + specific sources (IpSrc::Any is not allowed)" + )] + SsmRejectsAnySource(IpAddr), + + /// The internal forwarding config is missing or invalid. + #[error(transparent)] + InvalidInternalForwarding(#[from] ExternalInternalForwardingError), + + /// The given string does not parse as an IP address. + #[error("Invalid source IP address '{0}': {1}")] + InvalidSourceIpAddress(String, std::net::AddrParseError), +} + +/// The reason an address cannot serve as a multicast (S,G) source. +/// +/// The rules are validated in variant order; an address caught by more +/// than one invalid rule only reports the first hit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InvalidMulticastSource { + /// `0.0.0.0` or `::`. + Unspecified, + + /// `127.0.0.0/8` or `::1`. + Loopback, + + /// `255.255.255.255`, the limited broadcast address that [RFC 919 §7] + /// dictates must not be forwarded. IPv6 has no broadcast address, making + /// this IPv4 only. + /// + /// [RFC 919 §7]: https://www.rfc-editor.org/rfc/rfc919#section-7 + Broadcast, + + /// `169.254.0.0/16` ([RFC 3927]) or `fe80::/10` ([RFC 4291 §2.5.6]). + /// + /// [RFC 3927]: https://www.rfc-editor.org/rfc/rfc3927 + /// [RFC 4291 §2.5.6]: https://www.rfc-editor.org/rfc/rfc4291#section-2.5.6 + LinkLocal, + + /// `0.0.0.0/8`. [RFC 1122 §3.2.1.3] permits it as a source before a + /// host learns its own address, but reverse-path forwarding cannot + /// resolve it to an incoming interface. + /// + /// [RFC 1122 §3.2.1.3]: https://www.rfc-editor.org/rfc/rfc1122#section-3.2.1.3 + ThisNetwork, + + /// The class E block `240.0.0.0/4`, which the IANA special-purpose + /// registry ([RFC 6890]) marks "Source: False". + /// + /// [RFC 6890]: https://www.rfc-editor.org/rfc/rfc6890 + Reserved, +} + +impl fmt::Display for InvalidMulticastSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unspecified => write!(f, "is the unspecified address"), + Self::Loopback => write!(f, "is a loopback address"), + Self::Broadcast => write!(f, "is the broadcast address"), + Self::LinkLocal => write!(f, "is a link-local address"), + Self::ThisNetwork => { + write!(f, "is in 0.0.0.0/8 (this host on this network)") + } + Self::Reserved => { + write!(f, "is in the reserved class E block (240.0.0.0/4)") + } + } + } +} + +/// The IPv6 representation of an embedded IPv4 address. +/// +/// Both variants carry the IPv4 address within the low 32 bits and differ only +/// in the 96-bit prefix ahead of those bits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EmbeddedIpv4 { + /// The `::ffff:0:0/96` form, which represents an IPv4 node's address + /// to an IPv6 application ([RFC 4291 §2.5.5.2]). + /// + /// [RFC 4291 §2.5.5.2]: https://www.rfc-editor.org/rfc/rfc4291#section-2.5.5.2 + Mapped, + + /// The `::/96` form, which [RFC 4291 §2.5.5.1] deprecates because the + /// transition mechanisms that used it are obsolete. + /// + /// [RFC 4291 §2.5.5.1]: https://www.rfc-editor.org/rfc/rfc4291#section-2.5.5.1 + Compatible, +} + +impl fmt::Display for EmbeddedIpv4 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Mapped => { + write!(f, "IPv4-mapped (::ffff:0:0/96, RFC 4291 §2.5.5.2)") + } + Self::Compatible => { + write!(f, "IPv4-compatible (::/96, RFC 4291 §2.5.5.1)") + } + } + } +} + +/// A validated external SSM group address. +/// +/// IPv4 SSM addresses must be within the `232.0.0.0/8` range, excluding the +/// reserved `232.0.0.0` null address. +/// +/// IPv6 SSM addresses satisfy the SSM checks on [`ExternalMulticastIp`]. +/// +/// SSM channels are identified by an `(S,G)` pairing; an any-source (ASM) +/// filter is invalid for this type. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SsmMulticastIp(ExternalMulticastIp); + +/// A validated exact multicast source address. +/// +/// Sources must be unicast and use the same address family as the group. The +/// rule set matches OPTE's source validator. +/// +/// IPv4 loopback, broadcast, unspecified, link-local, and "this host on this +/// network" (`0.0.0.0/8`, RFC 1122 §3.2.1.3) addresses are rejected as +/// sources, as is the class E block (`240.0.0.0/4`), which RFC 1112 §4 +/// reserves and the IANA special-purpose registry (RFC 6890) marks as +/// "Source: False". Shared address space (`100.64.0.0/10`, RFC 6598) is +/// permitted because it can source traffic inside an operator network. +/// +/// IPv6 loopback, unspecified, link-local, IPv4-mapped (RFC 4291 §2.5.5.2), +/// and IPv4-compatible (RFC 4291 §2.5.5.1) addresses are rejected as sources. +/// A link-local source is never forwarded off-link (RFC 3927 §2.7, RFC 4291 +/// §2.5.6), and `0.0.0.0/8` is marked as not forwardable in the IANA +/// special-purpose registry (RFC 6890, per RFC 1812 §5.3.7), which means +/// that neither of these could ever match a source filter. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExactSource(IpAddr); + +/// A non-empty list of validated exact multicast source addresses +/// (sorted and deduplicated). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NonEmptyExactSources(Vec); + +/// Stored source filter for an external multicast group. +/// +/// A list that is absent, empty, a singleton `Any` (wildcard), or containing +/// `Any` alongside exact entries always collapses to just the singleton `Any`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SourceFilter { + /// Any source, maps to the `(*,G)` wildcard. + Any, + /// Exact sources only, the `(S,G)` list. + Exact(NonEmptyExactSources), +} + +/// A requested source entry, which is validated on its own address. +/// +/// The incoming deserialized form is an [`IpSrc`]. +/// +/// The validations that depend on the group address, namely the +/// address-family match and the ASM/SSM source-list disambiguation, are +/// applied once the group IP is known. +#[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, +)] +#[serde(try_from = "IpSrc", into = "IpSrc")] +pub enum SourceEntry { + /// Wildcard entry, which collapses an ASM filter to unrestricted. + Any, + /// A single source that passed unicast validation. + Exact(ExactSource), +} + +impl JsonSchema for SourceEntry { + fn schema_name() -> String { + IpSrc::schema_name() + } + + fn is_referenceable() -> bool { + IpSrc::is_referenceable() + } + + fn schema_id() -> std::borrow::Cow<'static, str> { + IpSrc::schema_id() + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + IpSrc::json_schema(generator) + } +} + +impl TryFrom for SourceEntry { + type Error = MulticastGroupCreateExternalError; + + fn try_from(source: IpSrc) -> Result { + Ok(match source { + IpSrc::Any => Self::Any, + IpSrc::Exact(ip) => Self::Exact(ExactSource::new(ip)?), + }) + } +} + +impl From for IpSrc { + fn from(entry: SourceEntry) -> Self { + match entry { + SourceEntry::Any => IpSrc::Any, + SourceEntry::Exact(source) => IpSrc::Exact(source.ip()), + } + } +} + +/// NAT target for an external multicast group. +/// +/// The MAC must be a multicast MAC and the internal IP must lie within the +/// `ff04::/64` internal underlay block. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema, +)] +#[serde(try_from = "NatTarget", into = "NatTarget")] +pub struct ExternalNatTarget { + internal_ip: UnderlayMulticastIpv6, + inner_mac: MacAddr, + vni: Vni, +} + +/// Errors from constructing an [`ExternalNatTarget`]. +#[derive(Clone, Debug, thiserror::Error)] +pub enum ExternalNatTargetError { + /// The inner MAC is not multicast. + #[error("NAT target inner MAC address {0} is not a multicast MAC address")] + InvalidInnerMac(MacAddr), + /// The internal IP is outside `ff04::/64`. + #[error( + "NAT target internal IP address {0} is not in the reserved underlay \ + multicast subnet (ff04::/64)" + )] + InvalidInternalIp(Ipv6Addr), +} + +/// Errors from converting the older optional forwarding shape. +#[derive(Clone, Debug, thiserror::Error)] +pub enum ExternalInternalForwardingError { + /// The request omitted its NAT target. + #[error("External multicast groups require a NAT target")] + MissingNatTarget, + /// The NAT target failed validation. + #[error(transparent)] + InvalidNatTarget(#[from] ExternalNatTargetError), +} + +/// Internal forwarding for an external multicast group. +/// +/// External groups always carry a NAT target. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema, +)] +pub struct ExternalInternalForwarding { + /// The group's underlay NAT target. + pub nat_target: ExternalNatTarget, +} + +impl TryFrom for ExternalNatTarget { + type Error = ExternalNatTargetError; + + fn try_from(target: NatTarget) -> Result { + if !target.inner_mac.is_multicast() { + return Err(ExternalNatTargetError::InvalidInnerMac( + target.inner_mac, + )); + } + + let internal_ip = UnderlayMulticastIpv6::new(target.internal_ip) + .map_err(|_| { + ExternalNatTargetError::InvalidInternalIp(target.internal_ip) + })?; + + Ok(Self { internal_ip, inner_mac: target.inner_mac, vni: target.vni }) + } +} + +impl From for NatTarget { + fn from(target: ExternalNatTarget) -> Self { + Self { + internal_ip: target.internal_ip.into(), + inner_mac: target.inner_mac, + vni: target.vni, + } + } +} + +impl From for UnderlayMulticastIpv6 { + fn from(target: ExternalNatTarget) -> Self { + target.internal_ip + } +} + +impl TryFrom for ExternalInternalForwarding { + type Error = ExternalInternalForwardingError; + + fn try_from(forwarding: InternalForwarding) -> Result { + let nat_target = forwarding + .nat_target + .ok_or(ExternalInternalForwardingError::MissingNatTarget)? + .try_into()?; + Ok(Self { nat_target }) + } +} + +impl From for InternalForwarding { + fn from(forwarding: ExternalInternalForwarding) -> Self { + Self { nat_target: Some(forwarding.nat_target.into()) } + } +} + +impl NonEmptyExactSources { + /// Create a sorted, deduplicated list, while returning `None` for empty + /// input(s). + pub(crate) fn new(sources: Vec) -> Option { + let sources: Vec<_> = + sources.into_iter().collect::>().into_iter().collect(); + (!sources.is_empty()).then_some(Self(sources)) + } + + fn into_vec(self) -> Vec { + self.0 + } + + /// Return the sources as a slice representation. + pub fn as_slice(&self) -> &[ExactSource] { + &self.0 + } +} + +impl SsmMulticastIp { + /// Return the SSM form of an [`ExternalMulticastIp`] or `None` for ASM. + pub fn new(addr: ExternalMulticastIp) -> Option { + addr.is_ssm().then_some(Self(addr)) + } + + /// Return the underlying external multicast address. + pub fn ip(&self) -> ExternalMulticastIp { + self.0 + } +} + +impl From for ExternalMulticastIp { + fn from(addr: SsmMulticastIp) -> Self { + addr.0 + } +} + +impl From for IpAddr { + fn from(addr: SsmMulticastIp) -> Self { + addr.0.into() + } +} + +impl fmt::Display for SsmMulticastIp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl ExactSource { + /// Validate an exact multicast source address. + /// + /// # Errors + /// + /// Returns [`MulticastGroupCreateExternalError`] if `ip` is multicast or + /// cannot be used as a unicast source. + pub fn new(ip: IpAddr) -> Result { + validate_exact_source(ip)?; + Ok(Self(ip)) + } + + /// Return the source address. + pub fn ip(&self) -> IpAddr { + self.0 + } +} + +impl From for IpAddr { + fn from(source: ExactSource) -> Self { + source.0 + } +} + +impl From for IpSrc { + fn from(source: ExactSource) -> Self { + IpSrc::Exact(source.0) + } +} + +impl fmt::Display for ExactSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// An external any-source (ASM) multicast group used at creation time. +/// +/// ASM uses the host-group model described in [RFC 1112], with member +/// subscribers receiving traffic sent to this group from any permissible +/// source. +/// +/// The requested source list may be absent, empty, exact-only, [`IpSrc::Any`], +/// or a mixture of `Any` and exact entries. +/// +/// A source of `Any` subsumes all exact source entries (related to the +/// filter-mode merge rules of [RFC 3376] §3.2). Upon subsumption, +/// every exact entry is validated first, then any-source constructs (mixed +/// lists included) are canonicalized to an unrestricted filter +/// at deserialization, and then exact-only lists are kept around. +/// +/// [RFC 1112]: https://www.rfc-editor.org/rfc/rfc1112.html +/// [RFC 3376]: https://www.rfc-editor.org/rfc/rfc3376.html +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AsmMulticastGroupCreate { + pub(crate) group_ip: ExternalMulticastIp, + pub(crate) tag: Option, + pub(crate) internal_forwarding: ExternalInternalForwarding, + pub(crate) external_forwarding: ExternalForwarding, + pub(crate) sources: SourceFilter, +} + +/// An external source-specific (SSM) multicast group used at creation time. +/// +/// SSM uses the `(S,G)` channel model from [RFC 4607]. +/// +/// The source list must always contain at least one exact source; +/// `IpSrc::Any` cannot occur in this request type. +/// +/// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SsmMulticastGroupCreate { + pub(crate) group_ip: SsmMulticastIp, + pub(crate) tag: Option, + pub(crate) internal_forwarding: ExternalInternalForwarding, + pub(crate) external_forwarding: ExternalForwarding, + pub(crate) sources: NonEmptyExactSources, +} + +/// A validated external multicast group creation entry. +/// +/// Deserializing the flat request validates the group IP and source list +/// and picks the ASM (any-source) or SSM (source-specific) shape. +/// +/// Serializing emits the canonical flat form. A request that mixed `Any` +/// (wildcard) with exact sources does not round-trip. +/// +/// On decode, this checks the group address, each exact source, and +/// the address-family relationship. Source-list rules follow the appropriate +/// ASM or SSM split. +/// +/// # Examples +/// +/// An SSM group with a populated NAT target: +/// +/// ``` +/// use dpd_types_versions::latest::mcast::MulticastGroupCreateExternalEntry; +/// +/// let body = serde_json::json!({ +/// "group_ip": "232.1.2.3", +/// "tag": "client", +/// "internal_forwarding": { +/// "nat_target": { +/// "internal_ip": "ff04::1", +/// "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, +/// "vni": 100 +/// } +/// }, +/// "external_forwarding": { "vlan_id": null }, +/// "sources": [{ "Exact": "10.0.0.1" }] +/// }); +/// +/// let group: MulticastGroupCreateExternalEntry = +/// serde_json::from_value(body).unwrap(); +/// assert!(matches!(group, MulticastGroupCreateExternalEntry::Ssm(_))); +/// ``` +/// +/// An ASM group with the source list omitted: +/// +/// ``` +/// use dpd_types_versions::latest::mcast::MulticastGroupCreateExternalEntry; +/// +/// let body = serde_json::json!({ +/// "group_ip": "239.1.2.3", +/// "tag": null, +/// "internal_forwarding": { +/// "nat_target": { +/// "internal_ip": "ff04::1", +/// "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, +/// "vni": 100 +/// } +/// }, +/// +/// "external_forwarding": { "vlan_id": null } +/// }); +/// +/// let group: MulticastGroupCreateExternalEntry = +/// serde_json::from_value(body).unwrap(); +/// assert!(matches!(group, MulticastGroupCreateExternalEntry::Asm(_))); +/// ``` +/// +/// An ASM group may carry exact sources, but a source of `Any` (wildcard), a +/// singleton `["Any"]`, or an empty list all collapse to the same unrestricted +/// shape. +/// +/// ``` +/// use dpd_types_versions::latest::mcast::MulticastGroupCreateExternalEntry; +/// +/// let mut body = serde_json::json!({ +/// "group_ip": "239.1.2.3", +/// "tag": null, +/// "internal_forwarding": { +/// "nat_target": { +/// "internal_ip": "ff04::1", +/// "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, +/// "vni": 100 +/// } +/// }, +/// "external_forwarding": { "vlan_id": null }, +/// "sources": [{ "Exact": "10.0.0.1" }, { "Exact": "10.0.0.2" }] +/// }); +/// +/// let entry: MulticastGroupCreateExternalEntry = +/// serde_json::from_value(body.clone()).unwrap(); +/// let MulticastGroupCreateExternalEntry::Asm(group) = &entry else { +/// panic!("239.1.2.3 is an ASM address"); +/// }; +/// assert_eq!(group.exact_sources().unwrap().len(), 2); +/// +/// for sources in [ +/// serde_json::json!(["Any", { "Exact": "10.0.0.1" }]), +/// serde_json::json!(["Any"]), +/// serde_json::json!([]), +/// ] { +/// body["sources"] = sources; +/// let entry: MulticastGroupCreateExternalEntry = +/// serde_json::from_value(body.clone()).unwrap(); +/// let MulticastGroupCreateExternalEntry::Asm(group) = &entry else { +/// panic!("239.1.2.3 is an ASM address"); +/// }; +/// assert!(group.allows_any_source()); +/// assert_eq!( +/// serde_json::to_value(&entry).unwrap()["sources"], +/// serde_json::Value::Null +/// ); +/// } +/// ``` +/// +/// An SSM group must carry exact sources, while ASM can omit them. Both an +/// absent list and a singleton `Any` (wildcard) are rejected: +/// +/// ``` +/// use dpd_types_versions::latest::mcast::MulticastGroupCreateExternalEntry; +/// +/// let mut body = serde_json::json!({ +/// "group_ip": "232.1.2.3", +/// "tag": null, +/// "internal_forwarding": { +/// "nat_target": { +/// "internal_ip": "ff04::1", +/// "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, +/// "vni": 100 +/// } +/// }, +/// "external_forwarding": { "vlan_id": null } +/// }); +/// +/// let no_sources = +/// serde_json::from_value::(body.clone()); +/// assert!(no_sources.is_err()); +/// +/// body["sources"] = serde_json::json!(["Any"]); +/// let singleton_any = +/// serde_json::from_value::(body); +/// assert!(singleton_any.is_err()); +/// ``` +/// +/// IPv6 SSM groups, in the `ff3x::/32` prefix of RFC 4607 and in `ffbx::/32` +/// with the X flag set: +/// +/// ``` +/// use dpd_types_versions::latest::mcast::MulticastGroupCreateExternalEntry; +/// +/// for group_ip in ["ff3e::4000:1", "ffbe::4000:1"] { +/// let body = serde_json::json!({ +/// "group_ip": group_ip, +/// "tag": null, +/// "internal_forwarding": { +/// "nat_target": { +/// "internal_ip": "ff04::1", +/// "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, +/// "vni": 100 +/// } +/// }, +/// "external_forwarding": { "vlan_id": null }, +/// "sources": [{ "Exact": "2001:db8::1" }] +/// }); +/// +/// let group: MulticastGroupCreateExternalEntry = +/// serde_json::from_value(body).unwrap(); +/// assert!(matches!(group, MulticastGroupCreateExternalEntry::Ssm(_))); +/// } +/// ``` +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + try_from = "MulticastGroupCreateExternalEntryShadow", + into = "MulticastGroupCreateExternalEntryShadow" +)] +pub enum MulticastGroupCreateExternalEntry { + /// Any-source group; the source filter is canonicalized at parse. + Asm(AsmMulticastGroupCreate), + /// Source-specific group, carrying at least one validated exact source. + Ssm(SsmMulticastGroupCreate), +} + +/// A flat request schema for creating a customer-visible overlay multicast +/// group. +/// +/// The group IP address disambiguates whether the group is ASM or SSM. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[schemars(rename = "MulticastGroupCreateExternalEntry")] +pub(crate) struct MulticastGroupCreateExternalEntryShadow { + /// The multicast address for the group. + pub(crate) group_ip: ExternalMulticastIp, + /// Tag for validating update/delete requests. If a tag is not provided, + /// one is auto-generated as `{uuid}:{group_ip}`. + pub(crate) tag: Option, + /// The required NAT target is checked against switch state by the server. + pub(crate) internal_forwarding: ExternalInternalForwarding, + /// Egress VLAN configuration for external forwarding. + pub(crate) external_forwarding: ExternalForwarding, + /// Optional source list. + /// + /// A source list can be absent from ASM group requests. If absent, + /// empty, or containing `Any`, the group permits every source (i.e., a + /// wildcard). + /// + /// SSM groups require at least one exact source in the list and + /// reject `Any` wildcard entries. + pub(crate) sources: Option>, +} + +impl TryFrom + for MulticastGroupCreateExternalEntry +{ + type Error = MulticastGroupCreateExternalError; + + fn try_from( + entry: MulticastGroupCreateExternalEntryShadow, + ) -> Result { + let classified = + RequestSources::new(entry.group_ip, entry.sources.as_deref())?; + + Ok(match classified { + RequestSources::Asm(sources) => { + Self::Asm(AsmMulticastGroupCreate { + group_ip: entry.group_ip, + tag: entry.tag, + internal_forwarding: entry.internal_forwarding, + external_forwarding: entry.external_forwarding, + sources, + }) + } + RequestSources::Ssm { group_ip, sources } => { + Self::Ssm(SsmMulticastGroupCreate { + group_ip, + tag: entry.tag, + internal_forwarding: entry.internal_forwarding, + external_forwarding: entry.external_forwarding, + sources, + }) + } + }) + } +} + +impl From + for MulticastGroupCreateExternalEntryShadow +{ + fn from(entry: MulticastGroupCreateExternalEntry) -> Self { + match entry { + MulticastGroupCreateExternalEntry::Asm(group) => Self { + group_ip: group.group_ip, + tag: group.tag, + internal_forwarding: group.internal_forwarding, + external_forwarding: group.external_forwarding, + sources: group.sources.to_entries(), + }, + MulticastGroupCreateExternalEntry::Ssm(group) => Self { + group_ip: group.group_ip.into(), + tag: group.tag, + internal_forwarding: group.internal_forwarding, + external_forwarding: group.external_forwarding, + sources: Some( + group + .sources + .into_vec() + .into_iter() + .map(SourceEntry::Exact) + .collect(), + ), + }, + } + } +} + +impl JsonSchema for MulticastGroupCreateExternalEntry { + fn schema_name() -> String { + MulticastGroupCreateExternalEntryShadow::schema_name() + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + MulticastGroupCreateExternalEntryShadow::json_schema(generator) + } +} + +impl TryFrom + for MulticastGroupCreateExternalEntry +{ + type Error = MulticastGroupCreateExternalError; + + fn try_from( + entry: v7::mcast::MulticastGroupCreateExternalEntry, + ) -> Result { + let sources = entry + .sources + .map(|sources| { + sources.into_iter().map(SourceEntry::try_from).collect() + }) + .transpose()?; + + Self::try_from(MulticastGroupCreateExternalEntryShadow { + group_ip: ExternalMulticastIp::new(entry.group_ip)?, + tag: entry.tag, + internal_forwarding: entry.internal_forwarding.try_into()?, + external_forwarding: entry.external_forwarding, + sources, + }) + } +} + +/// Updated forwarding and source-filter settings for an external group. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MulticastGroupUpdateExternalEntry { + /// Required internal forwarding config. + pub internal_forwarding: ExternalInternalForwarding, + /// External VLAN config. + pub external_forwarding: ExternalForwarding, + /// Replacement source list, or `None` to preserve the current filter. + pub sources: Option>, +} + +impl TryFrom + for MulticastGroupUpdateExternalEntry +{ + type Error = MulticastGroupCreateExternalError; + + fn try_from( + entry: v8::mcast::MulticastGroupUpdateExternalEntry, + ) -> Result { + let sources = match entry.sources { + Some(sources) => sources + .into_iter() + .map(SourceEntry::try_from) + .collect::>()?, + None => vec![SourceEntry::Any], + }; + + Ok(Self { + internal_forwarding: entry.internal_forwarding.try_into()?, + external_forwarding: entry.external_forwarding, + sources: Some(sources), + }) + } +} + +/// Used to identify an external multicast group by IP address. +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct MulticastExternalGroupIpParam { + /// A validated external multicast group IP address. + pub group_ip: ExternalMulticastIp, +} + +impl TryFrom + for MulticastExternalGroupIpParam +{ + type Error = ExternalMulticastIpError; + + fn try_from( + param: v1::mcast::MulticastGroupIpParam, + ) -> Result { + Ok(Self { group_ip: ExternalMulticastIp::new(param.group_ip)? }) + } +} + +/// Response structure for external multicast group operations. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MulticastGroupExternalResponse { + /// The validated multicast address of the group. + pub group_ip: ExternalMulticastIp, + /// ASIC replication group ID for the external group. + pub external_group_id: MulticastGroupId, + /// Tag for validating update/delete requests. Always present and generated + /// as `{uuid}:{group_ip}` if not provided at creation time. + pub tag: String, + /// NAT target for forwarding onto the underlay. + pub internal_forwarding: ExternalInternalForwarding, + /// Egress VLAN configuration for external forwarding. + pub external_forwarding: ExternalForwarding, + /// Source filter, or `None` when any source is permitted. + pub sources: Option>, +} + +impl From + for v8::mcast::MulticastGroupExternalResponse +{ + fn from(resp: MulticastGroupExternalResponse) -> Self { + Self { + group_ip: resp.group_ip.into(), + external_group_id: resp.external_group_id, + tag: resp.tag, + internal_forwarding: resp.internal_forwarding.into(), + external_forwarding: resp.external_forwarding, + sources: resp.sources, + } + } +} + +/// Unified response type for operations that return mixed group types. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MulticastGroupResponse { + /// An internal group on the underlay, with members. + Underlay(MulticastGroupUnderlayResponse), + /// An external overlay group; NAT target and VLAN, no direct members. + External(MulticastGroupExternalResponse), +} + +impl From for v8::mcast::MulticastGroupResponse { + fn from(resp: MulticastGroupResponse) -> Self { + match resp { + MulticastGroupResponse::Underlay(u) => Self::Underlay(u), + MulticastGroupResponse::External(e) => Self::External(e.into()), + } + } +} diff --git a/dpd-types/versions/src/mcast_external_scope/mod.rs b/dpd-types/versions/src/mcast_external_scope/mod.rs new file mode 100644 index 00000000..4d59a3ab --- /dev/null +++ b/dpd-types/versions/src/mcast_external_scope/mod.rs @@ -0,0 +1,15 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/ +// +// Copyright 2026 Oxide Computer Company + +//! Version `MCAST_EXTERNAL_SCOPE` of the DPD API. +//! +//! This version added validated external multicast address, source, and NAT +//! target types. +//! This migration changed `MulticastGroupCreateExternalEntry` from a struct to +//! an ASM/SSM enum disambiguated at deserialization, and modified the external +//! `group_ip` and `internal_forwarding` fields to the validated types. + +pub mod mcast; diff --git a/dpd/Cargo.toml b/dpd/Cargo.toml index e0dc4f90..2624e3a8 100644 --- a/dpd/Cargo.toml +++ b/dpd/Cargo.toml @@ -38,6 +38,7 @@ console-subscriber = { version = "0.5.0", optional = true } csv.workspace = true futures.workspace = true libc.workspace = true +nonempty.workspace = true openssl.workspace = true oxnet.workspace = true rand = { workspace = true, optional = true } diff --git a/dpd/src/api_server.rs b/dpd/src/api_server.rs index 061563df..08173efb 100644 --- a/dpd/src/api_server.rs +++ b/dpd/src/api_server.rs @@ -32,6 +32,7 @@ use dpd_types::link::{ use dpd_types::loopback::{LoopbackIpv4Path, LoopbackIpv6Path}; use dpd_types::mcast::UnderlayMulticastIpv6; use dpd_types::mcast::{ + ExternalMulticastIp, MulticastExternalGroupIpParam, MulticastGroupCreateExternalEntry, MulticastGroupCreateUnderlayEntry, MulticastGroupExternalResponse, MulticastGroupIpParam, MulticastGroupResponse, MulticastGroupTagQuery, @@ -68,7 +69,7 @@ use dpd_types::switch_port::{Led, LedState, ManagementMode, SwitchPortView}; use dpd_types::table; use dpd_types::table::TableParam; use dpd_types::transceivers::{Datapath, Monitors, PowerState, Transceiver}; -use dpd_types_versions::{v1, v7}; +use dpd_types_versions::{v1, v7, v8}; use dropshot::BuildError; use dropshot::ClientErrorStatusCode; use dropshot::ClientSpecifiesVersionInHeader; @@ -1987,7 +1988,7 @@ impl DpdApi for DpdApiImpl { let switch: &Switch = rqctx.context(); let entry = group.into_inner(); - mcast::add_group_internal(switch, entry) + mcast::add_group_underlay(switch, entry) .map(HttpResponseCreated) .map_err(HttpError::from) } @@ -2056,7 +2057,7 @@ impl DpdApi for DpdApiImpl { let admin_scoped = path.into_inner().group_ip; let tag = query.into_inner().tag; - mcast::modify_group_internal( + mcast::modify_group_underlay( switch, admin_scoped, tag.as_ref(), @@ -2088,13 +2089,13 @@ impl DpdApi for DpdApiImpl { let tag = match entry.tag.take() { Some(t) => t, None => { - mcast::get_group_internal(switch, underlay) + mcast::get_group_underlay(switch, underlay) .map_err(HttpError::from)? .tag } }; - mcast::modify_group_internal(switch, underlay, &tag, entry.into()) + mcast::modify_group_underlay(switch, underlay, &tag, entry.into()) .map(|resp| HttpResponseOk(resp.into())) .map_err(HttpError::from) } @@ -2106,23 +2107,23 @@ impl DpdApi for DpdApiImpl { let switch: &Switch = rqctx.context(); let underlay = path.into_inner().group_ip; - mcast::get_group_internal(switch, underlay) + mcast::get_group_underlay(switch, underlay) .map(HttpResponseOk) .map_err(HttpError::from) } async fn multicast_group_update_external( rqctx: RequestContext>, - path: Path, + path: Path, query: Query, group: TypedBody, ) -> Result, HttpError> { let switch: &Switch = rqctx.context(); let entry = group.into_inner(); - let ip = path.into_inner().group_ip; + let group_ip = path.into_inner().group_ip; let tag = query.into_inner().tag; - mcast::modify_group_external(switch, ip, tag.as_ref(), entry) + mcast::modify_group_external(switch, group_ip, tag.as_ref(), entry) .map(HttpResponseOk) .map_err(HttpError::from) } @@ -2147,8 +2148,19 @@ impl DpdApi for DpdApiImpl { .to_string(), }; - mcast::modify_group_external(switch, ip, &tag, entry.into()) - .map(|resp| HttpResponseCreated(resp.into())) + let entry = v8::mcast::MulticastGroupUpdateExternalEntry::from(entry); + let entry = MulticastGroupUpdateExternalEntry::try_from(entry) + .map_err(|e| HttpError::for_bad_request(None, e.to_string()))?; + + let group_ip = ExternalMulticastIp::new(ip) + .map_err(|e| HttpError::for_bad_request(None, e.to_string()))?; + + mcast::modify_group_external(switch, group_ip, &tag, entry) + .map(|resp| { + let v8_resp = + v8::mcast::MulticastGroupExternalResponse::from(resp); + HttpResponseCreated(v8_resp.into()) + }) .map_err(HttpError::from) } @@ -2174,10 +2186,20 @@ impl DpdApi for DpdApiImpl { let v7_entry = v7::mcast::MulticastGroupUpdateExternalEntry::from(entry); - mcast::modify_group_external(switch, ip, &tag, v7_entry.into()) + let v8_entry = + v8::mcast::MulticastGroupUpdateExternalEntry::from(v7_entry); + let entry = MulticastGroupUpdateExternalEntry::try_from(v8_entry) + .map_err(|e| HttpError::for_bad_request(None, e.to_string()))?; + + let group_ip = ExternalMulticastIp::new(ip) + .map_err(|e| HttpError::for_bad_request(None, e.to_string()))?; + + mcast::modify_group_external(switch, group_ip, &tag, entry) .map(|resp| { + let v8_resp = + v8::mcast::MulticastGroupExternalResponse::from(resp); let v7_resp = - v7::mcast::MulticastGroupExternalResponse::from(resp); + v7::mcast::MulticastGroupExternalResponse::from(v8_resp); HttpResponseCreated(v7_resp.into()) }) .map_err(HttpError::from) @@ -2207,7 +2229,8 @@ impl DpdApi for DpdApiImpl { } }; - let entries = mcast::get_range(switch, last_addr, limit, None); + let entries = mcast::get_range(switch, last_addr, limit, None) + .map_err(HttpError::from)?; Ok(HttpResponseOk(ResultsPage::new( entries, @@ -2244,7 +2267,8 @@ impl DpdApi for DpdApiImpl { } }; - let entries = mcast::get_range(switch, last_addr, limit, Some(&tag)); + let entries = mcast::get_range(switch, last_addr, limit, Some(&tag)) + .map_err(HttpError::from)?; Ok(HttpResponseOk(ResultsPage::new( entries, &EmptyScanParams {}, @@ -2283,13 +2307,9 @@ impl DpdApi for DpdApiImpl { } async fn multicast_reset_untagged_v1( - rqctx: RequestContext>, + _rqctx: RequestContext>, ) -> Result { - let switch: &Switch = rqctx.context(); - - mcast::reset_untagged(switch) - .map(|_| HttpResponseDeleted()) - .map_err(HttpError::from) + Ok(HttpResponseDeleted()) } #[cfg(feature = "tofino_asic")] diff --git a/dpd/src/link.rs b/dpd/src/link.rs index 7837f6a9..1a6a4eba 100644 --- a/dpd/src/link.rs +++ b/dpd/src/link.rs @@ -20,6 +20,7 @@ use crate::table::uplink; use crate::transceivers::qsfp_xcvr_mpn; use crate::types::DpdError; use crate::types::DpdResult; +use crate::types::ignore_missing; use aal::AsicId; use aal::AsicOps; use aal::AsicResult; @@ -1645,9 +1646,11 @@ fn set_mac_config( } fn clear_mac_config(switch: &Switch, asic_id: AsicId) -> DpdResult<()> { - mac::mac_clear(switch, asic_id)?; - mac::mcast_mac_clear(switch, asic_id)?; - mcast::mcast_egress::del_port_mapping_entry(switch, asic_id)?; + ignore_missing(mac::mac_clear(switch, asic_id))?; + ignore_missing(mac::mcast_mac_clear(switch, asic_id))?; + ignore_missing(mcast::mcast_egress::del_port_mapping_entry( + switch, asic_id, + ))?; Ok(()) } diff --git a/dpd/src/loopback.rs b/dpd/src/loopback.rs index f0a4665c..409fc32b 100644 --- a/dpd/src/loopback.rs +++ b/dpd/src/loopback.rs @@ -32,7 +32,7 @@ pub fn add_loopback_ipv4(switch: &Switch, addr: &Ipv4Entry) -> DpdResult<()> { } match table::port_ip::loopback_ipv4_add(switch, addr.addr) { Ok(()) => _ = loopback_data.v4_addrs.insert(addr.clone()), - Err(DpdError::Switch(AsicError::Exists)) => { + Err(DpdError::Switch(AsicError::Exists(_))) => { if !loopback_data.v4_addrs.contains(addr) { warn!( switch.log, @@ -82,7 +82,7 @@ pub fn add_loopback_ipv6(switch: &Switch, addr: &Ipv6Entry) -> DpdResult<()> { match table::port_ip::loopback_ipv6_add(switch, addr.addr) { Ok(()) => _ = loopback_data.v6_addrs.insert(addr.clone()), - Err(DpdError::Switch(AsicError::Exists)) => { + Err(DpdError::Switch(AsicError::Exists(_))) => { if !loopback_data.v6_addrs.contains(addr) { warn!( switch.log, diff --git a/dpd/src/main.rs b/dpd/src/main.rs index 720b32dd..a16ceb18 100644 --- a/dpd/src/main.rs +++ b/dpd/src/main.rs @@ -31,6 +31,7 @@ use signal_hook_tokio::Signals; use slog::debug; use slog::error; use slog::info; +use slog::warn; use tokio::sync::Mutex as TokioMutex; use tokio::time::Duration; use tokio::time::sleep; @@ -386,13 +387,25 @@ impl Switch { key: &M, ) -> DpdResult<()> { let mut t = self.table_get(table_type)?; - t.entry_del(&self.asic_hdl, key).map_err(|e| { - debug!( - self.log, - "failed to delete entry from {:?}: {:?}", table_type, e - ); - e - }) + let untracked = t.usage.occupancy == 0; + + t.entry_del(&self.asic_hdl, key) + .inspect(|()| { + if untracked { + warn!( + self.log, + "deleted entry from table with no tracked entries"; + "table" => %table_type, + ); + } + }) + .map_err(|e| { + debug!( + self.log, + "failed to delete entry from {table_type:?}: {e:?}" + ); + e + }) } /// Fetch all of the entries in a P4 table and return them diff --git a/dpd/src/mcast/mod.rs b/dpd/src/mcast/mod.rs index 7b961945..71006f3c 100644 --- a/dpd/src/mcast/mod.rs +++ b/dpd/src/mcast/mod.rs @@ -9,106 +9,99 @@ //! This is the entrypoint for managing multicast groups, including creating, //! modifying, and deleting groups. //! -//! ## Overview +//! An external group has an ingress NAT entry keyed by its address and an +//! optional VLAN. On the sidecar, the NAT target fields are the action +//! parameters. Its `internal_ip` identifies the underlay group, which owns the +//! replication members. //! -//! There are two types of multicast groups: -//! - **External (Overlay) groups**: Entry points for overlay traffic, -//! have NAT targets, VLAN IDs, and no direct members. External groups -//! reference internal groups via NAT targets to perform the -//! actual packet replication and forwarding. +//! Underlay groups exist in an `ff04::/64` internal block, within the +//! admin-local scope `ff04::/16` ([RFC 7346], [RFC 4291]). A copy to an +//! external member is decapsulated, and picks up a VLAN tag if one is set. +//! Underlay copies keep their Geneve encapsulation. //! -//! - **Internal (Underlay) groups**: Handle actual packet replication to -//! members, containing ALL members (either direction - overlay or -//! underlay). +//! Rack-originated traffic arrives already encapsulated to the underlay +//! address. No external NAT. The `mcast_tag` selects the external replication +//! group, the underlay replication group, or both. Packet copies for +//! `Direction::External` members are decapsulated before delivery. //! -//! ### Member Directions +//! Scope is not carried through to encapsulation. External IPv6 group +//! validation admits every scope except reserved `0x0` and the +//! interface-local `0x1` and link-local `0x2` scopes that a router must not +//! forward past (RFC 4291 §2.7). Each group maps to an `ff04::` (scope-4) +//! underlay address. A scope check on the outer header sees admin-local scope +//! for scope-3, scope-5, and scope-8 external groups, while containment for the +//! NAT path comes from both egress port and VLAN config. //! -//! Internal groups contain members with two traffic directions: -//! - **External direction**: Members in overlay networks (customer networks), -//! which receive decapsulated packets with (possible) VLAN tags on multicast -//! egress. -//! -//! - **Underlay direction**: Members in underlay networks -//! (rack infrastructure, instances), which receive encapsulated Geneve -//! packets on multicast egress. -//! -//! ### Bifurcated Multicast Design: -//! -//! The multicast implementation uses a bifurcated design that separates -//! external (customer) and (internal) underlay traffic: -//! -//! 1. External-only groups (IPv4 and IPv6 outside the reserved underlay -//! subnet ff04::/64): -//! - Created from API control plane IPs for customer traffic -//! - Handle customer traffic to/from outside the rack -//! - Use the external multicast API (/multicast/external-groups) -//! - Must have NAT targets pointing to internal groups for proper forwarding -//! -//! 2. Internal groups (underlay IPv6 multicast): -//! - Reserved underlay subnet ff04::/64, within admin-local scope-4 -//! (ff04::/16) as defined in [RFC 7346] and [RFC 4291] -//! - Geneve encapsulated multicast traffic (NAT targets of -//! external-only groups) -//! - Use the internal multicast API (/multicast/underlay-groups) -//! - Can replicate to: -//! a) External group members (customer traffic) -//! b) Underlay-only members (infrastructure traffic) -//! c) Both external and underlay members (bifurcated replication) -//! -//! [RFC 7346]: https://www.rfc-editor.org/rfc/rfc7346.html //! [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html +//! [RFC 7346]: https://www.rfc-editor.org/rfc/rfc7346.html use std::{ - collections::{BTreeMap, BTreeSet, HashSet}, + borrow::Borrow, + collections::{BTreeMap, BTreeSet, HashSet, btree_map::Entry}, net::{IpAddr, Ipv4Addr, Ipv6Addr}, ops::Bound, sync::{Arc, Mutex, Weak}, }; use aal::{AsicError, AsicMulticastOps}; -use common::{network::NatTarget, ports::PortId}; -use dpd_types::{ - link::LinkId, - mcast::{ - Direction, ExternalForwarding, InternalForwarding, IpSrc, - MulticastGroupCreateExternalEntry, MulticastGroupCreateUnderlayEntry, - MulticastGroupExternalResponse, MulticastGroupId, MulticastGroupMember, - MulticastGroupResponse, MulticastGroupUnderlayResponse, - MulticastGroupUpdateExternalEntry, MulticastGroupUpdateUnderlayEntry, - UnderlayMulticastIpv6, - }, +use common::network::NatTarget; +use dpd_types::mcast::{ + Direction, ExternalForwarding, ExternalInternalForwarding, + ExternalMulticastIp, ExternalNatTarget, IpSrc, + MulticastGroupCreateExternalEntry, MulticastGroupCreateUnderlayEntry, + MulticastGroupExternalResponse, MulticastGroupId, MulticastGroupMember, + MulticastGroupResponse, MulticastGroupUnderlayResponse, + MulticastGroupUpdateExternalEntry, MulticastGroupUpdateUnderlayEntry, + MulticastTag, SourceFilter, UnderlayMulticastIpv6, }; +use nonempty::NonEmpty; use oxnet::{Ipv4Net, Ipv6Net}; use slog::{debug, error, warn}; use uuid::Uuid; use crate::{ Switch, table, - types::{DpdError, DpdResult}, + types::{DpdError, DpdResult, after_unwind, ignore_exists, ignore_missing}, }; mod rollback; -mod validate; -use rollback::{GroupCreateRollbackContext, GroupUpdateRollbackContext}; -use validate::{ - validate_multicast_address, validate_nat_target, - validate_not_underlay_subnet, validate_tag, validate_tag_format, +use rollback::{ + DeleteStep, GroupCreateRollbackContext, GroupDeleteRollbackContext, + GroupUpdateRollbackContext, unwind_outcome, }; -/// Empty source list constant for use with `unwrap_or` when normalizing -/// `Option>`. -const NO_SOURCES: &[IpSrc] = &[]; - -/// Convert sources from storage format to slice for table operations. +/// The key identifier for a multicast group, built only from a validated +/// group address. /// -/// Treats `None` as an empty slice, which semantically means "any source" in -/// the context of multicast source filtering. This allows add/remove functions -/// to handle the `None/empty/Any` trifecta uniformly. -fn sources_as_slice(sources: &Option>) -> &[IpSrc] { - sources.as_deref().unwrap_or(NO_SOURCES) +/// The group's "kind" lives in its [`GroupKind`] type (not in the key). +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct GroupKey(IpAddr); + +impl GroupKey { + /// Key an external group by its address. + fn external(ip: ExternalMulticastIp) -> Self { + Self(ip.into()) + } + + /// Key an underlay group by its `ff04::/64` address. + fn underlay(ip: UnderlayMulticastIpv6) -> Self { + Self(IpAddr::V6(ip.into())) + } + + /// Return the keyed address. + fn ip(&self) -> IpAddr { + self.0 + } } +impl Borrow for GroupKey { + fn borrow(&self) -> &IpAddr { + &self.0 + } +} + +/// Inner ID holding a weak handle to the free pool it returns. #[derive(Debug)] struct ScopedIdInner(MulticastGroupId, Weak>>); @@ -125,9 +118,9 @@ impl Drop for ScopedIdInner { } } -/// Wrapper for multicast group IDs during allocation that automatically -/// returns them to the free pool when dropped. This prevents group ID leaks -/// when operations fail during group creation. +/// Reference-counted ID wrapper for a multicast group ID during allocation. +/// Dropping it returns the ID to the free pool, making sure a failed creation +/// does not leak it. #[derive(Clone, Debug)] struct ScopedGroupId(Arc); @@ -144,14 +137,22 @@ impl From for ScopedGroupId { } } -/// Multicast replication configuration (internal only). +/// Multicast replication configuration (underlay groups only). #[derive(Clone, Debug, Default, PartialEq, Eq)] -struct MulticastReplicationInfo { +pub(crate) struct MulticastReplicationInfo { rid: u16, level1_excl_id: u16, level2_excl_id: u16, } +impl MulticastReplicationInfo { + /// Default level exclusion IDs to 0 for underlay groups + /// since they can only be configured internally without API calls. + fn new(rid: MulticastGroupId) -> Self { + Self { rid, level1_excl_id: 0, level2_excl_id: 0 } + } +} + /// Represents a multicast group configuration. /// /// This structure is used to manage multicast groups, including their @@ -163,14 +164,119 @@ pub(crate) struct MulticastGroup { /// Tag for validating update/delete requests. Always present and generated /// as `{uuid}:{group_ip}` if not provided at creation time. pub(crate) tag: String, - pub(crate) int_fwding: InternalForwarding, - pub(crate) ext_fwding: ExternalForwarding, - pub(crate) sources: Option>, - replication_info: Option, - pub(crate) members: Vec, + kind: GroupKind, +} + +/// The kind of a [`MulticastGroup`]: external or underlay. +/// +/// An external group carries ingress state: where to NAT, the egress VLAN, +/// and the source filter. +/// +/// An underlay group carries replication state in [`Replication`]. +#[derive(Clone, Debug)] +enum GroupKind { + /// A customer-visible overlay multicast group. + /// + /// The ingress entry point that NATs inbound traffic to an underlay group. + External { + group_ip: ExternalMulticastIp, + nat_target: ExternalNatTarget, + vlan_id: Option, + sources: SourceFilter, + }, + /// An underlay group in the internal `ff04::/64` block that drives + /// replication to its members (subscribers). + /// + /// Packet copies to [`Direction::External`] members are decapsulated. + /// Packet copies to [`Direction::Underlay`] members retain Geneve + /// encapsulation. The packet's `mcast_tag` selects external replication, + /// underlay replication, or both (bifurcated replication). + Underlay { group_ip: UnderlayMulticastIpv6, replication: Replication }, +} + +/// The replication state of an underlay group. +/// +/// Member subscribers and replication info exist when `Active`, which +/// means the group owns both a decap bitmap entry and a replication entry. +#[derive(Clone, Debug)] +enum Replication { + /// No members. + /// + /// Groups are created memberless and filled in eventually on joins. + Empty, + /// Replicating to a non-empty member set. + Active { + info: MulticastReplicationInfo, + members: NonEmpty, + }, +} + +impl Replication { + fn new( + info: MulticastReplicationInfo, + members: Vec, + ) -> Self { + match NonEmpty::from_vec(members) { + Some(members) => Self::Active { info, members }, + None => Self::Empty, + } + } + + fn info(&self) -> Option<&MulticastReplicationInfo> { + match self { + Self::Empty => None, + Self::Active { info, .. } => Some(info), + } + } + + fn members(&self) -> impl Iterator { + match self { + Self::Empty => None, + Self::Active { members, .. } => Some(members.iter()), + } + .into_iter() + .flatten() + } } impl MulticastGroup { + /// Return the group's address as read from its [`GroupKind`]. + fn ip(&self) -> IpAddr { + match &self.kind { + GroupKind::External { group_ip, .. } => (*group_ip).into(), + GroupKind::Underlay { group_ip, .. } => (*group_ip).into(), + } + } + + fn nat_target(&self) -> Option { + match &self.kind { + GroupKind::External { nat_target, .. } => Some(*nat_target), + GroupKind::Underlay { .. } => None, + } + } + + fn vlan_id(&self) -> Option { + match &self.kind { + GroupKind::External { vlan_id, .. } => *vlan_id, + GroupKind::Underlay { .. } => None, + } + } + + fn replication(&self) -> Option<&Replication> { + match &self.kind { + GroupKind::External { .. } => None, + GroupKind::Underlay { replication, .. } => Some(replication), + } + } + + fn members(&self) -> impl Iterator { + self.replication().into_iter().flat_map(Replication::members) + } + + fn replication_info(&self) -> Option<&MulticastReplicationInfo> { + self.replication().and_then(Replication::info) + } + fn external_group_id(&self) -> MulticastGroupId { self.external_scoped_group.id() } @@ -179,56 +285,83 @@ impl MulticastGroup { self.underlay_scoped_group.id() } - fn external_scoped_group_id(&self) -> &ScopedGroupId { - &self.external_scoped_group + /// ASIC group that carries members of `direction`. + fn group_id(&self, direction: Direction) -> MulticastGroupId { + match direction { + Direction::External => self.external_group_id(), + Direction::Underlay => self.underlay_group_id(), + } } +} - fn underlay_scoped_group_id(&self) -> &ScopedGroupId { - &self.underlay_scoped_group - } +impl TryFrom<&MulticastGroup> for MulticastGroupExternalResponse { + type Error = DpdError; - fn to_external_response( - &self, - group_ip: IpAddr, - ) -> MulticastGroupExternalResponse { - MulticastGroupExternalResponse { - group_ip, - external_group_id: self.external_group_id(), - tag: self.tag.clone(), - internal_forwarding: self.int_fwding.clone(), - external_forwarding: self.ext_fwding.clone(), - sources: self.sources.clone(), + /// Build the external API response for an external group. + /// + /// # Errors + /// + /// Returns [`DpdError::Invalid`] if `group` is an underlay group. + fn try_from(group: &MulticastGroup) -> DpdResult { + match MulticastGroupResponse::from(group) { + MulticastGroupResponse::External(response) => Ok(response), + MulticastGroupResponse::Underlay(response) => { + Err(DpdError::Invalid(format!( + "multicast group {} is not an external group", + response.group_ip + ))) + } } } +} - fn to_underlay_response( - &self, - group_ip: UnderlayMulticastIpv6, - ) -> MulticastGroupUnderlayResponse { - MulticastGroupUnderlayResponse { - group_ip, - external_group_id: self.external_group_id(), - underlay_group_id: self.underlay_group_id(), - tag: self.tag.clone(), - members: self.members.clone(), +impl TryFrom<&MulticastGroup> for MulticastGroupUnderlayResponse { + type Error = DpdError; + + /// Build the underlay response for an underlay group. + /// + /// # Errors + /// + /// Returns [`DpdError::Invalid`] if `group` is an external group. + fn try_from(group: &MulticastGroup) -> DpdResult { + match MulticastGroupResponse::from(group) { + MulticastGroupResponse::Underlay(response) => Ok(response), + MulticastGroupResponse::External(response) => { + Err(DpdError::Invalid(format!( + "multicast group {} is not an underlay group", + response.group_ip + ))) + } } } +} - fn to_response(&self, group_ip: IpAddr) -> MulticastGroupResponse { - match group_ip { - IpAddr::V4(_) => MulticastGroupResponse::External( - self.to_external_response(group_ip), - ), - IpAddr::V6(ipv6) => { - // Try to create UnderlayMulticastIpv6 - if successful, it's an underlay group - match UnderlayMulticastIpv6::new(ipv6) { - Ok(admin_scoped) => MulticastGroupResponse::Underlay( - self.to_underlay_response(admin_scoped), - ), - Err(_) => MulticastGroupResponse::External( - self.to_external_response(group_ip), - ), - } +impl From<&MulticastGroup> for MulticastGroupResponse { + /// Build the response variant matching the group's [`GroupKind`]. + fn from(group: &MulticastGroup) -> Self { + match &group.kind { + GroupKind::External { group_ip, nat_target, vlan_id, sources } => { + Self::External(MulticastGroupExternalResponse { + group_ip: *group_ip, + external_group_id: group.external_group_id(), + tag: group.tag.clone(), + internal_forwarding: ExternalInternalForwarding { + nat_target: *nat_target, + }, + external_forwarding: ExternalForwarding { + vlan_id: *vlan_id, + }, + sources: sources.to_ip_sources(), + }) + } + GroupKind::Underlay { group_ip, replication } => { + Self::Underlay(MulticastGroupUnderlayResponse { + group_ip: *group_ip, + external_group_id: group.external_group_id(), + underlay_group_id: group.underlay_group_id(), + tag: group.tag.clone(), + members: replication.members().cloned().collect(), + }) } } } @@ -238,16 +371,18 @@ impl MulticastGroup { #[derive(Debug)] pub struct MulticastGroupData { /// Multicast group configurations keyed by group IP. - groups: BTreeMap, - /// Stack of available group IDs for O(1) allocation. + groups: BTreeMap, + /// The stack of available group IDs for O(1) allocation. /// Pre-populated with all IDs from GENERATOR_START to u16::MAX-1. free_group_ids: Arc>>, - /// 1:1 mapping from admin-local group IP to external group that uses it as NAT - /// target (admin_local_ip -> external_group_ip) - nat_target_refs: BTreeMap, + /// 1:1 mapping from admin-local group IP to the external group that uses it + /// as NAT a target (admin_local_ip -> external_group_ip). + nat_target_refs: BTreeMap, } impl MulticastGroupData { + /// The lowest group ID that could be available in the free pool; lower IDs + /// remain reserved. const GENERATOR_START: u16 = 100; /// Creates a new instance of MulticastGroupData with pre-populated free @@ -288,10 +423,10 @@ impl MulticastGroupData { /// Add 1:1 forwarding reference from admin-local IP to external group's IP. fn add_forwarding_refs( &mut self, - external_group_ip: IpAddr, + external_group_key: GroupKey, admin_scoped_ip: UnderlayMulticastIpv6, ) { - self.nat_target_refs.insert(admin_scoped_ip, external_group_ip); + self.nat_target_refs.insert(admin_scoped_ip, external_group_key); } /// Remove 1:1 forwarding reference. @@ -299,16 +434,57 @@ impl MulticastGroupData { self.nat_target_refs.remove(&admin_scoped_ip); } - /// Get the VLAN ID for an internal multicast group by looking up + /// Get the VLAN ID for an underlay multicast group by looking up /// the referencing external group (1:1 mapping). - fn get_vlan_for_internal_addr( + fn get_vlan_for_underlay_addr( &self, internal_ip: UnderlayMulticastIpv6, ) -> Option { self.nat_target_refs .get(&internal_ip) - .and_then(|external_ip| self.groups.get(external_ip)) - .and_then(|group| group.ext_fwding.vlan_id) + .and_then(|external_key| self.groups.get(external_key)) + .and_then(|group| group.vlan_id()) + } + + fn validate_group_exists(&self, group_key: GroupKey) -> DpdResult<()> { + if self.groups.contains_key(&group_key) { + return Err(DpdError::Exists(format!( + "multicast group for IP {} already exists", + group_key.ip(), + ))); + } + Ok(()) + } + + fn get_nat_target_group( + &self, + group_addr: IpAddr, + nat_target: ExternalNatTarget, + ) -> DpdResult<&MulticastGroup> { + let internal_ip: UnderlayMulticastIpv6 = nat_target.into(); + if let Some(existing_ref) = self.nat_target_refs.get(&internal_ip) + && existing_ref.ip() != group_addr + { + return Err(DpdError::Invalid(format!( + "underlay group {internal_ip} is already referenced by external group {}", + existing_ref.ip(), + ))); + } + + let Some(underlay_group) = self.groups.get(&IpAddr::from(internal_ip)) + else { + return Err(DpdError::MissingNatTarget(format!( + "underlay group {internal_ip} referenced by external group \ + {group_addr} is absent from the switch", + ))); + }; + + let GroupKind::Underlay { .. } = &underlay_group.kind else { + return Err(DpdError::Invalid(format!( + "multicast group for IP {internal_ip} is not an underlay group", + ))); + }; + Ok(underlay_group) } } @@ -318,135 +494,122 @@ impl Default for MulticastGroupData { } } -/// Add an external multicast group to the switch, which creates the group on -/// the ASIC and associates it with a group IP address and updates associated -/// tables for NAT and L3 routing. +/// Add an external multicast group to the switch: bind the address to its +/// NAT-target underlay group's ASIC groups and program the NAT and L3 route +/// entries. /// /// If anything fails, the group is cleaned up and an error is returned. pub(crate) fn add_group_external( s: &Switch, group_info: MulticastGroupCreateExternalEntry, ) -> DpdResult { - let group_ip = group_info.group_ip; + let group_ip = group_info.group_ip(); + let group_key = GroupKey::external(group_ip); + let group_addr: IpAddr = group_ip.into(); + let sources = SourceFilter::from(&group_info); // Acquire the lock to the multicast data structure at the start to ensure // deterministic operation order let mut mcast = s.mcast.lock().unwrap(); - let nat_target = - group_info.internal_forwarding.nat_target.ok_or_else(|| { - DpdError::Invalid( - "external groups must have NAT target".to_string(), - ) - })?; + let nat_target = group_info.internal_forwarding().nat_target; - validate_external_group_creation(&mcast, group_ip, &group_info)?; - validate_nat_target(nat_target)?; + mcast.validate_group_exists(group_key)?; - // Validate that NAT target points to an existing group and get its IDs - let internal_group_ip = nat_target.internal_ip.into(); - let internal_group = - mcast.groups.get(&internal_group_ip).ok_or_else(|| { - DpdError::Invalid(format!( - "multicast group for IP address {group_ip} must have a NAT target \ - that is also a tracked multicast group", - )) - })?; + let underlay_group = mcast.get_nat_target_group(group_addr, nat_target)?; + let tag = match group_info.tag() { + Some(t) => String::from(t.parse::()?), + None => generate_default_tag(group_addr), + }; + + // Set IDs to match the underlay group from the NAT target + let scoped_external_id = underlay_group.external_scoped_group.clone(); + let scoped_underlay_id = underlay_group.underlay_scoped_group.clone(); + + let group = MulticastGroup { + external_scoped_group: scoped_external_id, + underlay_scoped_group: scoped_underlay_id, + tag, + kind: GroupKind::External { + group_ip, + nat_target, + vlan_id: group_info.external_forwarding().vlan_id, + sources: sources.clone(), + }, + }; + let response = MulticastGroupExternalResponse::try_from(&group)?; + + let admin_local_ip: UnderlayMulticastIpv6 = nat_target.into(); + let previous_vlan = mcast.get_vlan_for_underlay_addr(admin_local_ip); - // Set IDs to match the internal group from the NAT target - let scoped_external_id = internal_group.external_scoped_group.clone(); - let scoped_underlay_id = internal_group.underlay_scoped_group.clone(); + clear_external_entries(s, group_addr)?; // Create rollback context once for reuse throughout this function let rollback_ctx = GroupCreateRollbackContext::new_external( s, - group_ip, - scoped_external_id.id(), - scoped_underlay_id.id(), - nat_target, - group_info.sources.as_deref(), - group_info.external_forwarding.vlan_id, + group_addr, + group.external_group_id(), + group.underlay_group_id(), + &sources, + group_info.external_forwarding().vlan_id, ); - // Configure external tables and handle VLAN propagation - configure_external_tables(s, &group_info) - .and_then(|_| { - // Perform VLAN propagation if needed - if let Some(vlan_id) = group_info.external_forwarding.vlan_id { - perform_vlan_propagation( - s, - &mcast, - group_ip, - vlan_id, - nat_target.internal_ip.into(), - ) - } else { - Ok(()) - } - }) - .map_err(|e| rollback_ctx.rollback_and_return_error(e))?; - - // NOTE: If perform_vlan_propagation succeeded, it updated the internal group's - // bitmap entry with the VLAN. The remaining operations below are infallible - // so no rollback is needed. - // - // If adding fallible operations here, consider adding VLAN propagation rollback. - - // This validation already passed in validate_nat_target, so it cannot fail here - let admin_local_ip = UnderlayMulticastIpv6::new(nat_target.internal_ip)?; - - let tag = match &group_info.tag { - Some(t) => { - validate_tag_format(t)?; - t.clone() - } - None => { - let generated = generate_default_tag(group_ip); - validate_tag_format(&generated)?; - generated - } - }; + configure_external_tables(s, &group_info, &sources) + .map_err(|e| rollback_ctx.rollback(e))?; - let group = MulticastGroup { - external_scoped_group: scoped_external_id, - underlay_scoped_group: scoped_underlay_id, - tag, - int_fwding: group_info.internal_forwarding.clone(), - ext_fwding: group_info.external_forwarding.clone(), - sources: canonicalize_sources(group_info.sources.clone()), - replication_info: None, - // External groups are entry points only - actual members reside in referenced internal groups - members: Vec::new(), - }; + do_vlan_propagation( + s, + group_addr, + group_info.external_forwarding().vlan_id, + underlay_group, + ) + .map_err(|e| { + rollback_ctx.rollback_with_vlan_restore( + e, + underlay_group, + previous_vlan, + ) + })?; - mcast.groups.insert(group_ip, group.clone()); - mcast.add_forwarding_refs(group_ip, admin_local_ip); + // VLAN propagation is the last fallible step. Any fallible work added + // below must also restore the underlay group's previous bitmap VLAN. + mcast.groups.insert(group_key, group); + mcast.add_forwarding_refs(group_key, admin_local_ip); - Ok(group.to_external_response(group_ip)) + Ok(response) } -/// Add an internal multicast group to the switch, which creates the group on +/// Add an underlay multicast group to the switch, which creates the group on /// the ASIC and associates it with a group IP address and updates associated /// tables for multicast replication and L3 routing. /// /// If anything fails, the group is cleaned up and an error is returned. -pub(crate) fn add_group_internal( +pub(crate) fn add_group_underlay( s: &Switch, group_info: MulticastGroupCreateUnderlayEntry, ) -> DpdResult { let group_ip = group_info.group_ip; + let group_key = GroupKey::underlay(group_ip); // Acquire the lock to the multicast data structure at the start to ensure // deterministic operation order let mut mcast = s.mcast.lock().unwrap(); - validate_internal_group_creation(&mcast, group_ip)?; + mcast.validate_group_exists(group_key)?; + + let ipv6 = Ipv6Addr::from(group_ip); + ignore_missing(table::mcast::mcast_replication::del_ipv6_entry(s, ipv6))?; + ignore_missing(table::mcast::mcast_route::del_ipv6_entry(s, ipv6))?; + let tag = match &group_info.tag { + Some(t) => String::from(t.parse::()?), + None => generate_default_tag(group_ip.into()), + }; let (scoped_external_id, scoped_underlay_id) = allocate_multicast_group_ids(s, &mut mcast, group_ip.into())?; // Create rollback context for cleanup if operations fail - let rollback_ctx = GroupCreateRollbackContext::new_internal( + let rollback_ctx = GroupCreateRollbackContext::new_underlay( s, group_ip.into(), scoped_external_id.id(), @@ -454,85 +617,56 @@ pub(crate) fn add_group_internal( ); // Get VLAN ID from referencing external groups - let vlan_id = mcast.get_vlan_for_internal_addr(group_ip); + let vlan_id = mcast.get_vlan_for_underlay_addr(group_ip); let external_group_id = scoped_external_id.id(); let underlay_group_id = scoped_underlay_id.id(); let mut added_members = Vec::new(); // Only configure replication if there are members - let replication_info = if !group_info.members.is_empty() { - let replication_info = configure_replication(external_group_id); + let replication_info = (!group_info.members.is_empty()) + .then(|| MulticastReplicationInfo::new(external_group_id)); + if let Some(replication_info) = &replication_info { add_ports_to_groups( s, group_ip.into(), &group_info.members, external_group_id, underlay_group_id, - &replication_info, + replication_info, &mut added_members, ) - .map_err(|e| rollback_ctx.rollback_and_return_error(e))?; - - configure_internal_tables( - s, - group_ip.into(), - external_group_id, - underlay_group_id, - Some(&replication_info), - &added_members, - vlan_id, - ) - .map_err(|e| rollback_ctx.rollback_and_return_error(e))?; - - Some(replication_info) - } else { - // No members - configure minimal tables for empty group - configure_internal_tables( - s, - group_ip.into(), - external_group_id, - underlay_group_id, - None, - &added_members, - vlan_id, - ) - .map_err(|e| rollback_ctx.rollback_and_return_error(e))?; - - None - }; + .map_err(|e| rollback_ctx.rollback(e))?; + } - let tag = match &group_info.tag { - Some(t) => { - validate_tag_format(t)?; - t.clone() - } - None => { - let generated = generate_default_tag(group_ip.into()); - validate_tag_format(&generated)?; - generated - } - }; + configure_underlay_tables( + s, + group_ip, + external_group_id, + underlay_group_id, + replication_info.as_ref(), + &added_members, + vlan_id, + ) + .map_err(|e| rollback_ctx.rollback(e))?; // Generic internal datastructure (vs API interface) let group = MulticastGroup { external_scoped_group: scoped_external_id, underlay_scoped_group: scoped_underlay_id, tag, - int_fwding: InternalForwarding { - nat_target: None, // Internal groups don't have NAT targets - }, - ext_fwding: ExternalForwarding { - vlan_id: None, // Internal groups don't have VLANs + kind: GroupKind::Underlay { + group_ip, + replication: match replication_info { + Some(info) => Replication::new(info, group_info.members), + None => Replication::Empty, + }, }, - sources: None, // Internal groups don't have sources - replication_info, - members: group_info.members, }; - mcast.groups.insert(group_ip.into(), group.clone()); + mcast.groups.insert(group_key, group.clone()); - Ok(group.to_underlay_response(group_ip)) + MulticastGroupUnderlayResponse::try_from(&group) } /// Delete a multicast group from the switch, including all associated tables @@ -550,7 +684,7 @@ pub(crate) fn add_group_internal( /// # Errors /// /// Returns an error if: -/// - Attempting to delete an internal group that is still referenced by an +/// - Attempting to delete an underlay group that is still referenced by an /// external group via NAT target /// - The provided tag does not match the group's existing tag pub(crate) fn del_group( @@ -559,57 +693,81 @@ pub(crate) fn del_group( tag: &str, ) -> DpdResult<()> { let mut mcast = s.mcast.lock().unwrap(); + del_group_locked(s, &mut mcast, group_ip, tag) +} + +fn del_group_locked( + s: &Switch, + mcast: &mut MulticastGroupData, + group_ip: IpAddr, + tag: &str, +) -> DpdResult<()> { + let group = mcast.groups.get(&group_ip).ok_or_else(|| { + DpdError::Missing(format!( + "Multicast group for IP {group_ip} not found" + )) + })?; - // Check if this is an internal group referenced by an external group. - // Internal groups are identified by addresses in the reserved underlay + // Check if this is an underlay group referenced by an external group. + // Underlay groups are identified by addresses in the reserved underlay // subnet (i.e., ff04::/64). if let IpAddr::V6(ipv6) = group_ip && let Ok(admin_scoped) = UnderlayMulticastIpv6::new(ipv6) && let Some(external_ip) = mcast.nat_target_refs.get(&admin_scoped) { return Err(DpdError::Invalid(format!( - "cannot delete internal group {group_ip}: still referenced \ - by external group {external_ip} via NAT target" + "cannot delete underlay group {group_ip}: still referenced \ + by external group {} via NAT target", + external_ip.ip(), ))); } // Validate tag before removing the group. - let group_entry = mcast.groups.get(&group_ip).ok_or_else(|| { - DpdError::Missing(format!( - "Multicast group for IP {group_ip} not found", - )) - })?; - validate_tag(&group_entry.tag, tag)?; - - let group = mcast.groups.remove(&group_ip).unwrap(); + validate_tag(&group.tag, tag)?; let nat_target_to_remove = - group.int_fwding.nat_target.map(|nat| nat.internal_ip.into()); + group.nat_target().map(UnderlayMulticastIpv6::from); debug!(s.log, "deleting multicast group for IP {group_ip}"); - delete_group_tables(s, group_ip, &group)?; + let rollback_ctx = GroupDeleteRollbackContext::new(s, group); + let mut deleted = Vec::new(); + + delete_group_tables(s, group, &mut deleted) + .map_err(|e| rollback_ctx.rollback(e, &deleted))?; - // Only delete ASIC groups for internal groups (groups without NAT targets). - // External groups share ASIC resources with their referenced internal groups. - if group.int_fwding.nat_target.is_none() { + if let Some(internal_ip) = nat_target_to_remove + && group.vlan_id().is_some() + && let Some(underlay_group) = + mcast.groups.get(&IpAddr::from(internal_ip)) + { + ensure_bitmap_vlan(s, underlay_group, None).map_err(|e| { + rollback_ctx.rollback_with_vlan_restore(e, underlay_group, &deleted) + })?; + } + + // External groups share ASIC resources with their referenced underlay groups. + if matches!(group.kind, GroupKind::Underlay { .. }) { + deleted.push(DeleteStep::Domains); delete_multicast_groups( s, group_ip, - group.external_scoped_group_id().clone(), - group.underlay_scoped_group_id().clone(), - )?; + group.external_group_id(), + group.underlay_group_id(), + ) + .map_err(|e| rollback_ctx.rollback(e, &deleted))?; } - if let Some(IpAddr::V6(ipv6)) = nat_target_to_remove { - mcast.rm_forwarding_refs(UnderlayMulticastIpv6::new(ipv6)?); + if let Some(internal_ip) = nat_target_to_remove { + mcast.rm_forwarding_refs(internal_ip); } + mcast.groups.remove(&group_ip); Ok(()) } -/// Get an internal multicast group configuration by admin-local IPv6 address. -pub(crate) fn get_group_internal( +/// Get an underlay multicast group configuration by admin-local IPv6 address. +pub(crate) fn get_group_underlay( s: &Switch, admin_local: UnderlayMulticastIpv6, ) -> DpdResult { @@ -618,11 +776,11 @@ pub(crate) fn get_group_internal( let group = mcast.groups.get(&group_ip).ok_or_else(|| { DpdError::Missing(format!( - "internal multicast group for IP {group_ip} not found", + "underlay multicast group for IP {group_ip} not found", )) })?; - Ok(group.to_underlay_response(admin_local)) + MulticastGroupUnderlayResponse::try_from(group) } /// Get a multicast group configuration. @@ -634,166 +792,124 @@ pub(crate) fn get_group( let group = mcast.groups.get(&group_ip).ok_or_else(|| { DpdError::Missing(format!( - "multicast group for IP {group_ip} not found", + "multicast group for IP {group_ip} not found" )) })?; - Ok(group.to_response(group_ip)) + Ok(group.into()) } +/// Update an external group's forwarding, VLAN, and sources under its tag. pub(crate) fn modify_group_external( s: &Switch, - group_ip: IpAddr, + group_ip: ExternalMulticastIp, tag: &str, new_group_info: MulticastGroupUpdateExternalEntry, ) -> DpdResult { + let group_key = GroupKey::external(group_ip); + let group_addr: IpAddr = group_ip.into(); let mut mcast = s.mcast.lock().unwrap(); // Check existence and validate tag before making any changes - let existing_group = mcast.groups.get(&group_ip).ok_or_else(|| { + let existing_group = mcast.groups.get(&group_key).ok_or_else(|| { DpdError::Missing(format!( - "Multicast group for IP {group_ip} not found" + "Multicast group for IP {group_addr} not found" )) })?; validate_tag(&existing_group.tag, tag)?; - let nat_target = - new_group_info.internal_forwarding.nat_target.ok_or_else(|| { - DpdError::Invalid( - "external groups must have NAT target".to_string(), - ) - })?; - - validate_multicast_address(group_ip, new_group_info.sources.as_deref())?; - validate_nat_target(nat_target)?; - - let group_entry = mcast.groups.remove(&group_ip).unwrap(); - let old_nat_target = group_entry.int_fwding.nat_target; + let GroupKind::External { + sources: old_sources, + nat_target: old_nat_target, + vlan_id: old_vlan_id, + .. + } = &existing_group.kind + else { + return Err(DpdError::Invalid(format!( + "multicast group {group_addr} is not an external group" + ))); + }; - // Create rollback context for external group update - let group_entry_for_rollback = group_entry.clone(); - let rollback_ctx = GroupUpdateRollbackContext::new( - s, - group_ip, - &group_entry_for_rollback, - new_group_info.external_forwarding.vlan_id, - ); + let old_nat_target = *old_nat_target; + let old_vlan_id = *old_vlan_id; - // Pre-compute normalized sources for rollback purposes - let normalized_sources = - canonicalize_sources(new_group_info.sources.clone()); + let new_sources = match new_group_info.sources.as_deref() { + Some(sources) => SourceFilter::from_entries(group_ip, Some(sources)) + .map_err(|e| DpdError::Invalid(e.to_string()))?, + None => old_sources.clone(), + }; - // Try to update external tables first - if let Err(e) = - update_external_tables(s, group_ip, &group_entry, &new_group_info) - { - // Restore original group and return error - mcast.groups.insert(group_ip, group_entry); - return Err( - rollback_ctx.rollback_external(e, normalized_sources.as_deref()) - ); - } + let nat_target = new_group_info.internal_forwarding.nat_target; + let new_underlay_group = + mcast.get_nat_target_group(group_addr, nat_target)?; + let group_entry = existing_group.clone(); - let mut updated_group = group_entry.clone(); + let old_underlay_ip: UnderlayMulticastIpv6 = old_nat_target.into(); + let new_underlay_ip: UnderlayMulticastIpv6 = nat_target.into(); - // Update NAT target references if NAT target changed - if let Some(old_nat) = old_nat_target { - let old_internal_ip = old_nat.internal_ip; - let new_internal_ip = nat_target.internal_ip; - - if old_internal_ip != new_internal_ip { - // Validate both IPs before mutating state to avoid partial updates. - // On error, restore the group entry before returning. - let (old_admin, new_admin) = match ( - UnderlayMulticastIpv6::new(old_internal_ip), - UnderlayMulticastIpv6::new(new_internal_ip), - ) { - (Ok(old), Ok(new)) => (old, new), - (Err(e), _) | (_, Err(e)) => { - mcast.groups.insert(group_ip, group_entry); - return Err(DpdError::Invalid(e.to_string())); - } - }; - mcast.rm_forwarding_refs(old_admin); - mcast.add_forwarding_refs(group_ip, new_admin); - } - } + let target_changed = old_underlay_ip != new_underlay_ip; + let old_underlay_group = + mcast.groups.get(&IpAddr::V6(old_underlay_ip.into())); + // VLAN is assigned directly: `Some(x)` sets the VLAN, `None` removes it. + // Unlike `sources`, `None` does not preserve the current value. + let new_vlan_id = new_group_info.external_forwarding.vlan_id; - // Tags are immutable (validated above, never changed) - updated_group.int_fwding.nat_target = Some(nat_target); - - let old_vlan_id = updated_group.ext_fwding.vlan_id; - // VLAN is assigned directly -> `Some(x)` sets VLAN, `None` removes VLAN - updated_group.ext_fwding.vlan_id = - new_group_info.external_forwarding.vlan_id; - updated_group.sources = canonicalize_sources( - new_group_info.sources.clone().or(updated_group.sources), - ); + let bitmap_updates = [ + (target_changed || old_vlan_id != new_vlan_id).then(|| { + BitmapVlanUpdate { + group: new_underlay_group, + old_vlan_id: mcast.get_vlan_for_underlay_addr(new_underlay_ip), + new_vlan_id, + } + }), + old_underlay_group.filter(|_| target_changed).map(|group| { + BitmapVlanUpdate { group, old_vlan_id, new_vlan_id: None } + }), + ] + .into_iter() + .flatten() + .collect::>(); - // Update bitmap tables with new VLAN if VLAN changed - // Also, handles possible membership skew between update internal + external calls. - if old_vlan_id != updated_group.ext_fwding.vlan_id { - let internal_ip = nat_target.internal_ip.into(); - - let bitmap_result = match mcast.groups.get(&internal_ip) { - Some(internal_group) - if internal_group.replication_info.is_some() => - { - // Only update bitmap if internal group has replication - let port_bitmap = create_port_bitmap( - &internal_group.members, - Direction::External, - ); + // Create rollback context for external group update + let rollback_ctx = + GroupUpdateRollbackContext::new(s, &group_entry, new_vlan_id); - // During external group update, bitmap entry exists - update it - table::mcast::mcast_egress::update_bitmap_entry( - s, - internal_group.external_group_id(), - &port_bitmap, - updated_group.ext_fwding.vlan_id, - ) - } - Some(_) => Ok(()), // Internal group exists but has no replication - None => Err(DpdError::Invalid(format!( - "internal group not found when updating bitmap: internal_ip={internal_ip}, external_group={group_ip}", - ))), - }; + update_external_tables( + s, + &group_entry, + &new_group_info, + old_nat_target, + old_sources, + &new_sources, + &bitmap_updates, + ) + .map_err(|e| rollback_ctx.rollback_external(e, &new_sources))?; - if let Err(e) = bitmap_result { - // Rollback the external table changes and NAT target references - mcast.groups.insert(group_ip, group_entry); - - // Rollback NAT target references if they were changed - if let Some(old_nat) = old_nat_target { - let old_internal_ip = old_nat.internal_ip; - let new_internal_ip = nat_target.internal_ip; - - if old_internal_ip != new_internal_ip { - // Restore original references (reverse of what we did above) - if let (Ok(old_admin), Ok(new_admin)) = ( - UnderlayMulticastIpv6::new(old_internal_ip), - UnderlayMulticastIpv6::new(new_internal_ip), - ) { - mcast.rm_forwarding_refs(new_admin); - mcast.add_forwarding_refs(group_ip, old_admin); - } - } - } + let updated_group = MulticastGroup { + external_scoped_group: new_underlay_group.external_scoped_group.clone(), + underlay_scoped_group: new_underlay_group.underlay_scoped_group.clone(), + kind: GroupKind::External { + group_ip, + nat_target, + vlan_id: new_vlan_id, + sources: new_sources, + }, + ..group_entry + }; - error!( - s.log, - "failed to update bitmap table for external group {group_ip}: {e:?}" - ); - return Err(rollback_ctx.rollback_and_restore(e)); - } + // Update NAT target references if NAT target changed + if target_changed { + mcast.rm_forwarding_refs(old_underlay_ip); + mcast.add_forwarding_refs(group_key, new_underlay_ip); } - let response = updated_group.to_external_response(group_ip); - mcast.groups.insert(group_ip, updated_group); - Ok(response) + let response = MulticastGroupExternalResponse::try_from(&updated_group); + mcast.groups.insert(group_key, updated_group); + response } -pub(crate) fn modify_group_internal( +/// Update an underlay group's members under its tag. +pub(crate) fn modify_group_underlay( s: &Switch, group_ip: UnderlayMulticastIpv6, tag: &str, @@ -801,38 +917,44 @@ pub(crate) fn modify_group_internal( ) -> DpdResult { let mut mcast = s.mcast.lock().unwrap(); + let group_key = GroupKey::underlay(group_ip); + let external_group_vlan_id = mcast.get_vlan_for_underlay_addr(group_ip); + // Check existence and validate tag before making any changes - let existing_group = - mcast.groups.get(&group_ip.into()).ok_or_else(|| { - DpdError::Missing(format!( - "Multicast group for IP {group_ip} not found" - )) - })?; - validate_tag(&existing_group.tag, tag)?; + let Entry::Occupied(existing_group) = mcast.groups.entry(group_key) else { + return Err(DpdError::Missing(format!( + "Multicast group for IP {group_ip} not found" + ))); + }; - let mut group_entry = mcast.groups.remove(&group_ip.into()).unwrap(); + validate_tag(&existing_group.get().tag, tag)?; - // Create rollback context for internal group update - let group_entry_for_rollback = group_entry.clone(); - let rollback_ctx = GroupUpdateRollbackContext::new( - s, - group_ip.into(), + if !matches!(existing_group.get().kind, GroupKind::Underlay { .. }) { + return Err(DpdError::Invalid(format!( + "multicast group {group_ip} is not an underlay group" + ))); + } + + // Repair the route before changing memberships or replication. + table::mcast::mcast_route::ensure_ipv6_entry(s, group_ip.into(), None)?; + + let mut group_entry = existing_group.remove(); + + // Create rollback context for underlay group update + let group_entry_for_rollback = group_entry.clone(); + let rollback_ctx = GroupUpdateRollbackContext::new( + s, &group_entry_for_rollback, - // Internal groups don't have VLANs, so `attempted_vlan_id` is None. - None, + external_group_vlan_id, ); - // Internal groups don't update sources, so always `None` - debug_assert!( - group_entry.sources.is_none(), - "Internal groups should not have sources" - ); - let sources = None; + let mut added_members = Vec::new(); + let mut removed_members = Vec::new(); // Configure replication based on member count transitions let replication_info = match ( new_group_info.members.is_empty(), - &group_entry.replication_info, + group_entry_for_rollback.replication_info(), ) { (true, Some(repl_info)) => { // Transition from members to empty. @@ -840,31 +962,46 @@ pub(crate) fn modify_group_internal( // First, remove ports from ASIC groups before cleaning up // replication table entries, otherwise stale ports cause subsequent // re-adds to fail with "already contains port". - process_membership_changes( + if let Err(e) = process_membership_changes( s, - group_ip.into(), &new_group_info.members, &group_entry, repl_info, - ) - .inspect_err(|_e| { - mcast.groups.insert(group_ip.into(), group_entry.clone()); - }) - .map_err(|e| rollback_ctx.rollback_and_restore(e))?; + &mut added_members, + &mut removed_members, + ) { + mcast.groups.insert(group_key, group_entry); + return Err(rollback_ctx.rollback_underlay( + e, + repl_info, + &added_members, + &removed_members, + )); + } - cleanup_empty_group_replication(s, group_ip.into(), &group_entry) - .map_err(|e| rollback_ctx.rollback_and_restore(e))?; + if let Err(e) = cleanup_empty_group_replication( + s, + &group_entry, + external_group_vlan_id, + ) { + mcast.groups.insert(group_key, group_entry); + return Err(rollback_ctx.rollback_underlay_and_restore( + e, + repl_info, + &added_members, + &removed_members, + )); + } - group_entry.replication_info = None; None } (false, None) => { // Transition from empty to members - configure replication - Some(configure_replication(group_entry.external_group_id())) + Some(MulticastReplicationInfo::new(group_entry.external_group_id())) } (false, Some(_)) => { // Already has members and replication - keep existing - group_entry.replication_info.clone() + group_entry.replication_info().cloned() } (true, None) => { // Already empty and no replication - keep none @@ -874,117 +1011,78 @@ pub(crate) fn modify_group_internal( // Early return for no-replication case -> just update metadata // Tags are immutable (validated above, never changed) - if replication_info.is_none() { - group_entry.sources = sources; - group_entry.members = new_group_info.members; - - let response = group_entry.to_underlay_response(group_ip); - mcast.groups.insert(group_ip.into(), group_entry); - return Ok(response); - } + let Some(replication_info) = replication_info else { + group_entry.kind = + GroupKind::Underlay { group_ip, replication: Replication::Empty }; + let response = MulticastGroupUnderlayResponse::try_from(&group_entry); + mcast.groups.insert(group_key, group_entry); + return response; + }; // Continue with replication processing - let repl_info = replication_info.as_ref().unwrap(); - let (added_members, removed_members) = process_membership_changes( + let repl_info = &replication_info; + if let Err(e) = process_membership_changes( s, - group_ip.into(), &new_group_info.members, &group_entry, repl_info, - ) - .inspect_err(|_e| { + &mut added_members, + &mut removed_members, + ) { // Restore group to mcast data structure - mcast.groups.insert(group_ip.into(), group_entry.clone()); - }) - .map_err(|e| rollback_ctx.rollback_internal(e, &[], &[]))?; + mcast.groups.insert(group_key, group_entry); + return Err(rollback_ctx.rollback_underlay( + e, + repl_info, + &added_members, + &removed_members, + )); + } - // Perform table updates - match update_group_tables( + // Ensure the bitmap exists before the replication entry. The VLAN comes + // from the referencing external group. + if let Err(e) = update_underlay_group_bitmap_tables( s, - group_ip.into(), &group_entry, - repl_info, - &sources, - &group_entry.sources, + &new_group_info.members, + external_group_vlan_id, ) { - Ok(()) => {} - Err(DpdError::Switch(AsicError::Missing(ref msg))) => { - // ASIC entry not found -> don't restore soft state, let the - // caller recreate. When omicron gets 404, it will CREATE fresh. - warn!( - s.log, - "ASIC entry missing during update, removing group from soft state"; - "group_ip" => %group_ip, - "error" => %msg, - ); - return Err(DpdError::Switch(AsicError::Missing(msg.clone()))); - } - Err(e) => { - // Other error - restore group and rollback - mcast.groups.insert(group_ip.into(), group_entry.clone()); - return Err(rollback_ctx.rollback_internal( - e, - &added_members, - &removed_members, - )); - } + // Restore the group and roll back + mcast.groups.insert(group_key, group_entry); + return Err(rollback_ctx.rollback_underlay_and_restore( + e, + repl_info, + &added_members, + &removed_members, + )); } - let filter_by_direction = - |members: &[MulticastGroupMember], direction: Direction| { - members - .iter() - .filter(|m| m.direction == direction) - .cloned() - .collect::>() - }; - - // Update bitmap tables if overlay members changed - let old_external_members = - filter_by_direction(&group_entry.members, Direction::External); - let new_external_members = - filter_by_direction(&new_group_info.members, Direction::External); - - if old_external_members != new_external_members { - // VLAN mapping maintained via add_forwarding_refs/rm_forwarding_refs - let external_group_vlan_id = mcast.get_vlan_for_internal_addr(group_ip); - - match update_internal_group_bitmap_tables( - s, - group_entry.external_group_id(), - &new_group_info.members, - &group_entry.members, - external_group_vlan_id, - ) { - Ok(()) => {} - Err(DpdError::Switch(AsicError::Missing(ref msg))) => { - // ASIC entry not found -> don't restore soft state - warn!( - s.log, - "ASIC bitmap entry missing during update, removing group from soft state"; - "group_ip" => %group_ip, - "error" => %msg, - ); - return Err(DpdError::Switch(AsicError::Missing(msg.clone()))); - } - Err(e) => { - // Other error - restore group and rollback - mcast.groups.insert(group_ip.into(), group_entry.clone()); - return Err(rollback_ctx.rollback_and_restore(e)); - } - } + // Ensure the replication entry on every update with members. + // + // An entry lost to a partial delete can then come back. It's inserted after + // the bitmap, where a rollback deletes a newly added bitmap on failure, and + // if that delete also fails, then the leftover is a bitmap that nothing + // can replicate toward (instead of a replication entry with no decap entry). + if let Err(e) = add_replication_entry(s, &group_entry, repl_info) { + mcast.groups.insert(group_key, group_entry); + return Err(rollback_ctx.rollback_underlay_and_restore( + e, + repl_info, + &added_members, + &removed_members, + )); } // Update group metadata and return success // Tags are immutable (validated above, never changed) - group_entry.sources = sources; - group_entry.replication_info = replication_info; - group_entry.members = new_group_info.members; - - let response = group_entry.to_underlay_response(group_ip); - mcast.groups.insert(group_ip.into(), group_entry.clone()); + group_entry.kind = GroupKind::Underlay { + group_ip, + replication: Replication::new(replication_info, new_group_info.members), + }; + let response = MulticastGroupUnderlayResponse::try_from(&group_entry); + mcast.groups.insert(group_key, group_entry); - Ok(response) + response } /// List all multicast groups over a range. @@ -993,7 +1091,7 @@ pub(crate) fn get_range( last: Option, limit: usize, tag: Option<&str>, -) -> Vec { +) -> DpdResult> { let mcast = s.mcast.lock().unwrap(); let lower_bound = match last { @@ -1001,36 +1099,36 @@ pub(crate) fn get_range( Some(last_ip) => Bound::Excluded(last_ip), }; - mcast + Ok(mcast .groups .range((lower_bound, Bound::Unbounded)) - .filter(|&(_ip, group)| { + .filter(|&(_key, group)| { // Filter by tag if specified tag.is_none_or(|tag_filter| group.tag == tag_filter) }) - .map(|(ip, group)| group.to_response(*ip)) + .map(|(_key, group)| group.into()) .take(limit) - .collect() + .collect()) } /// Reset all multicast groups (and associated routes) for a given tag. pub(crate) fn reset_tag(s: &Switch, tag: &str) -> DpdResult<()> { - let (external_groups, internal_groups) = { - let mcast = s.mcast.lock().unwrap(); - mcast - .groups - .iter() - .filter_map(|(ip, group)| { - (group.tag == tag) - .then_some((*ip, group.int_fwding.nat_target.is_some())) - }) - .partition::, _>(|(_, is_external)| *is_external) - }; + let mut mcast = s.mcast.lock().unwrap(); + let (external_groups, underlay_groups) = mcast + .groups + .iter() + .filter_map(|(key, group)| { + (group.tag == tag).then_some(( + key.ip(), + matches!(group.kind, GroupKind::External { .. }), + )) + }) + .partition::, _>(|(_, is_external)| *is_external); - // Delete external groups first since they reference internal groups + // Delete external groups first since they reference underlay groups // via NAT targets. Pass the tag for validation. - for (group_ip, _) in external_groups.into_iter().chain(internal_groups) { - if let Err(e) = del_group(s, group_ip, tag) { + for (group_ip, _) in external_groups.into_iter().chain(underlay_groups) { + if let Err(e) = del_group_locked(s, &mut mcast, group_ip, tag) { error!( s.log, "failed to delete multicast group for IP {group_ip}: {e:?}" @@ -1042,17 +1140,6 @@ pub(crate) fn reset_tag(s: &Switch, tag: &str) -> DpdResult<()> { Ok(()) } -/// Reset all multicast groups (and associated routes) without a tag. -/// -/// DEPRECATED: All groups have default tags generated at creation time. -/// This function is a no-op since no untagged groups can exist. -/// Retained for API version compatibility (v3 and earlier). -#[allow(unused_variables)] -pub(crate) fn reset_untagged(s: &Switch) -> DpdResult<()> { - // All groups have tags, so there are no untagged groups to delete. - Ok(()) -} - /// Reset all multicast groups (and associated routes). pub(crate) fn reset(s: &Switch) -> DpdResult<()> { let mut mcast = s.mcast.lock().unwrap(); @@ -1087,292 +1174,362 @@ pub(crate) fn reset(s: &Switch) -> DpdResult<()> { } /// Performs VLAN propagation for external groups. -fn perform_vlan_propagation( +fn do_vlan_propagation( s: &Switch, - mcast: &MulticastGroupData, group_ip: IpAddr, - vlan_id: u16, - internal_ip: IpAddr, + vlan_id: Option, + underlay_group: &MulticastGroup, ) -> DpdResult<()> { + let internal_ip = underlay_group.ip(); debug!( s.log, - "external group with VLAN references internal group, propagating VLAN"; + "external group with VLAN references underlay group, propagating VLAN"; "external_group" => %group_ip, - "vlan" => vlan_id, - "internal_group" => %internal_ip, + "vlan" => ?vlan_id, + "underlay_group" => %internal_ip, ); - let internal_group = mcast.groups.get(&internal_ip).ok_or_else(|| { - DpdError::Missing(format!( - "internal group not found during VLAN propagation: \ - internal_group={internal_ip}, external_group={group_ip}" + ensure_bitmap_vlan(s, underlay_group, vlan_id).map_err(|e| { + DpdError::McastGroupFailure(format!( + "failed to update external bitmap: vlan={vlan_id:?}, underlay_group={internal_ip}, error={e:?}", )) - })?; - - let (external_group_id, members) = ( - internal_group.external_scoped_group_id().clone(), - internal_group.members.clone(), - ); - - // Update bitmap entry with VLAN if internal group has members - if !members.is_empty() { - let port_bitmap = create_port_bitmap(&members, Direction::External); - table::mcast::mcast_egress::update_bitmap_entry( - s, - external_group_id.id(), - &port_bitmap, - Some(vlan_id), - ).map_err(|e| { - DpdError::McastGroupFailure(format!( - "failed to update external bitmap: vlan={vlan_id}, internal_group={internal_ip}, error={e:?}", - )) - })?; - } - Ok(()) + }) } -/// Remove source filters for a multicast group. -fn remove_source_filters( +/// Update the VLAN on an active underlay group's bitmap entry, failing if the +/// entry is missing. +/// +/// Rollback calls this method first when restoring a VLAN, and then re-adds the +/// entry itself if it is missing. +fn update_bitmap_vlan( s: &Switch, - group_ip: IpAddr, - sources: &[IpSrc], + group: &MulticastGroup, + vlan_id: Option, ) -> DpdResult<()> { - match group_ip { - IpAddr::V4(ipv4) => remove_ipv4_source_filters(s, ipv4, sources), - IpAddr::V6(ipv6) => remove_ipv6_source_filters(s, ipv6, sources), + if group.replication_info().is_none() { + return Ok(()); } + + let port_bitmap = create_port_bitmap(group.members(), Direction::External); + table::mcast::mcast_egress::update_bitmap_entry( + s, + group.external_group_id(), + &port_bitmap, + vlan_id, + ) } -fn remove_ipv4_source_filters( +/// Set the VLAN on an active underlay group's bitmap entry, re-adding the +/// entry if it is missing. +/// +/// The bitmap comes from the group's external members and `vlan_id`. +/// If it is missing, [`readd_underlay_entries`] restores it before the +/// replication entry and route. +fn ensure_bitmap_vlan( s: &Switch, - ipv4: Ipv4Addr, - sources: &[IpSrc], + group: &MulticastGroup, + vlan_id: Option, ) -> DpdResult<()> { - // If empty or `Any` was present, only a /0 entry was added - if sources.is_empty() || sources_contain_any(sources) { - return table::mcast::mcast_src_filter::del_ipv4_entry( - s, - Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), - ipv4, - ); - } + let Some(Replication::Active { info, .. }) = group.replication() else { + return Ok(()); + }; - for src in sources { - let prefix = match src { - IpSrc::Exact(IpAddr::V4(addr)) => Ipv4Net::new(*addr, 32).unwrap(), - _ => continue, - }; - table::mcast::mcast_src_filter::del_ipv4_entry(s, prefix, ipv4)?; + match update_bitmap_vlan(s, group, vlan_id) { + Err(DpdError::Switch(AsicError::Missing(detail))) => warn!( + s.log, + "re-adding missing multicast bitmap entry"; + "group_ip" => %group.ip(), + "detail" => detail, + ), + res => return res, } - Ok(()) + + let port_bitmap = create_port_bitmap(group.members(), Direction::External); + readd_underlay_entries(s, group, info, &port_bitmap, vlan_id) } -fn remove_ipv6_source_filters( +/// Re-add the bitmap, replication, and route entries for an active +/// underlay group whose bitmap entry went missing. +/// +/// The entries are rebuilt from the caller's bitmap and VLAN, the group's IDs, +/// and replication `info`. An existing replication entry gets rewritten, since +/// its IDs could be stale. Existing routes are kept because underlay routes +/// don't carry a VLAN. +fn readd_underlay_entries( s: &Switch, - ipv6: Ipv6Addr, - sources: &[IpSrc], + group: &MulticastGroup, + info: &MulticastReplicationInfo, + port_bitmap: &table::mcast::mcast_egress::PortBitmap, + vlan_id: Option, ) -> DpdResult<()> { - // If empty or `Any` was present, only a ::/0 entry was added - if sources.is_empty() || sources_contain_any(sources) { - return table::mcast::mcast_src_filter::del_ipv6_entry( - s, - Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 0).unwrap(), - ipv6, - ); - } + table::mcast::mcast_egress::add_bitmap_entry( + s, + group.external_group_id(), + port_bitmap, + vlan_id, + )?; - for src in sources { - let prefix = match src { - IpSrc::Exact(IpAddr::V6(addr)) => Ipv6Net::new(*addr, 128).unwrap(), - _ => continue, - }; - table::mcast::mcast_src_filter::del_ipv6_entry(s, prefix, ipv6)?; - } - Ok(()) -} + let IpAddr::V6(ipv6) = group.ip() else { + return Ok(()); + }; -/// Returns true if the source list contains `IpSrc::Any`. -/// -/// Used to optimize P4 source filter table entries. When `Any` is present, -/// we only add a single `/0` entry instead of individual entries for each -/// specific source, since `/0` allows all sources anyway. -/// -/// This handles the ASM lifecycle where a group may start with specific -/// sources (e.g., `[Exact(1.2.3.4), Exact(5.6.7.8)]`) and later have an -/// "any source" member join (becoming `[Exact(1.2.3.4), Exact(5.6.7.8), Any]`). -/// In the latter case, only the `/0` entry is added to the P4 table. -pub(super) fn sources_contain_any(sources: &[IpSrc]) -> bool { - sources.iter().any(|s| matches!(s, IpSrc::Any)) + add_replication_entry(s, group, info)?; + + ignore_exists(table::mcast::mcast_route::add_ipv6_entry(s, ipv6, vlan_id)) } -/// Generate a default tag for a multicast group if none is provided. -/// -/// Format: `{uuid}:{group_ip}` to match Omicron's tag format. -/// This ensures uniqueness across the group's lifecycle and prevents -/// tag collision when group IPs are reused after deletion. -fn generate_default_tag(group_ip: IpAddr) -> String { - format!("{}:{group_ip}", Uuid::new_v4()) +/// Remove source filters for a multicast group. +fn remove_source_filters( + s: &Switch, + group_ip: IpAddr, + sources: &SourceFilter, +) -> DpdResult<()> { + apply_each(sources.iter(), |source| { + ignore_missing(remove_source_filter(s, group_ip, source)) + }) + .map_err(|failures| failures.head.1) } -/// Canonicalize sources before storage to eliminate semantic ambiguity. +/// Apply an operation, `op`, to every source, moving past failures. /// -/// Multiple representations map to "allow any source" in P4: -/// - `None` (no sources specified) -/// - `Some([])` (empty source list) -/// - `Some([IpSrc::Any])` or `Some([..., IpSrc::Any, ...])` (explicit Any) +/// # Errors /// -/// This function canonicalizes all "any source" representations to `None`, -/// which is what omicron expects for ASM groups. This eliminates semantic -/// ambiguity and prevents unnecessary P4 table updates when toggling -/// between equivalent representations. -fn canonicalize_sources(sources: Option>) -> Option> { - match sources { - None => None, - Some(srcs) if srcs.is_empty() || sources_contain_any(&srcs) => None, - Some(srcs) => { - let deduped: Vec = - srcs.into_iter().collect::>().into_iter().collect(); - Some(deduped) - } +/// Returns each failing source paired with its error (in iteration order). +fn apply_each( + sources: impl Iterator, + mut op: impl FnMut(IpSrc) -> DpdResult<()>, +) -> Result<(), NonEmpty<(IpSrc, DpdError)>> { + match NonEmpty::collect( + sources + .filter_map(|source| op(source.clone()).err().map(|e| (source, e))), + ) { + Some(failures) => Err(failures), + None => Ok(()), } } -fn add_source_filters( +/// Synchronize ASIC source-filter entries with the given `desired` +/// source filter. +/// +/// This operation adds every entry into `desired`, and, if successful, +/// then removes entries in `current` that are not in the `desired` filter. +/// +/// Existing additions and missing removals are ignored. +pub(super) fn sync_source_filters( s: &Switch, group_ip: IpAddr, - sources: &[IpSrc], -) -> DpdResult<()> { - match group_ip { - IpAddr::V4(ipv4) => add_ipv4_source_filters(s, sources, ipv4), - IpAddr::V6(ipv6) => add_ipv6_source_filters(s, sources, ipv6), - } + current: &SourceFilter, + desired: &SourceFilter, +) -> Result<(), NonEmpty<(IpSrc, DpdError)>> { + let current: BTreeSet = current.iter().collect(); + let desired: BTreeSet = desired.iter().collect(); + + apply_each(desired.iter().cloned(), |source| { + ignore_exists(add_source_filter(s, group_ip, source)) + })?; + + apply_each(current.difference(&desired).cloned(), |source| { + ignore_missing(remove_source_filter(s, group_ip, source)) + }) } -fn add_ipv4_source_filters( +/// Restore ASIC source-filter entries from `applied` to the `original` +/// source filter. +/// +/// This operation adds every entry in `original`, then removes entries +/// present in the `applied` filter but not in the `original` one. The removals +/// run even if some additions fail. +/// +/// Existing additions and missing removals are ignored. Failures from both +/// steps are returned along with their source. +pub(super) fn restore_source_filters( s: &Switch, - sources: &[IpSrc], - dest_ip: Ipv4Addr, -) -> DpdResult<()> { - // If empty or `Any` is present, add the /0 entry to allow any source - if sources.is_empty() || sources_contain_any(sources) { - return table::mcast::mcast_src_filter::add_ipv4_entry( - s, - Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), - dest_ip, - ); + group_ip: IpAddr, + applied: &SourceFilter, + original: &SourceFilter, +) -> Result<(), NonEmpty<(IpSrc, DpdError)>> { + let applied: BTreeSet = applied.iter().collect(); + let original: BTreeSet = original.iter().collect(); + + let added = apply_each(original.iter().cloned(), |source| { + ignore_exists(add_source_filter(s, group_ip, source)) + }); + let removed = + apply_each(applied.difference(&original).cloned(), |source| { + ignore_missing(remove_source_filter(s, group_ip, source)) + }); + + match (added, removed) { + (Err(mut failures), Err(more)) => { + failures.extend(more); + Err(failures) + } + (Err(failures), Ok(())) | (Ok(()), Err(failures)) => Err(failures), + (Ok(()), Ok(())) => Ok(()), } +} - // Track successfully added entries for rollback on failure - let mut added_prefixes = Vec::new(); +#[derive(Clone, Copy)] +enum SourceFilterKey { + V4 { group: Ipv4Addr, prefix: Ipv4Net }, + V6 { group: Ipv6Addr, prefix: Ipv6Net }, +} - for src in sources { - let prefix = match src { - IpSrc::Exact(IpAddr::V4(addr)) => Ipv4Net::new(*addr, 32).unwrap(), - _ => continue, - }; - if let Err(e) = - table::mcast::mcast_src_filter::add_ipv4_entry(s, prefix, dest_ip) - { - // Rollback: remove previously added entries - for added in added_prefixes { - let _ = table::mcast::mcast_src_filter::del_ipv4_entry( - s, added, dest_ip, - ); +impl SourceFilterKey { + /// Pair a group address with a source as a source-filter table key. + /// + /// `IpSrc::Any` converts into the family's `/0` prefix (generic); an + /// exact source becomes a host prefix. + /// + /// # Errors + /// + /// Returns [`DpdError::Invalid`] if an exact source's address family + /// differs from `group_ip`'s. + fn new(group_ip: IpAddr, source: IpSrc) -> DpdResult { + match (group_ip, source) { + (IpAddr::V4(group), IpSrc::Any) => Ok(Self::V4 { + group, + prefix: Ipv4Net::new_unchecked(Ipv4Addr::UNSPECIFIED, 0), + }), + (IpAddr::V4(group), IpSrc::Exact(IpAddr::V4(addr))) => { + Ok(Self::V4 { group, prefix: Ipv4Net::host_net(addr) }) } - return Err(e); + (IpAddr::V6(group), IpSrc::Any) => Ok(Self::V6 { + group, + prefix: Ipv6Net::new_unchecked(Ipv6Addr::UNSPECIFIED, 0), + }), + (IpAddr::V6(group), IpSrc::Exact(IpAddr::V6(addr))) => { + Ok(Self::V6 { group, prefix: Ipv6Net::host_net(addr) }) + } + (_, IpSrc::Exact(source)) => Err(DpdError::Invalid(format!( + "source {source} does not match multicast group address \ + family ({group_ip})" + ))), } - added_prefixes.push(prefix); } - Ok(()) -} -fn add_ipv6_source_filters( - s: &Switch, - sources: &[IpSrc], - dest_ip: Ipv6Addr, -) -> DpdResult<()> { - // If empty or `Any` is present, add the ::/0 entry to allow any source - if sources.is_empty() || sources_contain_any(sources) { - return table::mcast::mcast_src_filter::add_ipv6_entry( - s, - Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 0).unwrap(), - dest_ip, - ); + /// Add this key's entry to the source-filter table. + fn add(self, s: &Switch) -> DpdResult<()> { + match self { + Self::V4 { group, prefix } => { + table::mcast::mcast_src_filter::add_ipv4_entry(s, prefix, group) + } + Self::V6 { group, prefix } => { + table::mcast::mcast_src_filter::add_ipv6_entry(s, prefix, group) + } + } } - // Track successfully added entries for rollback on failure - let mut added_prefixes = Vec::new(); - - for src in sources { - let prefix = match src { - IpSrc::Exact(IpAddr::V6(addr)) => Ipv6Net::new(*addr, 128).unwrap(), - _ => continue, - }; - if let Err(e) = - table::mcast::mcast_src_filter::add_ipv6_entry(s, prefix, dest_ip) - { - // Rollback: remove previously added entries - for added in added_prefixes { - let _ = table::mcast::mcast_src_filter::del_ipv6_entry( - s, added, dest_ip, - ); + /// Delete this key's entry from the source-filter table. + fn del(self, s: &Switch) -> DpdResult<()> { + match self { + Self::V4 { group, prefix } => { + table::mcast::mcast_src_filter::del_ipv4_entry(s, prefix, group) + } + Self::V6 { group, prefix } => { + table::mcast::mcast_src_filter::del_ipv6_entry(s, prefix, group) } - return Err(e); } - added_prefixes.push(prefix); } - Ok(()) } -fn validate_internal_group_creation( - mcast: &MulticastGroupData, - group_ip: UnderlayMulticastIpv6, +fn add_source_filter( + s: &Switch, + group_ip: IpAddr, + source: IpSrc, ) -> DpdResult<()> { - validate_group_exists(mcast, group_ip.into())?; - Ok(()) + SourceFilterKey::new(group_ip, source).and_then(|key| key.add(s)) } -fn validate_external_group_creation( - mcast: &MulticastGroupData, +fn remove_source_filter( + s: &Switch, group_ip: IpAddr, - group_info: &MulticastGroupCreateExternalEntry, + source: IpSrc, ) -> DpdResult<()> { - validate_group_exists(mcast, group_ip)?; - validate_multicast_address(group_ip, group_info.sources.as_deref())?; - validate_not_underlay_subnet(group_ip)?; + SourceFilterKey::new(group_ip, source).and_then(|key| key.del(s)) +} + +/// Validates that the request tag matches the existing group's tag. +/// +/// Tags are immutable after group creation. A matching tag is evidence the +/// caller created the group. +fn validate_tag(existing_tag: &str, request_tag: &str) -> DpdResult<()> { + if request_tag != existing_tag { + return Err(DpdError::Invalid( + "tag mismatch: provided tag does not match the group's tag" + .to_string(), + )); + } Ok(()) } -fn validate_group_exists( - mcast: &MulticastGroupData, +/// Generate a default tag for a multicast group if none is provided. +/// +/// Format: `{uuid}:{group_ip}` to match Omicron's tag format. +/// This ensures uniqueness across the group's lifecycle and prevents +/// tag collision when group IPs are reused after deletion. +fn generate_default_tag(group_ip: IpAddr) -> String { + format!("{}:{group_ip}", Uuid::new_v4()) +} + +fn add_source_filters( + s: &Switch, group_ip: IpAddr, + sources: &SourceFilter, ) -> DpdResult<()> { - if mcast.groups.contains_key(&group_ip) { - return Err(DpdError::Exists(format!( - "multicast group for IP {group_ip} already exists", - ))); + let keys = sources + .iter() + .map(|source| SourceFilterKey::new(group_ip, source)) + .collect::>>()?; + + keys.iter().enumerate().try_for_each(|(added, &key)| { + key.add(s).map_err(|e| { + after_unwind( + e, + unwind_outcome(keys[..added].iter().map(|key| key.del(s))), + ) + }) + }) +} + +/// Delete every route, NAT, and source-filter entry identified by `group_ip`, +/// all before an external group is created at that address. +/// +/// When group creation fails, a rollback failure can leave entries around +/// that no stored group owns. The NAT key includes the VLAN, so an entry +/// left under a different VLAN could keep translating this VLAN's traffic to +/// a stale target. And, a leftover source filter could keep admitting sources +/// a group should no longer allow. +fn clear_external_entries(s: &Switch, group_ip: IpAddr) -> DpdResult<()> { + match group_ip { + IpAddr::V4(ipv4) => { + table::mcast::mcast_src_filter::del_ipv4_entries(s, ipv4)?; + table::mcast::mcast_nat::del_ipv4_entries(s, ipv4)?; + ignore_missing(table::mcast::mcast_route::del_ipv4_entry(s, ipv4)) + } + IpAddr::V6(ipv6) => { + table::mcast::mcast_src_filter::del_ipv6_entries(s, ipv6)?; + table::mcast::mcast_nat::del_ipv6_entries(s, ipv6)?; + ignore_missing(table::mcast::mcast_route::del_ipv6_entry(s, ipv6)) + } } - Ok(()) } /// Configures external tables for an external multicast group. fn configure_external_tables( s: &Switch, group_info: &MulticastGroupCreateExternalEntry, + sources: &SourceFilter, ) -> DpdResult<()> { - let group_ip = group_info.group_ip; - let nat_target = - group_info.internal_forwarding.nat_target.ok_or_else(|| { - DpdError::Invalid( - "external groups must have NAT target".to_string(), - ) - })?; + let group_ip: IpAddr = group_info.group_ip().into(); + let nat_target: NatTarget = + group_info.internal_forwarding().nat_target.into(); + let vlan_id = group_info.external_forwarding().vlan_id; // Add source filter entries if needed - add_source_filters(s, group_ip, sources_as_slice(&group_info.sources))?; + add_source_filters(s, group_ip, sources)?; // Add NAT entry - let vlan_id = group_info.external_forwarding.vlan_id; match group_ip { IpAddr::V4(ipv4) => { table::mcast::mcast_nat::add_ipv4_entry( @@ -1388,16 +1545,12 @@ fn configure_external_tables( // Add routing entry match group_ip { - IpAddr::V4(ipv4) => table::mcast::mcast_route::add_ipv4_entry( - s, - ipv4, - group_info.external_forwarding.vlan_id, - ), - IpAddr::V6(ipv6) => table::mcast::mcast_route::add_ipv6_entry( - s, - ipv6, - group_info.external_forwarding.vlan_id, - ), + IpAddr::V4(ipv4) => { + table::mcast::mcast_route::add_ipv4_entry(s, ipv4, vlan_id) + } + IpAddr::V6(ipv6) => { + table::mcast::mcast_route::add_ipv6_entry(s, ipv6, vlan_id) + } } } @@ -1412,13 +1565,34 @@ fn allocate_multicast_group_ids( ) -> DpdResult<(ScopedGroupId, ScopedGroupId)> { debug!(s.log, "creating multicast group IDs for IP {group_ip}"); - // Always allocate both group IDs to avoid allocation delays during member addition + // Always allocate both group IDs to avoid allocation delays during member + // addition let external_group_id = mcast.generate_group_id()?; let underlay_group_id = mcast.generate_group_id()?; - // Create ASIC groups without holding the lock + // Remove any stale decap bitmap data for external multicast groups + // before using the recycled external ID. + // + // A failed rollback can leave ASIC entries behind. A restart can also + // repopulate the free pool while the ASIC retains its entries. + // `tbl_decap_ports` is keyed only by `egress_rid`, so a group without a + // bitmap of its own could inherit a stale entry. + ignore_missing(table::mcast::mcast_egress::del_bitmap_entry( + s, + external_group_id.id(), + ))?; + create_asic_group(s, external_group_id.id(), group_ip)?; - create_asic_group(s, underlay_group_id.id(), group_ip)?; + create_asic_group(s, underlay_group_id.id(), group_ip).map_err(|e| { + after_unwind( + e, + ignore_missing( + s.asic_hdl + .mc_group_destroy(external_group_id.id()) + .map_err(DpdError::from), + ), + ) + })?; Ok((external_group_id, underlay_group_id)) } @@ -1426,32 +1600,43 @@ fn allocate_multicast_group_ids( fn delete_multicast_groups( s: &Switch, group_ip: IpAddr, - external_group_id: ScopedGroupId, - underlay_group_id: ScopedGroupId, + external_id: MulticastGroupId, + underlay_id: MulticastGroupId, ) -> DpdResult<()> { - let external_id = external_group_id.id(); - if let Err(e) = s.asic_hdl.mc_group_destroy(external_id) { - warn!( - s.log, - "failed to delete external multicast group"; - "IP" => %group_ip, - "ID" => external_id, - "error" => ?e, - ); + let mut first_err = None; + + match s.asic_hdl.mc_group_destroy(external_id) { + Ok(()) | Err(AsicError::Missing(_)) => {} + Err(e) => { + warn!( + s.log, + "failed to delete external multicast group"; + "group_ip" => %group_ip, + "group_id" => external_id, + "error" => ?e, + ); + first_err.get_or_insert_with(|| e.into()); + } } - let underlay_id = underlay_group_id.id(); - if let Err(e) = s.asic_hdl.mc_group_destroy(underlay_id) { - warn!( - s.log, - "failed to delete underlay multicast group"; - "IP" => %group_ip, - "ID" => underlay_id, - "error" => ?e, - ); + match s.asic_hdl.mc_group_destroy(underlay_id) { + Ok(()) | Err(AsicError::Missing(_)) => {} + Err(e) => { + warn!( + s.log, + "failed to delete underlay multicast group"; + "group_ip" => %group_ip, + "group_id" => underlay_id, + "error" => ?e, + ); + first_err.get_or_insert_with(|| e.into()); + } } - Ok(()) + match first_err { + Some(err) => Err(err), + None => Ok(()), + } } fn create_asic_group( @@ -1459,12 +1644,35 @@ fn create_asic_group( group_id: MulticastGroupId, group_ip: IpAddr, ) -> DpdResult<()> { - s.asic_hdl.mc_group_create(group_id).map_err(|e: AsicError| { - DpdError::McastGroupFailure(format!( - "failed to create multicast group for IP {group_ip} with ID \ - {group_id}: {e:?}" - )) - }) + let log_failure = |e: AsicError| { + error!( + s.log, + "failed to create multicast group"; + "group_ip" => %group_ip, + "group_id" => group_id, + "error" => ?e, + ); + DpdError::from(e) + }; + + match s.asic_hdl.mc_group_create(group_id) { + Err(AsicError::Exists(detail)) => { + warn!( + s.log, + "recreating recycled multicast group"; + "group_ip" => %group_ip, + "group_id" => group_id, + "detail" => detail, + ); + + ignore_missing( + s.asic_hdl.mc_group_destroy(group_id).map_err(DpdError::from), + )?; + + s.asic_hdl.mc_group_create(group_id).map_err(log_failure) + } + res => res.map_err(log_failure), + } } fn add_ports_to_groups( @@ -1474,7 +1682,7 @@ fn add_ports_to_groups( external_group_id: MulticastGroupId, underlay_group_id: MulticastGroupId, replication_info: &MulticastReplicationInfo, - added_members: &mut Vec<(PortId, LinkId, Direction)>, + added_members: &mut Vec, ) -> DpdResult<()> { for member in members { let group_id = match member.direction { @@ -1492,13 +1700,17 @@ fn add_ports_to_groups( replication_info.level1_excl_id, ) .map_err(|e| { - DpdError::McastGroupFailure(format!( - "failed to add port {port_id} to group for IP {group_ip}: {e:?}", - port_id = member.port_id - )) + error!( + s.log, + "failed to add port to multicast group"; + "group_ip" => %group_ip, + "port" => %member.port_id, + "error" => ?e, + ); + DpdError::from(e) })?; - added_members.push((member.port_id, member.link_id, member.direction)); + added_members.push(member.clone()); } Ok(()) @@ -1506,257 +1718,243 @@ fn add_ports_to_groups( fn process_membership_changes( s: &Switch, - group_ip: IpAddr, new_members: &[MulticastGroupMember], group_entry: &MulticastGroup, replication_info: &MulticastReplicationInfo, -) -> DpdResult<(Vec, Vec)> { - // First validate that IPv4 doesn't have underlay members - if group_ip.is_ipv4() - && new_members.iter().any(|m| m.direction == Direction::Underlay) - { - return Err(DpdError::Invalid(format!( - "multicast group for IPv4 {group_ip} cannot have underlay members" - ))); - } - - let prev_members = - group_entry.members.iter().cloned().collect::>(); - let new_members_set = new_members.iter().cloned().collect::>(); + added_members: &mut Vec, + removed_members: &mut Vec, +) -> DpdResult<()> { + let group_ip = group_entry.ip(); - let mut added_members = Vec::new(); - let mut removed_members = Vec::new(); + let prev_members = group_entry.members().collect::>(); + let new_members_set = new_members.iter().collect::>(); // Remove members from ASIC - for member in prev_members.difference(&new_members_set) { - let group_id = match member.direction { - Direction::External => group_entry.external_group_id(), - Direction::Underlay => group_entry.underlay_group_id(), - }; + let mut seen = HashSet::new(); + for member in group_entry + .members() + .filter(|m| !new_members_set.contains(m) && seen.insert(*m)) + { + let group_id = group_entry.group_id(member.direction); let asic_id = s.port_link_to_asic_id(member.port_id, member.link_id)?; - s.asic_hdl.mc_port_remove(group_id, asic_id).map_err(|e| { - DpdError::McastGroupFailure(format!( - "failed to remove port {port_id} from group for IP {group_ip}: {e:?}", - port_id = member.port_id - )) - })?; - removed_members.push(member.clone()); - } - // Add new members to ASIC - for member in new_members_set.difference(&prev_members) { - if group_ip.is_ipv4() && member.direction == Direction::Underlay { - continue; + match s.asic_hdl.mc_port_remove(group_id, asic_id) { + Ok(()) => {} + Err(AsicError::Missing(detail)) => debug!( + s.log, + "port already absent from multicast group"; + "group_ip" => %group_ip, + "port" => %member.port_id, + "detail" => detail, + ), + Err(e) => { + error!( + s.log, + "failed to remove port from multicast group"; + "group_ip" => %group_ip, + "port" => %member.port_id, + "error" => ?e, + ); + return Err(e.into()); + } } + } - let group_id = match member.direction { - Direction::External => group_entry.external_group_id(), - Direction::Underlay => group_entry.underlay_group_id(), - }; + // Add all members to the ASIC with those already present being ignored + let mut seen = HashSet::new(); + for member in new_members.iter().filter(|m| seen.insert(*m)) { + let group_id = group_entry.group_id(member.direction); let asic_id = s.port_link_to_asic_id(member.port_id, member.link_id)?; - s.asic_hdl - .mc_port_add( - group_id, - asic_id, - replication_info.rid, - replication_info.level1_excl_id, - ) - .map_err(|e| { - DpdError::McastGroupFailure(format!( - "failed to add port {port_id} to group for IP {group_ip}: {e:?}", - port_id = member.port_id - )) - })?; + match s.asic_hdl.mc_port_add( + group_id, + asic_id, + replication_info.rid, + replication_info.level1_excl_id, + ) { + Ok(()) => {} + Err(AsicError::Exists(detail)) => debug!( + s.log, + "port already present in multicast group"; + "group_ip" => %group_ip, + "port" => %member.port_id, + "detail" => detail, + ), + Err(e) => { + error!( + s.log, + "failed to add port to multicast group"; + "group_ip" => %group_ip, + "port" => %member.port_id, + "error" => ?e, + ); + return Err(e.into()); + } + } - added_members.push(member.clone()); + if !prev_members.contains(member) { + added_members.push(member.clone()); + } } - Ok((added_members, removed_members)) -} - -/// Default level exclusion IDs to 0 for internal groups -/// since they can only be configured internally without API calls. -fn configure_replication( - external_group_id: MulticastGroupId, -) -> MulticastReplicationInfo { - MulticastReplicationInfo { - rid: external_group_id, - level1_excl_id: 0, - level2_excl_id: 0, - } + Ok(()) } #[allow(clippy::too_many_arguments)] -fn configure_internal_tables( +fn configure_underlay_tables( s: &Switch, - group_ip: IpAddr, + group_ip: UnderlayMulticastIpv6, external_group_id: MulticastGroupId, underlay_group_id: MulticastGroupId, replication_info: Option<&MulticastReplicationInfo>, - added_members: &[(PortId, LinkId, Direction)], + added_members: &[MulticastGroupMember], vlan_id: Option, // VLAN ID from referencing external group ) -> DpdResult<()> { - match (group_ip, replication_info) { - // Note: There are no internal IPv4 groups, only external IPv4 groups - (IpAddr::V4(_), _) => Err(DpdError::Invalid( - "IPv4 groups cannot be created as internal groups".to_string(), - )), - - (IpAddr::V6(ipv6), Some(replication_info)) => { - table::mcast::mcast_replication::add_ipv6_entry( - s, - ipv6, - underlay_group_id, - external_group_id, - replication_info.rid, - replication_info.level1_excl_id, - replication_info.level2_excl_id, - ) - .and_then(|_| { - table::mcast::mcast_route::add_ipv6_entry( - s, ipv6, - vlan_id, // VLAN from referencing external group - ) - }) - .and_then(|_| { - // Add bitmap entry for overlay members only - // (decapsulation decision only needed for overlay traffic) - let external_port_bitmap = - create_external_port_bitmap(added_members); - table::mcast::mcast_egress::add_bitmap_entry( - s, - external_group_id, - &external_port_bitmap, - vlan_id, // VLAN from referencing external group - ) - }) - } + let ipv6 = Ipv6Addr::from(group_ip); - (IpAddr::V6(ipv6), None) => { - // For empty groups, just add basic route entry - no replication needed yet - table::mcast::mcast_route::add_ipv6_entry( - s, ipv6, vlan_id, // VLAN from referencing external group - ) - } + if let Some(replication_info) = replication_info { + // Add a bitmap entry for overlay members only + // (the decap decision is only needed for overlay traffic) + let external_port_bitmap = + create_port_bitmap(added_members, Direction::External); + table::mcast::mcast_egress::add_bitmap_entry( + s, + external_group_id, + &external_port_bitmap, + vlan_id, // VLAN from referencing external group + )?; + table::mcast::mcast_replication::add_ipv6_entry( + s, + ipv6, + underlay_group_id, + external_group_id, + replication_info.rid, + replication_info.level1_excl_id, + replication_info.level2_excl_id, + )?; } + + table::mcast::mcast_route::add_ipv6_entry( + s, ipv6, vlan_id, // VLAN from referencing external group + ) } -fn update_group_tables( +fn add_replication_entry( s: &Switch, - group_ip: IpAddr, group_entry: &MulticastGroup, replication_info: &MulticastReplicationInfo, - new_sources: &Option>, - old_sources: &Option>, ) -> DpdResult<()> { - if let Some(existing_replication) = &group_entry.replication_info { - if replication_info.rid != existing_replication.rid - || replication_info.level1_excl_id - != existing_replication.level1_excl_id - || replication_info.level2_excl_id - != existing_replication.level2_excl_id - { + let GroupKind::Underlay { group_ip, .. } = &group_entry.kind else { + return Ok(()); + }; + + let ipv6 = Ipv6Addr::from(*group_ip); + + match table::mcast::mcast_replication::add_ipv6_entry( + s, + ipv6, + group_entry.underlay_group_id(), + group_entry.external_group_id(), + replication_info.rid, + replication_info.level1_excl_id, + replication_info.level2_excl_id, + ) { + Err(DpdError::Switch(AsicError::Exists(_))) => { update_replication_tables( s, - group_ip, + ipv6, group_entry.external_group_id(), group_entry.underlay_group_id(), replication_info, - )?; - } - } else { - // First time setting up replication for this group - use add instead of update - match group_ip { - IpAddr::V4(_) => {} // IPv4 groups don't have replication entries - IpAddr::V6(ipv6) => { - table::mcast::mcast_replication::add_ipv6_entry( - s, - ipv6, - group_entry.underlay_group_id(), - group_entry.external_group_id(), - replication_info.rid, - replication_info.level1_excl_id, - replication_info.level2_excl_id, - )?; - } + ) } + res => res, } +} - if new_sources != old_sources { - remove_source_filters(s, group_ip, sources_as_slice(old_sources))?; - add_source_filters(s, group_ip, sources_as_slice(new_sources))?; - } - - Ok(()) +/// A pending VLAN change to an underlay group's port bitmap. +/// +/// An external multicast group propagates its VLAN onto the underlay group +/// mapped by its NAT target. Retargeting the NAT target or modifying the +/// VLAN yields an update for the new underlay group, and, when that target +/// is moved, a second update clearing the old one. +struct BitmapVlanUpdate<'a> { + group: &'a MulticastGroup, + old_vlan_id: Option, + new_vlan_id: Option, } fn update_external_tables( s: &Switch, - group_ip: IpAddr, group_entry: &MulticastGroup, new_group_info: &MulticastGroupUpdateExternalEntry, + old_nat_target: ExternalNatTarget, + old_sources: &SourceFilter, + new_sources: &SourceFilter, + bitmap_updates: &[BitmapVlanUpdate<'_>], ) -> DpdResult<()> { - // Update sources if they changed (normalize both sides for comparison) - let new_sources = canonicalize_sources(new_group_info.sources.clone()); - if new_sources != group_entry.sources { - remove_source_filters( - s, - group_ip, - sources_as_slice(&group_entry.sources), - )?; - add_source_filters(s, group_ip, sources_as_slice(&new_sources))?; - } + let group_ip = group_entry.ip(); + + // Sync sources first + sync_source_filters(s, group_ip, old_sources, new_sources) + .map_err(|failures| failures.head.1)?; + + let old_vlan_id = group_entry.vlan_id(); + let new_vlan_id = new_group_info.external_forwarding.vlan_id; + + let new_nat_target = new_group_info.internal_forwarding.nat_target; + + // Route tables use simple dst_addr matching but select forward vs forward_vlan action + match group_ip { + IpAddr::V4(ipv4) => { + table::mcast::mcast_route::ensure_ipv4_entry(s, ipv4, new_vlan_id) + } + IpAddr::V6(ipv6) => { + table::mcast::mcast_route::ensure_ipv6_entry(s, ipv6, new_vlan_id) + } + }?; - let old_vlan_id = group_entry.ext_fwding.vlan_id; - let new_vlan_id = new_group_info.external_forwarding.vlan_id; + let mut attempted_bitmaps = 0; - // Update NAT target - external groups always have NAT targets - // Also handles VLAN changes since VLAN is part of the NAT match key - let new_nat_target = - new_group_info.internal_forwarding.nat_target.ok_or_else(|| { - DpdError::Invalid( - "external groups must have NAT target".to_string(), - ) - })?; + let res = (|| { + for update in bitmap_updates { + attempted_bitmaps += 1; + ensure_bitmap_vlan(s, update.group, update.new_vlan_id)?; + } - if Some(new_nat_target) != group_entry.int_fwding.nat_target - || old_vlan_id != new_vlan_id - { + // Update NAT target as external groups always have NAT targets; + // this also handles VLAN changes since VLAN is part of the NAT match + // key update_nat_tables( s, group_ip, - Some(new_nat_target), - group_entry.int_fwding.nat_target, + new_nat_target.into(), + old_nat_target.into(), old_vlan_id, new_vlan_id, )?; - } + Ok(()) + })(); - // Update route table VLAN if it changed - // Route tables use simple dst_addr matching but select forward vs forward_vlan action - if old_vlan_id != new_vlan_id { - match group_ip { - IpAddr::V4(ipv4) => table::mcast::mcast_route::update_ipv4_entry( - s, - ipv4, - old_vlan_id, - new_vlan_id, - ), - IpAddr::V6(ipv6) => table::mcast::mcast_route::update_ipv6_entry( - s, - ipv6, - old_vlan_id, - new_vlan_id, - ), - }?; - } + match res { + Ok(()) => Ok(()), + Err(e) => { + let rollback_ctx = + GroupUpdateRollbackContext::new(s, group_entry, new_vlan_id); - Ok(()) + Err(rollback_ctx.rollback_with_bitmap_restore( + e, + bitmap_updates[..attempted_bitmaps] + .iter() + .rev() + .map(|update| (update.group, update.old_vlan_id)), + )) + } + } } /// Delete bitmap entries for a group with replication checks. @@ -1764,13 +1962,36 @@ fn delete_group_bitmap_entries( s: &Switch, group: &MulticastGroup, ) -> DpdResult<()> { - // Only delete bitmap entries if the group had replication info - // (which indicates bitmap entries were created) - if group.replication_info.is_none() { - return Ok(()); // No bitmap entries were ever created + // Underlay groups delete entries even when `Replication::Empty` because + // a failed rollback can leave entries behind. + // + // Note: external groups share the underlay group's IDs, but never own + // a bitmap entry. + if group.replication().is_none() { + return Ok(()); // External group } // Delete external bitmap entry only (underlay doesn't use decap bitmap) - table::mcast::mcast_egress::del_bitmap_entry(s, group.external_group_id()) + ignore_missing(table::mcast::mcast_egress::del_bitmap_entry( + s, + group.external_group_id(), + )) +} + +fn add_group_bitmap_entries( + s: &Switch, + group: &MulticastGroup, + vlan_id: Option, +) -> DpdResult<()> { + if group.replication_info().is_none() { + return Ok(()); + } + let port_bitmap = create_port_bitmap(group.members(), Direction::External); + table::mcast::mcast_egress::add_bitmap_entry( + s, + group.external_group_id(), + &port_bitmap, + vlan_id, + ) } /// Cleanup replication tables when transitioning group to empty membership. @@ -1779,217 +2000,197 @@ fn delete_group_bitmap_entries( /// replication entries. fn cleanup_empty_group_replication( s: &Switch, - group_ip: IpAddr, group_entry: &MulticastGroup, + external_group_vlan_id: Option, ) -> DpdResult<()> { + let group_ip = group_entry.ip(); + debug!(s.log, "cleaning up replication for empty group {group_ip}"); // Only proceed if group actually has replication info to clean up - if group_entry.replication_info.is_none() { + if group_entry.replication_info().is_none() { return Ok(()); } - // Attempt cleanup operations in sequence - delete_group_bitmap_entries(s, group_entry) - .and_then(|_| delete_replication_entries(s, group_ip, group_entry)) -} + delete_group_bitmap_entries(s, group_entry)?; -/// Create a port bitmap for external (overlay) members only. -fn create_external_port_bitmap( - added_members: &[(PortId, LinkId, Direction)], -) -> table::mcast::mcast_egress::PortBitmap { - let external_members: Vec = added_members - .iter() - .filter(|(_, _, direction)| *direction == Direction::External) - .map(|(port_id, link_id, direction)| MulticastGroupMember { - port_id: *port_id, - link_id: *link_id, - direction: *direction, - }) - .collect(); - create_port_bitmap(&external_members, Direction::External) + if let Err(err) = delete_replication_entries(s, group_entry) { + if let Err(restore_err) = + add_group_bitmap_entries(s, group_entry, external_group_vlan_id) + { + return Err(DpdError::McastGroupFailure(format!( + "failed to delete replication entry: {err:?}; failed to restore bitmap: {restore_err:?}" + ))); + } + return Err(err); + } + + Ok(()) } /// Delete replication table entries. fn delete_replication_entries( s: &Switch, - group_ip: IpAddr, group: &MulticastGroup, ) -> DpdResult<()> { - // Only delete replication entries if the group had replication info - // (which indicates replication entries were created) - if group.replication_info.is_none() { - return Ok(()); // No replication entries were ever created - } - - // Delete replication entry (only IPv6 has replication table) - match group_ip { - IpAddr::V4(_) => Ok(()), // IPv4 doesn't use replication table - IpAddr::V6(ipv6) => { - table::mcast::mcast_replication::del_ipv6_entry(s, ipv6) - } - } + // As with the bitmap, underlay groups delete even when + // `Replication::Empty`. + let GroupKind::Underlay { group_ip, .. } = &group.kind else { + return Ok(()); + }; + ignore_missing(table::mcast::mcast_replication::del_ipv6_entry( + s, + Ipv6Addr::from(*group_ip), + )) } fn delete_group_tables( s: &Switch, - group_ip: IpAddr, group: &MulticastGroup, + deleted: &mut Vec, ) -> DpdResult<()> { - match group_ip { - IpAddr::V4(ipv4) => { - // Source filters and NAT entries only exist for external groups - // (which have NAT targets). - if group.int_fwding.nat_target.is_some() { - remove_ipv4_source_filters( - s, - ipv4, - sources_as_slice(&group.sources), - )?; - table::mcast::mcast_nat::del_ipv4_entry( - s, - ipv4, - group.ext_fwding.vlan_id, - )?; - } - - delete_group_bitmap_entries(s, group)?; - - table::mcast::mcast_route::del_ipv4_entry(s, ipv4)?; + let group_ip = group.ip(); + + match &group.kind { + GroupKind::External { sources, vlan_id, .. } => { + deleted.push(DeleteStep::SourceFilters); + remove_source_filters(s, group_ip, sources)?; + ignore_missing(match group_ip { + IpAddr::V4(ipv4) => { + table::mcast::mcast_nat::del_ipv4_entry(s, ipv4, *vlan_id) + } + IpAddr::V6(ipv6) => { + table::mcast::mcast_nat::del_ipv6_entry(s, ipv6, *vlan_id) + } + })?; + deleted.push(DeleteStep::Nat); } - IpAddr::V6(ipv6) => { - delete_replication_entries(s, group_ip, group)?; - - // Source filters only exist for external groups (which have - // NAT targets). Internal groups don't have source filtering. - if group.int_fwding.nat_target.is_some() { - remove_ipv6_source_filters( - s, - ipv6, - sources_as_slice(&group.sources), - )?; - table::mcast::mcast_nat::del_ipv6_entry( - s, - ipv6, - group.ext_fwding.vlan_id, - )?; - } + GroupKind::Underlay { .. } => { + delete_replication_entries(s, group)?; + deleted.push(DeleteStep::Replication); delete_group_bitmap_entries(s, group)?; - - table::mcast::mcast_route::del_ipv6_entry(s, ipv6)?; + deleted.push(DeleteStep::Bitmap); } } + ignore_missing(match group_ip { + IpAddr::V4(ipv4) => table::mcast::mcast_route::del_ipv4_entry(s, ipv4), + IpAddr::V6(ipv6) => table::mcast::mcast_route::del_ipv6_entry(s, ipv6), + })?; + deleted.push(DeleteStep::Route); + Ok(()) } fn update_replication_tables( s: &Switch, - group_ip: IpAddr, + group_ip: Ipv6Addr, external_group_id: MulticastGroupId, underlay_group_id: MulticastGroupId, replication_info: &MulticastReplicationInfo, ) -> DpdResult<()> { - match group_ip { - IpAddr::V4(_) => Ok(()), - IpAddr::V6(ipv6) => table::mcast::mcast_replication::update_ipv6_entry( - s, - ipv6, - underlay_group_id, - external_group_id, - replication_info.rid, - replication_info.level1_excl_id, - replication_info.level2_excl_id, - ), - } + table::mcast::mcast_replication::update_ipv6_entry( + s, + group_ip, + underlay_group_id, + external_group_id, + replication_info.rid, + replication_info.level1_excl_id, + replication_info.level2_excl_id, + ) } fn update_nat_tables( s: &Switch, group_ip: IpAddr, - new_nat_target: Option, - old_nat_target: Option, + new_nat_target: NatTarget, + old_nat_target: NatTarget, old_vlan_id: Option, new_vlan_id: Option, ) -> DpdResult<()> { - match (group_ip, new_nat_target, old_nat_target) { - (IpAddr::V4(ipv4), Some(new_nat), Some(old_nat)) => { - // NAT to NAT -> update existing entry (handles VLAN changes internally) - table::mcast::mcast_nat::update_ipv4_entry( - s, - ipv4, - new_nat, - old_nat, - old_vlan_id, - new_vlan_id, - ) - } - (IpAddr::V6(ipv6), Some(new_nat), Some(old_nat)) => { - // NAT to NAT -> update existing entry (handles VLAN changes internally) - table::mcast::mcast_nat::update_ipv6_entry( - s, - ipv6, - new_nat, - old_nat, - old_vlan_id, - new_vlan_id, - ) - } - (IpAddr::V4(ipv4), Some(nat), None) => { - // No NAT to NAT - add new entry - table::mcast::mcast_nat::add_ipv4_entry(s, ipv4, nat, new_vlan_id) - } - (IpAddr::V6(ipv6), Some(nat), None) => { - // No NAT to NAT - add new entry - table::mcast::mcast_nat::add_ipv6_entry(s, ipv6, nat, new_vlan_id) - } - (IpAddr::V4(ipv4), None, Some(_)) => { - // NAT to no NAT - delete entry - table::mcast::mcast_nat::del_ipv4_entry(s, ipv4, old_vlan_id) - } - (IpAddr::V6(ipv6), None, Some(_)) => { - // NAT to no NAT - delete entry - table::mcast::mcast_nat::del_ipv6_entry(s, ipv6, old_vlan_id) - } - _ => Ok(()), // No change (None → None) + // The VLAN is part of the NAT match key, which must be handled + // inside the table update. + match group_ip { + IpAddr::V4(ipv4) => table::mcast::mcast_nat::update_ipv4_entry( + s, + ipv4, + new_nat_target, + old_nat_target, + old_vlan_id, + new_vlan_id, + ), + IpAddr::V6(ipv6) => table::mcast::mcast_nat::update_ipv6_entry( + s, + ipv6, + new_nat_target, + old_nat_target, + old_vlan_id, + new_vlan_id, + ), } } -/// Update internal group's bitmap entries when members change. -fn update_internal_group_bitmap_tables( +/// Set an underlay group's bitmap entry from its members. +fn update_underlay_group_bitmap_tables( s: &Switch, - external_group_id: MulticastGroupId, + group: &MulticastGroup, new_members: &[MulticastGroupMember], - old_members: &[MulticastGroupMember], external_group_vlan: Option, ) -> DpdResult<()> { - let prev_had_members = !old_members.is_empty(); - let now_has_members = !new_members.is_empty(); + let external_group_id = group.external_group_id(); + if new_members.is_empty() { + return Ok(()); + } // Create bitmap for overlay members only (decapsulation decision applies only to overlay traffic) let external_port_bitmap = create_port_bitmap(new_members, Direction::External); - if !prev_had_members && now_has_members { + let Some(Replication::Active { info, .. }) = group.replication() else { // First time adding members - use add_bitmap_entry with external group's VLAN - table::mcast::mcast_egress::add_bitmap_entry( - s, - external_group_id, - &external_port_bitmap, - external_group_vlan, // Use external group's VLAN for bitmap entries - )?; - } else if prev_had_members && now_has_members { - // Members changed but still have members - use external group's VLAN - table::mcast::mcast_egress::update_bitmap_entry( + return match table::mcast::mcast_egress::add_bitmap_entry( s, external_group_id, &external_port_bitmap, external_group_vlan, // Use external group's VLAN for bitmap entries - )?; - } + ) { + Err(DpdError::Switch(AsicError::Exists(_))) => { + table::mcast::mcast_egress::update_bitmap_entry( + s, + external_group_id, + &external_port_bitmap, + external_group_vlan, + ) + } + res => res, + }; + }; - Ok(()) + // Members remain -> update the entry with the external group's VLAN + match table::mcast::mcast_egress::update_bitmap_entry( + s, + external_group_id, + &external_port_bitmap, + external_group_vlan, // Use external group's VLAN for bitmap entries + ) { + Err(DpdError::Switch(AsicError::Missing(detail))) => { + warn!( + s.log, + "re-adding missing multicast bitmap entry"; + "group_ip" => %group.ip(), + "detail" => detail, + ); + readd_underlay_entries( + s, + group, + info, + &external_port_bitmap, + external_group_vlan, + ) + } + res => res, + } } /// Update forwarding tables during rollback. @@ -2001,20 +2202,19 @@ fn update_internal_group_bitmap_tables( /// # Arguments /// /// * `s` - Switch instance for table operations. -/// * `group_ip` - IP address of the multicast group. -/// * `external_group_id` - ID of the external multicast group for bitmap updates. -/// * `members` - Group members used to recreate port bitmap. -/// * `current_vlan_id` - VLAN currently in the table (may be the attempted new VLAN). +/// * `group` - The group's state before the failed update. The IPv6 path +/// rewrites its bitmap entry only if it is a [`Replication::Active`] +/// underlay group. +/// * `current_vlan_id` - VLAN currently in the table (may be the attempted new +/// VLAN). /// * `target_vlan_id` - VLAN to restore to (the original VLAN). fn update_fwding_tables( s: &Switch, - group_ip: IpAddr, - external_group_id: MulticastGroupId, - members: &[MulticastGroupMember], + group: &MulticastGroup, current_vlan_id: Option, target_vlan_id: Option, ) -> DpdResult<()> { - match group_ip { + match group.ip() { IpAddr::V4(ipv4) => table::mcast::mcast_route::update_ipv4_entry( s, ipv4, @@ -2032,27 +2232,16 @@ fn update_fwding_tables( // entries exist, whereas underlay replication uses ASIC multicast // groups directly). // - // Skip if no external members to avoid spurious errors when called - // during rollback on empty groups. - if !members.iter().any(|m| m.direction == Direction::External) { - return Ok(()); - } - - let external_port_bitmap = - create_port_bitmap(members, Direction::External); - table::mcast::mcast_egress::update_bitmap_entry( - s, - external_group_id, - &external_port_bitmap, - target_vlan_id, - ) + // `update_bitmap_vlan` skips groups that are not replicating, so + // rollback on an empty or external group leaves the bitmap alone. + update_bitmap_vlan(s, group, target_vlan_id) }), } } /// Create port bitmap from members filtered by direction. -fn create_port_bitmap( - members: &[MulticastGroupMember], +fn create_port_bitmap<'a>( + members: impl IntoIterator, direction: Direction, ) -> table::mcast::mcast_egress::PortBitmap { let mut port_bitmap = table::mcast::mcast_egress::PortBitmap::new(); @@ -2066,13 +2255,50 @@ fn create_port_bitmap( #[cfg(test)] mod tests { - use super::*; - use common::ports::RearPort; use std::thread; + use common::ports::RearPort; + use dpd_types::link::LinkId; + use dpd_types::mcast::{ + ExactSource, MAX_TAG_LENGTH, MulticastGroupCreateExternalError, + SourceEntry, + }; + + use super::*; + + #[test] + fn test_validate_tag() { + // Existing tag matches request tag + assert!(validate_tag("my-tag", "my-tag").is_ok()); + + // Existing tag but request has different tag + assert!(validate_tag("owner-a", "owner-b").is_err()); + assert!(validate_tag("owner-a", "").is_err()); + assert!(validate_tag("owner-a", "tag/with/slashes").is_err()); + } + + #[test] + fn test_tag_format_error_maps_to_bad_request() { + for tag in [ + String::new(), + "a".repeat(MAX_TAG_LENGTH + 1), + "tag with spaces".to_string(), + ] { + let err = tag.parse::().unwrap_err(); + let expected = err.to_string(); + let err = DpdError::from(err); + assert!( + matches!(&err, DpdError::Invalid(message) if message == &expected) + ); + let err = dropshot::HttpError::from(err); + assert_eq!(err.status_code.as_u16(), 400); + assert_eq!(err.external_message, expected); + } + } + #[test] fn test_scoped_group_id_drop_returns_to_pool() { - let free_ids = Arc::new(Mutex::new(vec![100, 101, 102])); + let free_ids = Arc::new(Mutex::new(vec![100, 102])); { let scoped_id = ScopedGroupId::from(ScopedIdInner( 101, @@ -2082,9 +2308,7 @@ mod tests { } // ID should be returned to pool - let pool = free_ids.lock().unwrap(); - assert!(pool.contains(&101)); - assert_eq!(pool.len(), 4); // Original 3 + returned 1 + assert_eq!(*free_ids.lock().unwrap(), vec![100, 102, 101]); } #[test] @@ -2132,8 +2356,6 @@ mod tests { // Should return error when no IDs available let result = mcast_data.generate_group_id(); - assert!(result.is_err()); - match result.unwrap_err() { DpdError::ResourceExhausted(msg) => { assert!(msg.contains("no free multicast group IDs available")); @@ -2142,41 +2364,6 @@ mod tests { } } - #[test] - fn test_concurrent_id_allocation() { - let mcast_data = Arc::new(Mutex::new(MulticastGroupData::new())); - let mut handles = Vec::new(); - - // Spawn multiple threads to allocate IDs concurrently - for _ in 0..10 { - let mcast_data_clone = Arc::clone(&mcast_data); - let handle = thread::spawn(move || { - let mut data = mcast_data_clone.lock().unwrap(); - data.generate_group_id().unwrap() - }); - handles.push(handle); - } - - // Collect all allocated IDs - let mut allocated_ids = Vec::new(); - for handle in handles { - allocated_ids.push(handle.join().unwrap()); - } - - let mut ids: Vec<_> = allocated_ids.iter().map(|v| v.id()).collect(); - - // All IDs should be unique - ids.sort(); - ids.dedup(); - assert_eq!(ids.len(), 10); - - // All IDs should be in valid range - for id in ids { - assert!(id >= MulticastGroupData::GENERATOR_START); - assert!(id < MulticastGroupId::MAX); - } - } - #[test] fn test_concurrent_allocation_and_deallocation() { let mcast_data = Arc::new(Mutex::new(MulticastGroupData::new())); @@ -2202,14 +2389,14 @@ mod tests { handle.join().unwrap(); } - // Pool should have all IDs back (minus any that might still be in use) + // Every `ScopedGroupId` was already dropped before the join, meaning + // that the pool is exactly full once again. let pool_size = { let data = mcast_data.lock().unwrap(); data.free_group_ids.lock().unwrap().len() }; - // Should have close to the original number of IDs let expected_size = (MulticastGroupId::MAX - MulticastGroupData::GENERATOR_START) as usize; @@ -2273,34 +2460,6 @@ mod tests { assert_eq!(final_pool_size, initial_pool_size); } - #[test] - fn test_sources_contain_any() { - // Empty slice has no Any - assert!(!sources_contain_any(&[])); - - // Slice with only exact sources has no Any - let exact_only = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), - ]; - assert!(!sources_contain_any(&exact_only)); - - // Slice with Any returns true - let with_any = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Any, - ]; - assert!(sources_contain_any(&with_any)); - - // Slice with only Any returns true - let only_any = vec![IpSrc::Any]; - assert!(sources_contain_any(&only_any)); - - // Multiple Any entries still returns true - let multiple_any = vec![IpSrc::Any, IpSrc::Any]; - assert!(sources_contain_any(&multiple_any)); - } - #[test] fn test_create_port_bitmap_empty() { let members: Vec = vec![]; @@ -2376,44 +2535,276 @@ mod tests { assert!(!underlay_bitmap.contains_port(15)); } + fn external_ip(addr: &str) -> ExternalMulticastIp { + addr.parse().unwrap() + } + + #[test] + fn test_nat_target_group_must_exist() { + let mcast_data = MulticastGroupData::new(); + let nat_target = NatTarget { + internal_ip: "ff04::1".parse().unwrap(), + inner_mac: common::network::MacAddr::new(1, 0, 0x5e, 0, 0, 1), + vni: common::network::Vni::new(1).unwrap(), + }; + + let err = mcast_data + .get_nat_target_group( + "239.1.1.1".parse().unwrap(), + nat_target.try_into().unwrap(), + ) + .unwrap_err(); + assert!(matches!(err, DpdError::MissingNatTarget(_)), "{err:?}"); + } + + fn source_entries( + sources: Option<&[IpSrc]>, + ) -> Result>, MulticastGroupCreateExternalError> + { + sources + .map(|sources| { + sources.iter().cloned().map(SourceEntry::try_from).collect() + }) + .transpose() + } + + fn canonicalize_sources( + group_ip: ExternalMulticastIp, + sources: Option>, + ) -> Option> { + let entries = source_entries(sources.as_deref()).unwrap(); + + SourceFilter::from_entries(group_ip, entries.as_deref()) + .unwrap() + .to_ip_sources() + } + #[test] fn test_canonicalize_sources() { + let asm_group = external_ip("224.1.2.3"); + // None stays None - assert_eq!(canonicalize_sources(None), None); + assert_eq!(canonicalize_sources(asm_group, None), None); // Empty vec normalizes to None - assert_eq!(canonicalize_sources(Some(vec![])), None); + assert_eq!(canonicalize_sources(asm_group, Some(vec![])), None); // Vec with only IpSrc::Any normalizes to None - assert_eq!(canonicalize_sources(Some(vec![IpSrc::Any])), None); + assert_eq!( + canonicalize_sources(asm_group, Some(vec![IpSrc::Any])), + None + ); // Vec with Any mixed with Exact normalizes to None (Any subsumes all) assert_eq!( - canonicalize_sources(Some(vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Any, - ])), + canonicalize_sources( + asm_group, + Some(vec![ + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), + IpSrc::Any, + ]) + ), None ); - // Vec with only Exact sources is deduplicated and sorted - let exact_sources = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), - ]; - let expected = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - ]; - assert_eq!(canonicalize_sources(Some(exact_sources)), Some(expected)); + // A vec with only Exact sources is deduplicated and sorted + assert_eq!( + canonicalize_sources( + asm_group, + Some(vec![ + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), + ]) + ), + Some(vec![ + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), + ]) + ); // Single Exact source stays as-is - let single_exact = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1, - )))]; + let ipv6_group = external_ip("ff0e::1"); + assert_eq!( + canonicalize_sources( + ipv6_group, + Some(vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1 + )))]) + ), + Some(vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1 + )))]) + ); + } + + #[test] + fn test_group_key_order_and_lookup() { + let first_external = external_ip("224.1.2.3"); + let second_external = external_ip("239.255.255.250"); + let third_external = external_ip("ff0e::1"); + let underlay: UnderlayMulticastIpv6 = "ff04::1".parse().unwrap(); + + let mut map: BTreeMap = BTreeMap::new(); + map.insert(GroupKey::external(third_external), 4); + map.insert(GroupKey::external(second_external), 2); + map.insert(GroupKey::underlay(underlay), 3); + map.insert(GroupKey::external(first_external), 1); + + assert_eq!(map.get(&"224.1.2.3".parse::().unwrap()), Some(&1)); + assert_eq!( + map.get(&"239.255.255.250".parse::().unwrap()), + Some(&2) + ); + assert_eq!(map.get(&"ff04::1".parse::().unwrap()), Some(&3)); + assert_eq!(map.get(&"10.0.0.1".parse::().unwrap()), None); + + let ipv6_only: Vec = map + .range(( + Bound::Included("ff00::".parse::().unwrap()), + Bound::Unbounded, + )) + .map(|(key, _)| key.ip()) + .collect(); assert_eq!( - canonicalize_sources(Some(single_exact.clone())), - Some(single_exact) + ipv6_only, + vec![ + "ff04::1".parse::().unwrap(), + "ff0e::1".parse::().unwrap(), + ] ); } + + #[test] + fn test_source_filter_create_and_update_agree() { + type SourceFilterCase = + (&'static str, Option>, Option); + + let exact_source = |addr: &str| addr.parse::().unwrap(); + let exact = |addr: &str| IpSrc::Exact(addr.parse::().unwrap()); + + let cases: Vec = vec![ + // ASM: 1) absent, 2) empty, 3) just Any, and 4) mixed Any for + // any source. + ("224.1.2.3", None, Some(SourceFilter::Any)), + ("224.1.2.3", Some(vec![]), Some(SourceFilter::Any)), + ("224.1.2.3", Some(vec![IpSrc::Any]), Some(SourceFilter::Any)), + ( + "224.1.2.3", + Some(vec![IpSrc::Any, exact("10.1.1.1")]), + Some(SourceFilter::Any), + ), + // ASM: exact sources are deduplicated and sorted. + ( + "224.1.2.3", + Some(vec![exact("10.1.1.2"), exact("10.1.1.1")]), + Some(SourceFilter::from_exact([ + exact_source("10.1.1.1"), + exact_source("10.1.1.2"), + ])), + ), + // SSM: exact sources are required and preserved. + ( + "232.1.2.3", + Some(vec![exact("10.1.1.1")]), + Some(SourceFilter::from_exact([exact_source("10.1.1.1")])), + ), + // SSM: 1) absent, 2) empty, 3) Any, and 4) mixed Any are rejected. + ("232.1.2.3", None, None), + ("232.1.2.3", Some(vec![]), None), + ("232.1.2.3", Some(vec![IpSrc::Any]), None), + ("232.1.2.3", Some(vec![IpSrc::Any, exact("10.1.1.1")]), None), + ("ff0e::1", None, Some(SourceFilter::Any)), + ("ff0e::1", Some(vec![]), Some(SourceFilter::Any)), + ("ff0e::1", Some(vec![IpSrc::Any]), Some(SourceFilter::Any)), + ( + "ff0e::1", + Some(vec![IpSrc::Any, exact("2001:db8::1")]), + Some(SourceFilter::Any), + ), + ( + "ff3e::4000:1", + Some(vec![ + exact("2001:db8::2"), + exact("2001:db8::1"), + exact("2001:db8::2"), + ]), + Some(SourceFilter::from_exact([ + exact_source("2001:db8::1"), + exact_source("2001:db8::2"), + ])), + ), + ("ff3e::4000:1", None, None), + ("ff3e::4000:1", Some(vec![]), None), + ("ff3e::4000:1", Some(vec![IpSrc::Any]), None), + ( + "ff3e::4000:1", + Some(vec![IpSrc::Any, exact("2001:db8::1")]), + None, + ), + // Invalid source addresses and family mismatches are rejected. + ("224.1.2.3", Some(vec![exact("2001:db8::1")]), None), + ("ff0e::1", Some(vec![exact("10.1.1.1")]), None), + ("232.1.2.3", Some(vec![exact("0.0.0.0")]), None), + ("ff3e::4000:1", Some(vec![exact("fe80::1")]), None), + ("224.1.2.3", Some(vec![IpSrc::Any, exact("224.9.9.9")]), None), + ("224.1.2.3", Some(vec![IpSrc::Any, exact("2001:db8::1")]), None), + ( + "ff0e::1", + Some(vec![IpSrc::Any, exact("::ffff:192.0.2.1")]), + None, + ), + ("ff0e::1", Some(vec![IpSrc::Any, exact("10.1.1.1")]), None), + ]; + + for (group_ip, sources, expected) in cases { + let group = external_ip(group_ip); + let body = serde_json::json!({ + "group_ip": group_ip, + "tag": null, + "internal_forwarding": { + "nat_target": { + "internal_ip": "ff04::1", + "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, + "vni": 100, + } + }, + "external_forwarding": { "vlan_id": null }, + "sources": serde_json::to_value(&sources).unwrap(), + }); + + let created = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(body); + let updated = + source_entries(sources.as_deref()).and_then(|entries| { + SourceFilter::from_entries(group, entries.as_deref()) + }); + + match expected { + Some(expected) => { + let created = SourceFilter::from(&created.unwrap()); + assert_eq!( + created, expected, + "create filter for {group_ip} with {sources:?}" + ); + assert_eq!( + updated.unwrap(), + expected, + "update filter for {group_ip} with {sources:?}" + ); + } + None => { + assert!( + created.is_err(), + "create should reject {group_ip} with {sources:?}" + ); + assert!( + updated.is_err(), + "update should reject {group_ip} with {sources:?}" + ); + } + } + } + } } diff --git a/dpd/src/mcast/rollback.rs b/dpd/src/mcast/rollback.rs index fb73cd78..9d26b494 100644 --- a/dpd/src/mcast/rollback.rs +++ b/dpd/src/mcast/rollback.rs @@ -11,24 +11,46 @@ //! rollback parameters once and provide reusable error handling throughout //! multi-step operations. -use std::{ - fmt, - net::{IpAddr, Ipv4Addr, Ipv6Addr}, -}; - -use aal::AsicMulticastOps; -use oxnet::{Ipv4Net, Ipv6Net}; -use slog::{debug, error}; +use std::{fmt, net::IpAddr}; -use common::{network::NatTarget, ports::PortId}; +use aal::{AsicError, AsicMulticastOps}; +use slog::{debug, error, warn}; use super::{ - Direction, IpSrc, LinkId, MulticastGroup, MulticastGroupId, - MulticastGroupMember, MulticastReplicationInfo, NO_SOURCES, - add_source_filters, remove_source_filters, sources_contain_any, - update_fwding_tables, update_replication_tables, + Direction, GroupKind, IpSrc, MulticastGroup, MulticastGroupId, + MulticastGroupMember, MulticastReplicationInfo, Replication, SourceFilter, + add_group_bitmap_entries, add_replication_entry, add_source_filter, + remove_source_filter, restore_source_filters, update_fwding_tables, + update_replication_tables, }; -use crate::{Switch, table, types::DpdResult}; +use crate::{ + Switch, table, + types::{DpdError, DpdResult, after_unwind, ignore_exists, ignore_missing}, +}; + +const ROLLBACK_FAILURE_MSG: &str = "failed operation during rollback"; + +/// Consume all undo results and return the first rollback error, if any. +/// +/// Lazy undo steps execute during the fold, even after a failure. +// `try_fold` would skip remaining undo steps after the first error. +#[allow(clippy::manual_try_fold)] +pub(super) fn unwind_outcome( + steps: impl IntoIterator>, +) -> DpdResult<()> { + steps.into_iter().fold(Ok(()), |unwind, step| unwind.and(step)) +} + +/// Remove each filter entry, reporting failures without aborting early. +fn remove_source_filters( + sources: &SourceFilter, + mut remove: impl FnMut(IpSrc) -> Result<(), E>, +) -> Vec<(IpSrc, E)> { + sources + .iter() + .filter_map(|source| remove(source.clone()).err().map(|e| (source, e))) + .collect() +} /// Trait providing shared rollback functionality for multicast group operations. /// @@ -40,91 +62,298 @@ trait RollbackOps { fn external_group_id(&self) -> MulticastGroupId; fn underlay_group_id(&self) -> MulticastGroupId; - /// Rollback port changes (removing added ports, re-adding removed ports). - /// Each group type implements this differently - external groups are no-op, internal groups handle ports. - fn rollback_ports( + /// ASIC group that carries members of `direction`. + fn group_id(&self, direction: Direction) -> MulticastGroupId { + match direction { + Direction::External => self.external_group_id(), + Direction::Underlay => self.underlay_group_id(), + } + } + + /// Undo port changes in reverse: first remove `added_ports`, then re-add + /// `removed_ports`. Every port is tried through and the first error is + /// returned. + fn undo_port_changes( &self, added_ports: &[MulticastGroupMember], removed_ports: &[MulticastGroupMember], replication_info: &MulticastReplicationInfo, - ) -> DpdResult<()>; + ) -> DpdResult<()> { + let switch = self.switch(); + let mut first_err = None; + let mut failed_port_resolution = Vec::new(); + let mut failed_port_removal = Vec::new(); + let mut failed_port_addition = Vec::new(); + + // Remove added ports + for member in added_ports.iter().rev() { + let group_id = self.group_id(member.direction); + + match switch.port_link_to_asic_id(member.port_id, member.link_id) { + Ok(asic_id) => { + if let Err(e) = + switch.asic_hdl.mc_port_remove(group_id, asic_id) + { + error!( + switch.log, + "failed to remove port during rollback"; + "port" => %member.port_id, + "asic_id" => asic_id, + "group_id" => group_id, + "error" => ?e, + ); + + if let Err(e) = ignore_missing(Err(DpdError::from(e))) { + first_err.get_or_insert(e); + failed_port_removal + .push((member.port_id, member.link_id)); + } + } + } + Err(e) => { + error!( + switch.log, + "failed to resolve port link during rollback"; + "port" => %member.port_id, + "link" => %member.link_id, + "error" => ?e, + ); + + first_err.get_or_insert(e); + failed_port_resolution + .push((member.port_id, member.link_id)); + } + } + } + + // Re-add removed ports + for member in removed_ports.iter().rev() { + let group_id = self.group_id(member.direction); + + match switch.port_link_to_asic_id(member.port_id, member.link_id) { + Ok(asic_id) => { + if let Err(e) = switch.asic_hdl.mc_port_add( + group_id, + asic_id, + replication_info.rid, + replication_info.level1_excl_id, + ) { + error!( + switch.log, + "failed to add port during rollback"; + "port" => %member.port_id, + "asic_id" => asic_id, + "group_id" => group_id, + "error" => ?e, + ); + + if let Err(e) = ignore_exists(Err(DpdError::from(e))) { + first_err.get_or_insert(e); + failed_port_addition + .push((member.port_id, member.link_id)); + } + } + } + Err(e) => { + error!( + switch.log, + "failed to resolve port link during rollback"; + "port" => %member.port_id, + "link" => %member.link_id, + "error" => ?e, + ); + + first_err.get_or_insert(e); + failed_port_resolution + .push((member.port_id, member.link_id)); + } + } + } + + // Log summary of any failures + if !failed_port_resolution.is_empty() { + error!( + switch.log, + "rollback failed to resolve port links"; + "count" => failed_port_resolution.len(), + "ports" => ?failed_port_resolution, + "warning" => "These ports may remain in inconsistent state", + ); + } + + if !failed_port_removal.is_empty() { + error!( + switch.log, + "rollback failed to remove ports from ASIC"; + "count" => failed_port_removal.len(), + "ports" => ?failed_port_removal, + "warning" => "These ports may remain in inconsistent state", + ); + } + + if !failed_port_addition.is_empty() { + error!( + switch.log, + "rollback failed to re-add ports to ASIC"; + "count" => failed_port_addition.len(), + "ports" => ?failed_port_addition, + "warning" => "These ports may remain in inconsistent state", + ); + } + + // Return the first error encountered, if any + match first_err { + Some(e) => Err(e), + None => Ok(()), + } + } + + fn remove_source_filters(&self, sources: &SourceFilter) -> DpdResult<()> { + unwind_outcome( + remove_source_filters(sources, |source| { + remove_source_filter(self.switch(), self.group_ip(), source) + }) + .into_iter() + .map(|(source, err)| { + self.log_cleanup_error( + "delete source filter entry", + &format!( + "for source {source:?} and group {}", + self.group_ip() + ), + Err::<(), _>(err), + ) + }), + ) + } /// Rollback source filter changes. fn rollback_source_filters( &self, - new_sources: Option<&[IpSrc]>, - orig_sources: Option<&[IpSrc]>, + new_sources: &SourceFilter, + orig_sources: &SourceFilter, ) -> DpdResult<()> { - self.log_rollback_error( - "remove new source filters", - &format!("for group {}", self.group_ip()), - remove_source_filters( - self.switch(), - self.group_ip(), - new_sources.unwrap_or(NO_SOURCES), - ), - ); - self.log_rollback_error( - "restore original source filters", - &format!("for group {}", self.group_ip()), - add_source_filters( + unwind_outcome( + restore_source_filters( self.switch(), self.group_ip(), - orig_sources.unwrap_or(NO_SOURCES), - ), - ); - Ok(()) + new_sources, + orig_sources, + ) + .err() + .into_iter() + .flatten() + .map(|(source, err)| { + self.log_rollback_error( + "restore original source filters", + &format!( + "for source {source:?} and group {}", + self.group_ip() + ), + Err::<(), _>(err), + ) + }), + ) } - /// Rollback internal group changes (ports only, no sources). - fn rollback_internal_update( + /// Log a rollback failure and report it to the caller. + /// + /// Undo steps that delete active state use [`Self::log_cleanup_error`], + /// which tolerates entries already cleaned up beforehand. + fn log_rollback_error( &self, - added_ports: &[MulticastGroupMember], - removed_ports: &[MulticastGroupMember], - replication_info: &MulticastReplicationInfo, - ) -> DpdResult<()> { - self.collect_rollback_result("port changes", || { - self.rollback_ports(added_ports, removed_ports, replication_info) - }); - - Ok(()) - } - - /// Execute rollback operation and track errors for summary logging. - fn collect_rollback_result(&self, operation: &str, f: F) -> bool + operation: &str, + context: &str, + result: Result, + ) -> DpdResult<()> where - F: FnOnce() -> DpdResult<()>, + E: fmt::Debug + Into, { - match f() { - Ok(()) => false, + match result { + Ok(_) => Ok(()), Err(e) => { - debug!( + error!( self.switch().log, - "failed operation during rollback"; + "{ROLLBACK_FAILURE_MSG}"; "operation" => operation, + "context" => context, "error" => ?e, ); - true + + Err(e.into()) } } } - /// Log rollback errors without propagating them. - fn log_rollback_error( + fn log_cleanup_error( &self, operation: &str, context: &str, result: Result, - ) { - if let Err(e) = result { - debug!( - self.switch().log, - "failed operation during rollback"; - "operation" => operation, - "context" => context, - "error" => ?e, - ); + ) -> DpdResult<()> + where + E: fmt::Debug + Into, + { + match result.map(|_| ()).map_err(Into::into) { + Err(DpdError::Switch(AsicError::Missing(detail))) => { + debug!( + self.switch().log, + "rollback cleanup found entry already absent"; + "operation" => operation, + "context" => context, + "detail" => detail, + ); + Ok(()) + } + res => self.log_rollback_error(operation, context, res), + } + } + + /// Restore a group's bitmap VLAN to `vlan_id`, the value in place before + /// the failed operation. + /// + /// A missing entry is logged and re-added from the group's membership. + /// Failures, including a failed re-add, are logged and returned. + fn restore_bitmap_vlan( + &self, + group: &MulticastGroup, + vlan_id: Option, + ) -> DpdResult<()> { + let restored = match super::update_bitmap_vlan( + self.switch(), + group, + vlan_id, + ) { + Err(DpdError::Switch(AsicError::Missing(detail))) => { + warn!( + self.switch().log, + "re-adding missing multicast bitmap entry during rollback"; + "group" => group.external_group_id(), + "detail" => detail, + ); + upsert_bitmap_entry(self.switch(), group, vlan_id) + } + res => res, + }; + + self.log_rollback_error( + "restore multicast bitmap VLAN", + &format!("for group {}", group.external_group_id()), + restored, + ) + } +} + +fn upsert_bitmap_entry( + switch: &Switch, + group: &MulticastGroup, + vlan_id: Option, +) -> DpdResult<()> { + match add_group_bitmap_entries(switch, group, vlan_id) { + Err(DpdError::Switch(AsicError::Exists(_))) => { + super::update_bitmap_vlan(switch, group, vlan_id) } + res => res, } } @@ -134,9 +363,23 @@ pub(crate) struct GroupCreateRollbackContext<'a> { group_ip: IpAddr, external_id: MulticastGroupId, underlay_id: MulticastGroupId, - nat_target: Option, - sources: Option<&'a [IpSrc]>, - vlan_id: Option, + kind: CreateRollbackKind<'a>, +} + +/// Group kind disambiguation for a creation request rollback, determining +/// which state to undo. +/// +/// Both rollback kinds delete the group's multicast route entry. +#[derive(Clone, Copy)] +enum CreateRollbackKind<'a> { + /// External creation: remove source filters and the NAT entry. The ASIC + /// groups remain after rollback because they belong to the underlay group + /// this external group targets. + External { sources: &'a SourceFilter, vlan_id: Option }, + /// Underlay creation: destroy the ASIC groups and, for IPv6, the egress + /// bitmap and replication entries. No source filters or NAT entries were + /// installed. + Underlay, } impl RollbackOps for GroupCreateRollbackContext<'_> { @@ -155,102 +398,6 @@ impl RollbackOps for GroupCreateRollbackContext<'_> { fn underlay_group_id(&self) -> MulticastGroupId { self.underlay_id } - - fn rollback_ports( - &self, - added_ports: &[MulticastGroupMember], - _removed_ports: &[MulticastGroupMember], - _replication_info: &MulticastReplicationInfo, - ) -> DpdResult<()> { - if self.nat_target.is_some() { - Ok(()) - } else { - debug!( - self.switch.log, - "rolling back multicast group creation"; - "group" => %self.group_ip, - "ports" => added_ports.len(), - ); - - let mut first_error = None; - let mut failed_port_resolution = Vec::new(); - let mut failed_port_removal = Vec::new(); - - for member in added_ports { - let group_id = match member.direction { - Direction::External => self.external_id, - Direction::Underlay => self.underlay_id, - }; - - match self - .switch - .port_link_to_asic_id(member.port_id, member.link_id) - { - Ok(asic_id) => { - if let Err(e) = self - .switch - .asic_hdl - .mc_port_remove(group_id, asic_id) - { - error!( - self.switch.log, - "failed to remove port during rollback"; - "port" => %member.port_id, - "asic_id" => asic_id, - "group" => group_id, - "error" => ?e, - ); - - first_error.get_or_insert_with(|| e.into()); - - failed_port_removal - .push((member.port_id, member.link_id)); - } - } - Err(e) => { - error!( - self.switch.log, - "failed to resolve port link during rollback"; - "port" => %member.port_id, - "link" => %member.link_id, - "error" => ?e, - ); - - first_error.get_or_insert(e); - failed_port_resolution - .push((member.port_id, member.link_id)); - } - } - } - - // Log summary of any failures - if !failed_port_resolution.is_empty() { - error!( - self.switch.log, - "rollback failed to resolve port links"; - "count" => failed_port_resolution.len(), - "ports" => ?failed_port_resolution, - "warning" => "These ports may remain in inconsistent state", - ); - } - - if !failed_port_removal.is_empty() { - error!( - self.switch.log, - "rollback failed to remove ports from ASIC"; - "count" => failed_port_removal.len(), - "ports" => ?failed_port_removal, - "warning" => "These ports may remain in inconsistent state", - ); - } - - // Return the first error encountered, if any - match first_error { - Some(e) => Err(e), - None => Ok(()), - } - } - } } impl<'a> GroupCreateRollbackContext<'a> { @@ -260,8 +407,7 @@ impl<'a> GroupCreateRollbackContext<'a> { group_ip: IpAddr, external_id: MulticastGroupId, underlay_id: MulticastGroupId, - nat_target: NatTarget, - sources: Option<&'a [IpSrc]>, + sources: &'a SourceFilter, vlan_id: Option, ) -> Self { Self { @@ -269,14 +415,12 @@ impl<'a> GroupCreateRollbackContext<'a> { group_ip, external_id, underlay_id, - nat_target: Some(nat_target), - sources, - vlan_id, + kind: CreateRollbackKind::External { sources, vlan_id }, } } - /// Create rollback context for internal group operations. - pub(crate) fn new_internal( + /// Create rollback context for underlay group operations. + pub(crate) fn new_underlay( switch: &'a Switch, group_ip: IpAddr, external_id: MulticastGroupId, @@ -287,266 +431,147 @@ impl<'a> GroupCreateRollbackContext<'a> { group_ip, external_id, underlay_id, - nat_target: None, - sources: None, - vlan_id: None, + kind: CreateRollbackKind::Underlay, } } - /// Perform rollback with member context and return error. - pub(crate) fn rollback_with_members_and_return_error( + /// Restore a referenced underlay group's bitmap VLAN, then roll back the + /// failed creation step. + pub(crate) fn rollback_with_vlan_restore( &self, - error: E, - added_members: &[(PortId, LinkId, Direction)], - replication_info: &MulticastReplicationInfo, - ) -> E { - if let Err(rollback_err) = - self.perform_group_create_rollback(added_members, replication_info) - { - error!( - self.switch.log, - "rollback failed for group creation"; - "group" => %self.group_ip, - "error" => ?rollback_err, - ); - } - error + err: DpdError, + underlay_group: &MulticastGroup, + previous_vlan: Option, + ) -> DpdError { + let restored = self.restore_bitmap_vlan(underlay_group, previous_vlan); + self.rollback(after_unwind(err, restored)) } /// Perform rollback and return error. - pub(crate) fn rollback_and_return_error(&self, error: E) -> E { - self.rollback_with_members_and_return_error( - error, - &[], // No members - &MulticastReplicationInfo::default(), // Dummy replication info + pub(crate) fn rollback(&self, err: DpdError) -> DpdError { + after_unwind( + err, + unwind_outcome([self.remove_groups(), self.remove_tables()]), ) } - /// Perform group creation rollback. - fn perform_group_create_rollback( - &self, - added_members: &[(PortId, LinkId, Direction)], - replication_info: &MulticastReplicationInfo, - ) -> DpdResult<()> { - let added_members_converted: Vec = added_members - .iter() - .map(|(port_id, link_id, direction)| MulticastGroupMember { - port_id: *port_id, - link_id: *link_id, - direction: *direction, - }) - .collect(); - - self.collect_rollback_result("port removal", || { - self.rollback_ports(&added_members_converted, &[], replication_info) - }); - self.collect_rollback_result("ASIC group deletion", || { - self.remove_groups() - }); - self.collect_rollback_result("table cleanup", || self.remove_tables()); - - Ok(()) - } - /// Remove multicast groups from ASIC. fn remove_groups(&self) -> DpdResult<()> { - // External groups don't destroy ASIC groups (they're shared with internal group) - if self.nat_target.is_none() { - self.log_rollback_error( + // External groups don't destroy ASIC groups (they're shared with underlay group). + if !matches!(self.kind, CreateRollbackKind::Underlay) { + return Ok(()); + } + + unwind_outcome([ + self.log_cleanup_error( "remove external multicast group", &format!( "for IP {} with ID {}", self.group_ip, self.external_id ), self.switch.asic_hdl.mc_group_destroy(self.external_id), - ); - self.log_rollback_error( + ), + self.log_cleanup_error( "remove underlay multicast group", &format!( "for IP {} with ID {}", self.group_ip, self.underlay_id ), self.switch.asic_hdl.mc_group_destroy(self.underlay_id), - ); - } - Ok(()) + ), + ]) } /// Remove table entries. fn remove_tables(&self) -> DpdResult<()> { - match self.group_ip { - IpAddr::V4(ipv4) => { - // IPv4 groups are always external-only and never create bitmap entries - // (only IPv6 internal groups with replication create bitmap entries) - - // If `Any` was present, only a /0 entry was added (collapsing) - match self.sources { - Some(srcs) if sources_contain_any(srcs) => { - self.log_rollback_error( - "delete IPv4 any-source filter entry", - &format!("for group {ipv4}"), - table::mcast::mcast_src_filter::del_ipv4_entry( - self.switch, - Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), - ipv4, - ), - ); - } - Some(srcs) => { - for src in srcs { - let IpSrc::Exact(IpAddr::V4(addr)) = src else { - continue; - }; - self.log_rollback_error( - "delete IPv4 source filter entry", - &format!("for source {addr} and group {ipv4}"), - table::mcast::mcast_src_filter::del_ipv4_entry( - self.switch, - Ipv4Net::new(*addr, 32).unwrap(), - ipv4, - ), - ); - } - } - None => { - // Normalized None means "allow any source", which - // added a /0 entry. - self.log_rollback_error( - "delete IPv4 any-source filter entry", - &format!("for group {ipv4}"), - table::mcast::mcast_src_filter::del_ipv4_entry( - self.switch, - Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), - ipv4, - ), - ); - } - } - if self.nat_target.is_some() { - self.log_rollback_error( - "delete IPv4 NAT entry", - &format!("for group {ipv4}"), - table::mcast::mcast_nat::del_ipv4_entry( - self.switch, - ipv4, - self.vlan_id, - ), - ); - } - self.log_rollback_error( - "delete IPv4 route entry", - &format!("for group {ipv4}"), - table::mcast::mcast_route::del_ipv4_entry( - self.switch, - ipv4, - ), - ); - } - IpAddr::V6(ipv6) => { - // Clean up external bitmap entry only if both external and underlay groups exist - // (bitmap entries are only created for internal groups with both group types) - self.log_rollback_error( - "delete IPv6 egress bitmap entry", - &format!("for external group {}", self.external_id), - table::mcast::mcast_egress::del_bitmap_entry( - self.switch, - self.external_id, - ), - ); + unwind_outcome([ + self.remove_underlay_entries(), + // Source filters only exist for external groups (which have + // NAT targets). Underlay groups don't have source filtering. + self.remove_external_entries(), + self.remove_route_entry(), + ]) + } - self.log_rollback_error( - "delete IPv6 replication entry", - &format!("for group {ipv6}"), - table::mcast::mcast_replication::del_ipv6_entry( - self.switch, - ipv6, - ), - ); + fn remove_underlay_entries(&self) -> DpdResult<()> { + let (CreateRollbackKind::Underlay, IpAddr::V6(ipv6)) = + (self.kind, self.group_ip) + else { + return Ok(()); + }; - // Source filters only exist for external groups (which have - // NAT targets). Internal groups don't have source filtering. - if self.nat_target.is_some() { - // If `Any` was present, only a ::/0 entry was added (collapsing) - match self.sources { - Some(srcs) if sources_contain_any(srcs) => { - self.log_rollback_error( - "delete IPv6 any-source filter entry", - &format!("for group {ipv6}"), - table::mcast::mcast_src_filter::del_ipv6_entry( - self.switch, - Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 0) - .unwrap(), - ipv6, - ), - ); - } - Some(srcs) => { - for src in srcs { - let IpSrc::Exact(IpAddr::V6(addr)) = src else { - continue; - }; - self.log_rollback_error( - "delete IPv6 source filter entry", - &format!( - "for source {addr} and group {ipv6}" - ), - table::mcast::mcast_src_filter::del_ipv6_entry( - self.switch, - Ipv6Net::new(*addr, 128).unwrap(), - ipv6, - ), - ); - } - } - None => { - // Normalized None means "allow any source", which - // added a /0 entry. - self.log_rollback_error( - "delete IPv6 any-source filter entry", - &format!("for group {ipv6}"), - table::mcast::mcast_src_filter::del_ipv6_entry( - self.switch, - Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 0) - .unwrap(), - ipv6, - ), - ); - } - } + unwind_outcome([ + self.log_cleanup_error( + "delete IPv6 egress bitmap entry", + &format!("for external group {}", self.external_id), + table::mcast::mcast_egress::del_bitmap_entry( + self.switch, + self.external_id, + ), + ), + self.log_cleanup_error( + "delete IPv6 replication entry", + &format!("for group {ipv6}"), + table::mcast::mcast_replication::del_ipv6_entry( + self.switch, + ipv6, + ), + ), + ]) + } - self.log_rollback_error( - "delete IPv6 NAT entry", - &format!("for group {ipv6}"), - table::mcast::mcast_nat::del_ipv6_entry( - self.switch, - ipv6, - self.vlan_id, - ), - ); - } - self.log_rollback_error( - "delete IPv6 route entry", - &format!("for group {ipv6}"), - table::mcast::mcast_route::del_ipv6_entry( - self.switch, - ipv6, - ), - ); - } + fn remove_external_entries(&self) -> DpdResult<()> { + let CreateRollbackKind::External { sources, vlan_id } = self.kind + else { + return Ok(()); + }; + + let filters_removed = self.remove_source_filters(sources); + let nat_removed = match self.group_ip { + IpAddr::V4(ipv4) => self.log_cleanup_error( + "delete IPv4 NAT entry", + &format!("for group {ipv4}"), + table::mcast::mcast_nat::del_ipv4_entry( + self.switch, + ipv4, + vlan_id, + ), + ), + IpAddr::V6(ipv6) => self.log_cleanup_error( + "delete IPv6 NAT entry", + &format!("for group {ipv6}"), + table::mcast::mcast_nat::del_ipv6_entry( + self.switch, + ipv6, + vlan_id, + ), + ), + }; + + filters_removed.and(nat_removed) + } + + fn remove_route_entry(&self) -> DpdResult<()> { + match self.group_ip { + IpAddr::V4(ipv4) => self.log_cleanup_error( + "delete IPv4 route entry", + &format!("for group {ipv4}"), + table::mcast::mcast_route::del_ipv4_entry(self.switch, ipv4), + ), + IpAddr::V6(ipv6) => self.log_cleanup_error( + "delete IPv6 route entry", + &format!("for group {ipv6}"), + table::mcast::mcast_route::del_ipv6_entry(self.switch, ipv6), + ), } - Ok(()) } } /// Rollback context for multicast group update operations. pub(crate) struct GroupUpdateRollbackContext<'a> { switch: &'a Switch, - group_ip: IpAddr, original_group: &'a MulticastGroup, - /// The VLAN that the update is attempting to set. This may differ from - /// `original_group.ext_fwding.vlan_id` when a VLAN change is in progress. - /// Used during rollback to delete entries that may have been added. - attempted_vlan_id: Option, + table_vlan_id: Option, } impl RollbackOps for GroupUpdateRollbackContext<'_> { @@ -555,7 +580,7 @@ impl RollbackOps for GroupUpdateRollbackContext<'_> { } fn group_ip(&self) -> IpAddr { - self.group_ip + self.original_group.ip() } fn external_group_id(&self) -> MulticastGroupId { @@ -565,310 +590,2980 @@ impl RollbackOps for GroupUpdateRollbackContext<'_> { fn underlay_group_id(&self) -> MulticastGroupId { self.original_group.underlay_group_id() } +} - fn rollback_ports( +impl<'a> GroupUpdateRollbackContext<'a> { + fn rollback_underlay_update( &self, added_ports: &[MulticastGroupMember], removed_ports: &[MulticastGroupMember], replication_info: &MulticastReplicationInfo, ) -> DpdResult<()> { - // External groups don't need port rollback - if self.original_group.replication_info.is_none() { - return Ok(()); - } - - // Internal group -> perform actual port rollback + // Underlay group -> perform actual port rollback debug!( self.switch.log, "rolling back multicast group update"; - "group" => ?self.group_ip, + "group" => %self.group_ip(), "added_ports" => added_ports.len(), "removed_ports" => removed_ports.len(), ); - let mut first_error = None; - let mut failed_port_resolution = Vec::new(); - let mut failed_port_removal = Vec::new(); - let mut failed_port_addition = Vec::new(); - - // Remove added ports - for member in added_ports { - let group_id = match member.direction { - Direction::External => self.external_group_id(), - Direction::Underlay => self.underlay_group_id(), - }; - - match self - .switch - .port_link_to_asic_id(member.port_id, member.link_id) - { - Ok(asic_id) => { - if let Err(e) = - self.switch.asic_hdl.mc_port_remove(group_id, asic_id) - { - error!( - self.switch.log, - "failed to remove port during rollback"; - "port" => %member.port_id, - "asic_id" => asic_id, - "group" => group_id, - "error" => ?e, - ); - - first_error.get_or_insert_with(|| e.into()); - failed_port_removal - .push((member.port_id, member.link_id)); - } - } - Err(e) => { - error!( - self.switch.log, - "failed to resolve port link during rollback"; - "port" => %member.port_id, - "link" => %member.link_id, - "error" => ?e, - ); - - first_error.get_or_insert(e); - failed_port_resolution - .push((member.port_id, member.link_id)); - } - } - } - - // Re-add removed ports - for member in removed_ports { - let group_id = match member.direction { - Direction::External => self.external_group_id(), - Direction::Underlay => self.underlay_group_id(), - }; - - match self - .switch - .port_link_to_asic_id(member.port_id, member.link_id) - { - Ok(asic_id) => { - if let Err(e) = self.switch.asic_hdl.mc_port_add( - group_id, - asic_id, - replication_info.rid, - replication_info.level1_excl_id, - ) { - error!( - self.switch.log, - "failed to add port during rollback"; - "port" => %member.port_id, - "asic_id" => asic_id, - "group" => group_id, - "error" => ?e, - ); - - first_error.get_or_insert_with(|| e.into()); - failed_port_addition - .push((member.port_id, member.link_id)); - } - } - Err(e) => { - error!( - self.switch.log, - "failed to resolve port link during rollback"; - "port" => %member.port_id, - "link" => %member.link_id, - "error" => ?e, - ); - - first_error.get_or_insert(e); - failed_port_resolution - .push((member.port_id, member.link_id)); - } - } - } - - // Log summary of any failures - if !failed_port_resolution.is_empty() { - error!( - self.switch.log, - "rollback failed to resolve port links"; - "count" => failed_port_resolution.len(), - "ports" => ?failed_port_resolution, - "warning" => "These ports may remain in inconsistent state", - ); - } - - if !failed_port_removal.is_empty() { - error!( - self.switch.log, - "rollback failed to remove ports from ASIC"; - "count" => failed_port_removal.len(), - "ports" => ?failed_port_removal, - "warning" => "These ports may remain in inconsistent state", - ); - } - - if !failed_port_addition.is_empty() { - error!( - self.switch.log, - "rollback failed to re-add ports to ASIC"; - "count" => failed_port_addition.len(), - "ports" => ?failed_port_addition, - "warning" => "These ports may remain in inconsistent state", - ); - } - - // Return the first error encountered, if any - match first_error { - Some(e) => Err(e), - None => Ok(()), - } + self.log_rollback_error( + "port changes", + &format!("for group {}", self.group_ip()), + self.undo_port_changes( + added_ports, + removed_ports, + replication_info, + ), + ) } -} -impl<'a> GroupUpdateRollbackContext<'a> { - /// Create rollback context for group update operations. + /// Create the rollback context for an update on the `original_group`. /// - /// # Arguments + /// `table_vlan_id` is the attempted VLAN for an external update, or the VLAN + /// propagated from the referencing external group for an underlay update. /// - /// * `switch` - Switch instance for table operations. - /// * `group_ip` - IP address of the multicast group being updated. - /// * `original_group` - Reference to the group's state before the update. - /// * `attempted_vlan_id` - The VLAN that the update is attempting to set. - /// Used during rollback to delete entries that may have been added. + /// On rollback, we restore the external group's original VLAN and leave + /// the underlay group's one in place. pub(crate) fn new( switch: &'a Switch, - group_ip: IpAddr, original_group: &'a MulticastGroup, - attempted_vlan_id: Option, + table_vlan_id: Option, ) -> Self { - Self { switch, group_ip, original_group, attempted_vlan_id } - } - - /// Restore tables and return error. - pub(crate) fn rollback_and_restore(&self, error: E) -> E { - if let Err(rollback_err) = self.restore_tables() { - error!( - self.switch.log, - "failed to restore tables during rollback"; - "group" => %self.group_ip, - "error" => ?rollback_err, - ); - } - error + Self { switch, original_group, table_vlan_id } } /// Restore table entries to original state. fn restore_tables(&self) -> DpdResult<()> { - let external_group_id = self.original_group.external_group_id(); - let underlay_group_id = self.original_group.underlay_group_id(); - let replication_info = &self.original_group.replication_info; - let vlan_id = self.original_group.ext_fwding.vlan_id; - let nat_target = self.original_group.int_fwding.nat_target; - let prev_members = self.original_group.members.to_vec(); - - if let Some(replication_info) = replication_info { - self.log_rollback_error( - "restore replication settings", - &format!("for group {}", self.group_ip), - update_replication_tables( + unwind_outcome([ + self.restore_replication(), + self.remove_added_bitmap(), + self.restore_fwding_tables(), + ]) + } + + fn restore_replication(&self) -> DpdResult<()> { + let GroupKind::Underlay { + group_ip, + replication: Replication::Active { info: replication_info, .. }, + } = &self.original_group.kind + else { + return Ok(()); + }; + + let restored = match update_replication_tables( + self.switch, + (*group_ip).into(), + self.original_group.external_group_id(), + self.original_group.underlay_group_id(), + replication_info, + ) { + Err(DpdError::Switch(AsicError::Missing(detail))) => { + warn!( + self.switch.log, + "re-adding missing multicast replication entry during rollback"; + "group" => %group_ip, + "detail" => detail, + ); + add_replication_entry( self.switch, - self.group_ip, - external_group_id, - underlay_group_id, + self.original_group, replication_info, - ), - ); - } - - // Restore NAT settings for external groups (internal groups have no - // NAT entries). Both new_tgt and old_tgt are the same original NAT - // target since we're restoring to the original state. - if let Some(nat) = nat_target { - match self.group_ip { - IpAddr::V4(ipv4) => { - self.log_rollback_error( - "restore IPv4 NAT settings", - &format!("for group {}", self.group_ip), - table::mcast::mcast_nat::update_ipv4_entry( - self.switch, - ipv4, - nat, - nat, - self.attempted_vlan_id, - vlan_id, - ), - ); - } - IpAddr::V6(ipv6) => { - self.log_rollback_error( - "restore IPv6 NAT settings", - &format!("for group {}", self.group_ip), - table::mcast::mcast_nat::update_ipv6_entry( - self.switch, - ipv6, - nat, - nat, - self.attempted_vlan_id, - vlan_id, - ), - ); - } + ) } - } + res => res, + }; + + self.log_rollback_error( + "restore replication settings", + &format!("for group {group_ip}"), + restored, + ) + } + + fn remove_added_bitmap(&self) -> DpdResult<()> { + let GroupKind::Underlay { replication: Replication::Empty, .. } = + &self.original_group.kind + else { + return Ok(()); + }; + + self.log_cleanup_error( + "delete newly added multicast bitmap", + &format!("for group {}", self.group_ip()), + table::mcast::mcast_egress::del_bitmap_entry( + self.switch, + self.original_group.external_group_id(), + ), + ) + } + + fn restore_fwding_tables(&self) -> DpdResult<()> { + let restore_vlan_id = match &self.original_group.kind { + GroupKind::External { vlan_id, .. } => *vlan_id, + GroupKind::Underlay { .. } => self.table_vlan_id, + }; self.log_rollback_error( "restore VLAN settings", - &format!("for group {}", self.group_ip), + &format!("for group {}", self.group_ip()), update_fwding_tables( self.switch, - self.group_ip, - external_group_id, - &prev_members, - self.attempted_vlan_id, - vlan_id, + self.original_group, + self.table_vlan_id, + restore_vlan_id, ), - ); - Ok(()) + ) } /// Rollback external group updates. /// /// Note: `new_sources` should be the normalized sources that were actually /// applied to the tables (not the raw request sources). - pub(crate) fn rollback_external( + pub(crate) fn rollback_external( &self, - error: E, - new_sources: Option<&[IpSrc]>, - ) -> E { - // Always try to rollback source filters; with normalization, even - // None represents a /0 "any source" entry that may need to be undone. - let orig_sources = self.original_group.sources.as_deref(); - self.collect_rollback_result("source filter restoration", || { - self.rollback_source_filters(new_sources, orig_sources) - }); - error + err: DpdError, + new_sources: &SourceFilter, + ) -> DpdError { + // Underlay groups have no source filters to restore. + let unwind = match &self.original_group.kind { + GroupKind::External { sources, .. } => { + self.rollback_source_filters(new_sources, sources) + } + GroupKind::Underlay { .. } => Ok(()), + }; + after_unwind(err, unwind.and(self.restore_fwding_tables())) + } + + /// Rollback underlay group updates. + pub(crate) fn rollback_underlay( + &self, + err: DpdError, + replication_info: &MulticastReplicationInfo, + added_ports: &[MulticastGroupMember], + removed_ports: &[MulticastGroupMember], + ) -> DpdError { + after_unwind( + err, + self.rollback_underlay_update( + added_ports, + removed_ports, + replication_info, + ), + ) + } + + /// Restore the bitmap VLAN on every group in `updates`, then report the + /// initial failure together with that failure's outcome. + /// + /// This attempts every group even if an earlier restore fails. + pub(crate) fn rollback_with_bitmap_restore<'b>( + &self, + err: DpdError, + updates: impl IntoIterator)>, + ) -> DpdError { + let restored = + unwind_outcome(updates.into_iter().map(|(group, vlan_id)| { + self.restore_bitmap_vlan(group, vlan_id) + })); + + after_unwind(err, restored) } - /// Rollback internal group updates. - pub(crate) fn rollback_internal( + /// Undo an underlay group's port changes, restore its tables, and report + /// the result with the failure that originally triggered the rollback. + pub(crate) fn rollback_underlay_and_restore( &self, - error: E, + err: DpdError, + replication_info: &MulticastReplicationInfo, added_ports: &[MulticastGroupMember], removed_ports: &[MulticastGroupMember], - ) -> E { - // Get replication info from original group - if let Some(replication_info) = &self.original_group.replication_info - && let Err(rollback_err) = self.rollback_internal_update( + ) -> DpdError { + after_unwind( + err, + self.rollback_underlay_update( added_ports, removed_ports, replication_info, ) - { - error!( - self.switch.log, - "rollback failed for internal group update"; - "group" => %self.group_ip, - "error" => ?rollback_err, - ); + .and(self.restore_tables()), + ) + } +} + +/// A deletion step recorded for rollback, including partially completed steps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum DeleteStep { + /// Source filter entries, recorded before removal since removal is per + /// entry and can fail partway. + SourceFilters, + /// The external group's NAT ingress entry. + Nat, + /// The underlay group's replication entry. + Replication, + /// The underlay group's egress decap bitmap entry. + Bitmap, + /// The group's multicast route entry. + Route, + /// The underlay group's external and underlay ASIC domains. + Domains, +} + +/// Rollback context for multicast group delete operations. +pub(crate) struct GroupDeleteRollbackContext<'a> { + switch: &'a Switch, + group: &'a MulticastGroup, +} + +impl RollbackOps for GroupDeleteRollbackContext<'_> { + fn switch(&self) -> &Switch { + self.switch + } + + fn group_ip(&self) -> IpAddr { + self.group.ip() + } + + fn external_group_id(&self) -> MulticastGroupId { + self.group.external_group_id() + } + + fn underlay_group_id(&self) -> MulticastGroupId { + self.group.underlay_group_id() + } +} + +impl<'a> GroupDeleteRollbackContext<'a> { + /// Create rollback context for deleting `group`. + pub(crate) fn new(switch: &'a Switch, group: &'a MulticastGroup) -> Self { + Self { switch, group } + } + + /// Restore the `deleted` steps and report the result with the failure + /// that triggered the rollback. + pub(crate) fn rollback( + &self, + err: DpdError, + deleted: &[DeleteStep], + ) -> DpdError { + after_unwind(err, self.restore_tables(deleted)) + } + + /// Restore a referenced underlay group's bitmap VLAN to this external + /// group's VLAN; then, restore the `deleted` steps in sequence. + pub(crate) fn rollback_with_vlan_restore( + &self, + err: DpdError, + underlay_group: &MulticastGroup, + deleted: &[DeleteStep], + ) -> DpdError { + let restored = + self.restore_bitmap_vlan(underlay_group, self.group.vlan_id()); + self.rollback(after_unwind(err, restored), deleted) + } + + fn restore_tables(&self, deleted: &[DeleteStep]) -> DpdResult<()> { + let restore = |step: DeleteStep, f: &dyn Fn() -> DpdResult<()>| { + if deleted.contains(&step) { f() } else { Ok(()) } + }; + + match &self.group.kind { + GroupKind::External { sources, nat_target, vlan_id, .. } => { + unwind_outcome([ + restore(DeleteStep::SourceFilters, &|| { + self.readd_source_filters(sources) + }), + restore(DeleteStep::Nat, &|| { + self.readd_nat(*nat_target, *vlan_id) + }), + restore(DeleteStep::Route, &|| self.readd_route(*vlan_id)), + ]) + } + GroupKind::Underlay { + replication: Replication::Active { info, .. }, + .. + } => unwind_outcome([ + restore(DeleteStep::Domains, &|| self.restore_domains()), + restore(DeleteStep::Bitmap, &|| self.readd_bitmap()).and_then( + |()| { + restore(DeleteStep::Replication, &|| { + self.readd_replication(info) + }) + }, + ), + restore(DeleteStep::Route, &|| self.readd_route(None)), + ]), + GroupKind::Underlay { replication: Replication::Empty, .. } => { + unwind_outcome([ + restore(DeleteStep::Domains, &|| self.restore_domains()), + restore(DeleteStep::Route, &|| self.readd_route(None)), + ]) + } + } + } + + fn restore_domains(&self) -> DpdResult<()> { + unwind_outcome( + [Direction::External, Direction::Underlay] + .into_iter() + .map(|direction| self.restore_domain(direction)), + ) + } + + fn restore_domain(&self, direction: Direction) -> DpdResult<()> { + let group_id = self.group_id(direction); + + if !self.switch.asic_hdl.mc_group_exists(group_id) { + self.log_rollback_error( + "restore multicast group", + &format!("for IP {} with ID {group_id}", self.group_ip()), + ignore_exists( + self.switch + .asic_hdl + .mc_group_create(group_id) + .map_err(DpdError::from), + ), + )?; + } + + let Some(Replication::Active { info, members }) = + self.group.replication() + else { + return Ok(()); + }; + + unwind_outcome( + members.iter().filter(|member| member.direction == direction).map( + |member| { + let added = self + .switch + .port_link_to_asic_id(member.port_id, member.link_id) + .and_then(|asic_id| { + ignore_exists( + self.switch + .asic_hdl + .mc_port_add( + group_id, + asic_id, + info.rid, + info.level1_excl_id, + ) + .map_err(DpdError::from), + ) + }); + + self.log_rollback_error( + "restore multicast group member", + &format!( + "for port {} link {} in group {} with ID {group_id}", + member.port_id, + member.link_id, + self.group_ip() + ), + added, + ) + }, + ), + ) + } + + fn readd_source_filters(&self, sources: &SourceFilter) -> DpdResult<()> { + unwind_outcome(sources.iter().map(|source| { + self.log_rollback_error( + "restore source filter entry", + &format!("for source {source:?} and group {}", self.group_ip()), + ignore_exists(add_source_filter( + self.switch, + self.group_ip(), + source.clone(), + )), + ) + })) + } + + fn readd_nat( + &self, + nat_target: dpd_types::mcast::ExternalNatTarget, + vlan_id: Option, + ) -> DpdResult<()> { + let nat_target = nat_target.into(); + let result = match self.group_ip() { + IpAddr::V4(ipv4) => table::mcast::mcast_nat::update_ipv4_entry( + self.switch, + ipv4, + nat_target, + nat_target, + vlan_id, + vlan_id, + ), + IpAddr::V6(ipv6) => table::mcast::mcast_nat::update_ipv6_entry( + self.switch, + ipv6, + nat_target, + nat_target, + vlan_id, + vlan_id, + ), + }; + + self.log_rollback_error( + "restore NAT entry", + &format!("for group {}", self.group_ip()), + result, + ) + } + + fn readd_route(&self, vlan_id: Option) -> DpdResult<()> { + let result = match self.group_ip() { + IpAddr::V4(ipv4) => table::mcast::mcast_route::ensure_ipv4_entry( + self.switch, + ipv4, + vlan_id, + ), + IpAddr::V6(ipv6) => table::mcast::mcast_route::ensure_ipv6_entry( + self.switch, + ipv6, + vlan_id, + ), + }; + + self.log_rollback_error( + "restore route entry", + &format!("for group {}", self.group_ip()), + result, + ) + } + + fn readd_bitmap(&self) -> DpdResult<()> { + self.log_rollback_error( + "restore multicast bitmap entry", + &format!("for group {}", self.group.external_group_id()), + upsert_bitmap_entry(self.switch, self.group, None), + ) + } + + fn readd_replication( + &self, + info: &MulticastReplicationInfo, + ) -> DpdResult<()> { + self.log_rollback_error( + "restore replication entry", + &format!("for group {}", self.group_ip()), + add_replication_entry(self.switch, self.group, info), + ) + } +} + +#[cfg(test)] +mod tests { + use dpd_types::mcast::ExactSource; + + use super::*; + + #[test] + fn test_source_filter_rollback_reports_all_failures() { + let sources = SourceFilter::from_exact( + ["10.0.0.1", "10.0.0.2", "10.0.0.3"] + .map(|address| address.parse::().unwrap()), + ); + let expected: Vec<_> = sources.iter().collect(); + + let mut attempted = Vec::new(); + let errs = remove_source_filters(&sources, |source| { + let index = attempted.len(); + attempted.push(source); + if index == 1 { Ok(()) } else { Err(index) } + }); + + // The first and third removals fail, but every source + // should still be attempted. + assert_eq!(attempted, expected); + + // These errors should follow in source order. + assert_eq!( + errs, + vec![(expected[0].clone(), 0), (expected[2].clone(), 2)] + ); + } + + #[test] + fn test_source_filter_rollback_attempts_any_just_once() { + for fail in [false, true] { + let mut attempted = Vec::new(); + let errs = remove_source_filters(&SourceFilter::Any, |source| { + attempted.push(source); + if fail { Err("delete failed") } else { Ok(()) } + }); + + assert_eq!(attempted, vec![IpSrc::Any]); + if fail { + assert_eq!(errs, vec![(IpSrc::Any, "delete failed")]); + } else { + assert!(errs.is_empty()); + } + } + } +} + +/// Rollback tests driven by the `chaos` ASIC backend, which fails table and +/// ASIC operations at configured probabilities. +#[cfg(all(test, feature = "chaos"))] +mod chaos_tests { + use std::{ + collections::BTreeMap, + convert::Infallible, + sync::{Arc, Mutex}, + }; + + use common::{ + network::{MacAddr, NatTarget, Vni, multicast_mac_addr}, + ports::RearPort, + table::TableType, + }; + use dpd_types::link::LinkId; + use dpd_types::mcast::{ExactSource, SourceEntry}; + use slog::{Drain, KV}; + + use super::*; + use crate::{ + Switch, config::Config, mcast::*, table::mcast::mcast_nat, + types::DpdError, + }; + + /// Captures the fields of each [`ROLLBACK_FAILURE_MSG`] record. + #[derive(Clone, Default)] + struct RollbackLog { + entries: Arc>>>, + } + + struct LogFields(BTreeMap); + + impl slog::Serializer for LogFields { + fn emit_arguments( + &mut self, + key: slog::Key, + value: &std::fmt::Arguments<'_>, + ) -> slog::Result { + self.0.insert(key.to_string(), value.to_string()); + Ok(()) + } + } + + impl Drain for RollbackLog { + type Ok = (); + type Err = Infallible; + + fn log( + &self, + record: &slog::Record<'_>, + _values: &slog::OwnedKVList, + ) -> Result<(), Infallible> { + if record.msg().to_string() == ROLLBACK_FAILURE_MSG { + let mut fields = LogFields(BTreeMap::new()); + record.kv().serialize(record, &mut fields).unwrap(); + self.entries.lock().unwrap().push(fields.0); + } + Ok(()) + } + } + + /// Build a switch whose logger feeds `RollbackLog`, registered with the + /// tables that the multicast paths use. + fn chaos_switch(config: Config) -> (Switch, RollbackLog) { + let logs = RollbackLog::default(); + let log = slog::Logger::root(logs.clone().fuse(), slog::o!()); + let mut switch = Switch::new(log, "sidecar", config).unwrap(); + for table in [ + TableType::McastIpv6, + TableType::RouteIpv4Mcast, + TableType::McastIpv4SrcFilter, + TableType::NatIngressIpv4Mcast, + TableType::RouteIpv6Mcast, + TableType::McastEgressDecapPorts, + TableType::McastIpv6SrcFilter, + TableType::NatIngressIpv6Mcast, + ] { + switch.table_add(table).unwrap(); + } + + (switch, logs) + } + + /// Install the underlay fixture at `ff04::123` in the reserved `ff04::/64` + /// block. + fn install_underlay_group( + switch: &Switch, + ) -> (UnderlayMulticastIpv6, GroupKey) { + install_underlay_group_at(switch, "ff04::123".parse().unwrap()) + } + + /// Install an underlay group directly into the tables and the group map, + /// without an external group mapping. + fn install_underlay_group_at( + switch: &Switch, + underlay_ip: UnderlayMulticastIpv6, + ) -> (UnderlayMulticastIpv6, GroupKey) { + let underlay_key = GroupKey::underlay(underlay_ip); + let (external_scoped_group, underlay_scoped_group) = { + let mut groups = switch.mcast.lock().unwrap(); + ( + groups.generate_group_id().unwrap(), + groups.generate_group_id().unwrap(), + ) + }; + let repl = MulticastReplicationInfo::new(external_scoped_group.id()); + let members = vec![ + MulticastGroupMember { + port_id: RearPort::new(0).unwrap().into(), + link_id: LinkId(0), + direction: Direction::External, + }, + MulticastGroupMember { + port_id: RearPort::new(1).unwrap().into(), + link_id: LinkId(0), + direction: Direction::Underlay, + }, + ]; + + configure_underlay_tables( + switch, + underlay_ip, + external_scoped_group.id(), + underlay_scoped_group.id(), + Some(&repl), + &members, + None, + ) + .unwrap(); + + let underlay = MulticastGroup { + external_scoped_group, + underlay_scoped_group, + tag: "underlay-rollback-test".to_string(), + kind: GroupKind::Underlay { + group_ip: underlay_ip, + replication: Replication::new(repl, members), + }, + }; + switch.mcast.lock().unwrap().groups.insert(underlay_key, underlay); + + (underlay_ip, underlay_key) + } + + /// Install a memberless underlay group at `ff04::123`: a route entry and + /// the stored group, with no bitmap or replication entry. + fn install_empty_underlay_group( + switch: &Switch, + ) -> (UnderlayMulticastIpv6, GroupKey) { + let underlay_ip: UnderlayMulticastIpv6 = "ff04::123".parse().unwrap(); + let underlay_key = GroupKey::underlay(underlay_ip); + let (external_scoped_group, underlay_scoped_group) = { + let mut groups = switch.mcast.lock().unwrap(); + ( + groups.generate_group_id().unwrap(), + groups.generate_group_id().unwrap(), + ) + }; + + configure_underlay_tables( + switch, + underlay_ip, + external_scoped_group.id(), + underlay_scoped_group.id(), + None, + &[], + None, + ) + .unwrap(); + + let underlay = MulticastGroup { + external_scoped_group, + underlay_scoped_group, + tag: "underlay-empty-test".to_string(), + kind: GroupKind::Underlay { + group_ip: underlay_ip, + replication: Replication::Empty, + }, + }; + switch.mcast.lock().unwrap().groups.insert(underlay_key, underlay); + + (underlay_ip, underlay_key) + } + + /// Return occupancy, inserts, deletes, and updates (in that order). + fn table_usage(switch: &Switch, table: TableType) -> (u32, u64, u64, u64) { + let table = switch.table_get(table).unwrap(); + ( + table.usage.occupancy, + table.usage.inserts, + table.usage.deletes, + table.usage.updates, + ) + } + + fn bitmap_entry( + switch: &Switch, + external_group_id: MulticastGroupId, + ) -> Option<(table::mcast::mcast_egress::PortBitmap, Option)> { + let dump = table::mcast::mcast_egress::bitmap_table_dump(switch, false) + .unwrap(); + + let group_id = external_group_id.to_string(); + let entry = dump.entries.into_iter().find(|entry| { + entry.keys.get("mcast_external_grp") == Some(&group_id) + })?; + + let mut ports = [0u32; 8]; + for (index, word) in ports.iter_mut().enumerate() { + *word = + entry.action_args[&format!("ports_{index}")].parse().unwrap(); + } + + let vlan_id = entry + .action_args + .get("vlan_id") + .map(|vlan_id| vlan_id.parse().unwrap()); + Some((ports.into(), vlan_id)) + } + + fn route_action( + switch: &Switch, + group_ip: IpAddr, + ) -> Option<(String, Option)> { + let dump = match group_ip { + IpAddr::V4(_) => { + table::mcast::mcast_route::ipv4_table_dump(switch, false) + } + IpAddr::V6(_) => { + table::mcast::mcast_route::ipv6_table_dump(switch, false) + } + } + .unwrap(); + + let address = group_ip.to_string(); + dump.entries + .into_iter() + .find(|entry| entry.keys.get("dst_addr") == Some(&address)) + .map(|entry| { + (entry.action, entry.action_args.get("vlan_id").cloned()) + }) + } + + fn source_filter_prefixes( + switch: &Switch, + group_ip: IpAddr, + ) -> BTreeSet { + let dump = match group_ip { + IpAddr::V4(_) => { + table::mcast::mcast_src_filter::ipv4_table_dump(switch, false) + } + IpAddr::V6(_) => { + table::mcast::mcast_src_filter::ipv6_table_dump(switch, false) + } + } + .unwrap(); + + let address = group_ip.to_string(); + dump.entries + .into_iter() + .filter(|entry| entry.keys.get("dst_addr") == Some(&address)) + .map(|entry| entry.keys["src_addr"].clone()) + .collect() + } + + fn external_tables(group_ip: IpAddr) -> (TableType, TableType, TableType) { + match group_ip { + IpAddr::V4(_) => ( + TableType::RouteIpv4Mcast, + TableType::McastIpv4SrcFilter, + TableType::NatIngressIpv4Mcast, + ), + IpAddr::V6(_) => ( + TableType::RouteIpv6Mcast, + TableType::McastIpv6SrcFilter, + TableType::NatIngressIpv6Mcast, + ), + } + } + + fn external_nat_target( + group_ip: IpAddr, + underlay_ip: UnderlayMulticastIpv6, + ) -> NatTarget { + let inner_mac = match group_ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + MacAddr::new( + 0x01, + 0x00, + 0x5e, + octets[1] & 0x7f, + octets[2], + octets[3], + ) + } + IpAddr::V6(ip) => multicast_mac_addr(ip), + }; + NatTarget { + internal_ip: underlay_ip.into(), + inner_mac, + vni: Vni::new(100).unwrap(), + } + } + + fn external_entry( + group_ip: IpAddr, + tag: &str, + nat_target: NatTarget, + vlan_id: Option, + sources: serde_json::Value, + ) -> MulticastGroupCreateExternalEntry { + serde_json::from_value(serde_json::json!({ + "group_ip": group_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": vlan_id }, + "sources": sources, + })) + .unwrap() + } + + /// Fail external group creation for `external_ip` on a full route table and + /// assert that the mapped underlay group remains. + /// + /// When `fail_source_removal` is true, each of the three filter deletions + /// fails and is logged separately. When false, all three succeed. + /// + /// The failed route insertion leaves no entry to delete. Cleanup logs the + /// missing entry at debug level, without a `ROLLBACK_FAILURE_MSG` record. + fn external_create_failure(external_ip: IpAddr, fail_source_removal: bool) { + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let mut config = Config::default(); + + if fail_source_removal { + config.asic_config.table_entry_del.values.insert(source_table, 1.0); + } + + config.asic_config.mc_config.mc_group_destroy = + asic::chaos::Chaos::new(1.0); + config.asic_config.mc_config.mc_port_remove = + asic::chaos::Chaos::new(1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + + let (before_group, before_repl) = { + let groups = switch.mcast.lock().unwrap(); + let underlay = groups.groups.get(&underlay_key).unwrap(); + ( + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from(underlay).unwrap(), + ) + .unwrap(), + underlay.replication_info().cloned(), + ) + }; + + let before_tables: BTreeMap<_, _> = [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + route_table, + ] + .into_iter() + .map(|table| (table, table_usage(&switch, table))) + .collect(); + + { + let mut route = switch.table_get(route_table).unwrap(); + route.usage.size = route.usage.occupancy; + } + + let source_ips = if external_ip.is_ipv4() { + ["192.0.2.1", "192.0.2.2", "192.0.2.3"] + } else { + ["2001:db8::1", "2001:db8::2", "2001:db8::3"] + }; + + let nat_target = external_nat_target(external_ip, underlay_ip); + let entry = external_entry( + external_ip, + "external-rollback-test", + nat_target, + None, + serde_json::json!( + source_ips.map(|ip| serde_json::json!({ "Exact": ip })) + ), + ); + + let err = add_group_external(&switch, entry).unwrap_err(); + assert_eq!( + matches!(err, DpdError::Unwind { .. }), + fail_source_removal, + "unexpected rollback failure reporting: {err:?}" + ); + + let init: &DpdError = match &err { + DpdError::Unwind { initial: init, .. } => init, + err => err, + }; + assert!( + matches!( + init, + DpdError::TableFull(table) + if *table == route_table.to_string() + ), + "creation error was replaced: {err:?}" + ); + + for (table, before) in before_tables { + assert_eq!(table_usage(&switch, table), before, "{table}"); + } + + let groups = switch.mcast.lock().unwrap(); + assert_eq!(groups.groups.len(), 1); + assert!(!groups.groups.contains_key(&external_ip)); + assert!(groups.nat_target_refs.is_empty()); + + let underlay = groups.groups.get(&underlay_key).unwrap(); + assert_eq!( + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from(underlay).unwrap() + ) + .unwrap(), + before_group + ); + assert_eq!(underlay.replication_info().cloned(), before_repl); + assert_eq!(table_usage(&switch, nat_table), (0, 1, 1, 0)); + let remaining = if fail_source_removal { 3 } else { 0 }; + assert_eq!( + table_usage(&switch, source_table), + (remaining, 3, u64::from(3 - remaining), 0) + ); + + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), remaining as usize, "{errs:?}"); + for (err, source) in errs.iter().zip(source_ips) { + assert_eq!(err["operation"], "delete source filter entry"); + assert!(err["context"].contains(source)); + assert!(err["error"].contains("table_entry_del")); + } + } + + #[test] + fn test_external_create_failure_preserves_underlay() { + for group_ip in ["232.1.2.3", "ff3e::4000:1"] { + external_create_failure(group_ip.parse().unwrap(), false); + } + } + + #[test] + fn test_external_create_failure_with_cleanup_errors() { + for group_ip in ["232.1.2.3", "ff3e::4000:1"] { + external_create_failure(group_ip.parse().unwrap(), true); + } + } + + #[test] + fn test_external_vlan_prop_failure_reports_restore_failure() { + let mut config = Config::default(); + config + .asic_config + .table_entry_update + .values + .insert(TableType::McastEgressDecapPorts, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + + let before_underlay = { + let groups = switch.mcast.lock().unwrap(); + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from( + &groups.groups[&underlay_key], + ) + .unwrap(), + ) + .unwrap() + }; + + let external_ip: IpAddr = "239.10.10.10".parse().unwrap(); + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let before_decap = + table_usage(&switch, TableType::McastEgressDecapPorts); + + let nat_target = external_nat_target(external_ip, underlay_ip); + let entry = external_entry( + external_ip, + "external-vlan-propagation-test", + nat_target, + Some(10), + serde_json::json!([{ "Exact": "192.0.2.1" }]), + ); + + let err = add_group_external(&switch, entry).unwrap_err(); + assert!( + matches!( + err, + DpdError::Unwind { initial: ref init, .. } + if matches!(**init, DpdError::McastGroupFailure(_)) + ), + "creation error was replaced: {err:?}" + ); + + assert_eq!(table_usage(&switch, route_table), (0, 1, 1, 0)); + assert_eq!(table_usage(&switch, source_table), (0, 1, 1, 0)); + assert_eq!(table_usage(&switch, nat_table), (0, 1, 1, 0)); + assert_eq!( + table_usage(&switch, TableType::McastEgressDecapPorts), + before_decap + ); + + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), 1, "{errs:?}"); + assert_eq!(errs[0]["operation"], "restore multicast bitmap VLAN"); + assert!(errs[0]["error"].contains("table_entry_update")); + let groups = switch.mcast.lock().unwrap(); + assert_eq!(groups.groups.len(), 1); + assert!(groups.nat_target_refs.is_empty()); + assert_eq!( + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from( + &groups.groups[&underlay_key] + ) + .unwrap(), + ) + .unwrap(), + before_underlay + ); + } + + /// Check the rollback process for VLAN changes and NAT retargets on + /// `external_ip`. + /// + /// `retarget` keeps the existing test VLAN and changes the NAT target; + /// otherwise, the VLAN will get modified. Sources change either way, and + /// NAT programming will fail. + /// + /// A retarget keeps the NAT key and hits the injected `table_entry_update` + /// failure. A VLAN change attempts to delete the old key first; the injected + /// `table_entry_del` failure stops it there. + fn external_update_failure(external_ip: IpAddr, retarget: bool) { + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let mut config = Config::default(); + let failures = if retarget { + &mut config.asic_config.table_entry_update + } else { + &mut config.asic_config.table_entry_del + }; + + failures.values.insert(nat_table, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (target_ip, target_key) = if retarget { + let (ip, key) = install_underlay_group_at( + &switch, + "ff04::124".parse().unwrap(), + ); + (ip, Some(key)) + } else { + (underlay_ip, None) + }; + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-update-test"; + + let entry = external_entry( + external_ip, + tag, + nat_target, + Some(10), + serde_json::Value::Null, + ); + + let og = add_group_external(&switch, entry).unwrap(); + let before_route = table_usage(&switch, route_table); + let before_bitmap = + table_usage(&switch, TableType::McastEgressDecapPorts); + let before_filters = table_usage(&switch, source_table); + let before_route_action = route_action(&switch, external_ip); + let bitmap_group_ids: Vec = { + let groups = switch.mcast.lock().unwrap(); + [Some(underlay_key), target_key] + .into_iter() + .flatten() + .map(|key| groups.groups[&key].external_group_id()) + .collect() + }; + + let before_bitmap_entries: Vec<_> = bitmap_group_ids + .iter() + .map(|group_id| bitmap_entry(&switch, *group_id)) + .collect(); + let before_target = target_key.map(|key| { + let groups = switch.mcast.lock().unwrap(); + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from(&groups.groups[&key]) + .unwrap(), + ) + .unwrap() + }); + + let new_source = SourceEntry::Exact( + if external_ip.is_ipv4() { "192.0.2.1" } else { "2001:db8::1" } + .parse() + .unwrap(), + ); + + let err = modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: NatTarget { + internal_ip: target_ip.into(), + ..nat_target + } + .try_into() + .unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id: Some(if retarget { 10 } else { 20 }), + }, + sources: Some(vec![new_source]), + }, + ) + .unwrap_err(); + + assert!( + matches!(err, DpdError::Switch(_)), + "update error was replaced or unwound: {err:?}" + ); + assert_eq!(table_usage(&switch, nat_table), (1, 1, 0, 0)); + + let after_route = table_usage(&switch, route_table); + assert_eq!( + (after_route.0, after_route.1, after_route.2), + (before_route.0, before_route.1, before_route.2), + "route entries were added or removed" + ); + assert_eq!( + route_action(&switch, external_ip), + before_route_action, + "route action was not restored" + ); + + let after_bitmap = + table_usage(&switch, TableType::McastEgressDecapPorts); + assert_eq!( + (after_bitmap.0, after_bitmap.1, after_bitmap.2), + (before_bitmap.0, before_bitmap.1, before_bitmap.2), + "bitmap entries were added or removed" + ); + assert_eq!( + bitmap_group_ids + .iter() + .map(|group_id| bitmap_entry(&switch, *group_id)) + .collect::>(), + before_bitmap_entries, + "bitmap entries were not restored" + ); + + assert_eq!( + table_usage(&switch, source_table).0, + before_filters.0, + "source filter occupancy was not restored" + ); + + let groups = switch.mcast.lock().unwrap(); + let group_ip = ExternalMulticastIp::new(external_ip).unwrap(); + let group_key = GroupKey::external(group_ip); + + assert_eq!(groups.nat_target_refs.len(), 1); + assert_eq!(groups.nat_target_refs.get(&underlay_ip), Some(&group_key)); + + let group = &groups.groups[&group_key]; + assert_eq!( + serde_json::to_value( + MulticastGroupExternalResponse::try_from(group).unwrap() + ) + .unwrap(), + serde_json::to_value(og).unwrap() + ); + assert_eq!( + group.underlay_group_id(), + groups.groups[&underlay_key].underlay_group_id() + ); + + if let (Some(key), Some(before)) = (target_key, before_target.as_ref()) + { + assert_eq!( + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from( + &groups.groups[&key] + ) + .unwrap() + ) + .unwrap(), + *before, + "retarget destination group changed" + ); + } + remove_source_filter(&switch, external_ip, IpSrc::Any).unwrap(); + assert!(matches!( + remove_source_filter(&switch, external_ip, new_source.into()), + Err(DpdError::Switch(aal::AsicError::Missing(_))) + )); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_external_update_nat_failure_restores_state() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + external_update_failure(group_ip.parse().unwrap(), false); + } + } + + #[test] + fn test_external_retarget_failure_restores_bitmaps() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + external_update_failure(group_ip.parse().unwrap(), true); + } + } + + /// Delete the underlay bitmap entry behind an external group on + /// `external_ip`, then check that updating the group succeeds and re-adds + /// the entry. + /// + /// `retarget` moves the NAT target to a second underlay group, forcing the + /// missing entry to be the one whose VLAN gets cleared; otherwise, the VLAN + /// changes on the same underlay group. + fn external_update_readds_missing_bitmap( + external_ip: IpAddr, + retarget: bool, + ) { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, _) = install_underlay_group(&switch); + let target_ip = if retarget { + install_underlay_group_at(&switch, "ff04::124".parse().unwrap()).0 + } else { + underlay_ip + }; + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-readd-test"; + + let entry = + serde_json::from_value::( + serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + }), + ) + .unwrap(); + let og = add_group_external(&switch, entry).unwrap(); + + table::mcast::mcast_egress::del_bitmap_entry( + &switch, + og.external_group_id, + ) + .unwrap(); + let before_bitmap = + table_usage(&switch, TableType::McastEgressDecapPorts); + + modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: NatTarget { + internal_ip: target_ip.into(), + ..nat_target + } + .try_into() + .unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id: Some(if retarget { 10 } else { 20 }), + }, + sources: None, + }, + ) + .unwrap(); + + let after_bitmap = + table_usage(&switch, TableType::McastEgressDecapPorts); + assert_eq!(after_bitmap.0, before_bitmap.0 + 1, "bitmap not re-added"); + assert_eq!(after_bitmap.1, before_bitmap.1 + 1); + assert_eq!( + table_usage(&switch, TableType::McastIpv6).0, + if retarget { 2 } else { 1 } + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_external_vlan_update_readds_missing_bitmap() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + external_update_readds_missing_bitmap( + group_ip.parse().unwrap(), + false, + ); + } + } + + #[test] + fn test_external_retarget_readds_missing_bitmap() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + external_update_readds_missing_bitmap( + group_ip.parse().unwrap(), + true, + ); + } + } + + #[test] + fn test_external_update_failure_keeps_readded_bitmap() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + let external_ip: IpAddr = group_ip.parse().unwrap(); + let (_, _, nat_table) = external_tables(external_ip); + let mut config = Config::default(); + config.asic_config.table_entry_del.values.insert(nat_table, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, _) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-readd-rollback-test"; + + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + })) + .unwrap(); + let og = add_group_external(&switch, entry).unwrap(); + table::mcast::mcast_egress::del_bitmap_entry( + &switch, + og.external_group_id, + ) + .unwrap(); + + let err = modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: nat_target.try_into().unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id: Some(20), + }, + sources: None, + }, + ) + .unwrap_err(); + assert!(matches!(err, DpdError::Switch(_)), "{err:?}"); + + let dump = + table::mcast::mcast_egress::bitmap_table_dump(&switch, false) + .unwrap(); + assert_eq!(dump.entries.len(), 1, "re-added bitmap was removed"); + assert_eq!( + dump.entries[0].action_args.get("vlan_id").map(String::as_str), + Some("10") + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + + #[test] + fn test_external_create_clears_orphaned_entries() { + for (group_ip, stale_source, source) in [ + ("224.1.2.3", "192.0.2.9", "192.0.2.1"), + ("ff0e::1", "2001:db8::9", "2001:db8::1"), + ] { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, _) = install_underlay_group(&switch); + let external_ip: IpAddr = group_ip.parse().unwrap(); + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let nat_target = external_nat_target(external_ip, underlay_ip); + let stale_source: IpAddr = stale_source.parse().unwrap(); + + match (external_ip, stale_source) { + (IpAddr::V4(group), IpAddr::V4(src)) => { + table::mcast::mcast_nat::add_ipv4_entry( + &switch, + group, + nat_target, + Some(10), + ) + .unwrap(); + table::mcast::mcast_src_filter::add_ipv4_entry( + &switch, + oxnet::Ipv4Net::host_net(src), + group, + ) + .unwrap(); + table::mcast::mcast_route::add_ipv4_entry( + &switch, + group, + Some(10), + ) + .unwrap(); + } + (IpAddr::V6(group), IpAddr::V6(src)) => { + table::mcast::mcast_nat::add_ipv6_entry( + &switch, + group, + nat_target, + Some(10), + ) + .unwrap(); + table::mcast::mcast_src_filter::add_ipv6_entry( + &switch, + oxnet::Ipv6Net::host_net(src), + group, + ) + .unwrap(); + table::mcast::mcast_route::add_ipv6_entry( + &switch, + group, + Some(10), + ) + .unwrap(); + } + _ => unreachable!(), + } + let before: Vec<_> = [route_table, source_table, nat_table] + .into_iter() + .map(|table| table_usage(&switch, table)) + .collect(); + + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": "external-orphan-test", + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 20 }, + "sources": [{ "Exact": source }], + })) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + + for (table, before) in + [route_table, source_table, nat_table].into_iter().zip(before) + { + assert_eq!( + table_usage(&switch, table), + (before.0, before.1 + 1, before.2 + 1, before.3), + "{table}" + ); + } + let nat_dump = match external_ip { + IpAddr::V4(_) => { + table::mcast::mcast_nat::ipv4_table_dump(&switch, false) + } + IpAddr::V6(_) => { + table::mcast::mcast_nat::ipv6_table_dump(&switch, false) + } + } + .unwrap(); + assert_eq!(nat_dump.entries.len(), 1); + assert_eq!( + nat_dump.entries[0].keys.get("vlan_id").map(String::as_str), + Some("20") + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + + #[test] + fn test_underlay_update_readds_missing_entries() { + let (switch, logs) = chaos_switch(Config::default()); + let free_ids = { + let groups = switch.mcast.lock().unwrap(); + groups.free_group_ids.lock().unwrap().len() + }; + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (external_id, tag, members) = { + let groups = switch.mcast.lock().unwrap(); + let group = &groups.groups[&underlay_key]; + ( + group.external_group_id(), + group.tag.clone(), + group.members().cloned().collect::>(), + ) + }; + table::mcast::mcast_egress::del_bitmap_entry(&switch, external_id) + .unwrap(); + table::mcast::mcast_replication::del_ipv6_entry( + &switch, + underlay_ip.into(), + ) + .unwrap(); + + let new_members: Vec<_> = members + .iter() + .filter(|member| member.direction == Direction::Underlay) + .cloned() + .collect(); + assert!(!new_members.is_empty() && new_members.len() < members.len()); + + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: new_members.clone() }, + ) + .unwrap(); + assert_eq!(response.members, new_members); + + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.contains_key(&underlay_key)); + assert_eq!(groups.free_group_ids.lock().unwrap().len(), free_ids - 2); + for table in [ + TableType::McastIpv6, + TableType::RouteIpv6Mcast, + TableType::McastEgressDecapPorts, + ] { + assert_eq!(table_usage(&switch, table).0, 1, "{table}"); + } + assert!(logs.entries.lock().unwrap().is_empty()); + } + + fn external_update_after_underlay_abandoned(retarget: bool) { + let (switch, _logs) = chaos_switch(Config::default()); + let free_ids = { + let groups = switch.mcast.lock().unwrap(); + groups.free_group_ids.lock().unwrap().len() + }; + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + + let external_ip: IpAddr = "ff0e::1".parse().unwrap(); + let group_ip = ExternalMulticastIp::new(external_ip).unwrap(); + let external_key = GroupKey::external(group_ip); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-abandon-test"; + let entry = + serde_json::from_value::( + serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + }), + ) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + + let underlay = + switch.mcast.lock().unwrap().groups.remove(&underlay_key).unwrap(); + let ipv6 = Ipv6Addr::from(underlay_ip); + table::mcast::mcast_egress::del_bitmap_entry( + &switch, + underlay.external_group_id(), + ) + .unwrap(); + table::mcast::mcast_replication::del_ipv6_entry(&switch, ipv6).unwrap(); + table::mcast::mcast_route::del_ipv6_entry(&switch, ipv6).unwrap(); + drop(underlay); + + { + let groups = switch.mcast.lock().unwrap(); + assert_eq!(groups.groups.len(), 1); + assert!(groups.groups.contains_key(&external_key)); + assert_eq!( + groups.nat_target_refs.get(&underlay_ip), + Some(&external_key) + ); + assert_eq!( + groups.free_group_ids.lock().unwrap().len(), + free_ids - 2 + ); + } + + let update = |internal_ip: UnderlayMulticastIpv6| { + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: NatTarget { + internal_ip: internal_ip.into(), + ..nat_target + } + .try_into() + .unwrap(), + }, + external_forwarding: ExternalForwarding { vlan_id: None }, + sources: None, + } + }; + + let err = + modify_group_external(&switch, group_ip, tag, update(underlay_ip)) + .unwrap_err(); + assert!(matches!(err, DpdError::MissingNatTarget(_)), "{err:?}"); + let http_err = dropshot::HttpError::from(err); + assert_eq!(http_err.status_code.as_u16(), 409); + assert_eq!( + http_err.error_code.as_deref(), + Some(common::MISSING_NAT_TARGET_ERROR_CODE) + ); + + let (target_ip, target_key) = if retarget { + install_underlay_group_at(&switch, "ff04::124".parse().unwrap()) + } else { + install_underlay_group_at(&switch, underlay_ip) + }; + + let response = + modify_group_external(&switch, group_ip, tag, update(target_ip)) + .unwrap(); + let groups = switch.mcast.lock().unwrap(); + let target = &groups.groups[&target_key]; + assert_eq!(response.external_group_id, target.external_group_id()); + assert_eq!( + groups.groups[&external_key].underlay_group_id(), + target.underlay_group_id() + ); + assert_eq!(groups.nat_target_refs.len(), 1); + assert_eq!(groups.nat_target_refs.get(&target_ip), Some(&external_key)); + assert_eq!(groups.free_group_ids.lock().unwrap().len(), free_ids - 2); + } + + #[test] + fn test_underlay_rollback_restores_propagated_vlan() { + for fail_port_add in [false, true] { + let mut config = Config::default(); + config + .asic_config + .table_entry_del + .values + .insert(TableType::McastIpv6, 1.0); + if fail_port_add { + config.asic_config.mc_config.mc_port_add = + asic::chaos::Chaos::new(1.0); + } + let (switch, _logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + + let external_ip: IpAddr = "ff0e::1".parse().unwrap(); + let nat_target = external_nat_target(external_ip, underlay_ip); + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": "external-vlan-rollback-test", + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + })) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + + let bitmap_vlan = || { + let dump = table::mcast::mcast_egress::bitmap_table_dump( + &switch, false, + ) + .unwrap(); + assert_eq!(dump.entries.len(), 1); + dump.entries[0].action_args.get("vlan_id").cloned() + }; + assert_eq!(bitmap_vlan().as_deref(), Some("10")); + + let (underlay_tag, members) = + underlay_tag_and_members(&switch, underlay_key); + + let err = modify_group_underlay( + &switch, + underlay_ip, + &underlay_tag, + MulticastGroupUpdateUnderlayEntry { members: Vec::new() }, + ) + .unwrap_err(); + + let init = match &err { + DpdError::Unwind { initial: init, unwind } => { + assert!( + fail_port_add + && unwind.len() == 1 + && matches!( + &unwind.head, + DpdError::Switch( + aal::AsicError::Synthetic(op) + ) if op == "mc_port_add" + ), + "{err:?}" + ); + init.as_ref() + } + err => { + assert!(!fail_port_add, "{err:?}"); + err + } + }; + assert!( + matches!( + init, + DpdError::Switch(aal::AsicError::Synthetic(op)) + if op == "table_entry_del" + ), + "{err:?}" + ); + assert_eq!(bitmap_vlan().as_deref(), Some("10")); + assert_eq!( + underlay_tag_and_members(&switch, underlay_key).1, + members + ); + } + } + + fn nat_vlan_change_failure( + group_ip: IpAddr, + restore_fails: bool, + ) -> (Switch, TableType, DpdError) { + let (switch, _logs) = chaos_switch(Config::default()); + let (_, _, nat_table) = external_tables(group_ip); + let tgt = external_nat_target(group_ip, "ff04::123".parse().unwrap()); + let (old_vlan, new_vlan) = (Some(10), Some(4095)); + + match group_ip { + IpAddr::V4(ip) => { + mcast_nat::add_ipv4_entry(&switch, ip, tgt, old_vlan) + } + IpAddr::V6(ip) => { + mcast_nat::add_ipv6_entry(&switch, ip, tgt, old_vlan) + } + } + .unwrap(); + + if restore_fails { + switch.table_get(nat_table).unwrap().usage.size = 0; + } + + let err = match group_ip { + IpAddr::V4(ip) => mcast_nat::update_ipv4_entry( + &switch, ip, tgt, tgt, old_vlan, new_vlan, + ), + IpAddr::V6(ip) => mcast_nat::update_ipv6_entry( + &switch, ip, tgt, tgt, old_vlan, new_vlan, + ), + } + .unwrap_err(); + + (switch, nat_table, err) + } + + #[test] + fn test_nat_vlan_change_failure_restores_entry() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + let group_ip: IpAddr = group_ip.parse().unwrap(); + let (switch, nat_table, err) = + nat_vlan_change_failure(group_ip, false); + + assert!(!matches!(err, DpdError::Unwind { .. }), "{err:?}"); + assert_eq!(table_usage(&switch, nat_table), (1, 2, 1, 0)); + + let dump = match group_ip { + IpAddr::V4(_) => mcast_nat::ipv4_table_dump(&switch, false), + IpAddr::V6(_) => mcast_nat::ipv6_table_dump(&switch, false), + } + .unwrap(); + assert_eq!(dump.entries.len(), 1); + assert_eq!( + dump.entries[0].keys.get("vlan_id").map(String::as_str), + Some("10"), + "{:?}", + dump.entries[0].keys + ); + } + } + + #[test] + fn test_nat_vlan_change_failure_reports_lost_entry() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + let (switch, nat_table, err) = + nat_vlan_change_failure(group_ip.parse().unwrap(), true); + + assert!( + matches!( + err, + DpdError::Unwind { ref unwind, .. } + if unwind.len() == 1 + && matches!(unwind.head, DpdError::TableFull(_)) + ), + "{err:?}" + ); + assert_eq!(table_usage(&switch, nat_table), (0, 1, 1, 0)); + } + } + + #[test] + fn test_underlay_first_members_overwrite_stale_bitmap() { + let (switch, logs) = chaos_switch(Config::default()); + let (_, underlay_key) = install_empty_underlay_group(&switch); + let group = switch.mcast.lock().unwrap().groups[&underlay_key].clone(); + + table::mcast::mcast_egress::add_bitmap_entry( + &switch, + group.external_group_id(), + &table::mcast::mcast_egress::PortBitmap::new(), + Some(10), + ) + .unwrap(); + + let members = vec![MulticastGroupMember { + port_id: RearPort::new(0).unwrap().into(), + link_id: LinkId(0), + direction: Direction::External, + }]; + update_underlay_group_bitmap_tables(&switch, &group, &members, None) + .unwrap(); + + assert_eq!( + table_usage(&switch, TableType::McastEgressDecapPorts), + (1, 1, 0, 1) + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_del_empty_underlay_clears_stale_entries() { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_empty_underlay_group(&switch); + let group = switch.mcast.lock().unwrap().groups[&underlay_key].clone(); + let repl = MulticastReplicationInfo::new(group.external_group_id()); + + table::mcast::mcast_egress::add_bitmap_entry( + &switch, + group.external_group_id(), + &table::mcast::mcast_egress::PortBitmap::new(), + None, + ) + .unwrap(); + table::mcast::mcast_replication::add_ipv6_entry( + &switch, + underlay_ip.into(), + group.underlay_group_id(), + group.external_group_id(), + repl.rid, + repl.level1_excl_id, + repl.level2_excl_id, + ) + .unwrap(); + + del_group(&switch, underlay_key.ip(), &group.tag).unwrap(); + + for table in [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + ] { + assert_eq!(table_usage(&switch, table), (0, 1, 1, 0), "{table}"); + } + assert!(switch.mcast.lock().unwrap().groups.is_empty()); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_underlay_create_clears_stale_entries() { + let mut config = Config::default(); + config.asic_config.mc_config.mc_group_create = + asic::chaos::Chaos::new(1.0); + + let (switch, _logs) = chaos_switch(config); + let free_ids = { + let groups = switch.mcast.lock().unwrap(); + groups.free_group_ids.lock().unwrap().len() + }; + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + drop(switch.mcast.lock().unwrap().groups.remove(&underlay_key)); + + let err = add_group_underlay( + &switch, + MulticastGroupCreateUnderlayEntry { + group_ip: underlay_ip, + tag: Some("underlay-stale-test".to_string()), + members: Vec::new(), + }, + ) + .unwrap_err(); + assert!( + matches!(err, DpdError::Switch(aal::AsicError::Synthetic(_))), + "{err:?}" + ); + + for table in [TableType::McastIpv6, TableType::RouteIpv6Mcast] { + assert_eq!(table_usage(&switch, table), (0, 1, 1, 0), "{table}"); + } + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.is_empty()); + assert_eq!(groups.free_group_ids.lock().unwrap().len(), free_ids); + } + + #[test] + fn test_external_update_after_underlay_abandoned_recreate() { + external_update_after_underlay_abandoned(false); + } + + #[test] + fn test_external_update_after_underlay_abandoned_retarget() { + external_update_after_underlay_abandoned(true); + } + + #[test] + fn test_external_create_rejects_deleted_underlay() { + let (switch, logs) = chaos_switch(Config::default()); + let free_ids = { + let groups = switch.mcast.lock().unwrap(); + groups.free_group_ids.lock().unwrap().len() + }; + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, _) = underlay_tag_and_members(&switch, underlay_key); + del_group(&switch, underlay_key.ip(), &tag).unwrap(); + + let tables = [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + TableType::RouteIpv4Mcast, + TableType::McastIpv4SrcFilter, + TableType::McastIpv6SrcFilter, + TableType::NatIngressIpv4Mcast, + TableType::NatIngressIpv6Mcast, + ]; + + let before: BTreeMap<_, _> = tables + .into_iter() + .map(|table| (table, table_usage(&switch, table))) + .collect(); + + for external_ip in ["239.1.2.3", "ff0e::1"] { + let external_ip: IpAddr = external_ip.parse().unwrap(); + let entry = external_entry( + external_ip, + "external-deleted-underlay-test", + external_nat_target(external_ip, underlay_ip), + Some(10), + serde_json::Value::Null, + ); + + let err = add_group_external(&switch, entry).unwrap_err(); + assert!(matches!(err, DpdError::MissingNatTarget(_)), "{err:?}"); + let http_err = dropshot::HttpError::from(err); + assert_eq!(http_err.status_code.as_u16(), 409); + assert_eq!( + http_err.error_code.as_deref(), + Some(common::MISSING_NAT_TARGET_ERROR_CODE) + ); + } + + for (table, usage) in before { + assert_eq!(table_usage(&switch, table), usage, "{table}"); + } + + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.is_empty()); + assert!(groups.nat_target_refs.is_empty()); + assert_eq!(groups.free_group_ids.lock().unwrap().len(), free_ids); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_del_external_nat_failure_keeps_underlay_vlan() { + let external_ip: IpAddr = "239.1.2.3".parse().unwrap(); + let (_, source_table, nat_table) = external_tables(external_ip); + let mut config = Config::default(); + config.asic_config.table_entry_del.values.insert(nat_table, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, _) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-del-nat-failure-test"; + + let entry = + serde_json::from_value::( + serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + }), + ) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + + let before_decap = + table_usage(&switch, TableType::McastEgressDecapPorts); + assert_eq!(before_decap, (1, 1, 0, 1)); + let before_filters = table_usage(&switch, source_table); + assert_eq!(before_filters.0, 1); + + let err = del_group(&switch, external_ip, tag).unwrap_err(); + assert!(matches!(err, DpdError::Switch(_)), "{err:?}"); + + assert_eq!( + table_usage(&switch, TableType::McastEgressDecapPorts), + before_decap, + "underlay VLAN was cleared" + ); + assert_eq!(table_usage(&switch, nat_table), (1, 1, 0, 0)); + assert_eq!( + table_usage(&switch, source_table), + (1, 2, 1, 0), + "source filters were not restored" + ); + + let external_key = + GroupKey::external(ExternalMulticastIp::new(external_ip).unwrap()); + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.contains_key(&external_key)); + assert_eq!( + groups.nat_target_refs.get(&underlay_ip), + Some(&external_key) + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_underlay_bitmap_failure_skips_repl_and_route() { + let mut config = Config::default(); + config + .asic_config + .table_entry_add + .values + .insert(TableType::McastEgressDecapPorts, 1.0); + + let (switch, logs) = chaos_switch(config); + let underlay_ip: UnderlayMulticastIpv6 = "ff04::123".parse().unwrap(); + let (external_scoped_group, underlay_scoped_group) = { + let mut groups = switch.mcast.lock().unwrap(); + ( + groups.generate_group_id().unwrap(), + groups.generate_group_id().unwrap(), + ) + }; + let repl = MulticastReplicationInfo::new(external_scoped_group.id()); + let members = vec![MulticastGroupMember { + port_id: RearPort::new(0).unwrap().into(), + link_id: LinkId(0), + direction: Direction::External, + }]; + + let before: Vec<_> = [ + TableType::McastEgressDecapPorts, + TableType::McastIpv6, + TableType::RouteIpv6Mcast, + ] + .into_iter() + .map(|table| (table, table_usage(&switch, table))) + .collect(); + + let err = configure_underlay_tables( + &switch, + underlay_ip, + external_scoped_group.id(), + underlay_scoped_group.id(), + Some(&repl), + &members, + Some(10), + ) + .unwrap_err(); + assert!(matches!(err, DpdError::Switch(_)), "{err:?}"); + + for (table, before) in before { + assert_eq!(table_usage(&switch, table), before, "{table}"); + } + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_reset_tag_underlay_failure_after_externals() { + let mut config = Config::default(); + config.asic_config.mc_config.mc_group_destroy = + asic::chaos::Chaos::new(1.0); + + let (switch, logs) = chaos_switch(config); + let tag = "reset-tag-failure-test"; + let underlay_ips: [UnderlayMulticastIpv6; 2] = + ["ff04::123", "ff04::124"].map(|ip| ip.parse().unwrap()); + let external_ips: [IpAddr; 2] = + ["239.1.2.3", "ff0e::1"].map(|ip| ip.parse().unwrap()); + let other_ip: UnderlayMulticastIpv6 = "ff04::125".parse().unwrap(); + + for group_ip in underlay_ips.into_iter().chain([other_ip]) { + let group_tag = + if group_ip == other_ip { "reset-tag-other-test" } else { tag }; + add_group_underlay( + &switch, + MulticastGroupCreateUnderlayEntry { + group_ip, + tag: Some(group_tag.to_string()), + members: Vec::new(), + }, + ) + .unwrap(); + } + + for (external_ip, underlay_ip) in + external_ips.into_iter().zip(underlay_ips) + { + let nat_target = external_nat_target(external_ip, underlay_ip); + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + })) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + } + + let err = reset_tag(&switch, tag).unwrap_err(); + assert!(matches!(err, DpdError::Switch(_)), "{err:?}"); + + let groups = switch.mcast.lock().unwrap(); + for external_ip in external_ips { + assert!(!groups.groups.contains_key(&external_ip)); + } + for underlay_ip in underlay_ips { + assert!(groups.groups.contains_key(&IpAddr::from(underlay_ip))); + } + assert!(groups.groups.contains_key(&IpAddr::from(other_ip))); + assert_eq!(groups.groups.len(), 3); + assert!(groups.nat_target_refs.is_empty()); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_del_underlay_bitmap_failure_restores_repl() { + let mut config = Config::default(); + config + .asic_config + .table_entry_del + .values + .insert(TableType::McastEgressDecapPorts, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, members) = underlay_tag_and_members(&switch, underlay_key); + let before_repl = table_usage(&switch, TableType::McastIpv6); + assert_eq!(before_repl.0, 1); + + let err = del_group(&switch, underlay_key.ip(), &tag).unwrap_err(); + assert!(matches!(err, DpdError::Switch(_)), "{err:?}"); + + assert_eq!( + table_usage(&switch, TableType::McastIpv6).0, + before_repl.0, + "replication entry was not restored" + ); + assert!( + switch.mcast.lock().unwrap().groups.contains_key(&underlay_key) + ); + assert!(logs.entries.lock().unwrap().is_empty()); + + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: members.clone() }, + ) + .unwrap(); + assert_eq!(response.members, members); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 1); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_del_external_restore_failure_reports_unwind() { + let external_ip: IpAddr = "239.1.2.3".parse().unwrap(); + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let mut config = Config::default(); + config.asic_config.table_entry_del.values.insert(nat_table, 1.0); + config.asic_config.table_entry_add.values.insert(source_table, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let IpAddr::V4(ipv4) = external_ip else { unreachable!() }; + mcast_nat::add_ipv4_entry(&switch, ipv4, nat_target, None).unwrap(); + table::mcast::mcast_route::add_ipv4_entry(&switch, ipv4, None).unwrap(); + + let tag = "external-del-restore-failure-test"; + let group_ip = ExternalMulticastIp::new(external_ip).unwrap(); + let external_key = GroupKey::external(group_ip); + { + let mut groups = switch.mcast.lock().unwrap(); + let underlay = &groups.groups[&underlay_key]; + let external = MulticastGroup { + external_scoped_group: underlay.external_scoped_group.clone(), + underlay_scoped_group: underlay.underlay_scoped_group.clone(), + tag: tag.to_string(), + kind: GroupKind::External { + group_ip, + nat_target: nat_target.try_into().unwrap(), + vlan_id: None, + sources: SourceFilter::from_exact(["192.0.2.1" + .parse::() + .unwrap()]), + }, + }; + groups.groups.insert(external_key, external); + groups.add_forwarding_refs(external_key, underlay_ip); + } + + let before_route = table_usage(&switch, route_table); + + let err = del_group(&switch, external_ip, tag).unwrap_err(); + assert!( + matches!( + err, + DpdError::Unwind { initial: ref init, ref unwind } + if matches!(**init, DpdError::Switch(_)) + && unwind.len() == 1 + ), + "{err:?}" + ); + + let http_err = dropshot::HttpError::from(err); + assert_eq!(http_err.status_code.as_u16(), 418); + assert_eq!( + http_err.error_code.as_deref(), + Some(common::ROLLBACK_FAILURE_ERROR_CODE) + ); + + assert_eq!(table_usage(&switch, nat_table), (1, 1, 0, 0)); + assert_eq!(table_usage(&switch, route_table), before_route); + assert_eq!(table_usage(&switch, source_table).0, 0); + + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), 1, "{errs:?}"); + assert_eq!(errs[0]["operation"], "restore source filter entry"); + assert!(errs[0]["context"].contains("192.0.2.1")); + assert!(errs[0]["error"].contains("table_entry_add")); + + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.contains_key(&external_key)); + assert_eq!( + groups.nat_target_refs.get(&underlay_ip), + Some(&external_key) + ); + } + + #[test] + fn test_external_update_readds_missing_source_filters() { + for (group_ip, source_ips) in [ + ("224.1.2.3", ["192.0.2.1", "192.0.2.2"]), + ("ff0e::1", ["2001:db8::1", "2001:db8::2"]), + ] { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, _) = install_underlay_group(&switch); + let external_ip: IpAddr = group_ip.parse().unwrap(); + let (_, source_table, _) = external_tables(external_ip); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-readd-sources-test"; + + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": source_ips.map(|ip| { + serde_json::json!({ "Exact": ip }) + }), + })) + .unwrap(); + + add_group_external(&switch, entry).unwrap(); + assert_eq!(table_usage(&switch, source_table).0, 2); + + for source_ip in source_ips { + match (external_ip, source_ip.parse::().unwrap()) { + (IpAddr::V4(group), IpAddr::V4(source)) => { + table::mcast::mcast_src_filter::del_ipv4_entry( + &switch, + oxnet::Ipv4Net::host_net(source), + group, + ) + } + (IpAddr::V6(group), IpAddr::V6(source)) => { + table::mcast::mcast_src_filter::del_ipv6_entry( + &switch, + oxnet::Ipv6Net::host_net(source), + group, + ) + } + _ => unreachable!(), + } + .unwrap(); + } + assert_eq!(table_usage(&switch, source_table).0, 0); + + let sources = source_ips + .map(|ip| SourceEntry::Exact(ip.parse().unwrap())) + .to_vec(); + modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: nat_target.try_into().unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id: Some(10), + }, + sources: Some(sources), + }, + ) + .unwrap(); + + assert_eq!( + table_usage(&switch, source_table).0, + 2, + "source filters were not re-added" + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + + #[test] + fn test_underlay_update_readds_missing_repl() { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, members) = underlay_tag_and_members(&switch, underlay_key); + + table::mcast::mcast_replication::del_ipv6_entry( + &switch, + underlay_ip.into(), + ) + .unwrap(); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 0); + + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: members.clone() }, + ) + .unwrap(); + assert_eq!(response.members, members); + + assert_eq!( + table_usage(&switch, TableType::McastIpv6).0, + 1, + "replication entry was not re-added" + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_underlay_update_rollback_readds_missing_repl() { + for fail_port_remove in [false, true] { + let mut config = Config::default(); + config + .asic_config + .table_entry_update + .values + .insert(TableType::McastEgressDecapPorts, 1.0); + if fail_port_remove { + config.asic_config.mc_config.mc_port_remove = + asic::chaos::Chaos::new(1.0); + } + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, members) = + underlay_tag_and_members(&switch, underlay_key); + + table::mcast::mcast_replication::del_ipv6_entry( + &switch, + underlay_ip.into(), + ) + .unwrap(); + + let before_repl = table_usage(&switch, TableType::McastIpv6); + assert_eq!(before_repl.0, 0); + + let mut new_members = members.clone(); + new_members.push(MulticastGroupMember { + port_id: RearPort::new(2).unwrap().into(), + link_id: LinkId(0), + direction: Direction::External, + }); + + let err = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: new_members }, + ) + .unwrap_err(); + + let rollback_op = if fail_port_remove { + "mc_port_remove" + } else { + "table_entry_update" + }; + assert!( + matches!( + err, + DpdError::Unwind { initial: ref init, ref unwind } + if matches!( + init.as_ref(), + DpdError::Switch( + aal::AsicError::Synthetic(op) + ) if op == "table_entry_update" + ) && unwind.len() == 1 + && matches!( + &unwind.head, + DpdError::Switch( + aal::AsicError::Synthetic(op) + ) if op == rollback_op + ) + ), + "{err:?}" + ); + + assert_eq!( + table_usage(&switch, TableType::McastIpv6), + (1, before_repl.1 + 1, before_repl.2, before_repl.3), + "replication entry was not re-added by rollback" + ); + + let errs = logs.entries.lock().unwrap(); + let expected: &[(&str, &str)] = if fail_port_remove { + &[ + ("port changes", "mc_port_remove"), + ("restore VLAN settings", "table_entry_update"), + ] + } else { + &[("restore VLAN settings", "table_entry_update")] + }; + assert_eq!(errs.len(), expected.len(), "{errs:?}"); + for (err, &(op, failed_call)) in errs.iter().zip(expected) { + assert_eq!(err["operation"], op); + assert!(err["error"].contains(failed_call)); + } + + let groups = switch.mcast.lock().unwrap(); + let group = &groups.groups[&underlay_key]; + assert_eq!(group.members().cloned().collect::>(), members); + } + } + + #[test] + fn test_del_external_rollback_bitmap_readd_hits_full_table() { + let external_ip: IpAddr = "239.1.2.3".parse().unwrap(); + let (route_table, _, nat_table) = external_tables(external_ip); + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-del-missing-bitmap-test"; + + let entry = + serde_json::from_value::( + serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + }), + ) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + + let external_group_id = switch.mcast.lock().unwrap().groups + [&underlay_key] + .external_group_id(); + table::mcast::mcast_egress::del_bitmap_entry( + &switch, + external_group_id, + ) + .unwrap(); + + { + let mut decap = + switch.table_get(TableType::McastEgressDecapPorts).unwrap(); + decap.usage.size = decap.usage.occupancy; + } + + let before_decap = + table_usage(&switch, TableType::McastEgressDecapPorts); + assert_eq!(before_decap.0, 0); + + let err = del_group(&switch, external_ip, tag).unwrap_err(); + assert!( + matches!( + err, + DpdError::Unwind { initial: ref init, ref unwind } + if matches!(**init, DpdError::TableFull(_)) + && unwind.len() == 1 + && matches!(unwind.head, DpdError::TableFull(_)) + ), + "{err:?}" + ); + + assert_eq!( + table_usage(&switch, TableType::McastEgressDecapPorts), + before_decap + ); + assert!(bitmap_entry(&switch, external_group_id).is_none()); + assert_eq!(table_usage(&switch, nat_table).0, 1); + assert_eq!(table_usage(&switch, route_table).0, 1); + assert_eq!( + route_action(&switch, external_ip) + .and_then(|(_, vlan_id)| vlan_id) + .as_deref(), + Some("10") + ); + + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), 1, "{errs:?}"); + assert_eq!(errs[0]["operation"], "restore multicast bitmap VLAN"); + + let external_key = + GroupKey::external(ExternalMulticastIp::new(external_ip).unwrap()); + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.contains_key(&external_key)); + assert_eq!( + groups.nat_target_refs.get(&underlay_ip), + Some(&external_key) + ); + } + + fn underlay_tag_and_members( + switch: &Switch, + underlay_key: GroupKey, + ) -> (String, Vec) { + let groups = switch.mcast.lock().unwrap(); + let group = &groups.groups[&underlay_key]; + (group.tag.clone(), group.members().cloned().collect()) + } + + #[test] + fn test_del_underlay_destroy_failure_keeps_group_usable() { + let mut config = Config::default(); + config.asic_config.mc_config.mc_group_destroy = + asic::chaos::Chaos::new(1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, members) = underlay_tag_and_members(&switch, underlay_key); + let tables = [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + ]; + for table in tables { + assert_eq!(table_usage(&switch, table).0, 1, "{table}"); + } + + let err = del_group(&switch, underlay_key.ip(), &tag).unwrap_err(); + assert!( + matches!( + err, + DpdError::Switch(aal::AsicError::Synthetic(ref op)) + if op == "mc_group_destroy" + ), + "{err:?}" + ); + + for table in tables { + assert_eq!(table_usage(&switch, table).0, 1, "{table}"); + } + assert_eq!(underlay_tag_and_members(&switch, underlay_key).1, members); + assert!(logs.entries.lock().unwrap().is_empty()); + + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: members.clone() }, + ) + .unwrap(); + assert_eq!(response.members, members); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_del_underlay_domain_restore_failure_reports_unwind() { + for fail_create in [true, false] { + let mut config = Config::default(); + config.asic_config.mc_config.mc_group_destroy = + asic::chaos::Chaos::new(1.0); + if fail_create { + config.asic_config.mc_config.mc_group_create = + asic::chaos::Chaos::new(1.0); + } else { + config.asic_config.mc_config.mc_port_add = + asic::chaos::Chaos::new(1.0); + } + let (op, failed_call) = if fail_create { + ("restore multicast group", "mc_group_create") + } else { + ("restore multicast group member", "mc_port_add") + }; + + let (switch, logs) = chaos_switch(config); + let (_, underlay_key) = install_underlay_group(&switch); + let (tag, members) = + underlay_tag_and_members(&switch, underlay_key); + + let err = del_group(&switch, underlay_key.ip(), &tag).unwrap_err(); + assert!( + matches!( + err, + DpdError::Unwind { initial: ref init, ref unwind } + if matches!(**init, DpdError::Switch(_)) + && unwind.len() == 1 + ), + "{err:?}" + ); + + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), 2, "{errs:?}"); + for err in errs.iter() { + assert_eq!(err["operation"], op); + assert!(err["error"].contains(failed_call), "{err:?}"); + } + + for table in [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + ] { + assert_eq!(table_usage(&switch, table).0, 1, "{table}"); + } + assert_eq!( + underlay_tag_and_members(&switch, underlay_key).1, + members + ); + } + } + + #[test] + fn test_underlay_unchanged_update_readd_failure_keeps_members() { + let mut config = Config::default(); + config.asic_config.mc_config.mc_port_add = asic::chaos::Chaos::new(1.0); + config.asic_config.mc_config.mc_port_remove = + asic::chaos::Chaos::new(1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, members) = underlay_tag_and_members(&switch, underlay_key); + + let err = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: members.clone() }, + ) + .unwrap_err(); + assert!( + matches!( + err, + DpdError::Switch(aal::AsicError::Synthetic(ref op)) + if op == "mc_port_add" + ), + "{err:?}" + ); + + assert_eq!(underlay_tag_and_members(&switch, underlay_key).1, members); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_underlay_unchanged_update_readds_missing_bitmap() { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, members) = underlay_tag_and_members(&switch, underlay_key); + let external_id = switch.mcast.lock().unwrap().groups[&underlay_key] + .external_group_id(); + + table::mcast::mcast_egress::del_bitmap_entry(&switch, external_id) + .unwrap(); + assert_eq!(table_usage(&switch, TableType::McastEgressDecapPorts).0, 0); + + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: members.clone() }, + ) + .unwrap(); + assert_eq!(response.members, members); + + assert_eq!( + bitmap_entry(&switch, external_id), + Some((create_port_bitmap(&members, Direction::External), None)), + "bitmap entry was not re-added" + ); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 1); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_external_update_readds_retained_source_filters() { + for (group_ip, [first, second, third]) in [ + ("224.1.2.3", ["192.0.2.1", "192.0.2.2", "192.0.2.3"]), + ("ff0e::1", ["2001:db8::1", "2001:db8::2", "2001:db8::3"]), + ] { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, _) = install_underlay_group(&switch); + let external_ip: IpAddr = group_ip.parse().unwrap(); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-retained-sources-test"; + let [first, second, third]: [IpAddr; 3] = + [first, second, third].map(|ip| ip.parse().unwrap()); + let prefix_len = if external_ip.is_ipv4() { 32 } else { 128 }; + let [first_prefix, second_prefix, third_prefix] = + [first, second, third].map(|ip| format!("{ip}/{prefix_len}")); + + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": [{ "Exact": first }, { "Exact": second }], + })) + .unwrap(); + + add_group_external(&switch, entry).unwrap(); + assert_eq!( + source_filter_prefixes(&switch, external_ip), + BTreeSet::from([first_prefix.clone(), second_prefix.clone()]) + ); + + remove_source_filter(&switch, external_ip, IpSrc::Exact(second)) + .unwrap(); + assert_eq!( + source_filter_prefixes(&switch, external_ip), + BTreeSet::from([first_prefix]) + ); + + let sources = [second, third] + .map(|ip| SourceEntry::Exact(ExactSource::new(ip).unwrap())) + .to_vec(); + modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: nat_target.try_into().unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id: Some(10), + }, + sources: Some(sources), + }, + ) + .unwrap(); + + assert!(logs.entries.lock().unwrap().is_empty()); + assert_eq!( + source_filter_prefixes(&switch, external_ip), + BTreeSet::from([second_prefix, third_prefix]) + ); + } + } + + fn forwarding_table_snapshot( + switch: &Switch, + table_type: TableType, + ) -> serde_json::Value { + let mut dump = + table::get_entries(switch, table_type.to_string(), false).unwrap(); + dump.entries.sort_by(|left, right| left.keys.cmp(&right.keys)); + serde_json::to_value(dump).unwrap() + } + + #[test] + fn test_put_readds_missing_external_nat_and_routes() { + for external_ip in ["239.1.2.3", "ff0e::1"] { + for vlan_id in [None, Some(10)] { + for (remove_nat, remove_route) in + [(true, false), (false, true), (true, true)] + { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, _) = install_underlay_group(&switch); + let external_ip: IpAddr = external_ip.parse().unwrap(); + let (route_table, _, nat_table) = + external_tables(external_ip); + let nat_target = + external_nat_target(external_ip, underlay_ip); + let tag = "external-put-repair-test"; + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": vlan_id }, + "sources": null, + })) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + let before_route = + forwarding_table_snapshot(&switch, route_table); + let before_nat = + forwarding_table_snapshot(&switch, nat_table); + + match external_ip { + IpAddr::V4(ipv4) => { + if remove_nat { + mcast_nat::del_ipv4_entry( + &switch, ipv4, vlan_id, + ) + .unwrap(); + } + if remove_route { + table::mcast::mcast_route::del_ipv4_entry( + &switch, ipv4, + ) + .unwrap(); + } + } + IpAddr::V6(ipv6) => { + if remove_nat { + mcast_nat::del_ipv6_entry( + &switch, ipv6, vlan_id, + ) + .unwrap(); + } + if remove_route { + table::mcast::mcast_route::del_ipv6_entry( + &switch, ipv6, + ) + .unwrap(); + } + } + } + + for _ in 0..2 { + modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: + ExternalInternalForwarding { + nat_target: nat_target + .try_into() + .unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id, + }, + sources: None, + }, + ) + .unwrap(); + assert_eq!( + forwarding_table_snapshot(&switch, route_table), + before_route, + ); + assert_eq!( + forwarding_table_snapshot(&switch, nat_table), + before_nat, + ); + } + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + } + } + + #[test] + fn test_put_readds_missing_underlay_routes() { + for (was_active, want_active) in + [(false, false), (false, true), (true, false), (true, true)] + { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = if was_active { + install_underlay_group(&switch) + } else { + install_empty_underlay_group(&switch) + }; + let (tag, original_members) = + underlay_tag_and_members(&switch, underlay_key); + let members = if want_active { + if was_active { + original_members + } else { + vec![MulticastGroupMember { + port_id: RearPort::new(0).unwrap().into(), + link_id: LinkId(0), + direction: Direction::External, + }] + } + } else { + Vec::new() + }; + let before_route = + forwarding_table_snapshot(&switch, TableType::RouteIpv6Mcast); + table::mcast::mcast_route::del_ipv6_entry( + &switch, + underlay_ip.into(), + ) + .unwrap(); + assert_eq!(table_usage(&switch, TableType::RouteIpv6Mcast).0, 0); + + for _ in 0..2 { + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { + members: members.clone(), + }, + ) + .unwrap(); + assert_eq!(response.members, members); + assert_eq!( + forwarding_table_snapshot( + &switch, + TableType::RouteIpv6Mcast, + ), + before_route, + ); + for table_type in + [TableType::McastIpv6, TableType::McastEgressDecapPorts] + { + assert_eq!( + table_usage(&switch, table_type).0, + u32::from(want_active), + "{table_type}", + ); + } + } + assert!(logs.entries.lock().unwrap().is_empty()); } - error } } diff --git a/dpd/src/mcast/validate.rs b/dpd/src/mcast/validate.rs deleted file mode 100644 index c5c2fcb2..00000000 --- a/dpd/src/mcast/validate.rs +++ /dev/null @@ -1,1059 +0,0 @@ -// 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 address validation. -//! -//! Reserved multicast addresses are defined by IANA: -//! . - -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - -use super::IpSrc; -use crate::types::{DpdError, DpdResult}; -use common::network::NatTarget; -use dpd_types::mcast::MulticastTag; -use omicron_common::address::{ - IPV4_LINK_LOCAL_MULTICAST_SUBNET, IPV4_SSM_SUBNET, - UNDERLAY_MULTICAST_SUBNET, -}; - -const IPV4_SSM_RESERVED_NULL: Ipv4Addr = Ipv4Addr::new(232, 0, 0, 0); -const IPV6_SSM_MIN_VALID_GROUP_ID: u32 = 0x4000_0001; - -/// Check if an IP address is unicast (emulating the unstable std::net API). -/// For IP addresses, unicast means simply "not multicast". -const fn is_unicast(addr: IpAddr) -> bool { - !addr.is_multicast() -} - -/// Validates if a multicast address is allowed for group creation. -/// -/// Returns a [`DpdResult`] indicating whether the address is valid or not. -pub(crate) fn validate_multicast_address( - addr: IpAddr, - sources: Option<&[IpSrc]>, -) -> DpdResult<()> { - validate_source_address_family(addr, sources)?; - - // First validate that source addresses are unicast - validate_source_addresses(sources)?; - - // Then validate the multicast address itself - match addr { - IpAddr::V4(ipv4) => validate_ipv4_multicast(ipv4, sources), - IpAddr::V6(ipv6) => validate_ipv6_multicast(ipv6, sources), - } -} - -/// Validates that exact sources use the multicast group's address family. -fn validate_source_address_family( - group: IpAddr, - sources: Option<&[IpSrc]>, -) -> DpdResult<()> { - let Some(sources) = sources else { - return Ok(()); - }; - - for source in sources { - let IpSrc::Exact(source) = source else { - continue; - }; - if source.is_ipv4() != group.is_ipv4() { - return Err(DpdError::Invalid(format!( - "Source IP {source} does not match multicast group address family ({group})", - ))); - } - } - Ok(()) -} - -/// Validates the NAT target inner MAC and internal IP address. -/// -/// NAT targets must use addresses from the reserved underlay multicast subnet -/// (ff04::/64) which is allocated by Omicron for internal multicast routing. -pub(crate) fn validate_nat_target(nat_target: NatTarget) -> DpdResult<()> { - if !nat_target.inner_mac.is_multicast() { - return Err(DpdError::Invalid(format!( - "NAT target inner MAC address {inner_mac} is not a multicast MAC address", - inner_mac = nat_target.inner_mac - ))); - } - - if !UNDERLAY_MULTICAST_SUBNET.contains(nat_target.internal_ip) { - return Err(DpdError::Invalid(format!( - "NAT target internal IP address {} is not in the reserved \ - underlay multicast subnet (ff04::/64)", - nat_target.internal_ip - ))); - } - - Ok(()) -} - -/// Check if an IP address is a Source-Specific Multicast (SSM) address. -/// -/// IPv6 SSM uses `ff3x::/32`: flags `3`, any scope, and a zero second -/// segment. This excludes the broader `ff30::/12` range and RFC 3306 -/// unicast-prefix-based multicast addresses, whose second segment carries -/// `plen`. -/// -/// [RFC 4607 §1]: https://www.rfc-editor.org/rfc/rfc4607#section-1 -/// [RFC 3306]: https://www.rfc-editor.org/rfc/rfc3306 -pub(crate) fn is_ssm(addr: IpAddr) -> bool { - match addr { - IpAddr::V4(ipv4) => IPV4_SSM_SUBNET.contains(ipv4), - IpAddr::V6(ipv6) => { - let segs = ipv6.segments(); - segs[0] & 0xfff0 == 0xff30 && segs[1] == 0 - } - } -} - -/// Check if sources contain an Any variant. -fn contains_any_source(sources: &[IpSrc]) -> bool { - sources.iter().any(|s| matches!(s, IpSrc::Any)) -} - -/// Validates IPv4 multicast addresses. -fn validate_ipv4_multicast( - addr: Ipv4Addr, - sources: Option<&[IpSrc]>, -) -> DpdResult<()> { - // Verify this is actually a multicast address - if !addr.is_multicast() { - return Err(DpdError::Invalid(format!( - "{addr} is not a multicast address", - ))); - } - - // If this is SSM, require specific sources (RFC 4607) - if is_ssm(addr.into()) { - if addr == IPV4_SSM_RESERVED_NULL { - return Err(DpdError::Invalid(format!( - "{addr} is reserved and must not be used as a destination \ - (RFC 4607)", - ))); - } - if sources.is_none() || sources.unwrap().is_empty() { - return Err(DpdError::Invalid(format!( - "{addr} is a Source-Specific Multicast address and \ - requires at least one source to be defined", - ))); - } - if contains_any_source(sources.unwrap()) { - return Err(DpdError::Invalid(format!( - "{addr} is a Source-Specific Multicast address and \ - requires specific sources (IpSrc::Any is not allowed)", - ))); - } - return Ok(()); - } - - // Check reserved subnets - if IPV4_LINK_LOCAL_MULTICAST_SUBNET.contains(addr) { - return Err(DpdError::Invalid(format!( - "{addr} is in the reserved link-local multicast subnet", - ))); - } - - Ok(()) -} - -/// Validates IPv6 multicast addresses. -fn validate_ipv6_multicast( - addr: Ipv6Addr, - sources: Option<&[IpSrc]>, -) -> DpdResult<()> { - if !addr.is_multicast() { - return Err(DpdError::Invalid(format!( - "{addr} is not a multicast address", - ))); - } - - // Allow admin-local (4), site-local (5), organization-local (8), and - // global (e) scopes. Reject reserved, interface-local, link-local, - // realm-local, and unassigned scopes. See RFC 7346 §2. - let scope = addr.segments()[0] & 0x000f; - let scope_name = match scope { - 0x0 | 0xf => Some("reserved"), - 0x1 => Some("interface-local"), - 0x2 => Some("link-local"), - 0x3 => Some("realm-local"), - 0x4 | 0x5 | 0x8 | 0xe => None, - _ => Some("unassigned"), - }; - - if let Some(scope_name) = scope_name { - return Err(DpdError::Invalid(format!( - "{addr} has {scope_name} multicast scope ({scope:#x}), which \ - cannot be used for multicast groups. Allowed scopes are 0x4 \ - (admin-local), 0x5 (site-local), 0x8 (organization-local), and \ - 0xe (global)", - ))); - } - - // If this is SSM, require specific sources (RFC 4607) - if is_ssm(addr.into()) { - let segs = addr.segments(); - let within_prefix = - segs[2] == 0 && segs[3] == 0 && segs[4] == 0 && segs[5] == 0; - let group_id = (u32::from(segs[6]) << 16) | u32::from(segs[7]); - if !within_prefix || group_id < IPV6_SSM_MIN_VALID_GROUP_ID { - return Err(DpdError::Invalid(format!( - "{addr} is not a valid IPv6 SSM address (group IDs \ - ff3x::4000:1 through ff3x::ffff:ffff, RFC 4607)", - ))); - } - if sources.is_none() || sources.unwrap().is_empty() { - return Err(DpdError::Invalid(format!( - "{addr} is an IPv6 Source-Specific Multicast address (ff3x::/32) \ - and requires at least one source to be defined", - ))); - } - if contains_any_source(sources.unwrap()) { - return Err(DpdError::Invalid(format!( - "{addr} is an IPv6 Source-Specific Multicast address (ff3x::/32) \ - and requires specific sources (IpSrc::Any is not allowed)", - ))); - } - return Ok(()); - } - - Ok(()) -} - -/// Validates that IPv6 addresses are not in the reserved underlay subnet. -/// -/// External groups may use admin-local addresses (ff04::/16) but not the -/// reserved underlay subnet (ff04::/64), which is used for internal underlay -/// multicast group allocation. -pub(crate) fn validate_not_underlay_subnet(addr: IpAddr) -> DpdResult<()> { - if let IpAddr::V6(ipv6) = addr - && UNDERLAY_MULTICAST_SUBNET.contains(ipv6) - { - return Err(DpdError::Invalid(format!( - "{addr} is in the reserved underlay multicast subnet (ff04::/64, \ - within admin-local scope ff04::/16) and must be created via the \ - internal multicast API", - ))); - } - Ok(()) -} - -/// Validates that source IP addresses are unicast. -pub(crate) fn validate_source_addresses( - sources: Option<&[IpSrc]>, -) -> DpdResult<()> { - let sources = match sources { - Some(sources) => sources, - None => return Ok(()), - }; - - for source in sources { - // Any-source has no IP address to validate here; SSM policy is checked - // by the multicast-address validator. - match source { - IpSrc::Exact(ip) => validate_exact_source_address(*ip)?, - IpSrc::Any => {} - } - } - Ok(()) -} - -/// Validates a single exact source IP address. -fn validate_exact_source_address(ip: IpAddr) -> DpdResult<()> { - // Reject multicast addresses before checking other invalid source ranges. - if !is_unicast(ip) { - return Err(DpdError::Invalid(format!( - "Source IP {ip} must be a unicast address (multicast addresses are not allowed)", - ))); - } - - // Check for other problematic address types - match ip { - IpAddr::V4(ipv4) => validate_ipv4_source_address(ipv4), - IpAddr::V6(ipv6) => validate_ipv6_source_address(ipv6), - } -} - -/// Validates IPv4 source addresses for problematic types. -fn validate_ipv4_source_address(ipv4: Ipv4Addr) -> DpdResult<()> { - let first_octet = ipv4.octets()[0]; - if ipv4.is_loopback() - || ipv4.is_broadcast() - || ipv4.is_unspecified() - || ipv4.is_link_local() - || first_octet == 0 - || first_octet >= 240 - { - return Err(DpdError::Invalid(format!( - "Source IP {ipv4} is not a valid source address \ - (loopback, broadcast, unspecified, link-local, 0.0.0.0/8, and \ - 240.0.0.0/4 addresses are not allowed)", - ))); - } - Ok(()) -} - -/// Validates IPv6 source addresses for problematic types. -fn validate_ipv6_source_address(ipv6: Ipv6Addr) -> DpdResult<()> { - if ipv6.is_loopback() - || ipv6.is_unspecified() - || ipv6.is_unicast_link_local() - { - return Err(DpdError::Invalid(format!( - "Source IP {ipv6} is not a valid source address \ - (loopback, unspecified, and link-local addresses are not allowed)", - ))); - } - - let embedded = match ipv6.segments() { - [0, 0, 0, 0, 0, 0xffff, ..] => { - Some("IPv4-mapped (::ffff:0:0/96, RFC 4291 §2.5.5.2)") - } - [0, 0, 0, 0, 0, 0, ..] => { - Some("IPv4-compatible (::/96, RFC 4291 §2.5.5.1)") - } - _ => None, - }; - if let Some(form) = embedded { - return Err(DpdError::Invalid(format!( - "Source IP {ipv6} embeds an IPv4 address, {form}, and is not a \ - valid IPv6 source address", - ))); - } - - Ok(()) -} - -/// Validates tag format for group creation. -/// -/// Delegates to [`MulticastTag::from_str`] which enforces: -/// - Length: 1-80 ASCII bytes -/// - Characters: alphanumeric, hyphens, underscores, colons, or periods -/// -/// Auto-generated tags use the format `{uuid}:{group_ip}`. -pub(crate) fn validate_tag_format(tag: &str) -> DpdResult<()> { - tag.parse::() - .map(|_| ()) - .map_err(|e| DpdError::Invalid(e.to_string())) -} - -/// Validates that the request tag matches the existing group's tag. -/// -/// Tags are immutable after group creation. This validation ensures the caller -/// created the group before allowing mutations. -pub(crate) fn validate_tag( - existing_tag: &str, - request_tag: &str, -) -> DpdResult<()> { - if request_tag != existing_tag { - return Err(DpdError::Invalid( - "tag mismatch: provided tag does not match the group's tag" - .to_string(), - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use common::network::{MacAddr, Vni}; - use dpd_types::mcast::MAX_TAG_LENGTH; - - /// Admin-local IPv6 multicast prefix (ff04::/16, scope 4). - const ADMIN_LOCAL_PREFIX: u16 = 0xff04; - - #[test] - fn test_ipv4_validation() { - // These should be allowed - assert!( - validate_ipv4_multicast(Ipv4Addr::new(224, 1, 0, 1), None).is_ok() - ); - assert!( - validate_ipv4_multicast(Ipv4Addr::new(224, 2, 2, 3), None).is_ok() - ); - assert!( - validate_ipv4_multicast(Ipv4Addr::new(231, 1, 2, 3), None).is_ok() - ); - - // These should be rejected - assert!( - validate_ipv4_multicast(Ipv4Addr::new(224, 0, 0, 1), None).is_err() - ); // Link-local - assert!( - validate_ipv4_multicast(Ipv4Addr::new(224, 0, 0, 5), None).is_err() - ); // Link-local - assert!( - validate_ipv4_multicast(Ipv4Addr::new(192, 168, 1, 1), None) - .is_err() - ); // Not multicast - } - - #[test] - fn test_ipv6_validation() { - // These should be allowed - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 0x1234), - None - ) - .is_ok() - ); // Global - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff05, 0, 0, 0, 0, 0, 0, 0x1111), - None - ) - .is_ok() - ); // Site-local - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff08, 0, 0, 0, 0, 0, 0, 0x5678), - None - ) - .is_ok() - ); // Organization-local - - // These should be rejected - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 0x1), - None - ) - .is_err() - ); // Link-local - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff01, 0, 0, 0, 0, 0, 0, 0x2,), - None - ) - .is_err() - ); // Interface-local - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x1), - None - ) - .is_err() - ); // Not multicast - } - - #[test] - fn test_ipv4_ssm_with_sources() { - let ssm_addr = Ipv4Addr::new(232, 1, 2, 3); - let asm_addr = Ipv4Addr::new(224, 1, 2, 3); - - let exact_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))]; - let any_source = vec![IpSrc::Any]; - - // SSM requires sources - assert!(validate_ipv4_multicast(ssm_addr, Some(&[])).is_err()); - assert!(validate_ipv4_multicast(ssm_addr, None).is_err()); - - // SSM with exact source - assert!( - validate_ipv4_multicast(ssm_addr, Some(&exact_sources)).is_ok() - ); - - // SSM with any-source is not allowed (RFC 4607) - assert!(validate_ipv4_multicast(ssm_addr, Some(&any_source)).is_err()); - - // ASM without sources - assert!(validate_ipv4_multicast(asm_addr, None).is_ok()); - assert!(validate_ipv4_multicast(asm_addr, Some(&[])).is_ok()); - - // ASM with sources - assert!( - validate_ipv4_multicast(asm_addr, Some(&exact_sources)).is_ok() - ); - assert!(validate_ipv4_multicast(asm_addr, Some(&any_source)).is_ok()); - } - - #[test] - fn test_ipv6_ssm_with_sources() { - let ssm_addr = Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0xf000, 0x1234); - let asm_addr = Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 0x1234); - - let exact_sources = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x1, - )))]; - let any_source = vec![IpSrc::Any]; - - // SSM requires sources - assert!(validate_ipv6_multicast(ssm_addr, Some(&[])).is_err()); - assert!(validate_ipv6_multicast(ssm_addr, None).is_err()); - - // SSM with exact source - assert!( - validate_ipv6_multicast(ssm_addr, Some(&exact_sources)).is_ok() - ); - - // SSM with any-source is not allowed (RFC 4607) - assert!(validate_ipv6_multicast(ssm_addr, Some(&any_source)).is_err()); - - // ASM without sources - assert!(validate_ipv6_multicast(asm_addr, None).is_ok()); - assert!(validate_ipv6_multicast(asm_addr, Some(&[])).is_ok()); - - // ASM with sources - assert!( - validate_ipv6_multicast(asm_addr, Some(&exact_sources)).is_ok() - ); - assert!(validate_ipv6_multicast(asm_addr, Some(&any_source)).is_ok()); - } - - #[test] - fn test_multicast_validation_boundaries() { - let v4_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))]; - let v6_sources = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1, - )))]; - - // 232.0.0.0 is the reserved null SSM destination; everything above - // 232.0.0.0 through to 232.255.255.255 is usable. - assert!( - validate_ipv4_multicast( - Ipv4Addr::new(232, 0, 0, 0), - Some(&v4_sources) - ) - .is_err() - ); - assert!( - validate_ipv4_multicast(Ipv4Addr::new(232, 0, 0, 0), None).is_err() - ); - assert!( - validate_ipv4_multicast( - Ipv4Addr::new(232, 0, 0, 1), - Some(&v4_sources) - ) - .is_ok() - ); - assert!( - validate_ipv4_multicast( - Ipv4Addr::new(232, 0, 1, 0), - Some(&v4_sources) - ) - .is_ok() - ); - assert!( - validate_ipv4_multicast( - Ipv4Addr::new(232, 255, 255, 255), - Some(&v4_sources) - ) - .is_ok() - ); - assert!( - validate_ipv4_multicast(Ipv4Addr::new(231, 255, 255, 255), None) - .is_ok() - ); - assert!( - validate_ipv4_multicast(Ipv4Addr::new(233, 0, 0, 0), None).is_ok() - ); - - let invalid_group_ids = [(0, 0x1234), (0x3fff, 0xffff), (0x4000, 0)]; - for (hi, lo) in invalid_group_ids { - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, hi, lo), - Some(&v6_sources) - ) - .is_err(), - "group ID {hi:#06x}{lo:04x} should be rejected", - ); - } - - let valid_group_ids = [ - (0x4000, 1), - (0x7fff, 0xffff), - (0x8000, 0), - (0x9000, 0), - (0xefff, 0xffff), - (0xf000, 0), - (0xfeff, 0xffff), - (0xffff, 0xffff), - ]; - for (hi, lo) in valid_group_ids { - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, hi, lo), - Some(&v6_sources) - ) - .is_ok(), - "group ID {hi:#06x}{lo:04x} should be accepted", - ); - } - - // Inside the ff3e::/32 SSM block but outside ff3e::/96, so not a - // valid 32-bit group ID. - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff3e, 0, 0x1234, 0, 0, 0, 0xf000, 0x1), - Some(&v6_sources) - ) - .is_err() - ); - - // Unusable and unassigned scope nibbles are rejected across flag - // variants, SSM included. - for seg0 in [ - 0xff30, 0xff31, 0xff32, 0xff33, 0xff36, 0xff39, 0xff3d, 0xff3f, - 0xff03, 0xff11, 0xff12, 0xff13, 0xff07, 0xff1a, - ] { - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(seg0, 0, 0, 0, 0, 0, 0xf000, 0x1), - Some(&v6_sources) - ) - .is_err(), - "{seg0:x}::/16 should be rejected for its scope" - ); - } - } - - #[test] - fn test_is_ssm_function() { - // Test IPv4 SSM detection - assert!(is_ssm(IpAddr::V4(Ipv4Addr::new(232, 0, 0, 1)))); - assert!(is_ssm(IpAddr::V4(Ipv4Addr::new(232, 255, 255, 255)))); - assert!(!is_ssm(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 1)))); - assert!(!is_ssm(IpAddr::V4(Ipv4Addr::new(231, 0, 0, 1)))); - - // Test IPv6 SSM detection (ff3x::/32) - assert!(is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff30, 0, 0, 0, 0, 0, 0, 0x1 - )))); // With 0 scope - assert!(is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff3e, 0, 0, 0, 0, 0, 0, 0x1 - )))); // Global scope (e) - assert!(is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff35, 0, 0, 0, 0, 0, 0, 0x1 - )))); // Site-local scope (5) - // The zero second segment keeps this address inside the - // ff3e::/32 SSM range. - assert!(is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff3e, 0, 0x1234, 0, 0, 0, 0, 0x1 - )))); - - // Not SSM - assert!(!is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff0e, 0, 0, 0, 0, 0, 0, 0x1 - )))); // Flag bit not 3 - assert!(!is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff1e, 0, 0, 0, 0, 0, 0, 0x1 - )))); // Flag bit not 3 - // RFC 3306 unicast-prefix-based ASM address. This shares ff30::/12 but - // has a plen of 32 in the second segment, outside of the ff3e::/32 range. - assert!(!is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff3e, 0x0020, 0, 0x1234, 0, 0, 0, 0x1 - )))); - } - - #[test] - fn test_address_validation_integrated() { - // Test the main validate_multicast_address function - - // Valid IPv4 non-SSM address, no sources - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(224, 1, 0, 1)), - None - ) - .is_ok() - ); - - // Valid IPv4 SSM address with exact sources - let ssm_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))]; - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&ssm_sources) - ) - .is_ok() - ); - - // IPv4 SSM with Any is rejected (RFC 4607) - let ssm_with_any = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Any, - ]; - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&ssm_with_any) - ) - .is_err() - ); - - // Valid IPv6 non-SSM address, no sources - assert!( - validate_multicast_address( - IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 0x1234)), - None - ) - .is_ok() - ); - - // Valid IPv6 SSM address with sources - let ip6_sources = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x1, - )))]; - assert!( - validate_multicast_address( - IpAddr::V6(Ipv6Addr::new( - 0xff3e, 0, 0, 0, 0, 0, 0xf000, 0x1234 - )), - Some(&ip6_sources) - ) - .is_ok() - ); - - // Error cases - - // Not a multicast address - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), - None - ) - .is_err() - ); - - // IPv4 SSM without sources - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - None - ) - .is_err() - ); - - // IPv4 ASM with sources (including Any, which is valid for ASM) - let asm_sources = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Any, - ]; - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(224, 1, 2, 3)), - Some(&asm_sources) - ) - .is_ok() - ); - - // IPv6 SSM without sources - assert!( - validate_multicast_address( - IpAddr::V6(Ipv6Addr::new( - 0xff3e, 0, 0, 0, 0, 0, 0xf000, 0x1234 - )), - None - ) - .is_err() - ); - - // IPv6 ASM with sources - assert!( - validate_multicast_address( - IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 0x1234)), - Some(&ip6_sources) - ) - .is_ok() - ); - - // Source and group address families must match. - let invalid_v6_source_for_v4_group = vec![IpSrc::Exact(IpAddr::V6( - Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), - ))]; - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&invalid_v6_source_for_v4_group), - ) - .is_err() - ); - - let invalid_v4_source_for_v6_group = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))]; - assert!( - validate_multicast_address( - IpAddr::V6(Ipv6Addr::new( - 0xff3e, 0, 0, 0, 0, 0, 0xf000, 0x1234, - )), - Some(&invalid_v4_source_for_v6_group), - ) - .is_err() - ); - } - - #[test] - fn test_validate_nat_target() { - // Unicast internal IP should be rejected - let ucast_nat_target = NatTarget { - internal_ip: Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), - inner_mac: MacAddr::new(0x00, 0x00, 0x00, 0x00, 0x00, 0x01), - vni: Vni::new(100).unwrap(), - }; - assert!(validate_nat_target(ucast_nat_target).is_err()); - - // Valid NAT target in reserved underlay subnet (ff04::/64) - let valid_nat_target = NatTarget { - internal_ip: Ipv6Addr::new( - ADMIN_LOCAL_PREFIX, - 0, - 0, - 0, - 0, - 0, - 0, - 0x1234, - ), - inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x00, 0x00, 0x01), - vni: Vni::new(100).unwrap(), - }; - assert!(validate_nat_target(valid_nat_target).is_ok()); - - // Admin-local address outside ff04::/64 should be rejected - // ff04:0:0:1::1234 is in ff04::/16 but not in ff04::/64 - let outside_underlay_nat_target = NatTarget { - internal_ip: Ipv6Addr::new( - ADMIN_LOCAL_PREFIX, - 0, - 0, - 1, // This puts it outside ff04::/64 - 0, - 0, - 0, - 0x1234, - ), - inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x00, 0x00, 0x01), - vni: Vni::new(100).unwrap(), - }; - assert!(validate_nat_target(outside_underlay_nat_target).is_err()); - } - - #[test] - fn test_validate_source_addresses() { - // Valid unicast IPv4 sources - let valid_ipv4_sources = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), - ]; - assert!(validate_source_addresses(Some(&valid_ipv4_sources)).is_ok()); - - // Valid unicast IPv6 sources - let valid_ipv6_sources = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1, - )))]; - assert!(validate_source_addresses(Some(&valid_ipv6_sources)).is_ok()); - - // Any-source is valid - let any_source = vec![IpSrc::Any]; - assert!(validate_source_addresses(Some(&any_source)).is_ok()); - - // Invalid multicast IPv4 source - let invalid_mcast_ipv4 = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(224, 1, 1, 1)))]; - assert!(validate_source_addresses(Some(&invalid_mcast_ipv4)).is_err()); - - // Invalid multicast IPv6 source - let invalid_mcast_ipv6 = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0xff0e, 0, 0, 0, 0, 0, 0, 1, - )))]; - assert!(validate_source_addresses(Some(&invalid_mcast_ipv6)).is_err()); - - // Invalid broadcast IPv4 source - let invalid_broadcast = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(255, 255, 255, 255)))]; - assert!(validate_source_addresses(Some(&invalid_broadcast)).is_err()); - - // Invalid loopback IPv4 source - let invalid_loopback_ipv4 = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))]; - assert!( - validate_source_addresses(Some(&invalid_loopback_ipv4)).is_err() - ); - - // Invalid loopback IPv6 source - let invalid_loopback_ipv6 = vec![IpSrc::Exact(IpAddr::V6( - Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1), - ))]; - assert!( - validate_source_addresses(Some(&invalid_loopback_ipv6)).is_err() - ); - - // ::ffff:192.0.2.1, RFC 4291 §2.5.5.2 - let invalid_v4_mapped = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0, 0, 0, 0, 0, 0xffff, 0xc000, 0x0201, - )))]; - assert!(validate_source_addresses(Some(&invalid_v4_mapped)).is_err()); - - // ::192.0.2.1, RFC 4291 §2.5.5.1 - let invalid_v4_compat = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0, 0, 0, 0, 0, 0, 0xc000, 0x0201, - )))]; - assert!(validate_source_addresses(Some(&invalid_v4_compat)).is_err()); - - // 0.0.0.0/8, this host on this network, RFC 1122 §3.2.1.3 - let invalid_this_network = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(0, 1, 2, 3)))]; - assert!( - validate_source_addresses(Some(&invalid_this_network)).is_err() - ); - - // 240.0.0.0/4, class E, RFC 1112 §4 - for octets in [[240, 0, 0, 1], [255, 255, 255, 254]] { - let invalid_class_e = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::from(octets)))]; - assert!( - validate_source_addresses(Some(&invalid_class_e)).is_err(), - "{octets:?} should be rejected as a class E source" - ); - } - - let valid_below_class_e = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(223, 255, 255, 255)))]; - assert!(validate_source_addresses(Some(&valid_below_class_e)).is_ok()); - - // No sources should be valid - assert!(validate_source_addresses(None).is_ok()); - - // Empty sources should be valid - assert!(validate_source_addresses(Some(&[])).is_ok()); - } - - #[test] - fn test_address_validation_with_source_validation() { - // Valid case: SSM address with valid unicast sources - let valid_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))]; - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&valid_sources) - ) - .is_ok() - ); - - // Invalid case: SSM address with multicast source (should fail source validation first) - let invalid_mcast_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(224, 1, 1, 1)))]; - let result = validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&invalid_mcast_sources), - ); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("must be a unicast address") - ); - - // Invalid case: SSM address with loopback source - let invalid_loopback_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))]; - let result = validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&invalid_loopback_sources), - ); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("is not a valid source address") - ); - } - - #[test] - fn test_validate_not_underlay_subnet() { - // Reserved underlay subnet (ff04::/64) should be rejected - let underlay_addr = - IpAddr::V6(Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1)); - assert!(validate_not_underlay_subnet(underlay_addr).is_err()); - - // Another address in ff04::/64 - let underlay_addr2 = - IpAddr::V6(Ipv6Addr::new(0xff04, 0, 0, 0, 0xdead, 0xbeef, 0, 1)); - assert!(validate_not_underlay_subnet(underlay_addr2).is_err()); - - // Other admin-local /64s should be allowed (e.g., ff04:0:0:1::/64) - let other_admin_local = - IpAddr::V6(Ipv6Addr::new(0xff04, 0, 0, 1, 0, 0, 0, 1)); - assert!(validate_not_underlay_subnet(other_admin_local).is_ok()); - - // ff04:0:0:2::/64 should also be allowed - let other_admin_local2 = - IpAddr::V6(Ipv6Addr::new(0xff04, 0, 0, 2, 0, 0, 0, 1)); - assert!(validate_not_underlay_subnet(other_admin_local2).is_ok()); - - // IPv4 multicast should always be allowed (not in underlay subnet) - let ipv4_mcast = IpAddr::V4(Ipv4Addr::new(224, 1, 2, 3)); - assert!(validate_not_underlay_subnet(ipv4_mcast).is_ok()); - - // Non-admin-local IPv6 multicast should be allowed - let global_mcast = - IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)); - assert!(validate_not_underlay_subnet(global_mcast).is_ok()); - - // Site-local multicast should be allowed - let site_local = IpAddr::V6(Ipv6Addr::new(0xff05, 0, 0, 0, 0, 0, 0, 1)); - assert!(validate_not_underlay_subnet(site_local).is_ok()); - } - - #[test] - fn test_validate_tag() { - // Existing tag matches request tag - assert!(validate_tag("my-tag", "my-tag").is_ok()); - - // Existing tag but request has different tag - assert!(validate_tag("owner-a", "owner-b").is_err()); - assert!(validate_tag("owner-a", "").is_err()); - assert!(validate_tag("owner-a", "tag/with/slashes").is_err()); - } - - #[test] - fn test_validate_tag_format() { - use super::validate_tag_format; - - // Valid tags - assert!(validate_tag_format("my-tag").is_ok()); - assert!(validate_tag_format("nexus").is_ok()); - assert!(validate_tag_format("a1b2c3").is_ok()); - assert!(validate_tag_format("tag_with_underscore").is_ok()); - assert!(validate_tag_format("tag.with.periods").is_ok()); - assert!(validate_tag_format("tag:with:colons").is_ok()); - assert!(validate_tag_format("mixed-tag_v1.0:test").is_ok()); - - // Auto-generated tag format (uuid:ip) - assert!( - validate_tag_format( - "550e8400-e29b-41d4-a716-446655440000:224.1.2.3" - ) - .is_ok() - ); - - // Tag at exactly MAX_TAG_LENGTH characters is valid - assert!(validate_tag_format(&"a".repeat(MAX_TAG_LENGTH)).is_ok()); - - // Empty tag rejected - assert!(validate_tag_format("").is_err()); - - // Tag exceeding MAX_TAG_LENGTH characters rejected - assert!(validate_tag_format(&"a".repeat(MAX_TAG_LENGTH + 1)).is_err()); - - // Invalid characters rejected - assert!(validate_tag_format("tag with spaces").is_err()); - assert!(validate_tag_format("tag/with/slashes").is_err()); - assert!(validate_tag_format("tag@with@at").is_err()); - assert!(validate_tag_format("tag#with#hash").is_err()); - } -} diff --git a/dpd/src/port_settings.rs b/dpd/src/port_settings.rs index cfcea772..d924fff9 100644 --- a/dpd/src/port_settings.rs +++ b/dpd/src/port_settings.rs @@ -4,11 +4,11 @@ // // Copyright 2026 Oxide Computer Company -use crate::DpdError; use crate::DpdResult; use crate::Switch; use crate::link::Link; use crate::link::LinkParams; +use crate::types::after_unwind; use aal::AsicOps; use common::ports::Ipv4Entry; use common::ports::Ipv6Entry; @@ -245,10 +245,7 @@ impl PortSettingsDiff { ); // If there is an unwind error, send back a more specific // error, containing the initial error and the unwind error. - return Err(DpdError::Unwind { - initial: Box::new(e), - unwind: Box::new(ue), - }); + return Err(after_unwind(e, Err(ue))); } Ok(_) => return Err(e), } diff --git a/dpd/src/table/mac.rs b/dpd/src/table/mac.rs index 652b94f4..96785503 100644 --- a/dpd/src/table/mac.rs +++ b/dpd/src/table/mac.rs @@ -69,7 +69,7 @@ fn mac_clear_common(s: &Switch, type_: TableType, port: u16) -> DpdResult<()> { /// Add a new entry to the MAC table. /// -/// An error is returned if the entry already exists. Use `mac_update` instead. +/// An error is returned if the entry already exists. pub fn mac_set(s: &Switch, port: u16, mac: MacAddr) -> DpdResult<()> { mac_set_common(s, TableType::PortMacAddress, port, mac) } @@ -101,7 +101,7 @@ pub fn reset(s: &Switch) -> DpdResult<()> { /// Add a new entry to the MAC table. /// -/// An error is returned if the entry already exists. Use `mac_update` instead. +/// An error is returned if the entry already exists. pub fn mcast_mac_set(s: &Switch, port: u16, mac: MacAddr) -> DpdResult<()> { mac_set_common(s, TableType::PortMacAddressMcast, port, mac) } diff --git a/dpd/src/table/mcast/mcast_egress.rs b/dpd/src/table/mcast/mcast_egress.rs index 10eed2e8..fdbe7a37 100644 --- a/dpd/src/table/mcast/mcast_egress.rs +++ b/dpd/src/table/mcast/mcast_egress.rs @@ -6,16 +6,15 @@ //! Table operations for multicast egress entries. -use dpd_types::table; use std::fmt; -use crate::{Switch, table::*}; - use aal::{ActionParse, MatchParse}; use aal_macros::*; -use dpd_types::mcast::MulticastGroupId; +use dpd_types::{mcast::MulticastGroupId, table}; use slog::debug; +use crate::{Switch, table::*}; + #[derive(MatchParse, Hash)] struct MatchKeyDecapPorts { #[match_xlate(name = "egress_rid")] @@ -265,11 +264,17 @@ pub(crate) fn port_mapping_counter_fetch( } /// 256-port bitmap (8 × 32-bit) for multicast egress decapsulation filtering. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct PortBitmap { ports: [u32; 8], } +impl From<[u32; 8]> for PortBitmap { + fn from(ports: [u32; 8]) -> Self { + Self { ports } + } +} + impl PortBitmap { /// Create a new empty port bitmap. pub(crate) fn new() -> Self { diff --git a/dpd/src/table/mcast/mcast_nat.rs b/dpd/src/table/mcast/mcast_nat.rs index 961f1644..9106eff4 100644 --- a/dpd/src/table/mcast/mcast_nat.rs +++ b/dpd/src/table/mcast/mcast_nat.rs @@ -11,17 +11,21 @@ //! check in the P4 pipeline. Decapsulated Geneve packets never reach these //! tables, so each group only needs a single entry with an exact VLAN match. -use dpd_types::table; use std::net::{Ipv4Addr, Ipv6Addr}; -use crate::{Switch, table::*}; - -use super::{Ipv4VlanMatchKey, Ipv6VlanMatchKey}; -use aal::ActionParse; +use aal::{ActionParse, AsicError}; use aal_macros::*; use common::network::{MacAddr, NatTarget}; +use dpd_types::table; use slog::debug; +use super::{Ipv4VlanMatchKey, Ipv6VlanMatchKey}; +use crate::{ + Switch, + table::*, + types::{after_unwind, ignore_missing}, +}; + #[derive(ActionParse, Debug)] enum Ipv4Action { #[action_xlate(name = "mcast_forward_ipv4_to")] @@ -61,6 +65,7 @@ pub(crate) fn add_ipv4_entry( /// Update a NAT entry for IPv4 multicast traffic. /// +/// Missing entries are re-added with the requested target and VLAN. /// When VLAN changes, old entry is deleted and a new one added because /// the VLAN is part of the match key and cannot be updated in place. pub(crate) fn update_ipv4_entry( @@ -83,20 +88,23 @@ pub(crate) fn update_ipv4_entry( s.log, "update ingress mcast entry {match_key} -> {action_key:?}" ); - return s.table_entry_update( + return match s.table_entry_update( TableType::NatIngressIpv4Mcast, &match_key, &action_key, - ); + ) { + Err(DpdError::Switch(AsicError::Missing(_))) => { + add_ipv4_entry(s, ip, new_tgt, new_vlan_id) + } + res => res, + }; } - del_ipv4_entry_with_tgt(s, ip, old_tgt, old_vlan_id)?; - if let Err(e) = add_ipv4_entry(s, ip, new_tgt, new_vlan_id) { + ignore_missing(del_ipv4_entry(s, ip, old_vlan_id))?; + add_ipv4_entry(s, ip, new_tgt, new_vlan_id).map_err(|e| { debug!(s.log, "add failed, restoring old NAT entries for {ip}"); - let _ = add_ipv4_entry(s, ip, old_tgt, old_vlan_id); - return Err(e); - } - Ok(()) + after_unwind(e, add_ipv4_entry(s, ip, old_tgt, old_vlan_id)) + }) } /// Delete a NAT entry for IPv4 multicast traffic. @@ -110,34 +118,13 @@ pub(crate) fn del_ipv4_entry( s.table_entry_del(TableType::NatIngressIpv4Mcast, &match_key) } -/// Delete a NAT entry for IPv4 multicast traffic with rollback support. -/// -/// If deletion fails, restores the entry using the provided NAT target. -pub(crate) fn del_ipv4_entry_with_tgt( - s: &Switch, - ip: Ipv4Addr, - tgt: NatTarget, - vlan_id: Option, -) -> DpdResult<()> { - let match_key = Ipv4VlanMatchKey::new(ip, vlan_id); - debug!(s.log, "delete ingress mcast entry {match_key}"); - if let Err(e) = - s.table_entry_del(TableType::NatIngressIpv4Mcast, &match_key) - { - debug!(s.log, "delete failed, restoring entry for {ip}"); - let action_key = Ipv4Action::Forward { - target: tgt.internal_ip, - inner_mac: tgt.inner_mac, - vni: tgt.vni.as_u32(), - }; - let _ = s.table_entry_add( - TableType::NatIngressIpv4Mcast, - &match_key, - &action_key, - ); - return Err(e); - } - Ok(()) +/// Delete every IPv4 multicast NAT entry for `ip` (under any VLAN). +pub(crate) fn del_ipv4_entries(s: &Switch, ip: Ipv4Addr) -> DpdResult<()> { + super::del_entries_where::( + s, + TableType::NatIngressIpv4Mcast, + |key| key.dst_addr == ip, + ) } /// Dump the IPv4 NAT table's contents. @@ -194,6 +181,7 @@ pub(crate) fn add_ipv6_entry( /// Update a NAT entry for IPv6 multicast traffic. /// +/// Missing entries are re-added with the requested target and VLAN. /// When VLAN changes, old entry is deleted and a new one added because /// the VLAN is part of the match key and cannot be updated in place. pub(crate) fn update_ipv6_entry( @@ -216,20 +204,23 @@ pub(crate) fn update_ipv6_entry( s.log, "update ingress mcast entry {match_key} -> {action_key:?}" ); - return s.table_entry_update( + return match s.table_entry_update( TableType::NatIngressIpv6Mcast, &match_key, &action_key, - ); + ) { + Err(DpdError::Switch(AsicError::Missing(_))) => { + add_ipv6_entry(s, ip, new_tgt, new_vlan_id) + } + res => res, + }; } - del_ipv6_entry_with_tgt(s, ip, old_tgt, old_vlan_id)?; - if let Err(e) = add_ipv6_entry(s, ip, new_tgt, new_vlan_id) { + ignore_missing(del_ipv6_entry(s, ip, old_vlan_id))?; + add_ipv6_entry(s, ip, new_tgt, new_vlan_id).map_err(|e| { debug!(s.log, "add failed, restoring old NAT entries for {ip}"); - let _ = add_ipv6_entry(s, ip, old_tgt, old_vlan_id); - return Err(e); - } - Ok(()) + after_unwind(e, add_ipv6_entry(s, ip, old_tgt, old_vlan_id)) + }) } /// Delete a NAT entry for IPv6 multicast traffic. @@ -243,34 +234,13 @@ pub(crate) fn del_ipv6_entry( s.table_entry_del(TableType::NatIngressIpv6Mcast, &match_key) } -/// Delete a NAT entry for IPv6 multicast traffic with rollback support. -/// -/// If deletion fails, restores the entry using the provided NAT target. -pub(crate) fn del_ipv6_entry_with_tgt( - s: &Switch, - ip: Ipv6Addr, - tgt: NatTarget, - vlan_id: Option, -) -> DpdResult<()> { - let match_key = Ipv6VlanMatchKey::new(ip, vlan_id); - debug!(s.log, "delete ingress mcast entry {match_key}"); - if let Err(e) = - s.table_entry_del(TableType::NatIngressIpv6Mcast, &match_key) - { - debug!(s.log, "delete failed, restoring entry for {ip}"); - let action_key = Ipv6Action::Forward { - target: tgt.internal_ip, - inner_mac: tgt.inner_mac, - vni: tgt.vni.as_u32(), - }; - let _ = s.table_entry_add( - TableType::NatIngressIpv6Mcast, - &match_key, - &action_key, - ); - return Err(e); - } - Ok(()) +/// Delete every IPv6 multicast NAT entry for `ip` (under any VLAN). +pub(crate) fn del_ipv6_entries(s: &Switch, ip: Ipv6Addr) -> DpdResult<()> { + super::del_entries_where::( + s, + TableType::NatIngressIpv6Mcast, + |key| key.dst_addr == ip, + ) } /// Dump the IPv6 NAT table's contents. diff --git a/dpd/src/table/mcast/mcast_route.rs b/dpd/src/table/mcast/mcast_route.rs index cfa925d3..e3ca20e2 100644 --- a/dpd/src/table/mcast/mcast_route.rs +++ b/dpd/src/table/mcast/mcast_route.rs @@ -13,11 +13,11 @@ //! VLAN-based access control (preventing VLAN translation) is handled by NAT //! ingress tables before encapsulation, not by route tables. -use dpd_types::table; use std::net::{Ipv4Addr, Ipv6Addr}; -use aal::ActionParse; +use aal::{ActionParse, AsicError}; use aal_macros::*; +use dpd_types::table; use omicron_common::address::UNDERLAY_MULTICAST_SUBNET; use slog::debug; @@ -77,6 +77,28 @@ pub(crate) fn update_ipv4_entry( return Ok(()); } + set_ipv4_entry(s, route, new_vlan_id) +} + +/// Insert a missing IPv4 multicast route or update its forwarding action. +pub(crate) fn ensure_ipv4_entry( + s: &Switch, + route: Ipv4Addr, + vlan_id: Option, +) -> DpdResult<()> { + match set_ipv4_entry(s, route, vlan_id) { + Err(DpdError::Switch(AsicError::Missing(_))) => { + add_ipv4_entry(s, route, vlan_id) + } + res => res, + } +} + +fn set_ipv4_entry( + s: &Switch, + route: Ipv4Addr, + new_vlan_id: Option, +) -> DpdResult<()> { let match_key = Ipv4MatchKey::new(route); let action = match new_vlan_id { None => Ipv4Action::Forward, @@ -169,6 +191,30 @@ pub(crate) fn update_ipv6_entry( return Ok(()); } + set_ipv6_entry(s, route, new_vlan_id) +} + +/// Insert a missing IPv6 multicast route or update its forwarding action. +/// +/// Underlay routes use `forward`, regardless of `vlan_id`. +pub(crate) fn ensure_ipv6_entry( + s: &Switch, + route: Ipv6Addr, + vlan_id: Option, +) -> DpdResult<()> { + match set_ipv6_entry(s, route, vlan_id) { + Err(DpdError::Switch(AsicError::Missing(_))) => { + add_ipv6_entry(s, route, vlan_id) + } + res => res, + } +} + +fn set_ipv6_entry( + s: &Switch, + route: Ipv6Addr, + new_vlan_id: Option, +) -> DpdResult<()> { let match_key = Ipv6MatchKey::new(route); // Reserved underlay multicast subnet (ff04::/64) is internal to the rack diff --git a/dpd/src/table/mcast/mcast_src_filter.rs b/dpd/src/table/mcast/mcast_src_filter.rs index c9e4ca38..016de666 100644 --- a/dpd/src/table/mcast/mcast_src_filter.rs +++ b/dpd/src/table/mcast/mcast_src_filter.rs @@ -6,19 +6,19 @@ //! Table operations for multicast source filter entries. -use dpd_types::table; use std::{ fmt, net::{Ipv4Addr, Ipv6Addr}, }; -use crate::{Switch, table::*}; - use aal::{ActionParse, MatchParse}; use aal_macros::*; +use dpd_types::table; use oxnet::{Ipv4Net, Ipv6Net}; use slog::debug; +use crate::{Switch, table::*}; + #[derive(MatchParse, Hash)] struct Ipv4MatchKey { #[match_xlate(name = "src_addr", type = "lpm")] @@ -98,6 +98,19 @@ pub(crate) fn del_ipv4_entry( s.table_entry_del(TableType::McastIpv4SrcFilter, &match_key) } +/// Delete every IPv4 multicast source filter entry for `dst_addr`, no matter +/// its source. +pub(crate) fn del_ipv4_entries( + s: &Switch, + dst_addr: Ipv4Addr, +) -> DpdResult<()> { + super::del_entries_where::( + s, + TableType::McastIpv4SrcFilter, + |key| key.dst_addr == dst_addr, + ) +} + /// Dump the IPv4 multicast source filter table's contents. pub(crate) fn ipv4_table_dump( s: &Switch, @@ -151,6 +164,19 @@ pub(crate) fn del_ipv6_entry( s.table_entry_del(TableType::McastIpv6SrcFilter, &match_key) } +/// Delete every IPv6 multicast source filter entry for `dst_addr`, no matter +/// its source. +pub(crate) fn del_ipv6_entries( + s: &Switch, + dst_addr: Ipv6Addr, +) -> DpdResult<()> { + super::del_entries_where::( + s, + TableType::McastIpv6SrcFilter, + |key| key.dst_addr == dst_addr, + ) +} + /// Dump the IPv6 multicast source filter table's contents. pub(crate) fn ipv6_table_dump( s: &Switch, diff --git a/dpd/src/table/mcast/mod.rs b/dpd/src/table/mcast/mod.rs index 5630d016..3a34aeb9 100644 --- a/dpd/src/table/mcast/mod.rs +++ b/dpd/src/table/mcast/mod.rs @@ -9,11 +9,16 @@ use std::{ convert::TryInto, fmt, + hash::Hash, net::{Ipv4Addr, Ipv6Addr}, }; -use aal::MatchParse; +use aal::{ActionParse, MatchParse}; use aal_macros::*; +use common::table::TableType; +use slog::debug; + +use crate::{Switch, types::DpdResult}; pub(crate) mod mcast_egress; pub(crate) mod mcast_nat; @@ -21,6 +26,32 @@ pub(crate) mod mcast_replication; pub(crate) mod mcast_route; pub(crate) mod mcast_src_filter; +/// Delete every entry in `table` whose key satisfies the `matches` check. +/// +/// All table keys are collected before deletion occurs to prevent the table +/// lock from being held across `table_entry_del`. +fn del_entries_where( + s: &Switch, + table: TableType, + matches: impl Fn(&M) -> bool, +) -> DpdResult<()> +where + M: MatchParse + Hash + fmt::Display, + A: ActionParse, +{ + let keys: Vec = s + .table_get(table)? + .get_entries::(&s.asic_hdl, false)? + .into_iter() + .filter_map(|(key, _)| matches(&key).then_some(key)) + .collect(); + + keys.iter().try_for_each(|key| { + debug!(s.log, "delete {table} entry {key}"); + s.table_entry_del(table, key) + }) +} + #[derive(MatchParse, Hash)] struct Ipv4MatchKey { dst_addr: Ipv4Addr, diff --git a/dpd/src/table/mod.rs b/dpd/src/table/mod.rs index a499c0ad..ec24466a 100644 --- a/dpd/src/table/mod.rs +++ b/dpd/src/table/mod.rs @@ -124,7 +124,7 @@ impl Table { self.asic_data .entry_add(hdl, key, data) .map_err(|e| { - if let aal::AsicError::Exists = e { + if let aal::AsicError::Exists(_) = e { self.usage.collisions += 1; } e.into() @@ -169,7 +169,7 @@ impl Table { e.into() }) .map(|()| { - self.usage.occupancy -= 1; + self.usage.occupancy = self.usage.occupancy.saturating_sub(1); self.usage.deletes += 1; }) } diff --git a/dpd/src/table/uplink.rs b/dpd/src/table/uplink.rs index 9b47a557..9cbdaf74 100644 --- a/dpd/src/table/uplink.rs +++ b/dpd/src/table/uplink.rs @@ -11,7 +11,8 @@ use slog::{error, info}; use crate::Switch; use crate::table::*; -use aal::{ActionParse, MatchParse}; +use crate::types::{DpdError, after_unwind, ignore_missing}; +use aal::{ActionParse, AsicError, MatchParse}; use aal_macros::*; #[derive(MatchParse, Debug, Hash)] @@ -87,18 +88,22 @@ pub fn uplink_set(s: &Switch, port: u16) -> DpdResult<()> { s.log, "set guest_traffic_allowed on {} failed: {:?}", port, e ); - let _ = clear_ingress_uplink(s, port); - Err(e) + Err(after_unwind(e, clear_ingress_uplink(s, port))) } } } /// Remove an entry from the uplink tables. pub fn uplink_clear(s: &Switch, port: u16) -> DpdResult<()> { - clear_ingress_uplink(s, port)?; + let ingress_cleared = match clear_ingress_uplink(s, port) { + Ok(()) => true, + Err(DpdError::Switch(AsicError::Missing(_))) => false, + Err(e) => return Err(e), + }; let match_key = EgressMatchKey { out_port: port }; - match s.table_entry_del(TableType::UplinkEgress, &match_key) { + match ignore_missing(s.table_entry_del(TableType::UplinkEgress, &match_key)) + { Ok(_) => { info!(s.log, "cleared guest_traffic_allowed on {}", port); Ok(()) @@ -108,8 +113,12 @@ pub fn uplink_clear(s: &Switch, port: u16) -> DpdResult<()> { s.log, "clear guest_traffic_allowed on {} failed: {:?}", port, e ); - set_ingress_uplink(s, port)?; - Err(e) + + if ingress_cleared { + Err(after_unwind(e, set_ingress_uplink(s, port))) + } else { + Err(e) + } } } } diff --git a/dpd/src/types.rs b/dpd/src/types.rs index c626f8a0..2c4492dc 100644 --- a/dpd/src/types.rs +++ b/dpd/src/types.rs @@ -7,16 +7,77 @@ //! General types used throughout Dendrite. use aal::AsicError; +use common::MISSING_NAT_TARGET_ERROR_CODE; use common::ROLLBACK_FAILURE_ERROR_CODE; use common::SmfError; use common::ports::PortId; use common::ports::QsfpPort; use dpd_types::link::LinkId; +use nonempty::NonEmpty; use std::{convert, net::IpAddr}; use transceiver_controller::Error as TransceiverError; +/// Result of any operation that can fail with a [`DpdError`]. pub type DpdResult = Result; +/// Ignore the [`AsicError::Missing`] ASIC error, as deleting something +/// already removed still counts as success. +/// +/// Every other error is returned as-is. +pub(crate) fn ignore_missing(res: DpdResult<()>) -> DpdResult<()> { + match res { + Err(DpdError::Switch(AsicError::Missing(_))) => Ok(()), + res => res, + } +} + +/// Combine an initial failure with the outcome after unwinding it. +/// +/// Returns `initial` when the undo succeeded, and a [`DpdError::Unwind`] +/// carrying the initial error and rollback failures when it did not. The HTTP +/// mapping keeps the initial error's status and flags the rollback through the +/// `ROLLBACK_FAILURE_ERROR_CODE` error code. +/// +/// For an existing `Unwind`, this appends the new failure to its unwind list. +pub(crate) fn after_unwind( + initial: DpdError, + unwind_outcome: DpdResult<()>, +) -> DpdError { + match (initial, unwind_outcome) { + (initial, Ok(())) => initial, + (DpdError::Unwind { initial, mut unwind }, Err(e)) => { + unwind.push(e); + DpdError::Unwind { initial, unwind } + } + (initial, Err(unwind)) => DpdError::Unwind { + initial: Box::new(initial), + unwind: Box::new(NonEmpty::new(unwind)), + }, + } +} + +fn display_unwind(unwind: &NonEmpty) -> String { + unwind.iter().map(ToString::to_string).collect::>().join("; ") +} + +/// Ignore `Exists` errors from the ASIC or from dpd itself, acting as +/// the add-side counterpart to [`ignore_missing`]. +/// +/// The existing entry is left as is. If its action data differs from what the +/// caller meant to program, then nothing warns. +pub(crate) fn ignore_exists(res: DpdResult<()>) -> DpdResult<()> { + match res { + Err(DpdError::Switch(AsicError::Exists(_)) | DpdError::Exists(_)) => { + Ok(()) + } + res => res, + } +} + +/// The possible failures the dataplane daemon can report. +// The `From for dropshot::HttpError` implementation maps each +// variant to a status code; each new variant added here needs a decision about +// what the client will see. #[derive(Debug, thiserror::Error)] pub enum DpdError { #[error("I/O error: {0:?}")] @@ -27,6 +88,8 @@ pub enum DpdError { Exists(String), #[error("Resource is busy: {0}")] Busy(String), + #[error("Missing NAT target: {0}")] + MissingNatTarget(String), #[error("Resource is missing: {0}")] Missing(String), #[error("Invalid argument: {0}")] @@ -63,8 +126,8 @@ pub enum DpdError { NoLanesAvailable { port_id: PortId }, #[error("Port \"{port_id}\" is not a QSFP port")] NotAQsfpPort { port_id: PortId }, - #[error("Unwind: initial: {initial}, unwind: {unwind}")] - Unwind { initial: Box, unwind: Box }, + #[error("Unwind: initial: {initial}, unwind: {}", display_unwind(.unwind))] + Unwind { initial: Box, unwind: Box> }, #[error("No transceiver controller initialized")] NoTransceiverController, #[error("Failed to operate on transceivers")] @@ -159,10 +222,11 @@ impl convert::From for dropshot::HttpError { headers: None, } } - DpdError::Switch(AsicError::Exists) => { - dropshot::HttpError::for_client_error_with_status( + DpdError::Switch(AsicError::Exists(msg)) => { + dropshot::HttpError::for_client_error( None, dropshot::ClientErrorStatusCode::CONFLICT, + msg, ) } DpdError::Switch(AsicError::Synthetic(message)) => { @@ -196,21 +260,22 @@ impl convert::From for dropshot::HttpError { DpdError::Io(e) => { dropshot::HttpError::for_internal_error(e.to_string()) } - DpdError::Exists(e) => { - dropshot::HttpError::for_client_error_with_status( - Some(e), - dropshot::ClientErrorStatusCode::CONFLICT, - ) - } + DpdError::Exists(e) => dropshot::HttpError::for_client_error( + None, + dropshot::ClientErrorStatusCode::CONFLICT, + e, + ), DpdError::Busy(e) => { dropshot::HttpError::for_unavail(None, e.to_string()) } - DpdError::Missing(e) => { - dropshot::HttpError::for_client_error_with_status( - Some(e), - dropshot::ClientErrorStatusCode::NOT_FOUND, + DpdError::MissingNatTarget(e) => { + dropshot::HttpError::for_client_error( + Some(MISSING_NAT_TARGET_ERROR_CODE.into()), + dropshot::ClientErrorStatusCode::CONFLICT, + e, ) } + DpdError::Missing(e) => dropshot::HttpError::for_not_found(None, e), DpdError::Invalid(e) => { dropshot::HttpError::for_bad_request(None, e) } @@ -250,15 +315,16 @@ impl convert::From for dropshot::HttpError { e @ DpdError::NotAQsfpPort { .. } => { dropshot::HttpError::for_bad_request(None, format!("{e}")) } - DpdError::Unwind { initial, unwind } => dropshot::HttpError { - status_code: dropshot::ErrorStatusCode::INTERNAL_SERVER_ERROR, - error_code: Some(ROLLBACK_FAILURE_ERROR_CODE.into()), - external_message: "inconsistent internal state".into(), - internal_message: format!( - "rollback error: initial: {initial}, unwind: {unwind}" - ), - headers: None, - }, + DpdError::Unwind { initial, unwind } => { + let detail = format!( + "rollback error: initial: {initial}, unwind: {}", + display_unwind(&unwind) + ); + let mut err = dropshot::HttpError::from(*initial); + err.error_code = Some(ROLLBACK_FAILURE_ERROR_CODE.into()); + err.internal_message = detail; + err + } e @ DpdError::NoTransceiverController => { dropshot::HttpError::for_unavail(None, format!("{e}")) } @@ -320,6 +386,18 @@ impl convert::From for DpdError { } } +impl convert::From for DpdError { + fn from(err: dpd_types::mcast::ExternalMulticastIpError) -> Self { + DpdError::Invalid(err.to_string()) + } +} + +impl convert::From for DpdError { + fn from(err: dpd_types::mcast::MulticastTagParseError) -> Self { + DpdError::Invalid(err.to_string()) + } +} + impl convert::From for DpdError { fn from(err: common::table::TableError) -> Self { match err { diff --git a/openapi/dpd/dpd-14.0.0-0e5ee0.json b/openapi/dpd/dpd-14.0.0-14424d.json similarity index 98% rename from openapi/dpd/dpd-14.0.0-0e5ee0.json rename to openapi/dpd/dpd-14.0.0-14424d.json index 1be26aea..62a50cc9 100644 --- a/openapi/dpd/dpd-14.0.0-0e5ee0.json +++ b/openapi/dpd/dpd-14.0.0-14424d.json @@ -1249,7 +1249,7 @@ "/multicast/external-groups": { "post": { "summary": "Create an external-only multicast group configuration.", - "description": "External-only groups are used for IPv4 and IPv6 multicast traffic that does not require replication infrastructure. Any admitted IPv6 scope may be used, admin-local included, except within the reserved underlay subnet ff04::/64, which belongs to the internal multicast API.\n\nThese groups carry no direct members and require a NAT target.", + "description": "External-only groups carry either IPv4 or IPv6 traffic and share their NAT target's replication groups. Any admitted IPv6 scope can be used, including admin-local, except for the reserved underlay subnet of ff04::/64, which belongs specifically to the internal multicast API.\n\nThe request body is decoded into either an ASM or SSM-classified request with validation running on the group address and source list.\n\nThese groups do not carry member subscribers, as membership lives only on the underlay group mapped to by the required NAT target.\n\nThe NAT target must reference an existing underlay group. The underlay group cannot already be referenced by another external group.", "operationId": "multicast_group_create_external", "requestBody": { "content": { @@ -1284,16 +1284,16 @@ "/multicast/external-groups/{group_ip}": { "put": { "summary": "Update an external-only multicast group configuration for a given group IP address.", - "description": "External-only groups are used for IPv4 and IPv6 multicast traffic that does not require replication infrastructure. Any admitted IPv6 scope may be used, admin-local included, except within the reserved underlay subnet ff04::/64, which belongs to the internal multicast API.\n\nThe `tag` query parameter must match the group's existing tag.", + "description": "External-only groups carry IPv4 or IPv6 traffic and share their NAT target's replication groups. Any admitted IPv6 scope can be used, including admin-local, except for the reserved underlay subnet of ff04::/64, which belongs to the internal multicast API.\n\nThe path address is checked as an external multicast address before the handler runs.\n\nOmitting `sources` preserves the group's existing source filter.\n\nThe `tag` query parameter must match the group's existing tag. The NAT target must reference an existing underlay group. The underlay group cannot be referenced by another external group.", "operationId": "multicast_group_update_external", "parameters": [ { "in": "path", "name": "group_ip", + "description": "A validated external multicast group IP address.", "required": true, "schema": { - "type": "string", - "format": "ip" + "$ref": "#/components/schemas/ExternalMulticastIp" } }, { @@ -6489,6 +6489,48 @@ } } }, + "ExternalInternalForwarding": { + "description": "Internal forwarding for an external multicast group.\n\nExternal groups always carry a NAT target.", + "type": "object", + "properties": { + "nat_target": { + "description": "The group's underlay NAT target.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalNatTarget" + } + ] + } + }, + "required": [ + "nat_target" + ] + }, + "ExternalMulticastIp": { + "description": "A validated IP address for a customer-visible overlay multicast group.\n\nJSON uses the ordinary IP-address string form. Deserialization applies the module's validation rules before the request reaches an endpoint handler.", + "type": "string", + "format": "ip" + }, + "ExternalNatTarget": { + "description": "NAT target for an external multicast group.\n\nThe MAC must be a multicast MAC and the internal IP must lie within the `ff04::/64` internal underlay block.", + "type": "object", + "properties": { + "inner_mac": { + "$ref": "#/components/schemas/MacAddr" + }, + "internal_ip": { + "$ref": "#/components/schemas/UnderlayMulticastIpv6" + }, + "vni": { + "$ref": "#/components/schemas/Vni" + } + }, + "required": [ + "inner_mac", + "internal_ip", + "vni" + ] + }, "Fault": { "description": "A Fault represents a specific kind of failure, and carries some additional context. Currently Faults are only used to describe Link failures, but there is no reason they couldn't be used elsewhere.", "oneOf": [ @@ -6831,20 +6873,6 @@ "vni" ] }, - "InternalForwarding": { - "description": "Represents the NAT target for multicast traffic for internal/underlay forwarding.", - "type": "object", - "properties": { - "nat_target": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/NatTarget" - } - ] - } - } - }, "IpNet": { "x-rust-type": { "crate": "oxnet", @@ -8394,21 +8422,36 @@ ] }, "MulticastGroupCreateExternalEntry": { - "description": "A multicast group configuration for POST requests for external (to the rack) groups.", + "description": "A flat request schema for creating a customer-visible overlay multicast group.\n\nThe group IP address disambiguates whether the group is ASM or SSM.", "type": "object", "properties": { "external_forwarding": { - "$ref": "#/components/schemas/ExternalForwarding" + "description": "Egress VLAN configuration for external forwarding.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalForwarding" + } + ] }, "group_ip": { - "type": "string", - "format": "ip" + "description": "The multicast address for the group.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalMulticastIp" + } + ] }, "internal_forwarding": { - "$ref": "#/components/schemas/InternalForwarding" + "description": "The required NAT target is checked against switch state by the server.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalInternalForwarding" + } + ] }, "sources": { "nullable": true, + "description": "Optional source list.\n\nA source list can be absent from ASM group requests. If absent, empty, or containing `Any`, the group permits every source (i.e., a wildcard).\n\nSSM groups require at least one exact source in the list and reject `Any` wildcard entries.", "type": "array", "items": { "$ref": "#/components/schemas/IpSrc" @@ -8451,26 +8494,42 @@ ] }, "MulticastGroupExternalResponse": { - "description": "Response structure for external multicast group operations. These groups handle IPv4 and non-admin-local IPv6 multicast via NAT targets.", + "description": "Response structure for external multicast group operations.", "type": "object", "properties": { "external_forwarding": { - "$ref": "#/components/schemas/ExternalForwarding" + "description": "Egress VLAN configuration for external forwarding.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalForwarding" + } + ] }, "external_group_id": { + "description": "ASIC replication group ID for the external group.", "type": "integer", "format": "uint16", "minimum": 0 }, "group_ip": { - "type": "string", - "format": "ip" + "description": "The validated multicast address of the group.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalMulticastIp" + } + ] }, "internal_forwarding": { - "$ref": "#/components/schemas/InternalForwarding" + "description": "NAT target for forwarding onto the underlay.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalInternalForwarding" + } + ] }, "sources": { "nullable": true, + "description": "Source filter, or `None` when any source is permitted.", "type": "array", "items": { "$ref": "#/components/schemas/IpSrc" @@ -8513,7 +8572,7 @@ "description": "Unified response type for operations that return mixed group types.", "oneOf": [ { - "description": "Response structure for underlay/internal multicast group operations. These groups handle admin-local IPv6 multicast with full replication.", + "description": "An internal group on the underlay, with members.", "type": "object", "properties": { "external_group_id": { @@ -8556,23 +8615,38 @@ ] }, { - "description": "Response structure for external multicast group operations. These groups handle IPv4 and non-admin-local IPv6 multicast via NAT targets.", + "description": "An external overlay group; NAT target and VLAN, no direct members.", "type": "object", "properties": { "external_forwarding": { - "$ref": "#/components/schemas/ExternalForwarding" + "description": "Egress VLAN configuration for external forwarding.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalForwarding" + } + ] }, "external_group_id": { + "description": "ASIC replication group ID for the external group.", "type": "integer", "format": "uint16", "minimum": 0 }, "group_ip": { - "type": "string", - "format": "ip" + "description": "The validated multicast address of the group.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalMulticastIp" + } + ] }, "internal_forwarding": { - "$ref": "#/components/schemas/InternalForwarding" + "description": "NAT target for forwarding onto the underlay.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalInternalForwarding" + } + ] }, "kind": { "type": "string", @@ -8582,6 +8656,7 @@ }, "sources": { "nullable": true, + "description": "Source filter, or `None` when any source is permitted.", "type": "array", "items": { "$ref": "#/components/schemas/IpSrc" @@ -8661,17 +8736,28 @@ ] }, "MulticastGroupUpdateExternalEntry": { - "description": "A multicast group update entry for PUT requests for external (to the rack) groups.\n\nTag validation is performed via the `tag` query parameter.", + "description": "Updated forwarding and source-filter settings for an external group.", "type": "object", "properties": { "external_forwarding": { - "$ref": "#/components/schemas/ExternalForwarding" + "description": "External VLAN config.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalForwarding" + } + ] }, "internal_forwarding": { - "$ref": "#/components/schemas/InternalForwarding" + "description": "Required internal forwarding config.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalInternalForwarding" + } + ] }, "sources": { "nullable": true, + "description": "Replacement source list, or `None` to preserve the current filter.", "type": "array", "items": { "$ref": "#/components/schemas/IpSrc" diff --git a/openapi/dpd/dpd-latest.json b/openapi/dpd/dpd-latest.json index ebaa5053..1085d162 120000 --- a/openapi/dpd/dpd-latest.json +++ b/openapi/dpd/dpd-latest.json @@ -1 +1 @@ -dpd-14.0.0-0e5ee0.json \ No newline at end of file +dpd-14.0.0-14424d.json \ No newline at end of file From 10de8c34dc93c926bd34d1daf932f173aaaf2090 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Tue, 13 Oct 2026 16:10:19 +0000 Subject: [PATCH 4/5] [post-merge] fix prepare_expected_pkt calls after merge --- dpd-client/tests/integration_tests/mcast.rs | 30 +++++---------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/dpd-client/tests/integration_tests/mcast.rs b/dpd-client/tests/integration_tests/mcast.rs index c51ea4ec..273e5f88 100644 --- a/dpd-client/tests/integration_tests/mcast.rs +++ b/dpd-client/tests/integration_tests/mcast.rs @@ -2889,23 +2889,13 @@ async fn run_bifurcated_inner_ttl_test( let test_pkt = TestPacket { packet: Arc::new(geneve_pkt.clone()), port: ingress }; - let underlay_pkt = prepare_expected_pkt( - switch, - &geneve_pkt, - None, - None, - Some(egress_underlay), - ); + let underlay_pkt = + prepare_expected_pkt(switch, &geneve_pkt, None, None, egress_underlay); let mut expected_pkts = Vec::new(); if drop_label.is_none() { - let external_pkt = prepare_expected_pkt( - switch, - &og_pkt, - vlan, - None, - Some(egress_external), - ); + let external_pkt = + prepare_expected_pkt(switch, &og_pkt, vlan, None, egress_external); expected_pkts.push(TestPacket { packet: Arc::new(external_pkt), port: egress_external, @@ -3161,21 +3151,13 @@ async fn run_external_only_inner_ttl_test( vec![ TestPacket { packet: Arc::new(prepare_expected_pkt( - switch, - &og_pkt, - vlan, - None, - Some(egress1), + switch, &og_pkt, vlan, None, egress1, )), port: egress1, }, TestPacket { packet: Arc::new(prepare_expected_pkt( - switch, - &og_pkt, - vlan, - None, - Some(egress2), + switch, &og_pkt, vlan, None, egress2, )), port: egress2, }, From 6366b61a1dcded0174b7c936156a6bdcc2a9cb71 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Mon, 19 Oct 2026 04:06:32 +0000 Subject: [PATCH 5/5] [types, err] typed embedded rendezvous-point errors This also includes `PartialEq` and `Eq` derives on multicast error enums, allowing tests to use `assert_eq!` on the full error. --- dpd-types/versions/src/impls/mcast.rs | 288 +++++++++--------- dpd-types/versions/src/latest.rs | 1 + .../src/mcast_external_scope/mcast.rs | 76 ++++- 3 files changed, 215 insertions(+), 150 deletions(-) diff --git a/dpd-types/versions/src/impls/mcast.rs b/dpd-types/versions/src/impls/mcast.rs index 69e333dc..1f872d73 100644 --- a/dpd-types/versions/src/impls/mcast.rs +++ b/dpd-types/versions/src/impls/mcast.rs @@ -38,7 +38,7 @@ use omicron_common::address::{IPV4_SSM_SUBNET, UNDERLAY_MULTICAST_SUBNET}; use crate::latest::mcast::{ AsmMulticastGroupCreate, EmbeddedIpv4, Error, ExactSource, ExternalForwarding, ExternalInternalForwarding, ExternalMulticastIp, - ExternalMulticastIpError, InvalidMulticastSource, IpSrc, + ExternalMulticastIpError, InvalidEmbeddedRp, InvalidMulticastSource, IpSrc, MulticastGroupCreateExternalEntry, MulticastGroupCreateExternalError, MulticastGroupResponse, MulticastTag, NonEmptyExactSources, SourceEntry, SourceFilter, SsmMulticastGroupCreate, SsmMulticastIp, @@ -120,28 +120,18 @@ const IPV6_SCOPE_INTERFACE_LOCAL: u16 = 0x1; const IPV6_SCOPE_LINK_LOCAL: u16 = 0x2; // Flags field: the nibble above scope (RFC 4291 §2.7). - const IPV6_FLAGS_SHIFT: u16 = 4; const IPV6_FLAGS_MASK: u16 = 0xf; const IPV6_FLAG_TRANSIENT: u16 = 0x1; const IPV6_FLAG_PREFIX_BASED: u16 = 0x2; const IPV6_FLAG_EMBEDDED_RP: u16 = 0x4; + +// RIID and prefix length in the second segment (RFC 3956 §3, RFC 3306 §4). const IPV6_RIID_SHIFT: u16 = 8; const IPV6_RIID_MASK: u16 = 0xf; const IPV6_PREFIX_BASED_PLEN_MASK: u16 = 0xff; const IPV6_PREFIX_BASED_MAX_PLEN: u16 = 64; -// Reasons an embedded-Rendezvous Point (RP) address gets rejected -// (RFC 7371 §4.2.2, §4.2.3; RFC 3956 §4, §6.3, §10). - -const RP_REQUIRES_PREFIX_BASED: &str = "the P flag is required"; -const RP_REQUIRES_TRANSIENT: &str = "the T flag is required"; -const RP_PLEN_OUT_OF_RANGE: &str = - "the prefix length must be from 1 through 64"; -const RP_RIID_ZERO: &str = "the RP interface ID must be nonzero"; -const RP_UNUSABLE_ADDRESS: &str = - "the derived RP address is in ::/16, link-local, or multicast space"; - // SSM flag pattern: R=0, P=T=1 (RFC 7371 §4.1.2). const IPV6_SSM_PREFIX: u16 = 0xff30; const IPV6_SSM_PREFIX_MASK: u16 = 0xff70; @@ -191,6 +181,9 @@ pub(crate) fn validate_external_multicast_ip( } if let IpAddr::V6(ipv6) = addr { + // We classify on just the scope nibble. A /16 prefix comparison + // encodes flags=0 and would accept, e.g., ff11::1 despite its + // interface-local scope. let segs = ipv6.segments(); match segs[0] & IPV6_SCOPE_MASK { IPV6_SCOPE_RESERVED_ZERO => { @@ -226,21 +219,22 @@ pub(crate) fn validate_external_multicast_ip( }; if flags & IPV6_FLAG_PREFIX_BASED == 0 { - return malformed(RP_REQUIRES_PREFIX_BASED); + return malformed(InvalidEmbeddedRp::RequiresPrefixBased); } if flags & IPV6_FLAG_TRANSIENT == 0 { - return malformed(RP_REQUIRES_TRANSIENT); + return malformed(InvalidEmbeddedRp::RequiresTransient); } if plen == 0 || plen > IPV6_PREFIX_BASED_MAX_PLEN { - return malformed(RP_PLEN_OUT_OF_RANGE); + return malformed(InvalidEmbeddedRp::PrefixLengthOutOfRange( + plen as u8, + )); } if riid == 0 { - return malformed(RP_RIID_ZERO); + return malformed(InvalidEmbeddedRp::ZeroInterfaceId); } - if !is_usable_rendezvous_point_address( - embedded_rendezvous_point_address(segs, plen, riid), - ) { - return malformed(RP_UNUSABLE_ADDRESS); + let rp = embedded_rendezvous_point_address(segs, plen, riid); + if !is_usable_rendezvous_point_address(rp) { + return malformed(InvalidEmbeddedRp::UnusableAddress(rp)); } } else if flags & IPV6_FLAG_PREFIX_BASED != 0 && flags & IPV6_FLAG_TRANSIENT == 0 @@ -789,11 +783,11 @@ mod tests { #[test] fn rejects_non_multicast() { for addr in ["10.0.0.1", "192.168.1.1", "2001:db8::1", "::1"] { - assert!( - matches!( - addr.parse::(), - Err(ExternalMulticastIpError::NotMulticast(_)) - ), + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::NotMulticast( + addr.parse::().unwrap() + )), "{addr} should be rejected as non-multicast" ); } @@ -820,20 +814,20 @@ mod tests { } for addr in ["ff01::1", "ff02::1", "ff11::1", "ff32::4000:1"] { - assert!( - matches!( - addr.parse::(), - Err(ExternalMulticastIpError::LocalIpv6Scope(_)) - ), + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::LocalIpv6Scope( + addr.parse::().unwrap() + )), "{addr} should be rejected as an unforwardable scope" ); } - assert!( - matches!( - "ff00::1".parse::(), - Err(ExternalMulticastIpError::ReservedIpv6Scope(_)) - ), + assert_eq!( + "ff00::1".parse::(), + Err(ExternalMulticastIpError::ReservedIpv6Scope( + "ff00::1".parse::().unwrap() + )), "ff00::1 should be rejected as reserved scope 0" ); } @@ -841,11 +835,11 @@ mod tests { #[test] fn rejects_underlay_subnet() { for addr in ["ff04::1", "ff04::ffff:ffff:ffff:ffff"] { - assert!( - matches!( - addr.parse::(), - Err(ExternalMulticastIpError::ReservedUnderlaySubnet(_)) - ), + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::ReservedUnderlaySubnet( + addr.parse::().unwrap() + )), "{addr} should be rejected as underlay" ); } @@ -853,11 +847,11 @@ mod tests { #[test] fn rejects_reserved_ssm_null() { - assert!( - matches!( - "232.0.0.0".parse::(), - Err(ExternalMulticastIpError::ReservedSsmNull(_)) - ), + assert_eq!( + "232.0.0.0".parse::(), + Err(ExternalMulticastIpError::ReservedSsmNull( + "232.0.0.0".parse::().unwrap() + )), "232.0.0.0 should be rejected as reserved" ); for addr in ["232.0.0.1", "232.0.0.255", "232.0.1.0"] { @@ -871,28 +865,47 @@ mod tests { #[test] fn rejects_malformed_embedded_rendezvous_point() { for (addr, expected) in [ - ("ff5e:140:2001:db8::1", RP_REQUIRES_PREFIX_BASED), - ("ff6e:140:2001:db8::1", RP_REQUIRES_TRANSIENT), - ("ff7e:100:2001:db8::1", RP_PLEN_OUT_OF_RANGE), - ("ff7e:180:2001:db8::1", RP_PLEN_OUT_OF_RANGE), - ("ff7e:40:2001:db8::1", RP_RIID_ZERO), - ("ff7e:140::1", RP_UNUSABLE_ADDRESS), - ("ff7e:140:fe80::1", RP_UNUSABLE_ADDRESS), - ("ff7e:140:ff00::1", RP_UNUSABLE_ADDRESS), + ("ff5e:140:2001:db8::1", InvalidEmbeddedRp::RequiresPrefixBased), + ("ff6e:140:2001:db8::1", InvalidEmbeddedRp::RequiresTransient), + ( + "ff7e:100:2001:db8::1", + InvalidEmbeddedRp::PrefixLengthOutOfRange(0), + ), + ( + "ff7e:180:2001:db8::1", + InvalidEmbeddedRp::PrefixLengthOutOfRange(128), + ), + ("ff7e:40:2001:db8::1", InvalidEmbeddedRp::ZeroInterfaceId), + ("ff4e::1", InvalidEmbeddedRp::RequiresPrefixBased), + ( + "ff7e:140::1", + InvalidEmbeddedRp::UnusableAddress(Ipv6Addr::LOCALHOST), + ), + ( + "ff7e:140:fe80::1", + InvalidEmbeddedRp::UnusableAddress("fe80::1".parse().unwrap()), + ), + ( + "ff7e:140:ff00::1", + InvalidEmbeddedRp::UnusableAddress("ff00::1".parse().unwrap()), + ), + ( + "ff7e:104:fff::1", + InvalidEmbeddedRp::UnusableAddress(Ipv6Addr::LOCALHOST), + ), + ( + "ff7e:108:ff12::1", + InvalidEmbeddedRp::UnusableAddress("ff00::1".parse().unwrap()), + ), ] { - match addr.parse::() { + assert_eq!( + addr.parse::(), Err(ExternalMulticastIpError::MalformedEmbeddedRp { - reason, - .. - }) => assert_eq!( - reason, expected, - "{addr} rejected for the wrong reason" - ), - other => panic!( - "{addr} should be rejected as malformed embedded-RP, \ - got {other:?}" - ), - } + addr: addr.parse::().unwrap(), + reason: expected, + }), + "{addr} should be rejected as {expected:?}" + ); } } @@ -967,11 +980,11 @@ mod tests { #[test] fn rejects_invalid_ipv6_ssm_group_ids() { for addr in ["ff3e::4000:0", "ff3e:f000::4000:0"] { - assert!( - matches!( - addr.parse::(), - Err(ExternalMulticastIpError::InvalidIpv6Ssm(_)) - ), + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::InvalidIpv6Ssm( + addr.parse::().unwrap() + )), "{addr} should be rejected as invalid SSM" ); } @@ -1090,12 +1103,15 @@ mod tests { fn ssm_requires_sources() { for sources in [None, Some(vec![])] { for group in ["232.1.2.3", "ff3e::4000:1", "ff35::4000:1"] { - assert!(matches!( - entry(group, sources.clone()), - Err(MulticastGroupCreateExternalError::SsmRequiresSources( - _ - )) - )); + assert_eq!( + entry(group, sources.clone()).err(), + Some( + MulticastGroupCreateExternalError::SsmRequiresSources( + group.parse::().unwrap() + ) + ), + "{group} with sources {sources:?} should be rejected" + ); } } } @@ -1106,14 +1122,15 @@ mod tests { [("232.1.2.3", "10.1.1.1"), ("ff35::4000:1", "2001:db8::1")] { for sources in [vec![IpSrc::Any], vec![exact(source), IpSrc::Any]] { - assert!(matches!( - entry(group, Some(sources)), - Err( + assert_eq!( + entry(group, Some(sources.clone())).err(), + Some( MulticastGroupCreateExternalError::SsmRejectsAnySource( - _ + group.parse::().unwrap() ) - ) - )); + ), + "{group} with sources {sources:?} should be rejected" + ); } } } @@ -1171,13 +1188,12 @@ mod tests { ("224.1.2.3", "2001:db8::1"), ("ff0e::1", "10.1.1.1"), ] { - assert!( - matches!( - entry(group_ip, Some(vec![exact(source)])), - Err( - MulticastGroupCreateExternalError::SourceFamilyMismatch { .. } - ) - ), + assert_eq!( + entry(group_ip, Some(vec![exact(source)])).err(), + Some(MulticastGroupCreateExternalError::SourceFamilyMismatch { + source_ip: source.parse::().unwrap(), + group: group_ip.parse::().unwrap(), + }), "{source} for {group_ip} should be rejected" ); } @@ -1185,14 +1201,18 @@ mod tests { #[test] fn rejects_invalid_exact_sources() { - assert!(matches!( - entry("224.1.2.3", Some(vec![exact("224.9.9.9")])), - Err(MulticastGroupCreateExternalError::SourceNotUnicast(_)) - )); - assert!(matches!( - entry("ff0e::1", Some(vec![exact("ff0e::2")])), - Err(MulticastGroupCreateExternalError::SourceNotUnicast(_)) - )); + assert_eq!( + entry("224.1.2.3", Some(vec![exact("224.9.9.9")])).err(), + Some(MulticastGroupCreateExternalError::SourceNotUnicast( + "224.9.9.9".parse::().unwrap() + )) + ); + assert_eq!( + entry("ff0e::1", Some(vec![exact("ff0e::2")])).err(), + Some(MulticastGroupCreateExternalError::SourceNotUnicast( + "ff0e::2".parse::().unwrap() + )) + ); for (source, expected) in [ ("127.0.0.1", InvalidMulticastSource::Loopback), @@ -1203,14 +1223,12 @@ mod tests { ("240.0.0.1", InvalidMulticastSource::Reserved), ("255.255.255.254", InvalidMulticastSource::Reserved), ] { - assert!( - matches!( - entry("232.1.2.3", Some(vec![exact(source)])), - Err(MulticastGroupCreateExternalError::InvalidIpv4Source { - reason, - .. - }) if reason == expected - ), + assert_eq!( + entry("232.1.2.3", Some(vec![exact(source)])).err(), + Some(MulticastGroupCreateExternalError::InvalidIpv4Source { + addr: source.parse::().unwrap(), + reason: expected, + }), "{source} should be rejected as {expected:?}" ); } @@ -1221,14 +1239,12 @@ mod tests { ("fe80::1", InvalidMulticastSource::LinkLocal), ("febf:ffff::1", InvalidMulticastSource::LinkLocal), ] { - assert!( - matches!( - entry("ff0e::1", Some(vec![exact(source)])), - Err(MulticastGroupCreateExternalError::InvalidIpv6Source { - reason, - .. - }) if reason == expected - ), + assert_eq!( + entry("ff0e::1", Some(vec![exact(source)])).err(), + Some(MulticastGroupCreateExternalError::InvalidIpv6Source { + addr: source.parse::().unwrap(), + reason: expected, + }), "{source} should be rejected as {expected:?}" ); } @@ -1244,14 +1260,12 @@ mod tests { ("::ffff:192.0.2.1", EmbeddedIpv4::Mapped), ("::192.0.2.1", EmbeddedIpv4::Compatible), ] { - assert!( - matches!( - entry("ff0e::1", Some(vec![exact(source)])), - Err(MulticastGroupCreateExternalError::Ipv4EmbeddedSource { - form, - .. - }) if form == expected - ), + assert_eq!( + entry("ff0e::1", Some(vec![exact(source)])).err(), + Some(MulticastGroupCreateExternalError::Ipv4EmbeddedSource { + addr: source.parse::().unwrap(), + form: expected, + }), "{source} should be rejected as {expected:?}" ); } @@ -1381,11 +1395,11 @@ mod tests { #[test] fn rejects_malformed_rfc3306_prefix_based() { for addr in ["ff2e:20:2001:db8::1", "ff2e::1"] { - assert!( - matches!( - addr.parse::(), - Err(ExternalMulticastIpError::MalformedPrefixBased { .. }) - ), + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::MalformedPrefixBased { + addr: addr.parse::().unwrap(), + }), "{addr} should be rejected as malformed prefix-based" ); } @@ -1439,11 +1453,11 @@ mod tests { #[test] fn rejects_only_reserved_base_addresses() { for addr in ["224.0.0.0", "ff03::", "ff05::", "ff0e::", "ff0f::"] { - assert!( - matches!( - addr.parse::(), - Err(ExternalMulticastIpError::ReservedBaseAddress(_)) - ), + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::ReservedBaseAddress( + addr.parse::().unwrap() + )), "{addr} should be rejected as a reserved base address" ); } @@ -1457,13 +1471,13 @@ mod tests { #[test] fn source_validation_precedes_family_mismatch() { - assert!(matches!( - entry("232.1.2.3", Some(vec![exact("::1")])), - Err(MulticastGroupCreateExternalError::InvalidIpv6Source { + assert_eq!( + entry("232.1.2.3", Some(vec![exact("::1")])).err(), + Some(MulticastGroupCreateExternalError::InvalidIpv6Source { + addr: Ipv6Addr::LOCALHOST, reason: InvalidMulticastSource::Loopback, - .. }) - )); + ); } #[test] diff --git a/dpd-types/versions/src/latest.rs b/dpd-types/versions/src/latest.rs index 786e916a..66edbc93 100644 --- a/dpd-types/versions/src/latest.rs +++ b/dpd-types/versions/src/latest.rs @@ -94,6 +94,7 @@ pub mod mcast { pub use crate::v14::mcast::ExternalMulticastIpError; pub use crate::v14::mcast::ExternalNatTarget; pub use crate::v14::mcast::ExternalNatTargetError; + pub use crate::v14::mcast::InvalidEmbeddedRp; pub use crate::v14::mcast::InvalidMulticastSource; pub use crate::v14::mcast::MulticastExternalGroupIpParam; pub use crate::v14::mcast::MulticastGroupCreateExternalEntry; diff --git a/dpd-types/versions/src/mcast_external_scope/mcast.rs b/dpd-types/versions/src/mcast_external_scope/mcast.rs index caaeaaa8..baef7d88 100644 --- a/dpd-types/versions/src/mcast_external_scope/mcast.rs +++ b/dpd-types/versions/src/mcast_external_scope/mcast.rs @@ -177,7 +177,7 @@ impl fmt::Display for ExternalMulticastIp { } /// Errors returned when parsing or validating an [`ExternalMulticastIp`]. -#[derive(Clone, Debug, thiserror::Error)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum ExternalMulticastIpError { /// The address is not a multicast address. #[error("Address {0} is not a multicast address")] @@ -238,9 +238,9 @@ pub enum ExternalMulticastIpError { addr: IpAddr, }, - /// The embedded-Rendezvous (RP) R flag is set with an invalid flag - /// combination, prefix length, RP interface ID, or a derived RP address - /// (RFC 7371 §4.2.2, §4.2.3; RFC 3956 §4, §6.3, §10). + /// The embedded rendezvous point (RP) R flag is set with an invalid flag + /// combination, prefix length, RP interface ID, or derived RP address. + /// [`InvalidEmbeddedRp`] names the rule that failed. #[error( "Address {addr} sets the R flag, but {reason} (RFC 3956, RFC 7371)" )] @@ -248,7 +248,7 @@ pub enum ExternalMulticastIpError { /// The rejected group address. addr: IpAddr, /// The embedded-RP rule that failed. - reason: &'static str, + reason: InvalidEmbeddedRp, }, /// The given string does not parse as an IP address. @@ -258,7 +258,7 @@ pub enum ExternalMulticastIpError { /// Errors returned when validating an external multicast group creation /// request. -#[derive(Clone, Debug, thiserror::Error)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum MulticastGroupCreateExternalError { /// The group IP failed external multicast address validation, returning an /// [`ExternalMulticastIpError`]. @@ -348,8 +348,8 @@ pub enum MulticastGroupCreateExternalError { /// The reason an address cannot serve as a multicast (S,G) source. /// -/// The rules are validated in variant order; an address caught by more -/// than one invalid rule only reports the first hit. +/// The rules are validated in variant order. An address that breaks more +/// than one rule only reports the first hit. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum InvalidMulticastSource { /// `0.0.0.0` or `::`. @@ -402,6 +402,55 @@ impl fmt::Display for InvalidMulticastSource { } } +/// The reason an IPv6 group address with the R flag set cannot embed a +/// rendezvous point (RP) address ([RFC 7371] §4.2.2, §4.2.3; [RFC 3956] §4, +/// §6.3, §10). +/// +/// The rules are validated in variant order. An address that breaks more +/// than one rule only reports the first hit. +/// +/// [RFC 3956]: https://www.rfc-editor.org/rfc/rfc3956.html +/// [RFC 7371]: https://www.rfc-editor.org/rfc/rfc7371.html +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InvalidEmbeddedRp { + /// The P flag is not set ([RFC 7371] §4.2.2, §4.2.3). + RequiresPrefixBased, + /// The T flag is not set ([RFC 7371] §4.2.2, §4.2.3). + RequiresTransient, + /// The prefix length, carried here, is zero or greater than 64 + /// ([RFC 3956] §4). + PrefixLengthOutOfRange(u8), + /// The RP interface ID is zero ([RFC 3956] §6.3). + ZeroInterfaceId, + /// The derived RP address, carried here, is in `::/16`, link-local, or + /// multicast space ([RFC 3956] §4, §10). + /// + /// The address is built from the group's network prefix and RP interface + /// ID. + UnusableAddress(Ipv6Addr), +} + +impl fmt::Display for InvalidEmbeddedRp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RequiresPrefixBased => write!(f, "the P flag is required"), + Self::RequiresTransient => write!(f, "the T flag is required"), + Self::PrefixLengthOutOfRange(plen) => write!( + f, + "the prefix length {plen} is outside the range 1 through 64" + ), + Self::ZeroInterfaceId => { + write!(f, "the RP interface ID must be nonzero") + } + Self::UnusableAddress(rp) => write!( + f, + "the derived RP address {rp} is in ::/16, link-local, or \ + multicast space" + ), + } + } +} + /// The IPv6 representation of an embedded IPv4 address. /// /// Both variants carry the IPv4 address within the low 32 bits and differ only @@ -461,9 +510,10 @@ pub struct SsmMulticastIp(ExternalMulticastIp); /// IPv6 loopback, unspecified, link-local, IPv4-mapped (RFC 4291 §2.5.5.2), /// and IPv4-compatible (RFC 4291 §2.5.5.1) addresses are rejected as sources. /// A link-local source is never forwarded off-link (RFC 3927 §2.7, RFC 4291 -/// §2.5.6), and `0.0.0.0/8` is marked as not forwardable in the IANA -/// special-purpose registry (RFC 6890, per RFC 1812 §5.3.7), which means -/// that neither of these could ever match a source filter. +/// §2.5.6). `0.0.0.0/8` is marked as not forwardable in the IANA +/// special-purpose registry (RFC 6890), and a router SHOULD NOT forward a +/// source on network 0 (RFC 1812 §5.3.7). Neither could ever match a source +/// filter. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ExactSource(IpAddr); @@ -566,7 +616,7 @@ pub struct ExternalNatTarget { } /// Errors from constructing an [`ExternalNatTarget`]. -#[derive(Clone, Debug, thiserror::Error)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum ExternalNatTargetError { /// The inner MAC is not multicast. #[error("NAT target inner MAC address {0} is not a multicast MAC address")] @@ -580,7 +630,7 @@ pub enum ExternalNatTargetError { } /// Errors from converting the older optional forwarding shape. -#[derive(Clone, Debug, thiserror::Error)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum ExternalInternalForwardingError { /// The request omitted its NAT target. #[error("External multicast groups require a NAT target")]