diff --git a/.github/buildomat/jobs/test-proptest.sh b/.github/buildomat/jobs/test-proptest.sh new file mode 100755 index 00000000..50885153 --- /dev/null +++ b/.github/buildomat/jobs/test-proptest.sh @@ -0,0 +1,36 @@ +#!/bin/bash +#: +#: name = "test-proptest" +#: variety = "basic" +#: target = "helios-3.0" +#: rust_toolchain = true +#: output_rules = [ +#: "/work/proptest-regressions/**/*", +#: ] +#: + +set -o errexit +set -o pipefail +set -o xtrace + +source .github/buildomat/common.sh + +NEXTEST_VERSION='0.9.97' +nextest_tmp="$(mktemp -d)" +trap 'rm -rf "${nextest_tmp}"' EXIT +nextest_tar="${nextest_tmp}/nextest.tar.gz" +curl -sSfL --retry 10 -o "${nextest_tar}" \ + "https://get.nexte.st/${NEXTEST_VERSION}/illumos" +mkdir -p "${CARGO_HOME:-${HOME}/.cargo}/bin" +tar -xzf "${nextest_tar}" -C "${CARGO_HOME:-${HOME}/.cargo}/bin" + +status=0 +pushd dpd-types/versions +PROPTEST_CASES=1000000 cargo nextest run --lib proptests || status=$? +if [[ -d proptest-regressions ]]; then + mkdir -p /work/proptest-regressions/dpd-types-versions + cp -r proptest-regressions/. /work/proptest-regressions/dpd-types-versions/ +fi +popd + +exit "${status}" diff --git a/Cargo.lock b/Cargo.lock index f7272829..c4887c1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,18 +457,22 @@ dependencies = [ [[package]] name = "bit-set" -version = "0.8.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.8.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] [[package]] name = "bitflags" @@ -587,6 +591,30 @@ dependencies = [ "zeroize", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bstr" version = "1.12.0" @@ -723,6 +751,17 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chacha20poly1305" version = "0.10.1" @@ -730,7 +769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -1131,6 +1170,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1701,6 +1746,7 @@ dependencies = [ "libc", "mockall", "nexus-client", + "nonempty", "omicron-common", "openssl", "oxide-tokio-rt", @@ -1754,6 +1800,7 @@ dependencies = [ "common 0.1.0", "crc", "dpd-types", + "dpd-types-versions", "futures", "http", "lazy_static", @@ -1815,12 +1862,13 @@ name = "dpd-types-versions" version = "0.1.0" dependencies = [ "chrono", - "dropshot 0.18.0", - "omicron-common", "oxnet", + "proptest", "rand 0.9.3", "schemars 0.8.22", "serde", + "serde_json", + "test-strategy", "thiserror 2.0.18", "uuid", ] @@ -2565,6 +2613,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -4308,6 +4357,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -4433,7 +4488,7 @@ version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 1.0.109", @@ -4445,7 +4500,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.118", @@ -5403,6 +5458,15 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -5740,17 +5804,17 @@ dependencies = [ [[package]] name = "proptest" -version = "1.8.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bb0be07becd10686a0bb407298fb425360a5c44a663774406340c59a22de4ce" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ "bit-set", "bit-vec", "bitflags 2.11.1", - "lazy_static", + "chacha20 0.10.2", + "core_detect", "num-traits", - "rand 0.9.3", - "rand_chacha 0.9.0", + "rand 0.10.3", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -5931,6 +5995,16 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -5969,13 +6043,19 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.3", + "rand_core 0.10.1", ] [[package]] @@ -8040,6 +8120,18 @@ dependencies = [ "winnow 0.7.14", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -9471,6 +9563,9 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] [[package]] name = "winreg" diff --git a/Cargo.toml b/Cargo.toml index a7e73841..1dbe3ba6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,7 @@ kstat-rs = "0.2.4" lazy_static = "1.5" libc = "0.2" mockall = "0.14.0" +nonempty = "0.12.0" omicron-zone-package = "0.12" openssl = "0.10" oxide-tokio-rt = "0.1.2" @@ -92,6 +93,7 @@ predicates = "3.1.4" pretty_assertions = "1.4" progenitor = "0.15.1" progenitor-extras = "0.3.0" +proptest = "1.10" rand = "0.9" regex = "1.12" regress = "0.11" @@ -112,6 +114,7 @@ slog-term = "2.9" socket2 = { version = "0.6", features = ["all"] } strum = { version = "0.28", features = [ "derive" ] } tabwriter = { version = "1", features = ["ansi_formatting"] } +test-strategy = "0.4" thiserror = "2.0" tokio = "1.37" toml = "1.0" diff --git a/aal/src/lib.rs b/aal/src/lib.rs index 36b0bf75..337e5415 100644 --- a/aal/src/lib.rs +++ b/aal/src/lib.rs @@ -109,11 +109,11 @@ pub enum AsicError { Synthetic(String), /// A general indication that a caller is trying to create something that /// already exists. - #[error("Already exists")] - Exists, + #[error("Already exists: {}", .0)] + Exists(String), /// A general indication that a caller is trying to modify something that /// is not present. - #[error("Missing")] + #[error("Missing: {}", .0)] Missing(String), } @@ -126,6 +126,10 @@ pub trait AsicMulticastOps { /// For a given multicast group, return the number of ports assigned to it. fn mc_port_count(&self, group_id: u16) -> AsicResult; + /// For a given multicast group, return the ASIC identifiers of the ports + /// assigned to it. + fn mc_port_list(&self, group_id: u16) -> AsicResult>; + /// Add a port to a multicast group. The port is identified using its ASIC /// identifier. fn mc_port_add( diff --git a/asic/src/chaos/fault.rs b/asic/src/chaos/fault.rs new file mode 100644 index 00000000..99a15b8d --- /dev/null +++ b/asic/src/chaos/fault.rs @@ -0,0 +1,130 @@ +// 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 aal::{AsicError, AsicResult}; +use common::table::TableType; +use serde::{Deserialize, Serialize}; + +use super::{Handle, McOp, TableOp}; + +/// An inclusive range of call numbers used for multicast fault injection. +/// +/// Each operation is counted starting from 1. `last: None` means no upper +/// bound is set. For example, `first: 3, last: None` forces call 3 and +/// every call after to that operation to fail. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CallRange { + pub first: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last: Option, +} + +impl CallRange { + pub fn nth(call: u64) -> Self { + Self { first: call, last: Some(call) } + } + + pub fn between(first: u64, last: u64) -> Self { + Self { first, last: Some(last) } + } + + pub fn starting_at(first: u64) -> Self { + Self { first, last: None } + } + + pub fn contains(&self, call: u64) -> bool { + call >= self.first && self.last.is_none_or(|last| call <= last) + } +} + +/// A fault injected on an operation, failing the calls within its [`CallRange`]. +pub(super) struct Fault { + /// Calls counted against this fault so far. + calls: u64, + /// The call numbers that fail. + range: CallRange, +} + +impl Fault { + pub(super) fn new(range: CallRange) -> Self { + Self { calls: 0, range } + } +} + +impl Handle { + pub(super) fn check_mc_fault(&self, op: McOp) -> AsicResult<()> { + if self.config.mc_config.unsupported.contains(&op) { + return Err(AsicError::OperationUnsupported); + } + + let failed_call = { + let mut faults = self.mc_faults.lock().unwrap(); + faults.get_mut(&op).and_then(|fault| { + fault.calls += 1; + fault.range.contains(fault.calls).then_some(fault.calls) + }) + }; + + if let Some(call) = failed_call { + slog::error!(self.log, "chaos error: {} (call {})", op, call); + return Err(AsicError::Synthetic(op.to_string())); + } + + self.config.mc_config.chaos(op).unfurled(&self.log, &op.to_string()) + } + + pub fn inject_mc_faults(&self, op: McOp, range: CallRange) { + self.mc_faults.lock().unwrap().insert(op, Fault::new(range)); + } + + pub fn clear_mc_faults(&self, op: McOp) { + self.mc_faults.lock().unwrap().remove(&op); + } + + pub(super) fn check_table_fault( + &self, + table: TableType, + op: TableOp, + ) -> AsicResult<()> { + let failed_call = { + let mut faults = self.table_faults.lock().unwrap(); + faults.get_mut(&(table, op)).and_then(|fault| { + fault.calls += 1; + fault.range.contains(fault.calls).then_some(fault.calls) + }) + }; + + match failed_call { + Some(call) => { + slog::error!( + self.log, + "chaos table error: {} on {} (call {})", + op, + table, + call + ); + Err(AsicError::Synthetic(op.to_string())) + } + None => Ok(()), + } + } + + pub fn inject_table_faults( + &self, + table: TableType, + op: TableOp, + range: CallRange, + ) { + self.table_faults + .lock() + .unwrap() + .insert((table, op), Fault::new(range)); + } + + pub fn clear_table_faults(&self, table: TableType, op: TableOp) { + self.table_faults.lock().unwrap().remove(&(table, op)); + } +} diff --git a/asic/src/chaos/mod.rs b/asic/src/chaos/mod.rs index ccf2deed..3df78875 100644 --- a/asic/src/chaos/mod.rs +++ b/asic/src/chaos/mod.rs @@ -7,7 +7,8 @@ use rand::random; use serde::{Deserialize, Serialize}; use slog::Logger; -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap, btree_map}; +use std::fmt; use std::sync::Mutex; use tokio::sync::mpsc; @@ -24,8 +25,12 @@ pub use crate::faux_fsm::FsmState; pub use crate::faux_fsm::FsmType; pub use crate::faux_fsm::PortFsmState; +mod fault; pub mod table; +pub use fault::CallRange; +use fault::Fault; + const CPU_PORT: u16 = 47; /// Chaos that happens according to a probability. @@ -57,7 +62,7 @@ impl Chaos { /// Return a chaos error according to the underlying probability value. pub fn unfurled(&self, log: &Logger, message: &str) -> AsicResult<()> { - if self.value >= random() { + if self.value > random() { slog::error!(log, "chaos error: {}", message); return Err(AsicError::Synthetic(message.into())); } @@ -143,7 +148,7 @@ impl TableChaos { message: &str, ) -> AsicResult<()> { if let Some(value) = self.values.get(&id) - && *value >= random() + && *value > random() { slog::error!(log, "chaos table error: {}", message); return Err(AsicError::Synthetic(message.into())); @@ -152,6 +157,62 @@ impl TableChaos { } } +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum McOp { + McPortCount, + McPortList, + McPortAdd, + McPortRemove, + McGroupCreate, + McGroupDestroy, + McGroupsCount, + McSetMaxNodes, +} + +impl fmt::Display for McOp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::McPortCount => "mc_port_count", + Self::McPortList => "mc_port_list", + Self::McPortAdd => "mc_port_add", + Self::McPortRemove => "mc_port_remove", + Self::McGroupCreate => "mc_group_create", + Self::McGroupDestroy => "mc_group_destroy", + Self::McGroupsCount => "mc_groups_count", + Self::McSetMaxNodes => "mc_set_max_nodes", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TableOp { + EntryAdd, + EntryUpdate, + EntryDel, +} + +impl fmt::Display for TableOp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::EntryAdd => "table_entry_add", + Self::EntryUpdate => "table_entry_update", + Self::EntryDel => "table_entry_del", + }) + } +} + #[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct AsicMulticastConfig { pub mc_port_count: Chaos, @@ -161,6 +222,27 @@ pub struct AsicMulticastConfig { pub mc_group_destroy: Chaos, pub mc_groups_count: Chaos, pub mc_set_max_nodes: Chaos, + #[serde(default)] + pub mc_port_list: Chaos, + #[serde(default)] + pub faults: BTreeMap, + #[serde(default)] + pub unsupported: BTreeSet, +} + +impl AsicMulticastConfig { + fn chaos(&self, op: McOp) -> &Chaos { + match op { + McOp::McPortCount => &self.mc_port_count, + McOp::McPortList => &self.mc_port_list, + McOp::McPortAdd => &self.mc_port_add, + McOp::McPortRemove => &self.mc_port_remove, + McOp::McGroupCreate => &self.mc_group_create, + McOp::McGroupDestroy => &self.mc_group_destroy, + McOp::McGroupsCount => &self.mc_groups_count, + McOp::McSetMaxNodes => &self.mc_set_max_nodes, + } + } } /// The chaos ASIC config contains chaos values for each ASIC operation. @@ -232,6 +314,9 @@ impl AsicConfig { mc_group_destroy: Chaos::new(v), mc_groups_count: Chaos::new(v), mc_set_max_nodes: Chaos::new(v), + mc_port_list: Chaos::new(v), + faults: BTreeMap::new(), + unsupported: BTreeSet::new(), }, } } @@ -255,6 +340,7 @@ impl AsicConfig { mc_config: AsicMulticastConfig { mc_port_count: Chaos::new(v), mc_groups_count: Chaos::new(v), + mc_port_list: Chaos::new(v), ..Default::default() }, ..Default::default() @@ -335,6 +421,9 @@ pub struct Port { /// A hanlde for a chaos asic. pub struct Handle { ports: Mutex>, + mc_groups: Mutex>>, + mc_faults: Mutex>, + table_faults: Mutex>, config: AsicConfig, log: Logger, } @@ -344,6 +433,16 @@ impl Handle { pub fn new(log: &Logger, config: &AsicConfig) -> AsicResult { Ok(Handle { ports: Mutex::new(HashMap::new()), + mc_groups: Mutex::new(BTreeMap::new()), + mc_faults: Mutex::new( + config + .mc_config + .faults + .iter() + .map(|(&op, &range)| (op, Fault::new(range))) + .collect(), + ), + table_faults: Mutex::new(BTreeMap::new()), config: config.clone(), log: log.clone(), }) @@ -366,19 +465,6 @@ macro_rules! unfurl { } pub(crate) use unfurl; -/// A convenience macro for unfurling multicast chaos. The $name should be a -/// regular `Chaos` member of [`AsicMulticastConfigConfig`]. The `handle` is -/// a [`Handle`] object. -macro_rules! unfurl_mc { - ($handle:ident, $name:ident) => { - $handle - .config - .mc_config - .$name - .unfurled(&$handle.log, stringify!($name))? - }; -} - /// A convenience macro for unfurling tabular chaos. The $name should be a /// `TableChaos` member of [`AsicConfig`]. The `handle` is a [`Handle`] object. macro_rules! table_unfurl { @@ -390,44 +476,76 @@ pub(crate) use table_unfurl; impl AsicMulticastOps for Handle { fn mc_domains(&self) -> Vec { - let len = self.ports.lock().unwrap().len() as u16; - (0..len).collect() + self.mc_groups.lock().unwrap().keys().copied().collect() + } + + fn mc_port_count(&self, group_id: u16) -> AsicResult { + self.check_mc_fault(McOp::McPortCount)?; + let groups = self.mc_groups.lock().unwrap(); + get_mc_group(&groups, group_id).map(BTreeSet::len) } - fn mc_port_count(&self, _group_id: u16) -> AsicResult { - unfurl_mc!(self, mc_port_count); - Ok(self.ports.lock().unwrap().len()) + fn mc_port_list(&self, group_id: u16) -> AsicResult> { + self.check_mc_fault(McOp::McPortList)?; + let groups = self.mc_groups.lock().unwrap(); + get_mc_group(&groups, group_id) + .map(|ports| ports.iter().copied().collect()) } fn mc_port_add( &self, - _group_id: u16, - _port: u16, + group_id: u16, + port: u16, _rid: u16, _level1_excl_id: u16, ) -> AsicResult<()> { - unfurl_mc!(self, mc_port_add); - Err(AsicError::OperationUnsupported) + self.check_mc_fault(McOp::McPortAdd)?; + let mut groups = self.mc_groups.lock().unwrap(); + if get_mc_group_mut(&mut groups, group_id)?.insert(port) { + Ok(()) + } else { + Err(AsicError::Exists(format!( + "port {port} already in multicast group {group_id}" + ))) + } } - fn mc_port_remove(&self, _group_id: u16, _port: u16) -> AsicResult<()> { - unfurl_mc!(self, mc_port_remove); - Ok(()) + fn mc_port_remove(&self, group_id: u16, port: u16) -> AsicResult<()> { + self.check_mc_fault(McOp::McPortRemove)?; + let mut groups = self.mc_groups.lock().unwrap(); + if get_mc_group_mut(&mut groups, group_id)?.remove(&port) { + Ok(()) + } else { + Err(AsicError::Missing(format!( + "port {port} not in multicast group {group_id}" + ))) + } } - fn mc_group_create(&self, _group_id: u16) -> AsicResult<()> { - unfurl_mc!(self, mc_group_create); - Err(AsicError::OperationUnsupported) + fn mc_group_create(&self, group_id: u16) -> AsicResult<()> { + self.check_mc_fault(McOp::McGroupCreate)?; + match self.mc_groups.lock().unwrap().entry(group_id) { + btree_map::Entry::Vacant(entry) => { + entry.insert(BTreeSet::new()); + Ok(()) + } + btree_map::Entry::Occupied(_) => Err(AsicError::Exists(format!( + "multicast group {group_id} already exists" + ))), + } } - fn mc_group_destroy(&self, _group_id: u16) -> AsicResult<()> { - unfurl_mc!(self, mc_group_destroy); - Ok(()) + fn mc_group_destroy(&self, group_id: u16) -> AsicResult<()> { + self.check_mc_fault(McOp::McGroupDestroy)?; + match self.mc_groups.lock().unwrap().remove(&group_id) { + Some(_) => Ok(()), + None => Err(missing_mc_group(group_id)), + } } fn mc_groups_count(&self) -> AsicResult { - unfurl_mc!(self, mc_groups_count); - Ok(self.ports.lock().unwrap().len()) + self.check_mc_fault(McOp::McGroupsCount)?; + Ok(self.mc_groups.lock().unwrap().len()) } fn mc_set_max_nodes( @@ -435,8 +553,7 @@ impl AsicMulticastOps for Handle { _max_nodes: u32, _max_link_aggregated_nodes: u32, ) -> AsicResult<()> { - unfurl_mc!(self, mc_set_max_nodes); - Ok(()) + self.check_mc_fault(McOp::McSetMaxNodes) } } @@ -552,7 +669,7 @@ impl AsicOps for Handle { // and use it as before. let port_hdl = PortHdl::new(connector, 0); if ports.contains_key(&port_hdl) { - return Err(AsicError::Exists); + return Err(AsicError::Exists(format!("port {port_hdl} exists"))); } ports.insert(port_hdl, Port { enabled: true }); self.port_to_asic_id(port_hdl).map(|id| (port_hdl, id)) @@ -620,3 +737,21 @@ fn get_port_mut( None => Err(AsicError::InvalidArg("no such port".to_string())), } } + +fn missing_mc_group(group_id: u16) -> AsicError { + AsicError::Missing(format!("no such multicast group: {group_id}")) +} + +fn get_mc_group( + groups: &BTreeMap>, + group_id: u16, +) -> AsicResult<&BTreeSet> { + groups.get(&group_id).ok_or_else(|| missing_mc_group(group_id)) +} + +fn get_mc_group_mut( + groups: &mut BTreeMap>, + group_id: u16, +) -> AsicResult<&mut BTreeSet> { + groups.get_mut(&group_id).ok_or_else(|| missing_mc_group(group_id)) +} diff --git a/asic/src/chaos/table.rs b/asic/src/chaos/table.rs index 36515270..6083dc1e 100644 --- a/asic/src/chaos/table.rs +++ b/asic/src/chaos/table.rs @@ -4,27 +4,28 @@ // // Copyright 2026 Oxide Computer Company -use std::collections::HashSet; +use std::collections::HashMap; use std::collections::hash_map::DefaultHasher; use std::hash::Hash; use std::hash::Hasher; use std::sync::Mutex; -use crate::chaos::{Handle, table_unfurl}; +use crate::chaos::{Handle, TableOp, table_unfurl}; use aal::{ - ActionParse, AsicError, AsicResult, CounterData, MatchParse, TableOps, + ActionData, ActionParse, AsicError, AsicResult, CounterData, MatchData, + MatchParse, TableOps, }; use common::table::TableType; pub struct Table { type_: TableType, - keys: Mutex>, + keys: Mutex>, } impl TableOps for Table { fn new(hdl: &Handle, type_: TableType) -> AsicResult { table_unfurl!(hdl, type_, table_new); - Ok(Table { type_, keys: Mutex::new(HashSet::new()) }) + Ok(Table { type_, keys: Mutex::new(HashMap::new()) }) } fn size(&self) -> usize { @@ -39,7 +40,7 @@ impl TableOps for Table { fn clear(&self, hdl: &Handle) -> AsicResult<()> { table_unfurl!(hdl, self.type_, table_clear); let mut keys = self.keys.lock().unwrap(); - *keys = HashSet::new(); + *keys = HashMap::new(); Ok(()) } @@ -47,18 +48,22 @@ impl TableOps for Table { &self, hdl: &Handle, key: &M, - _data: &A, + data: &A, ) -> AsicResult<()> { let mut hasher = DefaultHasher::new(); key.hash(&mut hasher); let x: u64 = hasher.finish(); let mut keys = self.keys.lock().unwrap(); - if keys.contains(&x) { - return Err(AsicError::Exists); + if keys.contains_key(&x) { + return Err(AsicError::Exists(format!( + "entry already in table {}", + self.type_ + ))); } + hdl.check_table_fault(self.type_, TableOp::EntryAdd)?; table_unfurl!(hdl, self.type_, table_entry_add); - keys.insert(x); + keys.insert(x, (key.key_to_ir()?, data.action_to_ir()?)); Ok(()) } @@ -66,19 +71,21 @@ impl TableOps for Table { &self, hdl: &Handle, key: &M, - _data: &A, + data: &A, ) -> AsicResult<()> { let mut hasher = DefaultHasher::new(); key.hash(&mut hasher); let x: u64 = hasher.finish(); - let keys = self.keys.lock().unwrap(); - if !keys.contains(&x) { + let mut keys = self.keys.lock().unwrap(); + let Some((_, action)) = keys.get_mut(&x) else { return Err(AsicError::Missing( "table entry not found".to_string(), )); - } + }; + hdl.check_table_fault(self.type_, TableOp::EntryUpdate)?; table_unfurl!(hdl, self.type_, table_entry_update); + *action = data.action_to_ir()?; Ok(()) } @@ -92,11 +99,12 @@ impl TableOps for Table { let x: u64 = hasher.finish(); let mut keys = self.keys.lock().unwrap(); - if !keys.contains(&x) { + if !keys.contains_key(&x) { return Err(AsicError::Missing( "table entry not found".to_string(), )); } + hdl.check_table_fault(self.type_, TableOp::EntryDel)?; table_unfurl!(hdl, self.type_, table_entry_del); keys.remove(&x); Ok(()) @@ -107,7 +115,14 @@ impl TableOps for Table { _hdl: &Handle, _from_hardware: bool, ) -> AsicResult> { - Err(AsicError::OperationUnsupported) + self.keys + .lock() + .unwrap() + .values() + .map(|(key, action)| { + Ok((M::ir_to_key(key)?, A::ir_to_action(action)?)) + }) + .collect() } fn get_counters( diff --git a/asic/src/softnpu/mod.rs b/asic/src/softnpu/mod.rs index 94246790..c6cf6977 100644 --- a/asic/src/softnpu/mod.rs +++ b/asic/src/softnpu/mod.rs @@ -154,6 +154,11 @@ impl AsicMulticastOps for Handle { Ok(self.ports.lock().unwrap().len()) } + fn mc_port_list(&self, _group_id: u16) -> AsicResult> { + // TODO: Read members from replication table bitmaps via DumpRequest. + Err(AsicError::OperationUnsupported) + } + fn mc_port_add( &self, _group_id: u16, @@ -338,9 +343,7 @@ impl AsicOps for Handle { // and use it as before. let port_hdl = PortHdl::new(connector, 0); if ports.contains_key(&port_hdl) { - return Err(AsicError::InvalidArg(format!( - "Port {port_hdl:?} exists" - ))); + return Err(AsicError::Exists(format!("Port {port_hdl:?} exists"))); } ports.insert(port_hdl, Port { enabled: true, tx_eq: 0 }); self.port_to_asic_id(port_hdl).map(|id| (port_hdl, id)) @@ -349,7 +352,7 @@ impl AsicOps for Handle { fn port_delete(&self, port_hdl: PortHdl) -> AsicResult<()> { let mut ports = self.ports.lock().unwrap(); if !ports.contains_key(&port_hdl) { - return Err(AsicError::InvalidArg(format!( + return Err(AsicError::Missing(format!( "Port {port_hdl:?} does not exist" ))); } diff --git a/asic/src/tofino_asic/imported_bf_functions b/asic/src/tofino_asic/imported_bf_functions index 547abe30..37ceca49 100644 --- a/asic/src/tofino_asic/imported_bf_functions +++ b/asic/src/tofino_asic/imported_bf_functions @@ -25,6 +25,7 @@ bf_mc_mgrp_destroy bf_mc_mgrp_get_count bf_mc_node_create bf_mc_node_destroy +bf_mc_node_get_association bf_mc_node_update bf_mc_associate_node bf_mc_dissociate_node diff --git a/asic/src/tofino_asic/mcast.rs b/asic/src/tofino_asic/mcast.rs index c6b2d4b3..dc12ce2e 100644 --- a/asic/src/tofino_asic/mcast.rs +++ b/asic/src/tofino_asic/mcast.rs @@ -18,7 +18,12 @@ use aal::{AsicError, AsicResult}; pub struct DomainState { id: u16, mgrp_hdl: bf_mc_mgrp_hdl_t, + /// Ports whose node is associated with the `mgrp_hdl`, i.e. a port still + /// available for replication. ports: HashMap, + /// Nodes that are dissociated (or were never associated) but failed to + /// destroy. `destroy_detached_nodes` retries them. + detached_nodes: Vec, } /* @@ -120,11 +125,38 @@ fn dissociate_node( mgrp_hdl: bf_mc_mgrp_hdl_t, node_hdl: bf_mc_node_hdl_t, ) -> AsicResult<()> { - unsafe { + let res = unsafe { bf_mc_dissociate_node(mcast_hdl, dev_id, mgrp_hdl, node_hdl) - .check_error("dissociating multicast node from group")?; + .check_error("dissociating multicast node from group") + }; + + if let Err(AsicError::Exists(_)) = &res + && !node_is_associated(mcast_hdl, dev_id, node_hdl)? + { + return Ok(()); } - Ok(()) + res +} + +fn node_is_associated( + mcast_hdl: bf_mc_session_hdl_t, + dev_id: bf_dev_id_t, + node_hdl: bf_mc_node_hdl_t, +) -> AsicResult { + let mut is_associated = false; + unsafe { + bf_mc_node_get_association( + mcast_hdl, + dev_id, + node_hdl, + &mut is_associated, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + .check_error("getting multicast node association")?; + } + Ok(is_associated) } fn node_create( @@ -162,21 +194,60 @@ fn node_destroy( Ok(()) } -fn cleanup_node( +/// Dissociate the `node_hdl` from `mgrp_hdl` and destroy it. +/// +/// A dissociation error can leave the node attached or detached. The caller +/// retains its handle for retry or re-association. +/// +/// Once the node is detached, a failed destroy on the underlying ASIC retains +/// the handle in `detached_nodes` and still returns a non-error result because +/// the port no longer replicates to subscribers. +fn detach_node( + log: &slog::Logger, bf: &BfCommon, - mgrp_hdl: Option, - port_state: &MulticastState, + mgrp_hdl: bf_mc_mgrp_hdl_t, + node_hdl: bf_mc_node_hdl_t, + detached_nodes: &mut Vec, ) -> AsicResult<()> { - if let Some(mgrp_hdl) = mgrp_hdl { - dissociate_node( - bf.mcast_hdl, - bf.dev_id, - mgrp_hdl, - port_state.node_hdl, - )?; + match dissociate_node(bf.mcast_hdl, bf.dev_id, mgrp_hdl, node_hdl) { + Ok(()) | Err(AsicError::Missing(_)) => {} + Err(e) => return Err(e), + } + + if let Err(e) = node_destroy(bf.mcast_hdl, bf.dev_id, node_hdl) { + error!( + log, + "multicast node {:#x} detached but not destroyed: {:?}", + node_hdl, + e + ); + detached_nodes.push(node_hdl); } + Ok(()) +} - node_destroy(bf.mcast_hdl, bf.dev_id, port_state.node_hdl) +/// Retry destroying the handles in `detached_nodes` within the domain state, +/// keeping any that remain in a failing state, and returning the first failure. +fn destroy_detached_nodes( + log: &slog::Logger, + bf: &BfCommon, + domain: &mut DomainState, +) -> Option { + let mut first_error = None; + for node_hdl in std::mem::take(&mut domain.detached_nodes) { + if let Err(e) = node_destroy(bf.mcast_hdl, bf.dev_id, node_hdl) { + error!( + log, + "destroying detached multicast node {:#x} in domain {}: {:?}", + node_hdl, + domain.id, + e + ); + first_error.get_or_insert(e); + domain.detached_nodes.push(node_hdl); + } + } + first_error } fn set_max_node_threshold( @@ -211,24 +282,36 @@ pub fn domains(hdl: &Handle) -> Vec { list } -#[allow(dead_code)] -fn domain_ports(domain: &DomainState) -> Vec { +fn domain_ports(hdl: &Handle, domain: &DomainState) -> AsicResult> { let mut list = Vec::new(); + let bf = hdl.bf_get(); - for port in domain.ports.keys() { - list.push(*port) + for (port, mc) in &domain.ports { + match node_is_associated(bf.mcast_hdl, bf.dev_id, mc.node_hdl) { + Ok(true) => list.push(*port), + Ok(false) | Err(AsicError::Missing(_)) => {} + Err(e) => return Err(e), + } } list.sort_unstable(); - list + Ok(list) } /// Get the number of ports in a multicast domain. pub fn domain_port_count(hdl: &Handle, group_id: u16) -> AsicResult { - let mut domains = hdl.domains.lock().unwrap(); - match domains.get_mut(&group_id) { - Some(d) => Ok(d.ports.len()), - None => Err(AsicError::InvalidArg("no such domain domain".to_string())), + let domains = hdl.domains.lock().unwrap(); + match domains.get(&group_id) { + Some(d) => domain_ports(hdl, d).map(|ports| ports.len()), + None => Err(AsicError::Missing("no such domain".to_string())), + } +} + +pub fn domain_port_list(hdl: &Handle, group_id: u16) -> AsicResult> { + let domains = hdl.domains.lock().unwrap(); + match domains.get(&group_id) { + Some(d) => domain_ports(hdl, d), + None => Err(AsicError::Missing("no such domain".to_string())), } } @@ -245,16 +328,27 @@ pub fn domain_add_port( let mut domains = hdl.domains.lock().unwrap(); let domain = match domains.get_mut(&group_id) { Some(d) => Ok(d), - None => Err(AsicError::InvalidArg("no such domain domain".to_string())), + None => Err(AsicError::Missing("no such domain".to_string())), }?; - if domain.ports.contains_key(&port) { - return Err(AsicError::InvalidArg( - "port already in domain".to_string(), - )); + let bf = hdl.bf_get(); + if let Some(mc) = domain.ports.get(&port) { + if node_is_associated(bf.mcast_hdl, bf.dev_id, mc.node_hdl)? { + return Err(AsicError::Exists(format!( + "port {port} already in multicast domain {group_id}" + ))); + } + + return associate_node( + bf.mcast_hdl, + bf.dev_id, + domain.mgrp_hdl, + mc.node_hdl, + level_1_excl_id, + ); } - let bf = hdl.bf_get(); + destroy_detached_nodes(&hdl.log, &bf, domain); let mut mc = MulticastState { node_hdl: 0, @@ -283,11 +377,12 @@ pub fn domain_add_port( Ok(()) } Err(e) => { - if let Err(x) = cleanup_node(&bf, None, &mc) { + if let Err(x) = node_destroy(bf.mcast_hdl, bf.dev_id, mc.node_hdl) { error!( hdl.log, "post-failure multicast cleanup failed: {:?}", x ); + domain.detached_nodes.push(mc.node_hdl); } Err(e) } @@ -304,21 +399,27 @@ pub fn domain_remove_port( let mut domains = hdl.domains.lock().unwrap(); let domain = match domains.get_mut(&group_id) { Some(d) => Ok(d), - None => Err(AsicError::InvalidArg("no such domain domain".to_string())), + None => Err(AsicError::Missing("no such domain".to_string())), }?; let bf = hdl.bf_get(); + destroy_detached_nodes(&hdl.log, &bf, domain); - let mc = match domain.ports.remove(&port) { - Some(n) => n, - None => { - return Err(AsicError::InvalidArg( - "port not in domain".to_string(), - )); - } - }; + let node_hdl = domain + .ports + .get(&port) + .ok_or_else(|| AsicError::Missing("port not in domain".to_string()))? + .node_hdl; - cleanup_node(&bf, Some(domain.mgrp_hdl), &mc)?; + detach_node( + &hdl.log, + &bf, + domain.mgrp_hdl, + node_hdl, + &mut domain.detached_nodes, + )?; + + domain.ports.remove(&port); Ok(()) } @@ -327,7 +428,9 @@ pub fn domain_create(hdl: &Handle, group_id: u16) -> AsicResult<()> { info!(hdl.log, "creating multicast domain {}", group_id); let mut domains = hdl.domains.lock().unwrap(); if domains.get(&group_id).is_some() { - return Err(AsicError::InvalidArg("domain already exists".to_string())); + return Err(AsicError::Exists(format!( + "multicast domain {group_id} already exists" + ))); }; let bf = hdl.bf_get(); @@ -335,7 +438,12 @@ pub fn domain_create(hdl: &Handle, group_id: u16) -> AsicResult<()> { let mgrp_hdl = mgrp_create(bf.mcast_hdl, bf.dev_id, group_id)?; domains.insert( group_id, - DomainState { id: group_id, mgrp_hdl, ports: HashMap::new() }, + DomainState { + id: group_id, + mgrp_hdl, + ports: HashMap::new(), + detached_nodes: Vec::new(), + }, ); Ok(()) } @@ -344,27 +452,61 @@ pub fn domain_create(hdl: &Handle, group_id: u16) -> AsicResult<()> { pub fn domain_destroy(hdl: &Handle, group_id: u16) -> AsicResult<()> { info!(hdl.log, "destroying multicast domain {}", group_id); let mut domains = hdl.domains.lock().unwrap(); - let mut domain = match domains.remove(&group_id) { + let domain = match domains.get_mut(&group_id) { Some(d) => Ok(d), - None => Err(AsicError::InvalidArg("no such domain".to_string())), + None => Err(AsicError::Missing("no such domain".to_string())), }?; let bf = hdl.bf_get(); let mgrp_hdl = domain.mgrp_hdl; - for (port, mc) in domain.ports.drain() { - if let Err(e) = cleanup_node(&bf, Some(mgrp_hdl), &mc) { + let domain_id = domain.id; + let mut first_err = None; + domain.ports.retain(|port, mc| { + if let Err(e) = detach_node( + &hdl.log, + &bf, + mgrp_hdl, + mc.node_hdl, + &mut domain.detached_nodes, + ) { error!( hdl.log, "cleaning up port {} for multicast domain {}: {:?}", port, - domain.id, + domain_id, e ); + first_err.get_or_insert(e); + true + } else { + false } + }); + + if let Some(e) = destroy_detached_nodes(&hdl.log, &bf, domain) { + first_err.get_or_insert(e); + } + + if let Some(e) = first_err { + return Err(e); } - mgrp_destroy(bf.mcast_hdl, bf.dev_id, domain.mgrp_hdl) + match mgrp_destroy(bf.mcast_hdl, bf.dev_id, mgrp_hdl) { + Ok(()) | Err(AsicError::Missing(_)) => { + domains.remove(&group_id); + Ok(()) + } + Err(e) => { + if matches!( + mgrp_destroy(bf.mcast_hdl, bf.dev_id, mgrp_hdl), + Ok(()) | Err(AsicError::Missing(_)) + ) { + domains.remove(&group_id); + } + Err(e) + } + } } /// Domain exists. diff --git a/asic/src/tofino_asic/mod.rs b/asic/src/tofino_asic/mod.rs index c26a0fd2..7d102f30 100644 --- a/asic/src/tofino_asic/mod.rs +++ b/asic/src/tofino_asic/mod.rs @@ -80,6 +80,10 @@ impl AsicMulticastOps for Handle { mcast::domain_port_count(self, group_id) } + fn mc_port_list(&self, group_id: u16) -> AsicResult> { + mcast::domain_port_list(self, group_id) + } + fn mc_port_add( &self, group_id: u16, @@ -459,7 +463,7 @@ impl Handle { pub fn sde_error(ctx: impl ToString, err: bf_status_t) -> AsicError { match err { - bf_wrapper::BF_ALREADY_EXISTS => AsicError::Exists, + bf_wrapper::BF_ALREADY_EXISTS => AsicError::Exists(ctx.to_string()), bf_wrapper::BF_OBJECT_NOT_FOUND => AsicError::Missing(ctx.to_string()), _ => AsicError::SdeError { ctx: ctx.to_string(), diff --git a/asic/src/tofino_asic/table.rs b/asic/src/tofino_asic/table.rs index 98f48888..ac6ad3c6 100644 --- a/asic/src/tofino_asic/table.rs +++ b/asic/src/tofino_asic/table.rs @@ -739,7 +739,7 @@ impl aal::TableOps for Table { let data_hdl = self.data_to_asic(&action)?; let bf = hdl.bf_get(); - unsafe { + let res = unsafe { bf_rt_table_entry_add( self.rt_hdl, bf.rt_sess, @@ -748,8 +748,8 @@ impl aal::TableOps for Table { data_hdl.data_hdl, ) } - .check_error("adding entry") - .log_error(hdl) + .check_error("adding entry"); + log_unexpected_error(res, hdl) } fn entry_update( @@ -764,7 +764,7 @@ impl aal::TableOps for Table { let data_hdl = self.data_to_asic(&action)?; let bf = hdl.bf_get(); - unsafe { + let res = unsafe { bf_rt_table_entry_mod( self.rt_hdl, bf.rt_sess, @@ -773,8 +773,8 @@ impl aal::TableOps for Table { data_hdl.data_hdl, ) } - .check_error("updating entry") - .log_error(hdl) + .check_error("updating entry"); + log_unexpected_error(res, hdl) } fn entry_del( @@ -785,7 +785,7 @@ impl aal::TableOps for Table { let key_hdl = self.key_to_asic(key.key_to_ir()?)?; let bf = hdl.bf_get(); - unsafe { + let res = unsafe { bf_rt_table_entry_del( self.rt_hdl, bf.rt_sess, @@ -793,8 +793,8 @@ impl aal::TableOps for Table { key_hdl.key_hdl, ) } - .check_error("deleting entry") - .log_error(hdl) + .check_error("deleting entry"); + log_unexpected_error(res, hdl) } fn get_entries( @@ -876,3 +876,10 @@ impl aal::TableOps for Table { Ok(values) } } + +fn log_unexpected_error(res: AsicResult<()>, hdl: &Handle) -> AsicResult<()> { + match res { + Err(AsicError::Exists(_) | AsicError::Missing(_)) => res, + res => res.log_error(hdl), + } +} diff --git a/asic/src/tofino_common/ports.rs b/asic/src/tofino_common/ports.rs index 82b4a207..0752452b 100644 --- a/asic/src/tofino_common/ports.rs +++ b/asic/src/tofino_common/ports.rs @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/ // -// Copyright 2025 Oxide Computer Company +// Copyright 2026 Oxide Computer Company use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -181,7 +181,9 @@ impl PortData { ) -> AsicResult<()> { match self.config_ports.insert(port_hdl, cfg) { None => Ok(()), - _ => Err(AsicError::Exists), + _ => { + Err(AsicError::Exists(format!("port {port_hdl} already added"))) + } } } diff --git a/asic/src/tofino_stub/mcast.rs b/asic/src/tofino_stub/mcast.rs index 22ad588f..6897efef 100644 --- a/asic/src/tofino_stub/mcast.rs +++ b/asic/src/tofino_stub/mcast.rs @@ -13,7 +13,7 @@ pub struct McGroupData { } fn no_group(group_id: u16) -> AsicError { - AsicError::InvalidArg(format!("no such multicast group: {group_id}")) + AsicError::Missing(format!("no such multicast group: {group_id}")) } impl McGroupData { @@ -26,10 +26,20 @@ impl McGroupData { pub fn domain_port_count(&self, group_id: u16) -> AsicResult { match self.groups.get(&group_id) { Some(g) => Ok(g.len()), - None => Err(no_group(group_id)), + None => Err(AsicError::Missing(format!( + "no such multicast group: {group_id}" + ))), } } + pub fn domain_port_list(&self, group_id: u16) -> AsicResult> { + let group = + self.groups.get(&group_id).ok_or_else(|| no_group(group_id))?; + let mut ports: Vec = group.iter().copied().collect(); + ports.sort_unstable(); + Ok(ports) + } + /// Add a port to a multicast domain. pub fn domain_port_add( &mut self, @@ -45,7 +55,7 @@ impl McGroupData { match group.insert(port) { true => Ok(()), - false => Err(AsicError::InvalidArg(format!( + false => Err(AsicError::Exists(format!( "multicast group {group_id} already contains port {port}" ))), } @@ -64,7 +74,7 @@ impl McGroupData { match group.remove(&port) { true => Ok(()), - false => Err(AsicError::InvalidArg(format!( + false => Err(AsicError::Missing(format!( "multicast group {group_id} doesn't contain port {port}" ))), } @@ -74,7 +84,7 @@ impl McGroupData { #[allow(clippy::map_entry)] pub fn domain_create(&mut self, group_id: u16) -> AsicResult<()> { if self.groups.contains_key(&group_id) { - Err(AsicError::InvalidArg(format!( + Err(AsicError::Exists(format!( "multicast group {group_id} already exists" ))) } else { diff --git a/asic/src/tofino_stub/mod.rs b/asic/src/tofino_stub/mod.rs index 42beec94..c1934cbc 100644 --- a/asic/src/tofino_stub/mod.rs +++ b/asic/src/tofino_stub/mod.rs @@ -75,6 +75,10 @@ impl AsicMulticastOps for StubHandle { let mc_data = self.mc_data.lock().unwrap(); mc_data.domain_port_count(group_id) } + fn mc_port_list(&self, group_id: u16) -> AsicResult> { + let mc_data = self.mc_data.lock().unwrap(); + mc_data.domain_port_list(group_id) + } fn mc_port_add( &self, group_id: u16, diff --git a/asic/src/tofino_stub/table.rs b/asic/src/tofino_stub/table.rs index 1ab31f60..a82dea23 100644 --- a/asic/src/tofino_stub/table.rs +++ b/asic/src/tofino_stub/table.rs @@ -104,7 +104,7 @@ impl TableOps for Table { _s: &StubHandle, _from_hardware: bool, ) -> AsicResult> { - Err(AsicError::OperationUnsupported) + Ok(Vec::new()) } fn get_counters( diff --git a/common/src/lib.rs b/common/src/lib.rs index 25ed16b8..39e2d286 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -28,9 +28,22 @@ pub use smf::{SmfError, SmfResult, is_smf_active}; /// The default port on which the Dendrite API server listens. pub const DEFAULT_DPD_PORT: u16 = 12224; -/// The http error code used when a rollback failure occurs. +/// The `error_code` sent in an HTTP error response when a rollback failure +/// occurs. pub const ROLLBACK_FAILURE_ERROR_CODE: &str = "rollback failure"; +/// The `error_code` sent in an HTTP error response when an external multicast +/// group's NAT target (its underlay group) is absent from the switch. +pub const MISSING_NAT_TARGET_ERROR_CODE: &str = "missing nat target"; + +/// The `error_code` sent in an HTTP error response when a multicast group's +/// switch state is unconfirmed and its repair could not complete successfully. +pub const MCAST_REPAIR_PENDING_ERROR_CODE: &str = "multicast repair pending"; + +/// The `error_code` sent in an HTTP error response when a request's tag does +/// not match the multicast group's tag. +pub const MCAST_TAG_MISMATCH_ERROR_CODE: &str = "multicast tag mismatch"; + /// Given two arrays, return two vectors containing only the unique items from each array. pub fn purge_common(a: &[T], b: &[T]) -> (Vec, Vec) where diff --git a/common/src/nat.rs b/common/src/nat.rs index 5e57f8a9..34110493 100644 --- a/common/src/nat.rs +++ b/common/src/nat.rs @@ -4,7 +4,7 @@ // // Copyright 2026 Oxide Computer Company -pub use dpd_types::nat::{Ipv4Nat, Ipv6Nat}; +pub use dpd_types::nat::IpNat; #[cfg(test)] mod tests { @@ -12,9 +12,9 @@ mod tests { #[test] fn test_vni() { - assert!(Vni::new(u32::MAX).is_none()); - assert!(Vni::new(0).is_some()); - assert!(Vni::new(Vni::MAX_VNI).is_some()); - assert!(Vni::new(Vni::MAX_VNI + 1).is_none()); + assert!(Vni::new(u32::MAX).is_err()); + assert!(Vni::new(0).is_ok()); + assert!(Vni::new(Vni::MAX_VNI).is_ok()); + assert!(Vni::new(Vni::MAX_VNI + 1).is_err()); } } diff --git a/common/src/network.rs b/common/src/network.rs index b5f55ae3..032cc299 100644 --- a/common/src/network.rs +++ b/common/src/network.rs @@ -57,8 +57,9 @@ pub enum VlanError { /// Returns [`VlanError::InvalidVlan`] if the ID falls outside `1..=4094`. /// /// [IEEE 802.1Q]: https://ieeexplore.ieee.org/document/10004498 -// TODO: replace with a validated `VlanId` newtype in oxnet, shared with -// omicron's `common/src/vlan.rs` bounds check. +// TODO: replace with oxnet's validated `VlanId` newtype +// (oxidecomputer/oxnet#85), which is shared with omicron's `common/src/vlan.rs` +// bounds check. pub fn validate_vlan(id: impl Into) -> Result<(), VlanError> { let id: u16 = id.into(); if !(1..=4094).contains(&id) { diff --git a/dpd-api/src/lib.rs b/dpd-api/src/lib.rs index 7441178e..5810fa57 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, v3, v4, v7, v8, v9, v14}; 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), (13, ALLOW_DDM_TRAFFIC), (12, PRBS_ERROR_TRACKING), (11, WALLCLOCK_HISTORY), @@ -1313,12 +1314,38 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/nat/ipv6/{ipv6}", + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn nat_ipv6_list( rqctx: RequestContext, path: Path, query: Query>, - ) -> Result>, HttpError>; + ) -> Result< + HttpResponseOk>>, + HttpError, + >; + + /** + * Get all of the external->internal NAT mappings for a given address. + */ + #[endpoint { + method = GET, + path = "/nat/ipv6/{ipv6}", + versions = ..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "nat_ipv6_list", + }] + async fn nat_ipv6_list_v1( + rqctx: RequestContext, + path: Path, + query: Query>, + ) -> Result>, HttpError> { + let HttpResponseOk(page) = + Self::nat_ipv6_list(rqctx, path, query).await?; + Ok(HttpResponseOk(ResultsPage { + items: page.items.into_iter().map(Into::into).collect(), + next_page: page.next_page, + })) + } /** * Get the external->internal NAT mapping for the given address and starting L3 @@ -1327,12 +1354,30 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/nat/ipv6/{ipv6}/{low}", + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn nat_ipv6_get( rqctx: RequestContext, path: Path, ) -> Result, HttpError>; + /** + * Get the external->internal NAT mapping for the given address and starting L3 + * port. + */ + #[endpoint { + method = GET, + path = "/nat/ipv6/{ipv6}/{low}", + versions = ..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "nat_ipv6_get", + }] + async fn nat_ipv6_get_v1( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError> { + Self::nat_ipv6_get(rqctx, path).await.map(|resp| resp.map(Into::into)) + } + /** * Add an external->internal NAT mapping for the given address and L3 port * range. @@ -1348,7 +1393,8 @@ pub trait DpdApi { */ #[endpoint { method = PUT, - path = "/nat/ipv6/{ipv6}/{low}/{high}" + path = "/nat/ipv6/{ipv6}/{low}/{high}", + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn nat_ipv6_create( rqctx: RequestContext, @@ -1356,6 +1402,33 @@ pub trait DpdApi { target: TypedBody, ) -> Result; + /** + * Add an external->internal NAT mapping for the given address and L3 port + * range. + * + * This maps an external IPv6 address and L3 port range to: + * - A gimlet's IPv6 address + * - A gimlet's MAC address + * - A Geneve VNI + * + * These identify the gimlet on which a guest is running, and gives OPTE the + * information it needs to identify the guest VM that uses the external IPv6 + * and port range when making connections outside of an Oxide rack. + */ + #[endpoint { + method = PUT, + path = "/nat/ipv6/{ipv6}/{low}/{high}", + versions = ..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "nat_ipv6_create", + }] + async fn nat_ipv6_create_v1( + rqctx: RequestContext, + path: Path, + target: TypedBody, + ) -> Result { + Self::nat_ipv6_create(rqctx, path, target.map(Into::into)).await + } + /** * Delete the NAT mapping for an IPv6 address and starting L3 port. */ @@ -1397,12 +1470,38 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/nat/ipv4/{ipv4}", + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn nat_ipv4_list( rqctx: RequestContext, path: Path, query: Query>, - ) -> Result>, HttpError>; + ) -> Result< + HttpResponseOk>>, + HttpError, + >; + + /** + * Get all of the external->internal NAT mappings for a given IPv4 address. + */ + #[endpoint { + method = GET, + path = "/nat/ipv4/{ipv4}", + versions = ..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "nat_ipv4_list", + }] + async fn nat_ipv4_list_v1( + rqctx: RequestContext, + path: Path, + query: Query>, + ) -> Result>, HttpError> { + let HttpResponseOk(page) = + Self::nat_ipv4_list(rqctx, path, query).await?; + Ok(HttpResponseOk(ResultsPage { + items: page.items.into_iter().map(Into::into).collect(), + next_page: page.next_page, + })) + } /** * Get the external->internal NAT mapping for the given address/port @@ -1410,12 +1509,29 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/nat/ipv4/{ipv4}/{low}", + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn nat_ipv4_get( rqctx: RequestContext, path: Path, ) -> Result, HttpError>; + /** + * Get the external->internal NAT mapping for the given address/port + */ + #[endpoint { + method = GET, + path = "/nat/ipv4/{ipv4}/{low}", + versions = ..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "nat_ipv4_get", + }] + async fn nat_ipv4_get_v1( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError> { + Self::nat_ipv4_get(rqctx, path).await.map(|resp| resp.map(Into::into)) + } + /** * Add an external->internal NAT mapping for the given address/port range * @@ -1431,7 +1547,8 @@ pub trait DpdApi { #[endpoint { method = PUT, - path = "/nat/ipv4/{ipv4}/{low}/{high}" + path = "/nat/ipv4/{ipv4}/{low}/{high}", + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn nat_ipv4_create( rqctx: RequestContext, @@ -1439,6 +1556,32 @@ pub trait DpdApi { target: TypedBody, ) -> Result; + /** + * Add an external->internal NAT mapping for the given address/port range + * + * This maps an external IPv6 address and L3 port range to: + * - A gimlet's IPv6 address + * - A gimlet's MAC address + * - A Geneve VNI + * + * These identify the gimlet on which a guest is running, and gives OPTE the + * information it needs to identify the guest VM that uses the external IPv6 + * and port range when making connections outside of an Oxide rack. + */ + #[endpoint { + method = PUT, + path = "/nat/ipv4/{ipv4}/{low}/{high}", + versions = ..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "nat_ipv4_create", + }] + async fn nat_ipv4_create_v1( + rqctx: RequestContext, + path: Path, + target: TypedBody, + ) -> Result { + Self::nat_ipv4_create(rqctx, path, target.map(Into::into)).await + } + /** * Clear the NAT mappings for an IPv4 address and starting L3 port. */ @@ -1468,7 +1611,7 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/attached_subnet", - versions = VERSION_ATTACHED_SUBNETS.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn attached_subnet_list( rqctx: RequestContext, @@ -1485,19 +1628,63 @@ pub trait DpdApi { HttpError, >; + /** + * Get all of the external subnets with internal mappings + */ + #[endpoint { + method = GET, + path = "/attached_subnet", + versions = VERSION_ATTACHED_SUBNETS..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "attached_subnet_list", + }] + async fn attached_subnet_list_v3( + rqctx: RequestContext, + query: Query< + PaginationParams, + >, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + let HttpResponseOk(page) = + Self::attached_subnet_list(rqctx, query).await?; + Ok(HttpResponseOk(ResultsPage { + items: page.items.into_iter().map(Into::into).collect(), + next_page: page.next_page, + })) + } + /** * Get the mapping for the given external subnet. */ #[endpoint { method = GET, path = "/attached_subnet/{subnet}", - versions = VERSION_ATTACHED_SUBNETS.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn attached_subnet_get( rqctx: RequestContext, path: Path, ) -> Result, HttpError>; + /** + * Get the mapping for the given external subnet. + */ + #[endpoint { + method = GET, + path = "/attached_subnet/{subnet}", + versions = VERSION_ATTACHED_SUBNETS..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "attached_subnet_get", + }] + async fn attached_subnet_get_v3( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError> { + Self::attached_subnet_get(rqctx, path) + .await + .map(|resp| resp.map(Into::into)) + } + /** * Add a mapping to an internal target for an external subnet address. * @@ -1508,7 +1695,7 @@ pub trait DpdApi { #[endpoint { method = PUT, path = "/attached_subnet/{subnet}", - versions = VERSION_ATTACHED_SUBNETS.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn attached_subnet_create( rqctx: RequestContext, @@ -1516,6 +1703,27 @@ pub trait DpdApi { target: TypedBody, ) -> Result; + /** + * Add a mapping to an internal target for an external subnet address. + * + * These identify the gimlet on which a guest is running, and gives OPTE the + * information it needs to identify the guest VM that uses the external + * subnet. + */ + #[endpoint { + method = PUT, + path = "/attached_subnet/{subnet}", + versions = VERSION_ATTACHED_SUBNETS..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "attached_subnet_create", + }] + async fn attached_subnet_create_v3( + rqctx: RequestContext, + path: Path, + target: TypedBody, + ) -> Result { + Self::attached_subnet_create(rqctx, path, target.map(Into::into)).await + } + /** * Delete the mapping for an external subnet */ @@ -1905,7 +2113,7 @@ pub trait DpdApi { ) -> Result, HttpError> { Self::table_dump( rqctx, - Query::from(latest::snapshot::TableDumpOptions { + Query::from(v9::snapshot::TableDumpOptions { from_hardware: false, }), path, @@ -1971,14 +2179,24 @@ 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 carry either IPv4 or IPv6 traffic and share their + * NAT target's replication groups. Any admitted IPv6 scope can be used, + * including admin-local, except for the reserved underlay subnet of + * ff04::/64, which belongs specifically to the internal multicast API. + * + * The group address determines whether the group is any-source (ASM) or + * source-specific (SSM). An SSM group requires at least one exact source. + * + * These groups do not carry member subscribers, as membership lives + * only on the underlay group mapped to by the required NAT target. + * + * The NAT target must reference an existing underlay group. The underlay + * group cannot already be referenced by another external group. */ #[endpoint { method = POST, path = "/multicast/external-groups", - versions = VERSION_MCAST_STRICT_UNDERLAY.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn multicast_group_create_external( rqctx: RequestContext, @@ -1988,6 +2206,39 @@ 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, + operation_id = "multicast_group_create_external", + }] + async fn multicast_group_create_external_v8( + rqctx: RequestContext, + group: TypedBody, + ) -> Result< + HttpResponseCreated, + HttpError, + > { + let body = group + .try_map(|entry| { + v14::mcast::MulticastGroupCreateExternalEntry::try_from(entry) + }) + .map_err(|e: v14::mcast::MulticastGroupCreateExternalError| { + HttpError::for_bad_request(None, e.to_string()) + })?; + + Self::multicast_group_create_external(rqctx, body) + .await + .map(|resp| resp.map(Into::into)) + } + /// Create an external-only multicast group configuration. #[endpoint { method = POST, @@ -2002,7 +2253,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)) } @@ -2068,10 +2319,9 @@ pub trait DpdApi { > { let v4_body = group .try_map(|entry| { - let group_ip = latest::mcast::UnderlayMulticastIpv6::try_from( - entry.group_ip, - )?; - Ok(latest::mcast::MulticastGroupCreateUnderlayEntry { + let group_ip = + v8::mcast::UnderlayMulticastIpv6::try_from(entry.group_ip)?; + Ok(v8::mcast::MulticastGroupCreateUnderlayEntry { group_ip, tag: entry.tag, members: entry.members, @@ -2135,13 +2385,32 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/multicast/groups/{group_ip}", - versions = VERSION_MCAST_STRICT_UNDERLAY.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn multicast_group_get( rqctx: RequestContext, path: Path, ) -> Result, HttpError>; + /** + * Get the multicast group configuration for a given group IP address. + */ + #[endpoint { + method = GET, + path = "/multicast/groups/{group_ip}", + versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "multicast_group_get", + }] + async fn multicast_group_get_v8( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError> + { + Self::multicast_group_get(rqctx, path) + .await + .map(|resp| resp.map(Into::into)) + } + /// Get the multicast group configuration for a given group IP address. #[endpoint { method = GET, @@ -2154,7 +2423,7 @@ pub trait DpdApi { path: Path, ) -> Result, HttpError> { - Self::multicast_group_get(rqctx, path) + Self::multicast_group_get_v8(rqctx, path) .await .map(|resp| resp.map(Into::into)) } @@ -2213,8 +2482,8 @@ pub trait DpdApi { HttpError, > { let v4_path = path.try_map(|p| { - latest::mcast::UnderlayMulticastIpv6::try_from(p.group_ip) - .map(|group_ip| latest::mcast::MulticastUnderlayGroupIpParam { + v8::mcast::UnderlayMulticastIpv6::try_from(p.group_ip) + .map(|group_ip| v8::mcast::MulticastUnderlayGroupIpParam { group_ip, }) .map_err(|e| { @@ -2278,19 +2547,27 @@ 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 carry IPv4 or IPv6 traffic and share their NAT + * target's replication groups. Any admitted IPv6 scope can be used, + * including admin-local, except for the reserved underlay subnet of + * ff04::/64, which belongs to the internal multicast API. + * + * The path address must be a valid external multicast address. + * + * Omitting `sources` preserves the group's existing source filter. * * The `tag` query parameter must match the group's existing tag. + * The NAT target must reference an existing underlay group. The underlay + * group cannot be referenced by another external group. */ #[endpoint { method = PUT, path = "/multicast/external-groups/{group_ip}", - versions = VERSION_MCAST_STRICT_UNDERLAY.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn multicast_group_update_external( rqctx: RequestContext, - path: Path, + path: Path, query: Query, group: TypedBody, ) -> Result< @@ -2298,6 +2575,47 @@ 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, + operation_id = "multicast_group_update_external", + }] + async fn multicast_group_update_external_v8( + rqctx: RequestContext, + path: Path, + query: Query, + group: TypedBody, + ) -> Result< + HttpResponseOk, + HttpError, + > { + let path = path.try_map(|param| { + v14::mcast::MulticastExternalGroupIpParam::try_from(param) + .map_err(|e| HttpError::for_bad_request(None, e.to_string())) + })?; + + let group = group + .try_map(|entry| { + v14::mcast::MulticastGroupUpdateExternalEntry::try_from(entry) + }) + .map_err(|e: v14::mcast::MulticastGroupCreateExternalError| { + HttpError::for_bad_request(None, e.to_string()) + })?; + + Self::multicast_group_update_external(rqctx, path, query, group) + .await + .map(|resp| resp.map(Into::into)) + } + /** * Update an external-only multicast group configuration. * @@ -2351,7 +2669,7 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/multicast/groups", - versions = VERSION_MCAST_STRICT_UNDERLAY.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn multicast_groups_list( rqctx: RequestContext, @@ -2366,6 +2684,32 @@ pub trait DpdApi { HttpError, >; + /** + * List all multicast groups. + */ + #[endpoint { + method = GET, + path = "/multicast/groups", + versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "multicast_groups_list", + }] + async fn multicast_groups_list_v8( + rqctx: RequestContext, + query_params: Query< + PaginationParams, + >, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + let HttpResponseOk(page) = + Self::multicast_groups_list(rqctx, query_params).await?; + Ok(HttpResponseOk(ResultsPage { + items: page.items.into_iter().map(Into::into).collect(), + next_page: page.next_page, + })) + } + /// List all multicast groups. #[endpoint { method = GET, @@ -2383,7 +2727,7 @@ pub trait DpdApi { HttpError, > { let HttpResponseOk(page) = - Self::multicast_groups_list(rqctx, query_params).await?; + Self::multicast_groups_list_v8(rqctx, query_params).await?; Ok(HttpResponseOk(ResultsPage { items: page.items.into_iter().map(Into::into).collect(), next_page: page.next_page, @@ -2426,7 +2770,7 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/multicast/tags/{tag}", - versions = VERSION_MCAST_STRICT_UNDERLAY.., + versions = VERSION_MCAST_EXTERNAL_SCOPE.., }] async fn multicast_groups_list_by_tag( rqctx: RequestContext, @@ -2442,6 +2786,38 @@ pub trait DpdApi { HttpError, >; + /** + * List all multicast groups with a given tag. + * + * Returns paginated multicast groups matching the specified tag. Tags are + * assigned at group creation and are immutable. Use this endpoint to find + * all groups associated with a specific client or component. + */ + #[endpoint { + method = GET, + path = "/multicast/tags/{tag}", + versions = VERSION_MCAST_STRICT_UNDERLAY..VERSION_MCAST_EXTERNAL_SCOPE, + operation_id = "multicast_groups_list_by_tag", + }] + async fn multicast_groups_list_by_tag_v8( + rqctx: RequestContext, + path: Path, + query_params: Query< + PaginationParams, + >, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + let HttpResponseOk(page) = + Self::multicast_groups_list_by_tag(rqctx, path, query_params) + .await?; + Ok(HttpResponseOk(ResultsPage { + items: page.items.into_iter().map(Into::into).collect(), + next_page: page.next_page, + })) + } + /// List all multicast groups with a given tag. #[endpoint { method = GET, @@ -2459,7 +2835,7 @@ pub trait DpdApi { HttpResponseOk>, HttpError, > { - let HttpResponseOk(page) = Self::multicast_groups_list_by_tag( + let HttpResponseOk(page) = Self::multicast_groups_list_by_tag_v8( rqctx, path.map(Into::into), query_params, @@ -2553,7 +2929,7 @@ pub trait DpdApi { * Delete all multicast groups (and associated routes) without a tag. */ // Required method: the latest version always returns 410 Gone, while v1 - // actually deletes untagged groups. The semantic behavior is different. + // returns 204 with no effect now that every group has a tag. #[endpoint { method = DELETE, path = "/multicast/untagged", diff --git a/dpd-client/Cargo.toml b/dpd-client/Cargo.toml index 71dca280..730c8e50 100644 --- a/dpd-client/Cargo.toml +++ b/dpd-client/Cargo.toml @@ -25,6 +25,7 @@ transceiver-controller = { workspace = true, features = [ "api-traits" ] } uuid.workspace = true common.workspace = true +dpd-types-versions.workspace = true progenitor.workspace = true progenitor-extras = { workspace = true, features = ["slog"] } diff --git a/dpd-client/src/lib.rs b/dpd-client/src/lib.rs index 9e86eeee..52856d51 100644 --- a/dpd-client/src/lib.rs +++ b/dpd-client/src/lib.rs @@ -6,10 +6,13 @@ //! Client library for the Dendrite data plane daemon. -pub use common::ROLLBACK_FAILURE_ERROR_CODE; use common::counters; use common::network; use common::ports; +pub use common::{ + MCAST_REPAIR_PENDING_ERROR_CODE, MCAST_TAG_MISMATCH_ERROR_CODE, + MISSING_NAT_TARGET_ERROR_CODE, ROLLBACK_FAILURE_ERROR_CODE, +}; use slog::Logger; use std::cmp::Ordering; use std::fmt; @@ -37,10 +40,20 @@ progenitor::generate_api!( derives = [ PartialEq ], patch = { PortId = {derives = [PartialOrd, Ord, PartialEq, Eq, Hash, Clone] }, + MulticastGroupMember = {derives = [Eq, Hash] }, }, crates = { "oxnet" = "0.1.0", }, + // Replace the generated newtypes for these validated types with the + // server's own set, ensuring clients apply the same checks when + // constructing or parsing them. + replace = { + ExternalMulticastIp = dpd_types_versions::latest::mcast::ExternalMulticastIp, + MulticastTag = dpd_types_versions::latest::mcast::MulticastTag, + UnderlayMulticastIpv6 = dpd_types_versions::latest::mcast::UnderlayMulticastIpv6, + Vni = dpd_types_versions::latest::network::Vni, + }, ); impl progenitor::progenitor_client::ClientHooks for Client { @@ -307,46 +320,23 @@ impl From for types::RMonCounters { } } -impl TryFrom for network::Vni { - type Error = String; - - fn try_from(t: types::Vni) -> Result { - network::Vni::new(t.0) - .ok_or_else(|| String::from("VNI is out of valid range")) - } -} - -impl From for types::Vni { - fn from(t: network::Vni) -> types::Vni { - types::Vni(t.as_u32()) - } -} - -impl TryFrom for network::InstanceTarget { - type Error = String; - - fn try_from( - t: types::InstanceTarget, - ) -> Result { - Ok(Self { +impl From for network::InstanceTarget { + fn from(t: types::InstanceTarget) -> Self { + Self { internal_ip: t.internal_ip, inner_mac: t.inner_mac.into(), - vni: t.vni.try_into()?, - }) + vni: t.vni, + } } } -impl TryFrom for network::NatTarget { - type Error = String; - - fn try_from( - t: types::NatTarget, - ) -> Result { - Ok(Self { +impl From for network::NatTarget { + fn from(t: types::NatTarget) -> Self { + Self { internal_ip: t.internal_ip, inner_mac: t.inner_mac.into(), - vni: t.vni.try_into()?, - }) + vni: t.vni, + } } } diff --git a/dpd-client/tests/chaos_tests/port_settings.rs b/dpd-client/tests/chaos_tests/port_settings.rs index bd781779..669ecc6b 100644 --- a/dpd-client/tests/chaos_tests/port_settings.rs +++ b/dpd-client/tests/chaos_tests/port_settings.rs @@ -9,6 +9,7 @@ use super::harness::{ new_dpd_client, run_dpd, }; use super::util::{link_list_ipv4, link_list_ipv6}; +use anyhow::Context; use asic::chaos::{AsicConfig, Chaos, TableChaos}; use asic::table_chaos; use common::table::TableType; @@ -395,7 +396,13 @@ async fn test_port_settings_txn_sweep() -> anyhow::Result<()> { } } }) - .await?; + .await + .with_context(|| { + format!( + "after {success} applied, {fail} failed, \ + {rollback_fail} rollback-failed iterations" + ) + })?; print!("operation succeeded, settings changed as expected"); success += 1; } @@ -483,15 +490,11 @@ async fn test_port_settings_txn_par_sweep() -> anyhow::Result<()> { } fn is_rollback_error(e: &dpd_client::Error) -> bool { - if e.status() != Some(StatusCode::INTERNAL_SERVER_ERROR) { - return false; - } - if let dpd_client::Error::ErrorResponse(err) = e - && err.error_code == Some(ROLLBACK_FAILURE_ERROR_CODE.into()) - { - return true; - } - false + matches!( + e, + dpd_client::Error::ErrorResponse(err) + if err.error_code.as_deref() == Some(ROLLBACK_FAILURE_ERROR_CODE) + ) } async fn current_port_settings( diff --git a/dpd-client/tests/integration_tests/attached_subnet.rs b/dpd-client/tests/integration_tests/attached_subnet.rs index 43a85180..e264207b 100644 --- a/dpd-client/tests/integration_tests/attached_subnet.rs +++ b/dpd-client/tests/integration_tests/attached_subnet.rs @@ -138,7 +138,7 @@ async fn test_egress(switch: &Switch, test: &ExternalTest) -> TestResult { let tgt = types::InstanceTarget { internal_ip: test.gimlet_ip.parse().unwrap(), inner_mac: test.instance_mac.parse::()?.into(), - vni: test.geneve_vni.into(), + vni: test.geneve_vni.try_into().unwrap(), }; switch.client.attached_subnet_create(&attached_subnet, &tgt).await.unwrap(); @@ -226,7 +226,7 @@ async fn test_ingress(switch: &Switch, test: &ExternalTest) -> TestResult { let tgt = types::InstanceTarget { internal_ip: test.gimlet_ip.parse().unwrap(), inner_mac: test.instance_mac.parse::()?.into(), - vni: test.geneve_vni.into(), + vni: test.geneve_vni.try_into().unwrap(), }; switch .client diff --git a/dpd-client/tests/integration_tests/mcast.rs b/dpd-client/tests/integration_tests/mcast.rs index 3c2ed0b1..e076d499 100644 --- a/dpd-client/tests/integration_tests/mcast.rs +++ b/dpd-client/tests/integration_tests/mcast.rs @@ -7,50 +7,52 @@ use std::{ collections::HashSet, net::{IpAddr, Ipv4Addr, Ipv6Addr}, - sync::Arc, + sync::{Arc, LazyLock}, }; -use crate::integration_tests::common; -use crate::integration_tests::common::prelude::*; -use crate::integration_tests::counters::check_counter_incremented; -use ::common::counters::SERVICE_PORT_LABEL; -use ::common::network::MacAddr; use anyhow::anyhow; -use dpd_client::{Error, types}; use futures::TryStreamExt; use oxnet::MulticastMac; -use packet::{Endpoint, eth, geneve, ipv4, ipv6, sidecar, udp}; + +use ::common::counters::SERVICE_PORT_LABEL; +use ::common::network::MacAddr; +use dpd_client::Error; +use dpd_client::MCAST_TAG_MISMATCH_ERROR_CODE; +use dpd_client::MISSING_NAT_TARGET_ERROR_CODE; +use dpd_client::types; +use dpd_types::mcast::{ + ExternalMulticastIp, MulticastTag, UnderlayMulticastIpv6, +}; +use packet::{Endpoint, L4Endpoint, eth, geneve, ipv4, ipv6, sidecar, udp}; + +use crate::integration_tests::common; +use crate::integration_tests::common::prelude::*; +use crate::integration_tests::counters::check_counter_incremented; const MULTICAST_TEST_IPV4: Ipv4Addr = Ipv4Addr::new(224, 0, 1, 0); const MULTICAST_TEST_IPV6: Ipv6Addr = Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 1, 0x1010); 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"; +const GIMLET_MAC: [u8; 6] = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]; const GIMLET_IP: Ipv6Addr = Ipv6Addr::new(0xfd00, 0x1122, 0x7788, 0x0101, 0, 0, 0, 4); /// Tag used for multicast group validation in tests. -const TEST_TAG: &str = "mcast_integration_test"; - -// Tag validation test consts -const TAG_A: &str = "tag_a"; -const TAG_B: &str = "tag_b"; -const TAG_WRONG: &str = "wrong_tag"; -const TAG_DIFFERENT: &str = "different_tag"; - -trait ToIpAddr { - fn to_ip_addr(&self) -> IpAddr; -} - -impl ToIpAddr for types::UnderlayMulticastIpv6 { - fn to_ip_addr(&self) -> IpAddr { - IpAddr::V6(self.0) - } -} +static TEST_TAG: LazyLock = + LazyLock::new(|| "mcast_integration_test".parse().unwrap()); + +static TAG_A: LazyLock = + LazyLock::new(|| "tag_a".parse().unwrap()); +static TAG_B: LazyLock = + LazyLock::new(|| "tag_b".parse().unwrap()); +static TAG_WRONG: LazyLock = + LazyLock::new(|| "wrong_tag".parse().unwrap()); +static TAG_DIFFERENT: LazyLock = + LazyLock::new(|| "different_tag".parse().unwrap()); /// Count table entries matching a specific IP address in any key field. fn count_entries_for_ip(entries: &[types::TableEntry], ip: &str) -> usize { @@ -76,9 +78,9 @@ fn has_vlan_action_for_ip( async fn create_test_multicast_group( switch: &Switch, group_ip: IpAddr, - tag: Option<&str>, + tag: Option<&MulticastTag>, ports: &[(PhysPort, types::Direction)], - internal_forwarding: types::InternalForwarding, + nat_target: Option, external_forwarding: types::ExternalForwarding, sources: Option>, ) -> types::MulticastGroupResponse { @@ -98,9 +100,13 @@ async fn create_test_multicast_group( IpAddr::V4(_) => { // IPv4 groups are always external and require NAT targets let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip, - tag: tag.map(String::from), - internal_forwarding, + group_ip: group_ip.try_into().unwrap(), + tag: tag.map(ToString::to_string), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.expect( + "external multicast group requires a NAT target", + ), + }, external_forwarding, sources, }; @@ -126,10 +132,11 @@ async fn create_test_multicast_group( .is_admin_local_multicast() { // Admin-local IPv6 groups are internal - let admin_local_ip = types::UnderlayMulticastIpv6(ipv6); + let admin_local_ip = + UnderlayMulticastIpv6::try_from(ipv6).unwrap(); let internal_entry = types::MulticastGroupCreateUnderlayEntry { group_ip: admin_local_ip, - tag: tag.map(String::from), + tag: tag.map(ToString::to_string), members, }; @@ -150,9 +157,13 @@ async fn create_test_multicast_group( } else { // Non-admin-local IPv6 groups are external-only and require NAT targets let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip, - tag: tag.map(String::from), - internal_forwarding, + group_ip: group_ip.try_into().unwrap(), + tag: tag.map(ToString::to_string), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.expect( + "external multicast group requires a NAT target", + ), + }, external_forwarding, sources, }; @@ -177,20 +188,15 @@ async fn create_test_multicast_group( } } -fn make_tag(tag: &str) -> types::MulticastTag { - tag.parse().expect("tag should parse") -} - /// Clean up a test group, failing if it cannot be deleted properly. async fn cleanup_test_group( switch: &Switch, group_ip: IpAddr, - tag: &str, + tag: &MulticastTag, ) -> TestResult { - let del_tag = make_tag(tag); switch .client - .multicast_group_delete(&group_ip, &del_tag) + .multicast_group_delete(&group_ip, tag) .await .map_err(|e| { anyhow!("Failed to delete test group {}: {:?}", group_ip, e) @@ -201,9 +207,11 @@ async fn cleanup_test_group( fn get_group_ip(response: &types::MulticastGroupResponse) -> IpAddr { match response { types::MulticastGroupResponse::Underlay { group_ip, .. } => { - group_ip.to_ip_addr() + (*group_ip).into() + } + types::MulticastGroupResponse::External { group_ip, .. } => { + (*group_ip).into() } - types::MulticastGroupResponse::External { group_ip, .. } => *group_ip, } } @@ -253,12 +261,12 @@ fn get_sources( fn get_nat_target( response: &types::MulticastGroupResponse, -) -> Option<&types::NatTarget> { +) -> Option<&types::ExternalNatTarget> { match response { types::MulticastGroupResponse::Underlay { .. } => None, types::MulticastGroupResponse::External { internal_forwarding, .. - } => internal_forwarding.nat_target.as_ref(), + } => Some(&internal_forwarding.nat_target), } } @@ -269,19 +277,19 @@ fn get_tag(response: &types::MulticastGroupResponse) -> &String { } } -fn create_nat_target_ipv4() -> types::NatTarget { - types::NatTarget { - internal_ip: MULTICAST_NAT_IP, +fn create_nat_target_ipv4() -> types::ExternalNatTarget { + types::ExternalNatTarget { + internal_ip: MULTICAST_NAT_IP.try_into().unwrap(), inner_mac: MacAddr::new(0x11, 0x22, 0x33, 0x44, 0x55, 0x66).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), } } -fn create_nat_target_ipv6() -> types::NatTarget { - types::NatTarget { - internal_ip: MULTICAST_NAT_IP, +fn create_nat_target_ipv6() -> types::ExternalNatTarget { + types::ExternalNatTarget { + internal_ip: MULTICAST_NAT_IP.try_into().unwrap(), inner_mac: MacAddr::new(0x11, 0x22, 0x33, 0x44, 0x55, 0x66).into(), - vni: 101.into(), + vni: 101.try_into().unwrap(), } } @@ -309,15 +317,17 @@ fn create_ipv4_multicast_packet( ]; let multicast_mac = MacAddr::from(mac_bytes); - let src_endpoint = - Endpoint::parse(&src_mac.to_string(), src_ip, src_port).unwrap(); + let src_endpoint = Endpoint::L4(L4Endpoint { + mac: src_mac, + ip: src_ip.parse().unwrap(), + port: src_port, + }); - let dst_endpoint = Endpoint::parse( - &multicast_mac.to_string(), - &multicast_ip.to_string(), - dst_port, - ) - .unwrap(); + let dst_endpoint = Endpoint::L4(L4Endpoint { + mac: multicast_mac, + ip: multicast_ip.into(), + port: dst_port, + }); let mut pkt = common::gen_udp_packet(src_endpoint, dst_endpoint); if let Some(vlan_id) = vlan_id { @@ -355,15 +365,17 @@ fn create_ipv6_multicast_packet( ]; let multicast_mac = MacAddr::from(mac_bytes); - let src_endpoint = - Endpoint::parse(&src_mac.to_string(), src_ip, src_port).unwrap(); + let src_endpoint = Endpoint::L4(L4Endpoint { + mac: src_mac, + ip: src_ip.parse().unwrap(), + port: src_port, + }); - let dst_endpoint = Endpoint::parse( - &multicast_mac.to_string(), - &multicast_ip.to_string(), - dst_port, - ) - .unwrap(); + let dst_endpoint = Endpoint::L4(L4Endpoint { + mac: multicast_mac, + ip: multicast_ip.into(), + port: dst_port, + }); let mut pkt = common::gen_udp_packet(src_endpoint, dst_endpoint); if let Some(vlan_id) = vlan_id { @@ -381,8 +393,8 @@ fn prepare_expected_pkt( switch: &Switch, send_pkt: &packet::Packet, vlan: Option, - nat_target: Option<&types::NatTarget>, - switch_port: Option, + nat_target: Option<&types::ExternalNatTarget>, + switch_port: PhysPort, ) -> packet::Packet { match nat_target { Some(nat) => { @@ -398,29 +410,27 @@ fn prepare_expected_pkt( encapped.deparse().unwrap().to_vec() }; - let switch_port_mac = - switch.get_port_mac(switch_port.unwrap()).unwrap().to_string(); + let switch_port_mac = switch.get_port_mac(switch_port).unwrap(); let mut forward_pkt = common::gen_external_geneve_packet( - Endpoint::parse( - &switch_port_mac, - "::0", - common::tofino_flow_hash( + Endpoint::L4(L4Endpoint { + mac: switch_port_mac, + ip: Ipv6Addr::UNSPECIFIED.into(), + port: common::tofino_flow_hash( send_pkt.src().unwrap(), send_pkt.dst().unwrap(), L4Protocol::Udp, ), - ) - .unwrap(), - Endpoint::parse( - &MacAddr::from(nat.internal_ip.derive_multicast_mac()) - .to_string(), - &nat.internal_ip.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), + }), + Endpoint::L4(L4Endpoint { + mac: Ipv6Addr::from(nat.internal_ip) + .derive_multicast_mac() + .into(), + ip: nat.internal_ip.into(), + port: geneve::GENEVE_UDP_PORT, + }), eth::ETHER_ETHER, - *nat.vni, + nat.vni.as_u32(), &ingress_payload, ); @@ -451,11 +461,8 @@ fn prepare_expected_pkt( } // Rewrite src mac - if let Some(port) = switch_port { - let port_mac = switch.get_port_mac(port).unwrap(); - recv_pkt.hdrs.eth_hdr.as_mut().unwrap().eth_smac = - port_mac.clone(); - } + let port_mac = switch.get_port_mac(switch_port).unwrap(); + recv_pkt.hdrs.eth_hdr.as_mut().unwrap().eth_smac = port_mac.clone(); recv_pkt } @@ -504,9 +511,9 @@ async fn test_group_creation_with_validation() -> TestResult { let internal_group = create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(egress1, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -521,10 +528,10 @@ async fn test_group_creation_with_validation() -> TestResult { // Test creating a group with invalid parameters (e.g., invalid VLAN ID) // IPv4 groups are always external let external_invalid = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4(MULTICAST_TEST_IPV4), + group_ip: IpAddr::V4(MULTICAST_TEST_IPV4).try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(4096), // Invalid: VLAN ID must be 1-4094 @@ -551,10 +558,10 @@ async fn test_group_creation_with_validation() -> TestResult { // Test with the null VLAN ID 0 (also invalid) let external_vlan0 = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4(MULTICAST_TEST_IPV4), + group_ip: IpAddr::V4(MULTICAST_TEST_IPV4).try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(0), // Invalid: VLAN 0 is the null VID @@ -581,10 +588,10 @@ async fn test_group_creation_with_validation() -> TestResult { // Test with reserved VLAN ID 4095 (also invalid) let external_vlan4095 = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4(MULTICAST_TEST_IPV4), + group_ip: IpAddr::V4(MULTICAST_TEST_IPV4).try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(4095), // Invalid: VLAN 4095 is reserved @@ -612,10 +619,10 @@ async fn test_group_creation_with_validation() -> TestResult { // Test with valid parameters // IPv4 groups are always external let external_valid = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4(MULTICAST_TEST_IPV4_SSM), + group_ip: IpAddr::V4(MULTICAST_TEST_IPV4_SSM).try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![types::IpSrc::Exact( @@ -636,12 +643,12 @@ async fn test_group_creation_with_validation() -> TestResult { "External group should reference the same external group ID as the internal group" ); - assert_eq!(created.group_ip, MULTICAST_TEST_IPV4_SSM); - assert_eq!(created.tag, TEST_TAG); assert_eq!( - created.internal_forwarding.nat_target, - Some(nat_target.clone()) + IpAddr::from(created.group_ip), + IpAddr::V4(MULTICAST_TEST_IPV4_SSM) ); + assert_eq!(created.tag, TEST_TAG.as_ref()); + assert_eq!(created.internal_forwarding.nat_target, nat_target.clone()); assert_eq!(created.external_forwarding.vlan_id, Some(10)); assert_eq!( created.sources, @@ -651,13 +658,15 @@ async fn test_group_creation_with_validation() -> TestResult { ); // Clean up external first (references internal via NAT target), then internal - cleanup_test_group(switch, created.group_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, created.group_ip.into(), &TEST_TAG) + .await + .unwrap(); + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] #[ignore] -async fn test_internal_ipv6_validation() -> TestResult { +async fn test_underlay_ipv6_validation() -> TestResult { let switch = &*get_switch().await; let (port_id, link_id) = switch.link_id(PhysPort(15)).unwrap(); @@ -698,16 +707,16 @@ async fn test_internal_ipv6_validation() -> TestResult { .client .multicast_group_update_underlay( &created.group_ip, - &make_tag(TEST_TAG), + &TEST_TAG, &update_entry, ) .await .expect("Should update internal IPv6 group") .into_inner(); - assert_eq!(updated.tag, TEST_TAG); + assert_eq!(updated.tag, TEST_TAG.as_ref()); - cleanup_test_group(switch, created.group_ip.to_ip_addr(), TEST_TAG).await + cleanup_test_group(switch, IpAddr::from(created.group_ip), &TEST_TAG).await } #[tokio::test] @@ -743,17 +752,17 @@ async fn test_vlan_propagation_to_internal() -> TestResult { .into_inner(); // Create external group that references the admin-scoped group - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: "ff04::200".parse().unwrap(), // References admin-scoped group inner_mac: MacAddr::new(0x03, 0x00, 0x00, 0x00, 0x00, 0x03).into(), - vni: 200.into(), + vni: 200.try_into().unwrap(), }; let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4("224.1.2.3".parse().unwrap()), + group_ip: "224.1.2.3".parse().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(42) }, // This VLAN should be used by admin-scoped group sources: None, @@ -768,13 +777,8 @@ async fn test_vlan_propagation_to_internal() -> TestResult { assert_eq!(created_external.external_forwarding.vlan_id, Some(42)); assert_eq!( - created_external - .internal_forwarding - .nat_target - .as_ref() - .unwrap() - .internal_ip, - "ff04::200".parse::().unwrap() + created_external.internal_forwarding.nat_target.internal_ip, + "ff04::200".parse::().unwrap() ); // Verify IPv4 route table has one entry for the VLAN group. @@ -831,20 +835,133 @@ async fn test_vlan_propagation_to_internal() -> TestResult { // Verify the admin-scoped group's bitmap entry has VLAN 42 from external // group propagation. - assert!( - bitmap_table - .entries - .iter() - .any(|entry| entry.action_args.values().any(|v| v.contains("42"))), - "Admin-scoped group bitmap should have VLAN 42 from external group" + let bitmap_entry = bitmap_table + .entries + .iter() + .find(|entry| { + entry.keys.get("mcast_external_grp") + == Some(&created_admin.external_group_id.to_string()) + }) + .expect("Internal group bitmap entry should exist"); + assert_eq!(bitmap_entry.action, "set_decap_ports_and_vlan"); + assert_eq!( + bitmap_entry.action_args.get("vlan_id").map(String::as_str), + Some("42"), ); + let second_admin = switch + .client + .multicast_group_create_underlay( + &types::MulticastGroupCreateUnderlayEntry { + group_ip: "ff04::201".parse().unwrap(), + ..internal_group_entry.clone() + }, + ) + .await? + .into_inner(); + + let mut port_args = bitmap_entry.action_args.clone(); + port_args.remove("vlan_id"); + let mut update = types::MulticastGroupUpdateExternalEntry { + internal_forwarding: external_group.internal_forwarding.clone(), + external_forwarding: external_group.external_forwarding.clone(), + sources: None, + }; + + for (target, vlan_id) in [ + (&second_admin, Some(42)), + (&created_admin, None), + (&created_admin, Some(42)), + ] { + update.internal_forwarding.nat_target.internal_ip = target.group_ip; + update.external_forwarding.vlan_id = vlan_id; + let updated = switch + .client + .multicast_group_update_external( + &created_external.group_ip, + &TEST_TAG, + &update, + ) + .await? + .into_inner(); + assert_eq!(updated.external_group_id, target.external_group_id); + assert_eq!(updated.internal_forwarding, update.internal_forwarding); + assert_eq!(updated.external_forwarding.vlan_id, vlan_id); + + let bitmaps = switch + .client + .table_dump("Egress.mcast_egress.tbl_decap_ports", false) + .await? + .into_inner(); + + for group in [&created_admin, &second_admin] { + let entry = bitmaps + .entries + .iter() + .find(|entry| { + entry.keys.get("mcast_external_grp") + == Some(&group.external_group_id.to_string()) + }) + .unwrap(); + let group_vlan = + if group.external_group_id == target.external_group_id { + vlan_id + } else { + None + }; + let mut expected_args = port_args.clone(); + let expected_action = match group_vlan { + Some(vlan_id) => { + expected_args.insert("vlan_id".into(), vlan_id.to_string()); + "set_decap_ports_and_vlan" + } + None => "set_decap_ports", + }; + assert_eq!(entry.action, expected_action); + assert_eq!(entry.action_args, expected_args); + } + } + // Delete external group first since it references the internal group via // NAT target - cleanup_test_group(switch, created_external.group_ip, TEST_TAG) + cleanup_test_group(switch, created_external.group_ip.into(), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, created_admin.group_ip.to_ip_addr(), TEST_TAG) + + let recreated = switch + .client + .multicast_group_create_external( + &types::MulticastGroupCreateExternalEntry { + external_forwarding: types::ExternalForwarding { + vlan_id: None, + }, + ..external_group + }, + ) + .await? + .into_inner(); + assert_eq!(recreated.external_group_id, created_admin.external_group_id); + assert_eq!(recreated.external_forwarding.vlan_id, None); + + let bitmaps = switch + .client + .table_dump("Egress.mcast_egress.tbl_decap_ports", false) + .await? + .into_inner(); + let entry = bitmaps + .entries + .iter() + .find(|entry| { + entry.keys.get("mcast_external_grp") + == Some(&created_admin.external_group_id.to_string()) + }) + .unwrap(); + assert_eq!(entry.action, "set_decap_ports"); + assert_eq!(entry.action_args, port_args); + cleanup_test_group(switch, recreated.group_ip.into(), &TEST_TAG).await?; + cleanup_test_group(switch, IpAddr::from(second_admin.group_ip), &TEST_TAG) + .await?; + cleanup_test_group(switch, IpAddr::from(created_admin.group_ip), &TEST_TAG) .await } @@ -858,9 +975,9 @@ async fn test_group_api_lifecycle() { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(egress1, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -871,10 +988,10 @@ async fn test_group_api_lifecycle() { let vlan_id = 10; let nat_target = create_nat_target_ipv4(); let external_create = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(vlan_id), @@ -891,12 +1008,9 @@ async fn test_group_api_lifecycle() { let external_group_id = created.external_group_id; - assert_eq!(created.group_ip, MULTICAST_TEST_IPV4); - assert_eq!(created.tag, TEST_TAG); - assert_eq!( - created.internal_forwarding.nat_target, - Some(nat_target.clone()) - ); + assert_eq!(IpAddr::from(created.group_ip), IpAddr::V4(MULTICAST_TEST_IPV4)); + assert_eq!(created.tag, TEST_TAG.as_ref()); + assert_eq!(created.internal_forwarding.nat_target, nat_target.clone()); assert_eq!(created.external_forwarding.vlan_id, Some(vlan_id)); // Route table: 1 entry (dst_addr only, VLAN via action) @@ -950,7 +1064,7 @@ async fn test_group_api_lifecycle() { // Get groups by tag let tagged_groups = switch .client - .multicast_groups_list_by_tag_stream(&make_tag(TEST_TAG), None) + .multicast_groups_list_by_tag_stream(&TEST_TAG, None) .try_collect::>() .await .expect("Should be able to get groups by tag"); @@ -970,7 +1084,7 @@ async fn test_group_api_lifecycle() { .expect("Should be able to get group by ID"); assert_eq!(get_external_group_id(&group[0]), external_group_id); - assert_eq!(get_tag(&group[0]), TEST_TAG); + assert_eq!(get_tag(&group[0]), TEST_TAG.as_ref()); // Also test getting by IP address let group_by_ip = switch @@ -985,16 +1099,16 @@ async fn test_group_api_lifecycle() { ); // Update the group - let updated_nat_target = types::NatTarget { - internal_ip: MULTICAST_NAT_IP.into(), + let updated_nat_target = types::ExternalNatTarget { + internal_ip: MULTICAST_NAT_IP.try_into().unwrap(), inner_mac: MacAddr::from(MULTICAST_NAT_IP.derive_multicast_mac()) .into(), - vni: 200.into(), + vni: 200.try_into().unwrap(), }; let external_update = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(updated_nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: updated_nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(20) }, sources: None, @@ -1003,8 +1117,8 @@ async fn test_group_api_lifecycle() { let updated = switch .client .multicast_group_update_external( - &group_ip, - &make_tag(TEST_TAG), + &group_ip.try_into().unwrap(), + &TEST_TAG, &external_update, ) .await @@ -1012,19 +1126,15 @@ async fn test_group_api_lifecycle() { .into_inner(); assert_eq!(updated.external_group_id, external_group_id); - assert_eq!(updated.tag, TEST_TAG); - assert_eq!( - updated.internal_forwarding.nat_target, - Some(updated_nat_target) - ); + assert_eq!(updated.tag, TEST_TAG.as_ref()); + assert_eq!(updated.internal_forwarding.nat_target, updated_nat_target); assert_eq!(updated.external_forwarding.vlan_id, Some(20)); assert_eq!(updated.sources, None); // Delete the group (must provide matching tag) - let del_tag = make_tag(TEST_TAG); switch .client - .multicast_group_delete(&group_ip, &del_tag) + .multicast_group_delete(&group_ip, &TEST_TAG) .await .expect("Should be able to delete group"); @@ -1063,7 +1173,7 @@ async fn test_group_api_lifecycle() { ); // Clean up the internal group (external was already deleted above) - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await.unwrap(); + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await.unwrap(); } /// Tests tag validation behavior on multicast group deletion. @@ -1082,9 +1192,9 @@ async fn test_multicast_del_tag_validation() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(11), types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1093,10 +1203,10 @@ async fn test_multicast_del_tag_validation() -> TestResult { // Test Case 1: Delete with mismatched tag should fail let tagged_group_ip = IpAddr::V4(Ipv4Addr::new(224, 0, 10, 1)); let external_tagged = types::MulticastGroupCreateExternalEntry { - group_ip: tagged_group_ip, + group_ip: tagged_group_ip.try_into().unwrap(), tag: Some(TAG_A.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1109,9 +1219,8 @@ async fn test_multicast_del_tag_validation() -> TestResult { .expect("Should create tagged group"); // Attempt delete with wrong tag - should fail - let del_tag = make_tag(TAG_B); let wrong_tag_result = - switch.client.multicast_group_delete(&tagged_group_ip, &del_tag).await; + switch.client.multicast_group_delete(&tagged_group_ip, &TAG_B).await; match &wrong_tag_result { Err(Error::ErrorResponse(resp)) => { @@ -1120,6 +1229,10 @@ async fn test_multicast_del_tag_validation() -> TestResult { 400, "Wrong tag should return 400 Bad Request" ); + assert_eq!( + resp.error_code.as_deref(), + Some(MCAST_TAG_MISMATCH_ERROR_CODE) + ); } _ => panic!( "Expected ErrorResponse for tag mismatch, got: {:?}", @@ -1129,7 +1242,7 @@ async fn test_multicast_del_tag_validation() -> TestResult { // Case: Empty string should fail client-side validation (schema enforces minLength: 1) assert!( - "".parse::().is_err(), + "".parse::().is_err(), "Empty tag should fail client-side validation" ); @@ -1142,10 +1255,9 @@ async fn test_multicast_del_tag_validation() -> TestResult { ); // Case: Delete with correct tag should succeed - let del_tag = make_tag(TAG_A); switch .client - .multicast_group_delete(&tagged_group_ip, &del_tag) + .multicast_group_delete(&tagged_group_ip, &TAG_A) .await .expect("Should delete group with matching tag"); @@ -1163,10 +1275,10 @@ async fn test_multicast_del_tag_validation() -> TestResult { // then delete with the generated tag let auto_tagged_group_ip = IpAddr::V4(Ipv4Addr::new(224, 0, 10, 2)); let external_auto_tagged = types::MulticastGroupCreateExternalEntry { - group_ip: auto_tagged_group_ip, + group_ip: auto_tagged_group_ip.try_into().unwrap(), tag: None, // Will get auto-generated tag - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1181,18 +1293,17 @@ async fn test_multicast_del_tag_validation() -> TestResult { // The group should have an auto-generated tag assert!(!created.tag.is_empty(), "Group should have auto-generated tag"); - let auto_tag = created.tag.clone(); + let auto_tag: MulticastTag = created.tag.parse().unwrap(); // Delete with correct auto-generated tag should succeed - let del_tag = make_tag(auto_tag.as_str()); switch .client - .multicast_group_delete(&auto_tagged_group_ip, &del_tag) + .multicast_group_delete(&auto_tagged_group_ip, &auto_tag) .await .expect("Should delete group with matching auto-generated tag"); // Clean up internal group - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -1201,30 +1312,48 @@ async fn test_multicast_tagged_groups_management() { let switch = &*get_switch().await; // Create multiple groups with the same tag - let tag = "test_tag_management"; + let tag: MulticastTag = "test_tag_management".parse().unwrap(); + let internal_tag: MulticastTag = + format!("{}_internal", tag).parse().unwrap(); // Create admin-scoped IPv6 internal group for actual replication let internal_multicast_ip = IpAddr::V6(MULTICAST_NAT_IP); create_test_multicast_group( switch, internal_multicast_ip, - Some(&format!("{}_internal", tag)), + Some(&internal_tag), &[(PhysPort(11), types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; + let additional_internal_ips: [Ipv6Addr; 2] = + ["ff04::2", "ff04::3"].map(|ip| ip.parse().unwrap()); + + for internal_ip in additional_internal_ips { + create_test_multicast_group( + switch, + IpAddr::V6(internal_ip), + Some(&internal_tag), + &[(PhysPort(11), types::Direction::Underlay)], + None, + types::ExternalForwarding { vlan_id: None }, + None, + ) + .await; + } + let nat_target = create_nat_target_ipv4(); let group_ip = IpAddr::V4(MULTICAST_TEST_IPV4); // Create first IPv4 external group (entry point only, no members) let external_group1 = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: Some(tag.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1241,8 +1370,11 @@ async fn test_multicast_tagged_groups_management() { let external_group2 = types::MulticastGroupCreateExternalEntry { group_ip: "224.0.1.2".parse().unwrap(), // Different IP tag: Some(tag.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: additional_internal_ips[0].try_into().unwrap(), + ..nat_target.clone() + }, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1259,8 +1391,11 @@ async fn test_multicast_tagged_groups_management() { let external_group3 = types::MulticastGroupCreateExternalEntry { group_ip: "224.0.1.3".parse().unwrap(), // Different IP tag: Some(TAG_DIFFERENT.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: additional_internal_ips[1].try_into().unwrap(), + ..nat_target.clone() + }, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1276,7 +1411,7 @@ async fn test_multicast_tagged_groups_management() { // List groups by tag let tagged_groups = switch .client - .multicast_groups_list_by_tag_stream(&make_tag(tag), None) + .multicast_groups_list_by_tag_stream(&tag, None) .try_collect::>() .await .expect("Should list groups by tag"); @@ -1285,14 +1420,14 @@ async fn test_multicast_tagged_groups_management() { let group_ips: HashSet<_> = tagged_groups.iter().map(|g| get_group_ip(g)).collect(); - assert!(group_ips.contains(&created1.group_ip)); - assert!(group_ips.contains(&created2.group_ip)); - assert!(!group_ips.contains(&created3.group_ip)); + assert!(group_ips.contains(&IpAddr::from(created1.group_ip))); + assert!(group_ips.contains(&IpAddr::from(created2.group_ip))); + assert!(!group_ips.contains(&IpAddr::from(created3.group_ip))); // Delete all groups with the tag switch .client - .multicast_reset_by_tag(&make_tag(tag)) + .multicast_reset_by_tag(&tag) .await .expect("Should delete all groups with tag"); @@ -1306,9 +1441,22 @@ async fn test_multicast_tagged_groups_management() { let remaining_ips: HashSet<_> = remaining_groups.iter().map(|g| get_group_ip(g)).collect(); - assert!(!remaining_ips.contains(&created1.group_ip)); - assert!(!remaining_ips.contains(&created2.group_ip)); - assert!(remaining_ips.contains(&created3.group_ip)); + assert!(!remaining_ips.contains(&IpAddr::from(created1.group_ip))); + assert!(!remaining_ips.contains(&IpAddr::from(created2.group_ip))); + assert!(remaining_ips.contains(&IpAddr::from(created3.group_ip))); + + cleanup_test_group(switch, IpAddr::from(created3.group_ip), &TAG_DIFFERENT) + .await + .unwrap(); + for internal_ip in [ + MULTICAST_NAT_IP, + additional_internal_ips[0], + additional_internal_ips[1], + ] { + cleanup_test_group(switch, IpAddr::V6(internal_ip), &internal_tag) + .await + .unwrap(); + } } #[tokio::test] @@ -1358,22 +1506,12 @@ async fn test_api_internal_ipv6_bifurcated_replication() -> TestResult { ); // Verify members are preserved - assert_eq!(created.members.len(), 2); - let external_members: Vec<_> = created - .members - .iter() - .filter(|m| m.direction == types::Direction::External) - .collect(); - let underlay_members: Vec<_> = created - .members - .iter() - .filter(|m| m.direction == types::Direction::Underlay) - .collect(); - - assert_eq!(external_members.len(), 1); - assert_eq!(underlay_members.len(), 1); + assert_eq!( + created.members.iter().collect::>(), + admin_scoped_group.members.iter().collect::>(), + ); - cleanup_test_group(switch, created.group_ip.to_ip_addr(), TEST_TAG).await + cleanup_test_group(switch, IpAddr::from(created.group_ip), &TEST_TAG).await } #[tokio::test] @@ -1405,7 +1543,7 @@ async fn test_api_internal_ipv6_underlay_only() -> TestResult { assert_eq!(created.members.len(), 1); assert_eq!(created.members[0].direction, types::Direction::Underlay); - cleanup_test_group(switch, created.group_ip.to_ip_addr(), TEST_TAG).await + cleanup_test_group(switch, IpAddr::from(created.group_ip), &TEST_TAG).await } #[tokio::test] @@ -1438,7 +1576,7 @@ async fn test_api_internal_ipv6_external_only() -> TestResult { assert_eq!(created.members.len(), 1); assert_eq!(created.members[0].direction, types::Direction::External); - cleanup_test_group(switch, created.group_ip.to_ip_addr(), TEST_TAG).await + cleanup_test_group(switch, IpAddr::from(created.group_ip), &TEST_TAG).await } #[tokio::test] @@ -1451,9 +1589,9 @@ async fn test_api_invalid_combinations() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(26), types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1461,10 +1599,10 @@ async fn test_api_invalid_combinations() -> TestResult { // IPv4 with underlay members should fail let ipv4_with_underlay = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4("224.1.0.200".parse().unwrap()), // Avoid 224.0.0.0/24 reserved range + group_ip: "224.1.0.200".parse().unwrap(), // Avoid 224.0.0.0/24 reserved range tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -1478,12 +1616,27 @@ async fn test_api_invalid_combinations() -> TestResult { .expect("IPv4 external group should be created") .into_inner(); + let internal_multicast_ip2 = "ff04::2".parse().unwrap(); + create_test_multicast_group( + switch, + IpAddr::V6(internal_multicast_ip2), + Some(&TEST_TAG), + &[(PhysPort(26), types::Direction::Underlay)], + None, + types::ExternalForwarding { vlan_id: None }, + None, + ) + .await; + // Non-admin-scoped IPv6 should use external API let non_admin_ipv6 = types::MulticastGroupCreateExternalEntry { group_ip: "ff0e::400".parse().unwrap(), // Global scope, not admin-scoped tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv6()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: internal_multicast_ip2.try_into().unwrap(), + ..create_nat_target_ipv6() + }, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(20) }, sources: None, @@ -1496,43 +1649,17 @@ async fn test_api_invalid_combinations() -> TestResult { .expect("Non-admin-scoped IPv6 should use external API") .into_inner(); - // Admin-scoped IPv6 with underlay members should fail via external API - let admin_scoped_external_entry = - types::MulticastGroupCreateExternalEntry { - group_ip: "ff04::500".parse().unwrap(), // Admin-scoped - tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv6()), - }, - external_forwarding: types::ExternalForwarding { - vlan_id: Some(30), - }, - sources: None, - }; + assert!("ff04::500".parse::().is_err()); - // This should fail because admin-scoped groups must use internal API - let result = switch - .client - .multicast_group_create_external(&admin_scoped_external_entry) + cleanup_test_group(switch, created_ipv4.group_ip.into(), &TEST_TAG) .await - .expect_err("Admin-scoped IPv6 should fail via external API"); - - // Verify it's the expected validation error - match result { - Error::ErrorResponse(inner) => { - assert_eq!(inner.status(), 400); - assert!(inner.message.contains("admin-local scope")); - } - _ => panic!( - "Expected ErrorResponse for admin-local external group creation" - ), - } - - cleanup_test_group(switch, created_ipv4.group_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(switch, created_non_admin.group_ip, TEST_TAG) + .unwrap(); + cleanup_test_group(switch, created_non_admin.group_ip.into(), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await.unwrap(); + cleanup_test_group(switch, IpAddr::V6(internal_multicast_ip2), &TEST_TAG) + .await } #[tokio::test] @@ -1551,9 +1678,9 @@ async fn test_ipv4_multicast_invalid_destination_mac() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(egress1, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1566,11 +1693,9 @@ async fn test_ipv4_multicast_invalid_destination_mac() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -1591,15 +1716,17 @@ async fn test_ipv4_multicast_invalid_destination_mac() -> TestResult { // Using a unicast MAC instead of the proper multicast MAC let invalid_mac = MacAddr::new(0x00, 0x11, 0x22, 0x33, 0x44, 0x55); - let src_endpoint = - Endpoint::parse(&src_mac.to_string(), src_ip, src_port).unwrap(); + let src_endpoint = Endpoint::L4(L4Endpoint { + mac: src_mac, + ip: src_ip.parse().unwrap(), + port: src_port, + }); - let dst_endpoint = Endpoint::parse( - &invalid_mac.to_string(), - &ipv4_addr.to_string(), - dst_port, - ) - .unwrap(); + let dst_endpoint = Endpoint::L4(L4Endpoint { + mac: invalid_mac, + ip: ipv4_addr.into(), + port: dst_port, + }); // Generate packet with invalid MAC let to_send = common::gen_udp_packet(src_endpoint, dst_endpoint); @@ -1625,10 +1752,10 @@ async fn test_ipv4_multicast_invalid_destination_mac() -> TestResult { .unwrap(); // Cleanup: Remove both external IPv4 group and underlay IPv6 group - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -1646,9 +1773,9 @@ async fn test_ipv6_multicast_invalid_destination_mac() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(egress1, types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1665,12 +1792,17 @@ async fn test_ipv6_multicast_invalid_destination_mac() -> TestResult { // Using a unicast MAC instead of the proper 33:33:xx:xx:xx:xx format let invalid_mac = MacAddr::new(0x00, 0x11, 0x22, 0x33, 0x44, 0x55); - let src_endpoint = - Endpoint::parse(&src_mac.to_string(), "2001:db8::1", 3333).unwrap(); + let src_endpoint = Endpoint::L4(L4Endpoint { + mac: src_mac, + ip: "2001:db8::1".parse().unwrap(), + port: 3333, + }); - let dst_endpoint = - Endpoint::parse(&invalid_mac.to_string(), &ipv6_addr.to_string(), 4444) - .unwrap(); + let dst_endpoint = Endpoint::L4(L4Endpoint { + mac: invalid_mac, + ip: ipv6_addr.into(), + port: 4444, + }); // Generate packet with invalid MAC let to_send = common::gen_udp_packet(src_endpoint, dst_endpoint); @@ -1714,7 +1846,7 @@ async fn test_ipv6_multicast_invalid_destination_mac() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG).await + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG).await } #[tokio::test] @@ -1731,9 +1863,9 @@ async fn test_multicast_ttl_zero() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(egress1, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1745,11 +1877,9 @@ async fn test_multicast_ttl_zero() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -1793,10 +1923,10 @@ async fn test_multicast_ttl_zero() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -1813,9 +1943,9 @@ async fn test_multicast_ttl_one() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(egress1, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1827,11 +1957,9 @@ async fn test_multicast_ttl_one() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -1875,10 +2003,10 @@ async fn test_multicast_ttl_one() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -1903,9 +2031,9 @@ async fn test_ipv4_multicast_basic_replication_nat_ingress() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &underlay_members, - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -1923,11 +2051,9 @@ async fn test_ipv4_multicast_basic_replication_nat_ingress() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &external_members, - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan }, None, ) @@ -1975,14 +2101,14 @@ async fn test_ipv4_multicast_basic_replication_nat_ingress() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv2 = prepare_expected_pkt( switch, &to_send, vlan, get_nat_target(&created_group), - Some(egress3), + egress3, ); let test_pkt = TestPacket { packet: Arc::new(to_send), port: ingress }; @@ -2010,10 +2136,10 @@ async fn test_ipv4_multicast_basic_replication_nat_ingress() -> TestResult { .unwrap(); // Cleanup external first, then internal - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -2037,9 +2163,9 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &replication_members, - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -2052,11 +2178,9 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan }, None, ) @@ -2081,10 +2205,10 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() // decrement the ttl/hlim, and update the desination MAC to the // Egress port MAC let expected_pkt1 = - prepare_expected_pkt(switch, &og_pkt, vlan, None, Some(egress1)); + prepare_expected_pkt(switch, &og_pkt, vlan, None, egress1); let expected_pkt2 = - prepare_expected_pkt(switch, &og_pkt, vlan, None, Some(egress2)); + prepare_expected_pkt(switch, &og_pkt, vlan, None, egress2); // Use same NAT target as the one used in the original packet let nat_target = create_nat_target_ipv4(); @@ -2097,21 +2221,20 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() // According to mcast_tag_check table, when geneve.isValid() is true and // mcast_tag is 0, it should invalidate the underlay group and set decap let geneve_pkt = common::gen_geneve_packet_with_mcast_tag( - Endpoint::parse( - GIMLET_MAC, - &GIMLET_IP.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), - Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), - &nat_target.internal_ip.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), + Endpoint::L4(L4Endpoint { + mac: GIMLET_MAC.into(), + ip: GIMLET_IP.into(), + port: geneve::GENEVE_UDP_PORT, + }), + Endpoint::L4(L4Endpoint { + mac: Ipv6Addr::from(nat_target.internal_ip) + .derive_multicast_mac() + .into(), + ip: nat_target.internal_ip.into(), + port: geneve::GENEVE_UDP_PORT, + }), eth::ETHER_IPV4, - nat_target.vni.clone().into(), + nat_target.vni.as_u32(), 0, // mcast_tag = 0 &payload, ); @@ -2198,10 +2321,10 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() "decapped replicas must not hit the underlay counter" ); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, MULTICAST_NAT_IP.into(), TEST_TAG).await + cleanup_test_group(switch, MULTICAST_NAT_IP.into(), &TEST_TAG).await } #[tokio::test] @@ -2220,12 +2343,12 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_members() create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress3, types::Direction::Underlay), (egress4, types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -2237,11 +2360,9 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_members() let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -2269,19 +2390,18 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_members() let eth_hdr_len = og_pkt.hdrs.eth_hdr.as_ref().unwrap().hdr_len(); let payload = og_pkt.deparse().unwrap()[eth_hdr_len..].to_vec(); - let geneve_src = Endpoint::parse( - GIMLET_MAC, - &GIMLET_IP.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(); - let geneve_dst = Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), - &nat_target.internal_ip.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(); + let geneve_src = Endpoint::L4(L4Endpoint { + mac: GIMLET_MAC.into(), + ip: GIMLET_IP.into(), + port: geneve::GENEVE_UDP_PORT, + }); + let geneve_dst = Endpoint::L4(L4Endpoint { + mac: Ipv6Addr::from(nat_target.internal_ip) + .derive_multicast_mac() + .into(), + ip: nat_target.internal_ip.into(), + port: geneve::GENEVE_UDP_PORT, + }); // Create the Geneve packet with mcast_tag = 1 // According to mcast_tag_check table, when geneve.isValid() is true and @@ -2290,7 +2410,7 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_members() geneve_src, geneve_dst, eth::ETHER_IPV4, - nat_target.vni.clone().into(), + nat_target.vni.as_u32(), 1, // mcast_tag = 1 &payload, ); @@ -2300,9 +2420,9 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_members() // Vlan should be stripped and we only replicate to underlay ports let recv_pkt1 = - prepare_expected_pkt(switch, &geneve_pkt, None, None, Some(egress3)); + prepare_expected_pkt(switch, &geneve_pkt, None, None, egress3); let recv_pkt2 = - prepare_expected_pkt(switch, &geneve_pkt, None, None, Some(egress4)); + prepare_expected_pkt(switch, &geneve_pkt, None, None, egress4); // We expect the packet not be decapped and forwarded to both egress // ports @@ -2383,10 +2503,10 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_members() "underlay-tagged replicas must not hit the external counter" ); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, MULTICAST_NAT_IP.into(), TEST_TAG).await + cleanup_test_group(switch, MULTICAST_NAT_IP.into(), &TEST_TAG).await } #[tokio::test] @@ -2408,14 +2528,14 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::External), (egress2, types::Direction::External), (egress3, types::Direction::Underlay), (egress4, types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -2428,11 +2548,9 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan }, None, ) @@ -2465,21 +2583,20 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe // mcast_tag is 2, it should not invalidate any group, decapping only the // external group(s) let geneve_pkt = common::gen_geneve_packet_with_mcast_tag( - Endpoint::parse( - GIMLET_MAC, - &GIMLET_IP.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), - Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), - &nat_target.internal_ip.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), + Endpoint::L4(L4Endpoint { + mac: GIMLET_MAC.into(), + ip: GIMLET_IP.into(), + port: geneve::GENEVE_UDP_PORT, + }), + Endpoint::L4(L4Endpoint { + mac: Ipv6Addr::from(nat_target.internal_ip) + .derive_multicast_mac() + .into(), + ip: nat_target.internal_ip.into(), + port: geneve::GENEVE_UDP_PORT, + }), eth::ETHER_IPV4, - nat_target.vni.clone().into(), + nat_target.vni.as_u32(), 2, // mcast_tag = 2 &payload, ); @@ -2488,17 +2605,15 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe TestPacket { packet: Arc::new(geneve_pkt.clone()), port: ingress }; // External ports should be replicated with Vlan information - let recv_pkt1 = - prepare_expected_pkt(switch, &og_pkt, vlan, None, Some(egress1)); + let recv_pkt1 = prepare_expected_pkt(switch, &og_pkt, vlan, None, egress1); - let recv_pkt2 = - prepare_expected_pkt(switch, &og_pkt, vlan, None, Some(egress2)); + let recv_pkt2 = prepare_expected_pkt(switch, &og_pkt, vlan, None, egress2); // Vlan should be stripped when we replicate to underlay ports let recv_pkt3 = - prepare_expected_pkt(switch, &geneve_pkt, None, None, Some(egress3)); + prepare_expected_pkt(switch, &geneve_pkt, None, None, egress3); let recv_pkt4 = - prepare_expected_pkt(switch, &geneve_pkt, None, None, Some(egress4)); + prepare_expected_pkt(switch, &geneve_pkt, None, None, egress4); // We expect 2 packets to be decapped and forwarded to external ports // and 2 packets to be forwarded to underlay ports (still encapped) @@ -2637,17 +2752,17 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe "decapped replicas must not hit the underlay counter" ); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, MULTICAST_NAT_IP.into(), TEST_TAG).await + cleanup_test_group(switch, MULTICAST_NAT_IP.into(), &TEST_TAG).await } fn build_inner_ttl_geneve_pkt( external_ip: IpAddr, src_mac: MacAddr, vlan: Option, - nat_target: &types::NatTarget, + nat_target: &types::ExternalNatTarget, inner_ttl: u8, mcast_tag: u8, inner_eth: bool, @@ -2704,21 +2819,20 @@ fn build_inner_ttl_geneve_pkt( }; let geneve_pkt = common::gen_geneve_packet_with_mcast_tag( - Endpoint::parse( - GIMLET_MAC, - &GIMLET_IP.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), - Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), - &nat_target.internal_ip.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), + Endpoint::L4(L4Endpoint { + mac: GIMLET_MAC.into(), + ip: GIMLET_IP.into(), + port: geneve::GENEVE_UDP_PORT, + }), + Endpoint::L4(L4Endpoint { + mac: Ipv6Addr::from(nat_target.internal_ip) + .derive_multicast_mac() + .into(), + ip: nat_target.internal_ip.into(), + port: geneve::GENEVE_UDP_PORT, + }), ether_type, - nat_target.vni.clone().into(), + nat_target.vni.as_u32(), mcast_tag, &payload, ); @@ -2741,12 +2855,12 @@ async fn run_bifurcated_inner_ttl_test( create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress_external, types::Direction::External), (egress_underlay, types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -2761,9 +2875,9 @@ async fn run_bifurcated_inner_ttl_test( let created_group = create_test_multicast_group( switch, external_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], - types::InternalForwarding { nat_target: Some(nat_target.clone()) }, + Some(nat_target.clone()), types::ExternalForwarding { vlan_id: vlan }, None, ) @@ -2783,23 +2897,13 @@ async fn run_bifurcated_inner_ttl_test( let test_pkt = TestPacket { packet: Arc::new(geneve_pkt.clone()), port: ingress }; - let underlay_pkt = prepare_expected_pkt( - switch, - &geneve_pkt, - None, - None, - Some(egress_underlay), - ); + let underlay_pkt = + prepare_expected_pkt(switch, &geneve_pkt, None, None, egress_underlay); let mut expected_pkts = Vec::new(); if drop_label.is_none() { - let external_pkt = prepare_expected_pkt( - switch, - &og_pkt, - vlan, - None, - Some(egress_external), - ); + let external_pkt = + prepare_expected_pkt(switch, &og_pkt, vlan, None, egress_external); expected_pkts.push(TestPacket { packet: Arc::new(external_pkt), port: egress_external, @@ -2903,10 +3007,10 @@ async fn run_bifurcated_inner_ttl_test( } } - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, MULTICAST_NAT_IP.into(), TEST_TAG).await + cleanup_test_group(switch, MULTICAST_NAT_IP.into(), &TEST_TAG).await } #[tokio::test] @@ -3008,12 +3112,12 @@ async fn run_external_only_inner_ttl_test( create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -3028,9 +3132,9 @@ async fn run_external_only_inner_ttl_test( let created_group = create_test_multicast_group( switch, external_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], - types::InternalForwarding { nat_target: Some(nat_target.clone()) }, + Some(nat_target.clone()), types::ExternalForwarding { vlan_id: vlan }, None, ) @@ -3055,21 +3159,13 @@ async fn run_external_only_inner_ttl_test( vec![ TestPacket { packet: Arc::new(prepare_expected_pkt( - switch, - &og_pkt, - vlan, - None, - Some(egress1), + switch, &og_pkt, vlan, None, egress1, )), port: egress1, }, TestPacket { packet: Arc::new(prepare_expected_pkt( - switch, - &og_pkt, - vlan, - None, - Some(egress2), + switch, &og_pkt, vlan, None, egress2, )), port: egress2, }, @@ -3161,10 +3257,10 @@ async fn run_external_only_inner_ttl_test( ); } - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, MULTICAST_NAT_IP.into(), TEST_TAG).await + cleanup_test_group(switch, MULTICAST_NAT_IP.into(), &TEST_TAG).await } #[tokio::test] @@ -3512,9 +3608,9 @@ async fn test_ipv4_multicast_drops_ingress_is_egress_port() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(ingress, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // No NAT target for admin-scoped group None, ) @@ -3526,11 +3622,9 @@ async fn test_ipv4_multicast_drops_ingress_is_egress_port() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: None }, None, ) @@ -3571,10 +3665,10 @@ async fn test_ipv4_multicast_drops_ingress_is_egress_port() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -3592,12 +3686,12 @@ async fn test_ipv6_multicast_hop_limit_zero() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -3609,11 +3703,9 @@ async fn test_ipv6_multicast_hop_limit_zero() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -3653,10 +3745,10 @@ async fn test_ipv6_multicast_hop_limit_zero() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -3674,12 +3766,12 @@ async fn test_ipv6_multicast_hop_limit_one() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -3691,11 +3783,9 @@ async fn test_ipv6_multicast_hop_limit_one() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -3739,10 +3829,10 @@ async fn test_ipv6_multicast_hop_limit_one() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -3761,9 +3851,9 @@ async fn test_ipv6_multicast_basic_replication_nat_ingress() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &underlay_members, - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -3776,11 +3866,9 @@ async fn test_ipv6_multicast_basic_replication_nat_ingress() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan }, None, ) @@ -3818,7 +3906,7 @@ async fn test_ipv6_multicast_basic_replication_nat_ingress() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let test_pkt = TestPacket { packet: Arc::new(to_send), port: ingress }; @@ -3843,10 +3931,10 @@ async fn test_ipv6_multicast_basic_replication_nat_ingress() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -3866,12 +3954,12 @@ async fn test_ipv4_multicast_source_filtering_exact_match() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -3886,11 +3974,9 @@ async fn test_ipv4_multicast_source_filtering_exact_match() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, Some(vec![allowed_src]), ) @@ -3923,7 +4009,7 @@ async fn test_ipv4_multicast_source_filtering_exact_match() -> TestResult { &allowed_pkt, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv12 = prepare_expected_pkt( @@ -3931,7 +4017,7 @@ async fn test_ipv4_multicast_source_filtering_exact_match() -> TestResult { &allowed_pkt, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let test_pkts = vec![ @@ -3960,10 +4046,10 @@ async fn test_ipv4_multicast_source_filtering_exact_match() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -3983,12 +4069,12 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -4010,11 +4096,9 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, Some(allowed_sources), ) @@ -4056,7 +4140,7 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { &allowed_pkt1, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv22 = prepare_expected_pkt( @@ -4064,7 +4148,7 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { &allowed_pkt2, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv12 = prepare_expected_pkt( @@ -4072,7 +4156,7 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { &allowed_pkt1, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv21 = prepare_expected_pkt( @@ -4080,7 +4164,7 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { &allowed_pkt2, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let test_pkts = vec![ @@ -4112,10 +4196,10 @@ async fn test_ipv4_multicast_source_filtering_multiple_exact() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -4135,12 +4219,12 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -4161,11 +4245,9 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan }, Some(sources), ) @@ -4208,7 +4290,7 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { &allowed_pkt1, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv22 = prepare_expected_pkt( @@ -4216,7 +4298,7 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { &allowed_pkt2, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv12 = prepare_expected_pkt( @@ -4224,7 +4306,7 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { &allowed_pkt1, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv21 = prepare_expected_pkt( @@ -4232,7 +4314,7 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { &allowed_pkt2, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let test_pkts = vec![ @@ -4266,10 +4348,10 @@ async fn test_ipv6_multicast_multiple_source_filtering() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -4288,12 +4370,12 @@ async fn test_multicast_dynamic_membership() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -4306,11 +4388,9 @@ async fn test_multicast_dynamic_membership() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -4332,7 +4412,7 @@ async fn test_multicast_dynamic_membership() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv2 = prepare_expected_pkt( @@ -4340,7 +4420,7 @@ async fn test_multicast_dynamic_membership() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let test_pkt = @@ -4358,8 +4438,8 @@ async fn test_multicast_dynamic_membership() -> TestResult { // but we can update their NAT target, vlan, and sources. // Must pass same tag for validation. let external_update_entry = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, // Keep the same NAT target external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, // Test with VLAN like reference test sources: None, @@ -4368,8 +4448,8 @@ async fn test_multicast_dynamic_membership() -> TestResult { switch .client .multicast_group_update_external( - &get_group_ip(&created_group), - &make_tag(TEST_TAG), + &get_group_ip(&created_group).try_into().unwrap(), + &TEST_TAG, &external_update_entry, ) .await @@ -4403,8 +4483,8 @@ async fn test_multicast_dynamic_membership() -> TestResult { switch .client .multicast_group_update_underlay( - &types::UnderlayMulticastIpv6(ipv6), - &make_tag(TEST_TAG), + &UnderlayMulticastIpv6::try_from(ipv6).unwrap(), + &TEST_TAG, &internal_update_entry, ) .await @@ -4416,14 +4496,14 @@ async fn test_multicast_dynamic_membership() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv2_new = prepare_expected_pkt( switch, &to_send, vlan, get_nat_target(&created_group), - Some(egress3), + egress3, ); let test_pkt_new = TestPacket { packet: Arc::new(to_send), port: ingress }; @@ -4435,10 +4515,10 @@ async fn test_multicast_dynamic_membership() -> TestResult { switch.packet_test(vec![test_pkt_new], expected_pkts_new).unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -4458,14 +4538,14 @@ async fn test_multicast_multiple_groups() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::External), (egress2, types::Direction::External), (egress3, types::Direction::External), (egress4, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -4478,16 +4558,31 @@ async fn test_multicast_multiple_groups() -> TestResult { let created_group1 = create_test_multicast_group( switch, multicast_ip1, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) .await; + let internal_multicast_ip2 = "ff04::2".parse().unwrap(); + create_test_multicast_group( + switch, + IpAddr::V6(internal_multicast_ip2), + Some(&TEST_TAG), + &[ + (egress1, types::Direction::External), + (egress2, types::Direction::External), + (egress3, types::Direction::External), + (egress4, types::Direction::External), + ], + None, + types::ExternalForwarding { vlan_id: None }, + None, + ) + .await; + // Create second IPv4 external group with NAT target (no members) let multicast_ip2 = IpAddr::V4(Ipv4Addr::new(224, 1, 2, 0)); // Changed to valid range let vlan2 = Some(20); @@ -4495,11 +4590,12 @@ async fn test_multicast_multiple_groups() -> TestResult { let created_group2 = create_test_multicast_group( switch, multicast_ip2, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(types::ExternalNatTarget { + internal_ip: internal_multicast_ip2.try_into().unwrap(), + ..create_nat_target_ipv4() + }), types::ExternalForwarding { vlan_id: Some(20) }, None, ) @@ -4530,7 +4626,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send1, vlan1, get_nat_target(&created_group1), - Some(egress1), + egress1, ); let to_recv1_2 = prepare_expected_pkt( @@ -4538,7 +4634,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send1, vlan1, get_nat_target(&created_group1), - Some(egress2), + egress2, ); let to_recv2_1 = prepare_expected_pkt( @@ -4546,7 +4642,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send2, vlan2, get_nat_target(&created_group2), - Some(egress3), + egress3, ); let to_recv2_2 = prepare_expected_pkt( @@ -4554,16 +4650,15 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send2, vlan2, get_nat_target(&created_group2), - Some(egress4), + egress4, ); - // Since both groups NAT to the same admin-scoped group, they both replicate to all ports let to_recv1_3 = prepare_expected_pkt( switch, &to_send1, vlan1, get_nat_target(&created_group1), - Some(egress3), + egress3, ); let to_recv1_4 = prepare_expected_pkt( @@ -4571,7 +4666,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send1, vlan1, get_nat_target(&created_group1), - Some(egress4), + egress4, ); let to_recv2_3 = prepare_expected_pkt( @@ -4579,7 +4674,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send2, vlan2, get_nat_target(&created_group2), - Some(egress1), + egress1, ); let to_recv2_4 = prepare_expected_pkt( @@ -4587,7 +4682,7 @@ async fn test_multicast_multiple_groups() -> TestResult { &to_send2, vlan2, get_nat_target(&created_group2), - Some(egress2), + egress2, ); let test_pkts = vec![ @@ -4596,7 +4691,6 @@ async fn test_multicast_multiple_groups() -> TestResult { ]; let expected_pkts = vec![ - // First multicast group - replicates to all ports since both groups share same NAT target TestPacket { packet: Arc::new(to_recv1_1), port: egress1 }, TestPacket { packet: Arc::new(to_recv1_2), port: egress2 }, TestPacket { packet: Arc::new(to_recv1_3), port: egress3 }, @@ -4610,13 +4704,15 @@ async fn test_multicast_multiple_groups() -> TestResult { switch.packet_test(test_pkts, expected_pkts).unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group1), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group1), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group2), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group2), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await.unwrap(); + cleanup_test_group(switch, IpAddr::V6(internal_multicast_ip2), &TEST_TAG) + .await } #[tokio::test] @@ -4635,17 +4731,36 @@ async fn test_multicast_reset_all_tables() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::External), (egress2, types::Direction::External), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) .await; + let additional_internal_ips: [Ipv6Addr; 3] = + ["ff04::3", "ff04::4", "ff04::5"].map(|ip| ip.parse().unwrap()); + + for internal_ip in additional_internal_ips { + create_test_multicast_group( + switch, + IpAddr::V6(internal_ip), + Some(&TEST_TAG), + &[ + (egress1, types::Direction::External), + (egress2, types::Direction::External), + ], + None, + types::ExternalForwarding { vlan_id: None }, + None, + ) + .await; + } + // IPv4 external group with NAT and VLAN let multicast_ip1 = IpAddr::V4(MULTICAST_TEST_IPV4); let vlan1 = Some(10); @@ -4653,11 +4768,9 @@ async fn test_multicast_reset_all_tables() -> TestResult { let created_group1 = create_test_multicast_group( switch, multicast_ip1, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: vlan1 }, None, ) @@ -4670,11 +4783,12 @@ async fn test_multicast_reset_all_tables() -> TestResult { let created_group2 = create_test_multicast_group( switch, multicast_ip2, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv6()), - }, + Some(types::ExternalNatTarget { + internal_ip: additional_internal_ips[0].try_into().unwrap(), + ..create_nat_target_ipv6() + }), types::ExternalForwarding { vlan_id: vlan2 }, None, // No sources for this group ) @@ -4684,7 +4798,7 @@ async fn test_multicast_reset_all_tables() -> TestResult { let ipv6 = Ipv6Addr::new(ADMIN_LOCAL_MULTICAST_PREFIX, 0, 0, 0, 0, 0, 0, 2); let group_entry2b = types::MulticastGroupCreateUnderlayEntry { - group_ip: types::UnderlayMulticastIpv6(ipv6), + group_ip: UnderlayMulticastIpv6::try_from(ipv6).unwrap(), tag: Some(TEST_TAG.to_string()), members: vec![types::MulticastGroupMember { port_id: switch.link_id(egress1).unwrap().0, @@ -4711,11 +4825,12 @@ async fn test_multicast_reset_all_tables() -> TestResult { let created_group3 = create_test_multicast_group( switch, multicast_ip3, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(types::ExternalNatTarget { + internal_ip: additional_internal_ips[1].try_into().unwrap(), + ..create_nat_target_ipv4() + }), types::ExternalForwarding { vlan_id: vlan3 }, sources.clone(), ) @@ -4730,11 +4845,12 @@ async fn test_multicast_reset_all_tables() -> TestResult { let created_group4 = create_test_multicast_group( switch, multicast_ip4, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv6()), - }, + Some(types::ExternalNatTarget { + internal_ip: additional_internal_ips[2].try_into().unwrap(), + ..create_nat_target_ipv6() + }), types::ExternalForwarding { vlan_id: vlan4 }, ipv6_sources.clone(), ) @@ -4923,10 +5039,13 @@ async fn test_multicast_reset_all_tables() -> TestResult { for group_ip in [ get_group_ip(&created_group1), get_group_ip(&created_group2), - created_group2b.group_ip.to_ip_addr(), + IpAddr::from(created_group2b.group_ip), get_group_ip(&created_group3), get_group_ip(&created_group4), internal_multicast_ip, + IpAddr::V6(additional_internal_ips[0]), + IpAddr::V6(additional_internal_ips[1]), + IpAddr::V6(additional_internal_ips[2]), ] { let result = switch.client.multicast_group_get(&group_ip).await; @@ -4955,13 +5074,13 @@ async fn test_multicast_multiple_packets() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (egress1, types::Direction::Underlay), (egress2, types::Direction::Underlay), (egress3, types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // Admin-scoped groups don't need NAT targets None, ) @@ -4974,11 +5093,9 @@ async fn test_multicast_multiple_packets() -> TestResult { let created_group = create_test_multicast_group( switch, multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // External groups have no members - types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), - }, + Some(create_nat_target_ipv4()), types::ExternalForwarding { vlan_id: Some(10) }, None, ) @@ -5011,7 +5128,7 @@ async fn test_multicast_multiple_packets() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress1), + egress1, ); let to_recv2 = prepare_expected_pkt( @@ -5019,7 +5136,7 @@ async fn test_multicast_multiple_packets() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress2), + egress2, ); let to_recv3 = prepare_expected_pkt( @@ -5027,7 +5144,7 @@ async fn test_multicast_multiple_packets() -> TestResult { &to_send, vlan, get_nat_target(&created_group), - Some(egress3), + egress3, ); test_pkts.push(TestPacket { packet: Arc::new(to_send), port: ingress }); @@ -5057,10 +5174,10 @@ async fn test_multicast_multiple_packets() -> TestResult { .await .unwrap(); - cleanup_test_group(switch, get_group_ip(&created_group), TEST_TAG) + cleanup_test_group(switch, get_group_ip(&created_group), &TEST_TAG) .await .unwrap(); - cleanup_test_group(switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -5156,17 +5273,17 @@ async fn test_external_group_nat_target_validation() -> TestResult { let (port_id, link_id) = switch.link_id(PhysPort(11)).unwrap(); // Creating external group with NAT target referencing non-existent group should fail - let nonexistent_nat_target = types::NatTarget { + let nonexistent_nat_target = types::ExternalNatTarget { internal_ip: "ff04::1".parse().unwrap(), // Admin-scoped IPv6 that does not exist inner_mac: MacAddr::new(0x03, 0x00, 0x00, 0x00, 0x00, 0x01).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), }; let group_with_invalid_nat = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4("224.1.0.101".parse().unwrap()), + group_ip: "224.1.0.101".parse().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nonexistent_nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nonexistent_nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -5180,7 +5297,11 @@ async fn test_external_group_nat_target_validation() -> TestResult { match res { Error::ErrorResponse(inner) => { - assert_eq!(inner.status(), 400, "Expected 400 Bad Request"); + assert_eq!(inner.status(), 409, "Expected 409 Conflict"); + assert_eq!( + inner.error_code.as_deref(), + Some(MISSING_NAT_TARGET_ERROR_CODE) + ); } _ => panic!("Expected ErrorResponse for invalid NAT target"), } @@ -5218,17 +5339,17 @@ async fn test_external_group_nat_target_validation() -> TestResult { ); // Test 3: Now create external group with valid NAT target - let valid_nat_target = types::NatTarget { + let valid_nat_target = types::ExternalNatTarget { internal_ip: "ff04::1".parse().unwrap(), // References the admin-scoped group we just created inner_mac: MacAddr::new(0x03, 0x00, 0x00, 0x00, 0x00, 0x02).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), }; let group_with_valid_nat = types::MulticastGroupCreateExternalEntry { - group_ip: IpAddr::V4("224.1.0.102".parse().unwrap()), + group_ip: "224.1.0.102".parse().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(valid_nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: valid_nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -5249,27 +5370,127 @@ async fn test_external_group_nat_target_validation() -> TestResult { // Verify NAT target configuration assert_eq!( - created_external - .internal_forwarding - .nat_target - .as_ref() - .unwrap() - .internal_ip, + created_external.internal_forwarding.nat_target.internal_ip, valid_nat_target.internal_ip, "External group's NAT target should point to the correct internal IP" ); - // Delete external group first since it references the internal group via NAT target - cleanup_test_group(switch, created_external.group_ip, TEST_TAG) - .await - .unwrap(); - cleanup_test_group(switch, created_admin.group_ip.to_ip_addr(), TEST_TAG) + let update_with_missing_nat_target = + types::MulticastGroupUpdateExternalEntry { + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: "ff04::2".parse().unwrap(), + inner_mac: MacAddr::new(0x03, 0x00, 0x00, 0x00, 0x00, 0x03) + .into(), + vni: 101.try_into().unwrap(), + }, + }, + external_forwarding: types::ExternalForwarding { + vlan_id: Some(10), + }, + sources: None, + }; + + let res = switch + .client + .multicast_group_update_external( + &created_external.group_ip, + &TEST_TAG, + &update_with_missing_nat_target, + ) .await -} + .expect_err("Should reject an update with a missing NAT target"); -#[tokio::test] -#[ignore] -async fn test_ipv6_multicast_scope_validation() { + match res { + Error::ErrorResponse(inner) => { + assert_eq!(inner.status(), 409, "Expected 409 Conflict"); + assert_eq!( + inner.error_code.as_deref(), + Some(MISSING_NAT_TARGET_ERROR_CODE) + ); + } + _ => panic!("Expected ErrorResponse for missing NAT target"), + } + + let second_admin = switch + .client + .multicast_group_create_underlay( + &types::MulticastGroupCreateUnderlayEntry { + group_ip: "ff04::2".parse().unwrap(), + ..admin_scoped_group.clone() + }, + ) + .await? + .into_inner(); + + let second_external = switch + .client + .multicast_group_create_external( + &types::MulticastGroupCreateExternalEntry { + group_ip: "224.1.0.103".parse().unwrap(), + internal_forwarding: update_with_missing_nat_target + .internal_forwarding + .clone(), + ..group_with_valid_nat.clone() + }, + ) + .await? + .into_inner(); + + let err = switch + .client + .multicast_group_create_external(&group_with_invalid_nat) + .await + .expect_err("Should reject an already owned NAT target"); + + let Error::ErrorResponse(response) = err else { + panic!("Unexpected NAT target error: {err:?}"); + }; + assert_eq!(response.status(), 400); + assert!(response.message.contains("already referenced")); + + let err = switch + .client + .multicast_group_update_external( + &created_external.group_ip, + &TEST_TAG, + &update_with_missing_nat_target, + ) + .await + .expect_err("Should reject an already owned NAT target"); + + let Error::ErrorResponse(response) = err else { + panic!("Unexpected NAT target error: {err:?}"); + }; + assert_eq!(response.status(), 400); + assert!(response.message.contains("already referenced")); + + let group_after_rejected_update = switch + .client + .multicast_group_get(&created_external.group_ip.into()) + .await + .expect("Should retain the external group after the rejected update") + .into_inner(); + assert_eq!( + get_nat_target(&group_after_rejected_update), + Some(&valid_nat_target), + ); + + // Delete external group first since it references the internal group via NAT target + cleanup_test_group(switch, created_external.group_ip.into(), &TEST_TAG) + .await + .unwrap(); + cleanup_test_group(switch, second_external.group_ip.into(), &TEST_TAG) + .await?; + cleanup_test_group(switch, IpAddr::from(second_admin.group_ip), &TEST_TAG) + .await?; + cleanup_test_group(switch, IpAddr::from(created_admin.group_ip), &TEST_TAG) + .await +} + +#[tokio::test] +#[ignore] +async fn test_ipv6_multicast_scope_validation() { let switch = &*get_switch().await; let (egress_port, egress_link) = switch.link_id(PhysPort(11)).unwrap(); @@ -5293,61 +5514,13 @@ async fn test_ipv6_multicast_scope_validation() { "Admin-local scope (ff04::/16) should work with internal API" ); - // Site-local scope (ff05::/16) - should be rejected (only admin-local ff04 allowed) - let site_local_group = types::MulticastGroupCreateUnderlayEntry { - group_ip: "ff05::200".parse().unwrap(), - tag: Some(TEST_TAG.to_string()), - members: vec![types::MulticastGroupMember { - port_id: egress_port.clone(), - link_id: egress_link, - direction: types::Direction::External, - }], - }; - - let site_local_result = - switch.client.multicast_group_create_underlay(&site_local_group).await; - assert!( - site_local_result.is_err(), - "Site-local scope (ff05::/16) should be rejected - only admin-local (ff04) allowed" - ); - - // Organization-local scope (ff08::/16) - should be rejected (only admin-local ff04 allowed) - let org_local_group = types::MulticastGroupCreateUnderlayEntry { - group_ip: "ff08::300".parse().unwrap(), - tag: Some(TEST_TAG.to_string()), - members: vec![types::MulticastGroupMember { - port_id: egress_port.clone(), - link_id: egress_link, - direction: types::Direction::External, - }], - }; - - let org_local_result = - switch.client.multicast_group_create_underlay(&org_local_group).await; - assert!( - org_local_result.is_err(), - "Organization-local scope (ff08::/16) should be rejected - only admin-local (ff04) allowed" - ); - - // Global scope (ff0e::/16) - should be rejected by server-side validation - let global_scope_group = types::MulticastGroupCreateUnderlayEntry { - group_ip: "ff0e::400".parse().unwrap(), - tag: Some(TEST_TAG.to_string()), - members: vec![types::MulticastGroupMember { - port_id: egress_port.clone(), - link_id: egress_link, - direction: types::Direction::External, - }], - }; - - let global_scope_result = switch - .client - .multicast_group_create_underlay(&global_scope_group) - .await; - assert!( - global_scope_result.is_err(), - "Global scope (ff0e::/16) should be rejected by server-side validation" - ); + // Underlay groups must fall within the internal ff04::/64 block, so + // site-local (ff05), organization-local (ff08), and global (ff0e) scopes + // fail to parse as `UnderlayMulticastIpv6` before any request reaches + // dpd. + assert!("ff05::200".parse::().is_err()); + assert!("ff08::300".parse::().is_err()); + assert!("ff0e::400".parse::().is_err()); // Test the reverse: admin-scoped should be rejected by external API // First create an admin-scoped group to reference @@ -5367,54 +5540,24 @@ async fn test_ipv6_multicast_scope_validation() { .await .expect("Should create target group"); - let admin_scoped_external = types::MulticastGroupCreateExternalEntry { - group_ip: "ff04::500".parse().unwrap(), - tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(types::NatTarget { - internal_ip: "ff04::1000".parse().unwrap(), - inner_mac: MacAddr::new(0x02, 0x00, 0x00, 0x00, 0x00, 0x01) - .into(), - vni: 100.into(), - }), - }, - external_forwarding: types::ExternalForwarding { vlan_id: Some(42) }, - sources: None, - }; - - let admin_external_result = switch - .client - .multicast_group_create_external(&admin_scoped_external) - .await; - assert!( - admin_external_result.is_err(), - "Admin-scoped addresses should be rejected by external API" - ); - let external_error_msg = - format!("{:?}", admin_external_result.unwrap_err()); - assert!( - external_error_msg.contains("admin-local scope"), - "Error should indicate admin-local addresses require internal API" - ); + assert!("ff04::500".parse::().is_err()); // Cleanup all created groups let admin_local_group = admin_local_result.unwrap().into_inner(); let target_group = target_result.into_inner(); - let del_tag = make_tag(TEST_TAG); switch .client .multicast_group_delete( - &admin_local_group.group_ip.to_ip_addr(), - &del_tag, + &IpAddr::from(admin_local_group.group_ip), + &TEST_TAG, ) .await .ok(); - let del_tag = make_tag(TEST_TAG); switch .client - .multicast_group_delete(&target_group.group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(target_group.group_ip), &TEST_TAG) .await .ok(); } @@ -5460,9 +5603,9 @@ async fn test_multicast_group_id_recycling() -> TestResult { let group1 = create_test_multicast_group( switch, group1_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(11), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -5472,9 +5615,9 @@ async fn test_multicast_group_id_recycling() -> TestResult { let group2 = create_test_multicast_group( switch, group2_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(12), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -5483,10 +5626,9 @@ async fn test_multicast_group_id_recycling() -> TestResult { assert_ne!(get_external_group_id(&group1), get_external_group_id(&group2)); // Delete the first group - let del_tag = make_tag(TEST_TAG); switch .client - .multicast_group_delete(&group1_ip, &del_tag) + .multicast_group_delete(&group1_ip, &TEST_TAG) .await .expect("Should be able to delete first group"); @@ -5506,9 +5648,9 @@ async fn test_multicast_group_id_recycling() -> TestResult { let group3 = create_test_multicast_group( switch, group3_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(13), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -5523,10 +5665,9 @@ async fn test_multicast_group_id_recycling() -> TestResult { ); // Create a fourth group after deleting group2, it should reuse group2's ID - let del_tag = make_tag(TEST_TAG); switch .client - .multicast_group_delete(&group2_ip, &del_tag) + .multicast_group_delete(&group2_ip, &TEST_TAG) .await .expect("Should be able to delete second group"); @@ -5555,9 +5696,9 @@ async fn test_multicast_group_id_recycling() -> TestResult { let group4 = create_test_multicast_group( switch, group4_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(14), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -5570,8 +5711,8 @@ async fn test_multicast_group_id_recycling() -> TestResult { "Fourth group should reuse Group2's underlay ID due to LIFO recycling" ); - cleanup_test_group(switch, group3_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(switch, group4_ip, TEST_TAG).await + cleanup_test_group(switch, group3_ip, &TEST_TAG).await.unwrap(); + cleanup_test_group(switch, group4_ip, &TEST_TAG).await } #[tokio::test] @@ -5596,9 +5737,9 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { create_test_multicast_group( &switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // No members (Omicron setup) - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -5610,17 +5751,17 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { _ => panic!("Expected IPv6 address"), }; - let nat_target = types::NatTarget { - internal_ip: ipv6, + let nat_target = types::ExternalNatTarget { + internal_ip: ipv6.try_into().unwrap(), inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x00, 0x00, 0x01).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), }; let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, // Test with VLAN like reference test sources: None, @@ -5680,21 +5821,20 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { let eth_hdr_len = og_pkt.hdrs.eth_hdr.as_ref().unwrap().hdr_len(); let payload = og_pkt.deparse().unwrap()[eth_hdr_len..].to_vec(); let geneve_pkt = common::gen_geneve_packet_with_mcast_tag( - Endpoint::parse( - GIMLET_MAC, - &GIMLET_IP.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), - Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), - &nat_target.internal_ip.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), + Endpoint::L4(L4Endpoint { + mac: GIMLET_MAC.into(), + ip: GIMLET_IP.into(), + port: geneve::GENEVE_UDP_PORT, + }), + Endpoint::L4(L4Endpoint { + mac: Ipv6Addr::from(nat_target.internal_ip) + .derive_multicast_mac() + .into(), + ip: nat_target.internal_ip.into(), + port: geneve::GENEVE_UDP_PORT, + }), eth::ETHER_IPV6, - nat_target.vni.clone().into(), + nat_target.vni.as_u32(), 2, // mcast_tag = 2 (allows both external and underlay replication) &payload, ); @@ -5753,18 +5893,16 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { ], }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); switch .client - .multicast_group_update_underlay( - &ipv6_update, - &make_tag(TEST_TAG), - &update_entry, - ) + .multicast_group_update_underlay(&ipv6_update, &TEST_TAG, &update_entry) .await .expect("Should update internal group with members"); @@ -5804,21 +5942,20 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { // Create Geneve packet for the underlay to underlay replication, no decap let payload2 = og_pkt2.deparse().unwrap()[eth_hdr_len..].to_vec(); let to_send_again = common::gen_geneve_packet_with_mcast_tag( - Endpoint::parse( - GIMLET_MAC, - &GIMLET_IP.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), - Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), - &nat_target.internal_ip.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), + Endpoint::L4(L4Endpoint { + mac: GIMLET_MAC.into(), + ip: GIMLET_IP.into(), + port: geneve::GENEVE_UDP_PORT, + }), + Endpoint::L4(L4Endpoint { + mac: Ipv6Addr::from(nat_target.internal_ip) + .derive_multicast_mac() + .into(), + ip: nat_target.internal_ip.into(), + port: geneve::GENEVE_UDP_PORT, + }), eth::ETHER_IPV6, - nat_target.vni.clone().into(), + nat_target.vni.as_u32(), 2, // mcast_tag = 2 (allows both external and underlay replication) &payload2, ); @@ -5829,7 +5966,7 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { &og_pkt2, Some(10), // VLAN should come from external group None, // No NAT target for external members - Some(egress1), + egress1, ); let expected2 = prepare_expected_pkt( @@ -5837,7 +5974,7 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { &og_pkt2, Some(10), // VLAN should come from external group None, // No NAT target for external members (like reference test) - Some(egress2), + egress2, ); // Underlay member gets the Geneve packet unchanged @@ -5846,7 +5983,7 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { &to_send_again, None, // No VLAN None, // No NAT target for underlay member - Some(egress3), + egress3, ); let send_again = @@ -5879,16 +6016,18 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { members: vec![], // Remove all members }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); switch .client .multicast_group_update_underlay( &ipv6_update, - &make_tag(TEST_TAG), + &TEST_TAG, &empty_update_entry, ) .await @@ -5944,11 +6083,7 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { switch .client - .multicast_group_update_underlay( - &ipv6_update, - &make_tag(TEST_TAG), - &readd_entry, - ) + .multicast_group_update_underlay(&ipv6_update, &TEST_TAG, &readd_entry) .await .expect("Should re-add members after removing all"); @@ -5970,8 +6105,8 @@ async fn test_multicast_empty_then_add_members_ipv6() -> TestResult { "Internal group should have 3 members after re-add" ); - cleanup_test_group(&switch, external_group_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(&switch, internal_group_ip, TEST_TAG).await + cleanup_test_group(&switch, external_group_ip, &TEST_TAG).await.unwrap(); + cleanup_test_group(&switch, internal_group_ip, &TEST_TAG).await } #[tokio::test] @@ -5995,29 +6130,29 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { create_test_multicast_group( &switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[], // No members - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; // Create external group that references the internal group (empty, no members) - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6 address"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x02, 0x64).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), }; let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, // Test with VLAN like reference test sources: None, @@ -6073,25 +6208,24 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { Some(10), ); - // Create Geneve packet targeting the internal group (like test_encapped_multicast_geneve_mcast_tag_to_underlay_members) + // Create Geneve packet targeting the internal group let eth_hdr_len = og_pkt.hdrs.eth_hdr.as_ref().unwrap().hdr_len(); let payload = og_pkt.deparse().unwrap()[eth_hdr_len..].to_vec(); let geneve_pkt = common::gen_geneve_packet_with_mcast_tag( - Endpoint::parse( - GIMLET_MAC, - &GIMLET_IP.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), - Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), - &nat_target.internal_ip.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), + Endpoint::L4(L4Endpoint { + mac: GIMLET_MAC.into(), + ip: GIMLET_IP.into(), + port: geneve::GENEVE_UDP_PORT, + }), + Endpoint::L4(L4Endpoint { + mac: Ipv6Addr::from(nat_target.internal_ip) + .derive_multicast_mac() + .into(), + ip: nat_target.internal_ip.into(), + port: geneve::GENEVE_UDP_PORT, + }), eth::ETHER_IPV4, - nat_target.vni.clone().into(), + nat_target.vni.as_u32(), 2, // mcast_tag = 2 (allows both external and underlay replication) &payload, ); @@ -6145,18 +6279,16 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { members: vec![external_member1, external_member2, underlay_member], }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); switch .client - .multicast_group_update_underlay( - &ipv6_update, - &make_tag(TEST_TAG), - &update_entry, - ) + .multicast_group_update_underlay(&ipv6_update, &TEST_TAG, &update_entry) .await .expect("Should update internal group with members"); @@ -6196,21 +6328,20 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { // Create Geneve packet for the second test let payload2 = og_pkt2.deparse().unwrap()[eth_hdr_len..].to_vec(); let test_packet2 = common::gen_geneve_packet_with_mcast_tag( - Endpoint::parse( - GIMLET_MAC, - &GIMLET_IP.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), - Endpoint::parse( - &MacAddr::from(nat_target.internal_ip.derive_multicast_mac()) - .to_string(), - &nat_target.internal_ip.to_string(), - geneve::GENEVE_UDP_PORT, - ) - .unwrap(), + Endpoint::L4(L4Endpoint { + mac: GIMLET_MAC.into(), + ip: GIMLET_IP.into(), + port: geneve::GENEVE_UDP_PORT, + }), + Endpoint::L4(L4Endpoint { + mac: Ipv6Addr::from(nat_target.internal_ip) + .derive_multicast_mac() + .into(), + ip: nat_target.internal_ip.into(), + port: geneve::GENEVE_UDP_PORT, + }), eth::ETHER_IPV4, - nat_target.vni.clone().into(), + nat_target.vni.as_u32(), 2, // mcast_tag = 2 (allows both external and underlay replication) &payload2, ); @@ -6221,7 +6352,7 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { &og_pkt2, Some(10), // VLAN should come from external group None, // No NAT target for external members (like reference test) - Some(egress1), + egress1, ); let expected2 = prepare_expected_pkt( @@ -6229,7 +6360,7 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { &og_pkt2, Some(10), // VLAN should come from external group None, // No NAT target for external members (like reference test) - Some(egress2), + egress2, ); // Underlay member gets the Geneve packet unchanged (like test_encapped_multicast_geneve_mcast_tag_to_underlay_members) @@ -6238,7 +6369,7 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { &test_packet2, None, // No VLAN None, // No NAT target for underlay member - Some(egress3), + egress3, ); let send_again = @@ -6271,16 +6402,18 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { members: vec![], // Remove all members }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); switch .client .multicast_group_update_underlay( &ipv6_update, - &make_tag(TEST_TAG), + &TEST_TAG, &empty_update_entry, ) .await @@ -6326,13 +6459,13 @@ async fn test_multicast_empty_then_add_members_ipv4() -> TestResult { switch.packet_test(vec![send_final], expected_final)?; - cleanup_test_group(&switch, external_group_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(&switch, internal_group_ip, TEST_TAG).await + cleanup_test_group(&switch, external_group_ip, &TEST_TAG).await.unwrap(); + cleanup_test_group(&switch, internal_group_ip, &TEST_TAG).await } #[tokio::test] #[ignore] -async fn test_multicast_rollback_external_group_creation_failure() -> TestResult +async fn test_multicast_create_missing_nat_target_preserves_state() -> TestResult { let switch = &*get_switch().await; @@ -6352,12 +6485,12 @@ async fn test_multicast_rollback_external_group_creation_failure() -> TestResult create_test_multicast_group( &switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (PhysPort(15), types::Direction::External), (PhysPort(17), types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -6404,17 +6537,17 @@ async fn test_multicast_rollback_external_group_creation_failure() -> TestResult } _ => panic!("Expected IPv6 address"), }; - let nat_target = types::NatTarget { - internal_ip: invalid_internal_ip, + let nat_target = types::ExternalNatTarget { + internal_ip: invalid_internal_ip.try_into().unwrap(), inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x02, 0x66).into(), - vni: 200.into(), + vni: 200.try_into().unwrap(), }; let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: None, - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -6502,7 +6635,7 @@ async fn test_multicast_rollback_external_group_creation_failure() -> TestResult "Source filter table should be unchanged after rollback" ); - cleanup_test_group(&switch, internal_group_ip, TEST_TAG).await + cleanup_test_group(&switch, internal_group_ip, &TEST_TAG).await } #[tokio::test] @@ -6525,12 +6658,12 @@ async fn test_multicast_rollback_member_update_failure() -> TestResult { create_test_multicast_group( &switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (PhysPort(15), types::Direction::External), (PhysPort(17), types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -6557,17 +6690,19 @@ async fn test_multicast_rollback_member_update_failure() -> TestResult { let update_request = types::MulticastGroupUpdateUnderlayEntry { members: invalid_members }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); // This should fail and trigger rollback let result = switch .client .multicast_group_update_underlay( &ipv6_update, - &make_tag(TEST_TAG), + &TEST_TAG, &update_request, ) .await; @@ -6589,12 +6724,12 @@ async fn test_multicast_rollback_member_update_failure() -> TestResult { "Member count should be unchanged after rollback" ); - cleanup_test_group(&switch, internal_group_ip, TEST_TAG).await + cleanup_test_group(&switch, internal_group_ip, &TEST_TAG).await } #[tokio::test] #[ignore] -async fn test_multicast_rollback_nat_transition_failure() -> TestResult { +async fn test_multicast_update_invalid_nat_mac_preserves_state() -> TestResult { let switch = &*get_switch().await; let internal_group_ip = IpAddr::V6(Ipv6Addr::new( @@ -6613,29 +6748,29 @@ async fn test_multicast_rollback_nat_transition_failure() -> TestResult { create_test_multicast_group( &switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(15), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; // Create external group with NAT - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6 address"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x02, 0x68).into(), - vni: 104.into(), + vni: 104.try_into().unwrap(), }; let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -6662,19 +6797,19 @@ async fn test_multicast_rollback_nat_transition_failure() -> TestResult { .expect("Should be able to dump NAT table"); // Attempt to update NAT target to invalid configuration that should fail - let invalid_nat_target = types::NatTarget { + let invalid_nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6 address"), }, // Invalid MAC (not multicast) inner_mac: MacAddr::new(0x02, 0x00, 0x00, 0x00, 0x00, 0x00).into(), - vni: 300.into(), + vni: 300.try_into().unwrap(), }; let invalid_update = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(invalid_nat_target), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: invalid_nat_target, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -6684,8 +6819,8 @@ async fn test_multicast_rollback_nat_transition_failure() -> TestResult { let result = switch .client .multicast_group_update_external( - &external_group_ip, - &make_tag(TEST_TAG), + &external_group_ip.try_into().unwrap(), + &TEST_TAG, &invalid_update, ) .await; @@ -6733,140 +6868,13 @@ async fn test_multicast_rollback_nat_transition_failure() -> TestResult { "NAT table should be unchanged after rollback" ); - cleanup_test_group(&switch, external_group_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(&switch, internal_group_ip, TEST_TAG).await -} - -#[tokio::test] -#[ignore] -async fn test_multicast_rollback_vlan_propagation_consistency() { - let switch = &*get_switch().await; - - let internal_group_ip = IpAddr::V6(Ipv6Addr::new( - ADMIN_LOCAL_MULTICAST_PREFIX, - 0, - 0, - 0, - 0, - 0, - 0, - 105, - )); - let external_group_ip = IpAddr::V4(Ipv4Addr::new(224, 1, 2, 105)); - - // Create internal group with members (so bitmap entry get created) - create_test_multicast_group( - &switch, - internal_group_ip, - Some(TEST_TAG), - &[ - (PhysPort(15), types::Direction::External), - (PhysPort(17), types::Direction::Underlay), - ], - types::InternalForwarding { nat_target: None }, - types::ExternalForwarding { vlan_id: None }, - None, - ) - .await; - - // Get initial bitmap table state - let _initial_bitmap_table = switch - .client - .table_dump("Egress.mcast_egress.tbl_decap_ports", false) - .await - .expect("Should be able to dump bitmap table"); - - // First, delete the internal group to break the NAT target reference - cleanup_test_group(&switch, internal_group_ip, TEST_TAG) - .await - .expect("Should cleanup internal group"); - - let nat_target = types::NatTarget { - internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, - _ => panic!("Expected IPv6 address"), - }, - inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x02, 0x69).into(), - vni: 105.into(), - }; - - // Get initial table states before attempting creation - let initial_route_table = switch - .client - .table_dump("Ingress.l3_router.MulticastRouter4.tbl", false) - .await - .expect("Should be able to dump route table"); - let initial_bitmap_table = switch - .client - .table_dump("Egress.mcast_egress.tbl_decap_ports", false) - .await - .expect("Should be able to dump bitmap table"); - - // Attempt to create external group that references the deleted internal group - let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, - tag: None, - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target), - }, - external_forwarding: types::ExternalForwarding { vlan_id: Some(999) }, - sources: None, - }; - - // This should fail because the NAT target (internal group) no longer exists - let result = - switch.client.multicast_group_create_external(&external_entry).await; - - assert!( - result.is_err(), - "External group creation should fail when NAT target doesn't exist" - ); - - // Verify rollback worked - tables should remain unchanged - let post_route_table = switch - .client - .table_dump("Ingress.l3_router.MulticastRouter4.tbl", false) - .await - .expect("Should be able to dump route table after rollback"); - let post_bitmap_table = switch - .client - .table_dump("Egress.mcast_egress.tbl_decap_ports", false) - .await - .expect("Should be able to dump bitmap table after rollback"); - - assert_eq!( - initial_route_table.entries.len(), - post_route_table.entries.len(), - "Route table should be unchanged after rollback" - ); - assert_eq!( - initial_bitmap_table.entries.len(), - post_bitmap_table.entries.len(), - "Bitmap table should be unchanged after rollback" - ); - - // No external group should exist since creation failed - let groups = switch - .client - .multicast_groups_list(None, None) - .await - .expect("Should be able to list groups after rollback"); - - let external_groups: Vec<_> = groups - .items - .iter() - .filter(|g| get_group_ip(g) == external_group_ip) - .collect(); - - assert!( - external_groups.is_empty(), - "No external group should exist after failed creation" - ); + cleanup_test_group(&switch, external_group_ip, &TEST_TAG).await.unwrap(); + cleanup_test_group(&switch, internal_group_ip, &TEST_TAG).await } #[tokio::test] #[ignore] -async fn test_multicast_rollback_source_filter_update() -> TestResult { +async fn test_multicast_update_invalid_source_preserves_state() -> TestResult { let switch = &*get_switch().await; // First create the internal admin-scoped group that will be the NAT target @@ -6875,9 +6883,9 @@ async fn test_multicast_rollback_source_filter_update() -> TestResult { create_test_multicast_group( switch, internal_multicast_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(egress1, types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, // No NAT needed for internal groups None, ) @@ -6885,10 +6893,10 @@ async fn test_multicast_rollback_source_filter_update() -> TestResult { // Create IPv4 SSM group that supports source filters let group_ip = IpAddr::V4(Ipv4Addr::new(232, 1, 1, 100)); // SSM range - let nat_target = types::NatTarget { - internal_ip: MULTICAST_NAT_IP.into(), + let nat_target = types::ExternalNatTarget { + internal_ip: MULTICAST_NAT_IP.try_into().unwrap(), inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x01, 0x64).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), }; // Create initial SSM group with source filters @@ -6898,10 +6906,10 @@ async fn test_multicast_rollback_source_filter_update() -> TestResult { ]; let external_group = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target, }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(initial_sources.clone()), @@ -6936,8 +6944,8 @@ async fn test_multicast_rollback_source_filter_update() -> TestResult { let result = switch .client .multicast_group_update_external( - &group_ip, - &make_tag(TEST_TAG), + &group_ip.try_into().unwrap(), + &TEST_TAG, &failing_update_entry, ) .await; @@ -6979,8 +6987,8 @@ async fn test_multicast_rollback_source_filter_update() -> TestResult { ); // Clean up external group first (it references internal group via NAT target) - cleanup_test_group(&switch, group_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(&switch, internal_multicast_ip, TEST_TAG).await + cleanup_test_group(&switch, group_ip, &TEST_TAG).await.unwrap(); + cleanup_test_group(&switch, internal_multicast_ip, &TEST_TAG).await } #[tokio::test] @@ -7003,12 +7011,12 @@ async fn test_multicast_rollback_partial_member_addition() -> TestResult { create_test_multicast_group( &switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (PhysPort(15), types::Direction::External), (PhysPort(16), types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -7051,17 +7059,19 @@ async fn test_multicast_rollback_partial_member_addition() -> TestResult { let update_request = types::MulticastGroupUpdateUnderlayEntry { members: mixed_members }; - let ipv6_update = types::UnderlayMulticastIpv6(match internal_group_ip { + let ipv6_update: UnderlayMulticastIpv6 = match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }); + } + .try_into() + .unwrap(); // This should fail after partially adding some members, triggering incremental rollback let result = switch .client .multicast_group_update_underlay( &ipv6_update, - &make_tag(TEST_TAG), + &TEST_TAG, &update_request, ) .await; @@ -7086,147 +7096,7 @@ async fn test_multicast_rollback_partial_member_addition() -> TestResult { "Member count should be unchanged after partial addition rollback" ); - cleanup_test_group(&switch, internal_group_ip, TEST_TAG).await -} - -#[tokio::test] -#[ignore] -async fn test_multicast_rollback_table_operation_failure() { - let switch = &*get_switch().await; - - let internal_group_ip = IpAddr::V6(Ipv6Addr::new( - ADMIN_LOCAL_MULTICAST_PREFIX, - 0, - 0, - 0, - 0, - 0, - 0, - 107, - )); - let external_group_ip = IpAddr::V4(Ipv4Addr::new(224, 1, 2, 107)); - - // Create internal group first - create_test_multicast_group( - &switch, - internal_group_ip, - Some(TEST_TAG), - &[ - (PhysPort(15), types::Direction::External), - (PhysPort(17), types::Direction::Underlay), - ], - types::InternalForwarding { nat_target: None }, - types::ExternalForwarding { vlan_id: None }, - None, - ) - .await; - - // Delete the internal group to break the NAT target reference - cleanup_test_group(&switch, internal_group_ip, TEST_TAG) - .await - .expect("Should cleanup internal group"); - - // Get table states after internal group deletion but before external group attempt - let initial_route_table = switch - .client - .table_dump("Ingress.l3_router.MulticastRouter4.tbl", false) - .await - .expect("Should be able to dump route table"); - let initial_nat_table = switch - .client - .table_dump("Ingress.nat_ingress.ingress_ipv4_mcast", false) - .await - .expect("Should be able to dump NAT table"); - let initial_bitmap_table = switch - .client - .table_dump("Egress.mcast_egress.tbl_decap_ports", false) - .await - .expect("Should be able to dump bitmap table"); - - // Attempt to create external group that references the non-existent internal group - let broken_nat_target = types::NatTarget { - internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, - _ => panic!("Expected IPv6 address"), - }, - inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x02, 0x6b).into(), - vni: 107.into(), - }; - - let external_entry = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, - tag: None, - internal_forwarding: types::InternalForwarding { - nat_target: Some(broken_nat_target), - }, - external_forwarding: types::ExternalForwarding { vlan_id: Some(200) }, - sources: None, - }; - - // This should fail because the NAT target (internal group) doesn't exist - let result = - switch.client.multicast_group_create_external(&external_entry).await; - - // Verify the creation failed - assert!( - result.is_err(), - "External group creation should fail when NAT target doesn't exist" - ); - - // Verify table rollback worked - all tables should be unchanged - let post_route_table = switch - .client - .table_dump("Ingress.l3_router.MulticastRouter4.tbl", false) - .await - .expect("Should be able to dump route table after rollback"); - - let post_nat_table = switch - .client - .table_dump("Ingress.nat_ingress.ingress_ipv4_mcast", false) - .await - .expect("Should be able to dump NAT table after rollback"); - - let post_bitmap_table = switch - .client - .table_dump("Egress.mcast_egress.tbl_decap_ports", false) - .await - .expect("Should be able to dump bitmap table after rollback"); - - assert_eq!( - post_route_table.entries.len(), - initial_route_table.entries.len(), - "Route table should be unchanged after table operation rollback" - ); - - assert_eq!( - post_nat_table.entries.len(), - initial_nat_table.entries.len(), - "NAT table should be unchanged after table operation rollback" - ); - - assert_eq!( - post_bitmap_table.entries.len(), - initial_bitmap_table.entries.len(), - "Bitmap table should be unchanged after table operation rollback" - ); - - // Verify no external group was created - let groups = switch - .client - .multicast_groups_list(None, None) - .await - .expect("Should be able to list groups after rollback"); - - let external_groups: Vec<_> = groups - .items - .iter() - .filter(|g| get_group_ip(g) == external_group_ip) - .collect(); - - assert!( - external_groups.is_empty(), - "No external group should exist after table operation rollback" - ); + cleanup_test_group(&switch, internal_group_ip, &TEST_TAG).await } #[tokio::test] @@ -7250,12 +7120,12 @@ async fn test_multicast_group_get_underlay() -> TestResult { let _created_group = create_test_multicast_group( &switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[ (PhysPort(10), types::Direction::External), (PhysPort(12), types::Direction::Underlay), ], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -7263,12 +7133,14 @@ async fn test_multicast_group_get_underlay() -> TestResult { let retrieved_underlay = switch .client - .multicast_group_get_underlay(&types::UnderlayMulticastIpv6( - match internal_group_ip { + .multicast_group_get_underlay( + &match internal_group_ip { IpAddr::V6(ipv6) => ipv6, _ => panic!("Expected IPv6 address"), - }, - )) + } + .try_into() + .unwrap(), + ) .await .expect( "Should be able to get underlay group via admin-scoped endpoint", @@ -7276,8 +7148,8 @@ async fn test_multicast_group_get_underlay() -> TestResult { .into_inner(); // Verify the response matches what we created - assert_eq!(retrieved_underlay.group_ip.to_ip_addr(), internal_group_ip); - assert_eq!(retrieved_underlay.tag, TEST_TAG); + assert_eq!(IpAddr::from(retrieved_underlay.group_ip), internal_group_ip); + assert_eq!(retrieved_underlay.tag, TEST_TAG.as_ref()); assert_eq!(retrieved_underlay.members.len(), 2); // Compare with generic GET endpoint result @@ -7309,7 +7181,7 @@ async fn test_multicast_group_get_underlay() -> TestResult { ); } } - cleanup_test_group(&switch, internal_group_ip, TEST_TAG).await + cleanup_test_group(&switch, internal_group_ip, &TEST_TAG).await } const SOURCE_FILTER_IPV4_TABLE: &str = @@ -7344,21 +7216,21 @@ async fn test_source_filter_ipv4_collapses_to_any() -> TestResult { let _internal = create_test_multicast_group( switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(10), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x01, 0x64).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), }; // Get baseline source filter table state @@ -7372,10 +7244,10 @@ async fn test_source_filter_ipv4_collapses_to_any() -> TestResult { // Create external group with mixed sources: specific + `Any` // The optimization should collapse this to just one /0 entry let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![ @@ -7415,17 +7287,11 @@ async fn test_source_filter_ipv4_collapses_to_any() -> TestResult { // Verify deletion ordering: attempting to delete internal group first should fail // because the external group still references it via NAT target - let del_tag = make_tag(TEST_TAG); let delete_internal_first_result = switch .client - .multicast_group_delete(&internal_group_ip, &del_tag) + .multicast_group_delete(&internal_group_ip, &TEST_TAG) .await; - assert!( - delete_internal_first_result.is_err(), - "Deleting internal group while still referenced by external group should fail" - ); - if let Err(Error::ErrorResponse(resp)) = &delete_internal_first_result { let error_msg = format!("{resp:?}"); assert!( @@ -7437,8 +7303,8 @@ async fn test_source_filter_ipv4_collapses_to_any() -> TestResult { } // Cleanup in correct order: external first, then internal - cleanup_test_group(switch, external_group_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(switch, internal_group_ip, TEST_TAG).await + cleanup_test_group(switch, external_group_ip, &TEST_TAG).await.unwrap(); + cleanup_test_group(switch, internal_group_ip, &TEST_TAG).await } /// Test IPv6 source filter collapsing when `IpSrc::Any` is present. @@ -7465,21 +7331,21 @@ async fn test_source_filter_ipv6_collapses_to_any() -> TestResult { let _internal = create_test_multicast_group( switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(10), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6"), }, inner_mac: MacAddr::new(0x33, 0x33, 0x00, 0x00, 0x01, 0x00).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), }; let baseline_table = switch @@ -7491,10 +7357,10 @@ async fn test_source_filter_ipv6_collapses_to_any() -> TestResult { // Create external group with mixed sources: specific + `Any` let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![ @@ -7534,17 +7400,11 @@ async fn test_source_filter_ipv6_collapses_to_any() -> TestResult { // because the external group still references it via NAT target. // This is particularly important for IPv6 where external groups (ff0e::*) // sort AFTER internal groups (ff04::*) in BTreeMap iteration order. - let del_tag = make_tag(TEST_TAG); let delete_internal_first_result = switch .client - .multicast_group_delete(&internal_group_ip, &del_tag) + .multicast_group_delete(&internal_group_ip, &TEST_TAG) .await; - assert!( - delete_internal_first_result.is_err(), - "Deleting internal group while still referenced by external group should fail" - ); - if let Err(Error::ErrorResponse(resp)) = &delete_internal_first_result { let error_msg = format!("{resp:?}"); assert!( @@ -7556,8 +7416,8 @@ async fn test_source_filter_ipv6_collapses_to_any() -> TestResult { } // Cleanup in correct order: external first, then internal - cleanup_test_group(switch, external_group_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(switch, internal_group_ip, TEST_TAG).await + cleanup_test_group(switch, external_group_ip, &TEST_TAG).await.unwrap(); + cleanup_test_group(switch, internal_group_ip, &TEST_TAG).await } /// Test that updating a group from specific sources to include `Any` @@ -7583,21 +7443,21 @@ async fn test_source_filter_update_to_any() -> TestResult { let _internal = create_test_multicast_group( switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(10), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x01, 0x65).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), }; let baseline_table = switch @@ -7609,10 +7469,10 @@ async fn test_source_filter_update_to_any() -> TestResult { // Create external group with only specific sources (no `Any`) let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![ @@ -7642,8 +7502,8 @@ async fn test_source_filter_update_to_any() -> TestResult { // Update to include `Any`, simulating an "any source" member joining let update_entry = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![ @@ -7656,8 +7516,8 @@ async fn test_source_filter_update_to_any() -> TestResult { let updated = switch .client .multicast_group_update_external( - &external_group_ip, - &make_tag(TEST_TAG), + &external_group_ip.try_into().unwrap(), + &TEST_TAG, &update_entry, ) .await @@ -7684,8 +7544,8 @@ async fn test_source_filter_update_to_any() -> TestResult { ); // Cleanup in correct order: external first, then internal - cleanup_test_group(switch, external_group_ip, TEST_TAG).await.unwrap(); - cleanup_test_group(switch, internal_group_ip, TEST_TAG).await + cleanup_test_group(switch, external_group_ip, &TEST_TAG).await.unwrap(); + cleanup_test_group(switch, internal_group_ip, &TEST_TAG).await } /// Test that source filter entries are properly cleaned up when a group is deleted. @@ -7710,21 +7570,21 @@ async fn test_source_filter_cleanup_on_delete() -> TestResult { let _internal = create_test_multicast_group( switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(10), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x01, 0x66).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), }; let baseline_table = switch @@ -7736,10 +7596,10 @@ async fn test_source_filter_cleanup_on_delete() -> TestResult { // Create external group with sources let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![ @@ -7768,7 +7628,7 @@ async fn test_source_filter_cleanup_on_delete() -> TestResult { ); // Delete the external group - cleanup_test_group(switch, external_group_ip, TEST_TAG) + cleanup_test_group(switch, external_group_ip, &TEST_TAG) .await .expect("Should delete external group"); @@ -7785,7 +7645,7 @@ async fn test_source_filter_cleanup_on_delete() -> TestResult { "Source filter entries should be cleaned up after group deletion" ); - cleanup_test_group(switch, internal_group_ip, TEST_TAG).await + cleanup_test_group(switch, internal_group_ip, &TEST_TAG).await } /// Test that empty sources `Some(vec![])` is normalized to None and adds /0 entry. @@ -7810,21 +7670,21 @@ async fn test_source_filter_empty_vec_normalizes_to_any() -> TestResult { let _internal = create_test_multicast_group( switch, internal_group_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(10), types::Direction::External)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) .await; - let nat_target = types::NatTarget { + let nat_target = types::ExternalNatTarget { internal_ip: match internal_group_ip { - IpAddr::V6(ipv6) => ipv6, + IpAddr::V6(ipv6) => ipv6.try_into().unwrap(), _ => panic!("Expected IPv6"), }, inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x01, 0x01, 0x67).into(), - vni: 100.into(), + vni: 100.try_into().unwrap(), }; let baseline_table = switch @@ -7837,10 +7697,10 @@ async fn test_source_filter_empty_vec_normalizes_to_any() -> TestResult { // Create external group with empty sources vec - should normalize to None // and add a single /0 entry (allow any source) let external_group = types::MulticastGroupCreateExternalEntry { - group_ip: external_group_ip, + group_ip: external_group_ip.try_into().unwrap(), tag: Some(TEST_TAG.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: Some(vec![]), // Empty vec should normalize to None @@ -7873,7 +7733,7 @@ async fn test_source_filter_empty_vec_normalizes_to_any() -> TestResult { ); // Cleanup - cleanup_test_group(switch, external_group_ip, TEST_TAG).await.unwrap(); + cleanup_test_group(switch, external_group_ip, &TEST_TAG).await.unwrap(); // Verify the /0 entry was removed let after_delete_table = switch @@ -7888,7 +7748,7 @@ async fn test_source_filter_empty_vec_normalizes_to_any() -> TestResult { "Source filter entry should be cleaned up after deletion" ); - cleanup_test_group(switch, internal_group_ip, TEST_TAG).await + cleanup_test_group(switch, internal_group_ip, &TEST_TAG).await } /// Test that updating non-existent groups returns 404. @@ -7896,9 +7756,10 @@ async fn test_source_filter_empty_vec_normalizes_to_any() -> TestResult { #[ignore] async fn test_update_nonexistent_group_returns_404() -> TestResult { let switch = &*get_switch().await; + let tag: MulticastTag = "nonexistent_test".parse().unwrap(); // Case: Update non-existent underlay group - let nonexistent_underlay: types::UnderlayMulticastIpv6 = + let nonexistent_underlay: UnderlayMulticastIpv6 = Ipv6Addr::new(ADMIN_LOCAL_MULTICAST_PREFIX, 0, 0, 0, 0, 0, 0, 0xdead) .try_into() .unwrap(); @@ -7916,7 +7777,7 @@ async fn test_update_nonexistent_group_returns_404() -> TestResult { .client .multicast_group_update_underlay( &nonexistent_underlay, - &make_tag("nonexistent_test"), + &tag, &underlay_update, ) .await; @@ -7934,15 +7795,22 @@ async fn test_update_nonexistent_group_returns_404() -> TestResult { let external_update = types::MulticastGroupUpdateExternalEntry { external_forwarding: types::ExternalForwarding { vlan_id: Some(100) }, - internal_forwarding: types::InternalForwarding { nat_target: None }, + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: "ff04::1".parse().unwrap(), + inner_mac: MacAddr::new(1, 0, 94, 0, 0, 1).into(), + vni: 100.try_into().unwrap(), + }, + }, + sources: None, }; let result = switch .client .multicast_group_update_external( - &nonexistent_external, - &make_tag("nonexistent_test"), + &nonexistent_external.try_into().unwrap(), + &tag, &external_update, ) .await; @@ -7970,7 +7838,7 @@ async fn test_delete_nonexistent_group_returns_404() -> TestResult { // Case: Delete non-existent group with a tag provided let nonexistent_ip = IpAddr::V4(Ipv4Addr::new(239, 255, 255, 253)); - let del_tag = make_tag("some_tag"); + let del_tag: MulticastTag = "some_tag".parse().unwrap(); let result = switch.client.multicast_group_delete(&nonexistent_ip, &del_tag).await; @@ -7997,11 +7865,11 @@ async fn test_delete_nonexistent_group_returns_404() -> TestResult { async fn test_underlay_delete_recreate_recovery_flow() -> TestResult { let switch = &*get_switch().await; - let group_ip: types::UnderlayMulticastIpv6 = + let group_ip: UnderlayMulticastIpv6 = Ipv6Addr::new(ADMIN_LOCAL_MULTICAST_PREFIX, 0, 0, 0, 0, 0, 0, 0x501) .try_into() .unwrap(); - let tag = "recovery_flow_test"; + let tag: MulticastTag = "recovery_flow_test".parse().unwrap(); // Case: Create underlay group with initial member let (port_id_1, link_id_1) = switch.link_id(PhysPort(15)).unwrap(); @@ -8024,10 +7892,9 @@ async fn test_underlay_delete_recreate_recovery_flow() -> TestResult { assert_eq!(created.into_inner().members.len(), 1); // Case: Delete the group (simulating recovery from stale state) - let del_tag = make_tag(tag); switch .client - .multicast_group_delete(&group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(group_ip), &tag) .await .expect("Should delete group during recovery"); @@ -8072,7 +7939,7 @@ async fn test_underlay_delete_recreate_recovery_flow() -> TestResult { 2, "Recreated group should have 2 members" ); - assert_eq!(recreated_inner.tag, tag); + assert_eq!(recreated_inner.tag, tag.as_ref()); // Verify we can fetch it let fetched = switch @@ -8086,7 +7953,7 @@ async fn test_underlay_delete_recreate_recovery_flow() -> TestResult { // Cleanup switch .client - .multicast_reset_by_tag(&make_tag(tag)) + .multicast_reset_by_tag(&tag) .await .expect("Should cleanup by tag"); @@ -8099,7 +7966,7 @@ async fn test_underlay_delete_recreate_recovery_flow() -> TestResult { #[ignore] async fn test_vlan_lifecycle_route_entries() -> TestResult { let switch = &*get_switch().await; - let tag = "vlan_lifecycle_test"; + let tag: MulticastTag = "vlan_lifecycle_test".parse().unwrap(); // Setup: create internal admin-scoped group for NAT target let internal_ip = IpAddr::V6(MULTICAST_NAT_IP); @@ -8107,9 +7974,9 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { create_test_multicast_group( switch, internal_ip, - Some(tag), + Some(&tag), &[(egress_port, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -8121,10 +7988,10 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { // Create without VLAN let create_no_vlan = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: Some(tag.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: None }, sources: None, @@ -8138,8 +8005,8 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { // Update to add VLAN 10 let update_add_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -8148,8 +8015,8 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, - &make_tag(tag), + &group_ip.try_into().unwrap(), + &tag, &update_add_vlan, ) .await @@ -8168,8 +8035,8 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { // Update to change VLAN 10 -> 20, verify no stale entries let update_change_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(20) }, sources: None, @@ -8178,8 +8045,8 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, - &make_tag(tag), + &group_ip.try_into().unwrap(), + &tag, &update_change_vlan, ) .await @@ -8202,8 +8069,8 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { // Update to remove VLAN let update_remove_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: None }, sources: None, @@ -8212,8 +8079,8 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, - &make_tag(tag), + &group_ip.try_into().unwrap(), + &tag, &update_remove_vlan, ) .await @@ -8233,7 +8100,7 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { // Cleanup switch .client - .multicast_reset_by_tag(&make_tag(tag)) + .multicast_reset_by_tag(&tag) .await .expect("Should cleanup by tag"); @@ -8247,7 +8114,7 @@ async fn test_vlan_lifecycle_route_entries() -> TestResult { #[ignore] async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { let switch = &*get_switch().await; - let tag = "vlan_lifecycle_v6_test"; + let tag: MulticastTag = "vlan_lifecycle_v6_test".parse().unwrap(); // Setup: create internal admin-scoped group for NAT target let internal_ip = IpAddr::V6(MULTICAST_NAT_IP); @@ -8255,9 +8122,9 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { create_test_multicast_group( switch, internal_ip, - Some(tag), + Some(&tag), &[(egress_port, types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -8270,10 +8137,10 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { // Create without VLAN let create_no_vlan = types::MulticastGroupCreateExternalEntry { - group_ip, + group_ip: group_ip.try_into().unwrap(), tag: Some(tag.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: None }, sources: None, @@ -8287,8 +8154,8 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { // Update to add VLAN 10 let update_add_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -8297,8 +8164,8 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, - &make_tag(tag), + &group_ip.try_into().unwrap(), + &tag, &update_add_vlan, ) .await @@ -8317,8 +8184,8 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { // Update to change VLAN 10 -> 20, verify no stale entries let update_change_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(20) }, sources: None, @@ -8327,8 +8194,8 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, - &make_tag(tag), + &group_ip.try_into().unwrap(), + &tag, &update_change_vlan, ) .await @@ -8351,8 +8218,8 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { // Update to remove VLAN let update_remove_vlan = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(nat_target.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), }, external_forwarding: types::ExternalForwarding { vlan_id: None }, sources: None, @@ -8361,8 +8228,8 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { let updated = switch .client .multicast_group_update_external( - &group_ip, - &make_tag(tag), + &group_ip.try_into().unwrap(), + &tag, &update_remove_vlan, ) .await @@ -8382,7 +8249,7 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { // Cleanup switch .client - .multicast_reset_by_tag(&make_tag(tag)) + .multicast_reset_by_tag(&tag) .await .expect("Should cleanup by tag"); @@ -8395,7 +8262,7 @@ async fn test_vlan_lifecycle_route_entries_ipv6() -> TestResult { /// group's tag. This prevents unauthorized modifications. #[tokio::test] #[ignore] -async fn test_tag_immutability_on_update() -> TestResult { +async fn test_underlay_update_rejects_tag_mismatch() -> TestResult { let switch = &*get_switch().await; let (port_id, link_id) = switch.link_id(PhysPort(11)).unwrap(); @@ -8431,11 +8298,7 @@ async fn test_tag_immutability_on_update() -> TestResult { let result = switch .client - .multicast_group_update_underlay( - &group_ip, - &make_tag(TAG_WRONG), - &update_entry, - ) + .multicast_group_update_underlay(&group_ip, &TAG_WRONG, &update_entry) .await; match &result { @@ -8445,10 +8308,14 @@ async fn test_tag_immutability_on_update() -> TestResult { 400, "Update with wrong tag should return 400" ); + assert_eq!( + resp.error_code.as_deref(), + Some(MCAST_TAG_MISMATCH_ERROR_CODE) + ); // Security: error should not reveal the correct tag let body = format!("{:?}", resp); assert!( - !body.contains(TAG_A), + !body.contains(TAG_A.as_ref()), "Error message should not reveal correct tag" ); } @@ -8458,10 +8325,9 @@ async fn test_tag_immutability_on_update() -> TestResult { } // Cleanup - let del_tag = make_tag(TAG_A); switch .client - .multicast_group_delete(&group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(group_ip), &TAG_A) .await .expect("Should delete with correct tag"); @@ -8501,10 +8367,9 @@ async fn test_tag_validation_on_delete() -> TestResult { let group_ip = created.group_ip; // Attempt delete with wrong tag - let del_tag = make_tag(TAG_WRONG); let result = switch .client - .multicast_group_delete(&group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(group_ip), &TAG_WRONG) .await; match &result { @@ -8514,6 +8379,10 @@ async fn test_tag_validation_on_delete() -> TestResult { 400, "Delete with wrong tag should return 400" ); + assert_eq!( + resp.error_code.as_deref(), + Some(MCAST_TAG_MISMATCH_ERROR_CODE) + ); } _ => { panic!("Expected ErrorResponse for tag mismatch, got: {:?}", result) @@ -8527,13 +8396,16 @@ async fn test_tag_validation_on_delete() -> TestResult { .await .expect("Group should still exist after failed delete"); - assert_eq!(fetched.into_inner().tag, TAG_A, "Tag should be preserved"); + assert_eq!( + fetched.into_inner().tag, + TAG_A.as_ref(), + "Tag should be preserved" + ); // Delete with correct tag - let del_tag = make_tag(TAG_A); switch .client - .multicast_group_delete(&group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(group_ip), &TAG_A) .await .expect("Should delete with correct tag"); @@ -8556,9 +8428,9 @@ async fn test_tag_validation() -> TestResult { create_test_multicast_group( switch, internal_ip, - Some(TEST_TAG), + Some(&TEST_TAG), &[(PhysPort(11), types::Direction::Underlay)], - types::InternalForwarding { nat_target: None }, + None, types::ExternalForwarding { vlan_id: None }, None, ) @@ -8566,10 +8438,10 @@ async fn test_tag_validation() -> TestResult { let external_ip = IpAddr::V4(Ipv4Addr::new(224, 0, 12, 1)); let create_entry = types::MulticastGroupCreateExternalEntry { - group_ip: external_ip, + group_ip: external_ip.try_into().unwrap(), tag: Some(TAG_A.to_string()), - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, sources: None, @@ -8582,8 +8454,8 @@ async fn test_tag_validation() -> TestResult { .expect("Should create external group"); let update_entry = types::MulticastGroupUpdateExternalEntry { - internal_forwarding: types::InternalForwarding { - nat_target: Some(create_nat_target_ipv4()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: create_nat_target_ipv4(), }, external_forwarding: types::ExternalForwarding { vlan_id: Some(20) }, sources: None, @@ -8592,8 +8464,8 @@ async fn test_tag_validation() -> TestResult { let result = switch .client .multicast_group_update_external( - &external_ip, - &make_tag(TAG_WRONG), + &external_ip.try_into().unwrap(), + &TAG_WRONG, &update_entry, ) .await; @@ -8605,24 +8477,29 @@ async fn test_tag_validation() -> TestResult { 400, "Update with wrong tag should return 400" ); + assert_eq!( + resp.error_code.as_deref(), + Some(MCAST_TAG_MISMATCH_ERROR_CODE) + ); assert!( - !format!("{:?}", resp).contains(TAG_A), + !format!("{:?}", resp).contains(TAG_A.as_ref()), "Error should not reveal tag" ); } _ => panic!("Expected ErrorResponse for tag mismatch"), } - cleanup_test_group(switch, external_ip, TAG_A).await.unwrap(); - cleanup_test_group(switch, internal_ip, TEST_TAG).await.unwrap(); + cleanup_test_group(switch, external_ip, &TAG_A).await.unwrap(); + cleanup_test_group(switch, internal_ip, &TEST_TAG).await.unwrap(); // Case: Case-sensitive tag matching let (port_id, link_id) = switch.link_id(PhysPort(11)).unwrap(); + let case_tag: MulticastTag = "CaseSensitiveTag".parse().unwrap(); let create_entry = types::MulticastGroupCreateUnderlayEntry { group_ip: "ff04::900".parse().unwrap(), - tag: Some("CaseSensitiveTag".to_string()), + tag: Some(case_tag.to_string()), members: vec![types::MulticastGroupMember { port_id: port_id.clone(), link_id, @@ -8639,10 +8516,10 @@ async fn test_tag_validation() -> TestResult { let case_group_ip = created.group_ip; - let del_tag = make_tag("casesensitivetag"); + let del_tag: MulticastTag = "casesensitivetag".parse().unwrap(); let result = switch .client - .multicast_group_delete(&case_group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(case_group_ip), &del_tag) .await; assert!( @@ -8650,18 +8527,18 @@ async fn test_tag_validation() -> TestResult { "Case-insensitive tag should fail" ); - let del_tag = make_tag("CaseSensitiveTag"); switch .client - .multicast_group_delete(&case_group_ip.to_ip_addr(), &del_tag) + .multicast_group_delete(&IpAddr::from(case_group_ip), &case_tag) .await .expect("Should delete with correct case"); // Case: Reset by non-existent tag (no-op) + let nonexistent_tag: MulticastTag = "nonexistent_tag_xyz".parse().unwrap(); switch .client - .multicast_reset_by_tag(&make_tag("nonexistent_tag_xyz")) + .multicast_reset_by_tag(&nonexistent_tag) .await .expect("Reset with non-existent tag should succeed"); diff --git a/dpd-client/tests/integration_tests/table_tests.rs b/dpd-client/tests/integration_tests/table_tests.rs index 1407db41..149948de 100644 --- a/dpd-client/tests/integration_tests/table_tests.rs +++ b/dpd-client/tests/integration_tests/table_tests.rs @@ -17,6 +17,7 @@ use reqwest::StatusCode; use dpd_client::ClientInfo; use dpd_client::ResponseValue; use dpd_client::types; +use dpd_types::mcast::{MulticastTag, UnderlayMulticastIpv6}; use crate::integration_tests::common::prelude::*; @@ -314,7 +315,7 @@ impl TableTest for types::Ipv4Nat { 1, ), inner_mac: MacAddr::new(0xe0, 0xd5, 0x5e, 0x67, 0x89, 0xab).into(), - vni: 0.into(), + vni: 0.try_into().unwrap(), }; switch .client @@ -352,7 +353,7 @@ impl TableTest for types::Ipv6Nat { let tgt = types::NatTarget { internal_ip: "fd00:1122:7788:0101::4".parse::().unwrap(), inner_mac: MacAddr::new(0xe0, 0xd5, 0x5e, 0x67, 0x89, 0xab).into(), - vni: 0.into(), + vni: 0.try_into().unwrap(), }; switch .client @@ -483,7 +484,7 @@ impl TableTest // Admin-local IPv6 groups are internal with replication info and members let internal_entry = types::MulticastGroupCreateUnderlayEntry { - group_ip: types::UnderlayMulticastIpv6(group_ip), + group_ip: UnderlayMulticastIpv6::try_from(group_ip).unwrap(), tag: Some(MCAST_TAG.to_string()), members: vec![ types::MulticastGroupMember { @@ -503,7 +504,7 @@ impl TableTest async fn delete_entry(switch: &Switch, idx: usize) -> OpResult<()> { let ip = IpAddr::V6(gen_ipv6_multicast_addr(idx)); - let del_tag: types::MulticastTag = + let del_tag: MulticastTag = MCAST_TAG.parse().expect("tag should parse"); switch.client.multicast_group_delete(&ip, &del_tag).await } @@ -513,7 +514,7 @@ impl TableTest switch .client .multicast_groups_list_by_tag_stream( - &MCAST_TAG.parse::().unwrap(), + &MCAST_TAG.parse::().unwrap(), None, ) .try_collect::>() diff --git a/dpd-types/versions/Cargo.toml b/dpd-types/versions/Cargo.toml index 70dcaeec..aecbde92 100644 --- a/dpd-types/versions/Cargo.toml +++ b/dpd-types/versions/Cargo.toml @@ -4,12 +4,19 @@ version = "0.1.0" edition = "2024" [dependencies] -chrono.workspace = true -dropshot.workspace = true -omicron-common.workspace = true +chrono = { workspace = true, features = ["serde"] } oxnet.workspace = true +proptest = { workspace = true, optional = true } rand.workspace = true -schemars.workspace = true +schemars = { workspace = true, features = ["chrono", "uuid1"] } serde.workspace = true thiserror.workspace = true uuid.workspace = true + +[dev-dependencies] +proptest.workspace = true +serde_json.workspace = true +test-strategy.workspace = true + +[features] +testing = ["dep:proptest"] diff --git a/dpd-types/versions/src/impls/mcast.rs b/dpd-types/versions/src/impls/mcast.rs index a067c211..fb7c7928 100644 --- a/dpd-types/versions/src/impls/mcast.rs +++ b/dpd-types/versions/src/impls/mcast.rs @@ -4,34 +4,428 @@ // // Copyright 2026 Oxide Computer Company +//! Unversioned constants and conversions for the multicast types re-exported +//! by [`crate::latest::mcast`]. +//! +//! [`IPV4_SSM_SUBNET`], [`IPV6_SSM_SUBNETS`], and +//! [`IPV6_ADMIN_SCOPED_MULTICAST_PREFIX`] are unversioned because [RFC 4607] +//! and [RFC 4291] establish them for every API version. The validators, +//! [`is_ssm_address`], and the source rules are defined in the +//! [`MCAST_EXTERNAL_SCOPE`] module because they encode Oxide policy or our +//! interpretation of the RFCs. [`UNDERLAY_MULTICAST_SUBNET`] and the tag +//! rules are defined in [`MCAST_STRICT_UNDERLAY`]. +//! +//! [`is_ssm_address`]: crate::v14::mcast::is_ssm_address +//! [`MCAST_EXTERNAL_SCOPE`]: crate::v14::mcast +//! [`MCAST_STRICT_UNDERLAY`]: crate::v8::mcast +//! [`UNDERLAY_MULTICAST_SUBNET`]: crate::v8::mcast::UNDERLAY_MULTICAST_SUBNET +//! [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html +//! [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html + use std::{ fmt, - net::{IpAddr, Ipv6Addr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, str::FromStr, }; -use omicron_common::address::UNDERLAY_MULTICAST_SUBNET; +use oxnet::{Ipv4Net, Ipv6Net}; use crate::latest::mcast::{ - Error, IpSrc, MulticastGroupResponse, MulticastTag, UnderlayMulticastIpv6, + AsmMulticastGroupCreate, EmbeddedIpv4, ExactSource, ExternalForwarding, + ExternalInternalForwarding, ExternalMulticastIp, ExternalMulticastIpError, + ExternalNatTarget, InvalidEmbeddedRp, InvalidMulticastSource, IpSrc, + MAX_TAG_LENGTH, MulticastGroupCreateExternalEntry, + MulticastGroupCreateExternalError, MulticastGroupResponse, + MulticastSourceError, MulticastSourceParseError, MulticastTag, + MulticastTagParseError, NonEmptyExactSources, SourceEntry, SourceFilter, + SsmMulticastGroupCreate, SsmMulticastIp, UNDERLAY_MULTICAST_SUBNET, + UnderlayMulticastError, UnderlayMulticastIpv6, UnderlayMulticastParseError, + UnicastAddrV4, UnicastAddrV6, is_ssm_address, + validate_external_multicast_ip, +}; +use crate::v14::mcast::{ + validate_unicast_source_ipv4, validate_unicast_source_ipv6, }; -/// Maximum length for multicast tags. -pub const MAX_TAG_LENGTH: usize = 80; +/// IPv4 Source-Specific Multicast (SSM) subnet (`232.0.0.0/8`) per +/// [RFC 4607] §1. +/// +/// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html +// TODO: move to oxnet with `IPV6_SSM_SUBNETS`, next to its multicast scope +// predicates, along with an RFC 4607 `is_ssm` predicate. +pub const IPV4_SSM_SUBNET: Ipv4Net = + Ipv4Net::new_unchecked(Ipv4Addr::new(232, 0, 0, 0), 8); + +const fn ipv6_ssm_subnet(scope: u16) -> Ipv6Net { + Ipv6Net::new_unchecked( + Ipv6Addr::new(0xff30 | scope, 0, 0, 0, 0, 0, 0, 0), + 32, + ) +} + +/// IPv6 Source-Specific Multicast (SSM) subnets, one per scope field value. +/// +/// [RFC 4607] §1 specifies "ff3x::/32 for each scope x", meaning one /32 +/// block per scope (ff30::/32, ff31::/32, ..., ff3f::/32). +/// +/// These blocks cannot be represented by a single CIDR: the scope nibble +/// precedes the 16 zero bits that complete each /32. In particular, ff3e:1:: +/// is outside ff3e::/32, even though it is inside the broader ff30::/12 +/// prefix. +/// +/// These are the allocation blocks only. An address can be classified +/// via the [`is_ssm_address`] predicate, which also accepts the `ffbx` +/// (X flag set) form, a nonzero `ff2` nibble ([RFC 7371] §4.1.1), and a +/// nonzero `rsvd` nibble (RIID in [RFC 3956] §3). +/// +/// [`is_ssm_address`]: crate::v14::mcast::is_ssm_address +/// [RFC 3956]: https://www.rfc-editor.org/rfc/rfc3956.html +/// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html +/// [RFC 7371]: https://www.rfc-editor.org/rfc/rfc7371.html +pub const IPV6_SSM_SUBNETS: [Ipv6Net; 16] = [ + ipv6_ssm_subnet(0x0), + ipv6_ssm_subnet(0x1), + ipv6_ssm_subnet(0x2), + ipv6_ssm_subnet(0x3), + ipv6_ssm_subnet(0x4), + ipv6_ssm_subnet(0x5), + ipv6_ssm_subnet(0x6), + ipv6_ssm_subnet(0x7), + ipv6_ssm_subnet(0x8), + ipv6_ssm_subnet(0x9), + ipv6_ssm_subnet(0xa), + ipv6_ssm_subnet(0xb), + ipv6_ssm_subnet(0xc), + ipv6_ssm_subnet(0xd), + ipv6_ssm_subnet(0xe), + ipv6_ssm_subnet(0xf), +]; + +/// Admin-local IPv6 multicast prefix (ff04::/16) as a `u16`, used for address +/// construction of underlay multicast addresses. +/// +/// See [RFC 4291] §2.7 and [RFC 7346] for the multicast address format and +/// associated scope definitions. +/// +/// [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html +/// [RFC 7346]: https://www.rfc-editor.org/rfc/rfc7346.html +pub const IPV6_ADMIN_SCOPED_MULTICAST_PREFIX: u16 = 0xff04; + +/// A request's sources whether in ASM or SSM structure, driven by its +/// group address. +pub(crate) enum RequestSources { + /// Any-source group, with the filter already canonicalized. + Asm(SourceFilter), + /// Source-specific group, carrying the narrowed address alongside the + /// sources that [RFC 4607] requires it to have. + /// + /// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html + Ssm { group_ip: SsmMulticastIp, sources: NonEmptyExactSources }, +} + +impl RequestSources { + /// Disambiguate a create request into its any-source (ASM) or + /// source-specific (SSM) structure, validating each source against the + /// group address. + /// + /// This rejects any source whose address family differs from the group's. + /// An SSM group rejects an `Any` entry and requires at least one exact + /// source, since routers must ignore a request for an SSM destination + /// that names no source ([RFC 4607] §2); an ASM group accepts either form. + /// + /// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html + pub(crate) fn new( + group_ip: ExternalMulticastIp, + sources: Option<&[SourceEntry]>, + ) -> Result { + let group: IpAddr = group_ip.into(); + let sources = sources.unwrap_or_default(); + let has_any = sources.iter().any(|s| matches!(s, SourceEntry::Any)); + let exact = sources + .iter() + .filter_map(|source| match source { + SourceEntry::Any => None, + SourceEntry::Exact(source) => Some(*source), + }) + .collect::>(); + + if let Some(source) = + exact.iter().find(|source| source.ip().is_ipv4() != group.is_ipv4()) + { + return Err( + MulticastGroupCreateExternalError::SourceFamilyMismatch { + source_ip: source.ip(), + group, + }, + ); + } + + match (SsmMulticastIp::new(group_ip), has_any) { + (None, true) => Ok(Self::Asm(SourceFilter::Any)), + (None, false) => Ok(Self::Asm(SourceFilter::from_exact(exact))), + (Some(_), true) => Err( + MulticastGroupCreateExternalError::SsmRejectsAnySource(group), + ), + (Some(group_ip), false) => NonEmptyExactSources::new(exact) + .map(|sources| Self::Ssm { group_ip, sources }) + .ok_or(MulticastGroupCreateExternalError::SsmRequiresSources( + group, + )), + } + } +} + +impl From<&MulticastGroupCreateExternalEntry> for SourceFilter { + /// Build the stored filter from a validated create request. + fn from(entry: &MulticastGroupCreateExternalEntry) -> Self { + match entry { + MulticastGroupCreateExternalEntry::Asm(group) => { + group.sources.clone() + } + MulticastGroupCreateExternalEntry::Ssm(group) => { + Self::Exact(group.sources.clone()) + } + } + } +} + +impl SourceFilter { + /// Borrow the exact sources or `None` if the filter is unrestricted. + pub fn exact_sources(&self) -> Option<&[ExactSource]> { + match self { + Self::Any => None, + Self::Exact(sources) => Some(sources.as_slice()), + } + } + + /// Validate and canonicalize source entries against the given + /// `group_ip`. + /// + /// Each entry has previously been validated on its own address. + /// This checks only the address-family (mis-)match and the ASM/SSM + /// disambiguation. + /// + /// # Errors + /// + /// Returns an [`MulticastGroupCreateExternalError`] if a source and the + /// group IP use different address families, or if an SSM group requests + /// an `Any`, or if it does not supply an exact source at all. + pub fn from_entries( + group_ip: ExternalMulticastIp, + sources: Option<&[SourceEntry]>, + ) -> Result { + Ok(match RequestSources::new(group_ip, sources)? { + RequestSources::Asm(sources) => sources, + RequestSources::Ssm { sources, .. } => Self::Exact(sources), + }) + } + + /// Build a filter from the exact source list, collapsing an empty list + /// into `Any`. + pub fn from_exact(sources: impl IntoIterator) -> Self { + match NonEmptyExactSources::new(sources.into_iter().collect()) { + Some(sources) => Self::Exact(sources), + None => Self::Any, + } + } -/// Error parsing a multicast tag from a string. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MulticastTagParseError(pub(crate) String); + /// Iterate over source entries. + pub fn iter(&self) -> impl Iterator + '_ { + let (any, exact) = match self { + Self::Any => (Some(IpSrc::Any), [].as_slice()), + Self::Exact(sources) => (None, sources.as_slice()), + }; + any.into_iter().chain(exact.iter().copied().map(IpSrc::from)) + } + + /// Project the filter back into its serialized form. + /// + /// [`SourceFilter::Any`] emits an absent list, not an explicit + /// `[Any]` one. + /// + /// A request that mixes `Any` with exact sources does not round-trip. + pub fn to_ip_sources(&self) -> Option> { + match self { + Self::Any => None, + Self::Exact(sources) => Some( + sources.as_slice().iter().copied().map(IpSrc::from).collect(), + ), + } + } + + /// Project the source filter into the request's entry shape. + /// + /// [`SourceFilter::Any`] emits an absent list and not an explicit + /// [`SourceEntry::Any`] one, as with + /// [`SourceFilter::to_ip_sources`]. + pub fn to_entries(&self) -> Option> { + match self { + Self::Any => None, + Self::Exact(sources) => Some( + sources + .as_slice() + .iter() + .copied() + .map(SourceEntry::Exact) + .collect(), + ), + } + } +} + +impl AsmMulticastGroupCreate { + /// Return the multicast group address. + pub fn group_ip(&self) -> ExternalMulticastIp { + self.group_ip + } + + /// Return the requested tag, if present. + pub fn tag(&self) -> Option<&str> { + self.tag.as_deref() + } + + /// Return the internal forwarding configuration. + pub fn internal_forwarding(&self) -> &ExternalInternalForwarding { + &self.internal_forwarding + } + + /// Return the external forwarding configuration. + pub fn external_forwarding(&self) -> &ExternalForwarding { + &self.external_forwarding + } + + /// Return the source filter in its serialized form. + pub fn sources(&self) -> Option + '_> { + self.sources + .exact_sources() + .map(|sources| sources.iter().copied().map(IpSrc::from)) + } + + /// Return whether the group accepts every source address. + pub fn allows_any_source(&self) -> bool { + matches!(self.sources, SourceFilter::Any) + } +} + +impl SsmMulticastGroupCreate { + /// Return the SSM group address. + pub fn group_ip(&self) -> SsmMulticastIp { + self.group_ip + } + + /// Return the requested tag, if present. + pub fn tag(&self) -> Option<&str> { + self.tag.as_deref() + } + + /// Return the internal forwarding configuration. + pub fn internal_forwarding(&self) -> &ExternalInternalForwarding { + &self.internal_forwarding + } + + /// Return the external forwarding configuration. + pub fn external_forwarding(&self) -> &ExternalForwarding { + &self.external_forwarding + } + + /// Return the exact source addresses. + pub fn sources(&self) -> &[ExactSource] { + self.sources.as_slice() + } +} + +impl MulticastGroupCreateExternalEntry { + /// Return the multicast group address. + pub fn group_ip(&self) -> ExternalMulticastIp { + match self { + Self::Asm(group) => group.group_ip, + Self::Ssm(group) => group.group_ip.into(), + } + } + + /// Return the requested tag, if present. + pub fn tag(&self) -> Option<&str> { + match self { + Self::Asm(group) => group.tag(), + Self::Ssm(group) => group.tag(), + } + } + + /// Return the internal forwarding configuration. + pub fn internal_forwarding(&self) -> &ExternalInternalForwarding { + match self { + Self::Asm(group) => group.internal_forwarding(), + Self::Ssm(group) => group.internal_forwarding(), + } + } + + /// Return the external forwarding configuration. + pub fn external_forwarding(&self) -> &ExternalForwarding { + match self { + Self::Asm(group) => group.external_forwarding(), + Self::Ssm(group) => group.external_forwarding(), + } + } + + /// Return the source filter in its serialized form. + pub fn sources(&self) -> Option + '_> { + let sources = match self { + Self::Asm(group) => group.sources.exact_sources()?, + Self::Ssm(group) => group.sources(), + }; + Some(sources.iter().copied().map(IpSrc::from)) + } +} + +impl MulticastGroupResponse { + /// Return the multicast group IP address. + pub fn ip(&self) -> IpAddr { + match self { + Self::Underlay(resp) => resp.group_ip.into(), + Self::External(resp) => resp.group_ip.into(), + } + } + + /// Return the tag. + pub fn tag(&self) -> &str { + match self { + Self::Underlay(resp) => &resp.tag, + Self::External(resp) => &resp.tag, + } + } +} + +impl fmt::Display for IpSrc { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + IpSrc::Exact(ip) => write!(f, "{ip}"), + IpSrc::Any => write!(f, "any"), + } + } +} impl UnderlayMulticastIpv6 { - /// Create a new UnderlayMulticastIpv6 if the address is within the - /// underlay multicast subnet (ff04::/64). - pub fn new(addr: Ipv6Addr) -> Result { + /// Create a new `UnderlayMulticastIpv6` if the address is within the + /// underlay multicast subnet (`ff04::/64`). + /// + /// # Errors + /// + /// Returns [`UnderlayMulticastError::InvalidUnderlayMulticastIp`] if + /// `addr` is outside [`UNDERLAY_MULTICAST_SUBNET`]. + pub fn new(addr: Ipv6Addr) -> Result { if !UNDERLAY_MULTICAST_SUBNET.contains(addr) { - return Err(Error::InvalidUnderlayMulticastIp(addr)); + return Err(UnderlayMulticastError::InvalidUnderlayMulticastIp( + addr, + )); } Ok(Self(addr)) } + + /// Return the inner IPv6 address. + #[inline] + pub const fn ip(&self) -> Ipv6Addr { + self.0 + } } impl From for IpAddr { @@ -47,13 +441,32 @@ impl fmt::Display for UnderlayMulticastIpv6 { } impl FromStr for UnderlayMulticastIpv6 { - type Err = Error; + type Err = UnderlayMulticastParseError; fn from_str(s: &str) -> Result { - let addr: Ipv6Addr = s - .parse() - .map_err(|e| Error::InvalidIpv6Address(s.to_string(), e))?; - Self::new(addr) + let addr: Ipv6Addr = s.parse().map_err(|e| { + UnderlayMulticastParseError::InvalidIpv6(s.to_string(), e) + })?; + Ok(Self::new(addr)?) + } +} + +impl FromStr for MulticastTag { + type Err = MulticastTagParseError; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err(MulticastTagParseError::Empty); + } + if s.len() > MAX_TAG_LENGTH { + return Err(MulticastTagParseError::TooLong); + } + if !s.bytes().all(|b| { + b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') + }) { + return Err(MulticastTagParseError::InvalidCharacter); + } + Ok(MulticastTag(s.to_string())) } } @@ -63,76 +476,1248 @@ impl AsRef for MulticastTag { } } -impl From for String { - fn from(tag: MulticastTag) -> Self { - tag.0 +impl fmt::Display for MulticastTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) } } -impl From for MulticastTag { - fn from(tag: String) -> Self { - MulticastTag(tag) +impl TryFrom<&str> for MulticastTag { + type Error = MulticastTagParseError; + + fn try_from(tag: &str) -> Result { + tag.parse() } } -impl fmt::Display for MulticastTagParseError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) +impl std::str::FromStr for ExternalMulticastIp { + type Err = ExternalMulticastIpError; + + fn from_str(s: &str) -> Result { + let addr: IpAddr = s.parse().map_err(|e| { + ExternalMulticastIpError::InvalidIpAddress(s.to_string(), e) + })?; + Self::new(addr) } } -impl std::error::Error for MulticastTagParseError {} +impl ExternalMulticastIp { + /// Create a new `ExternalMulticastIp` if the address is valid for an + /// external multicast group. + /// + /// # Errors + /// + /// Returns [`ExternalMulticastIpError`] if `addr` cannot identify an + /// external multicast group. + /// + /// # Examples + /// + /// ``` + /// use std::net::IpAddr; + /// use dpd_types_versions::latest::mcast::ExternalMulticastIp; + /// + /// let global: IpAddr = "ff0e::1".parse().unwrap(); + /// assert!(ExternalMulticastIp::new(global).is_ok()); + /// + /// let underlay: IpAddr = "ff04::1".parse().unwrap(); + /// assert!(ExternalMulticastIp::new(underlay).is_err()); + /// ``` + pub fn new(addr: IpAddr) -> Result { + validate_external_multicast_ip(addr)?; + Ok(Self(addr)) + } -impl FromStr for MulticastTag { - type Err = MulticastTagParseError; + /// Return the inner IP address. + #[inline] + pub const fn ip(&self) -> IpAddr { + self.0 + } - fn from_str(s: &str) -> Result { - if s.is_empty() { - return Err(MulticastTagParseError( - "tag cannot be empty".to_string(), - )); - } - if s.len() > MAX_TAG_LENGTH { - return Err(MulticastTagParseError(format!( - "tag cannot exceed {MAX_TAG_LENGTH} bytes" - ))); - } - if !s.bytes().all(|b| { - b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') - }) { - return Err(MulticastTagParseError( - "tag must contain only ASCII alphanumeric characters, hyphens, \ - underscores, colons, or periods" - .to_string(), - )); - } - Ok(MulticastTag(s.to_string())) + /// Check whether this address is a Source-Specific Multicast address. + pub fn is_ssm(&self) -> bool { + is_ssm_address(self.0) } } -impl MulticastGroupResponse { - /// Get the multicast group IP address. - pub fn ip(&self) -> IpAddr { +impl fmt::Display for ExternalMulticastIp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl fmt::Display for InvalidMulticastSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Underlay(resp) => resp.group_ip.into(), - Self::External(resp) => resp.group_ip, + Self::Unspecified => write!(f, "is the unspecified address"), + Self::Loopback => write!(f, "is a loopback address"), + Self::Broadcast => write!(f, "is the broadcast address"), + Self::LinkLocal => write!(f, "is a link-local address"), + Self::ThisNetwork => { + write!(f, "is in 0.0.0.0/8 (this host on this network)") + } + Self::Reserved => { + write!(f, "is in the reserved class E block (240.0.0.0/4)") + } } } +} - /// Get the tag. - pub fn tag(&self) -> &str { +impl fmt::Display for InvalidEmbeddedRp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Underlay(resp) => &resp.tag, - Self::External(resp) => &resp.tag, + Self::RequiresPrefixBased => write!(f, "the P flag is required"), + Self::RequiresTransient => write!(f, "the T flag is required"), + Self::PrefixLengthOutOfRange(plen) => write!( + f, + "the prefix length {plen} is outside the range 1 through 64" + ), + Self::ZeroInterfaceId => { + write!(f, "the RP interface ID must be nonzero") + } + Self::UnusableAddress(rp) => write!( + f, + "the derived RP address {rp} is in ::/16, link-local, or \ + multicast space" + ), } } } -impl fmt::Display for IpSrc { +impl fmt::Display for EmbeddedIpv4 { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - IpSrc::Exact(ip) => write!(f, "{ip}"), - IpSrc::Any => write!(f, "any"), + Self::Mapped => { + write!(f, "IPv4-mapped (::ffff:0:0/96, RFC 4291 §2.5.5.2)") + } + Self::Compatible => { + write!(f, "IPv4-compatible (::/96, RFC 4291 §2.5.5.1)") + } + } + } +} + +impl UnicastAddrV4 { + /// Create a validated IPv4 unicast address. + /// + /// # Errors + /// + /// - [`MulticastSourceError::NotUnicast`] for a multicast address. + /// - [`MulticastSourceError::InvalidIpv4`] for the first + /// [`InvalidMulticastSource`] rule the address hits. + pub fn new(addr: Ipv4Addr) -> Result { + validate_unicast_source_ipv4(addr)?; + Ok(Self(addr)) + } + + /// Return the inner IPv4 address. + #[inline] + pub const fn ip(&self) -> Ipv4Addr { + self.0 + } +} + +impl UnicastAddrV6 { + /// Create a validated IPv6 unicast address. + /// + /// # Errors + /// + /// - [`MulticastSourceError::NotUnicast`] for a multicast address. + /// - [`MulticastSourceError::InvalidIpv6`] for the first + /// [`InvalidMulticastSource`] rule the address hits. + /// - [`MulticastSourceError::Ipv4Embedded`] for an IPv4-mapped or + /// IPv4-compatible address. + pub fn new(addr: Ipv6Addr) -> Result { + validate_unicast_source_ipv6(addr)?; + Ok(Self(addr)) + } + + /// Return the inner IPv6 address. + #[inline] + pub const fn ip(&self) -> Ipv6Addr { + self.0 + } +} + +impl fmt::Display for UnicastAddrV4 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl From for IpAddr { + fn from(addr: UnicastAddrV4) -> Self { + IpAddr::V4(addr.0) + } +} + +impl fmt::Display for UnicastAddrV6 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl From for IpAddr { + fn from(addr: UnicastAddrV6) -> Self { + IpAddr::V6(addr.0) + } +} + +impl From for UnderlayMulticastIpv6 { + fn from(target: ExternalNatTarget) -> Self { + target.internal_ip + } +} + +impl NonEmptyExactSources { + /// Create a sorted, deduplicated list, while returning `None` for empty + /// input(s). + pub(crate) fn new(mut sources: Vec) -> Option { + sources.sort_unstable(); + sources.dedup(); + (!sources.is_empty()).then_some(Self(sources)) + } + + pub(crate) fn into_vec(self) -> Vec { + self.0 + } + + /// Return the sources as a slice representation. + pub fn as_slice(&self) -> &[ExactSource] { + &self.0 + } +} + +impl SsmMulticastIp { + /// Return the SSM form of an [`ExternalMulticastIp`] or `None` for ASM. + pub fn new(addr: ExternalMulticastIp) -> Option { + addr.is_ssm().then_some(Self(addr)) + } + + /// Return the underlying external multicast address. + pub fn ip(&self) -> ExternalMulticastIp { + self.0 + } +} + +impl From for ExternalMulticastIp { + fn from(addr: SsmMulticastIp) -> Self { + addr.0 + } +} + +impl From for IpAddr { + fn from(addr: SsmMulticastIp) -> Self { + addr.0.into() + } +} + +impl fmt::Display for SsmMulticastIp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl ExactSource { + /// Validate an exact multicast source address. + /// + /// # Errors + /// + /// - [`MulticastSourceError::NotUnicast`] for a multicast address. + /// - [`MulticastSourceError::InvalidIpv4`] or + /// [`MulticastSourceError::InvalidIpv6`] for the first + /// [`InvalidMulticastSource`] rule the address hits. + /// - [`MulticastSourceError::Ipv4Embedded`] for an IPv4-mapped or + /// IPv4-compatible IPv6 address. + pub fn new(ip: IpAddr) -> Result { + let ip = match ip { + IpAddr::V4(addr) => UnicastAddrV4::new(addr)?.into(), + IpAddr::V6(addr) => UnicastAddrV6::new(addr)?.into(), + }; + Ok(Self(ip)) + } + + /// Return the source address. + pub fn ip(&self) -> IpAddr { + self.0 + } +} + +impl std::str::FromStr for ExactSource { + type Err = MulticastSourceParseError; + + fn from_str(s: &str) -> Result { + let addr: IpAddr = s.parse().map_err(|e| { + MulticastSourceParseError::InvalidIp(s.to_string(), e) + })?; + Ok(Self::new(addr)?) + } +} + +impl From for IpAddr { + fn from(source: ExactSource) -> Self { + source.0 + } +} + +impl From for IpSrc { + fn from(source: ExactSource) -> Self { + IpSrc::Exact(source.0) + } +} + +impl fmt::Display for ExactSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[cfg(any(test, feature = "testing"))] +mod arbitrary_impls { + use std::net::{Ipv4Addr, Ipv6Addr}; + + use proptest::prelude::*; + + use super::IPV6_ADMIN_SCOPED_MULTICAST_PREFIX; + use crate::v8::mcast::UnderlayMulticastIpv6; + use crate::v14::mcast::{UnicastAddrV4, UnicastAddrV6}; + + /// Generate IPv4 unicast addresses that work as a multicast source. + /// + /// This avoids 0/8, 127/8, 169.254/16, and the multicast/reserved ranges + /// (224/3 onward). + impl Arbitrary for UnicastAddrV4 { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + prop_oneof![ + 0x0100_0000u32..=0x7eff_ffff, + 0x8000_0000u32..=0xa9fd_ffff, + 0xa9ff_0000u32..=0xdfff_ffff, + ] + .prop_map(|bits| { + UnicastAddrV4::new(Ipv4Addr::from(bits)) + .expect("generated IPv4 source is valid") + }) + .boxed() + } + } + + /// Generate IPv6 unicast addresses that `UnicastAddrV6::new` accepts. + impl Arbitrary for UnicastAddrV6 { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + prop_oneof![ + (1u128 << 32)..(0xffffu128 << 32), + (1u128 << 48)..(0xfe80u128 << 112), + (0xfec0u128 << 112)..(0xff00u128 << 112), + ] + .prop_map(|bits| { + UnicastAddrV6::new(Ipv6Addr::from(bits)) + .expect("generated IPv6 source is valid") + }) + .boxed() + } + } + + /// Generate underlay multicast addresses within the internal `ff04::/64` + /// block. + impl Arbitrary for UnderlayMulticastIpv6 { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + any::() + .prop_map(|bits| { + let addr = Ipv6Addr::from( + (u128::from(IPV6_ADMIN_SCOPED_MULTICAST_PREFIX) << 112) + | u128::from(bits), + ); + UnderlayMulticastIpv6::new(addr) + .expect("generated underlay address is valid") + }) + .boxed() + } + } +} + +#[cfg(test)] +mod proptests; + +#[cfg(test)] +mod tests { + use super::*; + + use crate::latest::mcast::{ + EmbeddedIpv4, ExternalMulticastIpError, InvalidEmbeddedRp, + InvalidMulticastSource, MAX_TAG_LENGTH, MulticastSourceError, + MulticastTag, UnderlayMulticastError, UnderlayMulticastIpv6, + }; + use crate::v14::mcast::MulticastGroupCreateExternalEntryShadow; + + #[test] + fn tag_schema_bounds() { + let schema = + serde_json::to_value(schemars::schema_for!(MulticastTag)).unwrap(); + assert_eq!(schema["maxLength"].as_u64(), Some(MAX_TAG_LENGTH as u64)); + assert_eq!(schema["minLength"].as_u64(), Some(1)); + } + + #[test] + fn tag_format() { + let parse = |tag: &str| tag.parse::(); + + // Valid tags + assert!(parse("my-tag").is_ok()); + assert!(parse("nexus").is_ok()); + assert!(parse("a1b2c3").is_ok()); + assert!(parse("tag_with_underscore").is_ok()); + assert!(parse("tag.with.periods").is_ok()); + assert!(parse("tag:with:colons").is_ok()); + assert!(parse("mixed-tag_v1.0:test").is_ok()); + + // Auto-generated tag format (uuid:ip) + assert!( + parse("550e8400-e29b-41d4-a716-446655440000:224.1.2.3").is_ok() + ); + + // Tag at exactly MAX_TAG_LENGTH characters is valid + assert!(parse(&"a".repeat(MAX_TAG_LENGTH)).is_ok()); + + // Empty tag rejected + assert!(parse("").is_err()); + + // Tag exceeding MAX_TAG_LENGTH characters rejected + assert!(parse(&"a".repeat(MAX_TAG_LENGTH + 1)).is_err()); + + // Invalid characters rejected + assert!(parse("tag with spaces").is_err()); + assert!(parse("invalid/tag").is_err()); + assert!(parse("tag@with@at").is_err()); + assert!(parse("tag#with#hash").is_err()); + } + + #[test] + fn accepts_ipv4_asm() { + for addr in [ + "224.0.1.0", + "224.0.1.255", + "224.1.2.3", + "239.255.255.250", + "225.0.0.1", + "231.255.255.255", + ] { + let parsed = addr.parse::().unwrap(); + assert_eq!(IpAddr::from(parsed), addr.parse::().unwrap()); + } + } + + #[test] + fn accepts_ipv4_ssm() { + for addr in ["232.0.0.1", "232.1.2.3", "232.255.255.255"] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + } + + #[test] + fn accepts_admin_local_ipv6_outside_underlay_subnet() { + for addr in ["ff04:1::1", "ff04::1:0:0:0:1", "ff14::1"] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + } + + #[test] + fn rejects_non_multicast() { + for addr in ["10.0.0.1", "192.168.1.1", "2001:db8::1", "::1"] { + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::NotMulticast( + addr.parse::().unwrap() + )), + "{addr} should be rejected as non-multicast" + ); + } + } + + #[test] + fn accepts_forwardable_ipv6_scopes() { + for addr in [ + "ff03::1", + "ff05::1", + "ff06::1", + "ff07::1", + "ff08::1", + "ff09::1", + "ff0d::1", + "ff0e::1", + "ff0f::1", + "ff3e::4000:1", + ] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + + for addr in ["ff01::1", "ff02::1", "ff11::1", "ff32::4000:1"] { + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::LocalIpv6Scope( + addr.parse::().unwrap() + )), + "{addr} should be rejected as an unforwardable scope" + ); + } + + assert_eq!( + "ff00::1".parse::(), + Err(ExternalMulticastIpError::ReservedIpv6Scope( + "ff00::1".parse::().unwrap() + )), + "ff00::1 should be rejected as reserved scope 0" + ); + } + + #[test] + fn rejects_underlay_subnet() { + for addr in ["ff04::1", "ff04::ffff:ffff:ffff:ffff"] { + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::ReservedUnderlaySubnet( + addr.parse::().unwrap() + )), + "{addr} should be rejected as underlay" + ); + } + } + + #[test] + fn rejects_reserved_ssm_null() { + assert_eq!( + "232.0.0.0".parse::(), + Err(ExternalMulticastIpError::ReservedSsmNull( + "232.0.0.0".parse::().unwrap() + )), + "232.0.0.0 should be rejected as reserved" + ); + for addr in ["232.0.0.1", "232.0.0.255", "232.0.1.0"] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + } + + #[test] + fn rejects_malformed_embedded_rendezvous_point() { + for (addr, expected) in [ + ("ff5e:140:2001:db8::1", InvalidEmbeddedRp::RequiresPrefixBased), + ("ff6e:140:2001:db8::1", InvalidEmbeddedRp::RequiresTransient), + ( + "ff7e:100:2001:db8::1", + InvalidEmbeddedRp::PrefixLengthOutOfRange(0), + ), + ( + "ff7e:180:2001:db8::1", + InvalidEmbeddedRp::PrefixLengthOutOfRange(128), + ), + ("ff7e:40:2001:db8::1", InvalidEmbeddedRp::ZeroInterfaceId), + ("ff4e::1", InvalidEmbeddedRp::RequiresPrefixBased), + ( + "ff7e:140::1", + InvalidEmbeddedRp::UnusableAddress(Ipv6Addr::LOCALHOST), + ), + ( + "ff7e:140:fe80::1", + InvalidEmbeddedRp::UnusableAddress("fe80::1".parse().unwrap()), + ), + ( + "ff7e:140:ff00::1", + InvalidEmbeddedRp::UnusableAddress("ff00::1".parse().unwrap()), + ), + ( + "ff7e:104:fff::1", + InvalidEmbeddedRp::UnusableAddress(Ipv6Addr::LOCALHOST), + ), + ( + "ff7e:108:ff12::1", + InvalidEmbeddedRp::UnusableAddress("ff00::1".parse().unwrap()), + ), + ] { + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::MalformedEmbeddedRp { + addr: addr.parse::().unwrap(), + reason: expected, + }), + "{addr} should be rejected as {expected:?}" + ); + } + } + + #[test] + fn accepts_embedded_rendezvous_point_as_asm() { + for addr in [ + "ff7e:140:2001:db8::1", + "ff7e:120:2001:db8::1", + "ff7e:f140:2001:db8:beef:feed:0:1234", + "ff7e:140:2001:db8:beef:feed:0:1234", + ] { + let parsed = addr + .parse::() + .unwrap_or_else(|e| panic!("{addr} should be accepted: {e}")); + assert!(!parsed.is_ssm(), "{addr} should not be SSM"); + } + } + + #[test] + fn detects_ssm_addresses() { + for addr in [ + "232.0.0.1", + "232.255.255.255", + "ff3e::4000:1", + "ff35::4000:1", + "ffbe::4000:1", + "ff3e:f000::4000:1", + "ff3e:100::4000:1", + ] { + assert!( + addr.parse::().unwrap().is_ssm(), + "{addr} should be SSM" + ); + } + for addr in [ + "224.1.2.3", + "231.0.0.1", + "233.0.0.0", + "ff0e::1", + "ff05::1", + "ffbe:20::4000:1", + "ff3e:20::4000:1", + ] { + assert!( + !addr.parse::().unwrap().is_ssm(), + "{addr} should not be SSM" + ); + } + } + + #[test] + fn rejects_ipv6_ssm_null_address() { + for addr in ["ff3e::4000:0", "ff3e:f000::4000:0"] { + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::ReservedSsmNull( + addr.parse::().unwrap() + )), + "{addr} should be rejected as the SSM null address" + ); + } + } + + #[test] + fn accepts_valid_ipv6_ssm_group_ids() { + for addr in [ + "ff3e::1", + "ff3e::1234", + "ff3e::3fff:ffff", + "ff3e::4000:1", + "ff3e::7fff:ffff", + "ff3e::8000:0", + "ff3e::ffff:ffff", + "ff35::4000:1", + "ff3e:0:1234::f000:1", + "ff3e:f000:1234::1", + "ff3e:0:1234::4000:0", + ] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + } + + #[test] + fn accepts_rfc3306_unicast_prefix_ipv6_as_asm() { + let parsed: ExternalMulticastIp = "ff3e:20:1234::1".parse().unwrap(); + assert!(!parsed.is_ssm()); + } + + fn internal_forwarding() -> ExternalInternalForwarding { + serde_json::from_value( + serde_json::json!({ "nat_target": nat_target_json() }), + ) + .unwrap() + } + + fn nat_target_json() -> serde_json::Value { + serde_json::json!({ + "internal_ip": "ff04::1", + "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, + "vni": 100, + }) + } + + #[test] + fn nat_target_validates_on_deserialize() { + let forwarding = + |nat_target| serde_json::json!({ "nat_target": nat_target }); + + assert!( + serde_json::from_value::(forwarding( + nat_target_json() + ),) + .is_ok() + ); + assert!( + serde_json::from_value::(forwarding( + serde_json::Value::Null + ),) + .is_err() + ); + + let mut unicast_mac = nat_target_json(); + unicast_mac["inner_mac"] = + serde_json::json!({ "a": [0, 0, 94, 0, 0, 1] }); + assert!( + serde_json::from_value::(forwarding( + unicast_mac + ),) + .is_err() + ); + + let mut outside_underlay = nat_target_json(); + outside_underlay["internal_ip"] = serde_json::json!("ff04:0:0:1::1"); + assert!( + serde_json::from_value::(forwarding( + outside_underlay + ),) + .is_err() + ); + } + + fn entry( + group_ip: &str, + sources: Option>, + ) -> Result< + MulticastGroupCreateExternalEntry, + MulticastGroupCreateExternalError, + > { + let sources = sources + .map(|sources| { + sources.into_iter().map(SourceEntry::try_from).collect() + }) + .transpose()?; + + MulticastGroupCreateExternalEntry::try_from( + MulticastGroupCreateExternalEntryShadow { + group_ip: group_ip.parse().unwrap(), + tag: None, + internal_forwarding: internal_forwarding(), + external_forwarding: ExternalForwarding { vlan_id: None }, + sources, + }, + ) + } + + fn exact(addr: &str) -> IpSrc { + IpSrc::Exact(addr.parse().unwrap()) + } + + #[test] + fn ssm_requires_sources() { + for sources in [None, Some(vec![])] { + for group in ["232.1.2.3", "ff3e::4000:1", "ff35::4000:1"] { + assert_eq!( + entry(group, sources.clone()).err(), + Some( + MulticastGroupCreateExternalError::SsmRequiresSources( + group.parse::().unwrap() + ) + ), + "{group} with sources {sources:?} should be rejected" + ); + } + } + } + + #[test] + fn ssm_rejects_any_source() { + for (group, source) in + [("232.1.2.3", "10.1.1.1"), ("ff35::4000:1", "2001:db8::1")] + { + for sources in [vec![IpSrc::Any], vec![exact(source), IpSrc::Any]] { + assert_eq!( + entry(group, Some(sources.clone())).err(), + Some( + MulticastGroupCreateExternalError::SsmRejectsAnySource( + group.parse::().unwrap() + ) + ), + "{group} with sources {sources:?} should be rejected" + ); + } + } + } + + #[test] + fn ssm_accepts_exact_sources() { + let group = entry("232.1.2.3", Some(vec![exact("10.1.1.1")])).unwrap(); + let MulticastGroupCreateExternalEntry::Ssm(ssm) = group else { + panic!("232.1.2.3 should classify as SSM"); + }; + assert_eq!( + ssm.sources().iter().map(ExactSource::ip).collect::>(), + vec!["10.1.1.1".parse::().unwrap()] + ); + assert_eq!( + IpAddr::from(ssm.group_ip()), + "232.1.2.3".parse::().unwrap() + ); + + for group in ["ff3e::4000:1", "ff35::4000:1"] { + assert!(matches!( + entry(group, Some(vec![exact("2001:db8::1")])), + Ok(MulticastGroupCreateExternalEntry::Ssm(_)) + )); + } + } + + #[test] + fn asm_accepts_any_source_and_no_sources() { + for sources in [ + None, + Some(vec![]), + Some(vec![IpSrc::Any]), + Some(vec![exact("10.1.1.1"), IpSrc::Any]), + ] { + let group = entry("224.1.2.3", sources.clone()).unwrap(); + assert!( + matches!(group, MulticastGroupCreateExternalEntry::Asm(_)), + "224.1.2.3 with {sources:?} should classify as ASM" + ); + assert!(group.sources().is_none()); + } + + assert!(matches!( + entry("ff0e::1", Some(vec![IpSrc::Any])), + Ok(MulticastGroupCreateExternalEntry::Asm(_)) + )); + } + + #[test] + fn rejects_source_family_mismatch() { + for (group_ip, source) in [ + ("232.1.2.3", "2001:db8::1"), + ("ff3e::4000:1", "10.1.1.1"), + ("224.1.2.3", "2001:db8::1"), + ("ff0e::1", "10.1.1.1"), + ] { + assert_eq!( + entry(group_ip, Some(vec![exact(source)])).err(), + Some(MulticastGroupCreateExternalError::SourceFamilyMismatch { + source_ip: source.parse::().unwrap(), + group: group_ip.parse::().unwrap(), + }), + "{source} for {group_ip} should be rejected" + ); + } + } + + #[test] + fn rejects_invalid_exact_sources() { + assert_eq!( + entry("224.1.2.3", Some(vec![exact("224.9.9.9")])).err(), + Some(MulticastGroupCreateExternalError::InvalidSource( + MulticastSourceError::NotUnicast( + "224.9.9.9".parse::().unwrap() + ) + )) + ); + assert_eq!( + entry("ff0e::1", Some(vec![exact("ff0e::2")])).err(), + Some(MulticastGroupCreateExternalError::InvalidSource( + MulticastSourceError::NotUnicast( + "ff0e::2".parse::().unwrap() + ) + )) + ); + + for (source, expected) in [ + ("127.0.0.1", InvalidMulticastSource::Loopback), + ("255.255.255.255", InvalidMulticastSource::Broadcast), + ("0.0.0.0", InvalidMulticastSource::Unspecified), + ("0.1.2.3", InvalidMulticastSource::ThisNetwork), + ("169.254.10.1", InvalidMulticastSource::LinkLocal), + ("240.0.0.1", InvalidMulticastSource::Reserved), + ("255.255.255.254", InvalidMulticastSource::Reserved), + ] { + assert_eq!( + entry("232.1.2.3", Some(vec![exact(source)])).err(), + Some(MulticastGroupCreateExternalError::InvalidSource( + MulticastSourceError::InvalidIpv4 { + addr: source.parse::().unwrap(), + reason: expected, + } + )), + "{source} should be rejected as {expected:?}" + ); + } + + for (source, expected) in [ + ("::", InvalidMulticastSource::Unspecified), + ("::1", InvalidMulticastSource::Loopback), + ("fe80::1", InvalidMulticastSource::LinkLocal), + ("febf:ffff::1", InvalidMulticastSource::LinkLocal), + ] { + assert_eq!( + entry("ff0e::1", Some(vec![exact(source)])).err(), + Some(MulticastGroupCreateExternalError::InvalidSource( + MulticastSourceError::InvalidIpv6 { + addr: source.parse::().unwrap(), + reason: expected, + } + )), + "{source} should be rejected as {expected:?}" + ); + } + + for source in ["100.64.0.1", "223.255.255.255"] { + assert!( + entry("232.1.2.3", Some(vec![exact(source)])).is_ok(), + "{source} should be accepted as a source" + ); + } + + for (source, expected) in [ + ("::ffff:192.0.2.1", EmbeddedIpv4::Mapped), + ("::192.0.2.1", EmbeddedIpv4::Compatible), + ] { + assert_eq!( + entry("ff0e::1", Some(vec![exact(source)])).err(), + Some(MulticastGroupCreateExternalError::InvalidSource( + MulticastSourceError::Ipv4Embedded { + addr: source.parse::().unwrap(), + form: expected, + } + )), + "{source} should be rejected as {expected:?}" + ); + } + } + + #[test] + fn deserialize_enforces_joint_validation() { + let body = |group_ip: &str, sources: serde_json::Value| { + serde_json::json!({ + "group_ip": group_ip, + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": sources, + }) + }; + + assert!( + serde_json::from_value::(body( + "232.1.2.3", + serde_json::Value::Null, + )) + .is_err() + ); + + assert!(matches!( + serde_json::from_value::(body( + "232.1.2.3", + serde_json::json!([{ "Exact": "10.1.1.1" }]), + )) + .unwrap(), + MulticastGroupCreateExternalEntry::Ssm(_) + )); + + assert!(matches!( + serde_json::from_value::(body( + "224.1.2.3", + serde_json::Value::Null, + )) + .unwrap(), + MulticastGroupCreateExternalEntry::Asm(_) + )); + } + + #[test] + fn serde_round_trips_flat_request_form() { + for (body, expected) in [ + ( + serde_json::json!({ + "group_ip": "224.1.2.3", + "tag": "asm-tag", + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": 10 }, + "sources": ["Any"], + }), + serde_json::json!({ + "group_ip": "224.1.2.3", + "tag": "asm-tag", + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + }), + ), + ( + serde_json::json!({ + "group_ip": "ff0e::1", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + }), + serde_json::json!({ + "group_ip": "ff0e::1", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + }), + ), + ( + serde_json::json!({ + "group_ip": "232.1.2.3", + "tag": "ssm-tag", + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": [ + { "Exact": "10.1.1.1" }, + { "Exact": "10.1.1.2" }, + ], + }), + serde_json::json!({ + "group_ip": "232.1.2.3", + "tag": "ssm-tag", + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": [ + { "Exact": "10.1.1.1" }, + { "Exact": "10.1.1.2" }, + ], + }), + ), + ( + serde_json::json!({ + "group_ip": "ff3e::4000:1", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": 4094 }, + "sources": [{ "Exact": "2001:db8::1" }], + }), + serde_json::json!({ + "group_ip": "ff3e::4000:1", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": 4094 }, + "sources": [{ "Exact": "2001:db8::1" }], + }), + ), + ] { + let parsed = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(body) + .unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), expected); + } + } + + #[test] + fn rejects_malformed_rfc3306_prefix_based() { + for addr in ["ff2e:20:2001:db8::1", "ff2e::1"] { + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::MalformedPrefixBased { + addr: addr.parse::().unwrap(), + }), + "{addr} should be rejected as malformed prefix-based" + ); + } + } + + #[test] + fn accepts_prefix_based_ipv6_as_asm() { + for addr in [ + "ff3e:40::1", + "ff3e:80::1", + "ff3e:f020::1", + "ff3e:ff20::1", + "ff3e:140:2001:db8::1", + "ff3e:40:2001:db8::1", + "ff7e:140:2001:db8:beef:feed:0:1234", + "ff7e:f140:2001:db8:beef:feed:0:1234", + "fffe:f40:2001:db8:beef:feed:0:1234", + ] { + assert!( + matches!( + entry(addr, None), + Ok(MulticastGroupCreateExternalEntry::Asm(_)) + ), + "{addr} should be accepted as ASM" + ); + } + } + + #[test] + fn accepts_transient_flagged_ipv6_as_asm() { + assert!(matches!( + entry("ff1e::1", None), + Ok(MulticastGroupCreateExternalEntry::Asm(_)) + )); + } + + #[test] + fn rejects_ipv4_local_network_control_block() { + for host in 1..=u8::MAX { + let addr = IpAddr::V4(Ipv4Addr::new(224, 0, 0, host)); + assert_eq!( + ExternalMulticastIp::new(addr), + Err(ExternalMulticastIpError::LocalIpv4Scope(addr)), + "{addr} should be rejected as link-local multicast" + ); + } + + assert!( + serde_json::from_value::( + serde_json::json!({ + "group_ip": "224.0.0.5", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + }), + ) + .is_err() + ); + } + + #[test] + fn rejects_only_reserved_base_addresses() { + for addr in ["224.0.0.0", "ff03::", "ff05::", "ff0e::", "ff0f::"] { + assert_eq!( + addr.parse::(), + Err(ExternalMulticastIpError::ReservedBaseAddress( + addr.parse::().unwrap() + )), + "{addr} should be rejected as a reserved base address" + ); + } + for addr in ["224.0.1.0", "ff05::1", "ff0e::1", "ff1e::"] { + assert!( + addr.parse::().is_ok(), + "{addr} should be accepted" + ); + } + } + + #[test] + fn source_validation_precedes_family_mismatch() { + assert_eq!( + entry("232.1.2.3", Some(vec![exact("::1")])).err(), + Some(MulticastGroupCreateExternalError::InvalidSource( + MulticastSourceError::InvalidIpv6 { + addr: Ipv6Addr::LOCALHOST, + reason: InvalidMulticastSource::Loopback, + } + )) + ); + } + + #[test] + fn deduplicates_exact_sources() { + let asm_group = entry( + "224.1.2.3", + Some(vec![exact("10.1.1.1"), exact("10.1.1.1")]), + ) + .unwrap(); + let MulticastGroupCreateExternalEntry::Asm(asm) = asm_group else { + panic!("224.1.2.3 should classify as ASM"); + }; + assert_eq!(asm.sources.exact_sources().unwrap().len(), 1); + + let ssm_group = entry( + "232.1.2.3", + Some(vec![exact("10.1.1.1"), exact("10.1.1.1")]), + ) + .unwrap(); + let MulticastGroupCreateExternalEntry::Ssm(ssm) = ssm_group else { + panic!("232.1.2.3 should classify as SSM"); + }; + assert_eq!(ssm.sources().len(), 1); + } + + #[test] + fn mixed_any_and_exact_sources_serialize_to_null() { + let parsed = + serde_json::from_value::( + serde_json::json!({ + "group_ip": "224.1.2.3", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": ["Any", { "Exact": "1.2.3.4" }], + }), + ) + .unwrap(); + + assert_eq!( + serde_json::to_value(parsed).unwrap(), + serde_json::json!({ + "group_ip": "224.1.2.3", + "tag": null, + "internal_forwarding": { "nat_target": nat_target_json() }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + }) + ); + } + + #[test] + fn external_multicast_ip_serde_uses_plain_string() { + let parsed = + serde_json::from_str::("\"239.1.1.1\"") + .unwrap(); + assert_eq!( + IpAddr::from(parsed), + "239.1.1.1".parse::().unwrap() + ); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"239.1.1.1\""); + + assert!( + serde_json::from_str::("\"10.0.0.1\"") + .is_err() + ); + assert!( + serde_json::from_str::("\"ff04::1\"").is_err() + ); + } + + #[test] + fn underlay_multicast_ipv6_validates_subnet() { + let parsed: UnderlayMulticastIpv6 = "ff04::1".parse().unwrap(); + assert_eq!(IpAddr::from(parsed), "ff04::1".parse::().unwrap()); + + for addr in ["ff04:0:0:1::1", "ff05::1", "2001:db8::1"] { + assert!( + matches!( + UnderlayMulticastIpv6::new(addr.parse().unwrap()), + Err(UnderlayMulticastError::InvalidUnderlayMulticastIp(_)) + ), + "{addr} should be rejected as outside ff04::/64" + ); } } } diff --git a/dpd-types/versions/src/impls/mcast/proptests.rs b/dpd-types/versions/src/impls/mcast/proptests.rs new file mode 100644 index 00000000..2f474d53 --- /dev/null +++ b/dpd-types/versions/src/impls/mcast/proptests.rs @@ -0,0 +1,662 @@ +// 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 + +//! Property-based tests for multicast address validation. +//! +//! Properties stated with `iff` hold bidirectionally: the implementation +//! accepts an input if and only if the spec does. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use proptest::prelude::*; +use test_strategy::{Arbitrary, proptest}; + +use crate::impls::test_util::{json_round_trip, outcome, shrink_fully}; +use crate::latest::mcast::{ + EmbeddedIpv4, ExternalMulticastIp, ExternalMulticastIpError, + InvalidEmbeddedRp, InvalidMulticastSource, MAX_TAG_LENGTH, + MulticastSourceError, MulticastTag, UnderlayMulticastIpv6, UnicastAddrV4, + UnicastAddrV6, is_ssm_address, +}; +use crate::testing::{ + ipv4_asm_group_strategy, ipv4_ssm_group_strategy, ipv6_asm_group_strategy, + ipv6_ssm_group_strategy, usable_scope_strategy, +}; + +/// Spec for SSM classification per RFC 4607 §1, RFC 7371 §4.1.2, and +/// RFC 3306 §6, with the `ff2` and `rsvd` nibbles ignored upon receipt +/// (RFC 7371 §4.1.1, RFC 3956 §3). +fn spec_is_ssm(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(v4) => v4.octets()[0] == 232, + IpAddr::V6(v6) => { + let bits = u128::from(v6); + let flags = (bits >> 116) & 0xf; + let plen = (bits >> 96) & 0xff; + bits >> 120 == 0xff && flags & 0x7 == 0x3 && plen == 0 + } + } +} + +/// Spec for an IPv4 group per RFC 1112 §4 and RFC 4607 §4.3. +fn spec_group_v4(addr: Ipv4Addr) -> Result<(), ExternalMulticastIpError> { + let ip = IpAddr::V4(addr); + if addr.octets()[0] & 0xf0 != 0xe0 { + return Err(ExternalMulticastIpError::NotMulticast(ip)); + } + if addr.octets() == [224, 0, 0, 0] { + return Err(ExternalMulticastIpError::ReservedBaseAddress(ip)); + } + if u32::from(addr) & 0xffff_ff00 == 0xe000_0000 { + return Err(ExternalMulticastIpError::LocalIpv4Scope(ip)); + } + if addr.octets() == [232, 0, 0, 0] { + return Err(ExternalMulticastIpError::ReservedSsmNull(ip)); + } + Ok(()) +} + +/// Spec for the RP address embedded in a group per RFC 3956 §3 and §4. +fn spec_embedded_rp(addr: Ipv6Addr, plen: u32, riid: u16) -> Ipv6Addr { + let prefix = (u128::from(addr) >> 32) as u64; + let mask = if plen == 0 { 0 } else { u64::MAX << (64 - plen) }; + Ipv6Addr::from((u128::from(prefix & mask) << 64) | u128::from(riid)) +} + +/// Spec for a usable RP address per RFC 3956 §4 and §10. +fn spec_rp_usable(rp: Ipv6Addr) -> bool { + rp.segments()[0] != 0 && !rp.is_unicast_link_local() && !rp.is_multicast() +} + +/// Spec for an IPv6 group per RFC 4291 §2.7, RFC 3306 §4, RFC 3956 §3, +/// RFC 4607 §4.3, and RFC 7371 §4, plus the Oxide `ff04::/64` internal +/// underlay reservation. +fn spec_group_v6(addr: Ipv6Addr) -> Result<(), ExternalMulticastIpError> { + let ip = IpAddr::V6(addr); + let bits = u128::from(addr); + if bits >> 120 != 0xff { + return Err(ExternalMulticastIpError::NotMulticast(ip)); + } + match (bits >> 112) & 0xf { + 0 => return Err(ExternalMulticastIpError::ReservedIpv6Scope(ip)), + 1 | 2 => return Err(ExternalMulticastIpError::LocalIpv6Scope(ip)), + _ => {} + } + if bits >> 64 == 0xff04_0000_0000_0000 { + return Err(ExternalMulticastIpError::ReservedUnderlaySubnet(ip)); + } + let flags = (bits >> 116) & 0xf; + if flags == 0 && bits & ((1u128 << 112) - 1) == 0 { + return Err(ExternalMulticastIpError::ReservedBaseAddress(ip)); + } + let r_flag = flags & 0x4 != 0; + let p_flag = flags & 0x2 != 0; + let t_flag = flags & 0x1 != 0; + let plen = ((bits >> 96) & 0xff) as u32; + let riid = ((bits >> 104) & 0xf) as u16; + let rp_err = |reason| { + Err(ExternalMulticastIpError::MalformedEmbeddedRp { addr: ip, reason }) + }; + + if r_flag { + if !p_flag { + return rp_err(InvalidEmbeddedRp::RequiresPrefixBased); + } + if !t_flag { + return rp_err(InvalidEmbeddedRp::RequiresTransient); + } + if !(1..=64).contains(&plen) { + return rp_err(InvalidEmbeddedRp::PrefixLengthOutOfRange( + plen as u8, + )); + } + if riid == 0 { + return rp_err(InvalidEmbeddedRp::ZeroInterfaceId); + } + let rp = spec_embedded_rp(addr, plen, riid); + if !spec_rp_usable(rp) { + return rp_err(InvalidEmbeddedRp::UnusableAddress(rp)); + } + } else if p_flag && !t_flag { + return Err(ExternalMulticastIpError::MalformedPrefixBased { + addr: ip, + }); + } + + let network_prefix = (bits >> 32) as u64; + let group_id = bits as u32; + + if spec_is_ssm(ip) && network_prefix == 0 && group_id == 0x4000_0000 { + return Err(ExternalMulticastIpError::ReservedSsmNull(ip)); + } + Ok(()) +} + +/// Spec for the Oxide underlay multicast subnet `ff04::/64`. +fn spec_underlay(addr: Ipv6Addr) -> bool { + u128::from(addr) >> 64 == 0xff04_0000_0000_0000 +} + +/// Spec for an IPv4 (S,G) source per RFC 919 §7, RFC 1122 §3.2.1.3, +/// RFC 3927, and RFC 6890. +fn spec_source_v4(addr: Ipv4Addr) -> Result<(), MulticastSourceError> { + if addr.is_multicast() { + return Err(MulticastSourceError::NotUnicast(IpAddr::V4(addr))); + } + let first_octet = addr.octets()[0]; + let reason = if addr.is_unspecified() { + InvalidMulticastSource::Unspecified + } else if addr.is_loopback() { + InvalidMulticastSource::Loopback + } else if addr.is_broadcast() { + InvalidMulticastSource::Broadcast + } else if addr.is_link_local() { + InvalidMulticastSource::LinkLocal + } else if first_octet == 0 { + InvalidMulticastSource::ThisNetwork + } else if first_octet >= 240 { + InvalidMulticastSource::Reserved + } else { + return Ok(()); + }; + Err(MulticastSourceError::InvalidIpv4 { addr, reason }) +} + +/// Spec for an IPv6 (S,G) source per RFC 4291 §2.5. +fn spec_source_v6(addr: Ipv6Addr) -> Result<(), MulticastSourceError> { + if addr.is_multicast() { + return Err(MulticastSourceError::NotUnicast(IpAddr::V6(addr))); + } + let embedded = + |form| Err(MulticastSourceError::Ipv4Embedded { addr, form }); + let reason = if addr.is_unspecified() { + InvalidMulticastSource::Unspecified + } else if addr.is_loopback() { + InvalidMulticastSource::Loopback + } else if addr.is_unicast_link_local() { + InvalidMulticastSource::LinkLocal + } else if addr.to_ipv4_mapped().is_some() { + return embedded(EmbeddedIpv4::Mapped); + } else if u128::from(addr) >> 32 == 0 { + return embedded(EmbeddedIpv4::Compatible); + } else { + return Ok(()); + }; + Err(MulticastSourceError::InvalidIpv6 { addr, reason }) +} + +/// Generate candidate IPv4 bits biased toward 224/4, 224.0.0.0, 232.0.0.0, +/// and the class E block above 224/4. +fn group_v4_candidate_strategy() -> impl Strategy { + prop_oneof![ + any::(), + // 224.0.0.0/4, multicast + 0xe000_0000u32..=0xefff_ffff, + // 224.0.0.0, reserved base address + Just(0xe000_0000u32), + // 232.0.0.0, SSM null address + Just(0xe800_0000u32), + // 224.0.0.0/24, local network control block + 0xe000_0000u32..=0xe000_00ff, + // 232.0.0.0/24, SSM block reserved for IANA allocation + 0xe800_0000u32..=0xe800_00ff, + // 240.0.0.0/4, class E + 0xf000_0000u32..=u32::MAX, + ] +} + +/// Generate an IPv6 group from independently chosen flags, scope, `rsvd`, +/// RIID, prefix length, network prefix, and group ID fields. +#[derive(Debug, Arbitrary)] +struct GroupV6Case { + #[strategy(0u16..=0xf)] + flags: u16, + #[strategy(0u16..=0xf)] + scope: u16, + #[strategy(0u16..=0xf)] + rsvd: u16, + #[strategy(0u16..=0xf)] + riid: u16, + #[strategy(prop_oneof![ + Just(0u16), + Just(16u16), + Just(32u16), + Just(48u16), + Just(64u16), + Just(65u16), + 1u16..=64, + 65u16..=255, + ])] + plen: u16, + #[strategy(prop_oneof![Just([0u16; 4]), any::<[u16; 4]>()])] + prefix: [u16; 4], + #[strategy(prop_oneof![Just(0x4000_0000u32), Just(0u32), any::()])] + group_id: u32, +} + +impl GroupV6Case { + fn addr(&self) -> Ipv6Addr { + Ipv6Addr::new( + 0xff00 | (self.flags << 4) | self.scope, + (self.rsvd << 12) | (self.riid << 8) | self.plen, + self.prefix[0], + self.prefix[1], + self.prefix[2], + self.prefix[3], + (self.group_id >> 16) as u16, + self.group_id as u16, + ) + } +} + +/// Generate an embedded-RP group (i.e., R, P, and T flags set) whose derived RP +/// address is biased toward the `::/16`, link-local, and multicast boundaries +/// of `spec_rp_usable`, with the RP interface ID biased toward zero. +#[derive(Debug, Arbitrary)] +struct EmbeddedRpCase { + #[strategy(proptest::sample::select(&[0x7u16, 0xf][..]))] + flags: u16, + #[strategy(usable_scope_strategy())] + scope: u8, + #[strategy(0u16..=0xf)] + rsvd: u16, + #[strategy(prop_oneof![Just(0u16), 1u16..=0xf])] + riid: u16, + #[strategy(prop_oneof![ + Just(0u16), + Just(16u16), + Just(32u16), + Just(48u16), + Just(64u16), + Just(65u16), + 1u16..=64, + ])] + plen: u16, + #[strategy(prop_oneof![ + Just(0u16), + 0xfe00u16..=0xfe7f, + 0xfe80u16..=0xfebf, + 0xfec0u16..=0xfeff, + 0xff00u16..=0xffff, + any::(), + ])] + prefix_first: u16, + prefix_rest: [u16; 3], + group_id: u32, +} + +impl EmbeddedRpCase { + fn addr(&self) -> Ipv6Addr { + Ipv6Addr::new( + 0xff00 | (self.flags << 4) | u16::from(self.scope), + (self.rsvd << 12) | (self.riid << 8) | self.plen, + self.prefix_first, + self.prefix_rest[0], + self.prefix_rest[1], + self.prefix_rest[2], + (self.group_id >> 16) as u16, + self.group_id as u16, + ) + } +} + +/// Generate candidate IPv6 addresses biased toward the group rules: +/// independent fields, embedded RP, `ff04::/64` (including `ff04::`, which +/// is also a base address), the `ff0x::` base addresses, and the SSM null +/// addresses `ff3x::4000:0` and `ffbx::4000:0`, with and without nonzero +/// `rsvd` and RIID nibbles. +fn group_v6_candidate_strategy() -> impl Strategy { + prop_oneof![ + 6 => any::().prop_map(|case| case.addr()), + 2 => any::().prop_map(|case| case.addr()), + 1 => any::().prop_map(|low| { + Ipv6Addr::from((0xff04u128 << 112) | u128::from(low)) + }), + 1 => Just(Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 0)), + 1 => (0u16..=0xf).prop_map(|scope| { + Ipv6Addr::new(0xff00 | scope, 0, 0, 0, 0, 0, 0, 0) + }), + 1 => (proptest::sample::select(&[0x3u16, 0xb][..]), 0u16..=0xf) + .prop_map(|(flags, scope)| { + Ipv6Addr::new( + 0xff00 | (flags << 4) | scope, + 0, + 0, + 0, + 0, + 0, + 0x4000, + 0, + ) + }), + 1 => ( + proptest::sample::select(&[0x3u16, 0xb][..]), + 0u16..=0xf, + 0u16..=0xf, + 0u16..=0xf, + ) + .prop_map(|(flags, scope, rsvd, riid)| { + Ipv6Addr::new( + 0xff00 | (flags << 4) | scope, + (rsvd << 12) | (riid << 8), + 0, + 0, + 0, + 0, + 0x4000, + 0, + ) + }), + 1 => any::().prop_map(Ipv6Addr::from), + ] +} + +/// Generate candidate IPv4 bits biased toward the source rule blocks. +fn source_v4_candidate_strategy() -> impl Strategy { + prop_oneof![ + any::(), + // 0.0.0.0/8 -> this host on this network + 0u32..=0x00ff_ffff, + // 0.0.0.0, unspecified + Just(0u32), + // 127.0.0.0/8, loopback + 0x7f00_0000u32..=0x7fff_ffff, + // 169.254.0.0/16, link-local + 0xa9fe_0000u32..=0xa9fe_ffff, + // 240.0.0.0/4, class E + 0xf000_0000u32..=u32::MAX, + // 255.255.255.255, limited broadcast + Just(u32::MAX), + // 224.0.0.0/4, multicast + 0xe000_0000u32..=0xefff_ffff, + // 10.0.0.0/8, private (RFC 1918) and accepted + 0x0a00_0000u32..=0x0aff_ffff, + ] +} + +/// Generate candidate IPv6 addresses biased toward the source rule blocks. +fn source_v6_candidate_strategy() -> impl Strategy { + prop_oneof![ + any::(), + // `::`, unspecified + Just(0u128), + // `::1`, loopback + Just(1u128), + // `fe80::/10`, link-local + any::().prop_map(|bits| (0xfe80u128 << 112) | (bits >> 10)), + // `::ffff:0:0/96`, IPv4-mapped + any::().prop_map(|ipv4| (0xffffu128 << 32) | u128::from(ipv4)), + // `::/96`, IPv4-compatible + any::().prop_map(u128::from), + // `ff00::/8`, multicast + any::().prop_map(|bits| (0xffu128 << 120) | (bits >> 8)), + // `fc00::/7`, unique local (RFC 4193) and accepted + any::().prop_map(|bits| (0xfcu128 << 120) | (bits >> 7)), + // `64:ff9b::/96`, NAT64 well-known prefix (RFC 6052) and accepted + any::().prop_map(|ipv4| (0x64_ff9bu128 << 96) | u128::from(ipv4)), + ] + .prop_map(Ipv6Addr::from) +} + +/// Generate addresses biased toward the SSM ranges and their flag and +/// prefix-length neighbors. +fn ssm_candidate_strategy() -> impl Strategy { + let flags = prop_oneof![Just(0x3u16), Just(0xbu16), 0u16..=0xf]; + let plen = prop_oneof![Just(0u16), 0u16..=0xff]; + prop_oneof![ + any::().prop_map(|bits| IpAddr::V4(Ipv4Addr::from(bits))), + (0u32..=0x00ff_ffff) + .prop_map(|low| IpAddr::V4(Ipv4Addr::from(0xe800_0000 | low))), + (flags, 0u16..=0xf, any::(), plen, any::<[u16; 6]>()).prop_map( + |(flags, scope, seg1_high, plen, segs)| { + IpAddr::V6(Ipv6Addr::new( + 0xff00 | (flags << 4) | scope, + (u16::from(seg1_high) << 8) | plen, + segs[0], + segs[1], + segs[2], + segs[3], + segs[4], + segs[5], + )) + } + ), + any::().prop_map(|bits| IpAddr::V6(Ipv6Addr::from(bits))), + ] +} + +/// Generate candidate IPv6 addresses inside, near, and outside `ff04::/64`. +fn underlay_candidate_strategy() -> impl Strategy { + prop_oneof![ + any::().prop_map(|low| (0xff04u128 << 112) | u128::from(low)), + (1u64..(1 << 48), any::()).prop_map(|(mid, low)| { + (0xff04u128 << 112) | (u128::from(mid) << 64) | u128::from(low) + }), + (any::(), any::()).prop_map(|(first, low)| { + (u128::from(first) << 112) | u128::from(low) + }), + any::(), + ] + .prop_map(Ipv6Addr::from) +} + +/// Property: `UnderlayMulticastIpv6::new` accepts an input iff +/// `spec_underlay` accepts it, and deserialization round-trips to the +/// constructed value +#[proptest] +fn prop_underlay_matches_spec( + #[strategy(underlay_candidate_strategy())] addr: Ipv6Addr, +) { + let got = UnderlayMulticastIpv6::new(addr); + prop_assert_eq!( + got.is_ok(), + spec_underlay(addr), + "{}: construction disagrees with spec", + addr + ); + prop_assert_eq!( + json_round_trip(&addr, UnderlayMulticastIpv6::ip)?, + got.as_ref().ok().map(UnderlayMulticastIpv6::ip), + "{}: deserialization disagrees with construction", + addr + ); +} + +/// Property: `ExternalMulticastIp::new` and `spec_group_v4` provide the same +/// outcome, including the error variant, and deserialization round-trips to +/// the constructed value +#[proptest] +fn prop_group_v4_matches_spec( + #[strategy(group_v4_candidate_strategy())] + #[map(Ipv4Addr::from)] + addr: Ipv4Addr, +) { + let got = ExternalMulticastIp::new(IpAddr::V4(addr)); + let want = spec_group_v4(addr); + prop_assert_eq!(outcome(&got), outcome(&want), "{}", addr); + prop_assert_eq!( + json_round_trip(&IpAddr::V4(addr), ExternalMulticastIp::ip)?, + got.as_ref().ok().map(ExternalMulticastIp::ip), + "{}: deserialization disagrees with construction", + addr + ); +} + +/// Property: `ExternalMulticastIp::new` and `spec_group_v6` provide the same +/// outcome, including the error variant, and deserialization round-trips to +/// the constructed value +#[proptest] +fn prop_group_v6_matches_spec( + #[strategy(group_v6_candidate_strategy())] addr: Ipv6Addr, +) { + let got = ExternalMulticastIp::new(IpAddr::V6(addr)); + let want = spec_group_v6(addr); + prop_assert_eq!(outcome(&got), outcome(&want), "{}", addr); + prop_assert_eq!( + json_round_trip(&IpAddr::V6(addr), ExternalMulticastIp::ip)?, + got.as_ref().ok().map(ExternalMulticastIp::ip), + "{}: deserialization disagrees with construction", + addr + ); +} + +/// Property: `UnicastAddrV4::new` and `spec_source_v4` provide the same +/// outcome, including the error variant and its variant-order precedence, +/// and deserialization round-trips to the constructed value +#[proptest] +fn prop_source_v4_matches_spec( + #[strategy(source_v4_candidate_strategy())] + #[map(Ipv4Addr::from)] + addr: Ipv4Addr, +) { + let got = UnicastAddrV4::new(addr); + let want = spec_source_v4(addr); + prop_assert_eq!(outcome(&got), outcome(&want), "{}", addr); + prop_assert_eq!( + json_round_trip(&addr, UnicastAddrV4::ip)?, + got.as_ref().ok().map(UnicastAddrV4::ip), + "{}: deserialization disagrees with construction", + addr + ); +} + +/// Property: `UnicastAddrV6::new` and `spec_source_v6` provide the same +/// outcome, including the error variant and its variant-order precedence, +/// and deserialization round-trips to the constructed value +#[proptest] +fn prop_source_v6_matches_spec( + #[strategy(source_v6_candidate_strategy())] addr: Ipv6Addr, +) { + let got = UnicastAddrV6::new(addr); + let want = spec_source_v6(addr); + prop_assert_eq!(outcome(&got), outcome(&want), "{}", addr); + prop_assert_eq!( + json_round_trip(&addr, UnicastAddrV6::ip)?, + got.as_ref().ok().map(UnicastAddrV6::ip), + "{}: deserialization disagrees with construction", + addr + ); +} + +/// Property: `is_ssm_address` matches `spec_is_ssm` +#[proptest] +fn prop_is_ssm_matches_spec( + #[strategy(ssm_candidate_strategy())] addr: IpAddr, +) { + prop_assert_eq!(is_ssm_address(addr), spec_is_ssm(addr), "{}", addr); +} + +/// Property: every `Arbitrary` `UnderlayMulticastIpv6` satisfies +/// `spec_underlay` +#[proptest] +fn prop_arbitrary_underlay_matches_spec(addr: UnderlayMulticastIpv6) { + let ip = addr.ip(); + prop_assert!(spec_underlay(ip), "{}", ip); +} + +/// Property: every `Arbitrary` `UnicastAddrV4` satisfies `spec_source_v4` +#[proptest] +fn prop_arbitrary_source_v4_matches_spec(addr: UnicastAddrV4) { + let ip = addr.ip(); + prop_assert_eq!(spec_source_v4(ip), Ok(()), "{}", ip); +} + +/// Property: every `Arbitrary` `UnicastAddrV6` satisfies `spec_source_v6` +#[proptest] +fn prop_arbitrary_source_v6_matches_spec(addr: UnicastAddrV6) { + let ip = addr.ip(); + prop_assert_eq!(spec_source_v6(ip), Ok(()), "{}", ip); +} + +/// The exported multicast generators continue producing valid, correctly +/// designated values at every shrink step. +#[test] +fn generators_stay_valid_while_shrinking() { + shrink_fully(any::()); + shrink_fully(any::()); + shrink_fully(any::()); + shrink_fully(ipv4_asm_group_strategy()); + shrink_fully(ipv4_ssm_group_strategy()); + shrink_fully(ipv6_asm_group_strategy()); + shrink_fully(ipv6_ssm_group_strategy()); +} + +/// Property: every `ipv4_asm_group_strategy` group satisfies `spec_group_v4` +/// and is not SSM +#[proptest] +fn prop_asm_group_v4_matches_spec( + #[strategy(ipv4_asm_group_strategy())] addr: Ipv4Addr, +) { + prop_assert_eq!(spec_group_v4(addr), Ok(()), "{}", addr); + prop_assert!(!spec_is_ssm(IpAddr::V4(addr)), "{}", addr); +} + +/// Property: every `ipv4_ssm_group_strategy` group satisfies `spec_group_v4` +/// and is SSM +#[proptest] +fn prop_ssm_group_v4_matches_spec( + #[strategy(ipv4_ssm_group_strategy())] addr: Ipv4Addr, +) { + prop_assert_eq!(spec_group_v4(addr), Ok(()), "{}", addr); + prop_assert!(spec_is_ssm(IpAddr::V4(addr)), "{}", addr); +} + +/// Property: every `ipv6_asm_group_strategy` group satisfies `spec_group_v6` +/// and is not SSM +#[proptest] +fn prop_asm_group_v6_matches_spec( + #[strategy(ipv6_asm_group_strategy())] addr: Ipv6Addr, +) { + prop_assert_eq!(spec_group_v6(addr), Ok(()), "{}", addr); + prop_assert!(!spec_is_ssm(IpAddr::V6(addr)), "{}", addr); +} + +/// Property: every `ipv6_ssm_group_strategy` group satisfies `spec_group_v6` +/// and is SSM +#[proptest] +fn prop_ssm_group_v6_matches_spec( + #[strategy(ipv6_ssm_group_strategy())] addr: Ipv6Addr, +) { + prop_assert_eq!(spec_group_v6(addr), Ok(()), "{}", addr); + prop_assert!(spec_is_ssm(IpAddr::V6(addr)), "{}", addr); +} + +/// Spec for a multicast tag: 1 to 80 bytes, each an ASCII alphanumeric +/// character or one of `-`, `_`, `:`, and `.`. +fn spec_tag(tag: &str) -> bool { + (1..=80).contains(&tag.len()) + && tag + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_:.".contains(&b)) +} + +/// Generate candidate tags biased toward the length bounds and the allowed +/// character set. +fn tag_candidate_strategy() -> impl Strategy { + prop_oneof![ + any::(), + "[a-zA-Z0-9_.:-]{1,80}", + "[a-zA-Z0-9_.:-]{81,100}", + "[a-zA-Z0-9_.:-]{0,40}[^a-zA-Z0-9_.:-][a-zA-Z0-9_.:-]{0,39}", + Just(String::new()), + Just("a".repeat(MAX_TAG_LENGTH)), + Just("a".repeat(MAX_TAG_LENGTH + 1)), + ] +} + +/// Property: parsing a `MulticastTag` succeeds iff the spec accepts the tag, +/// and deserialization round-trips to the parsed value +#[proptest] +fn prop_tag_matches_spec(#[strategy(tag_candidate_strategy())] tag: String) { + let got = tag.parse::(); + prop_assert_eq!(got.is_ok(), spec_tag(&tag), "{:?}", tag); + prop_assert_eq!( + json_round_trip(&tag, |t: &MulticastTag| t.as_ref().to_string())?, + got.ok().map(String::from), + "{:?}: deserialization disagrees with parsing", + tag + ); +} diff --git a/dpd-types/versions/src/impls/mod.rs b/dpd-types/versions/src/impls/mod.rs index 285e642f..adee6d90 100644 --- a/dpd-types/versions/src/impls/mod.rs +++ b/dpd-types/versions/src/impls/mod.rs @@ -18,4 +18,6 @@ mod route; mod serdes; mod switch_identifiers; pub(crate) mod table; +#[cfg(test)] +pub(crate) mod test_util; mod transceivers; diff --git a/dpd-types/versions/src/impls/nat.rs b/dpd-types/versions/src/impls/nat.rs index bce1ac71..9a61fcc8 100644 --- a/dpd-types/versions/src/impls/nat.rs +++ b/dpd-types/versions/src/impls/nat.rs @@ -4,17 +4,9 @@ // // Copyright 2026 Oxide Computer Company -use crate::latest::nat::{Ipv4Nat, Ipv6Nat}; +use crate::latest::nat::IpNat; -impl PartialEq for Ipv6Nat { - fn eq(&self, other: &Self) -> bool { - self.external == other.external - && self.low == other.low - && self.high == other.high - } -} - -impl PartialEq for Ipv4Nat { +impl PartialEq for IpNat { fn eq(&self, other: &Self) -> bool { self.external == other.external && self.low == other.low diff --git a/dpd-types/versions/src/impls/network.rs b/dpd-types/versions/src/impls/network.rs index 8bf21df5..8f0e00c5 100644 --- a/dpd-types/versions/src/impls/network.rs +++ b/dpd-types/versions/src/impls/network.rs @@ -5,8 +5,64 @@ // Copyright 2026 Oxide Computer Company use std::fmt; +use std::str::FromStr; -use crate::latest::network::{InstanceTarget, NatTarget}; +use crate::latest::network::{ + InstanceTarget, NatTarget, Vni, VniError, VniParseError, +}; +use crate::v1; + +impl Vni { + /// Create a validated VNI. + /// + /// # Errors + /// + /// Returns [`VniError::OutOfRange`] if `value` exceeds [`Vni::MAX_VNI`]. + /// + /// # Examples + /// + /// ``` + /// use dpd_types_versions::latest::network::Vni; + /// + /// assert!(Vni::new(77).is_ok()); + /// assert!(Vni::new(Vni::MAX_VNI + 1).is_err()); + /// ``` + pub const fn new(value: u32) -> Result { + if value > Self::MAX_VNI { + return Err(VniError::OutOfRange { value }); + } + Ok(Self(value)) + } + + /// Return the inner 24-bit value. + #[inline] + pub const fn as_u32(self) -> u32 { + self.0 + } +} + +impl FromStr for Vni { + type Err = VniParseError; + + fn from_str(s: &str) -> Result { + let vni = s + .parse::() + .map_err(|e| VniParseError::InvalidInt(s.to_string(), e))?; + Ok(Self::new(vni)?) + } +} + +impl fmt::Display for Vni { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl From for u32 { + fn from(vni: v1::network::Vni) -> Self { + vni.0 + } +} impl fmt::Display for InstanceTarget { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { @@ -39,3 +95,45 @@ impl From for NatTarget { } } } + +#[cfg(any(test, feature = "testing"))] +mod arbitrary_impls { + use proptest::prelude::*; + + use crate::v14::network::Vni; + + /// Generate valid `Vni` values in `[0, Vni::MAX_VNI]`. + impl Arbitrary for Vni { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (0u32..=Vni::MAX_VNI) + .prop_map(|value| Vni::new(value).expect("VNI is in range")) + .boxed() + } + } +} + +#[cfg(test)] +mod proptests; + +#[cfg(test)] +mod tests { + use crate::latest::network::Vni; + use crate::v1; + + #[test] + fn vni_schema_bounds() { + let latest = serde_json::to_value(schemars::schema_for!(Vni)).unwrap(); + assert_eq!(latest["type"], "integer"); + assert_eq!(latest["format"], "uint32"); + assert_eq!(latest["minimum"].as_f64(), Some(0.0)); + assert_eq!(latest["maximum"].as_f64(), Some(f64::from(Vni::MAX_VNI))); + + let v1 = serde_json::to_value(schemars::schema_for!(v1::network::Vni)) + .unwrap(); + assert_eq!(v1["type"], "integer"); + assert!(v1.get("maximum").is_none(), "{v1}"); + } +} diff --git a/dpd-types/versions/src/impls/network/proptests.rs b/dpd-types/versions/src/impls/network/proptests.rs new file mode 100644 index 00000000..76b1dc98 --- /dev/null +++ b/dpd-types/versions/src/impls/network/proptests.rs @@ -0,0 +1,155 @@ +// 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 + +//! Property-based tests for VNI validation. +//! +//! Properties stated with `iff` hold bidirectionally: the implementation +//! accepts an input if and only if the spec does. + +use proptest::prelude::*; +use test_strategy::proptest; + +use crate::impls::test_util::{json_round_trip, shrink_fully}; +use crate::latest::network::{Vni, VniError, VniParseError}; +use crate::v1; + +const VNI_BOUND: u32 = 1 << 24; + +fn spec_vni(value: u32) -> Result { + if value < VNI_BOUND { + Ok(value) + } else { + Err(VniError::OutOfRange { value }) + } +} + +fn vni_candidate_strategy() -> impl Strategy { + prop_oneof![ + Just(0u32), + Just(Vni::MAX_VNI), + Just(Vni::MAX_VNI + 1), + Just(u32::MAX), + 0u32..=Vni::MAX_VNI, + (Vni::MAX_VNI + 1)..=u32::MAX, + ] +} + +fn valid_vni_strategy() -> impl Strategy { + prop_oneof![Just(0u32), Just(Vni::MAX_VNI), 0u32..=Vni::MAX_VNI] +} + +fn vni_string_strategy() -> impl Strategy { + prop_oneof![ + any::(), + "[+-]?[0-9]{1,12}", + Just(String::new()), + vni_candidate_strategy().prop_map(|value| value.to_string()), + prop_oneof![ + Just(u64::from(u32::MAX) + 1), + Just(u64::MAX), + any::(), + ] + .prop_map(|value| value.to_string()), + ] +} + +fn check_new( + value: u32, + new: impl Fn(u32) -> Result, +) -> Result<(), TestCaseError> { + prop_assert_eq!(new(value), spec_vni(value), "{}", value); + Ok(()) +} + +fn check_parse( + input: &str, + parse: impl Fn(&str) -> Result, +) -> Result<(), TestCaseError> { + match (parse(input), input.parse::()) { + (Ok(got), Ok(value)) => { + prop_assert!(value < VNI_BOUND, "{:?} accepted", input); + prop_assert_eq!(got, value, "{:?}", input); + } + (Err(VniParseError::InvalidInt(echo, _)), Err(_)) => { + prop_assert_eq!(echo, input); + } + ( + Err(VniParseError::Value(VniError::OutOfRange { value: got })), + Ok(value), + ) => { + prop_assert!(value >= VNI_BOUND, "{:?} rejected", input); + prop_assert_eq!(got, value, "{:?}", input); + } + (got, want) => { + return Err(TestCaseError::fail(format!( + "{input:?}: got {got:?}, u32 parse {want:?}" + ))); + } + } + Ok(()) +} + +/// Property: `Vni::new` accepts a value iff it fits in 24 bits +#[proptest] +fn prop_vni_new_matches_spec(#[strategy(vni_candidate_strategy())] value: u32) { + check_new(value, |v| Vni::new(v).map(Vni::as_u32))?; + check_new(value, |v| v1::network::Vni::new(v).map(|vni| vni.as_u32()))?; +} + +/// Property: `Vni` deserialization round-trips to the constructed value +#[proptest] +fn prop_vni_serde_matches_new( + #[strategy(vni_candidate_strategy())] value: u32, +) { + prop_assert_eq!( + json_round_trip(&value, |vni: &Vni| vni.as_u32())?, + Vni::new(value).ok().map(Vni::as_u32), + "{}", + value + ); + prop_assert_eq!( + json_round_trip(&value, |vni: &v1::network::Vni| vni.as_u32())?, + v1::network::Vni::new(value).ok().map(|vni| vni.as_u32()), + "{}", + value + ); +} + +/// Property: parsing a `Vni` succeeds iff the input parses as a `u32` that +/// fits in 24 bits +#[proptest] +fn prop_vni_from_str_matches_spec( + #[strategy(vni_string_strategy())] input: String, +) { + check_parse(&input, |s| s.parse::().map(Vni::as_u32))?; + check_parse(&input, |s| { + s.parse::().map(|vni| vni.as_u32()) + })?; +} + +/// Property: converting between the v1 and latest `Vni` preserves the value +#[proptest] +fn prop_vni_conversions_preserve_value( + #[strategy(valid_vni_strategy())] value: u32, +) { + let v1_vni = v1::network::Vni::new(value).unwrap(); + let latest = Vni::from(v1_vni); + prop_assert_eq!(latest.as_u32(), value); + prop_assert_eq!(v1::network::Vni::from(latest).as_u32(), value); +} + +/// Property: every `Arbitrary` `Vni` satisfies `spec_vni` +#[proptest] +fn prop_arbitrary_vni_matches_spec(vni: Vni) { + prop_assert_eq!(spec_vni(vni.as_u32()), Ok(vni.as_u32())); +} + +/// The `Arbitrary` `Vni` generator continues producing valid values at each +/// shrink step. +#[test] +fn vni_generator_stays_valid_while_shrinking() { + shrink_fully(any::()); +} diff --git a/dpd-types/versions/src/impls/route.rs b/dpd-types/versions/src/impls/route.rs index b2d6d65e..4aa8f55d 100644 --- a/dpd-types/versions/src/impls/route.rs +++ b/dpd-types/versions/src/impls/route.rs @@ -100,31 +100,3 @@ impl From for RouteTarget { RouteTarget::V6(route) } } - -impl TryFrom for Ipv4Route { - type Error = dropshot::HttpError; - - fn try_from(target: RouteTarget) -> Result { - match target { - RouteTarget::V4(route) => Ok(route), - _ => Err(dropshot::HttpError::for_bad_request( - None, - "expected an IPv4 route target".to_string(), - )), - } - } -} - -impl TryFrom for Ipv6Route { - type Error = dropshot::HttpError; - - fn try_from(target: RouteTarget) -> Result { - match target { - RouteTarget::V6(route) => Ok(route), - _ => Err(dropshot::HttpError::for_bad_request( - None, - "expected an IPv6 route target".to_string(), - )), - } - } -} diff --git a/dpd-types/versions/src/impls/test_util.rs b/dpd-types/versions/src/impls/test_util.rs new file mode 100644 index 00000000..3938bf18 --- /dev/null +++ b/dpd-types/versions/src/impls/test_util.rs @@ -0,0 +1,66 @@ +// 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 + +//! Result comparison and serde round-trip checks for the property tests in +//! [`super::mcast`] and [`super::network`]. + +use std::fmt::Debug; + +use proptest::prelude::*; +use proptest::strategy::ValueTree; +use proptest::test_runner::TestRunner; +use serde::Serialize; +use serde::de::DeserializeOwned; + +/// Shrink 256 generated value trees until `simplify()` returns false, +/// evaluating each value along the way. +/// +/// This uses a deterministic runner, with shrinking being used here to +/// exercise lower range bounds and earlier union arms. +/// +/// Calling `current()` reruns each mapping and its checks. +pub(crate) fn shrink_fully(strategy: S) { + let mut runner = TestRunner::deterministic(); + for _ in 0..256 { + let mut tree = strategy.new_tree(&mut runner).expect("new value tree"); + loop { + let _ = tree.current(); + if !tree.simplify() { + break; + } + } + } +} + +/// Discard the success value of `res`, keeping the error for comparison. +pub(crate) fn outcome(res: &Result) -> Result<(), &E> { + res.as_ref().map(drop) +} + +/// Deserialize `T` from the JSON encoding of `candidate`, checking that an +/// accepted value re-serializes to the same JSON and returning its projection +/// under `project`. +/// +/// # Errors +/// +/// Returns a [`TestCaseError`] if an accepted value does not re-serialize to +/// the original JSON. +pub(crate) fn json_round_trip( + candidate: &impl Serialize, + project: impl Fn(&T) -> V, +) -> Result, TestCaseError> +where + T: Serialize + DeserializeOwned + Debug, +{ + let json = serde_json::to_value(candidate).expect("candidate serializes"); + let Ok(decoded) = serde_json::from_value::(json.clone()) else { + return Ok(None); + }; + let reencoded = + serde_json::to_value(&decoded).expect("decoded value serializes"); + prop_assert_eq!(&reencoded, &json, "{:?} does not round-trip", decoded); + Ok(Some(project(&decoded))) +} diff --git a/dpd-types/versions/src/initial/mcast.rs b/dpd-types/versions/src/initial/mcast.rs index 5e2bbb91..1edc7aae 100644 --- a/dpd-types/versions/src/initial/mcast.rs +++ b/dpd-types/versions/src/initial/mcast.rs @@ -36,7 +36,16 @@ pub struct ExternalForwarding { /// Represents a member of a multicast group. #[derive( - Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema, + Clone, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Deserialize, + Serialize, + JsonSchema, )] pub struct MulticastGroupMember { pub port_id: PortId, @@ -49,7 +58,17 @@ pub struct MulticastGroupMember { /// `External` group members must have any packet encapsulation removed /// before packet delivery. #[derive( - Copy, Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema, + Copy, + Clone, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Deserialize, + Serialize, + JsonSchema, )] pub enum Direction { Underlay, diff --git a/dpd-types/versions/src/initial/network.rs b/dpd-types/versions/src/initial/network.rs index e758c293..1f68a8dc 100644 --- a/dpd-types/versions/src/initial/network.rs +++ b/dpd-types/versions/src/initial/network.rs @@ -221,38 +221,61 @@ impl From for MacAddr { #[serde(try_from = "u32")] pub struct Vni(pub(crate) u32); +/// Error raised while validating a [`Vni`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)] +pub enum VniError { + /// The value exceeds the 24-bit Geneve maximum. + #[error( + "VNI {value} exceeds the maximum 24-bit value {max}", + max = Vni::MAX_VNI + )] + OutOfRange { value: u32 }, +} + +/// Error returned when parsing a [`Vni`] from a string. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum VniParseError { + /// The string could not be parsed as a `u32`. + #[error("Invalid VNI '{0}': {1}")] + InvalidInt(String, std::num::ParseIntError), + /// The parsed integer was out of range. + #[error(transparent)] + Value(#[from] VniError), +} + impl Vni { pub const MAX_VNI: u32 = 0x00FF_FFFF; - const ERR_MSG: &'static str = "VNI out of 24-bit range"; /// Construct a new VNI, validating that it's a valid 24-bit value. - pub const fn new(vni: u32) -> Option { - // bool.then_some is not const, unforunately - if vni <= Self::MAX_VNI { Some(Self(vni)) } else { None } + pub const fn new(vni: u32) -> Result { + if vni > Self::MAX_VNI { + return Err(VniError::OutOfRange { value: vni }); + } + Ok(Self(vni)) } /// Return the VNI as a u32. - pub const fn as_u32(&self) -> u32 { + pub const fn as_u32(self) -> u32 { self.0 } } impl core::convert::TryFrom for Vni { - type Error = &'static str; + type Error = VniError; fn try_from(vni: u32) -> Result { - Self::new(vni).ok_or(Self::ERR_MSG) + Self::new(vni) } } impl core::str::FromStr for Vni { - type Err = &'static str; + type Err = VniParseError; fn from_str(s: &str) -> Result { - match s.parse::().map(Vni::new) { - Err(_) | Ok(None) => Err(Self::ERR_MSG), - Ok(Some(vni)) => Ok(vni), - } + let vni = s + .parse::() + .map_err(|e| VniParseError::InvalidInt(s.to_string(), e))?; + Ok(Self::new(vni)?) } } diff --git a/dpd-types/versions/src/initial/route.rs b/dpd-types/versions/src/initial/route.rs index 472ddb02..d7a3d51f 100644 --- a/dpd-types/versions/src/initial/route.rs +++ b/dpd-types/versions/src/initial/route.rs @@ -108,6 +108,8 @@ pub struct Ipv6RouteToken { } /// An object with IPv4 route settings used in concert with [`PortSettings`]. +/// +/// [`PortSettings`]: super::port::PortSettings #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct RouteSettingsV4 { pub link_id: u8, @@ -115,6 +117,8 @@ pub struct RouteSettingsV4 { } /// An object with IPV6 route settings used in concert with [`PortSettings`]. +/// +/// [`PortSettings`]: super::port::PortSettings #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct RouteSettingsV6 { pub link_id: u8, diff --git a/dpd-types/versions/src/latest.rs b/dpd-types/versions/src/latest.rs index 3b56eb39..19b7ea32 100644 --- a/dpd-types/versions/src/latest.rs +++ b/dpd-types/versions/src/latest.rs @@ -16,7 +16,7 @@ pub mod arp { } pub mod attached_subnet { - pub use crate::v3::attached_subnet::AttachedSubnetEntry; + pub use crate::v14::attached_subnet::AttachedSubnetEntry; } pub mod counters { @@ -67,30 +67,64 @@ pub mod loopback { pub mod mcast { pub use crate::v1::mcast::Direction; pub use crate::v1::mcast::ExternalForwarding; - pub use crate::v1::mcast::InternalForwarding; pub use crate::v1::mcast::MulticastGroupId; pub use crate::v1::mcast::MulticastGroupIdParam; pub use crate::v1::mcast::MulticastGroupIpParam; pub use crate::v1::mcast::MulticastGroupMember; pub use crate::v7::mcast::IpSrc; - pub use crate::v7::mcast::MulticastGroupCreateExternalEntry; - pub use crate::v8::mcast::Error; + pub use crate::v8::mcast::MAX_TAG_LENGTH; pub use crate::v8::mcast::MulticastGroupCreateUnderlayEntry; - pub use crate::v8::mcast::MulticastGroupExternalResponse; - pub use crate::v8::mcast::MulticastGroupResponse; pub use crate::v8::mcast::MulticastGroupTagQuery; pub use crate::v8::mcast::MulticastGroupUnderlayResponse; - pub use crate::v8::mcast::MulticastGroupUpdateExternalEntry; pub use crate::v8::mcast::MulticastGroupUpdateUnderlayEntry; pub use crate::v8::mcast::MulticastTag; + pub use crate::v8::mcast::MulticastTagParseError; pub use crate::v8::mcast::MulticastTagPath; pub use crate::v8::mcast::MulticastUnderlayGroupIpParam; + pub use crate::v8::mcast::UNDERLAY_MULTICAST_SUBNET; + pub use crate::v8::mcast::UnderlayMulticastError; pub use crate::v8::mcast::UnderlayMulticastIpv6; - - pub use crate::impls::mcast::MAX_TAG_LENGTH; - pub use crate::impls::mcast::MulticastTagParseError; + pub use crate::v8::mcast::UnderlayMulticastParseError; + + pub use crate::v14::mcast::AsmMulticastGroupCreate; + pub use crate::v14::mcast::EmbeddedIpv4; + pub use crate::v14::mcast::ExactSource; + pub use crate::v14::mcast::ExternalInternalForwarding; + pub use crate::v14::mcast::ExternalInternalForwardingError; + pub use crate::v14::mcast::ExternalMulticastIp; + pub use crate::v14::mcast::ExternalMulticastIpError; + pub use crate::v14::mcast::ExternalNatTarget; + pub use crate::v14::mcast::ExternalNatTargetError; + pub use crate::v14::mcast::InvalidEmbeddedRp; + pub use crate::v14::mcast::InvalidMulticastSource; + pub use crate::v14::mcast::MulticastExternalGroupIpParam; + pub use crate::v14::mcast::MulticastGroupCreateExternalEntry; + pub use crate::v14::mcast::MulticastGroupCreateExternalError; + pub use crate::v14::mcast::MulticastGroupExternalResponse; + pub use crate::v14::mcast::MulticastGroupResponse; + pub use crate::v14::mcast::MulticastGroupUpdateExternalEntry; + pub use crate::v14::mcast::MulticastSourceError; + pub use crate::v14::mcast::MulticastSourceParseError; + pub use crate::v14::mcast::NonEmptyExactSources; + pub use crate::v14::mcast::SourceEntry; + pub use crate::v14::mcast::SourceFilter; + pub use crate::v14::mcast::SsmMulticastGroupCreate; + pub use crate::v14::mcast::SsmMulticastIp; + pub use crate::v14::mcast::UnicastAddrV4; + pub use crate::v14::mcast::UnicastAddrV6; + + pub use crate::v14::mcast::is_ssm_address; + pub use crate::v14::mcast::validate_external_multicast_ip; + pub use crate::v14::mcast::validate_external_multicast_ipv4; + pub use crate::v14::mcast::validate_external_multicast_ipv6; + + // The SSM allocation blocks and the admin-local scope prefix are + // unversioned, since RFC 4607 and RFC 4291 fix them for every API version. + pub use crate::impls::mcast::IPV4_SSM_SUBNET; + pub use crate::impls::mcast::IPV6_ADMIN_SCOPED_MULTICAST_PREFIX; + pub use crate::impls::mcast::IPV6_SSM_SUBNETS; } pub mod misc { @@ -99,8 +133,6 @@ pub mod misc { } pub mod nat { - pub use crate::v1::nat::Ipv4Nat; - pub use crate::v1::nat::Ipv6Nat; pub use crate::v1::nat::NatIpv4Path; pub use crate::v1::nat::NatIpv4PortPath; pub use crate::v1::nat::NatIpv4RangePath; @@ -108,15 +140,19 @@ pub mod nat { pub use crate::v1::nat::NatIpv6PortPath; pub use crate::v1::nat::NatIpv6RangePath; pub use crate::v1::nat::NatToken; + + pub use crate::v14::nat::IpNat; } pub mod network { pub use crate::v1::network::MacAddr; pub use crate::v1::network::MacError; - pub use crate::v1::network::NatTarget; - pub use crate::v1::network::Vni; + pub use crate::v1::network::VniError; + pub use crate::v1::network::VniParseError; - pub use crate::v3::network::InstanceTarget; + pub use crate::v14::network::InstanceTarget; + pub use crate::v14::network::NatTarget; + pub use crate::v14::network::Vni; } pub mod oxstats { diff --git a/dpd-types/versions/src/lib.rs b/dpd-types/versions/src/lib.rs index 617a58c7..739c81ee 100644 --- a/dpd-types/versions/src/lib.rs +++ b/dpd-types/versions/src/lib.rs @@ -33,6 +33,8 @@ mod impls; pub mod latest; +#[cfg(any(test, feature = "testing"))] +pub mod testing; #[path = "initial/mod.rs"] pub mod v1; #[path = "asic_details/mod.rs"] @@ -43,6 +45,8 @@ pub mod v11; pub mod v12; #[path = "allow_ddm_traffic/mod.rs"] pub mod v13; +#[path = "mcast_external_scope/mod.rs"] +pub mod v14; #[path = "attached_subnets/mod.rs"] pub mod v3; #[path = "v4_over_v6_routes/mod.rs"] diff --git a/dpd-types/versions/src/mcast_external_scope/attached_subnet.rs b/dpd-types/versions/src/mcast_external_scope/attached_subnet.rs new file mode 100644 index 00000000..2295a621 --- /dev/null +++ b/dpd-types/versions/src/mcast_external_scope/attached_subnet.rs @@ -0,0 +1,27 @@ +// 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 + +//! Attached subnet types changed in API version `MCAST_EXTERNAL_SCOPE`. + +use oxnet::IpNet; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::network::InstanceTarget; +use crate::v3; + +/** represents an external subnet mapping */ +#[derive(Debug, Copy, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +pub struct AttachedSubnetEntry { + pub subnet: IpNet, + pub tgt: InstanceTarget, +} + +impl From for v3::attached_subnet::AttachedSubnetEntry { + fn from(entry: AttachedSubnetEntry) -> Self { + Self { subnet: entry.subnet, tgt: entry.tgt.into() } + } +} diff --git a/dpd-types/versions/src/mcast_external_scope/mcast.rs b/dpd-types/versions/src/mcast_external_scope/mcast.rs new file mode 100644 index 00000000..2968a53c --- /dev/null +++ b/dpd-types/versions/src/mcast_external_scope/mcast.rs @@ -0,0 +1,1486 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/ +// +// Copyright 2026 Oxide Computer Company + +//! Multicast types added or changed in API version `MCAST_EXTERNAL_SCOPE`. +//! +//! This version validates external multicast requests during deserialization. +//! +//! [`ExternalMulticastIp`] wraps a customer-visible group address, +//! [`ExactSource`] (built on [`UnicastAddrV4`] and [`UnicastAddrV6`]) a source +//! address, and [`ExternalNatTarget`] the group's underlay target in the +//! `ff04::/64` subnet. +//! +//! Requests retain their flat wire structure. +//! [`MulticastGroupCreateExternalEntry`] uses [`is_ssm_address`] to label +//! each request as either any-source (ASM) or source-specific (SSM) multicast +//! from its group address. +//! +//! # Group addresses +//! +//! An address can be rejected for a variety of reasons: when it cannot name a +//! group, when a router must not or should not forward it, or when it lies in +//! Oxide's `ff04::/64` underlay subnet, which belongs to the internal multicast +//! API. Everything else is game. An IANA allocation window, or a router's +//! permission to drop based on IP address, does not make this address +//! invalid at this layer. +//! +//! IPv6 flags sit above the scope nibble as `|X|R|P|T|` ([RFC 7371] §4.2.1): +//! T marks a transient address, P a prefix-based one, and R an embedded +//! rendezvous point (RP). X may be either value. +//! +//! | Address or condition | Outcome | Basis | +//! | --- | --- | --- | +//! | Not multicast | Reject | A group address must be multicast. | +//! | `224.0.0.0` | Reject | [RFC 1112] §4: never assigned to a group. | +//! | `224.0.0.1` through `224.0.0.255` | Reject | [RFC 5771] §4: control traffic not forwarded off link. The IANA registry, citing [RFC 1112], notes that routers should not forward it regardless of TTL. IPv4 has no scope field, making this the counterpart to IPv6 link-local scope. | +//! | `232.0.0.0` | Reject | [RFC 4607] §4.3: SSM null address. | +//! | `232.0.0.1` through `232.0.0.255` | Accept | [RFC 4607] §4.3: reserved for IANA allocation. | +//! | `232.0.0.0/8` | SSM | [RFC 4607] §1. | +//! | IPv6 scope `0` | Reject | [RFC 4291] §2.7: reserved. | +//! | IPv6 scopes `1` and `2` | Reject | [RFC 4291] §2.7: routers must not forward past interface-local or link-local scope. | +//! | IPv6 scopes `3` through `f` | Accept | [RFC 4291] §2.7; [RFC 7346]. Scope `f` is treated as global when sent or received. | +//! | `ff04::/64` | Reject | Oxide underlay subnet. The rest of `ff04::/16` is accepted. | +//! | `ff0x::` with no flags and an all-zero group ID | Reject | [RFC 4291] §2.7.1: never assigned. | +//! | Permanent addresses (T=0, P=R=0) | Accept | [RFC 4291] §2.7: a registered meaning is independent of scope, such as `ff0e::101` or `ff05::fb`. | +//! | P set without T | Reject | [RFC 7371] §4.1.1. | +//! | R set without both P and T | Reject | [RFC 3956] §3; [RFC 7371] §4.2.2. | +//! | R set with a prefix length outside 1 through 64, a zero RP interface ID, or a derived RP in `::/16`, `fe80::/10`, or `ff00::/8` | Reject | [RFC 3956] §4, §6.3. | +//! | Prefix-based `ff2` and `rsvd` nibbles | Ignored | [RFC 7371] §4.1.1; [RFC 3956] §3: sent as zero, ignored on receipt. | +//! | `ff3x::/32`, and `ffbx::/32` with X set, with R=0, P=T=1, and a zero prefix length | SSM | [RFC 4607] §1; [RFC 7371] §4.1.2; [RFC 3306] §6. | +//! | `ff3x::4000:0` | Reject | [RFC 4607] §4.3: SSM null address. | +//! | Other SSM group IDs: below `4000:0`, the IANA window `4000:1` through `7fff:ffff`, and the dynamic range from `8000:0` | Accept | [RFC 3307] §4; [RFC 4607] §1 lets routers drop the invalid low range; [RFC 10028] §3 partitions the dynamic range. | +//! | Network prefix of an SSM address | Not checked | [RFC 3306] §6 governs the node forming the address. | +//! +//! # Sources and requests +//! +//! - Sources are unicast. IPv4 sources exclude `0.0.0.0/8`, `127.0.0.0/8`, +//! `169.254.0.0/16`, `240.0.0.0/4`, and `255.255.255.255`. IPv6 sources +//! exclude `::`, `::1`, `fe80::/10`, and IPv4-mapped or IPv4-compatible +//! addresses. +//! - A source's address family must match the group's. +//! - An SSM group requires at least one exact source and rejects `Any` because +//! routers ignore an SSM requests without a source ([RFC 4607] §2). An ASM +//! group accepts exact sources, `Any` or no source list allows traffic +//! from any source. +//! - The NAT target's internal IP falls within `ff04::/64`, and its inner MAC +//! is a multicast MAC. +//! +//! [RFC 1112]: https://www.rfc-editor.org/rfc/rfc1112.html +//! [RFC 3306]: https://www.rfc-editor.org/rfc/rfc3306.html +//! [RFC 3307]: https://www.rfc-editor.org/rfc/rfc3307.html +//! [RFC 3956]: https://www.rfc-editor.org/rfc/rfc3956.html +//! [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html +//! [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html +//! [RFC 5771]: https://www.rfc-editor.org/rfc/rfc5771.html +//! [RFC 7346]: https://www.rfc-editor.org/rfc/rfc7346.html +//! [RFC 7371]: https://www.rfc-editor.org/rfc/rfc7371.html +//! [RFC 10028]: https://www.rfc-editor.org/rfc/rfc10028.html + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::impls::mcast::{IPV4_SSM_SUBNET, RequestSources}; +use crate::v1; +use crate::v1::mcast::{ + ExternalForwarding, InternalForwarding, MulticastGroupId, +}; +use crate::v1::network::MacAddr; +use crate::v7; +use crate::v7::mcast::IpSrc; +use crate::v8; +use crate::v8::mcast::{ + MulticastGroupUnderlayResponse, UNDERLAY_MULTICAST_SUBNET, + UnderlayMulticastIpv6, +}; +use crate::v14::network::{NatTarget, Vni}; + +/// A validated IP address for a customer-visible overlay multicast group. +/// +/// The overlay multicast group is the address guests can send to and join, +/// e.g. `233.252.0.1` or `ff0e::1`, and spans both address families. Its +/// rack-internal underlay mapping is validated separately via +/// `UnderlayMulticastIpv6`, with a 1:1 mapping between the two. +/// +/// JSON uses the ordinary IP-address string form. Deserialization rejects +/// addresses that are not multicast, use a reserved, interface-local, or +/// link-local IPv6 scope, fall in the IPv4 local network control block +/// (`224.0.0.0/24`) or the `ff04::/64` underlay subnet, are a reserved base +/// or SSM null address, or carry malformed prefix-based or embedded-RP flags. +#[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Deserialize, + Serialize, + JsonSchema, +)] +#[serde(try_from = "IpAddr", into = "IpAddr")] +pub struct ExternalMulticastIp(pub(crate) IpAddr); + +impl TryFrom for ExternalMulticastIp { + type Error = ExternalMulticastIpError; + + fn try_from(addr: IpAddr) -> Result { + Self::new(addr) + } +} + +impl From for IpAddr { + fn from(addr: ExternalMulticastIp) -> Self { + addr.0 + } +} + +/// Errors returned when parsing or validating an [`ExternalMulticastIp`]. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ExternalMulticastIpError { + /// The address is not a multicast address. + #[error("Address {0} is not a multicast address")] + NotMulticast(IpAddr), + + /// The address contains the IPv4 Local Network Control Block + /// (`224.0.0.0/24`), which routers should not forward off link + /// (RFC 5771 §4). + #[error( + "Address {0} is in the link-local IPv4 multicast block 224.0.0.0/24, \ + which is not forwarded off-link (RFC 5771 §4)" + )] + LocalIpv4Scope(IpAddr), + + /// The IPv6 multicast scope value zero is reserved. + #[error("Address {0} uses reserved IPv6 multicast scope 0")] + ReservedIpv6Scope(IpAddr), + + /// The IPv6 multicast scope is interface-local or link-local, which a + /// router must not forward beyond (RFC 4291 §2.7). + #[error( + "Address {0} has interface-local or link-local IPv6 multicast scope, \ + which routers must not forward beyond (RFC 4291 §2.7)" + )] + LocalIpv6Scope(IpAddr), + + /// The address is in the reserved `ff04::/64` underlay subnet for internal + /// routing. + #[error( + "Address {0} is in the reserved underlay multicast subnet (ff04::/64, \ + within admin-local scope ff04::/16) and cannot be used as an \ + external group" + )] + ReservedUnderlaySubnet(IpAddr), + + /// The address is reserved and never assigned to any multicast group: + /// `224.0.0.0` (RFC 1112 §4) or `ff0x::` (RFC 4291 §2.7.1). + #[error( + "Address {0} is reserved and never assigned to a multicast group \ + (RFC 1112 §4, RFC 4291 §2.7.1)" + )] + ReservedBaseAddress(IpAddr), + + /// The SSM null address, `232.0.0.0` or `ff3x::4000:0`, must not be used + /// as a destination (RFC 4607 §4.3). + #[error( + "Address {0} is the reserved SSM null address (232.0.0.0 or \ + ff3x::4000:0) and must not be used as a destination (RFC 4607 §4.3)" + )] + ReservedSsmNull(IpAddr), + + /// The prefix-based P flag is set without the T flag (RFC 7371 §4.1.1, + /// amending RFC 3306 §4). + #[error( + "Address {addr} sets the P flag, but RFC 7371 §4.1.1 requires the T \ + flag to be set as well" + )] + MalformedPrefixBased { + /// The rejected group address. + addr: IpAddr, + }, + + /// The embedded rendezvous point (RP) R flag is set with an invalid flag + /// combination, prefix length, RP interface ID, or derived RP address. + /// [`InvalidEmbeddedRp`] names the rule that failed. + #[error( + "Address {addr} sets the R flag, but {reason} (RFC 3956, RFC 7371)" + )] + MalformedEmbeddedRp { + /// The rejected group address. + addr: IpAddr, + /// The embedded-RP rule that failed. + reason: InvalidEmbeddedRp, + }, + + /// The given string does not parse as an IP address. + #[error("Invalid IP address '{0}': {1}")] + InvalidIpAddress(String, std::net::AddrParseError), +} + +/// Errors returned when validating an external multicast group creation +/// request. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum MulticastGroupCreateExternalError { + /// The group IP failed external multicast address validation, returning an + /// [`ExternalMulticastIpError`]. + #[error(transparent)] + InvalidGroupIp(#[from] ExternalMulticastIpError), + + /// An exact source address supplied in the request uses a different + /// address family than the given group IP address. + #[error( + "Source IP {source_ip} does not match multicast group address family \ + ({group})" + )] + SourceFamilyMismatch { + /// The rejected source address. + source_ip: IpAddr, + /// The group address whose family it must match. + group: IpAddr, + }, + + /// An exact source address failed source validation, returning a + /// [`MulticastSourceError`]. + #[error(transparent)] + InvalidSource(#[from] MulticastSourceError), + + /// An SSM group requires at least one exact source. + #[error( + "Group IP {0} is a Source-Specific Multicast address and requires at \ + least one source to be defined" + )] + SsmRequiresSources(IpAddr), + + /// An SSM group cannot use `IpSrc::Any` (only ASM groups can). + #[error( + "Group IP {0} is a Source-Specific Multicast address and requires \ + specific sources (IpSrc::Any is not allowed)" + )] + SsmRejectsAnySource(IpAddr), + + /// The internal forwarding config is missing or invalid. + #[error(transparent)] + InvalidInternalForwarding(#[from] ExternalInternalForwardingError), +} + +/// The reason an address cannot serve as a multicast `(S,G)` source. +/// +/// The rules are validated in variant order. An address that breaks more +/// than one rule only reports the first hit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InvalidMulticastSource { + /// `0.0.0.0` or `::`. + Unspecified, + + /// `127.0.0.0/8` or `::1`. + Loopback, + + /// `255.255.255.255`, the limited broadcast address that [RFC 919] §7 + /// dictates must not be forwarded. IPv6 has no broadcast address, making + /// this IPv4 only. + /// + /// [RFC 919]: https://www.rfc-editor.org/rfc/rfc919.html + Broadcast, + + /// `169.254.0.0/16` ([RFC 3927]) or `fe80::/10` ([RFC 4291] §2.5.6). + /// + /// [RFC 3927]: https://www.rfc-editor.org/rfc/rfc3927.html + /// [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html + LinkLocal, + + /// `0.0.0.0/8`. [RFC 1122] §3.2.1.3 permits it as a source before a + /// host learns its own address, but reverse-path forwarding cannot + /// resolve it to an incoming interface. + /// + /// [RFC 1122]: https://www.rfc-editor.org/rfc/rfc1122.html + ThisNetwork, + + /// The class E block `240.0.0.0/4`, which the IANA special-purpose + /// registry ([RFC 6890]) marks "Source: False". + /// + /// [RFC 6890]: https://www.rfc-editor.org/rfc/rfc6890.html + Reserved, +} + +/// The reason an IPv6 group address with the R flag set cannot embed a +/// rendezvous point (RP) address ([RFC 7371] §4.2.2, §4.2.3; [RFC 3956] §4, +/// §6.3, §10). +/// +/// The rules are validated in variant order. An address that breaks more +/// than one rule only reports the first hit. +/// +/// [RFC 3956]: https://www.rfc-editor.org/rfc/rfc3956.html +/// [RFC 7371]: https://www.rfc-editor.org/rfc/rfc7371.html +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InvalidEmbeddedRp { + /// The P flag is not set ([RFC 7371] §4.2.2, §4.2.3). + RequiresPrefixBased, + /// The T flag is not set ([RFC 7371] §4.2.2, §4.2.3). + RequiresTransient, + /// The prefix length, carried here, is zero or greater than 64 + /// ([RFC 3956] §4). + PrefixLengthOutOfRange(u8), + /// The RP interface ID is zero ([RFC 3956] §6.3). + ZeroInterfaceId, + /// The derived RP address, carried here, is in `::/16`, link-local, or + /// multicast space ([RFC 3956] §4, §10). + /// + /// The address is built from the group's network prefix and RP interface + /// ID. + UnusableAddress(Ipv6Addr), +} + +/// The IPv6 representation of an embedded IPv4 address. +/// +/// Both variants carry the IPv4 address within the low 32 bits and differ only +/// in the 96-bit prefix ahead of those bits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EmbeddedIpv4 { + /// The `::ffff:0:0/96` form, which represents an IPv4 node's address + /// to an IPv6 application ([RFC 4291] §2.5.5.2). + /// + /// [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html + Mapped, + + /// The `::/96` form, which [RFC 4291] §2.5.5.1 deprecates because the + /// transition mechanisms that used it are obsolete. + /// + /// [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html + Compatible, +} + +/// Errors raised while validating a multicast source address. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum MulticastSourceError { + /// A source address is a multicast address (not a unicast one). + #[error( + "Source IP {0} must be a unicast address (multicast addresses are not \ + allowed)" + )] + NotUnicast(IpAddr), + + /// An IPv4 source address is invalid if it is loopback, broadcast, + /// unspecified, link-local, in the "this host on this network" block + /// `0.0.0.0/8`, or in the class E block `240.0.0.0/4`. + #[error("Source IP {addr} {reason}, which is not a valid source address")] + InvalidIpv4 { + /// The rejected source address. + addr: Ipv4Addr, + /// The source rule that failed. + reason: InvalidMulticastSource, + }, + + /// An IPv6 source address is invalid if it is loopback, unspecified, or + /// link-local. + #[error("Source IP {addr} {reason}, which is not a valid source address")] + InvalidIpv6 { + /// The rejected source address. + addr: Ipv6Addr, + /// The source rule that failed. + reason: InvalidMulticastSource, + }, + + /// An IPv6 source address is invalid if it uses an IPv4-mapped + /// ([RFC 4291] §2.5.5.2) or IPv4-compatible ([RFC 4291] §2.5.5.1) + /// representation. Either form converts to an IPv4 address and would + /// carry IPv4 semantics past the IPv4 source checks. + /// + /// [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html + #[error( + "Source IP {addr} embeds an IPv4 address, {form}, which is not a \ + valid IPv6 source address" + )] + Ipv4Embedded { + /// The rejected source address. + addr: Ipv6Addr, + /// The embedded IPv4 form it uses. + form: EmbeddedIpv4, + }, +} + +/// Error returned when parsing an [`ExactSource`] from a string. +// Note: We separate this out from `MulticastSourceError`, which the source +// constructors (`ExactSource::new`, `UnicastAddrV4::new`, and +// `UnicastAddrV6::new`) return. A single error enum would place +// `InvalidIp` in their signatures, and their callers would have to handle a +// parse failure the constructors never produce. +// +// This follows `VniParseError` and `UnderlayMulticastParseError`. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum MulticastSourceParseError { + /// The string could not be parsed as an IP address. + #[error("Invalid source IP address '{0}': {1}")] + InvalidIp(String, std::net::AddrParseError), + /// The parsed address cannot be a multicast source. + #[error(transparent)] + Value(#[from] MulticastSourceError), +} + +/// A validated IPv4 unicast address for multicast source fields. +/// +/// A source must be unicast and forwardable. IPv4 excludes loopback, +/// broadcast (RFC 919 §7), the unspecified address, link-local (RFC 3927), +/// `0.0.0.0/8` (RFC 1122 §3.2.1.3), and `240.0.0.0/4` (RFC 6890). Private +/// ranges (RFC 1918) are allowed because overlay guests can use them. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(try_from = "Ipv4Addr", into = "Ipv4Addr")] +pub struct UnicastAddrV4(pub(crate) Ipv4Addr); + +/// A validated IPv6 unicast address for multicast source fields. +/// +/// A source must be unicast and forwardable. IPv6 excludes loopback, the +/// unspecified address, link-local (RFC 4291 §2.5.6), and the IPv4-mapped +/// (RFC 4291 §2.5.5.2) and IPv4-compatible (RFC 4291 §2.5.5.1) forms. ULA +/// (`fc00::/7`, RFC 4193) is allowed because overlay guests can use it. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(try_from = "Ipv6Addr", into = "Ipv6Addr")] +pub struct UnicastAddrV6(pub(crate) Ipv6Addr); + +impl TryFrom for UnicastAddrV4 { + type Error = MulticastSourceError; + + fn try_from(addr: Ipv4Addr) -> Result { + Self::new(addr) + } +} + +impl From for Ipv4Addr { + fn from(addr: UnicastAddrV4) -> Self { + addr.0 + } +} + +impl TryFrom for UnicastAddrV6 { + type Error = MulticastSourceError; + + fn try_from(addr: Ipv6Addr) -> Result { + Self::new(addr) + } +} + +impl From for Ipv6Addr { + fn from(addr: UnicastAddrV6) -> Self { + addr.0 + } +} + +/// A validated external SSM group address. +/// +/// IPv4 SSM addresses must be within the `232.0.0.0/8` range, excluding the +/// reserved `232.0.0.0` null address. +/// +/// IPv6 SSM addresses satisfy the SSM checks on [`ExternalMulticastIp`]. +/// +/// SSM channels are identified by an `(S,G)` pairing; an any-source (ASM) +/// filter is invalid for this type. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SsmMulticastIp(pub(crate) ExternalMulticastIp); + +/// A validated exact multicast source address. +/// +/// Sources must be unicast and use the same address family as the group. The +/// rule set matches OPTE's source validator. +/// +/// IPv4 loopback, broadcast, unspecified, link-local, and "this host on this +/// network" (`0.0.0.0/8`, RFC 1122 §3.2.1.3) addresses are rejected as +/// sources, as is the class E block (`240.0.0.0/4`), which RFC 1112 §4 +/// reserves and the IANA special-purpose registry (RFC 6890) marks as +/// "Source: False". Shared address space (`100.64.0.0/10`, RFC 6598) is +/// permitted because it can source traffic inside an operator network. +/// +/// IPv6 loopback, unspecified, link-local, IPv4-mapped (RFC 4291 §2.5.5.2), +/// and IPv4-compatible (RFC 4291 §2.5.5.1) addresses are rejected as sources. +/// A link-local source is never forwarded off-link (RFC 3927 §2.7, RFC 4291 +/// §2.5.6). `0.0.0.0/8` is marked as not forwardable in the IANA +/// special-purpose registry (RFC 6890), and a router SHOULD NOT forward a +/// source on network 0 (RFC 1812 §5.3.7). Neither could ever match a source +/// filter. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExactSource(pub(crate) IpAddr); + +/// A non-empty list of validated exact multicast source addresses +/// (sorted and deduplicated). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NonEmptyExactSources(pub(crate) Vec); + +/// Stored source filter for an external multicast group. +/// +/// A list that is absent, empty, a singleton `Any` (wildcard), or containing +/// `Any` alongside exact entries always collapses to just the singleton `Any`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SourceFilter { + /// Any source, maps to the `(*,G)` wildcard. + Any, + /// Exact sources only, the `(S,G)` list. + Exact(NonEmptyExactSources), +} + +/// A requested source entry, which is validated on its own address. +/// +/// The incoming deserialized form is an [`IpSrc`]. +/// +/// The validations that depend on the group address, namely the +/// address-family match and the ASM/SSM source-list disambiguation, are +/// applied once the group IP is known. +#[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, +)] +#[serde(try_from = "IpSrc", into = "IpSrc")] +pub enum SourceEntry { + /// Wildcard entry, which collapses an ASM filter to unrestricted. + Any, + /// A single source that passed unicast validation. + Exact(ExactSource), +} + +impl JsonSchema for SourceEntry { + fn schema_name() -> String { + IpSrc::schema_name() + } + + fn is_referenceable() -> bool { + IpSrc::is_referenceable() + } + + fn schema_id() -> std::borrow::Cow<'static, str> { + IpSrc::schema_id() + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + IpSrc::json_schema(generator) + } +} + +impl TryFrom for SourceEntry { + type Error = MulticastGroupCreateExternalError; + + fn try_from(source: IpSrc) -> Result { + Ok(match source { + IpSrc::Any => Self::Any, + IpSrc::Exact(ip) => Self::Exact(ExactSource::new(ip)?), + }) + } +} + +impl From for IpSrc { + fn from(entry: SourceEntry) -> Self { + match entry { + SourceEntry::Any => IpSrc::Any, + SourceEntry::Exact(source) => IpSrc::Exact(source.ip()), + } + } +} + +/// NAT target for an external multicast group. +/// +/// The MAC must be a multicast MAC and the internal IP must lie within the +/// `ff04::/64` internal underlay block. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema, +)] +#[serde(try_from = "NatTarget", into = "NatTarget")] +pub struct ExternalNatTarget { + pub(crate) internal_ip: UnderlayMulticastIpv6, + inner_mac: MacAddr, + vni: Vni, +} + +/// Errors from constructing an [`ExternalNatTarget`]. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ExternalNatTargetError { + /// The inner MAC is not multicast. + #[error("NAT target inner MAC address {0} is not a multicast MAC address")] + InvalidInnerMac(MacAddr), + /// The internal IP is outside `ff04::/64`. + #[error( + "NAT target internal IP address {0} is not in the reserved underlay \ + multicast subnet (ff04::/64)" + )] + InvalidInternalIp(Ipv6Addr), +} + +/// Errors from converting the older optional forwarding shape. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ExternalInternalForwardingError { + /// The request omitted its NAT target. + #[error("External multicast groups require a NAT target")] + MissingNatTarget, + /// The NAT target failed validation. + #[error(transparent)] + InvalidNatTarget(#[from] ExternalNatTargetError), +} + +/// Internal forwarding for an external multicast group. +/// +/// External groups always carry a NAT target. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema, +)] +pub struct ExternalInternalForwarding { + /// The group's underlay NAT target. + pub nat_target: ExternalNatTarget, +} + +impl TryFrom for ExternalNatTarget { + type Error = ExternalNatTargetError; + + fn try_from(target: NatTarget) -> Result { + if !target.inner_mac.is_multicast() { + return Err(ExternalNatTargetError::InvalidInnerMac( + target.inner_mac, + )); + } + + let internal_ip = UnderlayMulticastIpv6::new(target.internal_ip) + .map_err(|_| { + ExternalNatTargetError::InvalidInternalIp(target.internal_ip) + })?; + + Ok(Self { internal_ip, inner_mac: target.inner_mac, vni: target.vni }) + } +} + +impl From for NatTarget { + fn from(target: ExternalNatTarget) -> Self { + Self { + internal_ip: target.internal_ip.into(), + inner_mac: target.inner_mac, + vni: target.vni, + } + } +} + +impl TryFrom for ExternalInternalForwarding { + type Error = ExternalInternalForwardingError; + + fn try_from(forwarding: InternalForwarding) -> Result { + let nat_target = forwarding + .nat_target + .ok_or(ExternalInternalForwardingError::MissingNatTarget)?; + let nat_target = NatTarget::from(nat_target).try_into()?; + Ok(Self { nat_target }) + } +} + +impl From for InternalForwarding { + fn from(forwarding: ExternalInternalForwarding) -> Self { + let nat_target = NatTarget::from(forwarding.nat_target); + Self { nat_target: Some(nat_target.into()) } + } +} + +/// An external any-source (ASM) multicast group used at creation time. +/// +/// ASM uses the host-group model described in [RFC 1112], with member +/// subscribers receiving traffic sent to this group from any permissible +/// source. +/// +/// The requested source list may be absent, empty, exact-only, [`IpSrc::Any`], +/// or a mixture of `Any` and exact entries. +/// +/// A source of `Any` subsumes all exact source entries (related to the +/// filter-mode merge rules of [RFC 3376] §3.2). Upon subsumption, +/// every exact entry is validated first, then any-source constructs (mixed +/// lists included) are canonicalized to an unrestricted filter +/// at deserialization, and then exact-only lists are kept around. +/// +/// [RFC 1112]: https://www.rfc-editor.org/rfc/rfc1112.html +/// [RFC 3376]: https://www.rfc-editor.org/rfc/rfc3376.html +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AsmMulticastGroupCreate { + pub(crate) group_ip: ExternalMulticastIp, + pub(crate) tag: Option, + pub(crate) internal_forwarding: ExternalInternalForwarding, + pub(crate) external_forwarding: ExternalForwarding, + pub(crate) sources: SourceFilter, +} + +/// An external source-specific (SSM) multicast group used at creation time. +/// +/// SSM uses the `(S,G)` channel model from [RFC 4607]. +/// +/// The source list must always contain at least one exact source; +/// `IpSrc::Any` cannot occur in this request type. +/// +/// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SsmMulticastGroupCreate { + pub(crate) group_ip: SsmMulticastIp, + pub(crate) tag: Option, + pub(crate) internal_forwarding: ExternalInternalForwarding, + pub(crate) external_forwarding: ExternalForwarding, + pub(crate) sources: NonEmptyExactSources, +} + +/// A validated external multicast group creation entry. +/// +/// Deserializing the flat request validates the group IP and source list +/// and picks the ASM (any-source) or SSM (source-specific) shape. +/// +/// Serializing emits the canonical flat form. A request that mixed `Any` +/// (wildcard) with exact sources does not round-trip. +/// +/// On decode, this checks the group address, each exact source, and +/// the address-family relationship. Source-list rules follow the appropriate +/// ASM or SSM split. +/// +/// # Examples +/// +/// An SSM group with a populated NAT target: +/// +/// ``` +/// use dpd_types_versions::latest::mcast::MulticastGroupCreateExternalEntry; +/// +/// let body = serde_json::json!({ +/// "group_ip": "232.1.2.3", +/// "tag": "client", +/// "internal_forwarding": { +/// "nat_target": { +/// "internal_ip": "ff04::1", +/// "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, +/// "vni": 100 +/// } +/// }, +/// "external_forwarding": { "vlan_id": null }, +/// "sources": [{ "Exact": "10.0.0.1" }] +/// }); +/// +/// let group: MulticastGroupCreateExternalEntry = +/// serde_json::from_value(body).unwrap(); +/// assert!(matches!(group, MulticastGroupCreateExternalEntry::Ssm(_))); +/// ``` +/// +/// An ASM group with the source list omitted: +/// +/// ``` +/// use dpd_types_versions::latest::mcast::MulticastGroupCreateExternalEntry; +/// +/// let body = serde_json::json!({ +/// "group_ip": "239.1.2.3", +/// "tag": null, +/// "internal_forwarding": { +/// "nat_target": { +/// "internal_ip": "ff04::1", +/// "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, +/// "vni": 100 +/// } +/// }, +/// +/// "external_forwarding": { "vlan_id": null } +/// }); +/// +/// let group: MulticastGroupCreateExternalEntry = +/// serde_json::from_value(body).unwrap(); +/// assert!(matches!(group, MulticastGroupCreateExternalEntry::Asm(_))); +/// ``` +/// +/// An ASM group may carry exact sources, but a source of `Any` (wildcard), a +/// singleton `["Any"]`, or an empty list all collapse to the same unrestricted +/// shape. +/// +/// ``` +/// use dpd_types_versions::latest::mcast::MulticastGroupCreateExternalEntry; +/// +/// let mut body = serde_json::json!({ +/// "group_ip": "239.1.2.3", +/// "tag": null, +/// "internal_forwarding": { +/// "nat_target": { +/// "internal_ip": "ff04::1", +/// "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, +/// "vni": 100 +/// } +/// }, +/// "external_forwarding": { "vlan_id": null }, +/// "sources": [{ "Exact": "10.0.0.1" }, { "Exact": "10.0.0.2" }] +/// }); +/// +/// let entry: MulticastGroupCreateExternalEntry = +/// serde_json::from_value(body.clone()).unwrap(); +/// let MulticastGroupCreateExternalEntry::Asm(group) = &entry else { +/// panic!("239.1.2.3 is an ASM address"); +/// }; +/// assert_eq!(group.sources().unwrap().count(), 2); +/// +/// for sources in [ +/// serde_json::json!(["Any", { "Exact": "10.0.0.1" }]), +/// serde_json::json!(["Any"]), +/// serde_json::json!([]), +/// ] { +/// body["sources"] = sources; +/// let entry: MulticastGroupCreateExternalEntry = +/// serde_json::from_value(body.clone()).unwrap(); +/// let MulticastGroupCreateExternalEntry::Asm(group) = &entry else { +/// panic!("239.1.2.3 is an ASM address"); +/// }; +/// assert!(group.allows_any_source()); +/// assert_eq!( +/// serde_json::to_value(&entry).unwrap()["sources"], +/// serde_json::Value::Null +/// ); +/// } +/// ``` +/// +/// An SSM group must carry exact sources, while ASM can omit them. Both an +/// absent list and a singleton `Any` (wildcard) are rejected: +/// +/// ``` +/// use dpd_types_versions::latest::mcast::MulticastGroupCreateExternalEntry; +/// +/// let mut body = serde_json::json!({ +/// "group_ip": "232.1.2.3", +/// "tag": null, +/// "internal_forwarding": { +/// "nat_target": { +/// "internal_ip": "ff04::1", +/// "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, +/// "vni": 100 +/// } +/// }, +/// "external_forwarding": { "vlan_id": null } +/// }); +/// +/// let no_sources = +/// serde_json::from_value::(body.clone()); +/// assert!(no_sources.is_err()); +/// +/// body["sources"] = serde_json::json!(["Any"]); +/// let singleton_any = +/// serde_json::from_value::(body); +/// assert!(singleton_any.is_err()); +/// ``` +/// +/// IPv6 SSM groups, in the `ff3x::/32` prefix of RFC 4607 and in `ffbx::/32` +/// with the X flag set: +/// +/// ``` +/// use dpd_types_versions::latest::mcast::MulticastGroupCreateExternalEntry; +/// +/// for group_ip in ["ff3e::4000:1", "ffbe::4000:1"] { +/// let body = serde_json::json!({ +/// "group_ip": group_ip, +/// "tag": null, +/// "internal_forwarding": { +/// "nat_target": { +/// "internal_ip": "ff04::1", +/// "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, +/// "vni": 100 +/// } +/// }, +/// "external_forwarding": { "vlan_id": null }, +/// "sources": [{ "Exact": "2001:db8::1" }] +/// }); +/// +/// let group: MulticastGroupCreateExternalEntry = +/// serde_json::from_value(body).unwrap(); +/// assert!(matches!(group, MulticastGroupCreateExternalEntry::Ssm(_))); +/// } +/// ``` +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + try_from = "MulticastGroupCreateExternalEntryShadow", + into = "MulticastGroupCreateExternalEntryShadow" +)] +pub enum MulticastGroupCreateExternalEntry { + /// Any-source group; the source filter is canonicalized at parse. + Asm(AsmMulticastGroupCreate), + /// Source-specific group, carrying at least one validated exact source. + Ssm(SsmMulticastGroupCreate), +} + +/// Request body for creating a customer-visible overlay multicast group. +/// +/// The group IP address determines whether the group is ASM or SSM. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[schemars(rename = "MulticastGroupCreateExternalEntry")] +pub(crate) struct MulticastGroupCreateExternalEntryShadow { + /// The multicast address for the group. + pub(crate) group_ip: ExternalMulticastIp, + /// Tag for validating update/delete requests. If a tag is not provided, + /// one is auto-generated as `{uuid}:{group_ip}`. + pub(crate) tag: Option, + /// NAT target selecting the underlay group that replicates this group's + /// traffic. + /// + /// It must reference an existing underlay group. + pub(crate) internal_forwarding: ExternalInternalForwarding, + /// Egress VLAN configuration for external forwarding. + pub(crate) external_forwarding: ExternalForwarding, + /// Optional source list. + /// + /// A source list can be absent from ASM group requests. If absent, + /// empty, or containing `Any`, the group permits every source (i.e., a + /// wildcard). + /// + /// SSM groups require at least one exact source in the list and + /// reject `Any` wildcard entries. + pub(crate) sources: Option>, +} + +impl TryFrom + for MulticastGroupCreateExternalEntry +{ + type Error = MulticastGroupCreateExternalError; + + fn try_from( + entry: MulticastGroupCreateExternalEntryShadow, + ) -> Result { + let classified = + RequestSources::new(entry.group_ip, entry.sources.as_deref())?; + + Ok(match classified { + RequestSources::Asm(sources) => { + Self::Asm(AsmMulticastGroupCreate { + group_ip: entry.group_ip, + tag: entry.tag, + internal_forwarding: entry.internal_forwarding, + external_forwarding: entry.external_forwarding, + sources, + }) + } + RequestSources::Ssm { group_ip, sources } => { + Self::Ssm(SsmMulticastGroupCreate { + group_ip, + tag: entry.tag, + internal_forwarding: entry.internal_forwarding, + external_forwarding: entry.external_forwarding, + sources, + }) + } + }) + } +} + +impl From + for MulticastGroupCreateExternalEntryShadow +{ + fn from(entry: MulticastGroupCreateExternalEntry) -> Self { + match entry { + MulticastGroupCreateExternalEntry::Asm(group) => Self { + group_ip: group.group_ip, + tag: group.tag, + internal_forwarding: group.internal_forwarding, + external_forwarding: group.external_forwarding, + sources: group.sources.to_entries(), + }, + MulticastGroupCreateExternalEntry::Ssm(group) => Self { + group_ip: group.group_ip.into(), + tag: group.tag, + internal_forwarding: group.internal_forwarding, + external_forwarding: group.external_forwarding, + sources: Some( + group + .sources + .into_vec() + .into_iter() + .map(SourceEntry::Exact) + .collect(), + ), + }, + } + } +} + +impl JsonSchema for MulticastGroupCreateExternalEntry { + fn schema_name() -> String { + MulticastGroupCreateExternalEntryShadow::schema_name() + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + MulticastGroupCreateExternalEntryShadow::json_schema(generator) + } +} + +impl TryFrom + for MulticastGroupCreateExternalEntry +{ + type Error = MulticastGroupCreateExternalError; + + fn try_from( + entry: v7::mcast::MulticastGroupCreateExternalEntry, + ) -> Result { + let sources = entry + .sources + .map(|sources| { + sources.into_iter().map(SourceEntry::try_from).collect() + }) + .transpose()?; + + Self::try_from(MulticastGroupCreateExternalEntryShadow { + group_ip: ExternalMulticastIp::new(entry.group_ip)?, + tag: entry.tag, + internal_forwarding: entry.internal_forwarding.try_into()?, + external_forwarding: entry.external_forwarding, + sources, + }) + } +} + +/// Updated forwarding and source-filter settings for an external group. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MulticastGroupUpdateExternalEntry { + /// NAT target selecting the underlay group that replicates this group's + /// traffic. + pub internal_forwarding: ExternalInternalForwarding, + /// Egress VLAN configuration for external forwarding. + pub external_forwarding: ExternalForwarding, + /// Replacement source list. + /// + /// Exclude or set to null to preserve the current source filter. + pub sources: Option>, +} + +impl TryFrom + for MulticastGroupUpdateExternalEntry +{ + type Error = MulticastGroupCreateExternalError; + + fn try_from( + entry: v8::mcast::MulticastGroupUpdateExternalEntry, + ) -> Result { + let sources = match entry.sources { + Some(sources) => sources + .into_iter() + .map(SourceEntry::try_from) + .collect::>()?, + None => vec![SourceEntry::Any], + }; + + Ok(Self { + internal_forwarding: entry.internal_forwarding.try_into()?, + external_forwarding: entry.external_forwarding, + sources: Some(sources), + }) + } +} + +/// Used to identify an external multicast group by IP address. +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct MulticastExternalGroupIpParam { + /// External multicast group IP address. + pub group_ip: ExternalMulticastIp, +} + +impl TryFrom + for MulticastExternalGroupIpParam +{ + type Error = ExternalMulticastIpError; + + fn try_from( + param: v1::mcast::MulticastGroupIpParam, + ) -> Result { + Ok(Self { group_ip: ExternalMulticastIp::new(param.group_ip)? }) + } +} + +/// Response structure for external multicast group operations. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MulticastGroupExternalResponse { + /// The validated multicast address of the group. + pub group_ip: ExternalMulticastIp, + /// ASIC replication group ID for the external group. + pub external_group_id: MulticastGroupId, + /// Tag for validating update/delete requests. Always present and generated + /// as `{uuid}:{group_ip}` if not provided at creation time. + pub tag: String, + /// NAT target for forwarding onto the underlay. + pub internal_forwarding: ExternalInternalForwarding, + /// Egress VLAN configuration for external forwarding. + pub external_forwarding: ExternalForwarding, + /// Source filter, or null when any source is permitted. + pub sources: Option>, +} + +impl From + for v8::mcast::MulticastGroupExternalResponse +{ + fn from(resp: MulticastGroupExternalResponse) -> Self { + Self { + group_ip: resp.group_ip.into(), + external_group_id: resp.external_group_id, + tag: resp.tag, + internal_forwarding: resp.internal_forwarding.into(), + external_forwarding: resp.external_forwarding, + sources: resp.sources, + } + } +} + +/// Unified response type for operations that return mixed group types. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MulticastGroupResponse { + /// An internal group on the underlay, with members. + Underlay(MulticastGroupUnderlayResponse), + /// An external overlay group containing a NAT target and VLAN + /// configuration, having no direct members. + External(MulticastGroupExternalResponse), +} + +impl From for v8::mcast::MulticastGroupResponse { + fn from(resp: MulticastGroupResponse) -> Self { + match resp { + MulticastGroupResponse::Underlay(u) => Self::Underlay(u), + MulticastGroupResponse::External(e) => Self::External(e.into()), + } + } +} + +const IPV4_RESERVED_BASE: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 0); +const IPV4_SSM_RESERVED_NULL: Ipv4Addr = Ipv4Addr::new(232, 0, 0, 0); +const IPV6_SSM_NULL_GROUP_ID: u32 = 0x4000_0000; +const IPV6_SCOPE_MASK: u16 = 0x000f; +const IPV6_SCOPE_RESERVED_ZERO: u16 = 0x0; +const IPV6_SCOPE_INTERFACE_LOCAL: u16 = 0x1; +const IPV6_SCOPE_LINK_LOCAL: u16 = 0x2; + +// Flags field: the nibble above scope (RFC 4291 §2.7). +const IPV6_FLAGS_SHIFT: u16 = 4; +const IPV6_FLAGS_MASK: u16 = 0xf; +const IPV6_FLAG_TRANSIENT: u16 = 0x1; +const IPV6_FLAG_PREFIX_BASED: u16 = 0x2; +const IPV6_FLAG_EMBEDDED_RP: u16 = 0x4; + +// RIID and prefix length in the second segment (RFC 3956 §3, RFC 3306 §4). +const IPV6_RIID_SHIFT: u16 = 8; +const IPV6_RIID_MASK: u16 = 0xf; +const IPV6_PREFIX_BASED_PLEN_MASK: u16 = 0xff; +const IPV6_PREFIX_BASED_MAX_PLEN: u16 = 64; + +// SSM flag pattern: R=0, P=T=1 (RFC 7371 §4.1.2). +const IPV6_SSM_PREFIX: u16 = 0xff30; +const IPV6_SSM_PREFIX_MASK: u16 = 0xff70; + +fn embedded_rendezvous_point_address( + segs: [u16; 8], + plen: u16, + riid: u16, +) -> Ipv6Addr { + let mut rp = [0u16; 8]; + for (index, segment) in segs[2..6].iter().enumerate() { + let offset = (index as u16) * 16; + rp[index] = if offset >= plen { + 0 + } else if offset + 16 > plen { + segment & (!0u16 << (16 - (plen - offset))) + } else { + *segment + }; + } + rp[7] = riid; + Ipv6Addr::from(rp) +} + +fn is_usable_rendezvous_point_address(rp: Ipv6Addr) -> bool { + let first = rp.segments()[0]; + first != 0 && first & 0xffc0 != 0xfe80 && first >> 8 != 0xff +} + +/// Check the embedded rendezvous point (RP) fields of an address with the R, +/// P, and T flags set. +/// +/// The prefix length must range from 1 through 64 ([RFC 3956] §4), the RP +/// interface ID must be nonzero ([RFC 3956] §6.3), and the derived RP address +/// must lie outside `::/16`, `fe80::/10`, and `ff00::/8` ([RFC 3956] §4). +/// +/// # Errors +/// +/// Returns the [`InvalidEmbeddedRp`] for the first rule the address fails +/// at. +/// +/// [RFC 3956]: https://www.rfc-editor.org/rfc/rfc3956.html +fn validate_embedded_rendezvous_point( + segs: [u16; 8], + plen: u16, +) -> Result<(), InvalidEmbeddedRp> { + if plen == 0 || plen > IPV6_PREFIX_BASED_MAX_PLEN { + return Err(InvalidEmbeddedRp::PrefixLengthOutOfRange(plen as u8)); + } + let riid = (segs[1] >> IPV6_RIID_SHIFT) & IPV6_RIID_MASK; + if riid == 0 { + return Err(InvalidEmbeddedRp::ZeroInterfaceId); + } + let rp = embedded_rendezvous_point_address(segs, plen, riid); + is_usable_rendezvous_point_address(rp) + .then_some(()) + .ok_or(InvalidEmbeddedRp::UnusableAddress(rp)) +} + +const fn ipv6_segments_are_ssm(segs: [u16; 8]) -> bool { + segs[0] & IPV6_SSM_PREFIX_MASK == IPV6_SSM_PREFIX + && segs[1] & IPV6_PREFIX_BASED_PLEN_MASK == 0 +} + +/// Reject an address that cannot identify an external multicast group. +/// +/// This enforces the RFC constraints on the address, plus one Oxide allocation +/// rule: the underlay subnet `ff04::/64` belongs to the internal multicast API. +/// +/// Delegates to [`validate_external_multicast_ipv4`] or +/// [`validate_external_multicast_ipv6`], which list each rule. +pub fn validate_external_multicast_ip( + addr: IpAddr, +) -> Result<(), ExternalMulticastIpError> { + match addr { + IpAddr::V4(ipv4) => validate_external_multicast_ipv4(ipv4), + IpAddr::V6(ipv6) => validate_external_multicast_ipv6(ipv6), + } +} + +/// Reject an IPv4 address that cannot identify an external multicast group. +/// +/// # Errors +/// +/// Returns [`ExternalMulticastIpError::NotMulticast`] outside `224.0.0.0/4`, +/// [`ExternalMulticastIpError::ReservedBaseAddress`] for `224.0.0.0`, +/// [`ExternalMulticastIpError::LocalIpv4Scope`] for the rest of +/// `224.0.0.0/24`, or [`ExternalMulticastIpError::ReservedSsmNull`] for +/// `232.0.0.0`. +pub fn validate_external_multicast_ipv4( + ipv4: Ipv4Addr, +) -> Result<(), ExternalMulticastIpError> { + let addr = IpAddr::V4(ipv4); + if !ipv4.is_multicast() { + return Err(ExternalMulticastIpError::NotMulticast(addr)); + } + if ipv4 == IPV4_RESERVED_BASE { + return Err(ExternalMulticastIpError::ReservedBaseAddress(addr)); + } + if matches!(ipv4.octets(), [224, 0, 0, _]) { + return Err(ExternalMulticastIpError::LocalIpv4Scope(addr)); + } + if ipv4 == IPV4_SSM_RESERVED_NULL { + return Err(ExternalMulticastIpError::ReservedSsmNull(addr)); + } + Ok(()) +} + +/// Reject an IPv6 address that cannot identify an external multicast group. +/// +/// # Errors +/// +/// Returns the [`ExternalMulticastIpError`] for the first rule the address +/// fails. Scope is checked first, then the `ff04::/64` underlay subnet, the +/// reserved base address, the P, T, and R flag rules, and the SSM null address. +pub fn validate_external_multicast_ipv6( + ipv6: Ipv6Addr, +) -> Result<(), ExternalMulticastIpError> { + let addr = IpAddr::V6(ipv6); + if !ipv6.is_multicast() { + return Err(ExternalMulticastIpError::NotMulticast(addr)); + } + + // We classify on just the scope nibble. A /16 prefix comparison + // encodes flags=0 and would accept, e.g., ff11::1 despite its + // interface-local scope. + let segs = ipv6.segments(); + match segs[0] & IPV6_SCOPE_MASK { + IPV6_SCOPE_RESERVED_ZERO => { + return Err(ExternalMulticastIpError::ReservedIpv6Scope(addr)); + } + IPV6_SCOPE_INTERFACE_LOCAL | IPV6_SCOPE_LINK_LOCAL => { + return Err(ExternalMulticastIpError::LocalIpv6Scope(addr)); + } + _ => {} + } + + if UNDERLAY_MULTICAST_SUBNET.contains(ipv6) { + return Err(ExternalMulticastIpError::ReservedUnderlaySubnet(addr)); + } + + // With P set, the low octet of the 2nd segment is the prefix length. + // + // See RFC 3306 §4 and RFC 7371 §4.1.1. + let flags = (segs[0] >> IPV6_FLAGS_SHIFT) & IPV6_FLAGS_MASK; + let plen = segs[1] & IPV6_PREFIX_BASED_PLEN_MASK; + + if flags == 0 && segs[1..].iter().all(|seg| *seg == 0) { + return Err(ExternalMulticastIpError::ReservedBaseAddress(addr)); + } + + // R requires P and T (RFC 3956 §3, RFC 7371 §4.2.2), and P requires T + // (RFC 7371 §4.1.1). + let flag = |bit| flags & bit != 0; + let malformed = + |reason| ExternalMulticastIpError::MalformedEmbeddedRp { addr, reason }; + match ( + flag(IPV6_FLAG_EMBEDDED_RP), + flag(IPV6_FLAG_PREFIX_BASED), + flag(IPV6_FLAG_TRANSIENT), + ) { + (true, false, _) => { + return Err(malformed(InvalidEmbeddedRp::RequiresPrefixBased)); + } + (true, true, false) => { + return Err(malformed(InvalidEmbeddedRp::RequiresTransient)); + } + (true, true, true) => { + validate_embedded_rendezvous_point(segs, plen) + .map_err(malformed)?; + } + (false, true, false) => { + return Err(ExternalMulticastIpError::MalformedPrefixBased { + addr, + }); + } + (false, _, _) => {} + } + + // RFC 3306 §6 places the zero network prefix requirement on the node + // that forms an address, and RFC 4607 §1 has the system treat all of + // the ff3x::/32 block as SSM. The prefix matters only for the reserved + // null address ff3x::4000:0 (RFC 4607 §4.3), the one SSM destination + // rejected here. Group IDs below 0x4000_0000 are assigned with P=T=0 + // (RFC 3307 §4.1), making them invalid SSM addresses that a router may + // drop (RFC 4607 §1). 0x4000_0001 through 0x7fff_ffff are reserved for + // IANA allocation (RFC 3307 §4.2), and 0x8000_0000 and above are the + // dynamic range (RFC 3307 §4.3), which RFC 10028 §3 partitions, with + // host SSM allocation at 0xf000_0000 through 0xfcff_ffff. + if ipv6_segments_are_ssm(segs) { + let within_prefix = + segs[2] == 0 && segs[3] == 0 && segs[4] == 0 && segs[5] == 0; + let group_id = (u32::from(segs[6]) << 16) | u32::from(segs[7]); + if within_prefix && group_id == IPV6_SSM_NULL_GROUP_ID { + return Err(ExternalMulticastIpError::ReservedSsmNull(addr)); + } + } + + Ok(()) +} + +/// Check whether an address is a source-specific multicast (SSM) address. +/// +/// - IPv4 SSM uses `232.0.0.0/8` ([RFC 4607] §1). +/// - IPv6 SSM covers `ff3x::/32` and `ffbx::/32`, the latter with the X flag +/// set ([RFC 7371] §4.1.2). +/// - Both IPv6 ranges require R=0, P=T=1 ([RFC 7371] §4.1.2) and a zero +/// prefix length ([RFC 3306] §6). The `ff2` nibble ([RFC 7371] §4.1.1) +/// and the `rsvd` nibble (RIID in [RFC 3956] §3) are ignored on receipt. +/// +/// [RFC 3306]: https://www.rfc-editor.org/rfc/rfc3306.html +/// [RFC 3956]: https://www.rfc-editor.org/rfc/rfc3956.html +/// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html +/// [RFC 7371]: https://www.rfc-editor.org/rfc/rfc7371.html +// Versioned here: receivers typically ignore the reserved bits, but in +// categorizing `ffbx` and a nonzero `rsvd` nibble as SSM, we're taking a +// flexible interpretation of RFC 7371 and RFC 3956. +pub fn is_ssm_address(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(ipv4) => IPV4_SSM_SUBNET.contains(ipv4), + IpAddr::V6(ipv6) => ipv6_segments_are_ssm(ipv6.segments()), + } +} + +const IPV4_SOURCE_RESERVED_FIRST_OCTET: u8 = 240; + +pub(crate) fn validate_unicast_source_ipv4( + addr: Ipv4Addr, +) -> Result<(), MulticastSourceError> { + if addr.is_multicast() { + return Err(MulticastSourceError::NotUnicast(IpAddr::V4(addr))); + } + let reason = match addr.octets() { + [0, 0, 0, 0] => InvalidMulticastSource::Unspecified, + [127, ..] => InvalidMulticastSource::Loopback, + [255, 255, 255, 255] => InvalidMulticastSource::Broadcast, + [169, 254, ..] => InvalidMulticastSource::LinkLocal, + [0, ..] => InvalidMulticastSource::ThisNetwork, + [IPV4_SOURCE_RESERVED_FIRST_OCTET..=u8::MAX, ..] => { + InvalidMulticastSource::Reserved + } + _ => return Ok(()), + }; + Err(MulticastSourceError::InvalidIpv4 { addr, reason }) +} + +pub(crate) fn validate_unicast_source_ipv6( + addr: Ipv6Addr, +) -> Result<(), MulticastSourceError> { + if addr.is_multicast() { + return Err(MulticastSourceError::NotUnicast(IpAddr::V6(addr))); + } + let embedded = + |form| Err(MulticastSourceError::Ipv4Embedded { addr, form }); + let reason = match addr.segments() { + [0, 0, 0, 0, 0, 0, 0, 0] => InvalidMulticastSource::Unspecified, + [0, 0, 0, 0, 0, 0, 0, 1] => InvalidMulticastSource::Loopback, + [0xfe80..=0xfebf, ..] => InvalidMulticastSource::LinkLocal, + [0, 0, 0, 0, 0, 0xffff, ..] => { + return embedded(EmbeddedIpv4::Mapped); + } + [0, 0, 0, 0, 0, 0, ..] => { + return embedded(EmbeddedIpv4::Compatible); + } + _ => return Ok(()), + }; + Err(MulticastSourceError::InvalidIpv6 { addr, reason }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_embedded_rendezvous_point_address() { + for (group, expected) in [ + ("ff7e:140:2001:db8:beef:feed::", "2001:db8:beef:feed::1"), + ("ff7e:120:2001:db8::", "2001:db8::1"), + ("ff7e:120:2001:db8:dead::", "2001:db8::1"), + ("ff7e:130:2001:db8:beef::", "2001:db8:beef::1"), + ("ff7e:128:2001:db8:beef::", "2001:db8:be00::1"), + ] { + let segs = group.parse::().unwrap().segments(); + let plen = segs[1] & IPV6_PREFIX_BASED_PLEN_MASK; + let riid = (segs[1] >> IPV6_RIID_SHIFT) & IPV6_RIID_MASK; + + assert_eq!( + embedded_rendezvous_point_address(segs, plen, riid), + expected.parse::().unwrap(), + "{group} should derive RP {expected}" + ); + } + } +} diff --git a/dpd-types/versions/src/mcast_external_scope/mod.rs b/dpd-types/versions/src/mcast_external_scope/mod.rs new file mode 100644 index 00000000..186bb19b --- /dev/null +++ b/dpd-types/versions/src/mcast_external_scope/mod.rs @@ -0,0 +1,25 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/ +// +// Copyright 2026 Oxide Computer Company + +//! Version `MCAST_EXTERNAL_SCOPE` of the DPD API. +//! +//! This version added validated external multicast address, source, and NAT +//! target types. +//! +//! This migration changed `MulticastGroupCreateExternalEntry` from a struct to +//! an ASM/SSM enum disambiguated at deserialization, and modified the external +//! `group_ip` and `internal_forwarding` fields to the validated types. +//! +//! `Vni` now advertises its 24-bit range in the schema. `NatTarget`, +//! `InstanceTarget`, `IpNat` (the `Ipv4Nat` and `Ipv6Nat` schemas), and +//! `AttachedSubnetEntry` are updated to contain the new `Vni` type. +//! `VniError` and `VniParseError` were added to the v1 `Vni`, whose `new` and +//! `FromStr` can return them. + +pub mod attached_subnet; +pub mod mcast; +pub mod nat; +pub mod network; diff --git a/dpd-types/versions/src/mcast_external_scope/nat.rs b/dpd-types/versions/src/mcast_external_scope/nat.rs new file mode 100644 index 00000000..6c4167a4 --- /dev/null +++ b/dpd-types/versions/src/mcast_external_scope/nat.rs @@ -0,0 +1,87 @@ +// 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 + +//! NAT types changed in API version `MCAST_EXTERNAL_SCOPE`. + +use std::net::{Ipv4Addr, Ipv6Addr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::network::NatTarget; +use crate::v1; + +pub trait IpNatSchema { + const NAME: &'static str; + const DESCRIPTION: &'static str; +} + +impl IpNatSchema for Ipv4Addr { + const NAME: &'static str = "Ipv4Nat"; + const DESCRIPTION: &'static str = "Represents an IPv4 NAT reservation."; +} + +impl IpNatSchema for Ipv6Addr { + const NAME: &'static str = "Ipv6Nat"; + const DESCRIPTION: &'static str = "Represents an IPv6 NAT reservation."; +} + +#[derive(Debug, Copy, Clone, Deserialize, Serialize)] +pub struct IpNat { + pub external: T, + pub low: u16, + pub high: u16, + pub target: NatTarget, +} + +// NOTE: Used only to derive the schema (fields are never read at runtime). +#[derive(JsonSchema)] +#[allow(dead_code)] +struct IpNatShadow { + external: T, + low: u16, + high: u16, + target: NatTarget, +} + +impl JsonSchema for IpNat +where + T: IpNatSchema + JsonSchema, +{ + fn schema_name() -> String { + T::NAME.to_owned() + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + let mut schema = IpNatShadow::::json_schema(generator).into_object(); + schema.metadata().description = Some(T::DESCRIPTION.to_owned()); + schema.into() + } +} + +impl From> for v1::nat::Ipv6Nat { + fn from(nat: IpNat) -> Self { + Self { + external: nat.external, + low: nat.low, + high: nat.high, + target: nat.target.into(), + } + } +} + +impl From> for v1::nat::Ipv4Nat { + fn from(nat: IpNat) -> Self { + Self { + external: nat.external, + low: nat.low, + high: nat.high, + target: nat.target.into(), + } + } +} diff --git a/dpd-types/versions/src/mcast_external_scope/network.rs b/dpd-types/versions/src/mcast_external_scope/network.rs new file mode 100644 index 00000000..b15d2e89 --- /dev/null +++ b/dpd-types/versions/src/mcast_external_scope/network.rs @@ -0,0 +1,140 @@ +// 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 + +//! Network types modified in API version `MCAST_EXTERNAL_SCOPE`. + +use std::net::Ipv6Addr; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::v1; +use crate::v1::network::{MacAddr, VniError}; +use crate::v3; + +/// A validated Geneve Virtual Network Identifier. +/// +/// A Geneve VNI is a 24-bit value used to identify virtual networks +/// encapsulated using the Generic Network Virtualization Encapsulation (Geneve) +/// protocol (RFC 8926). Any value above the 24-bit maximum of 16777215 is +/// rejected at both construction and deserialization. +// TODO: replace with a validated `Vni` newtype in oxnet, shared with +// `omicron_common::api::external::Vni` and `oxide_vpc::api::Vni`. +// This type checks only the 24-bit width. Reserved system and multicast VNI +// ranges are Omicron policy. +#[derive( + Clone, + Copy, + Debug, + Deserialize, + Eq, + Hash, + JsonSchema, + PartialEq, + PartialOrd, + Ord, + Serialize, +)] +#[serde(try_from = "u32", into = "u32")] +pub struct Vni(#[schemars(range(max = 0xFF_FFFF))] pub(crate) u32); + +impl Vni { + /// Maximum Geneve VNI value. + /// + /// Virtual Network Identifiers are constrained to 24-bit values per the + /// Geneve specification ([RFC 8926] §3.4). + /// + /// [RFC 8926]: https://www.rfc-editor.org/rfc/rfc8926.html + pub const MAX_VNI: u32 = 0xFF_FFFF; +} + +impl TryFrom for Vni { + type Error = VniError; + + fn try_from(value: u32) -> Result { + Self::new(value) + } +} + +impl From for u32 { + fn from(vni: Vni) -> Self { + vni.0 + } +} + +/// Represents an internal NAT target. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Deserialize, JsonSchema, Serialize, +)] +pub struct NatTarget { + pub internal_ip: Ipv6Addr, + pub inner_mac: MacAddr, + pub vni: Vni, +} + +/** represents an internal target for either NAT or an external subnet mapping */ +#[derive( + Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, Eq, PartialEq, +)] +pub struct InstanceTarget { + pub internal_ip: Ipv6Addr, + pub inner_mac: MacAddr, + pub vni: Vni, +} + +const _: () = assert!(Vni::MAX_VNI == v1::network::Vni::MAX_VNI); + +impl From for Vni { + fn from(vni: v1::network::Vni) -> Self { + Self(vni.as_u32()) + } +} + +impl From for v1::network::Vni { + fn from(vni: Vni) -> Self { + Self::new(vni.as_u32()).expect("VNI ranges match across versions") + } +} + +impl From for NatTarget { + fn from(target: v1::network::NatTarget) -> Self { + Self { + internal_ip: target.internal_ip, + inner_mac: target.inner_mac, + vni: target.vni.into(), + } + } +} + +impl From for v1::network::NatTarget { + fn from(target: NatTarget) -> Self { + Self { + internal_ip: target.internal_ip, + inner_mac: target.inner_mac, + vni: target.vni.into(), + } + } +} + +impl From for InstanceTarget { + fn from(target: v3::network::InstanceTarget) -> Self { + Self { + internal_ip: target.internal_ip, + inner_mac: target.inner_mac, + vni: target.vni.into(), + } + } +} + +impl From for v3::network::InstanceTarget { + fn from(target: InstanceTarget) -> Self { + Self { + internal_ip: target.internal_ip, + inner_mac: target.inner_mac, + vni: target.vni.into(), + } + } +} diff --git a/dpd-types/versions/src/mcast_strict_underlay/mcast.rs b/dpd-types/versions/src/mcast_strict_underlay/mcast.rs index 0a1e246b..a4b4d4cf 100644 --- a/dpd-types/versions/src/mcast_strict_underlay/mcast.rs +++ b/dpd-types/versions/src/mcast_strict_underlay/mcast.rs @@ -9,9 +9,11 @@ use std::net::{IpAddr, Ipv6Addr}; +use oxnet::Ipv6Net; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::impls::mcast::IPV6_ADMIN_SCOPED_MULTICAST_PREFIX; use crate::v1; use crate::v1::mcast::{ ExternalForwarding, InternalForwarding, MulticastGroupId, @@ -20,6 +22,14 @@ use crate::v1::mcast::{ use crate::v7; use crate::v7::mcast::IpSrc; +/// Underlay multicast subnet (`ff04::/64`). +/// +/// See [`UnderlayMulticastIpv6`] for its role. +pub const UNDERLAY_MULTICAST_SUBNET: Ipv6Net = Ipv6Net::new_unchecked( + Ipv6Addr::new(IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, 0, 0, 0, 0, 0, 0, 0), + 64, +); + /// A validated underlay multicast IPv6 address. /// /// Underlay multicast addresses must be within the subnet allocated by Omicron @@ -42,7 +52,7 @@ use crate::v7::mcast::IpSrc; pub struct UnderlayMulticastIpv6(pub(crate) Ipv6Addr); impl TryFrom for UnderlayMulticastIpv6 { - type Error = Error; + type Error = UnderlayMulticastError; fn try_from(addr: Ipv6Addr) -> Result { Self::new(addr) @@ -73,14 +83,25 @@ impl TryFrom for UnderlayMulticastIpv6 { } } -#[derive(Clone, Debug, thiserror::Error)] -pub enum Error { +/// Errors raised while validating an underlay multicast address. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum UnderlayMulticastError { + /// The address is outside the underlay multicast subnet (`ff04::/64`). #[error( "Address {0} is not in underlay multicast subnet (must be ff04::/64)" )] InvalidUnderlayMulticastIp(Ipv6Addr), +} + +/// Error returned when parsing an [`UnderlayMulticastIpv6`] from a string. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum UnderlayMulticastParseError { + /// The string could not be parsed as an IPv6 address. #[error("Invalid IPv6 address '{0}': {1}")] - InvalidIpv6Address(String, std::net::AddrParseError), + InvalidIpv6(String, std::net::AddrParseError), + /// The parsed address was outside the underlay multicast subnet. + #[error(transparent)] + Value(#[from] UnderlayMulticastError), } /// A multicast group configuration for POST requests for internal (to the rack) @@ -207,22 +228,63 @@ impl From for v7::mcast::MulticastGroupResponse { } } +/// Maximum length for multicast tags. +pub const MAX_TAG_LENGTH: usize = 80; + /// Tag for identifying and authorizing multicast group operations. /// /// Tag format: 1 to 80 ASCII bytes containing alphanumeric characters, /// hyphens, underscores, colons, or periods. Default format is /// `{uuid}:{group_ip}`. #[derive( - Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema, + Clone, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Deserialize, + Serialize, + JsonSchema, )] +#[serde(try_from = "String", into = "String")] pub struct MulticastTag( #[schemars( length(min = 1, max = 80), regex(pattern = r"^[a-zA-Z0-9_.:-]+$") )] - pub String, + pub(crate) String, ); +/// Error parsing a multicast tag from a string. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum MulticastTagParseError { + #[error("tag cannot be empty")] + Empty, + #[error("tag cannot exceed {MAX_TAG_LENGTH} bytes")] + TooLong, + #[error( + "tag must contain only ASCII alphanumeric characters, hyphens, \ + underscores, colons, or periods" + )] + InvalidCharacter, +} + +impl From for String { + fn from(tag: MulticastTag) -> Self { + tag.0 + } +} + +impl TryFrom for MulticastTag { + type Error = MulticastTagParseError; + + fn try_from(tag: String) -> Result { + tag.parse() + } +} + /// Path parameter for multicast tag-based operations. #[derive(Deserialize, Serialize, JsonSchema)] pub struct MulticastTagPath { @@ -231,7 +293,9 @@ pub struct MulticastTagPath { impl From for MulticastTagPath { fn from(path: v1::misc::TagPath) -> Self { - Self { tag: path.tag.into() } + // Versions before `MCAST_STRICT_UNDERLAY` accept any tag string, and a + // malformed one matches against no group. + Self { tag: MulticastTag(path.tag) } } } diff --git a/dpd-types/versions/src/testing.rs b/dpd-types/versions/src/testing.rs new file mode 100644 index 00000000..571b2bcd --- /dev/null +++ b/dpd-types/versions/src/testing.rs @@ -0,0 +1,195 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/ +// +// Copyright 2026 Oxide Computer Company + +//! Proptest generators for multicast group addresses, importable by +//! downstream crates through the `testing` feature. +//! +//! The generators encode the rules of version [`MCAST_EXTERNAL_SCOPE`]. Each +//! group generator checks its address against that version's external +//! multicast validator and SSM check, failing the test at generation if either +//! rule narrows. +//! +//! [`MCAST_EXTERNAL_SCOPE`]: crate::v14::mcast + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use proptest::prelude::*; + +use crate::latest::mcast::{ + IPV4_SSM_SUBNET, IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, IPV6_SSM_SUBNETS, +}; +use crate::v14::mcast::{ + is_ssm_address, validate_external_multicast_ipv4, + validate_external_multicast_ipv6, +}; + +const IPV4_MULTICAST_FIRST_OCTET: u8 = 224; +const IPV6_MULTICAST_PREFIX: u16 = 0xff00; +/// SSM flags nibble (R=0, P=T=1) for `ff3x` addresses (RFC 7371 §4.1.2). +const IPV6_SSM_FLAGS: u16 = 0x3; +const IPV6_NON_SSM_FLAGS: [u16; 4] = [0x0, 0x1, 0x8, 0x9]; + +/// Usable IPv6 multicast scopes, containing every scope +/// `ExternalMulticastIp::new` accepts (3 through f). +const USABLE_MULTICAST_SCOPES: [u8; 13] = + [0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf]; + +/// Generate a usable IPv6 multicast scope nibble. +pub fn usable_scope_strategy() -> impl Strategy { + proptest::sample::select(&USABLE_MULTICAST_SCOPES[..]) +} + +/// Generate ASM (non-SSM) IPv4 multicast groups. +/// +/// This skips the local network control block (224.0.0.0/24) and 232/8, +/// weighting the arms toward the 224.0/16 boundary. +pub fn ipv4_asm_group_strategy() -> impl Strategy { + let mcast_base = IPV4_MULTICAST_FIRST_OCTET; + let mcast_end = mcast_base + 15; + let ssm_first = IPV4_SSM_SUBNET.addr().octets()[0]; + + prop_oneof![ + // mcast_base.0.1.0 - mcast_base.0.255.255 + (1u8..=u8::MAX, any::()) + .prop_map(move |(c, d)| Ipv4Addr::new(mcast_base, 0, c, d)), + // mcast_base.1.0.0 - mcast_base.255.255.255 + (1u8..=u8::MAX, any::(), any::()) + .prop_map(move |(b, c, d)| Ipv4Addr::new(mcast_base, b, c, d)), + // (mcast_base + 1).x.x.x - (ssm_first - 1).x.x.x + ((mcast_base + 1)..=ssm_first - 1, any::<[u8; 3]>()) + .prop_map(|(a, [b, c, d])| Ipv4Addr::new(a, b, c, d)), + // (ssm_first + 1).x.x.x - mcast_end.x.x.x + ((ssm_first + 1)..=mcast_end, any::<[u8; 3]>()) + .prop_map(|(a, [b, c, d])| Ipv4Addr::new(a, b, c, d)), + ] + .prop_map(|addr| checked_ipv4_group(addr, false)) +} + +/// Generate SSM IPv4 multicast groups (232/8), skipping the first /24, which +/// holds the SSM null address and the addresses reserved for IANA allocation +/// (RFC 4607 §4.3). +pub fn ipv4_ssm_group_strategy() -> impl Strategy { + let ssm_first = IPV4_SSM_SUBNET.addr().octets()[0]; + prop_oneof![ + // 232.1.0.0 - 232.255.255.255 + (1u8..=u8::MAX, any::(), any::()) + .prop_map(move |(b, c, d)| Ipv4Addr::new(ssm_first, b, c, d)), + // 232.0.1.0 - 232.0.255.255 + (1u8..=u8::MAX, any::()) + .prop_map(move |(c, d)| Ipv4Addr::new(ssm_first, 0, c, d)), + ] + .prop_map(|addr| checked_ipv4_group(addr, true)) +} + +/// Generate ASM (non-SSM) IPv6 multicast groups. +/// +/// This covers the non-SSM flags and the SSM flags with a nonzero prefix +/// length. It never produces embedded-RP groups (`ff7x`, `fffx`) or +/// prefix-based `ffbx` groups. +pub fn ipv6_asm_group_strategy() -> impl Strategy { + prop_oneof![ + // `ff::` where flags != `IPV6_SSM_FLAGS` + ( + proptest::sample::select(&IPV6_NON_SSM_FLAGS[..]), + usable_scope_strategy(), + any::<[u16; 7]>(), + ) + .prop_map(|(flags, scope, mut segs)| { + let first = + IPV6_MULTICAST_PREFIX | (flags << 4) | u16::from(scope); + // An admin-local address containing zero bits between `ff04` + // and the /64 boundary would fall in the reserved underlay + // subnet (`ff04::/64`), which `ExternalMulticastIp::new` + // rejects. + // + // Flags 0 with all of its remaining segments being zero + // would be the reserved base address `ff0x::`. In either case, + // the conditional sets the second segment to 1. + let in_underlay = first == IPV6_ADMIN_SCOPED_MULTICAST_PREFIX + && segs[..3] == [0, 0, 0]; + let is_base = flags == 0 && segs == [0; 7]; + if in_underlay || is_base { + segs[0] = 1; + } + checked_ipv6_group( + Ipv6Addr::new( + first, segs[0], segs[1], segs[2], segs[3], segs[4], + segs[5], segs[6], + ), + false, + ) + }), + // Use flags 3 with a nonzero prefix length to stay as ASM. + ( + usable_scope_strategy(), + any::(), + 1u8..=u8::MAX, + any::<[u16; 6]>(), + ) + .prop_map(|(scope, rsvd, plen, segs)| { + let first = IPV6_MULTICAST_PREFIX + | (IPV6_SSM_FLAGS << 4) + | u16::from(scope); + let seg1 = (u16::from(rsvd) << 8) | u16::from(plen); + checked_ipv6_group( + Ipv6Addr::new( + first, seg1, segs[0], segs[1], segs[2], segs[3], + segs[4], segs[5], + ), + false, + ) + }), + ] +} + +/// Generate SSM IPv6 multicast groups (`ff3x::/32`). +/// +/// Addresses stay within the dynamic range: the prefix bits between the /32 +/// boundary and the 32-bit group ID are zero, and the group ID is at least +/// `0x80000000` (RFC 3307 §4.3). This also avoids the null address +/// `ff3x::4000:0` (RFC 4607 §4.3). +pub fn ipv6_ssm_group_strategy() -> impl Strategy { + let ssm_base = IPV6_SSM_SUBNETS[0].addr().segments()[0]; + (usable_scope_strategy(), 0x8000u16..=u16::MAX, any::()).prop_map( + move |(scope, id_hi, id_lo)| { + checked_ipv6_group( + Ipv6Addr::new( + ssm_base | u16::from(scope), + 0, + 0, + 0, + 0, + 0, + id_hi, + id_lo, + ), + true, + ) + }, + ) +} + +fn checked_ipv4_group(addr: Ipv4Addr, ssm: bool) -> Ipv4Addr { + validate_external_multicast_ipv4(addr) + .expect("generated IPv4 group is valid"); + assert_eq!( + is_ssm_address(IpAddr::V4(addr)), + ssm, + "generated IPv4 group {addr} has the wrong SSM classification" + ); + addr +} + +fn checked_ipv6_group(addr: Ipv6Addr, ssm: bool) -> Ipv6Addr { + validate_external_multicast_ipv6(addr) + .expect("generated IPv6 group is valid"); + assert_eq!( + is_ssm_address(IpAddr::V6(addr)), + ssm, + "generated IPv6 group {addr} has the wrong SSM classification" + ); + addr +} diff --git a/dpd/Cargo.toml b/dpd/Cargo.toml index e0dc4f90..2624e3a8 100644 --- a/dpd/Cargo.toml +++ b/dpd/Cargo.toml @@ -38,6 +38,7 @@ console-subscriber = { version = "0.5.0", optional = true } csv.workspace = true futures.workspace = true libc.workspace = true +nonempty.workspace = true openssl.workspace = true oxnet.workspace = true rand = { workspace = true, optional = true } diff --git a/dpd/src/api_server.rs b/dpd/src/api_server.rs index c29c9d45..929f4d21 100644 --- a/dpd/src/api_server.rs +++ b/dpd/src/api_server.rs @@ -32,6 +32,7 @@ use dpd_types::link::{ use dpd_types::loopback::{LoopbackIpv4Path, LoopbackIpv6Path}; use dpd_types::mcast::UnderlayMulticastIpv6; use dpd_types::mcast::{ + ExternalMulticastIp, MulticastExternalGroupIpParam, MulticastGroupCreateExternalEntry, MulticastGroupCreateUnderlayEntry, MulticastGroupExternalResponse, MulticastGroupIpParam, MulticastGroupResponse, MulticastGroupTagQuery, @@ -68,7 +69,7 @@ use dpd_types::switch_port::{Led, LedState, ManagementMode, SwitchPortView}; use dpd_types::table; use dpd_types::table::TableParam; use dpd_types::transceivers::{Datapath, Monitors, PowerState, Transceiver}; -use dpd_types_versions::{v1, v7}; +use dpd_types_versions::{v1, v7, v8}; use dropshot::BuildError; use dropshot::ClientErrorStatusCode; use dropshot::ClientSpecifiesVersionInHeader; @@ -107,7 +108,7 @@ use crate::switch_port::FixedSideDevice; use crate::types::DpdError; use crate::{Switch, arp, loopback, ports, route}; use common::attached_subnet::AttachedSubnetEntry; -use common::nat::{Ipv4Nat, Ipv6Nat}; +use common::nat::IpNat; use common::network::{InstanceTarget, MacAddr, NatTarget}; use common::ports::PortId; use common::ports::QsfpPort; @@ -1406,7 +1407,7 @@ impl DpdApi for DpdApiImpl { rqctx: RequestContext>, path: Path, query: Query>, - ) -> Result>, HttpError> { + ) -> Result>>, HttpError> { let switch: &Switch = rqctx.context(); let params = path.into_inner(); let pag_params = query.into_inner(); @@ -1426,7 +1427,7 @@ impl DpdApi for DpdApiImpl { Ok(HttpResponseOk(ResultsPage::new( entries, &EmptyScanParams {}, - |e: &Ipv6Nat, _| NatToken { port: e.low }, + |e: &IpNat, _| NatToken { port: e.low }, )?)) } @@ -1513,7 +1514,7 @@ impl DpdApi for DpdApiImpl { rqctx: RequestContext>, path: Path, query: Query>, - ) -> Result>, HttpError> { + ) -> Result>>, HttpError> { let switch: &Switch = rqctx.context(); let params = path.into_inner(); let pag_params = query.into_inner(); @@ -1534,7 +1535,7 @@ impl DpdApi for DpdApiImpl { Ok(HttpResponseOk(ResultsPage::new( entries, &EmptyScanParams {}, - |e: &Ipv4Nat, _| NatToken { port: e.low }, + |e: &IpNat, _| NatToken { port: e.low }, )?)) } @@ -1987,7 +1988,7 @@ impl DpdApi for DpdApiImpl { let switch: &Switch = rqctx.context(); let entry = group.into_inner(); - mcast::add_group_internal(switch, entry) + mcast::add_group_underlay(switch, entry) .map(HttpResponseCreated) .map_err(HttpError::from) } @@ -2013,10 +2014,8 @@ impl DpdApi for DpdApiImpl { let switch: &Switch = rqctx.context(); let ip = path.into_inner().group_ip; - let existing_tag = mcast::get_group(switch, ip) - .map_err(HttpError::from)? - .tag() - .to_string(); + let existing_tag = + mcast::group_tag(switch, ip).map_err(HttpError::from)?; mcast::del_group(switch, ip, &existing_tag) .map(|_| HttpResponseDeleted()) @@ -2056,7 +2055,7 @@ impl DpdApi for DpdApiImpl { let admin_scoped = path.into_inner().group_ip; let tag = query.into_inner().tag; - mcast::modify_group_internal( + mcast::modify_group_underlay( switch, admin_scoped, tag.as_ref(), @@ -2087,14 +2086,11 @@ impl DpdApi for DpdApiImpl { let tag = match entry.tag.take() { Some(t) => t, - None => { - mcast::get_group_internal(switch, underlay) - .map_err(HttpError::from)? - .tag - } + None => mcast::group_tag(switch, IpAddr::V6(underlay.into())) + .map_err(HttpError::from)?, }; - mcast::modify_group_internal(switch, underlay, &tag, entry.into()) + mcast::modify_group_underlay(switch, underlay, &tag, entry.into()) .map(|resp| HttpResponseOk(resp.into())) .map_err(HttpError::from) } @@ -2106,23 +2102,23 @@ impl DpdApi for DpdApiImpl { let switch: &Switch = rqctx.context(); let underlay = path.into_inner().group_ip; - mcast::get_group_internal(switch, underlay) + mcast::get_group_underlay(switch, underlay) .map(HttpResponseOk) .map_err(HttpError::from) } async fn multicast_group_update_external( rqctx: RequestContext>, - path: Path, + path: Path, query: Query, group: TypedBody, ) -> Result, HttpError> { let switch: &Switch = rqctx.context(); let entry = group.into_inner(); - let ip = path.into_inner().group_ip; + let group_ip = path.into_inner().group_ip; let tag = query.into_inner().tag; - mcast::modify_group_external(switch, ip, tag.as_ref(), entry) + mcast::modify_group_external(switch, group_ip, tag.as_ref(), entry) .map(HttpResponseOk) .map_err(HttpError::from) } @@ -2141,14 +2137,22 @@ impl DpdApi for DpdApiImpl { let tag = match entry.tag.take() { Some(t) => t, - None => mcast::get_group(switch, ip) - .map_err(HttpError::from)? - .tag() - .to_string(), + None => mcast::group_tag(switch, ip).map_err(HttpError::from)?, }; - mcast::modify_group_external(switch, ip, &tag, entry.into()) - .map(|resp| HttpResponseCreated(resp.into())) + let entry = v8::mcast::MulticastGroupUpdateExternalEntry::from(entry); + let entry = MulticastGroupUpdateExternalEntry::try_from(entry) + .map_err(|e| HttpError::for_bad_request(None, e.to_string()))?; + + let group_ip = ExternalMulticastIp::new(ip) + .map_err(|e| HttpError::for_bad_request(None, e.to_string()))?; + + mcast::modify_group_external(switch, group_ip, &tag, entry) + .map(|resp| { + let v8_resp = + v8::mcast::MulticastGroupExternalResponse::from(resp); + HttpResponseCreated(v8_resp.into()) + }) .map_err(HttpError::from) } @@ -2166,18 +2170,25 @@ impl DpdApi for DpdApiImpl { let tag = match entry.tag.take() { Some(t) => t, - None => mcast::get_group(switch, ip) - .map_err(HttpError::from)? - .tag() - .to_string(), + None => mcast::group_tag(switch, ip).map_err(HttpError::from)?, }; let v7_entry = v7::mcast::MulticastGroupUpdateExternalEntry::from(entry); - mcast::modify_group_external(switch, ip, &tag, v7_entry.into()) + let v8_entry = + v8::mcast::MulticastGroupUpdateExternalEntry::from(v7_entry); + let entry = MulticastGroupUpdateExternalEntry::try_from(v8_entry) + .map_err(|e| HttpError::for_bad_request(None, e.to_string()))?; + + let group_ip = ExternalMulticastIp::new(ip) + .map_err(|e| HttpError::for_bad_request(None, e.to_string()))?; + + mcast::modify_group_external(switch, group_ip, &tag, entry) .map(|resp| { + let v8_resp = + v8::mcast::MulticastGroupExternalResponse::from(resp); let v7_resp = - v7::mcast::MulticastGroupExternalResponse::from(resp); + v7::mcast::MulticastGroupExternalResponse::from(v8_resp); HttpResponseCreated(v7_resp.into()) }) .map_err(HttpError::from) @@ -2207,7 +2218,8 @@ impl DpdApi for DpdApiImpl { } }; - let entries = mcast::get_range(switch, last_addr, limit, None); + let entries = mcast::get_range(switch, last_addr, limit, None) + .map_err(HttpError::from)?; Ok(HttpResponseOk(ResultsPage::new( entries, @@ -2244,7 +2256,8 @@ impl DpdApi for DpdApiImpl { } }; - let entries = mcast::get_range(switch, last_addr, limit, Some(&tag)); + let entries = mcast::get_range(switch, last_addr, limit, Some(&tag)) + .map_err(HttpError::from)?; Ok(HttpResponseOk(ResultsPage::new( entries, &EmptyScanParams {}, @@ -2283,13 +2296,9 @@ impl DpdApi for DpdApiImpl { } async fn multicast_reset_untagged_v1( - rqctx: RequestContext>, + _rqctx: RequestContext>, ) -> Result { - let switch: &Switch = rqctx.context(); - - mcast::reset_untagged(switch) - .map(|_| HttpResponseDeleted()) - .map_err(HttpError::from) + Ok(HttpResponseDeleted()) } #[cfg(feature = "tofino_asic")] diff --git a/dpd/src/link.rs b/dpd/src/link.rs index c2cdd320..758381b6 100644 --- a/dpd/src/link.rs +++ b/dpd/src/link.rs @@ -1641,23 +1641,15 @@ fn set_mac_config( clear_mac_config(switch, asic_id)?; mac::mac_set(switch, asic_id, mac)?; mac::mcast_mac_set(switch, asic_id, mac)?; - mcast::mcast_egress::add_port_mapping_entry(switch, asic_id)?; + mcast::mcast_egress::set_port_mapping_entry(switch, asic_id)?; Ok(()) } fn clear_mac_config(switch: &Switch, asic_id: AsicId) -> DpdResult<()> { - fn already_cleared(res: DpdResult<()>) -> DpdResult<()> { - match res { - Err(DpdError::Switch(aal::AsicError::Missing(_))) => Ok(()), - res => res, - } - } - - already_cleared(mac::mac_clear(switch, asic_id))?; - already_cleared(mac::mcast_mac_clear(switch, asic_id))?; - already_cleared(mcast::mcast_egress::del_port_mapping_entry( - switch, asic_id, - )) + mac::mac_clear(switch, asic_id)?; + mac::mcast_mac_clear(switch, asic_id)?; + mcast::mcast_egress::clear_port_mapping_entry(switch, asic_id)?; + Ok(()) } fn unplumb_link( diff --git a/dpd/src/loopback.rs b/dpd/src/loopback.rs index f0a4665c..409fc32b 100644 --- a/dpd/src/loopback.rs +++ b/dpd/src/loopback.rs @@ -32,7 +32,7 @@ pub fn add_loopback_ipv4(switch: &Switch, addr: &Ipv4Entry) -> DpdResult<()> { } match table::port_ip::loopback_ipv4_add(switch, addr.addr) { Ok(()) => _ = loopback_data.v4_addrs.insert(addr.clone()), - Err(DpdError::Switch(AsicError::Exists)) => { + Err(DpdError::Switch(AsicError::Exists(_))) => { if !loopback_data.v4_addrs.contains(addr) { warn!( switch.log, @@ -82,7 +82,7 @@ pub fn add_loopback_ipv6(switch: &Switch, addr: &Ipv6Entry) -> DpdResult<()> { match table::port_ip::loopback_ipv6_add(switch, addr.addr) { Ok(()) => _ = loopback_data.v6_addrs.insert(addr.clone()), - Err(DpdError::Switch(AsicError::Exists)) => { + Err(DpdError::Switch(AsicError::Exists(_))) => { if !loopback_data.v6_addrs.contains(addr) { warn!( switch.log, diff --git a/dpd/src/main.rs b/dpd/src/main.rs index 720b32dd..4847293c 100644 --- a/dpd/src/main.rs +++ b/dpd/src/main.rs @@ -31,6 +31,7 @@ use signal_hook_tokio::Signals; use slog::debug; use slog::error; use slog::info; +use slog::warn; use tokio::sync::Mutex as TokioMutex; use tokio::time::Duration; use tokio::time::sleep; @@ -351,6 +352,16 @@ impl Switch { table_type: TableType, key: &M, data: &A, + ) -> DpdResult<()> { + self.try_table_entry_add(table_type, key, data) + .inspect_err(|e| self.log_entry_conflict("add", table_type, e)) + } + + fn try_table_entry_add( + &self, + table_type: TableType, + key: &M, + data: &A, ) -> DpdResult<()> { let mut t = self.table_get(table_type)?; t.entry_add(&self.asic_hdl, key, data).map_err(|e| { @@ -368,6 +379,16 @@ impl Switch { table_type: TableType, key: &M, data: &A, + ) -> DpdResult<()> { + self.try_table_entry_update(table_type, key, data) + .inspect_err(|e| self.log_entry_conflict("update", table_type, e)) + } + + fn try_table_entry_update( + &self, + table_type: TableType, + key: &M, + data: &A, ) -> DpdResult<()> { let mut t = self.table_get(table_type)?; t.entry_update(&self.asic_hdl, key, data).map_err(|e| { @@ -379,20 +400,98 @@ impl Switch { }) } + fn log_entry_conflict( + &self, + op: &str, + table_type: TableType, + e: &DpdError, + ) { + if let DpdError::Switch(AsicError::Exists(_) | AsicError::Missing(_)) = + e + { + error!( + self.log, + "failed to {op} table entry"; + "table" => %table_type, + "error" => ?e, + ); + } + } + + /// Executes [`Self::table_entry_add`]. + /// + /// If an add fails due to conflict, this tries + /// [`Self::table_entry_update`] instead. + /// + /// Returns an error if the first failure is fatal or both calls fail. + pub fn table_entry_set( + &self, + table_type: TableType, + key: &M, + data: &A, + ) -> DpdResult<()> { + match self.try_table_entry_add(table_type, key, data) { + Err(DpdError::Switch(aal::AsicError::Exists(_))) => { + self.try_table_entry_update(table_type, key, data) + } + Err(full @ DpdError::TableFull(_)) => { + // If the table is full and this key does not exist, + // a "does not exist" error is just distracting. + self.try_table_entry_update(table_type, key, data) + .map_err(|_| full) + } + other => other, + } + } + /// Delete a single table entry. pub fn table_entry_del( &self, table_type: TableType, key: &M, + ) -> DpdResult<()> { + self.try_table_entry_del(table_type, key) + .inspect_err(|e| self.log_entry_conflict("delete", table_type, e)) + } + + fn try_table_entry_del( + &self, + table_type: TableType, + key: &M, ) -> DpdResult<()> { let mut t = self.table_get(table_type)?; - t.entry_del(&self.asic_hdl, key).map_err(|e| { - debug!( - self.log, - "failed to delete entry from {:?}: {:?}", table_type, e - ); - e - }) + let untracked = t.usage.occupancy == 0; + + t.entry_del(&self.asic_hdl, key) + .inspect(|()| { + if untracked { + warn!( + self.log, + "deleted entry from table with no tracked entries"; + "table" => %table_type, + ); + } + }) + .map_err(|e| { + debug!( + self.log, + "failed to delete entry from {table_type:?}: {e:?}" + ); + e + }) + } + + /// A variant of [`Self::table_entry_del`] that returns a successful + /// result if the deleted resource already does not exist. + pub fn table_entry_clear( + &self, + table_type: TableType, + key: &M, + ) -> DpdResult<()> { + match self.try_table_entry_del(table_type, key) { + Err(DpdError::Switch(aal::AsicError::Missing(_))) => Ok(()), + other => other, + } } /// Fetch all of the entries in a P4 table and return them diff --git a/dpd/src/mcast/mod.rs b/dpd/src/mcast/mod.rs index f67c429e..e1b32e0b 100644 --- a/dpd/src/mcast/mod.rs +++ b/dpd/src/mcast/mod.rs @@ -9,104 +9,101 @@ //! This is the entrypoint for managing multicast groups, including creating, //! modifying, and deleting groups. //! -//! ## Overview +//! An external group has an ingress NAT entry keyed by just its destination +//! address; the group's optional VLAN only tags egress packet copies. On the +//! sidecar, the NAT target fields are the action parameters. Its `internal_ip` +//! identifies the underlay group, which owns the replication membership. //! -//! There are two types of multicast groups: -//! - **External (Overlay) groups**: Entry points for overlay traffic, -//! have NAT targets, VLAN IDs, and no direct members. External groups -//! reference internal groups via NAT targets to perform the -//! actual packet replication and forwarding. +//! Underlay groups exist in an `ff04::/64` internal block, within the +//! admin-local scope `ff04::/16` ([RFC 7346], [RFC 4291]). A copy to an +//! external member is decapsulated, and picks up a VLAN tag if one is set. +//! Underlay copies keep their Geneve encapsulation. //! -//! - **Internal (Underlay) groups**: Handle actual packet replication to -//! members, containing ALL members (either direction - overlay or -//! underlay). +//! Rack-originated traffic arrives already encapsulated to the underlay +//! address. No external NAT. The `mcast_tag` selects the external replication +//! group, the underlay replication group, or both. Packet copies for +//! `Direction::External` members are decapsulated before delivery. //! -//! ### Member Directions +//! Scope is not carried through to encapsulation. External IPv6 group +//! validation admits every scope except reserved `0x0` and the +//! interface-local `0x1` and link-local `0x2` scopes that a router must not +//! forward past (RFC 4291 §2.7). Each group maps to an `ff04::` (scope-4) +//! underlay address. A scope check on the outer header sees admin-local scope +//! for scope-3, scope-5, and scope-8 external groups, while containment for the +//! NAT path comes from the group's member ports. Any ingress VLAN tag is +//! dropped at NAT ingress encapsulation, and the group's optional VLAN solely +//! tags the external egress packet copies. //! -//! Internal groups contain members with two traffic directions: -//! - **External direction**: Members in overlay networks (customer networks), -//! which receive decapsulated packets with (possible) VLAN tags on multicast -//! egress. -//! -//! - **Underlay direction**: Members in underlay networks -//! (rack infrastructure, instances), which receive encapsulated Geneve -//! packets on multicast egress. -//! -//! ### Bifurcated Multicast Design: -//! -//! The multicast implementation uses a bifurcated design that separates -//! external (customer) and (internal) underlay traffic: -//! -//! 1. External-only groups (IPv4 and non-admin-local IPv6): -//! - 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) -//! - Use the internal multicast API (/multicast/underlay-groups) -//! - Can replicate to: -//! a) External group members (customer traffic) -//! b) Underlay-only members (infrastructure traffic) -//! c) Both external and underlay members (bifurcated replication) -//! -//! [RFC 7346]: https://www.rfc-editor.org/rfc/rfc7346.html //! [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html +//! [RFC 7346]: https://www.rfc-editor.org/rfc/rfc7346.html use std::{ + borrow::Borrow, collections::{BTreeMap, BTreeSet, HashSet}, net::{IpAddr, Ipv4Addr, Ipv6Addr}, ops::Bound, sync::{Arc, Mutex, Weak}, }; -use aal::{AsicError, AsicMulticastOps}; -use common::{network::NatTarget, ports::PortId}; -use dpd_types::{ - link::LinkId, - mcast::{ - Direction, ExternalForwarding, InternalForwarding, IpSrc, - MulticastGroupCreateExternalEntry, MulticastGroupCreateUnderlayEntry, - MulticastGroupExternalResponse, MulticastGroupId, MulticastGroupMember, - MulticastGroupResponse, MulticastGroupUnderlayResponse, - MulticastGroupUpdateExternalEntry, MulticastGroupUpdateUnderlayEntry, - UnderlayMulticastIpv6, - }, +use aal::{AsicError, AsicId, AsicMulticastOps}; +use common::network::NatTarget; +use dpd_types::mcast::{ + Direction, ExternalForwarding, ExternalInternalForwarding, + ExternalMulticastIp, ExternalNatTarget, IpSrc, + MulticastGroupCreateExternalEntry, MulticastGroupCreateUnderlayEntry, + MulticastGroupExternalResponse, MulticastGroupId, MulticastGroupMember, + MulticastGroupResponse, MulticastGroupUnderlayResponse, + MulticastGroupUpdateExternalEntry, MulticastGroupUpdateUnderlayEntry, + MulticastTag, SourceFilter, UnderlayMulticastIpv6, }; +use nonempty::NonEmpty; use oxnet::{Ipv4Net, Ipv6Net}; use slog::{debug, error, warn}; use uuid::Uuid; use crate::{ Switch, table, - types::{DpdError, DpdResult}, + types::{DpdError, DpdResult, after_unwind}, }; mod rollback; -mod validate; -use rollback::{GroupCreateRollbackContext, GroupUpdateRollbackContext}; -use validate::{ - validate_multicast_address, validate_nat_target, - validate_not_underlay_subnet, validate_tag, validate_tag_format, +use rollback::{ + DeleteStep, GroupCreateRollbackContext, GroupDeleteRollbackContext, + GroupUpdateRollbackContext, unwind_outcome, }; -/// Empty source list constant for use with `unwrap_or` when normalizing -/// `Option>`. -const NO_SOURCES: &[IpSrc] = &[]; - -/// Convert sources from storage format to slice for table operations. +/// The key identifier for a multicast group, built only from a validated +/// group address. /// -/// Treats `None` as an empty slice, which semantically means "any source" in -/// the context of multicast source filtering. This allows add/remove functions -/// to handle the `None/empty/Any` trifecta uniformly. -fn sources_as_slice(sources: &Option>) -> &[IpSrc] { - sources.as_deref().unwrap_or(NO_SOURCES) +/// The group's "kind" lives in its [`GroupKind`] type (not in the key). +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct GroupKey(IpAddr); + +impl GroupKey { + /// Key an external group by its address. + fn external(ip: ExternalMulticastIp) -> Self { + Self(ip.into()) + } + + /// Key an underlay group by its `ff04::/64` address. + fn underlay(ip: UnderlayMulticastIpv6) -> Self { + Self(IpAddr::V6(ip.into())) + } + + /// Return the keyed address. + fn ip(&self) -> IpAddr { + self.0 + } } +impl Borrow for GroupKey { + fn borrow(&self) -> &IpAddr { + &self.0 + } +} + +/// Inner ID holding a weak handle to the free pool it returns. #[derive(Debug)] struct ScopedIdInner(MulticastGroupId, Weak>>); @@ -123,9 +120,9 @@ impl Drop for ScopedIdInner { } } -/// Wrapper for multicast group IDs during allocation that automatically -/// returns them to the free pool when dropped. This prevents group ID leaks -/// when operations fail during group creation. +/// Reference-counted ID wrapper for a multicast group ID during allocation. +/// Dropping it returns the ID to the free pool, making sure a failed creation +/// does not leak it. #[derive(Clone, Debug)] struct ScopedGroupId(Arc); @@ -142,14 +139,22 @@ impl From for ScopedGroupId { } } -/// Multicast replication configuration (internal only). +/// Multicast replication configuration (underlay groups only). #[derive(Clone, Debug, Default, PartialEq, Eq)] -struct MulticastReplicationInfo { +pub(crate) struct MulticastReplicationInfo { rid: u16, level1_excl_id: u16, level2_excl_id: u16, } +impl MulticastReplicationInfo { + /// Default level exclusion IDs to 0 for underlay groups + /// since they can only be configured internally without API calls. + fn new(rid: MulticastGroupId) -> Self { + Self { rid, level1_excl_id: 0, level2_excl_id: 0 } + } +} + /// Represents a multicast group configuration. /// /// This structure is used to manage multicast groups, including their @@ -161,14 +166,136 @@ pub(crate) struct MulticastGroup { /// Tag for validating update/delete requests. Always present and generated /// as `{uuid}:{group_ip}` if not provided at creation time. pub(crate) tag: String, - pub(crate) int_fwding: InternalForwarding, - pub(crate) ext_fwding: ExternalForwarding, - pub(crate) sources: Option>, - replication_info: Option, - pub(crate) members: Vec, + kind: GroupKind, +} + +/// The kind of a [`MulticastGroup`]: external or underlay. +/// +/// An external group carries ingress state: where to NAT, the egress VLAN, +/// and the source filter. +/// +/// An underlay group carries replication state in [`Replication`]. +#[derive(Clone, Debug)] +enum GroupKind { + /// A customer-visible overlay multicast group. + /// + /// The ingress entry point that NATs inbound traffic to an underlay group. + External { + group_ip: ExternalMulticastIp, + nat_target: ExternalNatTarget, + vlan_id: Option, + sources: SourceFilter, + }, + /// An underlay group in the internal `ff04::/64` block that drives + /// replication to its members (subscribers). + /// + /// Packet copies to [`Direction::External`] members are decapsulated. + /// Packet copies to [`Direction::Underlay`] members retain Geneve + /// encapsulation. The packet's `mcast_tag` selects external replication, + /// underlay replication, or both (bifurcated replication). + Underlay { group_ip: UnderlayMulticastIpv6, replication: Replication }, +} + +/// The replication state of an underlay group. +/// +/// Member subscribers and replication info exist when `Active`, which +/// means the group owns both a decap bitmap entry and a replication entry. +#[derive(Clone, Debug)] +enum Replication { + /// No members. + /// + /// Groups are created memberless and filled in eventually on joins. + Empty, + /// Replicating to a non-empty member set. + Active { + info: MulticastReplicationInfo, + members: NonEmpty, + }, +} + +impl Replication { + fn new( + info: MulticastReplicationInfo, + members: impl IntoIterator, + ) -> Self { + // We store members in canonical (port, link, direction) order. Reading + // back from the ASIC rebuilds membership with external members first. + // Without sorting, the response order would depend on the backend and + // on whether a read re-synced or not. + let mut members = members.into_iter().collect::>(); + members.sort_unstable(); + members.dedup(); + match NonEmpty::from_vec(members) { + Some(members) => Self::Active { info, members }, + None => Self::Empty, + } + } + + fn info(&self) -> Option<&MulticastReplicationInfo> { + match self { + Self::Empty => None, + Self::Active { info, .. } => Some(info), + } + } + + fn members(&self) -> impl Iterator { + match self { + Self::Empty => None, + Self::Active { members, .. } => Some(members.iter()), + } + .into_iter() + .flatten() + } + + fn add_members( + &mut self, + default_info: MulticastReplicationInfo, + added: &[MulticastGroupMember], + ) { + let info = self.info().cloned().unwrap_or(default_info); + let members = self.members().chain(added).cloned().collect::>(); + *self = Self::new(info, members); + } } impl MulticastGroup { + /// Return the group's address as read from its [`GroupKind`]. + fn ip(&self) -> IpAddr { + match &self.kind { + GroupKind::External { group_ip, .. } => (*group_ip).into(), + GroupKind::Underlay { group_ip, .. } => (*group_ip).into(), + } + } + + fn nat_target(&self) -> Option { + match &self.kind { + GroupKind::External { nat_target, .. } => Some(*nat_target), + GroupKind::Underlay { .. } => None, + } + } + + fn vlan_id(&self) -> Option { + match &self.kind { + GroupKind::External { vlan_id, .. } => *vlan_id, + GroupKind::Underlay { .. } => None, + } + } + + fn replication(&self) -> Option<&Replication> { + match &self.kind { + GroupKind::External { .. } => None, + GroupKind::Underlay { replication: repl, .. } => Some(repl), + } + } + + fn members(&self) -> impl Iterator { + self.replication().into_iter().flat_map(Replication::members) + } + + fn replication_info(&self) -> Option<&MulticastReplicationInfo> { + self.replication().and_then(Replication::info) + } + fn external_group_id(&self) -> MulticastGroupId { self.external_scoped_group.id() } @@ -177,56 +304,111 @@ impl MulticastGroup { self.underlay_scoped_group.id() } - fn external_scoped_group_id(&self) -> &ScopedGroupId { - &self.external_scoped_group + /// ASIC group that carries members of `direction`. + fn group_id(&self, direction: Direction) -> MulticastGroupId { + match direction { + Direction::External => self.external_group_id(), + Direction::Underlay => self.underlay_group_id(), + } } - fn underlay_scoped_group_id(&self) -> &ScopedGroupId { - &self.underlay_scoped_group + /// Return the external port bitmap covering both the group's current and + /// requested external members, or `None` if the request adds no external + /// ports. + fn widened_external_bitmap( + &self, + new_members: &[MulticastGroupMember], + ) -> Option { + let current = create_port_bitmap(self.members(), Direction::External); + let widened = create_port_bitmap( + self.members().chain(new_members), + Direction::External, + ); + (widened != current).then_some(widened) } - fn to_external_response( - &self, - group_ip: IpAddr, - ) -> MulticastGroupExternalResponse { - MulticastGroupExternalResponse { - group_ip, - external_group_id: self.external_group_id(), - tag: self.tag.clone(), - internal_forwarding: self.int_fwding.clone(), - external_forwarding: self.ext_fwding.clone(), - sources: self.sources.clone(), + /// Validates that the request tag matches the existing group's tag. + /// + /// Tags are immutable after group creation. A matching tag is evidence the + /// caller created the group. + fn validate_tag(&self, request_tag: &str) -> DpdResult<()> { + if request_tag != self.tag { + return Err(DpdError::McastTagMismatch( + "provided tag does not match the group's tag".to_string(), + )); } + Ok(()) } +} - fn to_underlay_response( - &self, - group_ip: UnderlayMulticastIpv6, - ) -> MulticastGroupUnderlayResponse { - MulticastGroupUnderlayResponse { - group_ip, - external_group_id: self.external_group_id(), - underlay_group_id: self.underlay_group_id(), - tag: self.tag.clone(), - members: self.members.clone(), +impl TryFrom<&MulticastGroup> for MulticastGroupExternalResponse { + type Error = DpdError; + + /// Build the external API response for an external group. + /// + /// # Errors + /// + /// Returns [`DpdError::Invalid`] if `group` is an underlay group. + fn try_from(group: &MulticastGroup) -> DpdResult { + match MulticastGroupResponse::from(group) { + MulticastGroupResponse::External(response) => Ok(response), + MulticastGroupResponse::Underlay(response) => { + Err(DpdError::Invalid(format!( + "multicast group {} is not an external group", + response.group_ip + ))) + } } } +} - fn to_response(&self, group_ip: IpAddr) -> MulticastGroupResponse { - match group_ip { - IpAddr::V4(_) => MulticastGroupResponse::External( - self.to_external_response(group_ip), - ), - IpAddr::V6(ipv6) => { - // Try to create UnderlayMulticastIpv6 - if successful, it's an underlay group - match UnderlayMulticastIpv6::new(ipv6) { - Ok(admin_scoped) => MulticastGroupResponse::Underlay( - self.to_underlay_response(admin_scoped), - ), - Err(_) => MulticastGroupResponse::External( - self.to_external_response(group_ip), - ), - } +impl TryFrom<&MulticastGroup> for MulticastGroupUnderlayResponse { + type Error = DpdError; + + /// Build the underlay response for an underlay group. + /// + /// # Errors + /// + /// Returns [`DpdError::Invalid`] if `group` is an external group. + fn try_from(group: &MulticastGroup) -> DpdResult { + match MulticastGroupResponse::from(group) { + MulticastGroupResponse::Underlay(response) => Ok(response), + MulticastGroupResponse::External(response) => { + Err(DpdError::Invalid(format!( + "multicast group {} is not an underlay group", + response.group_ip + ))) + } + } + } +} + +impl From<&MulticastGroup> for MulticastGroupResponse { + /// Build the response variant matching the group's [`GroupKind`]. + fn from(group: &MulticastGroup) -> Self { + match &group.kind { + GroupKind::External { group_ip, nat_target, vlan_id, sources } => { + Self::External(MulticastGroupExternalResponse { + group_ip: *group_ip, + external_group_id: group.external_group_id(), + tag: group.tag.clone(), + internal_forwarding: ExternalInternalForwarding { + nat_target: *nat_target, + }, + external_forwarding: ExternalForwarding { + vlan_id: *vlan_id, + }, + sources: sources.to_ip_sources(), + }) + } + GroupKind::Underlay { group_ip, replication: repl } => { + Self::Underlay(MulticastGroupUnderlayResponse { + group_ip: *group_ip, + external_group_id: group.external_group_id(), + underlay_group_id: group.underlay_group_id(), + tag: group.tag.clone(), + members: repl.members().cloned().collect(), + }) } } } @@ -236,16 +418,19 @@ impl MulticastGroup { #[derive(Debug)] pub struct MulticastGroupData { /// Multicast group configurations keyed by group IP. - groups: BTreeMap, - /// Stack of available group IDs for O(1) allocation. + groups: BTreeMap, + pending_repairs: BTreeSet, + /// The stack of available group IDs for O(1) allocation. /// Pre-populated with all IDs from GENERATOR_START to u16::MAX-1. free_group_ids: Arc>>, - /// 1:1 mapping from admin-local group IP to external group that uses it as NAT - /// target (admin_local_ip -> external_group_ip) - nat_target_refs: BTreeMap, + /// 1:1 mapping from admin-local group IP to the external group that uses it + /// as a NAT target (admin_local_ip -> external_group_ip). + nat_target_refs: BTreeMap, } impl MulticastGroupData { + /// The lowest group ID that could be available in the free pool; lower IDs + /// remain reserved. const GENERATOR_START: u16 = 100; /// Creates a new instance of MulticastGroupData with pre-populated free @@ -259,6 +444,7 @@ impl MulticastGroupData { Self { groups: BTreeMap::new(), + pending_repairs: BTreeSet::new(), free_group_ids, nat_target_refs: BTreeMap::new(), } @@ -286,10 +472,10 @@ impl MulticastGroupData { /// Add 1:1 forwarding reference from admin-local IP to external group's IP. fn add_forwarding_refs( &mut self, - external_group_ip: IpAddr, + external_group_key: GroupKey, admin_scoped_ip: UnderlayMulticastIpv6, ) { - self.nat_target_refs.insert(admin_scoped_ip, external_group_ip); + self.nat_target_refs.insert(admin_scoped_ip, external_group_key); } /// Remove 1:1 forwarding reference. @@ -297,16 +483,57 @@ impl MulticastGroupData { self.nat_target_refs.remove(&admin_scoped_ip); } - /// Get the VLAN ID for an internal multicast group by looking up + /// Get the VLAN ID for an underlay multicast group by looking up /// the referencing external group (1:1 mapping). - fn get_vlan_for_internal_addr( + fn get_vlan_for_underlay_addr( &self, internal_ip: UnderlayMulticastIpv6, ) -> Option { self.nat_target_refs .get(&internal_ip) - .and_then(|external_ip| self.groups.get(external_ip)) - .and_then(|group| group.ext_fwding.vlan_id) + .and_then(|external_key| self.groups.get(external_key)) + .and_then(|group| group.vlan_id()) + } + + fn validate_group_exists(&self, group_key: GroupKey) -> DpdResult<()> { + if self.groups.contains_key(&group_key) { + return Err(DpdError::Exists(format!( + "multicast group for IP {} already exists", + group_key.ip(), + ))); + } + Ok(()) + } + + fn get_nat_target_group( + &self, + group_addr: IpAddr, + nat_target: ExternalNatTarget, + ) -> DpdResult<&MulticastGroup> { + let internal_ip: UnderlayMulticastIpv6 = nat_target.into(); + if let Some(existing_ref) = self.nat_target_refs.get(&internal_ip) + && existing_ref.ip() != group_addr + { + return Err(DpdError::Invalid(format!( + "underlay group {internal_ip} is already referenced by external group {}", + existing_ref.ip(), + ))); + } + + let Some(underlay_group) = self.groups.get(&IpAddr::from(internal_ip)) + else { + return Err(DpdError::MissingNatTarget(format!( + "underlay group {internal_ip} referenced by external group \ + {group_addr} is absent from the switch", + ))); + }; + + let GroupKind::Underlay { .. } = &underlay_group.kind else { + return Err(DpdError::Invalid(format!( + "multicast group for IP {internal_ip} is not an underlay group", + ))); + }; + Ok(underlay_group) } } @@ -316,220 +543,208 @@ impl Default for MulticastGroupData { } } -/// Add an external multicast group to the switch, which creates the group on -/// the ASIC and associates it with a group IP address and updates associated -/// tables for NAT and L3 routing. +/// Add an external multicast group to the switch: bind the address to its +/// NAT-target underlay group's ASIC groups and program the NAT and L3 route +/// entries. /// /// If anything fails, the group is cleaned up and an error is returned. pub(crate) fn add_group_external( s: &Switch, group_info: MulticastGroupCreateExternalEntry, ) -> DpdResult { - let group_ip = group_info.group_ip; + let group_ip = group_info.group_ip(); + let group_key = GroupKey::external(group_ip); + let group_addr: IpAddr = group_ip.into(); + let sources = SourceFilter::from(&group_info); // Acquire the lock to the multicast data structure at the start to ensure // deterministic operation order let mut mcast = s.mcast.lock().unwrap(); - let nat_target = - group_info.internal_forwarding.nat_target.ok_or_else(|| { - DpdError::Invalid( - "external groups must have NAT target".to_string(), - ) - })?; - - validate_external_group_creation(&mcast, group_ip, &group_info)?; - validate_nat_target(nat_target)?; - - // Validate that NAT target points to an existing group and get its IDs - let internal_group_ip = nat_target.internal_ip.into(); - let internal_group = - mcast.groups.get(&internal_group_ip).ok_or_else(|| { - DpdError::Invalid(format!( - "multicast group for IP address {group_ip} must have a NAT target \ - that is also a tracked multicast group", - )) - })?; - - // Set IDs to match the internal group from the NAT target - let scoped_external_id = internal_group.external_scoped_group.clone(); - let scoped_underlay_id = internal_group.underlay_scoped_group.clone(); - - // Create rollback context once for reuse throughout this function - let rollback_ctx = GroupCreateRollbackContext::new_external( - s, - group_ip, - scoped_external_id.id(), - scoped_underlay_id.id(), - nat_target, - group_info.sources.as_deref(), - ); + let nat_target = group_info.internal_forwarding().nat_target; - // Configure external tables and handle VLAN propagation - configure_external_tables(s, &group_info) - .and_then(|_| { - // Perform VLAN propagation if needed - if let Some(vlan_id) = group_info.external_forwarding.vlan_id { - perform_vlan_propagation( - s, - &mcast, - group_ip, - vlan_id, - nat_target.internal_ip.into(), - ) - } else { - Ok(()) - } - }) - .map_err(|e| rollback_ctx.rollback_and_return_error(e))?; + mcast.validate_group_exists(group_key)?; - // NOTE: If perform_vlan_propagation succeeded, it updated the internal group's - // bitmap entry with the VLAN. The remaining operations below are infallible - // so no rollback is needed. - // - // If adding fallible operations here, consider adding VLAN propagation rollback. + repair_nat_target(s, &mut mcast, nat_target)?; + let underlay_group = mcast.get_nat_target_group(group_addr, nat_target)?; - // This validation already passed in validate_nat_target, so it cannot fail here - let admin_local_ip = UnderlayMulticastIpv6::new(nat_target.internal_ip)?; + if let Some(vlan_id) = group_info.external_forwarding().vlan_id { + common::network::validate_vlan(vlan_id)?; + } - let tag = match &group_info.tag { - Some(t) => { - validate_tag_format(t)?; - t.clone() - } - None => { - let generated = generate_default_tag(group_ip); - validate_tag_format(&generated)?; - generated - } + let tag = match group_info.tag() { + Some(t) => String::from(t.parse::()?), + None => generate_default_tag(group_addr), }; + // Set IDs to match the underlay group from the NAT target + let scoped_external_id = underlay_group.external_scoped_group.clone(); + let scoped_underlay_id = underlay_group.underlay_scoped_group.clone(); + let group = MulticastGroup { external_scoped_group: scoped_external_id, underlay_scoped_group: scoped_underlay_id, tag, - int_fwding: group_info.internal_forwarding.clone(), - ext_fwding: group_info.external_forwarding.clone(), - sources: canonicalize_sources(group_info.sources.clone()), - replication_info: None, - // External groups are entry points only - actual members reside in referenced internal groups - members: Vec::new(), + kind: GroupKind::External { + group_ip, + nat_target, + vlan_id: group_info.external_forwarding().vlan_id, + sources: sources.clone(), + }, }; + let response = MulticastGroupExternalResponse::try_from(&group)?; + + let admin_local_ip: UnderlayMulticastIpv6 = nat_target.into(); + + clear_external_entries(s, group_addr)?; + + // Create rollback context once for reuse throughout this function + let rollback_ctx = GroupCreateRollbackContext::new_external( + s, + group_ip, + group.external_group_id(), + group.underlay_group_id(), + &sources, + ); + + let configured = (|| { + configure_external_tables(s, &group_info, &sources) + .map_err(|e| rollback_ctx.rollback(e))?; + do_vlan_propagation( + s, + group_addr, + group_info.external_forwarding().vlan_id, + underlay_group, + ) + .map_err(|e| rollback_ctx.rollback_with_vlan_restore(e, underlay_group)) + })(); + + if let Err(err) = configured { + if matches!(err, DpdError::Unwind { .. }) { + mcast + .pending_repairs + .extend([group_key, GroupKey::underlay(admin_local_ip)]); + mcast.groups.insert(group_key, group); + mcast.add_forwarding_refs(group_key, admin_local_ip); + } + return Err(err); + } - mcast.groups.insert(group_ip, group.clone()); - mcast.add_forwarding_refs(group_ip, admin_local_ip); + // VLAN propagation is the last fallible step. Any fallible work added + // below must also restore the underlay group's previous bitmap VLAN. + mcast.groups.insert(group_key, group); + mcast.add_forwarding_refs(group_key, admin_local_ip); - Ok(group.to_external_response(group_ip)) + Ok(response) } -/// Add an internal multicast group to the switch, which creates the group on +/// Add an underlay multicast group to the switch, which creates the group on /// the ASIC and associates it with a group IP address and updates associated /// tables for multicast replication and L3 routing. /// /// If anything fails, the group is cleaned up and an error is returned. -pub(crate) fn add_group_internal( +pub(crate) fn add_group_underlay( s: &Switch, group_info: MulticastGroupCreateUnderlayEntry, ) -> DpdResult { let group_ip = group_info.group_ip; + let group_key = GroupKey::underlay(group_ip); // Acquire the lock to the multicast data structure at the start to ensure // deterministic operation order let mut mcast = s.mcast.lock().unwrap(); - validate_internal_group_creation(&mcast, group_ip)?; + mcast.validate_group_exists(group_key)?; + + let ipv6 = Ipv6Addr::from(group_ip); + table::mcast::mcast_replication::clear_ipv6_entry(s, ipv6)?; + table::mcast::mcast_route::clear_ipv6_entry(s, ipv6)?; - let (scoped_external_id, scoped_underlay_id) = - allocate_multicast_group_ids(s, &mut mcast, group_ip.into())?; + let tag = match &group_info.tag { + Some(t) => String::from(t.parse::()?), + None => generate_default_tag(group_ip.into()), + }; + // Always allocate both group IDs to avoid allocation interleaving during + // member addition + let scoped_external_id = mcast.generate_group_id()?; + let scoped_underlay_id = mcast.generate_group_id()?; // Create rollback context for cleanup if operations fail - let rollback_ctx = GroupCreateRollbackContext::new_internal( + let rollback_ctx = GroupCreateRollbackContext::new_underlay( s, - group_ip.into(), + group_ip, scoped_external_id.id(), scoped_underlay_id.id(), ); // Get VLAN ID from referencing external groups - let vlan_id = mcast.get_vlan_for_internal_addr(group_ip); + let vlan_id = mcast.get_vlan_for_underlay_addr(group_ip); let external_group_id = scoped_external_id.id(); let underlay_group_id = scoped_underlay_id.id(); - let mut added_members = Vec::new(); // Only configure replication if there are members - let replication_info = if !group_info.members.is_empty() { - let replication_info = configure_replication(external_group_id); - - add_ports_to_groups( - s, - group_ip.into(), - &group_info.members, - external_group_id, - underlay_group_id, - &replication_info, - &mut added_members, - ) - .map_err(|e| rollback_ctx.rollback_and_return_error(e))?; - - configure_internal_tables( - s, - group_ip.into(), - external_group_id, - underlay_group_id, - Some(&replication_info), - &added_members, - vlan_id, - ) - .map_err(|e| rollback_ctx.rollback_and_return_error(e))?; + let repl = (!group_info.members.is_empty()) + .then(|| MulticastReplicationInfo::new(external_group_id)); - Some(replication_info) - } else { - // No members - configure minimal tables for empty group - configure_internal_tables( + // Remove any stale decap bitmap data for external multicast groups + // before (re-)using the recycled external ID. + // + // A failed rollback can leave ASIC entries behind. A restart can also + // repopulate the free pool while the ASIC retains its entries. + // + // `tbl_decap_ports` is keyed only by `egress_rid`, letting a group + // without a bitmap of its own inherit a stale entry. + let configured = (|| { + table::mcast::mcast_egress::clear_bitmap_entry(s, external_group_id)?; + create_asic_group(s, external_group_id, group_ip.into())?; + create_asic_group(s, underlay_group_id, group_ip.into())?; + if let Some(info) = repl.as_ref() { + add_ports_to_groups( + s, + group_ip.into(), + &group_info.members, + external_group_id, + underlay_group_id, + info, + )?; + } + configure_underlay_tables( s, - group_ip.into(), + group_ip, external_group_id, underlay_group_id, - None, - &added_members, + repl.as_ref(), + &group_info.members, vlan_id, ) - .map_err(|e| rollback_ctx.rollback_and_return_error(e))?; - - None - }; - - let tag = match &group_info.tag { - Some(t) => { - validate_tag_format(t)?; - t.clone() - } - None => { - let generated = generate_default_tag(group_ip.into()); - validate_tag_format(&generated)?; - generated - } - }; + })(); - // Generic internal datastructure (vs API interface) let group = MulticastGroup { external_scoped_group: scoped_external_id, underlay_scoped_group: scoped_underlay_id, tag, - int_fwding: InternalForwarding { - nat_target: None, // Internal groups don't have NAT targets - }, - ext_fwding: ExternalForwarding { - vlan_id: None, // Internal groups don't have VLANs + kind: GroupKind::Underlay { + group_ip, + replication: match repl { + Some(info) => Replication::new(info, group_info.members), + None => Replication::Empty, + }, }, - sources: None, // Internal groups don't have sources - replication_info, - members: group_info.members, }; - mcast.groups.insert(group_ip.into(), group.clone()); + if let Err(e) = configured { + let err = rollback_ctx.rollback(e); + if matches!(err, DpdError::Unwind { .. }) { + mcast.pending_repairs.insert(group_key); + mcast.groups.insert(group_key, group); + } + return Err(err); + } - Ok(group.to_underlay_response(group_ip)) + let response = MulticastGroupUnderlayResponse::try_from(&group); + mcast.pending_repairs.remove(&group_key); + mcast.groups.insert(group_key, group); + response } /// Delete a multicast group from the switch, including all associated tables @@ -547,7 +762,7 @@ pub(crate) fn add_group_internal( /// # Errors /// /// Returns an error if: -/// - Attempting to delete an internal group that is still referenced by an +/// - Attempting to delete an underlay group that is still referenced by an /// external group via NAT target /// - The provided tag does not match the group's existing tag pub(crate) fn del_group( @@ -556,69 +771,122 @@ pub(crate) fn del_group( tag: &str, ) -> DpdResult<()> { let mut mcast = s.mcast.lock().unwrap(); + del_group_locked(s, &mut mcast, group_ip, tag) +} + +fn del_group_locked( + s: &Switch, + mcast: &mut MulticastGroupData, + group_ip: IpAddr, + tag: &str, +) -> DpdResult<()> { + let group = mcast.groups.get(&group_ip).cloned().ok_or_else(|| { + DpdError::Missing(format!( + "Multicast group for IP {group_ip} not found" + )) + })?; - // Check if this is an internal group referenced by an external group. - // Internal groups are identified by admin-scoped IPv6 addresses (ff04::/16). - 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) + // Check if this is an underlay group referenced by an external group. + if let GroupKind::Underlay { group_ip, .. } = &group.kind + && let Some(external_ip) = mcast.nat_target_refs.get(group_ip) { return Err(DpdError::Invalid(format!( - "cannot delete internal group {group_ip}: still referenced \ - by external group {external_ip} via NAT target" + "cannot delete underlay group {group_ip}: still referenced \ + by external group {} via NAT target", + external_ip.ip(), ))); } // Validate tag before removing the group. - let group_entry = mcast.groups.get(&group_ip).ok_or_else(|| { - DpdError::Missing(format!( - "Multicast group for IP {group_ip} not found", - )) - })?; - validate_tag(&group_entry.tag, tag)?; - - let group = mcast.groups.remove(&group_ip).unwrap(); + group.validate_tag(tag)?; let nat_target_to_remove = - group.int_fwding.nat_target.map(|nat| nat.internal_ip.into()); + group.nat_target().map(UnderlayMulticastIpv6::from); debug!(s.log, "deleting multicast group for IP {group_ip}"); - delete_group_tables(s, group_ip, &group)?; + let rollback_ctx = GroupDeleteRollbackContext::new(s, &group); + let mut deleted = Vec::new(); + + if let Err(e) = delete_group_tables(s, &group, &mut deleted) { + let err = rollback_ctx.rollback(e, &deleted); + return Err(reinsert_after_failed_delete(s, mcast, &group, err)); + } + + if let Some(internal_ip) = nat_target_to_remove + && group.vlan_id().is_some() + && let Some(underlay_group) = + mcast.groups.get(&IpAddr::from(internal_ip)) + && let Err(e) = ensure_bitmap_vlan(s, underlay_group, None) + { + let err = rollback_ctx.rollback_with_vlan_restore( + e, + underlay_group, + &deleted, + ); + return Err(reinsert_after_failed_delete(s, mcast, &group, err)); + } - // Only delete ASIC groups for internal groups (groups without NAT targets). - // External groups share ASIC resources with their referenced internal groups. - if group.int_fwding.nat_target.is_none() { - delete_multicast_groups( + // External groups share ASIC resources with their referenced underlay groups. + if matches!(group.kind, GroupKind::Underlay { .. }) { + deleted.push(DeleteStep::Domains); + if let Err(e) = delete_multicast_groups( s, group_ip, - group.external_scoped_group_id().clone(), - group.underlay_scoped_group_id().clone(), - )?; + group.external_group_id(), + group.underlay_group_id(), + ) { + let err = rollback_ctx.rollback(e, &deleted); + return Err(reinsert_after_failed_delete(s, mcast, &group, err)); + } } - if let Some(IpAddr::V6(ipv6)) = nat_target_to_remove { - mcast.rm_forwarding_refs(UnderlayMulticastIpv6::new(ipv6)?); + if let Some(internal_ip) = nat_target_to_remove { + mcast.rm_forwarding_refs(internal_ip); } + mcast.pending_repairs.remove(&group_ip); + mcast.groups.remove(&group_ip); Ok(()) } -/// Get an internal multicast group configuration by admin-local IPv6 address. -pub(crate) fn get_group_internal( +fn reinsert_after_failed_delete( + s: &Switch, + mcast: &mut MulticastGroupData, + group: &MulticastGroup, + err: DpdError, +) -> DpdError { + match &group.kind { + GroupKind::Underlay { group_ip, .. } => reinsert_after_rollback( + s, + mcast, + GroupKey::underlay(*group_ip), + group.clone(), + &[], + err, + ), + GroupKind::External { group_ip, nat_target, .. } => { + if matches!(err, DpdError::Unwind { .. }) { + mcast.pending_repairs.extend([ + GroupKey::external(*group_ip), + GroupKey::underlay((*nat_target).into()), + ]); + } + err + } + } +} + +/// Get an underlay multicast group configuration by admin-local IPv6 address. +pub(crate) fn get_group_underlay( s: &Switch, admin_local: UnderlayMulticastIpv6, ) -> DpdResult { - let mcast = s.mcast.lock().unwrap(); + let mut mcast = s.mcast.lock().unwrap(); let group_ip = IpAddr::V6(admin_local.into()); - - let group = mcast.groups.get(&group_ip).ok_or_else(|| { - DpdError::Missing(format!( - "internal multicast group for IP {group_ip} not found", - )) - })?; - - Ok(group.to_underlay_response(admin_local)) + MulticastGroupUnderlayResponse::try_from(&observed_group( + s, &mut mcast, group_ip, + )?) } /// Get a multicast group configuration. @@ -626,173 +894,253 @@ pub(crate) fn get_group( s: &Switch, group_ip: IpAddr, ) -> DpdResult { - let mcast = s.mcast.lock().unwrap(); - - let group = mcast.groups.get(&group_ip).ok_or_else(|| { - DpdError::Missing(format!( - "multicast group for IP {group_ip} not found", - )) - })?; + let mut mcast = s.mcast.lock().unwrap(); + Ok((&observed_group(s, &mut mcast, group_ip)?).into()) +} - Ok(group.to_response(group_ip)) +/// Get a multicast group's stored tag without reading or repairing its ASIC +/// state. +pub(crate) fn group_tag(s: &Switch, group_ip: IpAddr) -> DpdResult { + let mcast = s.mcast.lock().unwrap(); + mcast.groups.get(&group_ip).map(|group| group.tag.clone()).ok_or_else( + || { + DpdError::Missing(format!( + "multicast group for IP {group_ip} not found" + )) + }, + ) } -pub(crate) fn modify_group_external( +fn observed_group( s: &Switch, + mcast: &mut MulticastGroupData, group_ip: IpAddr, - tag: &str, - new_group_info: MulticastGroupUpdateExternalEntry, -) -> DpdResult { - let mut mcast = s.mcast.lock().unwrap(); - - // Check existence and validate tag before making any changes - let existing_group = mcast.groups.get(&group_ip).ok_or_else(|| { +) -> DpdResult { + let group = mcast.groups.get(&group_ip).cloned().ok_or_else(|| { DpdError::Missing(format!( - "Multicast group for IP {group_ip} not found" + "multicast group for IP {group_ip} not found" )) })?; - validate_tag(&existing_group.tag, tag)?; + let GroupKind::Underlay { group_ip: underlay_ip, .. } = &group.kind else { + repair_pending_external_group(s, mcast, group_ip)?; + return Ok(group); + }; + let underlay_ip = *underlay_ip; - let nat_target = - new_group_info.internal_forwarding.nat_target.ok_or_else(|| { - DpdError::Invalid( - "external groups must have NAT target".to_string(), - ) + if !mcast.pending_repairs.contains(&group_ip) { + return Ok(resync_members_from_asic(s, &group).unwrap_or(group)); + } + + let vlan_id = mcast.get_vlan_for_underlay_addr(underlay_ip); + let observed = + resync_after_failed_unwind(s, &group, vlan_id).ok_or_else(|| { + DpdError::McastRepairPending(format!( + "multicast group {group_ip} state is uncertain; retry the request" + )) })?; - validate_multicast_address(group_ip, new_group_info.sources.as_deref())?; - validate_nat_target(nat_target)?; - if let Some(vlan_id) = new_group_info.external_forwarding.vlan_id { - common::network::validate_vlan(vlan_id)?; - } + mcast.groups.insert(GroupKey::underlay(underlay_ip), observed.clone()); + mcast.pending_repairs.remove(&group_ip); + Ok(observed) +} - let group_entry = mcast.groups.remove(&group_ip).unwrap(); - let old_nat_target = group_entry.int_fwding.nat_target; +fn repair_pending_external_group( + s: &Switch, + mcast: &mut MulticastGroupData, + group_ip: IpAddr, +) -> DpdResult<()> { + if !mcast.pending_repairs.contains(&group_ip) { + return Ok(()); + } - // Create rollback context for external group update - let group_entry_for_rollback = group_entry.clone(); - let rollback_ctx = GroupUpdateRollbackContext::new( - s, - group_ip, - &group_entry_for_rollback, - new_group_info.external_forwarding.vlan_id, - ); + let Some(group) = mcast.groups.get(&group_ip).cloned() else { + return Ok(()); + }; - // Pre-compute normalized sources for rollback purposes - let normalized_sources = - canonicalize_sources(new_group_info.sources.clone()); + let GroupKind::External { nat_target, vlan_id, sources, .. } = &group.kind + else { + return Ok(()); + }; - // Try to update external tables first - if let Err(e) = - update_external_tables(s, group_ip, &group_entry, &new_group_info) - { - // Restore original group and return error - mcast.groups.insert(group_ip, group_entry); - return Err( - rollback_ctx.rollback_external(e, normalized_sources.as_deref()) + let stored = MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: *nat_target, + }, + external_forwarding: ExternalForwarding { vlan_id: *vlan_id }, + sources: None, + }; + let repaired = (|| { + repair_nat_target(s, mcast, *nat_target)?; + update_external_tables(s, &group, &stored, sources, &[])?; + let underlay = mcast.get_nat_target_group(group_ip, *nat_target)?; + ensure_bitmap_vlan(s, underlay, *vlan_id) + })(); + + if let Err(e) = repaired { + error!( + s.log, + "failed to repair multicast group"; + "group_ip" => %group_ip, + "error" => ?e, ); + return Err(DpdError::McastRepairPending(format!( + "multicast group {group_ip} state is uncertain; retry the request" + ))); } - let mut updated_group = group_entry.clone(); + mcast.pending_repairs.remove(&group_ip); + Ok(()) +} - // Update NAT target references if NAT target changed - if let Some(old_nat) = old_nat_target { - let old_internal_ip = old_nat.internal_ip; - let new_internal_ip = nat_target.internal_ip; - - if old_internal_ip != new_internal_ip { - // Validate both IPs before mutating state to avoid partial updates. - // On error, restore the group entry before returning. - let (old_admin, new_admin) = match ( - UnderlayMulticastIpv6::new(old_internal_ip), - UnderlayMulticastIpv6::new(new_internal_ip), - ) { - (Ok(old), Ok(new)) => (old, new), - (Err(e), _) | (_, Err(e)) => { - mcast.groups.insert(group_ip, group_entry); - return Err(DpdError::Invalid(e.to_string())); - } - }; - mcast.rm_forwarding_refs(old_admin); - mcast.add_forwarding_refs(group_ip, new_admin); - } +fn repair_nat_target( + s: &Switch, + mcast: &mut MulticastGroupData, + nat_target: ExternalNatTarget, +) -> DpdResult<()> { + let key = GroupKey::underlay(nat_target.into()); + if mcast.pending_repairs.contains(&key) { + observed_group(s, mcast, key.ip())?; } + Ok(()) +} - // Tags are immutable (validated above, never changed) - updated_group.int_fwding.nat_target = Some(nat_target); +/// Update an external group's forwarding, VLAN, and sources under its tag. +pub(crate) fn modify_group_external( + s: &Switch, + group_ip: ExternalMulticastIp, + tag: &str, + new_group_info: MulticastGroupUpdateExternalEntry, +) -> DpdResult { + let group_key = GroupKey::external(group_ip); + let group_addr: IpAddr = group_ip.into(); + let mut mcast = s.mcast.lock().unwrap(); - let old_vlan_id = updated_group.ext_fwding.vlan_id; - // VLAN is assigned directly -> `Some(x)` sets VLAN, `None` removes VLAN - updated_group.ext_fwding.vlan_id = - new_group_info.external_forwarding.vlan_id; - updated_group.sources = canonicalize_sources( - new_group_info.sources.clone().or(updated_group.sources), - ); + // Check existence and validate tag before making any changes + let group_entry = + mcast.groups.get(&group_key).cloned().ok_or_else(|| { + DpdError::Missing(format!( + "Multicast group for IP {group_addr} not found" + )) + })?; + group_entry.validate_tag(tag)?; - // Update bitmap tables with new VLAN if VLAN changed - // Also, handles possible membership skew between update internal + external calls. - if old_vlan_id != updated_group.ext_fwding.vlan_id { - let internal_ip = nat_target.internal_ip.into(); - - let bitmap_result = match mcast.groups.get(&internal_ip) { - Some(internal_group) - if internal_group.replication_info.is_some() => - { - // Only update bitmap if internal group has replication - let port_bitmap = create_port_bitmap( - &internal_group.members, - Direction::External, - ); + repair_pending_external_group(s, &mut mcast, group_addr)?; - // During external group update, bitmap entry exists - update it - table::mcast::mcast_egress::update_bitmap_entry( - s, - internal_group.external_group_id(), - &port_bitmap, - updated_group.ext_fwding.vlan_id, - ) - } - Some(_) => Ok(()), // Internal group exists but has no replication - None => Err(DpdError::Invalid(format!( - "internal group not found when updating bitmap: internal_ip={internal_ip}, external_group={group_ip}", - ))), - }; + let GroupKind::External { + sources: old_sources, + nat_target: old_nat_target, + vlan_id: old_vlan_id, + .. + } = &group_entry.kind + else { + return Err(DpdError::Invalid(format!( + "multicast group {group_addr} is not an external group" + ))); + }; - if let Err(e) = bitmap_result { - // Rollback the external table changes and NAT target references - mcast.groups.insert(group_ip, group_entry); - - // Rollback NAT target references if they were changed - if let Some(old_nat) = old_nat_target { - let old_internal_ip = old_nat.internal_ip; - let new_internal_ip = nat_target.internal_ip; - - if old_internal_ip != new_internal_ip { - // Restore original references (reverse of what we did above) - if let (Ok(old_admin), Ok(new_admin)) = ( - UnderlayMulticastIpv6::new(old_internal_ip), - UnderlayMulticastIpv6::new(new_internal_ip), - ) { - mcast.rm_forwarding_refs(new_admin); - mcast.add_forwarding_refs(group_ip, old_admin); - } - } + let old_nat_target = *old_nat_target; + let old_vlan_id = *old_vlan_id; + + let new_sources = match new_group_info.sources.as_deref() { + Some(sources) => SourceFilter::from_entries(group_ip, Some(sources)) + .map_err(|e| DpdError::Invalid(e.to_string()))?, + None => old_sources.clone(), + }; + + let nat_target = new_group_info.internal_forwarding.nat_target; + repair_nat_target(s, &mut mcast, nat_target)?; + let new_underlay_group = + mcast.get_nat_target_group(group_addr, nat_target)?; + + if let Some(vlan_id) = new_group_info.external_forwarding.vlan_id { + common::network::validate_vlan(vlan_id)?; + } + + let old_underlay_ip: UnderlayMulticastIpv6 = old_nat_target.into(); + let new_underlay_ip: UnderlayMulticastIpv6 = nat_target.into(); + + let target_changed = old_underlay_ip != new_underlay_ip; + let old_underlay_group = + mcast.groups.get(&IpAddr::V6(old_underlay_ip.into())); + // VLAN is assigned directly: `Some(x)` sets the VLAN, `None` removes it. + // Unlike `sources`, `None` does not preserve the current value. + let new_vlan_id = new_group_info.external_forwarding.vlan_id; + + let bitmap_updates = [ + (target_changed || old_vlan_id != new_vlan_id).then(|| { + BitmapVlanUpdate { + group: new_underlay_group, + old_vlan_id: mcast.get_vlan_for_underlay_addr(new_underlay_ip), + new_vlan_id, } + }), + old_underlay_group.filter(|_| target_changed).map(|group| { + BitmapVlanUpdate { group, old_vlan_id, new_vlan_id: None } + }), + ] + .into_iter() + .flatten() + .collect::>(); - error!( - s.log, - "failed to update bitmap table for external group {group_ip}: {e:?}" - ); - return Err(rollback_ctx.rollback_and_restore(e)); + // Create rollback context for external group update + let rollback_ctx = + GroupUpdateRollbackContext::new(s, &group_entry, new_vlan_id); + + if let Err(e) = update_external_tables( + s, + &group_entry, + &new_group_info, + &new_sources, + &bitmap_updates, + ) { + let err = rollback_ctx.rollback_external(e, &new_sources); + if matches!(err, DpdError::Unwind { .. }) { + mcast.pending_repairs.extend([ + group_key, + GroupKey::underlay(old_underlay_ip), + GroupKey::underlay(new_underlay_ip), + ]); } + return Err(err); } - let response = updated_group.to_external_response(group_ip); - mcast.groups.insert(group_ip, updated_group); - Ok(response) + let updated_group = MulticastGroup { + external_scoped_group: new_underlay_group.external_scoped_group.clone(), + underlay_scoped_group: new_underlay_group.underlay_scoped_group.clone(), + kind: GroupKind::External { + group_ip, + nat_target, + vlan_id: new_vlan_id, + sources: new_sources, + }, + ..group_entry + }; + + // Update NAT target references if NAT target changed + if target_changed { + mcast.rm_forwarding_refs(old_underlay_ip); + mcast.add_forwarding_refs(group_key, new_underlay_ip); + } + + let response = MulticastGroupExternalResponse::try_from(&updated_group); + mcast.groups.insert(group_key, updated_group); + response +} + +/// The step at which an underlay membership update failed, which helps decide +/// how the update should be rolled back. +#[derive(Clone, Copy, Debug)] +enum UpdateStep { + /// Adding or removing requested members. + Members, + /// Removing installed members that the request does not include. + UnwantedMembers, + /// Writing the bitmap, replication, or empty-group cleanup entries. + Tables, } -pub(crate) fn modify_group_internal( +/// Update an underlay group's members under its tag. +pub(crate) fn modify_group_underlay( s: &Switch, group_ip: UnderlayMulticastIpv6, tag: &str, @@ -800,190 +1148,347 @@ pub(crate) fn modify_group_internal( ) -> DpdResult { let mut mcast = s.mcast.lock().unwrap(); + let group_key = GroupKey::underlay(group_ip); + let external_group_vlan_id = mcast.get_vlan_for_underlay_addr(group_ip); + // Check existence and validate tag before making any changes - let existing_group = - mcast.groups.get(&group_ip.into()).ok_or_else(|| { - DpdError::Missing(format!( - "Multicast group for IP {group_ip} not found" - )) - })?; - validate_tag(&existing_group.tag, tag)?; + let Some(existing_group) = mcast.groups.get(&group_key) else { + return Err(DpdError::Missing(format!( + "Multicast group for IP {group_ip} not found" + ))); + }; - let mut group_entry = mcast.groups.remove(&group_ip.into()).unwrap(); + existing_group.validate_tag(tag)?; - // Create rollback context for internal group update - let group_entry_for_rollback = group_entry.clone(); - let rollback_ctx = GroupUpdateRollbackContext::new( - s, - group_ip.into(), - &group_entry_for_rollback, - // Internal groups don't have VLANs, so `attempted_vlan_id` is None. - None, - ); + if !matches!(existing_group.kind, GroupKind::Underlay { .. }) { + return Err(DpdError::Invalid(format!( + "multicast group {group_ip} is not an underlay group" + ))); + } - // Internal groups don't update sources, so always `None` - debug_assert!( - group_entry.sources.is_none(), - "Internal groups should not have sources" - ); - let sources = None; + // Repair the route before changing memberships or replication. + if mcast.pending_repairs.contains(&group_key) { + table::mcast::mcast_route::set_ipv6_entry(s, group_ip.into(), None)?; + mc_group_set(s, existing_group.external_group_id())?; + mc_group_set(s, existing_group.underlay_group_id())?; + } - // Configure replication based on member count transitions - let replication_info = match ( - new_group_info.members.is_empty(), - &group_entry.replication_info, - ) { - (true, Some(repl_info)) => { - // Transition from members to empty. - // - // First, remove ports from ASIC groups before cleaning up - // replication table entries, otherwise stale ports cause subsequent - // re-adds to fail with "already contains port". - process_membership_changes( + let original = existing_group.clone(); + let members = new_group_info.members; + let info = original.replication_info().cloned().unwrap_or_else(|| { + MulticastReplicationInfo::new(original.external_group_id()) + }); + + let widened = (!members.is_empty()) + .then(|| original.widened_external_bitmap(&members)) + .flatten(); + let mut added_members = Vec::new(); + let mut removed_members = Vec::new(); + let programmed = (|| { + if let Some(port_bitmap) = &widened { + table::mcast::mcast_egress::set_bitmap_entry( s, - group_ip.into(), - &new_group_info.members, - &group_entry, - repl_info, + original.external_group_id(), + port_bitmap, + external_group_vlan_id, ) - .inspect_err(|_e| { - mcast.groups.insert(group_ip.into(), group_entry.clone()); - }) - .map_err(|e| rollback_ctx.rollback_and_restore(e))?; + .map_err(|e| (UpdateStep::Tables, e))?; + } - cleanup_empty_group_replication(s, group_ip.into(), &group_entry) - .map_err(|e| rollback_ctx.rollback_and_restore(e))?; + // First, remove ports from ASIC groups before cleaning up + // replication table entries, otherwise stale ports cause + // subsequent re-adds to fail with "already contains port". + process_membership_changes( + s, + &members, + &original, + &info, + &mut added_members, + &mut removed_members, + ) + .map_err(|e| (UpdateStep::Members, e))?; + remove_unwanted_group_members(s, &original, &members) + .map_err(|e| (UpdateStep::UnwantedMembers, e))?; - group_entry.replication_info = None; - None - } - (false, None) => { - // Transition from empty to members - configure replication - Some(configure_replication(group_entry.external_group_id())) + if members.is_empty() { + return cleanup_empty_group_replication(s, &original) + .map_err(|e| (UpdateStep::Tables, e)); } - (false, Some(_)) => { - // Already has members and replication - keep existing - group_entry.replication_info.clone() - } - (true, None) => { - // Already empty and no replication - keep none - None + + // Ensure the bitmap exists before the replication entry. The VLAN + // comes from the referencing external group. The bitmap covers + // overlay members only because decapsulation applies only to overlay + // traffic. + table::mcast::mcast_egress::set_bitmap_entry( + s, + original.external_group_id(), + &create_port_bitmap(&members, Direction::External), + external_group_vlan_id, + ) + .map_err(|e| (UpdateStep::Tables, e))?; + + // Ensure the replication entry on every update with members. + // + // An entry lost to a partial delete can then come back. It's inserted + // after the bitmap, where a rollback deletes a newly added bitmap on + // failure, and if that delete also fails, then the leftover is a + // bitmap that nothing can replicate toward (instead of a replication + // entry with no decap entry). + set_replication_entry(s, &original, &info) + .map_err(|e| (UpdateStep::Tables, e)) + })(); + + if let Err((step, e)) = programmed { + let (restore_tables, mark_for_repair) = match step { + UpdateStep::Tables => (true, false), + UpdateStep::UnwantedMembers => (widened.is_some(), true), + UpdateStep::Members => (widened.is_some(), false), + }; + let rollback_ctx = GroupUpdateRollbackContext::new( + s, + &original, + external_group_vlan_id, + ); + let err = if restore_tables { + rollback_ctx.rollback_underlay_and_restore( + e, + &info, + &added_members, + &removed_members, + ) + } else { + after_unwind( + e, + rollback_ctx.rollback_underlay_update( + &added_members, + &removed_members, + &info, + ), + ) + }; + if mark_for_repair { + mcast.pending_repairs.insert(group_key); } + return Err(reinsert_after_rollback( + s, &mut mcast, group_key, original, &members, err, + )); + } + + let group = MulticastGroup { + kind: GroupKind::Underlay { + group_ip, + replication: Replication::new(info, members), + }, + ..original }; + let response = MulticastGroupUnderlayResponse::try_from(&group); + mcast.pending_repairs.remove(&group_key); + mcast.groups.insert(group_key, group); + response +} - // Early return for no-replication case -> just update metadata - // Tags are immutable (validated above, never changed) - if replication_info.is_none() { - group_entry.sources = sources; - group_entry.members = new_group_info.members; +fn reinsert_after_rollback( + s: &Switch, + mcast: &mut MulticastGroupData, + group_key: GroupKey, + mut group_entry: MulticastGroup, + attempted: &[MulticastGroupMember], + err: DpdError, +) -> DpdError { + if !matches!(err, DpdError::Unwind { .. }) { + return err; + } + + let external_id = group_entry.external_group_id(); + let GroupKind::Underlay { group_ip, replication } = &mut group_entry.kind + else { + return err; + }; + let group_ip = *group_ip; + replication + .add_members(MulticastReplicationInfo::new(external_id), attempted); - let response = group_entry.to_underlay_response(group_ip); - mcast.groups.insert(group_ip.into(), group_entry); - return Ok(response); + mcast.pending_repairs.insert(group_key); + let vlan_id = mcast.get_vlan_for_underlay_addr(group_ip); + if let Some(resynced) = resync_after_failed_unwind(s, &group_entry, vlan_id) + { + mcast.groups.insert(group_key, resynced); + mcast.pending_repairs.remove(&group_key); + return err; } - // Continue with replication processing - let repl_info = replication_info.as_ref().unwrap(); - let (added_members, removed_members) = process_membership_changes( - s, - group_ip.into(), - &new_group_info.members, - &group_entry, - repl_info, - ) - .inspect_err(|_e| { - // Restore group to mcast data structure - mcast.groups.insert(group_ip.into(), group_entry.clone()); - }) - .map_err(|e| rollback_ctx.rollback_internal(e, &[], &[]))?; + if let Some(external_key) = mcast.nat_target_refs.get(&group_ip).copied() { + warn!( + s.log, + "retaining multicast group after failed resync because an external group references it"; + "group_ip" => %group_ip, + "external_group_ip" => %external_key.ip(), + ); + mcast.groups.insert(group_key, group_entry); + return err; + } - // Perform table updates - match update_group_tables( - s, - group_ip.into(), - &group_entry, - repl_info, - &sources, - &group_entry.sources, - ) { - Ok(()) => {} - Err(DpdError::Switch(AsicError::Missing(ref msg))) => { - // ASIC entry not found -> don't restore soft state, let the - // caller recreate. When omicron gets 404, it will CREATE fresh. + match clear_group_after_failed_resync(s, &group_entry) { + Ok(()) => { warn!( s.log, - "ASIC entry missing during update, removing group from soft state"; + "cleared multicast group after failed resync; the control plane will recreate it"; "group_ip" => %group_ip, - "error" => %msg, ); - return Err(DpdError::Switch(AsicError::Missing(msg.clone()))); + mcast.groups.remove(&group_key); + mcast.pending_repairs.remove(&group_key); } Err(e) => { - // Other error - restore group and rollback - mcast.groups.insert(group_ip.into(), group_entry.clone()); - return Err(rollback_ctx.rollback_internal( - e, - &added_members, - &removed_members, - )); + error!( + s.log, + "failed to clear multicast group after failed resync; retaining the member union"; + "group_ip" => %group_ip, + "error" => ?e, + ); + mcast.groups.insert(group_key, group_entry); } } + err +} - let filter_by_direction = - |members: &[MulticastGroupMember], direction: Direction| { - members - .iter() - .filter(|m| m.direction == direction) - .cloned() - .collect::>() - }; +fn resync_after_failed_unwind( + s: &Switch, + group_entry: &MulticastGroup, + vlan_id: Option, +) -> Option { + let resynced = resync_members_from_asic(s, group_entry)?; + for direction in [Direction::External, Direction::Underlay] { + let group_id = resynced.group_id(direction); + if !s.asic_hdl.mc_group_exists(group_id) + && let Err(e) = mc_group_set(s, group_id) + { + error!( + s.log, + "failed to restore multicast group during recovery"; + "group_ip" => %group_entry.ip(), + "group_id" => group_id, + "error" => ?e, + ); + return None; + } + } - // Update bitmap tables if overlay members changed - let old_external_members = - filter_by_direction(&group_entry.members, Direction::External); - let new_external_members = - filter_by_direction(&new_group_info.members, Direction::External); + match reprogram_underlay_entries(s, &resynced, vlan_id) { + Ok(()) => Some(resynced), + Err(e) => { + error!( + s.log, + "failed to reprogram multicast forwarding entries during recovery"; + "group_ip" => %group_entry.ip(), + "error" => ?e, + ); + None + } + } +} - if old_external_members != new_external_members { - // VLAN mapping maintained via add_forwarding_refs/rm_forwarding_refs - let external_group_vlan_id = mcast.get_vlan_for_internal_addr(group_ip); +fn clear_group_after_failed_resync( + s: &Switch, + group: &MulticastGroup, +) -> DpdResult<()> { + delete_group_tables(s, group, &mut Vec::new())?; + delete_multicast_groups( + s, + group.ip(), + group.external_group_id(), + group.underlay_group_id(), + ) +} - match update_internal_group_bitmap_tables( - s, - group_entry.external_group_id(), - &new_group_info.members, - &group_entry.members, - external_group_vlan_id, - ) { - Ok(()) => {} - Err(DpdError::Switch(AsicError::Missing(ref msg))) => { - // ASIC entry not found -> don't restore soft state - warn!( +fn reprogram_underlay_entries( + s: &Switch, + group: &MulticastGroup, + vlan_id: Option, +) -> DpdResult<()> { + let GroupKind::Underlay { group_ip, replication: repl } = &group.kind + else { + return Ok(()); + }; + + match repl { + Replication::Active { info, .. } => { + set_group_bitmap_entries(s, group, vlan_id)?; + set_replication_entry(s, group, info)?; + } + Replication::Empty => cleanup_empty_group_replication(s, group)?, + } + + table::mcast::mcast_route::set_ipv6_entry(s, (*group_ip).into(), None) +} + +fn resync_members_from_asic( + s: &Switch, + group_entry: &MulticastGroup, +) -> Option { + let GroupKind::Underlay { group_ip, replication: repl } = &group_entry.kind + else { + return None; + }; + let group_ip = *group_ip; + let info = repl.info().cloned().unwrap_or_else(|| { + MulticastReplicationInfo::new(group_entry.external_group_id()) + }); + + let mut members = Vec::new(); + for direction in [Direction::External, Direction::Underlay] { + let group_id = group_entry.group_id(direction); + let asic_ids = match s.asic_hdl.mc_port_list(group_id) { + Ok(asic_ids) => asic_ids, + Err(AsicError::Missing(_)) => Vec::new(), + Err(AsicError::OperationUnsupported) => { + return Some(group_entry.clone()); + } + Err(e) => { + error!( s.log, - "ASIC bitmap entry missing during update, removing group from soft state"; + "failed to read multicast group members"; "group_ip" => %group_ip, - "error" => %msg, + "group_id" => group_id, + "error" => ?e, ); - return Err(DpdError::Switch(AsicError::Missing(msg.clone()))); + return None; } - Err(e) => { - // Other error - restore group and rollback - mcast.groups.insert(group_ip.into(), group_entry.clone()); - return Err(rollback_ctx.rollback_and_restore(e)); + }; + + for asic_id in asic_ids { + // Connector/channel mappings survive link deletion. + match s.asic_id_to_port_link(asic_id) { + Ok((port_id, link_id)) => { + members.push(MulticastGroupMember { + port_id, + link_id, + direction, + }); + } + Err(e) => { + error!( + s.log, + "failed to resolve multicast group member"; + "group_ip" => %group_ip, + "group_id" => group_id, + "asic_id" => asic_id, + "error" => ?e, + ); + return None; + } } } } - // Update group metadata and return success - // Tags are immutable (validated above, never changed) - group_entry.sources = sources; - group_entry.replication_info = replication_info; - group_entry.members = new_group_info.members; - - let response = group_entry.to_underlay_response(group_ip); - mcast.groups.insert(group_ip.into(), group_entry.clone()); - - Ok(response) + Some(MulticastGroup { + external_scoped_group: group_entry.external_scoped_group.clone(), + underlay_scoped_group: group_entry.underlay_scoped_group.clone(), + tag: group_entry.tag.clone(), + kind: GroupKind::Underlay { + group_ip, + replication: Replication::new(info, members), + }, + }) } /// List all multicast groups over a range. @@ -992,44 +1497,54 @@ pub(crate) fn get_range( last: Option, limit: usize, tag: Option<&str>, -) -> Vec { - let mcast = s.mcast.lock().unwrap(); +) -> DpdResult> { + let mut mcast = s.mcast.lock().unwrap(); let lower_bound = match last { None => Bound::Unbounded, Some(last_ip) => Bound::Excluded(last_ip), }; - mcast + let group_ips = mcast .groups .range((lower_bound, Bound::Unbounded)) - .filter(|&(_ip, group)| { + .filter(|&(_key, group)| { // Filter by tag if specified tag.is_none_or(|tag_filter| group.tag == tag_filter) }) - .map(|(ip, group)| group.to_response(*ip)) + .map(|(key, _group)| key.ip()) .take(limit) + .collect::>(); + + group_ips + .into_iter() + .map(|group_ip| match observed_group(s, &mut mcast, group_ip) { + Err(DpdError::McastRepairPending(_)) => { + Ok((&mcast.groups[&group_ip]).into()) + } + res => res.map(|group| (&group).into()), + }) .collect() } /// Reset all multicast groups (and associated routes) for a given tag. pub(crate) fn reset_tag(s: &Switch, tag: &str) -> DpdResult<()> { - let (external_groups, internal_groups) = { - let mcast = s.mcast.lock().unwrap(); - mcast - .groups - .iter() - .filter_map(|(ip, group)| { - (group.tag == tag) - .then_some((*ip, group.int_fwding.nat_target.is_some())) - }) - .partition::, _>(|(_, is_external)| *is_external) - }; + let mut mcast = s.mcast.lock().unwrap(); + let (external_groups, underlay_groups) = mcast + .groups + .iter() + .filter_map(|(key, group)| { + (group.tag == tag).then_some(( + key.ip(), + matches!(group.kind, GroupKind::External { .. }), + )) + }) + .partition::, _>(|(_, is_external)| *is_external); - // Delete external groups first since they reference internal groups + // Delete external groups first since they reference underlay groups // via NAT targets. Pass the tag for validation. - for (group_ip, _) in external_groups.into_iter().chain(internal_groups) { - if let Err(e) = del_group(s, group_ip, tag) { + for (group_ip, _) in external_groups.into_iter().chain(underlay_groups) { + if let Err(e) = del_group_locked(s, &mut mcast, group_ip, tag) { error!( s.log, "failed to delete multicast group for IP {group_ip}: {e:?}" @@ -1041,21 +1556,13 @@ pub(crate) fn reset_tag(s: &Switch, tag: &str) -> DpdResult<()> { Ok(()) } -/// Reset all multicast groups (and associated routes) without a tag. -/// -/// DEPRECATED: All groups have default tags generated at creation time. -/// This function is a no-op since no untagged groups can exist. -/// Retained for API version compatibility (v3 and earlier). -#[allow(unused_variables)] -pub(crate) fn reset_untagged(s: &Switch) -> DpdResult<()> { - // All groups have tags, so there are no untagged groups to delete. - Ok(()) -} - /// Reset all multicast groups (and associated routes). pub(crate) fn reset(s: &Switch) -> DpdResult<()> { let mut mcast = s.mcast.lock().unwrap(); + let keys = mcast.groups.keys().copied().collect::>(); + mcast.pending_repairs.extend(keys); + // Destroy ASIC groups let group_ids = s.asic_hdl.mc_domains(); for group_id in group_ids { @@ -1080,375 +1587,399 @@ pub(crate) fn reset(s: &Switch) -> DpdResult<()> { // Clear data structures mcast.groups.clear(); + mcast.pending_repairs.clear(); mcast.nat_target_refs.clear(); Ok(()) } /// Performs VLAN propagation for external groups. -fn perform_vlan_propagation( +fn do_vlan_propagation( s: &Switch, - mcast: &MulticastGroupData, group_ip: IpAddr, - vlan_id: u16, - internal_ip: IpAddr, + vlan_id: Option, + underlay_group: &MulticastGroup, ) -> DpdResult<()> { + let internal_ip = underlay_group.ip(); debug!( s.log, - "external group with VLAN references internal group, propagating VLAN"; + "external group with VLAN references underlay group, propagating VLAN"; "external_group" => %group_ip, - "vlan" => vlan_id, - "internal_group" => %internal_ip, + "vlan" => ?vlan_id, + "underlay_group" => %internal_ip, ); - let internal_group = mcast.groups.get(&internal_ip).ok_or_else(|| { - DpdError::Missing(format!( - "internal group not found during VLAN propagation: \ - internal_group={internal_ip}, external_group={group_ip}" + ensure_bitmap_vlan(s, underlay_group, vlan_id).map_err(|e| { + DpdError::McastGroupFailure(format!( + "failed to update external bitmap: vlan={vlan_id:?}, underlay_group={internal_ip}, error={e:?}", )) - })?; - - let (external_group_id, members) = ( - internal_group.external_scoped_group_id().clone(), - internal_group.members.clone(), - ); - - // Update bitmap entry with VLAN if internal group has members - if !members.is_empty() { - let port_bitmap = create_port_bitmap(&members, Direction::External); - table::mcast::mcast_egress::update_bitmap_entry( - s, - external_group_id.id(), - &port_bitmap, - Some(vlan_id), - ).map_err(|e| { - DpdError::McastGroupFailure(format!( - "failed to update external bitmap: vlan={vlan_id}, internal_group={internal_ip}, error={e:?}", - )) - })?; - } - Ok(()) + }) } -/// Remove source filters for a multicast group. -fn remove_source_filters( +/// Update the VLAN on an active underlay group's bitmap entry, failing if the +/// entry is missing. +/// +/// Rollback calls this method first when restoring a VLAN, and then re-adds the +/// entry itself if it is missing. +fn update_bitmap_vlan( s: &Switch, - group_ip: IpAddr, - sources: &[IpSrc], + group: &MulticastGroup, + vlan_id: Option, ) -> DpdResult<()> { - match group_ip { - IpAddr::V4(ipv4) => remove_ipv4_source_filters(s, ipv4, sources), - IpAddr::V6(ipv6) => remove_ipv6_source_filters(s, ipv6, sources), + if group.replication_info().is_none() { + return Ok(()); } -} -fn remove_ipv4_source_filters( - s: &Switch, - ipv4: Ipv4Addr, - sources: &[IpSrc], -) -> DpdResult<()> { - // If empty or `Any` was present, only a /0 entry was added - if sources.is_empty() || sources_contain_any(sources) { - return table::mcast::mcast_src_filter::del_ipv4_entry( - s, - Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), - ipv4, - ); - } - - for src in sources { - let prefix = match src { - IpSrc::Exact(IpAddr::V4(addr)) => Ipv4Net::new(*addr, 32).unwrap(), - _ => continue, - }; - table::mcast::mcast_src_filter::del_ipv4_entry(s, prefix, ipv4)?; - } - Ok(()) + let port_bitmap = create_port_bitmap(group.members(), Direction::External); + table::mcast::mcast_egress::update_bitmap_entry( + s, + group.external_group_id(), + &port_bitmap, + vlan_id, + ) } -fn remove_ipv6_source_filters( +/// Set the VLAN on an active underlay group's bitmap entry, re-adding the +/// entry if it is missing. +/// +/// The bitmap comes from the group's external members and `vlan_id`. +/// If it is missing, [`reprogram_underlay_entries`] restores it before the +/// replication entry and route. +fn ensure_bitmap_vlan( s: &Switch, - ipv6: Ipv6Addr, - sources: &[IpSrc], + group: &MulticastGroup, + vlan_id: Option, ) -> DpdResult<()> { - // If empty or `Any` was present, only a ::/0 entry was added - if sources.is_empty() || sources_contain_any(sources) { - return table::mcast::mcast_src_filter::del_ipv6_entry( - s, - Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 0).unwrap(), - ipv6, - ); - } + let Some(Replication::Active { .. }) = group.replication() else { + return Ok(()); + }; - for src in sources { - let prefix = match src { - IpSrc::Exact(IpAddr::V6(addr)) => Ipv6Net::new(*addr, 128).unwrap(), - _ => continue, - }; - table::mcast::mcast_src_filter::del_ipv6_entry(s, prefix, ipv6)?; + match update_bitmap_vlan(s, group, vlan_id) { + Err(DpdError::Switch(AsicError::Missing(detail))) => warn!( + s.log, + "re-adding missing multicast bitmap entry"; + "group_ip" => %group.ip(), + "detail" => detail, + ), + res => return res, } - Ok(()) -} -/// Returns true if the source list contains `IpSrc::Any`. -/// -/// Used to optimize P4 source filter table entries. When `Any` is present, -/// we only add a single `/0` entry instead of individual entries for each -/// specific source, since `/0` allows all sources anyway. -/// -/// This handles the ASM lifecycle where a group may start with specific -/// sources (e.g., `[Exact(1.2.3.4), Exact(5.6.7.8)]`) and later have an -/// "any source" member join (becoming `[Exact(1.2.3.4), Exact(5.6.7.8), Any]`). -/// In the latter case, only the `/0` entry is added to the P4 table. -pub(super) fn sources_contain_any(sources: &[IpSrc]) -> bool { - sources.iter().any(|s| matches!(s, IpSrc::Any)) + reprogram_underlay_entries(s, group, vlan_id) } -/// Generate a default tag for a multicast group if none is provided. -/// -/// Format: `{uuid}:{group_ip}` to match Omicron's tag format. -/// This ensures uniqueness across the group's lifecycle and prevents -/// tag collision when group IPs are reused after deletion. -fn generate_default_tag(group_ip: IpAddr) -> String { - format!("{}:{group_ip}", Uuid::new_v4()) +/// Remove source filters for a multicast group. +fn remove_source_filters( + s: &Switch, + group_ip: IpAddr, + sources: &SourceFilter, +) -> DpdResult<()> { + apply_each(sources.iter(), |source| { + clear_source_filter(s, group_ip, source) + }) + .map_err(|failures| failures.head.1) } -/// Canonicalize sources before storage to eliminate semantic ambiguity. +/// Apply an operation, `op`, to every source, moving past failures. /// -/// Multiple representations map to "allow any source" in P4: -/// - `None` (no sources specified) -/// - `Some([])` (empty source list) -/// - `Some([IpSrc::Any])` or `Some([..., IpSrc::Any, ...])` (explicit Any) +/// # Errors /// -/// This function canonicalizes all "any source" representations to `None`, -/// which is what omicron expects for ASM groups. This eliminates semantic -/// ambiguity and prevents unnecessary P4 table updates when toggling -/// between equivalent representations. -fn canonicalize_sources(sources: Option>) -> Option> { - match sources { - None => None, - Some(srcs) if srcs.is_empty() || sources_contain_any(&srcs) => None, - Some(srcs) => { - let deduped: Vec = - srcs.into_iter().collect::>().into_iter().collect(); - Some(deduped) - } +/// Returns each failing source paired with its error (in iteration order). +fn apply_each( + sources: impl Iterator, + mut op: impl FnMut(IpSrc) -> DpdResult<()>, +) -> Result<(), NonEmpty<(IpSrc, DpdError)>> { + match NonEmpty::collect( + sources + .filter_map(|source| op(source.clone()).err().map(|e| (source, e))), + ) { + Some(failures) => Err(failures), + None => Ok(()), } } -fn add_source_filters( +/// Restore ASIC source-filter entries from `applied` to the `original` +/// source filter. +/// +/// This operation adds every entry in `original`, then removes entries +/// present in the `applied` filter but not in the `original` one. The removals +/// run even if some additions fail. +/// +/// Existing additions and missing removals are ignored. Failures from both +/// steps are returned along with their source. +pub(super) fn restore_source_filters( s: &Switch, group_ip: IpAddr, - sources: &[IpSrc], -) -> DpdResult<()> { - match group_ip { - IpAddr::V4(ipv4) => add_ipv4_source_filters(s, sources, ipv4), - IpAddr::V6(ipv6) => add_ipv6_source_filters(s, sources, ipv6), + applied: &SourceFilter, + original: &SourceFilter, +) -> Result<(), NonEmpty<(IpSrc, DpdError)>> { + let applied: BTreeSet = applied.iter().collect(); + let original: BTreeSet = original.iter().collect(); + + let added = apply_each(original.iter().cloned(), |source| { + set_source_filter(s, group_ip, source) + }); + let removed = + apply_each(applied.difference(&original).cloned(), |source| { + clear_source_filter(s, group_ip, source) + }); + + match (added, removed) { + (Err(mut failures), Err(more)) => { + failures.extend(more); + Err(failures) + } + (Err(failures), Ok(())) | (Ok(()), Err(failures)) => Err(failures), + (Ok(()), Ok(())) => Ok(()), } } -fn add_ipv4_source_filters( - s: &Switch, - sources: &[IpSrc], - dest_ip: Ipv4Addr, -) -> DpdResult<()> { - // If empty or `Any` is present, add the /0 entry to allow any source - if sources.is_empty() || sources_contain_any(sources) { - return table::mcast::mcast_src_filter::add_ipv4_entry( - s, - Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), - dest_ip, - ); +#[derive(Clone, Copy)] +enum SourceFilterKey { + V4 { group: Ipv4Addr, prefix: Ipv4Net }, + V6 { group: Ipv6Addr, prefix: Ipv6Net }, +} + +impl SourceFilterKey { + /// Pair a group address with a source as a source-filter table key. + /// + /// `IpSrc::Any` converts into the family's `/0` prefix (generic); an + /// exact source becomes a host prefix. + /// + /// # Errors + /// + /// Returns [`DpdError::Invalid`] if an exact source's address family + /// differs from `group_ip`'s. + fn new(group_ip: IpAddr, source: IpSrc) -> DpdResult { + match (group_ip, source) { + (IpAddr::V4(group), IpSrc::Any) => Ok(Self::V4 { + group, + prefix: Ipv4Net::new_unchecked(Ipv4Addr::UNSPECIFIED, 0), + }), + (IpAddr::V4(group), IpSrc::Exact(IpAddr::V4(addr))) => { + Ok(Self::V4 { group, prefix: Ipv4Net::host_net(addr) }) + } + (IpAddr::V6(group), IpSrc::Any) => Ok(Self::V6 { + group, + prefix: Ipv6Net::new_unchecked(Ipv6Addr::UNSPECIFIED, 0), + }), + (IpAddr::V6(group), IpSrc::Exact(IpAddr::V6(addr))) => { + Ok(Self::V6 { group, prefix: Ipv6Net::host_net(addr) }) + } + (_, IpSrc::Exact(source)) => Err(DpdError::Invalid(format!( + "source {source} does not match multicast group address \ + family ({group_ip})" + ))), + } } - // Track successfully added entries for rollback on failure - let mut added_prefixes = Vec::new(); + /// Set this key's entry in the source-filter table. + fn set(self, s: &Switch) -> DpdResult<()> { + match self { + Self::V4 { group, prefix } => { + table::mcast::mcast_src_filter::set_ipv4_entry(s, prefix, group) + } + Self::V6 { group, prefix } => { + table::mcast::mcast_src_filter::set_ipv6_entry(s, prefix, group) + } + } + } - for src in sources { - let prefix = match src { - IpSrc::Exact(IpAddr::V4(addr)) => Ipv4Net::new(*addr, 32).unwrap(), - _ => continue, - }; - if let Err(e) = - table::mcast::mcast_src_filter::add_ipv4_entry(s, prefix, dest_ip) - { - // Rollback: remove previously added entries - for added in added_prefixes { - let _ = table::mcast::mcast_src_filter::del_ipv4_entry( - s, added, dest_ip, - ); + /// Clear this key's entry from the source-filter table. + fn clear(self, s: &Switch) -> DpdResult<()> { + match self { + Self::V4 { group, prefix } => { + table::mcast::mcast_src_filter::clear_ipv4_entry( + s, prefix, group, + ) + } + Self::V6 { group, prefix } => { + table::mcast::mcast_src_filter::clear_ipv6_entry( + s, prefix, group, + ) } - return Err(e); } - added_prefixes.push(prefix); } - Ok(()) } -fn add_ipv6_source_filters( +fn set_source_filter( s: &Switch, - sources: &[IpSrc], - dest_ip: Ipv6Addr, + group_ip: IpAddr, + source: IpSrc, ) -> DpdResult<()> { - // If empty or `Any` is present, add the ::/0 entry to allow any source - if sources.is_empty() || sources_contain_any(sources) { - return table::mcast::mcast_src_filter::add_ipv6_entry( - s, - Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 0).unwrap(), - dest_ip, - ); - } + SourceFilterKey::new(group_ip, source).and_then(|key| key.set(s)) +} - // Track successfully added entries for rollback on failure - let mut added_prefixes = Vec::new(); +fn clear_source_filter( + s: &Switch, + group_ip: IpAddr, + source: IpSrc, +) -> DpdResult<()> { + SourceFilterKey::new(group_ip, source).and_then(|key| key.clear(s)) +} - for src in sources { - let prefix = match src { - IpSrc::Exact(IpAddr::V6(addr)) => Ipv6Net::new(*addr, 128).unwrap(), - _ => continue, - }; - if let Err(e) = - table::mcast::mcast_src_filter::add_ipv6_entry(s, prefix, dest_ip) - { - // Rollback: remove previously added entries - for added in added_prefixes { - let _ = table::mcast::mcast_src_filter::del_ipv6_entry( - s, added, dest_ip, - ); +/// Remove ASIC source-filter entries for `group_ip` that are not in +/// `sources`. +fn remove_unwanted_source_filters( + s: &Switch, + group_ip: IpAddr, + sources: &SourceFilter, +) -> DpdResult<()> { + let mut ipv4_sources = BTreeSet::new(); + let mut ipv6_sources = BTreeSet::new(); + for source in sources.iter() { + match SourceFilterKey::new(group_ip, source)? { + SourceFilterKey::V4 { prefix, .. } => { + ipv4_sources.insert(prefix); + } + SourceFilterKey::V6 { prefix, .. } => { + ipv6_sources.insert(prefix); } - return Err(e); } - added_prefixes.push(prefix); } - Ok(()) + + match group_ip { + IpAddr::V4(group) => { + table::mcast::mcast_src_filter::retain_ipv4_sources( + s, + group, + &ipv4_sources, + ) + } + IpAddr::V6(group) => { + table::mcast::mcast_src_filter::retain_ipv6_sources( + s, + group, + &ipv6_sources, + ) + } + } } -fn validate_internal_group_creation( - mcast: &MulticastGroupData, - group_ip: UnderlayMulticastIpv6, -) -> DpdResult<()> { - validate_group_exists(mcast, group_ip.into())?; - Ok(()) +/// Generate a default tag for a multicast group if none is provided. +/// +/// Format: `{uuid}:{group_ip}` to match Omicron's tag format. +/// This ensures uniqueness across the group's lifecycle and prevents +/// tag collision when group IPs are reused after deletion. +fn generate_default_tag(group_ip: IpAddr) -> String { + format!("{}:{group_ip}", Uuid::new_v4()) } -fn validate_external_group_creation( - mcast: &MulticastGroupData, +/// Set every ASIC source-filter entry in `sources`. +/// +/// If one fails, the entries set before it are cleared. +fn set_source_filters( + s: &Switch, group_ip: IpAddr, - group_info: &MulticastGroupCreateExternalEntry, + sources: &SourceFilter, ) -> DpdResult<()> { - validate_group_exists(mcast, group_ip)?; - validate_multicast_address(group_ip, group_info.sources.as_deref())?; - validate_not_underlay_subnet(group_ip)?; - if let Some(vlan_id) = group_info.external_forwarding.vlan_id { - common::network::validate_vlan(vlan_id)?; - } - Ok(()) + let keys = sources + .iter() + .map(|source| SourceFilterKey::new(group_ip, source)) + .collect::>>()?; + + keys.iter().enumerate().try_for_each(|(configured, &key)| { + key.set(s).map_err(|e| { + after_unwind( + e, + unwind_outcome( + keys[..configured].iter().map(|key| key.clear(s)), + ), + ) + }) + }) } -fn validate_group_exists( - mcast: &MulticastGroupData, - group_ip: IpAddr, -) -> DpdResult<()> { - if mcast.groups.contains_key(&group_ip) { - return Err(DpdError::Exists(format!( - "multicast group for IP {group_ip} already exists", - ))); +/// Delete every route, NAT, and source-filter entry identified by `group_ip`, +/// all before an external group is created at that address. +/// +/// When group creation fails, a rollback failure can leave entries around +/// without stored ownership. A leftover NAT entry could keep sending this +/// address's traffic to a stale target, and a leftover source filter could +/// keep admitting source traffic a group should no longer allow. +fn clear_external_entries(s: &Switch, group_ip: IpAddr) -> DpdResult<()> { + match group_ip { + IpAddr::V4(ipv4) => { + table::mcast::mcast_src_filter::del_ipv4_entries(s, ipv4)?; + table::mcast::mcast_nat::clear_ipv4_entry(s, ipv4)?; + table::mcast::mcast_route::clear_ipv4_entry(s, ipv4) + } + IpAddr::V6(ipv6) => { + table::mcast::mcast_src_filter::del_ipv6_entries(s, ipv6)?; + table::mcast::mcast_nat::clear_ipv6_entry(s, ipv6)?; + table::mcast::mcast_route::clear_ipv6_entry(s, ipv6) + } } - Ok(()) } /// Configures external tables for an external multicast group. fn configure_external_tables( s: &Switch, group_info: &MulticastGroupCreateExternalEntry, + sources: &SourceFilter, ) -> DpdResult<()> { - let group_ip = group_info.group_ip; - let nat_target = - group_info.internal_forwarding.nat_target.ok_or_else(|| { - DpdError::Invalid( - "external groups must have NAT target".to_string(), - ) - })?; + let group_ip: IpAddr = group_info.group_ip().into(); + let nat_target: NatTarget = + group_info.internal_forwarding().nat_target.into(); + let vlan_id = group_info.external_forwarding().vlan_id; // Add source filter entries if needed - add_source_filters(s, group_ip, sources_as_slice(&group_info.sources))?; + set_source_filters(s, group_ip, sources)?; // Add NAT entry match group_ip { IpAddr::V4(ipv4) => { - table::mcast::mcast_nat::add_ipv4_entry(s, ipv4, nat_target)?; + table::mcast::mcast_nat::set_ipv4_entry(s, ipv4, nat_target)?; } IpAddr::V6(ipv6) => { - table::mcast::mcast_nat::add_ipv6_entry(s, ipv6, nat_target)?; + table::mcast::mcast_nat::set_ipv6_entry(s, ipv6, nat_target)?; } } // Add routing entry match group_ip { - IpAddr::V4(ipv4) => table::mcast::mcast_route::add_ipv4_entry( - s, - ipv4, - group_info.external_forwarding.vlan_id, - ), - IpAddr::V6(ipv6) => table::mcast::mcast_route::add_ipv6_entry( - s, - ipv6, - group_info.external_forwarding.vlan_id, - ), + IpAddr::V4(ipv4) => { + table::mcast::mcast_route::set_ipv4_entry(s, ipv4, vlan_id) + } + IpAddr::V6(ipv6) => { + table::mcast::mcast_route::set_ipv6_entry(s, ipv6, vlan_id) + } } } -/// Creates multicast group IDs for external and underlay groups. -/// -/// Groups can be created without members initially, and members are added later -/// when instances are added. -fn allocate_multicast_group_ids( - s: &Switch, - mcast: &mut MulticastGroupData, - group_ip: IpAddr, -) -> DpdResult<(ScopedGroupId, ScopedGroupId)> { - debug!(s.log, "creating multicast group IDs for IP {group_ip}"); - - // Always allocate both group IDs to avoid allocation delays during member addition - let external_group_id = mcast.generate_group_id()?; - let underlay_group_id = mcast.generate_group_id()?; - - // Create ASIC groups without holding the lock - create_asic_group(s, external_group_id.id(), group_ip)?; - create_asic_group(s, underlay_group_id.id(), group_ip)?; - - Ok((external_group_id, underlay_group_id)) -} - fn delete_multicast_groups( s: &Switch, group_ip: IpAddr, - external_group_id: ScopedGroupId, - underlay_group_id: ScopedGroupId, + external_id: MulticastGroupId, + underlay_id: MulticastGroupId, ) -> DpdResult<()> { - let external_id = external_group_id.id(); - if let Err(e) = s.asic_hdl.mc_group_destroy(external_id) { + let mut first_err = None; + + if let Err(e) = mc_group_clear(s, external_id) { warn!( s.log, "failed to delete external multicast group"; - "IP" => %group_ip, - "ID" => external_id, + "group_ip" => %group_ip, + "group_id" => external_id, "error" => ?e, ); + first_err.get_or_insert(e); } - let underlay_id = underlay_group_id.id(); - if let Err(e) = s.asic_hdl.mc_group_destroy(underlay_id) { + if let Err(e) = mc_group_clear(s, underlay_id) { warn!( s.log, "failed to delete underlay multicast group"; - "IP" => %group_ip, - "ID" => underlay_id, + "group_ip" => %group_ip, + "group_id" => underlay_id, "error" => ?e, ); + first_err.get_or_insert(e); } - Ok(()) + match first_err { + Some(err) => Err(err), + None => Ok(()), + } } fn create_asic_group( @@ -1456,12 +1987,81 @@ fn create_asic_group( group_id: MulticastGroupId, group_ip: IpAddr, ) -> DpdResult<()> { - s.asic_hdl.mc_group_create(group_id).map_err(|e: AsicError| { - DpdError::McastGroupFailure(format!( - "failed to create multicast group for IP {group_ip} with ID \ - {group_id}: {e:?}" - )) - }) + let log_failure = |e: AsicError| { + error!( + s.log, + "failed to create multicast group"; + "group_ip" => %group_ip, + "group_id" => group_id, + "error" => ?e, + ); + DpdError::from(e) + }; + + match s.asic_hdl.mc_group_create(group_id) { + Err(AsicError::Exists(detail)) => { + warn!( + s.log, + "recreating recycled multicast group"; + "group_ip" => %group_ip, + "group_id" => group_id, + "detail" => detail, + ); + + mc_group_clear(s, group_id)?; + + s.asic_hdl.mc_group_create(group_id).map_err(log_failure) + } + res => res.map_err(log_failure), + } +} + +pub(super) fn mc_group_set( + s: &Switch, + group_id: MulticastGroupId, +) -> DpdResult<()> { + match s.asic_hdl.mc_group_create(group_id) { + Ok(()) | Err(AsicError::Exists(_)) => Ok(()), + Err(e) => Err(e.into()), + } +} + +pub(super) fn mc_group_clear( + s: &Switch, + group_id: MulticastGroupId, +) -> DpdResult<()> { + match s.asic_hdl.mc_group_destroy(group_id) { + Ok(()) | Err(AsicError::Missing(_)) => Ok(()), + Err(e) => Err(e.into()), + } +} + +pub(super) fn mc_port_set( + s: &Switch, + group_id: MulticastGroupId, + asic_id: AsicId, + repl: &MulticastReplicationInfo, +) -> DpdResult<()> { + match s.asic_hdl.mc_port_add( + group_id, + asic_id, + repl.rid, + repl.level1_excl_id, + ) { + Ok(()) | Err(AsicError::Exists(_)) => Ok(()), + Err(e) => Err(e.into()), + } +} + +pub(super) fn mc_port_clear( + s: &Switch, + group_id: MulticastGroupId, + asic_id: AsicId, +) -> DpdResult<()> { + match s.asic_hdl.mc_port_remove(group_id, asic_id) { + Ok(()) | Err(AsicError::Missing(_)) => Ok(()), + Err(e) => Err(e.into()), + } } fn add_ports_to_groups( @@ -1470,8 +2070,7 @@ fn add_ports_to_groups( members: &[MulticastGroupMember], external_group_id: MulticastGroupId, underlay_group_id: MulticastGroupId, - replication_info: &MulticastReplicationInfo, - added_members: &mut Vec<(PortId, LinkId, Direction)>, + repl: &MulticastReplicationInfo, ) -> DpdResult<()> { for member in members { let group_id = match member.direction { @@ -1482,20 +2081,17 @@ fn add_ports_to_groups( let asic_id = s.port_link_to_asic_id(member.port_id, member.link_id)?; s.asic_hdl - .mc_port_add( - group_id, - asic_id, - replication_info.rid, - replication_info.level1_excl_id, - ) + .mc_port_add(group_id, asic_id, repl.rid, repl.level1_excl_id) .map_err(|e| { - DpdError::McastGroupFailure(format!( - "failed to add port {port_id} to group for IP {group_ip}: {e:?}", - port_id = member.port_id - )) + error!( + s.log, + "failed to add port to multicast group"; + "group_ip" => %group_ip, + "port" => %member.port_id, + "error" => ?e, + ); + DpdError::from(e) })?; - - added_members.push((member.port_id, member.link_id, member.direction)); } Ok(()) @@ -1503,252 +2099,293 @@ fn add_ports_to_groups( fn process_membership_changes( s: &Switch, - group_ip: IpAddr, new_members: &[MulticastGroupMember], group_entry: &MulticastGroup, - replication_info: &MulticastReplicationInfo, -) -> DpdResult<(Vec, Vec)> { - // First validate that IPv4 doesn't have underlay members - if group_ip.is_ipv4() - && new_members.iter().any(|m| m.direction == Direction::Underlay) - { - return Err(DpdError::Invalid(format!( - "multicast group for IPv4 {group_ip} cannot have underlay members" - ))); - } - - let prev_members = - group_entry.members.iter().cloned().collect::>(); - let new_members_set = new_members.iter().cloned().collect::>(); + repl: &MulticastReplicationInfo, + added_members: &mut Vec, + removed_members: &mut Vec, +) -> DpdResult<()> { + let group_ip = group_entry.ip(); - let mut added_members = Vec::new(); - let mut removed_members = Vec::new(); + let prev_members = group_entry.members().collect::>(); + let new_members_set = new_members.iter().collect::>(); // Remove members from ASIC - for member in prev_members.difference(&new_members_set) { - let group_id = match member.direction { - Direction::External => group_entry.external_group_id(), - Direction::Underlay => group_entry.underlay_group_id(), - }; + let mut seen = HashSet::new(); + for member in group_entry + .members() + .filter(|m| !new_members_set.contains(m) && seen.insert(*m)) + { + let group_id = group_entry.group_id(member.direction); let asic_id = s.port_link_to_asic_id(member.port_id, member.link_id)?; - s.asic_hdl.mc_port_remove(group_id, asic_id).map_err(|e| { - DpdError::McastGroupFailure(format!( - "failed to remove port {port_id} from group for IP {group_ip}: {e:?}", - port_id = member.port_id - )) - })?; - removed_members.push(member.clone()); - } - // Add new members to ASIC - for member in new_members_set.difference(&prev_members) { - if group_ip.is_ipv4() && member.direction == Direction::Underlay { - continue; + match s.asic_hdl.mc_port_remove(group_id, asic_id) { + Ok(()) => {} + Err(AsicError::Missing(detail)) => debug!( + s.log, + "port already absent from multicast group"; + "group_ip" => %group_ip, + "port" => %member.port_id, + "detail" => detail, + ), + Err(e) => { + error!( + s.log, + "failed to remove port from multicast group"; + "group_ip" => %group_ip, + "port" => %member.port_id, + "error" => ?e, + ); + return Err(e.into()); + } } + } - let group_id = match member.direction { - Direction::External => group_entry.external_group_id(), - Direction::Underlay => group_entry.underlay_group_id(), - }; + // Add all members to the ASIC with those already present being ignored + let mut seen = HashSet::new(); + for member in new_members.iter().filter(|m| seen.insert(*m)) { + let group_id = group_entry.group_id(member.direction); let asic_id = s.port_link_to_asic_id(member.port_id, member.link_id)?; - s.asic_hdl - .mc_port_add( - group_id, - asic_id, - replication_info.rid, - replication_info.level1_excl_id, - ) - .map_err(|e| { - DpdError::McastGroupFailure(format!( - "failed to add port {port_id} to group for IP {group_ip}: {e:?}", - port_id = member.port_id - )) - })?; + match s.asic_hdl.mc_port_add( + group_id, + asic_id, + repl.rid, + repl.level1_excl_id, + ) { + Ok(()) => {} + Err(AsicError::Exists(detail)) => debug!( + s.log, + "port already present in multicast group"; + "group_ip" => %group_ip, + "port" => %member.port_id, + "detail" => detail, + ), + Err(e) => { + error!( + s.log, + "failed to add port to multicast group"; + "group_ip" => %group_ip, + "port" => %member.port_id, + "error" => ?e, + ); + return Err(e.into()); + } + } - added_members.push(member.clone()); + if !prev_members.contains(member) { + added_members.push(member.clone()); + } } - Ok((added_members, removed_members)) -} - -/// Default level exclusion IDs to 0 for internal groups -/// since they can only be configured internally without API calls. -fn configure_replication( - external_group_id: MulticastGroupId, -) -> MulticastReplicationInfo { - MulticastReplicationInfo { - rid: external_group_id, - level1_excl_id: 0, - level2_excl_id: 0, - } + Ok(()) } -#[allow(clippy::too_many_arguments)] -fn configure_internal_tables( +fn remove_unwanted_group_members( s: &Switch, - group_ip: IpAddr, - external_group_id: MulticastGroupId, - underlay_group_id: MulticastGroupId, - replication_info: Option<&MulticastReplicationInfo>, - added_members: &[(PortId, LinkId, Direction)], - vlan_id: Option, // VLAN ID from referencing external group + group_entry: &MulticastGroup, + members: &[MulticastGroupMember], ) -> DpdResult<()> { - match (group_ip, replication_info) { - // Note: There are no internal IPv4 groups, only external IPv4 groups - (IpAddr::V4(_), _) => Err(DpdError::Invalid( - "IPv4 groups cannot be created as internal groups".to_string(), - )), - - (IpAddr::V6(ipv6), Some(replication_info)) => { - table::mcast::mcast_replication::add_ipv6_entry( - s, - ipv6, - underlay_group_id, - external_group_id, - replication_info.rid, - replication_info.level1_excl_id, - replication_info.level2_excl_id, - ) - .and_then(|_| { - table::mcast::mcast_route::add_ipv6_entry( - s, ipv6, - vlan_id, // VLAN from referencing external group - ) - }) - .and_then(|_| { - // Add bitmap entry for overlay members only - // (decapsulation decision only needed for overlay traffic) - let external_port_bitmap = - create_external_port_bitmap(added_members); - table::mcast::mcast_egress::add_bitmap_entry( - s, - external_group_id, - &external_port_bitmap, - vlan_id, // VLAN from referencing external group - ) + let group_ip = group_entry.ip(); + for direction in [Direction::External, Direction::Underlay] { + let group_id = group_entry.group_id(direction); + let wanted = members + .iter() + .filter(|member| member.direction == direction) + .map(|member| { + s.port_link_to_asic_id(member.port_id, member.link_id) }) - } + .collect::>>()?; - (IpAddr::V6(ipv6), None) => { - // For empty groups, just add basic route entry - no replication needed yet - table::mcast::mcast_route::add_ipv6_entry( - s, ipv6, vlan_id, // VLAN from referencing external group - ) + let present = match s.asic_hdl.mc_port_list(group_id) { + Ok(ports) => ports, + Err(AsicError::Missing(detail)) => { + debug!( + s.log, + "multicast group absent while removing unwanted ports"; + "group_ip" => %group_ip, + "group_id" => group_id, + "detail" => detail, + ); + continue; + } + Err(AsicError::OperationUnsupported) => { + debug!( + s.log, + "multicast group readback unsupported, skipping unwanted port removal"; + "group_ip" => %group_ip, + "group_id" => group_id, + ); + continue; + } + Err(e) => return Err(e.into()), + }; + + for asic_id in present.into_iter().filter(|port| !wanted.contains(port)) + { + warn!( + s.log, + "removing unwanted port from multicast group"; + "group_ip" => %group_ip, + "group_id" => group_id, + "asic_id" => asic_id, + ); + + match s.asic_hdl.mc_port_remove(group_id, asic_id) { + Ok(()) | Err(AsicError::Missing(_)) => {} + Err(e) => { + error!( + s.log, + "failed to remove unwanted port from multicast group"; + "group_ip" => %group_ip, + "group_id" => group_id, + "asic_id" => asic_id, + "error" => ?e, + ); + return Err(e.into()); + } + } } } + + Ok(()) } -fn update_group_tables( +#[allow(clippy::too_many_arguments)] +fn configure_underlay_tables( s: &Switch, - group_ip: IpAddr, - group_entry: &MulticastGroup, - replication_info: &MulticastReplicationInfo, - new_sources: &Option>, - old_sources: &Option>, + group_ip: UnderlayMulticastIpv6, + external_group_id: MulticastGroupId, + underlay_group_id: MulticastGroupId, + repl: Option<&MulticastReplicationInfo>, + added_members: &[MulticastGroupMember], + vlan_id: Option, // VLAN ID from referencing external group ) -> DpdResult<()> { - if let Some(existing_replication) = &group_entry.replication_info { - if replication_info.rid != existing_replication.rid - || replication_info.level1_excl_id - != existing_replication.level1_excl_id - || replication_info.level2_excl_id - != existing_replication.level2_excl_id - { - update_replication_tables( - s, - group_ip, - group_entry.external_group_id(), - group_entry.underlay_group_id(), - replication_info, - )?; - } - } else { - // First time setting up replication for this group - use add instead of update - match group_ip { - IpAddr::V4(_) => {} // IPv4 groups don't have replication entries - IpAddr::V6(ipv6) => { - table::mcast::mcast_replication::add_ipv6_entry( - s, - ipv6, - group_entry.underlay_group_id(), - group_entry.external_group_id(), - replication_info.rid, - replication_info.level1_excl_id, - replication_info.level2_excl_id, - )?; - } - } + let ipv6 = Ipv6Addr::from(group_ip); + + if let Some(repl) = repl { + // Add a bitmap entry for overlay members only + // (the decap decision is only needed for overlay traffic) + let external_port_bitmap = + create_port_bitmap(added_members, Direction::External); + table::mcast::mcast_egress::set_bitmap_entry( + s, + external_group_id, + &external_port_bitmap, + vlan_id, // VLAN from referencing external group + )?; + table::mcast::mcast_replication::set_ipv6_entry( + s, + ipv6, + underlay_group_id, + external_group_id, + repl.rid, + repl.level1_excl_id, + repl.level2_excl_id, + )?; } - if new_sources != old_sources { - remove_source_filters(s, group_ip, sources_as_slice(old_sources))?; - add_source_filters(s, group_ip, sources_as_slice(new_sources))?; - } + table::mcast::mcast_route::set_ipv6_entry( + s, ipv6, vlan_id, // VLAN from referencing external group + ) +} + +fn set_replication_entry( + s: &Switch, + group_entry: &MulticastGroup, + repl: &MulticastReplicationInfo, +) -> DpdResult<()> { + let GroupKind::Underlay { group_ip, .. } = &group_entry.kind else { + return Ok(()); + }; - Ok(()) + let ipv6 = Ipv6Addr::from(*group_ip); + + table::mcast::mcast_replication::set_ipv6_entry( + s, + ipv6, + group_entry.underlay_group_id(), + group_entry.external_group_id(), + repl.rid, + repl.level1_excl_id, + repl.level2_excl_id, + ) +} + +/// A pending VLAN change to an underlay group's port bitmap. +/// +/// An external multicast group propagates its VLAN onto the underlay group +/// mapped by its NAT target. Retargeting the NAT target or modifying the +/// VLAN yields an update for the new underlay group, and, when that target +/// is moved, a second update clearing the old one. +struct BitmapVlanUpdate<'a> { + group: &'a MulticastGroup, + old_vlan_id: Option, + new_vlan_id: Option, } fn update_external_tables( s: &Switch, - group_ip: IpAddr, group_entry: &MulticastGroup, new_group_info: &MulticastGroupUpdateExternalEntry, + new_sources: &SourceFilter, + bitmap_updates: &[BitmapVlanUpdate<'_>], ) -> DpdResult<()> { - // Update sources if they changed (normalize both sides for comparison) - let new_sources = canonicalize_sources(new_group_info.sources.clone()); - if new_sources != group_entry.sources { - remove_source_filters( - s, - group_ip, - sources_as_slice(&group_entry.sources), - )?; - add_source_filters(s, group_ip, sources_as_slice(&new_sources))?; - } + let group_ip = group_entry.ip(); - let old_vlan_id = group_entry.ext_fwding.vlan_id; - let new_vlan_id = new_group_info.external_forwarding.vlan_id; + // Sync sources first + apply_each(new_sources.iter(), |source| { + set_source_filter(s, group_ip, source) + }) + .map_err(|failures| failures.head.1)?; + remove_unwanted_source_filters(s, group_ip, new_sources)?; - // Update NAT target - external groups always have NAT targets - let new_nat_target = - new_group_info.internal_forwarding.nat_target.ok_or_else(|| { - DpdError::Invalid( - "external groups must have NAT target".to_string(), - ) - })?; + let new_vlan_id = new_group_info.external_forwarding.vlan_id; - if Some(new_nat_target) != group_entry.int_fwding.nat_target { - update_nat_tables( - s, - group_ip, - Some(new_nat_target), - group_entry.int_fwding.nat_target, - )?; - } + let new_nat_target = new_group_info.internal_forwarding.nat_target; - // Update route table VLAN if it changed // Route tables use simple dst_addr matching but select forward vs forward_vlan action - if old_vlan_id != new_vlan_id { - match group_ip { - IpAddr::V4(ipv4) => table::mcast::mcast_route::update_ipv4_entry( - s, - ipv4, - old_vlan_id, - new_vlan_id, - ), - IpAddr::V6(ipv6) => table::mcast::mcast_route::update_ipv6_entry( - s, - ipv6, - old_vlan_id, - new_vlan_id, - ), - }?; - } + match group_ip { + IpAddr::V4(ipv4) => { + table::mcast::mcast_route::set_ipv4_entry(s, ipv4, new_vlan_id) + } + IpAddr::V6(ipv6) => { + table::mcast::mcast_route::set_ipv6_entry(s, ipv6, new_vlan_id) + } + }?; - Ok(()) + let mut attempted_bitmaps = 0; + + let res = (|| { + for update in bitmap_updates { + attempted_bitmaps += 1; + ensure_bitmap_vlan(s, update.group, update.new_vlan_id)?; + } + + // Update NAT target -> external groups always have NAT targets + update_nat_tables(s, group_ip, new_nat_target.into())?; + Ok(()) + })(); + + match res { + Ok(()) => Ok(()), + Err(e) => { + let rollback_ctx = + GroupUpdateRollbackContext::new(s, group_entry, new_vlan_id); + + Err(rollback_ctx.rollback_with_bitmap_restore( + e, + bitmap_updates[..attempted_bitmaps] + .iter() + .rev() + .map(|update| (update.group, update.old_vlan_id)), + )) + } + } } /// Delete bitmap entries for a group with replication checks. @@ -1756,13 +2393,33 @@ fn delete_group_bitmap_entries( s: &Switch, group: &MulticastGroup, ) -> DpdResult<()> { - // Only delete bitmap entries if the group had replication info - // (which indicates bitmap entries were created) - if group.replication_info.is_none() { - return Ok(()); // No bitmap entries were ever created + // Underlay groups delete entries even when `Replication::Empty` because + // a failed rollback can leave entries behind. + // + // Note: external groups share the underlay group's IDs, but never own + // a bitmap entry. + if group.replication().is_none() { + return Ok(()); // External group } // Delete external bitmap entry only (underlay doesn't use decap bitmap) - table::mcast::mcast_egress::del_bitmap_entry(s, group.external_group_id()) + table::mcast::mcast_egress::clear_bitmap_entry(s, group.external_group_id()) +} + +fn set_group_bitmap_entries( + s: &Switch, + group: &MulticastGroup, + vlan_id: Option, +) -> DpdResult<()> { + if group.replication_info().is_none() { + return Ok(()); + } + let port_bitmap = create_port_bitmap(group.members(), Direction::External); + table::mcast::mcast_egress::set_bitmap_entry( + s, + group.external_group_id(), + &port_bitmap, + vlan_id, + ) } /// Cleanup replication tables when transitioning group to empty membership. @@ -1771,193 +2428,101 @@ fn delete_group_bitmap_entries( /// replication entries. fn cleanup_empty_group_replication( s: &Switch, - group_ip: IpAddr, group_entry: &MulticastGroup, ) -> DpdResult<()> { - debug!(s.log, "cleaning up replication for empty group {group_ip}"); + let group_ip = group_entry.ip(); - // Only proceed if group actually has replication info to clean up - if group_entry.replication_info.is_none() { - return Ok(()); - } + debug!(s.log, "cleaning up replication for empty group {group_ip}"); - // Attempt cleanup operations in sequence + delete_replication_entries(s, group_entry)?; delete_group_bitmap_entries(s, group_entry) - .and_then(|_| delete_replication_entries(s, group_ip, group_entry)) -} - -/// Create a port bitmap for external (overlay) members only. -fn create_external_port_bitmap( - added_members: &[(PortId, LinkId, Direction)], -) -> table::mcast::mcast_egress::PortBitmap { - let external_members: Vec = added_members - .iter() - .filter(|(_, _, direction)| *direction == Direction::External) - .map(|(port_id, link_id, direction)| MulticastGroupMember { - port_id: *port_id, - link_id: *link_id, - direction: *direction, - }) - .collect(); - create_port_bitmap(&external_members, Direction::External) } /// Delete replication table entries. fn delete_replication_entries( s: &Switch, - group_ip: IpAddr, group: &MulticastGroup, ) -> DpdResult<()> { - // Only delete replication entries if the group had replication info - // (which indicates replication entries were created) - if group.replication_info.is_none() { - return Ok(()); // No replication entries were ever created - } - - // Delete replication entry (only IPv6 has replication table) - match group_ip { - IpAddr::V4(_) => Ok(()), // IPv4 doesn't use replication table - IpAddr::V6(ipv6) => { - table::mcast::mcast_replication::del_ipv6_entry(s, ipv6) - } - } + // As with the bitmap, underlay groups delete even when + // `Replication::Empty`. + let GroupKind::Underlay { group_ip, .. } = &group.kind else { + return Ok(()); + }; + table::mcast::mcast_replication::clear_ipv6_entry( + s, + Ipv6Addr::from(*group_ip), + ) } fn delete_group_tables( s: &Switch, - group_ip: IpAddr, group: &MulticastGroup, + deleted: &mut Vec, ) -> DpdResult<()> { - match group_ip { - IpAddr::V4(ipv4) => { - // Source filters and NAT entries only exist for external groups - // (which have NAT targets). - if group.int_fwding.nat_target.is_some() { - remove_ipv4_source_filters( - s, - ipv4, - sources_as_slice(&group.sources), - )?; - table::mcast::mcast_nat::del_ipv4_entry(s, ipv4)?; + let group_ip = group.ip(); + + match &group.kind { + GroupKind::External { sources, .. } => { + deleted.push(DeleteStep::SourceFilters); + let removed = match group_ip { + IpAddr::V4(ipv4) => { + table::mcast::mcast_src_filter::del_ipv4_entries(s, ipv4) + } + IpAddr::V6(ipv6) => { + table::mcast::mcast_src_filter::del_ipv6_entries(s, ipv6) + } + }; + match removed { + Err(DpdError::Switch(AsicError::OperationUnsupported)) => { + remove_source_filters(s, group_ip, sources)? + } + res => res?, } + match group_ip { + IpAddr::V4(ipv4) => { + table::mcast::mcast_nat::clear_ipv4_entry(s, ipv4) + } + IpAddr::V6(ipv6) => { + table::mcast::mcast_nat::clear_ipv6_entry(s, ipv6) + } + }?; + deleted.push(DeleteStep::Nat); + } + GroupKind::Underlay { .. } => { + delete_replication_entries(s, group)?; + deleted.push(DeleteStep::Replication); delete_group_bitmap_entries(s, group)?; + deleted.push(DeleteStep::Bitmap); + } + } - table::mcast::mcast_route::del_ipv4_entry(s, ipv4)?; + match group_ip { + IpAddr::V4(ipv4) => { + table::mcast::mcast_route::clear_ipv4_entry(s, ipv4) } IpAddr::V6(ipv6) => { - delete_replication_entries(s, group_ip, group)?; - - // Source filters only exist for external groups (which have - // NAT targets). Internal groups don't have source filtering. - if group.int_fwding.nat_target.is_some() { - remove_ipv6_source_filters( - s, - ipv6, - sources_as_slice(&group.sources), - )?; - table::mcast::mcast_nat::del_ipv6_entry(s, ipv6)?; - } - - delete_group_bitmap_entries(s, group)?; - - table::mcast::mcast_route::del_ipv6_entry(s, ipv6)?; + table::mcast::mcast_route::clear_ipv6_entry(s, ipv6) } - } + }?; + deleted.push(DeleteStep::Route); Ok(()) } -fn update_replication_tables( - s: &Switch, - group_ip: IpAddr, - external_group_id: MulticastGroupId, - underlay_group_id: MulticastGroupId, - replication_info: &MulticastReplicationInfo, -) -> DpdResult<()> { - match group_ip { - IpAddr::V4(_) => Ok(()), - IpAddr::V6(ipv6) => table::mcast::mcast_replication::update_ipv6_entry( - s, - ipv6, - underlay_group_id, - external_group_id, - replication_info.rid, - replication_info.level1_excl_id, - replication_info.level2_excl_id, - ), - } -} - fn update_nat_tables( s: &Switch, group_ip: IpAddr, - new_nat_target: Option, - old_nat_target: Option, + new_nat_target: NatTarget, ) -> DpdResult<()> { - match (group_ip, new_nat_target, old_nat_target) { - (IpAddr::V4(ipv4), Some(new_nat), Some(_)) => { - // NAT to NAT -> update existing entry - table::mcast::mcast_nat::update_ipv4_entry(s, ipv4, new_nat) - } - (IpAddr::V6(ipv6), Some(new_nat), Some(_)) => { - // NAT to NAT -> update existing entry - table::mcast::mcast_nat::update_ipv6_entry(s, ipv6, new_nat) - } - (IpAddr::V4(ipv4), Some(nat), None) => { - // No NAT to NAT - add new entry - table::mcast::mcast_nat::add_ipv4_entry(s, ipv4, nat) - } - (IpAddr::V6(ipv6), Some(nat), None) => { - // No NAT to NAT - add new entry - table::mcast::mcast_nat::add_ipv6_entry(s, ipv6, nat) - } - (IpAddr::V4(ipv4), None, Some(_)) => { - // NAT to no NAT - delete entry - table::mcast::mcast_nat::del_ipv4_entry(s, ipv4) + match group_ip { + IpAddr::V4(ipv4) => { + table::mcast::mcast_nat::set_ipv4_entry(s, ipv4, new_nat_target) } - (IpAddr::V6(ipv6), None, Some(_)) => { - // NAT to no NAT - delete entry - table::mcast::mcast_nat::del_ipv6_entry(s, ipv6) + IpAddr::V6(ipv6) => { + table::mcast::mcast_nat::set_ipv6_entry(s, ipv6, new_nat_target) } - _ => Ok(()), // No change (None → None) - } -} - -/// Update internal group's bitmap entries when members change. -fn update_internal_group_bitmap_tables( - s: &Switch, - external_group_id: MulticastGroupId, - new_members: &[MulticastGroupMember], - old_members: &[MulticastGroupMember], - external_group_vlan: Option, -) -> DpdResult<()> { - let prev_had_members = !old_members.is_empty(); - let now_has_members = !new_members.is_empty(); - - // Create bitmap for overlay members only (decapsulation decision applies only to overlay traffic) - let external_port_bitmap = - create_port_bitmap(new_members, Direction::External); - - if !prev_had_members && now_has_members { - // First time adding members - use add_bitmap_entry with external group's VLAN - table::mcast::mcast_egress::add_bitmap_entry( - s, - external_group_id, - &external_port_bitmap, - external_group_vlan, // Use external group's VLAN for bitmap entries - )?; - } else if prev_had_members && now_has_members { - // Members changed but still have members - use external group's VLAN - table::mcast::mcast_egress::update_bitmap_entry( - s, - external_group_id, - &external_port_bitmap, - external_group_vlan, // Use external group's VLAN for bitmap entries - )?; } - - Ok(()) } /// Update forwarding tables during rollback. @@ -1969,20 +2534,19 @@ fn update_internal_group_bitmap_tables( /// # Arguments /// /// * `s` - Switch instance for table operations. -/// * `group_ip` - IP address of the multicast group. -/// * `external_group_id` - ID of the external multicast group for bitmap updates. -/// * `members` - Group members used to recreate port bitmap. -/// * `current_vlan_id` - VLAN currently in the table (may be the attempted new VLAN). +/// * `group` - The group's state before the failed update. The IPv6 path +/// rewrites its bitmap entry only if it is a [`Replication::Active`] +/// underlay group. +/// * `current_vlan_id` - VLAN currently in the table (may be the attempted new +/// VLAN). /// * `target_vlan_id` - VLAN to restore to (the original VLAN). fn update_fwding_tables( s: &Switch, - group_ip: IpAddr, - external_group_id: MulticastGroupId, - members: &[MulticastGroupMember], + group: &MulticastGroup, current_vlan_id: Option, target_vlan_id: Option, ) -> DpdResult<()> { - match group_ip { + match group.ip() { IpAddr::V4(ipv4) => table::mcast::mcast_route::update_ipv4_entry( s, ipv4, @@ -1999,28 +2563,14 @@ fn update_fwding_tables( // Update external bitmap for external members (only external bitmap // entries exist, whereas underlay replication uses ASIC multicast // groups directly). - // - // Skip if no external members to avoid spurious errors when called - // during rollback on empty groups. - if !members.iter().any(|m| m.direction == Direction::External) { - return Ok(()); - } - - let external_port_bitmap = - create_port_bitmap(members, Direction::External); - table::mcast::mcast_egress::update_bitmap_entry( - s, - external_group_id, - &external_port_bitmap, - target_vlan_id, - ) + set_group_bitmap_entries(s, group, target_vlan_id) }), } } /// Create port bitmap from members filtered by direction. -fn create_port_bitmap( - members: &[MulticastGroupMember], +fn create_port_bitmap<'a>( + members: impl IntoIterator, direction: Direction, ) -> table::mcast::mcast_egress::PortBitmap { let mut port_bitmap = table::mcast::mcast_egress::PortBitmap::new(); @@ -2034,13 +2584,68 @@ fn create_port_bitmap( #[cfg(test)] mod tests { - use super::*; - use common::ports::RearPort; use std::thread; + use common::ports::RearPort; + use dpd_types::link::LinkId; + use dpd_types::mcast::{ + ExactSource, MAX_TAG_LENGTH, MulticastGroupCreateExternalError, + SourceEntry, + }; + + use super::*; + + #[test] + fn test_group_validate_tag_rejects_mismatch() { + let scoped_id = + || ScopedGroupId::from(ScopedIdInner(0, std::sync::Weak::new())); + let group = MulticastGroup { + external_scoped_group: scoped_id(), + underlay_scoped_group: scoped_id(), + tag: "owner-a".to_string(), + kind: GroupKind::Underlay { + group_ip: "ff04::1".parse().unwrap(), + replication: Replication::Empty, + }, + }; + + // Existing tag matches request tag + assert!(group.validate_tag("owner-a").is_ok()); + + // Existing tag but request has different tag + for tag in ["owner-b", "", "invalid/tag"] { + assert!( + matches!( + group.validate_tag(tag), + Err(DpdError::McastTagMismatch(_)) + ), + "{tag:?}" + ); + } + } + + #[test] + fn test_tag_format_error_maps_to_bad_request() { + for tag in [ + String::new(), + "a".repeat(MAX_TAG_LENGTH + 1), + "tag with spaces".to_string(), + ] { + let err = tag.parse::().unwrap_err(); + let expected = err.to_string(); + let err = DpdError::from(err); + assert!( + matches!(&err, DpdError::Invalid(message) if message == &expected) + ); + let err = dropshot::HttpError::from(err); + assert_eq!(err.status_code.as_u16(), 400); + assert_eq!(err.external_message, expected); + } + } + #[test] fn test_scoped_group_id_drop_returns_to_pool() { - let free_ids = Arc::new(Mutex::new(vec![100, 101, 102])); + let free_ids = Arc::new(Mutex::new(vec![100, 102])); { let scoped_id = ScopedGroupId::from(ScopedIdInner( 101, @@ -2050,9 +2655,7 @@ mod tests { } // ID should be returned to pool - let pool = free_ids.lock().unwrap(); - assert!(pool.contains(&101)); - assert_eq!(pool.len(), 4); // Original 3 + returned 1 + assert_eq!(*free_ids.lock().unwrap(), vec![100, 102, 101]); } #[test] @@ -2099,10 +2702,8 @@ mod tests { } // Should return error when no IDs available - let result = mcast_data.generate_group_id(); - assert!(result.is_err()); - - match result.unwrap_err() { + let res = mcast_data.generate_group_id(); + match res.unwrap_err() { DpdError::ResourceExhausted(msg) => { assert!(msg.contains("no free multicast group IDs available")); } @@ -2110,41 +2711,6 @@ mod tests { } } - #[test] - fn test_concurrent_id_allocation() { - let mcast_data = Arc::new(Mutex::new(MulticastGroupData::new())); - let mut handles = Vec::new(); - - // Spawn multiple threads to allocate IDs concurrently - for _ in 0..10 { - let mcast_data_clone = Arc::clone(&mcast_data); - let handle = thread::spawn(move || { - let mut data = mcast_data_clone.lock().unwrap(); - data.generate_group_id().unwrap() - }); - handles.push(handle); - } - - // Collect all allocated IDs - let mut allocated_ids = Vec::new(); - for handle in handles { - allocated_ids.push(handle.join().unwrap()); - } - - let mut ids: Vec<_> = allocated_ids.iter().map(|v| v.id()).collect(); - - // All IDs should be unique - ids.sort(); - ids.dedup(); - assert_eq!(ids.len(), 10); - - // All IDs should be in valid range - for id in ids { - assert!(id >= MulticastGroupData::GENERATOR_START); - assert!(id < MulticastGroupId::MAX); - } - } - #[test] fn test_concurrent_allocation_and_deallocation() { let mcast_data = Arc::new(Mutex::new(MulticastGroupData::new())); @@ -2170,14 +2736,14 @@ mod tests { handle.join().unwrap(); } - // Pool should have all IDs back (minus any that might still be in use) + // Every `ScopedGroupId` was already dropped before the join, meaning + // that the pool is exactly full once again. let pool_size = { let data = mcast_data.lock().unwrap(); data.free_group_ids.lock().unwrap().len() }; - // Should have close to the original number of IDs let expected_size = (MulticastGroupId::MAX - MulticastGroupData::GENERATOR_START) as usize; @@ -2241,34 +2807,6 @@ mod tests { assert_eq!(final_pool_size, initial_pool_size); } - #[test] - fn test_sources_contain_any() { - // Empty slice has no Any - assert!(!sources_contain_any(&[])); - - // Slice with only exact sources has no Any - let exact_only = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), - ]; - assert!(!sources_contain_any(&exact_only)); - - // Slice with Any returns true - let with_any = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Any, - ]; - assert!(sources_contain_any(&with_any)); - - // Slice with only Any returns true - let only_any = vec![IpSrc::Any]; - assert!(sources_contain_any(&only_any)); - - // Multiple Any entries still returns true - let multiple_any = vec![IpSrc::Any, IpSrc::Any]; - assert!(sources_contain_any(&multiple_any)); - } - #[test] fn test_create_port_bitmap_empty() { let members: Vec = vec![]; @@ -2344,44 +2882,276 @@ mod tests { assert!(!underlay_bitmap.contains_port(15)); } + fn external_ip(addr: &str) -> ExternalMulticastIp { + addr.parse().unwrap() + } + + #[test] + fn test_nat_target_group_must_exist() { + let mcast_data = MulticastGroupData::new(); + let nat_target = NatTarget { + internal_ip: "ff04::1".parse().unwrap(), + inner_mac: common::network::MacAddr::new(1, 0, 0x5e, 0, 0, 1), + vni: common::network::Vni::new(1).unwrap(), + }; + + let err = mcast_data + .get_nat_target_group( + "239.1.1.1".parse().unwrap(), + nat_target.try_into().unwrap(), + ) + .unwrap_err(); + assert!(matches!(err, DpdError::MissingNatTarget(_)), "{err:?}"); + } + + fn source_entries( + sources: Option<&[IpSrc]>, + ) -> Result>, MulticastGroupCreateExternalError> + { + sources + .map(|sources| { + sources.iter().cloned().map(SourceEntry::try_from).collect() + }) + .transpose() + } + + fn canonicalize_sources( + group_ip: ExternalMulticastIp, + sources: Option>, + ) -> Option> { + let entries = source_entries(sources.as_deref()).unwrap(); + + SourceFilter::from_entries(group_ip, entries.as_deref()) + .unwrap() + .to_ip_sources() + } + #[test] fn test_canonicalize_sources() { + let asm_group = external_ip("224.1.2.3"); + // None stays None - assert_eq!(canonicalize_sources(None), None); + assert_eq!(canonicalize_sources(asm_group, None), None); // Empty vec normalizes to None - assert_eq!(canonicalize_sources(Some(vec![])), None); + assert_eq!(canonicalize_sources(asm_group, Some(vec![])), None); // Vec with only IpSrc::Any normalizes to None - assert_eq!(canonicalize_sources(Some(vec![IpSrc::Any])), None); + assert_eq!( + canonicalize_sources(asm_group, Some(vec![IpSrc::Any])), + None + ); // Vec with Any mixed with Exact normalizes to None (Any subsumes all) assert_eq!( - canonicalize_sources(Some(vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Any, - ])), + canonicalize_sources( + asm_group, + Some(vec![ + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), + IpSrc::Any, + ]) + ), None ); - // Vec with only Exact sources is deduplicated and sorted - let exact_sources = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), - ]; - let expected = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - ]; - assert_eq!(canonicalize_sources(Some(exact_sources)), Some(expected)); + // A vec with only Exact sources is deduplicated and sorted + assert_eq!( + canonicalize_sources( + asm_group, + Some(vec![ + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), + ]) + ), + Some(vec![ + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), + ]) + ); // Single Exact source stays as-is - let single_exact = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1, - )))]; + let ipv6_group = external_ip("ff0e::1"); + assert_eq!( + canonicalize_sources( + ipv6_group, + Some(vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1 + )))]) + ), + Some(vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1 + )))]) + ); + } + + #[test] + fn test_group_key_order_and_lookup() { + let first_external = external_ip("224.1.2.3"); + let second_external = external_ip("239.255.255.250"); + let third_external = external_ip("ff0e::1"); + let underlay: UnderlayMulticastIpv6 = "ff04::1".parse().unwrap(); + + let mut map: BTreeMap = BTreeMap::new(); + map.insert(GroupKey::external(third_external), 4); + map.insert(GroupKey::external(second_external), 2); + map.insert(GroupKey::underlay(underlay), 3); + map.insert(GroupKey::external(first_external), 1); + + assert_eq!(map.get(&"224.1.2.3".parse::().unwrap()), Some(&1)); + assert_eq!( + map.get(&"239.255.255.250".parse::().unwrap()), + Some(&2) + ); + assert_eq!(map.get(&"ff04::1".parse::().unwrap()), Some(&3)); + assert_eq!(map.get(&"10.0.0.1".parse::().unwrap()), None); + + let ipv6_only: Vec = map + .range(( + Bound::Included("ff00::".parse::().unwrap()), + Bound::Unbounded, + )) + .map(|(key, _)| key.ip()) + .collect(); assert_eq!( - canonicalize_sources(Some(single_exact.clone())), - Some(single_exact) + ipv6_only, + vec![ + "ff04::1".parse::().unwrap(), + "ff0e::1".parse::().unwrap(), + ] ); } + + #[test] + fn test_source_filter_create_and_update_agree() { + type SourceFilterCase = + (&'static str, Option>, Option); + + let exact_source = |addr: &str| addr.parse::().unwrap(); + let exact = |addr: &str| IpSrc::Exact(addr.parse::().unwrap()); + + let cases: Vec = vec![ + // ASM: 1) absent, 2) empty, 3) just Any, and 4) mixed Any for + // any source. + ("224.1.2.3", None, Some(SourceFilter::Any)), + ("224.1.2.3", Some(vec![]), Some(SourceFilter::Any)), + ("224.1.2.3", Some(vec![IpSrc::Any]), Some(SourceFilter::Any)), + ( + "224.1.2.3", + Some(vec![IpSrc::Any, exact("10.1.1.1")]), + Some(SourceFilter::Any), + ), + // ASM: exact sources are deduplicated and sorted. + ( + "224.1.2.3", + Some(vec![exact("10.1.1.2"), exact("10.1.1.1")]), + Some(SourceFilter::from_exact([ + exact_source("10.1.1.1"), + exact_source("10.1.1.2"), + ])), + ), + // SSM: exact sources are required and preserved. + ( + "232.1.2.3", + Some(vec![exact("10.1.1.1")]), + Some(SourceFilter::from_exact([exact_source("10.1.1.1")])), + ), + // SSM: 1) absent, 2) empty, 3) Any, and 4) mixed Any are rejected. + ("232.1.2.3", None, None), + ("232.1.2.3", Some(vec![]), None), + ("232.1.2.3", Some(vec![IpSrc::Any]), None), + ("232.1.2.3", Some(vec![IpSrc::Any, exact("10.1.1.1")]), None), + ("ff0e::1", None, Some(SourceFilter::Any)), + ("ff0e::1", Some(vec![]), Some(SourceFilter::Any)), + ("ff0e::1", Some(vec![IpSrc::Any]), Some(SourceFilter::Any)), + ( + "ff0e::1", + Some(vec![IpSrc::Any, exact("2001:db8::1")]), + Some(SourceFilter::Any), + ), + ( + "ff3e::4000:1", + Some(vec![ + exact("2001:db8::2"), + exact("2001:db8::1"), + exact("2001:db8::2"), + ]), + Some(SourceFilter::from_exact([ + exact_source("2001:db8::1"), + exact_source("2001:db8::2"), + ])), + ), + ("ff3e::4000:1", None, None), + ("ff3e::4000:1", Some(vec![]), None), + ("ff3e::4000:1", Some(vec![IpSrc::Any]), None), + ( + "ff3e::4000:1", + Some(vec![IpSrc::Any, exact("2001:db8::1")]), + None, + ), + // Invalid source addresses and family mismatches are rejected. + ("224.1.2.3", Some(vec![exact("2001:db8::1")]), None), + ("ff0e::1", Some(vec![exact("10.1.1.1")]), None), + ("232.1.2.3", Some(vec![exact("0.0.0.0")]), None), + ("ff3e::4000:1", Some(vec![exact("fe80::1")]), None), + ("224.1.2.3", Some(vec![IpSrc::Any, exact("224.9.9.9")]), None), + ("224.1.2.3", Some(vec![IpSrc::Any, exact("2001:db8::1")]), None), + ( + "ff0e::1", + Some(vec![IpSrc::Any, exact("::ffff:192.0.2.1")]), + None, + ), + ("ff0e::1", Some(vec![IpSrc::Any, exact("10.1.1.1")]), None), + ]; + + for (group_ip, sources, expected) in cases { + let group = external_ip(group_ip); + let body = serde_json::json!({ + "group_ip": group_ip, + "tag": null, + "internal_forwarding": { + "nat_target": { + "internal_ip": "ff04::1", + "inner_mac": { "a": [1, 0, 94, 0, 0, 1] }, + "vni": 100, + } + }, + "external_forwarding": { "vlan_id": null }, + "sources": serde_json::to_value(&sources).unwrap(), + }); + + let created = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(body); + let updated = + source_entries(sources.as_deref()).and_then(|entries| { + SourceFilter::from_entries(group, entries.as_deref()) + }); + + match expected { + Some(expected) => { + let created = SourceFilter::from(&created.unwrap()); + assert_eq!( + created, expected, + "create filter for {group_ip} with {sources:?}" + ); + assert_eq!( + updated.unwrap(), + expected, + "update filter for {group_ip} with {sources:?}" + ); + } + None => { + assert!( + created.is_err(), + "create should reject {group_ip} with {sources:?}" + ); + assert!( + updated.is_err(), + "update should reject {group_ip} with {sources:?}" + ); + } + } + } + } } diff --git a/dpd/src/mcast/rollback.rs b/dpd/src/mcast/rollback.rs index 7d7062f2..3ed91ff7 100644 --- a/dpd/src/mcast/rollback.rs +++ b/dpd/src/mcast/rollback.rs @@ -11,24 +11,47 @@ //! rollback parameters once and provide reusable error handling throughout //! multi-step operations. -use std::{ - fmt, - net::{IpAddr, Ipv4Addr, Ipv6Addr}, -}; - -use aal::AsicMulticastOps; -use oxnet::{Ipv4Net, Ipv6Net}; -use slog::{debug, error}; +use std::{fmt, net::IpAddr}; -use common::{network::NatTarget, ports::PortId}; +use aal::{AsicError, AsicMulticastOps}; +use slog::{debug, error, warn}; use super::{ - Direction, IpSrc, LinkId, MulticastGroup, MulticastGroupId, - MulticastGroupMember, MulticastReplicationInfo, NO_SOURCES, - add_source_filters, remove_source_filters, sources_contain_any, - update_fwding_tables, update_replication_tables, + Direction, ExternalMulticastIp, GroupKind, IpSrc, MulticastGroup, + MulticastGroupId, MulticastGroupMember, MulticastReplicationInfo, + Replication, SourceFilter, UnderlayMulticastIpv6, clear_source_filter, + mc_group_clear, mc_group_set, mc_port_clear, mc_port_set, + restore_source_filters, set_group_bitmap_entries, set_replication_entry, + set_source_filter, update_fwding_tables, }; -use crate::{Switch, table, types::DpdResult}; +use crate::{ + Switch, table, + types::{DpdError, DpdResult, after_unwind}, +}; + +const ROLLBACK_FAILURE_MSG: &str = "failed operation during rollback"; + +/// Consume all undo results and return the first rollback error, if any. +/// +/// Lazy undo steps execute during the fold, even after a failure. +// `try_fold` would skip remaining undo steps after the first error. +#[allow(clippy::manual_try_fold)] +pub(super) fn unwind_outcome( + steps: impl IntoIterator>, +) -> DpdResult<()> { + steps.into_iter().fold(Ok(()), |unwind, step| unwind.and(step)) +} + +/// Remove each filter entry, reporting failures without aborting early. +fn remove_source_filters( + sources: &SourceFilter, + mut remove: impl FnMut(IpSrc) -> Result<(), E>, +) -> Vec<(IpSrc, E)> { + sources + .iter() + .filter_map(|source| remove(source.clone()).err().map(|e| (source, e))) + .collect() +} /// Trait providing shared rollback functionality for multicast group operations. /// @@ -40,91 +63,206 @@ trait RollbackOps { fn external_group_id(&self) -> MulticastGroupId; fn underlay_group_id(&self) -> MulticastGroupId; - /// Rollback port changes (removing added ports, re-adding removed ports). - /// Each group type implements this differently - external groups are no-op, internal groups handle ports. - fn rollback_ports( + /// ASIC group that carries members of `direction`. + fn group_id(&self, direction: Direction) -> MulticastGroupId { + match direction { + Direction::External => self.external_group_id(), + Direction::Underlay => self.underlay_group_id(), + } + } + + /// Undo port changes in reverse: first remove `added_ports`, then re-add + /// `removed_ports`. Every port is tried through and the first error is + /// returned. + fn undo_port_changes( &self, added_ports: &[MulticastGroupMember], removed_ports: &[MulticastGroupMember], - replication_info: &MulticastReplicationInfo, - ) -> DpdResult<()>; + repl: &MulticastReplicationInfo, + ) -> DpdResult<()> { + let switch = self.switch(); + let mut first_err = None; + + // Remove added ports + for member in added_ports.iter().rev() { + let group_id = self.group_id(member.direction); + + match switch.port_link_to_asic_id(member.port_id, member.link_id) { + Ok(asic_id) => { + if let Err(e) = mc_port_clear(switch, group_id, asic_id) { + error!( + switch.log, + "failed to remove port during rollback"; + "port" => %member.port_id, + "asic_id" => asic_id, + "group_id" => group_id, + "error" => ?e, + ); + + first_err.get_or_insert(e); + } + } + Err(e) => { + error!( + switch.log, + "failed to resolve port link during rollback"; + "port" => %member.port_id, + "link" => %member.link_id, + "error" => ?e, + ); + + first_err.get_or_insert(e); + } + } + } + + // Re-add removed ports + for member in removed_ports.iter().rev() { + let group_id = self.group_id(member.direction); + + match switch.port_link_to_asic_id(member.port_id, member.link_id) { + Ok(asic_id) => { + if let Err(e) = mc_port_set(switch, group_id, asic_id, repl) + { + error!( + switch.log, + "failed to add port during rollback"; + "port" => %member.port_id, + "asic_id" => asic_id, + "group_id" => group_id, + "error" => ?e, + ); + + first_err.get_or_insert(e); + } + } + Err(e) => { + error!( + switch.log, + "failed to resolve port link during rollback"; + "port" => %member.port_id, + "link" => %member.link_id, + "error" => ?e, + ); + + first_err.get_or_insert(e); + } + } + } + + // Return the first error encountered, if any + match first_err { + Some(e) => Err(e), + None => Ok(()), + } + } + + fn remove_source_filters(&self, sources: &SourceFilter) -> DpdResult<()> { + unwind_outcome( + remove_source_filters(sources, |source| { + clear_source_filter(self.switch(), self.group_ip(), source) + }) + .into_iter() + .map(|(source, err)| { + self.log_rollback_error( + "delete source filter entry", + &format!( + "for source {source:?} and group {}", + self.group_ip() + ), + Err::<(), _>(err), + ) + }), + ) + } /// Rollback source filter changes. fn rollback_source_filters( &self, - new_sources: Option<&[IpSrc]>, - orig_sources: Option<&[IpSrc]>, + new_sources: &SourceFilter, + orig_sources: &SourceFilter, ) -> DpdResult<()> { - self.log_rollback_error( - "remove new source filters", - &format!("for group {}", self.group_ip()), - remove_source_filters( - self.switch(), - self.group_ip(), - new_sources.unwrap_or(NO_SOURCES), - ), - ); - self.log_rollback_error( - "restore original source filters", - &format!("for group {}", self.group_ip()), - add_source_filters( + unwind_outcome( + restore_source_filters( self.switch(), self.group_ip(), - orig_sources.unwrap_or(NO_SOURCES), - ), - ); - Ok(()) + new_sources, + orig_sources, + ) + .err() + .into_iter() + .flatten() + .map(|(source, err)| { + self.log_rollback_error( + "restore original source filters", + &format!( + "for source {source:?} and group {}", + self.group_ip() + ), + Err::<(), _>(err), + ) + }), + ) } - /// Rollback internal group changes (ports only, no sources). - fn rollback_internal_update( + /// Log a rollback failure and report it to the caller. + fn log_rollback_error( &self, - added_ports: &[MulticastGroupMember], - removed_ports: &[MulticastGroupMember], - replication_info: &MulticastReplicationInfo, - ) -> DpdResult<()> { - self.collect_rollback_result("port changes", || { - self.rollback_ports(added_ports, removed_ports, replication_info) - }); - - Ok(()) - } - - /// Execute rollback operation and track errors for summary logging. - fn collect_rollback_result(&self, operation: &str, f: F) -> bool + operation: &str, + context: &str, + result: Result, + ) -> DpdResult<()> where - F: FnOnce() -> DpdResult<()>, + E: fmt::Debug + Into, { - match f() { - Ok(()) => false, + match result { + Ok(_) => Ok(()), Err(e) => { - debug!( + error!( self.switch().log, - "failed operation during rollback"; + "{ROLLBACK_FAILURE_MSG}"; "operation" => operation, + "context" => context, "error" => ?e, ); - true + + Err(e.into()) } } } - /// Log rollback errors without propagating them. - fn log_rollback_error( + /// Restore a group's bitmap VLAN to `vlan_id`, the value in place before + /// the failed operation. + /// + /// A missing entry is logged and re-added from the group's membership. + /// Failures, including a failed re-add, are logged and returned. + fn restore_bitmap_vlan( &self, - operation: &str, - context: &str, - result: Result, - ) { - if let Err(e) = result { - debug!( - self.switch().log, - "failed operation during rollback"; - "operation" => operation, - "context" => context, - "error" => ?e, - ); - } + group: &MulticastGroup, + vlan_id: Option, + ) -> DpdResult<()> { + let restored = match super::update_bitmap_vlan( + self.switch(), + group, + vlan_id, + ) { + Err(DpdError::Switch(AsicError::Missing(detail))) => { + warn!( + self.switch().log, + "re-adding missing multicast bitmap entry during rollback"; + "group" => group.external_group_id(), + "detail" => detail, + ); + set_group_bitmap_entries(self.switch(), group, vlan_id) + } + res => res, + }; + + self.log_rollback_error( + "restore multicast bitmap VLAN", + &format!("for group {}", group.external_group_id()), + restored, + ) } } @@ -134,8 +272,23 @@ pub(crate) struct GroupCreateRollbackContext<'a> { group_ip: IpAddr, external_id: MulticastGroupId, underlay_id: MulticastGroupId, - nat_target: Option, - sources: Option<&'a [IpSrc]>, + kind: CreateRollbackKind<'a>, +} + +/// Group kind disambiguation for a creation request rollback, determining +/// which state to undo. +/// +/// Both rollback kinds delete the group's multicast route entry. +#[derive(Clone, Copy)] +enum CreateRollbackKind<'a> { + /// External creation: remove source filters and the NAT entry. The ASIC + /// groups remain after rollback because they belong to the underlay group + /// this external group targets. + External { sources: &'a SourceFilter }, + /// Underlay creation: destroy the ASIC groups and, for IPv6, the egress + /// bitmap and replication entries. No source filters or NAT entries were + /// installed. + Underlay, } impl RollbackOps for GroupCreateRollbackContext<'_> { @@ -154,393 +307,169 @@ impl RollbackOps for GroupCreateRollbackContext<'_> { fn underlay_group_id(&self) -> MulticastGroupId { self.underlay_id } - - fn rollback_ports( - &self, - added_ports: &[MulticastGroupMember], - _removed_ports: &[MulticastGroupMember], - _replication_info: &MulticastReplicationInfo, - ) -> DpdResult<()> { - if self.nat_target.is_some() { - Ok(()) - } else { - debug!( - self.switch.log, - "rolling back multicast group creation"; - "group" => %self.group_ip, - "ports" => added_ports.len(), - ); - - let mut first_error = None; - let mut failed_port_resolution = Vec::new(); - let mut failed_port_removal = Vec::new(); - - for member in added_ports { - let group_id = match member.direction { - Direction::External => self.external_id, - Direction::Underlay => self.underlay_id, - }; - - match self - .switch - .port_link_to_asic_id(member.port_id, member.link_id) - { - Ok(asic_id) => { - if let Err(e) = self - .switch - .asic_hdl - .mc_port_remove(group_id, asic_id) - { - error!( - self.switch.log, - "failed to remove port during rollback"; - "port" => %member.port_id, - "asic_id" => asic_id, - "group" => group_id, - "error" => ?e, - ); - - first_error.get_or_insert_with(|| e.into()); - - failed_port_removal - .push((member.port_id, member.link_id)); - } - } - Err(e) => { - error!( - self.switch.log, - "failed to resolve port link during rollback"; - "port" => %member.port_id, - "link" => %member.link_id, - "error" => ?e, - ); - - first_error.get_or_insert(e); - failed_port_resolution - .push((member.port_id, member.link_id)); - } - } - } - - // Log summary of any failures - if !failed_port_resolution.is_empty() { - error!( - self.switch.log, - "rollback failed to resolve port links"; - "count" => failed_port_resolution.len(), - "ports" => ?failed_port_resolution, - "warning" => "These ports may remain in inconsistent state", - ); - } - - if !failed_port_removal.is_empty() { - error!( - self.switch.log, - "rollback failed to remove ports from ASIC"; - "count" => failed_port_removal.len(), - "ports" => ?failed_port_removal, - "warning" => "These ports may remain in inconsistent state", - ); - } - - // Return the first error encountered, if any - match first_error { - Some(e) => Err(e), - None => Ok(()), - } - } - } } impl<'a> GroupCreateRollbackContext<'a> { /// Create rollback context for external group operations. pub(crate) fn new_external( switch: &'a Switch, - group_ip: IpAddr, + group_ip: ExternalMulticastIp, external_id: MulticastGroupId, underlay_id: MulticastGroupId, - nat_target: NatTarget, - sources: Option<&'a [IpSrc]>, + sources: &'a SourceFilter, ) -> Self { Self { switch, - group_ip, + group_ip: group_ip.into(), external_id, underlay_id, - nat_target: Some(nat_target), - sources, + kind: CreateRollbackKind::External { sources }, } } - /// Create rollback context for internal group operations. - pub(crate) fn new_internal( + /// Create rollback context for underlay group operations. + pub(crate) fn new_underlay( switch: &'a Switch, - group_ip: IpAddr, + group_ip: UnderlayMulticastIpv6, external_id: MulticastGroupId, underlay_id: MulticastGroupId, ) -> Self { Self { switch, - group_ip, + group_ip: group_ip.into(), external_id, underlay_id, - nat_target: None, - sources: None, + kind: CreateRollbackKind::Underlay, } } - /// Perform rollback with member context and return error. - pub(crate) fn rollback_with_members_and_return_error( + /// Restore a referenced underlay group's bitmap VLAN, then roll back the + /// failed creation step. + pub(crate) fn rollback_with_vlan_restore( &self, - error: E, - added_members: &[(PortId, LinkId, Direction)], - replication_info: &MulticastReplicationInfo, - ) -> E { - if let Err(rollback_err) = - self.perform_group_create_rollback(added_members, replication_info) - { - error!( - self.switch.log, - "rollback failed for group creation"; - "group" => %self.group_ip, - "error" => ?rollback_err, - ); - } - error + err: DpdError, + underlay_group: &MulticastGroup, + ) -> DpdError { + let restored = self.restore_bitmap_vlan(underlay_group, None); + self.rollback(after_unwind(err, restored)) } /// Perform rollback and return error. - pub(crate) fn rollback_and_return_error(&self, error: E) -> E { - self.rollback_with_members_and_return_error( - error, - &[], // No members - &MulticastReplicationInfo::default(), // Dummy replication info + pub(crate) fn rollback(&self, err: DpdError) -> DpdError { + after_unwind( + err, + unwind_outcome([self.remove_tables(), self.remove_groups()]), ) } - /// Perform group creation rollback. - fn perform_group_create_rollback( - &self, - added_members: &[(PortId, LinkId, Direction)], - replication_info: &MulticastReplicationInfo, - ) -> DpdResult<()> { - let added_members_converted: Vec = added_members - .iter() - .map(|(port_id, link_id, direction)| MulticastGroupMember { - port_id: *port_id, - link_id: *link_id, - direction: *direction, - }) - .collect(); - - self.collect_rollback_result("port removal", || { - self.rollback_ports(&added_members_converted, &[], replication_info) - }); - self.collect_rollback_result("ASIC group deletion", || { - self.remove_groups() - }); - self.collect_rollback_result("table cleanup", || self.remove_tables()); - - Ok(()) - } - /// Remove multicast groups from ASIC. fn remove_groups(&self) -> DpdResult<()> { - // External groups don't destroy ASIC groups (they're shared with internal group) - if self.nat_target.is_none() { + // External groups don't destroy ASIC groups (they're shared with underlay group). + if !matches!(self.kind, CreateRollbackKind::Underlay) { + return Ok(()); + } + + unwind_outcome([ self.log_rollback_error( "remove external multicast group", &format!( "for IP {} with ID {}", self.group_ip, self.external_id ), - self.switch.asic_hdl.mc_group_destroy(self.external_id), - ); + mc_group_clear(self.switch, self.external_id), + ), self.log_rollback_error( "remove underlay multicast group", &format!( "for IP {} with ID {}", self.group_ip, self.underlay_id ), - self.switch.asic_hdl.mc_group_destroy(self.underlay_id), - ); - } - Ok(()) + mc_group_clear(self.switch, self.underlay_id), + ), + ]) } /// Remove table entries. fn remove_tables(&self) -> DpdResult<()> { - match self.group_ip { - IpAddr::V4(ipv4) => { - // IPv4 groups are always external-only and never create bitmap entries - // (only IPv6 internal groups with replication create bitmap entries) - - // If `Any` was present, only a /0 entry was added (collapsing) - match self.sources { - Some(srcs) if sources_contain_any(srcs) => { - self.log_rollback_error( - "delete IPv4 any-source filter entry", - &format!("for group {ipv4}"), - table::mcast::mcast_src_filter::del_ipv4_entry( - self.switch, - Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), - ipv4, - ), - ); - } - Some(srcs) => { - for src in srcs { - let IpSrc::Exact(IpAddr::V4(addr)) = src else { - continue; - }; - self.log_rollback_error( - "delete IPv4 source filter entry", - &format!("for source {addr} and group {ipv4}"), - table::mcast::mcast_src_filter::del_ipv4_entry( - self.switch, - Ipv4Net::new(*addr, 32).unwrap(), - ipv4, - ), - ); - } - } - None => { - // Normalized None means "allow any source", which - // added a /0 entry. - self.log_rollback_error( - "delete IPv4 any-source filter entry", - &format!("for group {ipv4}"), - table::mcast::mcast_src_filter::del_ipv4_entry( - self.switch, - Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), - ipv4, - ), - ); - } - } - if self.nat_target.is_some() { - self.log_rollback_error( - "delete IPv4 NAT entry", - &format!("for group {ipv4}"), - table::mcast::mcast_nat::del_ipv4_entry( - self.switch, - ipv4, - ), - ); - } - self.log_rollback_error( - "delete IPv4 route entry", - &format!("for group {ipv4}"), - table::mcast::mcast_route::del_ipv4_entry( - self.switch, - ipv4, - ), - ); - } - IpAddr::V6(ipv6) => { - // Clean up external bitmap entry only if both external and underlay groups exist - // (bitmap entries are only created for internal groups with both group types) - self.log_rollback_error( - "delete IPv6 egress bitmap entry", - &format!("for external group {}", self.external_id), - table::mcast::mcast_egress::del_bitmap_entry( - self.switch, - self.external_id, - ), - ); + unwind_outcome([ + self.remove_underlay_entries(), + // Source filters only exist for external groups (which have + // NAT targets). Underlay groups don't have source filtering. + self.remove_external_entries(), + self.remove_route_entry(), + ]) + } - self.log_rollback_error( - "delete IPv6 replication entry", - &format!("for group {ipv6}"), - table::mcast::mcast_replication::del_ipv6_entry( - self.switch, - ipv6, - ), - ); + fn remove_underlay_entries(&self) -> DpdResult<()> { + let (CreateRollbackKind::Underlay, IpAddr::V6(ipv6)) = + (self.kind, self.group_ip) + else { + return Ok(()); + }; - // Source filters only exist for external groups (which have - // NAT targets). Internal groups don't have source filtering. - if self.nat_target.is_some() { - // If `Any` was present, only a ::/0 entry was added (collapsing) - match self.sources { - Some(srcs) if sources_contain_any(srcs) => { - self.log_rollback_error( - "delete IPv6 any-source filter entry", - &format!("for group {ipv6}"), - table::mcast::mcast_src_filter::del_ipv6_entry( - self.switch, - Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 0) - .unwrap(), - ipv6, - ), - ); - } - Some(srcs) => { - for src in srcs { - let IpSrc::Exact(IpAddr::V6(addr)) = src else { - continue; - }; - self.log_rollback_error( - "delete IPv6 source filter entry", - &format!( - "for source {addr} and group {ipv6}" - ), - table::mcast::mcast_src_filter::del_ipv6_entry( - self.switch, - Ipv6Net::new(*addr, 128).unwrap(), - ipv6, - ), - ); - } - } - None => { - // Normalized None means "allow any source", which - // added a /0 entry. - self.log_rollback_error( - "delete IPv6 any-source filter entry", - &format!("for group {ipv6}"), - table::mcast::mcast_src_filter::del_ipv6_entry( - self.switch, - Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 0) - .unwrap(), - ipv6, - ), - ); - } - } + unwind_outcome([ + self.log_rollback_error( + "delete IPv6 egress bitmap entry", + &format!("for external group {}", self.external_id), + table::mcast::mcast_egress::clear_bitmap_entry( + self.switch, + self.external_id, + ), + ), + self.log_rollback_error( + "delete IPv6 replication entry", + &format!("for group {ipv6}"), + table::mcast::mcast_replication::clear_ipv6_entry( + self.switch, + ipv6, + ), + ), + ]) + } - self.log_rollback_error( - "delete IPv6 NAT entry", - &format!("for group {ipv6}"), - table::mcast::mcast_nat::del_ipv6_entry( - self.switch, - ipv6, - ), - ); - } - self.log_rollback_error( - "delete IPv6 route entry", - &format!("for group {ipv6}"), - table::mcast::mcast_route::del_ipv6_entry( - self.switch, - ipv6, - ), - ); - } + fn remove_external_entries(&self) -> DpdResult<()> { + let CreateRollbackKind::External { sources } = self.kind else { + return Ok(()); + }; + + let filters_removed = self.remove_source_filters(sources); + let nat_removed = match self.group_ip { + IpAddr::V4(ipv4) => self.log_rollback_error( + "delete IPv4 NAT entry", + &format!("for group {ipv4}"), + table::mcast::mcast_nat::clear_ipv4_entry(self.switch, ipv4), + ), + IpAddr::V6(ipv6) => self.log_rollback_error( + "delete IPv6 NAT entry", + &format!("for group {ipv6}"), + table::mcast::mcast_nat::clear_ipv6_entry(self.switch, ipv6), + ), + }; + + filters_removed.and(nat_removed) + } + + fn remove_route_entry(&self) -> DpdResult<()> { + match self.group_ip { + IpAddr::V4(ipv4) => self.log_rollback_error( + "delete IPv4 route entry", + &format!("for group {ipv4}"), + table::mcast::mcast_route::clear_ipv4_entry(self.switch, ipv4), + ), + IpAddr::V6(ipv6) => self.log_rollback_error( + "delete IPv6 route entry", + &format!("for group {ipv6}"), + table::mcast::mcast_route::clear_ipv6_entry(self.switch, ipv6), + ), } - Ok(()) } } /// Rollback context for multicast group update operations. pub(crate) struct GroupUpdateRollbackContext<'a> { switch: &'a Switch, - group_ip: IpAddr, original_group: &'a MulticastGroup, - /// The VLAN that the update is attempting to set. This may differ from - /// `original_group.ext_fwding.vlan_id` when a VLAN change is in progress. - /// Used during rollback to delete entries that may have been added. - attempted_vlan_id: Option, + table_vlan_id: Option, } impl RollbackOps for GroupUpdateRollbackContext<'_> { @@ -549,7 +478,7 @@ impl RollbackOps for GroupUpdateRollbackContext<'_> { } fn group_ip(&self) -> IpAddr { - self.group_ip + self.original_group.ip() } fn external_group_id(&self) -> MulticastGroupId { @@ -559,303 +488,5267 @@ impl RollbackOps for GroupUpdateRollbackContext<'_> { fn underlay_group_id(&self) -> MulticastGroupId { self.original_group.underlay_group_id() } +} - fn rollback_ports( +impl<'a> GroupUpdateRollbackContext<'a> { + pub(crate) fn rollback_underlay_update( &self, added_ports: &[MulticastGroupMember], removed_ports: &[MulticastGroupMember], - replication_info: &MulticastReplicationInfo, + repl: &MulticastReplicationInfo, ) -> DpdResult<()> { - // External groups don't need port rollback - if self.original_group.replication_info.is_none() { - return Ok(()); - } - - // Internal group -> perform actual port rollback + // Underlay group -> perform actual port rollback debug!( self.switch.log, "rolling back multicast group update"; - "group" => ?self.group_ip, + "group" => %self.group_ip(), "added_ports" => added_ports.len(), "removed_ports" => removed_ports.len(), ); - let mut first_error = None; - let mut failed_port_resolution = Vec::new(); - let mut failed_port_removal = Vec::new(); - let mut failed_port_addition = Vec::new(); + self.log_rollback_error( + "port changes", + &format!("for group {}", self.group_ip()), + self.undo_port_changes(added_ports, removed_ports, repl), + ) + } - // Remove added ports - for member in added_ports { - let group_id = match member.direction { - Direction::External => self.external_group_id(), - Direction::Underlay => self.underlay_group_id(), - }; + /// Create the rollback context for an update on the `original_group`. + /// + /// `table_vlan_id` is the attempted VLAN for an external update, or the VLAN + /// propagated from the referencing external group for an underlay update. + /// + /// On rollback, we restore the external group's original VLAN and leave + /// the underlay group's one in place. + pub(crate) fn new( + switch: &'a Switch, + original_group: &'a MulticastGroup, + table_vlan_id: Option, + ) -> Self { + Self { switch, original_group, table_vlan_id } + } - match self - .switch - .port_link_to_asic_id(member.port_id, member.link_id) - { - Ok(asic_id) => { - if let Err(e) = - self.switch.asic_hdl.mc_port_remove(group_id, asic_id) - { - error!( - self.switch.log, - "failed to remove port during rollback"; - "port" => %member.port_id, - "asic_id" => asic_id, - "group" => group_id, - "error" => ?e, - ); + /// Restore table entries to original state. + fn restore_tables(&self) -> DpdResult<()> { + match &self.original_group.kind { + GroupKind::Underlay { + replication: Replication::Active { .. }, + .. + } => self + .restore_fwding_tables() + .and_then(|()| self.restore_replication()), + GroupKind::Underlay { replication: Replication::Empty, .. } => { + unwind_outcome([ + self.remove_added_bitmap(), + self.restore_fwding_tables(), + ]) + } + GroupKind::External { .. } => self.restore_fwding_tables(), + } + } + + fn restore_replication(&self) -> DpdResult<()> { + let GroupKind::Underlay { + group_ip, + replication: Replication::Active { info: repl, .. }, + } = &self.original_group.kind + else { + return Ok(()); + }; + + self.log_rollback_error( + "restore replication settings", + &format!("for group {group_ip}"), + set_replication_entry(self.switch, self.original_group, repl), + ) + } + + fn remove_added_bitmap(&self) -> DpdResult<()> { + let GroupKind::Underlay { replication: Replication::Empty, .. } = + &self.original_group.kind + else { + return Ok(()); + }; + + self.log_rollback_error( + "delete newly added multicast bitmap", + &format!("for group {}", self.group_ip()), + super::cleanup_empty_group_replication( + self.switch, + self.original_group, + ), + ) + } + + fn restore_fwding_tables(&self) -> DpdResult<()> { + let restore_vlan_id = match &self.original_group.kind { + GroupKind::External { vlan_id, .. } => *vlan_id, + GroupKind::Underlay { .. } => self.table_vlan_id, + }; + + self.log_rollback_error( + "restore VLAN settings", + &format!("for group {}", self.group_ip()), + update_fwding_tables( + self.switch, + self.original_group, + self.table_vlan_id, + restore_vlan_id, + ), + ) + } + + /// Rollback external group updates. + /// + /// Note: `new_sources` should be the normalized sources that were actually + /// applied to the tables (not the raw request sources). + pub(crate) fn rollback_external( + &self, + err: DpdError, + new_sources: &SourceFilter, + ) -> DpdError { + // Underlay groups have no source filters to restore. + let unwind = match &self.original_group.kind { + GroupKind::External { sources, .. } => { + self.rollback_source_filters(new_sources, sources) + } + GroupKind::Underlay { .. } => Ok(()), + }; + after_unwind(err, unwind.and(self.restore_fwding_tables())) + } + + /// Restore the bitmap VLAN on every group in `updates`, then report the + /// initial failure together with that failure's outcome. + /// + /// This attempts every group even if an earlier restore fails. + pub(crate) fn rollback_with_bitmap_restore<'b>( + &self, + err: DpdError, + updates: impl IntoIterator)>, + ) -> DpdError { + let restored = + unwind_outcome(updates.into_iter().map(|(group, vlan_id)| { + self.restore_bitmap_vlan(group, vlan_id) + })); + + after_unwind(err, restored) + } + + /// Undo an underlay group's port changes, restore its tables, and report + /// the result with the failure that originally triggered the rollback. + pub(crate) fn rollback_underlay_and_restore( + &self, + err: DpdError, + repl: &MulticastReplicationInfo, + added_ports: &[MulticastGroupMember], + removed_ports: &[MulticastGroupMember], + ) -> DpdError { + after_unwind( + err, + self.rollback_underlay_update(added_ports, removed_ports, repl) + .and(self.restore_tables()), + ) + } +} + +/// A deletion step recorded for rollback, including partially completed steps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum DeleteStep { + /// Source filter entries, recorded before removal since removal is per + /// entry and can fail partway. + SourceFilters, + /// The external group's NAT ingress entry. + Nat, + /// The underlay group's replication entry. + Replication, + /// The underlay group's egress decap bitmap entry. + Bitmap, + /// The group's multicast route entry. + Route, + /// The underlay group's external and underlay ASIC domains. + Domains, +} + +/// Rollback context for multicast group delete operations. +pub(crate) struct GroupDeleteRollbackContext<'a> { + switch: &'a Switch, + group: &'a MulticastGroup, +} + +impl RollbackOps for GroupDeleteRollbackContext<'_> { + fn switch(&self) -> &Switch { + self.switch + } + + fn group_ip(&self) -> IpAddr { + self.group.ip() + } + + fn external_group_id(&self) -> MulticastGroupId { + self.group.external_group_id() + } + + fn underlay_group_id(&self) -> MulticastGroupId { + self.group.underlay_group_id() + } +} + +impl<'a> GroupDeleteRollbackContext<'a> { + /// Create rollback context for deleting `group`. + pub(crate) fn new(switch: &'a Switch, group: &'a MulticastGroup) -> Self { + Self { switch, group } + } + + /// Restore the `deleted` steps and report the result with the failure + /// that triggered the rollback. + pub(crate) fn rollback( + &self, + err: DpdError, + deleted: &[DeleteStep], + ) -> DpdError { + after_unwind(err, self.restore_tables(deleted)) + } + + /// Restore a referenced underlay group's bitmap VLAN to this external + /// group's VLAN; then, restore the `deleted` steps in sequence. + pub(crate) fn rollback_with_vlan_restore( + &self, + err: DpdError, + underlay_group: &MulticastGroup, + deleted: &[DeleteStep], + ) -> DpdError { + let restored = + self.restore_bitmap_vlan(underlay_group, self.group.vlan_id()); + self.rollback(after_unwind(err, restored), deleted) + } + + fn restore_tables(&self, deleted: &[DeleteStep]) -> DpdResult<()> { + let restore = |step: DeleteStep, f: &dyn Fn() -> DpdResult<()>| { + if deleted.contains(&step) { f() } else { Ok(()) } + }; + + match &self.group.kind { + GroupKind::External { sources, nat_target, vlan_id, .. } => { + unwind_outcome([ + restore(DeleteStep::SourceFilters, &|| { + self.readd_source_filters(sources) + }), + restore(DeleteStep::Nat, &|| self.readd_nat(*nat_target)), + restore(DeleteStep::Route, &|| self.readd_route(*vlan_id)), + ]) + } + GroupKind::Underlay { + replication: Replication::Active { info, .. }, + .. + } => unwind_outcome([ + restore(DeleteStep::Domains, &|| self.restore_domains()), + restore(DeleteStep::Bitmap, &|| self.readd_bitmap()).and_then( + |()| { + restore(DeleteStep::Replication, &|| { + self.readd_replication(info) + }) + }, + ), + restore(DeleteStep::Route, &|| self.readd_route(None)), + ]), + GroupKind::Underlay { replication: Replication::Empty, .. } => { + unwind_outcome([ + restore(DeleteStep::Domains, &|| self.restore_domains()), + restore(DeleteStep::Route, &|| self.readd_route(None)), + ]) + } + } + } + + fn restore_domains(&self) -> DpdResult<()> { + unwind_outcome( + [Direction::External, Direction::Underlay] + .into_iter() + .map(|direction| self.restore_domain(direction)), + ) + } + + fn restore_domain(&self, direction: Direction) -> DpdResult<()> { + let group_id = self.group_id(direction); + + if !self.switch.asic_hdl.mc_group_exists(group_id) { + self.log_rollback_error( + "restore multicast group", + &format!("for IP {} with ID {group_id}", self.group_ip()), + mc_group_set(self.switch, group_id), + )?; + } + + let Some(Replication::Active { info, members }) = + self.group.replication() + else { + return Ok(()); + }; + + unwind_outcome( + members.iter().filter(|member| member.direction == direction).map( + |member| { + let added = self + .switch + .port_link_to_asic_id(member.port_id, member.link_id) + .and_then(|asic_id| { + mc_port_set(self.switch, group_id, asic_id, info) + }); + + self.log_rollback_error( + "restore multicast group member", + &format!( + "for port {} link {} in group {} with ID {group_id}", + member.port_id, + member.link_id, + self.group_ip() + ), + added, + ) + }, + ), + ) + } + + fn readd_source_filters(&self, sources: &SourceFilter) -> DpdResult<()> { + unwind_outcome(sources.iter().map(|source| { + self.log_rollback_error( + "restore source filter entry", + &format!("for source {source:?} and group {}", self.group_ip()), + set_source_filter(self.switch, self.group_ip(), source.clone()), + ) + })) + } + + fn readd_nat( + &self, + nat_target: dpd_types::mcast::ExternalNatTarget, + ) -> DpdResult<()> { + self.log_rollback_error( + "restore NAT entry", + &format!("for group {}", self.group_ip()), + super::update_nat_tables( + self.switch, + self.group_ip(), + nat_target.into(), + ), + ) + } + + fn readd_route(&self, vlan_id: Option) -> DpdResult<()> { + let result = match self.group_ip() { + IpAddr::V4(ipv4) => table::mcast::mcast_route::set_ipv4_entry( + self.switch, + ipv4, + vlan_id, + ), + IpAddr::V6(ipv6) => table::mcast::mcast_route::set_ipv6_entry( + self.switch, + ipv6, + vlan_id, + ), + }; + + self.log_rollback_error( + "restore route entry", + &format!("for group {}", self.group_ip()), + result, + ) + } + + fn readd_bitmap(&self) -> DpdResult<()> { + self.log_rollback_error( + "restore multicast bitmap entry", + &format!("for group {}", self.group.external_group_id()), + set_group_bitmap_entries(self.switch, self.group, None), + ) + } + + fn readd_replication( + &self, + info: &MulticastReplicationInfo, + ) -> DpdResult<()> { + self.log_rollback_error( + "restore replication entry", + &format!("for group {}", self.group_ip()), + set_replication_entry(self.switch, self.group, info), + ) + } +} + +#[cfg(test)] +mod tests { + use dpd_types::mcast::ExactSource; + + use super::*; + + #[test] + fn test_source_filter_rollback_reports_all_failures() { + let sources = SourceFilter::from_exact( + ["10.0.0.1", "10.0.0.2", "10.0.0.3"] + .map(|address| address.parse::().unwrap()), + ); + let expected: Vec<_> = sources.iter().collect(); + + let mut attempted = Vec::new(); + let errs = remove_source_filters(&sources, |source| { + let index = attempted.len(); + attempted.push(source); + if index == 1 { Ok(()) } else { Err(index) } + }); + + // The first and third removals fail, but every source + // should still be attempted. + assert_eq!(attempted, expected); + + // These errors should follow in source order. + assert_eq!( + errs, + vec![(expected[0].clone(), 0), (expected[2].clone(), 2)] + ); + } + + #[test] + fn test_source_filter_rollback_attempts_any_just_once() { + for removal_fails in [false, true] { + let mut attempted = Vec::new(); + let errs = remove_source_filters(&SourceFilter::Any, |source| { + attempted.push(source); + if removal_fails { Err("delete failed") } else { Ok(()) } + }); + + assert_eq!(attempted, vec![IpSrc::Any]); + if removal_fails { + assert_eq!(errs, vec![(IpSrc::Any, "delete failed")]); + } else { + assert!(errs.is_empty()); + } + } + } +} + +/// Rollback tests driven by the `chaos` ASIC backend, which fails table and +/// ASIC operations at configured probabilities. +#[cfg(all(test, feature = "chaos"))] +mod chaos_tests { + use std::{ + collections::BTreeMap, + convert::Infallible, + sync::{Arc, Mutex}, + }; + + use aal::AsicId; + use asic::chaos::{CallRange, McOp, TableOp}; + use common::{ + network::{MacAddr, NatTarget, Vni, multicast_mac_addr}, + ports::RearPort, + table::TableType, + }; + use dpd_types::link::LinkId; + use dpd_types::mcast::{ExactSource, SourceEntry}; + use slog::{Drain, KV}; + + use super::*; + use crate::{ + Switch, config::Config, mcast::*, table::mcast::mcast_nat, + types::DpdError, + }; + + /// Captures the fields of each [`ROLLBACK_FAILURE_MSG`] record. + #[derive(Clone, Default)] + struct RollbackLog { + entries: Arc>>>, + } + + struct LogFields(BTreeMap); + + impl slog::Serializer for LogFields { + fn emit_arguments( + &mut self, + key: slog::Key, + value: &std::fmt::Arguments<'_>, + ) -> slog::Result { + self.0.insert(key.to_string(), value.to_string()); + Ok(()) + } + } + + impl Drain for RollbackLog { + type Ok = (); + type Err = Infallible; + + fn log( + &self, + record: &slog::Record<'_>, + _values: &slog::OwnedKVList, + ) -> Result<(), Infallible> { + if record.msg().to_string() == ROLLBACK_FAILURE_MSG { + let mut fields = LogFields(BTreeMap::new()); + record.kv().serialize(record, &mut fields).unwrap(); + self.entries.lock().unwrap().push(fields.0); + } + Ok(()) + } + } + + /// Build a switch whose logger feeds `RollbackLog`, registered with the + /// tables that the multicast paths use. + fn chaos_switch(config: Config) -> (Switch, RollbackLog) { + let logs = RollbackLog::default(); + let log = slog::Logger::root(logs.clone().fuse(), slog::o!()); + let mut switch = Switch::new(log, "sidecar", config).unwrap(); + for table in [ + TableType::McastIpv6, + TableType::RouteIpv4Mcast, + TableType::McastIpv4SrcFilter, + TableType::NatIngressIpv4Mcast, + TableType::RouteIpv6Mcast, + TableType::McastEgressDecapPorts, + TableType::McastIpv6SrcFilter, + TableType::NatIngressIpv6Mcast, + ] { + switch.table_add(table).unwrap(); + } + + (switch, logs) + } + + /// Install the underlay fixture at `ff04::123` in the reserved `ff04::/64` + /// block. + fn install_underlay_group( + switch: &Switch, + ) -> (UnderlayMulticastIpv6, GroupKey) { + install_underlay_group_at(switch, "ff04::123".parse().unwrap()) + } + + /// Install an underlay group directly into the tables and the group map, + /// without an external group mapping. + fn install_underlay_group_at( + switch: &Switch, + underlay_ip: UnderlayMulticastIpv6, + ) -> (UnderlayMulticastIpv6, GroupKey) { + let underlay_key = GroupKey::underlay(underlay_ip); + let (external_scoped_group, underlay_scoped_group) = { + let mut groups = switch.mcast.lock().unwrap(); + ( + groups.generate_group_id().unwrap(), + groups.generate_group_id().unwrap(), + ) + }; + let repl = MulticastReplicationInfo::new(external_scoped_group.id()); + let members = vec![ + MulticastGroupMember { + port_id: RearPort::new(0).unwrap().into(), + link_id: LinkId(0), + direction: Direction::External, + }, + MulticastGroupMember { + port_id: RearPort::new(1).unwrap().into(), + link_id: LinkId(0), + direction: Direction::Underlay, + }, + ]; + + configure_underlay_tables( + switch, + underlay_ip, + external_scoped_group.id(), + underlay_scoped_group.id(), + Some(&repl), + &members, + None, + ) + .unwrap(); + + create_asic_groups( + switch, + underlay_ip, + external_scoped_group.id(), + underlay_scoped_group.id(), + ); + add_ports_to_groups( + switch, + underlay_ip.into(), + &members, + external_scoped_group.id(), + underlay_scoped_group.id(), + &repl, + ) + .unwrap(); + + let underlay = MulticastGroup { + external_scoped_group, + underlay_scoped_group, + tag: "underlay-rollback-test".to_string(), + kind: GroupKind::Underlay { + group_ip: underlay_ip, + replication: Replication::new(repl, members), + }, + }; + switch.mcast.lock().unwrap().groups.insert(underlay_key, underlay); + + (underlay_ip, underlay_key) + } + + /// Install a memberless underlay group at `ff04::123`: a route entry and + /// the stored group, with no bitmap or replication entry. + fn install_empty_underlay_group( + switch: &Switch, + ) -> (UnderlayMulticastIpv6, GroupKey) { + let underlay_ip: UnderlayMulticastIpv6 = "ff04::123".parse().unwrap(); + let underlay_key = GroupKey::underlay(underlay_ip); + let (external_scoped_group, underlay_scoped_group) = { + let mut groups = switch.mcast.lock().unwrap(); + ( + groups.generate_group_id().unwrap(), + groups.generate_group_id().unwrap(), + ) + }; + + configure_underlay_tables( + switch, + underlay_ip, + external_scoped_group.id(), + underlay_scoped_group.id(), + None, + &[], + None, + ) + .unwrap(); + create_asic_groups( + switch, + underlay_ip, + external_scoped_group.id(), + underlay_scoped_group.id(), + ); + + let underlay = MulticastGroup { + external_scoped_group, + underlay_scoped_group, + tag: "underlay-empty-test".to_string(), + kind: GroupKind::Underlay { + group_ip: underlay_ip, + replication: Replication::Empty, + }, + }; + switch.mcast.lock().unwrap().groups.insert(underlay_key, underlay); + + (underlay_ip, underlay_key) + } + + fn create_asic_groups( + switch: &Switch, + underlay_ip: UnderlayMulticastIpv6, + external_group_id: MulticastGroupId, + underlay_group_id: MulticastGroupId, + ) { + for group_id in [external_group_id, underlay_group_id] { + create_asic_group(switch, group_id, underlay_ip.into()).unwrap(); + } + } + + /// Return occupancy, inserts, deletes, and updates (in that order). + fn table_usage(switch: &Switch, table: TableType) -> (u32, u64, u64, u64) { + let table = switch.table_get(table).unwrap(); + ( + table.usage.occupancy, + table.usage.inserts, + table.usage.deletes, + table.usage.updates, + ) + } + + fn bitmap_entry( + switch: &Switch, + external_group_id: MulticastGroupId, + ) -> Option<(table::mcast::mcast_egress::PortBitmap, Option)> { + let dump = table::mcast::mcast_egress::bitmap_table_dump(switch, false) + .unwrap(); + + let group_id = external_group_id.to_string(); + let entry = dump.entries.into_iter().find(|entry| { + entry.keys.get("mcast_external_grp") == Some(&group_id) + })?; + + let mut ports = [0u32; 8]; + for (index, word) in ports.iter_mut().enumerate() { + *word = + entry.action_args[&format!("ports_{index}")].parse().unwrap(); + } + + let vlan_id = entry + .action_args + .get("vlan_id") + .map(|vlan_id| vlan_id.parse().unwrap()); + Some((ports.into(), vlan_id)) + } + + fn route_action( + switch: &Switch, + group_ip: IpAddr, + ) -> Option<(String, Option)> { + let dump = match group_ip { + IpAddr::V4(_) => { + table::mcast::mcast_route::ipv4_table_dump(switch, false) + } + IpAddr::V6(_) => { + table::mcast::mcast_route::ipv6_table_dump(switch, false) + } + } + .unwrap(); + + let address = group_ip.to_string(); + dump.entries + .into_iter() + .find(|entry| entry.keys.get("dst_addr") == Some(&address)) + .map(|entry| { + (entry.action, entry.action_args.get("vlan_id").cloned()) + }) + } + + fn nat_target_addr(switch: &Switch, group_ip: IpAddr) -> Option { + let dump = match group_ip { + IpAddr::V4(_) => { + table::mcast::mcast_nat::ipv4_table_dump(switch, false) + } + IpAddr::V6(_) => { + table::mcast::mcast_nat::ipv6_table_dump(switch, false) + } + } + .unwrap(); + + let address = group_ip.to_string(); + dump.entries + .into_iter() + .find(|entry| entry.keys.get("dst_addr") == Some(&address)) + .and_then(|entry| entry.action_args.get("target").cloned()) + } + + fn source_filter_prefixes( + switch: &Switch, + group_ip: IpAddr, + ) -> BTreeSet { + let dump = match group_ip { + IpAddr::V4(_) => { + table::mcast::mcast_src_filter::ipv4_table_dump(switch, false) + } + IpAddr::V6(_) => { + table::mcast::mcast_src_filter::ipv6_table_dump(switch, false) + } + } + .unwrap(); + + let address = group_ip.to_string(); + dump.entries + .into_iter() + .filter(|entry| entry.keys.get("dst_addr") == Some(&address)) + .map(|entry| entry.keys["src_addr"].clone()) + .collect() + } + + fn external_tables(group_ip: IpAddr) -> (TableType, TableType, TableType) { + match group_ip { + IpAddr::V4(_) => ( + TableType::RouteIpv4Mcast, + TableType::McastIpv4SrcFilter, + TableType::NatIngressIpv4Mcast, + ), + IpAddr::V6(_) => ( + TableType::RouteIpv6Mcast, + TableType::McastIpv6SrcFilter, + TableType::NatIngressIpv6Mcast, + ), + } + } + + fn external_nat_target( + group_ip: IpAddr, + underlay_ip: UnderlayMulticastIpv6, + ) -> NatTarget { + let inner_mac = match group_ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + MacAddr::new( + 0x01, + 0x00, + 0x5e, + octets[1] & 0x7f, + octets[2], + octets[3], + ) + } + IpAddr::V6(ip) => multicast_mac_addr(ip), + }; + NatTarget { + internal_ip: underlay_ip.into(), + inner_mac, + vni: Vni::new(100).unwrap(), + } + } + + fn external_entry( + group_ip: IpAddr, + tag: &str, + nat_target: NatTarget, + vlan_id: Option, + sources: serde_json::Value, + ) -> MulticastGroupCreateExternalEntry { + serde_json::from_value(serde_json::json!({ + "group_ip": group_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": vlan_id }, + "sources": sources, + })) + .unwrap() + } + + /// Fail external group creation for `external_ip` on a full route table and + /// assert that the mapped underlay group remains. + /// + /// When `source_removal_fails` is true, each of the three filter deletions + /// fails and is logged separately. When false, all three succeed. + /// + /// The failed route insertion leaves no entry to delete. Cleanup logs the + /// missing entry at debug level, without a `ROLLBACK_FAILURE_MSG` record. + fn external_create_failure( + external_ip: IpAddr, + source_removal_fails: bool, + ) { + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let mut config = Config::default(); + + if source_removal_fails { + config.asic_config.table_entry_del.values.insert(source_table, 1.0); + } + + config.asic_config.mc_config.mc_group_destroy = + asic::chaos::Chaos::new(1.0); + config.asic_config.mc_config.mc_port_remove = + asic::chaos::Chaos::new(1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + + let (before_group, before_repl) = { + let groups = switch.mcast.lock().unwrap(); + let underlay = groups.groups.get(&underlay_key).unwrap(); + ( + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from(underlay).unwrap(), + ) + .unwrap(), + underlay.replication_info().cloned(), + ) + }; + + let before_tables: BTreeMap<_, _> = [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + route_table, + ] + .into_iter() + .map(|table| (table, table_usage(&switch, table))) + .collect(); + + { + let mut route = switch.table_get(route_table).unwrap(); + route.usage.size = route.usage.occupancy; + } + + let source_ips = if external_ip.is_ipv4() { + ["192.0.2.1", "192.0.2.2", "192.0.2.3"] + } else { + ["2001:db8::1", "2001:db8::2", "2001:db8::3"] + }; + + let nat_target = external_nat_target(external_ip, underlay_ip); + let entry = external_entry( + external_ip, + "external-rollback-test", + nat_target, + None, + serde_json::json!( + source_ips.map(|ip| serde_json::json!({ "Exact": ip })) + ), + ); + + let err = add_group_external(&switch, entry).unwrap_err(); + assert_eq!( + matches!(err, DpdError::Unwind { .. }), + source_removal_fails, + "unexpected rollback failure reporting: {err:?}" + ); + + let init: &DpdError = match &err { + DpdError::Unwind { initial: init, .. } => init, + err => err, + }; + assert!( + matches!( + init, + DpdError::TableFull(table) + if *table == route_table.to_string() + ), + "creation error was replaced: {err:?}" + ); + + for (table, before) in before_tables { + assert_eq!(table_usage(&switch, table), before, "{table}"); + } + + let groups = switch.mcast.lock().unwrap(); + assert_eq!(groups.groups.len(), 1 + usize::from(source_removal_fails)); + assert_eq!( + groups.groups.contains_key(&external_ip), + source_removal_fails + ); + assert_eq!(groups.nat_target_refs.is_empty(), !source_removal_fails); + assert_eq!( + groups.pending_repairs.contains(&external_ip), + source_removal_fails + ); + + let underlay = groups.groups.get(&underlay_key).unwrap(); + assert_eq!( + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from(underlay).unwrap() + ) + .unwrap(), + before_group + ); + assert_eq!(underlay.replication_info().cloned(), before_repl); + assert_eq!(table_usage(&switch, nat_table), (0, 1, 1, 0)); + let remaining = if source_removal_fails { 3 } else { 0 }; + assert_eq!( + table_usage(&switch, source_table), + (remaining, 3, u64::from(3 - remaining), 0) + ); + + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), remaining as usize, "{errs:?}"); + for (err, source) in errs.iter().zip(source_ips) { + assert_eq!(err["operation"], "delete source filter entry"); + assert!(err["context"].contains(source)); + assert!(err["error"].contains("table_entry_del")); + } + } + + #[test] + fn test_external_create_failure_preserves_underlay() { + for group_ip in ["232.1.2.3", "ff3e::4000:1"] { + external_create_failure(group_ip.parse().unwrap(), false); + } + } + + #[test] + fn test_external_create_failure_with_cleanup_errors() { + for group_ip in ["232.1.2.3", "ff3e::4000:1"] { + external_create_failure(group_ip.parse().unwrap(), true); + } + } + + #[test] + fn test_external_vlan_prop_failure_reports_restore_failure() { + let mut config = Config::default(); + config + .asic_config + .table_entry_update + .values + .insert(TableType::McastEgressDecapPorts, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + + let before_underlay = { + let groups = switch.mcast.lock().unwrap(); + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from( + &groups.groups[&underlay_key], + ) + .unwrap(), + ) + .unwrap() + }; + + let external_ip: IpAddr = "239.10.10.10".parse().unwrap(); + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let before_decap = + table_usage(&switch, TableType::McastEgressDecapPorts); + + let nat_target = external_nat_target(external_ip, underlay_ip); + let entry = external_entry( + external_ip, + "external-vlan-propagation-test", + nat_target, + Some(10), + serde_json::json!([{ "Exact": "192.0.2.1" }]), + ); + + let err = add_group_external(&switch, entry).unwrap_err(); + assert!( + matches!( + err, + DpdError::Unwind { initial: ref init, .. } + if matches!(**init, DpdError::McastGroupFailure(_)) + ), + "creation error was replaced: {err:?}" + ); + + assert_eq!(table_usage(&switch, route_table), (0, 1, 1, 0)); + assert_eq!(table_usage(&switch, source_table), (0, 1, 1, 0)); + assert_eq!(table_usage(&switch, nat_table), (0, 1, 1, 0)); + assert_eq!( + table_usage(&switch, TableType::McastEgressDecapPorts), + before_decap + ); + + { + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), 1, "{errs:?}"); + assert_eq!(errs[0]["operation"], "restore multicast bitmap VLAN"); + assert!(errs[0]["error"].contains("table_entry_update")); + } + let groups = switch.mcast.lock().unwrap(); + assert_eq!(groups.groups.len(), 2); + assert!(groups.groups.contains_key(&external_ip)); + assert!(!groups.nat_target_refs.is_empty()); + assert!(groups.pending_repairs.contains(&external_ip)); + assert!(groups.pending_repairs.contains(&underlay_key)); + assert_eq!( + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from( + &groups.groups[&underlay_key] + ) + .unwrap(), + ) + .unwrap(), + before_underlay + ); + } + + /// Check the rollback process for VLAN changes and NAT retargets on + /// `external_ip`. + /// + /// When `target_changed` is true, the NAT target changes and the VLAN + /// remains as is. Otherwise, the VLAN gets modified. Sources change either + /// way, and NAT programming fails. + /// + /// The NAT key is always just the destination address; both cases + /// update the entry in place and hit the injected `table_entry_update` + /// failure. + fn external_update_failure(external_ip: IpAddr, target_changed: bool) { + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let mut config = Config::default(); + config.asic_config.table_entry_update.values.insert(nat_table, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (target_ip, target_key) = if target_changed { + let (ip, key) = install_underlay_group_at( + &switch, + "ff04::124".parse().unwrap(), + ); + (ip, Some(key)) + } else { + (underlay_ip, None) + }; + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-update-test"; + + let entry = external_entry( + external_ip, + tag, + nat_target, + Some(10), + serde_json::Value::Null, + ); + + let og = add_group_external(&switch, entry).unwrap(); + let before_route = table_usage(&switch, route_table); + let before_bitmap = + table_usage(&switch, TableType::McastEgressDecapPorts); + let before_filters = table_usage(&switch, source_table); + let before_route_action = route_action(&switch, external_ip); + let bitmap_group_ids: Vec = { + let groups = switch.mcast.lock().unwrap(); + [Some(underlay_key), target_key] + .into_iter() + .flatten() + .map(|key| groups.groups[&key].external_group_id()) + .collect() + }; + + let before_bitmap_entries: Vec<_> = bitmap_group_ids + .iter() + .map(|group_id| bitmap_entry(&switch, *group_id)) + .collect(); + let before_target = target_key.map(|key| { + let groups = switch.mcast.lock().unwrap(); + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from(&groups.groups[&key]) + .unwrap(), + ) + .unwrap() + }); + + let new_source = SourceEntry::Exact( + if external_ip.is_ipv4() { "192.0.2.1" } else { "2001:db8::1" } + .parse() + .unwrap(), + ); + + let err = modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: NatTarget { + internal_ip: target_ip.into(), + ..nat_target + } + .try_into() + .unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id: Some(if target_changed { 10 } else { 20 }), + }, + sources: Some(vec![new_source]), + }, + ) + .unwrap_err(); + + assert!( + matches!(err, DpdError::Switch(_)), + "update error was replaced or unwound: {err:?}" + ); + assert_eq!(table_usage(&switch, nat_table), (1, 1, 0, 0)); + + let after_route = table_usage(&switch, route_table); + assert_eq!( + (after_route.0, after_route.1, after_route.2), + (before_route.0, before_route.1, before_route.2), + "route entries were added or removed" + ); + assert_eq!( + route_action(&switch, external_ip), + before_route_action, + "route action was not restored" + ); + + let after_bitmap = + table_usage(&switch, TableType::McastEgressDecapPorts); + assert_eq!( + (after_bitmap.0, after_bitmap.1, after_bitmap.2), + (before_bitmap.0, before_bitmap.1, before_bitmap.2), + "bitmap entries were added or removed" + ); + assert_eq!( + bitmap_group_ids + .iter() + .map(|group_id| bitmap_entry(&switch, *group_id)) + .collect::>(), + before_bitmap_entries, + "bitmap entries were not restored" + ); + + assert_eq!( + table_usage(&switch, source_table).0, + before_filters.0, + "source filter occupancy was not restored" + ); + + let groups = switch.mcast.lock().unwrap(); + let group_ip = ExternalMulticastIp::new(external_ip).unwrap(); + let group_key = GroupKey::external(group_ip); + + assert_eq!(groups.nat_target_refs.len(), 1); + assert_eq!(groups.nat_target_refs.get(&underlay_ip), Some(&group_key)); + + let group = &groups.groups[&group_key]; + assert_eq!( + serde_json::to_value( + MulticastGroupExternalResponse::try_from(group).unwrap() + ) + .unwrap(), + serde_json::to_value(og).unwrap() + ); + assert_eq!( + group.underlay_group_id(), + groups.groups[&underlay_key].underlay_group_id() + ); + + if let (Some(key), Some(before)) = (target_key, before_target.as_ref()) + { + assert_eq!( + serde_json::to_value( + MulticastGroupUnderlayResponse::try_from( + &groups.groups[&key] + ) + .unwrap() + ) + .unwrap(), + *before, + "retarget destination group changed" + ); + } + let any_prefix = + if external_ip.is_ipv4() { "0.0.0.0/0" } else { "::/0" }; + assert_eq!( + source_filter_prefixes(&switch, external_ip), + BTreeSet::from([any_prefix.to_string()]) + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_external_failed_unwind_repairs_on_read() { + for (group_ip, source_ip) in + [("224.1.2.3", "192.0.2.1"), ("ff0e::1", "2001:db8::1")] + { + let external_ip: IpAddr = group_ip.parse().unwrap(); + let (_, source_table, _) = external_tables(external_ip); + let (switch, _logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-repair-test"; + add_group_external( + &switch, + external_entry( + external_ip, + tag, + nat_target, + Some(10), + serde_json::Value::Null, + ), + ) + .unwrap(); + let stored = + serde_json::to_value(get_group(&switch, external_ip).unwrap()) + .unwrap(); + let stored_sources = source_filter_prefixes(&switch, external_ip); + let stored_route = route_action(&switch, external_ip); + + switch.asic_hdl.inject_table_faults( + source_table, + TableOp::EntryDel, + CallRange::starting_at(1), + ); + let update = || MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: nat_target.try_into().unwrap(), + }, + external_forwarding: ExternalForwarding { vlan_id: Some(20) }, + sources: Some(vec![SourceEntry::Exact( + ExactSource::new(source_ip.parse().unwrap()).unwrap(), + )]), + }; + let group = ExternalMulticastIp::new(external_ip).unwrap(); + + let err = modify_group_external(&switch, group, tag, update()) + .unwrap_err(); + assert!(matches!(err, DpdError::Unwind { .. }), "{err:?}"); + assert_ne!( + source_filter_prefixes(&switch, external_ip), + stored_sources + ); + { + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.contains(&external_ip)); + assert!(mcast.pending_repairs.contains(&underlay_key)); + } + + let res = get_group(&switch, external_ip); + assert!( + matches!(res, Err(DpdError::McastRepairPending(_))), + "{res:?}" + ); + let res = modify_group_external(&switch, group, tag, update()); + assert!( + matches!(res, Err(DpdError::McastRepairPending(_))), + "{res:?}" + ); + let listed = get_range(&switch, None, 1, Some(tag)).unwrap(); + assert_eq!(serde_json::to_value(&listed[0]).unwrap(), stored); + + switch.asic_hdl.clear_table_faults(source_table, TableOp::EntryDel); + assert_eq!( + serde_json::to_value(get_group(&switch, external_ip).unwrap()) + .unwrap(), + stored, + ); + assert_eq!( + source_filter_prefixes(&switch, external_ip), + stored_sources + ); + assert_eq!(route_action(&switch, external_ip), stored_route); + + get_group(&switch, underlay_key.ip()).unwrap(); + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + } + } + + #[test] + fn test_external_delete_removes_untracked_source_filters() { + for (group_ip, source_ip, stray_ip) in [ + ("224.1.2.3", "192.0.2.1", "192.0.2.2"), + ("ff0e::1", "2001:db8::1", "2001:db8::2"), + ] { + let external_ip: IpAddr = group_ip.parse().unwrap(); + let (switch, _logs) = chaos_switch(Config::default()); + let (underlay_ip, _) = install_underlay_group(&switch); + let tag = "external-delete-filters-test"; + add_group_external( + &switch, + external_entry( + external_ip, + tag, + external_nat_target(external_ip, underlay_ip), + None, + serde_json::json!([{ "Exact": source_ip }]), + ), + ) + .unwrap(); + set_source_filter( + &switch, + external_ip, + IpSrc::Exact(stray_ip.parse().unwrap()), + ) + .unwrap(); + assert_eq!(source_filter_prefixes(&switch, external_ip).len(), 2); + + del_group(&switch, external_ip, tag).unwrap(); + assert!(source_filter_prefixes(&switch, external_ip).is_empty()); + } + } + + #[test] + fn test_external_delete_vlan_cleanup_failure_marks_group() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + let external_ip: IpAddr = group_ip.parse().unwrap(); + let (switch, _logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let tag = "external-delete-vlan-test"; + add_group_external( + &switch, + external_entry( + external_ip, + tag, + external_nat_target(external_ip, underlay_ip), + Some(10), + serde_json::Value::Null, + ), + ) + .unwrap(); + let stored = + serde_json::to_value(get_group(&switch, external_ip).unwrap()) + .unwrap(); + + switch.asic_hdl.inject_table_faults( + TableType::McastEgressDecapPorts, + TableOp::EntryUpdate, + CallRange::starting_at(1), + ); + let err = del_group(&switch, external_ip, tag).unwrap_err(); + assert!(matches!(err, DpdError::Unwind { .. }), "{err:?}"); + { + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.groups.contains_key(&external_ip)); + assert!(mcast.pending_repairs.contains(&external_ip)); + assert!(mcast.pending_repairs.contains(&underlay_key)); + } + + switch.asic_hdl.clear_table_faults( + TableType::McastEgressDecapPorts, + TableOp::EntryUpdate, + ); + assert_eq!( + serde_json::to_value(get_group(&switch, external_ip).unwrap()) + .unwrap(), + stored, + ); + get_group(&switch, underlay_key.ip()).unwrap(); + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + + del_group(&switch, external_ip, tag).unwrap(); + assert!(source_filter_prefixes(&switch, external_ip).is_empty()); + assert!(route_action(&switch, external_ip).is_none()); + } + } + + #[test] + fn test_underlay_create_unwind_failure() { + let members = membership_members(); + let (switch, _logs) = chaos_switch(Config::default()); + let create = || MulticastGroupCreateUnderlayEntry { + group_ip: membership_group_ip(), + tag: Some(MEMBERSHIP_TEST_TAG.to_string()), + members: members[..2].to_vec(), + }; + switch.asic_hdl.inject_table_faults( + TableType::RouteIpv6Mcast, + TableOp::EntryAdd, + CallRange::nth(1), + ); + switch.asic_hdl.inject_table_faults( + TableType::McastIpv6, + TableOp::EntryDel, + CallRange::nth(1), + ); + + let err = add_group_underlay(&switch, create()).unwrap_err(); + assert!(matches!(err, DpdError::Unwind { .. }), "{err:?}"); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 1); + + let key = GroupKey::underlay(membership_group_ip()); + let retained = { + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.contains(&key)); + let group = &mcast.groups[&key]; + [group.external_group_id(), group.underlay_group_id()] + }; + + let err = add_group_underlay(&switch, create()).unwrap_err(); + assert!(matches!(err, DpdError::Exists(_)), "{err:?}"); + + let other = add_group_underlay( + &switch, + MulticastGroupCreateUnderlayEntry { + group_ip: "ff04::124".parse().unwrap(), + tag: Some(MEMBERSHIP_TEST_TAG.to_string()), + members: Vec::new(), + }, + ) + .unwrap(); + assert!(!retained.contains(&other.external_group_id)); + assert!(!retained.contains(&other.underlay_group_id)); + + switch.asic_hdl.inject_table_faults( + TableType::RouteIpv6Mcast, + TableOp::EntryAdd, + CallRange::starting_at(1), + ); + let err = + get_group_underlay(&switch, membership_group_ip()).unwrap_err(); + assert!(matches!(err, DpdError::McastRepairPending(_)), "{err:?}"); + { + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.contains(&key)); + let group = &mcast.groups[&key]; + assert_eq!( + [group.external_group_id(), group.underlay_group_id()], + retained, + ); + assert_eq!( + group.members().cloned().collect::>(), + members[..2], + ); + } + + switch + .asic_hdl + .clear_table_faults(TableType::RouteIpv6Mcast, TableOp::EntryAdd); + switch + .asic_hdl + .clear_table_faults(TableType::McastIpv6, TableOp::EntryDel); + let response = + get_group_underlay(&switch, membership_group_ip()).unwrap(); + assert_eq!( + [response.external_group_id, response.underlay_group_id], + retained, + ); + assert!(response.members.is_empty()); + assert_eq!( + asic_membership(&switch), + [Vec::::new(), Vec::new()], + ); + assert!(bitmap_entry(&switch, retained[0]).is_none()); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 0); + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + assert!(route_action(&switch, key.ip()).is_some()); + + del_group(&switch, key.ip(), MEMBERSHIP_TEST_TAG).unwrap(); + assert!(route_action(&switch, key.ip()).is_none()); + let mcast = switch.mcast.lock().unwrap(); + assert!(!mcast.groups.contains_key(&key)); + let free_group_ids = mcast.free_group_ids.lock().unwrap(); + for group_id in retained { + assert!(!switch.asic_hdl.mc_group_exists(group_id)); + assert!(free_group_ids.contains(&group_id)); + } + } + + #[test] + fn test_repair_pending_group_allows_tag_read_and_delete() { + let members = membership_members(); + let (switch, _logs) = chaos_switch(Config::default()); + create_membership_group(&switch, &members[..2]); + let key = GroupKey::underlay(membership_group_ip()); + switch.mcast.lock().unwrap().pending_repairs.insert(key); + switch + .asic_hdl + .inject_mc_faults(McOp::McPortList, CallRange::starting_at(1)); + + let err = + get_group_underlay(&switch, membership_group_ip()).unwrap_err(); + assert!(matches!(err, DpdError::McastRepairPending(_)), "{err:?}"); + let http_err = dropshot::HttpError::from(err); + assert_eq!(http_err.status_code.as_u16(), 503); + assert_eq!( + http_err.error_code.as_deref(), + Some(common::MCAST_REPAIR_PENDING_ERROR_CODE) + ); + assert_eq!( + http_err + .headers + .as_ref() + .and_then(|headers| headers.get("retry-after")) + .and_then(|value| value.to_str().ok()), + Some("10") + ); + let tag = group_tag(&switch, key.ip()).unwrap(); + assert_eq!(tag, MEMBERSHIP_TEST_TAG); + + del_group(&switch, key.ip(), &tag).unwrap(); + let mcast = switch.mcast.lock().unwrap(); + assert!(!mcast.groups.contains_key(&key)); + assert!(mcast.pending_repairs.is_empty()); + } + + #[test] + fn test_underlay_create_group_failure_keeps_group_ids() { + let (switch, _logs) = chaos_switch(Config::default()); + switch + .asic_hdl + .inject_mc_faults(McOp::McGroupCreate, CallRange::nth(2)); + switch + .asic_hdl + .inject_mc_faults(McOp::McGroupDestroy, CallRange::nth(1)); + + let err = add_group_underlay( + &switch, + MulticastGroupCreateUnderlayEntry { + group_ip: membership_group_ip(), + tag: Some(MEMBERSHIP_TEST_TAG.to_string()), + members: Vec::new(), + }, + ) + .unwrap_err(); + assert!(matches!(err, DpdError::Unwind { .. }), "{err:?}"); + + let key = GroupKey::underlay(membership_group_ip()); + let [external_id, underlay_id] = { + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.contains(&key)); + let group = &mcast.groups[&key]; + [group.external_group_id(), group.underlay_group_id()] + }; + assert!(switch.asic_hdl.mc_group_exists(external_id)); + assert!(!switch.asic_hdl.mc_group_exists(underlay_id)); + + switch.asic_hdl.clear_mc_faults(McOp::McGroupCreate); + switch.asic_hdl.clear_mc_faults(McOp::McGroupDestroy); + get_group_underlay(&switch, membership_group_ip()).unwrap(); + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + assert!(switch.asic_hdl.mc_group_exists(underlay_id)); + + del_group(&switch, key.ip(), MEMBERSHIP_TEST_TAG).unwrap(); + assert!(!switch.asic_hdl.mc_group_exists(external_id)); + assert!(!switch.asic_hdl.mc_group_exists(underlay_id)); + } + + #[test] + fn test_external_create_failed_unwind_keeps_target_reservation() { + for (group_ip, other_ip) in + [("224.1.2.3", "224.1.2.4"), ("ff0e::1", "ff0e::2")] + { + let external_ip: IpAddr = group_ip.parse().unwrap(); + let other_ip: IpAddr = other_ip.parse().unwrap(); + let (route_table, _, nat_table) = external_tables(external_ip); + let (switch, _logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-create-reservation-test"; + let create = || { + external_entry( + external_ip, + tag, + nat_target, + None, + serde_json::Value::Null, + ) + }; + switch.asic_hdl.inject_table_faults( + route_table, + TableOp::EntryAdd, + CallRange::nth(1), + ); + switch.asic_hdl.inject_table_faults( + nat_table, + TableOp::EntryDel, + CallRange::nth(1), + ); + + let err = add_group_external(&switch, create()).unwrap_err(); + assert!(matches!(err, DpdError::Unwind { .. }), "{err:?}"); + assert_eq!(table_usage(&switch, nat_table).0, 1); + let retained = { + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.contains(&external_ip)); + assert!(mcast.pending_repairs.contains(&underlay_key)); + assert_eq!( + mcast.nat_target_refs.get(&underlay_ip).map(GroupKey::ip), + Some(external_ip), + ); + serde_json::to_value( + MulticastGroupExternalResponse::try_from( + &mcast.groups[&external_ip], + ) + .unwrap(), + ) + .unwrap() + }; + + let err = add_group_external(&switch, create()).unwrap_err(); + assert!(matches!(err, DpdError::Exists(_)), "{err:?}"); + + assert!( + add_group_external( + &switch, + external_entry( + other_ip, + tag, + external_nat_target(other_ip, underlay_ip), + None, + serde_json::Value::Null, + ), + ) + .is_err() + ); + let underlay_tag = + underlay_tag_and_members(&switch, underlay_key).0; + let err = del_group(&switch, underlay_key.ip(), &underlay_tag) + .unwrap_err(); + assert!( + matches!(err, DpdError::Invalid(ref message) + if message.contains("still referenced")), + "{err:?}" + ); + + switch.asic_hdl.inject_table_faults( + route_table, + TableOp::EntryAdd, + CallRange::starting_at(1), + ); + let err = get_group(&switch, external_ip).unwrap_err(); + assert!(matches!(err, DpdError::McastRepairPending(_)), "{err:?}"); + { + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.contains(&external_ip)); + assert_eq!( + mcast.nat_target_refs.get(&underlay_ip).map(GroupKey::ip), + Some(external_ip), + ); + } + + switch.asic_hdl.clear_table_faults(route_table, TableOp::EntryAdd); + switch.asic_hdl.clear_table_faults(nat_table, TableOp::EntryDel); + let MulticastGroupResponse::External(response) = + get_group(&switch, external_ip).unwrap() + else { + panic!("expected an external response"); + }; + assert_eq!(serde_json::to_value(response).unwrap(), retained); + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + assert_eq!(table_usage(&switch, nat_table).0, 1); + assert_eq!( + source_filter_prefixes(&switch, external_ip), + BTreeSet::from([if external_ip.is_ipv4() { + "0.0.0.0/0".to_string() + } else { + "::/0".to_string() + }]), + ); + assert_eq!( + route_action(&switch, external_ip), + Some(("forward".to_string(), None)), + ); + + del_group(&switch, external_ip, tag).unwrap(); + assert_eq!(table_usage(&switch, nat_table).0, 0); + assert!(source_filter_prefixes(&switch, external_ip).is_empty()); + assert!(route_action(&switch, external_ip).is_none()); + let mcast = switch.mcast.lock().unwrap(); + assert!(!mcast.groups.contains_key(&external_ip)); + assert!(mcast.nat_target_refs.is_empty()); + assert!(mcast.pending_repairs.is_empty()); + } + } + + #[test] + fn test_external_create_vlan_unwind_failure() { + let external_ip: IpAddr = "ff0e::1".parse().unwrap(); + let (switch, _logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (_, members) = underlay_tag_and_members(&switch, underlay_key); + let external_id = switch.mcast.lock().unwrap().groups[&underlay_key] + .external_group_id(); + table::mcast::mcast_egress::clear_bitmap_entry(&switch, external_id) + .unwrap(); + switch.asic_hdl.inject_table_faults( + TableType::McastIpv6, + TableOp::EntryUpdate, + CallRange::nth(1), + ); + switch.asic_hdl.inject_table_faults( + TableType::McastEgressDecapPorts, + TableOp::EntryUpdate, + CallRange::nth(1), + ); + + let tag = "external-create-vlan-test"; + let err = add_group_external( + &switch, + external_entry( + external_ip, + tag, + external_nat_target(external_ip, underlay_ip), + Some(10), + serde_json::Value::Null, + ), + ) + .unwrap_err(); + assert!(matches!(err, DpdError::Unwind { .. }), "{err:?}"); + let bitmap = create_port_bitmap(&members, Direction::External); + assert_eq!( + bitmap_entry(&switch, external_id), + Some((bitmap.clone(), Some(10))) + ); + { + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.contains(&underlay_key)); + assert!(mcast.pending_repairs.contains(&external_ip)); + } + + switch + .asic_hdl + .clear_table_faults(TableType::McastIpv6, TableOp::EntryUpdate); + switch.asic_hdl.clear_table_faults( + TableType::McastEgressDecapPorts, + TableOp::EntryUpdate, + ); + get_group(&switch, external_ip).unwrap(); + get_group(&switch, underlay_key.ip()).unwrap(); + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 1); + assert_eq!( + bitmap_entry(&switch, external_id), + Some((bitmap.clone(), Some(10))) + ); + + del_group(&switch, external_ip, tag).unwrap(); + assert_eq!(bitmap_entry(&switch, external_id), Some((bitmap, None))); + } + + #[test] + fn test_external_create_repairs_marked_target() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + let external_ip: IpAddr = group_ip.parse().unwrap(); + let (switch, _logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-create-repair-test"; + let create = || { + external_entry( + external_ip, + tag, + nat_target, + None, + serde_json::Value::Null, + ) + }; + + table::mcast::mcast_route::clear_ipv6_entry( + &switch, + underlay_ip.into(), + ) + .unwrap(); + switch.mcast.lock().unwrap().pending_repairs.insert(underlay_key); + switch + .asic_hdl + .inject_mc_faults(McOp::McPortList, CallRange::starting_at(1)); + + let err = add_group_external(&switch, create()).unwrap_err(); + assert!(matches!(err, DpdError::McastRepairPending(_)), "{err:?}"); + { + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.contains(&underlay_key)); + assert!(!mcast.groups.contains_key(&external_ip)); + assert!(!mcast.nat_target_refs.contains_key(&underlay_ip)); + } + assert!(nat_target_addr(&switch, external_ip).is_none()); + assert!(route_action(&switch, external_ip).is_none()); + assert!(source_filter_prefixes(&switch, external_ip).is_empty()); + assert!(route_action(&switch, underlay_ip.into()).is_none()); + + switch.asic_hdl.clear_mc_faults(McOp::McPortList); + add_group_external(&switch, create()).unwrap(); + + assert!(route_action(&switch, underlay_ip.into()).is_some()); + assert!(route_action(&switch, external_ip).is_some()); + assert_eq!( + nat_target_addr(&switch, external_ip), + Some(Ipv6Addr::from(underlay_ip).to_string()), + ); + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.is_empty()); + assert!(mcast.groups.contains_key(&external_ip)); + assert_eq!( + mcast.nat_target_refs.get(&underlay_ip).map(GroupKey::ip), + Some(external_ip), + ); + } + } + + #[test] + fn test_external_retarget_repairs_marked_target() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + let external_ip: IpAddr = group_ip.parse().unwrap(); + let (switch, _logs) = chaos_switch(Config::default()); + let (underlay_ip, _) = install_underlay_group(&switch); + let (target_ip, target_key) = install_underlay_group_at( + &switch, + "ff04::124".parse().unwrap(), + ); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-retarget-repair-test"; + add_group_external( + &switch, + external_entry( + external_ip, + tag, + nat_target, + None, + serde_json::Value::Null, + ), + ) + .unwrap(); + switch.mcast.lock().unwrap().pending_repairs.insert(target_key); + + let retarget = || MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: NatTarget { + internal_ip: target_ip.into(), + ..nat_target + } + .try_into() + .unwrap(), + }, + external_forwarding: ExternalForwarding { vlan_id: None }, + sources: None, + }; + let group = ExternalMulticastIp::new(external_ip).unwrap(); + + switch + .asic_hdl + .inject_mc_faults(McOp::McPortList, CallRange::starting_at(1)); + let err = modify_group_external(&switch, group, tag, retarget()) + .unwrap_err(); + assert!(matches!(err, DpdError::McastRepairPending(_)), "{err:?}"); + { + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.contains(&target_key)); + assert_eq!( + mcast.nat_target_refs.get(&underlay_ip).map(GroupKey::ip), + Some(external_ip), + ); + assert!(!mcast.nat_target_refs.contains_key(&target_ip)); + } + assert_eq!( + nat_target_addr(&switch, external_ip), + Some(Ipv6Addr::from(underlay_ip).to_string()), + ); + + switch.asic_hdl.clear_mc_faults(McOp::McPortList); + modify_group_external(&switch, group, tag, retarget()).unwrap(); + assert_eq!( + nat_target_addr(&switch, external_ip), + Some(Ipv6Addr::from(target_ip).to_string()), + ); + let mcast = switch.mcast.lock().unwrap(); + assert!(mcast.pending_repairs.is_empty()); + assert_eq!( + mcast.nat_target_refs.get(&target_ip).map(GroupKey::ip), + Some(external_ip), + ); + assert!(!mcast.nat_target_refs.contains_key(&underlay_ip)); + } + } + + #[test] + fn test_external_update_nat_failure_restores_state() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + external_update_failure(group_ip.parse().unwrap(), false); + } + } + + #[test] + fn test_external_retarget_failure_restores_bitmaps() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + external_update_failure(group_ip.parse().unwrap(), true); + } + } + + /// Delete the underlay bitmap entry behind an external group on + /// `external_ip`, then check that updating the group succeeds and re-adds + /// the entry. + /// + /// When `target_changed` is true, the NAT target moves to another underlay + /// group and the old bitmap VLAN is cleared. Otherwise, the VLAN changes + /// on the same underlay group. + fn external_update_readds_missing_bitmap( + external_ip: IpAddr, + target_changed: bool, + ) { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (target_ip, target_key) = if target_changed { + install_underlay_group_at(&switch, "ff04::124".parse().unwrap()) + } else { + (underlay_ip, underlay_key) + }; + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-readd-test"; + + let entry = + serde_json::from_value::( + serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + }), + ) + .unwrap(); + let og = add_group_external(&switch, entry).unwrap(); + + table::mcast::mcast_egress::clear_bitmap_entry( + &switch, + og.external_group_id, + ) + .unwrap(); + let before_bitmap = + table_usage(&switch, TableType::McastEgressDecapPorts); + + modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: NatTarget { + internal_ip: target_ip.into(), + ..nat_target + } + .try_into() + .unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id: Some(if target_changed { 10 } else { 20 }), + }, + sources: None, + }, + ) + .unwrap(); + + let after_bitmap = + table_usage(&switch, TableType::McastEgressDecapPorts); + assert_eq!(after_bitmap.0, before_bitmap.0 + 1, "bitmap not re-added"); + assert_eq!(after_bitmap.1, before_bitmap.1 + 1); + assert_eq!( + table_usage(&switch, TableType::McastIpv6).0, + if target_changed { 2 } else { 1 } + ); + let bitmap_vlan = |key: GroupKey| { + let external_id = + switch.mcast.lock().unwrap().groups[&key].external_group_id(); + bitmap_entry(&switch, external_id).map(|(_, vlan_id)| vlan_id) + }; + if target_changed { + assert_eq!(bitmap_vlan(underlay_key), Some(None)); + assert_eq!(bitmap_vlan(target_key), Some(Some(10))); + } else { + assert_eq!(bitmap_vlan(underlay_key), Some(Some(20))); + } + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_external_vlan_update_readds_missing_bitmap() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + external_update_readds_missing_bitmap( + group_ip.parse().unwrap(), + false, + ); + } + } + + #[test] + fn test_external_retarget_readds_missing_bitmap() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + external_update_readds_missing_bitmap( + group_ip.parse().unwrap(), + true, + ); + } + } + + #[test] + fn test_external_update_failure_keeps_readded_bitmap() { + for group_ip in ["224.1.2.3", "ff0e::1"] { + let external_ip: IpAddr = group_ip.parse().unwrap(); + let (_, _, nat_table) = external_tables(external_ip); + let mut config = Config::default(); + config.asic_config.table_entry_update.values.insert(nat_table, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, _) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-readd-rollback-test"; + + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + })) + .unwrap(); + let og = add_group_external(&switch, entry).unwrap(); + table::mcast::mcast_egress::clear_bitmap_entry( + &switch, + og.external_group_id, + ) + .unwrap(); + + let err = modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: nat_target.try_into().unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id: Some(20), + }, + sources: None, + }, + ) + .unwrap_err(); + assert!(matches!(err, DpdError::Switch(_)), "{err:?}"); + + let dump = + table::mcast::mcast_egress::bitmap_table_dump(&switch, false) + .unwrap(); + assert_eq!(dump.entries.len(), 1, "re-added bitmap was removed"); + assert_eq!( + dump.entries[0].action_args.get("vlan_id").map(String::as_str), + Some("10") + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + + #[test] + fn test_external_create_tables_replace_existing_entries() { + for external_ip in ["239.1.2.3", "ff0e::1"] { + for full in [false, true] { + let (switch, logs) = chaos_switch(Config::default()); + let external_ip: IpAddr = external_ip.parse().unwrap(); + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let old_target = external_nat_target( + external_ip, + "ff04::123".parse().unwrap(), + ); + let entry = external_entry( + external_ip, + "external-create-set-test", + old_target, + Some(10), + serde_json::Value::Null, + ); + let sources = SourceFilter::from(&entry); + configure_external_tables(&switch, &entry, &sources).unwrap(); + + if full { + for table in [route_table, source_table, nat_table] { + let mut table = switch.table_get(table).unwrap(); + table.usage.size = table.usage.occupancy; + } + } + + let new_target = external_nat_target( + external_ip, + "ff04::456".parse().unwrap(), + ); + + for vlan_id in [Some(20), None] { + let entry = external_entry( + external_ip, + "external-create-set-test", + new_target, + vlan_id, + serde_json::Value::Null, + ); + configure_external_tables(&switch, &entry, &sources) + .unwrap(); + + let expected_route = match vlan_id { + Some(vlan_id) => ( + "forward_vlan".to_string(), + Some(vlan_id.to_string()), + ), + None => ("forward".to_string(), None), + }; + assert_eq!( + route_action(&switch, external_ip), + Some(expected_route) + ); + let nat = forwarding_table_snapshot(&switch, nat_table); + assert_eq!( + nat["entries"][0]["action_args"], + serde_json::json!({ + "target": new_target.internal_ip.to_string(), + "inner_mac": new_target.inner_mac.to_string(), + "vni": new_target.vni.as_u32().to_string(), + }) + ); + for table in [route_table, source_table, nat_table] { + assert_eq!(table_usage(&switch, table).0, 1, "{table}"); + } + } + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + } + + #[test] + fn test_external_create_clears_orphaned_entries() { + for (group_ip, stale_source, source) in [ + ("224.1.2.3", "192.0.2.9", "192.0.2.1"), + ("ff0e::1", "2001:db8::9", "2001:db8::1"), + ] { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, _) = install_underlay_group(&switch); + let external_ip: IpAddr = group_ip.parse().unwrap(); + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let nat_target = external_nat_target(external_ip, underlay_ip); + let stale_source: IpAddr = stale_source.parse().unwrap(); + + match (external_ip, stale_source) { + (IpAddr::V4(group), IpAddr::V4(src)) => { + table::mcast::mcast_nat::set_ipv4_entry( + &switch, group, nat_target, + ) + .unwrap(); + table::mcast::mcast_src_filter::set_ipv4_entry( + &switch, + oxnet::Ipv4Net::host_net(src), + group, + ) + .unwrap(); + table::mcast::mcast_route::set_ipv4_entry( + &switch, + group, + Some(10), + ) + .unwrap(); + } + (IpAddr::V6(group), IpAddr::V6(src)) => { + table::mcast::mcast_nat::set_ipv6_entry( + &switch, group, nat_target, + ) + .unwrap(); + table::mcast::mcast_src_filter::set_ipv6_entry( + &switch, + oxnet::Ipv6Net::host_net(src), + group, + ) + .unwrap(); + table::mcast::mcast_route::set_ipv6_entry( + &switch, + group, + Some(10), + ) + .unwrap(); + } + _ => unreachable!(), + } + let before: Vec<_> = [route_table, source_table, nat_table] + .into_iter() + .map(|table| table_usage(&switch, table)) + .collect(); + + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": "external-orphan-test", + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 20 }, + "sources": [{ "Exact": source }], + })) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + + for (table, before) in + [route_table, source_table, nat_table].into_iter().zip(before) + { + assert_eq!( + table_usage(&switch, table), + (before.0, before.1 + 1, before.2 + 1, before.3), + "{table}" + ); + } + let nat_dump = match external_ip { + IpAddr::V4(_) => { + table::mcast::mcast_nat::ipv4_table_dump(&switch, false) + } + IpAddr::V6(_) => { + table::mcast::mcast_nat::ipv6_table_dump(&switch, false) + } + } + .unwrap(); + assert_eq!(nat_dump.entries.len(), 1); + assert_eq!( + route_action(&switch, external_ip), + Some(("forward_vlan".to_string(), Some("20".to_string()))) + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + + #[test] + fn test_underlay_update_readds_missing_entries() { + for membership_changed in [false, true] { + for (repl_missing, bitmap_missing) in + [(true, false), (false, true), (true, true)] + { + let context = format!( + "membership_changed={membership_changed}, \ + repl_missing={repl_missing}, \ + bitmap_missing={bitmap_missing}" + ); + let (switch, logs) = chaos_switch(Config::default()); + let free_ids = { + let groups = switch.mcast.lock().unwrap(); + groups.free_group_ids.lock().unwrap().len() + }; + let (underlay_ip, underlay_key) = + install_underlay_group(&switch); + let (tag, members) = + underlay_tag_and_members(&switch, underlay_key); + let external_id = switch.mcast.lock().unwrap().groups + [&underlay_key] + .external_group_id(); + + if repl_missing { + table::mcast::mcast_replication::clear_ipv6_entry( + &switch, + underlay_ip.into(), + ) + .unwrap(); + } + if bitmap_missing { + table::mcast::mcast_egress::clear_bitmap_entry( + &switch, + external_id, + ) + .unwrap(); + } + assert_eq!( + table_usage(&switch, TableType::McastIpv6).0, + u32::from(!repl_missing), + "{context}" + ); + assert_eq!( + table_usage(&switch, TableType::McastEgressDecapPorts).0, + u32::from(!bitmap_missing), + "{context}" + ); + + let requested_members = if membership_changed { + let kept: Vec<_> = members + .iter() + .filter(|member| { + member.direction == Direction::Underlay + }) + .cloned() + .collect(); + kept + } else { + members + }; + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { + members: requested_members.clone(), + }, + ) + .unwrap(); + assert_eq!(response.members, requested_members, "{context}"); + assert_eq!( + bitmap_entry(&switch, external_id), + Some(( + create_port_bitmap( + &requested_members, + Direction::External, + ), + None, + )), + "{context}" + ); + + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.contains_key(&underlay_key)); + assert_eq!( + groups.free_group_ids.lock().unwrap().len(), + free_ids - 2, + "{context}" + ); + for table in [ + TableType::McastIpv6, + TableType::RouteIpv6Mcast, + TableType::McastEgressDecapPorts, + ] { + assert_eq!( + table_usage(&switch, table).0, + 1, + "{context}: {table}" + ); + } + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + } + + fn external_update_after_underlay_abandoned(target_changed: bool) { + let (switch, _logs) = chaos_switch(Config::default()); + let free_ids = { + let groups = switch.mcast.lock().unwrap(); + groups.free_group_ids.lock().unwrap().len() + }; + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + + let external_ip: IpAddr = "ff0e::1".parse().unwrap(); + let group_ip = ExternalMulticastIp::new(external_ip).unwrap(); + let external_key = GroupKey::external(group_ip); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-abandon-test"; + let entry = + serde_json::from_value::( + serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + }), + ) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + + let underlay = + switch.mcast.lock().unwrap().groups.remove(&underlay_key).unwrap(); + let ipv6 = Ipv6Addr::from(underlay_ip); + table::mcast::mcast_egress::clear_bitmap_entry( + &switch, + underlay.external_group_id(), + ) + .unwrap(); + table::mcast::mcast_replication::clear_ipv6_entry(&switch, ipv6) + .unwrap(); + table::mcast::mcast_route::clear_ipv6_entry(&switch, ipv6).unwrap(); + drop(underlay); + + let update = |internal_ip: UnderlayMulticastIpv6| { + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: NatTarget { + internal_ip: internal_ip.into(), + ..nat_target + } + .try_into() + .unwrap(), + }, + external_forwarding: ExternalForwarding { vlan_id: None }, + sources: None, + } + }; + + let err = + modify_group_external(&switch, group_ip, tag, update(underlay_ip)) + .unwrap_err(); + assert!(matches!(err, DpdError::MissingNatTarget(_)), "{err:?}"); + + let (target_ip, target_key) = if target_changed { + install_underlay_group_at(&switch, "ff04::124".parse().unwrap()) + } else { + install_underlay_group_at(&switch, underlay_ip) + }; + + let response = + modify_group_external(&switch, group_ip, tag, update(target_ip)) + .unwrap(); + let groups = switch.mcast.lock().unwrap(); + let target = &groups.groups[&target_key]; + assert_eq!(response.external_group_id, target.external_group_id()); + assert_eq!( + groups.groups[&external_key].underlay_group_id(), + target.underlay_group_id() + ); + assert_eq!(groups.nat_target_refs.len(), 1); + assert_eq!(groups.nat_target_refs.get(&target_ip), Some(&external_key)); + assert_eq!(groups.free_group_ids.lock().unwrap().len(), free_ids - 2); + } + + /// Scenarios for which emptying an underlay group's membership fails in + /// [`underlay_rollback_restores_propagated_vlan`]. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum VlanRestoreCase { + /// Deleting the replication entry fails. + ReplicationDelFails, + /// The bitmap entry is missing before the update. + BitmapMissing, + /// The bitmap table has no room to re-add the entry. + BitmapTableFull, + /// Deleting the bitmap entry fails. + BitmapDelFails, + } + + /// Check the bitmap VLAN after a failed attempt to remove all underlay + /// members. VLAN 10 comes from the referencing external group. + /// + /// `port_add_fails` prevents rollback from restoring removed members. + fn underlay_rollback_restores_propagated_vlan( + case: VlanRestoreCase, + port_add_fails: bool, + ) { + let context = format!("{case:?}, port_add_fails: {port_add_fails}"); + let mut config = Config::default(); + if case != VlanRestoreCase::BitmapDelFails { + config + .asic_config + .table_entry_del + .values + .insert(TableType::McastIpv6, 1.0); + } + let (switch, _logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + + let external_ip: IpAddr = "ff0e::1".parse().unwrap(); + let nat_target = external_nat_target(external_ip, underlay_ip); + let entry = + serde_json::from_value::( + serde_json::json!({ + "group_ip": external_ip, + "tag": "external-vlan-rollback-test", + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + }), + ) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + + let external_group_id = switch.mcast.lock().unwrap().groups + [&underlay_key] + .external_group_id(); + let (underlay_tag, members) = + underlay_tag_and_members(&switch, underlay_key); + let original_bitmap = + (create_port_bitmap(&members, Direction::External), Some(10)); + assert_eq!( + bitmap_entry(&switch, external_group_id), + Some(original_bitmap.clone()) + ); + + match case { + VlanRestoreCase::ReplicationDelFails => {} + VlanRestoreCase::BitmapMissing => { + table::mcast::mcast_egress::clear_bitmap_entry( + &switch, + external_group_id, + ) + .unwrap(); + assert!(bitmap_entry(&switch, external_group_id).is_none()); + } + VlanRestoreCase::BitmapTableFull => { + let mut bitmap = + switch.table_get(TableType::McastEgressDecapPorts).unwrap(); + bitmap.usage.size = bitmap.usage.occupancy; + } + VlanRestoreCase::BitmapDelFails => { + switch.asic_hdl.inject_table_faults( + TableType::McastEgressDecapPorts, + TableOp::EntryDel, + CallRange::nth(1), + ); + } + } + if port_add_fails { + switch + .asic_hdl + .inject_mc_faults(McOp::McPortAdd, CallRange::starting_at(1)); + } + + let (occupancy, inserts, deletes, updates) = + table_usage(&switch, TableType::McastEgressDecapPorts); + + let err = modify_group_underlay( + &switch, + underlay_ip, + &underlay_tag, + MulticastGroupUpdateUnderlayEntry { members: Vec::new() }, + ) + .unwrap_err(); + + let init = match &err { + DpdError::Unwind { initial: init, unwind } => { + assert!( + port_add_fails + && unwind.len() == 1 + && matches!( + &unwind.head, + DpdError::Switch(aal::AsicError::Synthetic(op)) + if op == "mc_port_add" + ), + "{context}: {err:?}" + ); + init.as_ref() + } + err => { + assert!(!port_add_fails, "{context}: {err:?}"); + err + } + }; + assert!( + matches!( + init, + DpdError::Switch(aal::AsicError::Synthetic(op)) + if op == "table_entry_del" + ), + "{context}: {err:?}" + ); + + let resynced_empty = + case == VlanRestoreCase::BitmapDelFails && port_add_fails; + let expected_usage = match case { + VlanRestoreCase::BitmapMissing => { + (occupancy + 1, inserts + 1, deletes, updates) + } + _ if resynced_empty => { + (occupancy - 1, inserts, deletes + 1, updates + 1) + } + _ => (occupancy, inserts, deletes, updates + 1), + }; + assert_eq!( + table_usage(&switch, TableType::McastEgressDecapPorts), + expected_usage, + "{context}" + ); + + let (expected_bitmap, expected_members, expected_repl) = + if resynced_empty { + (None, Vec::new(), 0) + } else { + (Some(original_bitmap), members, 1) + }; + assert_eq!( + bitmap_entry(&switch, external_group_id), + expected_bitmap, + "{context}" + ); + assert_eq!( + table_usage(&switch, TableType::McastIpv6).0, + expected_repl, + "{context}" + ); + assert_eq!( + underlay_tag_and_members(&switch, underlay_key).1, + expected_members, + "{context}" + ); + let read = get_group_underlay(&switch, underlay_ip); + if port_add_fails && !resynced_empty { + assert!( + matches!(read, Err(DpdError::McastRepairPending(_))), + "{context}" + ); + } else { + assert_eq!(read.unwrap().members, expected_members, "{context}"); + } + } + + #[test] + fn test_underlay_rollback_restores_propagated_vlan() { + for case in [ + VlanRestoreCase::ReplicationDelFails, + VlanRestoreCase::BitmapMissing, + VlanRestoreCase::BitmapTableFull, + VlanRestoreCase::BitmapDelFails, + ] { + for port_add_fails in [false, true] { + underlay_rollback_restores_propagated_vlan( + case, + port_add_fails, + ); + } + } + } + + #[test] + fn test_underlay_create_tables_replace_existing_entries() { + for full in [false, true] { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let group = + switch.mcast.lock().unwrap().groups[&underlay_key].clone(); + let external_id = group.external_group_id(); + let underlay_id = group.underlay_group_id(); + let members = vec![rear_member(2, Direction::External)]; + let info = MulticastReplicationInfo { + rid: external_id, + level1_excl_id: 7, + level2_excl_id: 9, + }; + let tables = [ + TableType::McastEgressDecapPorts, + TableType::McastIpv6, + TableType::RouteIpv6Mcast, + ]; + if full { + for table in tables { + let mut table = switch.table_get(table).unwrap(); + table.usage.size = table.usage.occupancy; + } + } + + for vlan_id in [Some(20), None] { + configure_underlay_tables( + &switch, + underlay_ip, + external_id, + underlay_id, + Some(&info), + &members, + vlan_id, + ) + .unwrap(); + + assert_eq!( + bitmap_entry(&switch, external_id), + Some(( + create_port_bitmap(&members, Direction::External), + vlan_id, + )) + ); + let repl = + forwarding_table_snapshot(&switch, TableType::McastIpv6); + assert_eq!( + repl["entries"][0]["action_args"], + serde_json::json!({ + "mcast_grp_a": external_id.to_string(), + "mcast_grp_b": underlay_id.to_string(), + "rid": info.rid.to_string(), + "level1_excl_id": info.level1_excl_id.to_string(), + "level2_excl_id": info.level2_excl_id.to_string(), + }) + ); + assert_eq!( + route_action(&switch, underlay_ip.into()), + Some(("forward".to_string(), None)) + ); + for table in tables { + assert_eq!(table_usage(&switch, table).0, 1, "{table}"); + } + } + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + + #[test] + fn test_underlay_first_members_overwrite_stale_bitmap() { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_empty_underlay_group(&switch); + let group = switch.mcast.lock().unwrap().groups[&underlay_key].clone(); + + table::mcast::mcast_egress::set_bitmap_entry( + &switch, + group.external_group_id(), + &table::mcast::mcast_egress::PortBitmap::new(), + Some(10), + ) + .unwrap(); + + { + let mut bitmap = + switch.table_get(TableType::McastEgressDecapPorts).unwrap(); + bitmap.usage.size = bitmap.usage.occupancy; + } + + let [external_member, underlay_member, _] = membership_members(); + let members = vec![external_member, underlay_member]; + let response = modify_group_underlay( + &switch, + underlay_ip, + &group.tag, + MulticastGroupUpdateUnderlayEntry { members: members.clone() }, + ) + .unwrap(); + assert_eq!(response.members, members); + + assert_eq!(stored_membership(&switch), members); + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &members) + ); + assert_eq!( + bitmap_entry(&switch, group.external_group_id()), + Some((create_port_bitmap(&members, Direction::External), None)) + ); + assert_eq!(table_usage(&switch, TableType::McastEgressDecapPorts).0, 1); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 1); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_del_empty_underlay_clears_stale_entries() { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_empty_underlay_group(&switch); + let group = switch.mcast.lock().unwrap().groups[&underlay_key].clone(); + let repl = MulticastReplicationInfo::new(group.external_group_id()); + + table::mcast::mcast_egress::set_bitmap_entry( + &switch, + group.external_group_id(), + &table::mcast::mcast_egress::PortBitmap::new(), + None, + ) + .unwrap(); + table::mcast::mcast_replication::set_ipv6_entry( + &switch, + underlay_ip.into(), + group.underlay_group_id(), + group.external_group_id(), + repl.rid, + repl.level1_excl_id, + repl.level2_excl_id, + ) + .unwrap(); + + del_group(&switch, underlay_key.ip(), &group.tag).unwrap(); + + for table in [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + ] { + assert_eq!(table_usage(&switch, table), (0, 1, 1, 0), "{table}"); + } + assert!(switch.mcast.lock().unwrap().groups.is_empty()); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_underlay_create_clears_stale_entries() { + let (switch, _logs) = chaos_switch(Config::default()); + let free_ids = { + let groups = switch.mcast.lock().unwrap(); + groups.free_group_ids.lock().unwrap().len() + }; + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + drop(switch.mcast.lock().unwrap().groups.remove(&underlay_key)); + switch + .asic_hdl + .inject_mc_faults(McOp::McGroupCreate, CallRange::starting_at(1)); + + let err = add_group_underlay( + &switch, + MulticastGroupCreateUnderlayEntry { + group_ip: underlay_ip, + tag: Some("underlay-stale-test".to_string()), + members: Vec::new(), + }, + ) + .unwrap_err(); + assert!( + matches!(err, DpdError::Switch(aal::AsicError::Synthetic(_))), + "{err:?}" + ); + + for table in [TableType::McastIpv6, TableType::RouteIpv6Mcast] { + assert_eq!(table_usage(&switch, table), (0, 1, 1, 0), "{table}"); + } + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.is_empty()); + assert_eq!(groups.free_group_ids.lock().unwrap().len(), free_ids); + } + + #[test] + fn test_external_update_after_underlay_abandoned_recreate() { + external_update_after_underlay_abandoned(false); + } + + #[test] + fn test_external_update_after_underlay_abandoned_retarget() { + external_update_after_underlay_abandoned(true); + } + + #[test] + fn test_external_create_rejects_deleted_underlay() { + let (switch, logs) = chaos_switch(Config::default()); + let free_ids = { + let groups = switch.mcast.lock().unwrap(); + groups.free_group_ids.lock().unwrap().len() + }; + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, _) = underlay_tag_and_members(&switch, underlay_key); + del_group(&switch, underlay_key.ip(), &tag).unwrap(); + + let tables = [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + TableType::RouteIpv4Mcast, + TableType::McastIpv4SrcFilter, + TableType::McastIpv6SrcFilter, + TableType::NatIngressIpv4Mcast, + TableType::NatIngressIpv6Mcast, + ]; + + let before: BTreeMap<_, _> = tables + .into_iter() + .map(|table| (table, table_usage(&switch, table))) + .collect(); + + for external_ip in ["239.1.2.3", "ff0e::1"] { + let external_ip: IpAddr = external_ip.parse().unwrap(); + let entry = external_entry( + external_ip, + "external-deleted-underlay-test", + external_nat_target(external_ip, underlay_ip), + Some(10), + serde_json::Value::Null, + ); + + let err = add_group_external(&switch, entry).unwrap_err(); + assert!(matches!(err, DpdError::MissingNatTarget(_)), "{err:?}"); + let http_err = dropshot::HttpError::from(err); + assert_eq!(http_err.status_code.as_u16(), 409); + assert_eq!( + http_err.error_code.as_deref(), + Some(common::MISSING_NAT_TARGET_ERROR_CODE) + ); + } + + for (table, usage) in before { + assert_eq!(table_usage(&switch, table), usage, "{table}"); + } + + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.is_empty()); + assert!(groups.nat_target_refs.is_empty()); + assert_eq!(groups.free_group_ids.lock().unwrap().len(), free_ids); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_del_external_nat_failure_keeps_underlay_vlan() { + let external_ip: IpAddr = "239.1.2.3".parse().unwrap(); + let (_, source_table, nat_table) = external_tables(external_ip); + let mut config = Config::default(); + config.asic_config.table_entry_del.values.insert(nat_table, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, _) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-del-nat-failure-test"; + + let entry = + serde_json::from_value::( + serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + }), + ) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + + let before_decap = + table_usage(&switch, TableType::McastEgressDecapPorts); + assert_eq!(before_decap, (1, 1, 0, 1)); + let before_filters = table_usage(&switch, source_table); + assert_eq!(before_filters.0, 1); + + let err = del_group(&switch, external_ip, tag).unwrap_err(); + assert!(matches!(err, DpdError::Switch(_)), "{err:?}"); + + assert_eq!( + table_usage(&switch, TableType::McastEgressDecapPorts), + before_decap, + "underlay VLAN was cleared" + ); + assert_eq!(table_usage(&switch, nat_table), (1, 1, 0, 0)); + assert_eq!( + table_usage(&switch, source_table), + (1, 2, 1, 0), + "source filters were not restored" + ); + + let external_key = + GroupKey::external(ExternalMulticastIp::new(external_ip).unwrap()); + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.contains_key(&external_key)); + assert_eq!( + groups.nat_target_refs.get(&underlay_ip), + Some(&external_key) + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_underlay_bitmap_failure_skips_repl_and_route() { + let mut config = Config::default(); + config + .asic_config + .table_entry_add + .values + .insert(TableType::McastEgressDecapPorts, 1.0); + + let (switch, logs) = chaos_switch(config); + let underlay_ip: UnderlayMulticastIpv6 = "ff04::123".parse().unwrap(); + let (external_scoped_group, underlay_scoped_group) = { + let mut groups = switch.mcast.lock().unwrap(); + ( + groups.generate_group_id().unwrap(), + groups.generate_group_id().unwrap(), + ) + }; + let repl = MulticastReplicationInfo::new(external_scoped_group.id()); + let members = vec![MulticastGroupMember { + port_id: RearPort::new(0).unwrap().into(), + link_id: LinkId(0), + direction: Direction::External, + }]; + + let before: Vec<_> = [ + TableType::McastEgressDecapPorts, + TableType::McastIpv6, + TableType::RouteIpv6Mcast, + ] + .into_iter() + .map(|table| (table, table_usage(&switch, table))) + .collect(); + + let err = configure_underlay_tables( + &switch, + underlay_ip, + external_scoped_group.id(), + underlay_scoped_group.id(), + Some(&repl), + &members, + Some(10), + ) + .unwrap_err(); + assert!(matches!(err, DpdError::Switch(_)), "{err:?}"); + + for (table, before) in before { + assert_eq!(table_usage(&switch, table), before, "{table}"); + } + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_reset_tag_underlay_failure_after_externals() { + let mut config = Config::default(); + config.asic_config.mc_config.mc_group_destroy = + asic::chaos::Chaos::new(1.0); + + let (switch, logs) = chaos_switch(config); + let tag = "reset-tag-failure-test"; + let underlay_ips: [UnderlayMulticastIpv6; 2] = + ["ff04::123", "ff04::124"].map(|ip| ip.parse().unwrap()); + let external_ips: [IpAddr; 2] = + ["239.1.2.3", "ff0e::1"].map(|ip| ip.parse().unwrap()); + let other_ip: UnderlayMulticastIpv6 = "ff04::125".parse().unwrap(); + + for group_ip in underlay_ips.into_iter().chain([other_ip]) { + let group_tag = + if group_ip == other_ip { "reset-tag-other-test" } else { tag }; + add_group_underlay( + &switch, + MulticastGroupCreateUnderlayEntry { + group_ip, + tag: Some(group_tag.to_string()), + members: Vec::new(), + }, + ) + .unwrap(); + } + + for (external_ip, underlay_ip) in + external_ips.into_iter().zip(underlay_ips) + { + let nat_target = external_nat_target(external_ip, underlay_ip); + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": null }, + "sources": null, + })) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + } + + let err = reset_tag(&switch, tag).unwrap_err(); + assert!(matches!(err, DpdError::Switch(_)), "{err:?}"); + + let groups = switch.mcast.lock().unwrap(); + for underlay_ip in underlay_ips { + assert!(groups.groups.contains_key(&IpAddr::from(underlay_ip))); + } + assert!(groups.groups.contains_key(&IpAddr::from(other_ip))); + assert_eq!(groups.groups.len(), 3); + assert!(groups.nat_target_refs.is_empty()); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_del_underlay_bitmap_failure_restores_repl() { + let mut config = Config::default(); + config + .asic_config + .table_entry_del + .values + .insert(TableType::McastEgressDecapPorts, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, members) = underlay_tag_and_members(&switch, underlay_key); + let before_repl = table_usage(&switch, TableType::McastIpv6); + assert_eq!(before_repl.0, 1); + + let err = del_group(&switch, underlay_key.ip(), &tag).unwrap_err(); + assert!(matches!(err, DpdError::Switch(_)), "{err:?}"); + + assert_eq!( + table_usage(&switch, TableType::McastIpv6).0, + before_repl.0, + "replication entry was not restored" + ); + assert!( + switch.mcast.lock().unwrap().groups.contains_key(&underlay_key) + ); + assert!(logs.entries.lock().unwrap().is_empty()); + + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: members.clone() }, + ) + .unwrap(); + assert_eq!(response.members, members); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 1); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_del_external_restore_failure_reports_unwind() { + let external_ip: IpAddr = "239.1.2.3".parse().unwrap(); + let (route_table, source_table, nat_table) = + external_tables(external_ip); + let mut config = Config::default(); + config.asic_config.table_entry_del.values.insert(nat_table, 1.0); + config.asic_config.table_entry_add.values.insert(source_table, 1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let IpAddr::V4(ipv4) = external_ip else { unreachable!() }; + mcast_nat::set_ipv4_entry(&switch, ipv4, nat_target).unwrap(); + table::mcast::mcast_route::set_ipv4_entry(&switch, ipv4, None).unwrap(); + + let tag = "external-del-restore-failure-test"; + let group_ip = ExternalMulticastIp::new(external_ip).unwrap(); + let external_key = GroupKey::external(group_ip); + { + let mut groups = switch.mcast.lock().unwrap(); + let underlay = &groups.groups[&underlay_key]; + let external = MulticastGroup { + external_scoped_group: underlay.external_scoped_group.clone(), + underlay_scoped_group: underlay.underlay_scoped_group.clone(), + tag: tag.to_string(), + kind: GroupKind::External { + group_ip, + nat_target: nat_target.try_into().unwrap(), + vlan_id: None, + sources: SourceFilter::from_exact(["192.0.2.1" + .parse::() + .unwrap()]), + }, + }; + groups.groups.insert(external_key, external); + groups.add_forwarding_refs(external_key, underlay_ip); + } + + let before_route = table_usage(&switch, route_table); + + let err = del_group(&switch, external_ip, tag).unwrap_err(); + assert!( + matches!( + err, + DpdError::Unwind { initial: ref init, ref unwind } + if matches!(**init, DpdError::Switch(_)) + && unwind.len() == 1 + ), + "{err:?}" + ); + + let http_err = dropshot::HttpError::from(err); + assert_eq!(http_err.status_code.as_u16(), 418); + assert_eq!( + http_err.error_code.as_deref(), + Some(common::ROLLBACK_FAILURE_ERROR_CODE) + ); + + assert_eq!(table_usage(&switch, nat_table), (1, 1, 0, 0)); + assert_eq!(table_usage(&switch, route_table), before_route); + assert_eq!(table_usage(&switch, source_table).0, 0); + + { + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), 1, "{errs:?}"); + assert_eq!(errs[0]["operation"], "restore source filter entry"); + assert!(errs[0]["context"].contains("192.0.2.1")); + assert!(errs[0]["error"].contains("table_entry_add")); + } + + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.contains_key(&external_key)); + assert_eq!( + groups.nat_target_refs.get(&underlay_ip), + Some(&external_key) + ); + } + + #[test] + fn test_external_update_readds_missing_source_filters() { + for (group_ip, source_ips) in [ + ("224.1.2.3", ["192.0.2.1", "192.0.2.2", "192.0.2.3"]), + ("ff0e::1", ["2001:db8::1", "2001:db8::2", "2001:db8::3"]), + ] { + let external_ip: IpAddr = group_ip.parse().unwrap(); + let [first, second, third]: [IpAddr; 3] = + source_ips.map(|ip| ip.parse().unwrap()); + let prefix_len = if external_ip.is_ipv4() { 32 } else { 128 }; + for sources_changed in [false, true] { + let context = + format!("{external_ip}: sources_changed={sources_changed}"); + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, _) = install_underlay_group(&switch); + let (_, source_table, _) = external_tables(external_ip); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-readd-sources-test"; + let initial_sources = [first, second]; + add_group_external( + &switch, + external_entry( + external_ip, + tag, + nat_target, + Some(10), + serde_json::json!( + initial_sources.map(|ip| { + serde_json::json!({ "Exact": ip }) + }) + ), + ), + ) + .unwrap(); + assert_eq!( + source_filter_prefixes(&switch, external_ip), + BTreeSet::from( + initial_sources.map(|ip| format!("{ip}/{prefix_len}")) + ), + "{context}" + ); + let missing = if sources_changed { + &initial_sources[1..] + } else { + &initial_sources[..] + }; + for source in missing { + clear_source_filter( + &switch, + external_ip, + IpSrc::Exact(*source), + ) + .unwrap(); + } + assert_eq!( + source_filter_prefixes(&switch, external_ip), + if sources_changed { + BTreeSet::from([format!("{first}/{prefix_len}")]) + } else { + BTreeSet::new() + }, + "{context}" + ); + if sources_changed { + set_source_filter(&switch, external_ip, IpSrc::Any) + .unwrap(); + } + + let wanted = if sources_changed { + [second, third] + } else { + initial_sources + }; + let sources = wanted + .map(|ip| SourceEntry::Exact(ExactSource::new(ip).unwrap())) + .to_vec(); + modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: nat_target.try_into().unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id: Some(10), + }, + sources: Some(sources), + }, + ) + .unwrap(); + + assert_eq!( + table_usage(&switch, source_table).0, + 2, + "{context}" + ); + assert_eq!( + source_filter_prefixes(&switch, external_ip), + BTreeSet::from( + wanted.map(|ip| format!("{ip}/{prefix_len}")) + ), + "{context}" + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + } + + #[test] + fn test_underlay_update_rollback_readds_missing_repl() { + for port_remove_fails in [false, true] { + let mut config = Config::default(); + if port_remove_fails { + config.asic_config.mc_config.mc_port_remove = + asic::chaos::Chaos::new(1.0); + } + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, members) = + underlay_tag_and_members(&switch, underlay_key); + switch.asic_hdl.inject_table_faults( + TableType::McastEgressDecapPorts, + TableOp::EntryUpdate, + CallRange::between(2, 3), + ); + + table::mcast::mcast_replication::clear_ipv6_entry( + &switch, + underlay_ip.into(), + ) + .unwrap(); + + let before_repl = table_usage(&switch, TableType::McastIpv6); + assert_eq!(before_repl.0, 0); + + let added_member = MulticastGroupMember { + port_id: RearPort::new(2).unwrap().into(), + link_id: LinkId(0), + direction: Direction::External, + }; + let mut new_members = members.clone(); + new_members.push(added_member.clone()); + + let err = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: new_members }, + ) + .unwrap_err(); + + let rollback_op = if port_remove_fails { + "mc_port_remove" + } else { + "table_entry_update" + }; + assert!( + matches!( + err, + DpdError::Unwind { initial: ref init, ref unwind } + if matches!( + init.as_ref(), + DpdError::Switch( + aal::AsicError::Synthetic(op) + ) if op == "table_entry_update" + ) && unwind.len() == 1 + && matches!( + &unwind.head, + DpdError::Switch( + aal::AsicError::Synthetic(op) + ) if op == rollback_op + ) + ), + "{err:?}" + ); + + assert_eq!( + table_usage(&switch, TableType::McastIpv6), + (1, before_repl.1 + 1, before_repl.2, before_repl.3), + "replication entry was not re-added by recovery" + ); + + let expected: &[(&str, &str)] = if port_remove_fails { + &[ + ("port changes", "mc_port_remove"), + ("restore VLAN settings", "table_entry_update"), + ] + } else { + &[("restore VLAN settings", "table_entry_update")] + }; + { + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), expected.len(), "{errs:?}"); + for (err, &(op, failed_call)) in errs.iter().zip(expected) { + assert_eq!(err["operation"], op); + assert!(err["error"].contains(failed_call)); + } + } + + let expected_members = if port_remove_fails { + vec![members[0].clone(), members[1].clone(), added_member] + } else { + members + }; + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &expected_members) + ); + let external_id = membership_group_id(&switch, Direction::External); + assert_eq!( + bitmap_entry(&switch, external_id), + Some(( + create_port_bitmap(&expected_members, Direction::External), + None, + )) + ); + let groups = switch.mcast.lock().unwrap(); + let group = &groups.groups[&underlay_key]; + assert_eq!( + group.members().cloned().collect::>(), + expected_members + ); + } + } + + #[test] + fn test_del_external_rollback_bitmap_readd_hits_full_table() { + let external_ip: IpAddr = "239.1.2.3".parse().unwrap(); + let (route_table, _, nat_table) = external_tables(external_ip); + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-del-missing-bitmap-test"; + + let entry = + serde_json::from_value::( + serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": 10 }, + "sources": null, + }), + ) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + + let external_group_id = switch.mcast.lock().unwrap().groups + [&underlay_key] + .external_group_id(); + table::mcast::mcast_egress::clear_bitmap_entry( + &switch, + external_group_id, + ) + .unwrap(); + + { + let mut decap = + switch.table_get(TableType::McastEgressDecapPorts).unwrap(); + decap.usage.size = decap.usage.occupancy; + } + + let before_decap = + table_usage(&switch, TableType::McastEgressDecapPorts); + assert_eq!(before_decap.0, 0); + + let err = del_group(&switch, external_ip, tag).unwrap_err(); + assert!( + matches!( + err, + DpdError::Unwind { initial: ref init, ref unwind } + if matches!(**init, DpdError::TableFull(_)) + && unwind.len() == 1 + && matches!(unwind.head, DpdError::TableFull(_)) + ), + "{err:?}" + ); + + assert_eq!( + table_usage(&switch, TableType::McastEgressDecapPorts), + before_decap + ); + assert!(bitmap_entry(&switch, external_group_id).is_none()); + assert_eq!(table_usage(&switch, nat_table).0, 1); + assert_eq!(table_usage(&switch, route_table).0, 1); + assert_eq!( + route_action(&switch, external_ip) + .and_then(|(_, vlan_id)| vlan_id) + .as_deref(), + Some("10") + ); + + { + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), 1, "{errs:?}"); + assert_eq!(errs[0]["operation"], "restore multicast bitmap VLAN"); + } + + let external_key = + GroupKey::external(ExternalMulticastIp::new(external_ip).unwrap()); + let groups = switch.mcast.lock().unwrap(); + assert!(groups.groups.contains_key(&external_key)); + assert_eq!( + groups.nat_target_refs.get(&underlay_ip), + Some(&external_key) + ); + } + + fn underlay_tag_and_members( + switch: &Switch, + underlay_key: GroupKey, + ) -> (String, Vec) { + let groups = switch.mcast.lock().unwrap(); + let group = &groups.groups[&underlay_key]; + (group.tag.clone(), group.members().cloned().collect()) + } + + #[test] + fn test_del_underlay_destroy_failure_keeps_group_usable() { + let mut config = Config::default(); + config.asic_config.mc_config.mc_group_destroy = + asic::chaos::Chaos::new(1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, members) = underlay_tag_and_members(&switch, underlay_key); + let tables = [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + ]; + for table in tables { + assert_eq!(table_usage(&switch, table).0, 1, "{table}"); + } + + let err = del_group(&switch, underlay_key.ip(), &tag).unwrap_err(); + assert!( + matches!( + err, + DpdError::Switch(aal::AsicError::Synthetic(ref op)) + if op == "mc_group_destroy" + ), + "{err:?}" + ); + + for table in tables { + assert_eq!(table_usage(&switch, table).0, 1, "{table}"); + } + assert_eq!(underlay_tag_and_members(&switch, underlay_key).1, members); + assert!(logs.entries.lock().unwrap().is_empty()); + + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: members.clone() }, + ) + .unwrap(); + assert_eq!(response.members, members); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_del_underlay_domain_restore_failure_reports_unwind() { + for group_create_fails in [true, false] { + let (op, failed_call, failed_op) = if group_create_fails { + ( + "restore multicast group", + "mc_group_create", + McOp::McGroupCreate, + ) + } else { + ( + "restore multicast group member", + "mc_port_add", + McOp::McPortAdd, + ) + }; + + let (switch, logs) = chaos_switch(Config::default()); + let (_, underlay_key) = install_underlay_group(&switch); + let (tag, members) = + underlay_tag_and_members(&switch, underlay_key); + switch + .asic_hdl + .inject_mc_faults(McOp::McGroupDestroy, CallRange::nth(2)); + switch.asic_hdl.inject_mc_faults(failed_op, CallRange::nth(1)); + + let (external_id, underlay_id) = { + let groups = switch.mcast.lock().unwrap(); + let group = &groups.groups[&underlay_key]; + (group.external_group_id(), group.underlay_group_id()) + }; + let underlay_ports = + switch.asic_hdl.mc_port_list(underlay_id).unwrap(); + + let err = del_group(&switch, underlay_key.ip(), &tag).unwrap_err(); + assert!( + matches!( + err, + DpdError::Unwind { initial: ref init, ref unwind } + if is_synthetic(init, McOp::McGroupDestroy) + && unwind.len() == 1 + && is_synthetic(&unwind.head, failed_op) + ), + "{err:?}" + ); + + assert!( + switch.asic_hdl.mc_port_list(external_id).unwrap().is_empty() + ); + assert_eq!( + switch.asic_hdl.mc_port_list(underlay_id).unwrap(), + underlay_ports + ); + + { + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), 1, "{errs:?}"); + assert_eq!(errs[0]["operation"], op); + assert!(errs[0]["error"].contains(failed_call), "{errs:?}"); + } + + for table in [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + ] { + assert_eq!(table_usage(&switch, table).0, 1, "{table}"); + } + + let underlay_members = members + .iter() + .filter(|member| member.direction == Direction::Underlay) + .cloned() + .collect::>(); + assert_eq!( + underlay_tag_and_members(&switch, underlay_key).1, + underlay_members + ); + assert_eq!( + bitmap_entry(&switch, external_id), + Some(( + create_port_bitmap(&underlay_members, Direction::External), + None + )) + ); + } + } + + #[test] + fn test_underlay_unchanged_update_readd_failure_keeps_members() { + let mut config = Config::default(); + config.asic_config.mc_config.mc_port_remove = + asic::chaos::Chaos::new(1.0); + + let (switch, logs) = chaos_switch(config); + let (underlay_ip, underlay_key) = install_underlay_group(&switch); + let (tag, members) = underlay_tag_and_members(&switch, underlay_key); + switch + .asic_hdl + .inject_mc_faults(McOp::McPortAdd, CallRange::starting_at(1)); + let tables = [TableType::McastIpv6, TableType::McastEgressDecapPorts]; + let before = tables.map(|table| table_usage(&switch, table)); + + let err = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: members.clone() }, + ) + .unwrap_err(); + assert!( + matches!( + err, + DpdError::Switch(aal::AsicError::Synthetic(ref op)) + if op == "mc_port_add" + ), + "{err:?}" + ); + + assert_eq!(underlay_tag_and_members(&switch, underlay_key).1, members); + assert_eq!(tables.map(|table| table_usage(&switch, table)), before); + assert!(logs.entries.lock().unwrap().is_empty()); + + switch.asic_hdl.clear_mc_faults(McOp::McPortAdd); + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { members: members.clone() }, + ) + .unwrap(); + assert_eq!(response.members, members); + assert!(logs.entries.lock().unwrap().is_empty()); + } + + #[test] + fn test_external_update_repairs_untracked_source_filters() { + for (group_ip, other_ip, source_ip, stale_ip) in [ + ("224.1.2.3", "224.1.2.4", "192.0.2.1", "192.0.2.2"), + ("ff0e::1", "ff0e::2", "2001:db8::1", "2001:db8::2"), + ] { + let external_ip: IpAddr = group_ip.parse().unwrap(); + let other_ip: IpAddr = other_ip.parse().unwrap(); + let stale_ip: IpAddr = stale_ip.parse().unwrap(); + let exact = SourceEntry::Exact( + ExactSource::new(source_ip.parse().unwrap()).unwrap(), + ); + for (source, cleanup_fails) in + [(SourceEntry::Any, false), (exact, false), (exact, true)] + { + let (_, source_table, _) = external_tables(external_ip); + let mut config = Config::default(); + if cleanup_fails { + config + .asic_config + .table_entry_del + .values + .insert(source_table, 1.0); + } + let (switch, logs) = chaos_switch(config); + let (underlay_ip, _) = install_underlay_group(&switch); + let nat_target = external_nat_target(external_ip, underlay_ip); + let tag = "external-source-filter-repair-test"; + add_group_external( + &switch, + external_entry( + external_ip, + tag, + nat_target, + None, + serde_json::json!([source]), + ), + ) + .unwrap(); + let wanted = source_filter_prefixes(&switch, external_ip); + let stored = serde_json::to_value( + get_group(&switch, external_ip).unwrap(), + ) + .unwrap(); + + set_source_filter(&switch, external_ip, IpSrc::Any).unwrap(); + set_source_filter(&switch, external_ip, IpSrc::Exact(stale_ip)) + .unwrap(); + set_source_filter(&switch, other_ip, IpSrc::Any).unwrap(); + let other_sources = source_filter_prefixes(&switch, other_ip); + let installed = source_filter_prefixes(&switch, external_ip); + assert_ne!(installed, wanted); + { + let mut table = switch.table_get(source_table).unwrap(); + table.usage.size = table.usage.occupancy; + } + + let res = modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: ExternalInternalForwarding { + nat_target: nat_target.try_into().unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id: None, + }, + sources: None, + }, + ); + let expected = if cleanup_fails { + let err = res.unwrap_err(); + assert!( + matches!( + err, + DpdError::Switch(AsicError::Synthetic(ref op)) + if op == "table_entry_del" + ), + "{err:?}" + ); + &installed + } else { + res.unwrap(); + &wanted + }; + assert_eq!( + &source_filter_prefixes(&switch, external_ip), + expected + ); + assert_eq!( + source_filter_prefixes(&switch, other_ip), + other_sources + ); + assert_eq!( + serde_json::to_value( + get_group(&switch, external_ip).unwrap() + ) + .unwrap(), + stored + ); + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + } + + fn forwarding_table_snapshot( + switch: &Switch, + table_type: TableType, + ) -> serde_json::Value { + let mut dump = + table::get_entries(switch, table_type.to_string(), false).unwrap(); + dump.entries.sort_by(|left, right| left.keys.cmp(&right.keys)); + serde_json::to_value(dump).unwrap() + } + + #[test] + fn test_put_readds_missing_external_nat_and_routes() { + for external_ip in ["239.1.2.3", "ff0e::1"] { + for vlan_id in [None, Some(10)] { + for (remove_nat, remove_route) in + [(true, false), (false, true), (true, true)] + { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, _) = install_underlay_group(&switch); + let external_ip: IpAddr = external_ip.parse().unwrap(); + let (route_table, _, nat_table) = + external_tables(external_ip); + let nat_target = + external_nat_target(external_ip, underlay_ip); + let tag = "external-put-repair-test"; + let entry = serde_json::from_value::< + MulticastGroupCreateExternalEntry, + >(serde_json::json!({ + "group_ip": external_ip, + "tag": tag, + "internal_forwarding": { "nat_target": nat_target }, + "external_forwarding": { "vlan_id": vlan_id }, + "sources": null, + })) + .unwrap(); + add_group_external(&switch, entry).unwrap(); + let before_route = + forwarding_table_snapshot(&switch, route_table); + let before_nat = + forwarding_table_snapshot(&switch, nat_table); + + match external_ip { + IpAddr::V4(ipv4) => { + if remove_nat { + mcast_nat::clear_ipv4_entry(&switch, ipv4) + .unwrap(); + } + if remove_route { + table::mcast::mcast_route::clear_ipv4_entry( + &switch, ipv4, + ) + .unwrap(); + } + } + IpAddr::V6(ipv6) => { + if remove_nat { + mcast_nat::clear_ipv6_entry(&switch, ipv6) + .unwrap(); + } + if remove_route { + table::mcast::mcast_route::clear_ipv6_entry( + &switch, ipv6, + ) + .unwrap(); + } + } + } - first_error.get_or_insert_with(|| e.into()); - failed_port_removal - .push((member.port_id, member.link_id)); + for _ in 0..2 { + modify_group_external( + &switch, + ExternalMulticastIp::new(external_ip).unwrap(), + tag, + MulticastGroupUpdateExternalEntry { + internal_forwarding: + ExternalInternalForwarding { + nat_target: nat_target + .try_into() + .unwrap(), + }, + external_forwarding: ExternalForwarding { + vlan_id, + }, + sources: None, + }, + ) + .unwrap(); + assert_eq!( + forwarding_table_snapshot(&switch, route_table), + before_route, + ); + assert_eq!( + forwarding_table_snapshot(&switch, nat_table), + before_nat, + ); } + assert!(logs.entries.lock().unwrap().is_empty()); } - Err(e) => { - error!( - self.switch.log, - "failed to resolve port link during rollback"; - "port" => %member.port_id, - "link" => %member.link_id, - "error" => ?e, + } + } + } + + #[test] + fn test_underlay_update_repairs_missing_route_when_marked() { + for (was_active, want_active) in + [(false, false), (false, true), (true, false), (true, true)] + { + let (switch, logs) = chaos_switch(Config::default()); + let (underlay_ip, underlay_key) = if was_active { + install_underlay_group(&switch) + } else { + install_empty_underlay_group(&switch) + }; + let (tag, original_members) = + underlay_tag_and_members(&switch, underlay_key); + let members = if want_active { + if was_active { + original_members + } else { + vec![MulticastGroupMember { + port_id: RearPort::new(0).unwrap().into(), + link_id: LinkId(0), + direction: Direction::External, + }] + } + } else { + Vec::new() + }; + let before_route = + forwarding_table_snapshot(&switch, TableType::RouteIpv6Mcast); + table::mcast::mcast_route::clear_ipv6_entry( + &switch, + underlay_ip.into(), + ) + .unwrap(); + assert_eq!(table_usage(&switch, TableType::RouteIpv6Mcast).0, 0); + switch.mcast.lock().unwrap().pending_repairs.insert(underlay_key); + + for _ in 0..2 { + let response = modify_group_underlay( + &switch, + underlay_ip, + &tag, + MulticastGroupUpdateUnderlayEntry { + members: members.clone(), + }, + ) + .unwrap(); + assert_eq!(response.members, members); + assert_eq!( + forwarding_table_snapshot( + &switch, + TableType::RouteIpv6Mcast, + ), + before_route, + ); + for table_type in + [TableType::McastIpv6, TableType::McastEgressDecapPorts] + { + assert_eq!( + table_usage(&switch, table_type).0, + u32::from(want_active), + "{table_type}", ); + } + } + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + + const MEMBERSHIP_TEST_TAG: &str = "underlay-membership-test"; + + fn membership_group_ip() -> UnderlayMulticastIpv6 { + "ff04::123".parse().unwrap() + } + + fn rear_member(port: u8, direction: Direction) -> MulticastGroupMember { + MulticastGroupMember { + port_id: RearPort::new(port).unwrap().into(), + link_id: LinkId(0), + direction, + } + } + + fn membership_members() -> [MulticastGroupMember; 3] { + [ + rear_member(0, Direction::External), + rear_member(1, Direction::Underlay), + rear_member(2, Direction::External), + ] + } + + fn create_membership_group( + switch: &Switch, + members: &[MulticastGroupMember], + ) { + add_group_underlay( + switch, + MulticastGroupCreateUnderlayEntry { + group_ip: membership_group_ip(), + tag: Some(MEMBERSHIP_TEST_TAG.to_string()), + members: members.to_vec(), + }, + ) + .unwrap(); + } + + fn put_membership( + switch: &Switch, + members: &[MulticastGroupMember], + ) -> DpdResult { + modify_group_underlay( + switch, + membership_group_ip(), + MEMBERSHIP_TEST_TAG, + MulticastGroupUpdateUnderlayEntry { members: members.to_vec() }, + ) + } + + fn stored_membership(switch: &Switch) -> Vec { + let mcast = switch.mcast.lock().unwrap(); + mcast.groups[&GroupKey::underlay(membership_group_ip())] + .members() + .cloned() + .collect() + } + + #[derive(Clone, Copy, Debug)] + enum Reader { + Underlay, + Group, + List, + } + + const READERS: [Reader; 3] = + [Reader::Underlay, Reader::Group, Reader::List]; + + fn read_membership( + switch: &Switch, + reader: Reader, + ) -> Vec { + let response = match reader { + Reader::Underlay => { + return get_group_underlay(switch, membership_group_ip()) + .unwrap() + .members; + } + Reader::Group => { + get_group(switch, membership_group_ip().into()).unwrap() + } + Reader::List => { + get_range(switch, None, 1, Some(MEMBERSHIP_TEST_TAG)) + .unwrap() + .pop() + .unwrap() + } + }; + let MulticastGroupResponse::Underlay(group) = response else { + panic!("expected an underlay response"); + }; + group.members + } + + fn read_memberships(switch: &Switch) -> [Vec; 3] { + READERS.map(|reader| read_membership(switch, reader)) + } + + fn assert_uncertain_reads(switch: &Switch) { + for res in [ + get_group_underlay(switch, membership_group_ip()).map(|_| ()), + get_group(switch, membership_group_ip().into()).map(|_| ()), + ] { + assert!( + matches!(res, Err(DpdError::McastRepairPending(_))), + "{res:?}" + ); + } + let MulticastGroupResponse::Underlay(listed) = + get_range(switch, None, 1, Some(MEMBERSHIP_TEST_TAG)) + .unwrap() + .pop() + .unwrap() + else { + panic!("expected an underlay response"); + }; + assert_eq!(listed.members, stored_membership(switch)); + } + + #[test] + fn test_underlay_member_order_is_consistent() { + let unsorted = [ + rear_member(2, Direction::Underlay), + rear_member(0, Direction::External), + rear_member(1, Direction::Underlay), + rear_member(0, Direction::Underlay), + ]; + let sorted = [ + rear_member(0, Direction::Underlay), + rear_member(0, Direction::External), + rear_member(1, Direction::Underlay), + rear_member(2, Direction::Underlay), + ]; + + let mut with_dup = unsorted.to_vec(); + with_dup.push(unsorted[1].clone()); + + for readback in [true, false] { + let mut config = Config::default(); + if !readback { + config + .asic_config + .mc_config + .unsupported + .insert(McOp::McPortList); + } + let (switch, _logs) = chaos_switch(config); + + let created = add_group_underlay( + &switch, + MulticastGroupCreateUnderlayEntry { + group_ip: membership_group_ip(), + tag: Some(MEMBERSHIP_TEST_TAG.to_string()), + members: unsorted.to_vec(), + }, + ) + .unwrap(); + assert_eq!(created.members, sorted, "readback={readback}"); + assert_consistent_membership(&switch, &sorted, readback); + + let updated = put_membership(&switch, &with_dup).unwrap(); + assert_eq!(updated.members, sorted, "readback={readback}"); + assert_consistent_membership(&switch, &sorted, readback); + } + } + + fn assert_consistent_membership( + switch: &Switch, + expected: &[MulticastGroupMember], + readback: bool, + ) { + assert_eq!(stored_membership(switch), expected, "readback={readback}"); + for (reader, read) in READERS.into_iter().zip(read_memberships(switch)) + { + assert_eq!(read, expected, "{reader:?} readback={readback}"); + } + if readback { + assert_eq!( + asic_membership(switch), + expected_membership(switch, expected) + ); + } + } + + #[test] + fn test_underlay_reads_repair_marked_group() { + for reader in READERS { + underlay_reads_repair_marked_group(reader); + } + } + + fn underlay_reads_repair_marked_group(reader: Reader) { + let members = membership_members(); + let (switch, _logs) = chaos_switch(Config::default()); + create_membership_group(&switch, &members[..2]); + + { + let mut mcast = switch.mcast.lock().unwrap(); + let group = mcast + .groups + .get_mut(&GroupKey::underlay(membership_group_ip())) + .unwrap(); + let external_id = group.external_group_id(); + let GroupKind::Underlay { replication, .. } = &mut group.kind + else { + panic!("expected an underlay group"); + }; + replication.add_members( + MulticastReplicationInfo::new(external_id), + &members, + ); + mcast + .pending_repairs + .insert(GroupKey::underlay(membership_group_ip())); + } + switch.asic_hdl.inject_table_faults( + TableType::McastIpv6, + TableOp::EntryUpdate, + CallRange::starting_at(1), + ); + + assert_uncertain_reads(&switch); + assert_eq!(stored_membership(&switch), members); + + switch + .asic_hdl + .inject_mc_faults(McOp::McPortList, CallRange::starting_at(1)); + assert_uncertain_reads(&switch); + assert_eq!(stored_membership(&switch), members); + + switch.asic_hdl.clear_mc_faults(McOp::McPortList); + switch + .asic_hdl + .clear_table_faults(TableType::McastIpv6, TableOp::EntryUpdate); + + assert_eq!( + read_membership(&switch, reader), + members[..2], + "{reader:?}" + ); + assert_eq!(stored_membership(&switch), members[..2], "{reader:?}"); + assert!( + switch.mcast.lock().unwrap().pending_repairs.is_empty(), + "{reader:?}" + ); + for read in read_memberships(&switch) { + assert_eq!(read, members[..2]); + } + + switch.asic_hdl.inject_table_faults( + TableType::McastIpv6, + TableOp::EntryUpdate, + CallRange::starting_at(1), + ); + for read in read_memberships(&switch) { + assert_eq!(read, members[..2]); + } + switch + .asic_hdl + .inject_mc_faults(McOp::McPortList, CallRange::starting_at(1)); + for read in read_memberships(&switch) { + assert_eq!(read, members[..2]); + } + assert_eq!(stored_membership(&switch), members[..2]); + + switch.asic_hdl.clear_mc_faults(McOp::McPortList); + switch + .asic_hdl + .clear_table_faults(TableType::McastIpv6, TableOp::EntryUpdate); + put_membership(&switch, &members).unwrap(); + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &members), + ); + } + + #[test] + fn test_underlay_read_recreates_missing_groups() { + let members = membership_members(); + let (switch, _logs) = chaos_switch(Config::default()); + create_membership_group(&switch, &members); + let group_ids = [Direction::External, Direction::Underlay] + .map(|direction| membership_group_id(&switch, direction)); + for group_id in group_ids { + switch.asic_hdl.mc_group_destroy(group_id).unwrap(); + } + switch + .mcast + .lock() + .unwrap() + .pending_repairs + .insert(GroupKey::underlay(membership_group_ip())); + + get_group_underlay(&switch, membership_group_ip()).unwrap(); + for group_id in group_ids { + assert!(switch.asic_hdl.mc_group_exists(group_id)); + } + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + } + + #[test] + fn test_underlay_read_restores_tables() { + for reader in READERS { + underlay_read_restores_tables(reader); + } + } + + fn underlay_read_restores_tables(reader: Reader) { + for (failed_table, readback) in [ + (TableType::McastIpv6, true), + (TableType::McastEgressDecapPorts, true), + (TableType::RouteIpv6Mcast, true), + (TableType::McastIpv6, false), + (TableType::McastEgressDecapPorts, false), + (TableType::RouteIpv6Mcast, false), + ] { + let members = membership_members(); + let mut config = Config::default(); + if !readback { + config + .asic_config + .mc_config + .unsupported + .insert(McOp::McPortList); + } + let (switch, _logs) = chaos_switch(config); + create_membership_group(&switch, &members); + let external_ip: IpAddr = "239.1.2.3".parse().unwrap(); + add_group_external( + &switch, + external_entry( + external_ip, + "external-read-repair-test", + external_nat_target(external_ip, membership_group_ip()), + Some(100), + serde_json::Value::Null, + ), + ) + .unwrap(); + let external_id = membership_group_id(&switch, Direction::External); + match failed_table { + TableType::McastIpv6 => { + table::mcast::mcast_replication::clear_ipv6_entry( + &switch, + membership_group_ip().into(), + ) + .unwrap(); + } + TableType::McastEgressDecapPorts => { + table::mcast::mcast_egress::clear_bitmap_entry( + &switch, + external_id, + ) + .unwrap(); + } + TableType::RouteIpv6Mcast => { + table::mcast::mcast_route::clear_ipv6_entry( + &switch, + membership_group_ip().into(), + ) + .unwrap(); + } + _ => unreachable!(), + } + switch + .mcast + .lock() + .unwrap() + .pending_repairs + .insert(GroupKey::underlay(membership_group_ip())); + switch.asic_hdl.inject_table_faults( + failed_table, + TableOp::EntryAdd, + CallRange::starting_at(1), + ); + + assert_uncertain_reads(&switch); + assert!( + switch + .mcast + .lock() + .unwrap() + .pending_repairs + .contains(&GroupKey::underlay(membership_group_ip())) + ); + if readback { + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &members), + ); + } - first_error.get_or_insert(e); - failed_port_resolution - .push((member.port_id, member.link_id)); - } + switch.asic_hdl.clear_table_faults(failed_table, TableOp::EntryAdd); + let context = format!("{reader:?}, {failed_table}, {readback}"); + let expected = members.iter().cloned().collect::>(); + assert_eq!( + read_membership(&switch, reader) + .into_iter() + .collect::>(), + expected, + "{context}" + ); + assert!( + switch.mcast.lock().unwrap().pending_repairs.is_empty(), + "{context}" + ); + for read in read_memberships(&switch) { + assert_eq!(read.into_iter().collect::>(), expected); + } + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 1); + assert_eq!( + bitmap_entry(&switch, external_id), + Some(( + create_port_bitmap(&members, Direction::External), + Some(100), + )), + ); + assert!( + route_action(&switch, membership_group_ip().into()).is_some() + ); + + let response = + get_group_underlay(&switch, membership_group_ip()).unwrap(); + modify_group_underlay( + &switch, + membership_group_ip(), + &response.tag, + MulticastGroupUpdateUnderlayEntry { members: Vec::new() }, + ) + .unwrap(); + for read in read_memberships(&switch) { + assert!(read.is_empty()); + } + for table in + [TableType::McastIpv6, TableType::McastEgressDecapPorts] + { + assert_eq!(table_usage(&switch, table).0, 0, "{table}"); } } + } - // Re-add removed ports - for member in removed_ports { - let group_id = match member.direction { - Direction::External => self.external_group_id(), - Direction::Underlay => self.underlay_group_id(), - }; + fn membership_group_id( + switch: &Switch, + direction: Direction, + ) -> MulticastGroupId { + let groups = switch.mcast.lock().unwrap(); + groups.groups[&GroupKey::underlay(membership_group_ip())] + .group_id(direction) + } - match self - .switch - .port_link_to_asic_id(member.port_id, member.link_id) - { - Ok(asic_id) => { - if let Err(e) = self.switch.asic_hdl.mc_port_add( - group_id, - asic_id, - replication_info.rid, - replication_info.level1_excl_id, - ) { - error!( - self.switch.log, - "failed to add port during rollback"; - "port" => %member.port_id, - "asic_id" => asic_id, - "group" => group_id, - "error" => ?e, - ); + fn asic_membership(switch: &Switch) -> [Vec; 2] { + [Direction::External, Direction::Underlay].map(|direction| { + switch + .asic_hdl + .mc_port_list(membership_group_id(switch, direction)) + .unwrap() + }) + } - first_error.get_or_insert_with(|| e.into()); - failed_port_addition - .push((member.port_id, member.link_id)); - } - } - Err(e) => { - error!( - self.switch.log, - "failed to resolve port link during rollback"; - "port" => %member.port_id, - "link" => %member.link_id, - "error" => ?e, - ); + fn expected_membership( + switch: &Switch, + members: &[MulticastGroupMember], + ) -> [Vec; 2] { + [Direction::External, Direction::Underlay].map(|direction| { + members + .iter() + .filter(|member| member.direction == direction) + .map(|member| { + switch + .port_link_to_asic_id(member.port_id, member.link_id) + .unwrap() + }) + .collect::>() + .into_iter() + .collect() + }) + } - first_error.get_or_insert(e); - failed_port_resolution - .push((member.port_id, member.link_id)); - } - } + #[test] + fn test_underlay_update_recreates_missing_asic_groups() { + let (switch, _logs) = chaos_switch(Config::default()); + let members = membership_members(); + create_membership_group(&switch, &members); + + for direction in [Direction::External, Direction::Underlay] { + let group_id = membership_group_id(&switch, direction); + switch.asic_hdl.mc_group_destroy(group_id).unwrap(); } + switch + .mcast + .lock() + .unwrap() + .pending_repairs + .insert(GroupKey::underlay(membership_group_ip())); + + put_membership(&switch, &members).unwrap(); + + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &members) + ); + assert_eq!(stored_membership(&switch), members.to_vec()); + } - // Log summary of any failures - if !failed_port_resolution.is_empty() { - error!( - self.switch.log, - "rollback failed to resolve port links"; - "count" => failed_port_resolution.len(), - "ports" => ?failed_port_resolution, - "warning" => "These ports may remain in inconsistent state", + fn add_unwanted_member(switch: &Switch, member: &MulticastGroupMember) { + let group_id = membership_group_id(switch, member.direction); + let repl = MulticastReplicationInfo::new(membership_group_id( + switch, + Direction::External, + )); + let asic_id = switch + .port_link_to_asic_id(member.port_id, member.link_id) + .unwrap(); + switch + .asic_hdl + .mc_port_add(group_id, asic_id, repl.rid, repl.level1_excl_id) + .unwrap(); + } + + fn is_synthetic(err: &DpdError, op: McOp) -> bool { + matches!( + err, + DpdError::Switch(aal::AsicError::Synthetic(failed)) + if *failed == op.to_string() + ) + } + + #[test] + fn test_underlay_update_rollback_preserves_membership() { + let [kept_member, added_member, failing_member] = membership_members(); + let init_members = [kept_member.clone()]; + let tgt_members = [kept_member.clone(), added_member, failing_member]; + + for stray_member in [None, Some(rear_member(3, Direction::External))] { + let context = format!("stray_member={stray_member:?}"); + let (switch, logs) = chaos_switch(Config::default()); + create_membership_group(&switch, &init_members); + if let Some(member) = &stray_member { + add_unwanted_member(&switch, member); + } + let mut installed_members = init_members.to_vec(); + installed_members.extend(stray_member); + let domains = switch.asic_hdl.mc_domains(); + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &installed_members), + "{context}" ); - } - if !failed_port_removal.is_empty() { - error!( - self.switch.log, - "rollback failed to remove ports from ASIC"; - "count" => failed_port_removal.len(), - "ports" => ?failed_port_removal, - "warning" => "These ports may remain in inconsistent state", + let before_group = { + let groups = switch.mcast.lock().unwrap(); + format!( + "{:?}", + groups.groups[&GroupKey::underlay(membership_group_ip())] + ) + }; + + let tables = [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + ]; + + let before = + tables.map(|table| forwarding_table_snapshot(&switch, table)); + + switch.asic_hdl.inject_mc_faults( + McOp::McPortAdd, + CallRange::nth(tgt_members.len() as u64), ); - } + let err = put_membership(&switch, &tgt_members).unwrap_err(); + assert!(is_synthetic(&err, McOp::McPortAdd), "{context}: {err:?}"); - if !failed_port_addition.is_empty() { - error!( - self.switch.log, - "rollback failed to re-add ports to ASIC"; - "count" => failed_port_addition.len(), - "ports" => ?failed_port_addition, - "warning" => "These ports may remain in inconsistent state", + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &installed_members), + "{context}" ); + assert_eq!(switch.asic_hdl.mc_domains(), domains, "{context}"); + + { + let groups = switch.mcast.lock().unwrap(); + assert_eq!( + format!( + "{:?}", + groups.groups + [&GroupKey::underlay(membership_group_ip())] + ), + before_group, + "{context}" + ); + } + + for (table, before) in tables.into_iter().zip(before) { + assert_eq!( + forwarding_table_snapshot(&switch, table), + before, + "{context}: {table}" + ); + } + assert!(logs.entries.lock().unwrap().is_empty()); } + } - // Return the first error encountered, if any - match first_error { - Some(e) => Err(e), - None => Ok(()), + #[test] + fn test_underlay_write_removes_member_left_by_failed_unwind() { + let [kept_member, added_member, failing_member] = membership_members(); + let init_members = [kept_member.clone()]; + let tgt_members = + [kept_member.clone(), added_member.clone(), failing_member]; + + let (switch, logs) = chaos_switch(Config::default()); + create_membership_group(&switch, &init_members); + let domains = switch.asic_hdl.mc_domains(); + + switch.asic_hdl.inject_mc_faults( + McOp::McPortAdd, + CallRange::nth(tgt_members.len() as u64), + ); + switch.asic_hdl.inject_mc_faults(McOp::McPortRemove, CallRange::nth(1)); + let err = put_membership(&switch, &tgt_members).unwrap_err(); + let DpdError::Unwind { initial, unwind } = &err else { + panic!("expected a failed unwind: {err:?}"); + }; + assert!(is_synthetic(initial, McOp::McPortAdd), "{err:?}"); + assert_eq!(unwind.len(), 1, "{err:?}"); + assert!(is_synthetic(&unwind.head, McOp::McPortRemove), "{err:?}"); + + let asic_members = vec![kept_member.clone(), added_member]; + assert_eq!(stored_membership(&switch), asic_members); + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &asic_members) + ); + assert_eq!(switch.asic_hdl.mc_domains(), domains); + + { + let errs = logs.entries.lock().unwrap(); + assert_eq!(errs.len(), 1, "{errs:?}"); + assert_eq!(errs[0]["operation"], "port changes"); + assert!(errs[0]["error"].contains("mc_port_remove"), "{errs:?}"); } + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + + let response = + put_membership(&switch, std::slice::from_ref(&kept_member)) + .unwrap(); + assert_eq!(response.members, vec![kept_member.clone()]); + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, std::slice::from_ref(&kept_member)) + ); + assert_eq!(stored_membership(&switch), vec![kept_member]); + assert_eq!(switch.asic_hdl.mc_domains(), domains); + assert_eq!(logs.entries.lock().unwrap().len(), 1); } -} -impl<'a> GroupUpdateRollbackContext<'a> { - /// Create rollback context for group update operations. - /// - /// # Arguments - /// - /// * `switch` - Switch instance for table operations. - /// * `group_ip` - IP address of the multicast group being updated. - /// * `original_group` - Reference to the group's state before the update. - /// * `attempted_vlan_id` - The VLAN that the update is attempting to set. - /// Used during rollback to delete entries that may have been added. - pub(crate) fn new( - switch: &'a Switch, - group_ip: IpAddr, - original_group: &'a MulticastGroup, - attempted_vlan_id: Option, - ) -> Self { - Self { switch, group_ip, original_group, attempted_vlan_id } + fn assert_failed_resync_cleared_group(switch: &Switch) { + assert!( + matches!( + get_group_underlay(switch, membership_group_ip()), + Err(DpdError::Missing(_)) + ), + "group was kept after a failed resync" + ); + assert!(switch.asic_hdl.mc_domains().is_empty()); + for table_type in [ + TableType::McastIpv6, + TableType::McastEgressDecapPorts, + TableType::RouteIpv6Mcast, + ] { + assert_eq!(table_usage(switch, table_type).0, 0, "{table_type}"); + } + assert!( + route_action(switch, IpAddr::from(membership_group_ip())).is_none() + ); + } + + fn assert_recreate_programs_exact_members( + switch: &Switch, + members: &[MulticastGroupMember], + ) { + create_membership_group(switch, members); + + assert_eq!( + stored_membership(switch).into_iter().collect::>(), + members.iter().cloned().collect::>() + ); + assert_eq!( + asic_membership(switch), + expected_membership(switch, members) + ); + assert_eq!(switch.asic_hdl.mc_domains().len(), 2); + assert_eq!( + bitmap_entry( + switch, + membership_group_id(switch, Direction::External) + ), + Some((create_port_bitmap(members, Direction::External), None)) + ); + for table_type in [TableType::McastIpv6, TableType::RouteIpv6Mcast] { + assert_eq!(table_usage(switch, table_type).0, 1, "{table_type}"); + } + } + + fn clear_membership_faults(switch: &Switch) { + for op in [McOp::McPortAdd, McOp::McPortRemove, McOp::McPortList] { + switch.asic_hdl.clear_mc_faults(op); + } } - /// Restore tables and return error. - pub(crate) fn rollback_and_restore(&self, error: E) -> E { - if let Err(rollback_err) = self.restore_tables() { - error!( - self.switch.log, - "failed to restore tables during rollback"; - "group" => %self.group_ip, - "error" => ?rollback_err, + #[test] + fn test_underlay_unobservable_unwind_clears_group() { + let [kept_member, added_member, failing_member] = membership_members(); + let init_members = [kept_member.clone()]; + let tgt_members = [kept_member, added_member, failing_member]; + + for readback_fails in [false, true] { + let context = if readback_fails { "readback" } else { "mapping" }; + let (switch, logs) = chaos_switch(Config::default()); + create_membership_group(&switch, &init_members); + if readback_fails { + switch + .asic_hdl + .inject_mc_faults(McOp::McPortList, CallRange::nth(1)); + } else { + assert!(switch.asic_id_to_port_link(AsicId::MAX).is_err()); + let info = MulticastReplicationInfo::new(membership_group_id( + &switch, + Direction::External, + )); + switch + .asic_hdl + .mc_port_add( + membership_group_id(&switch, Direction::Underlay), + AsicId::MAX, + info.rid, + info.level1_excl_id, + ) + .unwrap(); + } + + switch.asic_hdl.inject_mc_faults( + McOp::McPortAdd, + CallRange::nth(tgt_members.len() as u64), ); + switch + .asic_hdl + .inject_mc_faults(McOp::McPortRemove, CallRange::nth(1)); + let err = put_membership(&switch, &tgt_members).unwrap_err(); + let DpdError::Unwind { initial, unwind } = &err else { + panic!("{context}: expected a failed unwind: {err:?}"); + }; + assert!( + is_synthetic(initial, McOp::McPortAdd), + "{context}: {err:?}" + ); + assert_eq!(unwind.len(), 1, "{context}: {err:?}"); + assert!( + is_synthetic(&unwind.head, McOp::McPortRemove), + "{context}: {err:?}" + ); + + clear_membership_faults(&switch); + assert_failed_resync_cleared_group(&switch); + assert_eq!(logs.entries.lock().unwrap().len(), 1, "{context}"); + + assert_recreate_programs_exact_members(&switch, &tgt_members); + assert_eq!(logs.entries.lock().unwrap().len(), 1, "{context}"); } - error } - /// Restore table entries to original state. - fn restore_tables(&self) -> DpdResult<()> { - let external_group_id = self.original_group.external_group_id(); - let underlay_group_id = self.original_group.underlay_group_id(); - let replication_info = &self.original_group.replication_info; - let vlan_id = self.original_group.ext_fwding.vlan_id; - let nat_target = self.original_group.int_fwding.nat_target; - let prev_members = self.original_group.members.to_vec(); - - if let Some(replication_info) = replication_info { - self.log_rollback_error( - "restore replication settings", - &format!("for group {}", self.group_ip), - update_replication_tables( - self.switch, - self.group_ip, - external_group_id, - underlay_group_id, - replication_info, - ), + fn fail_unwind_to_empty_group(switch: &Switch) { + switch.asic_hdl.inject_mc_faults(McOp::McPortList, CallRange::nth(1)); + switch.asic_hdl.inject_mc_faults(McOp::McPortAdd, CallRange::nth(1)); + let err = put_membership(switch, &[]).unwrap_err(); + let DpdError::Unwind { initial, unwind } = &err else { + panic!("expected a failed unwind: {err:?}"); + }; + assert!(is_synthetic(initial, McOp::McPortList), "{err:?}"); + assert_eq!(unwind.len(), 1, "{err:?}"); + assert!(is_synthetic(&unwind.head, McOp::McPortAdd), "{err:?}"); + assert_eq!(asic_membership(switch), [Vec::::new(), Vec::new()]); + } + + fn failed_unwind_leaves_empty_group() -> (Switch, RollbackLog) { + let [kept_member, _, _] = membership_members(); + let (switch, logs) = chaos_switch(Config::default()); + create_membership_group(&switch, &[kept_member]); + + fail_unwind_to_empty_group(&switch); + assert!(stored_membership(&switch).is_empty()); + for table_type in + [TableType::McastIpv6, TableType::McastEgressDecapPorts] + { + assert_eq!(table_usage(&switch, table_type).0, 0, "{table_type}"); + } + assert_eq!(table_usage(&switch, TableType::RouteIpv6Mcast).0, 1); + assert_eq!(logs.entries.lock().unwrap().len(), 1); + (switch, logs) + } + + #[test] + fn test_underlay_empty_update_clears_tables_after_failed_unwind() { + let (switch, logs) = failed_unwind_leaves_empty_group(); + let domains = switch.asic_hdl.mc_domains(); + let group = switch.mcast.lock().unwrap().groups + [&GroupKey::underlay(membership_group_ip())] + .clone(); + + for _ in 0..2 { + table::mcast::mcast_egress::set_bitmap_entry( + &switch, + group.external_group_id(), + &table::mcast::mcast_egress::PortBitmap::new(), + None, + ) + .unwrap(); + set_replication_entry( + &switch, + &group, + &MulticastReplicationInfo::new(group.external_group_id()), + ) + .unwrap(); + for table_type in + [TableType::McastIpv6, TableType::McastEgressDecapPorts] + { + assert_eq!(table_usage(&switch, table_type).0, 1); + } + + let response = put_membership(&switch, &[]).unwrap(); + assert!(response.members.is_empty()); + assert!(stored_membership(&switch).is_empty()); + assert_eq!( + asic_membership(&switch), + [Vec::::new(), Vec::new()] ); + assert_eq!(switch.asic_hdl.mc_domains(), domains); + for table_type in + [TableType::McastIpv6, TableType::McastEgressDecapPorts] + { + assert_eq!( + table_usage(&switch, table_type).0, + 0, + "{table_type}" + ); + } + assert_eq!(table_usage(&switch, TableType::RouteIpv6Mcast).0, 1); } + assert_eq!(logs.entries.lock().unwrap().len(), 1); + } - // Restore NAT settings for external groups (internal groups have no - // NAT entries) by rewriting the entry with the original NAT target. - if let Some(nat) = nat_target { - match self.group_ip { - IpAddr::V4(ipv4) => { - self.log_rollback_error( - "restore IPv4 NAT settings", - &format!("for group {}", self.group_ip), - table::mcast::mcast_nat::update_ipv4_entry( - self.switch, - ipv4, - nat, - ), - ); - } - IpAddr::V6(ipv6) => { - self.log_rollback_error( - "restore IPv6 NAT settings", - &format!("for group {}", self.group_ip), - table::mcast::mcast_nat::update_ipv6_entry( - self.switch, - ipv6, - nat, - ), - ); - } + #[test] + fn test_underlay_empty_resync_cleanup_failure_keeps_old_entry() { + let [kept_member, _, _] = membership_members(); + for failed_table in + [TableType::McastIpv6, TableType::McastEgressDecapPorts] + { + let (switch, logs) = chaos_switch(Config::default()); + create_membership_group( + &switch, + std::slice::from_ref(&kept_member), + ); + let domains = switch.asic_hdl.mc_domains(); + switch.asic_hdl.inject_table_faults( + failed_table, + TableOp::EntryDel, + CallRange::starting_at(1), + ); + + fail_unwind_to_empty_group(&switch); + assert_eq!(stored_membership(&switch), vec![kept_member.clone()]); + assert_eq!(switch.asic_hdl.mc_domains(), domains); + assert_eq!( + table_usage(&switch, TableType::McastIpv6).0, + u32::from(failed_table == TableType::McastIpv6) + ); + assert_eq!( + table_usage(&switch, TableType::McastEgressDecapPorts).0, + 1 + ); + assert_eq!(logs.entries.lock().unwrap().len(), 1); + + assert_uncertain_reads(&switch); + assert_eq!(stored_membership(&switch), vec![kept_member.clone()]); + + switch.asic_hdl.clear_table_faults(failed_table, TableOp::EntryDel); + for read in read_memberships(&switch) { + assert!(read.is_empty()); } + assert!(stored_membership(&switch).is_empty()); + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + for table_type in + [TableType::McastIpv6, TableType::McastEgressDecapPorts] + { + assert_eq!( + table_usage(&switch, table_type).0, + 0, + "{table_type}" + ); + } + assert_eq!(logs.entries.lock().unwrap().len(), 1); } + } - self.log_rollback_error( - "restore VLAN settings", - &format!("for group {}", self.group_ip), - update_fwding_tables( - self.switch, - self.group_ip, - external_group_id, - &prev_members, - self.attempted_vlan_id, - vlan_id, + fn external_member_put_with_failed_unwind( + init_members: &[MulticastGroupMember], + tgt_members: &[MulticastGroupMember], + decap_update_failures: CallRange, + before_put: impl FnOnce(&Switch), + ) -> (Switch, RollbackLog) { + let (switch, logs) = chaos_switch(Config::default()); + create_membership_group(&switch, init_members); + before_put(&switch); + switch.asic_hdl.inject_table_faults( + TableType::McastEgressDecapPorts, + TableOp::EntryUpdate, + decap_update_failures, + ); + let removals = init_members + .iter() + .filter(|member| !tgt_members.contains(member)) + .count() as u64; + switch + .asic_hdl + .inject_mc_faults(McOp::McPortRemove, CallRange::nth(removals + 1)); + + let err = put_membership(&switch, tgt_members).unwrap_err(); + let DpdError::Unwind { initial, unwind } = &err else { + panic!("expected a failed unwind: {err:?}"); + }; + assert!( + matches!( + initial.as_ref(), + DpdError::Switch(AsicError::Synthetic(op)) + if op == "table_entry_update" ), + "{err:?}" ); - Ok(()) + assert!(is_synthetic(&unwind.head, McOp::McPortRemove), "{err:?}"); + + (switch, logs) } - /// Rollback external group updates. - /// - /// Note: `new_sources` should be the normalized sources that were actually - /// applied to the tables (not the raw request sources). - pub(crate) fn rollback_external( - &self, - error: E, - new_sources: Option<&[IpSrc]>, - ) -> E { - // Always try to rollback source filters; with normalization, even - // None represents a /0 "any source" entry that may need to be undone. - let orig_sources = self.original_group.sources.as_deref(); - self.collect_rollback_result("source filter restoration", || { - self.rollback_source_filters(new_sources, orig_sources) - }); - error + fn extending_members() + -> (Vec, Vec) { + let [a, _, c] = membership_members(); + (vec![a.clone()], vec![a, c]) } - /// Rollback internal group updates. - pub(crate) fn rollback_internal( - &self, - error: E, - added_ports: &[MulticastGroupMember], - removed_ports: &[MulticastGroupMember], - ) -> E { - // Get replication info from original group - if let Some(replication_info) = &self.original_group.replication_info - && let Err(rollback_err) = self.rollback_internal_update( - added_ports, - removed_ports, - replication_info, + fn replacing_members() + -> (Vec, Vec) { + let [a, b, c] = membership_members(); + (vec![a, b.clone()], vec![b, c]) + } + + #[test] + fn test_underlay_failed_unwind_reprograms_bitmap_from_asic_members() { + let (init_members, tgt_members) = extending_members(); + let (switch, logs) = external_member_put_with_failed_unwind( + &init_members, + &tgt_members, + CallRange::nth(2), + |_| {}, + ); + let external_id = membership_group_id(&switch, Direction::External); + + let stored = stored_membership(&switch); + assert_eq!( + stored.iter().cloned().collect::>(), + tgt_members.iter().cloned().collect::>() + ); + assert_eq!( + expected_membership(&switch, &stored), + asic_membership(&switch) + ); + assert_eq!( + bitmap_entry(&switch, external_id), + Some((create_port_bitmap(&stored, Direction::External), None)) + ); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 1); + assert_eq!(table_usage(&switch, TableType::RouteIpv6Mcast).0, 1); + assert_eq!(logs.entries.lock().unwrap().len(), 1); + } + + fn clear_external_member_put_faults(switch: &Switch) { + switch.asic_hdl.clear_table_faults( + TableType::McastEgressDecapPorts, + TableOp::EntryUpdate, + ); + clear_membership_faults(switch); + } + + #[test] + fn test_underlay_failed_unwind_reprogram_failure_clears_group() { + let (init_members, tgt_members) = extending_members(); + let (switch, logs) = external_member_put_with_failed_unwind( + &init_members, + &tgt_members, + CallRange::starting_at(2), + |_| {}, + ); + + clear_external_member_put_faults(&switch); + assert_failed_resync_cleared_group(&switch); + assert_eq!(logs.entries.lock().unwrap().len(), 2); + + assert_recreate_programs_exact_members(&switch, &tgt_members); + assert_eq!(logs.entries.lock().unwrap().len(), 2); + } + + #[test] + fn test_underlay_failed_unwind_referenced_group_stores_member_union() { + let external_ip: IpAddr = "239.1.2.3".parse().unwrap(); + let (init_members, tgt_members) = replacing_members(); + let (switch, logs) = external_member_put_with_failed_unwind( + &init_members, + &tgt_members, + CallRange::starting_at(2), + |switch| { + add_group_external( + switch, + external_entry( + external_ip, + "external-failed-resync-test", + external_nat_target(external_ip, membership_group_ip()), + None, + serde_json::Value::Null, + ), + ) + .unwrap(); + }, + ); + + clear_external_member_put_faults(&switch); + let union = membership_members().to_vec(); + assert_eq!(stored_membership(&switch), union); + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &union) + ); + assert_eq!(switch.asic_hdl.mc_domains().len(), 2); + assert!(route_action(&switch, membership_group_ip().into()).is_some()); + assert_eq!(logs.entries.lock().unwrap().len(), 2); + + get_group(&switch, external_ip).unwrap(); + assert!(route_action(&switch, external_ip).is_some()); + let groups = switch.mcast.lock().unwrap(); + assert_eq!( + groups + .nat_target_refs + .get(&membership_group_ip()) + .map(|key| key.ip()), + Some(external_ip) + ); + } + + #[test] + fn test_underlay_failed_unwind_teardown_failure_stores_member_union() { + for group_destroy_fails in [true, false] { + let (init_members, tgt_members) = replacing_members(); + let (switch, logs) = external_member_put_with_failed_unwind( + &init_members, + &tgt_members, + CallRange::starting_at(2), + |switch| { + if group_destroy_fails { + switch.asic_hdl.inject_mc_faults( + McOp::McGroupDestroy, + CallRange::nth(1), + ); + } else { + switch.asic_hdl.inject_table_faults( + TableType::RouteIpv6Mcast, + TableOp::EntryDel, + CallRange::nth(1), + ); + } + }, + ); + + clear_external_member_put_faults(&switch); + switch.asic_hdl.clear_mc_faults(McOp::McGroupDestroy); + switch.asic_hdl.clear_table_faults( + TableType::RouteIpv6Mcast, + TableOp::EntryDel, + ); + + assert_eq!( + stored_membership(&switch), + membership_members().to_vec() + ); + assert_eq!( + switch.asic_hdl.mc_domains().len(), + if group_destroy_fails { 1 } else { 2 } + ); + assert_eq!( + table_usage(&switch, TableType::RouteIpv6Mcast).0, + u32::from(!group_destroy_fails) + ); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 0); + assert_eq!(logs.entries.lock().unwrap().len(), 2); + + del_group( + &switch, + membership_group_ip().into(), + MEMBERSHIP_TEST_TAG, ) - { - error!( - self.switch.log, - "rollback failed for internal group update"; - "group" => %self.group_ip, - "error" => ?rollback_err, + .unwrap(); + assert_failed_resync_cleared_group(&switch); + assert_recreate_programs_exact_members(&switch, &tgt_members); + assert_eq!(logs.entries.lock().unwrap().len(), 2); + } + } + + #[test] + fn test_underlay_empty_unwind_repair() { + let [kept_member, _, _] = membership_members(); + let tgt_members = [kept_member]; + + for readback in [true, false] { + let mut config = Config::default(); + if !readback { + config + .asic_config + .mc_config + .unsupported + .insert(McOp::McPortList); + } + let (switch, logs) = chaos_switch(config); + create_membership_group(&switch, &[]); + let external_id = membership_group_id(&switch, Direction::External); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 0); + + switch.asic_hdl.inject_table_faults( + TableType::McastIpv6, + TableOp::EntryAdd, + CallRange::nth(1), + ); + switch + .asic_hdl + .inject_mc_faults(McOp::McPortRemove, CallRange::nth(1)); + let err = put_membership(&switch, &tgt_members).unwrap_err(); + let DpdError::Unwind { initial, unwind } = &err else { + panic!("expected a failed unwind: {err:?}"); + }; + assert!( + matches!( + initial.as_ref(), + DpdError::Switch(AsicError::Synthetic(op)) + if op == "table_entry_add" + ), + "{err:?}" + ); + assert!(is_synthetic(&unwind.head, McOp::McPortRemove), "{err:?}"); + + if readback { + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &tgt_members), + ); + } else { + assert_eq!( + [Direction::External, Direction::Underlay].map( + |direction| switch + .asic_hdl + .mc_port_count(membership_group_id( + &switch, direction, + )) + .unwrap(), + ), + expected_membership(&switch, &tgt_members) + .map(|members| members.len()), + ); + } + assert_eq!(stored_membership(&switch), tgt_members.to_vec()); + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + assert_eq!(table_usage(&switch, TableType::McastIpv6).0, 1); + assert_eq!( + bitmap_entry(&switch, external_id), + Some(( + create_port_bitmap(&tgt_members, Direction::External), + None, + )), + ); + assert_eq!(logs.entries.lock().unwrap().len(), 1); + } + } + + #[test] + fn test_underlay_empty_update_removes_unwanted_members() { + let [kept_member, added_member, failing_member] = membership_members(); + for init_members in [Vec::new(), vec![kept_member.clone()]] { + let (switch, logs) = chaos_switch(Config::default()); + create_membership_group(&switch, &init_members); + for unwanted_member in [&added_member, &failing_member] { + add_unwanted_member(&switch, unwanted_member); + } + let domains = switch.asic_hdl.mc_domains(); + assert_ne!( + asic_membership(&switch), + expected_membership(&switch, &init_members) + ); + + let response = put_membership(&switch, &[]).unwrap(); + assert!(response.members.is_empty()); + + assert_eq!( + asic_membership(&switch), + [Vec::::new(), Vec::new()] + ); + assert!(stored_membership(&switch).is_empty()); + assert_eq!(switch.asic_hdl.mc_domains(), domains); + for table_type in + [TableType::McastIpv6, TableType::McastEgressDecapPorts] + { + assert_eq!( + table_usage(&switch, table_type).0, + 0, + "{table_type}" + ); + } + assert!(logs.entries.lock().unwrap().is_empty()); + } + } + + #[test] + fn test_underlay_update_fails_when_unwanted_member_remains() { + let [kept_member, added_member, _] = membership_members(); + for members in [Vec::new(), vec![kept_member.clone()]] { + let (switch, logs) = chaos_switch(Config::default()); + create_membership_group(&switch, &members); + add_unwanted_member(&switch, &added_member); + let mut with_unwanted_member = members.clone(); + with_unwanted_member.push(added_member.clone()); + + switch + .asic_hdl + .inject_mc_faults(McOp::McPortRemove, CallRange::nth(1)); + let err = put_membership(&switch, &members).unwrap_err(); + assert!(is_synthetic(&err, McOp::McPortRemove), "{err:?}"); + + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &with_unwanted_member) ); + assert_eq!(stored_membership(&switch), members); + assert!(logs.entries.lock().unwrap().is_empty()); + assert!( + switch + .mcast + .lock() + .unwrap() + .pending_repairs + .contains(&GroupKey::underlay(membership_group_ip())) + ); + + switch + .asic_hdl + .inject_mc_faults(McOp::McPortList, CallRange::starting_at(1)); + let read = get_group_underlay(&switch, membership_group_ip()); + assert!( + matches!(read, Err(DpdError::McastRepairPending(_))), + "{read:?}" + ); + + switch.asic_hdl.clear_mc_faults(McOp::McPortList); + let read = get_group_underlay(&switch, membership_group_ip()) + .unwrap() + .members + .into_iter() + .collect::>(); + assert_eq!( + read, + with_unwanted_member.iter().cloned().collect::>() + ); + assert!(switch.mcast.lock().unwrap().pending_repairs.is_empty()); + + put_membership(&switch, &members).unwrap(); + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &members) + ); + } + } + + #[test] + fn test_underlay_unchanged_update_with_full_replication_table() { + let [kept_member, added_member, _] = membership_members(); + let members = vec![kept_member, added_member]; + let (switch, logs) = chaos_switch(Config::default()); + create_membership_group(&switch, &members); + + let before = table_usage(&switch, TableType::McastIpv6); + assert_eq!(before.0, 1); + { + let mut repl = switch.table_get(TableType::McastIpv6).unwrap(); + repl.usage.size = repl.usage.occupancy; } - error + + let response = put_membership(&switch, &members).unwrap(); + assert_eq!(response.members, members); + + assert_eq!( + table_usage(&switch, TableType::McastIpv6), + (before.0, before.1, before.2, before.3 + 1) + ); + assert_eq!( + asic_membership(&switch), + expected_membership(&switch, &members) + ); + assert!(logs.entries.lock().unwrap().is_empty()); } } diff --git a/dpd/src/mcast/validate.rs b/dpd/src/mcast/validate.rs deleted file mode 100644 index 428cafb9..00000000 --- a/dpd/src/mcast/validate.rs +++ /dev/null @@ -1,797 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at https://mozilla.org/MPL/2.0/ -// -// Copyright 2026 Oxide Computer Company - -//! Multicast address validation. -//! -//! Reserved multicast addresses are defined by IANA: -//! . - -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - -use super::IpSrc; -use crate::types::{DpdError, DpdResult}; -use common::network::NatTarget; -use dpd_types::mcast::MulticastTag; -use omicron_common::address::{ - IPV4_LINK_LOCAL_MULTICAST_SUBNET, IPV4_SSM_SUBNET, - IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, IPV6_LINK_LOCAL_MULTICAST_SUBNET, - IPV6_RESERVED_SCOPE_MULTICAST_SUBNET, IPV6_SSM_SUBNET, - UNDERLAY_MULTICAST_SUBNET, -}; - -/// Check if an IP address is unicast (emulating the unstable std::net API). -/// For IP addresses, unicast means simply "not multicast". -const fn is_unicast(addr: IpAddr) -> bool { - !addr.is_multicast() -} - -/// Validates if a multicast address is allowed for group creation. -/// -/// Returns a [`DpdResult`] indicating whether the address is valid or not. -pub(crate) fn validate_multicast_address( - addr: IpAddr, - sources: Option<&[IpSrc]>, -) -> DpdResult<()> { - // First validate that source addresses are unicast - validate_source_addresses(sources)?; - - // Then validate the multicast address itself - match addr { - IpAddr::V4(ipv4) => validate_ipv4_multicast(ipv4, sources), - IpAddr::V6(ipv6) => validate_ipv6_multicast(ipv6, sources), - } -} - -/// Validates the NAT target inner MAC and internal IP address. -/// -/// NAT targets must use addresses from the reserved underlay multicast subnet -/// (ff04::/64) which is allocated by Omicron for internal multicast routing. -pub(crate) fn validate_nat_target(nat_target: NatTarget) -> DpdResult<()> { - if !nat_target.inner_mac.is_multicast() { - return Err(DpdError::Invalid(format!( - "NAT target inner MAC address {inner_mac} is not a multicast MAC address", - inner_mac = nat_target.inner_mac - ))); - } - - if !UNDERLAY_MULTICAST_SUBNET.contains(nat_target.internal_ip) { - return Err(DpdError::Invalid(format!( - "NAT target internal IP address {} is not in the reserved \ - underlay multicast subnet (ff04::/64)", - nat_target.internal_ip - ))); - } - - Ok(()) -} - -/// Check if an IP address is a Source-Specific Multicast (SSM) address. -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), - } -} - -/// Check if sources contain an Any variant. -fn contains_any_source(sources: &[IpSrc]) -> bool { - sources.iter().any(|s| matches!(s, IpSrc::Any)) -} - -/// Validates IPv4 multicast addresses. -fn validate_ipv4_multicast( - addr: Ipv4Addr, - sources: Option<&[IpSrc]>, -) -> DpdResult<()> { - // Verify this is actually a multicast address - if !addr.is_multicast() { - return Err(DpdError::Invalid(format!( - "{addr} is not a multicast address", - ))); - } - - // If this is SSM, require specific sources (RFC 4607) - if is_ssm(addr.into()) { - if sources.is_none() || sources.unwrap().is_empty() { - return Err(DpdError::Invalid(format!( - "{addr} is a Source-Specific Multicast address and \ - requires at least one source to be defined", - ))); - } - if contains_any_source(sources.unwrap()) { - return Err(DpdError::Invalid(format!( - "{addr} is a Source-Specific Multicast address and \ - requires specific sources (IpSrc::Any is not allowed)", - ))); - } - return Ok(()); - } - - // Check reserved subnets - if IPV4_LINK_LOCAL_MULTICAST_SUBNET.contains(addr) { - return Err(DpdError::Invalid(format!( - "{addr} is in the reserved link-local multicast subnet", - ))); - } - - Ok(()) -} - -/// Validates IPv6 multicast addresses. -fn validate_ipv6_multicast( - addr: Ipv6Addr, - sources: Option<&[IpSrc]>, -) -> DpdResult<()> { - if !addr.is_multicast() { - return Err(DpdError::Invalid(format!( - "{addr} is not a multicast address", - ))); - } - - // If this is SSM, require specific sources (RFC 4607) - if is_ssm(addr.into()) { - if sources.is_none() || sources.unwrap().is_empty() { - return Err(DpdError::Invalid(format!( - "{addr} is an IPv6 Source-Specific Multicast address (ff3x::/32) \ - and requires at least one source to be defined", - ))); - } - if contains_any_source(sources.unwrap()) { - return Err(DpdError::Invalid(format!( - "{addr} is an IPv6 Source-Specific Multicast address (ff3x::/32) \ - and requires specific sources (IpSrc::Any is not allowed)", - ))); - } - return Ok(()); - } - - // 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(()) -} - -/// Validates that IPv6 addresses are not in the reserved underlay subnet. -/// -/// External groups may use admin-local addresses (ff04::/16) but not the -/// reserved underlay subnet (ff04::/64), which is used for internal underlay -/// multicast group allocation. -pub(crate) fn validate_not_underlay_subnet(addr: IpAddr) -> DpdResult<()> { - if let IpAddr::V6(ipv6) = addr - && UNDERLAY_MULTICAST_SUBNET.contains(ipv6) - { - return Err(DpdError::Invalid(format!( - "{addr} is in the reserved underlay multicast subnet (ff04::/64, \ - within admin-local scope ff04::/16) and must be created via the \ - internal multicast API", - ))); - } - Ok(()) -} - -/// Validates that source IP addresses are unicast. -pub(crate) fn validate_source_addresses( - sources: Option<&[IpSrc]>, -) -> DpdResult<()> { - let sources = match sources { - Some(sources) => sources, - None => return Ok(()), - }; - - for source in sources { - match source { - IpSrc::Exact(ip) => validate_exact_source_address(*ip)?, - IpSrc::Any => {} // Any-source is always valid - } - } - Ok(()) -} - -/// Validates a single exact source IP address. -fn validate_exact_source_address(ip: IpAddr) -> DpdResult<()> { - // First check if it's unicast (excludes multicast) - if !is_unicast(ip) { - return Err(DpdError::Invalid(format!( - "Source IP {ip} must be a unicast address (multicast addresses are not allowed)", - ))); - } - - // Check for other problematic address types - match ip { - IpAddr::V4(ipv4) => validate_ipv4_source_address(ipv4), - IpAddr::V6(ipv6) => validate_ipv6_source_address(ipv6), - } -} - -/// Validates IPv4 source addresses for problematic types. -fn validate_ipv4_source_address(ipv4: Ipv4Addr) -> DpdResult<()> { - if ipv4.is_loopback() - || ipv4.is_broadcast() - || ipv4.is_unspecified() - || ipv4.is_link_local() - { - return Err(DpdError::Invalid(format!( - "Source IP {ipv4} is not a valid source address \ - (loopback, broadcast, unspecified, and link-local addresses are not allowed)", - ))); - } - Ok(()) -} - -/// Validates IPv6 source addresses for problematic types. -fn validate_ipv6_source_address(ipv6: Ipv6Addr) -> DpdResult<()> { - if ipv6.is_loopback() - || ipv6.is_unspecified() - || ((ipv6.segments()[0] & 0xffc0) == 0xfe80) - { - return Err(DpdError::Invalid(format!( - "Source IP {ipv6} is not a valid source address \ - (loopback, unspecified, and link-local addresses are not allowed)", - ))); - } - Ok(()) -} - -/// Validates tag format for group creation. -/// -/// Delegates to [`MulticastTag::from_str`] which enforces: -/// - Length: 1-80 ASCII bytes -/// - Characters: alphanumeric, hyphens, underscores, colons, or periods -/// -/// Auto-generated tags use the format `{uuid}:{group_ip}`. -pub(crate) fn validate_tag_format(tag: &str) -> DpdResult<()> { - tag.parse::() - .map(|_| ()) - .map_err(|e| DpdError::Invalid(e.to_string())) -} - -/// Validates that the request tag matches the existing group's tag. -/// -/// Tags are immutable after group creation. This validation ensures the caller -/// created the group before allowing mutations. -pub(crate) fn validate_tag( - existing_tag: &str, - request_tag: &str, -) -> DpdResult<()> { - if request_tag != existing_tag { - return Err(DpdError::Invalid( - "tag mismatch: provided tag does not match the group's tag" - .to_string(), - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use common::network::{MacAddr, Vni}; - use dpd_types::mcast::MAX_TAG_LENGTH; - - /// Admin-local IPv6 multicast prefix (ff04::/16, scope 4). - const ADMIN_LOCAL_PREFIX: u16 = 0xff04; - - #[test] - fn test_ipv4_validation() { - // These should be allowed - assert!( - validate_ipv4_multicast(Ipv4Addr::new(224, 1, 0, 1), None).is_ok() - ); - assert!( - validate_ipv4_multicast(Ipv4Addr::new(224, 2, 2, 3), None).is_ok() - ); - assert!( - validate_ipv4_multicast(Ipv4Addr::new(231, 1, 2, 3), None).is_ok() - ); - - // These should be rejected - assert!( - validate_ipv4_multicast(Ipv4Addr::new(224, 0, 0, 1), None).is_err() - ); // Link-local - assert!( - validate_ipv4_multicast(Ipv4Addr::new(224, 0, 0, 5), None).is_err() - ); // Link-local - assert!( - validate_ipv4_multicast(Ipv4Addr::new(192, 168, 1, 1), None) - .is_err() - ); // Not multicast - } - - #[test] - fn test_ipv6_validation() { - // These should be allowed - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 0x1234), - None - ) - .is_ok() - ); // Global - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff05, 0, 0, 0, 0, 0, 0, 0x1111), - None - ) - .is_ok() - ); // Site-local - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff08, 0, 0, 0, 0, 0, 0, 0x5678), - None - ) - .is_ok() - ); // Organization-local - - // These should be rejected - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 0x1), - None - ) - .is_err() - ); // Link-local - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0xff01, 0, 0, 0, 0, 0, 0, 0x2,), - None - ) - .is_err() - ); // Interface-local - assert!( - validate_ipv6_multicast( - Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x1), - None - ) - .is_err() - ); // Not multicast - } - - #[test] - fn test_ipv4_ssm_with_sources() { - let ssm_addr = Ipv4Addr::new(232, 1, 2, 3); - let asm_addr = Ipv4Addr::new(224, 1, 2, 3); - - let exact_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))]; - let any_source = vec![IpSrc::Any]; - - // SSM requires sources - assert!(validate_ipv4_multicast(ssm_addr, Some(&[])).is_err()); - assert!(validate_ipv4_multicast(ssm_addr, None).is_err()); - - // SSM with exact source - assert!( - validate_ipv4_multicast(ssm_addr, Some(&exact_sources)).is_ok() - ); - - // SSM with any-source is not allowed (RFC 4607) - assert!(validate_ipv4_multicast(ssm_addr, Some(&any_source)).is_err()); - - // ASM without sources - assert!(validate_ipv4_multicast(asm_addr, None).is_ok()); - assert!(validate_ipv4_multicast(asm_addr, Some(&[])).is_ok()); - - // ASM with sources - assert!( - validate_ipv4_multicast(asm_addr, Some(&exact_sources)).is_ok() - ); - assert!(validate_ipv4_multicast(asm_addr, Some(&any_source)).is_ok()); - } - - #[test] - fn test_ipv6_ssm_with_sources() { - let ssm_addr = Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 0x1234); - let asm_addr = Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 0x1234); - - let exact_sources = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x1, - )))]; - let any_source = vec![IpSrc::Any]; - - // SSM requires sources - assert!(validate_ipv6_multicast(ssm_addr, Some(&[])).is_err()); - assert!(validate_ipv6_multicast(ssm_addr, None).is_err()); - - // SSM with exact source - assert!( - validate_ipv6_multicast(ssm_addr, Some(&exact_sources)).is_ok() - ); - - // SSM with any-source is not allowed (RFC 4607) - assert!(validate_ipv6_multicast(ssm_addr, Some(&any_source)).is_err()); - - // ASM without sources - assert!(validate_ipv6_multicast(asm_addr, None).is_ok()); - assert!(validate_ipv6_multicast(asm_addr, Some(&[])).is_ok()); - - // ASM with sources - assert!( - validate_ipv6_multicast(asm_addr, Some(&exact_sources)).is_ok() - ); - assert!(validate_ipv6_multicast(asm_addr, Some(&any_source)).is_ok()); - } - - #[test] - fn test_is_ssm_function() { - // Test IPv4 SSM detection - assert!(is_ssm(IpAddr::V4(Ipv4Addr::new(232, 0, 0, 1)))); - assert!(is_ssm(IpAddr::V4(Ipv4Addr::new(232, 255, 255, 255)))); - assert!(!is_ssm(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 1)))); - assert!(!is_ssm(IpAddr::V4(Ipv4Addr::new(231, 0, 0, 1)))); - - // Test IPv6 SSM detection (ff3x::/32) - assert!(is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff30, 0, 0, 0, 0, 0, 0, 0x1 - )))); // With 0 scope - assert!(is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff3e, 0, 0, 0, 0, 0, 0, 0x1 - )))); // Global scope (e) - assert!(is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff35, 0, 0, 0, 0, 0, 0, 0x1 - )))); // Site-local scope (5) - - // Not SSM - assert!(!is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff0e, 0, 0, 0, 0, 0, 0, 0x1 - )))); // Flag bit not 3 - assert!(!is_ssm(IpAddr::V6(Ipv6Addr::new( - 0xff1e, 0, 0, 0, 0, 0, 0, 0x1 - )))); // Flag bit not 3 - } - - #[test] - fn test_address_validation_integrated() { - // Test the main validate_multicast_address function - - // Valid IPv4 non-SSM address, no sources - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(224, 1, 0, 1)), - None - ) - .is_ok() - ); - - // Valid IPv4 SSM address with exact sources - let ssm_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))]; - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&ssm_sources) - ) - .is_ok() - ); - - // IPv4 SSM with Any is rejected (RFC 4607) - let ssm_with_any = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Any, - ]; - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&ssm_with_any) - ) - .is_err() - ); - - // Valid IPv6 non-SSM address, no sources - assert!( - validate_multicast_address( - IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 0x1234)), - None - ) - .is_ok() - ); - - // Valid IPv6 SSM address with sources - let ip6_sources = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x1, - )))]; - assert!( - validate_multicast_address( - IpAddr::V6(Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 0x1234)), - Some(&ip6_sources) - ) - .is_ok() - ); - - // Error cases - - // Not a multicast address - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), - None - ) - .is_err() - ); - - // IPv4 SSM without sources - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - None - ) - .is_err() - ); - - // IPv4 ASM with sources (including Any, which is valid for ASM) - let asm_sources = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Any, - ]; - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(224, 1, 2, 3)), - Some(&asm_sources) - ) - .is_ok() - ); - - // IPv6 SSM without sources - assert!( - validate_multicast_address( - IpAddr::V6(Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 0x1234)), - None - ) - .is_err() - ); - - // IPv6 ASM with sources - assert!( - validate_multicast_address( - IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 0x1234)), - Some(&ip6_sources) - ) - .is_ok() - ); - } - - #[test] - fn test_validate_nat_target() { - // Unicast internal IP should be rejected - let ucast_nat_target = NatTarget { - internal_ip: Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), - inner_mac: MacAddr::new(0x00, 0x00, 0x00, 0x00, 0x00, 0x01), - vni: Vni::new(100).unwrap(), - }; - assert!(validate_nat_target(ucast_nat_target).is_err()); - - // Valid NAT target in reserved underlay subnet (ff04::/64) - let valid_nat_target = NatTarget { - internal_ip: Ipv6Addr::new( - ADMIN_LOCAL_PREFIX, - 0, - 0, - 0, - 0, - 0, - 0, - 0x1234, - ), - inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x00, 0x00, 0x01), - vni: Vni::new(100).unwrap(), - }; - assert!(validate_nat_target(valid_nat_target).is_ok()); - - // Admin-local address outside ff04::/64 should be rejected - // ff04:0:0:1::1234 is in ff04::/16 but not in ff04::/64 - let outside_underlay_nat_target = NatTarget { - internal_ip: Ipv6Addr::new( - ADMIN_LOCAL_PREFIX, - 0, - 0, - 1, // This puts it outside ff04::/64 - 0, - 0, - 0, - 0x1234, - ), - inner_mac: MacAddr::new(0x01, 0x00, 0x5e, 0x00, 0x00, 0x01), - vni: Vni::new(100).unwrap(), - }; - assert!(validate_nat_target(outside_underlay_nat_target).is_err()); - } - - #[test] - fn test_validate_source_addresses() { - // Valid unicast IPv4 sources - let valid_ipv4_sources = vec![ - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), - IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), - ]; - assert!(validate_source_addresses(Some(&valid_ipv4_sources)).is_ok()); - - // Valid unicast IPv6 sources - let valid_ipv6_sources = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1, - )))]; - assert!(validate_source_addresses(Some(&valid_ipv6_sources)).is_ok()); - - // Any-source is valid - let any_source = vec![IpSrc::Any]; - assert!(validate_source_addresses(Some(&any_source)).is_ok()); - - // Invalid multicast IPv4 source - let invalid_mcast_ipv4 = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(224, 1, 1, 1)))]; - assert!(validate_source_addresses(Some(&invalid_mcast_ipv4)).is_err()); - - // Invalid multicast IPv6 source - let invalid_mcast_ipv6 = vec![IpSrc::Exact(IpAddr::V6(Ipv6Addr::new( - 0xff0e, 0, 0, 0, 0, 0, 0, 1, - )))]; - assert!(validate_source_addresses(Some(&invalid_mcast_ipv6)).is_err()); - - // Invalid broadcast IPv4 source - let invalid_broadcast = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(255, 255, 255, 255)))]; - assert!(validate_source_addresses(Some(&invalid_broadcast)).is_err()); - - // Invalid loopback IPv4 source - let invalid_loopback_ipv4 = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))]; - assert!( - validate_source_addresses(Some(&invalid_loopback_ipv4)).is_err() - ); - - // Invalid loopback IPv6 source - let invalid_loopback_ipv6 = vec![IpSrc::Exact(IpAddr::V6( - Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1), - ))]; - assert!( - validate_source_addresses(Some(&invalid_loopback_ipv6)).is_err() - ); - - // No sources should be valid - assert!(validate_source_addresses(None).is_ok()); - - // Empty sources should be valid - assert!(validate_source_addresses(Some(&[])).is_ok()); - } - - #[test] - fn test_address_validation_with_source_validation() { - // Valid case: SSM address with valid unicast sources - let valid_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))]; - assert!( - validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&valid_sources) - ) - .is_ok() - ); - - // Invalid case: SSM address with multicast source (should fail source validation first) - let invalid_mcast_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(224, 1, 1, 1)))]; - let result = validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&invalid_mcast_sources), - ); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("must be a unicast address") - ); - - // Invalid case: SSM address with loopback source - let invalid_loopback_sources = - vec![IpSrc::Exact(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))]; - let result = validate_multicast_address( - IpAddr::V4(Ipv4Addr::new(232, 1, 2, 3)), - Some(&invalid_loopback_sources), - ); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("is not a valid source address") - ); - } - - #[test] - fn test_validate_not_underlay_subnet() { - // Reserved underlay subnet (ff04::/64) should be rejected - let underlay_addr = - IpAddr::V6(Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1)); - assert!(validate_not_underlay_subnet(underlay_addr).is_err()); - - // Another address in ff04::/64 - let underlay_addr2 = - IpAddr::V6(Ipv6Addr::new(0xff04, 0, 0, 0, 0xdead, 0xbeef, 0, 1)); - assert!(validate_not_underlay_subnet(underlay_addr2).is_err()); - - // Other admin-local /64s should be allowed (e.g., ff04:0:0:1::/64) - let other_admin_local = - IpAddr::V6(Ipv6Addr::new(0xff04, 0, 0, 1, 0, 0, 0, 1)); - assert!(validate_not_underlay_subnet(other_admin_local).is_ok()); - - // ff04:0:0:2::/64 should also be allowed - let other_admin_local2 = - IpAddr::V6(Ipv6Addr::new(0xff04, 0, 0, 2, 0, 0, 0, 1)); - assert!(validate_not_underlay_subnet(other_admin_local2).is_ok()); - - // IPv4 multicast should always be allowed (not in underlay subnet) - let ipv4_mcast = IpAddr::V4(Ipv4Addr::new(224, 1, 2, 3)); - assert!(validate_not_underlay_subnet(ipv4_mcast).is_ok()); - - // Non-admin-local IPv6 multicast should be allowed - let global_mcast = - IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)); - assert!(validate_not_underlay_subnet(global_mcast).is_ok()); - - // Site-local multicast should be allowed - let site_local = IpAddr::V6(Ipv6Addr::new(0xff05, 0, 0, 0, 0, 0, 0, 1)); - assert!(validate_not_underlay_subnet(site_local).is_ok()); - } - - #[test] - fn test_validate_tag() { - // Existing tag matches request tag - assert!(validate_tag("my-tag", "my-tag").is_ok()); - - // Existing tag but request has different tag - assert!(validate_tag("owner-a", "owner-b").is_err()); - assert!(validate_tag("owner-a", "").is_err()); - assert!(validate_tag("owner-a", "tag/with/slashes").is_err()); - } - - #[test] - fn test_validate_tag_format() { - use super::validate_tag_format; - - // Valid tags - assert!(validate_tag_format("my-tag").is_ok()); - assert!(validate_tag_format("nexus").is_ok()); - assert!(validate_tag_format("a1b2c3").is_ok()); - assert!(validate_tag_format("tag_with_underscore").is_ok()); - assert!(validate_tag_format("tag.with.periods").is_ok()); - assert!(validate_tag_format("tag:with:colons").is_ok()); - assert!(validate_tag_format("mixed-tag_v1.0:test").is_ok()); - - // Auto-generated tag format (uuid:ip) - assert!( - validate_tag_format( - "550e8400-e29b-41d4-a716-446655440000:224.1.2.3" - ) - .is_ok() - ); - - // Tag at exactly MAX_TAG_LENGTH characters is valid - assert!(validate_tag_format(&"a".repeat(MAX_TAG_LENGTH)).is_ok()); - - // Empty tag rejected - assert!(validate_tag_format("").is_err()); - - // Tag exceeding MAX_TAG_LENGTH characters rejected - assert!(validate_tag_format(&"a".repeat(MAX_TAG_LENGTH + 1)).is_err()); - - // Invalid characters rejected - assert!(validate_tag_format("tag with spaces").is_err()); - assert!(validate_tag_format("tag/with/slashes").is_err()); - assert!(validate_tag_format("tag@with@at").is_err()); - assert!(validate_tag_format("tag#with#hash").is_err()); - } -} diff --git a/dpd/src/nat.rs b/dpd/src/nat.rs index c6031132..90e3b366 100644 --- a/dpd/src/nat.rs +++ b/dpd/src/nat.rs @@ -15,6 +15,7 @@ use crate::Switch; use crate::table; use crate::table::nat::{NatAddress, add_entry, delete_entry}; use crate::types::{DpdError, DpdResult}; +use common::nat::IpNat; use common::network::NatTarget; /// An inclusive range of ports, guaranteed by construction to have @@ -229,7 +230,7 @@ pub(crate) fn get_mappings_range( external: A, last_port: Option, max: usize, -) -> Vec { +) -> Vec> { let max = max.min(64); let port = match last_port { @@ -245,7 +246,12 @@ pub(crate) fn get_mappings_range( .iter() .filter(|e| e.l4_ports.low >= port) .take(max) - .map(|e| external.reservation(e.l4_ports, e.tgt)) + .map(|e| IpNat { + external, + low: e.l4_ports.low(), + high: e.l4_ports.high(), + target: e.tgt, + }) .collect() }) .unwrap_or_default() diff --git a/dpd/src/port_settings.rs b/dpd/src/port_settings.rs index cfcea772..d924fff9 100644 --- a/dpd/src/port_settings.rs +++ b/dpd/src/port_settings.rs @@ -4,11 +4,11 @@ // // Copyright 2026 Oxide Computer Company -use crate::DpdError; use crate::DpdResult; use crate::Switch; use crate::link::Link; use crate::link::LinkParams; +use crate::types::after_unwind; use aal::AsicOps; use common::ports::Ipv4Entry; use common::ports::Ipv6Entry; @@ -245,10 +245,7 @@ impl PortSettingsDiff { ); // If there is an unwind error, send back a more specific // error, containing the initial error and the unwind error. - return Err(DpdError::Unwind { - initial: Box::new(e), - unwind: Box::new(ue), - }); + return Err(after_unwind(e, Err(ue))); } Ok(_) => return Err(e), } diff --git a/dpd/src/rpw/mod.rs b/dpd/src/rpw/mod.rs index 99772309..20ba36c4 100644 --- a/dpd/src/rpw/mod.rs +++ b/dpd/src/rpw/mod.rs @@ -161,7 +161,7 @@ fn apply_updates( for entry in &updates { let nat_ip = entry.external_address; - let vni = match Vni::new(*entry.vni).ok_or(anyhow!("invalid vni")) { + let vni = match Vni::new(*entry.vni) { Ok(vni) => vni, Err(e) => { error!(switch.log, "unable to create nat entry"; "error" => ?e); diff --git a/dpd/src/table/mac.rs b/dpd/src/table/mac.rs index 8209f81a..c5e7f52d 100644 --- a/dpd/src/table/mac.rs +++ b/dpd/src/table/mac.rs @@ -4,7 +4,6 @@ // // Copyright 2026 Oxide Computer Company -use slog::debug; use slog::error; use slog::info; @@ -56,15 +55,11 @@ fn mac_set_common( fn mac_clear_common(s: &Switch, type_: TableType, port: u16) -> DpdResult<()> { let match_key = MacMatchKey { port }; - match s.table_entry_del(type_, &match_key) { + match s.table_entry_clear(type_, &match_key) { Ok(_) => { info!(s.log, "cleared mac on {port} in table {type_}",); Ok(()) } - Err(e @ DpdError::Switch(aal::AsicError::Missing(_))) => { - debug!(s.log, "no mac on {port} in table {type_} to clear"); - Err(e) - } Err(e) => { error!(s.log, "clear mac on {port} in table {type_} failed: {e:?}",); Err(e) @@ -74,7 +69,7 @@ fn mac_clear_common(s: &Switch, type_: TableType, port: u16) -> DpdResult<()> { /// Add a new entry to the MAC table. /// -/// An error is returned if the entry already exists. Use `mac_update` instead. +/// An error is returned if the entry already exists. pub fn mac_set(s: &Switch, port: u16, mac: MacAddr) -> DpdResult<()> { mac_set_common(s, TableType::PortMacAddress, port, mac) } @@ -106,7 +101,7 @@ pub fn reset(s: &Switch) -> DpdResult<()> { /// Add a new entry to the MAC table. /// -/// An error is returned if the entry already exists. Use `mac_update` instead. +/// An error is returned if the entry already exists. pub fn mcast_mac_set(s: &Switch, port: u16, mac: MacAddr) -> DpdResult<()> { mac_set_common(s, TableType::PortMacAddressMcast, port, mac) } diff --git a/dpd/src/table/mcast/mcast_egress.rs b/dpd/src/table/mcast/mcast_egress.rs index 10eed2e8..db5725dd 100644 --- a/dpd/src/table/mcast/mcast_egress.rs +++ b/dpd/src/table/mcast/mcast_egress.rs @@ -6,16 +6,15 @@ //! Table operations for multicast egress entries. -use dpd_types::table; use std::fmt; -use crate::{Switch, table::*}; - use aal::{ActionParse, MatchParse}; use aal_macros::*; -use dpd_types::mcast::MulticastGroupId; +use dpd_types::{mcast::MulticastGroupId, table}; use slog::debug; +use crate::{Switch, table::*}; + #[derive(MatchParse, Hash)] struct MatchKeyDecapPorts { #[match_xlate(name = "egress_rid")] @@ -85,16 +84,15 @@ enum PortIdAction { SetPortNumber { port_number: u8 }, } -/// Add a multicast entry to the decap table, keyed on +/// Update a multicast entry in the decap table, keyed on /// `mcast_external_grp` and setting the port bitmap. -pub(crate) fn add_bitmap_entry( +pub(crate) fn update_bitmap_entry( s: &Switch, mcast_external_grp: MulticastGroupId, port_bitmap: &PortBitmap, vlan_id: Option, ) -> DpdResult<()> { let match_key = MatchKeyDecapPorts::new(mcast_external_grp); - let action_data = match vlan_id { None => port_bitmap.to_action(), Some(vlan_id) => { @@ -102,21 +100,22 @@ pub(crate) fn add_bitmap_entry( port_bitmap.to_action_vlan(vlan_id) } }; + debug!( s.log, - "add multicast egress entry for decap {match_key} -> {action_data:?}" + "update multicast egress entry for decap {match_key} -> {action_data:?}" ); - s.table_entry_add( + s.try_table_entry_update( TableType::McastEgressDecapPorts, &match_key, &action_data, ) } -/// Update a multicast entry in the decap table, keyed on -/// `mcast_external_grp` and setting the port bitmap. -pub(crate) fn update_bitmap_entry( +/// Set a multicast entry in the decap table, keyed on +/// `mcast_external_grp`, which updates the port bitmap. +pub(crate) fn set_bitmap_entry( s: &Switch, mcast_external_grp: MulticastGroupId, port_bitmap: &PortBitmap, @@ -133,30 +132,24 @@ pub(crate) fn update_bitmap_entry( debug!( s.log, - "update multicast egress entry for decap {match_key} -> {action_data:?}" + "set multicast egress entry for decap {match_key} -> {action_data:?}" ); - s.table_entry_update( + s.table_entry_set( TableType::McastEgressDecapPorts, &match_key, &action_data, ) } -/// Delete a multicast entry from the decap table, keyed on +/// Clear a multicast entry from the decap table, keyed on /// `mcast_external_grp`. -pub(crate) fn del_bitmap_entry( +pub(crate) fn clear_bitmap_entry( s: &Switch, mcast_external_grp: MulticastGroupId, ) -> DpdResult<()> { let match_key = MatchKeyDecapPorts::new(mcast_external_grp); - - debug!( - s.log, - "delete multicast egress entry for decap {match_key} -> {mcast_external_grp}" - ); - - s.table_entry_del(TableType::McastEgressDecapPorts, &match_key) + s.table_entry_clear(TableType::McastEgressDecapPorts, &match_key) } /// Dump the multicast decap table. @@ -186,9 +179,9 @@ pub(crate) fn reset_bitmap_table(s: &Switch) -> DpdResult<()> { s.table_clear(TableType::McastEgressDecapPorts) } -/// Add a port ID entry to the port ID table for converting ASIC port IDs +/// Set a port ID entry to the port ID table for converting ASIC port IDs /// to port numbers. -pub(crate) fn add_port_mapping_entry( +pub(crate) fn set_port_mapping_entry( s: &Switch, asic_port_id: u16, ) -> DpdResult<()> { @@ -200,7 +193,7 @@ pub(crate) fn add_port_mapping_entry( debug!(s.log, "add port id entry {match_key} -> {action_data:?}"); - s.table_entry_add( + s.table_entry_set( TableType::McastEgressPortMapping, &match_key, &action_data, @@ -231,7 +224,7 @@ pub(crate) fn update_port_mapping_entry( /// Delete a port ID entry from the port ID table for converting ASIC port IDs /// to port numbers. -pub(crate) fn del_port_mapping_entry( +pub(crate) fn clear_port_mapping_entry( s: &Switch, asic_port_id: u16, ) -> DpdResult<()> { @@ -239,7 +232,7 @@ pub(crate) fn del_port_mapping_entry( debug!(s.log, "delete port id entry {match_key} -> {asic_port_id}"); - s.table_entry_del(TableType::McastEgressPortMapping, &match_key) + s.table_entry_clear(TableType::McastEgressPortMapping, &match_key) } /// Dump the multicast port mapping table. @@ -265,11 +258,17 @@ pub(crate) fn port_mapping_counter_fetch( } /// 256-port bitmap (8 × 32-bit) for multicast egress decapsulation filtering. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct PortBitmap { ports: [u32; 8], } +impl From<[u32; 8]> for PortBitmap { + fn from(ports: [u32; 8]) -> Self { + Self { ports } + } +} + impl PortBitmap { /// Create a new empty port bitmap. pub(crate) fn new() -> Self { diff --git a/dpd/src/table/mcast/mcast_nat.rs b/dpd/src/table/mcast/mcast_nat.rs index 43a716ef..e736e985 100644 --- a/dpd/src/table/mcast/mcast_nat.rs +++ b/dpd/src/table/mcast/mcast_nat.rs @@ -11,17 +11,17 @@ //! check in the P4 pipeline. Each group has a single entry keyed on its //! destination address. -use dpd_types::table; use std::net::{Ipv4Addr, Ipv6Addr}; -use crate::{Switch, table::*}; - -use super::{Ipv4MatchKey, Ipv6MatchKey}; use aal::ActionParse; use aal_macros::*; use common::network::{MacAddr, NatTarget}; +use dpd_types::table; use slog::debug; +use super::{Ipv4MatchKey, Ipv6MatchKey}; +use crate::{Switch, table::*}; + #[derive(ActionParse, Debug)] enum Ipv4Action { #[action_xlate(name = "mcast_forward_ipv4_to")] @@ -34,25 +34,8 @@ enum Ipv6Action { Forward { target: Ipv6Addr, inner_mac: MacAddr, vni: u32 }, } -/// Add a NAT entry for IPv4 multicast traffic. -pub(crate) fn add_ipv4_entry( - s: &Switch, - ip: Ipv4Addr, - tgt: NatTarget, -) -> DpdResult<()> { - let match_key = Ipv4MatchKey::new(ip); - let action_key = Ipv4Action::Forward { - target: tgt.internal_ip, - inner_mac: tgt.inner_mac, - vni: tgt.vni.as_u32(), - }; - - debug!(s.log, "add ingress mcast entry {match_key} -> {action_key:?}"); - s.table_entry_add(TableType::NatIngressIpv4Mcast, &match_key, &action_key) -} - -/// Update a NAT entry for IPv4 multicast traffic. -pub(crate) fn update_ipv4_entry( +/// Set a NAT entry for IPv4 multicast traffic. +pub(crate) fn set_ipv4_entry( s: &Switch, ip: Ipv4Addr, new_tgt: NatTarget, @@ -65,18 +48,13 @@ pub(crate) fn update_ipv4_entry( }; debug!(s.log, "update ingress mcast entry {match_key} -> {action_key:?}"); - s.table_entry_update( - TableType::NatIngressIpv4Mcast, - &match_key, - &action_key, - ) + s.table_entry_set(TableType::NatIngressIpv4Mcast, &match_key, &action_key) } -/// Delete a NAT entry for IPv4 multicast traffic. -pub(crate) fn del_ipv4_entry(s: &Switch, ip: Ipv4Addr) -> DpdResult<()> { +/// Clear a NAT entry for IPv4 multicast traffic. +pub(crate) fn clear_ipv4_entry(s: &Switch, ip: Ipv4Addr) -> DpdResult<()> { let match_key = Ipv4MatchKey::new(ip); - debug!(s.log, "delete ingress mcast entry {match_key}"); - s.table_entry_del(TableType::NatIngressIpv4Mcast, &match_key) + s.table_entry_clear(TableType::NatIngressIpv4Mcast, &match_key) } /// Dump the IPv4 NAT table's contents. @@ -103,25 +81,8 @@ pub(crate) fn reset_ipv4(s: &Switch) -> DpdResult<()> { s.table_clear(TableType::NatIngressIpv4Mcast) } -/// Add a NAT entry for IPv6 multicast traffic. -pub(crate) fn add_ipv6_entry( - s: &Switch, - ip: Ipv6Addr, - tgt: NatTarget, -) -> DpdResult<()> { - let match_key = Ipv6MatchKey::new(ip); - let action_key = Ipv6Action::Forward { - target: tgt.internal_ip, - inner_mac: tgt.inner_mac, - vni: tgt.vni.as_u32(), - }; - - debug!(s.log, "add ingress mcast entry {match_key} -> {action_key:?}"); - s.table_entry_add(TableType::NatIngressIpv6Mcast, &match_key, &action_key) -} - -/// Update a NAT entry for IPv6 multicast traffic. -pub(crate) fn update_ipv6_entry( +/// Set a NAT entry for IPv6 multicast traffic. +pub(crate) fn set_ipv6_entry( s: &Switch, ip: Ipv6Addr, new_tgt: NatTarget, @@ -134,18 +95,13 @@ pub(crate) fn update_ipv6_entry( }; debug!(s.log, "update ingress mcast entry {match_key} -> {action_key:?}"); - s.table_entry_update( - TableType::NatIngressIpv6Mcast, - &match_key, - &action_key, - ) + s.table_entry_set(TableType::NatIngressIpv6Mcast, &match_key, &action_key) } -/// Delete a NAT entry for IPv6 multicast traffic. -pub(crate) fn del_ipv6_entry(s: &Switch, ip: Ipv6Addr) -> DpdResult<()> { +/// Clear a NAT entry for IPv6 multicast traffic. +pub(crate) fn clear_ipv6_entry(s: &Switch, ip: Ipv6Addr) -> DpdResult<()> { let match_key = Ipv6MatchKey::new(ip); - debug!(s.log, "delete ingress mcast entry {match_key}"); - s.table_entry_del(TableType::NatIngressIpv6Mcast, &match_key) + s.table_entry_clear(TableType::NatIngressIpv6Mcast, &match_key) } /// Dump the IPv6 NAT table's contents. diff --git a/dpd/src/table/mcast/mcast_replication.rs b/dpd/src/table/mcast/mcast_replication.rs index 15274aec..30921bf6 100644 --- a/dpd/src/table/mcast/mcast_replication.rs +++ b/dpd/src/table/mcast/mcast_replication.rs @@ -32,7 +32,7 @@ enum Ipv6Action { }, } -/// Add an IPv6 multicast entries to the replication table: +/// Set an IPv6 multicast entries in the replication table: /// `dst_addr -> underlay_mcast_grp && external_mcast_grp, replication_id, /// level1_excl_id, level2_excl_id`. /// @@ -41,7 +41,7 @@ enum Ipv6Action { /// - underlay_mcast_grp: for replication to underlay/infrastructure ports (mcast_grp_b) /// /// Both groups are always allocated. -pub(crate) fn add_ipv6_entry( +pub(crate) fn set_ipv6_entry( s: &Switch, dst_addr: Ipv6Addr, underlay_mcast_grp: MulticastGroupId, @@ -66,54 +66,19 @@ pub(crate) fn add_ipv6_entry( level2_excl_id, }; - debug!(s.log, "add mcast_ipv6 entry {dst_addr} -> {action_data:?}"); + debug!(s.log, "update mcast_ipv6 entry {dst_addr} -> {action_data:?}"); - s.table_entry_add(TableType::McastIpv6, &match_key, &action_data) + s.table_entry_set(TableType::McastIpv6, &match_key, &action_data) } -/// Update an IPv6 multicast entries in the replication table. -/// -/// Updates the bifurcated replication configuration: -/// - external_mcast_grp: for replication to external/customer ports (mcast_grp_a) -/// - underlay_mcast_grp: for replication to underlay/infrastructure ports (mcast_grp_b) -pub(crate) fn update_ipv6_entry( +/// Clear an IPv6 multicast entries from replication table, keyed on +/// `dst_addr`. +pub(crate) fn clear_ipv6_entry( s: &Switch, dst_addr: Ipv6Addr, - underlay_mcast_grp: MulticastGroupId, - external_mcast_grp: MulticastGroupId, - replication_id: u16, - level1_excl_id: u16, - level2_excl_id: u16, ) -> DpdResult<()> { - if level2_excl_id > 511 { - return Err(DpdError::Invalid( - "`level2 exclusion id` exceeds 9-bit range".to_string(), - )); - } - let match_key = Ipv6MatchKey::new(dst_addr); - - let action_data = Ipv6Action::ConfigureIpv6 { - mcast_grp_a: external_mcast_grp, - mcast_grp_b: underlay_mcast_grp, - rid: replication_id, - level1_excl_id, - level2_excl_id, - }; - - debug!(s.log, "update mcast_ipv6 entry {dst_addr} -> {action_data:?}"); - - s.table_entry_update(TableType::McastIpv6, &match_key, &action_data) -} - -/// Delete an IPv6 multicast entries from replication table, keyed on -/// `dst_addr`. -pub(crate) fn del_ipv6_entry(s: &Switch, dst_addr: Ipv6Addr) -> DpdResult<()> { - let match_key = Ipv6MatchKey::new(dst_addr); - - debug!(s.log, "delete mcast_ipv6 entry {match_key}"); - - s.table_entry_del(TableType::McastIpv6, &match_key) + s.table_entry_clear(TableType::McastIpv6, &match_key) } /// Dump the IPv6 multicast table's contents. diff --git a/dpd/src/table/mcast/mcast_route.rs b/dpd/src/table/mcast/mcast_route.rs index e19e4b82..46b5bcef 100644 --- a/dpd/src/table/mcast/mcast_route.rs +++ b/dpd/src/table/mcast/mcast_route.rs @@ -13,12 +13,12 @@ //! NAT ingress also matches on destination address only. The group VLAN //! gets applied on egress. -use dpd_types::table; use std::net::{Ipv4Addr, Ipv6Addr}; use aal::ActionParse; use aal_macros::*; -use omicron_common::address::UNDERLAY_MULTICAST_SUBNET; +use dpd_types::mcast::UNDERLAY_MULTICAST_SUBNET; +use dpd_types::table; use slog::debug; use super::{Ipv4MatchKey, Ipv6MatchKey}; @@ -40,29 +40,6 @@ enum Ipv6Action { ForwardVLAN { vlan_id: u16 }, } -/// Add an IPv4 multicast route entry. -/// -/// The action is selected based on VLAN configuration: -/// - No VLAN: `forward` (no VLAN modification on egress) -/// - With VLAN: `forward_vlan(vlan_id)` (add VLAN tag on egress) -pub(crate) fn add_ipv4_entry( - s: &Switch, - route: Ipv4Addr, - vlan_id: Option, -) -> DpdResult<()> { - let match_key = Ipv4MatchKey::new(route); - let action = match vlan_id { - None => Ipv4Action::Forward, - Some(vlan_id) => { - common::network::validate_vlan(vlan_id)?; - Ipv4Action::ForwardVLAN { vlan_id } - } - }; - - debug!(s.log, "add multicast route entry {match_key} -> {action:?}"); - s.table_entry_add(TableType::RouteIpv4Mcast, &match_key, &action) -} - /// Update an IPv4 multicast route entry. /// /// Updates the action when VLAN configuration changes. Since the match key @@ -77,8 +54,21 @@ pub(crate) fn update_ipv4_entry( return Ok(()); } + set_ipv4_entry(s, route, new_vlan_id) +} + +/// Insert a missing IPv4 multicast route or update its forwarding action. +/// +/// The action is selected based on the VLAN configuration: +/// - no VLAN: `forward` (no VLAN modification on egress) +/// - with VLAN: `forward_vlan(vlan_id)` (add VLAN tag on egress) +pub(crate) fn set_ipv4_entry( + s: &Switch, + route: Ipv4Addr, + vlan_id: Option, +) -> DpdResult<()> { let match_key = Ipv4MatchKey::new(route); - let action = match new_vlan_id { + let action = match vlan_id { None => Ipv4Action::Forward, Some(vlan_id) => { common::network::validate_vlan(vlan_id)?; @@ -87,14 +77,13 @@ pub(crate) fn update_ipv4_entry( }; debug!(s.log, "update multicast route entry {match_key} -> {action:?}"); - s.table_entry_update(TableType::RouteIpv4Mcast, &match_key, &action) + s.table_entry_set(TableType::RouteIpv4Mcast, &match_key, &action) } -/// Delete an IPv4 multicast route entry. -pub(crate) fn del_ipv4_entry(s: &Switch, route: Ipv4Addr) -> DpdResult<()> { +/// Clear an IPv4 multicast route entry. +pub(crate) fn clear_ipv4_entry(s: &Switch, route: Ipv4Addr) -> DpdResult<()> { let match_key = Ipv4MatchKey::new(route); - debug!(s.log, "delete multicast route entry {match_key}"); - s.table_entry_del(TableType::RouteIpv4Mcast, &match_key) + s.table_entry_clear(TableType::RouteIpv4Mcast, &match_key) } /// Dump the IPv4 multicast routing table's contents. @@ -121,40 +110,6 @@ pub(crate) fn reset_ipv4(s: &Switch) -> DpdResult<()> { s.table_clear(TableType::RouteIpv4Mcast) } -/// Add an IPv6 multicast route entry. -/// -/// The action is selected based on VLAN configuration: -/// - No VLAN: `forward` (no VLAN modification on egress) -/// - With VLAN: `forward_vlan(vlan_id)` (add VLAN tag on egress) -/// -/// Reserved underlay multicast subnet (ff04::/64) is internal to the rack -/// and always uses Forward without VLAN tagging, regardless of the vlan_id -/// parameter. -pub(crate) fn add_ipv6_entry( - s: &Switch, - route: Ipv6Addr, - vlan_id: Option, -) -> DpdResult<()> { - let match_key = Ipv6MatchKey::new(route); - - // Reserved underlay multicast subnet (ff04::/64) is internal to the rack - // and doesn't require VLAN tagging. - let action: Ipv6Action = if UNDERLAY_MULTICAST_SUBNET.contains(route) { - Ipv6Action::Forward - } else { - match vlan_id { - None => Ipv6Action::Forward, - Some(vlan_id) => { - common::network::validate_vlan(vlan_id)?; - Ipv6Action::ForwardVLAN { vlan_id } - } - } - }; - - debug!(s.log, "add multicast route entry {match_key} -> {action:?}"); - s.table_entry_add(TableType::RouteIpv6Mcast, &match_key, &action) -} - /// Update an IPv6 multicast route entry. /// /// Updates the action when VLAN configuration changes. Since the match key @@ -169,6 +124,21 @@ pub(crate) fn update_ipv6_entry( return Ok(()); } + set_ipv6_entry(s, route, new_vlan_id) +} + +/// Insert a missing IPv6 multicast route or update its forwarding action. +/// +/// The action is selected based on the VLAN configuration: +/// - no VLAN: `forward` (no VLAN modification on egress) +/// - with VLAN: `forward_vlan(vlan_id)` (add VLAN tag on egress) +/// +/// Underlay routes use `forward`, regardless of `vlan_id`. +pub(crate) fn set_ipv6_entry( + s: &Switch, + route: Ipv6Addr, + vlan_id: Option, +) -> DpdResult<()> { let match_key = Ipv6MatchKey::new(route); // Reserved underlay multicast subnet (ff04::/64) is internal to the rack @@ -176,7 +146,7 @@ pub(crate) fn update_ipv6_entry( let action: Ipv6Action = if UNDERLAY_MULTICAST_SUBNET.contains(route) { Ipv6Action::Forward } else { - match new_vlan_id { + match vlan_id { None => Ipv6Action::Forward, Some(vlan_id) => { common::network::validate_vlan(vlan_id)?; @@ -186,14 +156,13 @@ pub(crate) fn update_ipv6_entry( }; debug!(s.log, "update multicast route entry {match_key} -> {action:?}"); - s.table_entry_update(TableType::RouteIpv6Mcast, &match_key, &action) + s.table_entry_set(TableType::RouteIpv6Mcast, &match_key, &action) } -/// Delete an IPv6 multicast route entry. -pub(crate) fn del_ipv6_entry(s: &Switch, route: Ipv6Addr) -> DpdResult<()> { +/// Clear an IPv6 multicast route entry. +pub(crate) fn clear_ipv6_entry(s: &Switch, route: Ipv6Addr) -> DpdResult<()> { let match_key = Ipv6MatchKey::new(route); - debug!(s.log, "delete multicast route entry {match_key}"); - s.table_entry_del(TableType::RouteIpv6Mcast, &match_key) + s.table_entry_clear(TableType::RouteIpv6Mcast, &match_key) } /// Dump the IPv6 multicast routing table's contents. diff --git a/dpd/src/table/mcast/mcast_src_filter.rs b/dpd/src/table/mcast/mcast_src_filter.rs index c9e4ca38..308834e7 100644 --- a/dpd/src/table/mcast/mcast_src_filter.rs +++ b/dpd/src/table/mcast/mcast_src_filter.rs @@ -6,19 +6,20 @@ //! Table operations for multicast source filter entries. -use dpd_types::table; use std::{ + collections::BTreeSet, fmt, net::{Ipv4Addr, Ipv6Addr}, }; -use crate::{Switch, table::*}; - use aal::{ActionParse, MatchParse}; use aal_macros::*; +use dpd_types::table; use oxnet::{Ipv4Net, Ipv6Net}; use slog::debug; +use crate::{Switch, table::*}; + #[derive(MatchParse, Hash)] struct Ipv4MatchKey { #[match_xlate(name = "src_addr", type = "lpm")] @@ -69,9 +70,9 @@ enum Ipv6Action { AllowSrc, } -/// Add a source filter entry for IPv4 multicast traffic: +/// Set a source filter entry for IPv4 multicast traffic: /// `src_addr, dst_addr -> allow_source_mcastv4`. -pub(crate) fn add_ipv4_entry( +pub(crate) fn set_ipv4_entry( s: &Switch, src_addr: Ipv4Net, dst_addr: Ipv4Addr, @@ -79,23 +80,50 @@ pub(crate) fn add_ipv4_entry( let match_key = Ipv4MatchKey::new(src_addr, dst_addr); let action_data = Ipv4Action::AllowSrc; - debug!(s.log, "add source filter entry {src_addr} -> {action_data:?}"); + debug!(s.log, "set source filter entry {src_addr} -> {action_data:?}"); - s.table_entry_add(TableType::McastIpv4SrcFilter, &match_key, &action_data) + s.table_entry_set(TableType::McastIpv4SrcFilter, &match_key, &action_data) } -/// Delete a source filter entry for IPv4 multicast traffic, keyed on +/// Clear a source filter entry for IPv4 multicast traffic, keyed on /// `src_addr, dst_addr`. -pub(crate) fn del_ipv4_entry( +pub(crate) fn clear_ipv4_entry( s: &Switch, src_addr: Ipv4Net, dst_addr: Ipv4Addr, ) -> DpdResult<()> { let match_key = Ipv4MatchKey::new(src_addr, dst_addr); - debug!(s.log, "delete source filter entry {src_addr} -> {dst_addr}"); + debug!(s.log, "clear source filter entry {src_addr} -> {dst_addr}"); + + s.table_entry_clear(TableType::McastIpv4SrcFilter, &match_key) +} - s.table_entry_del(TableType::McastIpv4SrcFilter, &match_key) +/// Delete every IPv4 multicast source filter entry for `dst_addr` whose +/// source is not in `sources`. +pub(crate) fn retain_ipv4_sources( + s: &Switch, + dst_addr: Ipv4Addr, + sources: &BTreeSet, +) -> DpdResult<()> { + super::del_entries_where::( + s, + TableType::McastIpv4SrcFilter, + |key| key.dst_addr == dst_addr && !sources.contains(&key.src_addr), + ) +} + +/// Delete every IPv4 multicast source filter entry for `dst_addr`, no matter +/// its source. +pub(crate) fn del_ipv4_entries( + s: &Switch, + dst_addr: Ipv4Addr, +) -> DpdResult<()> { + super::del_entries_where::( + s, + TableType::McastIpv4SrcFilter, + |key| key.dst_addr == dst_addr, + ) } /// Dump the IPv4 multicast source filter table's contents. @@ -122,9 +150,9 @@ pub(crate) fn reset_ipv4(s: &Switch) -> DpdResult<()> { s.table_clear(TableType::McastIpv4SrcFilter) } -/// Add a source filter entry for IPv6 multicast traffic: +/// Set a source filter entry for IPv6 multicast traffic: /// `src_addr, dst_addr -> allow_source_mcastv6`. -pub(crate) fn add_ipv6_entry( +pub(crate) fn set_ipv6_entry( s: &Switch, src_addr: Ipv6Net, dst_addr: Ipv6Addr, @@ -132,23 +160,50 @@ pub(crate) fn add_ipv6_entry( let match_key = Ipv6MatchKey::new(src_addr, dst_addr); let action_data = Ipv6Action::AllowSrc; - debug!(s.log, "add source filter entry {src_addr} -> {action_data:?}"); + debug!(s.log, "set source filter entry {src_addr} -> {action_data:?}"); - s.table_entry_add(TableType::McastIpv6SrcFilter, &match_key, &action_data) + s.table_entry_set(TableType::McastIpv6SrcFilter, &match_key, &action_data) } -/// Delete a source filter entry for IPv6 multicast traffic, keyed on +/// Clear a source filter entry for IPv6 multicast traffic, keyed on /// `src_addr, dst_addr`. -pub(crate) fn del_ipv6_entry( +pub(crate) fn clear_ipv6_entry( s: &Switch, src_addr: Ipv6Net, dst_addr: Ipv6Addr, ) -> DpdResult<()> { let match_key = Ipv6MatchKey::new(src_addr, dst_addr); - debug!(s.log, "delete source filter entry {src_addr} -> {dst_addr}"); + debug!(s.log, "clear source filter entry {src_addr} -> {dst_addr}"); + + s.table_entry_clear(TableType::McastIpv6SrcFilter, &match_key) +} - s.table_entry_del(TableType::McastIpv6SrcFilter, &match_key) +/// Delete every IPv6 multicast source filter entry for `dst_addr` whose +/// source is not in `sources`. +pub(crate) fn retain_ipv6_sources( + s: &Switch, + dst_addr: Ipv6Addr, + sources: &BTreeSet, +) -> DpdResult<()> { + super::del_entries_where::( + s, + TableType::McastIpv6SrcFilter, + |key| key.dst_addr == dst_addr && !sources.contains(&key.src_addr), + ) +} + +/// Delete every IPv6 multicast source filter entry for `dst_addr`, no matter +/// its source. +pub(crate) fn del_ipv6_entries( + s: &Switch, + dst_addr: Ipv6Addr, +) -> DpdResult<()> { + super::del_entries_where::( + s, + TableType::McastIpv6SrcFilter, + |key| key.dst_addr == dst_addr, + ) } /// Dump the IPv6 multicast source filter table's contents. diff --git a/dpd/src/table/mcast/mod.rs b/dpd/src/table/mcast/mod.rs index 1f49cbab..ebc8afc2 100644 --- a/dpd/src/table/mcast/mod.rs +++ b/dpd/src/table/mcast/mod.rs @@ -9,11 +9,16 @@ use std::{ convert::TryInto, fmt, + hash::Hash, net::{Ipv4Addr, Ipv6Addr}, }; -use aal::MatchParse; +use aal::{ActionParse, MatchParse}; use aal_macros::*; +use common::table::TableType; +use slog::debug; + +use crate::{Switch, types::DpdResult}; pub(crate) mod mcast_egress; pub(crate) mod mcast_nat; @@ -21,6 +26,32 @@ pub(crate) mod mcast_replication; pub(crate) mod mcast_route; pub(crate) mod mcast_src_filter; +/// Delete every entry in `table` whose key satisfies the `matches` check. +/// +/// All table keys are collected before deletion occurs to prevent the table +/// lock from being held across `table_entry_del`. +fn del_entries_where( + s: &Switch, + table: TableType, + matches: impl Fn(&M) -> bool, +) -> DpdResult<()> +where + M: MatchParse + Hash + fmt::Display, + A: ActionParse, +{ + let keys: Vec = s + .table_get(table)? + .get_entries::(&s.asic_hdl, false)? + .into_iter() + .filter_map(|(key, _)| matches(&key).then_some(key)) + .collect(); + + keys.iter().try_for_each(|key| { + debug!(s.log, "delete {table} entry {key}"); + s.table_entry_clear(table, key) + }) +} + #[derive(MatchParse, Hash)] struct Ipv4MatchKey { dst_addr: Ipv4Addr, diff --git a/dpd/src/table/mod.rs b/dpd/src/table/mod.rs index a499c0ad..ec24466a 100644 --- a/dpd/src/table/mod.rs +++ b/dpd/src/table/mod.rs @@ -124,7 +124,7 @@ impl Table { self.asic_data .entry_add(hdl, key, data) .map_err(|e| { - if let aal::AsicError::Exists = e { + if let aal::AsicError::Exists(_) = e { self.usage.collisions += 1; } e.into() @@ -169,7 +169,7 @@ impl Table { e.into() }) .map(|()| { - self.usage.occupancy -= 1; + self.usage.occupancy = self.usage.occupancy.saturating_sub(1); self.usage.deletes += 1; }) } diff --git a/dpd/src/table/nat.rs b/dpd/src/table/nat.rs index c27e13c7..edba403e 100644 --- a/dpd/src/table/nat.rs +++ b/dpd/src/table/nat.rs @@ -18,7 +18,6 @@ use aal_macros::*; use crate::Switch; use crate::nat::PortRange; use crate::table::*; -use common::nat::{Ipv4Nat, Ipv6Nat}; use common::network::{MacAddr, NatTarget}; pub(crate) trait NatAddress: Copy + Ord + fmt::Display { @@ -27,12 +26,9 @@ pub(crate) trait NatAddress: Copy + Ord + fmt::Display { type MatchKey: MatchParse + Hash + fmt::Display; type Action: ActionParse; - type Reservation; fn match_key(self, ports: PortRange) -> Self::MatchKey; fn action(tgt: NatTarget) -> Self::Action; - fn reservation(self, ports: PortRange, tgt: NatTarget) - -> Self::Reservation; } pub(crate) fn add_entry( @@ -83,7 +79,6 @@ impl NatAddress for Ipv4Addr { type MatchKey = Ipv4MatchKey; type Action = Ipv4Action; - type Reservation = Ipv4Nat; fn match_key(self, ports: PortRange) -> Ipv4MatchKey { Ipv4MatchKey::new(self, ports.low(), ports.high()) @@ -96,15 +91,6 @@ impl NatAddress for Ipv4Addr { vni: tgt.vni.as_u32(), } } - - fn reservation(self, ports: PortRange, tgt: NatTarget) -> Ipv4Nat { - Ipv4Nat { - external: self, - low: ports.low(), - high: ports.high(), - target: tgt, - } - } } impl NatAddress for Ipv6Addr { @@ -113,7 +99,6 @@ impl NatAddress for Ipv6Addr { type MatchKey = Ipv6MatchKey; type Action = Ipv6Action; - type Reservation = Ipv6Nat; fn match_key(self, ports: PortRange) -> Ipv6MatchKey { Ipv6MatchKey::new(self, ports.low(), ports.high()) @@ -126,15 +111,6 @@ impl NatAddress for Ipv6Addr { vni: tgt.vni.as_u32(), } } - - fn reservation(self, ports: PortRange, tgt: NatTarget) -> Ipv6Nat { - Ipv6Nat { - external: self, - low: ports.low(), - high: ports.high(), - target: tgt, - } - } } #[derive(MatchParse, Hash)] diff --git a/dpd/src/table/uplink.rs b/dpd/src/table/uplink.rs index 9b47a557..9ef05832 100644 --- a/dpd/src/table/uplink.rs +++ b/dpd/src/table/uplink.rs @@ -11,7 +11,8 @@ use slog::{error, info}; use crate::Switch; use crate::table::*; -use aal::{ActionParse, MatchParse}; +use crate::types::{DpdError, after_unwind}; +use aal::{ActionParse, AsicError, MatchParse}; use aal_macros::*; #[derive(MatchParse, Debug, Hash)] @@ -87,18 +88,21 @@ pub fn uplink_set(s: &Switch, port: u16) -> DpdResult<()> { s.log, "set guest_traffic_allowed on {} failed: {:?}", port, e ); - let _ = clear_ingress_uplink(s, port); - Err(e) + Err(after_unwind(e, clear_ingress_uplink(s, port))) } } } /// Remove an entry from the uplink tables. pub fn uplink_clear(s: &Switch, port: u16) -> DpdResult<()> { - clear_ingress_uplink(s, port)?; + let ingress_cleared = match clear_ingress_uplink(s, port) { + Ok(()) => true, + Err(DpdError::Switch(AsicError::Missing(_))) => false, + Err(e) => return Err(e), + }; let match_key = EgressMatchKey { out_port: port }; - match s.table_entry_del(TableType::UplinkEgress, &match_key) { + match s.table_entry_clear(TableType::UplinkEgress, &match_key) { Ok(_) => { info!(s.log, "cleared guest_traffic_allowed on {}", port); Ok(()) @@ -108,8 +112,12 @@ pub fn uplink_clear(s: &Switch, port: u16) -> DpdResult<()> { s.log, "clear guest_traffic_allowed on {} failed: {:?}", port, e ); - set_ingress_uplink(s, port)?; - Err(e) + + if ingress_cleared { + Err(after_unwind(e, set_ingress_uplink(s, port))) + } else { + Err(e) + } } } } diff --git a/dpd/src/types.rs b/dpd/src/types.rs index c626f8a0..753ace86 100644 --- a/dpd/src/types.rs +++ b/dpd/src/types.rs @@ -7,16 +7,60 @@ //! General types used throughout Dendrite. use aal::AsicError; +use common::MCAST_REPAIR_PENDING_ERROR_CODE; +use common::MCAST_TAG_MISMATCH_ERROR_CODE; +use common::MISSING_NAT_TARGET_ERROR_CODE; use common::ROLLBACK_FAILURE_ERROR_CODE; use common::SmfError; use common::ports::PortId; use common::ports::QsfpPort; use dpd_types::link::LinkId; +use nonempty::NonEmpty; use std::{convert, net::IpAddr}; use transceiver_controller::Error as TransceiverError; +/// Suggested `Retry-After` delay (in seconds) for multicast repair. +/// +/// The delay matches ddmd's periodic reconciliation interval. Clients may +/// choose their own retry timing. Repair runs when a request touches the group. +const MCAST_REPAIR_RETRY_AFTER_SECS: u64 = 10; + +/// Result of any operation that can fail with a [`DpdError`]. pub type DpdResult = Result; +/// Combine an initial failure with the outcome after unwinding it. +/// +/// Returns `initial` when the undo succeeded, and a [`DpdError::Unwind`] +/// carrying the initial error and rollback failures when it did not. The HTTP +/// mapping keeps the initial error's status and flags the rollback through the +/// `ROLLBACK_FAILURE_ERROR_CODE` error code. +/// +/// For an existing `Unwind`, this appends the new failure to its unwind list. +pub(crate) fn after_unwind( + initial: DpdError, + unwind_outcome: DpdResult<()>, +) -> DpdError { + match (initial, unwind_outcome) { + (initial, Ok(())) => initial, + (DpdError::Unwind { initial, mut unwind }, Err(e)) => { + unwind.push(e); + DpdError::Unwind { initial, unwind } + } + (initial, Err(unwind)) => DpdError::Unwind { + initial: Box::new(initial), + unwind: Box::new(NonEmpty::new(unwind)), + }, + } +} + +fn display_unwind(unwind: &NonEmpty) -> String { + unwind.iter().map(ToString::to_string).collect::>().join("; ") +} + +/// The possible failures the dataplane daemon can report. +// The `From for dropshot::HttpError` implementation maps each +// variant to a status code; each new variant added here needs a decision about +// what the client will see. #[derive(Debug, thiserror::Error)] pub enum DpdError { #[error("I/O error: {0:?}")] @@ -27,6 +71,12 @@ pub enum DpdError { Exists(String), #[error("Resource is busy: {0}")] Busy(String), + #[error("Multicast repair pending: {0}")] + McastRepairPending(String), + #[error("Multicast tag mismatch: {0}")] + McastTagMismatch(String), + #[error("Missing NAT target: {0}")] + MissingNatTarget(String), #[error("Resource is missing: {0}")] Missing(String), #[error("Invalid argument: {0}")] @@ -63,8 +113,8 @@ pub enum DpdError { NoLanesAvailable { port_id: PortId }, #[error("Port \"{port_id}\" is not a QSFP port")] NotAQsfpPort { port_id: PortId }, - #[error("Unwind: initial: {initial}, unwind: {unwind}")] - Unwind { initial: Box, unwind: Box }, + #[error("Unwind: initial: {initial}, unwind: {}", display_unwind(.unwind))] + Unwind { initial: Box, unwind: Box> }, #[error("No transceiver controller initialized")] NoTransceiverController, #[error("Failed to operate on transceivers")] @@ -159,10 +209,11 @@ impl convert::From for dropshot::HttpError { headers: None, } } - DpdError::Switch(AsicError::Exists) => { - dropshot::HttpError::for_client_error_with_status( + DpdError::Switch(AsicError::Exists(msg)) => { + dropshot::HttpError::for_client_error( None, dropshot::ClientErrorStatusCode::CONFLICT, + msg, ) } DpdError::Switch(AsicError::Synthetic(message)) => { @@ -196,24 +247,43 @@ impl convert::From for dropshot::HttpError { DpdError::Io(e) => { dropshot::HttpError::for_internal_error(e.to_string()) } - DpdError::Exists(e) => { - dropshot::HttpError::for_client_error_with_status( - Some(e), - dropshot::ClientErrorStatusCode::CONFLICT, - ) - } + DpdError::Exists(e) => dropshot::HttpError::for_client_error( + None, + dropshot::ClientErrorStatusCode::CONFLICT, + e, + ), DpdError::Busy(e) => { dropshot::HttpError::for_unavail(None, e.to_string()) } - DpdError::Missing(e) => { - dropshot::HttpError::for_client_error_with_status( - Some(e), - dropshot::ClientErrorStatusCode::NOT_FOUND, + DpdError::McastRepairPending(e) => { + let mut err = dropshot::HttpError::for_unavail( + Some(MCAST_REPAIR_PENDING_ERROR_CODE.into()), + e, + ); + err.add_header( + "retry-after", + MCAST_REPAIR_RETRY_AFTER_SECS.to_string(), + ) + .expect("retry-after is a valid header"); + err + } + DpdError::MissingNatTarget(e) => { + dropshot::HttpError::for_client_error( + Some(MISSING_NAT_TARGET_ERROR_CODE.into()), + dropshot::ClientErrorStatusCode::CONFLICT, + e, ) } + DpdError::Missing(e) => dropshot::HttpError::for_not_found(None, e), DpdError::Invalid(e) => { dropshot::HttpError::for_bad_request(None, e) } + DpdError::McastTagMismatch(e) => { + dropshot::HttpError::for_bad_request( + Some(MCAST_TAG_MISMATCH_ERROR_CODE.into()), + e, + ) + } DpdError::Faulted(e) => { dropshot::HttpError::for_bad_request(None, e) } @@ -250,15 +320,16 @@ impl convert::From for dropshot::HttpError { e @ DpdError::NotAQsfpPort { .. } => { dropshot::HttpError::for_bad_request(None, format!("{e}")) } - DpdError::Unwind { initial, unwind } => dropshot::HttpError { - status_code: dropshot::ErrorStatusCode::INTERNAL_SERVER_ERROR, - error_code: Some(ROLLBACK_FAILURE_ERROR_CODE.into()), - external_message: "inconsistent internal state".into(), - internal_message: format!( - "rollback error: initial: {initial}, unwind: {unwind}" - ), - headers: None, - }, + DpdError::Unwind { initial, unwind } => { + let detail = format!( + "rollback error: initial: {initial}, unwind: {}", + display_unwind(&unwind) + ); + let mut err = dropshot::HttpError::from(*initial); + err.error_code = Some(ROLLBACK_FAILURE_ERROR_CODE.into()); + err.internal_message = detail; + err + } e @ DpdError::NoTransceiverController => { dropshot::HttpError::for_unavail(None, format!("{e}")) } @@ -314,8 +385,20 @@ impl convert::From for DpdError { } } -impl convert::From for DpdError { - fn from(err: dpd_types::mcast::Error) -> Self { +impl convert::From for DpdError { + fn from(err: dpd_types::mcast::UnderlayMulticastError) -> Self { + DpdError::Invalid(err.to_string()) + } +} + +impl convert::From for DpdError { + fn from(err: dpd_types::mcast::ExternalMulticastIpError) -> Self { + DpdError::Invalid(err.to_string()) + } +} + +impl convert::From for DpdError { + fn from(err: dpd_types::mcast::MulticastTagParseError) -> Self { DpdError::Invalid(err.to_string()) } } 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-4b5e8b.json similarity index 97% rename from openapi/dpd/dpd-13.0.0-5db8bd.json rename to openapi/dpd/dpd-14.0.0-4b5e8b.json index 22694237..7e22276e 100644 --- a/openapi/dpd/dpd-13.0.0-5db8bd.json +++ b/openapi/dpd/dpd-14.0.0-4b5e8b.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 carry either IPv4 or IPv6 traffic and share their NAT target's replication groups. Any admitted IPv6 scope can be used, including admin-local, except for the reserved underlay subnet of ff04::/64, which belongs specifically to the internal multicast API.\n\nThe group address determines whether the group is any-source (ASM) or source-specific (SSM). An SSM group requires at least one exact source.\n\nThese groups do not carry member subscribers, as membership lives only on the underlay group mapped to by the required NAT target.\n\nThe NAT target must reference an existing underlay group. The underlay group cannot already be referenced by another external group.", "operationId": "multicast_group_create_external", "requestBody": { "content": { @@ -1284,16 +1284,16 @@ "/multicast/external-groups/{group_ip}": { "put": { "summary": "Update an external-only multicast group configuration for a given group IP address.", - "description": "External-only groups are used for IPv4 and 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 carry IPv4 or IPv6 traffic and share their NAT target's replication groups. Any admitted IPv6 scope can be used, including admin-local, except for the reserved underlay subnet of ff04::/64, which belongs to the internal multicast API.\n\nThe path address must be a valid external multicast address.\n\nOmitting `sources` preserves the group's existing source filter.\n\nThe `tag` query parameter must match the group's existing tag. The NAT target must reference an existing underlay group. The underlay group cannot be referenced by another external group.", "operationId": "multicast_group_update_external", "parameters": [ { "in": "path", "name": "group_ip", + "description": "External multicast group IP address.", "required": true, "schema": { - "type": "string", - "format": "ip" + "$ref": "#/components/schemas/ExternalMulticastIp" } }, { @@ -6489,6 +6489,48 @@ } } }, + "ExternalInternalForwarding": { + "description": "Internal forwarding for an external multicast group.\n\nExternal groups always carry a NAT target.", + "type": "object", + "properties": { + "nat_target": { + "description": "The group's underlay NAT target.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalNatTarget" + } + ] + } + }, + "required": [ + "nat_target" + ] + }, + "ExternalMulticastIp": { + "description": "A validated IP address for a customer-visible overlay multicast group.\n\nThe overlay multicast group is the address guests can send to and join, e.g. `233.252.0.1` or `ff0e::1`, and spans both address families. Its rack-internal underlay mapping is validated separately via `UnderlayMulticastIpv6`, with a 1:1 mapping between the two.\n\nJSON uses the ordinary IP-address string form. Deserialization rejects addresses that are not multicast, use a reserved, interface-local, or link-local IPv6 scope, fall in the IPv4 local network control block (`224.0.0.0/24`) or the `ff04::/64` underlay subnet, are a reserved base or SSM null address, or carry malformed prefix-based or embedded-RP flags.", + "type": "string", + "format": "ip" + }, + "ExternalNatTarget": { + "description": "NAT target for an external multicast group.\n\nThe MAC must be a multicast MAC and the internal IP must lie within the `ff04::/64` internal underlay block.", + "type": "object", + "properties": { + "inner_mac": { + "$ref": "#/components/schemas/MacAddr" + }, + "internal_ip": { + "$ref": "#/components/schemas/UnderlayMulticastIpv6" + }, + "vni": { + "$ref": "#/components/schemas/Vni" + } + }, + "required": [ + "inner_mac", + "internal_ip", + "vni" + ] + }, "Fault": { "description": "A Fault represents a specific kind of failure, and carries some additional context. Currently Faults are only used to describe Link failures, but there is no reason they couldn't be used elsewhere.", "oneOf": [ @@ -6831,20 +6873,6 @@ "vni" ] }, - "InternalForwarding": { - "description": "Represents the NAT target for multicast traffic for internal/underlay forwarding.", - "type": "object", - "properties": { - "nat_target": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/NatTarget" - } - ] - } - } - }, "IpNet": { "x-rust-type": { "crate": "oxnet", @@ -6937,7 +6965,7 @@ ] }, "Ipv4Nat": { - "description": "represents an IPv4 NAT reservation", + "description": "Represents an IPv4 NAT reservation.", "type": "object", "properties": { "external": { @@ -7146,7 +7174,7 @@ ] }, "Ipv6Nat": { - "description": "represents an IPv6 NAT reservation", + "description": "Represents an IPv6 NAT reservation.", "type": "object", "properties": { "external": { @@ -8394,21 +8422,36 @@ ] }, "MulticastGroupCreateExternalEntry": { - "description": "A multicast group configuration for POST requests for external (to the rack) groups.", + "description": "Request body for creating a customer-visible overlay multicast group.\n\nThe group IP address determines whether the group is ASM or SSM.", "type": "object", "properties": { "external_forwarding": { - "$ref": "#/components/schemas/ExternalForwarding" + "description": "Egress VLAN configuration for external forwarding.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalForwarding" + } + ] }, "group_ip": { - "type": "string", - "format": "ip" + "description": "The multicast address for the group.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalMulticastIp" + } + ] }, "internal_forwarding": { - "$ref": "#/components/schemas/InternalForwarding" + "description": "NAT target selecting the underlay group that replicates this group's traffic.\n\nIt must reference an existing underlay group.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalInternalForwarding" + } + ] }, "sources": { "nullable": true, + "description": "Optional source list.\n\nA source list can be absent from ASM group requests. If absent, empty, or containing `Any`, the group permits every source (i.e., a wildcard).\n\nSSM groups require at least one exact source in the list and reject `Any` wildcard entries.", "type": "array", "items": { "$ref": "#/components/schemas/IpSrc" @@ -8451,26 +8494,42 @@ ] }, "MulticastGroupExternalResponse": { - "description": "Response structure for external multicast group operations. These groups handle IPv4 and non-admin-local IPv6 multicast via NAT targets.", + "description": "Response structure for external multicast group operations.", "type": "object", "properties": { "external_forwarding": { - "$ref": "#/components/schemas/ExternalForwarding" + "description": "Egress VLAN configuration for external forwarding.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalForwarding" + } + ] }, "external_group_id": { + "description": "ASIC replication group ID for the external group.", "type": "integer", "format": "uint16", "minimum": 0 }, "group_ip": { - "type": "string", - "format": "ip" + "description": "The validated multicast address of the group.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalMulticastIp" + } + ] }, "internal_forwarding": { - "$ref": "#/components/schemas/InternalForwarding" + "description": "NAT target for forwarding onto the underlay.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalInternalForwarding" + } + ] }, "sources": { "nullable": true, + "description": "Source filter, or null when any source is permitted.", "type": "array", "items": { "$ref": "#/components/schemas/IpSrc" @@ -8513,7 +8572,7 @@ "description": "Unified response type for operations that return mixed group types.", "oneOf": [ { - "description": "Response structure for underlay/internal multicast group operations. These groups handle admin-local IPv6 multicast with full replication.", + "description": "An internal group on the underlay, with members.", "type": "object", "properties": { "external_group_id": { @@ -8556,23 +8615,38 @@ ] }, { - "description": "Response structure for external multicast group operations. These groups handle IPv4 and non-admin-local IPv6 multicast via NAT targets.", + "description": "An external overlay group containing a NAT target and VLAN configuration, having no direct members.", "type": "object", "properties": { "external_forwarding": { - "$ref": "#/components/schemas/ExternalForwarding" + "description": "Egress VLAN configuration for external forwarding.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalForwarding" + } + ] }, "external_group_id": { + "description": "ASIC replication group ID for the external group.", "type": "integer", "format": "uint16", "minimum": 0 }, "group_ip": { - "type": "string", - "format": "ip" + "description": "The validated multicast address of the group.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalMulticastIp" + } + ] }, "internal_forwarding": { - "$ref": "#/components/schemas/InternalForwarding" + "description": "NAT target for forwarding onto the underlay.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalInternalForwarding" + } + ] }, "kind": { "type": "string", @@ -8582,6 +8656,7 @@ }, "sources": { "nullable": true, + "description": "Source filter, or null when any source is permitted.", "type": "array", "items": { "$ref": "#/components/schemas/IpSrc" @@ -8661,17 +8736,28 @@ ] }, "MulticastGroupUpdateExternalEntry": { - "description": "A multicast group update entry for PUT requests for external (to the rack) groups.\n\nTag validation is performed via the `tag` query parameter.", + "description": "Updated forwarding and source-filter settings for an external group.", "type": "object", "properties": { "external_forwarding": { - "$ref": "#/components/schemas/ExternalForwarding" + "description": "Egress VLAN configuration for external forwarding.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalForwarding" + } + ] }, "internal_forwarding": { - "$ref": "#/components/schemas/InternalForwarding" + "description": "NAT target selecting the underlay group that replicates this group's traffic.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalInternalForwarding" + } + ] }, "sources": { "nullable": true, + "description": "Replacement source list.\n\nExclude or set to null to preserve the current source filter.", "type": "array", "items": { "$ref": "#/components/schemas/IpSrc" @@ -8699,7 +8785,7 @@ ] }, "NatTarget": { - "description": "represents an internal NAT target", + "description": "Represents an internal NAT target.", "type": "object", "properties": { "inner_mac": { @@ -10666,10 +10752,11 @@ ] }, "Vni": { - "description": "A Geneve Virtual Network Identifier.\n\nA Geneve VNI is a 24-bit value used to identify virtual networks encapsulated using the Generic Network Virtualization Encapsulation (Geneve) protocol (RFC 8926).", + "description": "A validated Geneve Virtual Network Identifier.\n\nA Geneve VNI is a 24-bit value used to identify virtual networks encapsulated using the Generic Network Virtualization Encapsulation (Geneve) protocol (RFC 8926). Any value above the 24-bit maximum of 16777215 is rejected at both construction and deserialization.", "type": "integer", "format": "uint32", - "minimum": 0 + "minimum": 0, + "maximum": 16777215 }, "ipv4ResultsPage": { "description": "A single page of results", diff --git a/openapi/dpd/dpd-latest.json b/openapi/dpd/dpd-latest.json index d1a5660e..4e62f5dc 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-4b5e8b.json \ No newline at end of file diff --git a/swadm/src/attached.rs b/swadm/src/attached.rs index 87d72a72..b0910088 100644 --- a/swadm/src/attached.rs +++ b/swadm/src/attached.rs @@ -4,7 +4,6 @@ // // Copyright 2026 Oxide Computer Company -use std::convert::TryFrom; use std::io::{Write, stdout}; use std::net::Ipv6Addr; @@ -95,7 +94,7 @@ async fn attsub_list( m.subnet, m.tgt.internal_ip, MacAddr::from(m.tgt.inner_mac.clone()), - m.tgt.vni.0, + m.tgt.vni.as_u32(), )?; } tw.flush()?; @@ -107,10 +106,7 @@ async fn attsub_get(client: &Client, attsub: IpNet) -> anyhow::Result<()> { let target = client .attached_subnet_get(&attsub) .await - .map(|r| { - common::network::InstanceTarget::try_from(r.into_inner()) - .expect("Invalid internal target from server") - }) + .map(|r| common::network::InstanceTarget::from(r.into_inner())) .context("failed to get IPv4 NAT mapping")?; println!("target: {target}"); Ok(()) @@ -123,11 +119,8 @@ async fn attsub_add( inner_mac: MacAddr, vni: Vni, ) -> anyhow::Result<()> { - let tgt = types::InstanceTarget { - internal_ip, - inner_mac: inner_mac.into(), - vni: types::Vni::from(vni), - }; + let tgt = + types::InstanceTarget { internal_ip, inner_mac: inner_mac.into(), vni }; client .attached_subnet_create(&attsub, &tgt) .await diff --git a/swadm/src/nat.rs b/swadm/src/nat.rs index 5beec07e..4e3b829b 100644 --- a/swadm/src/nat.rs +++ b/swadm/src/nat.rs @@ -4,7 +4,6 @@ // // Copyright 2026 Oxide Computer Company -use std::convert::TryFrom; use std::io::{Write, stdout}; use std::net::{IpAddr, Ipv6Addr}; @@ -116,7 +115,7 @@ async fn nat_list( entry.high, entry.target.internal_ip, MacAddr::from(entry.target.inner_mac), - entry.target.vni.0, + entry.target.vni.as_u32(), )?; } } @@ -134,7 +133,7 @@ async fn nat_list( entry.high, entry.target.internal_ip, MacAddr::from(entry.target.inner_mac), - entry.target.vni.0, + entry.target.vni.as_u32(), )?; } } @@ -153,10 +152,7 @@ async fn nat_get( let target = client .nat_ipv4_get(&ipv4, port) .await - .map(|r| { - common::network::NatTarget::try_from(r.into_inner()) - .expect("Invalid NAT target from server") - }) + .map(|r| common::network::NatTarget::from(r.into_inner())) .context("failed to get IPv4 NAT mapping")?; println!("target: {target}"); } @@ -164,10 +160,7 @@ async fn nat_get( let target = client .nat_ipv6_get(&ipv6, port) .await - .map(|r| { - common::network::NatTarget::try_from(r.into_inner()) - .expect("Invalid NAT target from server") - }) + .map(|r| common::network::NatTarget::from(r.into_inner())) .context("failed to get IPv6 NAT mapping")?; println!("target: {target}"); } @@ -184,11 +177,8 @@ async fn nat_add( inner_mac: MacAddr, vni: Vni, ) -> anyhow::Result<()> { - let tgt = types::NatTarget { - internal_ip, - inner_mac: inner_mac.into(), - vni: types::Vni::from(vni), - }; + let tgt = + types::NatTarget { internal_ip, inner_mac: inner_mac.into(), vni }; match external { IpAddr::V4(ext) => client .nat_ipv4_create(&ext, low_port, high_port, &tgt)