diff --git a/swadm/Cargo.toml b/swadm/Cargo.toml index eabb9018..db026679 100644 --- a/swadm/Cargo.toml +++ b/swadm/Cargo.toml @@ -31,3 +31,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"); +}