From 1bb7b72bee83a5bf2509247265a9eea350203414 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 c7997e94..cb168481 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 e26b144cf7e223011f885035bff1b27941e9c06a 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 5c0477d80e30c858c9fa1c40031d7fc18fdba375 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Tue, 2 Jun 2026 02:02:58 +0000 Subject: [PATCH 3/5] [swadm,multicast] add multicast inspection subcommand (->list, ->get) This adds a READ-only (no-mutation) `swadm multicast` subcommand (aliased as `mcast` as well) for inspecting the multicast groups programmed on the switch. The two commands are essentially a LIST and GET: - `multicast list [-t ] [-k external|underlay]` renders all groups as a table view, filtered by tag, by replication kind, or both. - `multicast get ` shows one group's full configuration. The kind filter matches on the response type: `external` means an external group with a NAT target and no (underlying) direction membership; `underlay` groups sit in the reserved ff04::/64 subnet and replicate to the member ports (to guest subscribers). --- swadm/Cargo.toml | 1 + swadm/src/main.rs | 9 ++ swadm/src/multicast.rs | 333 +++++++++++++++++++++++++++++++++++++++ swadm/tests/multicast.rs | 331 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 674 insertions(+) create mode 100644 swadm/src/multicast.rs create mode 100644 swadm/tests/multicast.rs diff --git a/swadm/Cargo.toml b/swadm/Cargo.toml index 0565ced7..ae317242 100644 --- a/swadm/Cargo.toml +++ b/swadm/Cargo.toml @@ -27,3 +27,4 @@ expectorate.workspace = true predicates.workspace = true serial_test.workspace = true thiserror.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/swadm/src/main.rs b/swadm/src/main.rs index b5fd1221..67026706 100644 --- a/swadm/src/main.rs +++ b/swadm/src/main.rs @@ -23,6 +23,7 @@ mod attached; mod compliance; mod counters; mod link; +mod multicast; mod nat; mod route; mod snapshot; @@ -66,6 +67,11 @@ enum Commands { #[command(subcommand)] cmd: nat::Nat, }, + #[clap(visible_alias = "mcast")] + Multicast { + #[command(subcommand)] + cmd: multicast::Multicast, + }, #[clap(visible_alias = "attsub")] AttachedSubnet { #[command(subcommand)] @@ -243,6 +249,9 @@ async fn main_impl() -> anyhow::Result<()> { attached::attsub_cmd(&client, p).await } Commands::Nat { cmd: p } => nat::nat_cmd(&client, p).await, + Commands::Multicast { cmd } => { + multicast::multicast_cmd(&client, cmd).await + } Commands::Counters { cmd: c } => counters::ctrs_cmd(&client, c).await, Commands::SwitchPort { cmd: p } => { switchport::switch_cmd(&client, p).await diff --git a/swadm/src/multicast.rs b/swadm/src/multicast.rs new file mode 100644 index 00000000..64082397 --- /dev/null +++ b/swadm/src/multicast.rs @@ -0,0 +1,333 @@ +// 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 + +//! Inspection of the multicast groups programmed on the switch. +//! +//! Here's an example set of output against a running dpd bin: +//! +//! ```text +//! $ swadm multicast list +//! GROUP IP KIND EXT GROUP ID UL GROUP ID TAG DETAIL +//! 224.0.1.50 external 65532 - oxide-demo nat=ff04::2 mac=33:33:00:00:00:02 vni=88 vlan=- src=any +//! 232.123.45.99 external 65534 - oxide-demo nat=ff04::1 mac=33:33:00:00:00:01 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 +//! ff04::1 underlay 65534 65533 oxide-demo rear0/0(underlay) rear0/0(external) +//! ff04::2 underlay 65532 65531 oxide-demo - +//! ``` +//! +//! ```text +//! $ swadm multicast get 232.123.45.99 +//! Group IP: 232.123.45.99 +//! Kind: external +//! External group ID: 65534 +//! Tag: oxide-demo +//! NAT target: ff04::1 (mac 33:33:00:00:00:01, vni 77) +//! VLAN: 10 +//! Sources: 10.0.0.1,10.0.0.2 +//! ``` +//! +//! ```text +//! $ swadm multicast get ff04::1 +//! Group IP: ff04::1 +//! Kind: underlay +//! External group ID: 65534 +//! Underlay group ID: 65533 +//! Tag: oxide-demo +//! Members: +//! rear0/0(underlay) +//! rear0/0(external) +//! ``` + +use std::fmt; +use std::io::{Write, stdout}; +use std::net::IpAddr; + +use anyhow::Context; +use clap::{Subcommand, ValueEnum}; +use colored::Colorize; +use futures::stream::{StreamExt, TryStreamExt}; +use tabwriter::TabWriter; + +use dpd_client::{Client, types}; + +/// Replication kind for a multicast group, matching the `KIND` column. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum GroupKind { + /// Groups with a NAT target and no direct members. + External, + /// Groups in the reserved underlay subnet (ff04::/64) that replicate + /// to member ports. + Underlay, +} + +#[derive(Debug, Subcommand)] +/// Inspect the multicast groups programmed on the switch. +pub enum Multicast { + /// List multicast groups, optionally filtered by tag. + #[clap(visible_alias = "ls")] + List { + /// Limit the listing to groups carrying the given tag. + #[clap(short = 't', long)] + tag: Option, + /// Limit the listing to external or underlay groups. + #[clap(short = 'k', long = "kind")] + kind: Option, + }, + /// Show the full configuration of a single multicast group. + Get { + /// Group IP address (IPv4, external IPv6, or underlay IPv6). + group_ip: IpAddr, + }, +} + +struct DirectionLabel<'a>(&'a types::Direction); + +impl fmt::Display for DirectionLabel<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + types::Direction::Underlay => f.write_str("underlay"), + types::Direction::External => f.write_str("external"), + } + } +} + +struct MembersSummary<'a>(&'a [types::MulticastGroupMember]); + +impl fmt::Display for MembersSummary<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0.is_empty() { + return f.write_str("-"); + } + let members = self + .0 + .iter() + .map(|member| { + format!( + "{}/{}({})", + member.port_id, + *member.link_id, + DirectionLabel(&member.direction), + ) + }) + .collect::>() + .join(" "); + f.write_str(&members) + } +} + +struct SourcesSummary<'a>(Option<&'a [types::IpSrc]>); + +impl fmt::Display for SourcesSummary<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Some(sources) if !sources.is_empty() => { + let sources = sources + .iter() + .map(|source| match source { + types::IpSrc::Exact(ip) => ip.to_string(), + types::IpSrc::Any => "any".to_string(), + }) + .collect::>() + .join(","); + f.write_str(&sources) + } + _ => f.write_str("any"), + } + } +} + +struct ExternalSummary<'a> { + internal_forwarding: &'a types::InternalForwarding, + external_forwarding: &'a types::ExternalForwarding, + sources: Option<&'a [types::IpSrc]>, +} + +impl fmt::Display for ExternalSummary<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.internal_forwarding.nat_target { + Some(t) => write!( + f, + "nat={} mac={} vni={}", + t.internal_ip, t.inner_mac, *t.vni, + )?, + None => f.write_str("nat=- mac=- vni=-")?, + } + match self.external_forwarding.vlan_id { + Some(v) => write!(f, " vlan={v}")?, + None => f.write_str(" vlan=-")?, + } + write!(f, " src={}", SourcesSummary(self.sources)) + } +} + +async fn multicast_list( + client: &Client, + tag: Option, + kind: Option, +) -> anyhow::Result<()> { + let tag = tag + .map(|tag| { + tag.parse::() + .map_err(|e| anyhow::anyhow!("invalid multicast tag: {e}")) + }) + .transpose()?; + let mut groups = match &tag { + Some(tag) => { + client.multicast_groups_list_by_tag_stream(tag, None).boxed() + } + None => client.multicast_groups_list_stream(None).boxed(), + }; + + let mut tw = TabWriter::new(stdout()); + writeln!( + &mut tw, + "{}\t{}\t{}\t{}\t{}\t{}", + "GROUP IP".underline(), + "KIND".underline(), + "EXT GROUP ID".underline(), + "UL GROUP ID".underline(), + "TAG".underline(), + "DETAIL".underline(), + )?; + + while let Some(group) = + groups.try_next().await.context("failed to list multicast groups")? + { + if !matches!( + (kind, &group), + (None, _) + | ( + Some(GroupKind::External), + types::MulticastGroupResponse::External { .. } + ) + | ( + Some(GroupKind::Underlay), + types::MulticastGroupResponse::Underlay { .. } + ) + ) { + continue; + } + match &group { + types::MulticastGroupResponse::Underlay { + group_ip, + external_group_id, + underlay_group_id, + tag, + members, + } => writeln!( + &mut tw, + "{}\tunderlay\t{}\t{}\t{}\t{}", + group_ip, + external_group_id, + underlay_group_id, + tag, + MembersSummary(members), + )?, + types::MulticastGroupResponse::External { + group_ip, + external_group_id, + tag, + internal_forwarding, + external_forwarding, + sources, + } => writeln!( + &mut tw, + "{}\texternal\t{}\t-\t{}\t{}", + group_ip, + external_group_id, + tag, + ExternalSummary { + internal_forwarding, + external_forwarding, + sources: sources.as_deref(), + }, + )?, + } + } + + tw.flush()?; + Ok(()) +} + +async fn multicast_get( + client: &Client, + group_ip: IpAddr, +) -> anyhow::Result<()> { + let group = client + .multicast_group_get(&group_ip) + .await + .with_context(|| format!("failed to get multicast group {group_ip}"))? + .into_inner(); + + match group { + types::MulticastGroupResponse::Underlay { + group_ip, + external_group_id, + underlay_group_id, + tag, + members, + } => { + println!("Group IP: {group_ip}"); + println!("Kind: underlay"); + println!("External group ID: {external_group_id}"); + println!("Underlay group ID: {underlay_group_id}"); + println!("Tag: {tag}"); + println!("Members:"); + if members.is_empty() { + println!(" (none)"); + } + for member in &members { + println!( + " {}/{}({})", + member.port_id, + *member.link_id, + DirectionLabel(&member.direction), + ); + } + } + types::MulticastGroupResponse::External { + group_ip, + external_group_id, + tag, + internal_forwarding, + external_forwarding, + sources, + } => { + println!("Group IP: {group_ip}"); + println!("Kind: external"); + println!("External group ID: {external_group_id}"); + println!("Tag: {tag}"); + match &internal_forwarding.nat_target { + Some(t) => println!( + "NAT target: {} (mac {}, vni {})", + t.internal_ip, t.inner_mac, *t.vni, + ), + None => println!("NAT target: (none)"), + } + match external_forwarding.vlan_id { + Some(v) => println!("VLAN: {v}"), + None => println!("VLAN: (none)"), + } + println!( + "Sources: {}", + SourcesSummary(sources.as_deref()) + ); + } + } + + Ok(()) +} + +pub async fn multicast_cmd( + client: &Client, + cmd: Multicast, +) -> anyhow::Result<()> { + match cmd { + Multicast::List { tag, kind } => { + multicast_list(client, tag, kind).await + } + Multicast::Get { group_ip } => multicast_get(client, group_ip).await, + } +} diff --git a/swadm/tests/multicast.rs b/swadm/tests/multicast.rs new file mode 100644 index 00000000..b4b01535 --- /dev/null +++ b/swadm/tests/multicast.rs @@ -0,0 +1,331 @@ +// 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 + +//! Integration test for the `swadm multicast` inspection subcommand. +//! +//! Seeds groups via `dpd-client`, then asserts `multicast list` / `multicast +//! get` render them correctly. Needs a running dpd, so it is `#[ignore]`d. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::process::Command; + +use common::network::MacAddr; +use dpd_client::{Client, ClientState, default_port, types}; + +// Path to the `swadm` executable. +const SWADM: &str = env!("CARGO_BIN_EXE_swadm"); +const HOST: &str = "[::1]"; +const TEST_TAG: &str = "swadm_multicast_test"; + +// External IPv4 group. +// +// External groups carry no members, only a NAT target. +// An SSM address (232.0.0.0/8) is used so the source filter is well-formed. +const EXT_IPV4: Ipv4Addr = Ipv4Addr::new(232, 123, 45, 99); +const EXT_VLAN: u16 = 10; +const EXT_SOURCE: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); +// NAT target VNI (matches omicron's `DEFAULT_MULTICAST_VNI`). +const EXT_VNI: u32 = 77; +// A second external group on an ASM address (224.0.0.0/4) with no VLAN or +// source filter. +const EXT_IPV4_ASM: Ipv4Addr = Ipv4Addr::new(224, 0, 1, 50); + +// Admin-local underlay group (ff04::/64) and the NAT target the external group +// forwards to. +const UNDERLAY_IPV6: Ipv6Addr = Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1); +const NAT_IP: Ipv6Addr = UNDERLAY_IPV6; +// A second underlay group with no members just to test the empty-member +// display branches ("-" in the list, "(none)" in get). +const UNDERLAY_IPV6_EMPTY: Ipv6Addr = + Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 2); + +fn swadm() -> Command { + let mut cmd = Command::new(SWADM); + cmd.arg("--host").arg(HOST); + cmd +} + +fn client() -> Client { + let log = slog::Logger::root(slog::Discard, slog::o!()); + let state = ClientState { tag: String::from("swadm-mcast-test"), log }; + Client::new(&format!("http://{HOST}:{}", default_port()), state) +} + +/// Run `swadm` with the given args and checks. +fn run_ok(args: &[&str]) -> String { + let output = swadm() + .args(args) + .output() + .unwrap_or_else(|e| panic!("failed to execute swadm {args:?}: {e}")); + assert!( + output.status.success(), + "swadm {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +/// Collect the KIND column of every data row in a `multicast list` rendering, +/// skipping the header. +fn group_kinds(list: &str) -> Vec { + list.lines() + .skip(1) + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| line.split_whitespace().nth(1)) + .map(str::to_string) + .collect() +} + +#[tokio::test] +#[ignore] +async fn test_multicast_list_and_get() { + let client = client(); + + client.multicast_reset().await.expect("failed to reset multicast groups"); + + // Seed a bifurcated underlay group with one underlay-replication member + // and one external-replication member, both on the first available link. + // + // Both direction labels are exercised in the rendered output (in one go). + let links = client.link_list_all(None).await.expect("failed to list links"); + let link = links + .into_inner() + .into_iter() + .next() + .expect("no links available to seed an underlay member"); + let member_path = format!("{}/{}", link.port_id, *link.link_id); + let underlay_member = types::MulticastGroupMember { + port_id: link.port_id.clone(), + link_id: link.link_id, + direction: types::Direction::Underlay, + }; + let external_member = types::MulticastGroupMember { + port_id: link.port_id.clone(), + link_id: link.link_id, + direction: types::Direction::External, + }; + + client + .multicast_group_create_underlay( + &types::MulticastGroupCreateUnderlayEntry { + group_ip: types::UnderlayMulticastIpv6(UNDERLAY_IPV6), + tag: Some(TEST_TAG.to_string()), + members: vec![underlay_member, external_member], + }, + ) + .await + .expect("failed to create underlay group"); + + // Seed a second underlay group with no members. + client + .multicast_group_create_underlay( + &types::MulticastGroupCreateUnderlayEntry { + group_ip: types::UnderlayMulticastIpv6(UNDERLAY_IPV6_EMPTY), + tag: Some(TEST_TAG.to_string()), + members: vec![], + }, + ) + .await + .expect("failed to create empty underlay group"); + + // Seed an external IPv4 group that NATs to the underlay group: VLAN-tagged + // on egress and filtered on a single source. + client + .multicast_group_create_external( + &types::MulticastGroupCreateExternalEntry { + group_ip: IpAddr::V4(EXT_IPV4), + tag: Some(TEST_TAG.to_string()), + internal_forwarding: types::InternalForwarding { + nat_target: Some(types::NatTarget { + internal_ip: NAT_IP, + inner_mac: MacAddr::new( + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + ) + .into(), + vni: EXT_VNI.into(), + }), + }, + external_forwarding: types::ExternalForwarding { + vlan_id: Some(EXT_VLAN), + }, + sources: Some(vec![types::IpSrc::Exact(IpAddr::V4( + EXT_SOURCE, + ))]), + }, + ) + .await + .expect("failed to create external group"); + + // Seed a second external group on an ASM address with no VLAN or source + // filter. External groups always require a NAT target, so this one keeps + // the NAT target but drops the VLAN and sources. + client + .multicast_group_create_external( + &types::MulticastGroupCreateExternalEntry { + group_ip: IpAddr::V4(EXT_IPV4_ASM), + tag: Some(TEST_TAG.to_string()), + internal_forwarding: types::InternalForwarding { + nat_target: Some(types::NatTarget { + internal_ip: NAT_IP, + inner_mac: MacAddr::new( + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + ) + .into(), + vni: EXT_VNI.into(), + }), + }, + external_forwarding: types::ExternalForwarding { + vlan_id: None, + }, + sources: None, + }, + ) + .await + .expect("failed to create ASM external group"); + + let list = run_ok(&["multicast", "list"]); + for header in + ["GROUP IP", "KIND", "EXT GROUP ID", "UL GROUP ID", "TAG", "DETAIL"] + { + assert!(list.contains(header), "list missing {header} header:\n{list}"); + } + assert!( + list.contains(&UNDERLAY_IPV6.to_string()) && list.contains("underlay"), + "list missing underlay group:\n{list}" + ); + // Both member directions render as `port/link(dir)` in the DETAIL column. + assert!( + list.contains(&format!("{member_path}(underlay)")) + && list.contains(&format!("{member_path}(external)")), + "list missing bifurcated members {member_path}:\n{list}" + ); + // The member-less underlay group renders "-" for its (empty) DETAIL. + let empty_row = list + .lines() + .find(|line| line.contains(&UNDERLAY_IPV6_EMPTY.to_string())) + .unwrap_or_else(|| { + panic!("list missing empty underlay group:\n{list}") + }); + assert!( + empty_row.contains("underlay") && empty_row.trim_end().ends_with('-'), + "empty underlay row missing \"-\" detail: {empty_row:?}" + ); + assert!( + list.contains(&EXT_IPV4.to_string()) && list.contains("external"), + "list missing external group:\n{list}" + ); + // The fully-populated external group renders its NAT target, VLAN, and + // source filter. + assert!( + list.contains(&format!("nat={NAT_IP}")) + && list.contains("mac=11:22:33:44:55:66") + && list.contains(&format!("vni={EXT_VNI}")) + && list.contains("vlan=10") + && list.contains(&format!("src={EXT_SOURCE}")), + "list missing external forwarding detail:\n{list}" + ); + // An SM group with an absent-VLAN and any-source branches. + assert!( + list.contains(&EXT_IPV4_ASM.to_string()) + && list.contains("vlan=-") + && list.contains("src=any"), + "list missing ASM external empty-detail branches:\n{list}" + ); + // Tag filtering should include our groups... + let by_tag = run_ok(&["multicast", "list", "-t", TEST_TAG]); + assert!( + by_tag.contains(&EXT_IPV4.to_string()), + "tag-filtered list missing external group:\n{by_tag}" + ); + // ...and a non-matching tag should return only the header. + let other = run_ok(&["multicast", "list", "-t", "no_such_tag"]); + assert!( + !other.contains(&EXT_IPV4.to_string()) + && !other.contains(&UNDERLAY_IPV6.to_string()), + "non-matching tag returned groups:\n{other}" + ); + // Kind filtering selects one variant and excludes the other. + let external_only = run_ok(&["multicast", "list", "-k", "external"]); + let kinds = group_kinds(&external_only); + assert!( + !kinds.is_empty() && kinds.iter().all(|kind| kind == "external"), + "external-only list is not filtered:\n{external_only}" + ); + assert!( + external_only.contains(&EXT_IPV4.to_string()), + "external-only list missing external group:\n{external_only}" + ); + + let underlay_only = run_ok(&["multicast", "list", "--kind", "underlay"]); + let kinds = group_kinds(&underlay_only); + assert!( + !kinds.is_empty() && kinds.iter().all(|kind| kind == "underlay"), + "underlay-only list is not filtered:\n{underlay_only}" + ); + assert!( + underlay_only.contains(&UNDERLAY_IPV6.to_string()), + "underlay-only list missing underlay group:\n{underlay_only}" + ); + + // The two filters compose. + let both = run_ok(&["multicast", "list", "-t", TEST_TAG, "-k", "external"]); + assert!( + both.contains(&EXT_IPV4.to_string()) + && group_kinds(&both).iter().all(|kind| kind == "external"), + "tag and kind filters do not compose:\n{both}" + ); + + // GET on the external group should show its detail, including + // the aligned KV labels and the fully-populated NAT/VLAN/source values. + let ext = run_ok(&["multicast", "get", &EXT_IPV4.to_string()]); + for label in ["Group IP:", "Kind:", "NAT target:", "VLAN:", "Sources:"] { + assert!(ext.contains(label), "get external missing {label}:\n{ext}"); + } + assert!(ext.contains("external"), "get external missing kind:\n{ext}"); + assert!( + ext.contains(&NAT_IP.to_string()) && ext.contains("vni 77"), + "get external missing NAT target detail:\n{ext}" + ); + assert!(ext.contains("VLAN: 10"), "get external vlan:\n{ext}"); + assert!( + ext.contains(&EXT_SOURCE.to_string()), + "get external missing source:\n{ext}" + ); + + // GET on the ASM group should show the "(none)"/"any" branches + // for an absent VLAN and source filter. + let asm = run_ok(&["multicast", "get", &EXT_IPV4_ASM.to_string()]); + assert!( + asm.contains("VLAN: (none)"), + "get ASM missing absent VLAN:\n{asm}" + ); + assert!( + asm.contains("Sources: any"), + "get ASM missing any-source branch:\n{asm}" + ); + + // GET on the underlay group should list both members, one per + // replication direction, as `port/link(dir)`. + let underlay = run_ok(&["multicast", "get", &UNDERLAY_IPV6.to_string()]); + assert!( + underlay.contains("underlay"), + "get underlay missing kind:\n{underlay}" + ); + assert!( + underlay.contains(&format!("{member_path}(underlay)")) + && underlay.contains(&format!("{member_path}(external)")), + "get underlay missing bifurcated members {member_path}:\n{underlay}" + ); + + // GET on the member-less underlay group shows the "(none)" members branch. + let empty = run_ok(&["multicast", "get", &UNDERLAY_IPV6_EMPTY.to_string()]); + assert!( + empty.contains("Members:") && empty.contains("(none)"), + "get empty underlay missing \"(none)\" members:\n{empty}" + ); + + client.multicast_reset().await.expect("failed to reset multicast groups"); +} From 5125d95a2c269fc2ff24e2579bb8ba45e9c50eb9 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Wed, 7 Oct 2026 10:59:18 +0000 Subject: [PATCH 4/5] [review] paired add/del subcommands, tabwriter, common test setup This update moves the swadm multicast testing harness onto the common expectorate setup from #350, and gives swadm multicast group create/delete (add/del) semantics, removing the tests' need for tokio and the dropshot client. `add/del` subcommands work on a pair (of groups) by default: `add` creates the underlay group, then the external group that maps to it (1:1, rolling the underlay back out if the external create fails), and `del` checks both (shared) tags before removing either group. `-t` defaults to the client tag on both subcommands, similar to `arp add`. `del --underlay-only` removes unreferenced underlay groups, including any left behind after a failed pair deletion. Includes: - `multicast get` runs through `TabWriter`, like `list`, for consistency. - `list` headers use Title Casing, like `nat`/`attached`. - `add` flags match what `nat add` has, i.e. `-i` underlay, `-m` mac, `-v` vni, and `--member` is long-only. - clap parses right into our new types (parent commit ~ type-validation), with bad addresses failing at arg parse-time. - extended test coverage and test helpers --- Cargo.lock | 1 + swadm/Cargo.toml | 2 +- swadm/src/multicast.rs | 544 ++++++++++++++++-- swadm/tests/cli/cmd.rs | 15 + .../cli/expect/multicast_add_duplicate.txt | 4 + .../expect/multicast_add_underlay_group.txt | 1 + .../multicast_del_external_tag_mismatch.txt | 1 + .../cli/expect/multicast_del_underlay.txt | 1 + .../multicast_del_underlay_only_external.txt | 1 + .../multicast_del_underlay_referenced.txt | 4 + .../cli/expect/multicast_get_default_tag.txt | 7 + .../cli/expect/multicast_get_external.txt | 7 + .../cli/expect/multicast_get_external_asm.txt | 7 + .../cli/expect/multicast_get_not_found.txt | 4 + .../expect/multicast_get_not_found_spare.txt | 4 + .../multicast_get_not_found_underlay.txt | 4 + .../cli/expect/multicast_get_underlay.txt | 8 + .../expect/multicast_get_underlay_empty.txt | 7 + swadm/tests/cli/expect/multicast_list.txt | 5 + .../expect/multicast_list_tag_external.txt | 3 + .../cli/expect/multicast_list_tag_none.txt | 1 + .../cli/expect/multicast_list_underlay.txt | 3 + swadm/tests/cli/main.rs | 1 + swadm/tests/cli/multicast.rs | 322 +++++++++++ swadm/tests/multicast.rs | 331 ----------- 25 files changed, 907 insertions(+), 381 deletions(-) create mode 100644 swadm/tests/cli/expect/multicast_add_duplicate.txt create mode 100644 swadm/tests/cli/expect/multicast_add_underlay_group.txt create mode 100644 swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt create mode 100644 swadm/tests/cli/expect/multicast_del_underlay.txt create mode 100644 swadm/tests/cli/expect/multicast_del_underlay_only_external.txt create mode 100644 swadm/tests/cli/expect/multicast_del_underlay_referenced.txt create mode 100644 swadm/tests/cli/expect/multicast_get_default_tag.txt create mode 100644 swadm/tests/cli/expect/multicast_get_external.txt create mode 100644 swadm/tests/cli/expect/multicast_get_external_asm.txt create mode 100644 swadm/tests/cli/expect/multicast_get_not_found.txt create mode 100644 swadm/tests/cli/expect/multicast_get_not_found_spare.txt create mode 100644 swadm/tests/cli/expect/multicast_get_not_found_underlay.txt create mode 100644 swadm/tests/cli/expect/multicast_get_underlay.txt create mode 100644 swadm/tests/cli/expect/multicast_get_underlay_empty.txt create mode 100644 swadm/tests/cli/expect/multicast_list.txt create mode 100644 swadm/tests/cli/expect/multicast_list_tag_external.txt create mode 100644 swadm/tests/cli/expect/multicast_list_tag_none.txt create mode 100644 swadm/tests/cli/expect/multicast_list_underlay.txt create mode 100644 swadm/tests/cli/multicast.rs delete mode 100644 swadm/tests/multicast.rs diff --git a/Cargo.lock b/Cargo.lock index 1b3999a7..5d72be0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7344,6 +7344,7 @@ dependencies = [ "colored", "common 0.1.0", "dpd-client 0.1.0", + "dpd-types", "expectorate", "futures", "humantime", diff --git a/swadm/Cargo.toml b/swadm/Cargo.toml index ae317242..dfc29a30 100644 --- a/swadm/Cargo.toml +++ b/swadm/Cargo.toml @@ -12,6 +12,7 @@ clap.workspace = true colored.workspace = true common.workspace = true dpd-client.workspace = true +dpd-types.workspace = true futures.workspace = true humantime.workspace = true oxide-tokio-rt.workspace = true @@ -27,4 +28,3 @@ expectorate.workspace = true predicates.workspace = true serial_test.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/swadm/src/multicast.rs b/swadm/src/multicast.rs index b3b5eb73..fa3aafd1 100644 --- a/swadm/src/multicast.rs +++ b/swadm/src/multicast.rs @@ -4,13 +4,26 @@ // // Copyright 2026 Oxide Computer Company -//! Inspection of the multicast groups programmed on the switch. +//! Inspection, creation, and deletion of the multicast groups programmed on +//! the switch. //! -//! Here's an example set of output against a running dpd bin: +//! An example session against a running dpd is shown below. Each `add` creates +//! an external group along with the underlay group it maps to. By default, +//! `del` checks both tags, then deletes the external group followed by its +//! underlay: //! //! ```text -//! $ swadm multicast list -//! GROUP IP KIND EXT GROUP ID UL GROUP ID TAG DETAIL +//! $ swadm multicast add 232.123.45.99 -i ff04::1 \ +//! --member rear0/0:underlay --member rear0/0:external \ +//! -m 33:33:00:00:00:01 -v 77 --vlan 10 -s 10.0.0.1 -s 10.0.0.2 \ +//! -t oxide-demo +//! $ swadm multicast add 224.0.1.50 -i ff04::2 \ +//! -m 33:33:00:00:00:02 -v 88 -t oxide-demo +//! ``` +//! +//! ```text +//! $ swadm multicast list -t oxide-demo +//! Group IP Kind Ext Group ID UL Group ID Tag Detail //! 224.0.1.50 external 65532 - oxide-demo nat=ff04::2 mac=33:33:00:00:00:02 vni=88 vlan=- src=any //! 232.123.45.99 external 65534 - oxide-demo nat=ff04::1 mac=33:33:00:00:00:01 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 //! ff04::1 underlay 65534 65533 oxide-demo rear0/0(underlay) rear0/0(external) @@ -19,40 +32,59 @@ //! //! ```text //! $ swadm multicast get 232.123.45.99 -//! Group IP: 232.123.45.99 -//! Kind: external -//! External group ID: 65534 -//! Tag: oxide-demo -//! NAT target: ff04::1 (mac 33:33:00:00:00:01, vni 77) -//! VLAN: 10 -//! Sources: 10.0.0.1,10.0.0.2 +//! Group IP: 232.123.45.99 +//! Kind: external +//! External group ID: 65534 +//! Tag: oxide-demo +//! NAT target: ff04::1 (mac 33:33:00:00:00:01, vni 77) +//! VLAN: 10 +//! Sources: 10.0.0.1,10.0.0.2 //! ``` //! //! ```text //! $ swadm multicast get ff04::1 -//! Group IP: ff04::1 -//! Kind: underlay -//! External group ID: 65534 -//! Underlay group ID: 65533 -//! Tag: oxide-demo +//! Group IP: ff04::1 +//! Kind: underlay +//! External group ID: 65534 +//! Underlay group ID: 65533 +//! Tag: oxide-demo //! Members: //! rear0/0(underlay) //! rear0/0(external) //! ``` +//! +//! ```text +//! $ swadm multicast del ff04::1 -t oxide-demo +//! Error: ff04::1 is an underlay group; delete the external group that maps to it +//! $ swadm multicast del 224.0.1.50 -t oxide-demo +//! $ swadm multicast del 232.123.45.99 -t oxide-demo +//! ``` +//! +//! If deleting the external group succeeds but deleting its underlay fails, +//! retry the underlay deletion: +//! +//! ```text +//! $ swadm multicast del ff04::1 --underlay-only -t oxide-demo +//! ``` use std::fmt; use std::io::{Write, stdout}; use std::net::IpAddr; +use std::str::FromStr; use anyhow::Context; -use clap::{Subcommand, ValueEnum}; +use clap::{Args, Subcommand, ValueEnum}; use colored::Colorize; use futures::stream::{StreamExt, TryStreamExt}; use tabwriter::TabWriter; -use dpd_client::{Client, types}; +use common::network::{MacAddr, Vni}; +use dpd_client::{Client, ClientInfo, types}; +use dpd_types::mcast::{ExternalMulticastIp, UnderlayMulticastIpv6}; -/// Replication kind for a multicast group, matching the `KIND` column. +use crate::LinkPath; + +/// Replication kind for a multicast group, matching the `Kind` column. #[derive(Debug, Clone, Copy, ValueEnum)] pub enum GroupKind { /// Groups with a NAT target and no direct members. @@ -63,7 +95,7 @@ pub enum GroupKind { } #[derive(Debug, Subcommand)] -/// Inspect the multicast groups programmed on the switch. +/// Inspect, create, and delete the multicast groups programmed on the switch. pub enum Multicast { /// List multicast groups, optionally filtered by tag. #[clap(visible_alias = "ls")] @@ -80,6 +112,83 @@ pub enum Multicast { /// Group IP address (IPv4, external IPv6, or underlay IPv6). group_ip: IpAddr, }, + /// Create an external multicast group and the associated underlay group + /// that it maps to (1:1). + Add(AddArgs), + /// Delete an external multicast group and the underlay group that it maps to. + Del { + /// External group address (IPv4, or IPv6 outside ff04::/64), or + /// an underlay address with --underlay-only. + group_ip: IpAddr, + /// The tag the groups were created with; defaults to the client tag. + #[clap(short = 't', long)] + tag: Option, + /// Delete only an unreferenced underlay group. + #[clap(long)] + underlay_only: bool, + }, +} + +#[derive(Debug, Args)] +pub struct AddArgs { + /// External group address (IPv4, or IPv6 outside ff04::/64). + group_ip: ExternalMulticastIp, + /// Underlay group address, within the internal ff04::/64 block. + #[clap(short = 'i', long)] + underlay: UnderlayMulticastIpv6, + /// Member described as `port/link:direction`, where direction is underlay + /// or external. + /// + /// Repeat for each member. + #[clap(long = "member")] + members: Vec, + /// Inner MAC address for the NAT target. + #[clap(short = 'm', long = "mac")] + inner_mac: MacAddr, + /// Geneve VNI for the NAT target. + #[clap(short = 'v', long)] + vni: Vni, + /// VLAN ID to tag forwarded packets with. + #[clap(long)] + vlan: Option, + /// Source address to filter on. + /// + /// Repeat for each source. Each source must match the group's + /// address family. SSM requires at least one source; omitting this option + /// accepts any source for ASM. + #[clap(short = 's', long = "source")] + sources: Vec, + /// Tag to create both groups with; defaults to the client tag. + #[clap(short = 't', long)] + tag: Option, +} + +/// A group member parsed from `port/link:direction`. +#[derive(Debug, Clone)] +pub struct MemberArg { + link: LinkPath, + direction: types::Direction, +} + +impl FromStr for MemberArg { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let (link, direction) = s.split_once(':').with_context(|| { + format!("expected port/link:direction, got {s}") + })?; + let direction = match direction { + "underlay" => Some(types::Direction::Underlay), + "external" => Some(types::Direction::External), + _ => None, + } + .with_context(|| { + format!( + "invalid direction {direction}: expected underlay or external" + ) + })?; + Ok(Self { link: link.parse()?, direction }) + } } struct DirectionLabel<'a>(&'a types::Direction); @@ -167,8 +276,7 @@ async fn multicast_list( ) -> anyhow::Result<()> { let tag = tag .map(|tag| { - tag.parse::() - .map_err(|e| anyhow::anyhow!("invalid multicast tag: {e}")) + tag.parse::().context("invalid multicast tag") }) .transpose()?; let mut groups = match &tag { @@ -182,12 +290,12 @@ async fn multicast_list( writeln!( &mut tw, "{}\t{}\t{}\t{}\t{}\t{}", - "GROUP IP".underline(), - "KIND".underline(), - "EXT GROUP ID".underline(), - "UL GROUP ID".underline(), - "TAG".underline(), - "DETAIL".underline(), + "Group IP".underline(), + "Kind".underline(), + "Ext Group ID".underline(), + "UL Group ID".underline(), + "Tag".underline(), + "Detail".underline(), )?; while let Some(group) = @@ -249,6 +357,190 @@ async fn multicast_list( Ok(()) } +/// Create both an underlay group and its associated external group with a +/// shared tag. +/// +/// If external creation fails, attempt to remove the underlay group. +/// +/// This preserves the initial creation error and includes any cleanup failure. +async fn multicast_add(client: &Client, args: AddArgs) -> anyhow::Result<()> { + let AddArgs { + group_ip, + underlay, + members, + inner_mac, + vni, + vlan, + sources, + tag, + } = args; + let tag = tag.unwrap_or_else(|| client.inner().tag.clone()); + let underlay_entry = types::MulticastGroupCreateUnderlayEntry { + group_ip: underlay, + tag: Some(tag), + members: members + .into_iter() + .map(|m| types::MulticastGroupMember { + port_id: m.link.port_id, + link_id: m.link.link_id, + direction: m.direction, + }) + .collect(), + }; + + let tag = client + .multicast_group_create_underlay(&underlay_entry) + .await + .with_context(|| { + format!("failed to create underlay multicast group {underlay}") + })? + .into_inner() + .tag; + + let external_entry = types::MulticastGroupCreateExternalEntry { + group_ip, + tag: Some(tag.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: underlay, + inner_mac: inner_mac.into(), + vni: types::Vni::from(vni), + }, + }, + external_forwarding: types::ExternalForwarding { vlan_id: vlan }, + sources: (!sources.is_empty()) + .then(|| sources.into_iter().map(types::IpSrc::Exact).collect()), + }; + + let created = client + .multicast_group_create_external(&external_entry) + .await + .with_context(|| { + format!("failed to create external multicast group {group_ip}") + }); + + if let Err(err) = created { + let underlay_ip = IpAddr::from(underlay); + let rollback = async { + let tag: types::MulticastTag = + tag.parse().with_context(|| format!("invalid tag {tag}"))?; + client + .multicast_group_delete(&underlay_ip, &tag) + .await + .with_context(|| { + format!("failed to remove underlay group {underlay}") + }) + }; + + return match rollback.await { + Ok(_) => Err(err), + Err(rollback_err) => Err(err.context(format!("{rollback_err:#}"))), + }; + } + Ok(()) +} + +fn underlay_for_delete( + group_ip: IpAddr, + group: types::MulticastGroupResponse, + tag: &str, + underlay_only: bool, +) -> anyhow::Result> { + match group { + types::MulticastGroupResponse::External { + internal_forwarding, + tag: group_tag, + .. + } => { + anyhow::ensure!( + !underlay_only, + "cannot use --underlay-only with external multicast group {group_ip}" + ); + + anyhow::ensure!( + group_tag == tag, + "tag mismatch for multicast group {group_ip}" + ); + + Ok(Some(IpAddr::from(internal_forwarding.nat_target.internal_ip))) + } + types::MulticastGroupResponse::Underlay { .. } => { + anyhow::ensure!( + underlay_only, + "{group_ip} is an underlay group; delete the external group that \ + maps to it" + ); + Ok(None) + } + } +} + +fn validate_underlay_for_delete( + underlay: IpAddr, + group: types::MulticastGroupResponse, + tag: &str, +) -> anyhow::Result<()> { + let underlay_tag = match group { + types::MulticastGroupResponse::Underlay { tag, .. } => tag, + types::MulticastGroupResponse::External { .. } => { + anyhow::bail!( + "multicast group {underlay} is not an underlay group" + ); + } + }; + + anyhow::ensure!( + underlay_tag == tag, + "tag mismatch for underlay multicast group {underlay}" + ); + Ok(()) +} + +async fn multicast_del( + client: &Client, + group_ip: IpAddr, + tag: String, + underlay_only: bool, +) -> anyhow::Result<()> { + let tag_param: types::MulticastTag = + tag.parse().with_context(|| format!("invalid tag {tag}"))?; + + let group = client + .multicast_group_get(&group_ip) + .await + .with_context(|| format!("failed to get multicast group {group_ip}"))? + .into_inner(); + let underlay = underlay_for_delete(group_ip, group, &tag, underlay_only)?; + + if let Some(underlay) = underlay { + let underlay_group = client + .multicast_group_get(&underlay) + .await + .with_context(|| { + format!("failed to get underlay multicast group {underlay}") + })? + .into_inner(); + validate_underlay_for_delete(underlay, underlay_group, &tag)?; + } + + client.multicast_group_delete(&group_ip, &tag_param).await.with_context( + || format!("failed to delete multicast group {group_ip}"), + )?; + + if let Some(underlay) = underlay { + client + .multicast_group_delete(&underlay, &tag_param) + .await + .with_context(|| { + format!( + "failed to delete underlay group {underlay}; use \ + --underlay-only to retry its deletion" + ) + })?; + } + Ok(()) +} + async fn multicast_get( client: &Client, group_ip: IpAddr, @@ -259,6 +551,7 @@ async fn multicast_get( .with_context(|| format!("failed to get multicast group {group_ip}"))? .into_inner(); + let mut tw = TabWriter::new(stdout()); match group { types::MulticastGroupResponse::Underlay { group_ip, @@ -267,22 +560,23 @@ async fn multicast_get( tag, members, } => { - println!("Group IP: {group_ip}"); - println!("Kind: underlay"); - println!("External group ID: {external_group_id}"); - println!("Underlay group ID: {underlay_group_id}"); - println!("Tag: {tag}"); - println!("Members:"); + writeln!(&mut tw, "Group IP:\t{group_ip}")?; + writeln!(&mut tw, "Kind:\tunderlay")?; + writeln!(&mut tw, "External group ID:\t{external_group_id}")?; + writeln!(&mut tw, "Underlay group ID:\t{underlay_group_id}")?; + writeln!(&mut tw, "Tag:\t{tag}")?; + writeln!(&mut tw, "Members:")?; if members.is_empty() { - println!(" (none)"); + writeln!(&mut tw, " (none)")?; } for member in &members { - println!( + writeln!( + &mut tw, " {}/{}({})", member.port_id, *member.link_id, DirectionLabel(&member.direction), - ); + )?; } } types::MulticastGroupResponse::External { @@ -293,26 +587,29 @@ async fn multicast_get( external_forwarding, sources, } => { - println!("Group IP: {group_ip}"); - println!("Kind: external"); - println!("External group ID: {external_group_id}"); - println!("Tag: {tag}"); + writeln!(&mut tw, "Group IP:\t{group_ip}")?; + writeln!(&mut tw, "Kind:\texternal")?; + writeln!(&mut tw, "External group ID:\t{external_group_id}")?; + writeln!(&mut tw, "Tag:\t{tag}")?; let nat_target = &internal_forwarding.nat_target; - println!( - "NAT target: {} (mac {}, vni {})", + writeln!( + &mut tw, + "NAT target:\t{} (mac {}, vni {})", nat_target.internal_ip, nat_target.inner_mac, *nat_target.vni, - ); + )?; match external_forwarding.vlan_id { - Some(v) => println!("VLAN: {v}"), - None => println!("VLAN: (none)"), + Some(v) => writeln!(&mut tw, "VLAN:\t{v}")?, + None => writeln!(&mut tw, "VLAN:\t(none)")?, } - println!( - "Sources: {}", + writeln!( + &mut tw, + "Sources:\t{}", SourcesSummary(sources.as_deref()) - ); + )?; } } + tw.flush()?; Ok(()) } @@ -325,5 +622,154 @@ pub async fn multicast_cmd( multicast_list(client, tag, kind).await } Multicast::Get { group_ip } => multicast_get(client, group_ip).await, + Multicast::Add(args) => multicast_add(client, args).await, + Multicast::Del { group_ip, tag, underlay_only } => { + let tag = tag.unwrap_or_else(|| client.inner().tag.clone()); + multicast_del(client, group_ip, tag, underlay_only).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn external_group( + tag: &str, + ) -> anyhow::Result { + Ok(types::MulticastGroupResponse::External { + group_ip: "232.123.45.99".parse()?, + external_group_id: 65534_u16, + tag: tag.to_string(), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: "ff04::1".parse()?, + inner_mac: "11:22:33:44:55:66".parse::()?.into(), + vni: types::Vni::from(77_u32), + }, + }, + external_forwarding: types::ExternalForwarding { + vlan_id: Some(10), + }, + sources: Some(vec![types::IpSrc::Exact("10.0.0.1".parse()?)]), + }) + } + + fn underlay_group( + tag: &str, + ) -> anyhow::Result { + Ok(types::MulticastGroupResponse::Underlay { + group_ip: "ff04::1".parse()?, + external_group_id: 65534_u16, + underlay_group_id: 65533_u16, + tag: tag.to_string(), + members: vec![], + }) + } + + #[test] + fn delete_external_requires_matching_tag() -> anyhow::Result<()> { + let group_ip = "232.123.45.99".parse()?; + underlay_for_delete( + group_ip, + external_group("other_tag")?, + "test_tag", + false, + ) + .expect_err("a mismatched external tag should prevent deletion"); + Ok(()) + } + + #[test] + fn delete_underlay_only_rejects_external() -> anyhow::Result<()> { + let group_ip = "232.123.45.99".parse()?; + underlay_for_delete( + group_ip, + external_group("test_tag")?, + "test_tag", + true, + ) + .expect_err("--underlay-only should reject external groups"); + Ok(()) + } + + #[test] + fn delete_underlay_requires_underlay_only() -> anyhow::Result<()> { + let group_ip = "ff04::1".parse()?; + underlay_for_delete( + group_ip, + underlay_group("test_tag")?, + "test_tag", + false, + ) + .expect_err("underlay deletion should require --underlay-only"); + Ok(()) + } + + #[test] + fn delete_underlay_only_skips_pair_deletion() -> anyhow::Result<()> { + let group_ip = "ff04::1".parse()?; + assert_eq!( + underlay_for_delete( + group_ip, + underlay_group("test_tag")?, + "test_tag", + true, + )?, + None + ); + Ok(()) + } + + #[test] + fn delete_pair_validates_underlay() -> anyhow::Result<()> { + let group_ip = "232.123.45.99".parse()?; + let underlay = "ff04::1".parse()?; + assert_eq!( + underlay_for_delete( + group_ip, + external_group("test_tag")?, + "test_tag", + false, + )?, + Some(underlay) + ); + validate_underlay_for_delete( + underlay, + underlay_group("test_tag")?, + "test_tag", + )?; + + validate_underlay_for_delete( + underlay, + underlay_group("other_tag")?, + "test_tag", + ) + .expect_err("a mismatched underlay tag should prevent deletion"); + + validate_underlay_for_delete( + underlay, + external_group("test_tag")?, + "test_tag", + ) + .expect_err("pair deletion should require an underlay group"); + Ok(()) + } + + #[test] + fn member_arg_parses_direction() -> anyhow::Result<()> { + let member: MemberArg = "int0/0:external".parse()?; + assert!(matches!(member.direction, types::Direction::External)); + let member: MemberArg = "int0/0:underlay".parse()?; + assert!(matches!(member.direction, types::Direction::Underlay)); + Ok(()) + } + + #[test] + fn member_arg_rejects_bad_input() { + "int0/0".parse::().expect_err("a member needs a direction"); + "int0/0:sideways" + .parse::() + .expect_err("direction must be underlay or external"); } } diff --git a/swadm/tests/cli/cmd.rs b/swadm/tests/cli/cmd.rs index 20d60e80..e83ad2cd 100644 --- a/swadm/tests/cli/cmd.rs +++ b/swadm/tests/cli/cmd.rs @@ -72,7 +72,16 @@ pub fn swadm_exact( .map(Cow::from) .unwrap_or_else(|_| common::DEFAULT_DPD_PORT.to_string().into()); + // CI exports RUST_BACKTRACE=1, which forces anyhow to append a "Stack + // backtrace:" section to every error swadm prints, and stderr would + // differ from a local run and never match an expectation file. + // + // We still keep panic backtraces but turn off the library-level ones, + // just like omdb's test harness does: + // https://github.com/oxidecomputer/omicron/blob/654472b6368b48743f34c0744e2acfb06278f796/dev-tools/omdb/tests/test_all_output.rs#L961-L969 let output = Command::new(self::SWADM) + .env("RUST_BACKTRACE", "1") + .env("RUST_LIB_BACKTRACE", "0") .args(["--host", host.as_ref(), "--port", port.as_ref()]) .args(input) .output()?; @@ -139,6 +148,11 @@ pub mod re { /// support nested parentheses. pub static PARENS: LazyLock = LazyLock::new(|| Regex::new(r"\([^)]*\)").unwrap().into()); + + /// A run of three or more digits between spaces or at a line's end, + /// i.e. allocated multicast group IDs in the `swadm multicast` output. + pub static GROUP_ID: LazyLock = + LazyLock::new(|| Regex::new(r"(?m) +\d{3,}( |$)").unwrap().into()); } /// A wrapper type for regex inputs that can be @@ -302,5 +316,6 @@ mod test { #[test] fn pattern_statics() { let _ = &*re::PARENS; + let _ = &*re::GROUP_ID; } } diff --git a/swadm/tests/cli/expect/multicast_add_duplicate.txt b/swadm/tests/cli/expect/multicast_add_duplicate.txt new file mode 100644 index 00000000..9495637b --- /dev/null +++ b/swadm/tests/cli/expect/multicast_add_duplicate.txt @@ -0,0 +1,4 @@ +Error: failed to create external multicast group 232.123.45.99 + +Caused by: + Error Response: status: 409 Conflict; \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_add_underlay_group.txt b/swadm/tests/cli/expect/multicast_add_underlay_group.txt new file mode 100644 index 00000000..753ac6c7 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_add_underlay_group.txt @@ -0,0 +1 @@ +error: invalid value 'ff04::5ad:1' for ' \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt b/swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt new file mode 100644 index 00000000..1236feab --- /dev/null +++ b/swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt @@ -0,0 +1 @@ +Error: tag mismatch for multicast group 232.123.45.99 diff --git a/swadm/tests/cli/expect/multicast_del_underlay.txt b/swadm/tests/cli/expect/multicast_del_underlay.txt new file mode 100644 index 00000000..f1e71924 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_del_underlay.txt @@ -0,0 +1 @@ +Error: ff04::5ad:1 is an underlay group; delete the external group that maps to it diff --git a/swadm/tests/cli/expect/multicast_del_underlay_only_external.txt b/swadm/tests/cli/expect/multicast_del_underlay_only_external.txt new file mode 100644 index 00000000..afbb5015 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_del_underlay_only_external.txt @@ -0,0 +1 @@ +Error: cannot use --underlay-only with external multicast group 232.123.45.99 diff --git a/swadm/tests/cli/expect/multicast_del_underlay_referenced.txt b/swadm/tests/cli/expect/multicast_del_underlay_referenced.txt new file mode 100644 index 00000000..8413ab19 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_del_underlay_referenced.txt @@ -0,0 +1,4 @@ +Error: failed to delete multicast group ff04::5ad:1 + +Caused by: + Error Response: status: 400 Bad Request; \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_get_default_tag.txt b/swadm/tests/cli/expect/multicast_get_default_tag.txt new file mode 100644 index 00000000..aebc05e9 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_default_tag.txt @@ -0,0 +1,7 @@ +Group IP: 224.0.1.51 +Kind: external +External group ID: +Tag: cli +NAT target: ff04::5ad:4 (mac 11:22:33:44:55:66, vni 77) +VLAN: (none) +Sources: any diff --git a/swadm/tests/cli/expect/multicast_get_external.txt b/swadm/tests/cli/expect/multicast_get_external.txt new file mode 100644 index 00000000..755eff5b --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_external.txt @@ -0,0 +1,7 @@ +Group IP: 232.123.45.99 +Kind: external +External group ID: +Tag: swadm_multicast_test +NAT target: ff04::5ad:1 (mac 11:22:33:44:55:66, vni 77) +VLAN: 10 +Sources: 10.0.0.1,10.0.0.2 diff --git a/swadm/tests/cli/expect/multicast_get_external_asm.txt b/swadm/tests/cli/expect/multicast_get_external_asm.txt new file mode 100644 index 00000000..e629c28d --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_external_asm.txt @@ -0,0 +1,7 @@ +Group IP: 224.0.1.50 +Kind: external +External group ID: +Tag: swadm_multicast_test +NAT target: ff04::5ad:2 (mac 11:22:33:44:55:66, vni 77) +VLAN: (none) +Sources: any diff --git a/swadm/tests/cli/expect/multicast_get_not_found.txt b/swadm/tests/cli/expect/multicast_get_not_found.txt new file mode 100644 index 00000000..7137e6c2 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_not_found.txt @@ -0,0 +1,4 @@ +Error: failed to get multicast group 232.123.45.99 + +Caused by: + Error Response: status: 404 Not Found; \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_get_not_found_spare.txt b/swadm/tests/cli/expect/multicast_get_not_found_spare.txt new file mode 100644 index 00000000..70de4711 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_not_found_spare.txt @@ -0,0 +1,4 @@ +Error: failed to get multicast group ff04::5ad:3 + +Caused by: + Error Response: status: 404 Not Found; \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_get_not_found_underlay.txt b/swadm/tests/cli/expect/multicast_get_not_found_underlay.txt new file mode 100644 index 00000000..c5c2d7f3 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_not_found_underlay.txt @@ -0,0 +1,4 @@ +Error: failed to get multicast group ff04::5ad:1 + +Caused by: + Error Response: status: 404 Not Found; \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_get_underlay.txt b/swadm/tests/cli/expect/multicast_get_underlay.txt new file mode 100644 index 00000000..513fe3dc --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_underlay.txt @@ -0,0 +1,8 @@ +Group IP: ff04::5ad:1 +Kind: underlay +External group ID: +Underlay group ID: +Tag: swadm_multicast_test +Members: + int0/0(underlay) + int0/0(external) diff --git a/swadm/tests/cli/expect/multicast_get_underlay_empty.txt b/swadm/tests/cli/expect/multicast_get_underlay_empty.txt new file mode 100644 index 00000000..490d3542 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_underlay_empty.txt @@ -0,0 +1,7 @@ +Group IP: ff04::5ad:2 +Kind: underlay +External group ID: +Underlay group ID: +Tag: swadm_multicast_test +Members: + (none) diff --git a/swadm/tests/cli/expect/multicast_list.txt b/swadm/tests/cli/expect/multicast_list.txt new file mode 100644 index 00000000..26044018 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_list.txt @@ -0,0 +1,5 @@ +Group IP Kind Ext Group ID UL Group ID Tag Detail +224.0.1.50 external - swadm_multicast_test nat=ff04::5ad:2 mac=11:22:33:44:55:66 vni=77 vlan=- src=any +232.123.45.99 external - swadm_multicast_test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 +ff04::5ad:1 underlay swadm_multicast_test int0/0(underlay) int0/0(external) +ff04::5ad:2 underlay swadm_multicast_test - diff --git a/swadm/tests/cli/expect/multicast_list_tag_external.txt b/swadm/tests/cli/expect/multicast_list_tag_external.txt new file mode 100644 index 00000000..f4bb8b7d --- /dev/null +++ b/swadm/tests/cli/expect/multicast_list_tag_external.txt @@ -0,0 +1,3 @@ +Group IP Kind Ext Group ID UL Group ID Tag Detail +224.0.1.50 external - swadm_multicast_test nat=ff04::5ad:2 mac=11:22:33:44:55:66 vni=77 vlan=- src=any +232.123.45.99 external - swadm_multicast_test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 diff --git a/swadm/tests/cli/expect/multicast_list_tag_none.txt b/swadm/tests/cli/expect/multicast_list_tag_none.txt new file mode 100644 index 00000000..43f89a66 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_list_tag_none.txt @@ -0,0 +1 @@ +Group IP Kind Ext Group ID UL Group ID Tag Detail diff --git a/swadm/tests/cli/expect/multicast_list_underlay.txt b/swadm/tests/cli/expect/multicast_list_underlay.txt new file mode 100644 index 00000000..62a52aca --- /dev/null +++ b/swadm/tests/cli/expect/multicast_list_underlay.txt @@ -0,0 +1,3 @@ +Group IP Kind Ext Group ID UL Group ID Tag Detail +ff04::5ad:1 underlay swadm_multicast_test int0/0(underlay) int0/0(external) +ff04::5ad:2 underlay swadm_multicast_test - diff --git a/swadm/tests/cli/main.rs b/swadm/tests/cli/main.rs index 722a07a6..7dbcf9f4 100644 --- a/swadm/tests/cli/main.rs +++ b/swadm/tests/cli/main.rs @@ -7,4 +7,5 @@ mod cmd; mod counters; mod link_apply; +mod multicast; mod tx_eq; diff --git a/swadm/tests/cli/multicast.rs b/swadm/tests/cli/multicast.rs new file mode 100644 index 00000000..5b5a5774 --- /dev/null +++ b/swadm/tests/cli/multicast.rs @@ -0,0 +1,322 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/ +// +// Copyright 2026 Oxide Computer Company + +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; + +use anyhow::Context; +use serial_test::serial; + +use crate::cmd; + +const TEST_TAG: &str = "swadm_multicast_test"; + +const EXT_IPV4: Ipv4Addr = Ipv4Addr::new(232, 123, 45, 99); +const EXT_IPV4_ASM: Ipv4Addr = Ipv4Addr::new(224, 0, 1, 50); +const EXT_SOURCE: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); +const EXT_SOURCE_2: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2); +const EXT_IPV4_DEFAULT_TAG: Ipv4Addr = Ipv4Addr::new(224, 0, 1, 51); +const EXT_VLAN: u16 = 10; +const EXT_VNI: u32 = 77; + +const UNDERLAY_IPV6: Ipv6Addr = Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0x5ad, 1); +const UNDERLAY_IPV6_EMPTY: Ipv6Addr = + Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0x5ad, 2); +const UNDERLAY_IPV6_SPARE: Ipv6Addr = + Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0x5ad, 3); +const UNDERLAY_IPV6_DEFAULT_TAG: Ipv6Addr = + Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0x5ad, 4); + +const MEMBER_LINK: &str = "int0/0"; +const INNER_MAC: &str = "11:22:33:44:55:66"; + +/// Delete this test's set of groups, and then (re-)create them with +/// `swadm multicast add`. +/// +/// Members here keep to a fixed link so that the rendered +/// `port/link(direction)` remains stable across runs. Each external group +/// targets its own underlay group because dpd enforces a 1:1 mapping between +/// an external group and its underlay NAT target. +fn create_groups() -> anyhow::Result<()> { + delete_groups(); + + cmd::swadm(format!("link get {MEMBER_LINK}")) + .with_context(|| format!("member link {MEMBER_LINK} is missing"))?; + + for args in [ + format!( + "multicast add {EXT_IPV4} -i {UNDERLAY_IPV6} \ + --member {MEMBER_LINK}:underlay --member {MEMBER_LINK}:external \ + -m {INNER_MAC} -v {EXT_VNI} --vlan {EXT_VLAN} -s {EXT_SOURCE} \ + -s {EXT_SOURCE_2} -t {TEST_TAG}" + ), + format!( + "multicast add {EXT_IPV4_ASM} --underlay {UNDERLAY_IPV6_EMPTY} \ + --mac {INNER_MAC} --vni {EXT_VNI} --tag {TEST_TAG}" + ), + ] { + cmd::swadm(&args).with_context(|| format!("swadm {args}"))?; + } + + Ok(()) +} + +fn delete_groups() { + for group in [EXT_IPV4, EXT_IPV4_ASM] { + let _ = cmd::swadm(format!("multicast del {group} -t {TEST_TAG}")); + } + let _ = cmd::swadm(format!("multicast del {EXT_IPV4_DEFAULT_TAG}")); + let _ = cmd::swadm(format!( + "multicast del {UNDERLAY_IPV6_DEFAULT_TAG} --underlay-only" + )); + for group in [UNDERLAY_IPV6, UNDERLAY_IPV6_EMPTY, UNDERLAY_IPV6_SPARE] { + let _ = cmd::swadm(format!( + "multicast del {group} --underlay-only -t {TEST_TAG}" + )); + } +} + +#[test] +#[serial] +#[ignore] +fn multicast_list() -> anyhow::Result<()> { + create_groups()?; + + for (args, file) in [ + (format!("multicast list -t {TEST_TAG}"), "multicast_list.txt"), + ( + format!("multicast list -t {TEST_TAG} --kind underlay"), + "multicast_list_underlay.txt", + ), + ( + format!("multicast list -t {TEST_TAG} -k external"), + "multicast_list_tag_external.txt", + ), + ( + "multicast ls -t no_such_tag".to_string(), + "multicast_list_tag_none.txt", + ), + ] { + cmd::swadm(args)?.strip(&*cmd::re::GROUP_ID).try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_get() -> anyhow::Result<()> { + create_groups()?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_external.txt"), + (IpAddr::V4(EXT_IPV4_ASM), "multicast_get_external_asm.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), + (IpAddr::V6(UNDERLAY_IPV6_EMPTY), "multicast_get_underlay_empty.txt"), + ] { + cmd::swadm(format!("multicast get {group}"))? + .strip(&*cmd::re::GROUP_ID) + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_get_not_found() -> anyhow::Result<()> { + delete_groups(); + + let err = cmd::swadm(format!("multicast get {EXT_IPV4}")) + .expect_err("get on a missing group should fail"); + + cmd::Output::try_from(err)? + .trunc_at("headers:") + .try_expectorate("multicast_get_not_found.txt") +} + +#[test] +#[serial] +#[ignore] +fn multicast_del_removes_pair() -> anyhow::Result<()> { + create_groups()?; + + cmd::swadm(format!("multicast del {EXT_IPV4} -t {TEST_TAG}"))?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_not_found.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_not_found_underlay.txt"), + ] { + let err = cmd::swadm(format!("multicast get {group}")) + .expect_err("deleted group should be gone"); + cmd::Output::try_from(err)? + .trunc_at("headers:") + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_del_rejects_external_tag_mismatch() -> anyhow::Result<()> { + create_groups()?; + + let err = cmd::swadm(format!("multicast del {EXT_IPV4} -t other_tag")) + .expect_err("a mismatched external tag should prevent deletion"); + cmd::Output::try_from(err)? + .try_expectorate("multicast_del_external_tag_mismatch.txt")?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_external.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), + ] { + cmd::swadm(format!("multicast get {group}"))? + .strip(&*cmd::re::GROUP_ID) + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_del_rejects_underlay_only_external() -> anyhow::Result<()> { + create_groups()?; + + let err = cmd::swadm(format!( + "multicast del {EXT_IPV4} --underlay-only -t {TEST_TAG}" + )) + .expect_err("--underlay-only should reject external groups"); + cmd::Output::try_from(err)? + .try_expectorate("multicast_del_underlay_only_external.txt")?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_external.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), + ] { + cmd::swadm(format!("multicast get {group}"))? + .strip(&*cmd::re::GROUP_ID) + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_del_rejects_underlay() -> anyhow::Result<()> { + create_groups()?; + + let err = + cmd::swadm(format!("multicast del {UNDERLAY_IPV6} -t {TEST_TAG}")) + .expect_err("del on an underlay group should fail"); + cmd::Output::try_from(err)? + .try_expectorate("multicast_del_underlay.txt")?; + + let err = cmd::swadm(format!( + "multicast del {UNDERLAY_IPV6} --underlay-only -t {TEST_TAG}" + )) + .expect_err("a referenced underlay group should not be deleted"); + cmd::Output::try_from(err)? + .trunc_at("headers:") + .try_expectorate("multicast_del_underlay_referenced.txt")?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_external.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), + ] { + cmd::swadm(format!("multicast get {group}"))? + .strip(&*cmd::re::GROUP_ID) + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_add_rolls_back_underlay() -> anyhow::Result<()> { + create_groups()?; + + let err = cmd::swadm(format!( + "multicast add {EXT_IPV4} -i {UNDERLAY_IPV6_SPARE} \ + -m {INNER_MAC} -v {EXT_VNI} -s {EXT_SOURCE} -t {TEST_TAG}" + )) + .expect_err("add on an existing external group should fail"); + cmd::Output::try_from(err)? + .trunc_at("headers:") + .try_expectorate("multicast_add_duplicate.txt")?; + + let err = cmd::swadm(format!("multicast get {UNDERLAY_IPV6_SPARE}")) + .expect_err("rolled-back underlay group should be gone"); + cmd::Output::try_from(err)? + .trunc_at("headers:") + .try_expectorate("multicast_get_not_found_spare.txt")?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_external.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), + ] { + cmd::swadm(format!("multicast get {group}"))? + .strip(&*cmd::re::GROUP_ID) + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +fn multicast_add_rejects_underlay_group() -> anyhow::Result<()> { + let err = cmd::swadm(format!( + "multicast add {UNDERLAY_IPV6} -i {UNDERLAY_IPV6_EMPTY} \ + -m {INNER_MAC} -v {EXT_VNI}" + )) + .expect_err("an underlay address is not a valid external group"); + cmd::Output::try_from(err)? + .trunc_at("': ") + .try_expectorate("multicast_add_underlay_group.txt") +} + +#[test] +#[serial] +#[ignore] +fn multicast_add_del_default_tag() -> anyhow::Result<()> { + delete_groups(); + + cmd::swadm(format!( + "multicast add {EXT_IPV4_DEFAULT_TAG} -i {UNDERLAY_IPV6_DEFAULT_TAG} \ + -m {INNER_MAC} -v {EXT_VNI}" + ))?; + cmd::swadm(format!("multicast get {EXT_IPV4_DEFAULT_TAG}"))? + .strip(&*cmd::re::GROUP_ID) + .try_expectorate("multicast_get_default_tag.txt")?; + + cmd::swadm(format!("multicast del {EXT_IPV4_DEFAULT_TAG}"))?; + for group in [ + IpAddr::V4(EXT_IPV4_DEFAULT_TAG), + IpAddr::V6(UNDERLAY_IPV6_DEFAULT_TAG), + ] { + cmd::swadm(format!("multicast get {group}")) + .expect_err("del without -t should remove both groups"); + } + + Ok(()) +} diff --git a/swadm/tests/multicast.rs b/swadm/tests/multicast.rs deleted file mode 100644 index b4b01535..00000000 --- a/swadm/tests/multicast.rs +++ /dev/null @@ -1,331 +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 - -//! Integration test for the `swadm multicast` inspection subcommand. -//! -//! Seeds groups via `dpd-client`, then asserts `multicast list` / `multicast -//! get` render them correctly. Needs a running dpd, so it is `#[ignore]`d. - -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::process::Command; - -use common::network::MacAddr; -use dpd_client::{Client, ClientState, default_port, types}; - -// Path to the `swadm` executable. -const SWADM: &str = env!("CARGO_BIN_EXE_swadm"); -const HOST: &str = "[::1]"; -const TEST_TAG: &str = "swadm_multicast_test"; - -// External IPv4 group. -// -// External groups carry no members, only a NAT target. -// An SSM address (232.0.0.0/8) is used so the source filter is well-formed. -const EXT_IPV4: Ipv4Addr = Ipv4Addr::new(232, 123, 45, 99); -const EXT_VLAN: u16 = 10; -const EXT_SOURCE: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); -// NAT target VNI (matches omicron's `DEFAULT_MULTICAST_VNI`). -const EXT_VNI: u32 = 77; -// A second external group on an ASM address (224.0.0.0/4) with no VLAN or -// source filter. -const EXT_IPV4_ASM: Ipv4Addr = Ipv4Addr::new(224, 0, 1, 50); - -// Admin-local underlay group (ff04::/64) and the NAT target the external group -// forwards to. -const UNDERLAY_IPV6: Ipv6Addr = Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1); -const NAT_IP: Ipv6Addr = UNDERLAY_IPV6; -// A second underlay group with no members just to test the empty-member -// display branches ("-" in the list, "(none)" in get). -const UNDERLAY_IPV6_EMPTY: Ipv6Addr = - Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 2); - -fn swadm() -> Command { - let mut cmd = Command::new(SWADM); - cmd.arg("--host").arg(HOST); - cmd -} - -fn client() -> Client { - let log = slog::Logger::root(slog::Discard, slog::o!()); - let state = ClientState { tag: String::from("swadm-mcast-test"), log }; - Client::new(&format!("http://{HOST}:{}", default_port()), state) -} - -/// Run `swadm` with the given args and checks. -fn run_ok(args: &[&str]) -> String { - let output = swadm() - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to execute swadm {args:?}: {e}")); - assert!( - output.status.success(), - "swadm {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr), - ); - String::from_utf8_lossy(&output.stdout).into_owned() -} - -/// Collect the KIND column of every data row in a `multicast list` rendering, -/// skipping the header. -fn group_kinds(list: &str) -> Vec { - list.lines() - .skip(1) - .filter(|line| !line.trim().is_empty()) - .filter_map(|line| line.split_whitespace().nth(1)) - .map(str::to_string) - .collect() -} - -#[tokio::test] -#[ignore] -async fn test_multicast_list_and_get() { - let client = client(); - - client.multicast_reset().await.expect("failed to reset multicast groups"); - - // Seed a bifurcated underlay group with one underlay-replication member - // and one external-replication member, both on the first available link. - // - // Both direction labels are exercised in the rendered output (in one go). - let links = client.link_list_all(None).await.expect("failed to list links"); - let link = links - .into_inner() - .into_iter() - .next() - .expect("no links available to seed an underlay member"); - let member_path = format!("{}/{}", link.port_id, *link.link_id); - let underlay_member = types::MulticastGroupMember { - port_id: link.port_id.clone(), - link_id: link.link_id, - direction: types::Direction::Underlay, - }; - let external_member = types::MulticastGroupMember { - port_id: link.port_id.clone(), - link_id: link.link_id, - direction: types::Direction::External, - }; - - client - .multicast_group_create_underlay( - &types::MulticastGroupCreateUnderlayEntry { - group_ip: types::UnderlayMulticastIpv6(UNDERLAY_IPV6), - tag: Some(TEST_TAG.to_string()), - members: vec![underlay_member, external_member], - }, - ) - .await - .expect("failed to create underlay group"); - - // Seed a second underlay group with no members. - client - .multicast_group_create_underlay( - &types::MulticastGroupCreateUnderlayEntry { - group_ip: types::UnderlayMulticastIpv6(UNDERLAY_IPV6_EMPTY), - tag: Some(TEST_TAG.to_string()), - members: vec![], - }, - ) - .await - .expect("failed to create empty underlay group"); - - // Seed an external IPv4 group that NATs to the underlay group: VLAN-tagged - // on egress and filtered on a single source. - client - .multicast_group_create_external( - &types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4(EXT_IPV4), - tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(types::NatTarget { - internal_ip: NAT_IP, - inner_mac: MacAddr::new( - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, - ) - .into(), - vni: EXT_VNI.into(), - }), - }, - external_forwarding: types::ExternalForwarding { - vlan_id: Some(EXT_VLAN), - }, - sources: Some(vec![types::IpSrc::Exact(IpAddr::V4( - EXT_SOURCE, - ))]), - }, - ) - .await - .expect("failed to create external group"); - - // Seed a second external group on an ASM address with no VLAN or source - // filter. External groups always require a NAT target, so this one keeps - // the NAT target but drops the VLAN and sources. - client - .multicast_group_create_external( - &types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4(EXT_IPV4_ASM), - tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(types::NatTarget { - internal_ip: NAT_IP, - inner_mac: MacAddr::new( - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, - ) - .into(), - vni: EXT_VNI.into(), - }), - }, - external_forwarding: types::ExternalForwarding { - vlan_id: None, - }, - sources: None, - }, - ) - .await - .expect("failed to create ASM external group"); - - let list = run_ok(&["multicast", "list"]); - for header in - ["GROUP IP", "KIND", "EXT GROUP ID", "UL GROUP ID", "TAG", "DETAIL"] - { - assert!(list.contains(header), "list missing {header} header:\n{list}"); - } - assert!( - list.contains(&UNDERLAY_IPV6.to_string()) && list.contains("underlay"), - "list missing underlay group:\n{list}" - ); - // Both member directions render as `port/link(dir)` in the DETAIL column. - assert!( - list.contains(&format!("{member_path}(underlay)")) - && list.contains(&format!("{member_path}(external)")), - "list missing bifurcated members {member_path}:\n{list}" - ); - // The member-less underlay group renders "-" for its (empty) DETAIL. - let empty_row = list - .lines() - .find(|line| line.contains(&UNDERLAY_IPV6_EMPTY.to_string())) - .unwrap_or_else(|| { - panic!("list missing empty underlay group:\n{list}") - }); - assert!( - empty_row.contains("underlay") && empty_row.trim_end().ends_with('-'), - "empty underlay row missing \"-\" detail: {empty_row:?}" - ); - assert!( - list.contains(&EXT_IPV4.to_string()) && list.contains("external"), - "list missing external group:\n{list}" - ); - // The fully-populated external group renders its NAT target, VLAN, and - // source filter. - assert!( - list.contains(&format!("nat={NAT_IP}")) - && list.contains("mac=11:22:33:44:55:66") - && list.contains(&format!("vni={EXT_VNI}")) - && list.contains("vlan=10") - && list.contains(&format!("src={EXT_SOURCE}")), - "list missing external forwarding detail:\n{list}" - ); - // An SM group with an absent-VLAN and any-source branches. - assert!( - list.contains(&EXT_IPV4_ASM.to_string()) - && list.contains("vlan=-") - && list.contains("src=any"), - "list missing ASM external empty-detail branches:\n{list}" - ); - // Tag filtering should include our groups... - let by_tag = run_ok(&["multicast", "list", "-t", TEST_TAG]); - assert!( - by_tag.contains(&EXT_IPV4.to_string()), - "tag-filtered list missing external group:\n{by_tag}" - ); - // ...and a non-matching tag should return only the header. - let other = run_ok(&["multicast", "list", "-t", "no_such_tag"]); - assert!( - !other.contains(&EXT_IPV4.to_string()) - && !other.contains(&UNDERLAY_IPV6.to_string()), - "non-matching tag returned groups:\n{other}" - ); - // Kind filtering selects one variant and excludes the other. - let external_only = run_ok(&["multicast", "list", "-k", "external"]); - let kinds = group_kinds(&external_only); - assert!( - !kinds.is_empty() && kinds.iter().all(|kind| kind == "external"), - "external-only list is not filtered:\n{external_only}" - ); - assert!( - external_only.contains(&EXT_IPV4.to_string()), - "external-only list missing external group:\n{external_only}" - ); - - let underlay_only = run_ok(&["multicast", "list", "--kind", "underlay"]); - let kinds = group_kinds(&underlay_only); - assert!( - !kinds.is_empty() && kinds.iter().all(|kind| kind == "underlay"), - "underlay-only list is not filtered:\n{underlay_only}" - ); - assert!( - underlay_only.contains(&UNDERLAY_IPV6.to_string()), - "underlay-only list missing underlay group:\n{underlay_only}" - ); - - // The two filters compose. - let both = run_ok(&["multicast", "list", "-t", TEST_TAG, "-k", "external"]); - assert!( - both.contains(&EXT_IPV4.to_string()) - && group_kinds(&both).iter().all(|kind| kind == "external"), - "tag and kind filters do not compose:\n{both}" - ); - - // GET on the external group should show its detail, including - // the aligned KV labels and the fully-populated NAT/VLAN/source values. - let ext = run_ok(&["multicast", "get", &EXT_IPV4.to_string()]); - for label in ["Group IP:", "Kind:", "NAT target:", "VLAN:", "Sources:"] { - assert!(ext.contains(label), "get external missing {label}:\n{ext}"); - } - assert!(ext.contains("external"), "get external missing kind:\n{ext}"); - assert!( - ext.contains(&NAT_IP.to_string()) && ext.contains("vni 77"), - "get external missing NAT target detail:\n{ext}" - ); - assert!(ext.contains("VLAN: 10"), "get external vlan:\n{ext}"); - assert!( - ext.contains(&EXT_SOURCE.to_string()), - "get external missing source:\n{ext}" - ); - - // GET on the ASM group should show the "(none)"/"any" branches - // for an absent VLAN and source filter. - let asm = run_ok(&["multicast", "get", &EXT_IPV4_ASM.to_string()]); - assert!( - asm.contains("VLAN: (none)"), - "get ASM missing absent VLAN:\n{asm}" - ); - assert!( - asm.contains("Sources: any"), - "get ASM missing any-source branch:\n{asm}" - ); - - // GET on the underlay group should list both members, one per - // replication direction, as `port/link(dir)`. - let underlay = run_ok(&["multicast", "get", &UNDERLAY_IPV6.to_string()]); - assert!( - underlay.contains("underlay"), - "get underlay missing kind:\n{underlay}" - ); - assert!( - underlay.contains(&format!("{member_path}(underlay)")) - && underlay.contains(&format!("{member_path}(external)")), - "get underlay missing bifurcated members {member_path}:\n{underlay}" - ); - - // GET on the member-less underlay group shows the "(none)" members branch. - let empty = run_ok(&["multicast", "get", &UNDERLAY_IPV6_EMPTY.to_string()]); - assert!( - empty.contains("Members:") && empty.contains("(none)"), - "get empty underlay missing \"(none)\" members:\n{empty}" - ); - - client.multicast_reset().await.expect("failed to reset multicast groups"); -} From 69fb56950fc3faf4f3c1df8f716bb7707d13e7ab Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Mon, 19 Oct 2026 18:24:30 +0000 Subject: [PATCH 5/5] [swadm] recovery hints on partial add/del + mask groups ids in fixtures --- swadm/src/multicast.rs | 16 ++- swadm/tests/cli/cmd.rs | 108 +++++++++++++++++- .../multicast_del_external_tag_mismatch.txt | 2 +- .../cli/expect/multicast_get_default_tag.txt | 2 +- .../cli/expect/multicast_get_external.txt | 2 +- .../cli/expect/multicast_get_external_asm.txt | 2 +- .../cli/expect/multicast_get_underlay.txt | 4 +- .../expect/multicast_get_underlay_empty.txt | 4 +- swadm/tests/cli/expect/multicast_list.txt | 8 +- .../expect/multicast_list_tag_external.txt | 4 +- .../cli/expect/multicast_list_underlay.txt | 4 +- swadm/tests/cli/multicast.rs | 14 +-- 12 files changed, 137 insertions(+), 33 deletions(-) diff --git a/swadm/src/multicast.rs b/swadm/src/multicast.rs index fa3aafd1..a17c5268 100644 --- a/swadm/src/multicast.rs +++ b/swadm/src/multicast.rs @@ -434,7 +434,11 @@ async fn multicast_add(client: &Client, args: AddArgs) -> anyhow::Result<()> { return match rollback.await { Ok(_) => Err(err), - Err(rollback_err) => Err(err.context(format!("{rollback_err:#}"))), + Err(rollback_err) => Err(anyhow::anyhow!( + "{err:#}; rollback also failed ({rollback_err:#}), leaving \ + underlay group {underlay} behind; remove it with \ + `swadm multicast del {underlay} --underlay-only -t {tag}`" + )), }; } Ok(()) @@ -459,7 +463,8 @@ fn underlay_for_delete( anyhow::ensure!( group_tag == tag, - "tag mismatch for multicast group {group_ip}" + "tag mismatch for multicast group {group_ip}: given {tag}, \ + group has {group_tag}" ); Ok(Some(IpAddr::from(internal_forwarding.nat_target.internal_ip))) @@ -491,7 +496,8 @@ fn validate_underlay_for_delete( anyhow::ensure!( underlay_tag == tag, - "tag mismatch for underlay multicast group {underlay}" + "tag mismatch for underlay multicast group {underlay}: given {tag}, \ + group has {underlay_tag}" ); Ok(()) } @@ -533,8 +539,8 @@ async fn multicast_del( .await .with_context(|| { format!( - "failed to delete underlay group {underlay}; use \ - --underlay-only to retry its deletion" + "failed to delete underlay group {underlay}; retry with \ + `swadm multicast del {underlay} --underlay-only -t {tag}`" ) })?; } diff --git a/swadm/tests/cli/cmd.rs b/swadm/tests/cli/cmd.rs index e83ad2cd..f4ff802c 100644 --- a/swadm/tests/cli/cmd.rs +++ b/swadm/tests/cli/cmd.rs @@ -144,15 +144,40 @@ pub mod re { use regex::Regex; use std::sync::LazyLock; - /// A block of parentheses with characters inside. Doesn't + /// A block of parentheses with characters inside. This doesn't /// support nested parentheses. pub static PARENS: LazyLock = LazyLock::new(|| Regex::new(r"\([^)]*\)").unwrap().into()); - /// A run of three or more digits between spaces or at a line's end, - /// i.e. allocated multicast group IDs in the `swadm multicast` output. - pub static GROUP_ID: LazyLock = - LazyLock::new(|| Regex::new(r"(?m) +\d{3,}( |$)").unwrap().into()); + /// Matches the multicast group IDs that dpd allocates, as printed out by + /// `swadm multicast get` (the "group ID:" lines) and `swadm multicast + /// list` (the two ID columns). Each capture group holds one ID, or `-` + /// when the group is lacking one. + /// + /// This is an example with captured spans, marked with `^`, in each output + /// form: + /// + /// ```text + /// External group ID: 65534 + /// ^^^^^ + /// 232.123.45.99 external 65534 - test nat=... + /// ^^^^^ ^ + /// ff04::5ad:1 underlay 65534 65533 test int0/0(...) + /// ^^^^^ ^^^^^ + /// ``` + /// + /// The regex crate has no lookaround operation, which means that the match + /// also incorporates the surrounding text (the label, or the group IP and + /// kind) in order to anchor each ID. + /// + /// Use [`Output::mask`](super::Output::mask) to replace only the captures. + pub static GROUP_ID: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?m)^(?:(?:External|Underlay) group ID: +(\d+)$|\S+ +(?:external|underlay) +(\d+|-) +(\d+|-) )", + ) + .unwrap() + .into() + }); } /// A wrapper type for regex inputs that can be @@ -244,6 +269,40 @@ impl Output { self } + /// Replaces the digits in each numeric capture group of the pattern with + /// `#` of the same width, keeping the column alignment intact. + pub fn mask(&mut self, reg: impl AsRef) -> &mut Self { + *self.as_mut() = reg + .as_ref() + .replace_all(self.as_ref(), |caps: ®ex::Captures<'_>| { + let matched = caps.get(0).unwrap(); + let id_spans: Vec<_> = caps + .iter() + .skip(1) + .flatten() + .filter(|group| { + group.as_str().bytes().all(|b| b.is_ascii_digit()) + }) + .map(|group| group.range()) + .collect(); + + matched + .as_str() + .char_indices() + .map(|(offset, ch)| { + let pos = matched.start() + offset; + if id_spans.iter().any(|span| span.contains(&pos)) { + '#' + } else { + ch + } + }) + .collect::() + }) + .to_string(); + self + } + /// Keeps only those lines of output text for which the pattern matches. /// /// This reformats line separators into a single newline. @@ -310,6 +369,7 @@ impl TryFrom for Output { #[cfg(test)] mod test { + use super::Output; use super::re; /// Verify these unwraps don't panic. @@ -318,4 +378,42 @@ mod test { let _ = &*re::PARENS; let _ = &*re::GROUP_ID; } + + #[test] + fn mask_group_ids_in_list() { + let list = "\ +Group IP Kind Ext Group ID UL Group ID Tag Detail +232.123.45.99 external 65534 - test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=777 vlan=100 src=10.0.0.1 +ff04::5ad:1 underlay 65534 65533 test int0/0(underlay) +"; + let expected = "\ +Group IP Kind Ext Group ID UL Group ID Tag Detail +232.123.45.99 external ##### - test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=777 vlan=100 src=10.0.0.1 +ff04::5ad:1 underlay ##### ##### test int0/0(underlay) +"; + let mut out = Output::Stdout(list.to_string()); + out.mask(&*re::GROUP_ID); + assert_eq!(AsRef::::as_ref(&out), expected); + } + + #[test] + fn mask_group_ids_in_get() { + let get = "\ +Group IP: ff04::5ad:1 +Kind: underlay +External group ID: 65534 +Underlay group ID: 65533 +Tag: test +"; + let expected = "\ +Group IP: ff04::5ad:1 +Kind: underlay +External group ID: ##### +Underlay group ID: ##### +Tag: test +"; + let mut out = Output::Stdout(get.to_string()); + out.mask(&*re::GROUP_ID); + assert_eq!(AsRef::::as_ref(&out), expected); + } } diff --git a/swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt b/swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt index 1236feab..e2b9f85a 100644 --- a/swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt +++ b/swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt @@ -1 +1 @@ -Error: tag mismatch for multicast group 232.123.45.99 +Error: tag mismatch for multicast group 232.123.45.99: given other_tag, group has swadm_multicast_test diff --git a/swadm/tests/cli/expect/multicast_get_default_tag.txt b/swadm/tests/cli/expect/multicast_get_default_tag.txt index aebc05e9..e9214e35 100644 --- a/swadm/tests/cli/expect/multicast_get_default_tag.txt +++ b/swadm/tests/cli/expect/multicast_get_default_tag.txt @@ -1,6 +1,6 @@ Group IP: 224.0.1.51 Kind: external -External group ID: +External group ID: ##### Tag: cli NAT target: ff04::5ad:4 (mac 11:22:33:44:55:66, vni 77) VLAN: (none) diff --git a/swadm/tests/cli/expect/multicast_get_external.txt b/swadm/tests/cli/expect/multicast_get_external.txt index 755eff5b..8645def7 100644 --- a/swadm/tests/cli/expect/multicast_get_external.txt +++ b/swadm/tests/cli/expect/multicast_get_external.txt @@ -1,6 +1,6 @@ Group IP: 232.123.45.99 Kind: external -External group ID: +External group ID: ##### Tag: swadm_multicast_test NAT target: ff04::5ad:1 (mac 11:22:33:44:55:66, vni 77) VLAN: 10 diff --git a/swadm/tests/cli/expect/multicast_get_external_asm.txt b/swadm/tests/cli/expect/multicast_get_external_asm.txt index e629c28d..eb9cea7a 100644 --- a/swadm/tests/cli/expect/multicast_get_external_asm.txt +++ b/swadm/tests/cli/expect/multicast_get_external_asm.txt @@ -1,6 +1,6 @@ Group IP: 224.0.1.50 Kind: external -External group ID: +External group ID: ##### Tag: swadm_multicast_test NAT target: ff04::5ad:2 (mac 11:22:33:44:55:66, vni 77) VLAN: (none) diff --git a/swadm/tests/cli/expect/multicast_get_underlay.txt b/swadm/tests/cli/expect/multicast_get_underlay.txt index 513fe3dc..ddb1941f 100644 --- a/swadm/tests/cli/expect/multicast_get_underlay.txt +++ b/swadm/tests/cli/expect/multicast_get_underlay.txt @@ -1,7 +1,7 @@ Group IP: ff04::5ad:1 Kind: underlay -External group ID: -Underlay group ID: +External group ID: ##### +Underlay group ID: ##### Tag: swadm_multicast_test Members: int0/0(underlay) diff --git a/swadm/tests/cli/expect/multicast_get_underlay_empty.txt b/swadm/tests/cli/expect/multicast_get_underlay_empty.txt index 490d3542..326862b7 100644 --- a/swadm/tests/cli/expect/multicast_get_underlay_empty.txt +++ b/swadm/tests/cli/expect/multicast_get_underlay_empty.txt @@ -1,7 +1,7 @@ Group IP: ff04::5ad:2 Kind: underlay -External group ID: -Underlay group ID: +External group ID: ##### +Underlay group ID: ##### Tag: swadm_multicast_test Members: (none) diff --git a/swadm/tests/cli/expect/multicast_list.txt b/swadm/tests/cli/expect/multicast_list.txt index 26044018..1c237e77 100644 --- a/swadm/tests/cli/expect/multicast_list.txt +++ b/swadm/tests/cli/expect/multicast_list.txt @@ -1,5 +1,5 @@ Group IP Kind Ext Group ID UL Group ID Tag Detail -224.0.1.50 external - swadm_multicast_test nat=ff04::5ad:2 mac=11:22:33:44:55:66 vni=77 vlan=- src=any -232.123.45.99 external - swadm_multicast_test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 -ff04::5ad:1 underlay swadm_multicast_test int0/0(underlay) int0/0(external) -ff04::5ad:2 underlay swadm_multicast_test - +224.0.1.50 external ##### - swadm_multicast_test nat=ff04::5ad:2 mac=11:22:33:44:55:66 vni=77 vlan=- src=any +232.123.45.99 external ##### - swadm_multicast_test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 +ff04::5ad:1 underlay ##### ##### swadm_multicast_test int0/0(underlay) int0/0(external) +ff04::5ad:2 underlay ##### ##### swadm_multicast_test - diff --git a/swadm/tests/cli/expect/multicast_list_tag_external.txt b/swadm/tests/cli/expect/multicast_list_tag_external.txt index f4bb8b7d..b6894ebe 100644 --- a/swadm/tests/cli/expect/multicast_list_tag_external.txt +++ b/swadm/tests/cli/expect/multicast_list_tag_external.txt @@ -1,3 +1,3 @@ Group IP Kind Ext Group ID UL Group ID Tag Detail -224.0.1.50 external - swadm_multicast_test nat=ff04::5ad:2 mac=11:22:33:44:55:66 vni=77 vlan=- src=any -232.123.45.99 external - swadm_multicast_test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 +224.0.1.50 external ##### - swadm_multicast_test nat=ff04::5ad:2 mac=11:22:33:44:55:66 vni=77 vlan=- src=any +232.123.45.99 external ##### - swadm_multicast_test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 diff --git a/swadm/tests/cli/expect/multicast_list_underlay.txt b/swadm/tests/cli/expect/multicast_list_underlay.txt index 62a52aca..c886aa50 100644 --- a/swadm/tests/cli/expect/multicast_list_underlay.txt +++ b/swadm/tests/cli/expect/multicast_list_underlay.txt @@ -1,3 +1,3 @@ Group IP Kind Ext Group ID UL Group ID Tag Detail -ff04::5ad:1 underlay swadm_multicast_test int0/0(underlay) int0/0(external) -ff04::5ad:2 underlay swadm_multicast_test - +ff04::5ad:1 underlay ##### ##### swadm_multicast_test int0/0(underlay) int0/0(external) +ff04::5ad:2 underlay ##### ##### swadm_multicast_test - diff --git a/swadm/tests/cli/multicast.rs b/swadm/tests/cli/multicast.rs index 5b5a5774..f90d5121 100644 --- a/swadm/tests/cli/multicast.rs +++ b/swadm/tests/cli/multicast.rs @@ -101,7 +101,7 @@ fn multicast_list() -> anyhow::Result<()> { "multicast_list_tag_none.txt", ), ] { - cmd::swadm(args)?.strip(&*cmd::re::GROUP_ID).try_expectorate(file)?; + cmd::swadm(args)?.mask(&*cmd::re::GROUP_ID).try_expectorate(file)?; } delete_groups(); @@ -121,7 +121,7 @@ fn multicast_get() -> anyhow::Result<()> { (IpAddr::V6(UNDERLAY_IPV6_EMPTY), "multicast_get_underlay_empty.txt"), ] { cmd::swadm(format!("multicast get {group}"))? - .strip(&*cmd::re::GROUP_ID) + .mask(&*cmd::re::GROUP_ID) .try_expectorate(file)?; } @@ -182,7 +182,7 @@ fn multicast_del_rejects_external_tag_mismatch() -> anyhow::Result<()> { (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), ] { cmd::swadm(format!("multicast get {group}"))? - .strip(&*cmd::re::GROUP_ID) + .mask(&*cmd::re::GROUP_ID) .try_expectorate(file)?; } @@ -208,7 +208,7 @@ fn multicast_del_rejects_underlay_only_external() -> anyhow::Result<()> { (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), ] { cmd::swadm(format!("multicast get {group}"))? - .strip(&*cmd::re::GROUP_ID) + .mask(&*cmd::re::GROUP_ID) .try_expectorate(file)?; } @@ -241,7 +241,7 @@ fn multicast_del_rejects_underlay() -> anyhow::Result<()> { (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), ] { cmd::swadm(format!("multicast get {group}"))? - .strip(&*cmd::re::GROUP_ID) + .mask(&*cmd::re::GROUP_ID) .try_expectorate(file)?; } @@ -275,7 +275,7 @@ fn multicast_add_rolls_back_underlay() -> anyhow::Result<()> { (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), ] { cmd::swadm(format!("multicast get {group}"))? - .strip(&*cmd::re::GROUP_ID) + .mask(&*cmd::re::GROUP_ID) .try_expectorate(file)?; } @@ -306,7 +306,7 @@ fn multicast_add_del_default_tag() -> anyhow::Result<()> { -m {INNER_MAC} -v {EXT_VNI}" ))?; cmd::swadm(format!("multicast get {EXT_IPV4_DEFAULT_TAG}"))? - .strip(&*cmd::re::GROUP_ID) + .mask(&*cmd::re::GROUP_ID) .try_expectorate("multicast_get_default_tag.txt")?; cmd::swadm(format!("multicast del {EXT_IPV4_DEFAULT_TAG}"))?;